1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-02-01 19:15:01 +02:00

Desktop: Refactor note list in preparation for plugin support (#8624)

Relates to #5389
This commit is contained in:
Laurent Cozic 2023-08-21 16:01:20 +01:00 committed by GitHub
parent e96ad7ccfa
commit 0edc66da49
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 2339 additions and 111 deletions

View File

@ -269,13 +269,36 @@ packages/app-desktop/gui/NoteEditor/utils/usePluginServiceRegistration.js
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.js
packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
packages/app-desktop/gui/NoteList/NoteList.js
packages/app-desktop/gui/NoteList/NoteList2.js
packages/app-desktop/gui/NoteList/NoteListSource.js
packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js
packages/app-desktop/gui/NoteList/commands/index.js
packages/app-desktop/gui/NoteList/types.js
packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.js
packages/app-desktop/gui/NoteList/utils/defaultLeftToRightListRenderer.js
packages/app-desktop/gui/NoteList/utils/defaultListRenderer.js
packages/app-desktop/gui/NoteList/utils/getNoteTitleHtml.js
packages/app-desktop/gui/NoteList/utils/prepareViewProps.js
packages/app-desktop/gui/NoteList/utils/types.js
packages/app-desktop/gui/NoteList/utils/useDragAndDrop.js
packages/app-desktop/gui/NoteList/utils/useFocusNote.js
packages/app-desktop/gui/NoteList/utils/useItemCss.js
packages/app-desktop/gui/NoteList/utils/useMoveNote.js
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.js
packages/app-desktop/gui/NoteList/utils/useOnNoteClick.js
packages/app-desktop/gui/NoteList/utils/useRenderedNotes.js
packages/app-desktop/gui/NoteList/utils/useScroll.js
packages/app-desktop/gui/NoteList/utils/useVisibleRange.test.js
packages/app-desktop/gui/NoteList/utils/useVisibleRange.js
packages/app-desktop/gui/NoteListControls/NoteListControls.js
packages/app-desktop/gui/NoteListControls/commands/focusSearch.js
packages/app-desktop/gui/NoteListControls/commands/index.js
packages/app-desktop/gui/NoteListItem.js
packages/app-desktop/gui/NoteListItem/NoteListItem.js
packages/app-desktop/gui/NoteListItem/utils/types.js
packages/app-desktop/gui/NoteListItem/utils/useItemElement.js
packages/app-desktop/gui/NoteListItem/utils/useItemEventHandlers.js
packages/app-desktop/gui/NoteListItem/utils/useOnContextMenu.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
packages/app-desktop/gui/NotePropertiesDialog.js
packages/app-desktop/gui/NoteRevisionViewer.js

25
.gitignore vendored
View File

@ -255,13 +255,36 @@ packages/app-desktop/gui/NoteEditor/utils/usePluginServiceRegistration.js
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.js
packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
packages/app-desktop/gui/NoteList/NoteList.js
packages/app-desktop/gui/NoteList/NoteList2.js
packages/app-desktop/gui/NoteList/NoteListSource.js
packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js
packages/app-desktop/gui/NoteList/commands/index.js
packages/app-desktop/gui/NoteList/types.js
packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.js
packages/app-desktop/gui/NoteList/utils/defaultLeftToRightListRenderer.js
packages/app-desktop/gui/NoteList/utils/defaultListRenderer.js
packages/app-desktop/gui/NoteList/utils/getNoteTitleHtml.js
packages/app-desktop/gui/NoteList/utils/prepareViewProps.js
packages/app-desktop/gui/NoteList/utils/types.js
packages/app-desktop/gui/NoteList/utils/useDragAndDrop.js
packages/app-desktop/gui/NoteList/utils/useFocusNote.js
packages/app-desktop/gui/NoteList/utils/useItemCss.js
packages/app-desktop/gui/NoteList/utils/useMoveNote.js
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.js
packages/app-desktop/gui/NoteList/utils/useOnNoteClick.js
packages/app-desktop/gui/NoteList/utils/useRenderedNotes.js
packages/app-desktop/gui/NoteList/utils/useScroll.js
packages/app-desktop/gui/NoteList/utils/useVisibleRange.test.js
packages/app-desktop/gui/NoteList/utils/useVisibleRange.js
packages/app-desktop/gui/NoteListControls/NoteListControls.js
packages/app-desktop/gui/NoteListControls/commands/focusSearch.js
packages/app-desktop/gui/NoteListControls/commands/index.js
packages/app-desktop/gui/NoteListItem.js
packages/app-desktop/gui/NoteListItem/NoteListItem.js
packages/app-desktop/gui/NoteListItem/utils/types.js
packages/app-desktop/gui/NoteListItem/utils/useItemElement.js
packages/app-desktop/gui/NoteListItem/utils/useItemEventHandlers.js
packages/app-desktop/gui/NoteListItem/utils/useOnContextMenu.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
packages/app-desktop/gui/NotePropertiesDialog.js
packages/app-desktop/gui/NoteRevisionViewer.js

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -139,6 +139,7 @@
"@joplin/lib": "~2.12",
"@joplin/renderer": "~2.12",
"@joplin/utils": "~2.12",
"@types/mustache": "4.2.2",
"async-mutex": "0.4.0",
"codemirror": "5.65.9",
"color": "3.2.1",
@ -154,6 +155,7 @@
"mark.js": "8.11.1",
"md5": "2.3.0",
"moment": "2.29.4",
"mustache": "4.2.0",
"node-fetch": "2.6.7",
"node-notifier": "10.0.1",
"node-rsa": "1.1.1",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

@ -4445,6 +4445,7 @@ __metadata:
"@joplin/utils": ~2.12
"@testing-library/react-hooks": 8.0.1
"@types/jest": 29.5.3
"@types/mustache": 4.2.2
"@types/node": 18.16.18
"@types/react": 18.0.24
"@types/react-redux": 7.1.25
@ -4471,6 +4472,7 @@ __metadata:
mark.js: 8.11.1
md5: 2.3.0
moment: 2.29.4
mustache: 4.2.0
nan: 2.17.0
node-fetch: 2.6.7
node-notifier: 10.0.1
@ -5026,6 +5028,7 @@ __metadata:
execa: 5.1.1
fs-extra: 11.1.1
glob: 10.3.3
html-entities: 1.4.0
jest: 29.5.0
moment: 2.29.4
node-fetch: 2.6.7