import * as React from 'react'; import { useCallback, forwardRef, LegacyRef, ChangeEvent, CSSProperties, MouseEventHandler, DragEventHandler, useMemo, memo } from 'react'; import { ItemFlow, ListRenderer, NoteListColumns, OnChangeEvent, OnChangeHandler } from '@joplin/lib/services/plugins/api/noteListType'; import { Size } from '@joplin/utils/types'; import useRootElement from './utils/useRootElement'; import useItemElement from './utils/useItemElement'; import { ItemEventHandlers, OnInputChange } from './utils/types'; import Note from '@joplin/lib/models/Note'; import { NoteEntity } from '@joplin/lib/services/database/types'; import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled'; import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote'; import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession'; import { noteIsLockedInShare } from '@joplin/lib/models/utils/readOnly'; import bridge from '../../services/bridge'; import { _ } from '@joplin/lib/locale'; import useRenderedNote from './utils/useRenderedNote'; import { Dispatch } from 'redux'; import getNoteElementIdFromJoplinId from './utils/getNoteElementIdFromJoplinId'; import { HighlightedWord } from '@joplin/lib/reducer'; interface NoteItemProps { dragIndex: number; flow: ItemFlow; highlightedWords: HighlightedWord[]; index: number; isProvisional: boolean; itemSize: Size; noteCount: number; onChange: OnChangeHandler; onClick: MouseEventHandler; onDoubleClick: MouseEventHandler; onContextMenu: MouseEventHandler; onDragOver: DragEventHandler; onDragStart: DragEventHandler; style: CSSProperties; note: NoteEntity; isWatched: boolean; isPublished: boolean; isSelected: boolean; tabIndex: number; focusVisible: boolean; listRenderer: ListRenderer; columns: NoteListColumns; dispatch: Dispatch; } const NoteListItem = (props: NoteItemProps, ref: LegacyRef) => { const noteId = props.note.id; const elementId = getNoteElementIdFromJoplinId(noteId); const onInputChange: OnInputChange = useCallback(async (event: ChangeEvent) => { const getValue = (element: HTMLInputElement) => { if (element.type === 'checkbox') return element.checked; if (element.type === 'text') return element.value; throw new Error(`Unsupported element: ${element.type}`); }; const changeEvent: OnChangeEvent = { noteId: noteId, elementId: event.currentTarget.getAttribute('data-id'), value: getValue(event.currentTarget), }; if (changeEvent.elementId === 'todo-checkbox') { const lockState = await Note.load(changeEvent.noteId, { fields: ['is_locked', 'share_id'] }); if (noteIsLockedInShare(lockState)) { event.target.checked = !changeEvent.value; bridge().showErrorMessageBox(_('This note is read-only because it is locked and contained within a share. To enable editing, it must be moved outside of the share.')); return; } if (isNoteLockEnabled()) { if (NoteLockNote.isLocked(lockState) && !NoteLockSession.instance().isUnlocked()) { // event.currentTarget is already cleared here, after the await - target is the same input event.target.checked = !changeEvent.value; bridge().showErrorMessageBox(_('Cannot change a locked note while the session is locked')); return; } } await Note.save({ id: changeEvent.noteId, todo_completed: changeEvent.value ? Date.now() : 0, }, { userSideValidation: true }); props.dispatch({ type: 'NOTE_SORT' }); } else { if (props.onChange) await props.onChange(changeEvent); } }, [props.onChange, noteId, props.dispatch]); const rootElement = useRootElement(elementId); const renderedNote = useRenderedNote(props.note, props.isSelected, props.isWatched, props.isPublished, props.listRenderer, props.highlightedWords, props.index, props.columns); const itemEventHandlers = useMemo((): ItemEventHandlers => ({ onInputChange, onClick: null }), [onInputChange]); const displayTitle = useMemo(() => Note.displayTitle(props.note), [props.note]); useItemElement( rootElement, noteId, renderedNote ? renderedNote.html : '', props.focusVisible, props.style, props.itemSize, props.onClick, props.onDoubleClick, props.flow, itemEventHandlers, displayTitle, ); const className = useMemo(() => { return [ 'note-list-item-wrapper', // This is not used by the app, but kept here because it may be used // by users for custom CSS. (props.index + 1) % 2 === 0 ? 'even' : 'odd', props.isProvisional && '-provisional', ].filter(e => !!e).join(' '); }, [props.index, props.isProvisional]); const isActiveDragItem = props.dragIndex === props.index; const isLastActiveDragItem = props.index === props.noteCount - 1 && props.dragIndex >= props.noteCount; const dragCursorStyle = useMemo(() => { if (props.flow === ItemFlow.TopToBottom) { let dragItemPosition = ''; if (isActiveDragItem) { dragItemPosition = 'top'; } else if (isLastActiveDragItem) { dragItemPosition = 'bottom'; } const output: React.CSSProperties = { width: props.itemSize.width, display: dragItemPosition ? 'block' : 'none', left: 0, }; if (dragItemPosition === 'top') { output.top = 0; } else { output.bottom = 0; } return output; } if (props.flow === ItemFlow.LeftToRight) { let dragItemPosition = ''; if (isActiveDragItem) { dragItemPosition = 'left'; } else if (isLastActiveDragItem) { dragItemPosition = 'right'; } const output: React.CSSProperties = { height: props.itemSize.height, display: dragItemPosition ? 'block' : 'none', top: 0, }; if (dragItemPosition === 'left') { output.left = 0; } else { output.right = 0; } return output; } throw new Error('Unreachable'); }, [isActiveDragItem, isLastActiveDragItem, props.flow, props.itemSize]); return
; }; export default memo(forwardRef(NoteListItem));