mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-08-29 18:19:38 +02:00
Compare commits
19 Commits
beta
...
fix/perfor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7777a2a19 | ||
|
|
d647eb8187 | ||
|
|
b181655edc | ||
|
|
67ffcb163c | ||
|
|
982cea7aa6 | ||
|
|
adab66f285 | ||
|
|
1740ba9e10 | ||
|
|
9e5ff04692 | ||
|
|
08bb216334 | ||
|
|
01da7cbb19 | ||
|
|
e326b912fd | ||
|
|
8bc072a543 | ||
|
|
79086108a5 | ||
|
|
1070831b63 | ||
|
|
efaffc8729 | ||
|
|
696f57423b | ||
|
|
4d97f7033f | ||
|
|
966d57dfa2 | ||
|
|
7a3111c6b3 |
@@ -13,6 +13,7 @@ buildscript {
|
||||
androidXCore = "1.7.0"
|
||||
androidXBrowser = "1.0.0"
|
||||
ndkVersion = "27.1.12297006"
|
||||
playBillingSdkVersion = "8.0.0"
|
||||
}
|
||||
|
||||
repositories {
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
|
||||
@@ -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} />
|
||||
});
|
||||
|
||||
@@ -198,7 +198,7 @@ export default function NotePreview({
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 100,
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center"
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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
33
inbox-public.asc
Normal 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-----
|
||||
512
packages/editor/src/extensions/drag-drop/index.ts
Normal file
512
packages/editor/src/extensions/drag-drop/index.ts
Normal 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;
|
||||
}
|
||||
@@ -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)"
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.`
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user