Merge branch 'master' into fix-db-init-background

Signed-off-by: Ammar Ahmed <40239442+ammarahm-ed@users.noreply.github.com>
This commit is contained in:
Ammar Ahmed
2023-04-15 23:22:20 +05:00
committed by GitHub
19 changed files with 255 additions and 164 deletions

View File

@@ -10,11 +10,11 @@
## Overview
Notesnook is a free (as in speech) & open source note taking app focused on user privacy & ease of use. To ensure zero knowledge principles, Notesnook encrypts everything on your device using `XChaCha20-Poly1305` & `Argon2`.
Notesnook is a free (as in speech) & open-source note-taking app focused on user privacy & ease of use. To ensure zero knowledge principles, Notesnook encrypts everything on your device using `XChaCha20-Poly1305` & `Argon2`.
Notesnook is our **proof** that privacy does _not_ (always) have to come at the cost of convenience. Our goal is to provide users peace of mind & 100% confidence that their notes are safe and secure. The decision to go fully open source is one of the most crucial steps towards that.
Notesnook is our **proof** that privacy does _not_ (always) have to come at the cost of convenience. We aim to provide users peace of mind & 100% confidence that their notes are safe and secure. The decision to go fully open source is one of the most crucial steps towards that.
This repository contains all the code required to build & use the Notesnook web, desktop & mobile clients. If you are looking for a full features list or screenshots, please check the [website](https://notesnook.com/).
This repository contains all the code required to build & use the Notesnook web, desktop & mobile clients. If you are looking for a full feature list or screenshots, please check the [website](https://notesnook.com/).
## Developer guide

View File

@@ -19,9 +19,9 @@
## Build instructions
> **Before you start it is recommended that you read [the contributing guidelines](/CONTRIBUTING.md).**
> **Before you start, it is recommended that you read [the contributing guidelines](/CONTRIBUTING.md).**
### Setting up development environment
### Setting up the development environment
Requirements:
@@ -57,7 +57,7 @@ npm install
### Running the app on Android
[Setup an Android emulator from Android Studio](https://developer.android.com/studio/run/managing-avds) if you haven't already and then run the following command to start the app in the Emulator:
[Setup an Android emulator from Android Studio](https://developer.android.com/studio/run/managing-avds) if you haven't already, and then run the following command to start the app in the Emulator:
```bash
npm run start:android
@@ -78,11 +78,11 @@ npm run start:ios
## Developer guide
> This project is in a transition state between Javascript & Typescript. We are gradually porting everything over to Typescript so if you can help with that, it'd be great!
> This project is in a transition state between Javascript & Typescript. We are gradually porting everything over to Typescript, so if you can help with that, it'd be great!
### The tech stack
We try to keep the stack as lean as possible
We try to keep the stack as lean as possible:
1. React Native
2. Typescript/Javascript
@@ -95,17 +95,17 @@ We try to keep the stack as lean as possible
The app codebase is distributed over two primary directories. `native/` and `app/`.
- `native/`: Includes `android/` and `ios/` folders and everything related to react native core functionality like bundling, development and packaging. Any react-native dependency that has native code i.e android & ios folders, is installed here.
- `native/`: Includes `android/` and `ios/` folders and everything related to react native core functionality like bundling, development, and packaging. Any react-native dependency with native code, i.e., android & ios folders, is installed here.
- `app/`: Includes all the app code other than the native part. All JS only dependencies are installed here.
- `components/`: Each component serves a specific purpose in the app UI, for example the `Paragraph` component is used to render paragraphs in the app and a `Header` component is used to render a `header` on all screens.
- `common/`: Features that have integral role in app functionality, for example, notesnook core is initialized here.
- `app/`: Includes all the app code other than the native part. All JS-only dependencies are installed here.
- `components/`: Each component serves a specific purpose in the app UI. For example, the `Paragraph` component is used to render paragraphs in the app, and a `Header` component is used to render a `header` on all screens.
- `common/`: Features that are integral to the app's functionality. For example, the notesnook core is initialized here.
- `hooks/`: Hooks for different app logic
- `navigation/`: Includes app navigation specific code. Here the app navigation, editor & side menu are rendered side by side in fluid tabs.
- `navigation/`: Includes app navigation-specific code. Here the app navigation, editor & side menu are rendered side by side in fluid tabs.
- `screens`: Navigator screens.
- `services`: Parts of code that do a specific function, for example, the `sync` service is responsibe for running Sync from anywhere in the app.
- `stores`: We use `zustand` for global state management in the app. There are multiple stores that provide the state for different parts of the app.
- `utils`: General purpose stuff such as constant values, utility functions etc.
- `services`: Parts of code that do a specific function. For example, the `sync` service runs Sync from anywhere in the app.
- `stores`: We use `zustand` for global state management in the app. Multiple stores provide the state for different parts of the app.
- `utils`: General purpose stuff such as constant values, utility functions, etc.
There are several other folders at the root:
@@ -115,11 +115,11 @@ There are several other folders at the root:
### Running the tests
When you are done making the required changes, you will need to run the tests to make sure you didn't break anything. We use Detox as the testing framework & the tests can be started as follows:
When you are done making the required changes, you must run the tests to ensure you didn't break anything. We use Detox as the testing framework & the tests can be started as follows:
### Android
To run the tests on android, you will need to create an emulator device on your system:
To run the tests on Android, you will need to create an emulator device on your system:
```
$ANDROID_HOME/tools/bin/avdmanager create avd -n Pixel_5_API_31 -d pixel --package "system-images;android-31;default;x86_64"
@@ -127,13 +127,13 @@ $ANDROID_HOME/tools/bin/avdmanager create avd -n Pixel_5_API_31 -d pixel --packa
If you face problems, follow the detailed guide in [Detox documentation](https://wix.github.io/Detox/docs/introduction/android-dev-env). Keep the emulator name set to `Pixel_5_API_31`.
Once you have created an emulator device, build the android apks
Once you have created an emulator device, build the Android apks:
```
npm run build:android
```
Finally run the tests
Finally, run the tests:
```
npm run test:android
@@ -141,9 +141,9 @@ npm run test:android
### iOS
To run e2e tests on iOS simulator, you must be on a Mac with XCode installed.
To run e2e tests on the iOS simulator, you must be on a Mac with XCode installed.
First install [AppleSimulatorUtils](https://github.com/wix/AppleSimulatorUtils)
First, install [AppleSimulatorUtils](https://github.com/wix/AppleSimulatorUtils):
```
brew tap wix/brew
@@ -156,7 +156,7 @@ Now build the iOS app for testing:
npm run build:ios
```
Finally run the tests:
Finally, run the tests:
```
npm run test:ios

View File

@@ -218,12 +218,14 @@ const List = ({
}
/>
</Animated.View>
<JumpToSectionDialog
screen={screen}
data={listData}
type={screen === "Notes" ? "home" : type}
scrollRef={scrollRef}
/>
{listData ? (
<JumpToSectionDialog
screen={screen}
data={listData}
type={screen === "Notes" ? "home" : type}
scrollRef={scrollRef}
/>
) : null}
</>
);
};

View File

@@ -25,22 +25,22 @@ import {
TouchableOpacity,
View
} from "react-native";
import { FlatList } from "react-native-actions-sheet";
import { notesnook } from "../../../../e2e/test.ids";
import { db } from "../../../common/database";
import { DDS } from "../../../services/device-detection";
import { presentSheet, ToastEvent } from "../../../services/event-manager";
import { ToastEvent, presentSheet } from "../../../services/event-manager";
import Navigation from "../../../services/navigation";
import { useMenuStore } from "../../../stores/use-menu-store";
import { useRelationStore } from "../../../stores/use-relation-store";
import { ph, pv, SIZE } from "../../../utils/size";
import { SIZE, ph, pv } from "../../../utils/size";
import { sleep } from "../../../utils/time";
import DialogHeader from "../../dialog/dialog-header";
import { Button } from "../../ui/button";
import { IconButton } from "../../ui/icon-button";
import Input from "../../ui/input";
import Seperator from "../../ui/seperator";
import Heading from "../../ui/typography/heading";
import { MoveNotes } from "../move-notes/movenote";
import { FlatList } from "react-native-actions-sheet";
let refs = [];
export class AddNotebookSheet extends React.Component {
@@ -245,13 +245,34 @@ export class AddNotebookSheet extends React.Component {
willFocus && this.topicInputRef.current?.focus();
};
renderTopicItem = ({ item, index }) => (
<TopicItem
item={item}
onPress={(item, index) => {
this.prevIndex = index;
this.prevItem = item;
this.topicInputRef.current?.setNativeProps({
text: item
});
this.topicInputRef.current?.focus();
this.currentInputValue = item;
this.setState({
editTopic: true
});
}}
onDelete={this.onDelete}
index={index}
colors={this.props.colors}
/>
);
render() {
const { colors } = this.props;
const { topics, topicInputFocused, notebook } = this.state;
return (
<View
style={{
maxHeight: DDS.isTab ? "90%" : "96%",
maxHeight: DDS.isTab ? "90%" : "97%",
borderRadius: DDS.isTab ? 5 : 0,
paddingHorizontal: 12
}}
@@ -266,17 +287,34 @@ export class AddNotebookSheet extends React.Component {
}}
blurOnSubmit={false}
/>
<DialogHeader
title={
notebook && notebook.dateCreated ? "Edit Notebook" : "New Notebook"
}
paragraph={
notebook && notebook.dateCreated
? "You are editing " + this.title + " notebook."
: "Notebooks are the best way to organize your notes."
}
/>
<Seperator half />
<View
style={{
flexDirection: "row",
width: "100%",
justifyContent: "space-between",
alignItems: "center"
}}
>
<Heading size={SIZE.lg}>
{notebook && notebook.dateCreated
? "Edit Notebook"
: "New Notebook"}
</Heading>
<Button
title="Save"
type="accent"
height={40}
style={{
borderRadius: 100,
paddingHorizontal: 24
}}
fontSize={SIZE.md}
onPress={this.addNewNotebook}
/>
</View>
<Seperator />
<Input
fwdRef={(ref) => (this.titleRef = ref)}
@@ -344,48 +382,11 @@ export class AddNotebookSheet extends React.Component {
keyExtractor={(item, index) => item + index.toString()}
keyboardShouldPersistTaps="always"
keyboardDismissMode="interactive"
ListFooterComponent={<View style={{ height: 50 }} />}
renderItem={({ item, index }) => (
<TopicItem
item={item}
onPress={(item, index) => {
this.prevIndex = index;
this.prevItem = item;
this.topicInputRef.current?.setNativeProps({
text: item
});
this.topicInputRef.current?.focus();
this.currentInputValue = item;
this.setState({
editTopic: true
});
}}
onDelete={this.onDelete}
index={index}
colors={colors}
/>
)}
/>
<Seperator />
<Button
width="100%"
fontSize={SIZE.md}
title={
notebook && notebook.dateCreated
? "Save changes"
: "Create notebook"
ListFooterComponent={
topics.length === 0 ? null : <View style={{ height: 50 }} />
}
type="accent"
onPress={this.addNewNotebook}
renderItem={this.renderTopicItem}
/>
{/*
{Platform.OS === 'ios' && (
<View
style={{
height: 40
}}
/>
)} */}
</View>
);
}

View File

@@ -21,9 +21,9 @@ import { Platform, TextInput, View } from "react-native";
import { ActionSheetRef, ScrollView } from "react-native-actions-sheet";
import DateTimePickerModal from "react-native-modal-datetime-picker";
import {
presentSheet,
PresentSheetOptions,
ToastEvent
ToastEvent,
presentSheet
} from "../../../services/event-manager";
import { useThemeStore } from "../../../stores/use-theme-store";
import { SIZE } from "../../../utils/size";
@@ -33,6 +33,7 @@ import Input from "../../ui/input";
import dayjs from "dayjs";
import DatePicker from "react-native-date-picker";
import { db } from "../../../common/database";
import { DDS } from "../../../services/device-detection";
import Navigation from "../../../services/navigation";
import Notifications, { Reminder } from "../../../services/notifications";
import PremiumService from "../../../services/premium";
@@ -41,6 +42,7 @@ import { useRelationStore } from "../../../stores/use-relation-store";
import { NoteType } from "../../../utils/types";
import { Dialog } from "../../dialog";
import { ReminderTime } from "../../ui/reminder-time";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
type ReminderSheetProps = {
@@ -219,35 +221,57 @@ export default function ReminderSheet({
return (
<View
style={{
paddingHorizontal: 12
paddingHorizontal: 12,
maxHeight: DDS.isTab ? "90%" : "99.99%"
}}
>
<View
style={{
flexDirection: "row",
width: "100%",
justifyContent: "space-between",
alignItems: "center"
}}
>
<Heading size={SIZE.lg}>Set reminder</Heading>
<Button
title="Save"
type="accent"
height={40}
style={{
borderRadius: 100,
paddingHorizontal: 24
}}
fontSize={SIZE.md}
onPress={saveReminder}
/>
</View>
<Dialog context="local" />
<ScrollView keyboardShouldPersistTaps="always">
<ScrollView>
<Input
fwdRef={titleRef}
defaultValue={reminder?.title || referencedItem?.title}
placeholder="Remind me of..."
onChangeText={(text) => (title.current = text)}
containerStyle={{ borderWidth: 0, borderBottomWidth: 1 }}
wrapperStyle={{
marginTop: 10
}}
/>
<Input
defaultValue={
reminder ? reminder?.description : referencedItem?.headline
}
placeholder="Add a quick note"
placeholder="Add a short note"
onChangeText={(text) => (details.current = text)}
containerStyle={{
borderWidth: 0,
borderBottomWidth: 1,
maxHeight: 80,
marginTop: 10
maxHeight: 80
}}
multiline
textAlignVertical="top"
inputStyle={{
height: 80
minHeight: 80,
paddingVertical: 12
}}
height={80}
wrapperStyle={{
@@ -432,6 +456,7 @@ export default function ReminderSheet({
fadeToColor={colors.bg}
theme={colors.night ? "dark" : "light"}
is24hourSource="locale"
androidVariant="nativeAndroid"
mode={reminderMode === ReminderModes.Repeat ? "time" : "datetime"}
/>
@@ -548,15 +573,6 @@ export default function ReminderSheet({
alignSelf: "flex-start"
}}
/>
<Button
style={{
width: "100%"
}}
title="Save"
type="accent"
fontSize={SIZE.md}
onPress={saveReminder}
/>
</ScrollView>
</View>
);
@@ -570,6 +586,7 @@ ReminderSheet.present = (
presentSheet({
context: isSheet ? "local" : undefined,
enableGesturesInScrollView: true,
noBottomPadding: true,
component: (ref, close, update) => (
<ReminderSheet
actionSheetRef={ref}

View File

@@ -65,6 +65,7 @@ import { deleteItems } from "../../../utils/functions";
import { presentDialog } from "../../dialog/functions";
import { Properties } from "../../properties";
import Sort from "../sort";
import Heading from "../../ui/typography/heading";
type ConfigItem = { id: string; type: string };
class TopicSheetConfig {

View File

@@ -80,10 +80,10 @@ export const normalize = (size) => {
}
};
export const SIZE = {
xxs: normalize(10.5) * scale.fontScale,
xs: normalize(12) * scale.fontScale,
sm: normalize(14.5) * scale.fontScale,
md: normalize(16) * scale.fontScale,
xxs: normalize(11) * scale.fontScale,
xs: normalize(12.5) * scale.fontScale,
sm: normalize(15) * scale.fontScale,
md: normalize(16.5) * scale.fontScale,
lg: normalize(22) * scale.fontScale,
xl: normalize(24) * scale.fontScale,
xxl: normalize(28) * scale.fontScale,
@@ -91,10 +91,10 @@ export const SIZE = {
};
export function updateSize() {
SIZE.xxs = normalize(10.5) * scale.fontScale;
SIZE.xs = normalize(12) * scale.fontScale;
SIZE.sm = normalize(14.5) * scale.fontScale;
SIZE.md = normalize(16) * scale.fontScale;
SIZE.xxs = normalize(11) * scale.fontScale;
SIZE.xs = normalize(12.5) * scale.fontScale;
SIZE.sm = normalize(15) * scale.fontScale;
SIZE.md = normalize(16.5) * scale.fontScale;
SIZE.lg = normalize(22) * scale.fontScale;
SIZE.xl = normalize(24) * scale.fontScale;
SIZE.xxl = normalize(28) * scale.fontScale;

View File

@@ -335,7 +335,13 @@ export const Search = ({ close, getKeyboardHeight, quicknote, mode }) => {
renderItem={renderItem}
estimatedItemSize={50}
ListHeaderComponent={
searchResults.length === 0 && searchKeyword.current ? (
mode === "selectTags" &&
(searchResults.length === 0 ||
(searchKeyword.current &&
searchKeyword.current.length > 0 &&
!searchResults.find(
(item) => item.title === searchKeyword.current
))) ? (
<ListItem
item={{
type: "tag",

View File

@@ -12,7 +12,7 @@
### The desktop app?
You can find all the desktop related code in [the `desktop/` directory](./desktop/). Since it uses the web app directly, we are keeping both together. (We should probably move it to it's own project at some point.)
You can find all the desktop-related code in [the `desktop/` directory](./desktop/). Since it uses the web app directly, we are keeping both together. (We should probably move it to its own project at some point.)
### Downloads & releases
@@ -21,7 +21,7 @@ You can find all the desktop related code in [the `desktop/` directory](./deskto
## Build instructions
**Before you start it is recommended that you read [the contributing guidelines](/CONTRIBUTING.md).**
> **Before you start, it is recommended that you read [the contributing guidelines](/CONTRIBUTING.md).**
### Setting up the development environment
@@ -66,11 +66,11 @@ npx serve apps/web/build
## Developer guide
> This project is in a transition state between Javascript & Typescript. We are gradually porting everything over to Typescript so if you can help with that, it'd be great!
> This project is in a transition state between Javascript & Typescript. We are gradually porting everything over to Typescript, so if you can help with that, it'd be great!
### The tech stack
We try to keep the stack as lean as possible
We try to keep the stack as lean as possible:
1. React v17: UI framework
2. Typescript/Javascript: The logical side of the app
@@ -82,21 +82,21 @@ We try to keep the stack as lean as possible
### Project structure
1. `src/`: 99% of the source code lives here & this is also where you'll be spending most of your time.
1. `index.tsx`: **the app entrypoint** responsible for loading the appropriate view based on the current route.
2. `app.js`: **the default route** that contains the whole note taking experience (notes list, navigation, editor etc.)
3. `views/`: Contains **all the views** including views for login, settings, notes, notebooks & topics.
4. `components/`: All the **reusuable UI components** are here (e.g. button, editor, etc.)
5. `stores/`: Contains the glue code & **logic for all the UI interaction**. For example, when you pin a note the `src/stores/note-store.js` is responsible for everything including refreshing the list to reflect the changes.
6. `navigation/`: All the **routing & navigation** logic lives here. The app uses 2 kinds of routers:
1. `routes.js`: This contains all the main routes like `/notes`, `/notebooks` with information on what to render when user goes to a particular route.
2. `hash-routes.js`: The hash routes are used for temporary navigation like opening dialogs, opening a note. These look like `#/notes/6307bbd65d5d5d5cb86f6f74/edit`.
7. `interfaces/`: This is where the **platform specific storage & encryption logic** lives. These interface implementations are used by the `@notesnook/core` to provide capabilities such as persistence & encryption.
8. `hooks/`: Contains all the **general purpose React hooks**
9. `utils/`: These are **general-purpose utilities** for performing various tasks such as downloading files, storing configuration etc.
1. `src/`: 99% of the source code lives here & this is also where you'll spend most of your time.
1. `index.tsx`: **the app entry point** responsible for loading the appropriate view based on the current route.
2. `app.js`: **the default route** that contains the whole note-taking experience (notes list, navigation, editor, etc.)
3. `views/`: Contains **all the views**, including views for login, settings, notes, notebooks & topics.
4. `components/`: All the **reusable UI components** are here (e.g., button, editor, etc.)
5. `stores/`: Contains the glue code & **logic for all the UI interactions**. For example, when you pin a note, the `src/stores/note-store.js` is responsible for everything, including refreshing the list to reflect the changes.
6. `navigation/`: All the **routing & navigation** logic lives here. The app uses two kinds of routers:
1. `routes.js`: This contains all the main routes like `/notes`, `/notebooks` with information on what to render when the user goes to a particular route.
2. `hash-routes.js`: The hash routes are used for temporary navigation, like opening dialogs or opening a note. These look like `#/notes/6307bbd65d5d5d5cb86f6f74/edit`.
7. `interfaces/`: This is where the **platform-specific storage & encryption logic** lives. These interface implementations are used by the `@notesnook/core` to provide capabilities such as persistence & encryption.
8. `hooks/`: Contains all the **general-purpose React hooks**
9. `utils/`: These are **general-purpose utilities** for performing various tasks such as downloading files, storing configuration, etc.
10. `common/`: This directory contains **the shared logic between the whole app**. For example, this is where the database is instantiated for use throughout the app.
11. `commands/`: These are **commands used by the desktop app** for things like checking for updates, storing backups etc.
2. `desktop/`: The Electron layer for **the desktop app lives here**. (This should be moved outside into it's own project).
11. `commands/`: These are **commands the desktop app uses** for things like checking for updates, storing backups etc.
2. `desktop/`: The Electron layer for **the desktop app lives here**. (This should be moved outside into its own project).
### Running the tests

View File

@@ -94,7 +94,9 @@ export function showAddNotebookDialog() {
isOpen={true}
onDone={async (nb: Record<string, unknown>) => {
// add the notebook to db
await db.notebooks?.add({ ...nb });
const notebook = await db.notebooks?.add({ ...nb });
if (!notebook) return perform(false);
notebookStore.refresh();
showToast("success", "Notebook added successfully!");
@@ -209,9 +211,8 @@ export function showError(title: string, message: string) {
export function showMultiDeleteConfirmation(length: number) {
return confirm({
title: `Delete ${length} items?`,
message: `These items will be **kept in your Trash for ${
db.settings?.getTrashCleanupInterval() || 7
} days** after which they will be permanently deleted.`,
message: `These items will be **kept in your Trash for ${db.settings?.getTrashCleanupInterval() || 7
} days** after which they will be permanently deleted.`,
positiveButtonText: "Yes",
negativeButtonText: "No"
});

View File

@@ -20,6 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useEffect, useState } from "react";
import { initializeDatabase } from "../common/db";
import "../utils/analytics";
import "../app.css";
if (process.env.NODE_ENV === "production") {
console.log = () => {};
@@ -28,14 +29,13 @@ if (process.env.NODE_ENV === "production") {
const memory = {
isAppLoaded: false
};
export default function useDatabase(persistence) {
export default function useDatabase(persistence?: "db" | "memory") {
const [isAppLoaded, setIsAppLoaded] = useState(memory.isAppLoaded);
useEffect(() => {
if (memory.isAppLoaded) return;
(async () => {
await import("../app.css");
await initializeDatabase(persistence);
setIsAppLoaded(true);
memory.isAppLoaded = true;

View File

@@ -32,7 +32,7 @@ const errorMessage =
* Returns the validity state of the given media query.
*
*/
const useMediaQuery = (mediaQuery) => {
const useMediaQuery = (mediaQuery: string) => {
if (!window.matchMedia) {
// eslint-disable-next-line no-console
console.warn(errorMessage);

View File

@@ -59,7 +59,7 @@ export default class Notes extends Collection {
if (
remoteNote.deleted &&
remoteNote.deleteReason !== "localOnly" &&
!localNote.localOnly
(!localNote || !localNote.localOnly)
)
return await this._collection.addItem(remoteNote);

View File

@@ -79,9 +79,6 @@ export default class Tags extends Collection {
return;
}
newName = this.sanitize(newName);
if (!newName) throw new Error("Tag title cannot be empty.");
await this._db.settings.setAlias(tagId, newName);
await this._collection.addItem({ ...tag, alias: newName });
}

View File

@@ -127,7 +127,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
id: {
default: undefined,
rendered: false,
parseHTML: () => `codeblock-${nanoid(12)}`
parseHTML: () => createCodeblockId()
},
caretPosition: {
default: undefined,
@@ -227,13 +227,18 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
setCodeBlock:
(attributes) =>
({ commands }) => {
return commands.setNode(this.name, attributes);
return commands.setNode(this.name, {
...attributes,
id: createCodeblockId()
});
},
toggleCodeBlock:
(attributes) =>
({ commands }) => {
console.log("TOGGLING!");
return commands.toggleNode(this.name, "paragraph", attributes);
return commands.toggleNode(this.name, "paragraph", {
...attributes,
id: createCodeblockId()
});
},
changeCodeBlockIndentation:
(options) =>
@@ -477,14 +482,16 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
find: backtickInputRegex,
type: this.type,
getAttributes: (match) => ({
language: match[1]
language: match[1],
id: createCodeblockId()
})
}),
textblockTypeInputRule({
find: tildeInputRegex,
type: this.type,
getAttributes: (match) => ({
language: match[1]
language: match[1],
id: createCodeblockId()
})
})
];
@@ -520,7 +527,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
if (isCode && !isInsideCodeBlock) {
tr.replaceSelectionWith(
this.type.create({
id: `codeblock-${nanoid(12)}`,
id: createCodeblockId(),
language,
indentType: indent.type,
indentLength: indent.amount
@@ -805,3 +812,7 @@ export function inferLanguage(node: Element) {
);
return language?.filename;
}
function createCodeblockId() {
return `codeblock-${nanoid(12)}`;
}

View File

@@ -125,9 +125,14 @@ export function HighlighterPlugin({
const changedBlocks: Set<string> = new Set();
for (const blockKey in pluginState.languages) {
if (HIGHLIGHTED_BLOCKS.has(blockKey)) continue;
const language = pluginState.languages[blockKey];
if (
HIGHLIGHTED_BLOCKS.has(blockKey) &&
refractor.registered(language)
) {
continue;
}
const languageDefinition = Languages.find(
(l) =>
l.filename === language || l.alias?.some((a) => a === language)
@@ -193,12 +198,21 @@ export function HighlighterPlugin({
});
if (changedBlocks.length > 0) {
const updated: Set<number> = new Set();
let hasChanges = false;
changedBlocks.forEach((block) => {
if (updated.has(block.pos)) return;
updated.add(block.pos);
const { id, language } = block.node.attrs;
if (languages[id]) {
if (
!languages[id] ||
(language && !refractor.registered(language))
) {
languages[id] = language;
hasChanges = true;
} else {
const newDecorations = getDecorations({
block,
defaultLanguage
@@ -221,12 +235,12 @@ export function HighlighterPlugin({
decorations = decorations.remove(toRemove);
if (toAdd.length > 0)
decorations = decorations.add(tr.doc, toAdd);
} else {
languages[id] = language;
hasChanges = true;
}
});
if (decorations !== pluginState.decorations) {
if (hasChanges) {
return { decorations, languages };
}
}

View File

@@ -1,5 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Adding a new codeblock & changing the language should apply the new highlighting 1`] = `"<div><div contenteditable=\\"true\\" translate=\\"no\\" tabindex=\\"0\\" class=\\"ProseMirror\\"><div class=\\"codeblock-view-content-wrap\\"><div class=\\"node-content-wrapper\\" style=\\"white-space: pre; min-width: 20px;\\"><span class=\\"token keyword\\">function</span> <span class=\\"token function\\">hello</span><span class=\\"token punctuation\\">(</span><span class=\\"token punctuation\\">)</span> <span class=\\"token punctuation\\">{</span> <span class=\\"token punctuation\\">}</span></div></div></div></div>"`;
exports[`codeblocks should get highlighted after pasting 1`] = `"<div><div contenteditable=\\"true\\" translate=\\"no\\" tabindex=\\"0\\" class=\\"ProseMirror\\"><div class=\\"codeblock-view-content-wrap\\"><div class=\\"node-content-wrapper\\" style=\\"white-space: pre; min-width: 20px;\\"><span class=\\"token keyword\\">function</span> <span class=\\"token function\\">hello</span><span class=\\"token punctuation\\">(</span><span class=\\"token punctuation\\">)</span> <span class=\\"token punctuation\\">{</span> <span class=\\"token punctuation\\">}</span></div></div><div class=\\"codeblock-view-content-wrap\\"><div class=\\"node-content-wrapper\\" style=\\"white-space: pre; min-width: 20px;\\"><span class=\\"token keyword\\">function</span> <span class=\\"token function\\">hello</span><span class=\\"token punctuation\\">(</span><span class=\\"token punctuation\\">)</span> <span class=\\"token punctuation\\">{</span> <span class=\\"token punctuation\\">}</span></div></div></div></div>"`;
exports[`codeblocks should get highlighted on init 1`] = `"<div><div contenteditable=\\"true\\" translate=\\"no\\" tabindex=\\"0\\" class=\\"ProseMirror\\"><div class=\\"codeblock-view-content-wrap\\"><div class=\\"node-content-wrapper\\" style=\\"white-space: pre; min-width: 20px;\\"><span class=\\"token keyword\\">function</span> <span class=\\"token function\\">hello</span><span class=\\"token punctuation\\">(</span><span class=\\"token punctuation\\">)</span> <span class=\\"token punctuation\\">{</span> <span class=\\"token punctuation\\">}</span></div></div><div class=\\"codeblock-view-content-wrap\\"><div class=\\"node-content-wrapper\\" style=\\"white-space: pre; min-width: 20px;\\"><span class=\\"token keyword\\">function</span> <span class=\\"token function\\">hello</span><span class=\\"token punctuation\\">(</span><span class=\\"token punctuation\\">)</span> <span class=\\"token punctuation\\">{</span> <span class=\\"token punctuation\\">}</span></div></div></div></div>"`;

View File

@@ -22,6 +22,7 @@ import { expect, test, vi } from "vitest";
import { CodeBlock, inferLanguage } from "../code-block";
import { HighlighterPlugin } from "../highlighter";
import { getChangedNodes } from "../../../utils/prosemirror";
import { refractor } from "refractor/lib/core";
const CODEBLOCKS_HTML = h("div", [
h("pre", [h("code", ["function hello() { }"])], {
@@ -191,3 +192,37 @@ test("editing code in a highlighted code block should not be too slow", async ()
expect(timings.reduce((a, b) => a + b) / timings.length).toBeLessThan(16);
expect(editorElement.outerHTML).toMatchSnapshot();
});
test("Adding a new codeblock & changing the language should apply the new highlighting", async () => {
const editorElement = h("div");
const { editor } = createEditor({
element: editorElement,
extensions: {
codeblock: CodeBlock
}
});
editor.commands.setCodeBlock();
editor.commands.insertContent("function hello() { }");
editor.commands.updateAttributes(CodeBlock.name, { language: "javascript" });
await new Promise((resolve) => setTimeout(resolve, 100));
expect(editorElement.outerHTML).toMatchSnapshot();
expect(refractor.registered("javascript")).toBe(true);
});
test("Switching codeblock language should register the new language", async () => {
const editorElement = h("div");
const { editor } = createEditor({
element: editorElement,
initialContent: CODEBLOCKS_HTML,
extensions: {
codeblock: CodeBlock
}
});
editor.commands.updateAttributes(CodeBlock.name, { language: "java" });
await new Promise((resolve) => setTimeout(resolve, 100));
expect(refractor.registered("java")).toBe(true);
});

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import tinycolor from "tinycolor2";
import { PopupWrapper } from "../../components/popup-presenter";
import { config } from "../../utils/config";
@@ -26,6 +26,7 @@ import { ColorPicker, DEFAULT_COLORS } from "../popups/color-picker";
import { useToolbarLocation } from "../stores/toolbar-store";
import { ToolProps } from "../types";
import { getToolbarElement } from "../utils/dom";
import { PositionOptions } from "../../utils/position";
type ColorToolProps = ToolProps & {
onColorChange: (color?: string) => void;
@@ -49,6 +50,15 @@ export function ColorTool(props: ColorToolProps) {
const [colors, setColors] = useState(
config.get<string[]>(`custom_${cacheKey}`, []) || []
);
const position: PositionOptions = useMemo(() => {
return {
isTargetAbsolute: true,
target: getToolbarElement(),
align: isBottom ? "center" : "end",
location: isBottom ? "top" : "below",
yOffset: 10
};
}, [isBottom]);
useEffect(() => {
config.set(`custom_${cacheKey}`, colors);
@@ -73,13 +83,7 @@ export function ColorTool(props: ColorToolProps) {
isOpen={isOpen}
id={props.icon}
group={"color"}
position={{
isTargetAbsolute: true,
target: getToolbarElement(),
align: isBottom ? "center" : "end",
location: isBottom ? "top" : "below",
yOffset: 10
}}
position={position}
focusOnRender={false}
blocking={false}
>