You've already forked joplin
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:
@ -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 {
|
||||
|
@ -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}}">
|
||||
@ -127,7 +138,7 @@ const defaultListRenderer: ListRenderer = {
|
||||
{{/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
|
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