You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Desktop: Refactor note list in preparation for plugin support (#8624)
Relates to #5389
This commit is contained in:
		| @@ -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
									
									
								
							
							
						
						
									
										25
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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[]; | ||||
|   | ||||
| @@ -17,7 +17,7 @@ import ItemList from '../ItemList'; | ||||
| const { connect } = require('react-redux'); | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
| import Folder from '@joplin/lib/models/Folder'; | ||||
| import { Props } from './types'; | ||||
| import { Props } from './utils/types'; | ||||
| import usePrevious from '../hooks/usePrevious'; | ||||
| import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly'; | ||||
| import { FolderEntity } from '@joplin/lib/services/database/types'; | ||||
|   | ||||
							
								
								
									
										304
									
								
								packages/app-desktop/gui/NoteList/NoteList2.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										304
									
								
								packages/app-desktop/gui/NoteList/NoteList2.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,304 @@ | ||||
| import * as React from 'react'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { useMemo, useRef, useEffect } from 'react'; | ||||
| import { AppState } from '../../app.reducer'; | ||||
| import BaseModel, { ModelType } from '@joplin/lib/BaseModel'; | ||||
| import { ItemFlow, Props } from './utils/types'; | ||||
| import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly'; | ||||
| import { FolderEntity } from '@joplin/lib/services/database/types'; | ||||
| import ItemChange from '@joplin/lib/models/ItemChange'; | ||||
| import { Size } from '@joplin/utils/types'; | ||||
| import NoteListItem from '../NoteListItem/NoteListItem'; | ||||
| import useRenderedNotes from './utils/useRenderedNotes'; | ||||
| import useItemCss from './utils/useItemCss'; | ||||
| import useOnContextMenu from '../NoteListItem/utils/useOnContextMenu'; | ||||
| import useVisibleRange from './utils/useVisibleRange'; | ||||
| import useScroll from './utils/useScroll'; | ||||
| import useFocusNote from './utils/useFocusNote'; | ||||
| import useOnNoteClick from './utils/useOnNoteClick'; | ||||
| import useMoveNote from './utils/useMoveNote'; | ||||
| import useOnKeyDown from './utils/useOnKeyDown'; | ||||
| import * as focusElementNoteList from './commands/focusElementNoteList'; | ||||
| import CommandService from '@joplin/lib/services/CommandService'; | ||||
| import useDragAndDrop from './utils/useDragAndDrop'; | ||||
| import usePrevious from '../hooks/usePrevious'; | ||||
| // import defaultLeftToRightItemRenderer from './utils/defaultLeftToRightListRenderer'; | ||||
| import defaultListRenderer from './utils/defaultListRenderer'; | ||||
| const { connect } = require('react-redux'); | ||||
|  | ||||
| const commands = { | ||||
| 	focusElementNoteList, | ||||
| }; | ||||
|  | ||||
| const NoteList = (props: Props) => { | ||||
| 	const listRef = useRef(null); | ||||
| 	const itemRefs = useRef<Record<string, HTMLDivElement>>({}); | ||||
| 	// const listRenderer = defaultLeftToRightItemRenderer; | ||||
| 	const listRenderer = defaultListRenderer; | ||||
|  | ||||
| 	const itemSize: Size = useMemo(() => { | ||||
| 		return { | ||||
| 			width: listRenderer.itemSize.width ? listRenderer.itemSize.width : props.size.width, | ||||
| 			height: listRenderer.itemSize.height, | ||||
| 		}; | ||||
| 	}, [listRenderer.itemSize, props.size.width]); | ||||
|  | ||||
| 	const itemsPerLine = useMemo(() => { | ||||
| 		if (listRenderer.flow === ItemFlow.TopToBottom) { | ||||
| 			return 1; | ||||
| 		} else { | ||||
| 			return Math.max(1, Math.floor(props.size.width / itemSize.width)); | ||||
| 		} | ||||
| 	}, [listRenderer.flow, props.size.width, itemSize.width]); | ||||
|  | ||||
| 	const { scrollTop, onScroll, makeItemIndexVisible } = useScroll( | ||||
| 		itemsPerLine, | ||||
| 		props.notes.length, | ||||
| 		itemSize, | ||||
| 		props.size, | ||||
| 		listRef | ||||
| 	); | ||||
|  | ||||
| 	const [startNoteIndex, endNoteIndex, startLineIndex, endLineIndex, totalLineCount, visibleItemCount] = useVisibleRange( | ||||
| 		itemsPerLine, | ||||
| 		scrollTop, | ||||
| 		props.size, | ||||
| 		itemSize, | ||||
| 		props.notes.length | ||||
| 	); | ||||
|  | ||||
| 	const focusNote = useFocusNote(itemRefs); | ||||
|  | ||||
| 	const moveNote = useMoveNote( | ||||
| 		props.notesParentType, | ||||
| 		props.noteSortOrder, | ||||
| 		props.selectedNoteIds, | ||||
| 		props.selectedFolderId, | ||||
| 		props.uncompletedTodosOnTop, | ||||
| 		props.showCompletedTodos, | ||||
| 		props.notes | ||||
| 	); | ||||
|  | ||||
| 	const renderedNotes = useRenderedNotes( | ||||
| 		startNoteIndex, | ||||
| 		endNoteIndex, | ||||
| 		props.notes, | ||||
| 		props.selectedNoteIds, | ||||
| 		listRenderer, | ||||
| 		props.highlightedWords, | ||||
| 		props.watchedNoteFiles | ||||
| 	); | ||||
|  | ||||
| 	const noteItemStyle = useMemo(() => { | ||||
| 		return { | ||||
| 			width: 'auto', | ||||
| 			height: itemSize.height, | ||||
| 		}; | ||||
| 	}, [itemSize.height]); | ||||
|  | ||||
| 	const noteListStyle = useMemo(() => { | ||||
| 		return { | ||||
| 			width: props.size.width, | ||||
| 			height: props.size.height, | ||||
| 		}; | ||||
| 	}, [props.size]); | ||||
|  | ||||
| 	const onNoteClick = useOnNoteClick(props.dispatch, focusNote); | ||||
|  | ||||
| 	const onKeyDown = useOnKeyDown( | ||||
| 		props.selectedNoteIds, | ||||
| 		moveNote, | ||||
| 		makeItemIndexVisible, | ||||
| 		focusNote, | ||||
| 		props.notes, | ||||
| 		props.dispatch, | ||||
| 		visibleItemCount, | ||||
| 		props.notes.length, | ||||
| 		listRenderer.flow, | ||||
| 		itemsPerLine | ||||
| 	); | ||||
|  | ||||
| 	useItemCss(listRenderer.itemCss); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		CommandService.instance().registerRuntime(commands.focusElementNoteList.declaration.name, commands.focusElementNoteList.runtime(focusNote)); | ||||
| 		return () => { | ||||
| 			CommandService.instance().unregisterRuntime(commands.focusElementNoteList.declaration.name); | ||||
| 		}; | ||||
| 	}, [focusNote]); | ||||
|  | ||||
| 	const onItemContextMenu = useOnContextMenu( | ||||
| 		props.selectedNoteIds, | ||||
| 		props.selectedFolderId, | ||||
| 		props.notes, | ||||
| 		props.dispatch, | ||||
| 		props.watchedNoteFiles, | ||||
| 		props.plugins, | ||||
| 		props.customCss | ||||
| 	); | ||||
|  | ||||
| 	const { onDragStart, onDragOver, onDrop, dragOverTargetNoteIndex } = useDragAndDrop(props.parentFolderIsReadOnly, | ||||
| 		props.selectedNoteIds, | ||||
| 		props.selectedFolderId, | ||||
| 		listRef, | ||||
| 		scrollTop, | ||||
| 		itemSize, | ||||
| 		props.notesParentType, | ||||
| 		props.noteSortOrder, | ||||
| 		props.uncompletedTodosOnTop, | ||||
| 		props.showCompletedTodos, | ||||
| 		listRenderer.flow, | ||||
| 		itemsPerLine | ||||
| 	); | ||||
|  | ||||
| 	const previousSelectedNoteIds = usePrevious(props.selectedNoteIds, []); | ||||
| 	const previousNoteCount = usePrevious(props.notes.length, 0); | ||||
| 	const previousVisible = usePrevious(props.visible, false); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (previousSelectedNoteIds !== props.selectedNoteIds && props.selectedNoteIds.length === 1) { | ||||
| 			const id = props.selectedNoteIds[0]; | ||||
| 			const doRefocus = props.notes.length < previousNoteCount && !props.focusedField; | ||||
|  | ||||
| 			for (let i = 0; i < props.notes.length; i++) { | ||||
| 				if (props.notes[i].id === id) { | ||||
| 					makeItemIndexVisible(i); | ||||
| 					if (doRefocus) { | ||||
| 						const ref = itemRefs.current[id]; | ||||
| 						if (ref) ref.focus(); | ||||
| 					} | ||||
| 					break; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	}, [makeItemIndexVisible, previousSelectedNoteIds, previousNoteCount, previousVisible, props.selectedNoteIds, props.notes, props.focusedField, props.visible]); | ||||
|  | ||||
| 	const highlightedWords = useMemo(() => { | ||||
| 		if (props.notesParentType === 'Search') { | ||||
| 			const query = BaseModel.byId(props.searches, props.selectedSearchId); | ||||
| 			if (query) return props.highlightedWords; | ||||
| 		} | ||||
| 		return []; | ||||
| 	}, [props.notesParentType, props.searches, props.selectedSearchId, props.highlightedWords]); | ||||
|  | ||||
| 	const renderEmptyList = () => { | ||||
| 		if (props.notes.length) return null; | ||||
| 		return <div className="emptylist">{props.folders.length ? _('No notes in here. Create one by clicking on "New note".') : _('There is currently no notebook. Create one by clicking on "New notebook".')}</div>; | ||||
| 	}; | ||||
|  | ||||
| 	const renderFiller = (key: string, style: React.CSSProperties) => { | ||||
| 		if (!props.notes.length) return null; | ||||
| 		if (style.height as number <= 0) return null; | ||||
| 		return <div key={key} style={style}></div>; | ||||
| 	}; | ||||
|  | ||||
| 	const renderNotes = () => { | ||||
| 		if (!props.notes.length) return null; | ||||
|  | ||||
| 		const output: JSX.Element[] = []; | ||||
|  | ||||
| 		for (let i = startNoteIndex; i <= endNoteIndex; i++) { | ||||
| 			const note = props.notes[i]; | ||||
| 			const renderedNote = renderedNotes[note.id]; | ||||
|  | ||||
| 			output.push( | ||||
| 				<NoteListItem | ||||
| 					key={note.id} | ||||
| 					ref={el => itemRefs.current[note.id] = el} | ||||
| 					index={i} | ||||
| 					dragIndex={dragOverTargetNoteIndex} | ||||
| 					noteCount={props.notes.length} | ||||
| 					itemSize={itemSize} | ||||
| 					noteHtml={renderedNote ? renderedNote.html : ''} | ||||
| 					noteId={note.id} | ||||
| 					onChange={listRenderer.onChange} | ||||
| 					onClick={onNoteClick} | ||||
| 					onContextMenu={onItemContextMenu} | ||||
| 					onDragStart={onDragStart} | ||||
| 					onDragOver={onDragOver} | ||||
| 					style={noteItemStyle} | ||||
| 					highlightedWords={highlightedWords} | ||||
| 					isProvisional={props.provisionalNoteIds.includes(note.id)} | ||||
| 					flow={listRenderer.flow} | ||||
| 				/> | ||||
| 			); | ||||
| 		} | ||||
|  | ||||
| 		return output; | ||||
| 	}; | ||||
|  | ||||
| 	const topFillerHeight = startLineIndex * itemSize.height; | ||||
| 	const bottomFillerHeight = (totalLineCount - endLineIndex - 1) * itemSize.height; | ||||
|  | ||||
| 	const fillerBaseStyle = useMemo(() => { | ||||
| 		// return { width: 'auto', border: '1px solid red', backgroundColor: 'green' }; | ||||
| 		return { width: 'auto' }; | ||||
| 	}, []); | ||||
|  | ||||
| 	const topFillerStyle = useMemo(() => { | ||||
| 		return { ...fillerBaseStyle, height: topFillerHeight }; | ||||
| 	}, [fillerBaseStyle, topFillerHeight]); | ||||
|  | ||||
| 	const bottomFillerStyle = useMemo(() => { | ||||
| 		return { ...fillerBaseStyle, height: bottomFillerHeight }; | ||||
| 	}, [fillerBaseStyle, bottomFillerHeight]); | ||||
|  | ||||
| 	const notesStyle = useMemo(() => { | ||||
| 		const output: React.CSSProperties = {}; | ||||
|  | ||||
| 		if (listRenderer.flow === ItemFlow.LeftToRight) { | ||||
| 			output.flexFlow = 'row wrap'; | ||||
| 		} else { | ||||
| 			output.flexDirection = 'column'; | ||||
| 		} | ||||
|  | ||||
| 		return output; | ||||
| 	}, [listRenderer.flow]); | ||||
|  | ||||
| 	return ( | ||||
| 		<div | ||||
| 			className="note-list" | ||||
| 			style={noteListStyle} | ||||
| 			ref={listRef} | ||||
| 			onScroll={onScroll} | ||||
| 			onKeyDown={onKeyDown} | ||||
| 			onDrop={onDrop} | ||||
| 		> | ||||
| 			{renderEmptyList()} | ||||
| 			{renderFiller('top', topFillerStyle)} | ||||
| 			<div className="notes" style={notesStyle}> | ||||
| 				{renderNotes()} | ||||
| 			</div> | ||||
| 			{renderFiller('bottom', bottomFillerStyle)} | ||||
| 		</div> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| const mapStateToProps = (state: AppState) => { | ||||
| 	const selectedFolder: FolderEntity = state.notesParentType === 'Folder' ? BaseModel.byId(state.folders, state.selectedFolderId) : null; | ||||
| 	const userId = state.settings['sync.userId']; | ||||
|  | ||||
| 	return { | ||||
| 		notes: state.notes, | ||||
| 		folders: state.folders, | ||||
| 		selectedNoteIds: state.selectedNoteIds, | ||||
| 		selectedFolderId: state.selectedFolderId, | ||||
| 		themeId: state.settings.theme, | ||||
| 		notesParentType: state.notesParentType, | ||||
| 		searches: state.searches, | ||||
| 		selectedSearchId: state.selectedSearchId, | ||||
| 		watchedNoteFiles: state.watchedNoteFiles, | ||||
| 		provisionalNoteIds: state.provisionalNoteIds, | ||||
| 		isInsertingNotes: state.isInsertingNotes, | ||||
| 		noteSortOrder: state.settings['notes.sortOrder.field'], | ||||
| 		uncompletedTodosOnTop: state.settings.uncompletedTodosOnTop, | ||||
| 		showCompletedTodos: state.settings.showCompletedTodos, | ||||
| 		highlightedWords: state.highlightedWords, | ||||
| 		plugins: state.pluginService.plugins, | ||||
| 		customCss: state.customCss, | ||||
| 		focusedField: state.focusedField, | ||||
| 		parentFolderIsReadOnly: state.notesParentType === 'Folder' && selectedFolder ? itemIsReadOnlySync(ModelType.Folder, ItemChange.SOURCE_UNSPECIFIED, selectedFolder as ItemSlice, userId, state.shareService) : false, | ||||
| 	}; | ||||
| }; | ||||
|  | ||||
| export default connect(mapStateToProps)(NoteList); | ||||
							
								
								
									
										113
									
								
								packages/app-desktop/gui/NoteList/NoteListSource.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								packages/app-desktop/gui/NoteList/NoteListSource.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,113 @@ | ||||
| import * as React from 'react'; | ||||
| import { useMemo, useState, useRef, useCallback } from 'react'; | ||||
| import { AppState } from '../../app.reducer'; | ||||
| import BaseModel, { ModelType } from '@joplin/lib/BaseModel'; | ||||
| import NoteListItem from '../NoteListItem'; | ||||
| import styled from 'styled-components'; | ||||
| import ItemList from '../ItemList'; | ||||
| const { connect } = require('react-redux'); | ||||
| import { Props } from './utils/types'; | ||||
| import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly'; | ||||
| import { FolderEntity } from '@joplin/lib/services/database/types'; | ||||
| import ItemChange from '@joplin/lib/models/ItemChange'; | ||||
|  | ||||
| const StyledRoot = styled.div``; | ||||
|  | ||||
| const NoteListComponent = (props: Props) => { | ||||
| 	const [width] = useState(0); | ||||
|  | ||||
| 	const itemHeight = 34; | ||||
|  | ||||
| 	const noteListRef = useRef(null); | ||||
| 	const itemListRef = useRef(null); | ||||
|  | ||||
| 	const style = useMemo(() => { | ||||
| 		return {}; | ||||
| 	}, []); | ||||
|  | ||||
| 	const renderItem = useCallback((item: any, index: number) => { | ||||
| 		return <NoteListItem | ||||
| 			key={item.id} | ||||
| 			style={style} | ||||
| 			item={item} | ||||
| 			index={index} | ||||
| 			themeId={props.themeId} | ||||
| 			width={width} | ||||
| 			height={itemHeight} | ||||
| 			dragItemIndex={0} | ||||
| 			highlightedWords={[]} | ||||
| 			isProvisional={props.provisionalNoteIds.includes(item.id)} | ||||
| 			isSelected={props.selectedNoteIds.indexOf(item.id) >= 0} | ||||
| 			isWatched={props.watchedNoteFiles.indexOf(item.id) < 0} | ||||
| 			itemCount={props.notes.length} | ||||
| 			onCheckboxClick={() => {}} | ||||
| 			onDragStart={()=>{}} | ||||
| 			onNoteDragOver={()=>{}} | ||||
| 			onTitleClick={() => {}} | ||||
| 			onContextMenu={() => {}} | ||||
| 			draggable={!props.parentFolderIsReadOnly} | ||||
| 		/>; | ||||
| 		// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied | ||||
| 	}, [style, props.themeId, width, itemHeight, props.provisionalNoteIds, props.selectedNoteIds, props.watchedNoteFiles, | ||||
| 		props.notes, | ||||
| 		props.notesParentType, | ||||
| 		props.searches, | ||||
| 		props.selectedSearchId, | ||||
| 		props.highlightedWords, | ||||
| 		props.parentFolderIsReadOnly, | ||||
| 	]); | ||||
| 	const renderItemList = () => { | ||||
| 		if (!props.notes.length) return null; | ||||
|  | ||||
| 		return ( | ||||
| 			<ItemList | ||||
| 				ref={itemListRef} | ||||
| 				disabled={props.isInsertingNotes} | ||||
| 				itemHeight={32} | ||||
| 				className={'note-list'} | ||||
| 				items={props.notes} | ||||
| 				style={props.size} | ||||
| 				itemRenderer={renderItem} | ||||
| 				onKeyDown={() => {}} | ||||
| 				onNoteDrop={()=>{}} | ||||
| 			/> | ||||
| 		); | ||||
| 	}; | ||||
|  | ||||
| 	if (!props.size) throw new Error('props.size is required'); | ||||
|  | ||||
| 	return ( | ||||
| 		<StyledRoot ref={noteListRef}> | ||||
| 			{renderItemList()} | ||||
| 		</StyledRoot> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| const mapStateToProps = (state: AppState) => { | ||||
| 	const selectedFolder: FolderEntity = state.notesParentType === 'Folder' ? BaseModel.byId(state.folders, state.selectedFolderId) : null; | ||||
| 	const userId = state.settings['sync.userId']; | ||||
|  | ||||
| 	return { | ||||
| 		notes: state.notes, | ||||
| 		folders: state.folders, | ||||
| 		selectedNoteIds: state.selectedNoteIds, | ||||
| 		selectedFolderId: state.selectedFolderId, | ||||
| 		themeId: state.settings.theme, | ||||
| 		notesParentType: state.notesParentType, | ||||
| 		searches: state.searches, | ||||
| 		selectedSearchId: state.selectedSearchId, | ||||
| 		watchedNoteFiles: state.watchedNoteFiles, | ||||
| 		provisionalNoteIds: state.provisionalNoteIds, | ||||
| 		isInsertingNotes: state.isInsertingNotes, | ||||
| 		noteSortOrder: state.settings['notes.sortOrder.field'], | ||||
| 		uncompletedTodosOnTop: state.settings.uncompletedTodosOnTop, | ||||
| 		showCompletedTodos: state.settings.showCompletedTodos, | ||||
| 		highlightedWords: state.highlightedWords, | ||||
| 		plugins: state.pluginService.plugins, | ||||
| 		customCss: state.customCss, | ||||
| 		focusedField: state.focusedField, | ||||
| 		parentFolderIsReadOnly: state.notesParentType === 'Folder' && selectedFolder ? itemIsReadOnlySync(ModelType.Folder, ItemChange.SOURCE_UNSPECIFIED, selectedFolder as ItemSlice, userId, state.shareService) : false, | ||||
| 	}; | ||||
| }; | ||||
|  | ||||
| export default connect(mapStateToProps)(NoteListComponent); | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { stateUtils } from '@joplin/lib/reducer'; | ||||
| import { itemAnchorRef } from '../NoteList'; | ||||
| import { FocusNote } from '../utils/useFocusNote'; | ||||
|  | ||||
| export const declaration: CommandDeclaration = { | ||||
| 	name: 'focusElementNoteList', | ||||
| @@ -9,15 +9,11 @@ export const declaration: CommandDeclaration = { | ||||
| 	parentLabel: () => _('Focus'), | ||||
| }; | ||||
|  | ||||
| export const runtime = (): CommandRuntime => { | ||||
| export const runtime = (focusNote: FocusNote): CommandRuntime => { | ||||
| 	return { | ||||
| 		execute: async (context: CommandContext, noteId: string = null) => { | ||||
| 			noteId = noteId || stateUtils.selectedNoteId(context.state); | ||||
|  | ||||
| 			if (noteId) { | ||||
| 				const ref = itemAnchorRef(noteId); | ||||
| 				if (ref) ref.focus(); | ||||
| 			} | ||||
| 			focusNote(noteId); | ||||
| 		}, | ||||
| 		enabledCondition: 'noteListHasNotes', | ||||
| 	}; | ||||
|   | ||||
							
								
								
									
										43
									
								
								packages/app-desktop/gui/NoteList/style.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								packages/app-desktop/gui/NoteList/style.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| .note-list { | ||||
| 	width: 100%; | ||||
| 	height: 100%; | ||||
| 	background-color: var(--joplin-background-color3); | ||||
| 	border-right: 1px solid var(--joplin-divider-color); | ||||
| 	overflow-x: hidden; | ||||
| 	overflow-y: scroll; | ||||
|  | ||||
| 	> .notes { | ||||
| 		display: flex; | ||||
| 		overflow-x: hidden; | ||||
| 	} | ||||
|  | ||||
| 	> .emptylist { | ||||
| 		padding: 10px; | ||||
| 		font-size: var(--joplin-font-size); | ||||
| 		color: var(--joplin-color); | ||||
| 		background-color: var(--joplin-background-color); | ||||
| 		font-family: var(--joplin-font-family); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .note-list-item { | ||||
| 	display: flex; | ||||
| } | ||||
|  | ||||
| .note-list-item-wrapper { | ||||
| 	border-color: var(--joplin-color); | ||||
| 	position: relative; | ||||
| 	box-sizing: border-box; | ||||
|  | ||||
| 	> .dragcursor { | ||||
| 		background-color: var(--joplin-color); | ||||
| 		position: absolute; | ||||
| 		z-index: 1000; | ||||
| 		width: 2px; | ||||
| 		height: 2px; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .note-list-item-wrapper.-provisional { | ||||
| 	opacity: 0.5; | ||||
| } | ||||
| @@ -1,29 +0,0 @@ | ||||
| import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types'; | ||||
| import { PluginStates } from '@joplin/lib/services/plugins/reducer'; | ||||
|  | ||||
| export interface Props { | ||||
| 	themeId: any; | ||||
| 	selectedNoteIds: string[]; | ||||
| 	notes: NoteEntity[]; | ||||
| 	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | ||||
| 	dispatch: Function; | ||||
| 	watchedNoteFiles: any[]; | ||||
| 	plugins: PluginStates; | ||||
| 	selectedFolderId: string; | ||||
| 	customCss: string; | ||||
| 	notesParentType: string; | ||||
| 	noteSortOrder: string; | ||||
| 	uncompletedTodosOnTop: boolean; | ||||
| 	showCompletedTodos: boolean; | ||||
| 	resizableLayoutEventEmitter: any; | ||||
| 	isInsertingNotes: boolean; | ||||
| 	folders: FolderEntity[]; | ||||
| 	size: any; | ||||
| 	searches: any[]; | ||||
| 	selectedSearchId: string; | ||||
| 	highlightedWords: string[]; | ||||
| 	provisionalNoteIds: string[]; | ||||
| 	visible: boolean; | ||||
| 	focusedField: string; | ||||
| 	parentFolderIsReadOnly: boolean; | ||||
| } | ||||
| @@ -0,0 +1,20 @@ | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import bridge from '../../../services/bridge'; | ||||
|  | ||||
| const canManuallySortNotes = (notesParentType: string, noteSortOrder: string) => { | ||||
| 	if (notesParentType !== 'Folder') return false; | ||||
|  | ||||
| 	if (noteSortOrder !== 'order') { | ||||
| 		const doIt = bridge().showConfirmMessageBox(_('To manually sort the notes, the sort order must be changed to "%s" in the menu "%s" > "%s"', _('Custom order'), _('View'), _('Sort notes by')), { | ||||
| 			buttons: [_('Do it now'), _('Cancel')], | ||||
| 		}); | ||||
| 		if (!doIt) return false; | ||||
|  | ||||
| 		Setting.setValue('notes.sortOrder.field', 'order'); | ||||
| 		return false; | ||||
| 	} | ||||
| 	return true; | ||||
| }; | ||||
|  | ||||
| export default canManuallySortNotes; | ||||
| @@ -0,0 +1,165 @@ | ||||
| import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer'; | ||||
| import { ItemFlow, ListRenderer } from './types'; | ||||
|  | ||||
| interface Props { | ||||
| 	note: { | ||||
| 		id: string; | ||||
| 		title: string; | ||||
| 		is_todo: number; | ||||
| 		todo_completed: number; | ||||
| 		body: string; | ||||
| 	}; | ||||
| 	item: { | ||||
| 		size: { | ||||
| 			width: number; | ||||
| 			height: number; | ||||
| 		}; | ||||
| 		selected: boolean; | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| const defaultLeftToRightItemRenderer: ListRenderer = { | ||||
| 	flow: ItemFlow.LeftToRight, | ||||
|  | ||||
| 	itemSize: { | ||||
| 		width: 150, | ||||
| 		height: 150, | ||||
| 	}, | ||||
|  | ||||
| 	dependencies: [ | ||||
| 		'item.selected', | ||||
| 		'item.size.width', | ||||
| 		'item.size.height', | ||||
| 		'note.body', | ||||
| 		'note.id', | ||||
| 		'note.is_shared', | ||||
| 		'note.is_todo', | ||||
| 		'note.isWatched', | ||||
| 		'note.titleHtml', | ||||
| 		'note.todo_completed', | ||||
| 	], | ||||
|  | ||||
| 	itemCss: // css | ||||
| 		`			 | ||||
| 		&:before { | ||||
| 			content: ''; | ||||
| 			border-bottom: 1px solid var(--joplin-divider-color); | ||||
| 			width: 90%; | ||||
| 			position: absolute; | ||||
| 			bottom: 0; | ||||
| 			left: 5%; | ||||
| 		} | ||||
| 	 | ||||
| 		> .content.-selected { | ||||
| 			background-color: var(--joplin-selected-color); | ||||
| 		} | ||||
|  | ||||
| 		&:hover { | ||||
| 			background-color: var(--joplin-background-color-hover3); | ||||
| 		} | ||||
| 	 | ||||
| 		> .content { | ||||
| 			display: flex; | ||||
| 			box-sizing: border-box; | ||||
| 			position: relative; | ||||
| 			width: 100%; | ||||
| 			padding: 16px; | ||||
| 			align-items: flex-start; | ||||
| 			overflow-y: hidden; | ||||
| 			flex-direction: column; | ||||
| 			user-select: none; | ||||
| 	 | ||||
| 			> .checkbox { | ||||
| 				display: flex; | ||||
| 				align-items: center; | ||||
|  | ||||
| 				> input { | ||||
| 					margin: 0px 10px 1px 0px; | ||||
| 				} | ||||
| 			} | ||||
| 	 | ||||
| 			> .title { | ||||
| 				font-family: var(--joplin-font-family); | ||||
| 				font-size: var(--joplin-font-size); | ||||
| 				color: var(--joplin-color); | ||||
| 				cursor: default; | ||||
| 				flex: 0; | ||||
| 				display: flex; | ||||
| 				align-items: flex-start; | ||||
| 				margin-bottom: 8px; | ||||
|  | ||||
| 				> .checkbox { | ||||
| 					margin: 0 6px 0 0; | ||||
| 				} | ||||
|  | ||||
| 				> .watchedicon { | ||||
| 					display: none; | ||||
| 					padding-right: 4px; | ||||
| 					color: var(--joplin-color); | ||||
| 				} | ||||
|  | ||||
| 				> .titlecontent { | ||||
| 					word-break: break-all; | ||||
| 					overflow: hidden; | ||||
| 					text-overflow: ellipsis; | ||||
| 					text-wrap: nowrap; | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			> .preview { | ||||
| 				overflow-y: hidden; | ||||
| 				font-family: var(--joplin-font-family); | ||||
| 				font-size: var(--joplin-font-size); | ||||
| 				color: var(--joplin-color); | ||||
| 				cursor: default; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		> .content.-shared { | ||||
| 			> .title { | ||||
| 				color: var(--joplin-color-warn3); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		> .content.-completed { | ||||
| 			> .title { | ||||
| 				opacity: 0.5; | ||||
| 				text-decoration: line-through; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		> .content.-watched { | ||||
| 			> .title { | ||||
| 				> .watchedicon { | ||||
| 					display: inline; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	`, | ||||
|  | ||||
| 	itemTemplate: // html | ||||
| 		` | ||||
| 		<div class="content {{#item.selected}}-selected{{/item.selected}} {{#note.is_shared}}-shared{{/note.is_shared}} {{#note.todo_completed}}-completed{{/note.todo_completed}} {{#note.isWatched}}-watched{{/note.isWatched}}"> | ||||
| 			<div style="width: {{titleWidth}}px;" class="title" data-id="{{note.id}}"> | ||||
| 				{{#note.is_todo}} | ||||
| 					<input class="checkbox" data-id="todo-checkbox" type="checkbox" {{#note.todo_completed}}checked="checked"{{/note.todo_completed}}> | ||||
| 				{{/note.is_todo}} | ||||
| 				<i class="watchedicon fa fa-share-square"></i> | ||||
| 				<div class="titlecontent">{{{note.titleHtml}}}</div> | ||||
| 			</div> | ||||
| 			<div class="preview">{{notePreview}}</div> | ||||
| 		</div> | ||||
| 	`, | ||||
|  | ||||
| 	onRenderNote: async (props: Props) => { | ||||
| 		const markupToHtml_ = new MarkupToHtml(); | ||||
|  | ||||
| 		return { | ||||
| 			...props, | ||||
| 			notePreview: markupToHtml_.stripMarkup(MarkupLanguage.Markdown, props.note.body).substring(0, 200), | ||||
| 			titleWidth: props.item.size.width - 32, | ||||
| 		}; | ||||
| 	}, | ||||
| }; | ||||
|  | ||||
| export default defaultLeftToRightItemRenderer; | ||||
							
								
								
									
										134
									
								
								packages/app-desktop/gui/NoteList/utils/defaultListRenderer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								packages/app-desktop/gui/NoteList/utils/defaultListRenderer.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,134 @@ | ||||
| import { ItemFlow, ListRenderer } from './types'; | ||||
|  | ||||
| interface Props { | ||||
| 	note: { | ||||
| 		id: string; | ||||
| 		title: string; | ||||
| 		is_todo: number; | ||||
| 		todo_completed: number; | ||||
| 	}; | ||||
| 	item: { | ||||
| 		size: { | ||||
| 			height: number; | ||||
| 		}; | ||||
| 		selected: boolean; | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| const defaultItemRenderer: ListRenderer = { | ||||
| 	flow: ItemFlow.TopToBottom, | ||||
|  | ||||
| 	itemSize: { | ||||
| 		width: 0, | ||||
| 		height: 34, | ||||
| 	}, | ||||
|  | ||||
| 	dependencies: [ | ||||
| 		'item.selected', | ||||
| 		'item.size.height', | ||||
| 		'note.id', | ||||
| 		'note.is_shared', | ||||
| 		'note.is_todo', | ||||
| 		'note.isWatched', | ||||
| 		'note.titleHtml', | ||||
| 		'note.todo_completed', | ||||
| 	], | ||||
|  | ||||
| 	itemCss: // css | ||||
| 		`	 | ||||
| 		&:before { | ||||
| 			content: ''; | ||||
| 			border-bottom: 1px solid var(--joplin-divider-color); | ||||
| 			width: 90%; | ||||
| 			position: absolute; | ||||
| 			bottom: 0; | ||||
| 			left: 5%; | ||||
| 		} | ||||
| 	 | ||||
| 		> .content.-selected { | ||||
| 			background-color: var(--joplin-selected-color); | ||||
| 		} | ||||
|  | ||||
| 		&:hover { | ||||
| 			background-color: var(--joplin-background-color-hover3); | ||||
| 		} | ||||
| 	 | ||||
| 		> .content { | ||||
| 			display: flex; | ||||
| 			box-sizing: border-box; | ||||
| 			position: relative; | ||||
| 			width: 100%; | ||||
| 			padding-left: 16px; | ||||
| 	 | ||||
| 			> .checkbox { | ||||
| 				display: flex; | ||||
| 				align-items: center; | ||||
|  | ||||
| 				> input { | ||||
| 					margin: 0px 10px 1px 0px; | ||||
| 				} | ||||
| 			} | ||||
| 	 | ||||
| 			> .title { | ||||
| 				font-family: var(--joplin-font-family); | ||||
| 				font-size: var(--joplin-font-size); | ||||
| 				text-decoration: none; | ||||
| 				color: var(--joplin-color); | ||||
| 				cursor: default; | ||||
| 				white-space: nowrap; | ||||
| 				flex: 1 1 0%; | ||||
| 				display: flex; | ||||
| 				align-items: center; | ||||
| 				overflow: hidden; | ||||
|  | ||||
| 				> .watchedicon { | ||||
| 					display: none; | ||||
| 					padding-right: 4px; | ||||
| 					color: var(--joplin-color); | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		> .content.-shared { | ||||
| 			> .title { | ||||
| 				color: var(--joplin-color-warn3); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		> .content.-completed { | ||||
| 			> .title { | ||||
| 				opacity: 0.5; | ||||
| 				text-decoration: line-through; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		> .content.-watched { | ||||
| 			> .title { | ||||
| 				> .watchedicon { | ||||
| 					display: inline; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	`, | ||||
|  | ||||
| 	itemTemplate: // html | ||||
| 		` | ||||
| 		<div class="content {{#item.selected}}-selected{{/item.selected}} {{#note.is_shared}}-shared{{/note.is_shared}} {{#note.todo_completed}}-completed{{/note.todo_completed}} {{#note.isWatched}}-watched{{/note.isWatched}}"> | ||||
| 			{{#note.is_todo}} | ||||
| 				<div class="checkbox"> | ||||
| 					<input data-id="todo-checkbox" type="checkbox" {{#note.todo_completed}}checked="checked"{{/note.todo_completed}}> | ||||
| 				</div> | ||||
| 			{{/note.is_todo}}	 | ||||
| 			<div class="title" data-id="{{note.id}}"> | ||||
| 				<i class="watchedicon fa fa-share-square"></i> | ||||
| 				<span>{{{note.titleHtml}}}</span> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	`, | ||||
|  | ||||
| 	onRenderNote: async (props: Props) => { | ||||
| 		return props; | ||||
| 	}, | ||||
| }; | ||||
|  | ||||
| export default defaultItemRenderer; | ||||
							
								
								
									
										45
									
								
								packages/app-desktop/gui/NoteList/utils/getNoteTitleHtml.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								packages/app-desktop/gui/NoteList/utils/getNoteTitleHtml.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| import { htmlentities } from '@joplin/utils/html'; | ||||
| const Mark = require('mark.js/dist/mark.min.js'); | ||||
| const markJsUtils = require('@joplin/lib/markJsUtils'); | ||||
| const { replaceRegexDiacritics, pregQuote } = require('@joplin/lib/string-utils'); | ||||
|  | ||||
| const getNoteTitleHtml = (highlightedWords: string[], displayTitle: string) => { | ||||
| 	if (highlightedWords.length) { | ||||
| 		const titleElement = document.createElement('span'); | ||||
| 		titleElement.textContent = displayTitle; | ||||
| 		const mark = new Mark(titleElement, { | ||||
| 			exclude: ['img'], | ||||
| 			acrossElements: true, | ||||
| 		}); | ||||
|  | ||||
| 		mark.unmark(); | ||||
|  | ||||
| 		try { | ||||
| 			for (const wordToBeHighlighted of highlightedWords) { | ||||
| 				markJsUtils.markKeyword(mark, wordToBeHighlighted, { | ||||
| 					pregQuote: pregQuote, | ||||
| 					replaceRegexDiacritics: replaceRegexDiacritics, | ||||
| 				}); | ||||
| 			} | ||||
| 		} catch (error) { | ||||
| 			if (error.name !== 'SyntaxError') { | ||||
| 				throw error; | ||||
| 			} | ||||
| 			// An error of 'Regular expression too large' might occour in the markJs library | ||||
| 			// when the input is really big, this catch is here to avoid the application crashing | ||||
| 			// https://github.com/laurent22/joplin/issues/7634 | ||||
| 			// console.error('Error while trying to highlight words from search: ', error); | ||||
| 		} | ||||
|  | ||||
| 		// Note: in this case it is safe to use dangerouslySetInnerHTML because titleElement | ||||
| 		// is a span tag that we created and that contains data that's been inserted as plain text | ||||
| 		// with `textContent` so it cannot contain any XSS attacks. We use this feature because | ||||
| 		// mark.js can only deal with DOM elements. | ||||
| 		// https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml | ||||
| 		return titleElement.outerHTML; | ||||
| 	} else { | ||||
| 		return htmlentities(displayTitle); | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| export default getNoteTitleHtml; | ||||
							
								
								
									
										51
									
								
								packages/app-desktop/gui/NoteList/utils/prepareViewProps.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								packages/app-desktop/gui/NoteList/utils/prepareViewProps.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| import { ListRendererDepependency } from './types'; | ||||
| import { NoteEntity } from '@joplin/lib/services/database/types'; | ||||
| import { Size } from '@joplin/utils/types'; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
|  | ||||
| const prepareViewProps = async (dependencies: ListRendererDepependency[], note: NoteEntity, itemSize: Size, selected: boolean, noteTitleHtml: string, noteIsWatched: boolean) => { | ||||
| 	const output: any = {}; | ||||
|  | ||||
| 	for (const dep of dependencies) { | ||||
|  | ||||
| 		if (dep.startsWith('note.')) { | ||||
| 			const splitted = dep.split('.'); | ||||
| 			if (splitted.length !== 2) throw new Error(`Invalid dependency name: ${dep}`); | ||||
| 			const propName = splitted.pop(); | ||||
| 			if (!output.note) output.note = {}; | ||||
| 			if (dep === 'note.titleHtml') { | ||||
| 				output.note.titleHtml = noteTitleHtml; | ||||
| 			} else if (dep === 'note.isWatched') { | ||||
| 				output.note.isWatched = noteIsWatched; | ||||
| 			} else { | ||||
| 				// The notes in the state only contain the properties defined in | ||||
| 				// Note.previewFields(). It means that if a view request a | ||||
| 				// property not present there, we need to load the full note. | ||||
| 				// One such missing property is the note body, which we don't | ||||
| 				// load by default. | ||||
| 				if (!(propName in note)) note = await Note.load(note.id); | ||||
| 				if (!(propName in note)) throw new Error(`Invalid dependency name: ${dep}`); | ||||
| 				output.note[propName] = (note as any)[propName]; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if (dep.startsWith('item.size.')) { | ||||
| 			const splitted = dep.split('.'); | ||||
| 			if (splitted.length !== 3) throw new Error(`Invalid dependency name: ${dep}`); | ||||
| 			const propName = splitted.pop(); | ||||
| 			if (!output.item) output.item = {}; | ||||
| 			if (!output.item.size) output.item.size = {}; | ||||
| 			if (!(propName in itemSize)) throw new Error(`Invalid dependency name: ${dep}`); | ||||
| 			output.item.size[propName] = (itemSize as any)[propName]; | ||||
| 		} | ||||
|  | ||||
| 		if (dep === 'item.selected') { | ||||
| 			if (!output.item) output.item = {}; | ||||
| 			output.item.selected = selected; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return output; | ||||
| }; | ||||
|  | ||||
| export default prepareViewProps; | ||||
							
								
								
									
										64
									
								
								packages/app-desktop/gui/NoteList/utils/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								packages/app-desktop/gui/NoteList/utils/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| import { FolderEntity, ItemRendererDatabaseDependency, NoteEntity } from '@joplin/lib/services/database/types'; | ||||
| import { PluginStates } from '@joplin/lib/services/plugins/reducer'; | ||||
| import { Size } from '@joplin/utils/types'; | ||||
| import { Dispatch } from 'redux'; | ||||
|  | ||||
| export interface Props { | ||||
| 	themeId: any; | ||||
| 	selectedNoteIds: string[]; | ||||
| 	notes: NoteEntity[]; | ||||
| 	dispatch: Dispatch; | ||||
| 	watchedNoteFiles: string[]; | ||||
| 	plugins: PluginStates; | ||||
| 	selectedFolderId: string; | ||||
| 	customCss: string; | ||||
| 	notesParentType: string; | ||||
| 	noteSortOrder: string; | ||||
| 	uncompletedTodosOnTop: boolean; | ||||
| 	showCompletedTodos: boolean; | ||||
| 	resizableLayoutEventEmitter: any; | ||||
| 	isInsertingNotes: boolean; | ||||
| 	folders: FolderEntity[]; | ||||
| 	size: Size; | ||||
| 	searches: any[]; | ||||
| 	selectedSearchId: string; | ||||
| 	highlightedWords: string[]; | ||||
| 	provisionalNoteIds: string[]; | ||||
| 	visible: boolean; | ||||
| 	focusedField: string; | ||||
| 	parentFolderIsReadOnly: boolean; | ||||
| } | ||||
|  | ||||
| export enum ItemFlow { | ||||
| 	TopToBottom = 'topToBottom', | ||||
| 	LeftToRight = 'leftToRight', | ||||
| } | ||||
|  | ||||
| export type RenderNoteView = Record<string, any>; | ||||
|  | ||||
| export interface OnChangeEvent { | ||||
| 	elementId: string; | ||||
| 	value: any; | ||||
| 	noteId: string; | ||||
| } | ||||
|  | ||||
| export type OnRenderNoteHandler = (props: any)=> Promise<RenderNoteView>; | ||||
| export type OnChangeHandler = (event: OnChangeEvent)=> Promise<void>; | ||||
|  | ||||
| export type ListRendererDepependency = | ||||
| 	ItemRendererDatabaseDependency | | ||||
| 	'item.size.width' | | ||||
| 	'item.size.height' | | ||||
| 	'item.selected' | | ||||
| 	'note.titleHtml' | | ||||
| 	'note.isWatched'; | ||||
|  | ||||
| export interface ListRenderer { | ||||
| 	flow: ItemFlow; | ||||
| 	itemSize: Size; | ||||
| 	itemCss?: string; | ||||
| 	dependencies: ListRendererDepependency[]; | ||||
| 	itemTemplate: string; | ||||
| 	onRenderNote: OnRenderNoteHandler; | ||||
| 	onChange?: OnChangeHandler; | ||||
| } | ||||
							
								
								
									
										102
									
								
								packages/app-desktop/gui/NoteList/utils/useDragAndDrop.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								packages/app-desktop/gui/NoteList/utils/useDragAndDrop.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,102 @@ | ||||
| import * as React from 'react'; | ||||
| import { useCallback, DragEventHandler, MutableRefObject, useState, useEffect } from 'react'; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
| import canManuallySortNotes from './canManuallySortNotes'; | ||||
| import { Size } from '@joplin/utils/types'; | ||||
| import { ItemFlow } from './types'; | ||||
|  | ||||
| const useDragAndDrop = ( | ||||
| 	parentFolderIsReadOnly: boolean, | ||||
| 	selectedNoteIds: string[], | ||||
| 	selectedFolderId: string, | ||||
| 	listRef: MutableRefObject<HTMLDivElement>, | ||||
| 	scrollTop: number, | ||||
| 	itemSize: Size, | ||||
| 	notesParentType: string, | ||||
| 	noteSortOrder: string, | ||||
| 	uncompletedTodosOnTop: boolean, | ||||
| 	showCompletedTodos: boolean, | ||||
| 	flow: ItemFlow, | ||||
| 	itemsPerLine: number | ||||
| ) => { | ||||
| 	const [dragOverTargetNoteIndex, setDragOverTargetNoteIndex] = useState(null); | ||||
|  | ||||
| 	const onGlobalDrop = useCallback(() => { | ||||
| 		setDragOverTargetNoteIndex(null); | ||||
| 	}, []); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		document.addEventListener('dragend', onGlobalDrop); | ||||
| 		return () => { | ||||
| 			document.removeEventListener('dragend', onGlobalDrop); | ||||
| 		}; | ||||
| 	}, [onGlobalDrop]); | ||||
|  | ||||
| 	const onDragStart: DragEventHandler = useCallback(event => { | ||||
| 		if (parentFolderIsReadOnly) return false; | ||||
|  | ||||
| 		let noteIds = []; | ||||
|  | ||||
| 		// Here there is two cases: | ||||
| 		// - If multiple notes are selected, we drag the group | ||||
| 		// - If only one note is selected, we drag the note that was clicked on | ||||
| 		//   (which might be different from the currently selected note) | ||||
| 		if (selectedNoteIds.length >= 2) { | ||||
| 			noteIds = selectedNoteIds; | ||||
| 		} else { | ||||
| 			const clickedNoteId = event.currentTarget.getAttribute('data-id'); | ||||
| 			if (clickedNoteId) noteIds.push(clickedNoteId); | ||||
| 		} | ||||
|  | ||||
| 		if (!noteIds.length) return false; | ||||
|  | ||||
| 		event.dataTransfer.setDragImage(new Image(), 1, 1); | ||||
| 		event.dataTransfer.clearData(); | ||||
| 		event.dataTransfer.setData('text/x-jop-note-ids', JSON.stringify(noteIds)); | ||||
| 		return true; | ||||
| 	}, [parentFolderIsReadOnly, selectedNoteIds]); | ||||
|  | ||||
|  | ||||
| 	const dragTargetNoteIndex = useCallback((event: React.DragEvent) => { | ||||
| 		const rect = listRef.current.getBoundingClientRect(); | ||||
| 		const lineIndexFloat = (event.clientY - rect.top + scrollTop) / itemSize.height; | ||||
| 		if (flow === ItemFlow.TopToBottom) { | ||||
| 			return Math.abs(Math.round(lineIndexFloat)); | ||||
| 		} else { | ||||
| 			const lineIndex = Math.floor(lineIndexFloat); | ||||
| 			const rowIndexFloat = (event.clientX - rect.left) / itemSize.width; | ||||
| 			const rowIndex = Math.round(rowIndexFloat); | ||||
| 			return lineIndex * itemsPerLine + rowIndex; | ||||
| 		} | ||||
| 	}, [listRef, itemSize, scrollTop, flow, itemsPerLine]); | ||||
|  | ||||
| 	const onDragOver: DragEventHandler = useCallback(event => { | ||||
| 		if (notesParentType !== 'Folder') return; | ||||
|  | ||||
| 		const dt = event.dataTransfer; | ||||
|  | ||||
| 		if (dt.types.indexOf('text/x-jop-note-ids') >= 0) { | ||||
| 			event.preventDefault(); | ||||
| 			const newIndex = dragTargetNoteIndex(event); | ||||
| 			if (dragOverTargetNoteIndex === newIndex) return; | ||||
| 			setDragOverTargetNoteIndex(newIndex); | ||||
| 		} | ||||
| 	}, [notesParentType, dragTargetNoteIndex, dragOverTargetNoteIndex]); | ||||
|  | ||||
| 	const onDrop: DragEventHandler = useCallback(async (event: any) => { | ||||
| 		// TODO: check that parent type is folder | ||||
| 		if (!canManuallySortNotes(notesParentType, noteSortOrder)) return; | ||||
|  | ||||
| 		const dt = event.dataTransfer; | ||||
| 		setDragOverTargetNoteIndex(null); | ||||
|  | ||||
| 		const targetNoteIndex = dragTargetNoteIndex(event); | ||||
| 		const noteIds: string[] = JSON.parse(dt.getData('text/x-jop-note-ids')); | ||||
|  | ||||
| 		await Note.insertNotesAt(selectedFolderId, noteIds, targetNoteIndex, uncompletedTodosOnTop, showCompletedTodos); | ||||
| 	}, [notesParentType, dragTargetNoteIndex, noteSortOrder, selectedFolderId, uncompletedTodosOnTop, showCompletedTodos]); | ||||
|  | ||||
| 	return { onDragStart, onDragOver, onDrop, dragOverTargetNoteIndex }; | ||||
| }; | ||||
|  | ||||
| export default useDragAndDrop; | ||||
							
								
								
									
										33
									
								
								packages/app-desktop/gui/NoteList/utils/useFocusNote.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								packages/app-desktop/gui/NoteList/utils/useFocusNote.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import { useRef, useCallback, MutableRefObject } from 'react'; | ||||
|  | ||||
| export type FocusNote = (noteId: string)=> void; | ||||
|  | ||||
| const useFocusNote = (itemRefs: MutableRefObject<Record<string, HTMLDivElement>>) => { | ||||
| 	const focusItemIID = useRef(null); | ||||
|  | ||||
| 	const focusNote: FocusNote = useCallback((noteId: string) => { | ||||
| 		// - We need to focus the item manually otherwise focus might be lost when the | ||||
| 		//   list is scrolled and items within it are being rebuilt. | ||||
| 		// - We need to use an interval because when leaving the arrow pressed, the rendering | ||||
| 		//   of items might lag behind and so the ref is not yet available at this point. | ||||
|  | ||||
| 		if (!itemRefs.current[noteId]) { | ||||
| 			if (focusItemIID.current) shim.clearInterval(focusItemIID.current); | ||||
| 			focusItemIID.current = shim.setInterval(() => { | ||||
| 				if (itemRefs.current[noteId]) { | ||||
| 					itemRefs.current[noteId].focus(); | ||||
| 					shim.clearInterval(focusItemIID.current); | ||||
| 					focusItemIID.current = null; | ||||
| 				} | ||||
| 			}, 10); | ||||
| 		} else { | ||||
| 			if (focusItemIID.current) shim.clearInterval(focusItemIID.current); | ||||
| 			itemRefs.current[noteId].focus(); | ||||
| 		} | ||||
| 	}, [itemRefs]); | ||||
|  | ||||
| 	return focusNote; | ||||
| }; | ||||
|  | ||||
| export default useFocusNote; | ||||
							
								
								
									
										19
									
								
								packages/app-desktop/gui/NoteList/utils/useItemCss.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								packages/app-desktop/gui/NoteList/utils/useItemCss.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| import { useEffect } from 'react'; | ||||
|  | ||||
| const useItemCss = (itemCss: string) => { | ||||
| 	useEffect(() => { | ||||
| 		const element = document.createElement('style'); | ||||
| 		element.setAttribute('type', 'text/css'); | ||||
| 		element.appendChild(document.createTextNode(` | ||||
| 			.note-list-item { | ||||
| 				${itemCss}; | ||||
| 			} | ||||
| 		`)); | ||||
| 		document.head.appendChild(element); | ||||
| 		return () => { | ||||
| 			element.remove(); | ||||
| 		}; | ||||
| 	}, [itemCss]); | ||||
| }; | ||||
|  | ||||
| export default useItemCss; | ||||
							
								
								
									
										25
									
								
								packages/app-desktop/gui/NoteList/utils/useMoveNote.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								packages/app-desktop/gui/NoteList/utils/useMoveNote.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| import BaseModel from '@joplin/lib/BaseModel'; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
| import { NoteEntity } from '@joplin/lib/services/database/types'; | ||||
| import { useCallback } from 'react'; | ||||
| import canManuallySortNotes from './canManuallySortNotes'; | ||||
|  | ||||
| const useMoveNote = (notesParentType: string, noteSortOrder: string, selectedNoteIds: string[], selectedFolderId: string, uncompletedTodosOnTop: boolean, showCompletedTodos: boolean, notes: NoteEntity[]) => { | ||||
| 	const moveNote = useCallback((direction: number, inc: number) => { | ||||
| 		if (!canManuallySortNotes(notesParentType, noteSortOrder)) return; | ||||
|  | ||||
| 		const noteId = selectedNoteIds[0]; | ||||
| 		let targetNoteIndex = BaseModel.modelIndexById(notes, noteId); | ||||
| 		if ((direction === 1)) { | ||||
| 			targetNoteIndex += inc + 1; | ||||
| 		} | ||||
| 		if ((direction === -1)) { | ||||
| 			targetNoteIndex -= inc; | ||||
| 		} | ||||
| 		void Note.insertNotesAt(selectedFolderId, selectedNoteIds, targetNoteIndex, uncompletedTodosOnTop, showCompletedTodos); | ||||
| 	}, [selectedFolderId, noteSortOrder, notes, notesParentType, selectedNoteIds, uncompletedTodosOnTop, showCompletedTodos]); | ||||
|  | ||||
| 	return moveNote; | ||||
| }; | ||||
|  | ||||
| export default useMoveNote; | ||||
							
								
								
									
										150
									
								
								packages/app-desktop/gui/NoteList/utils/useOnKeyDown.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								packages/app-desktop/gui/NoteList/utils/useOnKeyDown.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,150 @@ | ||||
| import * as React from 'react'; | ||||
| import BaseModel from '@joplin/lib/BaseModel'; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
| import CommandService from '@joplin/lib/services/CommandService'; | ||||
| import { NoteEntity } from '@joplin/lib/services/database/types'; | ||||
| import { useCallback } from 'react'; | ||||
| import { Dispatch } from 'redux'; | ||||
| import { FocusNote } from './useFocusNote'; | ||||
| import { ItemFlow } from './types'; | ||||
| import { KeyboardEventKey } from '@joplin/lib/dom'; | ||||
|  | ||||
| const useOnKeyDown = ( | ||||
| 	selectedNoteIds: string[], | ||||
| 	moveNote: (direction: number, inc: number)=> void, | ||||
| 	makeItemIndexVisible: (itemIndex: number)=> void, | ||||
| 	focusNote: FocusNote, | ||||
| 	notes: NoteEntity[], | ||||
| 	dispatch: Dispatch, | ||||
| 	visibleItemCount: number, | ||||
| 	noteCount: number, | ||||
| 	flow: ItemFlow, | ||||
| 	itemsPerLine: number | ||||
| ) => { | ||||
| 	const scrollNoteIndex = useCallback((visibleItemCount: number, key: KeyboardEventKey, ctrlKey: boolean, metaKey: boolean, noteIndex: number) => { | ||||
| 		if (flow === ItemFlow.TopToBottom) { | ||||
| 			if (key === 'PageUp') { | ||||
| 				noteIndex -= (visibleItemCount - 1); | ||||
| 			} else if (key === 'PageDown') { | ||||
| 				noteIndex += (visibleItemCount - 1); | ||||
| 			} else if ((key === 'End' && ctrlKey) || (key === 'ArrowDown' && metaKey)) { | ||||
| 				noteIndex = noteCount - 1; | ||||
| 			} else if ((key === 'Home' && ctrlKey) || (key === 'ArrowUp' && metaKey)) { | ||||
| 				noteIndex = 0; | ||||
| 			} else if (key === 'ArrowUp' && !metaKey) { | ||||
| 				noteIndex -= 1; | ||||
| 			} else if (key === 'ArrowDown' && !metaKey) { | ||||
| 				noteIndex += 1; | ||||
| 			} | ||||
| 			if (noteIndex < 0) noteIndex = 0; | ||||
| 			if (noteIndex > noteCount - 1) noteIndex = noteCount - 1; | ||||
| 		} | ||||
|  | ||||
| 		if (flow === ItemFlow.LeftToRight) { | ||||
| 			if (key === 'PageUp') { | ||||
| 				noteIndex -= (visibleItemCount - itemsPerLine); | ||||
| 			} else if (key === 'PageDown') { | ||||
| 				noteIndex += (visibleItemCount - itemsPerLine); | ||||
| 			} else if ((key === 'End' && ctrlKey) || (key === 'ArrowDown' && metaKey)) { | ||||
| 				noteIndex = noteCount - 1; | ||||
| 			} else if ((key === 'Home' && ctrlKey) || (key === 'ArrowUp' && metaKey)) { | ||||
| 				noteIndex = 0; | ||||
| 			} else if (key === 'ArrowUp' && !metaKey) { | ||||
| 				noteIndex -= itemsPerLine; | ||||
| 			} else if (key === 'ArrowDown' && !metaKey) { | ||||
| 				noteIndex += itemsPerLine; | ||||
| 			} else if (key === 'ArrowLeft' && !metaKey) { | ||||
| 				noteIndex -= 1; | ||||
| 			} else if (key === 'ArrowRight' && !metaKey) { | ||||
| 				noteIndex += 1; | ||||
| 			} | ||||
| 			if (noteIndex < 0) noteIndex = 0; | ||||
| 			if (noteIndex > noteCount - 1) noteIndex = noteCount - 1; | ||||
| 		} | ||||
|  | ||||
| 		return noteIndex; | ||||
| 	}, [noteCount, flow, itemsPerLine]); | ||||
|  | ||||
| 	const onKeyDown: React.KeyboardEventHandler<HTMLDivElement> = useCallback(async (event) => { | ||||
| 		const noteIds = selectedNoteIds; | ||||
| 		const key = event.key as KeyboardEventKey; | ||||
|  | ||||
| 		if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(key) && event.altKey) { | ||||
| 			if (flow === ItemFlow.TopToBottom) { | ||||
| 				await moveNote(key === 'ArrowDown' ? 1 : -1, 1); | ||||
| 			} else { | ||||
| 				if (key === 'ArrowRight') { | ||||
| 					await moveNote(1, 1); | ||||
| 				} else if (key === 'ArrowLeft') { | ||||
| 					await moveNote(-1, 1); | ||||
| 				} else if (key === 'ArrowUp') { | ||||
| 					await moveNote(-1, itemsPerLine); | ||||
| 				} else if (key === 'ArrowDown') { | ||||
| 					await moveNote(1, itemsPerLine); | ||||
| 				} | ||||
| 			} | ||||
| 			event.preventDefault(); | ||||
| 		} else if (noteIds.length > 0 && (key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowLeft' || key === 'ArrowRight' || key === 'PageDown' || key === 'PageUp' || key === 'End' || key === 'Home')) { | ||||
| 			const noteId = noteIds[0]; | ||||
| 			let noteIndex = BaseModel.modelIndexById(notes, noteId); | ||||
|  | ||||
| 			noteIndex = scrollNoteIndex(visibleItemCount, key, event.ctrlKey, event.metaKey, noteIndex); | ||||
|  | ||||
| 			const newSelectedNote = notes[noteIndex]; | ||||
|  | ||||
| 			dispatch({ | ||||
| 				type: 'NOTE_SELECT', | ||||
| 				id: newSelectedNote.id, | ||||
| 			}); | ||||
|  | ||||
| 			makeItemIndexVisible(noteIndex); | ||||
|  | ||||
| 			focusNote(newSelectedNote.id); | ||||
|  | ||||
| 			event.preventDefault(); | ||||
| 		} | ||||
|  | ||||
| 		if (noteIds.length && (key === 'Delete' || (key === 'Backspace' && event.metaKey))) { | ||||
| 			event.preventDefault(); | ||||
| 			void CommandService.instance().execute('deleteNote', noteIds); | ||||
| 		} | ||||
|  | ||||
| 		if (noteIds.length && key === ' ') { | ||||
| 			event.preventDefault(); | ||||
|  | ||||
| 			const selectedNotes = BaseModel.modelsByIds(notes, noteIds); | ||||
| 			const todos = selectedNotes.filter((n: any) => !!n.is_todo); | ||||
| 			if (!todos.length) return; | ||||
|  | ||||
| 			for (let i = 0; i < todos.length; i++) { | ||||
| 				const toggledTodo = Note.toggleTodoCompleted(todos[i]); | ||||
| 				await Note.save(toggledTodo); | ||||
| 			} | ||||
|  | ||||
| 			focusNote(todos[0].id); | ||||
| 		} | ||||
|  | ||||
| 		if (key === 'Tab') { | ||||
| 			event.preventDefault(); | ||||
|  | ||||
| 			if (event.shiftKey) { | ||||
| 				void CommandService.instance().execute('focusElement', 'sideBar'); | ||||
| 			} else { | ||||
| 				void CommandService.instance().execute('focusElement', 'noteTitle'); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if (key.toUpperCase() === 'A' && (event.ctrlKey || event.metaKey)) { | ||||
| 			event.preventDefault(); | ||||
|  | ||||
| 			dispatch({ | ||||
| 				type: 'NOTE_SELECT_ALL', | ||||
| 			}); | ||||
| 		} | ||||
| 	}, [moveNote, focusNote, visibleItemCount, scrollNoteIndex, makeItemIndexVisible, notes, selectedNoteIds, dispatch, flow, itemsPerLine]); | ||||
|  | ||||
|  | ||||
| 	return onKeyDown; | ||||
| }; | ||||
|  | ||||
| export default useOnKeyDown; | ||||
							
								
								
									
										41
									
								
								packages/app-desktop/gui/NoteList/utils/useOnNoteClick.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								packages/app-desktop/gui/NoteList/utils/useOnNoteClick.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| import * as React from 'react'; | ||||
| import { useCallback } from 'react'; | ||||
| import { Dispatch } from 'redux'; | ||||
| import { FocusNote } from './useFocusNote'; | ||||
|  | ||||
| const useOnNoteClick = (dispatch: Dispatch, focusNote: FocusNote) => { | ||||
| 	const onNoteClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => { | ||||
| 		const noteId = event.currentTarget.getAttribute('data-id'); | ||||
|  | ||||
| 		const targetTagName = event.target ? (event.target as any).tagName : ''; | ||||
|  | ||||
| 		// If we are for example on a checkbox, don't process the click since it | ||||
| 		// should be handled by the checkbox onChange handler. | ||||
| 		if (['INPUT'].includes(targetTagName)) return; | ||||
|  | ||||
| 		focusNote(noteId); | ||||
|  | ||||
| 		if (event.ctrlKey || event.metaKey) { | ||||
| 			event.preventDefault(); | ||||
| 			dispatch({ | ||||
| 				type: 'NOTE_SELECT_TOGGLE', | ||||
| 				id: noteId, | ||||
| 			}); | ||||
| 		} else if (event.shiftKey) { | ||||
| 			event.preventDefault(); | ||||
| 			dispatch({ | ||||
| 				type: 'NOTE_SELECT_EXTEND', | ||||
| 				id: noteId, | ||||
| 			}); | ||||
| 		} else { | ||||
| 			dispatch({ | ||||
| 				type: 'NOTE_SELECT', | ||||
| 				id: noteId, | ||||
| 			}); | ||||
| 		} | ||||
| 	}, [dispatch, focusNote]); | ||||
|  | ||||
| 	return onNoteClick; | ||||
| }; | ||||
|  | ||||
| export default useOnNoteClick; | ||||
							
								
								
									
										82
									
								
								packages/app-desktop/gui/NoteList/utils/useRenderedNotes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								packages/app-desktop/gui/NoteList/utils/useRenderedNotes.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| import { useState } from 'react'; | ||||
| import { ListRenderer } from './types'; | ||||
| import { NoteEntity } from '@joplin/lib/services/database/types'; | ||||
| import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; | ||||
| import * as Mustache from 'mustache'; | ||||
| import { createHash } from 'crypto'; | ||||
| import getNoteTitleHtml from './getNoteTitleHtml'; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
| import prepareViewProps from './prepareViewProps'; | ||||
|  | ||||
| interface RenderedNote { | ||||
| 	id: string; | ||||
| 	hash: string; | ||||
| 	html: string; | ||||
| } | ||||
|  | ||||
| const hashContent = (content: any) => { | ||||
| 	return createHash('sha1').update(JSON.stringify(content)).digest('hex'); | ||||
| }; | ||||
|  | ||||
| const useRenderedNotes = (startNoteIndex: number, endNoteIndex: number, notes: NoteEntity[], selectedNoteIds: string[], listRenderer: ListRenderer, highlightedWords: string[], watchedNoteFiles: string[]) => { | ||||
| 	const [renderedNotes, setRenderedNotes] = useState<Record<string, RenderedNote>>({}); | ||||
|  | ||||
| 	useAsyncEffect(async (event) => { | ||||
| 		if (event.cancelled) return; | ||||
|  | ||||
| 		const renderNote = async (note: NoteEntity): Promise<void> => { | ||||
| 			const isSelected = selectedNoteIds.includes(note.id); | ||||
| 			const isWatched = watchedNoteFiles.includes(note.id); | ||||
|  | ||||
| 			// Note: with this hash we're assuming that the list renderer | ||||
| 			// properties never changes. It means that later if we support | ||||
| 			// dynamic list renderers, we should include these into the hash. | ||||
| 			const viewHash = hashContent([ | ||||
| 				note.updated_time, | ||||
| 				isSelected, | ||||
| 				isWatched, | ||||
| 				highlightedWords, | ||||
| 			]); | ||||
|  | ||||
| 			if (renderedNotes[note.id] && renderedNotes[note.id].hash === viewHash) return null; | ||||
|  | ||||
| 			const titleHtml = getNoteTitleHtml(highlightedWords, Note.displayTitle(note)); | ||||
| 			const viewProps = await prepareViewProps( | ||||
| 				listRenderer.dependencies, | ||||
| 				note, | ||||
| 				listRenderer.itemSize, | ||||
| 				isSelected, | ||||
| 				titleHtml, | ||||
| 				isWatched | ||||
| 			); | ||||
| 			const view = await listRenderer.onRenderNote(viewProps); | ||||
|  | ||||
| 			if (event.cancelled) return null; | ||||
|  | ||||
| 			setRenderedNotes(prev => { | ||||
| 				if (prev[note.id] && prev[note.id].hash === viewHash) return prev; | ||||
|  | ||||
| 				return { | ||||
| 					...prev, | ||||
| 					[note.id]: { | ||||
| 						id: note.id, | ||||
| 						hash: viewHash, | ||||
| 						html: Mustache.render(listRenderer.itemTemplate, view), | ||||
| 					}, | ||||
| 				}; | ||||
| 			}); | ||||
| 		}; | ||||
|  | ||||
| 		const promises: Promise<void>[] = []; | ||||
|  | ||||
| 		for (let i = startNoteIndex; i <= endNoteIndex; i++) { | ||||
| 			promises.push(renderNote(notes[i])); | ||||
| 		} | ||||
|  | ||||
| 		await Promise.all(promises); | ||||
| 	}, [startNoteIndex, endNoteIndex, notes, selectedNoteIds, listRenderer, renderedNotes, watchedNoteFiles]); | ||||
|  | ||||
| 	return renderedNotes; | ||||
| }; | ||||
|  | ||||
| export default useRenderedNotes; | ||||
							
								
								
									
										99
									
								
								packages/app-desktop/gui/NoteList/utils/useScroll.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								packages/app-desktop/gui/NoteList/utils/useScroll.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
| import * as React from 'react'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import { Size } from '@joplin/utils/types'; | ||||
| import { useCallback, useState, useRef, useEffect, useMemo } from 'react'; | ||||
|  | ||||
| const useScroll = (itemsPerLine: number, noteCount: number, itemSize: Size, listSize: Size, listRef: React.MutableRefObject<HTMLDivElement>) => { | ||||
| 	const [scrollTop, setScrollTop] = useState(0); | ||||
| 	const lastScrollSetTime = useRef(0); | ||||
|  | ||||
| 	const maxScrollTop = useMemo(() => { | ||||
| 		return Math.max(0, itemSize.height * noteCount - listSize.height); | ||||
| 	}, [itemSize.height, noteCount, listSize.height]); | ||||
|  | ||||
| 	// This ugly hack is necessary because setting scrollTop at a high | ||||
| 	// frequency, while scrolling with the keyboard, is unreliable - the | ||||
| 	// property will appear to be set (reading it back gives the correct value), | ||||
| 	// but the scrollbar will not be at the expected position. That can be | ||||
| 	// verified by moving the scrollbar a little and reading the event value - | ||||
| 	// it will be different from what was set, and what was read. | ||||
| 	// | ||||
| 	// As a result, since we can't rely on setting or reading that value (to | ||||
| 	// check if it's correct), we forcefully set it multiple times over the next | ||||
| 	// few milliseconds, hoping that maybe one of these attempts will stick. | ||||
| 	// | ||||
| 	// This is most likely a race condition in either Chromimum or Electron | ||||
| 	// although I couldn't find an upstream issue. | ||||
| 	// | ||||
| 	// Setting the value only once after a short time, for example 10ms, helps | ||||
| 	// but still fails now and then. Setting it after 500ms would probably work | ||||
| 	// reliably but it's too slow so it makes sense to do it in an interval. | ||||
|  | ||||
| 	const setScrollTopLikeYouMeanItTimer = useRef(null); | ||||
| 	const setScrollTopLikeYouMeanItStartTime = useRef(0); | ||||
| 	const setScrollTopLikeYouMeanIt = useCallback((newScrollTop: number) => { | ||||
| 		if (setScrollTopLikeYouMeanItTimer.current) shim.clearInterval(setScrollTopLikeYouMeanItTimer.current); | ||||
| 		setScrollTopLikeYouMeanItStartTime.current = Date.now(); | ||||
|  | ||||
| 		setScrollTopLikeYouMeanItTimer.current = shim.setInterval(() => { | ||||
| 			if (!listRef.current) { | ||||
| 				shim.clearInterval(setScrollTopLikeYouMeanItTimer.current); | ||||
| 				setScrollTopLikeYouMeanItTimer.current = null; | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			listRef.current.scrollTop = newScrollTop; | ||||
| 			lastScrollSetTime.current = Date.now(); | ||||
|  | ||||
| 			if (Date.now() - setScrollTopLikeYouMeanItStartTime.current > 500) { | ||||
| 				shim.clearInterval(setScrollTopLikeYouMeanItTimer.current); | ||||
| 				setScrollTopLikeYouMeanItTimer.current = null; | ||||
| 			} | ||||
| 		}, 10); | ||||
| 	}, [listRef]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (setScrollTopLikeYouMeanItTimer.current) shim.clearInterval(setScrollTopLikeYouMeanItTimer.current); | ||||
| 		setScrollTopLikeYouMeanItTimer.current = null; | ||||
| 	}, []); | ||||
|  | ||||
| 	const makeItemIndexVisible = useCallback((itemIndex: number) => { | ||||
| 		const lineTopFloat = scrollTop / itemSize.height; | ||||
| 		const topFloat = lineTopFloat * itemsPerLine; // scrollTop / itemSize.height; | ||||
| 		const lineBottomFloat = (scrollTop + listSize.height - itemSize.height) / itemSize.height; | ||||
| 		const bottomFloat = lineBottomFloat * itemsPerLine; // (scrollTop + listSize.height - itemSize.height) / itemSize.height; | ||||
| 		const top = Math.min(noteCount - 1, Math.floor(topFloat) + 1); | ||||
| 		const bottom = Math.max(0, Math.floor(bottomFloat)); | ||||
|  | ||||
| 		if (itemIndex >= top && itemIndex <= bottom) return; | ||||
|  | ||||
| 		const lineIndex = Math.floor(itemIndex / itemsPerLine); | ||||
|  | ||||
| 		let newScrollTop = 0; | ||||
| 		if (itemIndex < top) { | ||||
| 			newScrollTop = itemSize.height * lineIndex; | ||||
| 		} else { | ||||
| 			newScrollTop = itemSize.height * (lineIndex + 1) - listSize.height; | ||||
| 		} | ||||
|  | ||||
| 		if (newScrollTop < 0) newScrollTop = 0; | ||||
| 		if (newScrollTop > maxScrollTop) newScrollTop = maxScrollTop; | ||||
|  | ||||
| 		setScrollTop(newScrollTop); | ||||
| 		setScrollTopLikeYouMeanIt(newScrollTop); | ||||
| 	}, [itemsPerLine, noteCount, itemSize.height, scrollTop, listSize.height, maxScrollTop, setScrollTopLikeYouMeanIt]); | ||||
|  | ||||
| 	const onScroll = useCallback((event: any) => { | ||||
| 		// Ignore the scroll event if it has just been set programmatically. | ||||
| 		if (Date.now() - lastScrollSetTime.current < 100) return; | ||||
| 		setScrollTop(event.target.scrollTop); | ||||
| 	}, []); | ||||
|  | ||||
| 	return { | ||||
| 		scrollTop, | ||||
| 		onScroll, | ||||
| 		makeItemIndexVisible, | ||||
| 	}; | ||||
| }; | ||||
|  | ||||
| export default useScroll; | ||||
| @@ -0,0 +1,61 @@ | ||||
| import useVisibleRange from './useVisibleRange'; | ||||
| import { renderHook } from '@testing-library/react-hooks'; | ||||
| import { Size } from '@joplin/utils/types'; | ||||
|  | ||||
| describe('useVisibleRange', () => { | ||||
|  | ||||
| 	test('should calculate indexes', () => { | ||||
|  | ||||
| 		// IN: scrollTop, listSize, itemSize, noteCount, flow | ||||
| 		// | ||||
| 		// OUT: [itemsPerLine, startNoteIndex, endNoteIndex, startLineIndex, endLineIndex, totalLineCount, visibleItemCount] | ||||
|  | ||||
| 		const testCases: [number, number, Size, Size, number, ReturnType<typeof useVisibleRange>][] = [ | ||||
| 			[ | ||||
| 				1, | ||||
| 				150, | ||||
| 				{ width: 100, height: 400 }, | ||||
| 				{ width: 100, height: 100 }, | ||||
| 				8, | ||||
| 				[1, 5, 1, 5, 8, 5], | ||||
| 			], | ||||
| 			[ | ||||
| 				2, | ||||
| 				100, | ||||
| 				{ width: 220, height: 380 }, | ||||
| 				{ width: 100, height: 100 }, | ||||
| 				12, | ||||
| 				[2, 9, 1, 4, 6, 8], | ||||
| 			], | ||||
| 			[ | ||||
| 				2, | ||||
| 				50, | ||||
| 				{ width: 220, height: 300 }, | ||||
| 				{ width: 100, height: 100 }, | ||||
| 				9, | ||||
| 				[0, 7, 0, 3, 5, 8], | ||||
| 			], | ||||
| 			[ | ||||
| 				4, | ||||
| 				0, | ||||
| 				{ width: 410, height: 450 }, | ||||
| 				{ width: 100, height: 100 }, | ||||
| 				30, | ||||
| 				[0, 19, 0, 4, 8, 20], | ||||
| 			], | ||||
| 		]; | ||||
|  | ||||
| 		for (const [scrollTop, listSize, itemSize, noteCount, flow, expected] of testCases) { | ||||
| 			const { result } = renderHook(() => useVisibleRange( | ||||
| 				scrollTop, | ||||
| 				listSize, | ||||
| 				itemSize, | ||||
| 				noteCount, | ||||
| 				flow | ||||
| 			)); | ||||
|  | ||||
| 			expect(result.current).toEqual(expected); | ||||
| 		} | ||||
| 	}); | ||||
|  | ||||
| }); | ||||
							
								
								
									
										57
									
								
								packages/app-desktop/gui/NoteList/utils/useVisibleRange.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								packages/app-desktop/gui/NoteList/utils/useVisibleRange.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| import { Size } from '@joplin/utils/types'; | ||||
| import { useMemo } from 'react'; | ||||
|  | ||||
| const useVisibleRange = (itemsPerLine: number, scrollTop: number, listSize: Size, itemSize: Size, noteCount: number) => { | ||||
| 	const startLineIndexFloat = useMemo(() => { | ||||
| 		return scrollTop / itemSize.height; | ||||
| 	}, [scrollTop, itemSize.height]); | ||||
|  | ||||
| 	const endLineIndexFloat = useMemo(() => { | ||||
| 		return startLineIndexFloat + (listSize.height / itemSize.height); | ||||
| 	}, [startLineIndexFloat, listSize.height, itemSize.height]); | ||||
|  | ||||
| 	const startLineIndex = useMemo(() => { | ||||
| 		return Math.floor(startLineIndexFloat); | ||||
| 	}, [startLineIndexFloat]); | ||||
|  | ||||
| 	const endLineIndex = useMemo(() => { | ||||
| 		return Math.floor(endLineIndexFloat); | ||||
| 	}, [endLineIndexFloat]); | ||||
|  | ||||
| 	const visibleLineCount = useMemo(() => { | ||||
| 		return endLineIndex - startLineIndex + 1; | ||||
| 	}, [endLineIndex, startLineIndex]); | ||||
|  | ||||
| 	const visibleItemCount = useMemo(() => { | ||||
| 		return visibleLineCount * itemsPerLine; | ||||
| 	}, [visibleLineCount, itemsPerLine]); | ||||
|  | ||||
| 	const startNoteIndex = useMemo(() => { | ||||
| 		return itemsPerLine * startLineIndex; | ||||
| 	}, [itemsPerLine, startLineIndex]); | ||||
|  | ||||
| 	const endNoteIndex = useMemo(() => { | ||||
| 		let output = (endLineIndex + 1) * itemsPerLine - 1; | ||||
| 		if (output >= noteCount) output = noteCount - 1; | ||||
| 		return output; | ||||
| 	}, [endLineIndex, itemsPerLine, noteCount]); | ||||
|  | ||||
| 	const totalLineCount = useMemo(() => { | ||||
| 		return Math.ceil(noteCount / itemsPerLine); | ||||
| 	}, [noteCount, itemsPerLine]); | ||||
|  | ||||
| 	// console.info('itemsPerLine', itemsPerLine); | ||||
| 	// console.info('startLineIndexFloat', startLineIndexFloat); | ||||
| 	// console.info('endLineIndexFloat', endLineIndexFloat); | ||||
| 	// console.info('visibleLineCount', visibleLineCount); | ||||
| 	// console.info('startNoteIndex', startNoteIndex); | ||||
| 	// console.info('endNoteIndex', endNoteIndex); | ||||
| 	// console.info('startLineIndex', startLineIndex); | ||||
| 	// console.info('endLineIndex', endLineIndex); | ||||
| 	// console.info('totalLineCount', totalLineCount); | ||||
| 	// console.info('visibleItemCount', visibleItemCount); | ||||
|  | ||||
| 	return [startNoteIndex, endNoteIndex, startLineIndex, endLineIndex, totalLineCount, visibleItemCount]; | ||||
| }; | ||||
|  | ||||
| export default useVisibleRange; | ||||
							
								
								
									
										143
									
								
								packages/app-desktop/gui/NoteListItem/NoteListItem.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								packages/app-desktop/gui/NoteListItem/NoteListItem.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,143 @@ | ||||
| import * as React from 'react'; | ||||
| import { useCallback, forwardRef, LegacyRef, ChangeEvent, CSSProperties, MouseEventHandler, DragEventHandler, useMemo, memo } from 'react'; | ||||
| import { ItemFlow, OnChangeEvent, OnChangeHandler } from '../NoteList/utils/types'; | ||||
| import { Size } from '@joplin/utils/types'; | ||||
| import useRootElement from './utils/useRootElement'; | ||||
| import useItemElement from './utils/useItemElement'; | ||||
| import useItemEventHandlers from './utils/useItemEventHandlers'; | ||||
| import { OnCheckboxChange } from './utils/types'; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
|  | ||||
| interface NoteItemProps { | ||||
| 	dragIndex: number; | ||||
| 	flow: ItemFlow; | ||||
| 	highlightedWords: string[]; | ||||
| 	index: number; | ||||
| 	isProvisional: boolean; | ||||
| 	itemSize: Size; | ||||
| 	noteCount: number; | ||||
| 	noteHtml: string; | ||||
| 	noteId: string; | ||||
| 	onChange: OnChangeHandler; | ||||
| 	onClick: MouseEventHandler<HTMLDivElement>; | ||||
| 	onContextMenu: MouseEventHandler; | ||||
| 	onDragOver: DragEventHandler; | ||||
| 	onDragStart: DragEventHandler; | ||||
| 	style: CSSProperties; | ||||
| } | ||||
|  | ||||
| const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => { | ||||
| 	const elementId = `list-note-${props.noteId}`; | ||||
|  | ||||
| 	const onCheckboxChange: OnCheckboxChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => { | ||||
| 		const changeEvent: OnChangeEvent = { | ||||
| 			noteId: props.noteId, | ||||
| 			elementId: event.currentTarget.getAttribute('data-id'), | ||||
| 			value: event.currentTarget.checked, | ||||
| 		}; | ||||
|  | ||||
| 		if (changeEvent.elementId === 'todo-checkbox') { | ||||
| 			await Note.save({ | ||||
| 				id: changeEvent.noteId, | ||||
| 				todo_completed: changeEvent.value ? Date.now() : 0, | ||||
| 			}, { userSideValidation: true }); | ||||
| 		} else { | ||||
| 			if (props.onChange) await props.onChange(changeEvent); | ||||
| 		} | ||||
| 	}, [props.onChange, props.noteId]); | ||||
|  | ||||
| 	const rootElement = useRootElement(elementId); | ||||
|  | ||||
| 	const itemElement = useItemElement( | ||||
| 		rootElement, | ||||
| 		props.noteId, | ||||
| 		props.noteHtml, | ||||
| 		props.style, | ||||
| 		props.itemSize, | ||||
| 		props.onClick, | ||||
| 		props.flow | ||||
| 	); | ||||
|  | ||||
| 	useItemEventHandlers(rootElement, itemElement, onCheckboxChange); | ||||
|  | ||||
| 	const className = useMemo(() => { | ||||
| 		return [ | ||||
| 			'note-list-item-wrapper', | ||||
|  | ||||
| 			// This is not used by the app, but kept here because it may be used | ||||
| 			// by users for custom CSS. | ||||
| 			(props.index + 1) % 2 === 0 ? 'even' : 'odd', | ||||
|  | ||||
| 			props.isProvisional && '-provisional', | ||||
| 		].filter(e => !!e).join(' '); | ||||
| 	}, [props.index, props.isProvisional]); | ||||
|  | ||||
| 	const isActiveDragItem = props.dragIndex === props.index; | ||||
| 	const isLastActiveDragItem = props.index === props.noteCount - 1 && props.dragIndex >= props.noteCount; | ||||
|  | ||||
| 	const dragCursorStyle = useMemo(() => { | ||||
| 		if (props.flow === ItemFlow.TopToBottom) { | ||||
| 			let dragItemPosition = ''; | ||||
| 			if (isActiveDragItem) { | ||||
| 				dragItemPosition = 'top'; | ||||
| 			} else if (isLastActiveDragItem) { | ||||
| 				dragItemPosition = 'bottom'; | ||||
| 			} | ||||
|  | ||||
| 			const output: React.CSSProperties = { | ||||
| 				width: props.itemSize.width, | ||||
| 				display: dragItemPosition ? 'block' : 'none', | ||||
| 				left: 0, | ||||
| 			}; | ||||
|  | ||||
| 			if (dragItemPosition === 'top') { | ||||
| 				output.top = 0; | ||||
| 			} else { | ||||
| 				output.bottom = 0; | ||||
| 			} | ||||
|  | ||||
| 			return output; | ||||
| 		} | ||||
|  | ||||
| 		if (props.flow === ItemFlow.LeftToRight) { | ||||
| 			let dragItemPosition = ''; | ||||
| 			if (isActiveDragItem) { | ||||
| 				dragItemPosition = 'left'; | ||||
| 			} else if (isLastActiveDragItem) { | ||||
| 				dragItemPosition = 'right'; | ||||
| 			} | ||||
|  | ||||
| 			const output: React.CSSProperties = { | ||||
| 				height: props.itemSize.height, | ||||
| 				display: dragItemPosition ? 'block' : 'none', | ||||
| 				top: 0, | ||||
| 			}; | ||||
|  | ||||
| 			if (dragItemPosition === 'left') { | ||||
| 				output.left = 0; | ||||
| 			} else { | ||||
| 				output.right = 0; | ||||
| 			} | ||||
|  | ||||
| 			return output; | ||||
| 		} | ||||
|  | ||||
| 		throw new Error('Unreachable'); | ||||
| 	}, [isActiveDragItem, isLastActiveDragItem, props.flow, props.itemSize]); | ||||
|  | ||||
| 	return <div | ||||
| 		id={elementId} | ||||
| 		ref={ref} | ||||
| 		draggable={true} | ||||
| 		tabIndex={0} | ||||
| 		className={className} | ||||
| 		data-id={props.noteId} | ||||
| 		onContextMenu={props.onContextMenu} | ||||
| 		onDragStart={props.onDragStart} | ||||
| 		onDragOver={props.onDragOver} | ||||
| 	> | ||||
| 		<div className="dragcursor" style={dragCursorStyle}></div> | ||||
| 	</div>; | ||||
| }; | ||||
|  | ||||
| export default memo(forwardRef(NoteListItem)); | ||||
							
								
								
									
										3
									
								
								packages/app-desktop/gui/NoteListItem/utils/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								packages/app-desktop/gui/NoteListItem/utils/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| import * as React from 'react'; | ||||
|  | ||||
| export type OnCheckboxChange = (event: React.ChangeEvent<HTMLInputElement>)=> void; | ||||
| @@ -0,0 +1,35 @@ | ||||
| import * as React from 'react'; | ||||
| import { Size } from '@joplin/utils/types'; | ||||
| import { useEffect, useState } from 'react'; | ||||
| import { ItemFlow } from '../../NoteList/utils/types'; | ||||
|  | ||||
| const useItemElement = (rootElement: HTMLDivElement, noteId: string, noteHtml: string, style: any, itemSize: Size, onClick: React.MouseEventHandler<HTMLDivElement>, flow: ItemFlow) => { | ||||
| 	const [itemElement, setItemElement] = useState<HTMLDivElement>(null); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (!rootElement) return () => {}; | ||||
|  | ||||
| 		const element = document.createElement('div'); | ||||
| 		element.setAttribute('data-id', noteId); | ||||
| 		element.className = 'note-list-item'; | ||||
| 		for (const [n, v] of Object.entries(style)) { | ||||
| 			(element.style as any)[n] = v; | ||||
| 		} | ||||
| 		if (flow === ItemFlow.LeftToRight) element.style.width = `${itemSize.width}px`; | ||||
| 		element.style.height = `${itemSize.height}px`; | ||||
| 		element.innerHTML = noteHtml; | ||||
| 		element.addEventListener('click', onClick as any); | ||||
|  | ||||
| 		rootElement.appendChild(element); | ||||
|  | ||||
| 		setItemElement(element); | ||||
|  | ||||
| 		return () => { | ||||
| 			element.remove(); | ||||
| 		}; | ||||
| 	}, [rootElement, itemSize, noteHtml, noteId, style, onClick, flow]); | ||||
|  | ||||
| 	return itemElement; | ||||
| }; | ||||
|  | ||||
| export default useItemElement; | ||||
| @@ -0,0 +1,27 @@ | ||||
| import { OnCheckboxChange } from './types'; | ||||
| import { useEffect } from 'react'; | ||||
|  | ||||
| const useItemEventHandlers = (rootElement: HTMLDivElement, itemElement: HTMLDivElement, onCheckboxChange: OnCheckboxChange) => { | ||||
| 	useEffect(() => { | ||||
| 		if (!itemElement) return () => {}; | ||||
|  | ||||
| 		const inputs = itemElement.getElementsByTagName('input'); | ||||
|  | ||||
| 		const mods: HTMLInputElement[] = []; | ||||
|  | ||||
| 		for (const input of inputs) { | ||||
| 			if (input.type === 'checkbox') { | ||||
| 				input.addEventListener('change', onCheckboxChange as any); | ||||
| 				mods.push(input); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return () => { | ||||
| 			for (const input of mods) { | ||||
| 				input.removeEventListener('change', onCheckboxChange as any); | ||||
| 			} | ||||
| 		}; | ||||
| 	}, [itemElement, rootElement, onCheckboxChange]); | ||||
| }; | ||||
|  | ||||
| export default useItemEventHandlers; | ||||
| @@ -0,0 +1,44 @@ | ||||
| import Folder from '@joplin/lib/models/Folder'; | ||||
| import { NoteEntity } from '@joplin/lib/services/database/types'; | ||||
| import { PluginStates } from '@joplin/lib/services/plugins/reducer'; | ||||
| import { useCallback } from 'react'; | ||||
| import { Dispatch } from 'redux'; | ||||
| import bridge from '../../../services/bridge'; | ||||
| import NoteListUtils from '../../utils/NoteListUtils'; | ||||
|  | ||||
| const useOnContextMenu = ( | ||||
| 	selectedNoteIds: string[], | ||||
| 	selectedFolderId: string, | ||||
| 	notes: NoteEntity[], | ||||
| 	dispatch: Dispatch, | ||||
| 	watchedNoteFiles: string[], | ||||
| 	plugins: PluginStates, | ||||
| 	customCss: string | ||||
| ) => { | ||||
| 	return useCallback((event: any) => { | ||||
| 		const currentNoteId = event.currentTarget.getAttribute('data-id'); | ||||
| 		if (!currentNoteId) return; | ||||
|  | ||||
| 		let noteIds = []; | ||||
| 		if (selectedNoteIds.indexOf(currentNoteId) < 0) { | ||||
| 			noteIds = [currentNoteId]; | ||||
| 		} else { | ||||
| 			noteIds = selectedNoteIds; | ||||
| 		} | ||||
|  | ||||
| 		if (!noteIds.length) return; | ||||
|  | ||||
| 		const menu = NoteListUtils.makeContextMenu(noteIds, { | ||||
| 			notes: notes, | ||||
| 			dispatch: dispatch, | ||||
| 			watchedNoteFiles: watchedNoteFiles, | ||||
| 			plugins: plugins, | ||||
| 			inConflictFolder: selectedFolderId === Folder.conflictFolderId(), | ||||
| 			customCss: customCss, | ||||
| 		}); | ||||
|  | ||||
| 		menu.popup({ window: bridge().window() }); | ||||
| 	}, [selectedNoteIds, notes, dispatch, watchedNoteFiles, plugins, selectedFolderId, customCss]); | ||||
| }; | ||||
|  | ||||
| export default useOnContextMenu; | ||||
| @@ -0,0 +1,17 @@ | ||||
| import { useState } from 'react'; | ||||
| import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; | ||||
| import { waitForElement } from '@joplin/lib/dom'; | ||||
|  | ||||
| const useRootElement = (elementId: string) => { | ||||
| 	const [rootElement, setRootElement] = useState<HTMLDivElement>(null); | ||||
|  | ||||
| 	useAsyncEffect(async (event) => { | ||||
| 		const element = await waitForElement(document, elementId); | ||||
| 		if (event.cancelled) return; | ||||
| 		setRootElement(element); | ||||
| 	}, [document, elementId]); | ||||
|  | ||||
| 	return rootElement; | ||||
| }; | ||||
|  | ||||
| export default useRootElement; | ||||
| @@ -1,7 +1,8 @@ | ||||
| import { themeStyle } from '@joplin/lib/theme'; | ||||
| import * as React from 'react'; | ||||
| import { useMemo, useState } from 'react'; | ||||
| import NoteList from '../NoteList/NoteList'; | ||||
| // import NoteList from '../NoteList/NoteList'; | ||||
| import NoteList2 from '../NoteList/NoteList2'; | ||||
| import NoteListControls from '../NoteListControls/NoteListControls'; | ||||
| import { Size } from '../ResizableLayout/utils/types'; | ||||
| import styled from 'styled-components'; | ||||
| @@ -39,10 +40,12 @@ export default function NoteListWrapper(props: Props) { | ||||
| 		}; | ||||
| 	}, [props.size, controlHeight]); | ||||
|  | ||||
| 	// <NoteList resizableLayoutEventEmitter={props.resizableLayoutEventEmitter} size={noteListSize} visible={props.visible}/> | ||||
|  | ||||
| 	return ( | ||||
| 		<StyledRoot> | ||||
| 			<NoteListControls height={controlHeight} width={noteListSize.width} onContentHeightChange={onContentHeightChange}/> | ||||
| 			<NoteList resizableLayoutEventEmitter={props.resizableLayoutEventEmitter} size={noteListSize} visible={props.visible}/> | ||||
| 			<NoteList2 resizableLayoutEventEmitter={props.resizableLayoutEventEmitter} size={noteListSize} visible={props.visible}/> | ||||
| 		</StyledRoot> | ||||
| 	); | ||||
| } | ||||
|   | ||||
| @@ -11,7 +11,7 @@ | ||||
| import { useEffect, useState } from 'react'; | ||||
| import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect'; | ||||
| import themeToCss from '@joplin/lib/services/style/themeToCss'; | ||||
| import { addExtraStyles, themeById } from '@joplin/lib/theme'; | ||||
| import { themeStyle } from '@joplin/lib/theme'; | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: any; | ||||
| @@ -21,7 +21,7 @@ export default function(props: Props): any { | ||||
| 	const [styleSheetContent, setStyleSheetContent] = useState(''); | ||||
|  | ||||
| 	useAsyncEffect(async (event: AsyncEffectEvent) => { | ||||
| 		const theme = addExtraStyles(themeById(props.themeId)); | ||||
| 		const theme = themeStyle(props.themeId); | ||||
| 		const themeCss = themeToCss(theme); | ||||
| 		if (event.cancelled) return; | ||||
| 		setStyleSheetContent(themeCss); | ||||
|   | ||||
| @@ -7,19 +7,19 @@ import InteropServiceHelper from '../../InteropServiceHelper'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types'; | ||||
| import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils'; | ||||
|  | ||||
| import bridge from '../../services/bridge'; | ||||
| import BaseModel from '@joplin/lib/BaseModel'; | ||||
| const bridge = require('@electron/remote').require('./bridge').default; | ||||
| const Menu = bridge().Menu; | ||||
| const MenuItem = bridge().MenuItem; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| const { clipboard } = require('electron'); | ||||
| import { Dispatch } from 'redux'; | ||||
|  | ||||
| const Menu = bridge().Menu; | ||||
| const MenuItem = bridge().MenuItem; | ||||
|  | ||||
| interface ContextMenuProps { | ||||
| 	notes: any[]; | ||||
| 	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | ||||
| 	dispatch: Function; | ||||
| 	dispatch: Dispatch; | ||||
| 	watchedNoteFiles: string[]; | ||||
| 	plugins: PluginStates; | ||||
| 	inConflictFolder: boolean; | ||||
| @@ -45,26 +45,26 @@ export default class NoteListUtils { | ||||
|  | ||||
| 		if (!hasEncrypted) { | ||||
| 			menu.append( | ||||
| 				new MenuItem(menuUtils.commandToStatefulMenuItem('setTags', noteIds)) | ||||
| 				new MenuItem(menuUtils.commandToStatefulMenuItem('setTags', noteIds) as any) | ||||
| 			); | ||||
|  | ||||
| 			menu.append( | ||||
| 				new MenuItem(menuUtils.commandToStatefulMenuItem('moveToFolder', noteIds)) | ||||
| 				new MenuItem(menuUtils.commandToStatefulMenuItem('moveToFolder', noteIds) as any) | ||||
| 			); | ||||
|  | ||||
| 			menu.append( | ||||
| 				new MenuItem(menuUtils.commandToStatefulMenuItem('duplicateNote', noteIds)) | ||||
| 				new MenuItem(menuUtils.commandToStatefulMenuItem('duplicateNote', noteIds) as any) | ||||
| 			); | ||||
|  | ||||
| 			if (singleNoteId) { | ||||
| 				const cmd = props.watchedNoteFiles.includes(singleNoteId) ? 'stopExternalEditing' : 'startExternalEditing'; | ||||
| 				menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem(cmd, singleNoteId))); | ||||
| 				menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem(cmd, singleNoteId) as any)); | ||||
| 			} | ||||
|  | ||||
| 			if (noteIds.length <= 1) { | ||||
| 				menu.append( | ||||
| 					new MenuItem( | ||||
| 						menuUtils.commandToStatefulMenuItem('toggleNoteType', noteIds) | ||||
| 						menuUtils.commandToStatefulMenuItem('toggleNoteType', noteIds) as any | ||||
| 					) | ||||
| 				); | ||||
| 			} else { | ||||
| @@ -125,7 +125,7 @@ export default class NoteListUtils { | ||||
| 			if ([9, 10].includes(Setting.value('sync.target'))) { | ||||
| 				menu.append( | ||||
| 					new MenuItem( | ||||
| 						menuUtils.commandToStatefulMenuItem('showShareNoteDialog', noteIds.slice()) | ||||
| 						menuUtils.commandToStatefulMenuItem('showShareNoteDialog', noteIds.slice()) as any | ||||
| 					) | ||||
| 				); | ||||
| 			} | ||||
| @@ -156,7 +156,7 @@ export default class NoteListUtils { | ||||
|  | ||||
| 			exportMenu.append( | ||||
| 				new MenuItem( | ||||
| 					menuUtils.commandToStatefulMenuItem('exportPdf', noteIds) | ||||
| 					menuUtils.commandToStatefulMenuItem('exportPdf', noteIds) as any | ||||
| 				) | ||||
| 			); | ||||
|  | ||||
| @@ -167,7 +167,7 @@ export default class NoteListUtils { | ||||
|  | ||||
| 		menu.append( | ||||
| 			new MenuItem( | ||||
| 				menuUtils.commandToStatefulMenuItem('deleteNote', noteIds) | ||||
| 				menuUtils.commandToStatefulMenuItem('deleteNote', noteIds) as any | ||||
| 			) | ||||
| 		); | ||||
|  | ||||
| @@ -179,7 +179,7 @@ export default class NoteListUtils { | ||||
|  | ||||
| 			if (cmdService.isEnabled(info.view.commandName)) { | ||||
| 				menu.append( | ||||
| 					new MenuItem(menuUtils.commandToStatefulMenuItem(info.view.commandName, noteIds)) | ||||
| 					new MenuItem(menuUtils.commandToStatefulMenuItem(info.view.commandName, noteIds) as any) | ||||
| 				); | ||||
| 			} | ||||
| 		} | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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; | ||||
| @@ -1,5 +1,3 @@ | ||||
| /* eslint-disable import/prefer-default-export */ | ||||
|  | ||||
| export const isInsideContainer = (node: any, className: string): boolean => { | ||||
| 	while (node) { | ||||
| 		if (node.classList && node.classList.contains(className)) return true; | ||||
| @@ -7,3 +5,49 @@ export const isInsideContainer = (node: any, className: string): boolean => { | ||||
| 	} | ||||
| 	return false; | ||||
| }; | ||||
|  | ||||
| export const waitForElement = async (parent: any, id: string): Promise<any> => { | ||||
| 	return new Promise((resolve, reject) => { | ||||
| 		const iid = setInterval(() => { | ||||
| 			try { | ||||
| 				const element = parent.getElementById(id); | ||||
| 				if (element) { | ||||
| 					clearInterval(iid); | ||||
| 					resolve(element); | ||||
| 				} | ||||
| 			} catch (error) { | ||||
| 				clearInterval(iid); | ||||
| 				reject(error); | ||||
| 			} | ||||
| 		}, 10); | ||||
| 	}); | ||||
| }; | ||||
|  | ||||
| // ----------------------------------------------------------------------- | ||||
| // Imported from https://github.com/Moh-Snoussi/keyboard-event-key-type | ||||
| // ----------------------------------------------------------------------- | ||||
|  | ||||
| type NumericKeypadKeys = 'Decimal' | 'Key11' | 'Key12' | 'Multiply' | 'Add' | 'Clear' | 'Divide' | 'Subtract' | 'Separator' | '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'; | ||||
| type UpperAlpha = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z'; | ||||
| type LowerAlpha = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z'; | ||||
| type ModifierKeys = 'Alt' | 'AltGraph' | 'CapsLock' | 'Control' | 'Fn' | 'FnLock' | 'Hyper' | 'Meta' | 'NumLock' | 'ScrollLock' | 'Shift' | 'Super' | 'Symbol' | 'SymbolLock'; | ||||
| type WhitespaceKeys = 'Enter' | 'Tab' | ' '; | ||||
| type NavigationKeys = 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'End' | 'Home' | 'PageDown' | 'PageUp'; | ||||
| type EditingKeys = 'Backspace' | 'Clear' | 'Copy' | 'CrSel' | 'Cut' | 'Delete' | 'EraseEof' | 'ExSel' | 'Insert' | 'Paste' | 'Redo' | 'Undo'; | ||||
| type UIKeys = 'Accept' | 'Again' | 'Attn' | 'Cancel' | 'ContextMenu' | 'Escape' | 'Execute' | 'Find' | 'Finish' | 'Help' | 'Pause' | 'Play' | 'Props' | 'Select' | 'ZoomIn' | 'ZoomOut'; | ||||
| type DeviceKeys = 'BrightnessDown' | 'BrightnessUp' | 'Eject' | 'LogOff' | 'Power' | 'PowerOff' | 'PrintScreen' | 'Hibernate' | 'Standby' | 'WakeUp'; | ||||
| type IMECompositionKeys = 'AllCandidates' | 'Alphanumeric' | 'CodeInput' | 'Compose' | 'Convert' | 'Dead' | 'FinalMode' | 'GroupFirst' | 'GroupLast' | 'GroupNext' | 'GroupPrevious' | 'ModeChange' | 'NextCandidate' | 'NonConvert' | 'PreviousCandidate' | 'Process' | 'SingleCandidate'; | ||||
| type LinuxDeadKeys = 'GDK_KEY_dead_grave' | 'GDK_KEY_dead_acute' | 'GDK_KEY_dead_circumflex' | 'GDK_KEY_dead_tilde' | 'GDK_KEY_dead_perispomeni' | 'GDK_KEY_dead_macron' | 'GDK_KEY_dead_breve' | 'GDK_KEY_dead_abovedot' | 'GDK_KEY_dead_diaeresis' | 'GDK_KEY_dead_abovering' | 'GDK_KEY_dead_doubleacute' | 'GDK_KEY_dead_caron' | 'GDK_KEY_dead_cedilla' | 'GDK_KEY_dead_ogonek' | 'GDK_KEY_dead_iota' | 'GDK_KEY_dead_voiced_sound' | 'GDK_KEY_dead_semivoiced_sound' | 'GDK_KEY_dead_belowdot' | 'GDK_KEY_dead_hook' | 'GDK_KEY_dead_horn' | 'GDK_KEY_dead_stroke' | 'GDK_KEY_dead_abovecomma' | 'GDK_KEY_dead_psili' | 'GDK_KEY_dead_abovereversedcomma' | 'GDK_KEY_dead_dasia' | 'GDK_KEY_dead_doublegrave' | 'GDK_KEY_dead_belowring' | 'GDK_KEY_dead_belowmacron' | 'GDK_KEY_dead_belowcircumflex' | 'GDK_KEY_dead_belowtilde' | 'GDK_KEY_dead_belowbreve' | 'GDK_KEY_dead_belowdiaeresis' | 'GDK_KEY_dead_invertedbreve' | 'GDK_KEY_dead_belowcomma' | 'GDK_KEY_dead_currency' | 'GDK_KEY_dead_a' | 'GDK_KEY_dead_A' | 'GDK_KEY_dead_e' | 'GDK_KEY_dead_E' | 'GDK_KEY_dead_i' | 'GDK_KEY_dead_I' | 'GDK_KEY_dead_o' | 'GDK_KEY_dead_O' | 'GDK_KEY_dead_u' | 'GDK_KEY_dead_U' | 'GDK_KEY_dead_small_schwa' | 'GDK_KEY_dead_capital_schwa' | 'GDK_KEY_dead_greek'; | ||||
| type FunctionKeys = 'F1' | 'F2' | 'F3' | 'F4' | 'F5' | 'F6' | 'F7' | 'F8' | 'F9' | 'F10' | 'F11' | 'F12' | 'F13' | 'F14' | 'F15' | 'F16' | 'F17' | 'F18' | 'F19' | 'F20' | 'Soft1' | 'Soft2' | 'Soft3' | 'Soft4'; | ||||
| type PhoneKeys = 'AppSwitch' | 'Call' | 'Camera' | 'CameraFocus' | 'EndCall' | 'GoBack' | 'GoHome' | 'HeadsetHook' | 'LastNumberRedial' | 'Notification' | 'MannerMode' | 'VoiceDial'; | ||||
| type MultimediaKeys = 'ChannelDown' | 'ChannelUp' | 'MediaFastForward' | 'MediaPause' | 'MediaPlay' | 'MediaPlayPause' | 'MediaRecord' | 'MediaRewind' | 'MediaStop' | 'MediaTrackNext' | 'MediaTrackPrevious'; | ||||
| type TVControlKeys = 'TV' | 'TV3DMode' | 'TVAntennaCable' | 'TVAudioDescription' | 'TVAudioDescriptionMixDown' | 'TVAudioDescriptionMixUp' | 'TVContentsMenu' | 'TVDataService' | 'TVInput' | 'TVInputComponent1' | 'TVInputComponent2' | 'TVInputComposite1' | 'TVInputComposite2' | 'TVInputHDMI1' | 'TVInputHDMI2' | 'TVInputHDMI3' | 'TVInputHDMI4' | 'TVInputVGA1' | 'TVMediaContext' | 'TVNetwork' | 'TVNumberEntry' | 'TVPower' | 'TVRadioService' | 'TVSatellite' | 'TVSatelliteBS' | 'TVSatelliteCS' | 'TVSatelliteToggle' | 'TVTerrestrialAnalog' | 'TVTerrestrialDigital' | 'TVTimer'; | ||||
| type MediaControllerKeys = 'AVRInput' | 'AVRPower' | 'ColorF0Red' | 'ColorF1Green' | 'ColorF2Yellow' | 'ColorF3Blue' | 'ColorF4Grey' | 'ColorF5Brown' | 'ClosedCaptionToggle' | 'Dimmer' | 'DisplaySwap' | 'DVR' | 'Exit' | 'FavoriteClear0' | 'FavoriteClear1' | 'FavoriteClear2' | 'FavoriteClear3' | 'FavoriteRecall0' | 'FavoriteRecall1' | 'FavoriteRecall2' | 'FavoriteRecall3' | 'FavoriteStore0' | 'FavoriteStore1' | 'FavoriteStore2' | 'FavoriteStore3' | 'Guide' | 'GuideNextDay' | 'GuidePreviousDay' | 'Info' | 'InstantReplay' | 'Link' | 'ListProgram' | 'LiveContent' | 'Lock' | 'MediaApps' | 'MediaAudioTrack' | 'MediaLast' | 'MediaSkipBackward' | 'MediaSkipForward' | 'MediaStepBackward' | 'MediaStepForward' | 'MediaTopMenu' | 'NavigateIn' | 'NavigateNext' | 'NavigateOut' | 'NavigatePrevious' | 'NextFavoriteChannel' | 'NextUserProfile' | 'OnDemand' | 'Pairing' | 'PinPDown' | 'PinPMove' | 'PinPToggle' | 'PinPUp' | 'PlaySpeedDown' | 'PlaySpeedReset' | 'PlaySpeedUp' | 'RandomToggle' | 'RcLowBattery' | 'RecordSpeedNext' | 'RfBypass' | 'ScanChannelsToggle' | 'ScreenModeNext' | 'Settings' | 'SplitScreenToggle' | 'STBInput' | 'STBPower' | 'Subtitle' | 'Teletext' | 'VideoModeNext' | 'Wink' | 'ZoomToggle'; | ||||
| type SpeechRecognitionKeys = 'SpeechCorrectionList' | 'SpeechInputToggle'; | ||||
| type DocumentKeys = 'Close' | 'New' | 'Open' | 'Print' | 'Save' | 'SpellCheck' | 'MailForward' | 'MailReply' | 'MailSend'; | ||||
| type ApplicationSelectorKeys = 'LaunchCalculator' | 'LaunchCalendar' | 'LaunchContacts' | 'LaunchMail' | 'LaunchMediaPlayer' | 'LaunchMusicPlayer' | 'LaunchMyComputer' | 'LaunchPhone' | 'LaunchScreenSaver' | 'LaunchSpreadsheet' | 'LaunchWebBrowser' | 'LaunchWebCam' | 'LaunchWordProcessor' | 'LaunchApplication1' | 'LaunchApplication2' | 'LaunchApplication3' | 'LaunchApplication4' | 'LaunchApplication5' | 'LaunchApplication6' | 'LaunchApplication7' | 'LaunchApplication8' | 'LaunchApplication9' | 'LaunchApplication10' | 'LaunchApplication11' | 'LaunchApplication12' | 'LaunchApplication13' | 'LaunchApplication14' | 'LaunchApplication15' | 'LaunchApplication16'; | ||||
| type BrowserControlKeys = 'BrowserBack' | 'BrowserFavorites' | 'BrowserForward' | 'BrowserHome' | 'BrowserRefresh' | 'BrowserSearch' | 'BrowserStop'; | ||||
| type KoreanKeyboardsOnly = 'HangulMode' | 'HanjaMode' | 'JunjaMode'; | ||||
| type SpecialValueKey = 'Unidentified'; | ||||
|  | ||||
| export declare type KeyboardEventKey = SpecialValueKey | ModifierKeys | WhitespaceKeys | NavigationKeys | EditingKeys | UIKeys | DeviceKeys | IMECompositionKeys | LinuxDeadKeys | FunctionKeys | PhoneKeys | MultimediaKeys | TVControlKeys | MediaControllerKeys | SpeechRecognitionKeys | DocumentKeys | ApplicationSelectorKeys | BrowserControlKeys | NumericKeypadKeys | UpperAlpha | LowerAlpha | KoreanKeyboardsOnly; | ||||
|   | ||||
| @@ -491,6 +491,7 @@ function changeSelectedNotes(draft: Draft<State>, action: any, options: any = nu | ||||
| 	if (action.id) noteIds = [action.id]; | ||||
| 	if (action.ids) noteIds = action.ids; | ||||
| 	if (action.noteId) noteIds = [action.noteId]; | ||||
| 	if (action.index) noteIds = [draft.notes[action.index].id]; | ||||
|  | ||||
| 	if (action.type === 'NOTE_SELECT') { | ||||
| 		if (JSON.stringify(draft.selectedNoteIds) === JSON.stringify(noteIds)) return; | ||||
|   | ||||
| @@ -46,6 +46,34 @@ export interface UserDataValue { | ||||
|  | ||||
| export type UserData = Record<string, Record<string, UserDataValue>>; | ||||
|  | ||||
| interface DatabaseTableColumn { | ||||
| 	type: string; | ||||
| } | ||||
|  | ||||
| interface DatabaseTable { | ||||
| 	[key: string]: DatabaseTableColumn; | ||||
| } | ||||
|  | ||||
| interface DatabaseTables { | ||||
| 	[key: string]: DatabaseTable; | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| @@ -300,3 +328,233 @@ export interface VersionEntity { | ||||
|   'version'?: number; | ||||
|   'type_'?: number; | ||||
| } | ||||
|  | ||||
|  | ||||
| export const databaseSchema: DatabaseTables = { | ||||
| 	folders: { | ||||
| 		created_time: { type: 'number' }, | ||||
| 		encryption_applied: { type: 'number' }, | ||||
| 		encryption_cipher_text: { type: 'string' }, | ||||
| 		icon: { type: 'string' }, | ||||
| 		id: { type: 'string' }, | ||||
| 		is_shared: { type: 'number' }, | ||||
| 		master_key_id: { type: 'string' }, | ||||
| 		parent_id: { type: 'string' }, | ||||
| 		share_id: { type: 'string' }, | ||||
| 		title: { type: 'string' }, | ||||
| 		updated_time: { type: 'number' }, | ||||
| 		user_created_time: { type: 'number' }, | ||||
| 		user_updated_time: { type: 'number' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	tags: { | ||||
| 		created_time: { type: 'number' }, | ||||
| 		encryption_applied: { type: 'number' }, | ||||
| 		encryption_cipher_text: { type: 'string' }, | ||||
| 		id: { type: 'string' }, | ||||
| 		is_shared: { type: 'number' }, | ||||
| 		parent_id: { type: 'string' }, | ||||
| 		title: { type: 'string' }, | ||||
| 		updated_time: { type: 'number' }, | ||||
| 		user_created_time: { type: 'number' }, | ||||
| 		user_updated_time: { type: 'number' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	note_tags: { | ||||
| 		created_time: { type: 'number' }, | ||||
| 		encryption_applied: { type: 'number' }, | ||||
| 		encryption_cipher_text: { type: 'string' }, | ||||
| 		id: { type: 'string' }, | ||||
| 		is_shared: { type: 'number' }, | ||||
| 		note_id: { type: 'string' }, | ||||
| 		tag_id: { type: 'string' }, | ||||
| 		updated_time: { type: 'number' }, | ||||
| 		user_created_time: { type: 'number' }, | ||||
| 		user_updated_time: { type: 'number' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	table_fields: { | ||||
| 		field_default: { type: 'string' }, | ||||
| 		field_name: { type: 'string' }, | ||||
| 		field_type: { type: 'number' }, | ||||
| 		id: { type: 'number' }, | ||||
| 		table_name: { type: 'string' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	sync_items: { | ||||
| 		force_sync: { type: 'number' }, | ||||
| 		id: { type: 'number' }, | ||||
| 		item_id: { type: 'string' }, | ||||
| 		item_location: { type: 'number' }, | ||||
| 		item_type: { type: 'number' }, | ||||
| 		sync_disabled: { type: 'number' }, | ||||
| 		sync_disabled_reason: { type: 'string' }, | ||||
| 		sync_target: { type: 'number' }, | ||||
| 		sync_time: { type: 'number' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	version: { | ||||
| 		table_fields_version: { type: 'number' }, | ||||
| 		version: { type: 'number' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	deleted_items: { | ||||
| 		deleted_time: { type: 'number' }, | ||||
| 		id: { type: 'number' }, | ||||
| 		item_id: { type: 'string' }, | ||||
| 		item_type: { type: 'number' }, | ||||
| 		sync_target: { type: 'number' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	settings: { | ||||
| 		key: { type: 'string' }, | ||||
| 		value: { type: 'string' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	alarms: { | ||||
| 		id: { type: 'number' }, | ||||
| 		note_id: { type: 'string' }, | ||||
| 		trigger_time: { type: 'number' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	item_changes: { | ||||
| 		before_change_item: { type: 'string' }, | ||||
| 		created_time: { type: 'number' }, | ||||
| 		id: { type: 'number' }, | ||||
| 		item_id: { type: 'string' }, | ||||
| 		item_type: { type: 'number' }, | ||||
| 		source: { type: 'number' }, | ||||
| 		type: { type: 'number' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	note_resources: { | ||||
| 		id: { type: 'number' }, | ||||
| 		is_associated: { type: 'number' }, | ||||
| 		last_seen_time: { type: 'number' }, | ||||
| 		note_id: { type: 'string' }, | ||||
| 		resource_id: { type: 'string' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	resource_local_states: { | ||||
| 		fetch_error: { type: 'string' }, | ||||
| 		fetch_status: { type: 'number' }, | ||||
| 		id: { type: 'number' }, | ||||
| 		resource_id: { type: 'string' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	resources: { | ||||
| 		created_time: { type: 'number' }, | ||||
| 		encryption_applied: { type: 'number' }, | ||||
| 		encryption_blob_encrypted: { type: 'number' }, | ||||
| 		encryption_cipher_text: { type: 'string' }, | ||||
| 		file_extension: { type: 'string' }, | ||||
| 		filename: { type: 'string' }, | ||||
| 		id: { type: 'string' }, | ||||
| 		is_shared: { type: 'number' }, | ||||
| 		master_key_id: { type: 'string' }, | ||||
| 		mime: { type: 'string' }, | ||||
| 		share_id: { type: 'string' }, | ||||
| 		size: { type: 'number' }, | ||||
| 		title: { type: 'string' }, | ||||
| 		updated_time: { type: 'number' }, | ||||
| 		user_created_time: { type: 'number' }, | ||||
| 		user_updated_time: { type: 'number' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	revisions: { | ||||
| 		body_diff: { type: 'string' }, | ||||
| 		created_time: { type: 'number' }, | ||||
| 		encryption_applied: { type: 'number' }, | ||||
| 		encryption_cipher_text: { type: 'string' }, | ||||
| 		id: { type: 'string' }, | ||||
| 		item_id: { type: 'string' }, | ||||
| 		item_type: { type: 'number' }, | ||||
| 		item_updated_time: { type: 'number' }, | ||||
| 		metadata_diff: { type: 'string' }, | ||||
| 		parent_id: { type: 'string' }, | ||||
| 		title_diff: { type: 'string' }, | ||||
| 		updated_time: { type: 'number' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	migrations: { | ||||
| 		created_time: { type: 'number' }, | ||||
| 		id: { type: 'number' }, | ||||
| 		number: { type: 'number' }, | ||||
| 		updated_time: { type: 'number' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	resources_to_download: { | ||||
| 		created_time: { type: 'number' }, | ||||
| 		id: { type: 'number' }, | ||||
| 		resource_id: { type: 'string' }, | ||||
| 		updated_time: { type: 'number' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	key_values: { | ||||
| 		id: { type: 'number' }, | ||||
| 		key: { type: 'string' }, | ||||
| 		type: { type: 'number' }, | ||||
| 		updated_time: { type: 'number' }, | ||||
| 		value: { type: 'string' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	notes: { | ||||
| 		altitude: { type: 'number' }, | ||||
| 		application_data: { type: 'string' }, | ||||
| 		author: { type: 'string' }, | ||||
| 		body: { type: 'string' }, | ||||
| 		conflict_original_id: { type: 'string' }, | ||||
| 		created_time: { type: 'number' }, | ||||
| 		encryption_applied: { type: 'number' }, | ||||
| 		encryption_cipher_text: { type: 'string' }, | ||||
| 		id: { type: 'string' }, | ||||
| 		is_conflict: { type: 'number' }, | ||||
| 		is_shared: { type: 'number' }, | ||||
| 		is_todo: { type: 'number' }, | ||||
| 		latitude: { type: 'number' }, | ||||
| 		longitude: { type: 'number' }, | ||||
| 		markup_language: { type: 'number' }, | ||||
| 		master_key_id: { type: 'string' }, | ||||
| 		order: { type: 'number' }, | ||||
| 		parent_id: { type: 'string' }, | ||||
| 		share_id: { type: 'string' }, | ||||
| 		source: { type: 'string' }, | ||||
| 		source_application: { type: 'string' }, | ||||
| 		source_url: { type: 'string' }, | ||||
| 		title: { type: 'string' }, | ||||
| 		todo_completed: { type: 'number' }, | ||||
| 		todo_due: { type: 'number' }, | ||||
| 		updated_time: { type: 'number' }, | ||||
| 		user_created_time: { type: 'number' }, | ||||
| 		user_data: { type: 'string' }, | ||||
| 		user_updated_time: { type: 'number' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	notes_normalized: { | ||||
| 		altitude: { type: 'number' }, | ||||
| 		body: { type: 'string' }, | ||||
| 		id: { type: 'string' }, | ||||
| 		is_todo: { type: 'number' }, | ||||
| 		latitude: { type: 'number' }, | ||||
| 		longitude: { type: 'number' }, | ||||
| 		parent_id: { type: 'string' }, | ||||
| 		source_url: { type: 'string' }, | ||||
| 		title: { type: 'string' }, | ||||
| 		todo_completed: { type: 'number' }, | ||||
| 		todo_due: { type: 'number' }, | ||||
| 		user_created_time: { type: 'number' }, | ||||
| 		user_updated_time: { type: 'number' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| 	tags_with_note_count: { | ||||
| 		created_time: { type: 'number' }, | ||||
| 		id: { type: 'string' }, | ||||
| 		note_count: { type: 'any' }, | ||||
| 		title: { type: 'string' }, | ||||
| 		todo_completed_count: { type: 'any' }, | ||||
| 		updated_time: { type: 'number' }, | ||||
| 		type_: { type: 'number' }, | ||||
| 	}, | ||||
| }; | ||||
|  | ||||
| export type ItemRendererDatabaseDependency = 'folder.created_time' | 'folder.encryption_applied' | 'folder.encryption_cipher_text' | 'folder.icon' | 'folder.id' | 'folder.is_shared' | 'folder.master_key_id' | 'folder.parent_id' | 'folder.share_id' | 'folder.title' | 'folder.updated_time' | 'folder.user_created_time' | 'folder.user_updated_time' | 'folder.type_' | 'note.altitude' | 'note.application_data' | 'note.author' | 'note.body' | 'note.conflict_original_id' | 'note.created_time' | 'note.encryption_applied' | 'note.encryption_cipher_text' | 'note.id' | 'note.is_conflict' | 'note.is_shared' | 'note.is_todo' | 'note.latitude' | 'note.longitude' | 'note.markup_language' | 'note.master_key_id' | 'note.order' | 'note.parent_id' | 'note.share_id' | 'note.source' | 'note.source_application' | 'note.source_url' | 'note.title' | 'note.todo_completed' | 'note.todo_due' | 'note.updated_time' | 'note.user_created_time' | 'note.user_data' | 'note.user_updated_time' | 'note.type_'; | ||||
| @@ -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; | ||||
|  | ||||
|   | ||||
| @@ -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
									
								
							
							
						
						
									
										5
									
								
								packages/utils/html.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| /* eslint-disable import/prefer-default-export */ | ||||
|  | ||||
| const Entities = require('html-entities').AllHtmlEntities; | ||||
|  | ||||
| export const htmlentities = new Entities().encode; | ||||
| @@ -1,6 +1,6 @@ | ||||
| /* eslint-disable import/prefer-default-export */ | ||||
|  | ||||
| import { sleep } from './time'; | ||||
| import { msleep } from './time'; | ||||
| import fetch from 'node-fetch'; | ||||
|  | ||||
| export const fetchWithRetry = async (url: string, opts: any = null) => { | ||||
| @@ -20,7 +20,7 @@ export const fetchWithRetry = async (url: string, opts: any = null) => { | ||||
| 			} | ||||
|  | ||||
| 			if (opts && opts.pause) { | ||||
| 				await sleep(opts.pause); | ||||
| 				await msleep(opts.pause); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -8,6 +8,9 @@ | ||||
|     "./net": "./dist/net.js", | ||||
|     "./fs": "./dist/fs.js", | ||||
|     "./env": "./dist/env.js", | ||||
|     "./types": "./dist/types.js", | ||||
|     "./time": "./dist/time.js", | ||||
|     "./html": "./dist/html.js", | ||||
|     "./Logger": "./dist/Logger.js" | ||||
|   }, | ||||
|   "publishConfig": { | ||||
| @@ -26,6 +29,7 @@ | ||||
|     "execa": "5.1.1", | ||||
|     "fs-extra": "11.1.1", | ||||
|     "glob": "10.3.3", | ||||
|     "html-entities": "1.4.0", | ||||
|     "moment": "2.29.4", | ||||
|     "node-fetch": "2.6.7", | ||||
|     "sprintf-js": "1.1.2" | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| /* eslint-disable import/prefer-default-export */ | ||||
|  | ||||
| export const sleep = (ms: number) => { | ||||
| export const msleep = (ms: number) => { | ||||
| 	return new Promise(resolve => setTimeout(resolve, ms)); | ||||
| }; | ||||
|   | ||||
							
								
								
									
										6
									
								
								packages/utils/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								packages/utils/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| /* eslint-disable import/prefer-default-export */ | ||||
|  | ||||
| export interface Size { | ||||
| 	width: number; | ||||
| 	height: number; | ||||
| } | ||||
| @@ -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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user