import * as React from 'react'; import { useState, useEffect, useCallback, useRef } from 'react'; // eslint-disable-next-line no-unused-vars import TinyMCE from './NoteBody/TinyMCE/TinyMCE'; import AceEditor from './NoteBody/AceEditor/AceEditor'; import { connect } from 'react-redux'; import MultiNoteActions from '../MultiNoteActions'; import NoteToolbar from '../NoteToolbar/NoteToolbar'; import { htmlToMarkdown, formNoteToNote } 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 } from './utils/useFormNote'; import styles_ from './styles'; import { NoteEditorProps, FormNote, ScrollOptions, ScrollOptionTypes, OnChangeEvent, NoteBodyEditorProps } from './utils/types'; import { attachResources } from './utils/resourceHandling'; const { themeStyle } = require('../../theme.js'); const NoteSearchBar = require('../NoteSearchBar.min.js'); const { reg } = require('lib/registry.js'); const { time } = require('lib/time-utils.js'); const markupLanguageUtils = require('lib/markupLanguageUtils'); const usePrevious = require('lib/hooks/usePrevious').default; const Setting = require('lib/models/Setting'); const { _ } = require('lib/locale'); const Note = require('lib/models/Note.js'); const { bridge } = require('electron').remote.require('./bridge'); const ExternalEditWatcher = require('lib/services/ExternalEditWatcher'); const eventManager = require('../../eventManager'); const NoteRevisionViewer = require('../NoteRevisionViewer.min'); const TagList = require('../TagList.min.js'); function NoteEditor(props: NoteEditorProps) { const theme = themeStyle(props.theme); const [showRevisions, setShowRevisions] = useState(false); const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false); const [scrollWhenReady, setScrollWhenReady] = useState(null); const editorRef = useRef(); const titleInputRef = useRef(); const isMountedRef = useRef(true); const noteSearchBarRef = useRef(null); const formNote_beforeLoad = useCallback(async (event:OnLoadEvent) => { await saveNoteIfWillChange(event.formNote); setShowRevisions(false); }, []); const formNote_afterLoad = useCallback(async () => { setTitleHasBeenManuallyChanged(false); }, []); const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({ syncStarted: props.syncStarted, noteId: props.noteId, isProvisional: props.isProvisional, titleInputRef: titleInputRef, editorRef: editorRef, onBeforeLoad: formNote_beforeLoad, onAfterLoad: formNote_afterLoad, }); const formNoteRef = useRef(); formNoteRef.current = { ...formNote }; const { localSearch, onChange: localSearch_change, onNext: localSearch_next, onPrevious: localSearch_previous, onClose: localSearch_close, setResultCount: setLocalSearchResultCount, showLocalSearch, setShowLocalSearch, searchMarkers: localSearchMarkerOptions, } = useNoteSearchBar(); // 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); function scheduleSaveNote(formNote: FormNote) { if (!formNote.saveActionQueue) throw new Error('saveActionQueue is not set!!'); // Sanity check reg.logger().debug('Scheduling...', formNote); const makeAction = (formNote: FormNote) => { return async function() { const note = await formNoteToNote(formNote); reg.logger().debug('Saving note...', note); const savedNote:any = await Note.save(note); setFormNote((prev: FormNote) => { return { ...prev, user_updated_time: savedNote.user_updated_time }; }); ExternalEditWatcher.instance().updateNoteFile(savedNote); props.dispatch({ type: 'EDITOR_NOTE_STATUS_REMOVE', id: formNote.id, }); }; }; formNote.saveActionQueue.push(makeAction(formNote)); } async function saveNoteIfWillChange(formNote: FormNote) { if (!formNote.id || !formNote.bodyWillChangeId) return; const body = await editorRef.current.content(); scheduleSaveNote({ ...formNote, body: body, bodyWillChangeId: 0, bodyChangeId: 0, }); } async function saveNoteAndWait(formNote: FormNote) { saveNoteIfWillChange(formNote); return formNote.saveActionQueue.waitForAllDone(); } const markupToHtml = useMarkupToHtml({ themeId: props.theme, customCss: props.customCss }); const allAssets = useCallback(async (markupLanguage: number): Promise => { const theme = themeStyle(props.theme); const markupToHtml = markupLanguageUtils.newMarkupToHtml({ resourceBaseUrl: `file://${Setting.value('resourceDir')}/`, }); return markupToHtml.allAssets(markupLanguage, theme); }, [props.theme]); const handleProvisionalFlag = useCallback(() => { if (props.isProvisional) { props.dispatch({ type: 'NOTE_PROVISIONAL_FLAG_CLEAR', id: formNote.id, }); } }, [props.isProvisional, formNote.id]); useEffect(() => { // This is not exactly a hack but a bit ugly. If the note was changed (willChangeId > 0) but not // yet saved, we need to save it now before the component is unmounted. However, we can't put // formNote in the dependency array or that effect will run every time the note changes. We only // want to run it once on unmount. So because of that we need to use that formNoteRef. return () => { isMountedRef.current = false; saveNoteIfWillChange(formNoteRef.current); }; }, []); const previousNoteId = usePrevious(formNote.id); useEffect(() => { if (formNote.id === previousNoteId) return; if (editorRef.current) { editorRef.current.resetScroll(); } setScrollWhenReady({ type: props.selectedNoteHash ? ScrollOptionTypes.Hash : ScrollOptionTypes.Percent, value: props.selectedNoteHash ? props.selectedNoteHash : props.lastEditorScrollPercents[props.noteId] || 0, }); }, [formNote.id, previousNoteId]); const onFieldChange = useCallback((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. reg.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); scheduleSaveNote(newNote); } }, [handleProvisionalFlag, formNote, isNewNote, titleHasBeenManuallyChanged]); useWindowCommandHandler({ windowCommand: props.windowCommand, dispatch: props.dispatch, formNote, setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, saveNoteAndWait }); const onDrop = useDropHandler({ editorRef }); const onBodyChange = useCallback((event: OnChangeEvent) => onFieldChange('body', event.content, event.changeId), [onFieldChange]); const onTitleChange = useCallback((event: any) => onFieldChange('title', event.target.value), [onFieldChange]); const onTitleKeydown = useCallback((event:any) => { const keyCode = event.keyCode; if (keyCode === 9) { // TAB event.preventDefault(); if (event.shiftKey) { props.dispatch({ type: 'WINDOW_COMMAND', name: 'focusElement', target: 'noteList', }); } else { props.dispatch({ type: 'WINDOW_COMMAND', name: 'focusElement', target: 'noteBody', }); } } }, [props.dispatch]); 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, handleProvisionalFlag]); const onMessage = useMessageHandler(scrollWhenReady, setScrollWhenReady, editorRef, setLocalSearchResultCount, props.dispatch, formNote); const introductionPostLinkClick = useCallback(() => { bridge().openExternal('https://www.patreon.com/posts/34246624'); }, []); const externalEditWatcher_noteChange = useCallback((event) => { if (event.id === formNote.id) { const newFormNote = { ...formNote, title: event.note.title, body: event.note.body, }; setFormNote(newFormNote); } }, [formNote]); const onNotePropertyChange = useCallback((event) => { setFormNote(formNote => { if (formNote.id !== event.note.id) return formNote; const newFormNote: FormNote = { ...formNote }; for (const key in event.note) { if (key === 'id') continue; (newFormNote as any)[key] = event.note[key]; } return newFormNote; }); }, []); useEffect(() => { eventManager.on('alarmChange', onNotePropertyChange); ExternalEditWatcher.instance().on('noteChange', externalEditWatcher_noteChange); return () => { eventManager.off('alarmChange', onNotePropertyChange); ExternalEditWatcher.instance().off('noteChange', externalEditWatcher_noteChange); }; }, [externalEditWatcher_noteChange, onNotePropertyChange]); const noteToolbar_buttonClick = useCallback((event: any) => { const cases: any = { 'startExternalEditing': async () => { props.dispatch({ type: 'WINDOW_COMMAND', name: 'commandStartExternalEditing', }); }, 'stopExternalEditing': () => { props.dispatch({ type: 'WINDOW_COMMAND', name: 'commandStopExternalEditing', }); }, 'setTags': async () => { await saveNoteAndWait(formNote); props.dispatch({ type: 'WINDOW_COMMAND', name: 'setTags', noteIds: [formNote.id], }); }, 'setAlarm': async () => { await saveNoteAndWait(formNote); props.dispatch({ type: 'WINDOW_COMMAND', name: 'editAlarm', noteId: formNote.id, }); }, 'showRevisions': () => { setShowRevisions(true); }, }; if (!cases[event.name]) throw new Error(`Unsupported event: ${event.name}`); cases[event.name](); }, [formNote]); const onScroll = useCallback((event: any) => { props.dispatch({ type: 'EDITOR_SCROLL_PERCENT_SET', noteId: formNote.id, percent: event.percent, }); }, [props.dispatch, formNote]); function renderNoNotes(rootStyle:any) { const emptyDivStyle = Object.assign( { backgroundColor: 'black', opacity: 0.1, }, rootStyle ); return
; } function renderNoteToolbar() { const toolbarStyle = { marginBottom: 0, }; return ; } const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId); const editorProps:NoteBodyEditorProps = { ref: editorRef, contentKey: formNote.id, style: styles.tinyMCE, onChange: onBodyChange, onWillChange: onBodyWillChange, onMessage: onMessage, content: formNote.body, contentMarkupLanguage: formNote.markup_language, contentOriginalCss: formNote.originalCss, resourceInfos: resourceInfos, htmlToMarkdown: htmlToMarkdown, markupToHtml: markupToHtml, allAssets: allAssets, attachResources: attachResources, disabled: false, theme: props.theme, dispatch: props.dispatch, noteToolbar: null,// renderNoteToolbar(), onScroll: onScroll, searchMarkers: searchMarkers, visiblePanes: props.noteVisiblePanes || ['editor', 'viewer'], keyboardMode: Setting.value('editor.keyboardMode'), locale: Setting.value('locale'), }; let editor = null; if (props.bodyEditor === 'TinyMCE') { editor = ; } else if (props.bodyEditor === 'AceEditor') { editor = ; } else { throw new Error(`Invalid editor: ${props.bodyEditor}`); } const wysiwygBanner = props.bodyEditor !== 'TinyMCE' ? null : (
This is an experimental WYSIWYG editor for evaluation only. Please do not use with important notes as you may lose some data! See the introduction post for more information.
); const noteRevisionViewer_onBack = useCallback(() => { setShowRevisions(false); }, []); const tagStyle = { marginBottom: 10, }; const tagList = props.selectedNoteTags.length ? : null; if (showRevisions) { const theme = themeStyle(props.theme); const revStyle = { ...props.style, display: 'inline-flex', padding: theme.margin, verticalAlign: 'top', boxSizing: 'border-box', }; return (
); } if (props.selectedNoteIds.length > 1) { return ; } const titleBarDate = {time.formatMsToLocal(formNote.user_updated_time)}; function renderSearchBar() { if (!showLocalSearch) return false; const theme = themeStyle(props.theme); return ( ); } if (formNote.encryption_applied || !formNote.id) { return renderNoNotes(styles.root); } return (
{tagList}
{renderNoteToolbar()} {titleBarDate}
{editor}
{renderSearchBar()}
{wysiwygBanner}
); } export { NoteEditor as NoteEditorComponent, }; const mapStateToProps = (state: any) => { const noteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null; return { noteId: noteId, notes: state.notes, folders: state.folders, selectedNoteIds: state.selectedNoteIds, isProvisional: state.provisionalNoteIds.includes(noteId), editorNoteStatuses: state.editorNoteStatuses, syncStarted: state.syncStarted, theme: state.settings.theme, watchedNoteFiles: state.watchedNoteFiles, windowCommand: state.windowCommand, notesParentType: state.notesParentType, historyNotes: state.historyNotes, selectedNoteTags: state.selectedNoteTags, lastEditorScrollPercents: state.lastEditorScrollPercents, selectedNoteHash: state.selectedNoteHash, searches: state.searches, selectedSearchId: state.selectedSearchId, customCss: state.customCss, noteVisiblePanes: state.noteVisiblePanes, }; }; export default connect(mapStateToProps)(NoteEditor);