2023-08-06 17:21:09 +01:00
|
|
|
import * as React from 'react';
|
2023-08-11 18:09:12 +01:00
|
|
|
import { useMemo, useState, useCallback, memo, useEffect } from 'react';
|
2023-08-06 17:21:09 +01:00
|
|
|
import { AppState } from '../../app.reducer';
|
|
|
|
|
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
|
|
|
|
const { connect } = require('react-redux');
|
2023-08-11 18:09:12 +01:00
|
|
|
import { ItemFlow, ListRenderer, ListRendererDepependency, OnChangeHandler, Props } from './types';
|
2023-08-06 17:21:09 +01:00
|
|
|
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
|
|
|
|
|
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
|
|
|
|
|
import ItemChange from '@joplin/lib/models/ItemChange';
|
|
|
|
|
import { Size } from '@joplin/utils/types';
|
|
|
|
|
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
2023-08-11 11:12:13 +01:00
|
|
|
import defaultListRenderer from './defaultListRenderer';
|
2023-08-11 10:57:27 +01:00
|
|
|
import * as Mustache from 'mustache';
|
2023-08-11 18:09:12 +01:00
|
|
|
import { waitForElement } from '@joplin/lib/dom';
|
|
|
|
|
import { msleep } from '@joplin/utils/time';
|
2023-08-06 17:21:09 +01:00
|
|
|
|
|
|
|
|
interface RenderedNote {
|
|
|
|
|
id: string;
|
|
|
|
|
html: string;
|
|
|
|
|
}
|
|
|
|
|
|
2023-08-11 18:09:12 +01:00
|
|
|
const useRenderedNotes = (notes: NoteEntity[], selectedNoteIds: string[], itemSize: Size, listRenderer: ListRenderer) => {
|
2023-08-06 17:21:09 +01:00
|
|
|
const initialValue = notes.map(n => {
|
|
|
|
|
return {
|
|
|
|
|
id: n.id,
|
|
|
|
|
html: '',
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const [renderedNotes, setRenderedNotes] = useState<RenderedNote[]>(initialValue);
|
|
|
|
|
|
2023-08-11 11:12:13 +01:00
|
|
|
const prepareViewProps = async (dependencies: ListRendererDepependency[], note: NoteEntity, itemSize: Size, selected: boolean) => {
|
2023-08-11 10:57:27 +01:00
|
|
|
const output: any = {};
|
|
|
|
|
for (const dep of dependencies) {
|
|
|
|
|
|
|
|
|
|
if (dep.startsWith('note.')) {
|
|
|
|
|
const splitted = dep.split('.');
|
|
|
|
|
if (splitted.length !== 2) throw new Error(`Invalid dependency name: ${dep}`);
|
|
|
|
|
const propName = splitted.pop();
|
|
|
|
|
if (!output.note) output.note = {};
|
|
|
|
|
if (!(propName in note)) throw new Error(`Invalid dependency name: ${dep}`);
|
|
|
|
|
output.note[propName] = (note as any)[propName];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (dep.startsWith('item.size.')) {
|
|
|
|
|
const splitted = dep.split('.');
|
|
|
|
|
if (splitted.length !== 3) throw new Error(`Invalid dependency name: ${dep}`);
|
|
|
|
|
const propName = splitted.pop();
|
|
|
|
|
if (!output.item) output.item = {};
|
|
|
|
|
if (!output.item.size) output.item.size = {};
|
|
|
|
|
if (!(propName in itemSize)) throw new Error(`Invalid dependency name: ${dep}`);
|
|
|
|
|
output.item.size[propName] = (itemSize as any)[propName];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (dep === 'item.selected') {
|
|
|
|
|
if (!output.item) output.item = {};
|
|
|
|
|
output.item.selected = selected;
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-08-06 17:21:09 +01:00
|
|
|
|
2023-08-11 10:57:27 +01:00
|
|
|
return output;
|
|
|
|
|
};
|
2023-08-06 17:21:09 +01:00
|
|
|
|
2023-08-11 10:57:27 +01:00
|
|
|
useAsyncEffect(async (event) => {
|
|
|
|
|
const newRenderedNotes: RenderedNote[] = [];
|
2023-08-06 17:21:09 +01:00
|
|
|
|
|
|
|
|
for (const note of notes) {
|
2023-08-11 18:09:12 +01:00
|
|
|
const view = await listRenderer.onRenderNote(await prepareViewProps(
|
|
|
|
|
listRenderer.dependencies,
|
2023-08-11 10:57:27 +01:00
|
|
|
note,
|
|
|
|
|
itemSize,
|
|
|
|
|
selectedNoteIds.includes(note.id)
|
|
|
|
|
));
|
2023-08-06 17:21:09 +01:00
|
|
|
|
|
|
|
|
newRenderedNotes.push({
|
|
|
|
|
id: note.id,
|
2023-08-11 18:09:12 +01:00
|
|
|
html: Mustache.render(listRenderer.itemTemplate, view),
|
2023-08-06 17:21:09 +01:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.cancelled) return null;
|
|
|
|
|
|
|
|
|
|
setRenderedNotes(newRenderedNotes);
|
|
|
|
|
}, [notes, selectedNoteIds, itemSize]);
|
|
|
|
|
|
|
|
|
|
return renderedNotes;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface NoteItemProps {
|
|
|
|
|
onClick: React.MouseEventHandler<HTMLDivElement>;
|
2023-08-11 18:09:12 +01:00
|
|
|
onChange: OnChangeHandler;
|
2023-08-06 17:21:09 +01:00
|
|
|
noteId: string;
|
|
|
|
|
noteHtml: string;
|
|
|
|
|
style: React.CSSProperties;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const NoteItem = memo((props: NoteItemProps) => {
|
2023-08-11 18:09:12 +01:00
|
|
|
const [rootElement, setRootElement] = useState<HTMLDivElement>(null);
|
|
|
|
|
const [itemElement, setItemElement] = useState<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
const elementId = `list-note-${props.noteId}`;
|
|
|
|
|
const idPrefix = 'user-note-list-item-';
|
|
|
|
|
|
|
|
|
|
const onCheckboxChange = useCallback((event: any) => {
|
|
|
|
|
const internalId: string = event.currentTarget.getAttribute('id');
|
|
|
|
|
const userId = internalId.substring(idPrefix.length);
|
|
|
|
|
void props.onChange({ noteId: props.noteId }, userId, { value: event.currentTarget.checked });
|
|
|
|
|
}, [props.onChange, props.noteId]);
|
|
|
|
|
|
|
|
|
|
useAsyncEffect(async (event) => {
|
|
|
|
|
const element = await waitForElement(document, elementId);
|
|
|
|
|
if (event.cancelled) return;
|
|
|
|
|
setRootElement(element);
|
|
|
|
|
}, [document, elementId]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!rootElement) return () => {};
|
|
|
|
|
|
|
|
|
|
const element = document.createElement('div');
|
|
|
|
|
element.setAttribute('data-note-id', props.noteId);
|
|
|
|
|
element.className = 'note-list-item';
|
|
|
|
|
for (const [n, v] of Object.entries(props.style)) {
|
|
|
|
|
(element.style as any)[n] = v;
|
|
|
|
|
}
|
|
|
|
|
element.innerHTML = props.noteHtml;
|
|
|
|
|
element.addEventListener('click', props.onClick as any);
|
|
|
|
|
|
|
|
|
|
rootElement.appendChild(element);
|
|
|
|
|
|
|
|
|
|
setItemElement(element);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
// TODO: do event handlers need to be removed if the element is removed?
|
|
|
|
|
// element.removeEventListener('click', props.onClick as any);
|
|
|
|
|
element.remove();
|
|
|
|
|
};
|
|
|
|
|
}, [rootElement, props.noteHtml, props.noteId, props.style, props.onClick, onCheckboxChange]);
|
|
|
|
|
|
|
|
|
|
useAsyncEffect(async (event) => {
|
|
|
|
|
if (!itemElement) return;
|
|
|
|
|
|
|
|
|
|
await msleep(1);
|
|
|
|
|
if (event.cancelled) return;
|
|
|
|
|
|
|
|
|
|
const inputs = itemElement.getElementsByTagName('input');
|
|
|
|
|
|
|
|
|
|
const all = rootElement.getElementsByTagName('*');
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < all.length; i++) {
|
|
|
|
|
const e = all[i];
|
|
|
|
|
if (e.getAttribute('id')) {
|
|
|
|
|
e.setAttribute('id', idPrefix + e.getAttribute('id'));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const input of inputs) {
|
|
|
|
|
if (input.type === 'checkbox') {
|
|
|
|
|
input.addEventListener('change', onCheckboxChange);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [itemElement]);
|
|
|
|
|
|
|
|
|
|
return <div id={elementId}></div>;
|
2023-08-06 17:21:09 +01:00
|
|
|
});
|
|
|
|
|
|
2023-08-11 10:57:27 +01:00
|
|
|
const NoteList = (props: Props) => {
|
2023-08-11 18:09:12 +01:00
|
|
|
const listRenderer = defaultListRenderer;
|
|
|
|
|
|
|
|
|
|
if (listRenderer.flow !== ItemFlow.TopToBottom) throw new Error('Not implemented');
|
2023-08-06 17:21:09 +01:00
|
|
|
|
|
|
|
|
const itemSize: Size = useMemo(() => {
|
2023-08-11 18:09:12 +01:00
|
|
|
return listRenderer.itemSize;
|
|
|
|
|
}, [listRenderer.itemSize]);
|
2023-08-06 17:21:09 +01:00
|
|
|
|
2023-08-11 18:09:12 +01:00
|
|
|
const renderedNotes = useRenderedNotes(props.notes, props.selectedNoteIds, itemSize, listRenderer);
|
2023-08-06 17:21:09 +01:00
|
|
|
|
|
|
|
|
const noteItemStyle = useMemo(() => {
|
|
|
|
|
return {
|
|
|
|
|
width: 'auto',
|
|
|
|
|
height: itemSize.height,
|
|
|
|
|
};
|
|
|
|
|
}, [itemSize.height]);
|
|
|
|
|
|
|
|
|
|
const noteListStyle = useMemo(() => {
|
|
|
|
|
return {
|
|
|
|
|
width: props.size.width,
|
|
|
|
|
height: props.size.height,
|
|
|
|
|
};
|
|
|
|
|
}, [props.size]);
|
|
|
|
|
|
|
|
|
|
const onNoteClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
|
|
|
|
const noteId = event.currentTarget.getAttribute('data-note-id');
|
|
|
|
|
|
|
|
|
|
if (event.ctrlKey || event.metaKey) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
props.dispatch({
|
|
|
|
|
type: 'NOTE_SELECT_TOGGLE',
|
|
|
|
|
id: noteId,
|
|
|
|
|
});
|
|
|
|
|
} else if (event.shiftKey) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
props.dispatch({
|
|
|
|
|
type: 'NOTE_SELECT_EXTEND',
|
|
|
|
|
id: noteId,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
props.dispatch({
|
|
|
|
|
type: 'NOTE_SELECT',
|
|
|
|
|
id: noteId,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}, [props.dispatch]);
|
|
|
|
|
|
2023-08-11 18:09:12 +01:00
|
|
|
useEffect(() => {
|
|
|
|
|
const element = document.createElement('style');
|
|
|
|
|
element.setAttribute('type', 'text/css');
|
|
|
|
|
element.appendChild(document.createTextNode(`
|
|
|
|
|
.note-list-item {
|
|
|
|
|
${listRenderer.itemCss};
|
|
|
|
|
}
|
|
|
|
|
`));
|
|
|
|
|
document.head.appendChild(element);
|
|
|
|
|
return () => {
|
|
|
|
|
element.remove();
|
|
|
|
|
};
|
|
|
|
|
}, [listRenderer.itemCss]);
|
|
|
|
|
|
2023-08-06 17:21:09 +01:00
|
|
|
const renderNotes = () => {
|
|
|
|
|
const output: JSX.Element[] = [];
|
|
|
|
|
|
|
|
|
|
for (const renderedNote of renderedNotes) {
|
|
|
|
|
output.push(
|
|
|
|
|
<NoteItem
|
|
|
|
|
key={renderedNote.id}
|
|
|
|
|
onClick={onNoteClick}
|
2023-08-11 18:09:12 +01:00
|
|
|
onChange={listRenderer.onChange}
|
2023-08-06 17:21:09 +01:00
|
|
|
noteId={renderedNote.id}
|
|
|
|
|
noteHtml={renderedNote.html}
|
|
|
|
|
style={noteItemStyle}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return output;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="note-list" style={noteListStyle}>
|
|
|
|
|
{renderNotes()}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const mapStateToProps = (state: AppState) => {
|
|
|
|
|
const selectedFolder: FolderEntity = state.notesParentType === 'Folder' ? BaseModel.byId(state.folders, state.selectedFolderId) : null;
|
|
|
|
|
const userId = state.settings['sync.userId'];
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
notes: state.notes,
|
|
|
|
|
folders: state.folders,
|
|
|
|
|
selectedNoteIds: state.selectedNoteIds,
|
|
|
|
|
selectedFolderId: state.selectedFolderId,
|
|
|
|
|
themeId: state.settings.theme,
|
|
|
|
|
notesParentType: state.notesParentType,
|
|
|
|
|
searches: state.searches,
|
|
|
|
|
selectedSearchId: state.selectedSearchId,
|
|
|
|
|
watchedNoteFiles: state.watchedNoteFiles,
|
|
|
|
|
provisionalNoteIds: state.provisionalNoteIds,
|
|
|
|
|
isInsertingNotes: state.isInsertingNotes,
|
|
|
|
|
noteSortOrder: state.settings['notes.sortOrder.field'],
|
|
|
|
|
uncompletedTodosOnTop: state.settings.uncompletedTodosOnTop,
|
|
|
|
|
showCompletedTodos: state.settings.showCompletedTodos,
|
|
|
|
|
highlightedWords: state.highlightedWords,
|
|
|
|
|
plugins: state.pluginService.plugins,
|
|
|
|
|
customCss: state.customCss,
|
|
|
|
|
focusedField: state.focusedField,
|
|
|
|
|
parentFolderIsReadOnly: state.notesParentType === 'Folder' && selectedFolder ? itemIsReadOnlySync(ModelType.Folder, ItemChange.SOURCE_UNSPECIFIED, selectedFolder as ItemSlice, userId, state.shareService) : false,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2023-08-11 10:57:27 +01:00
|
|
|
export default connect(mapStateToProps)(NoteList);
|