You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Desktop: Add support for multiple columns note list (#9924)
This commit is contained in:
		| @@ -335,9 +335,19 @@ 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/NoteListHeader/NoteListHeader.js | ||||
| packages/app-desktop/gui/NoteListHeader/NoteListHeaderItem.js | ||||
| packages/app-desktop/gui/NoteListHeader/types.js | ||||
| packages/app-desktop/gui/NoteListHeader/useDragAndDrop.test.js | ||||
| packages/app-desktop/gui/NoteListHeader/useDragAndDrop.js | ||||
| packages/app-desktop/gui/NoteListHeader/utils/getColumnTitle.js | ||||
| packages/app-desktop/gui/NoteListHeader/utils/useContextMenu.js | ||||
| packages/app-desktop/gui/NoteListHeader/utils/validateColumns.test.js | ||||
| packages/app-desktop/gui/NoteListHeader/utils/validateColumns.js | ||||
| packages/app-desktop/gui/NoteListItem.js | ||||
| packages/app-desktop/gui/NoteListItem/NoteListItem.js | ||||
| packages/app-desktop/gui/NoteListItem/utils/getNoteTitleHtml.js | ||||
| packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.test.js | ||||
| packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.js | ||||
| packages/app-desktop/gui/NoteListItem/utils/types.js | ||||
| packages/app-desktop/gui/NoteListItem/utils/useItemElement.js | ||||
| @@ -413,6 +423,7 @@ packages/app-desktop/gui/style/StyledMessage.js | ||||
| packages/app-desktop/gui/style/StyledTextInput.js | ||||
| packages/app-desktop/gui/utils/NoteListUtils.js | ||||
| packages/app-desktop/gui/utils/convertToScreenCoordinates.js | ||||
| packages/app-desktop/gui/utils/dragAndDrop.js | ||||
| packages/app-desktop/gui/utils/loadScript.js | ||||
| packages/app-desktop/gulpfile.js | ||||
| packages/app-desktop/integration-tests/main.spec.js | ||||
| @@ -867,6 +878,12 @@ packages/lib/services/keychain/KeychainServiceDriver.node.js | ||||
| packages/lib/services/keychain/KeychainServiceDriverBase.js | ||||
| packages/lib/services/noteList/defaultLeftToRightListRenderer.js | ||||
| packages/lib/services/noteList/defaultListRenderer.js | ||||
| packages/lib/services/noteList/defaultMultiColumnsRenderer.js | ||||
| packages/lib/services/noteList/depNameToNoteProp.js | ||||
| packages/lib/services/noteList/renderTemplate.test.js | ||||
| packages/lib/services/noteList/renderTemplate.js | ||||
| packages/lib/services/noteList/renderViewProps.test.js | ||||
| packages/lib/services/noteList/renderViewProps.js | ||||
| packages/lib/services/noteList/renderers.js | ||||
| packages/lib/services/ocr/OcrDriverBase.js | ||||
| packages/lib/services/ocr/OcrService.test.js | ||||
|   | ||||
							
								
								
									
										17
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										17
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -315,9 +315,19 @@ 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/NoteListHeader/NoteListHeader.js | ||||
| packages/app-desktop/gui/NoteListHeader/NoteListHeaderItem.js | ||||
| packages/app-desktop/gui/NoteListHeader/types.js | ||||
| packages/app-desktop/gui/NoteListHeader/useDragAndDrop.test.js | ||||
| packages/app-desktop/gui/NoteListHeader/useDragAndDrop.js | ||||
| packages/app-desktop/gui/NoteListHeader/utils/getColumnTitle.js | ||||
| packages/app-desktop/gui/NoteListHeader/utils/useContextMenu.js | ||||
| packages/app-desktop/gui/NoteListHeader/utils/validateColumns.test.js | ||||
| packages/app-desktop/gui/NoteListHeader/utils/validateColumns.js | ||||
| packages/app-desktop/gui/NoteListItem.js | ||||
| packages/app-desktop/gui/NoteListItem/NoteListItem.js | ||||
| packages/app-desktop/gui/NoteListItem/utils/getNoteTitleHtml.js | ||||
| packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.test.js | ||||
| packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.js | ||||
| packages/app-desktop/gui/NoteListItem/utils/types.js | ||||
| packages/app-desktop/gui/NoteListItem/utils/useItemElement.js | ||||
| @@ -393,6 +403,7 @@ packages/app-desktop/gui/style/StyledMessage.js | ||||
| packages/app-desktop/gui/style/StyledTextInput.js | ||||
| packages/app-desktop/gui/utils/NoteListUtils.js | ||||
| packages/app-desktop/gui/utils/convertToScreenCoordinates.js | ||||
| packages/app-desktop/gui/utils/dragAndDrop.js | ||||
| packages/app-desktop/gui/utils/loadScript.js | ||||
| packages/app-desktop/gulpfile.js | ||||
| packages/app-desktop/integration-tests/main.spec.js | ||||
| @@ -847,6 +858,12 @@ packages/lib/services/keychain/KeychainServiceDriver.node.js | ||||
| packages/lib/services/keychain/KeychainServiceDriverBase.js | ||||
| packages/lib/services/noteList/defaultLeftToRightListRenderer.js | ||||
| packages/lib/services/noteList/defaultListRenderer.js | ||||
| packages/lib/services/noteList/defaultMultiColumnsRenderer.js | ||||
| packages/lib/services/noteList/depNameToNoteProp.js | ||||
| packages/lib/services/noteList/renderTemplate.test.js | ||||
| packages/lib/services/noteList/renderTemplate.js | ||||
| packages/lib/services/noteList/renderViewProps.test.js | ||||
| packages/lib/services/noteList/renderViewProps.js | ||||
| packages/lib/services/noteList/renderers.js | ||||
| packages/lib/services/ocr/OcrDriverBase.js | ||||
| packages/lib/services/ocr/OcrService.test.js | ||||
|   | ||||
| @@ -22,7 +22,7 @@ const registerSimpleTopToBottomRenderer = async () => { | ||||
| 	 | ||||
| 		dependencies: [ | ||||
| 			'item.selected', | ||||
| 			'note.titleHtml', | ||||
| 			'note.title', | ||||
| 			'note.body', | ||||
| 			'note.user_updated_time', | ||||
| 		], | ||||
| @@ -55,8 +55,8 @@ const registerSimpleTopToBottomRenderer = async () => { | ||||
| 		itemTemplate: // html | ||||
| 			` | ||||
| 			<div class="content {{#item.selected}}-selected{{/item.selected}}"> | ||||
| 				<p class="title">{{{note.titleHtml}}}</p> | ||||
| 				<p class="date">{{{updatedTime}}}</p> | ||||
| 				<p class="title">{{note.title}}</p> | ||||
| 				<p class="date">{{updatedTime}}</p> | ||||
| 				<p class="body">{{noteBody}}</p> | ||||
| 			</div> | ||||
| 		`, | ||||
| @@ -90,7 +90,7 @@ const registerSimpleLeftToRightRenderer = async() => { | ||||
| 		dependencies: [ | ||||
| 			'note.id', | ||||
| 			'item.selected', | ||||
| 			'note.titleHtml', | ||||
| 			'note.title', | ||||
| 			'note.body', | ||||
| 		], | ||||
|  | ||||
| @@ -124,7 +124,7 @@ const registerSimpleLeftToRightRenderer = async() => { | ||||
| 					<img class="thumbnail" src="file://{{thumbnailFilePath}}"/> | ||||
| 				{{/thumbnailFilePath}} | ||||
| 				{{^thumbnailFilePath}} | ||||
| 					{{{note.titleHtml}}} | ||||
| 					{{{note.title}}} | ||||
| 				{{/thumbnailFilePath}} | ||||
| 			</div> | ||||
| 		`, | ||||
|   | ||||
| @@ -45,6 +45,8 @@ import restart from '../../services/restart'; | ||||
| const { connect } = require('react-redux'); | ||||
| import PromptDialog from '../PromptDialog'; | ||||
| import NotePropertiesDialog from '../NotePropertiesDialog'; | ||||
| import { NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType'; | ||||
| import validateColumns from '../NoteListHeader/utils/validateColumns'; | ||||
| import TrashNotification from '../TrashNotification/TrashNotification'; | ||||
|  | ||||
| const PluginManager = require('@joplin/lib/services/PluginManager'); | ||||
| @@ -89,6 +91,9 @@ interface Props { | ||||
| 	lastDeletionNotificationTime: number; | ||||
| 	selectedFolderId: string; | ||||
| 	mustUpgradeAppMessage: string; | ||||
| 	notesSortOrderField: string; | ||||
| 	notesSortOrderReverse: boolean; | ||||
| 	notesColumns: NoteListColumns; | ||||
| } | ||||
|  | ||||
| interface ShareFolderDialogOptions { | ||||
| @@ -737,6 +742,9 @@ class MainScreenComponent extends React.Component<Props, State> { | ||||
| 					themeId={this.props.themeId} | ||||
| 					listRendererId={this.props.listRendererId} | ||||
| 					startupPluginsLoaded={this.props.startupPluginsLoaded} | ||||
| 					notesSortOrderField={this.props.notesSortOrderField} | ||||
| 					notesSortOrderReverse={this.props.notesSortOrderReverse} | ||||
| 					columns={this.props.notesColumns} | ||||
| 					selectedFolderId={this.props.selectedFolderId} | ||||
| 				/>; | ||||
| 			}, | ||||
| @@ -934,6 +942,9 @@ const mapStateToProps = (state: AppState) => { | ||||
| 		lastDeletionNotificationTime: state.lastDeletionNotificationTime, | ||||
| 		selectedFolderId: state.selectedFolderId, | ||||
| 		mustUpgradeAppMessage: state.mustUpgradeAppMessage, | ||||
| 		notesSortOrderField: state.settings['notes.sortOrder.field'], | ||||
| 		notesSortOrderReverse: state.settings['notes.sortOrder.reverse'], | ||||
| 		notesColumns: validateColumns(state.settings['notes.columns']), | ||||
| 	}; | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -22,6 +22,7 @@ import usePrevious from '../hooks/usePrevious'; | ||||
| 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 { registerGlobalDragEndEvent, unregisterGlobalDragEndEvent } from '../utils/dragAndDrop'; | ||||
|  | ||||
| const commands = [ | ||||
| 	require('./commands/focusElementNoteList'), | ||||
| @@ -64,8 +65,6 @@ const NoteListComponent = (props: Props) => { | ||||
| 	const noteListRef = useRef(null); | ||||
| 	const itemListRef = useRef(null); | ||||
|  | ||||
| 	let globalDragEndEventRegistered_ = false; | ||||
|  | ||||
| 	const style = useMemo(() => { | ||||
| 		const theme = themeStyle(props.themeId); | ||||
|  | ||||
| @@ -129,22 +128,6 @@ const NoteListComponent = (props: Props) => { | ||||
| 		menu.popup({ window: bridge().window() }); | ||||
| 	}, [props.selectedNoteIds, props.notes, props.dispatch, props.watchedNoteFiles, props.plugins, props.selectedFolderId, props.customCss]); | ||||
|  | ||||
| 	const onGlobalDrop_ = () => { | ||||
| 		unregisterGlobalDragEndEvent_(); | ||||
| 		setDragOverTargetNoteIndex(null); | ||||
| 	}; | ||||
|  | ||||
| 	const registerGlobalDragEndEvent_ = () => { | ||||
| 		if (globalDragEndEventRegistered_) return; | ||||
| 		globalDragEndEventRegistered_ = true; | ||||
| 		document.addEventListener('dragend', onGlobalDrop_); | ||||
| 	}; | ||||
|  | ||||
| 	const unregisterGlobalDragEndEvent_ = () => { | ||||
| 		globalDragEndEventRegistered_ = false; | ||||
| 		document.removeEventListener('dragend', onGlobalDrop_); | ||||
| 	}; | ||||
|  | ||||
| 	const dragTargetNoteIndex_ = (event: any) => { | ||||
| 		return Math.abs(Math.round((event.clientY - itemListRef.current.offsetTop() + itemListRef.current.offsetScroll()) / itemHeight)); | ||||
| 	}; | ||||
| @@ -158,7 +141,7 @@ const NoteListComponent = (props: Props) => { | ||||
| 			event.preventDefault(); | ||||
| 			const newIndex = dragTargetNoteIndex_(event); | ||||
| 			if (dragOverTargetNoteIndex === newIndex) return; | ||||
| 			registerGlobalDragEndEvent_(); | ||||
| 			registerGlobalDragEndEvent(() => setDragOverTargetNoteIndex(null)); | ||||
| 			setDragOverTargetNoteIndex(newIndex); | ||||
| 		} | ||||
| 	}; | ||||
| @@ -185,7 +168,7 @@ const NoteListComponent = (props: Props) => { | ||||
| 			return; | ||||
| 		} | ||||
| 		const dt = event.dataTransfer; | ||||
| 		unregisterGlobalDragEndEvent_(); | ||||
| 		unregisterGlobalDragEndEvent(); | ||||
| 		setDragOverTargetNoteIndex(null); | ||||
|  | ||||
| 		const targetNoteIndex = dragTargetNoteIndex_(event); | ||||
|   | ||||
| @@ -213,6 +213,7 @@ const NoteList = (props: Props) => { | ||||
| 					isWatched={props.watchedNoteFiles.includes(note.id)} | ||||
| 					listRenderer={listRenderer} | ||||
| 					dispatch={props.dispatch} | ||||
| 					columns={props.columns} | ||||
| 				/>, | ||||
| 			); | ||||
| 		} | ||||
|   | ||||
| @@ -21,7 +21,7 @@ | ||||
| } | ||||
|  | ||||
| .note-list-item { | ||||
| 	display: flex; | ||||
|  	display: flex; | ||||
| } | ||||
|  | ||||
| .note-list-item-wrapper { | ||||
| @@ -40,4 +40,4 @@ | ||||
|  | ||||
| .note-list-item-wrapper.-provisional { | ||||
| 	opacity: 0.5; | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types'; | ||||
| import { ListRenderer } from '@joplin/lib/services/plugins/api/noteListType'; | ||||
| import { ListRenderer, NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType'; | ||||
| import { PluginStates } from '@joplin/lib/services/plugins/reducer'; | ||||
| import { Size } from '@joplin/utils/types'; | ||||
| import { Dispatch } from 'redux'; | ||||
| @@ -29,6 +29,7 @@ export interface Props { | ||||
| 	focusedField: string; | ||||
| 	parentFolderIsReadOnly: boolean; | ||||
| 	listRenderer: ListRenderer; | ||||
| 	columns: NoteListColumns; | ||||
| 	selectedFolderInTrash: boolean; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -85,11 +85,13 @@ const useDragAndDrop = ( | ||||
| 		} | ||||
| 	}, [notesParentType, dragTargetNoteIndex, dragOverTargetNoteIndex, selectedFolderInTrash]); | ||||
|  | ||||
| 	const onDrop: DragEventHandler = useCallback(async (event: any) => { | ||||
| 	const onDrop: DragEventHandler = useCallback(async (event) => { | ||||
| 		const dt = event.dataTransfer; | ||||
| 		if (!dt.types.includes('text/x-jop-note-ids')) return; | ||||
|  | ||||
| 		// TODO: check that parent type is folder | ||||
| 		if (!canManuallySortNotes(notesParentType, noteSortOrder, selectedFolderInTrash)) return; | ||||
|  | ||||
| 		const dt = event.dataTransfer; | ||||
| 		setDragOverTargetNoteIndex(null); | ||||
|  | ||||
| 		const targetNoteIndex = dragTargetNoteIndex(event); | ||||
|   | ||||
							
								
								
									
										64
									
								
								packages/app-desktop/gui/NoteListHeader/NoteListHeader.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								packages/app-desktop/gui/NoteListHeader/NoteListHeader.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| import * as React from 'react'; | ||||
| import { useMemo } from 'react'; | ||||
| import { NoteListColumns, OnClickHandler } from '@joplin/lib/services/plugins/api/noteListType'; | ||||
| import { CSSProperties } from 'styled-components'; | ||||
| import NoteListHeaderItem from './NoteListHeaderItem'; | ||||
| import { OnItemClickHander } from './types'; | ||||
| import useDragAndDrop, { DataType } from './useDragAndDrop'; | ||||
| import useContextMenu from './utils/useContextMenu'; | ||||
| import depNameToNoteProp from '@joplin/lib/services/noteList/depNameToNoteProp'; | ||||
|  | ||||
| interface Props { | ||||
| 	template: string; | ||||
| 	height: number; | ||||
| 	onClick: OnClickHandler; | ||||
| 	columns: NoteListColumns; | ||||
| 	notesSortOrderField: string; | ||||
| 	notesSortOrderReverse: boolean; | ||||
| 	onItemClick: OnItemClickHander; | ||||
| } | ||||
|  | ||||
| const defaultHeight = 26; | ||||
|  | ||||
| export default (props: Props) => { | ||||
| 	const { onItemDragStart, onItemDragOver, onItemDrop, onResizerDragStart, onResizerDragEnd, dropAt, draggedItem } = useDragAndDrop(props.columns); | ||||
| 	const onContextMenu = useContextMenu(props.columns); | ||||
|  | ||||
| 	const items: React.JSX.Element[] = []; | ||||
|  | ||||
| 	let isFirst = true; | ||||
| 	for (const column of props.columns) { | ||||
| 		let dragCursorLocation = null; | ||||
| 		if (draggedItem && draggedItem.type === DataType.HeaderItem) { | ||||
| 			dragCursorLocation = dropAt && dropAt.columnName === column.name ? dropAt.location : null; | ||||
| 		} | ||||
|  | ||||
| 		items.push(<NoteListHeaderItem | ||||
| 			isFirst={isFirst} | ||||
| 			key={column.name} | ||||
| 			column={column} | ||||
| 			isCurrent={`note.${props.notesSortOrderField}` === depNameToNoteProp(column.name)} | ||||
| 			isReverse={props.notesSortOrderReverse} | ||||
| 			onClick={props.onItemClick} | ||||
| 			onDragStart={onItemDragStart} | ||||
| 			onDragOver={onItemDragOver} | ||||
| 			onDrop={onItemDrop} | ||||
| 			onResizerDragStart={onResizerDragStart} | ||||
| 			onResizerDragEnd={onResizerDragEnd} | ||||
| 			dragCursorLocation={dragCursorLocation} | ||||
| 		/>); | ||||
| 		isFirst = false; | ||||
| 	} | ||||
|  | ||||
| 	const itemHeight = props.height ? props.height : defaultHeight; | ||||
|  | ||||
| 	const style = useMemo(() => { | ||||
| 		return { height: itemHeight } as CSSProperties; | ||||
| 	}, [itemHeight]); | ||||
|  | ||||
| 	return ( | ||||
| 		<div className="note-list-header" style={style} onContextMenu={onContextMenu} > | ||||
| 			{items} | ||||
| 		</div> | ||||
| 	); | ||||
| }; | ||||
| @@ -0,0 +1,93 @@ | ||||
| import * as React from 'react'; | ||||
| import { CSSProperties, useMemo, useCallback } from 'react'; | ||||
| import { OnItemClickHander } from './types'; | ||||
| import { NoteListColumn } from '@joplin/lib/services/plugins/api/noteListType'; | ||||
| import getColumnTitle from './utils/getColumnTitle'; | ||||
|  | ||||
| interface Props { | ||||
| 	isFirst: boolean; | ||||
| 	column: NoteListColumn; | ||||
| 	isCurrent: boolean; | ||||
| 	isReverse: boolean; | ||||
| 	onClick: OnItemClickHander; | ||||
| 	onDragStart: React.DragEventHandler; | ||||
| 	onDragOver: React.DragEventHandler; | ||||
| 	onDrop: React.DragEventHandler; | ||||
| 	onResizerDragStart: React.DragEventHandler; | ||||
| 	onResizerDragEnd: React.DragEventHandler; | ||||
| 	dragCursorLocation: 'before' | 'after' | null; | ||||
| } | ||||
|  | ||||
| export default (props: Props) => { | ||||
| 	const column = props.column; | ||||
|  | ||||
| 	const style = useMemo(() => { | ||||
| 		const output: CSSProperties = {}; | ||||
| 		if (column.width) { | ||||
| 			output.width = column.width; | ||||
| 		} else { | ||||
| 			output.flex = 1; | ||||
| 		} | ||||
| 		return output; | ||||
| 	}, [column.width]); | ||||
|  | ||||
| 	const classes = useMemo(() => { | ||||
| 		const output: string[] = ['item']; | ||||
| 		if (props.isFirst) output.push('-first'); | ||||
| 		if (props.isCurrent) { | ||||
| 			output.push('-current'); | ||||
| 			if (props.isReverse) output.push('-reverse'); | ||||
| 		} | ||||
| 		if (props.dragCursorLocation) output.push(`-drop-${props.dragCursorLocation}`); | ||||
| 		return output; | ||||
| 	}, [props.isFirst, props.isCurrent, props.isReverse, props.dragCursorLocation]); | ||||
|  | ||||
| 	const onClick: React.MouseEventHandler = useCallback((event) => { | ||||
| 		const name = event.currentTarget.getAttribute('data-name'); | ||||
| 		props.onClick({ name }); | ||||
| 	}, [props.onClick]); | ||||
|  | ||||
| 	const renderTitle = () => { | ||||
| 		let chevron = null; | ||||
| 		if (props.isCurrent) { | ||||
| 			const classes = ['chevron', 'fas']; | ||||
| 			classes.push(props.isReverse ? 'fa-chevron-down' : 'fa-chevron-up'); | ||||
| 			chevron = <i className={classes.join(' ')}></i>; | ||||
| 		} | ||||
| 		return <span className="titlewrapper">{getColumnTitle(column.name, true)}{chevron}</span>; | ||||
| 	}; | ||||
|  | ||||
| 	const renderResizer = () => { | ||||
| 		if (props.isFirst) return null; | ||||
|  | ||||
| 		return ( | ||||
| 			<div | ||||
| 				className="resizer" | ||||
| 				data-name={column.name} | ||||
| 				draggable={true} | ||||
| 				onDragStart={props.onResizerDragStart} | ||||
| 				onDragEnd={props.onResizerDragEnd} | ||||
| 			/> | ||||
| 		); | ||||
| 	}; | ||||
|  | ||||
| 	return ( | ||||
| 		<a | ||||
| 			data-name={column.name} | ||||
| 			draggable={true} | ||||
| 			className={classes.join(' ')} | ||||
| 			style={style} | ||||
| 			onClick={onClick} | ||||
| 			onDragStart={props.onDragStart} | ||||
| 			onDragOver={props.onDragOver} | ||||
| 			onDrop={props.onDrop} | ||||
| 		> | ||||
|  | ||||
| 			{renderResizer()} | ||||
|  | ||||
| 			<div className="inner"> | ||||
| 				{renderTitle()} | ||||
| 			</div> | ||||
| 		</a> | ||||
| 	); | ||||
| }; | ||||
							
								
								
									
										79
									
								
								packages/app-desktop/gui/NoteListHeader/style.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								packages/app-desktop/gui/NoteListHeader/style.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | ||||
| .note-list-header { | ||||
| 	display: flex; | ||||
| 	flex-direction: row; | ||||
| 	justify-content: flex-start; | ||||
| 	align-items: center; | ||||
| 	user-select: none; | ||||
|  | ||||
| 	> .item { | ||||
| 		display: flex; | ||||
| 		position: relative; | ||||
| 		align-items: center; | ||||
| 		height: 100%; | ||||
| 		color: var(--joplin-color); | ||||
| 		overflow: hidden; | ||||
|  | ||||
| 		> .resizer { | ||||
| 			position: absolute; | ||||
| 			opacity: 0; | ||||
| 			width: 6px; | ||||
| 			height: 100%; | ||||
| 			left: -3px; | ||||
| 			cursor: ew-resize; | ||||
| 		} | ||||
|  | ||||
| 		> .inner { | ||||
| 			padding-left: 8px; | ||||
| 			pointer-events: none; | ||||
|  | ||||
| 			> .titlewrapper { | ||||
| 				> .chevron { | ||||
| 					position: absolute; | ||||
| 					right: 8px; | ||||
| 					top: 8px; | ||||
| 					font-size: 10px; | ||||
| 					opacity: .6; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	$itemBorderHeight: calc(var(--joplin-note-list-header-height) - var(--joplin-note-list-header-border-padding) * 2); | ||||
|  | ||||
| 	> .item:before { | ||||
| 		content: ''; | ||||
| 		width: 1px; | ||||
| 		height: $itemBorderHeight; | ||||
| 		background: var(--joplin-divider-color); | ||||
| 		position: absolute; | ||||
| 		top: var(--joplin-note-list-header-border-padding); | ||||
| 	} | ||||
|  | ||||
| 	> .item.-first:before { | ||||
| 		background: none; | ||||
| 	} | ||||
|  | ||||
| 	> .item.-drop-before:before { | ||||
| 		content: ''; | ||||
| 		width: 2px; | ||||
| 		height: $itemBorderHeight; | ||||
| 		background: var(--joplin-color); | ||||
| 		position: absolute; | ||||
| 		top: var(--joplin-note-list-header-border-padding); | ||||
| 	} | ||||
|  | ||||
| 	> .item.-drop-after:after { | ||||
| 		content: ''; | ||||
| 		width: 2px; | ||||
| 		height: $itemBorderHeight; | ||||
| 		background: var(--joplin-color); | ||||
| 		position: absolute; | ||||
| 		top: var(--joplin-note-list-header-border-padding); | ||||
| 		right: 0; | ||||
| 	} | ||||
|  | ||||
| 	> .item.-current { | ||||
| 		font-weight: bold; | ||||
| 	} | ||||
|  | ||||
| } | ||||
							
								
								
									
										5
									
								
								packages/app-desktop/gui/NoteListHeader/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								packages/app-desktop/gui/NoteListHeader/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| export interface OnItemClickEvent { | ||||
| 	name: string; | ||||
| } | ||||
|  | ||||
| export type OnItemClickHander = (event: OnItemClickEvent)=> void; | ||||
| @@ -0,0 +1,72 @@ | ||||
| import { NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType'; | ||||
| import { dropHeaderAt } from './useDragAndDrop'; | ||||
|  | ||||
| const defaultColumns: NoteListColumns = [ | ||||
| 	{ | ||||
| 		name: 'note.todo_completed', | ||||
| 		width: 40, | ||||
| 	}, | ||||
| 	{ | ||||
| 		name: 'note.user_updated_time', | ||||
| 		width: 100, | ||||
| 	}, | ||||
| 	{ | ||||
| 		name: 'note.title', | ||||
| 		width: 0, | ||||
| 	}, | ||||
| ]; | ||||
|  | ||||
| describe('useDragAndDrop', () => { | ||||
|  | ||||
| 	test.each([ | ||||
| 		[ | ||||
| 			defaultColumns, | ||||
| 			{ | ||||
| 				name: 'note.title', | ||||
| 			}, | ||||
| 			{ | ||||
| 				columnName: 'note.todo_completed', | ||||
| 				location: 'before', | ||||
| 			}, | ||||
| 			[ | ||||
| 				'note.title', | ||||
| 				'note.todo_completed', | ||||
| 				'note.user_updated_time', | ||||
| 			], | ||||
| 		], | ||||
| 		[ | ||||
| 			defaultColumns, | ||||
| 			{ | ||||
| 				name: 'note.title', | ||||
| 			}, | ||||
| 			{ | ||||
| 				columnName: 'note.user_updated_time', | ||||
| 				location: 'before', | ||||
| 			}, | ||||
| 			[ | ||||
| 				'note.todo_completed', | ||||
| 				'note.title', | ||||
| 				'note.user_updated_time', | ||||
| 			], | ||||
| 		], | ||||
| 		[ | ||||
| 			defaultColumns, | ||||
| 			{ | ||||
| 				name: 'note.title', | ||||
| 			}, | ||||
| 			{ | ||||
| 				columnName: 'note.user_updated_time', | ||||
| 				location: 'after', | ||||
| 			}, | ||||
| 			[ | ||||
| 				'note.todo_completed', | ||||
| 				'note.user_updated_time', | ||||
| 				'note.title', | ||||
| 			], | ||||
| 		], | ||||
| 	])('should drop columns', (columns, header, insertAt, expected) => { | ||||
| 		const actual = dropHeaderAt(columns, header, insertAt as any).map(c => c.name); | ||||
| 		expect(actual).toEqual(expected); | ||||
| 	}); | ||||
|  | ||||
| }); | ||||
							
								
								
									
										252
									
								
								packages/app-desktop/gui/NoteListHeader/useDragAndDrop.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										252
									
								
								packages/app-desktop/gui/NoteListHeader/useDragAndDrop.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,252 @@ | ||||
| import * as React from 'react'; | ||||
| import { useCallback, useState, useRef, useMemo } from 'react'; | ||||
| import { registerGlobalDragEndEvent, unregisterGlobalDragEndEvent } from '../utils/dragAndDrop'; | ||||
| import { NoteListColumn, NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import { findParentElementByClassName } from '@joplin/utils/dom'; | ||||
|  | ||||
| interface DraggedHeader { | ||||
| 	name: string; | ||||
| } | ||||
|  | ||||
| interface InsertAt { | ||||
| 	columnName: NoteListColumn['name']; | ||||
| 	location: 'before' | 'after'; | ||||
| 	x: number; | ||||
| } | ||||
|  | ||||
| export enum DataType { | ||||
| 	HeaderItem = 'text/x-jop-header-item', | ||||
| 	Resizer = 'text/x-jop-header-resizer', | ||||
| } | ||||
|  | ||||
| interface DraggedItem { | ||||
| 	type: DataType; | ||||
| 	columnName: NoteListColumn['name']; | ||||
| 	initX: number; | ||||
| 	initBoundaries: number[]; | ||||
| } | ||||
|  | ||||
| const getHeader = (event: React.DragEvent) => { | ||||
| 	const dt = event.dataTransfer; | ||||
| 	const headerText = dt.getData(DataType.HeaderItem); | ||||
| 	if (!headerText) return null; | ||||
| 	return JSON.parse(headerText) as DraggedHeader; | ||||
| }; | ||||
|  | ||||
| const isDraggedHeaderItem = (event: React.DragEvent) => { | ||||
| 	return event.dataTransfer.types.includes(DataType.HeaderItem); | ||||
| }; | ||||
|  | ||||
| const isDraggedHeaderResizer = (event: React.DragEvent) => { | ||||
| 	return event.dataTransfer.types.includes(DataType.Resizer); | ||||
| }; | ||||
|  | ||||
| const getInsertAt = (event: React.DragEvent) => { | ||||
| 	const name = event.currentTarget.getAttribute('data-name') as NoteListColumn['name']; | ||||
| 	const rect = event.currentTarget.getBoundingClientRect(); | ||||
| 	const x = event.clientX - rect.x; | ||||
| 	const percent = x / rect.width; | ||||
|  | ||||
| 	const headerElement: Element = findParentElementByClassName(event.currentTarget, 'note-list-header'); | ||||
| 	const headerRect = headerElement.getBoundingClientRect(); | ||||
|  | ||||
| 	const data: InsertAt = { | ||||
| 		columnName: name, | ||||
| 		location: percent < 0.5 ? 'before' : 'after', | ||||
| 		x: event.clientX - headerRect.x, | ||||
| 	}; | ||||
|  | ||||
| 	return data; | ||||
| }; | ||||
|  | ||||
| export const dropHeaderAt = (columns: NoteListColumns, header: DraggedHeader, insertAt: InsertAt) => { | ||||
| 	const droppedColumn = columns.find(c => c.name === header.name); | ||||
| 	const newColumns: NoteListColumns = []; | ||||
|  | ||||
| 	for (const column of columns) { | ||||
| 		if (insertAt.columnName === column.name) { | ||||
| 			if (insertAt.location === 'before') { | ||||
| 				newColumns.push(droppedColumn); | ||||
| 				newColumns.push(column); | ||||
| 			} else { | ||||
| 				newColumns.push(column); | ||||
| 				newColumns.push(droppedColumn); | ||||
| 			} | ||||
| 		} else if (droppedColumn.name === column.name) { | ||||
| 			continue; | ||||
| 		} else { | ||||
| 			newColumns.push(column); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return newColumns; | ||||
| }; | ||||
|  | ||||
| const setupDataTransfer = (event: React.DragEvent, dataType: string, image: HTMLImageElement, data: any) => { | ||||
| 	event.dataTransfer.setDragImage(image, 1, 1); | ||||
| 	event.dataTransfer.clearData(); | ||||
| 	event.dataTransfer.setData(dataType, JSON.stringify(data)); | ||||
| 	event.dataTransfer.effectAllowed = 'move'; | ||||
| }; | ||||
|  | ||||
| const getEffectiveColumnWidths = (columns: NoteListColumns, totalWidth: number) => { | ||||
| 	let totalFixedWidth = 0; | ||||
| 	for (const c of columns) totalFixedWidth += c.width; | ||||
|  | ||||
| 	const dynamicWidth = totalWidth - totalFixedWidth; | ||||
|  | ||||
| 	const output: number[] = []; | ||||
| 	for (const c of columns) { | ||||
| 		output.push(c.width ? c.width : dynamicWidth); | ||||
| 	} | ||||
|  | ||||
| 	return output; | ||||
| }; | ||||
|  | ||||
| const getColumnsToBoundaries = (columns: NoteListColumns, totalWidth: number) => { | ||||
| 	const widths = getEffectiveColumnWidths(columns, totalWidth); | ||||
| 	const boundaries: number[] = [0]; | ||||
| 	let total = 0; | ||||
| 	for (const w of widths) { | ||||
| 		boundaries.push(total + w); | ||||
| 		total += w; | ||||
| 	} | ||||
| 	return boundaries; | ||||
| }; | ||||
|  | ||||
| const applyBoundariesToColumns = (columns: NoteListColumns, boundaries: number[]) => { | ||||
| 	const newColumns: NoteListColumns = []; | ||||
| 	let changed = false; | ||||
| 	for (let i = 0; i < columns.length; i++) { | ||||
| 		const column = columns[i]; | ||||
| 		const previousWidth = column.width; | ||||
| 		const newWidth = column.width ? boundaries[i + 1] - boundaries[i] : 0; | ||||
|  | ||||
| 		if (previousWidth !== newWidth) { | ||||
| 			changed = true; | ||||
| 		} | ||||
|  | ||||
| 		newColumns.push({ ...column, width: newWidth }); | ||||
| 	} | ||||
| 	return changed ? newColumns : columns; | ||||
| }; | ||||
|  | ||||
| export default (columns: NoteListColumns) => { | ||||
| 	const [dropAt, setDropAt] = useState<InsertAt|null>(null); | ||||
| 	const [draggedItem, setDraggedItem] = useState<DraggedItem|null>(null); | ||||
| 	const draggedItemRef = useRef<DraggedItem>(null); | ||||
| 	draggedItemRef.current = draggedItem; | ||||
| 	const columnsRef = useRef<NoteListColumns>(null); | ||||
| 	columnsRef.current = columns; | ||||
|  | ||||
| 	// The drag and drop image needs to be created in advance to avoid the globe 🌐 cursor. | ||||
| 	// https://www.sam.today/blog/html5-dnd-globe-icon | ||||
| 	const emptyImage = useMemo(() => { | ||||
| 		const image = new Image(1, 1); | ||||
| 		image.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='; | ||||
| 		return image; | ||||
| 	}, []); | ||||
|  | ||||
| 	const onItemDragStart: React.DragEventHandler = useCallback(event => { | ||||
| 		if (event.dataTransfer.items.length) return; | ||||
| 		const name = event.currentTarget.getAttribute('data-name') as NoteListColumn['name']; | ||||
|  | ||||
| 		setupDataTransfer(event, DataType.HeaderItem, emptyImage, { name }); | ||||
| 		setDraggedItem({ | ||||
| 			type: DataType.HeaderItem, | ||||
| 			columnName: name, | ||||
| 			initX: 0, | ||||
| 			initBoundaries: [], | ||||
| 		}); | ||||
| 	}, [emptyImage]); | ||||
|  | ||||
| 	const onItemDragOver: React.DragEventHandler = useCallback((event) => { | ||||
| 		if (!isDraggedHeaderItem(event)) return; | ||||
|  | ||||
| 		const data = getInsertAt(event); | ||||
|  | ||||
| 		setDropAt(current => { | ||||
| 			if (JSON.stringify(current) === JSON.stringify(data)) return current; | ||||
| 			return data; | ||||
| 		}); | ||||
|  | ||||
| 		registerGlobalDragEndEvent(() => setDropAt(null)); | ||||
| 	}, []); | ||||
|  | ||||
| 	const onItemDrop: React.DragEventHandler = useCallback(event => { | ||||
| 		const header = getHeader(event); | ||||
| 		if (!header) return; | ||||
|  | ||||
| 		unregisterGlobalDragEndEvent(); | ||||
|  | ||||
| 		const data = getInsertAt(event); | ||||
|  | ||||
| 		setDropAt(null); | ||||
|  | ||||
| 		if (header.name === data.columnName) return; | ||||
|  | ||||
| 		const newColumns = dropHeaderAt(columns, header, data); | ||||
|  | ||||
| 		if (JSON.stringify(newColumns) !== JSON.stringify(columns)) Setting.setValue('notes.columns', newColumns); | ||||
| 	}, [columns]); | ||||
|  | ||||
| 	const onResizerDragOver: React.DragEventHandler = useCallback(event => { | ||||
| 		if (!isDraggedHeaderResizer(event)) return; | ||||
|  | ||||
| 		// We use refs so that the identity of the `onResizerDragOver` callback doesn't change, so | ||||
| 		// that it can be removed as an event listener. | ||||
| 		const draggedItem = draggedItemRef.current; | ||||
| 		const columns = columnsRef.current; | ||||
|  | ||||
| 		const deltaX = event.clientX - draggedItem.initX; | ||||
| 		const columnIndex = columns.findIndex(c => c.name === draggedItem.columnName); | ||||
| 		const initBoundary = draggedItem.initBoundaries[columnIndex]; | ||||
| 		const minBoundary = columnIndex > 0 ? draggedItem.initBoundaries[columnIndex - 1] + 20 : 0; | ||||
| 		const maxBoundary = draggedItem.initBoundaries[columnIndex + 1] - 20; | ||||
|  | ||||
| 		let newBoundary = initBoundary + deltaX; | ||||
| 		if (newBoundary < minBoundary) newBoundary = minBoundary; | ||||
| 		if (newBoundary > maxBoundary) newBoundary = maxBoundary; | ||||
|  | ||||
| 		const newBoundaries = draggedItem.initBoundaries.slice(); | ||||
| 		newBoundaries[columnIndex] = newBoundary; | ||||
|  | ||||
| 		const newColumns = applyBoundariesToColumns(columns, newBoundaries); | ||||
|  | ||||
| 		if (newColumns !== columns) Setting.setValue('notes.columns', newColumns); | ||||
| 	}, []); | ||||
|  | ||||
| 	const onResizerDragEnd: React.DragEventHandler = useCallback(() => { | ||||
| 		document.removeEventListener('dragover', onResizerDragOver as any); | ||||
| 	}, [onResizerDragOver]); | ||||
|  | ||||
| 	const onResizerDragStart: React.DragEventHandler = useCallback(event => { | ||||
| 		const name = event.currentTarget.getAttribute('data-name') as NoteListColumn['name']; | ||||
|  | ||||
| 		setupDataTransfer(event, DataType.Resizer, emptyImage, { name }); | ||||
|  | ||||
| 		const headerElement: Element = findParentElementByClassName(event.currentTarget, 'note-list-header'); | ||||
| 		const headerRect = headerElement.getBoundingClientRect(); | ||||
| 		const boundaries = getColumnsToBoundaries(columns, headerRect.width); | ||||
|  | ||||
| 		setDraggedItem({ | ||||
| 			type: DataType.Resizer, | ||||
| 			columnName: name, | ||||
| 			initX: event.clientX, | ||||
| 			initBoundaries: boundaries, | ||||
| 		}); | ||||
|  | ||||
| 		document.addEventListener('dragover', onResizerDragOver as any); | ||||
| 	}, [columns, onResizerDragOver, emptyImage]); | ||||
|  | ||||
| 	return { | ||||
| 		onItemDragStart, | ||||
| 		onItemDragOver, | ||||
| 		onItemDrop, | ||||
| 		onResizerDragStart, | ||||
| 		onResizerDragEnd, | ||||
| 		dropAt, | ||||
| 		draggedItem, | ||||
| 	}; | ||||
| }; | ||||
| @@ -0,0 +1,27 @@ | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { ColumnName } from '@joplin/lib/services/plugins/api/noteListType'; | ||||
|  | ||||
| const titles: Record<ColumnName, ()=> string> = { | ||||
| 	'note.folder.title': () => _('Notebook: %s', _('Title')), | ||||
| 	'note.is_todo': () => _('To-do'), | ||||
| 	'note.latitude': () => _('Latitude'), | ||||
| 	'note.longitude': () => _('Longitude'), | ||||
| 	'note.source_url': () => _('Source'), | ||||
| 	'note.tags': () => _('Tags'), | ||||
| 	'note.title': () => _('Title'), | ||||
| 	'note.todo_completed': () => _('Completed'), | ||||
| 	'note.todo_due': () => _('Due'), | ||||
| 	'note.user_created_time': () => _('Created'), | ||||
| 	'note.user_updated_time': () => _('Updated'), | ||||
| }; | ||||
|  | ||||
| const titlesForHeader: Partial<Record<ColumnName, ()=> string>> = { | ||||
| 	'note.is_todo': () => '✓', | ||||
| }; | ||||
|  | ||||
| export default (name: ColumnName, forHeader = false) => { | ||||
| 	let fn: ()=> string = null; | ||||
| 	if (forHeader) fn = titlesForHeader[name]; | ||||
| 	if (!fn) fn = titles[name]; | ||||
| 	return fn ? fn() : name; | ||||
| }; | ||||
| @@ -0,0 +1,51 @@ | ||||
| import { useCallback } from 'react'; | ||||
| import bridge from '../../../services/bridge'; | ||||
| import { ColumnName, NoteListColumn, NoteListColumns, columnNames, defaultWidth } from '@joplin/lib/services/plugins/api/noteListType'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import { MenuItemConstructorOptions } from 'electron'; | ||||
| import getColumnTitle from './getColumnTitle'; | ||||
|  | ||||
| const Menu = bridge().Menu; | ||||
|  | ||||
| export default (columns: NoteListColumns) => { | ||||
| 	return useCallback(() => { | ||||
| 		const menuItems: MenuItemConstructorOptions[] = []; | ||||
|  | ||||
| 		for (const columnName of columnNames) { | ||||
| 			menuItems.push({ | ||||
| 				id: columnName, | ||||
| 				label: getColumnTitle(columnName), | ||||
| 				type: 'checkbox', | ||||
| 				checked: !!columns.find(c => c.name === columnName), | ||||
| 				click: (menuItem) => { | ||||
| 					const newColumns = columns.slice(); | ||||
| 					const { checked } = menuItem; | ||||
| 					const id = menuItem.id as ColumnName; | ||||
|  | ||||
| 					if (!checked) { | ||||
| 						if (columns.length === 1) return; | ||||
| 						const index = newColumns.findIndex(c => c.name === id); | ||||
| 						newColumns.splice(index, 1); | ||||
| 					} else { | ||||
| 						const newColumn: NoteListColumn = { | ||||
| 							name: id, | ||||
| 							width: defaultWidth, | ||||
| 						}; | ||||
|  | ||||
| 						newColumns.push(newColumn); | ||||
| 					} | ||||
|  | ||||
| 					Setting.setValue('notes.columns', newColumns); | ||||
| 				}, | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		menuItems.sort((a, b) => { | ||||
| 			return a.label < b.label ? -1 : +1; | ||||
| 		}); | ||||
|  | ||||
| 		const menu = Menu.buildFromTemplate(menuItems); | ||||
|  | ||||
| 		menu.popup({ window: bridge().window() }); | ||||
| 	}, [columns]); | ||||
| }; | ||||
| @@ -0,0 +1,51 @@ | ||||
| import { NoteListColumns, defaultListColumns } from '@joplin/lib/services/plugins/api/noteListType'; | ||||
| import validateColumns from './validateColumns'; | ||||
|  | ||||
| const makeColumns = (props: any) => { | ||||
| 	const columns: NoteListColumns = []; | ||||
| 	for (const p of props) { | ||||
| 		columns.push({ | ||||
| 			name: 'note.title', | ||||
| 			width: 100, | ||||
| 			...p, | ||||
| 		}); | ||||
| 	} | ||||
| 	return columns; | ||||
| }; | ||||
|  | ||||
| describe('validateColumns', () => { | ||||
|  | ||||
| 	test.each([ | ||||
| 		[ | ||||
| 			[{ width: 100 }, { width: 200 }, { width: 0 }], | ||||
| 			[{ width: 100 }, { width: 200 }, { width: 0 }], | ||||
| 		], | ||||
| 		[ | ||||
| 			[{ width: 100 }, { width: 200 }, { width: 100 }], | ||||
| 			[{ width: 100 }, { width: 200 }, { width: 0 }], | ||||
| 		], | ||||
| 		[ | ||||
| 			[{ width: 0 }, { width: 0 }, { width: 100 }], | ||||
| 			[{ width: 100 }, { width: 100 }, { width: 0 }], | ||||
| 		], | ||||
| 		[ | ||||
| 			[], | ||||
| 			defaultListColumns(), | ||||
| 		], | ||||
| 		[ | ||||
| 			null, | ||||
| 			defaultListColumns(), | ||||
| 		], | ||||
| 	])('should drop columns', (columnProps, expectedProps) => { | ||||
| 		const columns = columnProps ? makeColumns(columnProps) : columnProps; | ||||
| 		const expected = makeColumns(expectedProps); | ||||
|  | ||||
| 		const actual = validateColumns(columns); | ||||
| 		expect(actual).toEqual(expected); | ||||
|  | ||||
| 		const mustBeIdentical = JSON.stringify(columns) === JSON.stringify(expected); | ||||
|  | ||||
| 		expect(actual === columns).toBe(mustBeIdentical); | ||||
| 	}); | ||||
|  | ||||
| }); | ||||
| @@ -0,0 +1,30 @@ | ||||
| import { NoteListColumns, defaultListColumns } from '@joplin/lib/services/plugins/api/noteListType'; | ||||
|  | ||||
| export default (columns: NoteListColumns) => { | ||||
| 	if (!columns || !columns.length) return defaultListColumns(); | ||||
|  | ||||
| 	// There must be one column with flexible width | ||||
| 	if (!columns.find(c => !c.width)) { | ||||
| 		const newColumns = columns.slice(); | ||||
| 		newColumns[newColumns.length - 1] = { | ||||
| 			...newColumns[newColumns.length - 1], | ||||
| 			width: 0, | ||||
| 		}; | ||||
| 		return newColumns; | ||||
| 	} | ||||
|  | ||||
| 	// There can't be more than one column with flexible width | ||||
| 	if (columns.filter(c => !c.width).length > 1) { | ||||
| 		const newColumns = columns.slice(); | ||||
| 		for (let i = 0; i < newColumns.length; i++) { | ||||
| 			const col = newColumns[i]; | ||||
| 			newColumns[i] = { | ||||
| 				...col, | ||||
| 				width: i === newColumns.length - 1 ? 0 : 100, | ||||
| 			}; | ||||
| 		} | ||||
| 		return newColumns; | ||||
| 	} | ||||
|  | ||||
| 	return columns; | ||||
| }; | ||||
| @@ -1,6 +1,6 @@ | ||||
| import * as React from 'react'; | ||||
| import { useCallback, forwardRef, LegacyRef, ChangeEvent, CSSProperties, MouseEventHandler, DragEventHandler, useMemo, memo } from 'react'; | ||||
| import { ItemFlow, ListRenderer, OnChangeEvent, OnChangeHandler } from '@joplin/lib/services/plugins/api/noteListType'; | ||||
| import { ItemFlow, ListRenderer, NoteListColumns, OnChangeEvent, OnChangeHandler } from '@joplin/lib/services/plugins/api/noteListType'; | ||||
| import { Size } from '@joplin/utils/types'; | ||||
| import useRootElement from './utils/useRootElement'; | ||||
| import useItemElement from './utils/useItemElement'; | ||||
| @@ -29,6 +29,7 @@ interface NoteItemProps { | ||||
| 	isSelected: boolean; | ||||
| 	isWatched: boolean; | ||||
| 	listRenderer: ListRenderer; | ||||
| 	columns: NoteListColumns; | ||||
| 	dispatch: Dispatch; | ||||
| } | ||||
|  | ||||
| @@ -63,7 +64,7 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => { | ||||
|  | ||||
| 	const rootElement = useRootElement(elementId); | ||||
|  | ||||
| 	const renderedNote = useRenderedNote(props.note, props.isSelected, props.isWatched, props.listRenderer, props.highlightedWords, props.index); | ||||
| 	const renderedNote = useRenderedNote(props.note, props.isSelected, props.isWatched, props.listRenderer, props.highlightedWords, props.index, props.columns); | ||||
|  | ||||
| 	const itemElement = useItemElement( | ||||
| 		rootElement, | ||||
| @@ -75,7 +76,7 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => { | ||||
| 		props.flow, | ||||
| 	); | ||||
|  | ||||
| 	useItemEventHandlers(rootElement, itemElement, onInputChange); | ||||
| 	useItemEventHandlers(rootElement, itemElement, onInputChange, null); | ||||
|  | ||||
| 	const className = useMemo(() => { | ||||
| 		return [ | ||||
|   | ||||
| @@ -0,0 +1,101 @@ | ||||
| import { ListRendererDependency } from '@joplin/lib/services/plugins/api/noteListType'; | ||||
| import { FolderEntity, NoteEntity, TagEntity } from '@joplin/lib/services/database/types'; | ||||
| import { Size } from '@joplin/utils/types'; | ||||
| import prepareViewProps from './prepareViewProps'; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
| import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils'; | ||||
|  | ||||
| // Same as `prepareViewProps` but with default arguments to make testing code simpler. | ||||
| const prepare = async ( | ||||
| 	dependencies: ListRendererDependency[], | ||||
| 	note: NoteEntity, | ||||
| 	itemSize: Size = { width: 100, height: 20 }, | ||||
| 	selected = false, | ||||
| 	noteTitleHtml = '', | ||||
| 	noteIsWatched = false, | ||||
| 	noteTags: TagEntity[] = [], | ||||
| 	folder: FolderEntity = null, | ||||
| 	itemIndex = 0, | ||||
| ) => { | ||||
| 	return prepareViewProps( | ||||
| 		dependencies, | ||||
| 		note, | ||||
| 		itemSize, | ||||
| 		selected, | ||||
| 		noteTitleHtml, | ||||
| 		noteIsWatched, | ||||
| 		noteTags, | ||||
| 		folder, | ||||
| 		itemIndex, | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| describe('prepareViewProps', () => { | ||||
|  | ||||
| 	beforeEach(async () => { | ||||
| 		await setupDatabaseAndSynchronizer(1); | ||||
| 		await switchClient(1); | ||||
| 	}); | ||||
|  | ||||
| 	it('should prepare note properties', async () => { | ||||
| 		const note = await Note.save({ title: 'test' }); | ||||
|  | ||||
| 		expect(await prepare(['note.title', 'note.user_updated_time'], note)).toEqual({ | ||||
| 			note: { | ||||
| 				title: 'test', | ||||
| 				user_updated_time: note.user_updated_time, | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		expect(await prepare(['item.size.height'], note)).toEqual({ | ||||
| 			item: { | ||||
| 				size: { | ||||
| 					height: 20, | ||||
| 				}, | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		expect(await prepare(['item.selected'], note)).toEqual({ | ||||
| 			item: { | ||||
| 				selected: false, | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		expect(await prepare(['item.selected'], note, {}, true)).toEqual({ | ||||
| 			item: { | ||||
| 				selected: true, | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		expect(await prepare(['note.titleHtml'], note, {}, false, '<b>test</b>')).toEqual({ | ||||
| 			note: { | ||||
| 				titleHtml: '<b>test</b>', | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		expect(await prepare(['note.isWatched'], note, {}, false, '', true)).toEqual({ | ||||
| 			note: { | ||||
| 				isWatched: true, | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		expect(await prepare(['note.isWatched'], note, {}, false, '', false)).toEqual({ | ||||
| 			note: { | ||||
| 				isWatched: false, | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		expect(await prepare(['item.index'], note, {}, false, '', false, [], null, 5)).toEqual({ | ||||
| 			item: { | ||||
| 				index: 5, | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		expect(await prepare(['note.tags'], note, {}, false, '', false, [{ id: '1', title: 'one' }])).toEqual({ | ||||
| 			note: { | ||||
| 				tags: [{ id: '1', title: 'one' }], | ||||
| 			}, | ||||
| 		}); | ||||
| 	}); | ||||
|  | ||||
| }); | ||||
| @@ -1,24 +1,37 @@ | ||||
| import { ListRendererDependency } from '@joplin/lib/services/plugins/api/noteListType'; | ||||
| import { NoteEntity, TagEntity } from '@joplin/lib/services/database/types'; | ||||
| import { FolderEntity, NoteEntity, TagEntity } from '@joplin/lib/services/database/types'; | ||||
| import { Size } from '@joplin/utils/types'; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
|  | ||||
| const prepareViewProps = async (dependencies: ListRendererDependency[], note: NoteEntity, itemSize: Size, selected: boolean, noteTitleHtml: string, noteIsWatched: boolean, noteTags: TagEntity[], itemIndex: number) => { | ||||
| const prepareViewProps = async ( | ||||
| 	dependencies: ListRendererDependency[], | ||||
| 	note: NoteEntity, | ||||
| 	itemSize: Size, | ||||
| 	selected: boolean, | ||||
| 	noteTitleHtml: string, | ||||
| 	noteIsWatched: boolean, | ||||
| 	noteTags: TagEntity[], | ||||
| 	folder: FolderEntity | null, | ||||
| 	itemIndex: number, | ||||
| ) => { | ||||
| 	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}`); | ||||
| 			if (splitted.length <= 1) throw new Error(`Invalid dependency name: ${dep}`); | ||||
| 			const propName = splitted.pop(); | ||||
|  | ||||
| 			if (!output.note) output.note = {}; | ||||
| 			if (dep === 'note.titleHtml') { | ||||
| 			if (dep === 'note.titleHtml') { // For backward compatibility | ||||
| 				output.note.titleHtml = noteTitleHtml; | ||||
| 			} else if (dep === 'note.isWatched') { | ||||
| 				output.note.isWatched = noteIsWatched; | ||||
| 				output.note[propName] = noteIsWatched; | ||||
| 			} else if (dep === 'note.tags') { | ||||
| 				output.note.tags = noteTags; | ||||
| 				output.note[propName] = noteTags; | ||||
| 			} else if (dep === 'note.folder.title') { | ||||
| 				if (!output.note.folder) output.note.folder = {}; | ||||
| 				output.note.folder[propName] = folder.title; | ||||
| 			} else { | ||||
| 				// The notes in the state only contain the properties defined in | ||||
| 				// Note.previewFields(). It means that if a view request a | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import * as React from 'react'; | ||||
|  | ||||
| export type OnInputChange = (event: React.ChangeEvent<HTMLInputElement>)=> void; | ||||
| export type OnClick = (event: React.MouseEvent<HTMLElement>)=> void; | ||||
|   | ||||
| @@ -1,37 +1,51 @@ | ||||
| import { OnInputChange } from './types'; | ||||
| import { OnClick, OnInputChange } from './types'; | ||||
| import { useEffect } from 'react'; | ||||
|  | ||||
| const useItemEventHandlers = (rootElement: HTMLDivElement, itemElement: HTMLDivElement, onInputChange: OnInputChange) => { | ||||
| const useItemEventHandlers = (rootElement: HTMLDivElement, itemElement: HTMLDivElement, onInputChange: OnInputChange, onClick: OnClick) => { | ||||
| 	useEffect(() => { | ||||
| 		if (!itemElement) return () => {}; | ||||
|  | ||||
| 		const inputs = itemElement.getElementsByTagName('input'); | ||||
|  | ||||
| 		const checkboxes: HTMLInputElement[] = []; | ||||
| 		const textInputs: HTMLInputElement[] = []; | ||||
| 		const processedCheckboxes: HTMLInputElement[] = []; | ||||
| 		const processedTextInputs: HTMLInputElement[] = []; | ||||
|  | ||||
| 		for (const input of inputs) { | ||||
| 			if (input.type === 'checkbox') { | ||||
| 				input.addEventListener('change', onInputChange as any); | ||||
| 				checkboxes.push(input); | ||||
| 				processedCheckboxes.push(input); | ||||
| 			} | ||||
|  | ||||
| 			if (input.type === 'text') { | ||||
| 				input.addEventListener('change', onInputChange as any); | ||||
| 				textInputs.push(input); | ||||
| 				processedTextInputs.push(input); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		const buttons = itemElement.getElementsByTagName('button'); | ||||
| 		const processedButtons: HTMLButtonElement[] = []; | ||||
|  | ||||
| 		if (onClick) { | ||||
| 			for (const button of buttons) { | ||||
| 				button.addEventListener('click', onClick as any); | ||||
| 				processedButtons.push(button); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return () => { | ||||
| 			for (const input of checkboxes) { | ||||
| 			for (const input of processedCheckboxes) { | ||||
| 				input.removeEventListener('change', onInputChange as any); | ||||
| 			} | ||||
|  | ||||
| 			for (const input of textInputs) { | ||||
| 			for (const input of processedTextInputs) { | ||||
| 				input.removeEventListener('change', onInputChange as any); | ||||
| 			} | ||||
|  | ||||
| 			for (const button of processedButtons) { | ||||
| 				button.removeEventListener('click', onClick as any); | ||||
| 			} | ||||
| 		}; | ||||
| 	}, [itemElement, rootElement, onInputChange]); | ||||
| 	}, [itemElement, rootElement, onInputChange, onClick]); | ||||
| }; | ||||
|  | ||||
| export default useItemEventHandlers; | ||||
|   | ||||
| @@ -1,13 +1,16 @@ | ||||
| import { useState } from 'react'; | ||||
| import { ListRenderer } from '@joplin/lib/services/plugins/api/noteListType'; | ||||
| import { ListRenderer, ListRendererDependency, NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType'; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
| import { NoteEntity, TagEntity } from '@joplin/lib/services/database/types'; | ||||
| import { FolderEntity, NoteEntity, TagEntity } from '@joplin/lib/services/database/types'; | ||||
| import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; | ||||
| import renderTemplate from '@joplin/lib/services/noteList/renderTemplate'; | ||||
| import renderViewProps from '@joplin/lib/services/noteList/renderViewProps'; | ||||
| import { createHash } from 'crypto'; | ||||
| import getNoteTitleHtml from './getNoteTitleHtml'; | ||||
| import prepareViewProps from './prepareViewProps'; | ||||
| import * as Mustache from 'mustache'; | ||||
| import Tag from '@joplin/lib/models/Tag'; | ||||
| import { unique } from '@joplin/lib/array'; | ||||
| import Folder from '@joplin/lib/models/Folder'; | ||||
|  | ||||
| interface RenderedNote { | ||||
| 	id: string; | ||||
| @@ -19,17 +22,26 @@ const hashContent = (content: any) => { | ||||
| 	return createHash('sha1').update(JSON.stringify(content)).digest('hex'); | ||||
| }; | ||||
|  | ||||
| export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, listRenderer: ListRenderer, highlightedWords: string[], itemIndex: number) => { | ||||
| export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, listRenderer: ListRenderer, highlightedWords: string[], itemIndex: number, columns: NoteListColumns) => { | ||||
| 	const [renderedNote, setRenderedNote] = useState<RenderedNote>(null); | ||||
|  | ||||
| 	let dependencies = columns && columns.length ? columns.map(c => c.name) as ListRendererDependency[] : []; | ||||
| 	if (listRenderer.dependencies) dependencies = dependencies.concat(listRenderer.dependencies); | ||||
| 	dependencies = unique(dependencies); | ||||
|  | ||||
| 	useAsyncEffect(async (event) => { | ||||
| 		const renderNote = async (): Promise<void> => { | ||||
| 			let noteTags: TagEntity[] = []; | ||||
| 			let folder: FolderEntity = null; | ||||
|  | ||||
| 			if (listRenderer.dependencies.includes('note.tags')) { | ||||
| 			if (dependencies.includes('note.tags')) { | ||||
| 				noteTags = await Tag.tagsByNoteId(note.id, { fields: ['id', 'title'] }); | ||||
| 			} | ||||
|  | ||||
| 			if (dependencies.find(d => d.startsWith('note.folder'))) { | ||||
| 				folder = await Folder.load(note.parent_id, { fields: ['id', 'title'] }); | ||||
| 			} | ||||
|  | ||||
| 			// 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. | ||||
| @@ -40,22 +52,24 @@ export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, listR | ||||
| 				isWatched, | ||||
| 				highlightedWords, | ||||
| 				note.encryption_applied, | ||||
| 				JSON.stringify(columns), | ||||
| 				noteTags.map(t => t.title).sort().join(','), | ||||
| 				folder ? folder.title : '', | ||||
| 			]); | ||||
|  | ||||
| 			if (renderedNote && renderedNote.hash === viewHash) return null; | ||||
|  | ||||
| 			// console.info('RENDER', note.id, renderedNote ? renderedNote.hash : 'NULL', viewHash); | ||||
| 			const noteTitleHtml = getNoteTitleHtml(highlightedWords, Note.displayTitle(note)); | ||||
|  | ||||
| 			const titleHtml = getNoteTitleHtml(highlightedWords, Note.displayTitle(note)); | ||||
| 			const viewProps = await prepareViewProps( | ||||
| 				listRenderer.dependencies, | ||||
| 				dependencies, | ||||
| 				note, | ||||
| 				listRenderer.itemSize, | ||||
| 				isSelected, | ||||
| 				titleHtml, | ||||
| 				noteTitleHtml, | ||||
| 				isWatched, | ||||
| 				noteTags, | ||||
| 				folder, | ||||
| 				itemIndex, | ||||
| 			); | ||||
|  | ||||
| @@ -65,15 +79,24 @@ export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, listR | ||||
|  | ||||
| 			if (event.cancelled) return null; | ||||
|  | ||||
| 			await renderViewProps(view, [], { noteTitleHtml }); | ||||
|  | ||||
| 			if (event.cancelled) return null; | ||||
|  | ||||
| 			setRenderedNote({ | ||||
| 				id: note.id, | ||||
| 				hash: viewHash, | ||||
| 				html: Mustache.render(listRenderer.itemTemplate, view), | ||||
| 				html: renderTemplate( | ||||
| 					columns, | ||||
| 					listRenderer.itemTemplate, | ||||
| 					listRenderer.itemValueTemplates, | ||||
| 					view, | ||||
| 				), | ||||
| 			}); | ||||
| 		}; | ||||
|  | ||||
| 		void renderNote(); | ||||
| 	}, [note, isSelected, isWatched, listRenderer, renderedNote]); | ||||
| 	}, [note, isSelected, isWatched, listRenderer, renderedNote, columns]); | ||||
|  | ||||
| 	return renderedNote; | ||||
| }; | ||||
|   | ||||
| @@ -7,9 +7,14 @@ import { Size } from '../ResizableLayout/utils/types'; | ||||
| import styled from 'styled-components'; | ||||
| import { getDefaultListRenderer, getListRendererById } from '@joplin/lib/services/noteList/renderers'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import NoteListHeader from '../NoteListHeader/NoteListHeader'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { BaseBreakpoint, Breakpoints } from '../NoteList/utils/types'; | ||||
| import { ButtonSize, buttonSizePx } from '../Button/Button'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import { OnItemClickHander } from '../NoteListHeader/types'; | ||||
| import { NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType'; | ||||
| import depNameToNoteProp from '@joplin/lib/services/noteList/depNameToNoteProp'; | ||||
| import { getTrashFolderId } from '@joplin/lib/services/trash'; | ||||
|  | ||||
| const logger = Logger.create('NoteListWrapper'); | ||||
| @@ -21,6 +26,9 @@ interface Props { | ||||
| 	themeId: number; | ||||
| 	listRendererId: string; | ||||
| 	startupPluginsLoaded: boolean; | ||||
| 	notesSortOrderField: string; | ||||
| 	notesSortOrderReverse: boolean; | ||||
| 	columns: NoteListColumns; | ||||
| 	selectedFolderId: string; | ||||
| } | ||||
|  | ||||
| @@ -99,6 +107,8 @@ export default function NoteListWrapper(props: Props) { | ||||
| 	const [controlHeight] = useState(theme.topRowHeight); | ||||
| 	const listRenderer = useListRenderer(props.listRendererId, props.startupPluginsLoaded); | ||||
| 	const newNoteButtonRef = useRef(null); | ||||
| 	const isMultiColumns = listRenderer ? listRenderer.multiColumns : false; | ||||
| 	const columns = isMultiColumns ? props.columns : null; | ||||
|  | ||||
| 	const { breakpoint, dynamicBreakpoints, lineCount } = useNoteListControlsBreakpoints(props.size.width, newNoteButtonRef, props.selectedFolderId); | ||||
|  | ||||
| @@ -117,9 +127,38 @@ export default function NoteListWrapper(props: Props) { | ||||
| 	const noteListSize = useMemo(() => { | ||||
| 		return { | ||||
| 			width: props.size.width, | ||||
| 			height: props.size.height - noteListControlsHeight, | ||||
| 			height: props.size.height - noteListControlsHeight - (isMultiColumns ? theme.noteListHeaderHeight : 0), | ||||
| 		}; | ||||
| 	}, [props.size, noteListControlsHeight]); | ||||
| 	}, [props.size, noteListControlsHeight, theme.noteListHeaderHeight, isMultiColumns]); | ||||
|  | ||||
| 	const onHeaderItemClick: OnItemClickHander = useCallback(event => { | ||||
| 		const field = depNameToNoteProp(event.name as any).split('.')[1]; | ||||
|  | ||||
| 		if (!Setting.isAllowedEnumOption('notes.sortOrder.field', field)) { | ||||
| 			logger.warn(`Unsupported sorting option: ${field}`); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		if (Setting.value('notes.sortOrder.field') === field) { | ||||
| 			Setting.toggle('notes.sortOrder.reverse'); | ||||
| 		} else { | ||||
| 			Setting.setValue('notes.sortOrder.field', field); | ||||
| 		} | ||||
| 	}, []); | ||||
|  | ||||
| 	const renderHeader = () => { | ||||
| 		if (!listRenderer || !isMultiColumns) return null; | ||||
|  | ||||
| 		return <NoteListHeader | ||||
| 			height={theme.noteListHeaderHeight} | ||||
| 			template={listRenderer.headerTemplate} | ||||
| 			onClick={listRenderer.onHeaderClick} | ||||
| 			columns={columns} | ||||
| 			notesSortOrderField={props.notesSortOrderField} | ||||
| 			notesSortOrderReverse={props.notesSortOrderReverse} | ||||
| 			onItemClick={onHeaderItemClick} | ||||
| 		/>; | ||||
| 	}; | ||||
|  | ||||
| 	const renderNoteList = () => { | ||||
| 		if (!listRenderer) return null; | ||||
| @@ -128,6 +167,7 @@ export default function NoteListWrapper(props: Props) { | ||||
| 			resizableLayoutEventEmitter={props.resizableLayoutEventEmitter} | ||||
| 			size={noteListSize} | ||||
| 			visible={props.visible} | ||||
| 			columns={columns} | ||||
| 		/>; | ||||
| 	}; | ||||
|  | ||||
| @@ -144,6 +184,7 @@ export default function NoteListWrapper(props: Props) { | ||||
| 				padding={noteListControlsPadding} | ||||
| 				buttonVerticalGap={noteListControlsButtonVerticalGap} | ||||
| 			/> | ||||
| 			{renderHeader()} | ||||
| 			{renderNoteList()} | ||||
| 		</StyledRoot> | ||||
| 	); | ||||
|   | ||||
							
								
								
									
										18
									
								
								packages/app-desktop/gui/utils/dragAndDrop.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								packages/app-desktop/gui/utils/dragAndDrop.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| let globalDropEventCallback_: (()=> void)|null = null; | ||||
|  | ||||
| const onGlobalDrop = () => { | ||||
| 	const callback = globalDropEventCallback_; | ||||
| 	unregisterGlobalDragEndEvent(); | ||||
| 	if (callback) callback(); | ||||
| }; | ||||
|  | ||||
| export const registerGlobalDragEndEvent = (callback: ()=> void) => { | ||||
| 	if (globalDropEventCallback_) return; | ||||
| 	globalDropEventCallback_ = callback; | ||||
| 	document.addEventListener('dragend', onGlobalDrop); | ||||
| }; | ||||
|  | ||||
| export const unregisterGlobalDragEndEvent = () => { | ||||
| 	globalDropEventCallback_ = null; | ||||
| 	document.removeEventListener('dragend', onGlobalDrop); | ||||
| }; | ||||
| @@ -6,5 +6,6 @@ | ||||
| @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 'gui/NoteListHeader/style.scss' as note-list-header; | ||||
| @use 'gui/TrashNotification/style.scss' as trash-notification; | ||||
| @use 'main.scss' as main; | ||||
| @@ -6,7 +6,7 @@ | ||||
| SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" | ||||
| TEMP_PATH=~/src/plugin-tests | ||||
| NEED_COMPILING=1 | ||||
| PLUGIN_PATH=~/src/plugin-abc | ||||
| PLUGIN_PATH=~/src/joplin/packages/app-cli/tests/support/plugins/note_list_renderer | ||||
|  | ||||
| if [[ $NEED_COMPILING == 1 ]]; then | ||||
| 	mkdir -p "$TEMP_PATH" | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import Logger from '@joplin/utils/Logger'; | ||||
| import mergeGlobalAndLocalSettings from '../services/profileConfig/mergeGlobalAndLocalSettings'; | ||||
| import splitGlobalAndLocalSettings from '../services/profileConfig/splitGlobalAndLocalSettings'; | ||||
| import JoplinError from '../JoplinError'; | ||||
| import { defaultListColumns } from '../services/plugins/api/noteListType'; | ||||
| const { sprintf } = require('sprintf-js'); | ||||
| const ObjectUtils = require('../ObjectUtils'); | ||||
| const { toTitleCase } = require('../string-utils.js'); | ||||
| @@ -964,6 +965,14 @@ class Setting extends BaseModel { | ||||
| 				storage: SettingStorage.File, | ||||
| 				isGlobal: true, | ||||
| 			}, | ||||
| 			'notes.columns': { | ||||
| 				value: defaultListColumns(), | ||||
| 				public: false, | ||||
| 				type: SettingItemType.Array, | ||||
| 				storage: SettingStorage.File, | ||||
| 				isGlobal: false, | ||||
| 			}, | ||||
|  | ||||
| 			'notes.sortOrder.reverse': { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'note', public: true, label: () => _('Reverse sort order'), appTypes: [AppType.Cli] }, | ||||
| 			// NOTE: A setting whose name starts with 'notes.sortOrder' is special, | ||||
| 			// which implies changing the setting automatically triggers the refresh of notes. | ||||
|   | ||||
| @@ -20,6 +20,7 @@ | ||||
|     "@types/jest": "29.5.8", | ||||
|     "@types/js-yaml": "4.0.9", | ||||
|     "@types/markdown-it": "13.0.7", | ||||
|     "@types/mustache": "4.2.5", | ||||
|     "@types/node": "18.19.8", | ||||
|     "@types/node-rsa": "1.1.4", | ||||
|     "@types/react": "18.2.48", | ||||
|   | ||||
| @@ -40,7 +40,7 @@ const defaultLeftToRightItemRenderer: ListRenderer = { | ||||
| 		'note.is_shared', | ||||
| 		'note.is_todo', | ||||
| 		'note.isWatched', | ||||
| 		'note.titleHtml', | ||||
| 		'note.title', | ||||
| 		'note.todo_completed', | ||||
| 	], | ||||
|  | ||||
| @@ -149,7 +149,7 @@ const defaultLeftToRightItemRenderer: ListRenderer = { | ||||
| 					<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 class="titlecontent">{{note.title}}</div> | ||||
| 			</div> | ||||
| 			<div class="preview">{{notePreview}}</div> | ||||
| 		</div> | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import { _ } from '../../locale'; | ||||
| import { ItemFlow, ListRenderer } from '../plugins/api/noteListType'; | ||||
| import CommandService from '../CommandService'; | ||||
| import { ItemFlow, ListRenderer, OnClickEvent } from '../plugins/api/noteListType'; | ||||
|  | ||||
| interface Props { | ||||
| 	note: { | ||||
| @@ -17,7 +18,7 @@ interface Props { | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| const defaultListRenderer: ListRenderer = { | ||||
| const renderer: ListRenderer = { | ||||
| 	id: 'compact', | ||||
|  | ||||
| 	label: async () => _('Compact'), | ||||
| @@ -37,7 +38,7 @@ const defaultListRenderer: ListRenderer = { | ||||
| 		'note.is_shared', | ||||
| 		'note.is_todo', | ||||
| 		'note.isWatched', | ||||
| 		'note.titleHtml', | ||||
| 		'note.title', | ||||
| 		'note.todo_completed', | ||||
| 	], | ||||
|  | ||||
| @@ -117,6 +118,16 @@ const defaultListRenderer: ListRenderer = { | ||||
| 		} | ||||
| 	`, | ||||
|  | ||||
| 	headerTemplate: // html | ||||
| 		` | ||||
| 		<button data-id="title">Title</button><button data-id="updated">Updated</button> | ||||
| 	`, | ||||
|  | ||||
| 	onHeaderClick: async (event: OnClickEvent) => { | ||||
| 		const field = event.elementId === 'title' ? 'title' : 'user_updated_time'; | ||||
| 		void CommandService.instance().execute('toggleNotesSortOrderField', field); | ||||
| 	}, | ||||
|  | ||||
| 	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}}"> | ||||
| @@ -124,10 +135,10 @@ const defaultListRenderer: ListRenderer = { | ||||
| 				<div class="checkbox"> | ||||
| 					<input data-id="todo-checkbox" type="checkbox" {{#note.todo_completed}}checked="checked"{{/note.todo_completed}}> | ||||
| 				</div> | ||||
| 			{{/note.is_todo}}	 | ||||
| 			{{/note.is_todo}} | ||||
| 			<div class="title" data-id="{{note.id}}"> | ||||
| 				<i class="watchedicon fa fa-share-square"></i> | ||||
| 				<span>{{{note.titleHtml}}}</span> | ||||
| 				<span>{{note.title}}</span> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	`, | ||||
| @@ -137,4 +148,4 @@ const defaultListRenderer: ListRenderer = { | ||||
| 	}, | ||||
| }; | ||||
|  | ||||
| export default defaultListRenderer; | ||||
| export default renderer; | ||||
|   | ||||
							
								
								
									
										120
									
								
								packages/lib/services/noteList/defaultMultiColumnsRenderer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								packages/lib/services/noteList/defaultMultiColumnsRenderer.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,120 @@ | ||||
| import { _ } from '../../locale'; | ||||
| import CommandService from '../CommandService'; | ||||
| import { ItemFlow, ListRenderer, OnClickEvent } from '../plugins/api/noteListType'; | ||||
|  | ||||
| const renderer: ListRenderer = { | ||||
| 	id: 'detailed', | ||||
|  | ||||
| 	label: async () => _('Detailed'), | ||||
|  | ||||
| 	flow: ItemFlow.TopToBottom, | ||||
|  | ||||
| 	dependencies: [ | ||||
| 		'note.todo_completed', | ||||
| 		'item.selected', | ||||
| 		'note.is_shared', | ||||
| 		'note.isWatched', | ||||
| 	], | ||||
|  | ||||
| 	multiColumns: true, | ||||
|  | ||||
| 	itemSize: { | ||||
| 		width: 0, | ||||
| 		height: 34, | ||||
| 	}, | ||||
|  | ||||
| 	itemCss: // css | ||||
| 		`	 | ||||
| 		& { | ||||
| 			display: block; | ||||
| 		} | ||||
|  | ||||
| 		> .row { | ||||
| 			display: flex; | ||||
| 			height: 100%; | ||||
|  | ||||
| 			> .item { | ||||
| 				display: flex; | ||||
| 				align-items: center; | ||||
| 				box-sizing: border-box; | ||||
| 				padding-left: 8px; | ||||
| 				overflow: hidden; | ||||
| 				opacity: 0.6; | ||||
|  | ||||
| 				> .content { | ||||
| 					text-overflow: ellipsis; | ||||
| 					overflow: hidden; | ||||
| 					white-space: nowrap; | ||||
|  | ||||
| 					> .checkbox > input { | ||||
| 						padding: 0; | ||||
| 						margin: 0; | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			> .item[data-name="note.is_todo"], | ||||
| 			> .item[data-name="note.title"] { | ||||
| 				opacity: 1; | ||||
| 			} | ||||
|  | ||||
| 			> .item > .content > .watchedicon { | ||||
| 				display: none; | ||||
| 				margin-right: 8px; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		> .row.-watched > .item[data-name="note.title"] > .content > .watchedicon { | ||||
| 			display: inline-block; | ||||
| 		} | ||||
|  | ||||
| 		> .row.-selected { | ||||
| 			background-color: var(--joplin-selected-color); | ||||
| 		} | ||||
|  | ||||
| 		> .row.-shared { | ||||
| 			color: var(--joplin-color-warn3); | ||||
| 		} | ||||
|  | ||||
| 		> .row.-completed { | ||||
| 			opacity: 0.5; | ||||
| 		} | ||||
| 	`, | ||||
|  | ||||
| 	onHeaderClick: async (event: OnClickEvent) => { | ||||
| 		const field = event.elementId === 'title' ? 'title' : 'user_updated_time'; | ||||
| 		void CommandService.instance().execute('toggleNotesSortOrderField', field); | ||||
| 	}, | ||||
|  | ||||
| 	itemTemplate: // html | ||||
| 		` | ||||
| 			<div class="row {{#item.selected}}-selected{{/item.selected}} {{#note.is_shared}}-shared{{/note.is_shared}} {{#note.todo_completed}}-completed{{/note.todo_completed}} {{#note.isWatched}}-watched{{/note.isWatched}}"> | ||||
| 				{{#cells}} | ||||
| 					<div data-name="{{name}}" class="item" style="{{{styleHtml}}}"> | ||||
| 						<div class="content"> | ||||
| 							<i class="watchedicon fa fa-share-square"></i>{{{contentHtml}}} | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				{{/cells}} | ||||
| 			</div> | ||||
| 		`, | ||||
|  | ||||
| 	itemValueTemplates: { | ||||
| 		'note.is_todo': // html | ||||
| 			` | ||||
| 			{{#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}} | ||||
| 		`, | ||||
| 	}, | ||||
|  | ||||
| 	onRenderNote: async (props: any) => { | ||||
| 		return { | ||||
| 			...props, | ||||
| 		}; | ||||
| 	}, | ||||
| }; | ||||
|  | ||||
| export default renderer; | ||||
							
								
								
									
										7
									
								
								packages/lib/services/noteList/depNameToNoteProp.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								packages/lib/services/noteList/depNameToNoteProp.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| import { ListRendererDependency } from '../plugins/api/noteListType'; | ||||
|  | ||||
| export default (dep: ListRendererDependency) => { | ||||
| 	let output: string = dep as string; | ||||
| 	if (output === 'note.titleHtml') output = 'note.title'; | ||||
| 	return output; | ||||
| }; | ||||
							
								
								
									
										92
									
								
								packages/lib/services/noteList/renderTemplate.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								packages/lib/services/noteList/renderTemplate.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | ||||
| import { NoteListColumns } from '../plugins/api/noteListType'; | ||||
| import renderTemplate from './renderTemplate'; | ||||
|  | ||||
| describe('renderTemplate', () => { | ||||
|  | ||||
| 	it('should render a template', () => { | ||||
| 		const columns: NoteListColumns = [ | ||||
| 			{ | ||||
| 				name: 'note.is_todo', | ||||
| 				width: 40, | ||||
| 			}, | ||||
| 			{ | ||||
| 				name: 'note.user_updated_time', | ||||
| 				width: 100, | ||||
| 			}, | ||||
| 			{ | ||||
| 				name: 'note.titleHtml' as any, // Testing backward compatibility | ||||
| 				width: 200, | ||||
| 			}, | ||||
| 			{ | ||||
| 				name: 'note.title', | ||||
| 				width: 0, | ||||
| 			}, | ||||
| 		]; | ||||
|  | ||||
| 		const template = // html | ||||
| 		` | ||||
| 			<div> | ||||
| 				{{#cells}} | ||||
| 					<div data-name="{{name}}" class="item" style="{{{styleHtml}}}"> | ||||
| 						{{{contentHtml}}} | ||||
| 					</div> | ||||
| 				{{/cells}} | ||||
| 			</div> | ||||
| 		`; | ||||
|  | ||||
| 		const valueTemplates = { | ||||
| 			'note.is_todo': // html | ||||
| 				` | ||||
| 				<span> | ||||
| 					{{#note.is_todo}} | ||||
| 						{{#note.todo_completed}}[x]{{/note.todo_completed}}{{^note.todo_completed}}[ ]{{/note.todo_completed}}						 | ||||
| 					{{/note.is_todo}} | ||||
| 					{{^note.is_todo}} | ||||
| 						(-) | ||||
| 					{{/note.is_todo}} | ||||
| 				</span> | ||||
| 			`, | ||||
| 		}; | ||||
|  | ||||
| 		const view = { | ||||
| 			'note': { | ||||
| 				'user_updated_time': '18/02/24 14:30', | ||||
| 				'titleHtml': '<b>Hello</b>', | ||||
| 				'title': '<b>Hello2</b>', | ||||
| 				'is_todo': 0, | ||||
| 				'todo_completed': 10000, | ||||
| 			}, | ||||
| 		}; | ||||
|  | ||||
| 		{ | ||||
| 			const actual = renderTemplate(columns, template, valueTemplates, view); | ||||
| 			expect(actual).toContain('18/02/24 14:30'); | ||||
| 			expect(actual).toContain('<b>Hello</b>'); | ||||
| 			expect(actual).toContain('<b>Hello2</b>'); | ||||
|  | ||||
| 			const widthInfo = actual.match(/(width: (\d+)px|flex: 1)/g); | ||||
| 			expect(widthInfo).toEqual(['width: 40px', 'width: 100px', 'width: 200px', 'flex: 1']); | ||||
|  | ||||
| 			const dataNames = actual.match(/data-name="(.*?)"/g); | ||||
| 			expect(dataNames).toEqual([ | ||||
| 				'data-name="note.is_todo"', | ||||
| 				'data-name="note.user_updated_time"', | ||||
| 				'data-name="note.titleHtml"', | ||||
| 				'data-name="note.title"', | ||||
| 			]); | ||||
|  | ||||
| 			expect(actual).toContain('(-)'); | ||||
| 		} | ||||
|  | ||||
| 		{ | ||||
| 			const actual = renderTemplate(columns, template, valueTemplates, { note: { ...view.note, is_todo: 1 } }); | ||||
| 			expect(actual).toContain('[x]'); | ||||
| 		} | ||||
|  | ||||
| 		{ | ||||
| 			const actual = renderTemplate(columns, template, valueTemplates, { note: { ...view.note, is_todo: 1, todo_completed: 0 } }); | ||||
| 			expect(actual).toContain('[ ]'); | ||||
| 		} | ||||
| 	}); | ||||
|  | ||||
| }); | ||||
							
								
								
									
										50
									
								
								packages/lib/services/noteList/renderTemplate.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								packages/lib/services/noteList/renderTemplate.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | ||||
| import { escapeHtml } from '../../string-utils'; | ||||
| import { ColumnName, ListRendererItemValueTemplates, NoteListColumns, RenderNoteView } from '../plugins/api/noteListType'; | ||||
| import * as Mustache from 'mustache'; | ||||
| import { objectValueFromPath } from '@joplin/utils/object'; | ||||
|  | ||||
| interface Cell { | ||||
| 	name: ColumnName; | ||||
| 	styleHtml: string; | ||||
| 	value: any; | ||||
| 	contentHtml: ()=> string; | ||||
| } | ||||
|  | ||||
| export default (columns: NoteListColumns, itemTemplate: string, itemValueTemplates: ListRendererItemValueTemplates, view: RenderNoteView) => { | ||||
| 	// `note.title` is special and has already been rendered to HTML at this point, so we need | ||||
| 	// to ensure the string is not going to be escaped. | ||||
| 	itemTemplate = itemTemplate.replace(/\{\{note.title\}\}/g, '{{{note.title}}}'); | ||||
| 	if (!columns || !columns.length) return Mustache.render(itemTemplate, view); | ||||
|  | ||||
| 	const cells: Cell[] = []; | ||||
|  | ||||
| 	for (const column of columns) { | ||||
| 		const styleHtml: string[] = []; | ||||
|  | ||||
| 		if (column.width) { | ||||
| 			styleHtml.push(`width: ${column.width}px`); | ||||
| 		} else { | ||||
| 			styleHtml.push('flex: 1'); | ||||
| 		} | ||||
|  | ||||
| 		cells.push({ | ||||
| 			name: column.name, | ||||
| 			styleHtml: styleHtml.join('; '), | ||||
| 			value: objectValueFromPath(view, column.name) || '', | ||||
| 			contentHtml: function() { | ||||
| 				const name = this.name as ColumnName; | ||||
| 				if (itemValueTemplates[name]) { | ||||
| 					return Mustache.render(itemValueTemplates[name], view); | ||||
| 				} | ||||
| 				return ['note.titleHtml', 'note.title'].includes(name) ? this.value : escapeHtml(this.value); | ||||
| 			}, | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	const finalView = { | ||||
| 		cells, | ||||
| 		...view, | ||||
| 	}; | ||||
|  | ||||
| 	return Mustache.render(itemTemplate, finalView); | ||||
| }; | ||||
							
								
								
									
										60
									
								
								packages/lib/services/noteList/renderViewProps.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								packages/lib/services/noteList/renderViewProps.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import { RenderNoteView } from '../plugins/api/noteListType'; | ||||
| import renderViewProps from './renderViewProps'; | ||||
|  | ||||
| describe('renderViewProps', () => { | ||||
|  | ||||
| 	it('should render view props', async () => { | ||||
| 		const view: RenderNoteView = { | ||||
| 			note: { | ||||
| 				title: 'M&M\'s®', | ||||
| 				user_updated_time: (new Date(2024, 2, 20, 15, 30, 45, 500)).getTime(), | ||||
| 				todo_completed: (new Date(2024, 2, 21, 15, 30, 45, 500)).getTime(), | ||||
| 				isWatched: true, | ||||
| 				is_todo: 1, | ||||
| 				tags: [ | ||||
| 					{ title: 'one' }, | ||||
| 					{ title: 'two' }, | ||||
| 				], | ||||
| 			}, | ||||
| 		}; | ||||
|  | ||||
| 		await renderViewProps(view, [], { | ||||
| 			noteTitleHtml: 'M&M's®', | ||||
| 		}); | ||||
|  | ||||
| 		expect(view).toEqual({ | ||||
| 			note: { | ||||
| 				title: 'M&M's®', | ||||
| 				user_updated_time: '20/03/2024 15:30', | ||||
| 				todo_completed: '21/03/2024 15:30', | ||||
| 				isWatched: true, | ||||
| 				is_todo: 1, | ||||
| 				tags: 'one, two', | ||||
| 			}, | ||||
| 		}); | ||||
| 	}); | ||||
|  | ||||
| 	it('should handle invalid view props', async () => { | ||||
| 		const view: RenderNoteView = { | ||||
| 			note: { | ||||
| 				user_updated_time: 'not a number', | ||||
| 				tags: 123, | ||||
| 			}, | ||||
| 		}; | ||||
|  | ||||
| 		Logger.globalLogger.enabled = false; | ||||
| 		await renderViewProps(view, [], { | ||||
| 			noteTitleHtml: '', | ||||
| 		}); | ||||
| 		Logger.globalLogger.enabled = true; | ||||
|  | ||||
| 		expect(view).toEqual({ | ||||
| 			note: { | ||||
| 				user_updated_time: 'Invalid date', | ||||
| 				tags: 123, | ||||
| 			}, | ||||
| 		}); | ||||
| 	}); | ||||
|  | ||||
| }); | ||||
							
								
								
									
										50
									
								
								packages/lib/services/noteList/renderViewProps.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								packages/lib/services/noteList/renderViewProps.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import time from '../../time'; | ||||
| import { TagEntity } from '../database/types'; | ||||
| import { ListRendererDependency, RenderNoteView } from '../plugins/api/noteListType'; | ||||
|  | ||||
| const logger = Logger.create('renderViewProps'); | ||||
|  | ||||
| export interface RenderViewPropsOptions { | ||||
| 	// Note that we don't render the title here, because it requires the mark.js package which is | ||||
| 	// only available on the `app-desktop` package. So the caller needs to pre-render the title and | ||||
| 	// pass it as an option. | ||||
| 	noteTitleHtml: string; | ||||
| } | ||||
|  | ||||
| const renderViewProp = (name: ListRendererDependency, value: any, options: RenderViewPropsOptions) => { | ||||
| 	const renderers: Partial<Record<ListRendererDependency, ()=> string>> = { | ||||
| 		'note.user_updated_time': () => time.unixMsToLocalDateTime(value), | ||||
| 		'note.user_created_time': () => time.unixMsToLocalDateTime(value), | ||||
| 		'note.updated_time': () => time.unixMsToLocalDateTime(value), | ||||
| 		'note.created_time': () => time.unixMsToLocalDateTime(value), | ||||
| 		'note.todo_completed': () => value ? time.unixMsToLocalDateTime(value) : '', | ||||
| 		'note.tags': () => value ? value.map((t: TagEntity) => t.title).join(', ') : '', | ||||
| 		'note.title': () => options.noteTitleHtml, | ||||
| 	}; | ||||
|  | ||||
| 	try { | ||||
| 		const renderer = renderers[name]; | ||||
| 		if (renderer) return renderer(); | ||||
| 	} catch (error) { | ||||
| 		// If the input value doesn't have the expected format, it may have been changed by the | ||||
| 		// user. In that case we return the value without rendering it. | ||||
| 		logger.warn('Could not render property:', name, 'With value:', value, 'Error:', error); | ||||
| 		return value; | ||||
| 	} | ||||
|  | ||||
| 	return value; | ||||
| }; | ||||
|  | ||||
| const renderViewProps = async (view: RenderNoteView, parentPath: string[], options: RenderViewPropsOptions) => { | ||||
| 	for (const [name, value] of Object.entries(view)) { | ||||
| 		const currentPath = parentPath.concat([name]); | ||||
| 		if (value && typeof value === 'object' && !Array.isArray(value)) { | ||||
| 			await renderViewProps(value, currentPath, options); | ||||
| 		} else { | ||||
| 			view[name] = renderViewProp(currentPath.join('.') as ListRendererDependency, value, options); | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| export default renderViewProps; | ||||
| @@ -1,10 +1,12 @@ | ||||
| import { ListRenderer } from '../plugins/api/noteListType'; | ||||
| // import defaultLeftToRightItemRenderer from '../noteList/defaultLeftToRightListRenderer'; | ||||
| import defaultListRenderer from '../noteList/defaultListRenderer'; | ||||
| import defaultMultiColumnsRenderer from '../noteList/defaultMultiColumnsRenderer'; | ||||
| import { Store } from 'redux'; | ||||
|  | ||||
| const renderers_: ListRenderer[] = [ | ||||
| 	defaultListRenderer, | ||||
| 	defaultMultiColumnsRenderer, | ||||
| 	// defaultLeftToRightItemRenderer, | ||||
| ]; | ||||
|  | ||||
|   | ||||
| @@ -18,7 +18,9 @@ import { ListRenderer } from './noteListType'; | ||||
|  * | ||||
|  * * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/note_list_renderer) | ||||
|  * | ||||
|  * * [Default list renderer](https://github.com/laurent22/joplin/tree/dev/packages/lib/services/noteList/defaultListRenderer.ts) | ||||
|  * * [Default simple renderer](https://github.com/laurent22/joplin/tree/dev/packages/lib/services/noteList/defaultListRenderer.ts) | ||||
|  * | ||||
|  * * [Default detailed renderer](https://github.com/laurent22/joplin/tree/dev/packages/lib/services/noteList/defaultMultiColumnsRenderer.ts) | ||||
|  * | ||||
|  * ## Screenshots: | ||||
|  * | ||||
|   | ||||
| @@ -19,38 +19,52 @@ export interface OnChangeEvent { | ||||
| 	noteId: string; | ||||
| } | ||||
|  | ||||
| export interface OnClickEvent { | ||||
| 	elementId: string; | ||||
| } | ||||
|  | ||||
| export type OnRenderNoteHandler = (props: any)=> Promise<RenderNoteView>; | ||||
| export type OnChangeHandler = (event: OnChangeEvent)=> Promise<void>; | ||||
| export type OnClickHandler = (event: OnClickEvent)=> Promise<void>; | ||||
|  | ||||
| /** | ||||
|  * Most of these are the built-in note properties, such as `note.title`, | ||||
|  * `note.todo_completed`, etc. | ||||
|  * Most of these are the built-in note properties, such as `note.title`, `note.todo_completed`, etc. | ||||
|  * complemented with special properties such as `note.isWatched`, to know if a note is currently | ||||
|  * opened in the external editor, and `note.tags` to get the list tags associated with the note. | ||||
|  * | ||||
|  * Additionally, the `item.*` properties are specific to the rendered item. The | ||||
|  * most important being `item.selected`, which you can use to display the | ||||
|  * selected note in a different way. | ||||
|  * ## Item properties | ||||
|  * | ||||
|  * Finally some special properties are provided to make it easier to render | ||||
|  * notes. In particular, if possible prefer `note.titleHtml` to `note.title` | ||||
|  * since some important processing has already been done on the string, such as | ||||
|  * handling the search highlighter and escaping. Since it's HTML and already | ||||
|  * escaped you would insert it using `{{{titleHtml}}}` (triple-mustache syntax, | ||||
|  * which disables escaping). | ||||
|  * | ||||
|  * `notes.tag` gives you the list of tags associated with the note. | ||||
|  * | ||||
|  * `note.isWatched` tells you if the note is currently opened in an external | ||||
|  * editor. In which case you would generally display some indicator. | ||||
|  * The `item.*` properties are specific to the rendered item. The most important being | ||||
|  * `item.selected`, which you can use to display the selected note in a different way. | ||||
|  */ | ||||
| export type ListRendererDependency = | ||||
| 	ListRendererDatabaseDependency | | ||||
| 	'item.index' | | ||||
| 	'item.size.width' | | ||||
| 	'item.size.height' | | ||||
| 	'item.selected' | | ||||
| 	'note.titleHtml' | | ||||
| 	'item.size.height' | | ||||
| 	'item.size.width' | | ||||
| 	'note.folder.title' | | ||||
| 	'note.isWatched' | | ||||
| 	'note.tags'; | ||||
| 	'note.tags' | | ||||
| 	'note.titleHtml'; | ||||
|  | ||||
| export type ListRendererItemValueTemplates = Record<string, string>; | ||||
|  | ||||
| export const columnNames = [ | ||||
| 	'note.folder.title', | ||||
| 	'note.is_todo', | ||||
| 	'note.latitude', | ||||
| 	'note.longitude', | ||||
| 	'note.source_url', | ||||
| 	'note.tags', | ||||
| 	'note.title', | ||||
| 	'note.todo_completed', | ||||
| 	'note.todo_due', | ||||
| 	'note.user_created_time', | ||||
| 	'note.user_updated_time', | ||||
| ] as const; | ||||
|  | ||||
| export type ColumnName = typeof columnNames[number]; | ||||
|  | ||||
| export interface ListRenderer { | ||||
| 	/** | ||||
| @@ -65,6 +79,12 @@ export interface ListRenderer { | ||||
| 	 */ | ||||
| 	flow: ItemFlow; | ||||
|  | ||||
| 	/** | ||||
| 	 * Whether the renderer supports multiple columns. Applies only when `flow` | ||||
| 	 * is `topToBottom`. Defaults to `false`. | ||||
| 	 */ | ||||
| 	multiColumns?: boolean; | ||||
|  | ||||
| 	/** | ||||
| 	 * The size of each item must be specified in advance for performance | ||||
| 	 * reasons, and cannot be changed afterwards. If the item flow is top to | ||||
| @@ -97,22 +117,101 @@ export interface ListRenderer { | ||||
| 	 * that you do not add more than what you need since there is a performance | ||||
| 	 * penalty for each property. | ||||
| 	 */ | ||||
| 	dependencies: ListRendererDependency[]; | ||||
| 	dependencies?: ListRendererDependency[]; | ||||
|  | ||||
| 	headerTemplate?: string; | ||||
| 	headerHeight?: number; | ||||
| 	onHeaderClick?: OnClickHandler; | ||||
|  | ||||
| 	/** | ||||
| 	 * This is the HTML template that will be used to render the note list item. | ||||
| 	 * This is a [Mustache template](https://github.com/janl/mustache.js) and it | ||||
| 	 * will receive the variable you return from `onRenderNote` as tags. For | ||||
| 	 * example, if you return a property named `formattedDate` from | ||||
| 	 * `onRenderNote`, you can insert it in the template using `Created date: | ||||
| 	 * {{formattedDate}}`. | ||||
| 	 * This property is set differently depending on the `multiColumns` property. | ||||
| 	 * | ||||
| 	 * In order to get syntax highlighting working here, it's recommended | ||||
| 	 * installing an editor extension such as [es6-string-html VSCode | ||||
| 	 * ## If `multiColumns` is `false` | ||||
| 	 * | ||||
| 	 * There is only one column and the template is used to render the entire row. | ||||
| 	 * | ||||
| 	 * This is the HTML template that will be used to render the note list item. This is a [Mustache | ||||
| 	 * template](https://github.com/janl/mustache.js) and it will receive the variable you return | ||||
| 	 * from `onRenderNote` as tags. For example, if you return a property named `formattedDate` from | ||||
| 	 * `onRenderNote`, you can insert it in the template using `Created date: {{formattedDate}}` | ||||
| 	 * | ||||
| 	 * ## If `multiColumns` is `true` | ||||
| 	 * | ||||
| 	 * Since there is multiple columns, this template will be used to render each note property | ||||
| 	 * within the row. For example if the current columns are the Updated and Title properties, this | ||||
| 	 * template will be called once to render the updated time and a second time to render the | ||||
| 	 * title. To display the current property, the generic `value` property is provided - it will be | ||||
| 	 * replaced at runtime by the actual note property. To render something different depending on | ||||
| 	 * the note property, use `itemValueTemplate`. A minimal example would be | ||||
| 	 * `<span>{{value}}</span>` which will simply render the current property inside a span tag. | ||||
| 	 * | ||||
| 	 * In order to get syntax highlighting working here, it's recommended installing an editor | ||||
| 	 * extension such as [es6-string-html VSCode | ||||
| 	 * extension](https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html) | ||||
| 	 * | ||||
| 	 * ## Default property rendering | ||||
| 	 * | ||||
| 	 * Certain properties are automatically rendered once inserted in the Mustache template. Those | ||||
| 	 * are in particular all the date-related fields, such as `note.user_updated_time` or | ||||
| 	 * `note.todo_completed`. Internally, those are timestamps in milliseconds, however when | ||||
| 	 * rendered we display them as date/time strings using the user's preferred time format. Another | ||||
| 	 * notable auto-rendered property is `note.title` which is going to include additional HTML, | ||||
| 	 * such as the search markers. | ||||
| 	 * | ||||
| 	 * If you do not want this default rendering behaviour, for example if you want to display the | ||||
| 	 * raw timestamps in milliseconds, you can simply return custom properties from | ||||
| 	 * `onRenderNote()`. For example: | ||||
| 	 * | ||||
| 	 * ```typescript | ||||
| 	 * onRenderNote: async (props: any) => { | ||||
| 	 *     return { | ||||
| 	 *         ...props, | ||||
| 	 *         // Return the property under a different name | ||||
| 	 *         updatedTimeMs: props.note.user_updated_time, | ||||
| 	 *     } | ||||
| 	 * }, | ||||
| 	 * | ||||
| 	 * itemTemplate: // html | ||||
| 	 *     ` | ||||
| 	 *     <div> | ||||
| 	 *         Raw timestamp: {{updatedTimeMs}} <!-- This is **not** auto-rendered -> | ||||
| 	 *         Formatted time: {{note.user_updated_time}} <!-- This is --> | ||||
| 	 *     </div> | ||||
| 	 * `, | ||||
| 	 * | ||||
| 	 * ``` | ||||
| 	 * | ||||
| 	 * See | ||||
| 	 * `[https://github.com/laurent22/joplin/blob/dev/packages/lib/services/noteList/renderViewProps.ts](renderViewProps.ts)` | ||||
| 	 * for the list of properties that have a default rendering. | ||||
| 	 */ | ||||
| 	itemTemplate: string; | ||||
|  | ||||
| 	/** | ||||
| 	 * This property applies only when `multiColumns` is `true`. It is used to render something | ||||
| 	 * different for each note property. | ||||
| 	 * | ||||
| 	 * This is a map of actual dependencies to templates - you only need to return something if the | ||||
| 	 * default, as specified in `template`, is not enough. | ||||
| 	 * | ||||
| 	 * Again you need to return a Mustache template and it will be combined with the `template` | ||||
| 	 * property to create the final template. For example if you return a property named | ||||
| 	 * `formattedDate` from `onRenderNote`, you can insert it in the template using | ||||
| 	 * `{{formattedDate}}`. This string will replace `{{value}}` in the `template` property. | ||||
| 	 * | ||||
| 	 * So if the template property is set to `<span>{{value}}</span>`, the final template will be | ||||
| 	 * `<span>{{formattedDate}}</span>`. | ||||
| 	 * | ||||
| 	 * The property would be set as so: | ||||
| 	 * | ||||
| 	 * ```javascript | ||||
| 	 * itemValueTemplates: { | ||||
| 	 *     'note.user_updated_time': '{{formattedDate}}', | ||||
| 	 * } | ||||
| 	 * ``` | ||||
| 	 */ | ||||
| 	itemValueTemplates?: ListRendererItemValueTemplates; | ||||
|  | ||||
| 	/** | ||||
| 	 * This user-facing text is used for example in the View menu, so that your | ||||
| 	 * renderer can be selected. | ||||
| @@ -155,17 +254,15 @@ export interface ListRenderer { | ||||
| 	onRenderNote: OnRenderNoteHandler; | ||||
|  | ||||
| 	/** | ||||
| 	 * This handler allows adding some interactivity to the note renderer - | ||||
| 	 * whenever an input element within the item is changed (for example, when a | ||||
| 	 * checkbox is clicked, or a text input is changed), this `onChange` handler | ||||
| 	 * is going to be called. | ||||
| 	 * This handler allows adding some interactivity to the note renderer - whenever an input element | ||||
| 	 * within the item is changed (for example, when a checkbox is clicked, or a text input is | ||||
| 	 * changed), this `onChange` handler is going to be called. | ||||
| 	 * | ||||
| 	 * You can inspect `event.elementId` to know which element had some changes, | ||||
| 	 * and `event.value` to know the new value. `event.noteId` also tells you | ||||
| 	 * what note is affected, so that you can potentially apply changes to it. | ||||
| 	 * You can inspect `event.elementId` to know which element had some changes, and `event.value` | ||||
| 	 * to know the new value. `event.noteId` also tells you what note is affected, so that you can | ||||
| 	 * potentially apply changes to it. | ||||
| 	 * | ||||
| 	 * You specify the element ID, by setting a `data-id` attribute on the | ||||
| 	 * input. | ||||
| 	 * You specify the element ID, by setting a `data-id` attribute on the input. | ||||
| 	 * | ||||
| 	 * For example, if you have such a template: | ||||
| 	 * | ||||
| @@ -175,8 +272,46 @@ export interface ListRenderer { | ||||
| 	 * </div> | ||||
| 	 * ``` | ||||
| 	 * | ||||
| 	 * The event handler will receive an event with `elementId` set to | ||||
| 	 * `noteTitleInput`. | ||||
| 	 * The event handler will receive an event with `elementId` set to `noteTitleInput`. | ||||
| 	 * | ||||
| 	 * ## Default event handlers | ||||
| 	 * | ||||
| 	 * Currently one click event is automatically handled: | ||||
| 	 * | ||||
| 	 * If there is a checkbox with a `data-id="todo-checkbox"` attribute is present, it is going to | ||||
| 	 * automatically toggle the note to-do "completed" status. | ||||
| 	 * | ||||
| 	 * For example this is what is used in the default list renderer: | ||||
| 	 * | ||||
| 	 * `<input data-id="todo-checkbox" type="checkbox" {{#note.todo_completed}}checked="checked"{{/note.todo_completed}}>` | ||||
| 	 */ | ||||
| 	onChange?: OnChangeHandler; | ||||
| } | ||||
|  | ||||
| export interface NoteListColumn { | ||||
| 	name: ColumnName; | ||||
| 	width: number; | ||||
| } | ||||
|  | ||||
| export type NoteListColumns = NoteListColumn[]; | ||||
|  | ||||
| export const defaultWidth = 100; | ||||
|  | ||||
| export const defaultListColumns = () => { | ||||
| 	const columns: NoteListColumns = [ | ||||
| 		{ | ||||
| 			name: 'note.is_todo', | ||||
| 			width: 30, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: 'note.user_updated_time', | ||||
| 			width: defaultWidth, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: 'note.title', | ||||
| 			width: 0, | ||||
| 		}, | ||||
| 	]; | ||||
|  | ||||
| 	return columns; | ||||
| }; | ||||
|   | ||||
| @@ -151,6 +151,9 @@ export function addExtraStyles(style: any) { | ||||
|  | ||||
| 	style.configScreenPadding = style.mainPadding * 2; | ||||
|  | ||||
| 	style.noteListHeaderHeight = 26; | ||||
| 	style.noteListHeaderBorderPadding = 4; | ||||
|  | ||||
| 	style.icon = { | ||||
| 		...style.icon, | ||||
| 		color: style.color, | ||||
|   | ||||
| @@ -89,7 +89,7 @@ class Time { | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	public unixMsToLocalDateTime(ms: number) { | ||||
| 	public unixMsToLocalDateTime(ms: number): string { | ||||
| 		return moment.unix(ms / 1000).format('DD/MM/YYYY HH:mm'); | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -88,5 +88,6 @@ firstname | ||||
| lastname | ||||
| signup | ||||
| activatable | ||||
| titlewrapper | ||||
| notyf | ||||
| Notyf | ||||
| Notyf | ||||
|   | ||||
							
								
								
									
										9
									
								
								packages/utils/dom.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								packages/utils/dom.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| /* eslint-disable import/prefer-default-export */ | ||||
|  | ||||
| export const findParentElementByClassName = (element: any, parentClassName: string) => { | ||||
| 	while (element) { | ||||
| 		if (element.classList.contains(parentClassName)) return element; | ||||
| 		element = element.parentElement; | ||||
| 	} | ||||
| 	return null; | ||||
| }; | ||||
							
								
								
									
										31
									
								
								packages/utils/object.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								packages/utils/object.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| import { objectValueFromPath } from './object'; | ||||
|  | ||||
| describe('object', () => { | ||||
|  | ||||
| 	test.each([ | ||||
| 		[ | ||||
| 			{ | ||||
| 				note: { | ||||
| 					id: '123', | ||||
| 					title: 'my note', | ||||
| 				}, | ||||
| 			}, | ||||
| 			'note.title', | ||||
| 			'my note', | ||||
| 		], | ||||
| 		[ | ||||
| 			{ | ||||
| 				note: { | ||||
| 					id: '123', | ||||
| 					title: 'my note', | ||||
| 				}, | ||||
| 			}, | ||||
| 			'note.doesntexist', | ||||
| 			undefined, | ||||
| 		], | ||||
| 	])('should extract URLs', (object, path, expected) => { | ||||
| 		const actual = objectValueFromPath(object, path); | ||||
| 		expect(actual).toBe(expected); | ||||
| 	}); | ||||
|  | ||||
| }); | ||||
| @@ -1,4 +1,12 @@ | ||||
| /* eslint-disable import/prefer-default-export */ | ||||
| export const objectValueFromPath = (o: any, path: string) => { | ||||
| 	const elements = path.split('.'); | ||||
| 	let result = { ...o }; | ||||
| 	while (elements.length && result) { | ||||
| 		const e = elements.splice(0, 1)[0]; | ||||
| 		result = result[e]; | ||||
| 	} | ||||
| 	return result; | ||||
| }; | ||||
|  | ||||
| export function checkObjectHasProperties(object: any, properties: string[]) { | ||||
| 	for (const prop of properties) { | ||||
|   | ||||
| @@ -5,6 +5,7 @@ | ||||
|   "repository": "https://github.com/laurent22/joplin/tree/dev/packages/utils", | ||||
|   "exports": { | ||||
|     ".": "./dist/index.js", | ||||
|     "./dom": "./dist/dom.js", | ||||
|     "./env": "./dist/env.js", | ||||
|     "./object": "./dist/object.js", | ||||
|     "./fs": "./dist/fs.js", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user