You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-01-17 00:33:59 +02:00
Compare commits
24 Commits
issue-8510
...
paste_from
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
377d02e19d | ||
|
|
b92cb7deb7 | ||
|
|
0edc66da49 | ||
|
|
e96ad7ccfa | ||
|
|
817ef7bbed | ||
|
|
5bd0c9b3a0 | ||
|
|
46d9cd34a8 | ||
|
|
c3e08237fd | ||
|
|
b406f05241 | ||
|
|
2cbee6d8af | ||
|
|
c859ad48c1 | ||
|
|
1141b1c2a1 | ||
|
|
39c118be90 | ||
|
|
f9ac4e112b | ||
|
|
87e51aa8e6 | ||
|
|
41fdc0d44d | ||
|
|
a754a8d772 | ||
|
|
41d0363fd0 | ||
|
|
2a4c7a334e | ||
|
|
df1b0a96f4 | ||
|
|
0030681cb4 | ||
|
|
e7014492c5 | ||
|
|
4804c1c0c3 | ||
|
|
270d96ad07 |
@@ -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
27
.gitignore
vendored
@@ -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
BIN
Assets/Aide.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
})
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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!',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
304
packages/app-desktop/gui/NoteList/NoteList2.tsx
Normal file
304
packages/app-desktop/gui/NoteList/NoteList2.tsx
Normal 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);
|
||||
113
packages/app-desktop/gui/NoteList/NoteListSource.tsx
Normal file
113
packages/app-desktop/gui/NoteList/NoteListSource.tsx
Normal 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);
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
43
packages/app-desktop/gui/NoteList/style.scss
Normal file
43
packages/app-desktop/gui/NoteList/style.scss
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
134
packages/app-desktop/gui/NoteList/utils/defaultListRenderer.ts
Normal file
134
packages/app-desktop/gui/NoteList/utils/defaultListRenderer.ts
Normal 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;
|
||||
45
packages/app-desktop/gui/NoteList/utils/getNoteTitleHtml.ts
Normal file
45
packages/app-desktop/gui/NoteList/utils/getNoteTitleHtml.ts
Normal 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;
|
||||
51
packages/app-desktop/gui/NoteList/utils/prepareViewProps.ts
Normal file
51
packages/app-desktop/gui/NoteList/utils/prepareViewProps.ts
Normal 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;
|
||||
64
packages/app-desktop/gui/NoteList/utils/types.ts
Normal file
64
packages/app-desktop/gui/NoteList/utils/types.ts
Normal 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;
|
||||
}
|
||||
102
packages/app-desktop/gui/NoteList/utils/useDragAndDrop.ts
Normal file
102
packages/app-desktop/gui/NoteList/utils/useDragAndDrop.ts
Normal 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;
|
||||
33
packages/app-desktop/gui/NoteList/utils/useFocusNote.ts
Normal file
33
packages/app-desktop/gui/NoteList/utils/useFocusNote.ts
Normal 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;
|
||||
19
packages/app-desktop/gui/NoteList/utils/useItemCss.ts
Normal file
19
packages/app-desktop/gui/NoteList/utils/useItemCss.ts
Normal 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;
|
||||
25
packages/app-desktop/gui/NoteList/utils/useMoveNote.ts
Normal file
25
packages/app-desktop/gui/NoteList/utils/useMoveNote.ts
Normal 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;
|
||||
150
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.ts
Normal file
150
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.ts
Normal 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;
|
||||
41
packages/app-desktop/gui/NoteList/utils/useOnNoteClick.ts
Normal file
41
packages/app-desktop/gui/NoteList/utils/useOnNoteClick.ts
Normal 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;
|
||||
82
packages/app-desktop/gui/NoteList/utils/useRenderedNotes.ts
Normal file
82
packages/app-desktop/gui/NoteList/utils/useRenderedNotes.ts
Normal 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;
|
||||
99
packages/app-desktop/gui/NoteList/utils/useScroll.ts
Normal file
99
packages/app-desktop/gui/NoteList/utils/useScroll.ts
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
57
packages/app-desktop/gui/NoteList/utils/useVisibleRange.ts
Normal file
57
packages/app-desktop/gui/NoteList/utils/useVisibleRange.ts
Normal 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;
|
||||
143
packages/app-desktop/gui/NoteListItem/NoteListItem.tsx
Normal file
143
packages/app-desktop/gui/NoteListItem/NoteListItem.tsx
Normal 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));
|
||||
3
packages/app-desktop/gui/NoteListItem/utils/types.ts
Normal file
3
packages/app-desktop/gui/NoteListItem/utils/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export type OnCheckboxChange = (event: React.ChangeEvent<HTMLInputElement>)=> void;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
// }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"standard": "17.1.0",
|
||||
"tap": "16.3.7"
|
||||
"tap": "16.3.8"
|
||||
},
|
||||
"gitHead": "eb4b0e64eab40a51b0895d3a40a9d8c3cb7b1b14"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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_';
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}]`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
// }
|
||||
@@ -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: '',
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
5
packages/utils/html.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
const Entities = require('html-entities').AllHtmlEntities;
|
||||
|
||||
export const htmlentities = new Entities().encode;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
6
packages/utils/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
export interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
130
yarn.lock
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user