import * as React from 'react'; import { useState, useEffect, useCallback, useRef, useMemo, useContext } from 'react'; import TinyMCE from './NoteBody/TinyMCE/TinyMCE'; import { connect } from 'react-redux'; import MultiNoteActions from '../MultiNoteActions'; import { htmlToMarkdown } from './utils'; import useSearchMarkers from './utils/useSearchMarkers'; import useNoteSearchBar from './utils/useNoteSearchBar'; import useMessageHandler from './utils/useMessageHandler'; import useWindowCommandHandler from './utils/useWindowCommandHandler'; import useDropHandler from './utils/useDropHandler'; import useMarkupToHtml from './utils/useMarkupToHtml'; import useFormNote, { OnLoadEvent, OnSetFormNote } from './utils/useFormNote'; import useEffectiveNoteId from './utils/useEffectiveNoteId'; import useFolder from './utils/useFolder'; import styles_ from './styles'; import { NoteEditorProps, FormNote, OnChangeEvent, NoteBodyEditorProps, AllAssetsOptions, NoteBodyEditorRef } from './utils/types'; import CommandService from '@joplin/lib/services/CommandService'; import ToolbarButton from '../ToolbarButton/ToolbarButton'; import Button, { ButtonLevel } from '../Button/Button'; import eventManager, { EventName } from '@joplin/lib/eventManager'; import { AppState } from '../../app.reducer'; import ToolbarButtonUtils from '@joplin/lib/services/commands/ToolbarButtonUtils'; import { _, _n } from '@joplin/lib/locale'; import TagList from '../TagList'; import NoteTitleBar from './NoteTitle/NoteTitleBar'; import markupLanguageUtils from '@joplin/lib/utils/markupLanguageUtils'; import Setting from '@joplin/lib/models/Setting'; import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext'; import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher'; import { itemIsReadOnly } from '@joplin/lib/models/utils/readOnly'; const { themeStyle } = require('@joplin/lib/theme'); const { substrWithEllipsis } = require('@joplin/lib/string-utils'); import NoteSearchBar from '../NoteSearchBar'; import Note from '@joplin/lib/models/Note'; import Folder from '@joplin/lib/models/Folder'; import NoteRevisionViewer from '../NoteRevisionViewer'; import { parseShareCache } from '@joplin/lib/services/share/reducer'; import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; import { ModelType } from '@joplin/lib/BaseModel'; import BaseItem from '@joplin/lib/models/BaseItem'; import { ErrorCode } from '@joplin/lib/errors'; import ItemChange from '@joplin/lib/models/ItemChange'; import PlainEditor from './NoteBody/PlainEditor/PlainEditor'; import CodeMirror6 from './NoteBody/CodeMirror/v6/CodeMirror'; import CodeMirror5 from './NoteBody/CodeMirror/v5/CodeMirror'; import { openItemById } from './utils/contextMenu'; import getPluginSettingValue from '@joplin/lib/services/plugins/utils/getPluginSettingValue'; import { MarkupLanguage } from '@joplin/renderer'; import useScrollWhenReadyOptions from './utils/useScrollWhenReadyOptions'; import useScheduleSaveCallbacks from './utils/useScheduleSaveCallbacks'; import WarningBanner from './WarningBanner/WarningBanner'; import UserWebview from '../../services/plugins/UserWebview'; import Logger from '@joplin/utils/Logger'; import usePluginEditorView from './utils/usePluginEditorView'; import { stateUtils } from '@joplin/lib/reducer'; import { WindowIdContext } from '../NewWindowOrIFrame'; import { EditorActivationCheckFilterObject } from '@joplin/lib/services/plugins/api/types'; import PluginService from '@joplin/lib/services/plugins/PluginService'; import WebviewController from '@joplin/lib/services/plugins/WebviewController'; import AsyncActionQueue, { IntervalType } from '@joplin/lib/AsyncActionQueue'; const debounce = require('debounce'); const logger = Logger.create('NoteEditor'); const commands = [ require('./commands/showRevisions'), ]; const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance()); const onDragOver: React.DragEventHandler = event => event.preventDefault(); let editorIdCounter = 0; const makeNoteUpdateAction = (shownEditorViewIds: string[]) => { return async () => { for (const viewId of shownEditorViewIds) { const controller = PluginService.instance().viewControllerByViewId(viewId) as WebviewController; if (controller) controller.emitUpdate(); } }; }; function NoteEditorContent(props: NoteEditorProps) { const [showRevisions, setShowRevisions] = useState(false); const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false); const [isReadOnly, setIsReadOnly] = useState(false); const editorRef = useRef(); const titleInputRef = useRef(); const isMountedRef = useRef(true); const noteSearchBarRef = useRef(null); const viewUpdateAsyncQueue_ = useRef(new AsyncActionQueue(100, IntervalType.Fixed)); const shownEditorViewIds = props['plugins.shownEditorViewIds']; // Should be constant and unique to this instance of the editor. const editorId = useMemo(() => { return `editor-${editorIdCounter++}`; }, []); const setFormNoteRef = useRef(); const { saveNoteIfWillChange, scheduleSaveNote } = useScheduleSaveCallbacks({ setFormNote: setFormNoteRef, dispatch: props.dispatch, editorRef, editorId, }); const formNote_beforeLoad = useCallback(async (event: OnLoadEvent) => { await saveNoteIfWillChange(event.formNote); setShowRevisions(false); }, [saveNoteIfWillChange]); const formNote_afterLoad = useCallback(async () => { setTitleHasBeenManuallyChanged(false); }, []); const effectiveNoteId = useEffectiveNoteId(props); useAsyncEffect(async (event) => { if (!props.startupPluginsLoaded) return; let filterObject: EditorActivationCheckFilterObject = { activatedEditors: [], }; filterObject = await eventManager.filterEmit('editorActivationCheck', filterObject); if (event.cancelled) return; for (const editor of filterObject.activatedEditors) { const controller = PluginService.instance().pluginById(editor.pluginId).viewController(editor.viewId) as WebviewController; controller.setActive(editor.isActive); } }, [effectiveNoteId, props.startupPluginsLoaded]); useEffect(() => { if (!props.startupPluginsLoaded) return; viewUpdateAsyncQueue_.current.push(makeNoteUpdateAction(shownEditorViewIds)); }, [effectiveNoteId, shownEditorViewIds, props.startupPluginsLoaded]); const { editorPlugin, editorView } = usePluginEditorView(props.plugins, shownEditorViewIds); const builtInEditorVisible = !editorPlugin; const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({ noteId: effectiveNoteId, isProvisional: props.isProvisional, titleInputRef: titleInputRef, editorRef: editorRef, onBeforeLoad: formNote_beforeLoad, onAfterLoad: formNote_afterLoad, builtInEditorVisible, editorId, }); setFormNoteRef.current = setFormNote; const formNoteRef = useRef(); formNoteRef.current = { ...formNote }; const formNoteFolder = useFolder({ folderId: formNote.parent_id }); const { localSearch, onChange: localSearch_change, onNext: localSearch_next, onPrevious: localSearch_previous, onClose: localSearch_close, setResultCount: setLocalSearchResultCount, showLocalSearch, setShowLocalSearch, searchMarkers: localSearchMarkerOptions, } = useNoteSearchBar({ noteSearchBarRef }); // If the note has been modified in another editor, wait for it to be saved // before loading it in this editor. // const waitingToSaveNote = props.noteId && formNote.id !== props.noteId && props.editorNoteStatuses[props.noteId] === 'saving'; const styles = styles_(props); const whiteBackgroundNoteRendering = formNote.markup_language === MarkupLanguage.Html; const markupToHtml = useMarkupToHtml({ themeId: props.themeId, whiteBackgroundNoteRendering, customCss: props.customCss, plugins: props.plugins, settingValue: getPluginSettingValue, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const allAssets = useCallback(async (markupLanguage: number, options: AllAssetsOptions = null): Promise => { options = { contentMaxWidthTarget: '', ...options, }; const theme = themeStyle(options.themeId ? options.themeId : props.themeId); const markupToHtml = markupLanguageUtils.newMarkupToHtml({}, { resourceBaseUrl: `joplin-content://note-viewer/${Setting.value('resourceDir')}/`, customCss: props.customCss, }); return markupToHtml.allAssets(markupLanguage, theme, { contentMaxWidth: props.contentMaxWidth, contentMaxWidthTarget: options.contentMaxWidthTarget, whiteBackgroundNoteRendering: options.whiteBackgroundNoteRendering, }); }, [props.themeId, props.customCss, props.contentMaxWidth]); const handleProvisionalFlag = useCallback(() => { if (props.isProvisional) { props.dispatch({ type: 'NOTE_PROVISIONAL_FLAG_CLEAR', id: formNote.id, }); } }, [props.isProvisional, formNote.id, props.dispatch]); const scheduleNoteListResort = useMemo(() => { return debounce(() => { // Although the note list will update automatically, it may take some time. This // forces an immediate update. props.dispatch({ type: 'NOTE_SORT' }); }, 100); }, [props.dispatch]); useEffect(() => { props.onTitleChange?.(formNote.title); }, [formNote.title, props.onTitleChange]); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const onFieldChange = useCallback(async (field: string, value: any, changeId = 0) => { if (!isMountedRef.current) { // When the component is unmounted, various actions can happen which can // trigger onChange events, for example the textarea might be cleared. // We need to ignore these events, otherwise the note is going to be saved // with an invalid body. logger.debug('Skipping change event because the component is unmounted'); return; } handleProvisionalFlag(); const change = field === 'body' ? { body: value, } : { title: value, }; const newNote = { ...formNote, ...change, bodyWillChangeId: 0, bodyChangeId: 0, hasChanged: true, }; if (field === 'title') { setTitleHasBeenManuallyChanged(true); } if (isNewNote && !titleHasBeenManuallyChanged && field === 'body') { // TODO: Handle HTML/Markdown format newNote.title = Note.defaultTitle(value); } if (changeId !== null && field === 'body' && formNote.bodyWillChangeId !== changeId) { // Note was changed, but another note was loaded before save - skipping // The previously loaded note, that was modified, will be saved via saveNoteIfWillChange() } else { setFormNote(newNote); await scheduleSaveNote(newNote); } if (field === 'title') { // Scheduling a resort needs to be: // - called after scheduleSaveNote so that the new note title is used for sorting // - debounced because many calls to scheduleSaveNote can resolve at once scheduleNoteListResort(); } }, [handleProvisionalFlag, formNote, setFormNote, isNewNote, titleHasBeenManuallyChanged, scheduleNoteListResort, scheduleSaveNote]); const onDrop = useDropHandler({ editorRef }); const onBodyChange = useCallback((event: OnChangeEvent) => onFieldChange('body', event.content, event.changeId), [onFieldChange]); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const onTitleChange = useCallback((event: any) => onFieldChange('title', event.target.value), [onFieldChange]); const containerRef = useRef(null); useWindowCommandHandler({ dispatch: props.dispatch, setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, onBodyChange, containerRef, }); // const onTitleKeydown = useCallback((event:any) => { // const keyCode = event.keyCode; // if (keyCode === 9) { // // TAB // event.preventDefault(); // if (event.shiftKey) { // CommandService.instance().execute('focusElement', 'noteList'); // } else { // CommandService.instance().execute('focusElement', 'noteBody'); // } // } // }, [props.dispatch]); const shareCache = useMemo(() => { return parseShareCache(props.shareCacheSetting); }, [props.shareCacheSetting]); useAsyncEffect(async event => { if (!formNote.id) return; try { const result = await itemIsReadOnly(BaseItem, ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, formNote.id, props.syncUserId, shareCache); if (event.cancelled) return; setIsReadOnly(result); } catch (error) { if (error.code === ErrorCode.NotFound) { // Can happen if the note has been deleted but a render is // triggered anyway. It can be ignored. } else { throw error; } } }, [formNote.id, props.syncUserId, shareCache]); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const onBodyWillChange = useCallback((event: any) => { handleProvisionalFlag(); setFormNote(prev => { return { ...prev, bodyWillChangeId: event.changeId, hasChanged: true, }; }); props.dispatch({ type: 'EDITOR_NOTE_STATUS_SET', id: formNote.id, status: 'saving', }); }, [formNote, setFormNote, handleProvisionalFlag, props.dispatch]); const { scrollWhenReady, clearScrollWhenReady } = useScrollWhenReadyOptions({ noteId: formNote.id, selectedNoteHash: props.selectedNoteHash, lastEditorScrollPercents: props.lastEditorScrollPercents, editorRef, }); const windowId = useContext(WindowIdContext); const onMessage = useMessageHandler(scrollWhenReady, clearScrollWhenReady, windowId, editorRef, setLocalSearchResultCount, props.dispatch, formNote, htmlToMarkdown, markupToHtml); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const externalEditWatcher_noteChange = useCallback((event: any) => { if (event.id === formNote.id) { const newFormNote = { ...formNote, title: event.note.title, body: event.note.body, }; setFormNote(newFormNote); } }, [formNote, setFormNote]); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const onNotePropertyChange = useCallback((event: any) => { setFormNote(formNote => { if (formNote.id !== event.note.id) return formNote; const newFormNote: FormNote = { ...formNote }; for (const key in event.note) { if (key === 'id') continue; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied (newFormNote as any)[key] = event.note[key]; } return newFormNote; }); }, [setFormNote]); useEffect(() => { eventManager.on(EventName.AlarmChange, onNotePropertyChange); ExternalEditWatcher.instance().on('noteChange', externalEditWatcher_noteChange); return () => { eventManager.off(EventName.AlarmChange, onNotePropertyChange); ExternalEditWatcher.instance().off('noteChange', externalEditWatcher_noteChange); }; }, [externalEditWatcher_noteChange, onNotePropertyChange]); useEffect(() => { const dependencies = { setShowRevisions, isInFocusedDocument: () => { return containerRef.current?.ownerDocument?.hasFocus(); }, }; const registeredCommands = CommandService.instance().componentRegisterCommands( dependencies, commands, true, ); return () => { registeredCommands.deregister(); }; }, [setShowRevisions]); const onScroll = useCallback((event: { percent: number }) => { props.dispatch({ type: 'EDITOR_SCROLL_PERCENT_SET', // In callbacks of setTimeout()/setInterval(), props/state cannot be used // to refer the current value, since they would be one or more generations old. // For the purpose, useRef value should be used. noteId: formNoteRef.current.id, percent: event.percent, }); }, [props.dispatch]); function renderNoNotes(rootStyle: React.CSSProperties) { const emptyDivStyle = { backgroundColor: 'black', opacity: 0.1, ...rootStyle, }; return
; } function renderTagButton() { return ; } function renderTagBar() { const theme = themeStyle(props.themeId); const noteIds = [formNote.id]; const instructions = { void CommandService.instance().execute('setTags', noteIds); }} style={{ ...theme.clickableTextStyle, whiteSpace: 'nowrap' }}>{_('Click to add tags...')}; const tagList = props.selectedNoteTags.length ? : null; return (
{tagList}{instructions}
); } const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId, props.highlightedWords); const editorProps: NoteBodyEditorProps = { ref: editorRef, contentKey: formNote.id, style: styles.tinyMCE, whiteBackgroundNoteRendering, onChange: onBodyChange, onWillChange: onBodyWillChange, onMessage: onMessage, content: formNote.body, contentMarkupLanguage: formNote.markup_language, contentOriginalCss: formNote.originalCss, resourceInfos: resourceInfos, resourceDirectory: Setting.value('resourceDir'), htmlToMarkdown: htmlToMarkdown, markupToHtml: markupToHtml, allAssets: allAssets, disabled: isReadOnly, themeId: props.themeId, dispatch: props.dispatch, noteToolbar: null, onScroll: onScroll, setLocalSearchResultCount: setLocalSearchResultCount, setLocalSearch: localSearch_change, setShowLocalSearch, useLocalSearch: showLocalSearch, searchMarkers: searchMarkers, visiblePanes: props.noteVisiblePanes || ['editor', 'viewer'], keyboardMode: Setting.value('editor.keyboardMode'), locale: Setting.value('locale'), onDrop: onDrop, noteToolbarButtonInfos: props.toolbarButtonInfos, plugins: props.plugins, fontSize: Setting.value('style.editor.fontSize'), contentMaxWidth: props.contentMaxWidth, isSafeMode: props.isSafeMode, useCustomPdfViewer: props.useCustomPdfViewer, // We need it to identify the context for which media is rendered. // It is currently used to remember pdf scroll position for each attachments of each note uniquely. noteId: props.noteId, watchedNoteFiles: props.watchedNoteFiles, }; let editor = null; if (builtInEditorVisible) { if (props.bodyEditor === 'TinyMCE') { editor = ; } else if (props.bodyEditor === 'PlainText') { editor = ; } else if (props.bodyEditor === 'CodeMirror5') { editor = ; } else if (props.bodyEditor === 'CodeMirror6') { editor = ; } else { throw new Error(`Invalid editor: ${props.bodyEditor}`); } } const noteRevisionViewer_onBack = useCallback(() => { setShowRevisions(false); }, []); const onBannerResourceClick = useCallback(async (event: React.MouseEvent) => { event.preventDefault(); const resourceId = event.currentTarget.getAttribute('data-resource-id'); await openItemById(resourceId, props.dispatch); }, [props.dispatch]); if (showRevisions) { const theme = themeStyle(props.themeId); const revStyle: React.CSSProperties = { // ...props.style, display: 'inline-flex', padding: theme.margin, verticalAlign: 'top', boxSizing: 'border-box', flex: 1, }; return (
); } if (props.selectedNoteIds.length > 1) { return ; } function renderSearchBar() { if (!showLocalSearch) return false; const theme = themeStyle(props.themeId); return ( ); } function renderResourceWatchingNotification() { if (!Object.keys(props.watchedResources).length) return null; const resourceTitles = Object.keys(props.watchedResources).map(id => props.watchedResources[id].title); return (

{_('The following attachments are being watched for changes:')} {resourceTitles.join(', ')}

{_('The attachments will no longer be watched when you switch to a different note.')}

); } const renderResourceInSearchResultsNotification = () => { const resourceResults = props.searchResults.filter(r => r.id === props.noteId && r.item_type === ModelType.Resource); if (!resourceResults.length) return null; const renderResource = (id: string, title: string) => { return
  • {title}
  • ; }; return (

    {_n('The following attachment matches your search query:', 'The following attachments match your search query:', resourceResults.length)}

      {resourceResults.map(r => renderResource(r.item_id, r.title))}
    ); }; function renderSearchInfo() { const theme = themeStyle(props.themeId); if (formNoteFolder && ['Search', 'Tag', 'SmartFilter'].includes(props.notesParentType)) { return (
    ); } else { return null; } } const renderPluginEditor = () => { if (!editorPlugin) return null; const html = props.pluginHtmlContents[editorPlugin.id]?.[editorView.id] ?? ''; return ; }; if (formNote.encryption_applied || !formNote.id || !effectiveNoteId) { return renderNoNotes(styles.root); } const theme = themeStyle(props.themeId); return (
    {renderResourceWatchingNotification()} {renderResourceInSearchResultsNotification()} {renderSearchInfo()}
    {editor} {renderPluginEditor()}
    {renderSearchBar()}
    {renderTagButton()} {renderTagBar()}
    ); } interface ConnectProps { windowId: string; } const mapStateToProps = (state: AppState, ownProps: ConnectProps) => { const whenClauseContext = stateToWhenClauseContext(state, { windowId: ownProps.windowId }); const windowState = stateUtils.windowStateById(state, ownProps.windowId); const noteId = stateUtils.selectedNoteId(windowState); let bodyEditor = windowState.editorCodeView ? 'CodeMirror6' : 'TinyMCE'; if (state.settings.isSafeMode) { bodyEditor = 'PlainText'; } else if (windowState.editorCodeView && state.settings['editor.legacyMarkdown']) { bodyEditor = 'CodeMirror5'; } return { noteId, bodyEditor, isProvisional: state.provisionalNoteIds.includes(noteId), notes: windowState.notes, selectedNoteIds: windowState.selectedNoteIds, selectedFolderId: windowState.selectedFolderId, editorNoteStatuses: state.editorNoteStatuses, themeId: state.settings.theme, watchedNoteFiles: state.watchedNoteFiles, notesParentType: windowState.notesParentType, selectedNoteTags: windowState.selectedNoteTags, lastEditorScrollPercents: state.lastEditorScrollPercents, selectedNoteHash: windowState.selectedNoteHash, searches: state.searches, selectedSearchId: windowState.selectedSearchId, customCss: state.customViewerCss, noteVisiblePanes: windowState.noteVisiblePanes, watchedResources: state.watchedResources, highlightedWords: state.highlightedWords, plugins: state.pluginService.plugins, pluginHtmlContents: state.pluginService.pluginHtmlContents, 'plugins.shownEditorViewIds': state.settings['plugins.shownEditorViewIds'] || [], toolbarButtonInfos: toolbarButtonUtils.commandsToToolbarButtons([ 'historyBackward', 'historyForward', 'toggleEditors', 'toggleExternalEditing', ], whenClauseContext), setTagsToolbarButtonInfo: toolbarButtonUtils.commandsToToolbarButtons([ 'setTags', ], whenClauseContext)[0], contentMaxWidth: state.settings['style.editor.contentMaxWidth'], isSafeMode: state.settings.isSafeMode, useCustomPdfViewer: false, syncUserId: state.settings['sync.userId'], shareCacheSetting: state.settings['sync.shareCache'], searchResults: state.searchResults, }; }; export default connect(mapStateToProps)(NoteEditorContent);