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/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
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/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
|
||||||
|
@ -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>
|
||||||
`,
|
`,
|
||||||
|
@ -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']),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.note-list-item {
|
.note-list-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-list-item-wrapper {
|
.note-list-item-wrapper {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
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 * 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 [
|
||||||
|
@ -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 { 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
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
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/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;
|
@ -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"
|
||||||
|
@ -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.
|
||||||
|
@ -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",
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
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 { 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,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -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:
|
||||||
*
|
*
|
||||||
|
@ -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;
|
||||||
|
};
|
||||||
|
@ -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,
|
||||||
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,5 +88,6 @@ firstname
|
|||||||
lastname
|
lastname
|
||||||
signup
|
signup
|
||||||
activatable
|
activatable
|
||||||
|
titlewrapper
|
||||||
notyf
|
notyf
|
||||||
Notyf
|
Notyf
|
9
packages/utils/dom.ts
Normal file
9
packages/utils/dom.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
|
|
||||||
|
export const findParentElementByClassName = (element: any, parentClassName: string) => {
|
||||||
|
while (element) {
|
||||||
|
if (element.classList.contains(parentClassName)) return element;
|
||||||
|
element = element.parentElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
31
packages/utils/object.test.ts
Normal file
31
packages/utils/object.test.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { objectValueFromPath } from './object';
|
||||||
|
|
||||||
|
describe('object', () => {
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[
|
||||||
|
{
|
||||||
|
note: {
|
||||||
|
id: '123',
|
||||||
|
title: 'my note',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'note.title',
|
||||||
|
'my note',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
note: {
|
||||||
|
id: '123',
|
||||||
|
title: 'my note',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'note.doesntexist',
|
||||||
|
undefined,
|
||||||
|
],
|
||||||
|
])('should extract URLs', (object, path, expected) => {
|
||||||
|
const actual = objectValueFromPath(object, path);
|
||||||
|
expect(actual).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
@ -1,4 +1,12 @@
|
|||||||
/* eslint-disable import/prefer-default-export */
|
export const objectValueFromPath = (o: any, path: string) => {
|
||||||
|
const elements = path.split('.');
|
||||||
|
let result = { ...o };
|
||||||
|
while (elements.length && result) {
|
||||||
|
const e = elements.splice(0, 1)[0];
|
||||||
|
result = result[e];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
export function checkObjectHasProperties(object: any, properties: string[]) {
|
export function checkObjectHasProperties(object: any, properties: string[]) {
|
||||||
for (const prop of properties) {
|
for (const prop of properties) {
|
||||||
|
@ -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",
|
||||||
|
@ -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
|
||||||
|
Reference in New Issue
Block a user