Compare commits

..

19 Commits

Author SHA1 Message Date
Ammar Ahmed
b7777a2a19 mobile: fix groupOptions causes unnecessary rerenders on first load 2026-08-14 10:55:53 +05:00
01zulfi
d647eb8187 web: show error when unlock note password is empty (#10231)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-08-13 08:36:12 +05:00
Ammar Ahmed
b181655edc Merge pull request #10147 from kashaf-ansari-dev/fix/10011-recovery-key-font
mobile: improve recovery key readability
2026-08-10 14:12:46 +05:00
Ammar Ahmed
67ffcb163c Merge pull request #10183 from kashaf-ansari-dev/fix-10165-empty-trash-note
mobile: allow opening empty trashed notes without contentId
2026-08-10 14:12:12 +05:00
Ammar Ahmed
982cea7aa6 Merge pull request #10182 from kashaf-ansari-dev/mobile/improve-skip-wording
mobile: improve skip & go directly to app
2026-08-10 14:11:32 +05:00
Ammar Ahmed
adab66f285 Merge pull request #10198 from kashaf-ansari-dev/fix-rniap-play-billing
mobile: bump Play Billing Library to 8.0.0 via patch-package for reac…
2026-08-10 14:10:44 +05:00
Ammar Ahmed
1740ba9e10 mobile: bump Play Billing Library to 8.0.0 via patch-package for react-native-iap
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-08-10 14:09:22 +05:00
Ammar Ahmed
9e5ff04692 Merge pull request #10137 from streetwriters/fix/task-list-touch-drag
editor: drag & drop task list items with pointer events
2026-08-10 14:00:26 +05:00
Ammar Ahmed
08bb216334 editor: apply text direction to a whole list, not just the cursor's item 2026-08-09 18:00:53 +05:00
Ammar Ahmed
01da7cbb19 editor: render the drag preview in the editor's own context, 1:1 2026-08-05 19:55:23 +05:00
Ammar Ahmed
e326b912fd editor: RTL nesting, cheaper move and scroller lookup 2026-08-05 19:55:23 +05:00
Abdullah Atta
8bc072a543 editor: remove unnecessary comments
Co-authored-by: Abdullah Atta <thecodrr@protonmail.com>
Signed-off-by: Abdullah Atta <thecodrr@protonmail.com>
2026-08-05 19:55:23 +05:00
Ammar Ahmed
79086108a5 editor: drop placeholder on first item on task list should not hide when moving the dragged item above it or above the task list header. it should safely drop as the first list item.
- Improve drag/drop reliability be increasing the hit slop area for starting the drag.

- Ensure drag survives between rerenders
- Add a solid background to dragged item so it doesn't conflict with items underneath it
2026-08-05 19:55:23 +05:00
Ammar Ahmed
1070831b63 editor: only drop a task item into a task list & fix crash 2026-08-05 19:55:23 +05:00
Ammar Ahmed
efaffc8729 editor: drag task list items with pointer events 2026-08-05 19:55:23 +05:00
kashaf-ansari-dev
696f57423b mobile: improve recovery key readability
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-08-04 10:57:47 +05:00
kashaf-ansari-dev
4d97f7033f mobile: remove accent color on dialogue text and estrics from the string
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-08-03 10:49:31 +05:00
kashaf-ansari-dev
966d57dfa2 mobile: allow opening empty trashed notes without contentId
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-31 18:04:59 +05:00
kashaf-ansari-dev
7a3111c6b3 mobile: improve skip & go directly to app
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-31 13:07:16 +05:00
20 changed files with 897 additions and 50 deletions

View File

@@ -13,6 +13,7 @@ buildscript {
androidXCore = "1.7.0"
androidXBrowser = "1.0.0"
ndkVersion = "27.1.12297006"
playBillingSdkVersion = "8.0.0"
}
repositories {

View File

@@ -24,6 +24,8 @@ import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import { hideAuth } from "./common";
import { AuthParams } from "../../stores/use-navigation-store";
import { strings } from "@notesnook/intl";
import { presentDialog } from "../dialog/functions";
export const AuthHeader = (props: { welcome?: boolean }) => {
const { colors } = useThemeColors();
const route = useRoute();
@@ -56,18 +58,17 @@ export const AuthHeader = (props: { welcome?: boolean }) => {
{!props.welcome ? null : (
<Button
title="Skip"
title={strings.skipAndGoToApp()}
onPress={() => {
hideAuth();
presentDialog({
title: strings.offlineMode(),
paragraph: strings.offlineModeDesc(),
positiveText: strings.understand(),
positivePress: hideAuth as any
});
}}
iconSize={16}
type="plain"
iconPosition="right"
icon="chevron-right"
height={25}
iconStyle={{
marginTop: 2
}}
style={{
paddingHorizontal: 6
}}

View File

@@ -58,9 +58,9 @@ export const openNote = async (
}
if (isTrash) {
if (!note.contentId) return;
const content = await db.content.get(note.contentId as string);
const content = note.contentId
? await db.content.get(note.contentId)
: undefined;
presentSheet({
component: <NotePreview note={item} content={content} />
});

View File

@@ -198,7 +198,7 @@ export default function NotePreview({
<View
style={{
width: "100%",
height: 100,
flex: 1,
justifyContent: "center",
alignItems: "center"
}}

View File

@@ -230,7 +230,7 @@ class RecoveryKeySheet extends React.Component {
>
<Paragraph
color={colors.primary.paragraph}
size={AppFontSize.sm}
size={AppFontSize.md}
numberOfLines={2}
selectable
style={{
@@ -238,7 +238,9 @@ class RecoveryKeySheet extends React.Component {
maxWidth: "100%",
paddingRight: 10,
textAlign: "center",
textDecorationLine: "underline"
textDecorationLine: "underline",
letterSpacing: 0.5,
fontFamily: "monospace"
}}
>
{this.state.key}

View File

@@ -33,7 +33,6 @@ import {
setGroupOptionsById
} from "../../../hooks/use-group-options";
import { eSendEvent } from "../../../services/event-manager";
import Navigation from "../../../services/navigation";
import { RouteName } from "../../../stores/use-navigation-store";
import { useNotebookStore } from "../../../stores/use-notebook-store";
import { useTagStore } from "../../../stores/use-tag-store";
@@ -46,6 +45,7 @@ import { Button } from "../../ui/button";
import { Pressable } from "../../ui/pressable";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
import Navigation from "../../../services/navigation";
const Sort = ({
dataType,
screen,
@@ -87,8 +87,7 @@ const Sort = ({
};
const updateGroupOptions = async (_groupOptions: GroupOptions) => {
console.log(groupId, type);
setGroupOptionsById(groupType, _groupOptions, groupId, type);
await setGroupOptionsById(groupType, _groupOptions, groupId, type);
setGroupOptions(_groupOptions);
setTimeout(() => {
if (screen) Navigation.queueRoutesForUpdate(screen);

View File

@@ -54,15 +54,13 @@ export function useGroupOptions(
const [groupOptions, setGroupOptions] = useState(
getGroupOptions(groupingKey, id, type)
);
console.log(groupingKey, id, type, groupOptions, "options");
const groupOptionsRef = useRef(groupOptions);
groupOptionsRef.current = groupOptions;
useEffect(() => {
const onUpdate = (_groupingKey: string, _id?: string, _type?: string) => {
if (_groupingKey !== groupingKey) return;
if (_id && _type && _id !== id && _type !== type) return;
if (_groupingKey !== groupingKey || _type !== type) return;
if (_id && _type && _id !== id) return;
const options = getGroupOptions(groupingKey, id, type);
if (!options) return;
if (
@@ -70,9 +68,7 @@ export function useGroupOptions(
groupOptionsRef.current?.sortBy !== options.sortBy ||
groupOptionsRef.current?.sortDirection !== options?.sortDirection
) {
console.log("onUpdate", _id, _type);
setGroupOptions({ ...options });
Navigation.queueRoutesForUpdate();
}
};

View File

@@ -1,8 +1,98 @@
diff --git a/node_modules/react-native-iap/android/src/play/java/com/dooboolab/rniap/RNIapModule.kt b/node_modules/react-native-iap/android/src/play/java/com/dooboolab/rniap/RNIapModule.kt
index 70149d2..fc917f3 100644
index 70149d2..cc2b272 100644
--- a/node_modules/react-native-iap/android/src/play/java/com/dooboolab/rniap/RNIapModule.kt
+++ b/node_modules/react-native-iap/android/src/play/java/com/dooboolab/rniap/RNIapModule.kt
@@ -604,7 +604,7 @@ class RNIapModule(
@@ -15,10 +15,8 @@ import com.android.billingclient.api.GetBillingConfigParams
import com.android.billingclient.api.GetBillingConfigParams.Builder
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
-import com.android.billingclient.api.PurchaseHistoryRecord
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
-import com.android.billingclient.api.QueryPurchaseHistoryParams
import com.android.billingclient.api.QueryPurchasesParams
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.LifecycleEventListener
@@ -38,11 +36,14 @@ import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
+import com.android.billingclient.api.PendingPurchasesParams
@ReactModule(name = RNIapModule.TAG)
class RNIapModule(
private val reactContext: ReactApplicationContext,
- private val builder: BillingClient.Builder = BillingClient.newBuilder(reactContext).enablePendingPurchases(),
+ private val builder: BillingClient.Builder = BillingClient.newBuilder(reactContext).enablePendingPurchases(
+ PendingPurchasesParams.newBuilder().enableOneTimeProducts().build(),
+),
private val googleApiAvailability: GoogleApiAvailability = GoogleApiAvailability.getInstance(),
) : ReactContextBaseJavaModule(reactContext),
PurchasesUpdatedListener {
@@ -275,8 +276,10 @@ class RNIapModule(
.setProductList(skuList)
.build()
- billingClient.queryProductDetailsAsync(params) { billingResult, skuDetailsList ->
- if (!isValidResult(billingResult, promise)) return@queryProductDetailsAsync
+ billingClient.queryProductDetailsAsync(params) { billingResult, queryProductDetailsResult ->
+ if (!isValidResult(billingResult, promise)) return@queryProductDetailsAsync
+
+ val skuDetailsList = queryProductDetailsResult.productDetailsList
val items = Arguments.createArray()
for (skuDetails in skuDetailsList) {
@@ -553,43 +556,12 @@ class RNIapModule(
type: String,
promise: Promise,
) {
- ensureConnection(
- promise,
- ) { billingClient ->
- billingClient.queryPurchaseHistoryAsync(
- QueryPurchaseHistoryParams
- .newBuilder()
- .setProductType(
- if (type == "subs") BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP,
- ).build(),
- ) { billingResult: BillingResult, purchaseHistoryRecordList: MutableList<PurchaseHistoryRecord>? ->
-
- if (!isValidResult(billingResult, promise)) return@queryPurchaseHistoryAsync
-
- Log.d(TAG, purchaseHistoryRecordList.toString())
- val items = Arguments.createArray()
- purchaseHistoryRecordList?.forEach { purchase ->
- val item = Arguments.createMap()
- // Add both field names for compatibility
- item.putString("productId", purchase.products[0])
- item.putString("id", purchase.products[0])
- val products = Arguments.createArray()
- purchase.products.forEach { products.pushString(it) }
- item.putArray("productIds", products)
- item.putArray("ids", products)
- item.putDouble("transactionDate", purchase.purchaseTime.toDouble())
- item.putString("transactionReceipt", purchase.originalJson)
- item.putString("purchaseToken", purchase.purchaseToken)
- item.putString("purchaseTokenAndroid", purchase.purchaseToken)
- item.putString("dataAndroid", purchase.originalJson)
- item.putString("signatureAndroid", purchase.signature)
- item.putString("developerPayload", purchase.developerPayload.orEmpty())
- item.putString("platform", "android")
- items.pushMap(item)
- }
- promise.safeResolve(items)
- }
- }
+ promise.safeReject(
+ "E_UNSUPPORTED",
+ "getPurchaseHistoryByType is no longer supported since Play Billing Library 8 " +
+ "removed queryPurchaseHistoryAsync. Use getAvailableItemsByType for active " +
+ "purchases, or reconstruct history server-side.",
+ )
}
@ReactMethod
@@ -604,7 +576,7 @@ class RNIapModule(
isOfferPersonalized: Boolean, // New parameter in V5
promise: Promise,
) {

View File

@@ -33,15 +33,21 @@ type UnlockViewProps = {
};
export function UnlockView(props: UnlockViewProps) {
const { title, subtitle, buttonTitle, unlock } = props;
const [isWrong, setIsWrong] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | undefined>(
undefined
);
const [isUnlocking, setIsUnlocking] = useState(false);
const passwordRef = useRef<HTMLInputElement>(null);
const submit = useCallback(async () => {
if (!passwordRef.current?.value) return;
const password = passwordRef?.current?.value;
if (!password) {
setErrorMessage(strings.passwordRequired());
return;
}
setIsUnlocking(true);
const password = passwordRef.current.value;
try {
await unlock(password);
} catch (e) {
@@ -49,7 +55,7 @@ export function UnlockView(props: UnlockViewProps) {
e instanceof Error &&
e.message.includes("ciphertext cannot be decrypted using that key")
) {
setIsWrong(true);
setErrorMessage(strings.passwordIncorrect());
} else {
showToast("error", `${strings.couldNotUnlock()}: ` + e);
console.error(e);
@@ -57,7 +63,7 @@ export function UnlockView(props: UnlockViewProps) {
} finally {
setIsUnlocking(false);
}
}, [setIsWrong, unlock]);
}, [setErrorMessage, unlock]);
return (
<Flex
@@ -110,12 +116,12 @@ export function UnlockView(props: UnlockViewProps) {
onKeyUp={async (e) => {
if (e.key === "Enter") {
await submit();
} else if (isWrong) {
setIsWrong(false);
} else if (errorMessage) {
setErrorMessage(undefined);
}
}}
/>
{isWrong && <ErrorText sx={{ mt: 1 }} error="Wrong password" />}
{errorMessage && <ErrorText sx={{ mt: 1 }} error={errorMessage} />}
<Button
mt={3}
variant="accent"

33
inbox-public.asc Normal file
View File

@@ -0,0 +1,33 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
Version: openpgp-mobile
xsBNBGoNaK8BCAD0B33KK4LRAvN1lZLpJhQMyk/+Srss56PjFphMH1MmqMgIRRBP
3RykX+7+ibha+5WIFYpgBEaPM9osZz22XUGhVrUzUxJMScjhLWR6xyuv0qs0Dctg
ePdcupPbJND3j9W4OnOBXwv+Ko/fX5K+enJfPp6fxyWbf3X/1BnAYlHyLngBLz8P
mt5qj0qax5V4ujUVU8ByNFvQkjcA+ip0vol2xQlmKa5UXJ1KYfM7LQWa4gQqdaY8
8FOl6CaaWsXO/vCnIF47JClWGJfJ0rAaREI/Kj+MV+i98BPAcZG8Kj523QmgIHo6
Puahi1q9wqX6Vwe6hjhiTUJSWvNjZXGi9A4PABEBAAHNDU5OIDxOTkBOTi5OTj7C
wLsEEwEIAG8FgmoNaK8CCwcJkC34qZJdxA5jNRQAAAAAABwAEHNhbHRAbm90YXRp
b25zLm9wZW5wZ3Bqcy5vcmfLP2eEUAk99foAqkNQSyW9AhUIAhYAAhkBApsDAh4B
FiEEjXaqZcVUExRCuEhoLfipkl3EDmMAAKW7CACxh26CVTjRLjeq/GNueceWRJoT
qF/OMQY/3mnuLMbaEMuYSc03ml6jiMdqZy6qeKgjH36qvpfu68HxPYEOn3WQ/k9V
2Iug4xDFlqFw0IN2Gqkgur+FYbFCjG1mkqsrlKsN2QHqG8sf+5EXegpvNibI+43Q
HZp+Q5B8HftnBANvZngFJz3t0hCCpUgVOTK4vwK8IKDK7zbMqfnT4k5vNbfpxd30
5xfZkQXrltJLaHb5pzgVMeoM00TaHd8WBDFYn8BF1OI/TbcChYpRPkW7WoKKuZ/1
tIBfv/O5h3ZxgSc4NdL1FuLZ0nTscPoN+uBE29XQat8IOiYiA4DvKLbIQe89zsBN
BGoNaK8BCACppIfZHotgC8zKvCkj1sWAny5Qq/AkYdIJh/b//7NhnWyUgiSDnoEV
1yik33CiQgoORR42YfVCZCMH2ZydzeKdaNKd/fFLeMsog0ddp4cW68drDDVvkGso
vSMwvpSS/J4JJ2KXqIbvscJXGzAaFZ61BoY3kvRKHymHGe2oLs2bPscNug7mm8pm
18+IhNeOtuS6MZzdXr9rmfuTtY9zUIbIaOgY3EiiaRkvcQLPcBTwsoM9b9zNMK+1
ivA65KrtgN+T7axcIRT+QFFoNOw7mjHNQybb6qRABWS8AWouot4B2g9tNHndlhNV
aimI+P1RHaP9VOxJFneWkmvviXqznv4TABEBAAHCwKwEGAEIAGAFgmoNaK8JkC34
qZJdxA5jNRQAAAAAABwAEHNhbHRAbm90YXRpb25zLm9wZW5wZ3Bqcy5vcmeywJ1O
IyGqDaRT2EGH4ZZpApsMFiEEjXaqZcVUExRCuEhoLfipkl3EDmMAABm8B/9rKv4l
PNwYXLVQlhyGlF/MvdYvT4Fj2CxtO32Fo++dUlEYJqX++GihXr0HyjdE200Ttb1Q
IpZto9rx5X0QHKGEqVGM+kJ6STOWK5jRlADES6GKE3dZde4Z+QS+BBdEdveZtGwR
GsLArPKjhnbkiBXTrMUkoQa4eGanuXA452io5NsiIeNlMUsC6IAXxuZp8+iBtN9K
65iSrvmasdKgwttbqp0qiI5VudiEAQjrkHDMlGftMqSllZWkavlbpYPIN/Omzh8G
kVbBMAvQtDsxSFACnZCGQ1l3r9qDJWUQyn57qQgrQwYXx+sHIlOcdAMGiPbE49KP
kUYOKopIqix0i2/F
=rfLM
-----END PGP PUBLIC KEY BLOCK-----

View File

@@ -0,0 +1,512 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Editor, Extension } from "@tiptap/core";
import { NodeSelection, Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
import { isAndroid, isiOS } from "../../utils/platform.js";
/**
* Drags a node by its `[data-drag-handle]` with pointer events instead of
* the browser's HTML5 drag & drop, which on iOS and Android loses the
* gesture to text selection and in Firefox never starts inside
* `contenteditable` at all. The drop target is a real gap in the document,
* so the content moves apart the way it will once the node is dropped.
*
* NOTE: only task list items use this so far, see their component.
*/
export const DragDrop = Extension.create({
name: "dragDrop",
addProseMirrorPlugins: () => [dropGapPlugin()]
});
const DROP_GAP_CLASS = "drop-gap";
// how far the pointer has to move before this is a drag and not a tap, or
// how long it has to stay down without moving (touch only)
const DRAG_THRESHOLD = 4;
const HOLD_DELAY = 150;
// how far to the right the pointer travels to nest the item, and by how
// much the gap is indented to show it
const NEST_THRESHOLD = 40;
const NEST_INDENT = 24;
// distance from the edge of the scroller at which auto scrolling starts
const SCROLL_ZONE = 60;
const SCROLL_SPEED = 12;
// how far below a list the pointer can be and still drop into its last slot
const LIST_SLOP = 24;
type DropGap = { pos: number; height: number; indent: number };
const gapKey = new PluginKey<DropGap | null>("drop-gap");
function dropGapPlugin() {
return new Plugin<DropGap | null>({
key: gapKey,
state: {
init: () => null,
apply: (tr, value) => {
const meta = tr.getMeta(gapKey);
return meta === undefined ? value : meta;
}
},
props: {
decorations(state) {
const gap = gapKey.getState(state);
if (!gap || gap.pos > state.doc.content.size) return null;
return DecorationSet.create(state.doc, [
Decoration.widget(gap.pos, () => createGap(gap), {
side: -1,
ignoreSelection: true,
key: `drop-gap-${gap.pos}`
})
]);
}
}
});
}
function createGap({ height, indent }: DropGap) {
// a task list is made of list items, so the gap is one too
const element = document.createElement("li");
element.className = DROP_GAP_CLASS;
element.contentEditable = "false";
element.style.marginInlineStart = `${indent}px`;
element.style.height = "0px";
requestAnimationFrame(() => (element.style.height = `${height}px`));
return element;
}
function setGap(view: EditorView, gap: DropGap | null) {
const current = gapKey.getState(view.state);
if (current === gap) return;
if (current && gap && current.pos === gap.pos) {
const element = view.dom.querySelector<HTMLElement>(`.${DROP_GAP_CLASS}`);
if (element) {
element.style.marginInlineStart = `${gap.indent}px`;
current.indent = gap.indent;
return;
}
}
view.dispatch(view.state.tr.setMeta(gapKey, gap));
}
type Drag = {
item: HTMLElement;
pos: number;
end: number;
/** where the item's top edge is, relative to the pointer */
offsetY: number;
/** measured before the item is hidden, when it still has a size */
height: number;
startX: number;
/** the list reads right to left, so nesting is a drag to the left */
rtl: boolean;
gap?: DropGap;
preview: HTMLElement;
/** what the preview is currently as wide as */
previewWidth: number;
scroller: HTMLElement | null;
frame?: number;
};
/**
* Picks up the task item at `getPos` and moves it wherever it is dropped.
*/
export function startItemDrag(
editor: Editor,
getPos: () => number,
event: PointerEvent
) {
const handle = event.currentTarget as HTMLElement;
const item = handle.closest<HTMLElement>("li");
if (!editor.isEditable || event.button !== 0 || !item) return;
event.stopPropagation();
// the handle has no tap action of its own, so cancelling the default is
// safe — and on touch it is what stops the WebView from starting a text
// selection instead of the drag
if (event.cancelable) event.preventDefault();
const { view } = editor;
let drag: Drag | undefined;
let hold: number | undefined;
const start = () => {
clearTimeout(hold);
if (drag) return drag;
const pos = getPos();
const node = pos >= 0 && view.state.doc.nodeAt(pos);
if (!node) return undefined;
view.dispatch(
view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos))
);
if (isAndroid || isiOS) setTimeout(() => editor.commands.blur());
const box = item.getBoundingClientRect();
const rtl = getComputedStyle(item).direction === "rtl";
const { preview, row } = createPreview(view, item, box);
preview.style.direction = rtl ? "rtl" : "ltr";
drag = {
item,
pos,
end: pos + node.nodeSize,
offsetY: box.top - event.clientY,
height: row.getBoundingClientRect().height,
startX: event.clientX,
rtl,
preview,
previewWidth: box.width,
scroller: getScroller(view.dom)
};
item.style.display = "none";
document.body.style.setProperty("user-select", "none");
return drag;
};
const move = (e: PointerEvent) => {
const state =
drag ??
(Math.hypot(e.clientX - event.clientX, e.clientY - event.clientY) <
DRAG_THRESHOLD
? undefined
: start());
if (!state) return;
e.preventDefault();
const top = e.clientY + state.offsetY;
state.preview.style.transform = `translate3d(0, ${top}px, 0)`;
// undefined means "leave the gap as it is"; null clears it, so a drop
// outside any task list has no target and is cancelled
const target = findGap(view, state, e.clientX, e.clientY, top);
if (target !== undefined) {
state.gap = target ?? undefined;
setGap(view, target);
}
fitPreview(view, state);
autoScroll(state, e.clientY);
};
const end = () => {
const dropped = drag;
cleanup();
if (!dropped?.gap) return;
const at = moveItem(view, dropped.pos, dropped.gap.pos);
if (at !== null && dropped.gap.indent) nestItem(editor, at);
};
const cleanup = () => {
clearTimeout(hold);
// NOTE: on `window`, not the handle. The handle is re-rendered whenever
// the gap moves (a decoration change re-renders the node views), and
// listeners on the old element would be lost — the drag would freeze.
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", end);
window.removeEventListener("pointercancel", cleanup);
if (!drag) return;
cancelAnimationFrame(drag.frame ?? 0);
drag.preview.remove();
drag.item.style.removeProperty("display");
document.body.style.removeProperty("user-select");
setGap(view, null);
drag = undefined;
};
window.addEventListener("pointermove", move, { passive: false });
window.addEventListener("pointerup", end);
window.addEventListener("pointercancel", cleanup);
if (event.pointerType !== "mouse")
hold = setTimeout(start, HOLD_DELAY) as unknown as number;
}
/**
* A copy of the item that follows the pointer, with its nested items left
* out so that tall items stay easy to place.
*/
function createPreview(view: EditorView, item: HTMLElement, box: DOMRect) {
const preview = document.createElement("div");
preview.className = "drag-preview";
preview.style.left = `${box.left}px`;
preview.style.width = `${box.width}px`;
const context = document.createElement("div");
context.className = view.dom.className;
// `.ProseMirror:first-child` adds a top margin to the editor content; the
// wrapper is not that, so drop it or the card gains a top gap
context.style.margin = "0";
preview.appendChild(context);
const list = (item.parentElement ?? document.createElement("ul")).cloneNode(
false
) as HTMLElement;
list.style.margin = list.style.padding = "0";
context.appendChild(list);
const clone = item.cloneNode(true) as HTMLElement;
clone.style.margin = "0";
// the handle is what is being held, not part of the item, so leave it out
// of the copy — otherwise it takes up an empty slot on the start side
clone.querySelector("[data-drag-handle]")?.remove();
let children = 0;
clone.querySelectorAll("ul, ol").forEach((nested) => {
children += nested.querySelectorAll("li").length;
(nested.closest("[class$='-view-content-wrap']") ?? nested).remove();
});
if (children) {
const badge = document.createElement("span");
badge.className = "drag-preview-badge";
badge.textContent = `+${children}`;
clone.appendChild(badge);
}
list.appendChild(clone);
(view.dom.parentElement ?? document.body).appendChild(preview);
return { preview, row: clone };
}
/**
* Where the item would land: the sibling top edge nearest to the top edge
* of the item being dragged, within the task list under the pointer. The
* gap counts as one of those edges, which is what keeps it in place while
* the item is over it — moving it would move everything below it, putting a
* different edge under the item, and it would flicker between the two.
*
* Returns `undefined` to leave the gap where it is (the pointer is over the
* item itself, or off the document for a frame), and `null` to clear it —
* a task item only drops into a task list, so anywhere else is cancelled.
*
* The list is found under the pointer (`pointerY`), but the slot within it
* from the item's own top edge (`top`). The item's top rises above the list
* before the pointer does, so hit testing with the pointer is what lets the
* item reach the very first slot.
*/
function findGap(
view: EditorView,
drag: Drag,
x: number,
pointerY: number,
top: number
): DropGap | null | undefined {
const hx = Math.max(x, view.dom.getBoundingClientRect().left + 1);
// The list under the pointer, or the item's top (the handle is grabbed
// near the top, so they are close).
let list = listAt(view, hx, pointerY) ?? listAt(view, hx, top);
// ...but a list ending just above the point wins if it is deeper. This is
// how the last slot is reached: past the last row the point is over the
// parent, yet dropping there should land the item after the nested list's
// last row, not after the whole parent.
const above = listAbove(view, hx, Math.max(pointerY, top));
if (above && (!list || list.contains(above))) list = above;
if (!list) {
// off the document for a frame (keep the gap) vs. genuinely elsewhere
const element = document.elementFromPoint(hx, pointerY);
return !element || !view.dom.contains(element) ? undefined : null;
}
let closest: number | null = null;
let distance = Infinity;
const consider = (edge: number, pos: number) => {
if (Math.abs(edge - top) >= distance) return;
distance = Math.abs(edge - top);
closest = pos;
};
const children = Array.from(list.children) as HTMLElement[];
for (const child of children) {
const box = child.getBoundingClientRect();
if (!box.height) continue;
if (child.classList.contains(DROP_GAP_CLASS)) {
if (drag.gap) consider(box.top, drag.gap.pos);
continue;
}
const pos = posOf(view, child);
if (pos === null) continue;
consider(box.top, pos.before);
if (child === children.at(-1)) consider(box.bottom, pos.after);
}
if (closest === null) return null;
// dropping the item into itself is a no-op: leave the gap alone
if (closest > drag.pos && closest < drag.end) return undefined;
const toEnd = drag.rtl ? drag.startX - x : x - drag.startX;
const nest = toEnd > NEST_THRESHOLD && canNest(view, closest, drag);
return { pos: closest, height: drag.height, indent: nest ? NEST_INDENT : 0 };
}
/**
* The task list at the given point, if any. When the point is on a list's
* header (the tools bar sits above the first item, outside the `ul`) it
* still resolves to that list, so the item can be dropped into its first
* slot.
*/
function listAt(view: EditorView, x: number, y: number) {
const element = document.elementFromPoint(x, y);
if (!element || !view.dom.contains(element)) return null;
const list =
element.closest<HTMLElement>("ul.tasklist-content-wrapper") ||
element
.closest(".taskList-view-content-wrap")
?.querySelector<HTMLElement>("ul.tasklist-content-wrapper");
return list && view.dom.contains(list) ? list : null;
}
/**
* The task list whose bottom edge is just above `y` (within a slop) —
* nothing is under the point past the last row, so this is what makes the
* last slot reachable there. The deepest such list wins, so the last slot
* of a nested list is preferred to its parent's.
*
* `x` is not used to pick the list: the handle sits at the far left, well
* left of an indented nested list, and a note is a single column anyway —
* only that the point is not off to the right of the list.
*/
function listAbove(view: EditorView, x: number, y: number) {
let match: HTMLElement | null = null;
let matchTop = -Infinity;
const lists = view.dom.querySelectorAll<HTMLElement>(
"ul.tasklist-content-wrapper"
);
for (const list of lists) {
const r = list.getBoundingClientRect();
if (x > r.right) continue;
if (y < r.bottom || y > r.bottom + LIST_SLOP) continue;
// the deepest (lowest starting) list wins
if (r.top > matchTop) {
matchTop = r.top;
match = list;
}
}
return match;
}
/** the item is as wide as the gap it will land in, and as indented */
function fitPreview(view: EditorView, drag: Drag) {
const box = view.dom
.querySelector(`.${DROP_GAP_CLASS}`)
?.getBoundingClientRect();
if (!box?.width || box.width === drag.previewWidth) return;
drag.previewWidth = box.width;
drag.preview.style.left = `${box.left}px`;
drag.preview.style.width = `${box.width}px`;
}
/** the positions around the node `element` renders */
function posOf(view: EditorView, element: HTMLElement) {
try {
const $pos = view.state.doc.resolve(view.posAtDOM(element, 0));
for (let depth = $pos.depth; depth > 0; depth--)
if (view.nodeDOM($pos.before(depth)) === element)
return { before: $pos.before(depth), after: $pos.after(depth) };
} catch (e) {
// the element is not part of the document (yet)
}
return null;
}
/** an item can only nest under a sibling it will still have once moved */
function canNest(view: EditorView, pos: number, drag: Drag) {
const $pos = view.state.doc.resolve(pos);
let at = $pos.start();
for (let index = 0; index < $pos.index(); index++) {
if (at !== drag.pos) return true;
at += $pos.parent.child(index).nodeSize;
}
return false;
}
/** moves the item at `from` to `to`, returning where it ended up */
function moveItem(view: EditorView, from: number, to: number) {
const { state } = view;
const item = state.doc.nodeAt(from);
if (!item) return null;
if (to === from || to === from + item.nodeSize) return from;
// NOTE: `deleteRange`, not `delete`: taking the only child out of a
// nested list leaves the list empty, and an empty list is not valid
// content, so it would be filled with a blank item. This takes the list
// itself away instead.
const tr = state.tr.deleteRange(from, from + item.nodeSize);
const at = Math.min(tr.mapping.map(to), tr.doc.content.size);
// the target is always a task list, but guard anyway: dropping the item
// where it does not fit would put it somewhere unexpected
const $at = tr.doc.resolve(at);
if (!$at.parent.canReplaceWith($at.index(), $at.index(), item.type))
return null;
const steps = tr.steps.length;
tr.replaceRangeWith(at, at, item);
if (tr.steps.length === steps) return null;
// select the item, but only if it really landed where we think it did:
// NodeSelection throws if there is no node right after `at`
const node = tr.doc.resolve(at).nodeAfter;
if (node?.type === item.type) {
tr.setSelection(NodeSelection.create(tr.doc, at));
}
view.dispatch(tr.setMeta("uiEvent", "drop"));
return at;
}
function nestItem(editor: Editor, pos: number) {
const node = editor.state.doc.nodeAt(pos);
if (node)
editor
.chain()
.setTextSelection(pos + 1)
.sinkListItem(node.type.name)
.run();
}
/** dragging past the edge of the note scrolls it */
function autoScroll(drag: Drag, y: number) {
const box = drag.scroller?.getBoundingClientRect();
const top = (box?.top ?? 0) + SCROLL_ZONE;
const bottom = (box?.bottom ?? window.innerHeight) - SCROLL_ZONE;
const speed = y < top ? -SCROLL_SPEED : y > bottom ? SCROLL_SPEED : 0;
cancelAnimationFrame(drag.frame ?? 0);
if (!speed) return;
const step = () => {
(drag.scroller ?? window).scrollBy(0, speed);
drag.frame = requestAnimationFrame(step);
};
drag.frame = requestAnimationFrame(step);
}
function getScroller(element: HTMLElement): HTMLElement | null {
for (let node = element.parentElement; node; node = node.parentElement) {
if (node.scrollHeight <= node.clientHeight) continue;
if (/auto|scroll/.test(getComputedStyle(node).overflowY)) return node;
}
return null;
}

View File

@@ -26,6 +26,7 @@ import { useCallback } from "react";
import type { TaskItemAttributes } from "./task-item.js";
import { useIsMobile } from "../../toolbar/stores/toolbar-store.js";
import { isiOS } from "../../utils/platform.js";
import { startItemDrag } from "../drag-drop/index.js";
import { DesktopOnly } from "../../components/responsive/index.js";
import TaskItem from "@tiptap/extension-task-item";
import { strings } from "@notesnook/intl";
@@ -73,18 +74,16 @@ export function TaskItemComponent(
style={{
flexDirection: "row",
alignItems: "center",
maxWidth: "95%",
maxWidth: "100%",
flexGrow: 1
}}
>
{editor.isEditable && (
<Icon
className="dragHandle"
draggable="true"
// NOTE: Turning this off somehow makes drag/drop stop working
// properly on touch devices.
// contentEditable={false}
data-drag-handle
// dragging is ours, not the browser's: see extensions/drag-drop
onPointerDown={(e) => startItemDrag(editor, getPos, e.nativeEvent)}
path={Icons.dragHandle}
sx={{
opacity: [1, 1, 0],
@@ -93,7 +92,25 @@ export function TaskItemComponent(
cursor: "grab",
mr: "0.2rem",
fontFamily: "inherit",
marginTop: "calc((1lh - 18px) / 2)"
marginTop: "calc((1lh - 18px) / 2)",
// the browser must not take this gesture for scrolling, text
// selection or the long press callout
touchAction: "none",
userSelect: "none",
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
svg: { pointerEvents: "none" },
// hit slop: an invisible box larger than the icon, so a finger
// landing near the handle still starts the drag instead of the
// browser selecting the text next to it.
position: "relative",
"::before": {
content: '""',
position: "absolute",
insetBlock: "-12px",
insetInlineStart: "-16px",
insetInlineEnd: "-2px"
}
}}
size={isMobile ? "2.46ch" : "2.22ch"}
/>
@@ -154,7 +171,9 @@ export function TaskItemComponent(
sx={{
bg: "background",
opacity: 0,
alignSelf: "flex-start",
position: "absolute",
insetInlineEnd: 0,
top: 0,
marginTop: "calc((1lh - 14px) / 2)"
}}
>

View File

@@ -236,12 +236,12 @@ export function TaskListComponent(
if (readonly) e.preventDefault();
}}
sx={{
ul: {
"ul.tasklist-content-wrapper": {
display: "block",
paddingInlineStart: 0,
marginBlockStart: isNested ? 10 : 0,
marginBlockEnd: 0,
marginLeft: isNested ? (editor.isEditable ? -35 : -10) : 0,
marginInlineStart: isNested ? (editor.isEditable ? -35 : -10) : 0,
padding: 0
},
li: {

View File

@@ -0,0 +1,96 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { describe, expect, test } from "vitest";
import { createEditor } from "../../../../test-utils/index.js";
import { TaskListNode } from "../../task-list/task-list.js";
import { TaskItemNode } from "../../task-item/task-item.js";
import { BulletList } from "../../bullet-list/bullet-list.js";
import { ListItem } from "../../list-item/list-item.js";
import { Paragraph } from "../../paragraph/paragraph.js";
import { TextDirection } from "../text-direction.js";
function directions(editor: {
state: { doc: { descendants: (fn: (node: any) => void) => void } };
}) {
const found: Record<string, string[]> = {};
editor.state.doc.descendants((node) => {
const dir = node.attrs.textDirection;
if (dir !== undefined) (found[node.type.name] ??= []).push(dir);
});
return found;
}
/** cursor into the first paragraph of the document */
function cursorInFirstParagraph(editor: any) {
let pos = -1;
editor.state.doc.descendants((node: any, at: number) => {
if (pos === -1 && node.type.name === "paragraph") pos = at + 1;
});
editor.commands.setTextSelection(pos);
}
describe("text direction on lists", () => {
const cases = [
{
name: "task list",
extensions: {
taskList: TaskListNode,
taskListItem: TaskItemNode.configure({ nested: true }),
paragraph: Paragraph
},
content: `<ul class="checklist" dir="rtl"><li class="checklist--item"><p dir="rtl">one</p></li><li class="checklist--item"><p dir="rtl">two</p></li></ul>`
},
{
name: "bullet list",
extensions: {
bulletList: BulletList,
listItem: ListItem,
paragraph: Paragraph
},
content: `<ul dir="rtl"><li><p dir="rtl">one</p></li><li><p dir="rtl">two</p></li></ul>`
}
];
for (const { name, extensions, content } of cases) {
test(`switching a ${name} to ltr clears the direction of every item`, () => {
const { editor } = createEditor({
initialContent: content,
extensions: {
...extensions,
textDirection: TextDirection.configure({
types: ["paragraph", "taskList", "bulletList"]
})
}
});
// every paragraph starts rtl, matching the list
const before = directions(editor);
expect(Object.values(before).flat()).toContain("rtl");
cursorInFirstParagraph(editor);
editor.commands.setTextDirection(undefined);
// ...and nothing is left rtl — not the list, not any item, cursor or
// not, so the checkboxes/markers and the text no longer disagree
const after = directions(editor);
expect(Object.values(after).flat()).not.toContain("rtl");
});
}
});

View File

@@ -114,10 +114,40 @@ export const TextDirection = Extension.create<TextDirectionOptions>({
return {
setTextDirection:
(direction) =>
({ commands }) => {
return this.options.types.every((type) =>
commands.updateAttributes(type, { textDirection: direction })
);
({ state, tr, dispatch }) => {
const value = direction || "";
const { $from, from, to } = state.selection;
// Expand to the outermost block that carries a direction, so a
// whole task list turns together — every item's paragraph and
// all — instead of only the row the cursor is in. Otherwise the
// list's own direction flips while its items keep theirs, and the
// checkboxes and text end up on opposite sides.
let start = from;
let end = to;
for (let depth = $from.depth; depth > 0; depth--) {
if (this.options.types.includes($from.node(depth).type.name)) {
start = Math.min(start, $from.before(depth));
end = Math.max(end, $from.after(depth));
}
}
let changed = false;
state.doc.nodesBetween(start, end, (node, pos) => {
if (
!this.options.types.includes(node.type.name) ||
node.attrs.textDirection === value
)
return;
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
textDirection: value
});
changed = true;
});
if (changed) dispatch?.(tr);
return changed;
}
};
}

View File

@@ -63,6 +63,7 @@ import { SearchReplace } from "./extensions/search-replace/index.js";
import { Table } from "./extensions/table/index.js";
import TableCell from "./extensions/table-cell/index.js";
import { TaskItemNode } from "./extensions/task-item/index.js";
import { DragDrop } from "./extensions/drag-drop/index.js";
import { TaskListNode } from "./extensions/task-list/index.js";
import TextDirection from "./extensions/text-direction/index.js";
import { WebClipNode, WebClipOptions } from "./extensions/web-clip/index.js";
@@ -283,6 +284,7 @@ const useTiptap = (
OrderedList.configure({ keepMarks: true, keepAttributes: true }),
TaskItemNode.configure({ nested: true }),
TaskListNode,
DragDrop,
Link.extend({
inclusive: true
}).configure({

View File

@@ -1035,3 +1035,63 @@ del.diffdel {
.scroll-bar::-webkit-scrollbar-thumb:active {
background-color: var(--border);
}
/* Drag & drop (see extensions/drag-drop) */
.ProseMirror .drop-gap {
list-style-type: none;
box-sizing: border-box;
overflow: hidden;
margin-block: 8px;
border-radius: 5px;
background-color: var(--background-secondary);
border: 1px dashed var(--border);
transition: height 120ms ease-out, margin-inline-start 120ms ease-out;
}
.drag-preview {
position: fixed;
top: 0;
z-index: 9999;
margin: 0;
padding-block: 6px;
padding-inline: 8px;
box-sizing: border-box;
pointer-events: none;
user-select: none;
border-radius: 5px;
background-color: var(--background);
box-shadow: 0px 2px 10px 0px rgba(0, 0, 0, 0.2);
transition: left 120ms ease-out, width 120ms ease-out;
}
/* the item is styled by the list it was taken out of, so the preview has to
say what a task item looks like on its own */
.drag-preview ul {
margin: 0;
padding: 0;
}
.drag-preview li {
display: flex;
list-style-type: none;
margin: 0;
}
/* how many children are coming along with the item */
.drag-preview-badge {
align-self: center;
flex-shrink: 0;
margin-inline-start: 4px;
padding: 0px 6px;
border-radius: 100px;
font-size: 0.8em;
background-color: var(--background-secondary);
color: var(--paragraph-secondary);
}
@media (prefers-reduced-motion: reduce) {
.ProseMirror .drop-gap,
.drag-preview {
transition: none;
}
}

View File

@@ -7490,8 +7490,8 @@ msgid "Using {instance} (v{version})"
msgstr "Using {instance} (v{version})"
#: src/strings.ts:2812
msgid "Using Notesnook without an account will **NOT** sync your notes across devices and could result in data loss if you lose access to your device or uninstall the app. Make sure to backup your notes regularly."
msgstr "Using Notesnook without an account will **NOT** sync your notes across devices and could result in data loss if you lose access to your device or uninstall the app. Make sure to backup your notes regularly."
msgid "Using Notesnook without an account will NOT sync your notes across devices and could result in data loss if you lose access to your device or uninstall the app. Make sure to backup your notes regularly."
msgstr "Using Notesnook without an account will NOT sync your notes across devices and could result in data loss if you lose access to your device or uninstall the app. Make sure to backup your notes regularly."
#: src/strings.ts:2265
msgid "Using official Notesnook instance"

View File

@@ -7440,7 +7440,7 @@ msgid "Using {instance} (v{version})"
msgstr ""
#: src/strings.ts:2812
msgid "Using Notesnook without an account will **NOT** sync your notes across devices and could result in data loss if you lose access to your device or uninstall the app. Make sure to backup your notes regularly."
msgid "Using Notesnook without an account will NOT sync your notes across devices and could result in data loss if you lose access to your device or uninstall the app. Make sure to backup your notes regularly."
msgstr ""
#: src/strings.ts:2265

View File

@@ -2809,5 +2809,5 @@ Continue without attachments?`,
versionDeleted: () => actions.deleted.version(1),
offlineMode: () => t`Offline mode`,
offlineModeDesc: () =>
t`Using Notesnook without an account will **NOT** sync your notes across devices and could result in data loss if you lose access to your device or uninstall the app. Make sure to backup your notes regularly.`
t`Using Notesnook without an account will NOT sync your notes across devices and could result in data loss if you lose access to your device or uninstall the app. Make sure to backup your notes regularly.`
};