1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-01-17 00:33:59 +02:00

Compare commits

...

24 Commits

Author SHA1 Message Date
Laurent Cozic
377d02e19d paste from office 2023-08-21 17:02:44 +01:00
Laurent Cozic
b92cb7deb7 lock file 2023-08-21 16:01:29 +01:00
Laurent Cozic
0edc66da49 Desktop: Refactor note list in preparation for plugin support (#8624)
Relates to #5389
2023-08-21 16:01:20 +01:00
renovate[bot]
e96ad7ccfa Update dependency react-native-safe-area-context to v4.7.1 (#8695)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-21 11:44:38 +01:00
renovate[bot]
817ef7bbed Update dependency pg to v8.11.2 (#8700)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-21 07:54:22 +00:00
renovate[bot]
5bd0c9b3a0 Update dependency react-native-gesture-handler to v2.12.1 (#8696)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-20 12:08:00 +00:00
Joplin Bot
46d9cd34a8 Doc: Auto-update documentation
Auto-updated using release-website.sh
2023-08-20 00:38:24 +00:00
Laurent Cozic
c3e08237fd Android 2.12.1 2023-08-19 23:36:18 +01:00
Laurent Cozic
b406f05241 lock file 2023-08-19 23:20:52 +01:00
renovate[bot]
2cbee6d8af Update dependency tap to v16.3.8 (#8693)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-19 10:52:34 +00:00
renovate[bot]
c859ad48c1 Update dependency react-redux to v8.1.2 (#8690)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-18 19:11:44 +00:00
renovate[bot]
1141b1c2a1 Update dependency knex to v2.5.1 (#8689)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-18 17:27:55 +00:00
Laurent Cozic
39c118be90 Desktop: Fetch release info from Joplin server 2023-08-18 12:58:10 +01:00
Hubert
f9ac4e112b Server: Resolves #7808: Add a link to resend email verification email (#8650) 2023-08-18 12:48:09 +01:00
Laurent Cozic
87e51aa8e6 Doc: Remove support email 2023-08-18 11:50:44 +01:00
Henry Heino
41fdc0d44d Mobile: Fixes #8687: Hide markdown toolbar completely when low on vertical space (#8688) 2023-08-18 09:45:04 +01:00
renovate[bot]
a754a8d772 Update dependency knex to v2.5.0 (#8686)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-18 09:44:48 +01:00
Hubert
41d0363fd0 Android: Fixes #8510: The voice typing box covers the texts in the editor (#8685) 2023-08-18 09:42:03 +01:00
Hubert
2a4c7a334e Server: Fixes #8307: Searching for user should be case insensitive (#8682) 2023-08-18 09:39:57 +01:00
Hubert
df1b0a96f4 Server: Fixes #8308: Sorting users by "total size" leads to a crash (#8680) 2023-08-18 09:36:41 +01:00
Henry Heino
0030681cb4 Mobile: Fixes #8310: Preserve image rotation (and other metadata) when resizing (#8669) 2023-08-18 09:34:31 +01:00
Henry Heino
e7014492c5 Desktop: Fixes #8661: Fix note editor blank after syncing an encrypted note with remote changes (#8666) 2023-08-18 09:31:45 +01:00
Joplin Bot
4804c1c0c3 Doc: Auto-update documentation
Auto-updated using release-website.sh
2023-08-18 06:19:25 +00:00
Joplin Bot
270d96ad07 Doc: Auto-update documentation
Auto-updated using release-website.sh
2023-08-18 00:35:18 +00:00
83 changed files with 2936 additions and 351 deletions

View File

@@ -260,6 +260,7 @@ packages/app-desktop/gui/NoteEditor/utils/types.js
packages/app-desktop/gui/NoteEditor/utils/useDropHandler.js
packages/app-desktop/gui/NoteEditor/utils/useEffectiveNoteId.js
packages/app-desktop/gui/NoteEditor/utils/useFolder.js
packages/app-desktop/gui/NoteEditor/utils/useFormNote.test.js
packages/app-desktop/gui/NoteEditor/utils/useFormNote.js
packages/app-desktop/gui/NoteEditor/utils/useMarkupToHtml.js
packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.js
@@ -268,13 +269,36 @@ packages/app-desktop/gui/NoteEditor/utils/usePluginServiceRegistration.js
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.js
packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
packages/app-desktop/gui/NoteList/NoteList.js
packages/app-desktop/gui/NoteList/NoteList2.js
packages/app-desktop/gui/NoteList/NoteListSource.js
packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js
packages/app-desktop/gui/NoteList/commands/index.js
packages/app-desktop/gui/NoteList/types.js
packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.js
packages/app-desktop/gui/NoteList/utils/defaultLeftToRightListRenderer.js
packages/app-desktop/gui/NoteList/utils/defaultListRenderer.js
packages/app-desktop/gui/NoteList/utils/getNoteTitleHtml.js
packages/app-desktop/gui/NoteList/utils/prepareViewProps.js
packages/app-desktop/gui/NoteList/utils/types.js
packages/app-desktop/gui/NoteList/utils/useDragAndDrop.js
packages/app-desktop/gui/NoteList/utils/useFocusNote.js
packages/app-desktop/gui/NoteList/utils/useItemCss.js
packages/app-desktop/gui/NoteList/utils/useMoveNote.js
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.js
packages/app-desktop/gui/NoteList/utils/useOnNoteClick.js
packages/app-desktop/gui/NoteList/utils/useRenderedNotes.js
packages/app-desktop/gui/NoteList/utils/useScroll.js
packages/app-desktop/gui/NoteList/utils/useVisibleRange.test.js
packages/app-desktop/gui/NoteList/utils/useVisibleRange.js
packages/app-desktop/gui/NoteListControls/NoteListControls.js
packages/app-desktop/gui/NoteListControls/commands/focusSearch.js
packages/app-desktop/gui/NoteListControls/commands/index.js
packages/app-desktop/gui/NoteListItem.js
packages/app-desktop/gui/NoteListItem/NoteListItem.js
packages/app-desktop/gui/NoteListItem/utils/types.js
packages/app-desktop/gui/NoteListItem/utils/useItemElement.js
packages/app-desktop/gui/NoteListItem/utils/useItemEventHandlers.js
packages/app-desktop/gui/NoteListItem/utils/useOnContextMenu.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
packages/app-desktop/gui/NotePropertiesDialog.js
packages/app-desktop/gui/NoteRevisionViewer.js
@@ -410,6 +434,7 @@ packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/SelectionFormatting.js

27
.gitignore vendored
View File

@@ -246,6 +246,7 @@ packages/app-desktop/gui/NoteEditor/utils/types.js
packages/app-desktop/gui/NoteEditor/utils/useDropHandler.js
packages/app-desktop/gui/NoteEditor/utils/useEffectiveNoteId.js
packages/app-desktop/gui/NoteEditor/utils/useFolder.js
packages/app-desktop/gui/NoteEditor/utils/useFormNote.test.js
packages/app-desktop/gui/NoteEditor/utils/useFormNote.js
packages/app-desktop/gui/NoteEditor/utils/useMarkupToHtml.js
packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.js
@@ -254,13 +255,36 @@ packages/app-desktop/gui/NoteEditor/utils/usePluginServiceRegistration.js
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.js
packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
packages/app-desktop/gui/NoteList/NoteList.js
packages/app-desktop/gui/NoteList/NoteList2.js
packages/app-desktop/gui/NoteList/NoteListSource.js
packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js
packages/app-desktop/gui/NoteList/commands/index.js
packages/app-desktop/gui/NoteList/types.js
packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.js
packages/app-desktop/gui/NoteList/utils/defaultLeftToRightListRenderer.js
packages/app-desktop/gui/NoteList/utils/defaultListRenderer.js
packages/app-desktop/gui/NoteList/utils/getNoteTitleHtml.js
packages/app-desktop/gui/NoteList/utils/prepareViewProps.js
packages/app-desktop/gui/NoteList/utils/types.js
packages/app-desktop/gui/NoteList/utils/useDragAndDrop.js
packages/app-desktop/gui/NoteList/utils/useFocusNote.js
packages/app-desktop/gui/NoteList/utils/useItemCss.js
packages/app-desktop/gui/NoteList/utils/useMoveNote.js
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.js
packages/app-desktop/gui/NoteList/utils/useOnNoteClick.js
packages/app-desktop/gui/NoteList/utils/useRenderedNotes.js
packages/app-desktop/gui/NoteList/utils/useScroll.js
packages/app-desktop/gui/NoteList/utils/useVisibleRange.test.js
packages/app-desktop/gui/NoteList/utils/useVisibleRange.js
packages/app-desktop/gui/NoteListControls/NoteListControls.js
packages/app-desktop/gui/NoteListControls/commands/focusSearch.js
packages/app-desktop/gui/NoteListControls/commands/index.js
packages/app-desktop/gui/NoteListItem.js
packages/app-desktop/gui/NoteListItem/NoteListItem.js
packages/app-desktop/gui/NoteListItem/utils/types.js
packages/app-desktop/gui/NoteListItem/utils/useItemElement.js
packages/app-desktop/gui/NoteListItem/utils/useItemEventHandlers.js
packages/app-desktop/gui/NoteListItem/utils/useOnContextMenu.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
packages/app-desktop/gui/NotePropertiesDialog.js
packages/app-desktop/gui/NoteRevisionViewer.js
@@ -396,6 +420,7 @@ packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/SelectionFormatting.js

BIN
Assets/Aide.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -112,7 +112,7 @@
}).then(async function(result) {
if (!result.ok) {
console.error('Could not create Stripe checkout session', await result.text());
alert('The checkout session could not be created. Please contact support@joplincloud.com for support.');
alert('The checkout session could not be created. Please contact us on the forum for support.');
} else {
return result.json();
}

View File

@@ -23,8 +23,8 @@ function onCheckEnded() {
isCheckingForUpdate_ = false;
}
async function fetchLatestRelease() {
const response = await shim.fetch('https://api.github.com/repos/laurent22/joplin/releases');
async function fetchLatestReleases() {
const response = await shim.fetch('https://objects.joplinusercontent.com/r/releases');
if (!response.ok) {
const responseText = await response.text();
@@ -76,7 +76,7 @@ export default async function checkForUpdates(inBackground: boolean, parentWindo
logger.info(`Checking with options ${JSON.stringify(options)}`);
try {
const releases = await fetchLatestRelease();
const releases = await fetchLatestReleases();
const release = extractVersionInfo(releases, process.platform, process.arch, shim.isPortable(), options);
logger.info(`Current version: ${packageInfo.version}`);

View File

@@ -1,6 +1,7 @@
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import * as React from 'react';
import NoteListUtils from './utils/NoteListUtils';
import { Dispatch } from 'redux';
const { buildStyle } = require('@joplin/lib/theme');
const bridge = require('@electron/remote').require('./bridge').default;
@@ -9,8 +10,7 @@ interface MultiNoteActionsProps {
themeId: number;
selectedNoteIds: string[];
notes: any[];
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
dispatch: Function;
dispatch: Dispatch;
watchedNoteFiles: string[];
plugins: PluginStates;
inConflictFolder: boolean;

View File

@@ -3,7 +3,7 @@ import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHand
// eslint-disable-next-line no-unused-vars
import { EditorCommand, NoteBodyEditorProps } from '../../utils/types';
import { commandAttachFileToBody, handlePasteEvent } from '../../utils/resourceHandling';
import { commandAttachFileToBody, getResourcesFromPasteEvent } from '../../utils/resourceHandling';
import { ScrollOptions, ScrollOptionTypes } from '../../utils/types';
import { CommandValue } from '../../utils/types';
import { usePrevious, cursorPositionToTextOffset } from './utils';
@@ -268,7 +268,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
}, [props.content, props.visiblePanes, addListItem, wrapSelectionWithStrings, setEditorPercentScroll, setViewerPercentScroll, resetScroll]);
const onEditorPaste = useCallback(async (event: any = null) => {
const resourceMds = await handlePasteEvent(event);
const resourceMds = await getResourcesFromPasteEvent(event);
if (!resourceMds.length) return;
if (editorRef.current) {
editorRef.current.replaceSelection(resourceMds.join('\n'));

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
import { ScrollOptions, ScrollOptionTypes, EditorCommand, NoteBodyEditorProps, ResourceInfos } from '../../utils/types';
import { resourcesStatus, commandAttachFileToBody, handlePasteEvent, processPastedHtml, attachedResources } from '../../utils/resourceHandling';
import { resourcesStatus, commandAttachFileToBody, getResourcesFromPasteEvent, processPastedHtml, attachedResources } from '../../utils/resourceHandling';
import useScroll from './utils/useScroll';
import styles_ from './styles';
import CommandService from '@joplin/lib/services/CommandService';
@@ -1064,38 +1064,43 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
// to be processed in various ways.
event.preventDefault();
const resourceMds = await handlePasteEvent(event);
if (resourceMds.length) {
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, resourceMds.join('\n'), markupRenderOptions({ bodyOnly: true }));
editor.insertContent(result.html);
} else {
const pastedText = event.clipboardData.getData('text/plain');
const pastedText = event.clipboardData.getData('text/plain');
// event.clipboardData.getData('text/html') wraps the
// content with <html><body></body></html>, which seems to
// be not supported in editor.insertContent().
//
// when pasting text with Ctrl+Shift+V, the format should be
// ignored. In this case,
// event.clopboardData.getData('text/html') returns an empty
// string, but the clipboard.readHTML() still returns the
// formatted text.
const pastedHtml = event.clipboardData.getData('text/html') ? clipboard.readHTML() : '';
// We should only process the images if there is no plain text or
// HTML text in the clipboard. This is because certain applications,
// such as Word, are going to add multiple versions of the copied
// data to the clipboard - one with the text formatted as HTML, and
// one with the text as an image. In that case, we need to ignore
// the image and only process the HTML.
if (!pastedText && !pastedHtml) {
const resourceMds = await getResourcesFromPasteEvent(event);
if (resourceMds.length) {
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, resourceMds.join('\n'), markupRenderOptions({ bodyOnly: true }));
editor.insertContent(result.html);
}
} else {
if (BaseItem.isMarkdownTag(pastedText)) { // Paste a link to a note
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, pastedText, markupRenderOptions({ bodyOnly: true }));
editor.insertContent(result.html);
} else { // Paste regular text
// event.clipboardData.getData('text/html') wraps the content with <html><body></body></html>,
// which seems to be not supported in editor.insertContent().
//
// when pasting text with Ctrl+Shift+V, the format should be ignored.
// In this case, event.clopboardData.getData('text/html') returns an empty string, but the clipboard.readHTML() still returns the formatted text.
const pastedHtml = event.clipboardData.getData('text/html') ? clipboard.readHTML() : '';
if (pastedHtml) { // Handles HTML
const modifiedHtml = await processPastedHtml(pastedHtml);
editor.insertContent(modifiedHtml);
} else { // Handles plain text
pasteAsPlainText(pastedText);
}
// This code before was necessary to get undo working after
// pasting but it seems it's no longer necessary, so
// removing it for now. We also couldn't do it immediately
// it seems, or else nothing is added to the stack, so do it
// on the next frame.
//
// window.requestAnimationFrame(() =>
// editor.undoManager.add()); onChangeHandler();
}
}
}

View File

@@ -78,6 +78,7 @@ function NoteEditor(props: NoteEditorProps) {
const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({
syncStarted: props.syncStarted,
decryptionStarted: props.decryptionStarted,
noteId: effectiveNoteId,
isProvisional: props.isProvisional,
titleInputRef: titleInputRef,
@@ -633,6 +634,7 @@ const mapStateToProps = (state: AppState) => {
isProvisional: state.provisionalNoteIds.includes(noteId),
editorNoteStatuses: state.editorNoteStatuses,
syncStarted: state.syncStarted,
decryptionStarted: state.decryptionWorker?.state !== 'idle',
themeId: state.settings.theme,
richTextBannerDismissed: state.settings.richTextBannerDismissed,
watchedNoteFiles: state.watchedNoteFiles,

View File

@@ -6,7 +6,7 @@ import Resource from '@joplin/lib/models/Resource';
const bridge = require('@electron/remote').require('./bridge').default;
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
import htmlUtils from '@joplin/lib/htmlUtils';
import rendererHtmlUtils from '@joplin/renderer/htmlUtils';
import rendererHtmlUtils, { extractHtmlBody } from '@joplin/renderer/htmlUtils';
import Logger from '@joplin/utils/Logger';
const { fileUriToPath } = require('@joplin/lib/urlUtils');
const joplinRendererUtils = require('@joplin/renderer').utils;
@@ -107,7 +107,7 @@ export function resourcesStatus(resourceInfos: any) {
return joplinRendererUtils.resourceStatusName(lowestIndex);
}
export async function handlePasteEvent(event: any) {
export async function getResourcesFromPasteEvent(event: any) {
const output = [];
const formats = clipboard.availableFormats();
for (let i = 0; i < formats.length; i++) {
@@ -176,9 +176,9 @@ export async function processPastedHtml(html: string) {
}
}
return rendererHtmlUtils.sanitizeHtml(
return extractHtmlBody(rendererHtmlUtils.sanitizeHtml(
htmlUtils.replaceImageUrls(html, (src: string) => {
return mappedResources[src];
})
);
));
}

View File

@@ -1,10 +1,10 @@
// eslint-disable-next-line no-unused-vars
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { MarkupLanguage } from '@joplin/renderer';
import { RenderResult, RenderResultPluginAsset } from '@joplin/renderer/MarkupToHtml';
import { MarkupToHtmlOptions } from './useMarkupToHtml';
import { Dispatch } from 'redux';
export interface AllAssetsOptions {
contentMaxWidthTarget?: string;
@@ -15,11 +15,9 @@ export interface ToolbarButtonInfos {
}
export interface NoteEditorProps {
// style: any;
noteId: string;
themeId: number;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
dispatch: Function;
dispatch: Dispatch;
selectedNoteIds: string[];
selectedFolderId: string;
notes: any[];
@@ -27,6 +25,7 @@ export interface NoteEditorProps {
isProvisional: boolean;
editorNoteStatuses: any;
syncStarted: boolean;
decryptionStarted: boolean;
bodyEditor: string;
notesParentType: string;
selectedNoteTags: any[];

View File

@@ -0,0 +1,73 @@
import Note from '@joplin/lib/models/Note';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import { renderHook } from '@testing-library/react-hooks';
import useFormNote, { HookDependencies } from './useFormNote';
describe('useFormNote', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
});
it('should update note when decryption completes', async () => {
const testNote = await Note.save({ title: 'Test Note!' });
const makeFormNoteProps = (syncStarted: boolean, decryptionStarted: boolean): HookDependencies => {
return {
syncStarted,
decryptionStarted,
noteId: testNote.id,
isProvisional: false,
titleInputRef: null,
editorRef: null,
onBeforeLoad: ()=>{},
onAfterLoad: ()=>{},
};
};
const formNote = renderHook(props => useFormNote(props), {
initialProps: makeFormNoteProps(true, false),
});
await formNote.waitFor(() => {
expect(formNote.result.current.formNote).toMatchObject({
encryption_applied: 0,
title: testNote.title,
});
});
await Note.save({
id: testNote.id,
encryption_cipher_text: 'cipher_text',
encryption_applied: 1,
});
// Sync starting should cause a re-render
formNote.rerender(makeFormNoteProps(false, false));
await formNote.waitFor(() => {
expect(formNote.result.current.formNote).toMatchObject({
encryption_applied: 1,
});
});
formNote.rerender(makeFormNoteProps(false, true));
await Note.save({
id: testNote.id,
encryption_applied: 0,
title: 'Test Note!',
});
// Ending decryption should also cause a re-render
formNote.rerender(makeFormNoteProps(false, false));
await formNote.waitFor(() => {
expect(formNote.result.current.formNote).toMatchObject({
encryption_applied: 0,
title: 'Test Note!',
});
});
});
});

View File

@@ -18,8 +18,9 @@ export interface OnLoadEvent {
formNote: FormNote;
}
interface HookDependencies {
export interface HookDependencies {
syncStarted: boolean;
decryptionStarted: boolean;
noteId: string;
isProvisional: boolean;
titleInputRef: any;
@@ -61,15 +62,21 @@ function resourceInfosChanged(a: ResourceInfos, b: ResourceInfos): boolean {
}
export default function useFormNote(dependencies: HookDependencies) {
const { syncStarted, noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad } = dependencies;
const {
syncStarted, decryptionStarted, noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad,
} = dependencies;
const [formNote, setFormNote] = useState<FormNote>(defaultFormNote());
const [formNoteRefeshScheduled, setFormNoteRefreshScheduled] = useState<boolean>(false);
const [isNewNote, setIsNewNote] = useState(false);
const prevSyncStarted = usePrevious(syncStarted);
const prevDecryptionStarted = usePrevious(decryptionStarted);
const previousNoteId = usePrevious(formNote.id);
const [resourceInfos, setResourceInfos] = useState<ResourceInfos>({});
// Increasing the value of this counter cancels any ongoing note refreshes and starts
// a new refresh.
const [formNoteRefeshScheduled, setFormNoteRefreshScheduled] = useState<number>(0);
async function initNoteState(n: any) {
let originalCss = '';
@@ -107,7 +114,7 @@ export default function useFormNote(dependencies: HookDependencies) {
}
useEffect(() => {
if (!formNoteRefeshScheduled) return () => {};
if (formNoteRefeshScheduled <= 0) return () => {};
reg.logger().info('Sync has finished and note has never been changed - reloading it');
@@ -126,7 +133,7 @@ export default function useFormNote(dependencies: HookDependencies) {
}
await initNoteState(n);
setFormNoteRefreshScheduled(false);
setFormNoteRefreshScheduled(0);
};
void loadNote();
@@ -136,21 +143,32 @@ export default function useFormNote(dependencies: HookDependencies) {
};
}, [formNoteRefeshScheduled, noteId]);
const refreshFormNote = useCallback(() => {
// Increase the counter to cancel any ongoing refresh attempts
// and start a new one.
setFormNoteRefreshScheduled(formNoteRefeshScheduled + 1);
}, [formNoteRefeshScheduled]);
useEffect(() => {
// Check that synchronisation has just finished - and
// if the note has never been changed, we reload it.
// If the note has already been changed, it's a conflict
// that's already been handled by the synchronizer.
const decryptionJustEnded = prevDecryptionStarted && !decryptionStarted;
const syncJustEnded = prevSyncStarted && !syncStarted;
if (!prevSyncStarted) return;
if (syncStarted) return;
if (!decryptionJustEnded && !syncJustEnded) return;
if (formNote.hasChanged) return;
// Refresh the form note.
// This is kept separate from the above logic so that when prevSyncStarted is changed
// from true to false, it doesn't cancel the note from loading.
setFormNoteRefreshScheduled(true);
}, [prevSyncStarted, syncStarted, formNote.hasChanged]);
refreshFormNote();
}, [
prevSyncStarted, syncStarted,
prevDecryptionStarted, decryptionStarted,
formNote.hasChanged, refreshFormNote,
]);
useEffect(() => {
if (!noteId) {

View File

@@ -17,7 +17,7 @@ import ItemList from '../ItemList';
const { connect } = require('react-redux');
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
import { Props } from './types';
import { Props } from './utils/types';
import usePrevious from '../hooks/usePrevious';
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
import { FolderEntity } from '@joplin/lib/services/database/types';

View File

@@ -0,0 +1,304 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
import { useMemo, useRef, useEffect } from 'react';
import { AppState } from '../../app.reducer';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import { ItemFlow, Props } from './utils/types';
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
import { FolderEntity } from '@joplin/lib/services/database/types';
import ItemChange from '@joplin/lib/models/ItemChange';
import { Size } from '@joplin/utils/types';
import NoteListItem from '../NoteListItem/NoteListItem';
import useRenderedNotes from './utils/useRenderedNotes';
import useItemCss from './utils/useItemCss';
import useOnContextMenu from '../NoteListItem/utils/useOnContextMenu';
import useVisibleRange from './utils/useVisibleRange';
import useScroll from './utils/useScroll';
import useFocusNote from './utils/useFocusNote';
import useOnNoteClick from './utils/useOnNoteClick';
import useMoveNote from './utils/useMoveNote';
import useOnKeyDown from './utils/useOnKeyDown';
import * as focusElementNoteList from './commands/focusElementNoteList';
import CommandService from '@joplin/lib/services/CommandService';
import useDragAndDrop from './utils/useDragAndDrop';
import usePrevious from '../hooks/usePrevious';
// import defaultLeftToRightItemRenderer from './utils/defaultLeftToRightListRenderer';
import defaultListRenderer from './utils/defaultListRenderer';
const { connect } = require('react-redux');
const commands = {
focusElementNoteList,
};
const NoteList = (props: Props) => {
const listRef = useRef(null);
const itemRefs = useRef<Record<string, HTMLDivElement>>({});
// const listRenderer = defaultLeftToRightItemRenderer;
const listRenderer = defaultListRenderer;
const itemSize: Size = useMemo(() => {
return {
width: listRenderer.itemSize.width ? listRenderer.itemSize.width : props.size.width,
height: listRenderer.itemSize.height,
};
}, [listRenderer.itemSize, props.size.width]);
const itemsPerLine = useMemo(() => {
if (listRenderer.flow === ItemFlow.TopToBottom) {
return 1;
} else {
return Math.max(1, Math.floor(props.size.width / itemSize.width));
}
}, [listRenderer.flow, props.size.width, itemSize.width]);
const { scrollTop, onScroll, makeItemIndexVisible } = useScroll(
itemsPerLine,
props.notes.length,
itemSize,
props.size,
listRef
);
const [startNoteIndex, endNoteIndex, startLineIndex, endLineIndex, totalLineCount, visibleItemCount] = useVisibleRange(
itemsPerLine,
scrollTop,
props.size,
itemSize,
props.notes.length
);
const focusNote = useFocusNote(itemRefs);
const moveNote = useMoveNote(
props.notesParentType,
props.noteSortOrder,
props.selectedNoteIds,
props.selectedFolderId,
props.uncompletedTodosOnTop,
props.showCompletedTodos,
props.notes
);
const renderedNotes = useRenderedNotes(
startNoteIndex,
endNoteIndex,
props.notes,
props.selectedNoteIds,
listRenderer,
props.highlightedWords,
props.watchedNoteFiles
);
const noteItemStyle = useMemo(() => {
return {
width: 'auto',
height: itemSize.height,
};
}, [itemSize.height]);
const noteListStyle = useMemo(() => {
return {
width: props.size.width,
height: props.size.height,
};
}, [props.size]);
const onNoteClick = useOnNoteClick(props.dispatch, focusNote);
const onKeyDown = useOnKeyDown(
props.selectedNoteIds,
moveNote,
makeItemIndexVisible,
focusNote,
props.notes,
props.dispatch,
visibleItemCount,
props.notes.length,
listRenderer.flow,
itemsPerLine
);
useItemCss(listRenderer.itemCss);
useEffect(() => {
CommandService.instance().registerRuntime(commands.focusElementNoteList.declaration.name, commands.focusElementNoteList.runtime(focusNote));
return () => {
CommandService.instance().unregisterRuntime(commands.focusElementNoteList.declaration.name);
};
}, [focusNote]);
const onItemContextMenu = useOnContextMenu(
props.selectedNoteIds,
props.selectedFolderId,
props.notes,
props.dispatch,
props.watchedNoteFiles,
props.plugins,
props.customCss
);
const { onDragStart, onDragOver, onDrop, dragOverTargetNoteIndex } = useDragAndDrop(props.parentFolderIsReadOnly,
props.selectedNoteIds,
props.selectedFolderId,
listRef,
scrollTop,
itemSize,
props.notesParentType,
props.noteSortOrder,
props.uncompletedTodosOnTop,
props.showCompletedTodos,
listRenderer.flow,
itemsPerLine
);
const previousSelectedNoteIds = usePrevious(props.selectedNoteIds, []);
const previousNoteCount = usePrevious(props.notes.length, 0);
const previousVisible = usePrevious(props.visible, false);
useEffect(() => {
if (previousSelectedNoteIds !== props.selectedNoteIds && props.selectedNoteIds.length === 1) {
const id = props.selectedNoteIds[0];
const doRefocus = props.notes.length < previousNoteCount && !props.focusedField;
for (let i = 0; i < props.notes.length; i++) {
if (props.notes[i].id === id) {
makeItemIndexVisible(i);
if (doRefocus) {
const ref = itemRefs.current[id];
if (ref) ref.focus();
}
break;
}
}
}
}, [makeItemIndexVisible, previousSelectedNoteIds, previousNoteCount, previousVisible, props.selectedNoteIds, props.notes, props.focusedField, props.visible]);
const highlightedWords = useMemo(() => {
if (props.notesParentType === 'Search') {
const query = BaseModel.byId(props.searches, props.selectedSearchId);
if (query) return props.highlightedWords;
}
return [];
}, [props.notesParentType, props.searches, props.selectedSearchId, props.highlightedWords]);
const renderEmptyList = () => {
if (props.notes.length) return null;
return <div className="emptylist">{props.folders.length ? _('No notes in here. Create one by clicking on "New note".') : _('There is currently no notebook. Create one by clicking on "New notebook".')}</div>;
};
const renderFiller = (key: string, style: React.CSSProperties) => {
if (!props.notes.length) return null;
if (style.height as number <= 0) return null;
return <div key={key} style={style}></div>;
};
const renderNotes = () => {
if (!props.notes.length) return null;
const output: JSX.Element[] = [];
for (let i = startNoteIndex; i <= endNoteIndex; i++) {
const note = props.notes[i];
const renderedNote = renderedNotes[note.id];
output.push(
<NoteListItem
key={note.id}
ref={el => itemRefs.current[note.id] = el}
index={i}
dragIndex={dragOverTargetNoteIndex}
noteCount={props.notes.length}
itemSize={itemSize}
noteHtml={renderedNote ? renderedNote.html : ''}
noteId={note.id}
onChange={listRenderer.onChange}
onClick={onNoteClick}
onContextMenu={onItemContextMenu}
onDragStart={onDragStart}
onDragOver={onDragOver}
style={noteItemStyle}
highlightedWords={highlightedWords}
isProvisional={props.provisionalNoteIds.includes(note.id)}
flow={listRenderer.flow}
/>
);
}
return output;
};
const topFillerHeight = startLineIndex * itemSize.height;
const bottomFillerHeight = (totalLineCount - endLineIndex - 1) * itemSize.height;
const fillerBaseStyle = useMemo(() => {
// return { width: 'auto', border: '1px solid red', backgroundColor: 'green' };
return { width: 'auto' };
}, []);
const topFillerStyle = useMemo(() => {
return { ...fillerBaseStyle, height: topFillerHeight };
}, [fillerBaseStyle, topFillerHeight]);
const bottomFillerStyle = useMemo(() => {
return { ...fillerBaseStyle, height: bottomFillerHeight };
}, [fillerBaseStyle, bottomFillerHeight]);
const notesStyle = useMemo(() => {
const output: React.CSSProperties = {};
if (listRenderer.flow === ItemFlow.LeftToRight) {
output.flexFlow = 'row wrap';
} else {
output.flexDirection = 'column';
}
return output;
}, [listRenderer.flow]);
return (
<div
className="note-list"
style={noteListStyle}
ref={listRef}
onScroll={onScroll}
onKeyDown={onKeyDown}
onDrop={onDrop}
>
{renderEmptyList()}
{renderFiller('top', topFillerStyle)}
<div className="notes" style={notesStyle}>
{renderNotes()}
</div>
{renderFiller('bottom', bottomFillerStyle)}
</div>
);
};
const mapStateToProps = (state: AppState) => {
const selectedFolder: FolderEntity = state.notesParentType === 'Folder' ? BaseModel.byId(state.folders, state.selectedFolderId) : null;
const userId = state.settings['sync.userId'];
return {
notes: state.notes,
folders: state.folders,
selectedNoteIds: state.selectedNoteIds,
selectedFolderId: state.selectedFolderId,
themeId: state.settings.theme,
notesParentType: state.notesParentType,
searches: state.searches,
selectedSearchId: state.selectedSearchId,
watchedNoteFiles: state.watchedNoteFiles,
provisionalNoteIds: state.provisionalNoteIds,
isInsertingNotes: state.isInsertingNotes,
noteSortOrder: state.settings['notes.sortOrder.field'],
uncompletedTodosOnTop: state.settings.uncompletedTodosOnTop,
showCompletedTodos: state.settings.showCompletedTodos,
highlightedWords: state.highlightedWords,
plugins: state.pluginService.plugins,
customCss: state.customCss,
focusedField: state.focusedField,
parentFolderIsReadOnly: state.notesParentType === 'Folder' && selectedFolder ? itemIsReadOnlySync(ModelType.Folder, ItemChange.SOURCE_UNSPECIFIED, selectedFolder as ItemSlice, userId, state.shareService) : false,
};
};
export default connect(mapStateToProps)(NoteList);

View File

@@ -0,0 +1,113 @@
import * as React from 'react';
import { useMemo, useState, useRef, useCallback } from 'react';
import { AppState } from '../../app.reducer';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import NoteListItem from '../NoteListItem';
import styled from 'styled-components';
import ItemList from '../ItemList';
const { connect } = require('react-redux');
import { Props } from './utils/types';
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
import { FolderEntity } from '@joplin/lib/services/database/types';
import ItemChange from '@joplin/lib/models/ItemChange';
const StyledRoot = styled.div``;
const NoteListComponent = (props: Props) => {
const [width] = useState(0);
const itemHeight = 34;
const noteListRef = useRef(null);
const itemListRef = useRef(null);
const style = useMemo(() => {
return {};
}, []);
const renderItem = useCallback((item: any, index: number) => {
return <NoteListItem
key={item.id}
style={style}
item={item}
index={index}
themeId={props.themeId}
width={width}
height={itemHeight}
dragItemIndex={0}
highlightedWords={[]}
isProvisional={props.provisionalNoteIds.includes(item.id)}
isSelected={props.selectedNoteIds.indexOf(item.id) >= 0}
isWatched={props.watchedNoteFiles.indexOf(item.id) < 0}
itemCount={props.notes.length}
onCheckboxClick={() => {}}
onDragStart={()=>{}}
onNoteDragOver={()=>{}}
onTitleClick={() => {}}
onContextMenu={() => {}}
draggable={!props.parentFolderIsReadOnly}
/>;
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [style, props.themeId, width, itemHeight, props.provisionalNoteIds, props.selectedNoteIds, props.watchedNoteFiles,
props.notes,
props.notesParentType,
props.searches,
props.selectedSearchId,
props.highlightedWords,
props.parentFolderIsReadOnly,
]);
const renderItemList = () => {
if (!props.notes.length) return null;
return (
<ItemList
ref={itemListRef}
disabled={props.isInsertingNotes}
itemHeight={32}
className={'note-list'}
items={props.notes}
style={props.size}
itemRenderer={renderItem}
onKeyDown={() => {}}
onNoteDrop={()=>{}}
/>
);
};
if (!props.size) throw new Error('props.size is required');
return (
<StyledRoot ref={noteListRef}>
{renderItemList()}
</StyledRoot>
);
};
const mapStateToProps = (state: AppState) => {
const selectedFolder: FolderEntity = state.notesParentType === 'Folder' ? BaseModel.byId(state.folders, state.selectedFolderId) : null;
const userId = state.settings['sync.userId'];
return {
notes: state.notes,
folders: state.folders,
selectedNoteIds: state.selectedNoteIds,
selectedFolderId: state.selectedFolderId,
themeId: state.settings.theme,
notesParentType: state.notesParentType,
searches: state.searches,
selectedSearchId: state.selectedSearchId,
watchedNoteFiles: state.watchedNoteFiles,
provisionalNoteIds: state.provisionalNoteIds,
isInsertingNotes: state.isInsertingNotes,
noteSortOrder: state.settings['notes.sortOrder.field'],
uncompletedTodosOnTop: state.settings.uncompletedTodosOnTop,
showCompletedTodos: state.settings.showCompletedTodos,
highlightedWords: state.highlightedWords,
plugins: state.pluginService.plugins,
customCss: state.customCss,
focusedField: state.focusedField,
parentFolderIsReadOnly: state.notesParentType === 'Folder' && selectedFolder ? itemIsReadOnlySync(ModelType.Folder, ItemChange.SOURCE_UNSPECIFIED, selectedFolder as ItemSlice, userId, state.shareService) : false,
};
};
export default connect(mapStateToProps)(NoteListComponent);

View File

@@ -1,7 +1,7 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { stateUtils } from '@joplin/lib/reducer';
import { itemAnchorRef } from '../NoteList';
import { FocusNote } from '../utils/useFocusNote';
export const declaration: CommandDeclaration = {
name: 'focusElementNoteList',
@@ -9,15 +9,11 @@ export const declaration: CommandDeclaration = {
parentLabel: () => _('Focus'),
};
export const runtime = (): CommandRuntime => {
export const runtime = (focusNote: FocusNote): CommandRuntime => {
return {
execute: async (context: CommandContext, noteId: string = null) => {
noteId = noteId || stateUtils.selectedNoteId(context.state);
if (noteId) {
const ref = itemAnchorRef(noteId);
if (ref) ref.focus();
}
focusNote(noteId);
},
enabledCondition: 'noteListHasNotes',
};

View File

@@ -0,0 +1,43 @@
.note-list {
width: 100%;
height: 100%;
background-color: var(--joplin-background-color3);
border-right: 1px solid var(--joplin-divider-color);
overflow-x: hidden;
overflow-y: scroll;
> .notes {
display: flex;
overflow-x: hidden;
}
> .emptylist {
padding: 10px;
font-size: var(--joplin-font-size);
color: var(--joplin-color);
background-color: var(--joplin-background-color);
font-family: var(--joplin-font-family);
}
}
.note-list-item {
display: flex;
}
.note-list-item-wrapper {
border-color: var(--joplin-color);
position: relative;
box-sizing: border-box;
> .dragcursor {
background-color: var(--joplin-color);
position: absolute;
z-index: 1000;
width: 2px;
height: 2px;
}
}
.note-list-item-wrapper.-provisional {
opacity: 0.5;
}

View File

@@ -1,29 +0,0 @@
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
export interface Props {
themeId: any;
selectedNoteIds: string[];
notes: NoteEntity[];
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
dispatch: Function;
watchedNoteFiles: any[];
plugins: PluginStates;
selectedFolderId: string;
customCss: string;
notesParentType: string;
noteSortOrder: string;
uncompletedTodosOnTop: boolean;
showCompletedTodos: boolean;
resizableLayoutEventEmitter: any;
isInsertingNotes: boolean;
folders: FolderEntity[];
size: any;
searches: any[];
selectedSearchId: string;
highlightedWords: string[];
provisionalNoteIds: string[];
visible: boolean;
focusedField: string;
parentFolderIsReadOnly: boolean;
}

View File

@@ -0,0 +1,20 @@
import { _ } from '@joplin/lib/locale';
import Setting from '@joplin/lib/models/Setting';
import bridge from '../../../services/bridge';
const canManuallySortNotes = (notesParentType: string, noteSortOrder: string) => {
if (notesParentType !== 'Folder') return false;
if (noteSortOrder !== 'order') {
const doIt = bridge().showConfirmMessageBox(_('To manually sort the notes, the sort order must be changed to "%s" in the menu "%s" > "%s"', _('Custom order'), _('View'), _('Sort notes by')), {
buttons: [_('Do it now'), _('Cancel')],
});
if (!doIt) return false;
Setting.setValue('notes.sortOrder.field', 'order');
return false;
}
return true;
};
export default canManuallySortNotes;

View File

@@ -0,0 +1,165 @@
import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
import { ItemFlow, ListRenderer } from './types';
interface Props {
note: {
id: string;
title: string;
is_todo: number;
todo_completed: number;
body: string;
};
item: {
size: {
width: number;
height: number;
};
selected: boolean;
};
}
const defaultLeftToRightItemRenderer: ListRenderer = {
flow: ItemFlow.LeftToRight,
itemSize: {
width: 150,
height: 150,
},
dependencies: [
'item.selected',
'item.size.width',
'item.size.height',
'note.body',
'note.id',
'note.is_shared',
'note.is_todo',
'note.isWatched',
'note.titleHtml',
'note.todo_completed',
],
itemCss: // css
`
&:before {
content: '';
border-bottom: 1px solid var(--joplin-divider-color);
width: 90%;
position: absolute;
bottom: 0;
left: 5%;
}
> .content.-selected {
background-color: var(--joplin-selected-color);
}
&:hover {
background-color: var(--joplin-background-color-hover3);
}
> .content {
display: flex;
box-sizing: border-box;
position: relative;
width: 100%;
padding: 16px;
align-items: flex-start;
overflow-y: hidden;
flex-direction: column;
user-select: none;
> .checkbox {
display: flex;
align-items: center;
> input {
margin: 0px 10px 1px 0px;
}
}
> .title {
font-family: var(--joplin-font-family);
font-size: var(--joplin-font-size);
color: var(--joplin-color);
cursor: default;
flex: 0;
display: flex;
align-items: flex-start;
margin-bottom: 8px;
> .checkbox {
margin: 0 6px 0 0;
}
> .watchedicon {
display: none;
padding-right: 4px;
color: var(--joplin-color);
}
> .titlecontent {
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
}
}
> .preview {
overflow-y: hidden;
font-family: var(--joplin-font-family);
font-size: var(--joplin-font-size);
color: var(--joplin-color);
cursor: default;
}
}
> .content.-shared {
> .title {
color: var(--joplin-color-warn3);
}
}
> .content.-completed {
> .title {
opacity: 0.5;
text-decoration: line-through;
}
}
> .content.-watched {
> .title {
> .watchedicon {
display: inline;
}
}
}
`,
itemTemplate: // html
`
<div class="content {{#item.selected}}-selected{{/item.selected}} {{#note.is_shared}}-shared{{/note.is_shared}} {{#note.todo_completed}}-completed{{/note.todo_completed}} {{#note.isWatched}}-watched{{/note.isWatched}}">
<div style="width: {{titleWidth}}px;" class="title" data-id="{{note.id}}">
{{#note.is_todo}}
<input class="checkbox" data-id="todo-checkbox" type="checkbox" {{#note.todo_completed}}checked="checked"{{/note.todo_completed}}>
{{/note.is_todo}}
<i class="watchedicon fa fa-share-square"></i>
<div class="titlecontent">{{{note.titleHtml}}}</div>
</div>
<div class="preview">{{notePreview}}</div>
</div>
`,
onRenderNote: async (props: Props) => {
const markupToHtml_ = new MarkupToHtml();
return {
...props,
notePreview: markupToHtml_.stripMarkup(MarkupLanguage.Markdown, props.note.body).substring(0, 200),
titleWidth: props.item.size.width - 32,
};
},
};
export default defaultLeftToRightItemRenderer;

View File

@@ -0,0 +1,134 @@
import { ItemFlow, ListRenderer } from './types';
interface Props {
note: {
id: string;
title: string;
is_todo: number;
todo_completed: number;
};
item: {
size: {
height: number;
};
selected: boolean;
};
}
const defaultItemRenderer: ListRenderer = {
flow: ItemFlow.TopToBottom,
itemSize: {
width: 0,
height: 34,
},
dependencies: [
'item.selected',
'item.size.height',
'note.id',
'note.is_shared',
'note.is_todo',
'note.isWatched',
'note.titleHtml',
'note.todo_completed',
],
itemCss: // css
`
&:before {
content: '';
border-bottom: 1px solid var(--joplin-divider-color);
width: 90%;
position: absolute;
bottom: 0;
left: 5%;
}
> .content.-selected {
background-color: var(--joplin-selected-color);
}
&:hover {
background-color: var(--joplin-background-color-hover3);
}
> .content {
display: flex;
box-sizing: border-box;
position: relative;
width: 100%;
padding-left: 16px;
> .checkbox {
display: flex;
align-items: center;
> input {
margin: 0px 10px 1px 0px;
}
}
> .title {
font-family: var(--joplin-font-family);
font-size: var(--joplin-font-size);
text-decoration: none;
color: var(--joplin-color);
cursor: default;
white-space: nowrap;
flex: 1 1 0%;
display: flex;
align-items: center;
overflow: hidden;
> .watchedicon {
display: none;
padding-right: 4px;
color: var(--joplin-color);
}
}
}
> .content.-shared {
> .title {
color: var(--joplin-color-warn3);
}
}
> .content.-completed {
> .title {
opacity: 0.5;
text-decoration: line-through;
}
}
> .content.-watched {
> .title {
> .watchedicon {
display: inline;
}
}
}
`,
itemTemplate: // html
`
<div class="content {{#item.selected}}-selected{{/item.selected}} {{#note.is_shared}}-shared{{/note.is_shared}} {{#note.todo_completed}}-completed{{/note.todo_completed}} {{#note.isWatched}}-watched{{/note.isWatched}}">
{{#note.is_todo}}
<div class="checkbox">
<input data-id="todo-checkbox" type="checkbox" {{#note.todo_completed}}checked="checked"{{/note.todo_completed}}>
</div>
{{/note.is_todo}}
<div class="title" data-id="{{note.id}}">
<i class="watchedicon fa fa-share-square"></i>
<span>{{{note.titleHtml}}}</span>
</div>
</div>
`,
onRenderNote: async (props: Props) => {
return props;
},
};
export default defaultItemRenderer;

View File

@@ -0,0 +1,45 @@
import { htmlentities } from '@joplin/utils/html';
const Mark = require('mark.js/dist/mark.min.js');
const markJsUtils = require('@joplin/lib/markJsUtils');
const { replaceRegexDiacritics, pregQuote } = require('@joplin/lib/string-utils');
const getNoteTitleHtml = (highlightedWords: string[], displayTitle: string) => {
if (highlightedWords.length) {
const titleElement = document.createElement('span');
titleElement.textContent = displayTitle;
const mark = new Mark(titleElement, {
exclude: ['img'],
acrossElements: true,
});
mark.unmark();
try {
for (const wordToBeHighlighted of highlightedWords) {
markJsUtils.markKeyword(mark, wordToBeHighlighted, {
pregQuote: pregQuote,
replaceRegexDiacritics: replaceRegexDiacritics,
});
}
} catch (error) {
if (error.name !== 'SyntaxError') {
throw error;
}
// An error of 'Regular expression too large' might occour in the markJs library
// when the input is really big, this catch is here to avoid the application crashing
// https://github.com/laurent22/joplin/issues/7634
// console.error('Error while trying to highlight words from search: ', error);
}
// Note: in this case it is safe to use dangerouslySetInnerHTML because titleElement
// is a span tag that we created and that contains data that's been inserted as plain text
// with `textContent` so it cannot contain any XSS attacks. We use this feature because
// mark.js can only deal with DOM elements.
// https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
return titleElement.outerHTML;
} else {
return htmlentities(displayTitle);
}
};
export default getNoteTitleHtml;

View File

@@ -0,0 +1,51 @@
import { ListRendererDepependency } from './types';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { Size } from '@joplin/utils/types';
import Note from '@joplin/lib/models/Note';
const prepareViewProps = async (dependencies: ListRendererDepependency[], note: NoteEntity, itemSize: Size, selected: boolean, noteTitleHtml: string, noteIsWatched: boolean) => {
const output: any = {};
for (const dep of dependencies) {
if (dep.startsWith('note.')) {
const splitted = dep.split('.');
if (splitted.length !== 2) throw new Error(`Invalid dependency name: ${dep}`);
const propName = splitted.pop();
if (!output.note) output.note = {};
if (dep === 'note.titleHtml') {
output.note.titleHtml = noteTitleHtml;
} else if (dep === 'note.isWatched') {
output.note.isWatched = noteIsWatched;
} else {
// The notes in the state only contain the properties defined in
// Note.previewFields(). It means that if a view request a
// property not present there, we need to load the full note.
// One such missing property is the note body, which we don't
// load by default.
if (!(propName in note)) note = await Note.load(note.id);
if (!(propName in note)) throw new Error(`Invalid dependency name: ${dep}`);
output.note[propName] = (note as any)[propName];
}
}
if (dep.startsWith('item.size.')) {
const splitted = dep.split('.');
if (splitted.length !== 3) throw new Error(`Invalid dependency name: ${dep}`);
const propName = splitted.pop();
if (!output.item) output.item = {};
if (!output.item.size) output.item.size = {};
if (!(propName in itemSize)) throw new Error(`Invalid dependency name: ${dep}`);
output.item.size[propName] = (itemSize as any)[propName];
}
if (dep === 'item.selected') {
if (!output.item) output.item = {};
output.item.selected = selected;
}
}
return output;
};
export default prepareViewProps;

View File

@@ -0,0 +1,64 @@
import { FolderEntity, ItemRendererDatabaseDependency, NoteEntity } from '@joplin/lib/services/database/types';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { Size } from '@joplin/utils/types';
import { Dispatch } from 'redux';
export interface Props {
themeId: any;
selectedNoteIds: string[];
notes: NoteEntity[];
dispatch: Dispatch;
watchedNoteFiles: string[];
plugins: PluginStates;
selectedFolderId: string;
customCss: string;
notesParentType: string;
noteSortOrder: string;
uncompletedTodosOnTop: boolean;
showCompletedTodos: boolean;
resizableLayoutEventEmitter: any;
isInsertingNotes: boolean;
folders: FolderEntity[];
size: Size;
searches: any[];
selectedSearchId: string;
highlightedWords: string[];
provisionalNoteIds: string[];
visible: boolean;
focusedField: string;
parentFolderIsReadOnly: boolean;
}
export enum ItemFlow {
TopToBottom = 'topToBottom',
LeftToRight = 'leftToRight',
}
export type RenderNoteView = Record<string, any>;
export interface OnChangeEvent {
elementId: string;
value: any;
noteId: string;
}
export type OnRenderNoteHandler = (props: any)=> Promise<RenderNoteView>;
export type OnChangeHandler = (event: OnChangeEvent)=> Promise<void>;
export type ListRendererDepependency =
ItemRendererDatabaseDependency |
'item.size.width' |
'item.size.height' |
'item.selected' |
'note.titleHtml' |
'note.isWatched';
export interface ListRenderer {
flow: ItemFlow;
itemSize: Size;
itemCss?: string;
dependencies: ListRendererDepependency[];
itemTemplate: string;
onRenderNote: OnRenderNoteHandler;
onChange?: OnChangeHandler;
}

View File

@@ -0,0 +1,102 @@
import * as React from 'react';
import { useCallback, DragEventHandler, MutableRefObject, useState, useEffect } from 'react';
import Note from '@joplin/lib/models/Note';
import canManuallySortNotes from './canManuallySortNotes';
import { Size } from '@joplin/utils/types';
import { ItemFlow } from './types';
const useDragAndDrop = (
parentFolderIsReadOnly: boolean,
selectedNoteIds: string[],
selectedFolderId: string,
listRef: MutableRefObject<HTMLDivElement>,
scrollTop: number,
itemSize: Size,
notesParentType: string,
noteSortOrder: string,
uncompletedTodosOnTop: boolean,
showCompletedTodos: boolean,
flow: ItemFlow,
itemsPerLine: number
) => {
const [dragOverTargetNoteIndex, setDragOverTargetNoteIndex] = useState(null);
const onGlobalDrop = useCallback(() => {
setDragOverTargetNoteIndex(null);
}, []);
useEffect(() => {
document.addEventListener('dragend', onGlobalDrop);
return () => {
document.removeEventListener('dragend', onGlobalDrop);
};
}, [onGlobalDrop]);
const onDragStart: DragEventHandler = useCallback(event => {
if (parentFolderIsReadOnly) return false;
let noteIds = [];
// Here there is two cases:
// - If multiple notes are selected, we drag the group
// - If only one note is selected, we drag the note that was clicked on
// (which might be different from the currently selected note)
if (selectedNoteIds.length >= 2) {
noteIds = selectedNoteIds;
} else {
const clickedNoteId = event.currentTarget.getAttribute('data-id');
if (clickedNoteId) noteIds.push(clickedNoteId);
}
if (!noteIds.length) return false;
event.dataTransfer.setDragImage(new Image(), 1, 1);
event.dataTransfer.clearData();
event.dataTransfer.setData('text/x-jop-note-ids', JSON.stringify(noteIds));
return true;
}, [parentFolderIsReadOnly, selectedNoteIds]);
const dragTargetNoteIndex = useCallback((event: React.DragEvent) => {
const rect = listRef.current.getBoundingClientRect();
const lineIndexFloat = (event.clientY - rect.top + scrollTop) / itemSize.height;
if (flow === ItemFlow.TopToBottom) {
return Math.abs(Math.round(lineIndexFloat));
} else {
const lineIndex = Math.floor(lineIndexFloat);
const rowIndexFloat = (event.clientX - rect.left) / itemSize.width;
const rowIndex = Math.round(rowIndexFloat);
return lineIndex * itemsPerLine + rowIndex;
}
}, [listRef, itemSize, scrollTop, flow, itemsPerLine]);
const onDragOver: DragEventHandler = useCallback(event => {
if (notesParentType !== 'Folder') return;
const dt = event.dataTransfer;
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
event.preventDefault();
const newIndex = dragTargetNoteIndex(event);
if (dragOverTargetNoteIndex === newIndex) return;
setDragOverTargetNoteIndex(newIndex);
}
}, [notesParentType, dragTargetNoteIndex, dragOverTargetNoteIndex]);
const onDrop: DragEventHandler = useCallback(async (event: any) => {
// TODO: check that parent type is folder
if (!canManuallySortNotes(notesParentType, noteSortOrder)) return;
const dt = event.dataTransfer;
setDragOverTargetNoteIndex(null);
const targetNoteIndex = dragTargetNoteIndex(event);
const noteIds: string[] = JSON.parse(dt.getData('text/x-jop-note-ids'));
await Note.insertNotesAt(selectedFolderId, noteIds, targetNoteIndex, uncompletedTodosOnTop, showCompletedTodos);
}, [notesParentType, dragTargetNoteIndex, noteSortOrder, selectedFolderId, uncompletedTodosOnTop, showCompletedTodos]);
return { onDragStart, onDragOver, onDrop, dragOverTargetNoteIndex };
};
export default useDragAndDrop;

View File

@@ -0,0 +1,33 @@
import shim from '@joplin/lib/shim';
import { useRef, useCallback, MutableRefObject } from 'react';
export type FocusNote = (noteId: string)=> void;
const useFocusNote = (itemRefs: MutableRefObject<Record<string, HTMLDivElement>>) => {
const focusItemIID = useRef(null);
const focusNote: FocusNote = useCallback((noteId: string) => {
// - We need to focus the item manually otherwise focus might be lost when the
// list is scrolled and items within it are being rebuilt.
// - We need to use an interval because when leaving the arrow pressed, the rendering
// of items might lag behind and so the ref is not yet available at this point.
if (!itemRefs.current[noteId]) {
if (focusItemIID.current) shim.clearInterval(focusItemIID.current);
focusItemIID.current = shim.setInterval(() => {
if (itemRefs.current[noteId]) {
itemRefs.current[noteId].focus();
shim.clearInterval(focusItemIID.current);
focusItemIID.current = null;
}
}, 10);
} else {
if (focusItemIID.current) shim.clearInterval(focusItemIID.current);
itemRefs.current[noteId].focus();
}
}, [itemRefs]);
return focusNote;
};
export default useFocusNote;

View File

@@ -0,0 +1,19 @@
import { useEffect } from 'react';
const useItemCss = (itemCss: string) => {
useEffect(() => {
const element = document.createElement('style');
element.setAttribute('type', 'text/css');
element.appendChild(document.createTextNode(`
.note-list-item {
${itemCss};
}
`));
document.head.appendChild(element);
return () => {
element.remove();
};
}, [itemCss]);
};
export default useItemCss;

View File

@@ -0,0 +1,25 @@
import BaseModel from '@joplin/lib/BaseModel';
import Note from '@joplin/lib/models/Note';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { useCallback } from 'react';
import canManuallySortNotes from './canManuallySortNotes';
const useMoveNote = (notesParentType: string, noteSortOrder: string, selectedNoteIds: string[], selectedFolderId: string, uncompletedTodosOnTop: boolean, showCompletedTodos: boolean, notes: NoteEntity[]) => {
const moveNote = useCallback((direction: number, inc: number) => {
if (!canManuallySortNotes(notesParentType, noteSortOrder)) return;
const noteId = selectedNoteIds[0];
let targetNoteIndex = BaseModel.modelIndexById(notes, noteId);
if ((direction === 1)) {
targetNoteIndex += inc + 1;
}
if ((direction === -1)) {
targetNoteIndex -= inc;
}
void Note.insertNotesAt(selectedFolderId, selectedNoteIds, targetNoteIndex, uncompletedTodosOnTop, showCompletedTodos);
}, [selectedFolderId, noteSortOrder, notes, notesParentType, selectedNoteIds, uncompletedTodosOnTop, showCompletedTodos]);
return moveNote;
};
export default useMoveNote;

View File

@@ -0,0 +1,150 @@
import * as React from 'react';
import BaseModel from '@joplin/lib/BaseModel';
import Note from '@joplin/lib/models/Note';
import CommandService from '@joplin/lib/services/CommandService';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { useCallback } from 'react';
import { Dispatch } from 'redux';
import { FocusNote } from './useFocusNote';
import { ItemFlow } from './types';
import { KeyboardEventKey } from '@joplin/lib/dom';
const useOnKeyDown = (
selectedNoteIds: string[],
moveNote: (direction: number, inc: number)=> void,
makeItemIndexVisible: (itemIndex: number)=> void,
focusNote: FocusNote,
notes: NoteEntity[],
dispatch: Dispatch,
visibleItemCount: number,
noteCount: number,
flow: ItemFlow,
itemsPerLine: number
) => {
const scrollNoteIndex = useCallback((visibleItemCount: number, key: KeyboardEventKey, ctrlKey: boolean, metaKey: boolean, noteIndex: number) => {
if (flow === ItemFlow.TopToBottom) {
if (key === 'PageUp') {
noteIndex -= (visibleItemCount - 1);
} else if (key === 'PageDown') {
noteIndex += (visibleItemCount - 1);
} else if ((key === 'End' && ctrlKey) || (key === 'ArrowDown' && metaKey)) {
noteIndex = noteCount - 1;
} else if ((key === 'Home' && ctrlKey) || (key === 'ArrowUp' && metaKey)) {
noteIndex = 0;
} else if (key === 'ArrowUp' && !metaKey) {
noteIndex -= 1;
} else if (key === 'ArrowDown' && !metaKey) {
noteIndex += 1;
}
if (noteIndex < 0) noteIndex = 0;
if (noteIndex > noteCount - 1) noteIndex = noteCount - 1;
}
if (flow === ItemFlow.LeftToRight) {
if (key === 'PageUp') {
noteIndex -= (visibleItemCount - itemsPerLine);
} else if (key === 'PageDown') {
noteIndex += (visibleItemCount - itemsPerLine);
} else if ((key === 'End' && ctrlKey) || (key === 'ArrowDown' && metaKey)) {
noteIndex = noteCount - 1;
} else if ((key === 'Home' && ctrlKey) || (key === 'ArrowUp' && metaKey)) {
noteIndex = 0;
} else if (key === 'ArrowUp' && !metaKey) {
noteIndex -= itemsPerLine;
} else if (key === 'ArrowDown' && !metaKey) {
noteIndex += itemsPerLine;
} else if (key === 'ArrowLeft' && !metaKey) {
noteIndex -= 1;
} else if (key === 'ArrowRight' && !metaKey) {
noteIndex += 1;
}
if (noteIndex < 0) noteIndex = 0;
if (noteIndex > noteCount - 1) noteIndex = noteCount - 1;
}
return noteIndex;
}, [noteCount, flow, itemsPerLine]);
const onKeyDown: React.KeyboardEventHandler<HTMLDivElement> = useCallback(async (event) => {
const noteIds = selectedNoteIds;
const key = event.key as KeyboardEventKey;
if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(key) && event.altKey) {
if (flow === ItemFlow.TopToBottom) {
await moveNote(key === 'ArrowDown' ? 1 : -1, 1);
} else {
if (key === 'ArrowRight') {
await moveNote(1, 1);
} else if (key === 'ArrowLeft') {
await moveNote(-1, 1);
} else if (key === 'ArrowUp') {
await moveNote(-1, itemsPerLine);
} else if (key === 'ArrowDown') {
await moveNote(1, itemsPerLine);
}
}
event.preventDefault();
} else if (noteIds.length > 0 && (key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowLeft' || key === 'ArrowRight' || key === 'PageDown' || key === 'PageUp' || key === 'End' || key === 'Home')) {
const noteId = noteIds[0];
let noteIndex = BaseModel.modelIndexById(notes, noteId);
noteIndex = scrollNoteIndex(visibleItemCount, key, event.ctrlKey, event.metaKey, noteIndex);
const newSelectedNote = notes[noteIndex];
dispatch({
type: 'NOTE_SELECT',
id: newSelectedNote.id,
});
makeItemIndexVisible(noteIndex);
focusNote(newSelectedNote.id);
event.preventDefault();
}
if (noteIds.length && (key === 'Delete' || (key === 'Backspace' && event.metaKey))) {
event.preventDefault();
void CommandService.instance().execute('deleteNote', noteIds);
}
if (noteIds.length && key === ' ') {
event.preventDefault();
const selectedNotes = BaseModel.modelsByIds(notes, noteIds);
const todos = selectedNotes.filter((n: any) => !!n.is_todo);
if (!todos.length) return;
for (let i = 0; i < todos.length; i++) {
const toggledTodo = Note.toggleTodoCompleted(todos[i]);
await Note.save(toggledTodo);
}
focusNote(todos[0].id);
}
if (key === 'Tab') {
event.preventDefault();
if (event.shiftKey) {
void CommandService.instance().execute('focusElement', 'sideBar');
} else {
void CommandService.instance().execute('focusElement', 'noteTitle');
}
}
if (key.toUpperCase() === 'A' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
dispatch({
type: 'NOTE_SELECT_ALL',
});
}
}, [moveNote, focusNote, visibleItemCount, scrollNoteIndex, makeItemIndexVisible, notes, selectedNoteIds, dispatch, flow, itemsPerLine]);
return onKeyDown;
};
export default useOnKeyDown;

View File

@@ -0,0 +1,41 @@
import * as React from 'react';
import { useCallback } from 'react';
import { Dispatch } from 'redux';
import { FocusNote } from './useFocusNote';
const useOnNoteClick = (dispatch: Dispatch, focusNote: FocusNote) => {
const onNoteClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const noteId = event.currentTarget.getAttribute('data-id');
const targetTagName = event.target ? (event.target as any).tagName : '';
// If we are for example on a checkbox, don't process the click since it
// should be handled by the checkbox onChange handler.
if (['INPUT'].includes(targetTagName)) return;
focusNote(noteId);
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
dispatch({
type: 'NOTE_SELECT_TOGGLE',
id: noteId,
});
} else if (event.shiftKey) {
event.preventDefault();
dispatch({
type: 'NOTE_SELECT_EXTEND',
id: noteId,
});
} else {
dispatch({
type: 'NOTE_SELECT',
id: noteId,
});
}
}, [dispatch, focusNote]);
return onNoteClick;
};
export default useOnNoteClick;

View File

@@ -0,0 +1,82 @@
import { useState } from 'react';
import { ListRenderer } from './types';
import { NoteEntity } from '@joplin/lib/services/database/types';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import * as Mustache from 'mustache';
import { createHash } from 'crypto';
import getNoteTitleHtml from './getNoteTitleHtml';
import Note from '@joplin/lib/models/Note';
import prepareViewProps from './prepareViewProps';
interface RenderedNote {
id: string;
hash: string;
html: string;
}
const hashContent = (content: any) => {
return createHash('sha1').update(JSON.stringify(content)).digest('hex');
};
const useRenderedNotes = (startNoteIndex: number, endNoteIndex: number, notes: NoteEntity[], selectedNoteIds: string[], listRenderer: ListRenderer, highlightedWords: string[], watchedNoteFiles: string[]) => {
const [renderedNotes, setRenderedNotes] = useState<Record<string, RenderedNote>>({});
useAsyncEffect(async (event) => {
if (event.cancelled) return;
const renderNote = async (note: NoteEntity): Promise<void> => {
const isSelected = selectedNoteIds.includes(note.id);
const isWatched = watchedNoteFiles.includes(note.id);
// Note: with this hash we're assuming that the list renderer
// properties never changes. It means that later if we support
// dynamic list renderers, we should include these into the hash.
const viewHash = hashContent([
note.updated_time,
isSelected,
isWatched,
highlightedWords,
]);
if (renderedNotes[note.id] && renderedNotes[note.id].hash === viewHash) return null;
const titleHtml = getNoteTitleHtml(highlightedWords, Note.displayTitle(note));
const viewProps = await prepareViewProps(
listRenderer.dependencies,
note,
listRenderer.itemSize,
isSelected,
titleHtml,
isWatched
);
const view = await listRenderer.onRenderNote(viewProps);
if (event.cancelled) return null;
setRenderedNotes(prev => {
if (prev[note.id] && prev[note.id].hash === viewHash) return prev;
return {
...prev,
[note.id]: {
id: note.id,
hash: viewHash,
html: Mustache.render(listRenderer.itemTemplate, view),
},
};
});
};
const promises: Promise<void>[] = [];
for (let i = startNoteIndex; i <= endNoteIndex; i++) {
promises.push(renderNote(notes[i]));
}
await Promise.all(promises);
}, [startNoteIndex, endNoteIndex, notes, selectedNoteIds, listRenderer, renderedNotes, watchedNoteFiles]);
return renderedNotes;
};
export default useRenderedNotes;

View File

@@ -0,0 +1,99 @@
import * as React from 'react';
import shim from '@joplin/lib/shim';
import { Size } from '@joplin/utils/types';
import { useCallback, useState, useRef, useEffect, useMemo } from 'react';
const useScroll = (itemsPerLine: number, noteCount: number, itemSize: Size, listSize: Size, listRef: React.MutableRefObject<HTMLDivElement>) => {
const [scrollTop, setScrollTop] = useState(0);
const lastScrollSetTime = useRef(0);
const maxScrollTop = useMemo(() => {
return Math.max(0, itemSize.height * noteCount - listSize.height);
}, [itemSize.height, noteCount, listSize.height]);
// This ugly hack is necessary because setting scrollTop at a high
// frequency, while scrolling with the keyboard, is unreliable - the
// property will appear to be set (reading it back gives the correct value),
// but the scrollbar will not be at the expected position. That can be
// verified by moving the scrollbar a little and reading the event value -
// it will be different from what was set, and what was read.
//
// As a result, since we can't rely on setting or reading that value (to
// check if it's correct), we forcefully set it multiple times over the next
// few milliseconds, hoping that maybe one of these attempts will stick.
//
// This is most likely a race condition in either Chromimum or Electron
// although I couldn't find an upstream issue.
//
// Setting the value only once after a short time, for example 10ms, helps
// but still fails now and then. Setting it after 500ms would probably work
// reliably but it's too slow so it makes sense to do it in an interval.
const setScrollTopLikeYouMeanItTimer = useRef(null);
const setScrollTopLikeYouMeanItStartTime = useRef(0);
const setScrollTopLikeYouMeanIt = useCallback((newScrollTop: number) => {
if (setScrollTopLikeYouMeanItTimer.current) shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
setScrollTopLikeYouMeanItStartTime.current = Date.now();
setScrollTopLikeYouMeanItTimer.current = shim.setInterval(() => {
if (!listRef.current) {
shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
setScrollTopLikeYouMeanItTimer.current = null;
return;
}
listRef.current.scrollTop = newScrollTop;
lastScrollSetTime.current = Date.now();
if (Date.now() - setScrollTopLikeYouMeanItStartTime.current > 500) {
shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
setScrollTopLikeYouMeanItTimer.current = null;
}
}, 10);
}, [listRef]);
useEffect(() => {
if (setScrollTopLikeYouMeanItTimer.current) shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
setScrollTopLikeYouMeanItTimer.current = null;
}, []);
const makeItemIndexVisible = useCallback((itemIndex: number) => {
const lineTopFloat = scrollTop / itemSize.height;
const topFloat = lineTopFloat * itemsPerLine; // scrollTop / itemSize.height;
const lineBottomFloat = (scrollTop + listSize.height - itemSize.height) / itemSize.height;
const bottomFloat = lineBottomFloat * itemsPerLine; // (scrollTop + listSize.height - itemSize.height) / itemSize.height;
const top = Math.min(noteCount - 1, Math.floor(topFloat) + 1);
const bottom = Math.max(0, Math.floor(bottomFloat));
if (itemIndex >= top && itemIndex <= bottom) return;
const lineIndex = Math.floor(itemIndex / itemsPerLine);
let newScrollTop = 0;
if (itemIndex < top) {
newScrollTop = itemSize.height * lineIndex;
} else {
newScrollTop = itemSize.height * (lineIndex + 1) - listSize.height;
}
if (newScrollTop < 0) newScrollTop = 0;
if (newScrollTop > maxScrollTop) newScrollTop = maxScrollTop;
setScrollTop(newScrollTop);
setScrollTopLikeYouMeanIt(newScrollTop);
}, [itemsPerLine, noteCount, itemSize.height, scrollTop, listSize.height, maxScrollTop, setScrollTopLikeYouMeanIt]);
const onScroll = useCallback((event: any) => {
// Ignore the scroll event if it has just been set programmatically.
if (Date.now() - lastScrollSetTime.current < 100) return;
setScrollTop(event.target.scrollTop);
}, []);
return {
scrollTop,
onScroll,
makeItemIndexVisible,
};
};
export default useScroll;

View File

@@ -0,0 +1,61 @@
import useVisibleRange from './useVisibleRange';
import { renderHook } from '@testing-library/react-hooks';
import { Size } from '@joplin/utils/types';
describe('useVisibleRange', () => {
test('should calculate indexes', () => {
// IN: scrollTop, listSize, itemSize, noteCount, flow
//
// OUT: [itemsPerLine, startNoteIndex, endNoteIndex, startLineIndex, endLineIndex, totalLineCount, visibleItemCount]
const testCases: [number, number, Size, Size, number, ReturnType<typeof useVisibleRange>][] = [
[
1,
150,
{ width: 100, height: 400 },
{ width: 100, height: 100 },
8,
[1, 5, 1, 5, 8, 5],
],
[
2,
100,
{ width: 220, height: 380 },
{ width: 100, height: 100 },
12,
[2, 9, 1, 4, 6, 8],
],
[
2,
50,
{ width: 220, height: 300 },
{ width: 100, height: 100 },
9,
[0, 7, 0, 3, 5, 8],
],
[
4,
0,
{ width: 410, height: 450 },
{ width: 100, height: 100 },
30,
[0, 19, 0, 4, 8, 20],
],
];
for (const [scrollTop, listSize, itemSize, noteCount, flow, expected] of testCases) {
const { result } = renderHook(() => useVisibleRange(
scrollTop,
listSize,
itemSize,
noteCount,
flow
));
expect(result.current).toEqual(expected);
}
});
});

View File

@@ -0,0 +1,57 @@
import { Size } from '@joplin/utils/types';
import { useMemo } from 'react';
const useVisibleRange = (itemsPerLine: number, scrollTop: number, listSize: Size, itemSize: Size, noteCount: number) => {
const startLineIndexFloat = useMemo(() => {
return scrollTop / itemSize.height;
}, [scrollTop, itemSize.height]);
const endLineIndexFloat = useMemo(() => {
return startLineIndexFloat + (listSize.height / itemSize.height);
}, [startLineIndexFloat, listSize.height, itemSize.height]);
const startLineIndex = useMemo(() => {
return Math.floor(startLineIndexFloat);
}, [startLineIndexFloat]);
const endLineIndex = useMemo(() => {
return Math.floor(endLineIndexFloat);
}, [endLineIndexFloat]);
const visibleLineCount = useMemo(() => {
return endLineIndex - startLineIndex + 1;
}, [endLineIndex, startLineIndex]);
const visibleItemCount = useMemo(() => {
return visibleLineCount * itemsPerLine;
}, [visibleLineCount, itemsPerLine]);
const startNoteIndex = useMemo(() => {
return itemsPerLine * startLineIndex;
}, [itemsPerLine, startLineIndex]);
const endNoteIndex = useMemo(() => {
let output = (endLineIndex + 1) * itemsPerLine - 1;
if (output >= noteCount) output = noteCount - 1;
return output;
}, [endLineIndex, itemsPerLine, noteCount]);
const totalLineCount = useMemo(() => {
return Math.ceil(noteCount / itemsPerLine);
}, [noteCount, itemsPerLine]);
// console.info('itemsPerLine', itemsPerLine);
// console.info('startLineIndexFloat', startLineIndexFloat);
// console.info('endLineIndexFloat', endLineIndexFloat);
// console.info('visibleLineCount', visibleLineCount);
// console.info('startNoteIndex', startNoteIndex);
// console.info('endNoteIndex', endNoteIndex);
// console.info('startLineIndex', startLineIndex);
// console.info('endLineIndex', endLineIndex);
// console.info('totalLineCount', totalLineCount);
// console.info('visibleItemCount', visibleItemCount);
return [startNoteIndex, endNoteIndex, startLineIndex, endLineIndex, totalLineCount, visibleItemCount];
};
export default useVisibleRange;

View File

@@ -0,0 +1,143 @@
import * as React from 'react';
import { useCallback, forwardRef, LegacyRef, ChangeEvent, CSSProperties, MouseEventHandler, DragEventHandler, useMemo, memo } from 'react';
import { ItemFlow, OnChangeEvent, OnChangeHandler } from '../NoteList/utils/types';
import { Size } from '@joplin/utils/types';
import useRootElement from './utils/useRootElement';
import useItemElement from './utils/useItemElement';
import useItemEventHandlers from './utils/useItemEventHandlers';
import { OnCheckboxChange } from './utils/types';
import Note from '@joplin/lib/models/Note';
interface NoteItemProps {
dragIndex: number;
flow: ItemFlow;
highlightedWords: string[];
index: number;
isProvisional: boolean;
itemSize: Size;
noteCount: number;
noteHtml: string;
noteId: string;
onChange: OnChangeHandler;
onClick: MouseEventHandler<HTMLDivElement>;
onContextMenu: MouseEventHandler;
onDragOver: DragEventHandler;
onDragStart: DragEventHandler;
style: CSSProperties;
}
const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
const elementId = `list-note-${props.noteId}`;
const onCheckboxChange: OnCheckboxChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
const changeEvent: OnChangeEvent = {
noteId: props.noteId,
elementId: event.currentTarget.getAttribute('data-id'),
value: event.currentTarget.checked,
};
if (changeEvent.elementId === 'todo-checkbox') {
await Note.save({
id: changeEvent.noteId,
todo_completed: changeEvent.value ? Date.now() : 0,
}, { userSideValidation: true });
} else {
if (props.onChange) await props.onChange(changeEvent);
}
}, [props.onChange, props.noteId]);
const rootElement = useRootElement(elementId);
const itemElement = useItemElement(
rootElement,
props.noteId,
props.noteHtml,
props.style,
props.itemSize,
props.onClick,
props.flow
);
useItemEventHandlers(rootElement, itemElement, onCheckboxChange);
const className = useMemo(() => {
return [
'note-list-item-wrapper',
// This is not used by the app, but kept here because it may be used
// by users for custom CSS.
(props.index + 1) % 2 === 0 ? 'even' : 'odd',
props.isProvisional && '-provisional',
].filter(e => !!e).join(' ');
}, [props.index, props.isProvisional]);
const isActiveDragItem = props.dragIndex === props.index;
const isLastActiveDragItem = props.index === props.noteCount - 1 && props.dragIndex >= props.noteCount;
const dragCursorStyle = useMemo(() => {
if (props.flow === ItemFlow.TopToBottom) {
let dragItemPosition = '';
if (isActiveDragItem) {
dragItemPosition = 'top';
} else if (isLastActiveDragItem) {
dragItemPosition = 'bottom';
}
const output: React.CSSProperties = {
width: props.itemSize.width,
display: dragItemPosition ? 'block' : 'none',
left: 0,
};
if (dragItemPosition === 'top') {
output.top = 0;
} else {
output.bottom = 0;
}
return output;
}
if (props.flow === ItemFlow.LeftToRight) {
let dragItemPosition = '';
if (isActiveDragItem) {
dragItemPosition = 'left';
} else if (isLastActiveDragItem) {
dragItemPosition = 'right';
}
const output: React.CSSProperties = {
height: props.itemSize.height,
display: dragItemPosition ? 'block' : 'none',
top: 0,
};
if (dragItemPosition === 'left') {
output.left = 0;
} else {
output.right = 0;
}
return output;
}
throw new Error('Unreachable');
}, [isActiveDragItem, isLastActiveDragItem, props.flow, props.itemSize]);
return <div
id={elementId}
ref={ref}
draggable={true}
tabIndex={0}
className={className}
data-id={props.noteId}
onContextMenu={props.onContextMenu}
onDragStart={props.onDragStart}
onDragOver={props.onDragOver}
>
<div className="dragcursor" style={dragCursorStyle}></div>
</div>;
};
export default memo(forwardRef(NoteListItem));

View File

@@ -0,0 +1,3 @@
import * as React from 'react';
export type OnCheckboxChange = (event: React.ChangeEvent<HTMLInputElement>)=> void;

View File

@@ -0,0 +1,35 @@
import * as React from 'react';
import { Size } from '@joplin/utils/types';
import { useEffect, useState } from 'react';
import { ItemFlow } from '../../NoteList/utils/types';
const useItemElement = (rootElement: HTMLDivElement, noteId: string, noteHtml: string, style: any, itemSize: Size, onClick: React.MouseEventHandler<HTMLDivElement>, flow: ItemFlow) => {
const [itemElement, setItemElement] = useState<HTMLDivElement>(null);
useEffect(() => {
if (!rootElement) return () => {};
const element = document.createElement('div');
element.setAttribute('data-id', noteId);
element.className = 'note-list-item';
for (const [n, v] of Object.entries(style)) {
(element.style as any)[n] = v;
}
if (flow === ItemFlow.LeftToRight) element.style.width = `${itemSize.width}px`;
element.style.height = `${itemSize.height}px`;
element.innerHTML = noteHtml;
element.addEventListener('click', onClick as any);
rootElement.appendChild(element);
setItemElement(element);
return () => {
element.remove();
};
}, [rootElement, itemSize, noteHtml, noteId, style, onClick, flow]);
return itemElement;
};
export default useItemElement;

View File

@@ -0,0 +1,27 @@
import { OnCheckboxChange } from './types';
import { useEffect } from 'react';
const useItemEventHandlers = (rootElement: HTMLDivElement, itemElement: HTMLDivElement, onCheckboxChange: OnCheckboxChange) => {
useEffect(() => {
if (!itemElement) return () => {};
const inputs = itemElement.getElementsByTagName('input');
const mods: HTMLInputElement[] = [];
for (const input of inputs) {
if (input.type === 'checkbox') {
input.addEventListener('change', onCheckboxChange as any);
mods.push(input);
}
}
return () => {
for (const input of mods) {
input.removeEventListener('change', onCheckboxChange as any);
}
};
}, [itemElement, rootElement, onCheckboxChange]);
};
export default useItemEventHandlers;

View File

@@ -0,0 +1,44 @@
import Folder from '@joplin/lib/models/Folder';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { useCallback } from 'react';
import { Dispatch } from 'redux';
import bridge from '../../../services/bridge';
import NoteListUtils from '../../utils/NoteListUtils';
const useOnContextMenu = (
selectedNoteIds: string[],
selectedFolderId: string,
notes: NoteEntity[],
dispatch: Dispatch,
watchedNoteFiles: string[],
plugins: PluginStates,
customCss: string
) => {
return useCallback((event: any) => {
const currentNoteId = event.currentTarget.getAttribute('data-id');
if (!currentNoteId) return;
let noteIds = [];
if (selectedNoteIds.indexOf(currentNoteId) < 0) {
noteIds = [currentNoteId];
} else {
noteIds = selectedNoteIds;
}
if (!noteIds.length) return;
const menu = NoteListUtils.makeContextMenu(noteIds, {
notes: notes,
dispatch: dispatch,
watchedNoteFiles: watchedNoteFiles,
plugins: plugins,
inConflictFolder: selectedFolderId === Folder.conflictFolderId(),
customCss: customCss,
});
menu.popup({ window: bridge().window() });
}, [selectedNoteIds, notes, dispatch, watchedNoteFiles, plugins, selectedFolderId, customCss]);
};
export default useOnContextMenu;

View File

@@ -0,0 +1,17 @@
import { useState } from 'react';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { waitForElement } from '@joplin/lib/dom';
const useRootElement = (elementId: string) => {
const [rootElement, setRootElement] = useState<HTMLDivElement>(null);
useAsyncEffect(async (event) => {
const element = await waitForElement(document, elementId);
if (event.cancelled) return;
setRootElement(element);
}, [document, elementId]);
return rootElement;
};
export default useRootElement;

View File

@@ -1,7 +1,8 @@
import { themeStyle } from '@joplin/lib/theme';
import * as React from 'react';
import { useMemo, useState } from 'react';
import NoteList from '../NoteList/NoteList';
// import NoteList from '../NoteList/NoteList';
import NoteList2 from '../NoteList/NoteList2';
import NoteListControls from '../NoteListControls/NoteListControls';
import { Size } from '../ResizableLayout/utils/types';
import styled from 'styled-components';
@@ -39,10 +40,12 @@ export default function NoteListWrapper(props: Props) {
};
}, [props.size, controlHeight]);
// <NoteList resizableLayoutEventEmitter={props.resizableLayoutEventEmitter} size={noteListSize} visible={props.visible}/>
return (
<StyledRoot>
<NoteListControls height={controlHeight} width={noteListSize.width} onContentHeightChange={onContentHeightChange}/>
<NoteList resizableLayoutEventEmitter={props.resizableLayoutEventEmitter} size={noteListSize} visible={props.visible}/>
<NoteList2 resizableLayoutEventEmitter={props.resizableLayoutEventEmitter} size={noteListSize} visible={props.visible}/>
</StyledRoot>
);
}

View File

@@ -11,7 +11,7 @@
import { useEffect, useState } from 'react';
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
import themeToCss from '@joplin/lib/services/style/themeToCss';
import { addExtraStyles, themeById } from '@joplin/lib/theme';
import { themeStyle } from '@joplin/lib/theme';
interface Props {
themeId: any;
@@ -21,7 +21,7 @@ export default function(props: Props): any {
const [styleSheetContent, setStyleSheetContent] = useState('');
useAsyncEffect(async (event: AsyncEffectEvent) => {
const theme = addExtraStyles(themeById(props.themeId));
const theme = themeStyle(props.themeId);
const themeCss = themeToCss(theme);
if (event.cancelled) return;
setStyleSheetContent(themeCss);

View File

@@ -7,19 +7,19 @@ import InteropServiceHelper from '../../InteropServiceHelper';
import { _ } from '@joplin/lib/locale';
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import bridge from '../../services/bridge';
import BaseModel from '@joplin/lib/BaseModel';
const bridge = require('@electron/remote').require('./bridge').default;
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
const { clipboard } = require('electron');
import { Dispatch } from 'redux';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
interface ContextMenuProps {
notes: any[];
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
dispatch: Function;
dispatch: Dispatch;
watchedNoteFiles: string[];
plugins: PluginStates;
inConflictFolder: boolean;
@@ -45,26 +45,26 @@ export default class NoteListUtils {
if (!hasEncrypted) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('setTags', noteIds))
new MenuItem(menuUtils.commandToStatefulMenuItem('setTags', noteIds) as any)
);
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('moveToFolder', noteIds))
new MenuItem(menuUtils.commandToStatefulMenuItem('moveToFolder', noteIds) as any)
);
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('duplicateNote', noteIds))
new MenuItem(menuUtils.commandToStatefulMenuItem('duplicateNote', noteIds) as any)
);
if (singleNoteId) {
const cmd = props.watchedNoteFiles.includes(singleNoteId) ? 'stopExternalEditing' : 'startExternalEditing';
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem(cmd, singleNoteId)));
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem(cmd, singleNoteId) as any));
}
if (noteIds.length <= 1) {
menu.append(
new MenuItem(
menuUtils.commandToStatefulMenuItem('toggleNoteType', noteIds)
menuUtils.commandToStatefulMenuItem('toggleNoteType', noteIds) as any
)
);
} else {
@@ -125,7 +125,7 @@ export default class NoteListUtils {
if ([9, 10].includes(Setting.value('sync.target'))) {
menu.append(
new MenuItem(
menuUtils.commandToStatefulMenuItem('showShareNoteDialog', noteIds.slice())
menuUtils.commandToStatefulMenuItem('showShareNoteDialog', noteIds.slice()) as any
)
);
}
@@ -156,7 +156,7 @@ export default class NoteListUtils {
exportMenu.append(
new MenuItem(
menuUtils.commandToStatefulMenuItem('exportPdf', noteIds)
menuUtils.commandToStatefulMenuItem('exportPdf', noteIds) as any
)
);
@@ -167,7 +167,7 @@ export default class NoteListUtils {
menu.append(
new MenuItem(
menuUtils.commandToStatefulMenuItem('deleteNote', noteIds)
menuUtils.commandToStatefulMenuItem('deleteNote', noteIds) as any
)
);
@@ -179,7 +179,7 @@ export default class NoteListUtils {
if (cmdService.isEnabled(info.view.commandName)) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem(info.view.commandName, noteIds))
new MenuItem(menuUtils.commandToStatefulMenuItem(info.view.commandName, noteIds) as any)
);
}
}

View File

@@ -1,19 +1,14 @@
/* eslint-disable jest/require-top-level-describe */
const { default: Logger, TargetType } = require('@joplin/utils/Logger');
const initLib = require('@joplin/lib/initLib').default;
// TODO: Some libraries required by test-utils.js seem to fail to import with the
// jsdom environment.
//
// Thus, require('@joplin/lib/testing/test-utils.js') fails and some setup must be
// copied.
const logger = new Logger();
logger.addTarget(TargetType.Console);
logger.setLevel(Logger.LEVEL_WARN);
Logger.initializeGlobalLogger(logger);
initLib(logger);
const { shimInit } = require('@joplin/lib/shim-init-node');
const sqlite3 = require('sqlite3');
const SyncTargetNone = require('@joplin/lib/SyncTargetNone').default;
// Mock the S3 sync target -- the @aws-s3 libraries depend on an old version
// of uuid that doesn't work with jest without additional configuration.
jest.doMock('@joplin/lib/SyncTargetAmazonS3', () => {
return SyncTargetNone;
});
// @electron/remote requires electron to be running. Mock it.
jest.mock('@electron/remote', () => {
@@ -25,3 +20,18 @@ jest.mock('@electron/remote', () => {
},
};
});
// Import after mocking problematic libraries
const { afterEachCleanUp, afterAllCleanUp } = require('@joplin/lib/testing/test-utils.js');
shimInit({ nodeSqlite: sqlite3 });
afterEach(async () => {
await afterEachCleanUp();
});
afterAll(async () => {
await afterAllCleanUp();
});

View File

@@ -139,6 +139,7 @@
"@joplin/lib": "~2.12",
"@joplin/renderer": "~2.12",
"@joplin/utils": "~2.12",
"@types/mustache": "4.2.2",
"async-mutex": "0.4.0",
"codemirror": "5.65.9",
"color": "3.2.1",
@@ -154,6 +155,7 @@
"mark.js": "8.11.1",
"md5": "2.3.0",
"moment": "2.29.4",
"mustache": "4.2.0",
"node-fetch": "2.6.7",
"node-notifier": "10.0.1",
"node-rsa": "1.1.1",
@@ -162,7 +164,7 @@
"react": "18.2.0",
"react-datetime": "3.2.0",
"react-dom": "18.2.0",
"react-redux": "8.1.1",
"react-redux": "8.1.2",
"react-select": "5.7.4",
"react-toggle-button": "2.2.0",
"react-tooltip": "4.5.1",

View File

@@ -5,4 +5,5 @@
@use 'gui/JoplinCloudConfigScreen.scss' as joplin-cloud-config-screen;
@use 'gui/Dropdown/style.scss' as dropdown-control;
@use 'gui/ShareFolderDialog/style.scss' as share-folder-dialog;
@use 'gui/NoteList/style.scss' as note-list;
@use 'main.scss' as main;

View File

@@ -110,8 +110,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097717
versionName "2.12.0"
versionCode 2097718
versionName "2.12.1"
// ndk {
// abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
// }

View File

@@ -1,6 +1,6 @@
const React = require('react');
import { ReactElement, useCallback, useState } from 'react';
import { ReactElement, useCallback, useMemo, useState } from 'react';
import { LayoutChangeEvent, ScrollView, View, ViewStyle } from 'react-native';
import ToggleOverflowButton from './ToggleOverflowButton';
import ToolbarButton, { buttonSize } from './ToolbarButton';
@@ -18,19 +18,22 @@ const Toolbar = (props: ToolbarProps) => {
const [overflowButtonsVisible, setOverflowPopupVisible] = useState(false);
const [maxButtonsEachSide, setMaxButtonsEachSide] = useState(0);
const allButtonSpecs = props.buttons.reduce((accumulator: ButtonSpec[], current: ButtonGroup) => {
const newItems: ButtonSpec[] = [];
for (const item of current.items) {
if (item.visible ?? true) {
newItems.push(item);
const allButtonSpecs = useMemo(() => {
const buttons = props.buttons.reduce((accumulator: ButtonSpec[], current: ButtonGroup) => {
const newItems: ButtonSpec[] = [];
for (const item of current.items) {
if (item.visible ?? true) {
newItems.push(item);
}
}
}
return accumulator.concat(...newItems);
}, []);
return accumulator.concat(...newItems);
}, []);
// Sort from highest priority to lowest
allButtonSpecs.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
// Sort from highest priority to lowest
buttons.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
return buttons;
}, [props.buttons]);
const allButtonComponents: ReactElement[] = [];
let key = 0;
@@ -67,7 +70,9 @@ const Toolbar = (props: ToolbarProps) => {
);
const mainButtons: ReactElement[] = [];
if (maxButtonsEachSide < allButtonComponents.length) {
if (maxButtonsEachSide >= allButtonComponents.length) {
mainButtons.push(...allButtonComponents);
} else if (maxButtonsEachSide > 0) {
// We want the menu to look something like this:
// B I (…) 🔍 ⌨
// where (…) shows/hides overflow.
@@ -77,7 +82,7 @@ const Toolbar = (props: ToolbarProps) => {
mainButtons.push(toggleOverflowButton);
mainButtons.push(...allButtonComponents.slice(-maxButtonsEachSide));
} else {
mainButtons.push(...allButtonComponents);
mainButtons.push(toggleOverflowButton);
}
const styles = props.styleSheet.styles;

View File

@@ -0,0 +1,72 @@
import * as React from 'react';
import { describe, it, expect, beforeEach } from '@jest/globals';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native';
import '@testing-library/jest-native';
import NoteEditor from './NoteEditor';
import Setting from '@joplin/lib/models/Setting';
import { _ } from '@joplin/lib/locale';
import { MenuProvider } from 'react-native-popup-menu';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
describe('NoteEditor', () => {
beforeEach(async () => {
// Required to use ExtendedWebView
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
});
it('should hide the markdown toolbar when the window is small', async () => {
const wrappedNoteEditor = render(
<MenuProvider>
<NoteEditor
themeId={Setting.THEME_ARITIM_DARK}
initialText='Testing...'
style={{}}
toolbarEnabled={true}
readOnly={false}
onChange={()=>{}}
onSelectionChange={()=>{}}
onUndoRedoDepthChange={()=>{}}
onAttach={()=>{}}
/>
</MenuProvider>
);
// Maps from screen height to whether the markdown toolbar should be visible.
const testCases: [number, boolean][] = [
[10, false],
[1000, true],
[100, false],
[80, false],
[600, true],
];
const noteEditorRoot = await wrappedNoteEditor.findByTestId('note-editor-root');
const setRootHeight = (height: number) => {
act(() => {
// See https://stackoverflow.com/a/61774123
fireEvent(noteEditorRoot, 'layout', {
nativeEvent: {
layout: { height },
},
});
});
};
for (const [height, visible] of testCases) {
setRootHeight(height);
await waitFor(async () => {
const showMoreButton = await screen.queryByLabelText(_('Show more actions'));
if (visible) {
expect(showMoreButton).not.toBeNull();
} else {
expect(showMoreButton).toBeNull();
}
});
}
});
});

View File

@@ -8,7 +8,7 @@ import ExtendedWebView from '../ExtendedWebView';
const React = require('react');
import { forwardRef, RefObject, useImperativeHandle } from 'react';
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
import { View, ViewStyle } from 'react-native';
import { LayoutChangeEvent, View, ViewStyle } from 'react-native';
const { editorFont } = require('../global-style');
import SelectionFormatting from './SelectionFormatting';
@@ -368,6 +368,19 @@ function NoteEditor(props: Props, ref: any) {
console.error('NoteEditor: webview error');
}, []);
const [hasSpaceForToolbar, setHasSpaceForToolbar] = useState(true);
const toolbarEnabled = props.toolbarEnabled && hasSpaceForToolbar;
const onContainerLayout = useCallback((event: LayoutChangeEvent) => {
const containerHeight = event.nativeEvent.layout.height;
if (containerHeight < 140) {
setHasSpaceForToolbar(false);
} else {
setHasSpaceForToolbar(true);
}
}, []);
const toolbar = <MarkdownToolbar
style={{
// Don't show the markdown toolbar if there isn't enough space
@@ -385,10 +398,14 @@ function NoteEditor(props: Props, ref: any) {
// - `scrollEnabled` prevents iOS from scrolling the document (has no effect on Android)
// when an editable region (e.g. a the full-screen NoteEditor) is focused.
return (
<View style={{
...props.style,
flexDirection: 'column',
}}>
<View
testID='note-editor-root'
onLayout={onContainerLayout}
style={{
...props.style,
flexDirection: 'column',
}}
>
<EditLinkDialog
visible={linkDialogVisible}
themeId={props.themeId}
@@ -419,7 +436,7 @@ function NoteEditor(props: Props, ref: any) {
searchState={searchState}
/>
{props.toolbarEnabled ? toolbar : null}
{toolbarEnabled ? toolbar : null}
</View>
);
}

View File

@@ -36,7 +36,7 @@ const { BaseScreenComponent } = require('../base-screen.js');
const { themeStyle, editorFont } = require('../global-style.js');
const { dialogs } = require('../../utils/dialogs.js');
const DialogBox = require('react-native-dialogbox').default;
const ImageResizer = require('react-native-image-resizer').default;
import ImageResizer from '@bam.tech/react-native-image-resizer';
import shared from '@joplin/lib/components/shared/note-screen-shared';
import { ImagePickerResponse, launchImageLibrary } from 'react-native-image-picker';
import SelectDateTimeDialog from '../SelectDateTimeDialog';
@@ -586,7 +586,16 @@ class NoteScreenComponent extends BaseScreenComponent {
const format = mimeType === 'image/png' ? 'PNG' : 'JPEG';
reg.logger().info(`Resizing image ${localFilePath}`);
const resizedImage = await ImageResizer.createResizedImage(localFilePath, dimensions.width, dimensions.height, format, 85); // , 0, targetPath);
const resizedImage = await ImageResizer.createResizedImage(
localFilePath,
dimensions.width,
dimensions.height,
format,
85, // quality
undefined, // rotation
undefined, // outputPath
true // keep metadata
);
const resizedImagePath = resizedImage.uri;
reg.logger().info('Resized image ', resizedImagePath);

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import { useState, useEffect, useCallback } from 'react';
import { Banner, ActivityIndicator, Modal } from 'react-native-paper';
import { Banner, ActivityIndicator } from 'react-native-paper';
import { _, languageName } from '@joplin/lib/locale';
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
import { getVosk, Recorder, startRecording, Vosk } from '../../services/voiceTyping/vosk';
@@ -107,18 +107,16 @@ export default (props: Props) => {
};
return (
<Modal visible={true} style={{ display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
<Banner
visible={true}
icon={renderIcon()}
actions={[
{
label: _('Done'),
onPress: onDismiss,
},
]}>
{`${_('Voice typing...')}\n${renderContent()}`}
</Banner>
</Modal>
<Banner
visible={true}
icon={renderIcon()}
actions={[
{
label: _('Done'),
onPress: onDismiss,
},
]}>
{`${_('Voice typing...')}\n${renderContent()}`}
</Banner>
);
};

View File

@@ -353,7 +353,7 @@ PODS:
- React-Core
- react-native-image-picker (5.6.0):
- React-Core
- react-native-image-resizer (1.4.5):
- react-native-image-resizer (3.0.5):
- React-Core
- react-native-netinfo (9.4.1):
- React-Core
@@ -361,12 +361,8 @@ PODS:
- React
- react-native-saf-x (2.12.0):
- React-Core
- react-native-safe-area-context (4.6.4):
- RCT-Folly
- RCTRequired
- RCTTypeSafety
- react-native-safe-area-context (4.7.1):
- React-Core
- ReactCommon/turbomodule/core
- react-native-slider (4.4.2):
- React-Core
- react-native-sqlite-storage (6.0.1):
@@ -465,7 +461,7 @@ PODS:
- React-Core
- RNCPushNotificationIOS (1.11.0):
- React-Core
- RNDateTimePicker (7.3.0):
- RNDateTimePicker (7.4.1):
- React-Core
- RNDeviceInfo (10.7.0):
- React-Core
@@ -475,7 +471,7 @@ PODS:
- React-Core
- RNFS (2.20.0):
- React-Core
- RNGestureHandler (2.12.0):
- RNGestureHandler (2.12.1):
- React-Core
- RNLocalize (3.0.2):
- React-Core
@@ -584,7 +580,7 @@ DEPENDENCIES:
- "react-native-geolocation (from `../node_modules/@react-native-community/geolocation`)"
- react-native-get-random-values (from `../node_modules/react-native-get-random-values`)
- react-native-image-picker (from `../node_modules/react-native-image-picker`)
- react-native-image-resizer (from `../node_modules/react-native-image-resizer`)
- "react-native-image-resizer (from `../node_modules/@bam.tech/react-native-image-resizer`)"
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-rsa-native (from `../node_modules/react-native-rsa-native`)
- "react-native-saf-x (from `../node_modules/@joplin/react-native-saf-x`)"
@@ -703,7 +699,7 @@ EXTERNAL SOURCES:
react-native-image-picker:
:path: "../node_modules/react-native-image-picker"
react-native-image-resizer:
:path: "../node_modules/react-native-image-resizer"
:path: "../node_modules/@bam.tech/react-native-image-resizer"
react-native-netinfo:
:path: "../node_modules/@react-native-community/netinfo"
react-native-rsa-native:
@@ -824,11 +820,11 @@ SPEC CHECKSUMS:
react-native-geolocation: 0f7fe8a4c2de477e278b0365cce27d089a8c5903
react-native-get-random-values: dee677497c6a740b71e5612e8dbd83e7539ed5bb
react-native-image-picker: db60857e03d63721f19b6f4027de20429ddd9cba
react-native-image-resizer: d9fb629a867335bdc13230ac2a58702bb8c8828f
react-native-image-resizer: 00ceb0e05586c7aadf061eea676957a6c2ec60fa
react-native-netinfo: fefd4e98d75cbdd6e85fc530f7111a8afdf2b0c5
react-native-rsa-native: 12132eb627797529fdb1f0d22fd0f8f9678df64a
react-native-saf-x: 129cd2ddf120a1f6164c724b2846d172666b33de
react-native-safe-area-context: 68b07eabfb0d14547d36f6929c0e98d818064f02
react-native-safe-area-context: 9697629f7b2cda43cf52169bb7e0767d330648c2
react-native-slider: 33b8d190b59d4f67a541061bb91775d53d617d9d
react-native-sqlite-storage: f6d515e1c446d1e6d026aa5352908a25d4de3261
react-native-version-info: a106f23009ac0db4ee00de39574eb546682579b9
@@ -849,12 +845,12 @@ SPEC CHECKSUMS:
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
RNCClipboard: 41d8d918092ae8e676f18adada19104fa3e68495
RNCPushNotificationIOS: 64218f3c776c03d7408284a819b2abfda1834bc8
RNDateTimePicker: 01e6d27ba2e0931cd05049c5bff6171c3c027ea8
RNDateTimePicker: 9b4091348e53f540180abdc54984d839a556f593
RNDeviceInfo: 25d818c85db769cc0e7083d39efaa01a6f450df3
RNExitApp: c4e052df2568b43bec8a37c7cd61194d4cfee2c3
RNFileViewer: ce7ca3ac370e18554d35d6355cffd7c30437c592
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNGestureHandler: dec4645026e7401a0899f2846d864403478ff6a5
RNGestureHandler: c0d04458598fcb26052494ae23dda8f8f5162b13
RNLocalize: dbea38dcb344bf80ff18a1757b1becf11f70cae4
RNQuickAction: 6d404a869dc872cde841ad3147416a670d13fa93
RNReanimated: 9976fbaaeb8a188c36026154c844bf374b3b7eeb

View File

@@ -33,6 +33,21 @@ document.createRange = () => {
shimInit({ nodeSqlite: sqlite3 });
// This library has the following error when running within Jest:
// Invariant Violation: `new NativeEventEmitter()` requires a non-null argument.
jest.mock('react-native-device-info', () => {
return {
hasNotch: () => false,
};
});
// react-native-webview expects native iOS/Android code so needs to be mocked.
jest.mock('react-native-webview', () => {
const { View } = require('react-native');
return {
WebView: View,
};
});
// react-native-fs's CachesDirectoryPath export doesn't work in a testing environment.
// Use a temporary folder instead.

View File

@@ -18,6 +18,7 @@
"postinstall": "jetify && yarn run build"
},
"dependencies": {
"@bam.tech/react-native-image-resizer": "3.0.5",
"@joplin/lib": "~2.12",
"@joplin/react-native-alarm-notification": "~2.12",
"@joplin/react-native-saf-x": "~2.12",
@@ -53,10 +54,9 @@
"react-native-file-viewer": "2.1.5",
"react-native-fingerprint-scanner": "6.0.0",
"react-native-fs": "2.20.0",
"react-native-gesture-handler": "2.12.0",
"react-native-gesture-handler": "2.12.1",
"react-native-get-random-values": "1.9.0",
"react-native-image-picker": "5.6.0",
"react-native-image-resizer": "1.4.5",
"react-native-localize": "3.0.2",
"react-native-modal-datetime-picker": "15.0.1",
"react-native-paper": "5.9.1",
@@ -64,7 +64,7 @@
"react-native-quick-actions": "0.3.13",
"react-native-reanimated": "3.3.0",
"react-native-rsa-native": "2.0.5",
"react-native-safe-area-context": "4.6.4",
"react-native-safe-area-context": "4.7.1",
"react-native-securerandom": "1.0.1",
"react-native-share": "8.2.2",
"react-native-sqlite-storage": "6.0.1",
@@ -74,7 +74,7 @@
"react-native-vosk": "0.1.12",
"react-native-webview": "12.4.0",
"react-native-zip-archive": "6.0.9",
"react-redux": "8.1.1",
"react-redux": "8.1.2",
"redux": "4.2.1",
"rn-fetch-blob": "0.12.0",
"stream": "0.0.2",

View File

@@ -16,7 +16,7 @@
],
"devDependencies": {
"standard": "17.1.0",
"tap": "16.3.7"
"tap": "16.3.8"
},
"gitHead": "eb4b0e64eab40a51b0895d3a40a9d8c3cb7b1b14"
}

View File

@@ -1,5 +1,3 @@
/* eslint-disable import/prefer-default-export */
export const isInsideContainer = (node: any, className: string): boolean => {
while (node) {
if (node.classList && node.classList.contains(className)) return true;
@@ -7,3 +5,49 @@ export const isInsideContainer = (node: any, className: string): boolean => {
}
return false;
};
export const waitForElement = async (parent: any, id: string): Promise<any> => {
return new Promise((resolve, reject) => {
const iid = setInterval(() => {
try {
const element = parent.getElementById(id);
if (element) {
clearInterval(iid);
resolve(element);
}
} catch (error) {
clearInterval(iid);
reject(error);
}
}, 10);
});
};
// -----------------------------------------------------------------------
// Imported from https://github.com/Moh-Snoussi/keyboard-event-key-type
// -----------------------------------------------------------------------
type NumericKeypadKeys = 'Decimal' | 'Key11' | 'Key12' | 'Multiply' | 'Add' | 'Clear' | 'Divide' | 'Subtract' | 'Separator' | '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';
type UpperAlpha = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z';
type LowerAlpha = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z';
type ModifierKeys = 'Alt' | 'AltGraph' | 'CapsLock' | 'Control' | 'Fn' | 'FnLock' | 'Hyper' | 'Meta' | 'NumLock' | 'ScrollLock' | 'Shift' | 'Super' | 'Symbol' | 'SymbolLock';
type WhitespaceKeys = 'Enter' | 'Tab' | ' ';
type NavigationKeys = 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'End' | 'Home' | 'PageDown' | 'PageUp';
type EditingKeys = 'Backspace' | 'Clear' | 'Copy' | 'CrSel' | 'Cut' | 'Delete' | 'EraseEof' | 'ExSel' | 'Insert' | 'Paste' | 'Redo' | 'Undo';
type UIKeys = 'Accept' | 'Again' | 'Attn' | 'Cancel' | 'ContextMenu' | 'Escape' | 'Execute' | 'Find' | 'Finish' | 'Help' | 'Pause' | 'Play' | 'Props' | 'Select' | 'ZoomIn' | 'ZoomOut';
type DeviceKeys = 'BrightnessDown' | 'BrightnessUp' | 'Eject' | 'LogOff' | 'Power' | 'PowerOff' | 'PrintScreen' | 'Hibernate' | 'Standby' | 'WakeUp';
type IMECompositionKeys = 'AllCandidates' | 'Alphanumeric' | 'CodeInput' | 'Compose' | 'Convert' | 'Dead' | 'FinalMode' | 'GroupFirst' | 'GroupLast' | 'GroupNext' | 'GroupPrevious' | 'ModeChange' | 'NextCandidate' | 'NonConvert' | 'PreviousCandidate' | 'Process' | 'SingleCandidate';
type LinuxDeadKeys = 'GDK_KEY_dead_grave' | 'GDK_KEY_dead_acute' | 'GDK_KEY_dead_circumflex' | 'GDK_KEY_dead_tilde' | 'GDK_KEY_dead_perispomeni' | 'GDK_KEY_dead_macron' | 'GDK_KEY_dead_breve' | 'GDK_KEY_dead_abovedot' | 'GDK_KEY_dead_diaeresis' | 'GDK_KEY_dead_abovering' | 'GDK_KEY_dead_doubleacute' | 'GDK_KEY_dead_caron' | 'GDK_KEY_dead_cedilla' | 'GDK_KEY_dead_ogonek' | 'GDK_KEY_dead_iota' | 'GDK_KEY_dead_voiced_sound' | 'GDK_KEY_dead_semivoiced_sound' | 'GDK_KEY_dead_belowdot' | 'GDK_KEY_dead_hook' | 'GDK_KEY_dead_horn' | 'GDK_KEY_dead_stroke' | 'GDK_KEY_dead_abovecomma' | 'GDK_KEY_dead_psili' | 'GDK_KEY_dead_abovereversedcomma' | 'GDK_KEY_dead_dasia' | 'GDK_KEY_dead_doublegrave' | 'GDK_KEY_dead_belowring' | 'GDK_KEY_dead_belowmacron' | 'GDK_KEY_dead_belowcircumflex' | 'GDK_KEY_dead_belowtilde' | 'GDK_KEY_dead_belowbreve' | 'GDK_KEY_dead_belowdiaeresis' | 'GDK_KEY_dead_invertedbreve' | 'GDK_KEY_dead_belowcomma' | 'GDK_KEY_dead_currency' | 'GDK_KEY_dead_a' | 'GDK_KEY_dead_A' | 'GDK_KEY_dead_e' | 'GDK_KEY_dead_E' | 'GDK_KEY_dead_i' | 'GDK_KEY_dead_I' | 'GDK_KEY_dead_o' | 'GDK_KEY_dead_O' | 'GDK_KEY_dead_u' | 'GDK_KEY_dead_U' | 'GDK_KEY_dead_small_schwa' | 'GDK_KEY_dead_capital_schwa' | 'GDK_KEY_dead_greek';
type FunctionKeys = 'F1' | 'F2' | 'F3' | 'F4' | 'F5' | 'F6' | 'F7' | 'F8' | 'F9' | 'F10' | 'F11' | 'F12' | 'F13' | 'F14' | 'F15' | 'F16' | 'F17' | 'F18' | 'F19' | 'F20' | 'Soft1' | 'Soft2' | 'Soft3' | 'Soft4';
type PhoneKeys = 'AppSwitch' | 'Call' | 'Camera' | 'CameraFocus' | 'EndCall' | 'GoBack' | 'GoHome' | 'HeadsetHook' | 'LastNumberRedial' | 'Notification' | 'MannerMode' | 'VoiceDial';
type MultimediaKeys = 'ChannelDown' | 'ChannelUp' | 'MediaFastForward' | 'MediaPause' | 'MediaPlay' | 'MediaPlayPause' | 'MediaRecord' | 'MediaRewind' | 'MediaStop' | 'MediaTrackNext' | 'MediaTrackPrevious';
type TVControlKeys = 'TV' | 'TV3DMode' | 'TVAntennaCable' | 'TVAudioDescription' | 'TVAudioDescriptionMixDown' | 'TVAudioDescriptionMixUp' | 'TVContentsMenu' | 'TVDataService' | 'TVInput' | 'TVInputComponent1' | 'TVInputComponent2' | 'TVInputComposite1' | 'TVInputComposite2' | 'TVInputHDMI1' | 'TVInputHDMI2' | 'TVInputHDMI3' | 'TVInputHDMI4' | 'TVInputVGA1' | 'TVMediaContext' | 'TVNetwork' | 'TVNumberEntry' | 'TVPower' | 'TVRadioService' | 'TVSatellite' | 'TVSatelliteBS' | 'TVSatelliteCS' | 'TVSatelliteToggle' | 'TVTerrestrialAnalog' | 'TVTerrestrialDigital' | 'TVTimer';
type MediaControllerKeys = 'AVRInput' | 'AVRPower' | 'ColorF0Red' | 'ColorF1Green' | 'ColorF2Yellow' | 'ColorF3Blue' | 'ColorF4Grey' | 'ColorF5Brown' | 'ClosedCaptionToggle' | 'Dimmer' | 'DisplaySwap' | 'DVR' | 'Exit' | 'FavoriteClear0' | 'FavoriteClear1' | 'FavoriteClear2' | 'FavoriteClear3' | 'FavoriteRecall0' | 'FavoriteRecall1' | 'FavoriteRecall2' | 'FavoriteRecall3' | 'FavoriteStore0' | 'FavoriteStore1' | 'FavoriteStore2' | 'FavoriteStore3' | 'Guide' | 'GuideNextDay' | 'GuidePreviousDay' | 'Info' | 'InstantReplay' | 'Link' | 'ListProgram' | 'LiveContent' | 'Lock' | 'MediaApps' | 'MediaAudioTrack' | 'MediaLast' | 'MediaSkipBackward' | 'MediaSkipForward' | 'MediaStepBackward' | 'MediaStepForward' | 'MediaTopMenu' | 'NavigateIn' | 'NavigateNext' | 'NavigateOut' | 'NavigatePrevious' | 'NextFavoriteChannel' | 'NextUserProfile' | 'OnDemand' | 'Pairing' | 'PinPDown' | 'PinPMove' | 'PinPToggle' | 'PinPUp' | 'PlaySpeedDown' | 'PlaySpeedReset' | 'PlaySpeedUp' | 'RandomToggle' | 'RcLowBattery' | 'RecordSpeedNext' | 'RfBypass' | 'ScanChannelsToggle' | 'ScreenModeNext' | 'Settings' | 'SplitScreenToggle' | 'STBInput' | 'STBPower' | 'Subtitle' | 'Teletext' | 'VideoModeNext' | 'Wink' | 'ZoomToggle';
type SpeechRecognitionKeys = 'SpeechCorrectionList' | 'SpeechInputToggle';
type DocumentKeys = 'Close' | 'New' | 'Open' | 'Print' | 'Save' | 'SpellCheck' | 'MailForward' | 'MailReply' | 'MailSend';
type ApplicationSelectorKeys = 'LaunchCalculator' | 'LaunchCalendar' | 'LaunchContacts' | 'LaunchMail' | 'LaunchMediaPlayer' | 'LaunchMusicPlayer' | 'LaunchMyComputer' | 'LaunchPhone' | 'LaunchScreenSaver' | 'LaunchSpreadsheet' | 'LaunchWebBrowser' | 'LaunchWebCam' | 'LaunchWordProcessor' | 'LaunchApplication1' | 'LaunchApplication2' | 'LaunchApplication3' | 'LaunchApplication4' | 'LaunchApplication5' | 'LaunchApplication6' | 'LaunchApplication7' | 'LaunchApplication8' | 'LaunchApplication9' | 'LaunchApplication10' | 'LaunchApplication11' | 'LaunchApplication12' | 'LaunchApplication13' | 'LaunchApplication14' | 'LaunchApplication15' | 'LaunchApplication16';
type BrowserControlKeys = 'BrowserBack' | 'BrowserFavorites' | 'BrowserForward' | 'BrowserHome' | 'BrowserRefresh' | 'BrowserSearch' | 'BrowserStop';
type KoreanKeyboardsOnly = 'HangulMode' | 'HanjaMode' | 'JunjaMode';
type SpecialValueKey = 'Unidentified';
export declare type KeyboardEventKey = SpecialValueKey | ModifierKeys | WhitespaceKeys | NavigationKeys | EditingKeys | UIKeys | DeviceKeys | IMECompositionKeys | LinuxDeadKeys | FunctionKeys | PhoneKeys | MultimediaKeys | TVControlKeys | MediaControllerKeys | SpeechRecognitionKeys | DocumentKeys | ApplicationSelectorKeys | BrowserControlKeys | NumericKeypadKeys | UpperAlpha | LowerAlpha | KoreanKeyboardsOnly;

View File

@@ -491,6 +491,7 @@ function changeSelectedNotes(draft: Draft<State>, action: any, options: any = nu
if (action.id) noteIds = [action.id];
if (action.ids) noteIds = action.ids;
if (action.noteId) noteIds = [action.noteId];
if (action.index) noteIds = [draft.notes[action.index].id];
if (action.type === 'NOTE_SELECT') {
if (JSON.stringify(draft.selectedNoteIds) === JSON.stringify(noteIds)) return;

View File

@@ -46,6 +46,34 @@ export interface UserDataValue {
export type UserData = Record<string, Record<string, UserDataValue>>;
interface DatabaseTableColumn {
type: string;
}
interface DatabaseTable {
[key: string]: DatabaseTableColumn;
}
interface DatabaseTables {
[key: string]: DatabaseTable;
}
@@ -300,3 +328,233 @@ export interface VersionEntity {
'version'?: number;
'type_'?: number;
}
export const databaseSchema: DatabaseTables = {
folders: {
created_time: { type: 'number' },
encryption_applied: { type: 'number' },
encryption_cipher_text: { type: 'string' },
icon: { type: 'string' },
id: { type: 'string' },
is_shared: { type: 'number' },
master_key_id: { type: 'string' },
parent_id: { type: 'string' },
share_id: { type: 'string' },
title: { type: 'string' },
updated_time: { type: 'number' },
user_created_time: { type: 'number' },
user_updated_time: { type: 'number' },
type_: { type: 'number' },
},
tags: {
created_time: { type: 'number' },
encryption_applied: { type: 'number' },
encryption_cipher_text: { type: 'string' },
id: { type: 'string' },
is_shared: { type: 'number' },
parent_id: { type: 'string' },
title: { type: 'string' },
updated_time: { type: 'number' },
user_created_time: { type: 'number' },
user_updated_time: { type: 'number' },
type_: { type: 'number' },
},
note_tags: {
created_time: { type: 'number' },
encryption_applied: { type: 'number' },
encryption_cipher_text: { type: 'string' },
id: { type: 'string' },
is_shared: { type: 'number' },
note_id: { type: 'string' },
tag_id: { type: 'string' },
updated_time: { type: 'number' },
user_created_time: { type: 'number' },
user_updated_time: { type: 'number' },
type_: { type: 'number' },
},
table_fields: {
field_default: { type: 'string' },
field_name: { type: 'string' },
field_type: { type: 'number' },
id: { type: 'number' },
table_name: { type: 'string' },
type_: { type: 'number' },
},
sync_items: {
force_sync: { type: 'number' },
id: { type: 'number' },
item_id: { type: 'string' },
item_location: { type: 'number' },
item_type: { type: 'number' },
sync_disabled: { type: 'number' },
sync_disabled_reason: { type: 'string' },
sync_target: { type: 'number' },
sync_time: { type: 'number' },
type_: { type: 'number' },
},
version: {
table_fields_version: { type: 'number' },
version: { type: 'number' },
type_: { type: 'number' },
},
deleted_items: {
deleted_time: { type: 'number' },
id: { type: 'number' },
item_id: { type: 'string' },
item_type: { type: 'number' },
sync_target: { type: 'number' },
type_: { type: 'number' },
},
settings: {
key: { type: 'string' },
value: { type: 'string' },
type_: { type: 'number' },
},
alarms: {
id: { type: 'number' },
note_id: { type: 'string' },
trigger_time: { type: 'number' },
type_: { type: 'number' },
},
item_changes: {
before_change_item: { type: 'string' },
created_time: { type: 'number' },
id: { type: 'number' },
item_id: { type: 'string' },
item_type: { type: 'number' },
source: { type: 'number' },
type: { type: 'number' },
type_: { type: 'number' },
},
note_resources: {
id: { type: 'number' },
is_associated: { type: 'number' },
last_seen_time: { type: 'number' },
note_id: { type: 'string' },
resource_id: { type: 'string' },
type_: { type: 'number' },
},
resource_local_states: {
fetch_error: { type: 'string' },
fetch_status: { type: 'number' },
id: { type: 'number' },
resource_id: { type: 'string' },
type_: { type: 'number' },
},
resources: {
created_time: { type: 'number' },
encryption_applied: { type: 'number' },
encryption_blob_encrypted: { type: 'number' },
encryption_cipher_text: { type: 'string' },
file_extension: { type: 'string' },
filename: { type: 'string' },
id: { type: 'string' },
is_shared: { type: 'number' },
master_key_id: { type: 'string' },
mime: { type: 'string' },
share_id: { type: 'string' },
size: { type: 'number' },
title: { type: 'string' },
updated_time: { type: 'number' },
user_created_time: { type: 'number' },
user_updated_time: { type: 'number' },
type_: { type: 'number' },
},
revisions: {
body_diff: { type: 'string' },
created_time: { type: 'number' },
encryption_applied: { type: 'number' },
encryption_cipher_text: { type: 'string' },
id: { type: 'string' },
item_id: { type: 'string' },
item_type: { type: 'number' },
item_updated_time: { type: 'number' },
metadata_diff: { type: 'string' },
parent_id: { type: 'string' },
title_diff: { type: 'string' },
updated_time: { type: 'number' },
type_: { type: 'number' },
},
migrations: {
created_time: { type: 'number' },
id: { type: 'number' },
number: { type: 'number' },
updated_time: { type: 'number' },
type_: { type: 'number' },
},
resources_to_download: {
created_time: { type: 'number' },
id: { type: 'number' },
resource_id: { type: 'string' },
updated_time: { type: 'number' },
type_: { type: 'number' },
},
key_values: {
id: { type: 'number' },
key: { type: 'string' },
type: { type: 'number' },
updated_time: { type: 'number' },
value: { type: 'string' },
type_: { type: 'number' },
},
notes: {
altitude: { type: 'number' },
application_data: { type: 'string' },
author: { type: 'string' },
body: { type: 'string' },
conflict_original_id: { type: 'string' },
created_time: { type: 'number' },
encryption_applied: { type: 'number' },
encryption_cipher_text: { type: 'string' },
id: { type: 'string' },
is_conflict: { type: 'number' },
is_shared: { type: 'number' },
is_todo: { type: 'number' },
latitude: { type: 'number' },
longitude: { type: 'number' },
markup_language: { type: 'number' },
master_key_id: { type: 'string' },
order: { type: 'number' },
parent_id: { type: 'string' },
share_id: { type: 'string' },
source: { type: 'string' },
source_application: { type: 'string' },
source_url: { type: 'string' },
title: { type: 'string' },
todo_completed: { type: 'number' },
todo_due: { type: 'number' },
updated_time: { type: 'number' },
user_created_time: { type: 'number' },
user_data: { type: 'string' },
user_updated_time: { type: 'number' },
type_: { type: 'number' },
},
notes_normalized: {
altitude: { type: 'number' },
body: { type: 'string' },
id: { type: 'string' },
is_todo: { type: 'number' },
latitude: { type: 'number' },
longitude: { type: 'number' },
parent_id: { type: 'string' },
source_url: { type: 'string' },
title: { type: 'string' },
todo_completed: { type: 'number' },
todo_due: { type: 'number' },
user_created_time: { type: 'number' },
user_updated_time: { type: 'number' },
type_: { type: 'number' },
},
tags_with_note_count: {
created_time: { type: 'number' },
id: { type: 'string' },
note_count: { type: 'any' },
title: { type: 'string' },
todo_completed_count: { type: 'any' },
updated_time: { type: 'number' },
type_: { type: 'number' },
},
};
export type ItemRendererDatabaseDependency = 'folder.created_time' | 'folder.encryption_applied' | 'folder.encryption_cipher_text' | 'folder.icon' | 'folder.id' | 'folder.is_shared' | 'folder.master_key_id' | 'folder.parent_id' | 'folder.share_id' | 'folder.title' | 'folder.updated_time' | 'folder.user_created_time' | 'folder.user_updated_time' | 'folder.type_' | 'note.altitude' | 'note.application_data' | 'note.author' | 'note.body' | 'note.conflict_original_id' | 'note.created_time' | 'note.encryption_applied' | 'note.encryption_cipher_text' | 'note.id' | 'note.is_conflict' | 'note.is_shared' | 'note.is_todo' | 'note.latitude' | 'note.longitude' | 'note.markup_language' | 'note.master_key_id' | 'note.order' | 'note.parent_id' | 'note.share_id' | 'note.source' | 'note.source_application' | 'note.source_url' | 'note.title' | 'note.todo_completed' | 'note.todo_due' | 'note.updated_time' | 'note.user_created_time' | 'note.user_data' | 'note.user_updated_time' | 'note.type_';

View File

@@ -34,7 +34,6 @@ const { FileApiDriverLocal } = require('../file-api-driver-local');
const { FileApiDriverWebDav } = require('../file-api-driver-webdav.js');
const { FileApiDriverDropbox } = require('../file-api-driver-dropbox.js');
const { FileApiDriverOneDrive } = require('../file-api-driver-onedrive.js');
const { FileApiDriverAmazonS3 } = require('../file-api-driver-amazon-s3.js');
import SyncTargetRegistry from '../SyncTargetRegistry';
const SyncTargetMemory = require('../SyncTargetMemory.js');
const SyncTargetFilesystem = require('../SyncTargetFilesystem.js');
@@ -60,7 +59,6 @@ import Synchronizer from '../Synchronizer';
import SyncTargetNone from '../SyncTargetNone';
import { setRSA } from '../services/e2ee/ppk';
const md5 = require('md5');
const { S3Client } = require('@aws-sdk/client-s3');
const { Dirnames } = require('../services/synchronizer/utils/types');
import RSA from '../services/e2ee/RSA.node';
import { State as ShareState } from '../services/share/reducer';
@@ -627,6 +625,13 @@ async function initFileApi() {
const appDir = await api.appDirectory();
fileApi = new FileApi(appDir, new FileApiDriverOneDrive(api));
} else if (syncTargetId_ === SyncTargetRegistry.nameToId('amazon_s3')) {
// (Most of?) the @aws-sdk libraries depend on an old version of uuid
// that doesn't work with jest (without converting ES6 exports to CommonJS).
//
// Require it dynamically so that this doesn't break test environments that
// aren't configured to do this conversion.
const { FileApiDriverAmazonS3 } = require('../file-api-driver-amazon-s3.js');
const { S3Client } = require('@aws-sdk/client-s3');
// We make sure for S3 tests run in band because tests
// share the same directory which will cause locking errors.

View File

@@ -1,5 +1,3 @@
import { Theme } from './themes/type';
import theme_light from './themes/light';
import theme_dark from './themes/dark';
import theme_dracula from './themes/dracula';
@@ -113,31 +111,20 @@ globalStyle.buttonStyle = {
borderRadius: 4,
};
function addMissingProperties(theme: Theme) {
// if (!('backgroundColor3' in theme)) theme.backgroundColor3 = theme.backgroundColor;
// if (!('color3' in theme)) theme.color3 = theme.color;
// if (!('selectionBackgroundColor3' in theme)) {
// if (theme.appearance === 'dark') {
// theme.selectionBackgroundColor3 = '#ffffff77';
// } else {
// theme.selectionBackgroundColor3 = '#00000077';
// }
// }
// if (!('backgroundColorHover3' in theme)) theme.backgroundColorHover3 = Color(theme.selectionBackgroundColor3).alpha(0.5).rgb();
// if (!('selectionBorderColor3' in theme)) theme.selectionBorderColor3 = theme.backgroundColor3;
// TODO: pick base theme based on appearence
// const lightTheme = themes[Setting.THEME_LIGHT];
// for (const n in lightTheme) {
// if (!(n in theme)) theme[n] = lightTheme[n];
// }
return theme;
}
export function addExtraStyles(style: any) {
const zoomRatio = 1;
const fontSizes: any = {
fontSize: Math.round(12 * zoomRatio),
toolbarIconSize: 18,
};
fontSizes.noteViewerFontSize = Math.round(fontSizes.fontSize * 1.25);
style.zoomRatio = zoomRatio;
style = { ...fontSizes, ...style };
style.selectedDividerColor = Color(style.dividerColor).darken(0.2).hex();
style.iconColor = Color(style.color).alpha(0.8);
@@ -350,28 +337,14 @@ const themeCache_: any = {};
export function themeStyle(themeId: number) {
if (!themeId) throw new Error('Theme must be specified');
const zoomRatio = 1;
const cacheKey = themeId;
if (themeCache_[cacheKey]) return themeCache_[cacheKey];
// Font size are not theme specific, but they must be referenced
// and computed here to allow them to respond to settings changes
// without the need to restart
const fontSizes: any = {
fontSize: Math.round(12 * zoomRatio),
toolbarIconSize: 18,
};
fontSizes.noteViewerFontSize = Math.round(fontSizes.fontSize * 1.25);
let output: any = {};
output.zoomRatio = zoomRatio;
// All theme are based on the light style, and just override the
// relevant properties
output = { ...globalStyle, ...fontSizes, ...themes[themeId] };
output = addMissingProperties(output);
output = { ...globalStyle, ...themes[themeId] };
output = addExtraStyles(output);
output.cacheKey = cacheKey;

View File

@@ -1,11 +1,11 @@
import { RuleOptions } from '../../MdToHtml';
import htmlUtils from '../../htmlUtils';
import { attributesHtml } from '../../htmlUtils';
import utils from '../../utils';
function renderImageHtml(before: string, src: string, after: string, ruleOptions: RuleOptions) {
const r = utils.imageReplacement(ruleOptions.ResourceModel, src, ruleOptions.resources, ruleOptions.resourceBaseUrl, ruleOptions.itemIdToUrl);
if (typeof r === 'string') return r;
if (r) return `<img ${before} ${htmlUtils.attributesHtml(r)} ${after}/>`;
if (r) return `<img ${before} ${attributesHtml(r)} ${after}/>`;
return `[Image: ${src}]`;
}

View File

@@ -1,5 +1,5 @@
import { RuleOptions } from '../../MdToHtml';
import htmlUtils from '../../htmlUtils';
import { attributesHtml } from '../../htmlUtils';
import utils from '../../utils';
import createEventHandlingAttrs from '../createEventHandlingAttrs';
@@ -25,7 +25,7 @@ function plugin(markdownIt: any, ruleOptions: RuleOptions) {
postMessageSyntax: ruleOptions.postMessageSyntax ?? 'void',
}, null);
return `<img data-from-md ${htmlUtils.attributesHtml({ ...r, title: title, alt: token.content })} ${js}/>`;
return `<img data-from-md ${attributesHtml({ ...r, title: title, alt: token.content })} ${js}/>`;
}
return defaultRender(tokens, idx, options, env, self);
};

View File

@@ -1,4 +1,4 @@
import htmlUtils from './htmlUtils';
import htmlUtils, { extractHtmlBody } from './htmlUtils';
describe('htmlUtils', () => {
@@ -29,4 +29,26 @@ describe('htmlUtils', () => {
}
});
test('should extract the HTML body', () => {
const testCases: [string, string][] = [
[
'Just <b>testing</b>',
'Just <b>testing</b>',
],
[
'',
'',
],
[
'<html><head></head><meta bla><body>Here is the body<img src="test.png"/></body></html>',
'Here is the body<img src="test.png"/>',
],
];
for (const [input, expected] of testCases) {
const actual = extractHtmlBody(input);
expect(actual).toBe(expected);
}
});
});

View File

@@ -34,24 +34,28 @@ interface SanitizeHtmlOptions {
addNoMdConvClass: boolean;
}
class HtmlUtils {
export const attributesHtml = (attr: Record<string, string>) => {
const output = [];
public attributesHtml(attr: Record<string, string>) {
const output = [];
for (const n in attr) {
if (!attr.hasOwnProperty(n)) continue;
for (const n in attr) {
if (!attr.hasOwnProperty(n)) continue;
if (!attr[n]) {
output.push(n);
} else {
output.push(`${n}="${htmlentities(attr[n])}"`);
}
if (!attr[n]) {
output.push(n);
} else {
output.push(`${n}="${htmlentities(attr[n])}"`);
}
return output.join(' ');
}
return output.join(' ');
};
export const isSelfClosingTag = (tagName: string) => {
return selfClosingElements.includes(tagName.toLowerCase());
};
class HtmlUtils {
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
public processImageTags(html: string, callback: Function) {
if (!html) return '';
@@ -70,7 +74,7 @@ class HtmlUtils {
}
if (action.type === 'setAttributes') {
const attrHtml = this.attributesHtml(action.attrs);
const attrHtml = attributesHtml(action.attrs);
return `<img${before}${attrHtml}${after}>`;
}
@@ -103,7 +107,7 @@ class HtmlUtils {
}
if (action.type === 'setAttributes') {
const attrHtml = this.attributesHtml(action.attrs);
const attrHtml = attributesHtml(action.attrs);
return `<img${before}${attrHtml}${after}>`;
}
@@ -111,10 +115,6 @@ class HtmlUtils {
});
}
public isSelfClosingTag(tagName: string) {
return selfClosingElements.includes(tagName.toLowerCase());
}
public stripHtml(html: string) {
const output: string[] = [];
@@ -274,9 +274,9 @@ class HtmlUtils {
attrs['href'] = '#';
}
let attrHtml = this.attributesHtml(attrs);
let attrHtml = attributesHtml(attrs);
if (attrHtml) attrHtml = ` ${attrHtml}`;
const closingSign = this.isSelfClosingTag(name) ? '/>' : '>';
const closingSign = isSelfClosingTag(name) ? '/>' : '>';
output.push(`<${name}${attrHtml}${closingSign}`);
},
@@ -319,7 +319,7 @@ class HtmlUtils {
if (disallowedTagDepth) return;
if (this.isSelfClosingTag(name)) return;
if (isSelfClosingTag(name)) return;
output.push(`</${name}>`);
},
@@ -334,4 +334,53 @@ class HtmlUtils {
}
const makeHtmlTag = (name: string, attrs: Record<string, string>) => {
let attrHtml = attributesHtml(attrs);
if (attrHtml) attrHtml = ` ${attrHtml}`;
const closingSign = isSelfClosingTag(name) ? '/>' : '>';
return `<${name}${attrHtml}${closingSign}`;
};
// Will return either the content of the <BODY> tag if it exists, or the whole
// HTML (which would be a fragment of HTML)
export const extractHtmlBody = (html: string) => {
let inBody = false;
let bodyFound = false;
const output: string[] = [];
const parser = new htmlparser2.Parser({
onopentag: (name: string, attrs: Record<string, string>) => {
if (name === 'body') {
inBody = true;
bodyFound = true;
return;
}
if (inBody) {
output.push(makeHtmlTag(name, attrs));
}
},
ontext: (encodedText: string) => {
if (inBody) output.push(encodedText);
},
onclosetag: (name: string) => {
if (inBody && name === 'body') inBody = false;
if (inBody) {
if (isSelfClosingTag(name)) return;
output.push(`</${name}>`);
}
},
}, { decodeEntities: false });
parser.write(html);
parser.end();
return bodyFound ? output.join('') : html;
};
export default new HtmlUtils();

View File

@@ -37,7 +37,7 @@
"fs-extra": "11.1.1",
"html-entities": "1.4.0",
"jquery": "3.7.0",
"knex": "2.4.2",
"knex": "2.5.1",
"koa": "2.14.2",
"markdown-it": "13.0.1",
"mustache": "4.2.0",
@@ -45,7 +45,7 @@
"node-cron": "3.0.2",
"nodemailer": "6.9.4",
"nodemon": "2.0.22",
"pg": "8.11.1",
"pg": "8.11.2",
"pretty-bytes": "5.6.0",
"prettycron": "0.10.0",
"query-string": "7.1.3",

View File

@@ -1,43 +0,0 @@
// function stripeConfig() {
// if (!joplin || !joplin.stripeConfig) throw new Error('Stripe config is not set');
// return joplin.stripeConfig;
// }
// function newStripe() {
// return Stripe(stripeConfig().publishableKey);
// }
// async function createStripeCheckoutSession(priceId) {
// const urlQuery = new URLSearchParams(location.search);
// const coupon = urlQuery.get('coupon') || '';
// console.info('Creating Stripe session for price:', priceId, 'Coupon:', coupon);
// const result = await fetch(`${stripeConfig().webhookBaseUrl}/stripe/createCheckoutSession`, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify({
// priceId: priceId,
// coupon: coupon,
// }),
// });
// if (!result.ok) {
// console.error('Could not create Stripe checkout session', await result.text());
// alert('The checkout session could not be created. Please contact support@joplincloud.com for support.');
// } else {
// return result.json();
// }
// }
// async function startStripeCheckout(priceId) {
// const data = await createStripeCheckoutSession(stripeId);
// const result = await stripe.redirectToCheckout({
// sessionId: data.sessionId,
// });
// console.info('Redirected to checkout', result);
// }

View File

@@ -63,7 +63,7 @@ async function handleConfirmEmailNotification(ctx: AppContext): Promise<Notifica
if (!ctx.joplin.owner.email_confirmed) {
return {
id: 'confirmEmail',
messageHtml: renderMarkdown('An email has been sent to you containing an activation link to complete your registration.\n\nMake sure you click it to secure your account and keep access to it.'),
messageHtml: renderMarkdown(`An email has been sent to you containing an activation link to complete your registration. If you did not receive it, you may send it again from [your profile page](${profileUrl()}).\n\nMake sure you click it to secure your account and keep access to it.`),
levelClassName: levelClassName(NotificationLevel.Important),
closeUrl: '',
};

View File

@@ -100,7 +100,7 @@ router.get('admin/users', async (_path: SubPath, ctx: AppContext) => {
await userModel.checkIfAllowed(ctx.joplin.owner, AclAction.List);
const showDisabled = ctx.query.show_disabled === '1';
const searchQuery = ctx.query.query || '';
const searchQuery = (ctx.query.query && ctx.query.query.toString().toLowerCase()) || '';
const pagination = makeTablePagination(ctx.query, 'full_name', PaginationOrderDir.ASC);
pagination.limit = 1000;
@@ -112,7 +112,9 @@ router.get('admin/users', async (_path: SubPath, ctx: AppContext) => {
if (searchQuery) {
void query.where(qb => {
void qb.whereRaw('full_name like ?', [`%${searchQuery}%`]).orWhereRaw('email like ?', [`%${searchQuery}%`]);
void qb
.whereRaw('lower(full_name) like ?', [`%${searchQuery}%`])
.orWhereRaw('lower(email) like ?', [`%${searchQuery}%`]);
});
}
@@ -135,7 +137,7 @@ router.get('admin/users', async (_path: SubPath, ctx: AppContext) => {
label: _('Email'),
},
{
name: 'account',
name: 'account_type',
label: _('Account'),
},
{
@@ -143,15 +145,15 @@ router.get('admin/users', async (_path: SubPath, ctx: AppContext) => {
label: _('Max Item Size'),
},
{
name: 'total_size',
name: 'total_item_size',
label: _('Total Size'),
},
{
name: 'max_total_size',
name: 'max_total_item_size',
label: _('Max Total Size'),
},
{
name: 'can_share',
name: 'can_share_folder',
label: _('Can Share'),
},
],

View File

@@ -90,6 +90,7 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, formUser: User =
view.content.error = error;
view.content.postUrl = postUrl;
view.content.csrfTag = await createCsrfTag(ctx);
view.content.showSendAccountConfirmationEmailButton = !user.email_confirmed;
if (subscription) {
const lastPaymentAttempt = models.subscription().lastPaymentAttempt(subscription);
@@ -213,6 +214,7 @@ interface FormFields {
update_subscription_basic_button: string;
update_subscription_pro_button: string;
stop_impersonate_button: string;
send_account_confirmation_email: string;
}
router.post('users', async (path: SubPath, ctx: AppContext) => {
@@ -246,6 +248,8 @@ router.post('users', async (path: SubPath, ctx: AppContext) => {
// that user, except the current one (otherwise they would be
// logged out).
if (userToSave.password) await models.session().deleteByUserId(userToSave.id, contextSessionId(ctx));
} else if (fields.send_account_confirmation_email) {
await models.user().sendAccountConfirmationEmail(user);
} else if (fields.stop_impersonate_button) {
await stopImpersonating(ctx);
return redirect(ctx, config().baseUrl);

View File

@@ -27,7 +27,7 @@
</div>
<p id="password_strength" class="help"></p>
</div>
<div class="field">
<label class="label">Repeat password</label>
<div class="control">
@@ -37,6 +37,9 @@
<div class="control block">
<input type="submit" name="post_button" class="button is-primary" value="{{buttonTitle}}" />
{{#showSendAccountConfirmationEmailButton}}
<input type="submit" name="send_account_confirmation_email" class="button is-link" value="Send account confirmation email" />
{{/showSendAccountConfirmationEmailButton}}
</div>
</div>
@@ -50,7 +53,7 @@
<p class="help">Click for more info about the Pro plan and to upgrade your account.</p>
</div>
{{/showUpdateSubscriptionPro}}
<div class="control block">
<p><a class="button is-link" target="_blank" href="{{stripePortalUrl}}">Manage subscription</a></p>
<p class="help">Click to update your payment details, switch to a different billing cycle or plan, or to cancel your subscription.</p>

View File

@@ -4,6 +4,37 @@ import { rootDir } from './tool-utils';
const sqlts = require('@rmp135/sql-ts').default;
const fs = require('fs-extra');
function createRuntimeObject(table: any) {
const colStrings = [];
for (const col of table.columns) {
const name = col.propertyName;
const type = col.propertyType;
colStrings.push(`\t\t${name}: { type: '${type}' },`);
}
return `\t${table.name}: {\n${colStrings.join('\n')}\n\t},`;
}
const stringToSingular = (word: string) => {
if (word.endsWith('s')) return word.substring(0, word.length - 1);
return word;
};
const generateListRenderDependencyType = (tables: any[]) => {
const output: string[] = [];
for (const table of tables) {
if (!['notes', 'folders'].includes(table.name)) continue;
for (const col of table.columns) {
const name = col.propertyName;
output.push(`'${stringToSingular(table.name)}.${name}'`);
}
}
return output.join(' | ');
};
async function main() {
// Run the CLI app once so as to generate the database file
process.chdir(`${rootDir}/packages/app-cli`);
@@ -54,6 +85,11 @@ async function main() {
return table;
});
const tableStrings = [];
for (const table of definitions.tables) {
tableStrings.push(createRuntimeObject(table));
}
const tsString = sqlts.fromObject(definitions, sqlTsConfig)
.replace(/": /g, '"?: ');
const header = `// AUTO-GENERATED BY ${__filename.substr(rootDir.length + 1)}`;
@@ -65,7 +101,11 @@ async function main() {
const splitted = existingContent.split('// AUTO-GENERATED BY');
const staticContent = splitted[0];
await fs.writeFile(targetFile, `${staticContent}\n\n${header}\n\n${tsString}`, 'utf8');
const runtimeContent = `export const databaseSchema: DatabaseTables = {\n${tableStrings.join('\n')}\n};`;
const listRendererDependency = `export type ListRendererDatabaseDependency = ${generateListRenderDependencyType(definitions.tables)};`;
await fs.writeFile(targetFile, `${staticContent}\n\n${header}\n\n${tsString}\n\n${runtimeContent}\n\n${listRendererDependency}`, 'utf8');
}
main().catch((error) => {

View File

@@ -1,5 +1,5 @@
import { unique } from '@joplin/lib/ArrayUtils';
import htmlUtils from '@joplin/renderer/htmlUtils';
import { attributesHtml, isSelfClosingTag } from '@joplin/renderer/htmlUtils';
const Entities = require('html-entities').AllHtmlEntities;
const htmlentities = new Entities().encode;
const htmlparser2 = require('@joplin/fork-htmlparser2');
@@ -106,9 +106,9 @@ export default (html: string, _languageCode: string, translations: Record<string
state.translateStack.push(name);
}
let attrHtml = htmlUtils.attributesHtml(attrs);
let attrHtml = attributesHtml(attrs);
if (attrHtml) attrHtml = ` ${attrHtml}`;
const closingSign = htmlUtils.isSelfClosingTag(name) ? '/>' : '>';
const closingSign = isSelfClosingTag(name) ? '/>' : '>';
pushContent(state, `<${name}${attrHtml}${closingSign}`);
state.translateIsOpening = false;
@@ -133,7 +133,7 @@ export default (html: string, _languageCode: string, translations: Record<string
if (name === 'script') state.inScript = false;
if (htmlUtils.isSelfClosingTag(name)) return;
if (isSelfClosingTag(name)) return;
pushContent(state, `</${name}>`);
},

5
packages/utils/html.ts Normal file
View File

@@ -0,0 +1,5 @@
/* eslint-disable import/prefer-default-export */
const Entities = require('html-entities').AllHtmlEntities;
export const htmlentities = new Entities().encode;

View File

@@ -1,6 +1,6 @@
/* eslint-disable import/prefer-default-export */
import { sleep } from './time';
import { msleep } from './time';
import fetch from 'node-fetch';
export const fetchWithRetry = async (url: string, opts: any = null) => {
@@ -20,7 +20,7 @@ export const fetchWithRetry = async (url: string, opts: any = null) => {
}
if (opts && opts.pause) {
await sleep(opts.pause);
await msleep(opts.pause);
}
}
}

View File

@@ -8,6 +8,9 @@
"./net": "./dist/net.js",
"./fs": "./dist/fs.js",
"./env": "./dist/env.js",
"./types": "./dist/types.js",
"./time": "./dist/time.js",
"./html": "./dist/html.js",
"./Logger": "./dist/Logger.js"
},
"publishConfig": {
@@ -26,6 +29,7 @@
"execa": "5.1.1",
"fs-extra": "11.1.1",
"glob": "10.3.3",
"html-entities": "1.4.0",
"moment": "2.29.4",
"node-fetch": "2.6.7",
"sprintf-js": "1.1.2"

View File

@@ -1,5 +1,5 @@
/* eslint-disable import/prefer-default-export */
export const sleep = (ms: number) => {
export const msleep = (ms: number) => {
return new Promise(resolve => setTimeout(resolve, ms));
};

6
packages/utils/types.ts Normal file
View File

@@ -0,0 +1,6 @@
/* eslint-disable import/prefer-default-export */
export interface Size {
width: number;
height: number;
}

View File

@@ -1,5 +1,22 @@
# Joplin changelog
## [v2.12.12](https://github.com/laurent22/joplin/releases/tag/v2.12.12) (Pre-release) - 2023-08-19T22:44:56Z
- Improved: Draw red border around missing encryption key passwords ([#8636](https://github.com/laurent22/joplin/issues/8636)) ([#8493](https://github.com/laurent22/joplin/issues/8493) by Henry Heino)
- Improved: Fetch release info from Joplin server ([f0c1042](https://github.com/laurent22/joplin/commit/f0c1042))
- Improved: Link to FAQ when encryption password may have been reset by an update ([#8667](https://github.com/laurent22/joplin/issues/8667)) ([#8493](https://github.com/laurent22/joplin/issues/8493) by Henry Heino)
- Improved: Show missing sync password warning and link to FAQ ([#8644](https://github.com/laurent22/joplin/issues/8644)) ([#8625](https://github.com/laurent22/joplin/issues/8625) by Henry Heino)
- Improved: Temporarily revert to AES-128 as encryption method due to severe performance issues ([#8657](https://github.com/laurent22/joplin/issues/8657))
- Improved: Add an option to disable the image resizing prompt ([#8575](https://github.com/laurent22/joplin/issues/8575)) ([#8566](https://github.com/laurent22/joplin/issues/8566) by [@hubert](https://github.com/hubert))
- Improved: Always show reencrypt button ([#8555](https://github.com/laurent22/joplin/issues/8555)) ([#8380](https://github.com/laurent22/joplin/issues/8380) by Henry Heino)
- Improved: Auto-update to ARM64 version on Apple M1 hardware ([#8467](https://github.com/laurent22/joplin/issues/8467))
- Improved: Improved sharing error handling ([95ca89b](https://github.com/laurent22/joplin/commit/95ca89b))
- Improved: Sync 15 seconds after changing a note ([807384c](https://github.com/laurent22/joplin/commit/807384c))
- Improved: Updated packages @rmp135/sql-ts (v1.18.0), buildTools, glob (v10.3.3), react-select (v5.7.4), sharp (v0.32.3), word-wrap (v1.2.4)
- Fixed: Fix editor not refreshed when the current note changes during sync ([#8654](https://github.com/laurent22/joplin/issues/8654)) ([#8652](https://github.com/laurent22/joplin/issues/8652) by Henry Heino)
- Fixed: Error while quitting the app ([#8612](https://github.com/laurent22/joplin/issues/8612)) ([#8591](https://github.com/laurent22/joplin/issues/8591) by [@hubert](https://github.com/hubert))
- Fixed: Fix toggle external editing button always disabled in rich text editor ([#8595](https://github.com/laurent22/joplin/issues/8595)) ([#8541](https://github.com/laurent22/joplin/issues/8541) by Henry Heino)
## [v2.12.10](https://github.com/laurent22/joplin/releases/tag/v2.12.10) (Pre-release) - 2023-07-30T18:25:58Z
- Improved: Adding support for plugin icons ([#8499](https://github.com/laurent22/joplin/issues/8499)) ([#8408](https://github.com/laurent22/joplin/issues/8408) by [@hubert](https://github.com/hubert))

View File

@@ -1,5 +1,31 @@
# Joplin Android app changelog
## [android-v2.12.1](https://github.com/laurent22/joplin/releases/tag/android-v2.12.1) (Pre-release) - 2023-08-19T22:32:39Z
- New: Add JEX export (#8428 by Henry Heino)
- New: Add support for Joplin Cloud email to note functionality (#8460 by [@pedr](https://github.com/pedr))
- New: Add support for Voice Typing for most languages (#8309)
- New: Add support for share permissions (#8491)
- Improved: Add an option to disable the image resizing prompt (#8575) (#8566 by [@hubert](https://github.com/hubert))
- Improved: Add option to autodetect theme (#8498) (#8490 by Henry Heino)
- Improved: Improved Vosk error handling (1eeb5ab)
- Improved: Temporarily revert to AES-128 as encryption method due to severe performance issues (#8657)
- Improved: Updated packages @react-native-community/datetimepicker (v7.4.1), @react-native-community/geolocation (v3.0.6), @react-native-community/netinfo (v9.4.1), @rmp135/sql-ts (v1.18.0), @testing-library/react-native (v12.1.3), buildTools, clean-html (v2), dayjs (v1.11.9), domhandler (v5), gettext-parser (v7.0.1), glob (v10.3.3), highlight.js (v11.8.0), jsdom (v22.1.0), react-native-device-info (v10.7.0), react-native-document-picker (v9), react-native-drawer-layout (v3.2.1), react-native-gesture-handler (v2.12.0), react-native-get-random-values (v1.9.0), react-native-image-picker (v5.6.0), react-native-localize (v3.0.2), react-native-modal-datetime-picker (v15.0.1), react-native-paper (v5.9.1), react-native-reanimated (v3.2.0), react-native-safe-area-context (v4.6.4), react-redux (v8.1.2), sass (v1.63.6), sharp (v0.32.4), standard (v17.1.0), ts-loader (v9.4.4), url (v0.11.1), word-wrap (v1.2.5)
- Improved: Upgrade react-native-webview to v12 (9ceb7b9)
- Improved: Upgrade to React Native 0.71 (e740914)
- Improved: WebDAV: Show a more descriptive error message when the password is empty (#8477) (#8466 by Henry Heino)
- Fixed: Do not log data shared with the app (#8495) (#8211 by Henry Heino)
- Fixed: Fix frequent crashing on Android 12 ARM (#8516) (#8425 by Henry Heino)
- Fixed: Fixed link modal position on devices with notch (#8029) (#8027 by [@Letty](https://github.com/Letty))
- Fixed: Fixed text update issue when attaching a file to an empty note (78f3f1c)
- Fixed: Hide markdown toolbar completely when low on vertical space (#8688) (#8687 by Henry Heino)
- Fixed: Preserve image rotation (and other metadata) when resizing (#8669) (#8310 by Henry Heino)
- Fixed: Show warning if some items could not be decrypted (#8481) (#8381 by Henry Heino)
- Fixed: The voice typing box covers the texts in the editor (#8685) (#8510 by [@hubert](https://github.com/hubert))
- Fixed: Trying to fix sharing issues (#8533)
- Fixed: Unrevert #7953: Migrate to react-native-drawer-layout (#8379) (#7918 by Henry Heino)
- Security: Prevent XSS when passing specially encoded string to a link (57b4198)
## [android-v2.11.32](https://github.com/laurent22/joplin/releases/tag/android-v2.11.32) (Pre-release) - 2023-07-03T11:33:54Z
- Improved: Allow configuring voice typing model URL (2aab85f)

View File

@@ -18,7 +18,7 @@ Moreover, by getting a subscription you are supporting the development of the pr
We offer a 50% Education Discount for students and teachers. To claim it, please contact us from your university or school email address. You will then receive a URL you can use to subscribe to Joplin Cloud while benefiting from the 50% discount. This is valid for a whole year and can be renewed for as long as you are in education by contacting us again.
We may also offer bulk discounts for companies, associations and nonprofit organisations. Please [contact us](mailto:support@joplincloud.com) for more details.
We may also offer bulk discounts for companies, associations and nonprofit organisations. Please [contact us](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/Aide.png) for more details.
## Where is Joplin Cloud data located?

130
yarn.lock
View File

@@ -2840,6 +2840,16 @@ __metadata:
languageName: node
linkType: hard
"@bam.tech/react-native-image-resizer@npm:3.0.5":
version: 3.0.5
resolution: "@bam.tech/react-native-image-resizer@npm:3.0.5"
peerDependencies:
react: "*"
react-native: "*"
checksum: f555bd10aafab1a797b6f5142e59b1bb11f229b61d35c3e6c9d734bbd4999f97bcc86853f69965d758ca872aad024add7a852041da498b177eee923ef82e506d
languageName: node
linkType: hard
"@bcoe/v8-coverage@npm:^0.2.3":
version: 0.2.3
resolution: "@bcoe/v8-coverage@npm:0.2.3"
@@ -4435,6 +4445,7 @@ __metadata:
"@joplin/utils": ~2.12
"@testing-library/react-hooks": 8.0.1
"@types/jest": 29.5.3
"@types/mustache": 4.2.2
"@types/node": 18.16.18
"@types/react": 18.0.24
"@types/react-redux": 7.1.25
@@ -4461,6 +4472,7 @@ __metadata:
mark.js: 8.11.1
md5: 2.3.0
moment: 2.29.4
mustache: 4.2.0
nan: 2.17.0
node-fetch: 2.6.7
node-notifier: 10.0.1
@@ -4470,7 +4482,7 @@ __metadata:
react: 18.2.0
react-datetime: 3.2.0
react-dom: 18.2.0
react-redux: 8.1.1
react-redux: 8.1.2
react-select: 5.7.4
react-test-renderer: 18.2.0
react-toggle-button: 2.2.0
@@ -4502,6 +4514,7 @@ __metadata:
"@babel/core": 7.20.2
"@babel/preset-env": 7.20.2
"@babel/runtime": 7.20.0
"@bam.tech/react-native-image-resizer": 3.0.5
"@codemirror/commands": 6.2.2
"@codemirror/lang-cpp": 6.0.2
"@codemirror/lang-html": 6.4.3
@@ -4573,10 +4586,9 @@ __metadata:
react-native-file-viewer: 2.1.5
react-native-fingerprint-scanner: 6.0.0
react-native-fs: 2.20.0
react-native-gesture-handler: 2.12.0
react-native-gesture-handler: 2.12.1
react-native-get-random-values: 1.9.0
react-native-image-picker: 5.6.0
react-native-image-resizer: 1.4.5
react-native-localize: 3.0.2
react-native-modal-datetime-picker: 15.0.1
react-native-paper: 5.9.1
@@ -4584,7 +4596,7 @@ __metadata:
react-native-quick-actions: 0.3.13
react-native-reanimated: 3.3.0
react-native-rsa-native: 2.0.5
react-native-safe-area-context: 4.6.4
react-native-safe-area-context: 4.7.1
react-native-securerandom: 1.0.1
react-native-share: 8.2.2
react-native-sqlite-storage: 6.0.1
@@ -4594,7 +4606,7 @@ __metadata:
react-native-vosk: 0.1.12
react-native-webview: 12.4.0
react-native-zip-archive: 6.0.9
react-redux: 8.1.1
react-redux: 8.1.2
react-test-renderer: 18.2.0
redux: 4.2.1
rn-fetch-blob: 0.12.0
@@ -4640,7 +4652,7 @@ __metadata:
resolution: "@joplin/fork-sax@workspace:packages/fork-sax"
dependencies:
standard: 17.1.0
tap: 16.3.7
tap: 16.3.8
languageName: unknown
linkType: soft
@@ -4908,7 +4920,7 @@ __metadata:
jest-expect-message: 1.1.3
jquery: 3.7.0
jsdom: 22.1.0
knex: 2.4.2
knex: 2.5.1
koa: 2.14.2
markdown-it: 13.0.1
mustache: 4.2.0
@@ -4917,7 +4929,7 @@ __metadata:
node-mocks-http: 1.12.2
nodemailer: 6.9.4
nodemon: 2.0.22
pg: 8.11.1
pg: 8.11.2
pretty-bytes: 5.6.0
prettycron: 0.10.0
query-string: 7.1.3
@@ -5016,6 +5028,7 @@ __metadata:
execa: 5.1.1
fs-extra: 11.1.1
glob: 10.3.3
html-entities: 1.4.0
jest: 29.5.0
moment: 2.29.4
node-fetch: 2.6.7
@@ -21663,7 +21676,46 @@ __metadata:
languageName: node
linkType: hard
"knex@npm:2.4.2, knex@npm:^2.4.2":
"knex@npm:2.5.1":
version: 2.5.1
resolution: "knex@npm:2.5.1"
dependencies:
colorette: 2.0.19
commander: ^10.0.0
debug: 4.3.4
escalade: ^3.1.1
esm: ^3.2.25
get-package-type: ^0.1.0
getopts: 2.3.0
interpret: ^2.2.0
lodash: ^4.17.21
pg-connection-string: 2.6.1
rechoir: ^0.8.0
resolve-from: ^5.0.0
tarn: ^3.0.2
tildify: 2.0.0
peerDependenciesMeta:
better-sqlite3:
optional: true
mysql:
optional: true
mysql2:
optional: true
pg:
optional: true
pg-native:
optional: true
sqlite3:
optional: true
tedious:
optional: true
bin:
knex: bin/cli.js
checksum: 4f2da7fda51a450de25274eb76034c869de0427c17831dc8472b8116e879d23aae0592c2ce4e9b2a473417867063ac6e7b29021b39b4a4d502335017a5a09278
languageName: node
linkType: hard
"knex@npm:^2.4.2":
version: 2.4.2
resolution: "knex@npm:2.4.2"
dependencies:
@@ -26484,13 +26536,20 @@ __metadata:
languageName: node
linkType: hard
"pg-connection-string@npm:^2.6.1":
"pg-connection-string@npm:2.6.1":
version: 2.6.1
resolution: "pg-connection-string@npm:2.6.1"
checksum: 882344a47e1ecf3a91383e0809bf2ac48facea97fcec0358d6e060e1cbcb8737acde419b4c86f05da4ce4a16634ee50fff1d2bb787d73b52ccbfde697243ad8a
languageName: node
linkType: hard
"pg-connection-string@npm:^2.6.2":
version: 2.6.2
resolution: "pg-connection-string@npm:2.6.2"
checksum: 22265882c3b6f2320785378d0760b051294a684989163d5a1cde4009e64e84448d7bf67d9a7b9e7f69440c3ee9e2212f9aa10dd17ad6773f6143c6020cebbcb5
languageName: node
linkType: hard
"pg-int8@npm:1.0.1":
version: 1.0.1
resolution: "pg-int8@npm:1.0.1"
@@ -26527,14 +26586,14 @@ __metadata:
languageName: node
linkType: hard
"pg@npm:8.11.1":
version: 8.11.1
resolution: "pg@npm:8.11.1"
"pg@npm:8.11.2":
version: 8.11.2
resolution: "pg@npm:8.11.2"
dependencies:
buffer-writer: 2.0.0
packet-reader: 1.0.0
pg-cloudflare: ^1.1.1
pg-connection-string: ^2.6.1
pg-connection-string: ^2.6.2
pg-pool: ^3.6.1
pg-protocol: ^1.6.0
pg-types: ^2.1.0
@@ -26547,7 +26606,7 @@ __metadata:
peerDependenciesMeta:
pg-native:
optional: true
checksum: e608fe1c52725e1c0c4cbdf97e29df8f41b9fd21aed821866e3488b3a0622be1c19801d8d8eb31f0de35f040c4f69163c211358e7df8c48d15ee8f660d2bd4cc
checksum: dfea8a2269d500dee8c17291207e5a25897163480037beb7a59be35f51e33b519c297c943ea6898b285d6a74a0d661901dc9cff2e587cc4be0bbf09b833a71a5
languageName: node
linkType: hard
@@ -27861,9 +27920,9 @@ __metadata:
languageName: node
linkType: hard
"react-native-gesture-handler@npm:2.12.0":
version: 2.12.0
resolution: "react-native-gesture-handler@npm:2.12.0"
"react-native-gesture-handler@npm:2.12.1":
version: 2.12.1
resolution: "react-native-gesture-handler@npm:2.12.1"
dependencies:
"@egjs/hammerjs": ^2.0.17
hoist-non-react-statics: ^3.3.0
@@ -27873,7 +27932,7 @@ __metadata:
peerDependencies:
react: "*"
react-native: "*"
checksum: 5147357b3212e269d0b8003e1be9e0993d0770f4880f1a8f52d2d61512c4569c48ec7b866d0a9ee44038785c29e0f84fbb99fd18a8e09552a25910c71602d788
checksum: 1aec3d71c5fd3b8087e613708ee77a0ce7a1ac2bc87f5a6a22529300795b48aaa2359bcba7309f2c14f9de1ca1ffa4904d06b3cb7263820f657a4469976aecb3
languageName: node
linkType: hard
@@ -27912,15 +27971,6 @@ __metadata:
languageName: node
linkType: hard
"react-native-image-resizer@npm:1.4.5":
version: 1.4.5
resolution: "react-native-image-resizer@npm:1.4.5"
peerDependencies:
react-native: ">=v0.40.0"
checksum: c530b119f25c27b669148461554440acd05fb37f0a24a1afc3d58c861e426551c70c679983c0399034fd54ca3575c89d0863778c49295721cb33b65bbf3f0f37
languageName: node
linkType: hard
"react-native-localize@npm:3.0.2":
version: 3.0.2
resolution: "react-native-localize@npm:3.0.2"
@@ -28027,13 +28077,13 @@ __metadata:
languageName: node
linkType: hard
"react-native-safe-area-context@npm:4.6.4":
version: 4.6.4
resolution: "react-native-safe-area-context@npm:4.6.4"
"react-native-safe-area-context@npm:4.7.1":
version: 4.7.1
resolution: "react-native-safe-area-context@npm:4.7.1"
peerDependencies:
react: "*"
react-native: "*"
checksum: c6905193528dda61268565799d81817b1e436c32c120480084e48c955075e1017b399f763368ef39c4049d6eb0e2c05c3496390857d836b47ddcf861ab60871e
checksum: 2a07c0b0751c9ceb435c7b107a881080d7b4142fca597ea4701894aeb7c2fec02eeaef094bc28f3d2eaed39f4fc8ef26764c156bf17af93407d723db5608e1e5
languageName: node
linkType: hard
@@ -28289,9 +28339,9 @@ __metadata:
languageName: node
linkType: hard
"react-redux@npm:8.1.1":
version: 8.1.1
resolution: "react-redux@npm:8.1.1"
"react-redux@npm:8.1.2":
version: 8.1.2
resolution: "react-redux@npm:8.1.2"
dependencies:
"@babel/runtime": ^7.12.1
"@types/hoist-non-react-statics": ^3.3.1
@@ -28317,7 +28367,7 @@ __metadata:
optional: true
redux:
optional: true
checksum: 370676330727764d78f35e9c5a0ed0591d79482fe9b70fffcab4aa6bcccc6194e4f1ebd818b4b390351dea5557e70d3bd4d95d7a0ac9baa1f45d6bf2230ee713
checksum: 4d5976b0f721e4148475871fcabce2fee875cc7f70f9a292f3370d63b38aa1dd474eb303c073c5555f3e69fc732f3bac05303def60304775deb28361e3f4b7cc
languageName: node
linkType: hard
@@ -31698,9 +31748,9 @@ __metadata:
languageName: node
linkType: hard
"tap@npm:16.3.7":
version: 16.3.7
resolution: "tap@npm:16.3.7"
"tap@npm:16.3.8":
version: 16.3.8
resolution: "tap@npm:16.3.8"
dependencies:
"@isaacs/import-jsx": ^4.0.1
"@types/react": ^17.0.52
@@ -31744,7 +31794,7 @@ __metadata:
optional: true
bin:
tap: bin/run.js
checksum: aed5f74698a0d90c5c7e12f07f2b543aa517c4e4f0c54fdbe7a19397448a18bb2ac8a879be923f72ee36aea7fb324c578ed90005ad10cd5aa794018cd002c3f3
checksum: b63e064f1ea20aa4cbe8cd40fbe780def9757b637caaae8ee24d96b184d8627421045dd56168b21715f6ebff77e88db774cda0b80af113ae33432641aefcbb58
languageName: node
linkType: hard