Compare commits

...

19 Commits

Author SHA1 Message Date
01zulfi
7fe410ae89 web: bump version to 3.3.13
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-07 14:00:37 +05:00
Ammar Ahmed
4cbdd085d6 mobile: fix editor stops updating notes after first two notes edited (#9681) 2026-04-07 13:18:50 +05:00
01zulfi
24556dab83 web: show better error message on invalid recovery key (#9680)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-07 13:00:02 +05:00
Abdullah Atta
dd8116eba8 Merge pull request #9677 from streetwriters/fix/table-cell-selection-mobile
Fix table cell selection on mobile
2026-04-06 23:55:18 +05:00
Abdullah Atta
ae82a8c0a0 editor: only allow resizing column from resize handle 2026-04-06 23:49:04 +05:00
Chloe Oletto
af4dc5e9ea web: fix all tabs closed on sync when any note is deleted from another device (#9252)
* web: All tabs closed when any note is deleted.

Signed-off by: Chloe Oletto <NeedsChloesure@riseup.net>

* web: remove redundant noteID check, fix check to ensure locked notes are reopened.

Signed-off By: Chloe Oletto <NeedsChloesure@riseup.net>
2026-04-06 23:02:12 +05:00
Abdullah Atta
71b03a90dd editor: disable scroll during column resize 2026-04-06 21:01:25 +05:00
Abdullah Atta
728e50cd13 editor: fix awkward selection behaviour in table cells
this includes fixes to multiple UX bugs:

1. scroll now stops when user starts cell selection
2. moving cell selection to either edge auto scrolls the table
3. text selection does not trigger cell selection
2026-04-06 21:00:23 +05:00
01zulfi
c8e8935b34 web: add back button in recovery forms (#9676)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-06 16:27:33 +05:00
01zulfi
21f4b906d7 web: fix html entities shown in match result (#9645)
* web: fix non-breaking space shown as encoded in match result
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* core: decode matches in db.lookup.notesWithHighlighting
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* core: decode entities at parse time

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-04-06 14:20:10 +05:00
01zulfi
d49d42b736 common: guard against undefined backStack/forwardStack in TabSessionHistory (#9674)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-06 12:42:00 +05:00
SahilAslam001
267fe4e453 mobile: remove restoring app state on app launch #6294 (#9586)
Signed-off-by: Sahil Aslam <sahil.aslam011@gmail.com>
2026-04-06 11:20:30 +05:00
01zulfi
413ca61ef5 core: $headline$ format should get 60 chars from first p or h tag (#9658)
* core: note title headline format should get content from first p or h tag
* also decrease character limit to 60

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* Apply suggestion from @thecodrr

Signed-off-by: Abdullah Atta <thecodrr@protonmail.com>

---------

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
Signed-off-by: Abdullah Atta <thecodrr@protonmail.com>
Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-04-06 09:20:21 +05:00
Ammar Ahmed
02b96470bc Merge pull request #9631 from streetwriters/fix/file-size-check-fail
mobile: fix file size check shows error but continues uploading the file
2026-04-02 13:13:56 +05:00
Ammar Ahmed
7234128f78 docs(help): add reminders help page (#9637)
* docs: add remindershelp page

* Apply suggestions from code review

Co-authored-by: Abdullah Atta <thecodrr@protonmail.com>
Signed-off-by: Abdullah Atta <thecodrr@protonmail.com>

---------

Signed-off-by: Abdullah Atta <thecodrr@protonmail.com>
Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
Co-authored-by: Abdullah Atta <thecodrr@protonmail.com>
2026-04-02 12:38:28 +05:00
01zulfi
27e070e555 web: redirect automatically after final step in reset password flow (#9642)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-02 12:29:16 +05:00
01zulfi
539950bdc5 core: perform fuzzy search again if query contains non-alphanumeric chars (#9634)
* core: simplify fuzzy separator regex
* remove lookahead and lookbehind

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* core: do 2 pass fuzzy search if query contains non-alphanumeric chars

---------

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-04-02 12:13:06 +05:00
Ammar Ahmed
83280e7cee docs: clarify what happens on account deletion (#9636)
* docs: clarify what happens on account deletion

* intl: clarify what happens when account is deleted
2026-04-01 15:23:57 +05:00
01zulfi
cee0275565 web: handle crash on preview attachment (#9626)
* web: handle crash on preview attachment
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: better error message on attachment preview failure

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-04-01 13:07:17 +05:00
45 changed files with 578 additions and 390 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/desktop",
"version": "3.3.12",
"version": "3.3.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/desktop",
"version": "3.3.12",
"version": "3.3.13",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {

View File

@@ -2,7 +2,7 @@
"name": "@notesnook/desktop",
"productName": "Notesnook",
"description": "Your private note taking space",
"version": "3.3.12",
"version": "3.3.13",
"appAppleId": "1544027013",
"private": true,
"main": "./dist/cjs/index.js",

View File

@@ -199,11 +199,7 @@
</intent-filter>
</activity>
<service
android:name=".OnClearFromRecentService"
android:enabled="true"
android:exported="true"
android:stopWithTask="false" />
<service android:name="com.asterinet.react.bgactions.RNBackgroundActionsTask" />
<service

View File

@@ -28,10 +28,6 @@ public class MainActivity extends ReactActivity {
WebView.setWebContentsDebuggingEnabled(true);
}
try {
startService(new Intent(getBaseContext(), OnClearFromRecentService.class));
} catch (Exception ignored) {}
}
/**

View File

@@ -1,36 +0,0 @@
package com.streetwriters.notesnook;
import android.app.Service;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.IBinder;
import android.util.Log;
public class OnClearFromRecentService extends Service {
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
return START_NOT_STICKY;
}
@Override
public void onDestroy() {
super.onDestroy();
}
@Override
public void onTaskRemoved(Intent rootIntent) {
try {
SharedPreferences appStateDetails = getApplicationContext().getSharedPreferences("appStateDetails", MODE_PRIVATE);
SharedPreferences.Editor edit = appStateDetails.edit();
edit.remove("appState");
edit.apply();
stopSelf();
} catch (UnsatisfiedLinkError | Exception e) {
}
}
}

View File

@@ -93,21 +93,6 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
}
}
@ReactMethod
public void setAppState(final String appState) {
SharedPreferences appStateDetails = getReactApplicationContext().getSharedPreferences("appStateDetails", Context.MODE_PRIVATE);
SharedPreferences.Editor edit = appStateDetails.edit();
edit.putString("appState", appState);
edit.apply();
}
@ReactMethod(isBlockingSynchronousMethod = true)
public String getAppState() {
SharedPreferences appStateDetails = getReactApplicationContext().getSharedPreferences("appStateDetails", Context.MODE_PRIVATE);
String appStateValue = appStateDetails.getString("appState", "");
return appStateValue.isEmpty() ? null : appStateValue;
}
@ReactMethod(isBlockingSynchronousMethod = true)
public int getWidgetId() {
return NotePreviewConfigureActivity.appWidgetId;

View File

@@ -40,7 +40,6 @@ import Animated, {
withTiming
} from "react-native-reanimated";
import { useTabStore } from "../../screens/editor/tiptap/use-tab-store";
import { getAppState } from "../../screens/editor/tiptap/utils";
import { eSendEvent } from "../../services/event-manager";
import { useSettingStore } from "../../stores/use-setting-store";
import { eClearEditor } from "../../utils/events";
@@ -84,18 +83,9 @@ export const FluidPanels = forwardRef<TabsRef, TabProps>(function FluidTabs(
}: TabProps,
ref
) {
const appState = useMemo(() => getAppState(), []);
const deviceMode = useSettingStore((state) => state.deviceMode);
const fullscreen = useSettingStore((state) => state.fullscreen);
const translateX = useSharedValue(
widths
? appState &&
appState?.movedAway === false &&
useTabStore.getState().getCurrentNoteId()
? widths.sidebar + widths.list
: widths.sidebar
: 0
);
const translateX = useSharedValue(widths ? widths.sidebar : 0);
const startX = useSharedValue(0);
const currentTab = useSharedValue(1);
const previousTab = useSharedValue(1);
@@ -125,10 +115,7 @@ export const FluidPanels = forwardRef<TabsRef, TabProps>(function FluidTabs(
translateX.value = 0;
} else {
if (prevWidths.current?.sidebar !== widths.sidebar) {
translateX.value =
appState && appState?.movedAway === false
? editorPosition
: widths.sidebar;
translateX.value = widths.sidebar;
if (translateX.value === editorPosition) {
onChangeTab?.({ i: 2, from: 1 });
}
@@ -136,15 +123,7 @@ export const FluidPanels = forwardRef<TabsRef, TabProps>(function FluidTabs(
}
isLoaded.current = true;
prevWidths.current = widths;
}, [
deviceMode,
widths,
fullscreen,
translateX,
editorPosition,
appState,
onChangeTab
]);
}, [deviceMode, widths, fullscreen, translateX, editorPosition, onChangeTab]);
useEffect(() => {
const sub = BackHandler.addEventListener("hardwareBackPress", () => {

View File

@@ -53,15 +53,9 @@ async function fetchMonographData(noteId: string) {
const monograph = monographId
? await db.monographs.get(monographId)
: undefined;
const analyticsFeature = await isFeatureAvailable("monographAnalytics");
const analytics =
monographId && analyticsFeature
? await db.monographs.analytics(monographId)
: undefined;
return {
monograph,
monographId,
analytics
};
}
@@ -381,9 +375,6 @@ const PublishNoteSheet = ({
}}
>
<Paragraph size={AppFontSize.sm}>{strings.views()}</Paragraph>
<Paragraph>
{monographData?.result?.analytics?.totalViews || 0}
</Paragraph>
</View>
</View>
</View>

View File

@@ -30,6 +30,7 @@ import {
import { strings } from "@notesnook/intl";
import notifee from "@notifee/react-native";
import NetInfo, { NetInfoSubscription } from "@react-native-community/netinfo";
import dayjs from "dayjs";
import React, { useCallback, useEffect, useRef } from "react";
import {
AppState,
@@ -48,6 +49,7 @@ import * as RNIap from "react-native-iap";
import { DatabaseLogger, db, setupDatabase } from "../common/database";
import { initializeLogger } from "../common/database/logger";
import { MMKV } from "../common/database/mmkv";
import { deleteDCacheFiles } from "../common/filesystem/io";
import { endProgress, startProgress } from "../components/dialogs/progress";
import Migrate from "../components/sheets/migrate";
import NewFeature from "../components/sheets/new-feature";
@@ -58,11 +60,7 @@ import {
resetTabStore,
useTabStore
} from "../screens/editor/tiptap/use-tab-store";
import {
clearAppState,
editorController,
editorState
} from "../screens/editor/tiptap/utils";
import { editorController, editorState } from "../screens/editor/tiptap/utils";
import { useDragState } from "../screens/settings/editor/state";
import BackupService from "../services/backup";
import BiometricService from "../services/biometrics";
@@ -89,6 +87,7 @@ import { clearAllStores, initAfterSync } from "../stores";
import { refreshAllStores } from "../stores/create-db-collection-store";
import { useAttachmentStore } from "../stores/use-attachment-store";
import { useMessageStore } from "../stores/use-message-store";
import { useRelationStore } from "../stores/use-relation-store";
import { useSettingStore } from "../stores/use-setting-store";
import { SyncStatus, useUserStore } from "../stores/use-user-store";
import { updateStatusBarColor } from "../utils/colors";
@@ -105,12 +104,8 @@ import {
} from "../utils/events";
import { getGithubVersion } from "../utils/github-version";
import { fluidTabsRef } from "../utils/global-refs";
import { NotesnookModule } from "../utils/notesnook-module";
import { sleep } from "../utils/time";
import useFeatureManager from "./use-feature-manager";
import { deleteDCacheFiles } from "../common/filesystem/io";
import dayjs from "dayjs";
import { useRelationStore } from "../stores/use-relation-store";
const onCheckSyncStatus = async (type: SyncStatusEvent) => {
const { disableSync, disableAutoSync } = SettingsService.get();
@@ -173,7 +168,6 @@ const onAppOpenedFromURL = async (event: {
if (url.startsWith("https://app.notesnook.com/account/verified")) {
await onUserEmailVerified();
} else if (url.startsWith("ShareMedia://QuickNoteWidget")) {
clearAppState();
editorState().movedAway = false;
eSendEvent(eOnLoadNote, { newNote: true });
fluidTabsRef.current?.goToPage("editor", false);
@@ -361,23 +355,6 @@ async function checkForShareExtensionLaunchedInBackground() {
} catch (e) {}
}
async function saveEditorState() {
if (!editorState().movedAway) {
const id = useTabStore.getState().getCurrentNoteId();
const note = id ? await db.notes.note(id) : undefined;
const locked = note && (await db.vaults.itemExists(note));
if (locked) return;
const state = JSON.stringify({
editing: editorState().currentlyEditing,
movedAway: editorState().movedAway,
timestamp: Date.now()
});
NotesnookModule.setAppState(state);
} else {
NotesnookModule.setAppState("");
}
}
const onSuccessfulSubscription = async (
subscription: RNIap.ProductPurchase | RNIap.SubscriptionPurchase
) => {
@@ -592,13 +569,13 @@ export const useAppEvents = () => {
}, [isAppLoading, onSyncComplete]);
useEffect(() => {
if (initialUrl) {
if (initialUrl && !isAppLoading) {
onAppOpenedFromURL({
url: initialUrl!,
isInitialUrl: true
});
}
}, [initialUrl]);
}, [initialUrl, isAppLoading]);
const subscribeToPurchaseListeners = useCallback(async () => {
if (Platform.OS === "android") {
@@ -838,7 +815,6 @@ export const useAppEvents = () => {
Sync.run("global", false, "full");
reconnectSSE();
await checkForShareExtensionLaunchedInBackground();
NotesnookModule.setAppState("");
let user = await db.user.getUser();
if (user && !user?.isEmailConfirmed) {
try {
@@ -861,7 +837,6 @@ export const useAppEvents = () => {
eSendEvent(eEditorReset);
}
} else {
await saveEditorState();
if (
SettingsService.canLockAppInBackground() &&
!useSettingStore.getState().requestBiometrics &&

View File

@@ -21,7 +21,6 @@ import { useEffect } from "react";
import { NativeEventEmitter, NativeModule } from "react-native";
import { useRef } from "react";
import { Platform } from "react-native";
import { Linking } from "react-native";
import deviceInfoModule from "react-native-device-info";
import { strings } from "@notesnook/intl";
const ShortcutsEmitter = new NativeEventEmitter(
@@ -54,14 +53,9 @@ export const useShortcutManager = ({
}, [shortcuts]);
useEffect(() => {
Linking.getInitialURL().then((url) => {
if (url?.startsWith("ShareMedia://QuickNoteWidget")) {
onShortcutPressed(defaultShortcuts[0]);
}
});
if (!isSupported()) return;
Shortcuts.getInitialShortcut().then((shortcut) => {
if (initialShortcutRecieved.current) return;
if (initialShortcutRecieved.current || !shortcut) return;
onShortcutPressed(shortcut);
initialShortcutRecieved.current = true;
});

View File

@@ -47,12 +47,7 @@ import useGlobalSafeAreaInsets from "../hooks/use-global-safe-area-insets";
import { useShortcutManager } from "../hooks/use-shortcut-manager";
import { hideAllTooltips } from "../hooks/use-tooltip";
import { useTabStore } from "../screens/editor/tiptap/use-tab-store";
import {
clearAppState,
editorController,
editorState,
getAppState
} from "../screens/editor/tiptap/utils";
import { editorController, editorState } from "../screens/editor/tiptap/utils";
import { DDS } from "../services/device-detection";
import {
eSendEvent,
@@ -118,13 +113,9 @@ export const FluidPanelsView = React.memo(
useShortcutManager({
onShortcutPressed: async (item) => {
if (!item && getAppState()) {
editorState().movedAway = false;
fluidTabsRef.current?.goToPage("editor", false);
return;
}
if (!item) return;
if (item?.type === "notesnook.action.newnote") {
clearAppState();
if (!fluidTabsRef.current) {
setTimeout(() => {
eSendEvent(eOnLoadNote, { newNote: true });
@@ -205,7 +196,6 @@ export const FluidPanelsView = React.memo(
eSendEvent(eCloseFullscreenEditor, current);
}
const state = getAppState();
setTimeout(() => {
switch (current) {
case "tablet":
@@ -217,18 +207,10 @@ export const FluidPanelsView = React.memo(
}
break;
case "mobile":
if (
state &&
editorState().movedAway === false &&
useTabStore.getState().getCurrentNoteId()
) {
fluidTabsRef.current?.goToPage("editor", false);
} else {
fluidTabsRef.current?.goToPage(
fluidTabsRef.current?.page(),
false
);
}
fluidTabsRef.current?.goToPage(
fluidTabsRef.current?.page(),
false
);
break;
}
}, 0);
@@ -243,8 +225,8 @@ export const FluidPanelsView = React.memo(
const nextDeviceMode = DDS.isLargeTablet()
? "tablet"
: DDS.isSmallTab
? "smallTablet"
: "mobile";
? "smallTablet"
: "mobile";
setDeviceMode(nextDeviceMode, size);
},
[orientation, setDeviceMode, setDimensions]

View File

@@ -471,9 +471,8 @@ export const useEditorEvents = (
relationType: "from",
title: strings.dataTypesPluralCamelCase.reminder(),
onAdd: async () => {
const reminderFeature = await isFeatureAvailable(
"activeReminders"
);
const reminderFeature =
await isFeatureAvailable("activeReminders");
if (!reminderFeature.isAllowed) {
ToastManager.show({
type: "info",
@@ -560,7 +559,7 @@ export const useEditorEvents = (
.then((data: any) => {
console.log(
"Got attachment data:",
data,
!!data,
editorMessage.resolverId
);
editor.postMessage(NativeEvents.resolve, {

View File

@@ -69,14 +69,7 @@ import Commands from "./commands";
import { SessionHistory } from "./session-history";
import { EditorState, SavePayload } from "./types";
import { TabSessionItem, syncTabs, useTabStore } from "./use-tab-store";
import {
clearAppState,
defaultState,
getAppState,
isContentInvalid,
isEditorLoaded,
post
} from "./utils";
import { defaultState, isContentInvalid, isEditorLoaded, post } from "./utils";
import { Linking } from "react-native";
const loadNoteMutex = new Mutex();
@@ -167,7 +160,7 @@ export const useEditor = (
const tags = useTagStore((state) => state.items);
const insets = useGlobalSafeAreaInsets();
const isDefaultEditor = editorId === "";
const saveCount = useRef(0);
const saveCount = useRef<Record<string, number>>({});
const lastContentChangeTime = useRef<Record<string, number>>({});
const lock = useRef(false);
const currentLoadingNoteId = useRef<string>(undefined);
@@ -253,9 +246,9 @@ export const useEditor = (
clearTimeout(timers.current["loading-images" + noteId]);
}
saveCount.current = 0;
currentLoadingNoteId.current = undefined;
lock.current = false;
saveCount.current[tabId] = 0;
resetContent && postMessage(NativeEvents.title, "", tabId);
resetContent && (await commands.clearContent(tabId));
resetContent && (await commands.clearTags(tabId));
@@ -436,10 +429,9 @@ export const useEditor = (
lastContentChangeTime.current[id] = note.dateEdited;
if (
saveCount.current < 2 ||
saveCount.current[tabId] < 2 ||
currentNotes.current[id]?.title !== note.title ||
currentNotes.current[id]?.headline?.slice(0, 200) !==
note.headline?.slice(0, 200)
currentNotes.current[id]?.headline !== note.headline
) {
Navigation.queueRoutesForUpdate();
}
@@ -455,7 +447,11 @@ export const useEditor = (
currentNotes.current[id] = note;
}
saveCount.current++;
if (!saveCount.current[tabId]) {
saveCount.current[tabId] = 1;
} else {
saveCount.current[tabId]++;
}
clearTimeout(timers.current.onsave);
timers.current.onsave = setTimeout(async () => {
@@ -564,6 +560,7 @@ export const useEditor = (
useTabStore.getState().newTabSession(tabId, {});
}
}
saveCount.current[tabId] = 0;
setTimeout(() => {
if (state.current?.ready && !state.current.movedAway)
@@ -696,6 +693,7 @@ export const useEditor = (
currentNotes.current[item.id] = item;
editorSessionHistory.newSession(item.id);
saveCount.current[tabId] = 0;
await commands.setStatus(
getFormattedDate(item.dateEdited, "date-time"),
@@ -1025,22 +1023,11 @@ export const useEditor = (
);
const restoreEditorState = useCallback(async () => {
const appState = getAppState();
if (!appState) return;
state.current.isRestoringState = true;
state.current.currentlyEditing = true;
if (fluidTabsRef.current?.page() === "editor") {
state.current.movedAway = false;
}
if (!state.current.editorStateRestored) {
state.current.isRestoringState = true;
if (!DDS.isTab) {
fluidTabsRef.current?.goToPage("editor", false);
}
}
clearAppState();
state.current.isRestoringState = false;
}, []);
const onContentChanged = (noteId?: string) => {

View File

@@ -141,34 +141,6 @@ export function isContentInvalid(content: string | undefined) {
);
}
const canRestoreAppState = (appState: AppState) => {
return appState.editing && Date.now() < appState.timestamp + 3600000;
};
let appState: AppState | undefined;
export function setAppState(state: AppState) {
appState = state;
}
export function getAppState() {
if (appState && canRestoreAppState(appState)) return appState as AppState;
const json = NotesnookModule.getAppState();
if (json) {
appState = JSON.parse(json) as AppState;
if (canRestoreAppState(appState)) {
return appState;
} else {
clearAppState();
return null;
}
}
return null;
}
export function clearAppState() {
appState = undefined;
NotesnookModule.setAppState("");
}
export async function openInternalLink(url: string) {
const data = parseInternalLink(url);
if (!data?.id) return false;

View File

@@ -18,7 +18,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Platform } from "react-native";
import { db } from "../common/database";
import { setAppState } from "../screens/editor/tiptap/utils";
import { eOnLoadNote } from "../utils/events";
import { NotesnookModule } from "../utils/notesnook-module";
import { eSendEvent } from "./event-manager";
@@ -37,14 +36,6 @@ export const IntentService = {
onLaunch() {
if (launched || Platform.OS === "ios") return;
launched = true;
if (launchIntent["com.streetwriters.notesnook.OpenNoteId"]) {
setAppState({
movedAway: false,
editing: true,
timestamp: Date.now(),
noteId: launchIntent["com.streetwriters.notesnook.OpenNoteId"]
});
}
},
async onAppStateChanged() {
if (Platform.OS === "ios") return;

View File

@@ -138,7 +138,6 @@ const onEvent = async ({ type, detail }: Event) => {
if (type === EventType.PRESS) {
notifee.decrementBadgeCount();
if (notification?.data?.type === "quickNote") return;
NotesnookModule.setAppState("");
if (notification?.data?.type === "reminder" && notification?.id) {
const reminder = await db.reminders?.reminder(
notification.id?.split("_")[0]
@@ -453,13 +452,6 @@ async function loadNote(id: string, jump: boolean) {
if (!DDS.isTab && jump) {
fluidTabsRef.current?.goToPage("editor");
}
NotesnookModule.setAppState(
JSON.stringify({
editing: true,
movedAway: false,
timestamp: Date.now()
})
);
const tab = useTabStore.getState().getTabForNote(id);
if (useTabStore.getState().currentTab !== tab) {

View File

@@ -30,8 +30,6 @@ interface NotesnookModuleInterface {
getActivityName: () => Promise<string>;
setBackgroundColor: (color: string) => void;
setSecureMode: (enabled: boolean) => void;
setAppState: (appState: string) => void;
getAppState: () => string;
saveAndFinish: () => void;
setString: (storeName: string, key: string, value: string) => void;
getString: (storeName: string, key: string) => Promise<string>;
@@ -72,8 +70,6 @@ export const NotesnookModule: NotesnookModuleInterface = Platform.select({
getActivityName: () => {},
setBackgroundColor: () => {},
setSecureMode: () => {},
setAppState: () => {},
getAppState: () => {},
saveAndFinish: () => {},
getString: () => {},
setString: () => {},

View File

@@ -6,7 +6,7 @@
"scripts": {
"postinstall": "patch-package && node ./scripts/optimize-fonts.mjs",
"run-android": "react-native run-android --active-arch-only",
"run-android-release": "LOCAL_RELEASE_BUILD=true react-native run-android --active-arch-only --mode=release",
"run-android-release": "STAGING_BUILD=true react-native run-android --active-arch-only --mode=release",
"build": "echo 'No build script defined'",
"run-ios": "react-native run-ios",
"start": "react-native start",

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/web",
"version": "3.3.12",
"version": "3.3.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/web",
"version": "3.3.12",
"version": "3.3.13",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {

View File

@@ -1,7 +1,7 @@
{
"name": "@notesnook/web",
"description": "Your private note taking space",
"version": "3.3.12",
"version": "3.3.13",
"private": true,
"main": "./src/app.js",
"homepage": "https://notesnook.com/",

View File

@@ -571,25 +571,39 @@ export function Editor(props: EditorProps) {
onChange={onSave}
onDownloadAttachment={(attachment) => saveAttachment(attachment.hash)}
onPreviewAttachment={async (data) => {
const { hash, type, mime } = data;
const attachment = await db.attachments.attachment(hash);
if (attachment && mime.startsWith("image/")) {
await previewImageAttachment(attachment);
} else if (
attachment &&
onPreviewDocument &&
type === "file" &&
attachment.mimeType.startsWith("application/pdf")
) {
onPreviewDocument({ hash });
const blob = await downloadAttachment(hash, "blob", id);
if (!blob) {
useEditorStore.setState({ documentPreview: undefined });
return;
try {
const { hash, type } = data;
const attachment = await db.attachments.attachment(hash);
if (!attachment)
throw new Error("No attachment found with hash: " + hash);
if (attachment.mimeType.startsWith("image/")) {
await previewImageAttachment(attachment);
} else if (
onPreviewDocument &&
type === "file" &&
attachment.mimeType.startsWith("application/pdf")
) {
onPreviewDocument({ hash });
const blob = await downloadAttachment(hash, "blob", id);
if (!blob) {
useEditorStore.setState({ documentPreview: undefined });
return;
}
onPreviewDocument({ url: URL.createObjectURL(blob), hash });
} else {
logger.info("Attachment cannot be previewed", {
hash,
type,
mimeType: attachment?.mimeType
});
showToast("error", strings.attachmentPreviewFailed());
}
onPreviewDocument({ url: URL.createObjectURL(blob), hash });
} else {
showToast("error", strings.attachmentPreviewFailed());
} catch (e) {
logger.error(e, "Failed to preview attachment", { data });
showToast(
"error",
(e as Error).message || strings.attachmentPreviewFailed()
);
}
}}
onInsertAttachment={async (type) => {

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { HighlightedResult } from "@notesnook/core";
import { HighlightedResult, Match as TMatch } from "@notesnook/core";
import { Button, Flex, Text } from "@theme-ui/components";
import React, { Fragment, useState } from "react";
import { useEditorStore } from "../../stores/editor-store";
@@ -112,11 +112,7 @@ function SearchResult(props: SearchResultProps) {
}}
>
{item.title.map((match) => (
<Fragment key={match.id}>
<span>{match.prefix}</span>
<span className="match">{match.match}</span>
{match.suffix ? <span>{match.suffix}</span> : null}
</Fragment>
<Match match={match} />
))}
</Text>
@@ -171,11 +167,7 @@ function SearchResult(props: SearchResultProps) {
}}
>
{match.map((match) => (
<Fragment key={match.id}>
<span>{match.prefix}</span>
<span className="match">{match.match}</span>
{match.suffix ? <span>{match.suffix}</span> : null}
</Fragment>
<Match match={match} />
))}
</Text>
}
@@ -193,6 +185,16 @@ function SearchResult(props: SearchResultProps) {
export default React.memo(SearchResult);
function Match({ match }: { match: TMatch }) {
return (
<Fragment key={match.id}>
<span>{match.prefix}</span>
<span className="match">{match.match}</span>
{match.suffix ? <span>{match.suffix}</span> : null}
</Fragment>
);
}
async function menuItems(
item: HighlightedResult,
ids?: string[]

View File

@@ -89,7 +89,22 @@ const features: Record<FeatureKeys, Feature> = {
)
}
]
: [],
: [
{
title: "Improved UX on password reset flow",
subtitle:
"We've added some minor UX improvements to the password reset flow"
},
{
title: "Title $headline$ format change",
subtitle:
"The $headline$ format will now only be extracted from the first paragraph/heading of the note (up to 60 characters)."
},
{
title: "Various bug fixes",
subtitle: "We have squashed various bugs in this release."
}
],
cta: {
title: strings.gotIt(),
icon: Checkmark,

View File

@@ -286,14 +286,7 @@ class EditorStore extends BaseStore<EditorStore> {
const clearIds: string[] = [];
for (const session of sessions) {
if (session.type === "new") continue;
const noteId = isDeleted(item)
? null
: item.type === "note"
? item.id
: item.type === "tiptap"
? item.noteId
: null;
if (noteId && session.note.id !== noteId) continue;
if (session.note.id !== item.id && session.note.contentId !== item.id) continue;
if (isDeleted(item) || isTrashItem(item))
clearIds.push(session.tabId);
// if a note becomes conflicted, reopen the session

View File

@@ -43,6 +43,7 @@ type NewPasswordFormData = {
userResetRequired?: boolean;
password: string;
confirmPassword: string;
recoveryKey?: string;
};
type RecoveryFormData = {
@@ -304,7 +305,7 @@ function RecoveryMethods(props: BaseRecoveryComponentProps<"methods">) {
}
function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
const { navigate } = props;
const { navigate, formData } = props;
const [progress, setProgress] = useState(0);
useEffect(() => {
@@ -329,14 +330,17 @@ function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
subtitle: strings.keyRecoveryProgressDesc()
}}
onSubmit={async (form) => {
const recoveryKey = form.recoveryKey;
if (recoveryKey.length < 40) {
throw new Error(strings.invalidRecoveryKey());
}
setProgress(0);
const user = await db.user.getUser();
if (!user) throw new Error(strings.notLoggedIn());
await useKeyStore
.getState()
.setValue("userEncryptionKey", form.recoveryKey);
navigate("new", {});
await useKeyStore.getState().setValue("userEncryptionKey", recoveryKey);
navigate("new", form);
}}
>
<AuthField
@@ -346,8 +350,19 @@ function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
helpText={strings.enterRecoveryKeyHelp()}
autoComplete="none"
autoFocus
defaultValue={formData?.recoveryKey || ""}
/>
<SubmitButton text={strings.startAccountRecovery()} />
<Flex sx={{ gap: 1 }}>
<Button
variant="secondary"
type="button"
sx={{ mt: 50, borderRadius: 50 }}
onClick={() => navigate("methods")}
>
{strings.back()}
</Button>
<SubmitButton text={strings.startAccountRecovery()} />
</Flex>
<Button
type="button"
@@ -386,18 +401,29 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) {
subtitle: strings.resetPasswordWait()
}}
onSubmit={async (form) => {
setProgress(0);
try {
setProgress(0);
if (form.password !== form.confirmPassword)
throw new Error("Passwords do not match.");
if (form.password !== form.confirmPassword)
throw new Error("Passwords do not match.");
if (formData?.userResetRequired && !(await db.user.resetUser()))
throw new Error("Failed to reset user.");
if (formData?.userResetRequired && !(await db.user.resetUser()))
throw new Error("Failed to reset user.");
if (!(await db.user.resetPassword(form.password)))
throw new Error("Could not reset account password.");
if (!(await db.user.resetPassword(form.password)))
throw new Error("Could not reset account password.");
navigate("final");
navigate("final");
} catch (e) {
if ((e as Error).message === "invalid input") {
console.error(e);
throw new Error(
"Password reset failed because of invalid recovery key"
);
}
throw e;
}
}}
>
{(form?: NewPasswordFormData) => (
@@ -417,7 +443,22 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) {
label={strings.confirmPassword()}
defaultValue={form?.confirmPassword}
/>
<SubmitButton text={strings.continue()} />
<Flex sx={{ gap: 1 }}>
<Button
variant="secondary"
type="button"
sx={{ mt: 50, borderRadius: 50 }}
onClick={() =>
navigate(
formData?.userResetRequired ? "methods" : "method:key",
formData
)
}
>
{strings.back()}
</Button>
<SubmitButton text={strings.continue()} />
</Flex>
</>
)}
</RecoveryForm>
@@ -425,39 +466,20 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) {
}
function Final(_props: BaseRecoveryComponentProps<"final">) {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
async function finalize() {
await RecoveryKeyDialog.show({});
if (!isSessionExpired()) {
await db.user.logout(true, "Password changed.");
if (isSessionExpired()) {
openURL("/sessionexpired");
} else {
await db.user.clearSessions(true);
openURL("/login");
}
setIsReady(true);
}
finalize();
}, []);
if (!isReady && !isSessionExpired)
return <Loader text="" title={strings.loading() + "..."} />;
return (
<RecoveryForm
testId="step-finished"
type="final"
title={strings.recoverySuccess()}
subtitle={strings.recoverySuccessDesc()}
onSubmit={async () => {
openURL(isSessionExpired() ? "/sessionexpired" : "/login");
}}
>
<SubmitButton
text={
isSessionExpired() ? strings.continue() : strings.loginToYourAccount()
}
/>
</RecoveryForm>
);
return null;
}
type RecoveryFormProps<TType extends RecoveryRoutes> = {
@@ -537,7 +559,7 @@ export function RecoveryForm<T extends RecoveryRoutes>(
{subtitle}
</Text>
{typeof children === "function" ? children(form) : children}
<ErrorText error={error} />
<ErrorText error={error} sx={{ mt: 2 }} />
</Flex>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -28,7 +28,14 @@ Notesnook allows you to delete your accounts and your data without any questions
### What information is deleted?
All your notes, notebooks, attachments, and any other data that you created within the app will be immediately deleted.
When you delete your account, **all** of the following will be permanently removed:
- All your notes, notebooks, attachments, and any other data you created within the app
- Your account login credentials (email and password)
- Your subscription and billing information
- Your registered user account completely
After deletion, your email address will no longer be associated with Notesnook and cannot be used to log in.
### Is there any way to recover deleted data?

View File

@@ -0,0 +1,100 @@
---
title: Setting reminders on notes
description: Learn how to create reminders on your notes in Notesnook. Set one-time or recurring reminders with custom notification preferences.
---
# Creating reminders
Reminders help you stay on top of your important notes. You can create one-time or recurring reminders with custom notification settings.
## Adding a reminder to a note
# [Desktop/Web](#/tab/web)
1. Right click on a note to open the `Note properties` menu
2. Click on `Remind me` to open the add reminder dialog
3. Enter a title for the reminder and description (optional)
4. Choose your reminder type:
- **Once**: Set a specific date and time
- **Repeat** (paid plans only): Create a recurring reminder (daily, weekly, monthly, or yearly)
5. Select your notification preference (Silent, Vibrate, or Urgent)
6. Click `Add` to save the reminder
# [Mobile](#/tab/mobile)
1. Press the ![Three dot button](/three-dot-button.png) button on a note
2. Tap `Remind me` to open the add reminder dialog
3. Enter a title for the reminder and description (optional)
4. Choose your reminder type:
- **Once**: Set a specific date and time
- **Repeat** (paid plans only): Create a recurring reminder (daily, weekly, monthly, or yearly)
- **Permanent** _(Android only)_: A persistent reminder that shows up every day
5. Select your notification preference (Silent, Vibrate, or Urgent)
6. Tap the checkmark button on top right to save the reminder
---
## Creating a standalone reminder
You can also create reminders without attaching them to a specific note. This is useful for general tasks or events.
# [Desktop/Web](#/tab/web)
1. Go to the `Reminders` screen from the side menu
2. Click the add reminder button (![Plus icon](/plus-reminder-icon.png)) on top right
3. Enter a title for the reminder and description (optional)
4. Choose your reminder type (Once or Repeat)
5. Select your notification preference (Silent, Vibrate, or Urgent)
6. Click `Add` to save the reminder
# [Mobile](#/tab/mobile)
1. Go to the `Reminders` screen from the side menu
2. Tap the add reminder button (![Plus icon](/plus-icon.png)) on bottom right
3. Enter a title for the reminder and description (optional)
4. Choose your reminder type (Once, Repeat, or Permanent on Android)
5. Select your notification preference (Silent, Vibrate, or Urgent)
6. Tap the checkmark button on top right to save the reminder
---
## Configuring recurring reminders
> info Recurring reminders are a paid feature
>
> Recurring reminders can only be used if you are on a paid plan.
When you select **Repeat** mode, you can customize how often you want to be reminded:
- **Daily**: Get reminded every single day at your chosen time
- **Weekly**: Select specific days of the week (e.g., weekdays only)
- **Monthly**: Pick specific dates of the month
- **Yearly**: Choose the month and day for your yearly reminder
---
## Notification preferences
Choose how you want to be notified:
- **Silent**: Receive a notification without sound or vibration
- **Vibrate**: Get a notification with vibration
- **Urgent**: Receive a notification with sound and vibration
> info
>
> Reminders require notification permissions. You'll be asked to enable notifications when you create your first reminder.
---
## Editing or viewing reminders
To edit an existing reminder, go to `Reminders` screen from the side menu and select the reminder you want to edit. You can change all reminder settings, including the date, time, and recurrence pattern.
To see all reminders attached to a note, open the note properties go to `Reminders`. This will show you a list of all reminders associated with that note.
---
## Permanent reminders (Android only)
**Permanent reminders** are available only on Android devices. Unlike one-time or recurring reminders, permanent reminders persist every day and won't disappear after triggering. This is useful for important daily tasks or notes you want constant access to.

View File

@@ -40,7 +40,7 @@ Go to `Settings` > `Editor` > `Title format` to customize the title formatting.
You can use a combination of above templates in the note title. For example `Note $count$ - $date$` will become `Note 150 - 06-22-2023`.
**$headline$**: Up to first 10 words of the note's headline. This will keep updating the title as headline of the note changes until you manually edit the title. Shouldn't be used in combination with other templates.
**$headline$**: Up to first 60 characters of the note's first paragraph or heading. This will keep updating the title as headline of the note changes until you manually edit the title. Shouldn't be used in combination with other templates.
## Paragraph spacing

View File

@@ -58,6 +58,7 @@ navigation:
- path: keyboard-shortcuts.md
- path: privacy-mode.md
- path: reminders.md
- path: web-clipper
children:
- path: web-clipper/installation.md

View File

@@ -163,13 +163,13 @@ export class TabSessionHistory {
canGoBack(id: string) {
const tabHistory = this.options.get().tabSessionHistory[id];
if (!tabHistory) return false;
if (!tabHistory || !tabHistory.backStack) return false;
return tabHistory.backStack.length > 1;
}
canGoForward(id: string) {
const tabHistory = this.options.get().tabSessionHistory[id];
if (!tabHistory) return false;
if (!tabHistory || !tabHistory.forwardStack) return false;
return tabHistory.forwardStack.length >= 1;
}
}

View File

@@ -141,6 +141,23 @@ test("search reminders", () =>
}));
describe("notesWithHighlighting", () => {
test("search notes with nbsp in should decode html entities", () =>
noteTest({
title: "(with nbsp)",
content: {
type: "tiptap",
data: "<p>hello&nbsp;i&nbsp;am&nbsp;a&nbsp;note</p>"
}
}).then(async ({ db }) => {
const filtered = await db.lookup.notesWithHighlighting(
"hello",
db.notes.all
);
const item = await filtered.item(0);
expect(item.item).toBeDefined();
expect(item.item.content[0][0].suffix.includes("&nbsp;")).toBe(false);
}));
test("search notes with parentheses in query should load the item", () =>
noteTest({
title: "(with parantheses)"

View File

@@ -186,14 +186,15 @@ test("note title with headline format should keep generating headline title unti
[
["simple p tag", "<p>headline</p>", "headline"],
["across multiple tags", "<h1>title<h1><ol><li>list</li></ol>", "titlelist"],
["across multiple tags", "<h1>title</h1><ol><li>list</li></ol>", "title"],
["empty first p tag", "<p></p><p>actual title</p>", "actual title"],
[
"content with exceeded HEADLINE_CHARACTER_LIMIT",
"<p><strong>head</strong><em>line</em></p><h1>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam rutrum ex ac eros egestas, ut rhoncus felis faucibus. Mauris tempor orci nisl, vitae pulvinar turpis convallis n</h1>",
"headlineLorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam rutrum ex ac eros egestas, ut rhoncus felis faucibus. Mauris tempor orci nisl,"
"<p><strong>head</strong><em>line</em>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam rutrum ex ac eros egestas, ut rhoncus felis faucibus. Mauris tempor orci nisl, vitae pulvinar turpis convallis n</p>",
"headlineLorem ipsum dolor sit amet, consectetur adipiscing e"
]
].forEach(([testCase, content, expectedHeadline]) => {
test(`note title with headline format should generate headline title up to 150 characters - ${testCase}`, () =>
test(`note title with headline format should generate headline title up to 60 characters - ${testCase}`, () =>
noteTest().then(async ({ db }) => {
await db.settings.setTitleFormat("$headline$");
const id = await db.notes.add({

View File

@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { match } from "fuzzyjs";
import { decodeHTML } from "entities";
import Database from "./index.js";
import {
HighlightedResult,
@@ -815,7 +816,7 @@ export function splitHighlightedMatch(text: string): Match[][] {
}
matches.push({
match,
match: match,
prefix: prefix.replace(/\s{2,}/gm, " ").trimStart(),
suffix: suffix || "",
id: matchId || undefined
@@ -1053,7 +1054,7 @@ function highlightHtmlContent(html: string, queries: string[]): string {
}
},
{
decodeEntities: false,
decodeEntities: true,
xmlMode: false
}
);

View File

@@ -94,7 +94,7 @@ export class Tiptap {
}
toTitle() {
return extractTitle(this.data, 150);
return extractTitle(this.data, 60);
}
// isEmpty() {

View File

@@ -98,20 +98,35 @@ describe("lookup.fuzzy", () => {
const items = [
{ id: "1", title: "file search.jpg" },
{ id: "2", title: "file-search.jpg" },
{ id: "3", title: "file_search.jpg" }
{ id: "3", title: "file_search.jpg" },
{ id: "4", title: "file____search-393.jpg" },
{ id: "5", title: "note-393.jpg" }
];
test("query with space matches all separator variants", () => {
const result = fuzzy("file search", items, (i) => i.id, { title: 1 });
expect(result).toStrictEqual(items);
const result = fuzzy("fl srch", items, (i) => i.id, { title: 1 });
expect(result).toStrictEqual(items.slice(0, 4));
});
test("variants with only separators should match", () => {
const result = fuzzy(
"---",
[
{ id: "1", title: "--------.jpg" },
{ id: "2", title: "abc.jpg" }
],
(i) => i.id,
{ title: 1 }
);
expect(result).toStrictEqual([{ id: "1", title: "--------.jpg" }]);
});
test("query with special character between words matches all separator variants", () => {
let result = fuzzy("file_search", items, (i) => i.id, { title: 1 });
expect(result).toStrictEqual(items);
expect(result).toStrictEqual([items[2], items[3], items[0], items[1]]);
result = fuzzy("file-search", items, (i) => i.id, { title: 1 });
expect(result).toStrictEqual(items);
expect(result).toStrictEqual([items[1], items[0], items[2], items[3]]);
});
});
});

View File

@@ -20,18 +20,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { match, surround } from "fuzzyjs";
import { clone } from "./clone.js";
const SEPARATORS = /(?<=\w)[^a-zA-Z0-9](?=\w)/g;
function normalizeSeparators(str: string): string {
return str.replace(SEPARATORS, " ");
}
function hasSeparator(str: string): boolean {
const result = SEPARATORS.test(str);
SEPARATORS.lastIndex = 0;
return result;
}
export function fuzzy<T>(
query: string,
items: T[],
@@ -43,6 +31,50 @@ export function fuzzy<T>(
suffix?: string;
} = {}
): T[] {
const results = fuzzyMatch(query, items, getIdentifier, fields, options);
if (results.size === 0) return [];
// if the query contains spaces & other non-alphanumeric characters, then we
// should search again with a sanitized query to catch more matches.
const sanitizedQuery = sanitize(query);
if (sanitizedQuery !== query) {
for (const [key, value] of fuzzyMatch(
sanitizedQuery,
items,
getIdentifier,
fields,
options
)) {
const matchInFirstSearch = results.get(key);
if (matchInFirstSearch) {
// just give it an extra bump because matches from first search
// must always be higher as they probably match much more closely
// with the query given by the user.
matchInFirstSearch.score += value.score;
continue;
}
results.set(key, value);
}
}
const matches = Array.from(results.entries())
.sort((a, b) => b[1].score - a[1].score)
.map((item) => item[1].item);
return matches;
}
function fuzzyMatch<T>(
query: string,
items: T[],
getIdentifier: (item: T) => string,
fields: Partial<Record<keyof T, number>>,
options: {
limit?: number;
prefix?: string;
suffix?: string;
} = {}
) {
const results: Map<
string,
{
@@ -51,9 +83,6 @@ export function fuzzy<T>(
}
> = new Map();
const shouldNormalize = hasSeparator(query);
const finalQuery = shouldNormalize ? normalizeSeparators(query) : query;
for (const item of items) {
if (options.limit && results.size >= options.limit) break;
@@ -61,10 +90,7 @@ export function fuzzy<T>(
for (const field in fields) {
const value = `${item[field]}`;
const result = match(
finalQuery,
shouldNormalize ? normalizeSeparators(value) : value
);
const result = match(query, value);
if (!result.match) continue;
const oldMatch = results.get(identifier);
@@ -87,11 +113,10 @@ export function fuzzy<T>(
}
}
}
if (results.size === 0) return [];
const sorted = Array.from(results.entries());
sorted.sort((a, b) => b[1].score - a[1].score);
return sorted.map((item) => item[1].item);
return results;
}
const SEPARATORS = /[^a-zA-Z0-9]+/g;
function sanitize(str: string): string {
return str.replace(SEPARATORS, "").trim() || str;
}

View File

@@ -72,11 +72,31 @@ export function extractHeadline(html: string) {
return text;
}
const TITLE_SOURCE_TAGS = ["p", "h1", "h2", "h3", "h4", "h5", "h6"];
export function extractTitle(html: string, characterLimit: number) {
let text = "";
let rootTag: string | undefined = undefined;
const parser = new Parser(
{
onopentag: (name) => {
if (!rootTag && TITLE_SOURCE_TAGS.includes(name)) {
rootTag = name;
}
},
onclosetag: (name) => {
if (name === rootTag) {
if (text) {
parser.pause();
parser.end();
} else {
rootTag = undefined;
}
}
},
ontext: (data) => {
if (!rootTag) return;
text += data;
if (text.length > characterLimit) {
text = text.slice(0, characterLimit);
@@ -205,7 +225,7 @@ export function extractMatchingBlocks(html: string, matchTagName: string) {
},
{
lowerCaseTags: false,
decodeEntities: false
decodeEntities: true
}
);
parser.end(html);

View File

@@ -68,7 +68,7 @@
"@notesnook/intl": "file:../intl",
"@notesnook/theme": "file:../theme",
"@notesnook/ui": "file:../ui",
"@social-embed/lib": "^0.1.0-next.7",
"@social-embed/lib": "^0.1.0-next.11",
"@tiptap/core": "2.6.6",
"@tiptap/extension-blockquote": "^2.6.6",
"@tiptap/extension-bullet-list": "^2.6.6",

View File

@@ -169,7 +169,7 @@ function handleMouseDown(
if (
event.target instanceof Element &&
event.target.closest(".column-resize-handle") == null
event.target.closest(".column-resize-handle.active") == null
) {
console.log("No handle target");
return false;
@@ -225,6 +225,10 @@ function handleMouseDown(
function move(event: MouseEvent | TouchEvent): void {
if (event instanceof MouseEvent && !event.which) return finish(event);
// Prevent the page/table from scrolling while the user is dragging a
// column resize handle on touch. The touchmove listener is registered
// with { passive: false } below so this call is actually honoured.
if (isTouchEvent(event)) event.preventDefault();
const clientX = getClientX(event);
if (clientX === null) return;
const pluginState = columnResizingPluginKey.getState(view.state);
@@ -240,7 +244,9 @@ function handleMouseDown(
win.addEventListener("mousemove", move);
win.addEventListener("touchend", finish);
win.addEventListener("touchcancel", finish);
win.addEventListener("touchmove", move);
// { passive: false } is required so that event.preventDefault() inside
// move() is honoured — modern browsers default touchmove to passive.
win.addEventListener("touchmove", move, { passive: false });
event.preventDefault();
if (isTouchEvent(event)) (view as any).domObserver.disconnectSelection();
return true;
@@ -422,6 +428,11 @@ function createResizeHandle(active: boolean) {
const dom = document.createElement("div");
dom.className = "column-resize-handle";
if (active) dom.classList.add("active");
// Prevent the browser from treating a touch on this handle as a scroll/pan
// gesture. touch-action is evaluated at touchstart time, before the browser
// commits to any scroll mode, so this reliably prevents the table's
// overflow-scrolling container from capturing the drag on iOS Safari.
dom.style.touchAction = "none";
dom.onmouseenter = () => {
dom.classList.add("active");
};

View File

@@ -215,18 +215,87 @@ export function handleMouseDown(
// cell selection to be created).
setCellSelection($anchor, startEvent);
startEvent.preventDefault();
} else if (isTouchEvent(startEvent)) {
const selectedCell = view.domAtPos(view.state.selection.from).node;
if (startDOMCell?.contains(selectedCell)) {
$anchor = cellUnderMouse(view, startEvent);
if (!$anchor) return;
setCellSelection($anchor, startEvent);
}
} else if (!startDOMCell) {
// Not in a cell, let the default behavior happen.
return;
}
if (isTouchEvent(startEvent)) {
// For touch, only begin tracking cross-cell selection if the touch started
// inside the cell that already holds the active cursor/selection. Any other
// touch is a scroll or plain tap — leave it alone.
// Use ProseMirror cell positions rather than DOM containment so the check
// is robust regardless of what domAtPos returns (text node, <p>, etc.).
const $selectionCell = cellAround(view.state.selection.$anchor);
const $touchedCell = cellUnderMouse(view, startEvent);
if (
!$selectionCell ||
!$touchedCell ||
$selectionCell.pos !== $touchedCell.pos ||
!view.state.selection.empty
)
return;
}
// Auto-scroll state for touch cell selection. While the user is dragging a
// cell selection, normal scroll is suppressed (event.preventDefault). Instead
// we watch how close the finger is to the edges of the nearest scrollable
// ancestor and scroll that container programmatically.
const SCROLL_THRESHOLD = 40; // px from edge before scroll kicks in
const SCROLL_MAX_SPEED = 4; // px per animation frame at the very edge
let autoScrollRAF: number | null = null;
let autoScrollX = 0;
let autoScrollY = 0;
let scrollTarget: Element | null = null;
function scrollStep(): void {
autoScrollRAF = null;
if (!scrollTarget) return;
const rect = scrollTarget.getBoundingClientRect();
const leftDist = autoScrollX - rect.left;
const rightDist = rect.right - autoScrollX;
const topDist = autoScrollY - rect.top;
const bottomDist = rect.bottom - autoScrollY;
const dx =
leftDist < SCROLL_THRESHOLD
? -SCROLL_MAX_SPEED * (1 - leftDist / SCROLL_THRESHOLD)
: rightDist < SCROLL_THRESHOLD
? SCROLL_MAX_SPEED * (1 - rightDist / SCROLL_THRESHOLD)
: 0;
const dy =
topDist < SCROLL_THRESHOLD
? -SCROLL_MAX_SPEED * (1 - topDist / SCROLL_THRESHOLD)
: bottomDist < SCROLL_THRESHOLD
? SCROLL_MAX_SPEED * (1 - bottomDist / SCROLL_THRESHOLD)
: 0;
if (dx !== 0 || dy !== 0) {
scrollTarget.scrollBy(dx, dy);
autoScrollRAF = requestAnimationFrame(scrollStep);
}
}
function updateAutoScroll(x: number, y: number): void {
autoScrollX = x;
autoScrollY = y;
if (!scrollTarget)
// Walk up from the starting cell, not from view.dom. The table's scroll
// container (.scroll-bar) is an ancestor of the <td>, whereas view.dom is
// the editor root which sits *inside* the scroll container — walking up
// from it would skip past the table scroller and find the page scroller.
scrollTarget = findScrollableAncestor(startDOMCell ?? view.dom);
// Only schedule a new frame if one isn't already pending.
if (autoScrollRAF == null)
autoScrollRAF = requestAnimationFrame(scrollStep);
}
function stopAutoScroll(): void {
if (autoScrollRAF != null) {
cancelAnimationFrame(autoScrollRAF);
autoScrollRAF = null;
}
scrollTarget = null;
}
// Create and dispatch a cell selection between the given anchor and
// the position under the mouse.
function setCellSelection(
@@ -248,39 +317,88 @@ export function handleMouseDown(
}
// Stop listening to mouse motion events.
function stop(): void {
function stop(event?: Event): void {
view.root.removeEventListener("mouseup", stop);
view.root.removeEventListener("dragstart", stop);
view.root.removeEventListener("mousemove", move);
view.root.removeEventListener("touchmove", move);
view.root.removeEventListener("touchend", stop);
view.root.removeEventListener("touchcancel", stop);
if (tableEditingKey.getState(view.state) != null) {
const hadActiveSelection = tableEditingKey.getState(view.state) != null;
stopAutoScroll();
if (hadActiveSelection) {
(view as any).domObserver.suppressSelectionUpdates();
view.dispatch(view.state.tr.setMeta(tableEditingKey, -1));
}
// Prevent native drag-and-drop from moving/deleting selected text when the
// browser interprets a cell-selection drag as a text drag (Bug: content
// becomes empty). Also prevent the browser's synthetic tap after a
// touch-based cell selection from collapsing the CellSelection back to a
// text cursor in the last touched cell (Bug: cell selection disappears).
if (
event &&
(event.type === "dragstart" ||
(event.type === "touchend" && hadActiveSelection))
) {
event.preventDefault();
}
}
function move(_event: Event): void {
const event = _event as MouseEvent | DragEvent | TouchEvent;
// For touch: we've already committed this gesture to cell-selection tracking
// (the guard at touchstart ensured that). Suppress the browser scroll on
// every touchmove — this must happen here because:
// a) touchstart preventDefault is ignored when registered as passive (the
// default on most mobile browsers for ProseMirror's event dispatch), and
// b) our own touchmove listener is registered with { passive: false } so
// preventDefault() here is guaranteed to work.
if (isTouchEvent(event)) {
event.preventDefault();
// Always feed the current finger position to auto-scroll regardless of
// whether a cross-cell selection has started yet. This must be outside the
// branch below because once tableEditingKey has state (anchor != null) we
// no longer enter the isTouchEvent branch, so coordinates would go stale.
const x = getClientX(event);
const y = getClientY(event);
if (x != null && y != null) updateAutoScroll(x, y);
}
const anchor = tableEditingKey.getState(view.state);
let $anchor;
if (anchor != null) {
// Continuing an existing cross-cell selection
$anchor = view.state.doc.resolve(anchor);
} else if (isTouchEvent(event)) {
// For touch events, event.target stays fixed at the touchstart element,
// so use coordinates to detect movement into a different cell.
const $startCell = cellUnderMouse(view, startEvent);
const $currentCell = cellUnderMouse(view, event);
if ($startCell && $currentCell && $startCell.pos !== $currentCell.pos) {
$anchor = $startCell;
}
} else if (domInCell(view, event.target as Node) != startDOMCell) {
// Moving out of the initial cell -- start a new cell selection
$anchor = cellUnderMouse(view, startEvent);
if (!$anchor) return stop();
}
if ($anchor) setCellSelection($anchor, event);
if ($anchor) {
// For mouse: prevent native drag-and-drop from interfering.
if (!isTouchEvent(event)) event.preventDefault();
setCellSelection($anchor, event);
}
}
view.root.addEventListener("mouseup", stop);
view.root.addEventListener("dragstart", stop);
view.root.addEventListener("mousemove", move);
view.root.addEventListener("touchmove", move);
view.root.addEventListener("touchend", stop);
// { passive: false } is required for touchmove/touchend so that
// event.preventDefault() inside the handlers actually suppresses scroll.
// Without it, modern browsers treat the listener as passive by default and
// silently ignore any preventDefault() calls.
view.root.addEventListener("touchmove", move, { passive: false });
view.root.addEventListener("touchend", stop, { passive: false });
view.root.addEventListener("touchcancel", stop);
}
@@ -330,3 +448,11 @@ function cellUnderMouse(
if (!mousePos) return null;
return mousePos ? cellAround(view.state.doc.resolve(mousePos.pos)) : null;
}
function findScrollableAncestor(el: HTMLElement | Node): Element {
return (
el.parentElement?.closest(".scroll-bar") ||
document.scrollingElement ||
document.documentElement
);
}

View File

@@ -831,10 +831,6 @@ msgstr "All tools in this group will be removed from the toolbar."
msgid "All your backups are stored in 'Phone Storage/Notesnook/backups/' folder"
msgstr "All your backups are stored in 'Phone Storage/Notesnook/backups/' folder"
#: src/strings.ts:1078
msgid "All your data will be removed permanently. Make sure you have saved backup of your notes. This action is IRREVERSIBLE."
msgstr "All your data will be removed permanently. Make sure you have saved backup of your notes. This action is IRREVERSIBLE."
#: src/strings.ts:108
msgid "Already have an account?"
msgstr "Already have an account?"
@@ -1109,6 +1105,10 @@ msgstr "Available on iOS"
msgid "Available on iOS & Android"
msgstr "Available on iOS & Android"
#: src/strings.ts:2640
msgid "Back"
msgstr "Back"
#: src/strings.ts:2302
msgid "Background color"
msgstr "Background color"
@@ -3448,6 +3448,10 @@ msgstr "Invalid CORS proxy url"
msgid "Invalid email"
msgstr "Invalid email"
#: src/strings.ts:2642
msgid "Invalid recovery key. Make sure to input your account recovery key, not a 2FA recovery code."
msgstr "Invalid recovery key. Make sure to input your account recovery key, not a 2FA recovery code."
#: src/strings.ts:224
msgid "Issue created"
msgstr "Issue created"
@@ -7489,6 +7493,10 @@ msgstr "Your account password must be strong & unique."
msgid "Your account will be downgraded in {days} days"
msgstr "Your account will be downgraded in {days} days"
#: src/strings.ts:1078
msgid "Your account will be permanently deleted along with all your data, login credentials, and subscription information. This action is IRREVERSIBLE. Make sure you have saved a backup of your notes before proceeding."
msgstr "Your account will be permanently deleted along with all your data, login credentials, and subscription information. This action is IRREVERSIBLE. Make sure you have saved a backup of your notes before proceeding."
#: src/strings.ts:960
msgid "Your archive"
msgstr "Your archive"

View File

@@ -831,10 +831,6 @@ msgstr ""
msgid "All your backups are stored in 'Phone Storage/Notesnook/backups/' folder"
msgstr ""
#: src/strings.ts:1078
msgid "All your data will be removed permanently. Make sure you have saved backup of your notes. This action is IRREVERSIBLE."
msgstr ""
#: src/strings.ts:108
msgid "Already have an account?"
msgstr ""
@@ -1109,6 +1105,10 @@ msgstr ""
msgid "Available on iOS & Android"
msgstr ""
#: src/strings.ts:2640
msgid "Back"
msgstr ""
#: src/strings.ts:2302
msgid "Background color"
msgstr ""
@@ -3428,6 +3428,10 @@ msgstr ""
msgid "Invalid email"
msgstr ""
#: src/strings.ts:2642
msgid "Invalid recovery key. Make sure to input your account recovery key, not a 2FA recovery code."
msgstr ""
#: src/strings.ts:224
msgid "Issue created"
msgstr ""
@@ -7434,6 +7438,10 @@ msgstr ""
msgid "Your account will be downgraded in {days} days"
msgstr ""
#: src/strings.ts:1078
msgid "Your account will be permanently deleted along with all your data, login credentials, and subscription information. This action is IRREVERSIBLE. Make sure you have saved a backup of your notes before proceeding."
msgstr ""
#: src/strings.ts:960
msgid "Your archive"
msgstr ""

View File

@@ -1075,7 +1075,7 @@ $day$: Current day (eg. Monday)`,
logoutError: () => t`Error logging out`,
deleteAccount: () => t`Delete account`,
deleteAccountDesc: () =>
t`All your data will be removed permanently. Make sure you have saved backup of your notes. This action is IRREVERSIBLE.`,
t`Your account will be permanently deleted along with all your data, login credentials, and subscription information. This action is IRREVERSIBLE. Make sure you have saved a backup of your notes before proceeding.`,
enterAccountPassword: () => t`Enter account password`,
enterAccountPasswordDesc: () => t`Enter account password to proceed.`,
failedToDeleteAccount: () => t`Failed to delete account`,
@@ -2636,5 +2636,8 @@ Use this if changes from other devices are not appearing on this device. This wi
deleteData: () => t`Delete data`,
failedToAttachFile: () => t`Failed to attach file`,
unlockNoteToMergeConflicts: () => t`Unlock note to merge conflicts`,
confirmationEmailSent: () => t`Confirmation email sent`
confirmationEmailSent: () => t`Confirmation email sent`,
back: () => t`Back`,
invalidRecoveryKey: () =>
t`Invalid recovery key. Make sure to input your account recovery key, not a 2FA recovery code.`
};