1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-07-16 00:14:34 +02:00

Desktop: Add support for multiple columns note list (#9924)

This commit is contained in:
Laurent Cozic
2024-03-02 15:29:18 +00:00
committed by GitHub
parent f19b1c5364
commit 53d5cf55bc
50 changed files with 1677 additions and 115 deletions

View File

@ -335,9 +335,19 @@ packages/app-desktop/gui/NoteList/utils/useVisibleRange.js
packages/app-desktop/gui/NoteListControls/NoteListControls.js packages/app-desktop/gui/NoteListControls/NoteListControls.js
packages/app-desktop/gui/NoteListControls/commands/focusSearch.js packages/app-desktop/gui/NoteListControls/commands/focusSearch.js
packages/app-desktop/gui/NoteListControls/commands/index.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.js
packages/app-desktop/gui/NoteListItem/NoteListItem.js packages/app-desktop/gui/NoteListItem/NoteListItem.js
packages/app-desktop/gui/NoteListItem/utils/getNoteTitleHtml.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/prepareViewProps.js
packages/app-desktop/gui/NoteListItem/utils/types.js packages/app-desktop/gui/NoteListItem/utils/types.js
packages/app-desktop/gui/NoteListItem/utils/useItemElement.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/style/StyledTextInput.js
packages/app-desktop/gui/utils/NoteListUtils.js packages/app-desktop/gui/utils/NoteListUtils.js
packages/app-desktop/gui/utils/convertToScreenCoordinates.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/gui/utils/loadScript.js
packages/app-desktop/gulpfile.js packages/app-desktop/gulpfile.js
packages/app-desktop/integration-tests/main.spec.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/keychain/KeychainServiceDriverBase.js
packages/lib/services/noteList/defaultLeftToRightListRenderer.js packages/lib/services/noteList/defaultLeftToRightListRenderer.js
packages/lib/services/noteList/defaultListRenderer.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/noteList/renderers.js
packages/lib/services/ocr/OcrDriverBase.js packages/lib/services/ocr/OcrDriverBase.js
packages/lib/services/ocr/OcrService.test.js packages/lib/services/ocr/OcrService.test.js

17
.gitignore vendored
View File

@ -315,9 +315,19 @@ packages/app-desktop/gui/NoteList/utils/useVisibleRange.js
packages/app-desktop/gui/NoteListControls/NoteListControls.js packages/app-desktop/gui/NoteListControls/NoteListControls.js
packages/app-desktop/gui/NoteListControls/commands/focusSearch.js packages/app-desktop/gui/NoteListControls/commands/focusSearch.js
packages/app-desktop/gui/NoteListControls/commands/index.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.js
packages/app-desktop/gui/NoteListItem/NoteListItem.js packages/app-desktop/gui/NoteListItem/NoteListItem.js
packages/app-desktop/gui/NoteListItem/utils/getNoteTitleHtml.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/prepareViewProps.js
packages/app-desktop/gui/NoteListItem/utils/types.js packages/app-desktop/gui/NoteListItem/utils/types.js
packages/app-desktop/gui/NoteListItem/utils/useItemElement.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/style/StyledTextInput.js
packages/app-desktop/gui/utils/NoteListUtils.js packages/app-desktop/gui/utils/NoteListUtils.js
packages/app-desktop/gui/utils/convertToScreenCoordinates.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/gui/utils/loadScript.js
packages/app-desktop/gulpfile.js packages/app-desktop/gulpfile.js
packages/app-desktop/integration-tests/main.spec.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/keychain/KeychainServiceDriverBase.js
packages/lib/services/noteList/defaultLeftToRightListRenderer.js packages/lib/services/noteList/defaultLeftToRightListRenderer.js
packages/lib/services/noteList/defaultListRenderer.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/noteList/renderers.js
packages/lib/services/ocr/OcrDriverBase.js packages/lib/services/ocr/OcrDriverBase.js
packages/lib/services/ocr/OcrService.test.js packages/lib/services/ocr/OcrService.test.js

View File

@ -22,7 +22,7 @@ const registerSimpleTopToBottomRenderer = async () => {
dependencies: [ dependencies: [
'item.selected', 'item.selected',
'note.titleHtml', 'note.title',
'note.body', 'note.body',
'note.user_updated_time', 'note.user_updated_time',
], ],
@ -55,8 +55,8 @@ const registerSimpleTopToBottomRenderer = async () => {
itemTemplate: // html itemTemplate: // html
` `
<div class="content {{#item.selected}}-selected{{/item.selected}}"> <div class="content {{#item.selected}}-selected{{/item.selected}}">
<p class="title">{{{note.titleHtml}}}</p> <p class="title">{{note.title}}</p>
<p class="date">{{{updatedTime}}}</p> <p class="date">{{updatedTime}}</p>
<p class="body">{{noteBody}}</p> <p class="body">{{noteBody}}</p>
</div> </div>
`, `,
@ -90,7 +90,7 @@ const registerSimpleLeftToRightRenderer = async() => {
dependencies: [ dependencies: [
'note.id', 'note.id',
'item.selected', 'item.selected',
'note.titleHtml', 'note.title',
'note.body', 'note.body',
], ],
@ -124,7 +124,7 @@ const registerSimpleLeftToRightRenderer = async() => {
<img class="thumbnail" src="file://{{thumbnailFilePath}}"/> <img class="thumbnail" src="file://{{thumbnailFilePath}}"/>
{{/thumbnailFilePath}} {{/thumbnailFilePath}}
{{^thumbnailFilePath}} {{^thumbnailFilePath}}
{{{note.titleHtml}}} {{{note.title}}}
{{/thumbnailFilePath}} {{/thumbnailFilePath}}
</div> </div>
`, `,

View File

@ -45,6 +45,8 @@ import restart from '../../services/restart';
const { connect } = require('react-redux'); const { connect } = require('react-redux');
import PromptDialog from '../PromptDialog'; import PromptDialog from '../PromptDialog';
import NotePropertiesDialog from '../NotePropertiesDialog'; import NotePropertiesDialog from '../NotePropertiesDialog';
import { NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType';
import validateColumns from '../NoteListHeader/utils/validateColumns';
import TrashNotification from '../TrashNotification/TrashNotification'; import TrashNotification from '../TrashNotification/TrashNotification';
const PluginManager = require('@joplin/lib/services/PluginManager'); const PluginManager = require('@joplin/lib/services/PluginManager');
@ -89,6 +91,9 @@ interface Props {
lastDeletionNotificationTime: number; lastDeletionNotificationTime: number;
selectedFolderId: string; selectedFolderId: string;
mustUpgradeAppMessage: string; mustUpgradeAppMessage: string;
notesSortOrderField: string;
notesSortOrderReverse: boolean;
notesColumns: NoteListColumns;
} }
interface ShareFolderDialogOptions { interface ShareFolderDialogOptions {
@ -737,6 +742,9 @@ class MainScreenComponent extends React.Component<Props, State> {
themeId={this.props.themeId} themeId={this.props.themeId}
listRendererId={this.props.listRendererId} listRendererId={this.props.listRendererId}
startupPluginsLoaded={this.props.startupPluginsLoaded} startupPluginsLoaded={this.props.startupPluginsLoaded}
notesSortOrderField={this.props.notesSortOrderField}
notesSortOrderReverse={this.props.notesSortOrderReverse}
columns={this.props.notesColumns}
selectedFolderId={this.props.selectedFolderId} selectedFolderId={this.props.selectedFolderId}
/>; />;
}, },
@ -934,6 +942,9 @@ const mapStateToProps = (state: AppState) => {
lastDeletionNotificationTime: state.lastDeletionNotificationTime, lastDeletionNotificationTime: state.lastDeletionNotificationTime,
selectedFolderId: state.selectedFolderId, selectedFolderId: state.selectedFolderId,
mustUpgradeAppMessage: state.mustUpgradeAppMessage, mustUpgradeAppMessage: state.mustUpgradeAppMessage,
notesSortOrderField: state.settings['notes.sortOrder.field'],
notesSortOrderReverse: state.settings['notes.sortOrder.reverse'],
notesColumns: validateColumns(state.settings['notes.columns']),
}; };
}; };

View File

@ -22,6 +22,7 @@ import usePrevious from '../hooks/usePrevious';
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly'; import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
import { FolderEntity } from '@joplin/lib/services/database/types'; import { FolderEntity } from '@joplin/lib/services/database/types';
import ItemChange from '@joplin/lib/models/ItemChange'; import ItemChange from '@joplin/lib/models/ItemChange';
import { registerGlobalDragEndEvent, unregisterGlobalDragEndEvent } from '../utils/dragAndDrop';
const commands = [ const commands = [
require('./commands/focusElementNoteList'), require('./commands/focusElementNoteList'),
@ -64,8 +65,6 @@ const NoteListComponent = (props: Props) => {
const noteListRef = useRef(null); const noteListRef = useRef(null);
const itemListRef = useRef(null); const itemListRef = useRef(null);
let globalDragEndEventRegistered_ = false;
const style = useMemo(() => { const style = useMemo(() => {
const theme = themeStyle(props.themeId); const theme = themeStyle(props.themeId);
@ -129,22 +128,6 @@ const NoteListComponent = (props: Props) => {
menu.popup({ window: bridge().window() }); menu.popup({ window: bridge().window() });
}, [props.selectedNoteIds, props.notes, props.dispatch, props.watchedNoteFiles, props.plugins, props.selectedFolderId, props.customCss]); }, [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) => { const dragTargetNoteIndex_ = (event: any) => {
return Math.abs(Math.round((event.clientY - itemListRef.current.offsetTop() + itemListRef.current.offsetScroll()) / itemHeight)); return Math.abs(Math.round((event.clientY - itemListRef.current.offsetTop() + itemListRef.current.offsetScroll()) / itemHeight));
}; };
@ -158,7 +141,7 @@ const NoteListComponent = (props: Props) => {
event.preventDefault(); event.preventDefault();
const newIndex = dragTargetNoteIndex_(event); const newIndex = dragTargetNoteIndex_(event);
if (dragOverTargetNoteIndex === newIndex) return; if (dragOverTargetNoteIndex === newIndex) return;
registerGlobalDragEndEvent_(); registerGlobalDragEndEvent(() => setDragOverTargetNoteIndex(null));
setDragOverTargetNoteIndex(newIndex); setDragOverTargetNoteIndex(newIndex);
} }
}; };
@ -185,7 +168,7 @@ const NoteListComponent = (props: Props) => {
return; return;
} }
const dt = event.dataTransfer; const dt = event.dataTransfer;
unregisterGlobalDragEndEvent_(); unregisterGlobalDragEndEvent();
setDragOverTargetNoteIndex(null); setDragOverTargetNoteIndex(null);
const targetNoteIndex = dragTargetNoteIndex_(event); const targetNoteIndex = dragTargetNoteIndex_(event);

View File

@ -213,6 +213,7 @@ const NoteList = (props: Props) => {
isWatched={props.watchedNoteFiles.includes(note.id)} isWatched={props.watchedNoteFiles.includes(note.id)}
listRenderer={listRenderer} listRenderer={listRenderer}
dispatch={props.dispatch} dispatch={props.dispatch}
columns={props.columns}
/>, />,
); );
} }

View File

@ -21,7 +21,7 @@
} }
.note-list-item { .note-list-item {
display: flex; display: flex;
} }
.note-list-item-wrapper { .note-list-item-wrapper {

View File

@ -1,5 +1,5 @@
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types'; 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 { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { Size } from '@joplin/utils/types'; import { Size } from '@joplin/utils/types';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
@ -29,6 +29,7 @@ export interface Props {
focusedField: string; focusedField: string;
parentFolderIsReadOnly: boolean; parentFolderIsReadOnly: boolean;
listRenderer: ListRenderer; listRenderer: ListRenderer;
columns: NoteListColumns;
selectedFolderInTrash: boolean; selectedFolderInTrash: boolean;
} }

View File

@ -85,11 +85,13 @@ const useDragAndDrop = (
} }
}, [notesParentType, dragTargetNoteIndex, dragOverTargetNoteIndex, selectedFolderInTrash]); }, [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 // TODO: check that parent type is folder
if (!canManuallySortNotes(notesParentType, noteSortOrder, selectedFolderInTrash)) return; if (!canManuallySortNotes(notesParentType, noteSortOrder, selectedFolderInTrash)) return;
const dt = event.dataTransfer;
setDragOverTargetNoteIndex(null); setDragOverTargetNoteIndex(null);
const targetNoteIndex = dragTargetNoteIndex(event); const targetNoteIndex = dragTargetNoteIndex(event);

View 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>
);
};

View File

@ -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>
);
};

View 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;
}
}

View File

@ -0,0 +1,5 @@
export interface OnItemClickEvent {
name: string;
}
export type OnItemClickHander = (event: OnItemClickEvent)=> void;

View File

@ -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);
});
});

View 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,
};
};

View File

@ -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;
};

View File

@ -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]);
};

View File

@ -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);
});
});

View File

@ -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;
};

View File

@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { useCallback, forwardRef, LegacyRef, ChangeEvent, CSSProperties, MouseEventHandler, DragEventHandler, useMemo, memo } 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 { Size } from '@joplin/utils/types';
import useRootElement from './utils/useRootElement'; import useRootElement from './utils/useRootElement';
import useItemElement from './utils/useItemElement'; import useItemElement from './utils/useItemElement';
@ -29,6 +29,7 @@ interface NoteItemProps {
isSelected: boolean; isSelected: boolean;
isWatched: boolean; isWatched: boolean;
listRenderer: ListRenderer; listRenderer: ListRenderer;
columns: NoteListColumns;
dispatch: Dispatch; dispatch: Dispatch;
} }
@ -63,7 +64,7 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
const rootElement = useRootElement(elementId); 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( const itemElement = useItemElement(
rootElement, rootElement,
@ -75,7 +76,7 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
props.flow, props.flow,
); );
useItemEventHandlers(rootElement, itemElement, onInputChange); useItemEventHandlers(rootElement, itemElement, onInputChange, null);
const className = useMemo(() => { const className = useMemo(() => {
return [ return [

View File

@ -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' }],
},
});
});
});

View File

@ -1,24 +1,37 @@
import { ListRendererDependency } from '@joplin/lib/services/plugins/api/noteListType'; 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 { Size } from '@joplin/utils/types';
import Note from '@joplin/lib/models/Note'; 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 = {}; const output: any = {};
for (const dep of dependencies) { for (const dep of dependencies) {
if (dep.startsWith('note.')) { if (dep.startsWith('note.')) {
const splitted = dep.split('.'); 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(); const propName = splitted.pop();
if (!output.note) output.note = {}; if (!output.note) output.note = {};
if (dep === 'note.titleHtml') { if (dep === 'note.titleHtml') { // For backward compatibility
output.note.titleHtml = noteTitleHtml; output.note.titleHtml = noteTitleHtml;
} else if (dep === 'note.isWatched') { } else if (dep === 'note.isWatched') {
output.note.isWatched = noteIsWatched; output.note[propName] = noteIsWatched;
} else if (dep === 'note.tags') { } 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 { } else {
// The notes in the state only contain the properties defined in // The notes in the state only contain the properties defined in
// Note.previewFields(). It means that if a view request a // Note.previewFields(). It means that if a view request a

View File

@ -1,3 +1,4 @@
import * as React from 'react'; import * as React from 'react';
export type OnInputChange = (event: React.ChangeEvent<HTMLInputElement>)=> void; export type OnInputChange = (event: React.ChangeEvent<HTMLInputElement>)=> void;
export type OnClick = (event: React.MouseEvent<HTMLElement>)=> void;

View File

@ -1,37 +1,51 @@
import { OnInputChange } from './types'; import { OnClick, OnInputChange } from './types';
import { useEffect } from 'react'; import { useEffect } from 'react';
const useItemEventHandlers = (rootElement: HTMLDivElement, itemElement: HTMLDivElement, onInputChange: OnInputChange) => { const useItemEventHandlers = (rootElement: HTMLDivElement, itemElement: HTMLDivElement, onInputChange: OnInputChange, onClick: OnClick) => {
useEffect(() => { useEffect(() => {
if (!itemElement) return () => {}; if (!itemElement) return () => {};
const inputs = itemElement.getElementsByTagName('input'); const inputs = itemElement.getElementsByTagName('input');
const checkboxes: HTMLInputElement[] = []; const processedCheckboxes: HTMLInputElement[] = [];
const textInputs: HTMLInputElement[] = []; const processedTextInputs: HTMLInputElement[] = [];
for (const input of inputs) { for (const input of inputs) {
if (input.type === 'checkbox') { if (input.type === 'checkbox') {
input.addEventListener('change', onInputChange as any); input.addEventListener('change', onInputChange as any);
checkboxes.push(input); processedCheckboxes.push(input);
} }
if (input.type === 'text') { if (input.type === 'text') {
input.addEventListener('change', onInputChange as any); 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 () => { return () => {
for (const input of checkboxes) { for (const input of processedCheckboxes) {
input.removeEventListener('change', onInputChange as any); input.removeEventListener('change', onInputChange as any);
} }
for (const input of textInputs) { for (const input of processedTextInputs) {
input.removeEventListener('change', onInputChange as any); 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; export default useItemEventHandlers;

View File

@ -1,13 +1,16 @@
import { useState } from 'react'; 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 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 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 { createHash } from 'crypto';
import getNoteTitleHtml from './getNoteTitleHtml'; import getNoteTitleHtml from './getNoteTitleHtml';
import prepareViewProps from './prepareViewProps'; import prepareViewProps from './prepareViewProps';
import * as Mustache from 'mustache';
import Tag from '@joplin/lib/models/Tag'; import Tag from '@joplin/lib/models/Tag';
import { unique } from '@joplin/lib/array';
import Folder from '@joplin/lib/models/Folder';
interface RenderedNote { interface RenderedNote {
id: string; id: string;
@ -19,17 +22,26 @@ const hashContent = (content: any) => {
return createHash('sha1').update(JSON.stringify(content)).digest('hex'); 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); 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) => { useAsyncEffect(async (event) => {
const renderNote = async (): Promise<void> => { const renderNote = async (): Promise<void> => {
let noteTags: TagEntity[] = []; 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'] }); 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 // Note: with this hash we're assuming that the list renderer
// properties never changes. It means that later if we support // properties never changes. It means that later if we support
// dynamic list renderers, we should include these into the hash. // dynamic list renderers, we should include these into the hash.
@ -40,22 +52,24 @@ export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, listR
isWatched, isWatched,
highlightedWords, highlightedWords,
note.encryption_applied, note.encryption_applied,
JSON.stringify(columns),
noteTags.map(t => t.title).sort().join(','), noteTags.map(t => t.title).sort().join(','),
folder ? folder.title : '',
]); ]);
if (renderedNote && renderedNote.hash === viewHash) return null; 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( const viewProps = await prepareViewProps(
listRenderer.dependencies, dependencies,
note, note,
listRenderer.itemSize, listRenderer.itemSize,
isSelected, isSelected,
titleHtml, noteTitleHtml,
isWatched, isWatched,
noteTags, noteTags,
folder,
itemIndex, itemIndex,
); );
@ -65,15 +79,24 @@ export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, listR
if (event.cancelled) return null; if (event.cancelled) return null;
await renderViewProps(view, [], { noteTitleHtml });
if (event.cancelled) return null;
setRenderedNote({ setRenderedNote({
id: note.id, id: note.id,
hash: viewHash, hash: viewHash,
html: Mustache.render(listRenderer.itemTemplate, view), html: renderTemplate(
columns,
listRenderer.itemTemplate,
listRenderer.itemValueTemplates,
view,
),
}); });
}; };
void renderNote(); void renderNote();
}, [note, isSelected, isWatched, listRenderer, renderedNote]); }, [note, isSelected, isWatched, listRenderer, renderedNote, columns]);
return renderedNote; return renderedNote;
}; };

View File

@ -7,9 +7,14 @@ import { Size } from '../ResizableLayout/utils/types';
import styled from 'styled-components'; import styled from 'styled-components';
import { getDefaultListRenderer, getListRendererById } from '@joplin/lib/services/noteList/renderers'; import { getDefaultListRenderer, getListRendererById } from '@joplin/lib/services/noteList/renderers';
import Logger from '@joplin/utils/Logger'; import Logger from '@joplin/utils/Logger';
import NoteListHeader from '../NoteListHeader/NoteListHeader';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import { BaseBreakpoint, Breakpoints } from '../NoteList/utils/types'; import { BaseBreakpoint, Breakpoints } from '../NoteList/utils/types';
import { ButtonSize, buttonSizePx } from '../Button/Button'; 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'; import { getTrashFolderId } from '@joplin/lib/services/trash';
const logger = Logger.create('NoteListWrapper'); const logger = Logger.create('NoteListWrapper');
@ -21,6 +26,9 @@ interface Props {
themeId: number; themeId: number;
listRendererId: string; listRendererId: string;
startupPluginsLoaded: boolean; startupPluginsLoaded: boolean;
notesSortOrderField: string;
notesSortOrderReverse: boolean;
columns: NoteListColumns;
selectedFolderId: string; selectedFolderId: string;
} }
@ -99,6 +107,8 @@ export default function NoteListWrapper(props: Props) {
const [controlHeight] = useState(theme.topRowHeight); const [controlHeight] = useState(theme.topRowHeight);
const listRenderer = useListRenderer(props.listRendererId, props.startupPluginsLoaded); const listRenderer = useListRenderer(props.listRendererId, props.startupPluginsLoaded);
const newNoteButtonRef = useRef(null); 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); const { breakpoint, dynamicBreakpoints, lineCount } = useNoteListControlsBreakpoints(props.size.width, newNoteButtonRef, props.selectedFolderId);
@ -117,9 +127,38 @@ export default function NoteListWrapper(props: Props) {
const noteListSize = useMemo(() => { const noteListSize = useMemo(() => {
return { return {
width: props.size.width, 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 = () => { const renderNoteList = () => {
if (!listRenderer) return null; if (!listRenderer) return null;
@ -128,6 +167,7 @@ export default function NoteListWrapper(props: Props) {
resizableLayoutEventEmitter={props.resizableLayoutEventEmitter} resizableLayoutEventEmitter={props.resizableLayoutEventEmitter}
size={noteListSize} size={noteListSize}
visible={props.visible} visible={props.visible}
columns={columns}
/>; />;
}; };
@ -144,6 +184,7 @@ export default function NoteListWrapper(props: Props) {
padding={noteListControlsPadding} padding={noteListControlsPadding}
buttonVerticalGap={noteListControlsButtonVerticalGap} buttonVerticalGap={noteListControlsButtonVerticalGap}
/> />
{renderHeader()}
{renderNoteList()} {renderNoteList()}
</StyledRoot> </StyledRoot>
); );

View 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);
};

View File

@ -6,5 +6,6 @@
@use 'gui/Dropdown/style.scss' as dropdown-control; @use 'gui/Dropdown/style.scss' as dropdown-control;
@use 'gui/ShareFolderDialog/style.scss' as share-folder-dialog; @use 'gui/ShareFolderDialog/style.scss' as share-folder-dialog;
@use 'gui/NoteList/style.scss' as note-list; @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 'gui/TrashNotification/style.scss' as trash-notification;
@use 'main.scss' as main; @use 'main.scss' as main;

View File

@ -6,7 +6,7 @@
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
TEMP_PATH=~/src/plugin-tests TEMP_PATH=~/src/plugin-tests
NEED_COMPILING=1 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 if [[ $NEED_COMPILING == 1 ]]; then
mkdir -p "$TEMP_PATH" mkdir -p "$TEMP_PATH"

View File

@ -10,6 +10,7 @@ import Logger from '@joplin/utils/Logger';
import mergeGlobalAndLocalSettings from '../services/profileConfig/mergeGlobalAndLocalSettings'; import mergeGlobalAndLocalSettings from '../services/profileConfig/mergeGlobalAndLocalSettings';
import splitGlobalAndLocalSettings from '../services/profileConfig/splitGlobalAndLocalSettings'; import splitGlobalAndLocalSettings from '../services/profileConfig/splitGlobalAndLocalSettings';
import JoplinError from '../JoplinError'; import JoplinError from '../JoplinError';
import { defaultListColumns } from '../services/plugins/api/noteListType';
const { sprintf } = require('sprintf-js'); const { sprintf } = require('sprintf-js');
const ObjectUtils = require('../ObjectUtils'); const ObjectUtils = require('../ObjectUtils');
const { toTitleCase } = require('../string-utils.js'); const { toTitleCase } = require('../string-utils.js');
@ -964,6 +965,14 @@ class Setting extends BaseModel {
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true, 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] }, '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, // NOTE: A setting whose name starts with 'notes.sortOrder' is special,
// which implies changing the setting automatically triggers the refresh of notes. // which implies changing the setting automatically triggers the refresh of notes.

View File

@ -20,6 +20,7 @@
"@types/jest": "29.5.8", "@types/jest": "29.5.8",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/markdown-it": "13.0.7", "@types/markdown-it": "13.0.7",
"@types/mustache": "4.2.5",
"@types/node": "18.19.8", "@types/node": "18.19.8",
"@types/node-rsa": "1.1.4", "@types/node-rsa": "1.1.4",
"@types/react": "18.2.48", "@types/react": "18.2.48",

View File

@ -40,7 +40,7 @@ const defaultLeftToRightItemRenderer: ListRenderer = {
'note.is_shared', 'note.is_shared',
'note.is_todo', 'note.is_todo',
'note.isWatched', 'note.isWatched',
'note.titleHtml', 'note.title',
'note.todo_completed', '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}}> <input class="checkbox" data-id="todo-checkbox" type="checkbox" {{#note.todo_completed}}checked="checked"{{/note.todo_completed}}>
{{/note.is_todo}} {{/note.is_todo}}
<i class="watchedicon fa fa-share-square"></i> <i class="watchedicon fa fa-share-square"></i>
<div class="titlecontent">{{{note.titleHtml}}}</div> <div class="titlecontent">{{note.title}}</div>
</div> </div>
<div class="preview">{{notePreview}}</div> <div class="preview">{{notePreview}}</div>
</div> </div>

View File

@ -1,5 +1,6 @@
import { _ } from '../../locale'; import { _ } from '../../locale';
import { ItemFlow, ListRenderer } from '../plugins/api/noteListType'; import CommandService from '../CommandService';
import { ItemFlow, ListRenderer, OnClickEvent } from '../plugins/api/noteListType';
interface Props { interface Props {
note: { note: {
@ -17,7 +18,7 @@ interface Props {
}; };
} }
const defaultListRenderer: ListRenderer = { const renderer: ListRenderer = {
id: 'compact', id: 'compact',
label: async () => _('Compact'), label: async () => _('Compact'),
@ -37,7 +38,7 @@ const defaultListRenderer: ListRenderer = {
'note.is_shared', 'note.is_shared',
'note.is_todo', 'note.is_todo',
'note.isWatched', 'note.isWatched',
'note.titleHtml', 'note.title',
'note.todo_completed', '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 itemTemplate: // html
` `
<div class="content {{#item.selected}}-selected{{/item.selected}} {{#note.is_shared}}-shared{{/note.is_shared}} {{#note.todo_completed}}-completed{{/note.todo_completed}} {{#note.isWatched}}-watched{{/note.isWatched}}"> <div 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}}">
@ -127,7 +138,7 @@ const defaultListRenderer: ListRenderer = {
{{/note.is_todo}} {{/note.is_todo}}
<div class="title" data-id="{{note.id}}"> <div class="title" data-id="{{note.id}}">
<i class="watchedicon fa fa-share-square"></i> <i class="watchedicon fa fa-share-square"></i>
<span>{{{note.titleHtml}}}</span> <span>{{note.title}}</span>
</div> </div>
</div> </div>
`, `,
@ -137,4 +148,4 @@ const defaultListRenderer: ListRenderer = {
}, },
}; };
export default defaultListRenderer; export default renderer;

View 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;

View 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;
};

View 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('[ ]');
}
});
});

View 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);
};

View 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&amp;M&apos;s&reg;',
});
expect(view).toEqual({
note: {
title: 'M&amp;M&apos;s&reg;',
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,
},
});
});
});

View 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;

View File

@ -1,10 +1,12 @@
import { ListRenderer } from '../plugins/api/noteListType'; import { ListRenderer } from '../plugins/api/noteListType';
// import defaultLeftToRightItemRenderer from '../noteList/defaultLeftToRightListRenderer'; // import defaultLeftToRightItemRenderer from '../noteList/defaultLeftToRightListRenderer';
import defaultListRenderer from '../noteList/defaultListRenderer'; import defaultListRenderer from '../noteList/defaultListRenderer';
import defaultMultiColumnsRenderer from '../noteList/defaultMultiColumnsRenderer';
import { Store } from 'redux'; import { Store } from 'redux';
const renderers_: ListRenderer[] = [ const renderers_: ListRenderer[] = [
defaultListRenderer, defaultListRenderer,
defaultMultiColumnsRenderer,
// defaultLeftToRightItemRenderer, // defaultLeftToRightItemRenderer,
]; ];

View File

@ -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) * * [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: * ## Screenshots:
* *

View File

@ -19,38 +19,52 @@ export interface OnChangeEvent {
noteId: string; noteId: string;
} }
export interface OnClickEvent {
elementId: string;
}
export type OnRenderNoteHandler = (props: any)=> Promise<RenderNoteView>; export type OnRenderNoteHandler = (props: any)=> Promise<RenderNoteView>;
export type OnChangeHandler = (event: OnChangeEvent)=> Promise<void>; 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`, * Most of these are the built-in note properties, such as `note.title`, `note.todo_completed`, etc.
* `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 * ## Item properties
* most important being `item.selected`, which you can use to display the
* selected note in a different way.
* *
* Finally some special properties are provided to make it easier to render * The `item.*` properties are specific to the rendered item. The most important being
* notes. In particular, if possible prefer `note.titleHtml` to `note.title` * `item.selected`, which you can use to display the selected note in a different way.
* 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.
*/ */
export type ListRendererDependency = export type ListRendererDependency =
ListRendererDatabaseDependency | ListRendererDatabaseDependency |
'item.index' | 'item.index' |
'item.size.width' |
'item.size.height' |
'item.selected' | 'item.selected' |
'note.titleHtml' | 'item.size.height' |
'item.size.width' |
'note.folder.title' |
'note.isWatched' | '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 { export interface ListRenderer {
/** /**
@ -65,6 +79,12 @@ export interface ListRenderer {
*/ */
flow: ItemFlow; 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 * 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 * 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 * that you do not add more than what you need since there is a performance
* penalty for each property. * 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 property is set differently depending on the `multiColumns` property.
* 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}}`.
* *
* In order to get syntax highlighting working here, it's recommended * ## If `multiColumns` is `false`
* installing an editor extension such as [es6-string-html VSCode *
* 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) * 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; 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 * This user-facing text is used for example in the View menu, so that your
* renderer can be selected. * renderer can be selected.
@ -155,17 +254,15 @@ export interface ListRenderer {
onRenderNote: OnRenderNoteHandler; onRenderNote: OnRenderNoteHandler;
/** /**
* This handler allows adding some interactivity to the note renderer - * This handler allows adding some interactivity to the note renderer - whenever an input element
* whenever an input element within the item is changed (for example, when a * within the item is changed (for example, when a checkbox is clicked, or a text input is
* checkbox is clicked, or a text input is changed), this `onChange` handler * changed), this `onChange` handler is going to be called.
* is going to be called.
* *
* You can inspect `event.elementId` to know which element had some changes, * You can inspect `event.elementId` to know which element had some changes, and `event.value`
* and `event.value` to know the new value. `event.noteId` also tells you * to know the new value. `event.noteId` also tells you what note is affected, so that you can
* what note is affected, so that you can potentially apply changes to it. * potentially apply changes to it.
* *
* You specify the element ID, by setting a `data-id` attribute on the * You specify the element ID, by setting a `data-id` attribute on the input.
* input.
* *
* For example, if you have such a template: * For example, if you have such a template:
* *
@ -175,8 +272,46 @@ export interface ListRenderer {
* </div> * </div>
* ``` * ```
* *
* The event handler will receive an event with `elementId` set to * The event handler will receive an event with `elementId` set to `noteTitleInput`.
* `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; 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;
};

View File

@ -151,6 +151,9 @@ export function addExtraStyles(style: any) {
style.configScreenPadding = style.mainPadding * 2; style.configScreenPadding = style.mainPadding * 2;
style.noteListHeaderHeight = 26;
style.noteListHeaderBorderPadding = 4;
style.icon = { style.icon = {
...style.icon, ...style.icon,
color: style.color, color: style.color,

View File

@ -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'); return moment.unix(ms / 1000).format('DD/MM/YYYY HH:mm');
} }

View File

@ -88,5 +88,6 @@ firstname
lastname lastname
signup signup
activatable activatable
titlewrapper
notyf notyf
Notyf Notyf

9
packages/utils/dom.ts Normal file
View 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;
};

View 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);
});
});

View File

@ -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[]) { export function checkObjectHasProperties(object: any, properties: string[]) {
for (const prop of properties) { for (const prop of properties) {

View File

@ -5,6 +5,7 @@
"repository": "https://github.com/laurent22/joplin/tree/dev/packages/utils", "repository": "https://github.com/laurent22/joplin/tree/dev/packages/utils",
"exports": { "exports": {
".": "./dist/index.js", ".": "./dist/index.js",
"./dom": "./dist/dom.js",
"./env": "./dist/env.js", "./env": "./dist/env.js",
"./object": "./dist/object.js", "./object": "./dist/object.js",
"./fs": "./dist/fs.js", "./fs": "./dist/fs.js",

View File

@ -6845,6 +6845,7 @@ __metadata:
"@types/jest": 29.5.8 "@types/jest": 29.5.8
"@types/js-yaml": 4.0.9 "@types/js-yaml": 4.0.9
"@types/markdown-it": 13.0.7 "@types/markdown-it": 13.0.7
"@types/mustache": 4.2.5
"@types/nanoid": 3.0.0 "@types/nanoid": 3.0.0
"@types/node": 18.19.8 "@types/node": 18.19.8
"@types/node-rsa": 1.1.4 "@types/node-rsa": 1.1.4