Compare commits

..
83 changed files with 352 additions and 2935 deletions

No files matched your search

+1 -26
View File
@@ -260,7 +260,6 @@ 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
@@ -269,36 +268,13 @@ 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/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/NoteList/types.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
@@ -434,7 +410,6 @@ 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
+1 -26
View File
@@ -246,7 +246,6 @@ 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
@@ -255,36 +254,13 @@ 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/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/NoteList/types.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
@@ -420,7 +396,6 @@ 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
View File
Binary file not shown.

Before

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 us on the forum for support.');
alert('The checkout session could not be created. Please contact support@joplincloud.com for support.');
} else {
return result.json();
}
+3 -3
View File
@@ -23,8 +23,8 @@ function onCheckEnded() {
isCheckingForUpdate_ = false;
}
async function fetchLatestReleases() {
const response = await shim.fetch('https://objects.joplinusercontent.com/r/releases');
async function fetchLatestRelease() {
const response = await shim.fetch('https://api.github.com/repos/laurent22/joplin/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 fetchLatestReleases();
const releases = await fetchLatestRelease();
const release = extractVersionInfo(releases, process.platform, process.arch, shim.isPortable(), options);
logger.info(`Current version: ${packageInfo.version}`);
@@ -1,7 +1,6 @@
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;
@@ -10,7 +9,8 @@ interface MultiNoteActionsProps {
themeId: number;
selectedNoteIds: string[];
notes: any[];
dispatch: Dispatch;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
dispatch: Function;
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, getResourcesFromPasteEvent } from '../../utils/resourceHandling';
import { commandAttachFileToBody, handlePasteEvent } 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 getResourcesFromPasteEvent(event);
const resourceMds = await handlePasteEvent(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, getResourcesFromPasteEvent, processPastedHtml, attachedResources } from '../../utils/resourceHandling';
import { resourcesStatus, commandAttachFileToBody, handlePasteEvent, processPastedHtml, attachedResources } from '../../utils/resourceHandling';
import useScroll from './utils/useScroll';
import styles_ from './styles';
import CommandService from '@joplin/lib/services/CommandService';
@@ -1064,43 +1064,38 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
// to be processed in various ways.
event.preventDefault();
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);
}
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');
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,7 +78,6 @@ function NoteEditor(props: NoteEditorProps) {
const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({
syncStarted: props.syncStarted,
decryptionStarted: props.decryptionStarted,
noteId: effectiveNoteId,
isProvisional: props.isProvisional,
titleInputRef: titleInputRef,
@@ -634,7 +633,6 @@ 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, { extractHtmlBody } from '@joplin/renderer/htmlUtils';
import rendererHtmlUtils 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 getResourcesFromPasteEvent(event: any) {
export async function handlePasteEvent(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 extractHtmlBody(rendererHtmlUtils.sanitizeHtml(
return 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,9 +15,11 @@ export interface ToolbarButtonInfos {
}
export interface NoteEditorProps {
// style: any;
noteId: string;
themeId: number;
dispatch: Dispatch;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
dispatch: Function;
selectedNoteIds: string[];
selectedFolderId: string;
notes: any[];
@@ -25,7 +27,6 @@ export interface NoteEditorProps {
isProvisional: boolean;
editorNoteStatuses: any;
syncStarted: boolean;
decryptionStarted: boolean;
bodyEditor: string;
notesParentType: string;
selectedNoteTags: any[];
@@ -1,73 +0,0 @@
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,9 +18,8 @@ export interface OnLoadEvent {
formNote: FormNote;
}
export interface HookDependencies {
interface HookDependencies {
syncStarted: boolean;
decryptionStarted: boolean;
noteId: string;
isProvisional: boolean;
titleInputRef: any;
@@ -62,21 +61,15 @@ function resourceInfosChanged(a: ResourceInfos, b: ResourceInfos): boolean {
}
export default function useFormNote(dependencies: HookDependencies) {
const {
syncStarted, decryptionStarted, noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad,
} = dependencies;
const { syncStarted, 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 = '';
@@ -114,7 +107,7 @@ export default function useFormNote(dependencies: HookDependencies) {
}
useEffect(() => {
if (formNoteRefeshScheduled <= 0) return () => {};
if (!formNoteRefeshScheduled) return () => {};
reg.logger().info('Sync has finished and note has never been changed - reloading it');
@@ -133,7 +126,7 @@ export default function useFormNote(dependencies: HookDependencies) {
}
await initNoteState(n);
setFormNoteRefreshScheduled(0);
setFormNoteRefreshScheduled(false);
};
void loadNote();
@@ -143,32 +136,21 @@ 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 (!decryptionJustEnded && !syncJustEnded) return;
if (!prevSyncStarted) return;
if (syncStarted) 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.
refreshFormNote();
}, [
prevSyncStarted, syncStarted,
prevDecryptionStarted, decryptionStarted,
formNote.hasChanged, refreshFormNote,
]);
setFormNoteRefreshScheduled(true);
}, [prevSyncStarted, syncStarted, formNote.hasChanged]);
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 './utils/types';
import { Props } from './types';
import usePrevious from '../hooks/usePrevious';
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
import { FolderEntity } from '@joplin/lib/services/database/types';
@@ -1,304 +0,0 @@
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);
@@ -1,113 +0,0 @@
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 { FocusNote } from '../utils/useFocusNote';
import { itemAnchorRef } from '../NoteList';
export const declaration: CommandDeclaration = {
name: 'focusElementNoteList',
@@ -9,11 +9,15 @@ export const declaration: CommandDeclaration = {
parentLabel: () => _('Focus'),
};
export const runtime = (focusNote: FocusNote): CommandRuntime => {
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, noteId: string = null) => {
noteId = noteId || stateUtils.selectedNoteId(context.state);
focusNote(noteId);
if (noteId) {
const ref = itemAnchorRef(noteId);
if (ref) ref.focus();
}
},
enabledCondition: 'noteListHasNotes',
};
@@ -1,43 +0,0 @@
.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;
}
@@ -0,0 +1,29 @@
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;
}
@@ -1,20 +0,0 @@
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;
@@ -1,165 +0,0 @@
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;
@@ -1,134 +0,0 @@
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;
@@ -1,45 +0,0 @@
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;
@@ -1,51 +0,0 @@
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;
@@ -1,64 +0,0 @@
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;
}
@@ -1,102 +0,0 @@
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;
@@ -1,33 +0,0 @@
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;
@@ -1,19 +0,0 @@
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;
@@ -1,25 +0,0 @@
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;
@@ -1,150 +0,0 @@
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;
@@ -1,41 +0,0 @@
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;
@@ -1,82 +0,0 @@
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;
@@ -1,99 +0,0 @@
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;
@@ -1,61 +0,0 @@
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);
}
});
});
@@ -1,57 +0,0 @@
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;
@@ -1,143 +0,0 @@
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));
@@ -1,3 +0,0 @@
import * as React from 'react';
export type OnCheckboxChange = (event: React.ChangeEvent<HTMLInputElement>)=> void;
@@ -1,35 +0,0 @@
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;
@@ -1,27 +0,0 @@
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;
@@ -1,44 +0,0 @@
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;
@@ -1,17 +0,0 @@
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,8 +1,7 @@
import { themeStyle } from '@joplin/lib/theme';
import * as React from 'react';
import { useMemo, useState } from 'react';
// import NoteList from '../NoteList/NoteList';
import NoteList2 from '../NoteList/NoteList2';
import NoteList from '../NoteList/NoteList';
import NoteListControls from '../NoteListControls/NoteListControls';
import { Size } from '../ResizableLayout/utils/types';
import styled from 'styled-components';
@@ -40,12 +39,10 @@ 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}/>
<NoteList2 resizableLayoutEventEmitter={props.resizableLayoutEventEmitter} size={noteListSize} visible={props.visible}/>
<NoteList 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 { themeStyle } from '@joplin/lib/theme';
import { addExtraStyles, themeById } 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 = themeStyle(props.themeId);
const theme = addExtraStyles(themeById(props.themeId));
const themeCss = themeToCss(theme);
if (event.cancelled) return;
setStyleSheetContent(themeCss);
+15 -15
View File
@@ -7,19 +7,19 @@ import InteropServiceHelper from '../../InteropServiceHelper';
import { _ } from '@joplin/lib/locale';
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import bridge from '../../services/bridge';
import BaseModel from '@joplin/lib/BaseModel';
const bridge = require('@electron/remote').require('./bridge').default;
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
const { clipboard } = require('electron');
import { Dispatch } from 'redux';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
interface ContextMenuProps {
notes: any[];
dispatch: Dispatch;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
dispatch: Function;
watchedNoteFiles: string[];
plugins: PluginStates;
inConflictFolder: boolean;
@@ -45,26 +45,26 @@ export default class NoteListUtils {
if (!hasEncrypted) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('setTags', noteIds) as any)
new MenuItem(menuUtils.commandToStatefulMenuItem('setTags', noteIds))
);
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('moveToFolder', noteIds) as any)
new MenuItem(menuUtils.commandToStatefulMenuItem('moveToFolder', noteIds))
);
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('duplicateNote', noteIds) as any)
new MenuItem(menuUtils.commandToStatefulMenuItem('duplicateNote', noteIds))
);
if (singleNoteId) {
const cmd = props.watchedNoteFiles.includes(singleNoteId) ? 'stopExternalEditing' : 'startExternalEditing';
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem(cmd, singleNoteId) as any));
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem(cmd, singleNoteId)));
}
if (noteIds.length <= 1) {
menu.append(
new MenuItem(
menuUtils.commandToStatefulMenuItem('toggleNoteType', noteIds) as any
menuUtils.commandToStatefulMenuItem('toggleNoteType', noteIds)
)
);
} 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()) as any
menuUtils.commandToStatefulMenuItem('showShareNoteDialog', noteIds.slice())
)
);
}
@@ -156,7 +156,7 @@ export default class NoteListUtils {
exportMenu.append(
new MenuItem(
menuUtils.commandToStatefulMenuItem('exportPdf', noteIds) as any
menuUtils.commandToStatefulMenuItem('exportPdf', noteIds)
)
);
@@ -167,7 +167,7 @@ export default class NoteListUtils {
menu.append(
new MenuItem(
menuUtils.commandToStatefulMenuItem('deleteNote', noteIds) as any
menuUtils.commandToStatefulMenuItem('deleteNote', noteIds)
)
);
@@ -179,7 +179,7 @@ export default class NoteListUtils {
if (cmdService.isEnabled(info.view.commandName)) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem(info.view.commandName, noteIds) as any)
new MenuItem(menuUtils.commandToStatefulMenuItem(info.view.commandName, noteIds))
);
}
}
+14 -24
View File
@@ -1,14 +1,19 @@
/* eslint-disable jest/require-top-level-describe */
const { shimInit } = require('@joplin/lib/shim-init-node');
const sqlite3 = require('sqlite3');
const SyncTargetNone = require('@joplin/lib/SyncTargetNone').default;
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);
// 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', () => {
@@ -20,18 +25,3 @@ 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();
});
+1 -3
View File
@@ -139,7 +139,6 @@
"@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",
@@ -155,7 +154,6 @@
"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",
@@ -164,7 +162,7 @@
"react": "18.2.0",
"react-datetime": "3.2.0",
"react-dom": "18.2.0",
"react-redux": "8.1.2",
"react-redux": "8.1.1",
"react-select": "5.7.4",
"react-toggle-button": "2.2.0",
"react-tooltip": "4.5.1",
-1
View File
@@ -5,5 +5,4 @@
@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;
+2 -2
View File
@@ -110,8 +110,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097718
versionName "2.12.1"
versionCode 2097717
versionName "2.12.0"
// ndk {
// abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
// }
@@ -1,6 +1,6 @@
const React = require('react');
import { ReactElement, useCallback, useMemo, useState } from 'react';
import { ReactElement, useCallback, useState } from 'react';
import { LayoutChangeEvent, ScrollView, View, ViewStyle } from 'react-native';
import ToggleOverflowButton from './ToggleOverflowButton';
import ToolbarButton, { buttonSize } from './ToolbarButton';
@@ -18,22 +18,19 @@ const Toolbar = (props: ToolbarProps) => {
const [overflowButtonsVisible, setOverflowPopupVisible] = useState(false);
const [maxButtonsEachSide, setMaxButtonsEachSide] = useState(0);
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);
}
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);
}
}
return accumulator.concat(...newItems);
}, []);
return accumulator.concat(...newItems);
}, []);
// Sort from highest priority to lowest
buttons.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
return buttons;
}, [props.buttons]);
// Sort from highest priority to lowest
allButtonSpecs.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
const allButtonComponents: ReactElement[] = [];
let key = 0;
@@ -70,9 +67,7 @@ const Toolbar = (props: ToolbarProps) => {
);
const mainButtons: ReactElement[] = [];
if (maxButtonsEachSide >= allButtonComponents.length) {
mainButtons.push(...allButtonComponents);
} else if (maxButtonsEachSide > 0) {
if (maxButtonsEachSide < allButtonComponents.length) {
// We want the menu to look something like this:
// B I (…) 🔍 ⌨
// where (…) shows/hides overflow.
@@ -82,7 +77,7 @@ const Toolbar = (props: ToolbarProps) => {
mainButtons.push(toggleOverflowButton);
mainButtons.push(...allButtonComponents.slice(-maxButtonsEachSide));
} else {
mainButtons.push(toggleOverflowButton);
mainButtons.push(...allButtonComponents);
}
const styles = props.styleSheet.styles;
@@ -1,72 +0,0 @@
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 { LayoutChangeEvent, View, ViewStyle } from 'react-native';
import { View, ViewStyle } from 'react-native';
const { editorFont } = require('../global-style');
import SelectionFormatting from './SelectionFormatting';
@@ -368,19 +368,6 @@ 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
@@ -398,14 +385,10 @@ 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
testID='note-editor-root'
onLayout={onContainerLayout}
style={{
...props.style,
flexDirection: 'column',
}}
>
<View style={{
...props.style,
flexDirection: 'column',
}}>
<EditLinkDialog
visible={linkDialogVisible}
themeId={props.themeId}
@@ -436,7 +419,7 @@ function NoteEditor(props: Props, ref: any) {
searchState={searchState}
/>
{toolbarEnabled ? toolbar : null}
{props.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;
import ImageResizer from '@bam.tech/react-native-image-resizer';
const ImageResizer = require('react-native-image-resizer').default;
import shared from '@joplin/lib/components/shared/note-screen-shared';
import { ImagePickerResponse, launchImageLibrary } from 'react-native-image-picker';
import SelectDateTimeDialog from '../SelectDateTimeDialog';
@@ -586,16 +586,7 @@ 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, // quality
undefined, // rotation
undefined, // outputPath
true // keep metadata
);
const resizedImage = await ImageResizer.createResizedImage(localFilePath, dimensions.width, dimensions.height, format, 85); // , 0, targetPath);
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 } from 'react-native-paper';
import { Banner, ActivityIndicator, Modal } 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,16 +107,18 @@ export default (props: Props) => {
};
return (
<Banner
visible={true}
icon={renderIcon()}
actions={[
{
label: _('Done'),
onPress: onDismiss,
},
]}>
{`${_('Voice typing...')}\n${renderContent()}`}
</Banner>
<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>
);
};
+14 -10
View File
@@ -353,7 +353,7 @@ PODS:
- React-Core
- react-native-image-picker (5.6.0):
- React-Core
- react-native-image-resizer (3.0.5):
- react-native-image-resizer (1.4.5):
- React-Core
- react-native-netinfo (9.4.1):
- React-Core
@@ -361,8 +361,12 @@ PODS:
- React
- react-native-saf-x (2.12.0):
- React-Core
- react-native-safe-area-context (4.7.1):
- react-native-safe-area-context (4.6.4):
- RCT-Folly
- RCTRequired
- RCTTypeSafety
- React-Core
- ReactCommon/turbomodule/core
- react-native-slider (4.4.2):
- React-Core
- react-native-sqlite-storage (6.0.1):
@@ -461,7 +465,7 @@ PODS:
- React-Core
- RNCPushNotificationIOS (1.11.0):
- React-Core
- RNDateTimePicker (7.4.1):
- RNDateTimePicker (7.3.0):
- React-Core
- RNDeviceInfo (10.7.0):
- React-Core
@@ -471,7 +475,7 @@ PODS:
- React-Core
- RNFS (2.20.0):
- React-Core
- RNGestureHandler (2.12.1):
- RNGestureHandler (2.12.0):
- React-Core
- RNLocalize (3.0.2):
- React-Core
@@ -580,7 +584,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/@bam.tech/react-native-image-resizer`)"
- react-native-image-resizer (from `../node_modules/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`)"
@@ -699,7 +703,7 @@ EXTERNAL SOURCES:
react-native-image-picker:
:path: "../node_modules/react-native-image-picker"
react-native-image-resizer:
:path: "../node_modules/@bam.tech/react-native-image-resizer"
:path: "../node_modules/react-native-image-resizer"
react-native-netinfo:
:path: "../node_modules/@react-native-community/netinfo"
react-native-rsa-native:
@@ -820,11 +824,11 @@ SPEC CHECKSUMS:
react-native-geolocation: 0f7fe8a4c2de477e278b0365cce27d089a8c5903
react-native-get-random-values: dee677497c6a740b71e5612e8dbd83e7539ed5bb
react-native-image-picker: db60857e03d63721f19b6f4027de20429ddd9cba
react-native-image-resizer: 00ceb0e05586c7aadf061eea676957a6c2ec60fa
react-native-image-resizer: d9fb629a867335bdc13230ac2a58702bb8c8828f
react-native-netinfo: fefd4e98d75cbdd6e85fc530f7111a8afdf2b0c5
react-native-rsa-native: 12132eb627797529fdb1f0d22fd0f8f9678df64a
react-native-saf-x: 129cd2ddf120a1f6164c724b2846d172666b33de
react-native-safe-area-context: 9697629f7b2cda43cf52169bb7e0767d330648c2
react-native-safe-area-context: 68b07eabfb0d14547d36f6929c0e98d818064f02
react-native-slider: 33b8d190b59d4f67a541061bb91775d53d617d9d
react-native-sqlite-storage: f6d515e1c446d1e6d026aa5352908a25d4de3261
react-native-version-info: a106f23009ac0db4ee00de39574eb546682579b9
@@ -845,12 +849,12 @@ SPEC CHECKSUMS:
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
RNCClipboard: 41d8d918092ae8e676f18adada19104fa3e68495
RNCPushNotificationIOS: 64218f3c776c03d7408284a819b2abfda1834bc8
RNDateTimePicker: 9b4091348e53f540180abdc54984d839a556f593
RNDateTimePicker: 01e6d27ba2e0931cd05049c5bff6171c3c027ea8
RNDeviceInfo: 25d818c85db769cc0e7083d39efaa01a6f450df3
RNExitApp: c4e052df2568b43bec8a37c7cd61194d4cfee2c3
RNFileViewer: ce7ca3ac370e18554d35d6355cffd7c30437c592
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNGestureHandler: c0d04458598fcb26052494ae23dda8f8f5162b13
RNGestureHandler: dec4645026e7401a0899f2846d864403478ff6a5
RNLocalize: dbea38dcb344bf80ff18a1757b1becf11f70cae4
RNQuickAction: 6d404a869dc872cde841ad3147416a670d13fa93
RNReanimated: 9976fbaaeb8a188c36026154c844bf374b3b7eeb
-15
View File
@@ -33,21 +33,6 @@ 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.
+4 -4
View File
@@ -18,7 +18,6 @@
"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",
@@ -54,9 +53,10 @@
"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.1",
"react-native-gesture-handler": "2.12.0",
"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.7.1",
"react-native-safe-area-context": "4.6.4",
"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.2",
"react-redux": "8.1.1",
"redux": "4.2.1",
"rn-fetch-blob": "0.12.0",
"stream": "0.0.2",
+1 -1
View File
@@ -16,7 +16,7 @@
],
"devDependencies": {
"standard": "17.1.0",
"tap": "16.3.8"
"tap": "16.3.7"
},
"gitHead": "eb4b0e64eab40a51b0895d3a40a9d8c3cb7b1b14"
}
+2 -46
View File
@@ -1,3 +1,5 @@
/* 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;
@@ -5,49 +7,3 @@ 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;
-1
View File
@@ -491,7 +491,6 @@ 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;
-258
View File
@@ -46,34 +46,6 @@ 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;
}
@@ -328,233 +300,3 @@ 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_';
+2 -7
View File
@@ -34,6 +34,7 @@ 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');
@@ -59,6 +60,7 @@ 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';
@@ -625,13 +627,6 @@ 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.
+41 -14
View File
@@ -1,3 +1,5 @@
import { Theme } from './themes/type';
import theme_light from './themes/light';
import theme_dark from './themes/dark';
import theme_dracula from './themes/dracula';
@@ -111,20 +113,31 @@ 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);
@@ -337,14 +350,28 @@ 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, ...themes[themeId] };
output = { ...globalStyle, ...fontSizes, ...themes[themeId] };
output = addMissingProperties(output);
output = addExtraStyles(output);
output.cacheKey = cacheKey;
@@ -1,11 +1,11 @@
import { RuleOptions } from '../../MdToHtml';
import { attributesHtml } from '../../htmlUtils';
import htmlUtils 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} ${attributesHtml(r)} ${after}/>`;
if (r) return `<img ${before} ${htmlUtils.attributesHtml(r)} ${after}/>`;
return `[Image: ${src}]`;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { RuleOptions } from '../../MdToHtml';
import { attributesHtml } from '../../htmlUtils';
import htmlUtils 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 ${attributesHtml({ ...r, title: title, alt: token.content })} ${js}/>`;
return `<img data-from-md ${htmlUtils.attributesHtml({ ...r, title: title, alt: token.content })} ${js}/>`;
}
return defaultRender(tokens, idx, options, env, self);
};
+1 -23
View File
@@ -1,4 +1,4 @@
import htmlUtils, { extractHtmlBody } from './htmlUtils';
import htmlUtils from './htmlUtils';
describe('htmlUtils', () => {
@@ -29,26 +29,4 @@ 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);
}
});
});
+25 -74
View File
@@ -34,28 +34,24 @@ interface SanitizeHtmlOptions {
addNoMdConvClass: boolean;
}
export const attributesHtml = (attr: Record<string, string>) => {
const output = [];
for (const n in attr) {
if (!attr.hasOwnProperty(n)) continue;
if (!attr[n]) {
output.push(n);
} else {
output.push(`${n}="${htmlentities(attr[n])}"`);
}
}
return output.join(' ');
};
export const isSelfClosingTag = (tagName: string) => {
return selfClosingElements.includes(tagName.toLowerCase());
};
class HtmlUtils {
public attributesHtml(attr: Record<string, string>) {
const output = [];
for (const n in attr) {
if (!attr.hasOwnProperty(n)) continue;
if (!attr[n]) {
output.push(n);
} else {
output.push(`${n}="${htmlentities(attr[n])}"`);
}
}
return output.join(' ');
}
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
public processImageTags(html: string, callback: Function) {
if (!html) return '';
@@ -74,7 +70,7 @@ class HtmlUtils {
}
if (action.type === 'setAttributes') {
const attrHtml = attributesHtml(action.attrs);
const attrHtml = this.attributesHtml(action.attrs);
return `<img${before}${attrHtml}${after}>`;
}
@@ -107,7 +103,7 @@ class HtmlUtils {
}
if (action.type === 'setAttributes') {
const attrHtml = attributesHtml(action.attrs);
const attrHtml = this.attributesHtml(action.attrs);
return `<img${before}${attrHtml}${after}>`;
}
@@ -115,6 +111,10 @@ 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 = attributesHtml(attrs);
let attrHtml = this.attributesHtml(attrs);
if (attrHtml) attrHtml = ` ${attrHtml}`;
const closingSign = isSelfClosingTag(name) ? '/>' : '>';
const closingSign = this.isSelfClosingTag(name) ? '/>' : '>';
output.push(`<${name}${attrHtml}${closingSign}`);
},
@@ -319,7 +319,7 @@ class HtmlUtils {
if (disallowedTagDepth) return;
if (isSelfClosingTag(name)) return;
if (this.isSelfClosingTag(name)) return;
output.push(`</${name}>`);
},
@@ -334,53 +334,4 @@ 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();
+2 -2
View File
@@ -37,7 +37,7 @@
"fs-extra": "11.1.1",
"html-entities": "1.4.0",
"jquery": "3.7.0",
"knex": "2.5.1",
"knex": "2.4.2",
"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.2",
"pg": "8.11.1",
"pretty-bytes": "5.6.0",
"prettycron": "0.10.0",
"query-string": "7.1.3",
+43
View File
@@ -0,0 +1,43 @@
// 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. 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.`),
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.'),
levelClassName: levelClassName(NotificationLevel.Important),
closeUrl: '',
};
+4 -4
View File
@@ -137,7 +137,7 @@ router.get('admin/users', async (_path: SubPath, ctx: AppContext) => {
label: _('Email'),
},
{
name: 'account_type',
name: 'account',
label: _('Account'),
},
{
@@ -145,15 +145,15 @@ router.get('admin/users', async (_path: SubPath, ctx: AppContext) => {
label: _('Max Item Size'),
},
{
name: 'total_item_size',
name: 'total_size',
label: _('Total Size'),
},
{
name: 'max_total_item_size',
name: 'max_total_size',
label: _('Max Total Size'),
},
{
name: 'can_share_folder',
name: 'can_share',
label: _('Can Share'),
},
],
@@ -90,7 +90,6 @@ 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);
@@ -214,7 +213,6 @@ 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) => {
@@ -248,8 +246,6 @@ 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,9 +37,6 @@
<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>
@@ -53,7 +50,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>
+1 -41
View File
@@ -4,37 +4,6 @@ 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`);
@@ -85,11 +54,6 @@ 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)}`;
@@ -101,11 +65,7 @@ async function main() {
const splitted = existingContent.split('// AUTO-GENERATED BY');
const staticContent = splitted[0];
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');
await fs.writeFile(targetFile, `${staticContent}\n\n${header}\n\n${tsString}`, 'utf8');
}
main().catch((error) => {
@@ -1,5 +1,5 @@
import { unique } from '@joplin/lib/ArrayUtils';
import { attributesHtml, isSelfClosingTag } from '@joplin/renderer/htmlUtils';
import htmlUtils 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 = attributesHtml(attrs);
let attrHtml = htmlUtils.attributesHtml(attrs);
if (attrHtml) attrHtml = ` ${attrHtml}`;
const closingSign = isSelfClosingTag(name) ? '/>' : '>';
const closingSign = htmlUtils.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 (isSelfClosingTag(name)) return;
if (htmlUtils.isSelfClosingTag(name)) return;
pushContent(state, `</${name}>`);
},
-5
View File
@@ -1,5 +0,0 @@
/* eslint-disable import/prefer-default-export */
const Entities = require('html-entities').AllHtmlEntities;
export const htmlentities = new Entities().encode;
+2 -2
View File
@@ -1,6 +1,6 @@
/* eslint-disable import/prefer-default-export */
import { msleep } from './time';
import { sleep } 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 msleep(opts.pause);
await sleep(opts.pause);
}
}
}
-4
View File
@@ -8,9 +8,6 @@
"./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": {
@@ -29,7 +26,6 @@
"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 -1
View File
@@ -1,5 +1,5 @@
/* eslint-disable import/prefer-default-export */
export const msleep = (ms: number) => {
export const sleep = (ms: number) => {
return new Promise(resolve => setTimeout(resolve, ms));
};
-6
View File
@@ -1,6 +0,0 @@
/* eslint-disable import/prefer-default-export */
export interface Size {
width: number;
height: number;
}
-17
View File
@@ -1,22 +1,5 @@
# 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))
-26
View File
@@ -1,31 +1,5 @@
# 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)
+1 -1
View File
@@ -18,7 +18,7 @@ Moreover, by getting a subscription you are supporting the development of the pr
We offer a 50% Education Discount for students and teachers. To claim it, please contact us from your university or school email address. You will then receive a URL you can use to subscribe to Joplin Cloud while benefiting from the 50% discount. This is valid for a whole year and can be renewed for as long as you are in education by contacting us again.
We may also offer bulk discounts for companies, associations and nonprofit organisations. Please [contact us](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/Aide.png) for more details.
We may also offer bulk discounts for companies, associations and nonprofit organisations. Please [contact us](mailto:support@joplincloud.com) for more details.
## Where is Joplin Cloud data located?
+40 -90
View File
@@ -2840,16 +2840,6 @@ __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"
@@ -4445,7 +4435,6 @@ __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
@@ -4472,7 +4461,6 @@ __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
@@ -4482,7 +4470,7 @@ __metadata:
react: 18.2.0
react-datetime: 3.2.0
react-dom: 18.2.0
react-redux: 8.1.2
react-redux: 8.1.1
react-select: 5.7.4
react-test-renderer: 18.2.0
react-toggle-button: 2.2.0
@@ -4514,7 +4502,6 @@ __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
@@ -4586,9 +4573,10 @@ __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.1
react-native-gesture-handler: 2.12.0
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
@@ -4596,7 +4584,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.7.1
react-native-safe-area-context: 4.6.4
react-native-securerandom: 1.0.1
react-native-share: 8.2.2
react-native-sqlite-storage: 6.0.1
@@ -4606,7 +4594,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.2
react-redux: 8.1.1
react-test-renderer: 18.2.0
redux: 4.2.1
rn-fetch-blob: 0.12.0
@@ -4652,7 +4640,7 @@ __metadata:
resolution: "@joplin/fork-sax@workspace:packages/fork-sax"
dependencies:
standard: 17.1.0
tap: 16.3.8
tap: 16.3.7
languageName: unknown
linkType: soft
@@ -4920,7 +4908,7 @@ __metadata:
jest-expect-message: 1.1.3
jquery: 3.7.0
jsdom: 22.1.0
knex: 2.5.1
knex: 2.4.2
koa: 2.14.2
markdown-it: 13.0.1
mustache: 4.2.0
@@ -4929,7 +4917,7 @@ __metadata:
node-mocks-http: 1.12.2
nodemailer: 6.9.4
nodemon: 2.0.22
pg: 8.11.2
pg: 8.11.1
pretty-bytes: 5.6.0
prettycron: 0.10.0
query-string: 7.1.3
@@ -5028,7 +5016,6 @@ __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
@@ -21676,46 +21663,7 @@ __metadata:
languageName: node
linkType: hard
"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":
"knex@npm:2.4.2, knex@npm:^2.4.2":
version: 2.4.2
resolution: "knex@npm:2.4.2"
dependencies:
@@ -26536,20 +26484,13 @@ __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"
@@ -26586,14 +26527,14 @@ __metadata:
languageName: node
linkType: hard
"pg@npm:8.11.2":
version: 8.11.2
resolution: "pg@npm:8.11.2"
"pg@npm:8.11.1":
version: 8.11.1
resolution: "pg@npm:8.11.1"
dependencies:
buffer-writer: 2.0.0
packet-reader: 1.0.0
pg-cloudflare: ^1.1.1
pg-connection-string: ^2.6.2
pg-connection-string: ^2.6.1
pg-pool: ^3.6.1
pg-protocol: ^1.6.0
pg-types: ^2.1.0
@@ -26606,7 +26547,7 @@ __metadata:
peerDependenciesMeta:
pg-native:
optional: true
checksum: dfea8a2269d500dee8c17291207e5a25897163480037beb7a59be35f51e33b519c297c943ea6898b285d6a74a0d661901dc9cff2e587cc4be0bbf09b833a71a5
checksum: e608fe1c52725e1c0c4cbdf97e29df8f41b9fd21aed821866e3488b3a0622be1c19801d8d8eb31f0de35f040c4f69163c211358e7df8c48d15ee8f660d2bd4cc
languageName: node
linkType: hard
@@ -27920,9 +27861,9 @@ __metadata:
languageName: node
linkType: hard
"react-native-gesture-handler@npm:2.12.1":
version: 2.12.1
resolution: "react-native-gesture-handler@npm:2.12.1"
"react-native-gesture-handler@npm:2.12.0":
version: 2.12.0
resolution: "react-native-gesture-handler@npm:2.12.0"
dependencies:
"@egjs/hammerjs": ^2.0.17
hoist-non-react-statics: ^3.3.0
@@ -27932,7 +27873,7 @@ __metadata:
peerDependencies:
react: "*"
react-native: "*"
checksum: 1aec3d71c5fd3b8087e613708ee77a0ce7a1ac2bc87f5a6a22529300795b48aaa2359bcba7309f2c14f9de1ca1ffa4904d06b3cb7263820f657a4469976aecb3
checksum: 5147357b3212e269d0b8003e1be9e0993d0770f4880f1a8f52d2d61512c4569c48ec7b866d0a9ee44038785c29e0f84fbb99fd18a8e09552a25910c71602d788
languageName: node
linkType: hard
@@ -27971,6 +27912,15 @@ __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"
@@ -28077,13 +28027,13 @@ __metadata:
languageName: node
linkType: hard
"react-native-safe-area-context@npm:4.7.1":
version: 4.7.1
resolution: "react-native-safe-area-context@npm:4.7.1"
"react-native-safe-area-context@npm:4.6.4":
version: 4.6.4
resolution: "react-native-safe-area-context@npm:4.6.4"
peerDependencies:
react: "*"
react-native: "*"
checksum: 2a07c0b0751c9ceb435c7b107a881080d7b4142fca597ea4701894aeb7c2fec02eeaef094bc28f3d2eaed39f4fc8ef26764c156bf17af93407d723db5608e1e5
checksum: c6905193528dda61268565799d81817b1e436c32c120480084e48c955075e1017b399f763368ef39c4049d6eb0e2c05c3496390857d836b47ddcf861ab60871e
languageName: node
linkType: hard
@@ -28339,9 +28289,9 @@ __metadata:
languageName: node
linkType: hard
"react-redux@npm:8.1.2":
version: 8.1.2
resolution: "react-redux@npm:8.1.2"
"react-redux@npm:8.1.1":
version: 8.1.1
resolution: "react-redux@npm:8.1.1"
dependencies:
"@babel/runtime": ^7.12.1
"@types/hoist-non-react-statics": ^3.3.1
@@ -28367,7 +28317,7 @@ __metadata:
optional: true
redux:
optional: true
checksum: 4d5976b0f721e4148475871fcabce2fee875cc7f70f9a292f3370d63b38aa1dd474eb303c073c5555f3e69fc732f3bac05303def60304775deb28361e3f4b7cc
checksum: 370676330727764d78f35e9c5a0ed0591d79482fe9b70fffcab4aa6bcccc6194e4f1ebd818b4b390351dea5557e70d3bd4d95d7a0ac9baa1f45d6bf2230ee713
languageName: node
linkType: hard
@@ -31748,9 +31698,9 @@ __metadata:
languageName: node
linkType: hard
"tap@npm:16.3.8":
version: 16.3.8
resolution: "tap@npm:16.3.8"
"tap@npm:16.3.7":
version: 16.3.7
resolution: "tap@npm:16.3.7"
dependencies:
"@isaacs/import-jsx": ^4.0.1
"@types/react": ^17.0.52
@@ -31794,7 +31744,7 @@ __metadata:
optional: true
bin:
tap: bin/run.js
checksum: b63e064f1ea20aa4cbe8cd40fbe780def9757b637caaae8ee24d96b184d8627421045dd56168b21715f6ebff77e88db774cda0b80af113ae33432641aefcbb58
checksum: aed5f74698a0d90c5c7e12f07f2b543aa517c4e4f0c54fdbe7a19397448a18bb2ac8a879be923f72ee36aea7fb324c578ed90005ad10cd5aa794018cd002c3f3
languageName: node
linkType: hard