diff --git a/ElectronClient/InteropServiceHelper.js b/ElectronClient/InteropServiceHelper.js index 741f85b95f..d3848802f5 100644 --- a/ElectronClient/InteropServiceHelper.js +++ b/ElectronClient/InteropServiceHelper.js @@ -3,7 +3,6 @@ const { bridge } = require('electron').remote.require('./bridge'); const InteropService = require('lib/services/InteropService'); const Setting = require('lib/models/Setting'); const Note = require('lib/models/Note.js'); -const Folder = require('lib/models/Folder.js'); const { friendlySafeFilename } = require('lib/path-utils'); const md5 = require('md5'); const url = require('url'); @@ -70,7 +69,10 @@ class InteropServiceHelper { cleanup(); } } else { - // TODO: it is crashing at this point + // TODO: it is crashing at this point :( + // Appears to be a Chromium bug: https://github.com/electron/electron/issues/19946 + // Maybe can be fixed by doing everything from main process? + // i.e. creating a function `print()` that takes the `htmlFile` variable as input. win.webContents.print(options, (success, reason) => { // TODO: This is correct but broken in Electron 4. Need to upgrade to 5+ @@ -105,31 +107,12 @@ class InteropServiceHelper { return this.exportNoteTo_('printer', noteId, options); } - static async defaultFilename(noteIds, fileExtension) { - if (!noteIds) { - return ''; - } - - const note = await Note.load(noteIds[0]); + static async defaultFilename(noteId, fileExtension) { + if (!noteId) return ''; + const note = await Note.load(noteId); // In a rare case the passed not will be null, use the id for filename - if (note === null) { - const filename = friendlySafeFilename(noteIds[0], 100); - - return `${filename}.${fileExtension}`; - } - const folder = await Folder.load(note.parent_id); - - const filename = friendlySafeFilename(note.title, 100); - - // In a less rare case the folder will be null, just ignore it - if (folder === null) { - return `${filename}.${fileExtension}`; - } - - const foldername = friendlySafeFilename(folder.title, 100); - - // friendlySafeFilename assumes that the file extension is added after - return `${foldername} - ${filename}.${fileExtension}`; + const filename = friendlySafeFilename(note ? note.title : noteId, 100); + return `${filename}.${fileExtension}`; } static async export(dispatch, module, options = null) { @@ -138,9 +121,10 @@ class InteropServiceHelper { let path = null; if (module.target === 'file') { + const noteId = options.sourceNoteIds && options.sourceNoteIds.length ? options.sourceNoteIds[0] : null; path = bridge().showSaveDialog({ filters: [{ name: module.description, extensions: module.fileExtensions }], - defaultPath: await this.defaultFilename(options.sourceNoteIds, module.fileExtensions[0]), + defaultPath: await this.defaultFilename(noteId, module.fileExtensions[0]), }); } else { path = bridge().showOpenDialog({ diff --git a/ElectronClient/gui/MainScreen.jsx b/ElectronClient/gui/MainScreen.jsx index 42d4f615d2..864417499f 100644 --- a/ElectronClient/gui/MainScreen.jsx +++ b/ElectronClient/gui/MainScreen.jsx @@ -535,7 +535,7 @@ class MainScreenComponent extends React.Component { if (noteIds.length === 1) { path = bridge().showSaveDialog({ filters: [{ name: _('PDF File'), extensions: ['pdf'] }], - defaultPath: await InteropServiceHelper.defaultFilename(noteIds, 'pdf'), + defaultPath: await InteropServiceHelper.defaultFilename(noteIds[0], 'pdf'), }); } else { @@ -548,14 +548,20 @@ class MainScreenComponent extends React.Component { for (let i = 0; i < noteIds.length; i++) { const note = await Note.load(noteIds[i]); - const folder = Folder.byId(this.props.folders, note.parent_id); - const pdfPath = (noteIds.length === 1) ? path : - await shim.fsDriver().findUniqueFilename(`${path}/${this.pdfFileName_(note, folder)}`); + let pdfPath = ''; + + if (noteIds.length === 1) { + pdfPath = path; + } else { + const n = await InteropServiceHelper.defaultFilename(note.id, 'pdf'); + pdfPath = await shim.fsDriver().findUniqueFilename(`${path}/${n}`); + } await this.printTo_('pdf', { path: pdfPath, noteId: note.id }); } } catch (error) { + console.error(error); bridge().showErrorMessageBox(error.message); } } @@ -852,7 +858,6 @@ class MainScreenComponent extends React.Component { const shareNoteDialogOptions = this.state.shareNoteDialogOptions; const bodyEditor = this.props.settingEditorCodeView ? 'AceEditor' : 'TinyMCE'; - const noteTextComp = ; return (
@@ -870,7 +875,7 @@ class MainScreenComponent extends React.Component { - {noteTextComp} + {pluginDialog}
); diff --git a/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/AceEditor.tsx b/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/AceEditor.tsx index c9bde429dd..93841f3b78 100644 --- a/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/AceEditor.tsx +++ b/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/AceEditor.tsx @@ -251,6 +251,11 @@ function AceEditor(props: NoteBodyEditorProps, ref: any) { editor.clearSelection(); editor.moveCursorTo(0, 0); }, + supportsCommand: (/* name:string*/) => { + // TODO: not implemented, currently only used for "search" command + // which is not directly supported by Ace Editor. + return false; + }, execCommand: async (cmd: EditorCommand) => { if (!editor) return false; diff --git a/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.tsx b/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.tsx index 685f760a27..2761f19a07 100644 --- a/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.tsx +++ b/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.tsx @@ -13,7 +13,7 @@ function styles_(props:ToolbarProps) { return buildStyle('AceEditorToolbar', props.theme, (/* theme:any*/) => { return { root: { - // marginTop: 4, + flex: 1, marginBottom: 0, }, }; diff --git a/ElectronClient/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor.js b/ElectronClient/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor.js deleted file mode 100644 index 21c57cb4af..0000000000 --- a/ElectronClient/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor.js +++ /dev/null @@ -1,24 +0,0 @@ -'use strict'; -Object.defineProperty(exports, '__esModule', { value: true }); -const React = require('react'); -const react_1 = require('react'); -const PlainEditor = (props, ref) => { - const editorRef = react_1.useRef(); - react_1.useImperativeHandle(ref, () => { - return { - content: () => '', - }; - }, []); - react_1.useEffect(() => { - if (!editorRef.current) { return; } - editorRef.current.value = props.defaultEditorState.value; - }, [props.defaultEditorState]); - const onChange = react_1.useCallback((event) => { - props.onChange({ changeId: null, content: event.target.value }); - }, [props.onWillChange, props.onChange]); - return (React.createElement('div', { style: props.style }, - React.createElement('textarea', { ref: editorRef, style: { width: '100%', height: '100%' }, defaultValue: props.defaultEditorState.value, onChange: onChange }), - ';')); -}; -exports.default = react_1.forwardRef(PlainEditor); -// # sourceMappingURL=PlainEditor.js.map diff --git a/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx b/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx index 89650939d6..1aa77a6104 100644 --- a/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx +++ b/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx @@ -145,6 +145,8 @@ const TinyMCE = (props:NoteBodyEditorProps, ref:any) => { const markupToHtml = useRef(null); markupToHtml.current = props.markupToHtml; + const lastOnChangeEventContent = useRef(''); + const rootIdRef = useRef(`tinymce-${Date.now()}${Math.round(Math.random() * 10000)}`); const editorRef = useRef(null); editorRef.current = editor; @@ -170,15 +172,7 @@ const TinyMCE = (props:NoteBodyEditorProps, ref:any) => { if (nodeName === 'A' && (event.ctrlKey || event.metaKey)) { const href = event.target.getAttribute('href'); - // const joplinUrl = href.indexOf('joplin://') === 0 ? href : null; - // if (joplinUrl) { - // props.onMessage({ - // name: 'openInternal', - // args: { - // url: joplinUrl, - // }, - // }); if (href.indexOf('#') === 0) { const anchorName = href.substr(1); const anchor = editor.getDoc().getElementById(anchorName); @@ -188,12 +182,7 @@ const TinyMCE = (props:NoteBodyEditorProps, ref:any) => { reg.logger().warn('TinyMce: could not find anchor with ID ', anchorName); } } else { - props.onMessage({ - name: 'openUrl', - args: { - url: href, - }, - }); + props.onMessage({ channel: href }); } } }, [editor, props.onMessage]); @@ -216,6 +205,10 @@ const TinyMCE = (props:NoteBodyEditorProps, ref:any) => { clearState: () => { console.warn('TinyMCE::clearState - not implemented'); }, + supportsCommand: (name:string) => { + // TODO: should also handle commands that are not in this map (insertText, focus, etc); + return !!joplinCommandToTinyMceCommands[name]; + }, execCommand: async (cmd:EditorCommand) => { if (!editor) return false; @@ -398,6 +391,10 @@ const TinyMCE = (props:NoteBodyEditorProps, ref:any) => { .tox .tox-dialog__footer { border-color: ${theme.dividerColor} !important; } + + .tox-tinymce { + border-top: none !important; + } `)); return () => { @@ -627,15 +624,16 @@ const TinyMCE = (props:NoteBodyEditorProps, ref:any) => { let cancelled = false; const loadContent = async () => { + if (lastOnChangeEventContent.current === props.content) return; + const result = await props.markupToHtml(props.contentMarkupLanguage, props.content, markupRenderOptions({ resourceInfos: props.resourceInfos })); if (cancelled) return; + lastOnChangeEventContent.current = props.content; editor.setContent(result.html); await loadDocumentAssets(editor, await props.allAssets(props.contentMarkupLanguage)); - editor.getDoc().addEventListener('click', onEditorContentClick); - // Need to clear UndoManager to avoid this problem: // - Load note 1 // - Make a change @@ -650,9 +648,17 @@ const TinyMCE = (props:NoteBodyEditorProps, ref:any) => { return () => { cancelled = true; + }; + }, [editor, props.markupToHtml, props.allAssets, props.content, props.resourceInfos]); + + useEffect(() => { + if (!editor) return () => {}; + + editor.getDoc().addEventListener('click', onEditorContentClick); + return () => { editor.getDoc().removeEventListener('click', onEditorContentClick); }; - }, [editor, props.markupToHtml, props.allAssets, onEditorContentClick, props.resourceInfos]); + }, [editor, onEditorContentClick]); // ----------------------------------------------------------------------------------------- // Handle onChange event @@ -685,6 +691,8 @@ const TinyMCE = (props:NoteBodyEditorProps, ref:any) => { if (!editor) return; + lastOnChangeEventContent.current = contentMd; + props_onChangeRef.current({ changeId: changeId, content: contentMd, diff --git a/ElectronClient/gui/NoteEditor/NoteEditor.tsx b/ElectronClient/gui/NoteEditor/NoteEditor.tsx index d819c39b22..b745c7a63d 100644 --- a/ElectronClient/gui/NoteEditor/NoteEditor.tsx +++ b/ElectronClient/gui/NoteEditor/NoteEditor.tsx @@ -34,6 +34,8 @@ const NoteRevisionViewer = require('../NoteRevisionViewer.min'); const TagList = require('../TagList.min.js'); function NoteEditor(props: NoteTextProps) { + const theme = themeStyle(props.theme); + const [showRevisions, setShowRevisions] = useState(false); const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false); const [scrollWhenReady, setScrollWhenReady] = useState(null); @@ -268,7 +270,7 @@ function NoteEditor(props: NoteTextProps) { }); }, [formNote, handleProvisionalFlag]); - const onMessage = useMessageHandler(scrollWhenReady, setScrollWhenReady, editorRef, setLocalSearchResultCount, props.dispatch); + const onMessage = useMessageHandler(scrollWhenReady, setScrollWhenReady, editorRef, setLocalSearchResultCount, props.dispatch, formNote); const introductionPostLinkClick = useCallback(() => { bridge().openExternal('https://www.patreon.com/posts/34246624'); @@ -379,17 +381,13 @@ function NoteEditor(props: NoteTextProps) { function renderNoteToolbar() { const toolbarStyle = { - // marginTop: 4, marginBottom: 0, - flex: 1, }; return ; } @@ -414,7 +412,7 @@ function NoteEditor(props: NoteTextProps) { disabled: false, theme: props.theme, dispatch: props.dispatch, - noteToolbar: renderNoteToolbar(), + noteToolbar: null,// renderNoteToolbar(), onScroll: onScroll, searchMarkers: searchMarkers, visiblePanes: props.noteVisiblePanes || ['editor', 'viewer'], @@ -432,7 +430,7 @@ function NoteEditor(props: NoteTextProps) { } 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.
); @@ -511,13 +509,12 @@ function NoteEditor(props: NoteTextProps) { return (
- {wysiwygBanner} {tagList} -
+
+ {renderNoteToolbar()} {renderSearchBar()}
+ {wysiwygBanner}
); diff --git a/ElectronClient/gui/NoteEditor/styles/index.ts b/ElectronClient/gui/NoteEditor/styles/index.ts index 700d77d65e..e76f68c595 100644 --- a/ElectronClient/gui/NoteEditor/styles/index.ts +++ b/ElectronClient/gui/NoteEditor/styles/index.ts @@ -9,7 +9,7 @@ export default function styles(props: NoteTextProps) { ...props.style, boxSizing: 'border-box', paddingLeft: 10, - paddingTop: 10, + paddingTop: 5, borderLeftWidth: 1, borderLeftColor: theme.dividerColor, borderLeftStyle: 'solid', @@ -23,7 +23,7 @@ export default function styles(props: NoteTextProps) { paddingRight: 8, marginRight: theme.paddingLeft, color: theme.textStyle.color, - fontSize: theme.textStyle.fontSize * 1.25 * 1.5, + fontSize: theme.textStyle.fontSize * 1.25, backgroundColor: theme.backgroundColor, border: '1px solid', borderColor: theme.dividerColor, @@ -33,6 +33,8 @@ export default function styles(props: NoteTextProps) { fontFamily: theme.fontFamily, padding: 10, fontSize: theme.fontSize, + marginTop: 5, + marginBottom: 5, }, tinyMCE: { width: '100%', diff --git a/ElectronClient/gui/NoteEditor/utils/types.ts b/ElectronClient/gui/NoteEditor/utils/types.ts index 0014cd86d2..a5ba04ebe4 100644 --- a/ElectronClient/gui/NoteEditor/utils/types.ts +++ b/ElectronClient/gui/NoteEditor/utils/types.ts @@ -49,6 +49,7 @@ export interface NoteBodyEditorProps { visiblePanes: string[], keyboardMode: string, resourceInfos: ResourceInfos, + showLocalSearch: boolean, } export interface FormNote { diff --git a/ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts b/ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts index 79df656671..fc1b5b89b5 100644 --- a/ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts +++ b/ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; - +import { FormNote } from './types'; const BaseItem = require('lib/models/BaseItem'); const { _ } = require('lib/locale'); const BaseModel = require('lib/BaseModel.js'); @@ -15,7 +15,7 @@ const { clipboard } = require('electron'); const { toSystemSlashes } = require('lib/path-utils'); const { reg } = require('lib/registry.js'); -export default function useMessageHandler(scrollWhenReady:any, setScrollWhenReady:Function, editorRef:any, setLocalSearchResultCount:Function, dispatch:Function) { +export default function useMessageHandler(scrollWhenReady:any, setScrollWhenReady:Function, editorRef:any, setLocalSearchResultCount:Function, dispatch:Function, formNote:FormNote) { return useCallback(async (event: any) => { const msg = event.channel ? event.channel : ''; const args = event.args; @@ -128,10 +128,10 @@ export default function useMessageHandler(scrollWhenReady:any, setScrollWhenRead folderId: item.parent_id, noteId: item.id, hash: resourceUrlInfo.hash, - // historyNoteAction: { - // id: this.state.note.id, - // parent_id: this.state.note.parent_id, - // }, + historyNoteAction: { + id: formNote.id, + parent_id: formNote.parent_id, + }, }); } else { throw new Error(`Unsupported item type: ${item.type_}`); @@ -148,5 +148,5 @@ export default function useMessageHandler(scrollWhenReady:any, setScrollWhenRead } else { bridge().showErrorMessageBox(_('Unsupported link or message: %s', msg)); } - }, [dispatch, setLocalSearchResultCount, scrollWhenReady]); + }, [dispatch, setLocalSearchResultCount, scrollWhenReady, formNote]); } diff --git a/ElectronClient/gui/NoteEditor/utils/useResourceRefresher.js b/ElectronClient/gui/NoteEditor/utils/useResourceRefresher.js deleted file mode 100644 index 2553db78fc..0000000000 --- a/ElectronClient/gui/NoteEditor/utils/useResourceRefresher.js +++ /dev/null @@ -1,48 +0,0 @@ -'use strict'; -const __awaiter = (this && this.__awaiter) || function(thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function(resolve) { resolve(value); }); } - return new (P || (P = Promise))(function(resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator['throw'](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -Object.defineProperty(exports, '__esModule', { value: true }); -const react_1 = require('react'); -const resourceHandling_1 = require('./resourceHandling'); -const ResourceFetcher = require('lib/services/ResourceFetcher.js'); -const DecryptionWorker = require('lib/services/DecryptionWorker.js'); -const Note = require('lib/models/Note'); -function useResourceInfos(dependencies) { - const { noteBody } = dependencies; - const [resourceInfos, setResourceInfos] = react_1.useState({}); - function installResourceHandling(refreshResourceHandler) { - ResourceFetcher.instance().on('downloadComplete', refreshResourceHandler); - ResourceFetcher.instance().on('downloadStarted', refreshResourceHandler); - DecryptionWorker.instance().on('resourceDecrypted', refreshResourceHandler); - } - function uninstallResourceHandling(refreshResourceHandler) { - ResourceFetcher.instance().off('downloadComplete', refreshResourceHandler); - ResourceFetcher.instance().off('downloadStarted', refreshResourceHandler); - DecryptionWorker.instance().off('resourceDecrypted', refreshResourceHandler); - } - const refreshResource = react_1.useCallback(function(event) { - return __awaiter(this, void 0, void 0, function* () { - const resourceIds = yield Note.linkedResourceIds(noteBody); - if (resourceIds.indexOf(event.id) >= 0) { - resourceHandling_1.clearResourceCache(); - setResourceInfos(yield resourceHandling_1.attachedResources(noteBody)); - } - }); - }, [noteBody]); - react_1.useEffect(() => { - installResourceHandling(refreshResource); - return () => { - uninstallResourceHandling(refreshResource); - }; - }, [refreshResource]); - return { resourceInfos }; -} -exports.default = useResourceInfos; -// # sourceMappingURL=useResourceRefresher.js.map diff --git a/ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.ts b/ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.ts index 9a3f5787b4..7c26f9528b 100644 --- a/ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.ts +++ b/ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.ts @@ -59,8 +59,12 @@ export default function useWindowCommandHandler(dependencies:HookDependencies) { editorCmd.name = 'insertText', editorCmd.value = time.formatMsToLocal(new Date().getTime()); } else if (command.name === 'showLocalSearch') { - setShowLocalSearch(true); - if (noteSearchBarRef.current) noteSearchBarRef.current.wrappedInstance.focus(); + if (editorRef.current && editorRef.current.supportsCommand('search')) { + editorCmd.name = 'search'; + } else { + setShowLocalSearch(true); + if (noteSearchBarRef.current) noteSearchBarRef.current.wrappedInstance.focus(); + } } else if (command.name === 'insertTemplate') { editorCmd.name = 'insertText', editorCmd.value = time.formatMsToLocal(new Date().getTime()); diff --git a/ElectronClient/gui/NoteText.jsx b/ElectronClient/gui/NoteText.jsx deleted file mode 100644 index 3a08171646..0000000000 --- a/ElectronClient/gui/NoteText.jsx +++ /dev/null @@ -1,2283 +0,0 @@ -const React = require('react'); -const Note = require('lib/models/Note.js'); -const BaseItem = require('lib/models/BaseItem.js'); -const BaseModel = require('lib/BaseModel.js'); -const Resource = require('lib/models/Resource.js'); -const Folder = require('lib/models/Folder.js'); -const { time } = require('lib/time-utils.js'); -const Setting = require('lib/models/Setting.js'); -const InteropServiceHelper = require('../InteropServiceHelper.js'); -const { IconButton } = require('./IconButton.min.js'); -const { urlDecode, substrWithEllipsis } = require('lib/string-utils'); -const Toolbar = require('./Toolbar.min.js'); -const NoteToolbar = require('./NoteToolbar/NoteToolbar.js').default; -const TagList = require('./TagList.min.js'); -const { connect } = require('react-redux'); -const { _ } = require('lib/locale.js'); -const { reg } = require('lib/registry.js'); -const { MarkupToHtml } = require('lib/joplin-renderer'); -const shared = require('lib/components/shared/note-screen-shared.js'); -const { bridge } = require('electron').remote.require('./bridge'); -const { themeStyle } = require('../theme.js'); -const AceEditor = require('react-ace').default; -const Menu = bridge().Menu; -const MenuItem = bridge().MenuItem; -const { shim } = require('lib/shim.js'); -const eventManager = require('../eventManager'); -const fs = require('fs-extra'); -const md5 = require('md5'); -const mimeUtils = require('lib/mime-utils.js').mime; -const ObjectUtils = require('lib/ObjectUtils'); -const urlUtils = require('lib/urlUtils'); -const dialogs = require('./dialogs'); -const NoteListUtils = require('./utils/NoteListUtils'); -const NoteSearchBar = require('./NoteSearchBar.min.js'); -const markdownUtils = require('lib/markdownUtils'); -const ExternalEditWatcher = require('lib/services/ExternalEditWatcher'); -const ResourceFetcher = require('lib/services/ResourceFetcher'); -const { toSystemSlashes } = require('lib/path-utils'); -const { clipboard } = require('electron'); -const SearchEngine = require('lib/services/SearchEngine'); -const NoteTextViewer = require('./NoteTextViewer.min'); -const NoteRevisionViewer = require('./NoteRevisionViewer.min'); -const TemplateUtils = require('lib/TemplateUtils'); -const markupLanguageUtils = require('lib/markupLanguageUtils'); - -require('brace/mode/markdown'); -// https://ace.c9.io/build/kitchen-sink.html -// https://highlightjs.org/static/demo/ -require('brace/theme/chrome'); -require('brace/theme/solarized_light'); -require('brace/theme/solarized_dark'); -require('brace/theme/twilight'); -require('brace/theme/dracula'); -require('brace/theme/chaos'); -require('brace/keybinding/vim'); -require('brace/keybinding/emacs'); - -/* eslint-disable-next-line no-undef */ -class CustomHighlightRules extends ace.acequire( - 'ace/mode/markdown_highlight_rules' -).MarkdownHighlightRules { - constructor() { - super(); - if (Setting.value('markdown.plugin.mark')) { - this.$rules.start.push({ - // This is actually a highlight `mark`, but Ace has no token name for - // this so we made up our own. Reference for common tokens here: - // https://github.com/ajaxorg/ace/wiki/Creating-or-Extending-an-Edit-Mode#common-tokens - token: 'highlight_mark', - regex: '==[^ ](?:.*?[^ ])?==', - }); - } - } -} - -/* eslint-disable-next-line no-undef */ -class CustomMdMode extends ace.acequire('ace/mode/markdown').Mode { - constructor() { - super(); - this.HighlightRules = CustomHighlightRules; - } -} - -const NOTE_TAG_BAR_FEATURE_ENABLED = true; - -class NoteTextComponent extends React.Component { - constructor() { - super(); - - this.localSearchDefaultState = { - query: '', - selectedIndex: 0, - resultCount: 0, - searching: false, - }; - - this.state = { - note: null, - folder: null, - lastSavedNote: null, - isLoading: true, - webviewReady: false, - scrollHeight: null, - editorScrollTop: 0, - noteTags: [], - showRevisions: false, - loading: false, - - // If the current note was just created, and the title has never been - // changed by the user, this variable contains that note ID. Used - // to automatically set the title. - newAndNoTitleChangeNoteId: null, - bodyHtml: '', - lastRenderCssFiles: [], - lastRenderPluginAssets: [], - lastKeys: [], - showLocalSearch: false, - localSearch: Object.assign({}, this.localSearchDefaultState), - }; - - this.webviewRef_ = React.createRef(); - - this.lastLoadedNoteId_ = null; - - this.webviewListeners_ = null; - this.ignoreNextEditorScroll_ = false; - this.scheduleSaveTimeout_ = null; - this.restoreScrollTop_ = null; - this.lastSetHtml_ = ''; - this.lastSetMarkers_ = ''; - this.lastSetMarkersOptions_ = {}; - this.selectionRange_ = null; - this.lastComponentUpdateNoteId_ = null; - this.noteSearchBar_ = React.createRef(); - this.isPrinting_ = false; - - // Complicated but reliable method to get editor content height - // https://github.com/ajaxorg/ace/issues/2046 - this.editorMaxScrollTop_ = 0; - this.onAfterEditorRender_ = () => { - const r = this.editor_.editor.renderer; - this.editorMaxScrollTop_ = Math.max(0, r.layerConfig.maxHeight - r.$size.scrollerHeight); - - if (this.restoreScrollTop_ !== null) { - this.editorSetScrollTop(this.restoreScrollTop_); - this.restoreScrollTop_ = null; - } - }; - - this.onAlarmChange_ = event => { - if (event.noteId === this.props.noteId) this.scheduleReloadNote(this.props); - }; - this.onNoteTypeToggle_ = event => { - if (event.noteId === this.props.noteId) this.scheduleReloadNote(this.props); - }; - this.onTodoToggle_ = event => { - if (event.noteId === this.props.noteId) this.scheduleReloadNote(this.props); - }; - - this.onEditorPaste_ = async (event = null) => { - const formats = clipboard.availableFormats(); - for (let i = 0; i < formats.length; i++) { - const format = formats[i].toLowerCase(); - const formatType = format.split('/')[0]; - - if (formatType === 'image') { - if (event) event.preventDefault(); - - const image = clipboard.readImage(); - - const fileExt = mimeUtils.toFileExtension(format); - const filePath = `${Setting.value('tempDir')}/${md5(Date.now())}.${fileExt}`; - - await shim.writeImageToFile(image, format, filePath); - await this.commandAttachFile([filePath]); - await shim.fsDriver().remove(filePath); - } - } - }; - - this.onEditorKeyDown_ = event => { - const lastKeys = this.state.lastKeys.slice(); - lastKeys.push(event.key); - while (lastKeys.length > 2) lastKeys.splice(0, 1); - this.setState({ lastKeys: lastKeys }); - }; - - this.onEditorContextMenu_ = () => { - const menu = new Menu(); - - const selectedText = this.selectedText(); - const clipboardText = clipboard.readText(); - - menu.append( - new MenuItem({ - label: _('Cut'), - enabled: !!selectedText, - click: async () => { - this.editorCutText(); - }, - }) - ); - - menu.append( - new MenuItem({ - label: _('Copy'), - enabled: !!selectedText, - click: async () => { - this.editorCopyText(); - }, - }) - ); - - menu.append( - new MenuItem({ - label: _('Paste'), - enabled: true, - click: async () => { - if (clipboardText) { - this.editorPasteText(); - } else { - // To handle pasting images - this.onEditorPaste_(); - } - }, - }) - ); - - menu.popup(bridge().window()); - }; - - this.onDrop_ = async event => { - const dt = event.dataTransfer; - const createFileURL = event.altKey; - - if (dt.types.indexOf('text/x-jop-note-ids') >= 0) { - const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids')); - const linkText = []; - for (let i = 0; i < noteIds.length; i++) { - const note = await Note.load(noteIds[i]); - linkText.push(Note.markdownTag(note)); - } - - this.wrapSelectionWithStrings('', '', '', linkText.join('\n')); - } - - const files = dt.files; - if (!files || !files.length) return; - - const filesToAttach = []; - - for (let i = 0; i < files.length; i++) { - const file = files[i]; - if (!file.path) continue; - filesToAttach.push(file.path); - } - - await this.commandAttachFile(filesToAttach, createFileURL); - }; - - const updateSelectionRange = () => { - if (!this.rawEditor()) { - this.selectionRange_ = null; - return; - } - - const ranges = this.rawEditor() - .getSelection() - .getAllRanges(); - if (!ranges || !ranges.length || !this.state.note) { - this.selectionRange_ = null; - } else { - this.selectionRange_ = ranges[0]; - if (process.platform === 'linux') { - const textRange = this.textOffsetSelection(); - if (textRange.start != textRange.end) { - clipboard.writeText(this.state.note.body.slice( - Math.min(textRange.start, textRange.end), - Math.max(textRange.end, textRange.start)), 'selection'); - } - } - } - }; - - this.aceEditor_selectionChange = () => { - updateSelectionRange(); - }; - - this.aceEditor_focus = () => { - updateSelectionRange(); - }; - - this.externalEditWatcher_noteChange = event => { - if (!this.state.note || !this.state.note.id) return; - if (event.id === this.state.note.id) { - this.scheduleReloadNote(this.props); - } - }; - - this.refreshResource = async event => { - if (!this.state.note || !this.state.note.body) return; - const resourceIds = await Note.linkedResourceIds(this.state.note.body); - if (resourceIds.indexOf(event.id) >= 0) { - shared.clearResourceCache(); - this.lastSetHtml_ = ''; - this.scheduleHtmlUpdate(); - } - }; - - this.noteSearchBar_change = query => { - this.setState({ - localSearch: { - query: query, - selectedIndex: 0, - timestamp: Date.now(), - resultCount: this.state.localSearch.resultCount, - searching: true, - }, - }); - }; - - const noteSearchBarNextPrevious = inc => { - const ls = Object.assign({}, this.state.localSearch); - ls.selectedIndex += inc; - ls.timestamp = Date.now(); - if (ls.selectedIndex < 0) ls.selectedIndex = ls.resultCount - 1; - if (ls.selectedIndex >= ls.resultCount) ls.selectedIndex = 0; - - this.setState({ localSearch: ls }); - }; - - this.noteSearchBar_next = () => { - noteSearchBarNextPrevious(+1); - }; - - this.noteSearchBar_previous = () => { - noteSearchBarNextPrevious(-1); - }; - - this.noteSearchBar_close = () => { - this.setState({ - showLocalSearch: false, - }); - }; - - this.titleField_keyDown = this.titleField_keyDown.bind(this); - this.webview_ipcMessage = this.webview_ipcMessage.bind(this); - this.webview_domReady = this.webview_domReady.bind(this); - this.noteRevisionViewer_onBack = this.noteRevisionViewer_onBack.bind(this); - this.noteToolbar_buttonClick = this.noteToolbar_buttonClick.bind(this); - } - - noteToolbar_buttonClick(event) { - const cases = { - - 'startExternalEditing': () => { - this.commandStartExternalEditing(); - }, - - 'stopExternalEditing': () => { - this.commandStopExternalEditing(); - }, - - 'setTags': () => { - this.commandSetTags(); - }, - - 'setAlarm': () => { - this.commandSetAlarm(); - }, - - 'showRevisions': () => { - this.setState({ showRevisions: true }); - }, - }; - - if (!cases[event.name]) throw new Error(`Unsupported event: ${event.name}`); - - cases[event.name](); - } - - // Note: - // - What's called "cursor position" is expressed as { row: x, column: y } and is how Ace Editor get/set the cursor position - // - A "range" defines a selection with a start and end cusor position, expressed as { start: , end: } - // - A "text offset" below is the absolute position of the cursor in the string, as would be used in the indexOf() function. - // The functions below are used to convert between the different types. - rangeToTextOffsets(range, body) { - return { - start: this.cursorPositionToTextOffset(range.start, body), - end: this.cursorPositionToTextOffset(range.end, body), - }; - } - - currentTextOffset() { - return this.cursorPositionToTextOffset(this.editor_.editor.getCursorPosition(), this.state.note.body); - } - - cursorPositionToTextOffset(cursorPos, body) { - if (!this.editor_ || !this.editor_.editor || !this.state.note || !this.state.note.body) return 0; - - const noteLines = body.split('\n'); - - let pos = 0; - for (let i = 0; i < noteLines.length; i++) { - if (i > 0) pos++; // Need to add the newline that's been removed in the split() call above - - if (i === cursorPos.row) { - pos += cursorPos.column; - break; - } else { - pos += noteLines[i].length; - } - } - - return pos; - } - - textOffsetToCursorPosition(offset, body) { - const lines = body.split('\n'); - let row = 0; - let currentOffset = 0; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (currentOffset + line.length >= offset) { - return { - row: row, - column: offset - currentOffset, - }; - } - - row++; - currentOffset += line.length + 1; - } - } - - markupToHtml() { - if (this.markupToHtml_) return this.markupToHtml_; - - this.markupToHtml_ = markupLanguageUtils.newMarkupToHtml({ - resourceBaseUrl: `file://${Setting.value('resourceDir')}/`, - }); - - return this.markupToHtml_; - } - - async UNSAFE_componentWillMount() { - // If the note has been modified in another editor, wait for it to be saved - // before loading it in this editor. This is particularly relevant when - // switching layout from WYSIWYG to this editor before the note has finished saving. - while (this.props.noteId && this.props.editorNoteStatuses[this.props.noteId] === 'saving') { - console.info('Waiting for note to be saved...', this.props.editorNoteStatuses); - await time.msleep(100); - } - - let note = null; - let noteTags = []; - - if (this.props.noteId) { - note = await Note.load(this.props.noteId); - noteTags = this.props.noteTags || []; - await this.handleResourceDownloadMode(note); - } else { - console.warn('Trying to load a note with no ID - should no longer be possible'); - } - - const folder = note ? Folder.byId(this.props.folders, note.parent_id) : null; - - this.setState({ - lastSavedNote: Object.assign({}, note), - note: note, - folder: folder, - isLoading: false, - noteTags: noteTags, - }); - - this.lastLoadedNoteId_ = note ? note.id : null; - - this.updateHtml(note ? note.markup_language : null, note && note.body ? note.body : ''); - - eventManager.on('alarmChange', this.onAlarmChange_); - eventManager.on('noteTypeToggle', this.onNoteTypeToggle_); - eventManager.on('todoToggle', this.onTodoToggle_); - - shared.installResourceHandling(this.refreshResource); - - ExternalEditWatcher.instance().on('noteChange', this.externalEditWatcher_noteChange); - } - - componentWillUnmount() { - this.saveIfNeeded(); - - this.markupToHtml_ = null; - - eventManager.removeListener('alarmChange', this.onAlarmChange_); - eventManager.removeListener('noteTypeToggle', this.onNoteTypeToggle_); - eventManager.removeListener('todoToggle', this.onTodoToggle_); - - shared.uninstallResourceHandling(this.refreshResource); - - ExternalEditWatcher.instance().off('noteChange', this.externalEditWatcher_noteChange); - } - - componentDidUpdate() { - const currentNoteId = this.state.note ? this.state.note.id : null; - if (this.lastComponentUpdateNoteId_ !== currentNoteId && this.editor_) { - this.editor_.editor.getSession().setMode(new CustomMdMode()); - const undoManager = this.editor_.editor.getSession().getUndoManager(); - undoManager.reset(); - this.editor_.editor.getSession().setUndoManager(undoManager); - this.lastComponentUpdateNoteId_ = currentNoteId; - } - } - - webviewRef() { - if (!this.webviewRef_.current || !this.webviewRef_.current.wrappedInstance) return null; - if (!this.webviewRef_.current.wrappedInstance.domReady()) return null; - return this.webviewRef_.current.wrappedInstance; - } - - async saveIfNeeded(saveIfNewNote = false, options = {}) { - if (this.state.loading) return; - - const forceSave = saveIfNewNote && (this.state.note && !this.state.note.id); - - if (this.scheduleSaveTimeout_) clearTimeout(this.scheduleSaveTimeout_); - this.scheduleSaveTimeout_ = null; - if (!forceSave) { - if (!shared.isModified(this)) return; - } - await shared.saveNoteButton_press(this, null, options); - - ExternalEditWatcher.instance().updateNoteFile(this.state.note); - } - - async saveOneProperty(name, value) { - if (this.state.note && !this.state.note.id) { - const note = Object.assign({}, this.state.note); - note[name] = value; - this.setState({ note: note }); - this.scheduleSave(); - } else { - await shared.saveOneProperty(this, name, value); - } - } - - scheduleSave() { - if (this.scheduleSaveTimeout_) clearTimeout(this.scheduleSaveTimeout_); - this.scheduleSaveTimeout_ = setTimeout(() => { - this.saveIfNeeded(); - }, 500); - } - - scheduleReloadNote(props, options = null) { - if (this.scheduleReloadNoteIID_) { - clearTimeout(this.scheduleReloadNoteIID_); - this.scheduleReloadNoteIID_ = null; - } - - this.scheduleReloadNoteIID_ = setTimeout(() => { - this.reloadNote(props, options); - }, 10); - } - - // Generally, reloadNote() should not be called directly so that it's not called multiple times - // from multiple places within a short interval of time. Instead use scheduleReloadNote() to - // delay reloading a bit and make sure that only one reload operation is performed. - async reloadNote(props, options = null) { - if (!options) options = {}; - if (!('noReloadIfLocalChanges' in options)) options.noReloadIfLocalChanges = false; - - await this.saveIfNeeded(); - - const defer = () => { - this.setState({ loading: false }); - }; - - this.setState({ loading: true }); - - const stateNoteId = this.state.note ? this.state.note.id : null; - const noteId = props.noteId; - let parentFolder = null; - const isProvisionalNote = this.props.provisionalNoteIds.includes(noteId); - - let scrollPercent = this.props.lastEditorScrollPercents[noteId]; - if (!scrollPercent) scrollPercent = 0; - - const loadingNewNote = stateNoteId !== noteId; - this.lastLoadedNoteId_ = noteId; - const note = noteId ? await Note.load(noteId) : null; - if (noteId !== this.lastLoadedNoteId_) return defer(); // Race condition - current note was changed while this one was loading - if (options.noReloadIfLocalChanges && this.isModified()) return defer(); - - // If the note hasn't been changed, exit now - if (this.state.note && note) { - const diff = Note.diffObjects(this.state.note, note); - delete diff.type_; - if (!Object.getOwnPropertyNames(diff).length) return defer(); - } - - this.markupToHtml_ = null; - - // If we are loading nothing (noteId == null), make sure to - // set webviewReady to false too because the webview component - // is going to be removed in render(). - const webviewReady = !!this.webviewRef_.current && this.state.webviewReady && !!noteId; - - // Scroll back to top when loading new note - if (loadingNewNote) { - shared.clearResourceCache(); - - this.editorMaxScrollTop_ = 0; - - // HACK: To go around a bug in Ace editor, we first set the scroll position to 1 - // and then (in the renderer callback) to the value we actually need. The first - // operation helps clear the scroll position cache. See: - // https://github.com/ajaxorg/ace/issues/2195 - this.editorSetScrollTop(1); - this.restoreScrollTop_ = 0; - - // Only force focus on notes when creating a new note/todo - if (isProvisionalNote) { - const focusSettingName = note.is_todo ? 'newTodoFocus' : 'newNoteFocus'; - - requestAnimationFrame(() => { - if (Setting.value(focusSettingName) === 'title') { - if (this.titleField_) this.titleField_.focus(); - } else { - if (this.editor_) this.editor_.editor.focus(); - } - }); - } - - if (this.editor_) { - this.editor_.editor.clearSelection(); - this.editor_.editor.moveCursorTo(0, 0); - - setTimeout(() => { - // If we have an anchor hash, jump to that anchor - if (this.props.selectedNoteHash) { - this.webviewRef_.current.wrappedInstance.send('scrollToHash', this.props.selectedNoteHash); - } else { - // Otherwise restore the normal scroll position - this.setEditorPercentScroll(scrollPercent ? scrollPercent : 0); - this.setViewerPercentScroll(scrollPercent ? scrollPercent : 0); - } - }, 10); - } - - await this.handleResourceDownloadMode(note); - } - - if (note) { - parentFolder = Folder.byId(props.folders, note.parent_id); - } - - const newState = { - note: note, - lastSavedNote: Object.assign({}, note), - webviewReady: webviewReady, - folder: parentFolder, - lastKeys: [], - showRevisions: false, - }; - - if (!note) { - newState.newAndNoTitleChangeNoteId = null; - } else if (note.id !== this.state.newAndNoTitleChangeNoteId) { - newState.newAndNoTitleChangeNoteId = null; - } - - if (!note || loadingNewNote) { - newState.showLocalSearch = false; - newState.localSearch = Object.assign({}, this.localSearchDefaultState); - } - - this.lastSetHtml_ = ''; - this.lastSetMarkers_ = ''; - this.lastSetMarkersOptions_ = {}; - - this.setState(newState); - - // if (newState.note) await shared.refreshAttachedResources(this, newState.note.body); - - await this.updateHtml(newState.note ? newState.note.markup_language : null, newState.note ? newState.note.body : ''); - - defer(); - } - - async handleResourceDownloadMode(note) { - if (note && note.body && Setting.value('sync.resourceDownloadMode') === 'auto') { - const resourceIds = await Note.linkedResourceIds(note.body); - await ResourceFetcher.instance().markForDownload(resourceIds); - } - } - - async UNSAFE_componentWillReceiveProps(nextProps) { - if ('noteId' in nextProps && nextProps.noteId !== this.props.noteId) { - await this.scheduleReloadNote(nextProps); - } else if ('noteTags' in nextProps && this.areNoteTagsModified(nextProps.noteTags, this.state.noteTags)) { - this.setState({ - noteTags: nextProps.noteTags, - }); - } - - if (nextProps.syncStarted !== this.props.syncStarted && 'syncStarted' in nextProps && !nextProps.syncStarted && !this.isModified()) { - await this.scheduleReloadNote(nextProps, { noReloadIfLocalChanges: true }); - } - - if (nextProps.windowCommand) { - this.doCommand(nextProps.windowCommand); - } - } - - isModified() { - return shared.isModified(this); - } - - areNoteTagsModified(newTags, oldTags) { - if (!NOTE_TAG_BAR_FEATURE_ENABLED) return false; - - if (!oldTags) return true; - - if (newTags.length !== oldTags.length) return true; - - for (let i = 0; i < newTags.length; ++i) { - let found = false; - const currNewTag = newTags[i]; - for (let j = 0; j < oldTags.length; ++j) { - const currOldTag = oldTags[j]; - if (currOldTag.id === currNewTag.id) { - found = true; - if (currOldTag.updated_time !== currNewTag.updated_time) { - return true; - } - break; - } - } - if (!found) { - return true; - } - } - - return false; - } - - canDisplayTagBar() { - if (!NOTE_TAG_BAR_FEATURE_ENABLED) { - return false; - } - - if (!this.state.noteTags || this.state.noteTags.length === 0) { - return false; - } - - return true; - } - - async noteRevisionViewer_onBack() { - // When coming back from the revision viewer, the webview has been - // unmounted so will need to reload. We set webviewReady to false - // to make sure everything is reloaded as expected. - this.setState({ showRevisions: false, webviewReady: false }, () => { - this.lastSetHtml_ = ''; - this.scheduleReloadNote(this.props); - }); - } - - title_changeText(event) { - shared.noteComponent_change(this, 'title', event.target.value); - this.setState({ newAndNoTitleChangeNoteId: null }); - this.scheduleSave(); - } - - toggleIsTodo_onPress() { - shared.toggleIsTodo_onPress(this); - this.scheduleSave(); - } - - async webview_ipcMessage(event) { - const msg = event.channel ? event.channel : ''; - const args = event.args; - const arg0 = args && args.length >= 1 ? args[0] : null; - - if (msg !== 'percentScroll') console.info(`Got ipc-message: ${msg}`, args); - - if (msg.indexOf('checkboxclick:') === 0) { - // Ugly hack because setting the body here will make the scrollbar - // go to some random position. So we save the scrollTop here and it - // will be restored after the editor ref has been reset, and the - // "afterRender" event has been called. - this.restoreScrollTop_ = this.editorScrollTop(); - - const newBody = shared.toggleCheckbox(msg, this.state.note.body); - this.saveOneProperty('body', newBody); - } else if (msg.indexOf('error:') === 0) { - const s = msg.split(':'); - s.splice(0, 1); - reg.logger().error(s.join(':')); - } else if (msg === 'setMarkerCount') { - const ls = Object.assign({}, this.state.localSearch); - ls.resultCount = arg0; - ls.searching = false; - this.setState({ localSearch: ls }); - } else if (msg.indexOf('markForDownload:') === 0) { - const s = msg.split(':'); - if (s.length < 2) throw new Error(`Invalid message: ${msg}`); - ResourceFetcher.instance().markForDownload(s[1]); - } else if (msg === 'percentScroll') { - this.ignoreNextEditorScroll_ = true; - this.setEditorPercentScroll(arg0); - } else if (msg === 'contextMenu') { - const itemType = arg0 && arg0.type; - - const menu = new Menu(); - - if (itemType === 'image' || itemType === 'resource') { - const resource = await Resource.load(arg0.resourceId); - const resourcePath = Resource.fullPath(resource); - - menu.append( - new MenuItem({ - label: _('Open...'), - click: async () => { - const ok = bridge().openExternal(`file://${resourcePath}`); - if (!ok) bridge().showErrorMessageBox(_('This file could not be opened: %s', resourcePath)); - }, - }) - ); - - menu.append( - new MenuItem({ - label: _('Save as...'), - click: async () => { - const filePath = bridge().showSaveDialog({ - defaultPath: resource.filename ? resource.filename : resource.title, - }); - if (!filePath) return; - await fs.copy(resourcePath, filePath); - }, - }) - ); - - menu.append( - new MenuItem({ - label: _('Copy path to clipboard'), - click: async () => { - clipboard.writeText(toSystemSlashes(resourcePath)); - }, - }) - ); - } else if (itemType === 'text') { - menu.append( - new MenuItem({ - label: _('Copy'), - click: async () => { - clipboard.writeText(arg0.textToCopy); - }, - }) - ); - } else if (itemType === 'link') { - menu.append( - new MenuItem({ - label: _('Copy Link Address'), - click: async () => { - clipboard.writeText(arg0.textToCopy); - }, - }) - ); - } else { - reg.logger().error(`Unhandled item type: ${itemType}`); - return; - } - - menu.popup(bridge().window()); - } else if (msg.indexOf('joplin://') === 0) { - const resourceUrlInfo = urlUtils.parseResourceUrl(msg); - const itemId = resourceUrlInfo.itemId; - const item = await BaseItem.loadItemById(itemId); - - if (!item) throw new Error(`No item with ID ${itemId}`); - - if (item.type_ === BaseModel.TYPE_RESOURCE) { - const localState = await Resource.localState(item); - if (localState.fetch_status !== Resource.FETCH_STATUS_DONE || !!item.encryption_blob_encrypted) { - if (localState.fetch_status === Resource.FETCH_STATUS_ERROR) { - bridge().showErrorMessageBox(`${_('There was an error downloading this attachment:')}\n\n${localState.fetch_error}`); - } else { - bridge().showErrorMessageBox(_('This attachment is not downloaded or not decrypted yet')); - } - return; - } - const filePath = Resource.fullPath(item); - bridge().openItem(filePath); - } else if (item.type_ === BaseModel.TYPE_NOTE) { - this.props.dispatch({ - type: 'FOLDER_AND_NOTE_SELECT', - folderId: item.parent_id, - noteId: item.id, - hash: resourceUrlInfo.hash, - historyNoteAction: { - id: this.state.note.id, - parent_id: this.state.note.parent_id, - }, - }); - } else { - throw new Error(`Unsupported item type: ${item.type_}`); - } - } else if (urlUtils.urlProtocol(msg)) { - if (msg.indexOf('file://') === 0) { - // When using the file:// protocol, openExternal doesn't work (does nothing) with URL-encoded paths - require('electron').shell.openExternal(urlDecode(msg)); - } else { - require('electron').shell.openExternal(msg); - } - } else if (msg.indexOf('#') === 0) { - // This is an internal anchor, which is handled by the WebView so skip this case - } else { - bridge().showErrorMessageBox(_('Unsupported link or message: %s', msg)); - } - } - - editorMaxScroll() { - return this.editorMaxScrollTop_; - } - - editorScrollTop() { - return this.editor_.editor.getSession().getScrollTop(); - } - - editorSetScrollTop(v) { - if (!this.editor_) return; - this.editor_.editor.getSession().setScrollTop(v); - } - - setEditorPercentScroll(p) { - const noteId = this.props.noteId; - - if (noteId) { - this.props.dispatch({ - type: 'EDITOR_SCROLL_PERCENT_SET', - noteId: noteId, - percent: p, - }); - } - - this.editorSetScrollTop(p * this.editorMaxScroll()); - } - - setViewerPercentScroll(p) { - const noteId = this.props.noteId; - - if (noteId) { - this.props.dispatch({ - type: 'EDITOR_SCROLL_PERCENT_SET', - noteId: noteId, - percent: p, - }); - } - - if (this.webviewRef_.current) this.webviewRef_.current.wrappedInstance.send('setPercentScroll', p); - } - - editor_scroll() { - if (this.ignoreNextEditorScroll_) { - this.ignoreNextEditorScroll_ = false; - return; - } - - const m = this.editorMaxScroll(); - const percent = m ? this.editorScrollTop() / m : 0; - - this.setViewerPercentScroll(percent); - } - - webview_domReady() { - - console.info('webview_domReady', this.webviewRef_.current); - if (!this.webviewRef_.current) return; - - this.setState({ - webviewReady: true, - }); - - if (Setting.value('env') === 'dev') { - // this.webviewRef_.current.wrappedInstance.openDevTools(); - } - } - - editor_ref(element) { - if (this.editor_ === element) return; - - if (this.editor_) { - this.editor_.editor.renderer.off('afterRender', this.onAfterEditorRender_); - document.querySelector('#note-editor').removeEventListener('paste', this.onEditorPaste_, true); - document.querySelector('#note-editor').removeEventListener('keydown', this.onEditorKeyDown_); - document.querySelector('#note-editor').removeEventListener('contextmenu', this.onEditorContextMenu_); - this.editor_.editor.indent = this.indentOrig; - } - - this.editor_ = element; - - if (this.editor_) { - this.editor_.editor.renderer.on('afterRender', this.onAfterEditorRender_); - - const cancelledKeys = []; - const letters = ['F', 'T', 'P', 'Q', 'L', ',', 'G', 'K']; - for (let i = 0; i < letters.length; i++) { - const l = letters[i]; - cancelledKeys.push(`Ctrl+${l}`); - cancelledKeys.push(`Command+${l}`); - } - cancelledKeys.push('Alt+E'); - - for (let i = 0; i < cancelledKeys.length; i++) { - const k = cancelledKeys[i]; - this.editor_.editor.commands.bindKey(k, () => { - // HACK: Ace doesn't seem to provide a way to override its shortcuts, but throwing - // an exception from this undocumented function seems to cancel it without any - // side effect. - // https://stackoverflow.com/questions/36075846 - throw new Error(`HACK: Overriding Ace Editor shortcut: ${k}`); - }); - } - - document.querySelector('#note-editor').addEventListener('paste', this.onEditorPaste_, true); - document.querySelector('#note-editor').addEventListener('keydown', this.onEditorKeyDown_); - document.querySelector('#note-editor').addEventListener('contextmenu', this.onEditorContextMenu_); - - const lineLeftSpaces = function(line) { - let output = ''; - for (let i = 0; i < line.length; i++) { - if ([' ', '\t'].indexOf(line[i]) >= 0) { - output += line[i]; - } else { - break; - } - } - return output; - }; - - // Disable Markdown auto-completion (eg. auto-adding a dash after a line with a dash. - // https://github.com/ajaxorg/ace/issues/2754 - const that = this; // The "this" within the function below refers to something else - this.editor_.editor.getSession().getMode().getNextLineIndent = function(state, line) { - const ls = that.state.lastKeys; - if (ls.length >= 2 && ls[ls.length - 1] === 'Enter' && ls[ls.length - 2] === 'Enter') return this.$getIndent(line); - - const leftSpaces = lineLeftSpaces(line); - const lineNoLeftSpaces = line.trimLeft(); - - if (lineNoLeftSpaces.indexOf('- [ ] ') === 0 || lineNoLeftSpaces.indexOf('- [x] ') === 0 || lineNoLeftSpaces.indexOf('- [X] ') === 0) return `${leftSpaces}- [ ] `; - if (lineNoLeftSpaces.indexOf('- ') === 0) return `${leftSpaces}- `; - if (lineNoLeftSpaces.indexOf('* ') === 0 && line.trim() !== '* * *') return `${leftSpaces}* `; - - const bulletNumber = markdownUtils.olLineNumber(lineNoLeftSpaces); - if (bulletNumber) return `${leftSpaces + (bulletNumber + 1)}. `; - - return this.$getIndent(line); - }; - - // Markdown list indentation. (https://github.com/laurent22/joplin/pull/2713) - // If the current line starts with `markup.list` token, - // hitting `Tab` key indents the line instead of inserting tab at cursor. - this.indentOrig = this.editor_.editor.indent; - const indentOrig = this.indentOrig; - this.editor_.editor.indent = function() { - const range = this.getSelectionRange(); - if (range.isEmpty()) { - const row = range.start.row; - const tokens = this.session.getTokens(row); - - if (tokens.length > 0 && tokens[0].type == 'markup.list') { - if (tokens[0].value.search(/\d+\./) != -1) { - // Resets numbered list to 1. - this.session.replace({ start: { row, column: 0 }, end: { row, column: tokens[0].value.length } }, - tokens[0].value.replace(/\d+\./, '1.')); - } - - this.session.indentRows(row, row, '\t'); - return; - } - } - - indentOrig.call(this); - }; - } - } - - aceEditor_change(body) { - shared.noteComponent_change(this, 'body', body); - this.scheduleHtmlUpdate(); - this.scheduleSave(); - } - - scheduleHtmlUpdate(timeout = 500) { - if (this.scheduleHtmlUpdateIID_) { - clearTimeout(this.scheduleHtmlUpdateIID_); - this.scheduleHtmlUpdateIID_ = null; - } - - if (timeout) { - this.scheduleHtmlUpdateIID_ = setTimeout(() => { - this.updateHtml(); - }, timeout); - } else { - this.updateHtml(); - } - } - - async updateHtml(markupLanguage = null, body = null, options = null) { - if (!options) options = {}; - if (!('useCustomCss' in options)) options.useCustomCss = true; - - let bodyToRender = body; - - if (bodyToRender === null) { - bodyToRender = this.state.note && this.state.note.body ? this.state.note.body : ''; - markupLanguage = this.state.note ? this.state.note.markup_language : MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN; - } - - if (!markupLanguage) markupLanguage = MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN; - - const resources = await shared.attachedResources(bodyToRender); - - const theme = themeStyle(this.props.theme); - - const mdOptions = { - codeTheme: theme.codeThemeCss, - postMessageSyntax: 'ipcProxySendToHost', - userCss: options.useCustomCss ? this.props.customCss : '', - resources: resources, - codeHighlightCacheKey: this.state.note ? this.state.note.id : null, - }; - - const visiblePanes = this.props.visiblePanes || ['editor', 'viewer']; - - if (!bodyToRender.trim() && visiblePanes.indexOf('viewer') >= 0 && visiblePanes.indexOf('editor') < 0) { - // Fixes https://github.com/laurent22/joplin/issues/217 - bodyToRender = `${_('This note has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout'))}`; - } - - const result = await this.markupToHtml().render(markupLanguage, bodyToRender, theme, mdOptions); - - this.setState({ - bodyHtml: result.html, - lastRenderPluginAssets: result.pluginAssets, - }); - } - - titleField_keyDown(event) { - const keyCode = event.keyCode; - - if (keyCode === 9) { - // TAB - event.preventDefault(); - - if (event.shiftKey) { - this.props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'focusElement', - target: 'noteList', - }); - } else { - this.props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'focusElement', - target: 'noteBody', - }); - } - } - } - - async doCommand(command) { - if (!command) return; - - let fn = null; - let args = null; - - if (command.name === 'exportPdf') { - fn = this.commandSavePdf; - args = { noteIds: command.noteIds }; - } else if (command.name === 'print') { - fn = this.commandPrint; - } - - if (this.state.note) { - if (command.name === 'textBold') { - fn = this.commandTextBold; - } else if (command.name === 'textItalic') { - fn = this.commandTextItalic; - } else if (command.name === 'textLink') { - fn = this.commandTextLink; - } else if (command.name === 'insertDateTime') { - fn = this.commandDateTime; - } else if (command.name === 'commandStartExternalEditing') { - fn = this.commandStartExternalEditing; - } else if (command.name === 'commandStopExternalEditing') { - fn = this.commandStopExternalEditing; - } else if (command.name === 'showLocalSearch') { - fn = this.commandShowLocalSearch; - } else if (command.name === 'textCode') { - fn = this.commandTextCode; - } else if (command.name === 'insertTemplate') { - fn = () => { - return this.commandTemplate(command.value); - }; - } - } - - if (command.name === 'focusElement' && command.target === 'noteTitle') { - fn = () => { - if (!this.titleField_) return; - this.titleField_.focus(); - }; - } - - if (command.name === 'focusElement' && command.target === 'noteBody') { - fn = () => { - if (!this.editor_) return; - this.editor_.editor.focus(); - }; - } - - if (!fn) return; - - this.props.dispatch({ - type: 'WINDOW_COMMAND', - name: null, - }); - - requestAnimationFrame(() => { - fn = fn.bind(this); - fn(args); - }); - } - - commandShowLocalSearch() { - if (this.state.showLocalSearch) { - this.noteSearchBar_.current.wrappedInstance.focus(); - } else { - this.setState({ - showLocalSearch: true, - localSearch: Object.assign({}, this.localSearchDefaultState) }); - } - - this.props.dispatch({ - type: 'NOTE_VISIBLE_PANES_SET', - panes: ['editor', 'viewer'], - }); - } - - async commandAttachFile(filePaths = null, createFileURL = false) { - if (!filePaths) { - filePaths = bridge().showOpenDialog({ - properties: ['openFile', 'createDirectory', 'multiSelections'], - }); - if (!filePaths || !filePaths.length) return; - } - - await this.saveIfNeeded(true); - let note = await Note.load(this.state.note.id); - - const position = this.currentTextOffset(); - - for (let i = 0; i < filePaths.length; i++) { - const filePath = filePaths[i]; - try { - reg.logger().info(`Attaching ${filePath}`); - note = await shim.attachFileToNote(note, filePath, position, { - createFileURL: createFileURL, - resizeLargeImages: 'ask', - }); - if (!note) { - reg.logger().info('File attachment was cancelled'); - continue; - } - reg.logger().info('File was attached.'); - this.setState({ - note: Object.assign({}, note), - lastSavedNote: Object.assign({}, note), - }); - - this.updateHtml(note.markup_language, note.body); - } catch (error) { - reg.logger().error(error); - bridge().showErrorMessageBox(error.message); - } - } - } - - async commandSetAlarm() { - await this.saveIfNeeded(true); - - this.props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'editAlarm', - noteId: this.state.note.id, - }); - } - - async printTo_(target, options) { - // Concurrent print calls are disallowed to avoid incorrect settings being restored upon completion - if (this.isPrinting_) { - console.log(`Printing ${options.path} to ${target} disallowed, already printing.`); - return; - } - - this.isPrinting_ = true; - - // Need to save because the interop service reloads the note from the database - await this.saveIfNeeded(); - - if (target === 'pdf') { - try { - const pdfData = await InteropServiceHelper.exportNoteToPdf(options.noteId, { - printBackground: true, - pageSize: Setting.value('export.pdfPageSize'), - landscape: Setting.value('export.pdfPageOrientation') === 'landscape', - customCss: this.props.customCss, - }); - await shim.fsDriver().writeFile(options.path, pdfData, 'buffer'); - } catch (error) { - console.error(error); - bridge().showErrorMessageBox(error.message); - } - } else if (target === 'printer') { - try { - await InteropServiceHelper.printNote(options.noteId, { - printBackground: true, - customCss: this.props.customCss, - }); - } catch (error) { - console.error(error); - bridge().showErrorMessageBox(error.message); - } - } - this.isPrinting_ = false; - } - - async commandSavePdf(args) { - try { - if (!this.state.note && !args.noteIds) throw new Error('No notes selected for pdf export'); - - const noteIds = args.noteIds ? args.noteIds : [this.state.note.id]; - - let path = null; - if (noteIds.length === 1) { - path = bridge().showSaveDialog({ - filters: [{ name: _('PDF File'), extensions: ['pdf'] }], - defaultPath: await InteropServiceHelper.defaultFilename(noteIds, 'pdf'), - }); - - } else { - path = bridge().showOpenDialog({ - properties: ['openDirectory', 'createDirectory'], - }); - } - - if (!path) return; - - for (let i = 0; i < noteIds.length; i++) { - const note = await Note.load(noteIds[i]); - const folder = Folder.byId(this.props.folders, note.parent_id); - - const pdfPath = (noteIds.length === 1) ? path : - await shim.fsDriver().findUniqueFilename(`${path}/${this.pdfFileName_(note, folder)}`); - - await this.printTo_('pdf', { path: pdfPath, noteId: note.id }); - } - } catch (error) { - bridge().showErrorMessageBox(error.message); - } - } - - async commandPrint() { - try { - if (!this.state.note) throw new Error(_('Only one note can be printed at a time.')); - - await this.printTo_('printer', { noteId: this.state.note.id }); - } catch (error) { - bridge().showErrorMessageBox(error.message); - } - } - - async commandStartExternalEditing() { - await this.saveIfNeeded(true, { - autoTitle: false, - }); - NoteListUtils.startExternalEditing(this.state.note.id); - } - - async commandStopExternalEditing() { - NoteListUtils.stopExternalEditing(this.state.note.id); - } - - async commandSetTags() { - await this.saveIfNeeded(true); - - this.props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'setTags', - noteIds: [this.state.note.id], - }); - } - - // Returns the actual Ace Editor instance (not the React wrapper) - rawEditor() { - return this.editor_ && this.editor_.editor ? this.editor_.editor : null; - } - - updateEditorWithDelay(fn) { - setTimeout(() => { - if (!this.rawEditor()) return; - fn(this.rawEditor()); - }, 10); - } - - lineAtRow(row) { - if (!this.state.note) return ''; - const body = this.state.note.body; - const lines = body.split('\n'); - if (row < 0 || row >= lines.length) return ''; - return lines[row]; - } - - selectedText() { - if (!this.state.note || !this.state.note.body) return ''; - - const selection = this.textOffsetSelection(); - if (!selection || selection.start === selection.end) return ''; - - return this.state.note.body.substr(selection.start, selection.end - selection.start); - } - - editorCopyText() { - clipboard.writeText(this.selectedText()); - } - - editorCutText() { - const selectedText = this.selectedText(); - if (!selectedText) return; - - clipboard.writeText(selectedText); - - const s = this.textOffsetSelection(); - if (!s || s.start === s.end) return ''; - - const s1 = this.state.note.body.substr(0, s.start); - const s2 = this.state.note.body.substr(s.end); - - shared.noteComponent_change(this, 'body', s1 + s2); - - this.updateEditorWithDelay(editor => { - const range = this.selectionRange_; - range.setStart(range.start.row, range.start.column); - range.setEnd(range.start.row, range.start.column); - editor - .getSession() - .getSelection() - .setSelectionRange(range, false); - editor.focus(); - }, 10); - } - - editorPasteText() { - this.wrapSelectionWithStrings(clipboard.readText(), '', '', ''); - } - - selectionRangePreviousLine() { - if (!this.selectionRange_) return ''; - const row = this.selectionRange_.start.row; - return this.lineAtRow(row - 1); - } - - selectionRangeCurrentLine() { - if (!this.selectionRange_) return ''; - const row = this.selectionRange_.start.row; - return this.lineAtRow(row); - } - - textOffsetSelection() { - return this.selectionRange_ ? this.rangeToTextOffsets(this.selectionRange_, this.state.note.body) : null; - } - - wrapSelectionWithStrings(string1, string2 = '', defaultText = '', replacementText = null, byLine = false) { - if (!this.rawEditor() || !this.state.note) return; - - const selection = this.textOffsetSelection(); - - let newBody = this.state.note.body; - - if (selection && selection.start !== selection.end) { - const selectedLines = replacementText !== null ? replacementText : this.state.note.body.substr(selection.start, selection.end - selection.start); - const selectedStrings = byLine ? selectedLines.split(/\r?\n/) : [selectedLines]; - - newBody = this.state.note.body.substr(0, selection.start); - - let startCursorPos, endCursorPos; - - for (let i = 0; i < selectedStrings.length; i++) { - if (byLine == false) { - const start = selectedStrings[i].search(/[^\s]/); - const end = selectedStrings[i].search(/[^\s](?=[\s]*$)/); - newBody += selectedStrings[i].substr(0, start) + string1 + selectedStrings[i].substr(start, end - start + 1) + string2 + selectedStrings[i].substr(end + 1); - // Getting position for correcting offset in highlighted text when surrounded by white spaces - startCursorPos = this.textOffsetToCursorPosition(selection.start + start, newBody); - endCursorPos = this.textOffsetToCursorPosition(selection.start + end + 1, newBody); - - } else { newBody += string1 + selectedStrings[i] + string2; } - - } - - newBody += this.state.note.body.substr(selection.end); - - const r = this.selectionRange_; - - // Because some insertion strings will have newlines, we'll need to account for them - const str1Split = string1.split(/\r?\n/); - - // Add the number of newlines to the row - // and add the length of the final line to the column (for strings with no newlines this is the string length) - - let newRange = {}; - if (!byLine) { - // Correcting offset in Highlighted text when surrounded by white spaces - newRange = { - start: { row: startCursorPos.row, - column: startCursorPos.column + string1.length }, - end: { row: endCursorPos.row, - column: endCursorPos.column + string1.length }, - }; - } else { - newRange = { - start: { row: r.start.row + str1Split.length - 1, - column: r.start.column + str1Split[str1Split.length - 1].length }, - end: { row: r.end.row + str1Split.length - 1, - column: r.end.column + str1Split[str1Split.length - 1].length }, - }; - } - - if (replacementText !== null) { - const diff = replacementText.length - (selection.end - selection.start); - newRange.end.column += diff; - } - - this.updateEditorWithDelay(editor => { - const range = this.selectionRange_; - range.setStart(newRange.start.row, newRange.start.column); - range.setEnd(newRange.end.row, newRange.end.column); - editor - .getSession() - .getSelection() - .setSelectionRange(range, false); - editor.focus(); - }); - } else { - const middleText = replacementText !== null ? replacementText : defaultText; - const textOffset = this.currentTextOffset(); - const s1 = this.state.note.body.substr(0, textOffset); - const s2 = this.state.note.body.substr(textOffset); - newBody = s1 + string1 + middleText + string2 + s2; - - const p = this.textOffsetToCursorPosition(textOffset + string1.length, newBody); - const newRange = { - start: { row: p.row, column: p.column }, - end: { row: p.row, column: p.column + middleText.length }, - }; - - // BUG!! If replacementText contains newline characters, the logic - // to select the new text will not work. - - this.updateEditorWithDelay(editor => { - if (middleText && newRange) { - const range = this.selectionRange_; - range.setStart(newRange.start.row, newRange.start.column); - range.setEnd(newRange.end.row, newRange.end.column); - editor - .getSession() - .getSelection() - .setSelectionRange(range, false); - } else { - for (let i = 0; i < string1.length; i++) { - editor - .getSession() - .getSelection() - .moveCursorRight(); - } - } - editor.focus(); - }, 10); - } - - shared.noteComponent_change(this, 'body', newBody); - this.scheduleHtmlUpdate(); - this.scheduleSave(); - } - - toggleWrapSelection(strings1, strings2, defaultText) { - const selection = this.textOffsetSelection(); - const string = this.state.note.body.substr(selection.start, selection.end - selection.start); - let replaced = false; - for (let i = 0; i < strings1.length; i++) { - if (string.startsWith(strings1[i]) && string.endsWith(strings1[i])) { - this.wrapSelectionWithStrings('', '', '', string.substr(strings1[i].length, selection.end - selection.start - (2 * strings1[i].length))); - replaced = true; - break; - } - } - if (!replaced) { - this.wrapSelectionWithStrings(strings1[0], strings2[0], defaultText); - } - - } - - commandTextBold() { - this.toggleWrapSelection(['**'], ['**'], _('strong text')); - } - - commandTextItalic() { - this.toggleWrapSelection(['*', '_'], ['*', '_'], _('emphasized text')); - } - - commandDateTime() { - this.wrapSelectionWithStrings(time.formatMsToLocal(new Date().getTime())); - } - - commandTextCode() { - const selection = this.textOffsetSelection(); - const string = this.state.note.body.substr(selection.start, selection.end - selection.start); - - // Look for newlines - const match = string.match(/\r?\n/); - - if (match && match.length > 0) { - // Follow the same newline style - if (string.startsWith('```') && string.endsWith('```')) { - this.wrapSelectionWithStrings('', '', '', string.substr(4, selection.end - selection.start - 8)); - } else { - this.wrapSelectionWithStrings(`\`\`\`${match[0]}`, `${match[0]}\`\`\``); - } - } else { - this.toggleWrapSelection(['`'], ['`'], ''); - } - } - - commandTemplate(value) { - this.wrapSelectionWithStrings(TemplateUtils.render(value)); - } - - addListItem(string1, string2 = '', defaultText = '', byLine = false) { - let newLine = '\n'; - const range = this.selectionRange_; - if (!range || (range.start.row === range.end.row && !this.selectionRangeCurrentLine())) { - newLine = ''; - } - this.wrapSelectionWithStrings(newLine + string1, string2, defaultText, null, byLine); - } - - commandTextCheckbox() { - this.addListItem('- [ ] ', '', _('List item'), true); - } - - commandTextListUl() { - this.addListItem('- ', '', _('List item'), true); - } - - // Converting multiple lines to a numbered list will use the same number on each line - // Not ideal, but the rendered text will still be correct. - commandTextListOl() { - let bulletNumber = markdownUtils.olLineNumber(this.selectionRangeCurrentLine()); - if (!bulletNumber) bulletNumber = markdownUtils.olLineNumber(this.selectionRangePreviousLine()); - if (!bulletNumber) bulletNumber = 0; - this.addListItem(`${bulletNumber + 1}. `, '', _('List item'), true); - } - - commandTextHeading() { - this.addListItem('## ','','', true); - } - - commandTextHorizontalRule() { - this.addListItem('* * *'); - } - - async commandTextLink() { - const url = await dialogs.prompt(_('Insert Hyperlink')); - this.wrapSelectionWithStrings('[', `](${url})`); - } - - itemContextMenu() { - const note = this.state.note; - if (!note) return; - - const menu = new Menu(); - - menu.append( - new MenuItem({ - label: _('Attach file'), - click: async () => { - return this.commandAttachFile(); - }, - }) - ); - - menu.append( - new MenuItem({ - label: _('Tags'), - click: async () => { - return this.commandSetTags(); - }, - }) - ); - - if (note.is_todo) { - menu.append( - new MenuItem({ - label: _('Set alarm'), - click: async () => { - return this.commandSetAlarm(); - }, - }) - ); - } - - menu.popup(bridge().window()); - } - - createToolbarItems(note, editorIsVisible) { - const toolbarItems = []; - if (note && this.state.folder && ['Search', 'Tag', 'SmartFilter'].includes(this.props.notesParentType)) { - toolbarItems.push({ - title: _('In: %s', substrWithEllipsis(this.state.folder.title, 0, 16)), - iconName: 'fa-book', - onClick: () => { - this.props.dispatch({ - type: 'FOLDER_AND_NOTE_SELECT', - folderId: this.state.folder.id, - noteId: note.id, - }); - Folder.expandTree(this.props.folders, this.state.folder.parent_id); - }, - }); - } - - if (this.props.historyNotes.length) { - toolbarItems.push({ - tooltip: _('Back'), - iconName: 'fa-arrow-left', - onClick: () => { - if (!this.props.historyNotes.length) return; - - const lastItem = this.props.historyNotes[this.props.historyNotes.length - 1]; - - this.props.dispatch({ - type: 'FOLDER_AND_NOTE_SELECT', - folderId: lastItem.parent_id, - noteId: lastItem.id, - historyNoteAction: 'pop', - }); - }, - }); - } - - if (note.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN && editorIsVisible) { - toolbarItems.push({ - tooltip: _('Bold'), - iconName: 'fa-bold', - onClick: () => { - return this.commandTextBold(); - }, - }); - - toolbarItems.push({ - tooltip: _('Italic'), - iconName: 'fa-italic', - onClick: () => { - return this.commandTextItalic(); - }, - }); - - toolbarItems.push({ - type: 'separator', - }); - - toolbarItems.push({ - tooltip: _('Hyperlink'), - iconName: 'fa-link', - onClick: () => { - return this.commandTextLink(); - }, - }); - - toolbarItems.push({ - tooltip: _('Code'), - iconName: 'fa-code', - onClick: () => { - return this.commandTextCode(); - }, - }); - - toolbarItems.push({ - tooltip: _('Attach file'), - iconName: 'fa-paperclip', - onClick: () => { - return this.commandAttachFile(); - }, - }); - - toolbarItems.push({ - type: 'separator', - }); - - toolbarItems.push({ - tooltip: _('Numbered List'), - iconName: 'fa-list-ol', - onClick: () => { - return this.commandTextListOl(); - }, - }); - - toolbarItems.push({ - tooltip: _('Bulleted List'), - iconName: 'fa-list-ul', - onClick: () => { - return this.commandTextListUl(); - }, - }); - - toolbarItems.push({ - tooltip: _('Checkbox'), - iconName: 'fa-check-square', - onClick: () => { - return this.commandTextCheckbox(); - }, - }); - - toolbarItems.push({ - tooltip: _('Heading'), - iconName: 'fa-header', - onClick: () => { - return this.commandTextHeading(); - }, - }); - - toolbarItems.push({ - tooltip: _('Horizontal Rule'), - iconName: 'fa-ellipsis-h', - onClick: () => { - return this.commandTextHorizontalRule(); - }, - }); - - toolbarItems.push({ - tooltip: _('Insert Date Time'), - iconName: 'fa-calendar-plus-o', - onClick: () => { - return this.commandDateTime(); - }, - }); - - toolbarItems.push({ - type: 'separator', - }); - } - - return toolbarItems; - } - - renderNoNotes(rootStyle) { - const emptyDivStyle = Object.assign( - { - backgroundColor: 'black', - opacity: 0.1, - }, - rootStyle - ); - return
; - } - - renderMultiNotes(rootStyle) { - const theme = themeStyle(this.props.theme); - - const multiNotesButton_click = item => { - if (item.submenu) { - item.submenu.popup(bridge().window()); - } else { - item.click(); - } - }; - - const menu = NoteListUtils.makeContextMenu(this.props.selectedNoteIds, { - notes: this.props.notes, - dispatch: this.props.dispatch, - watchedNoteFiles: this.props.watchedNoteFiles, - }); - - const buttonStyle = Object.assign({}, theme.buttonStyle, { - marginBottom: 10, - }); - - const itemComps = []; - const menuItems = menu.items; - - for (let i = 0; i < menuItems.length; i++) { - const item = menuItems[i]; - if (!item.enabled) continue; - - itemComps.push( - - ); - } - - rootStyle = Object.assign({}, rootStyle, { - paddingTop: rootStyle.paddingLeft, - display: 'inline-flex', - justifyContent: 'center', - }); - - return ( -
-
{itemComps}
-
- ); - } - - render() { - const style = this.props.style; - const note = this.state.note; - const body = note && note.body ? note.body : ''; - const markupLanguage = note ? note.markup_language : MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN; - const theme = themeStyle(this.props.theme); - const visiblePanes = this.props.visiblePanes || ['editor', 'viewer']; - const isTodo = note && !!note.is_todo; - let keyboardMode = this.props.keyboardMode; - if (keyboardMode === 'default' || !keyboardMode) { - keyboardMode = null; - } - - const borderWidth = 1; - - const rootStyle = Object.assign( - { - borderLeft: `${borderWidth}px solid ${theme.dividerColor}`, - boxSizing: 'border-box', - paddingLeft: 10, - paddingRight: 0, - }, - style - ); - - const innerWidth = rootStyle.width - rootStyle.paddingLeft - rootStyle.paddingRight - borderWidth; - - if (this.state.showRevisions && note && note.id) { - rootStyle.paddingRight = rootStyle.paddingLeft; - rootStyle.paddingTop = rootStyle.paddingLeft; - rootStyle.paddingBottom = rootStyle.paddingLeft; - rootStyle.display = 'inline-flex'; - return ( -
- -
- ); - } - - if (this.props.selectedNoteIds.length > 1) { - return this.renderMultiNotes(rootStyle); - } else if (!note || !!note.encryption_applied) { - return this.renderNoNotes(rootStyle); - } - - const titleBarStyle = { - width: innerWidth - rootStyle.paddingLeft, - height: 30, - boxSizing: 'border-box', - marginTop: 10, - marginBottom: 0, - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - }; - - const titleEditorStyle = { - flex: 1, - display: 'inline-block', - paddingTop: 5, - paddingBottom: 5, - paddingLeft: 8, - paddingRight: 8, - marginRight: rootStyle.paddingLeft, - color: theme.textStyle.color, - fontSize: theme.textStyle.fontSize * 1.25 * 1.5, - backgroundColor: theme.backgroundColor, - border: '1px solid', - borderColor: theme.dividerColor, - overflow: 'hidden', - }; - - const toolbarStyle = { - marginTop: 4, - marginBottom: 0, - }; - - const tagStyle = { - marginBottom: 10, - height: 30, - }; - - const searchBarHeight = this.state.showLocalSearch ? 35 : 0; - - let bottomRowHeight = 0; - if (this.canDisplayTagBar()) { - bottomRowHeight = rootStyle.height - titleBarStyle.height - titleBarStyle.marginBottom - titleBarStyle.marginTop - theme.toolbarHeight - toolbarStyle.marginTop - toolbarStyle.marginBottom - tagStyle.height - tagStyle.marginBottom; - } else { - toolbarStyle.marginBottom = tagStyle.marginBottom, - bottomRowHeight = rootStyle.height - titleBarStyle.height - titleBarStyle.marginBottom - titleBarStyle.marginTop - theme.toolbarHeight - toolbarStyle.marginTop - toolbarStyle.marginBottom; - } - - bottomRowHeight -= searchBarHeight; - - const viewerStyle = { - width: Math.floor(innerWidth / 2), - height: bottomRowHeight, - overflow: 'hidden', - float: 'left', - verticalAlign: 'top', - boxSizing: 'border-box', - }; - - const paddingTop = 14; - - const editorStyle = { - width: innerWidth - viewerStyle.width, - height: bottomRowHeight - paddingTop, - overflowY: 'hidden', - float: 'left', - verticalAlign: 'top', - paddingTop: `${paddingTop}px`, - lineHeight: `${theme.textAreaLineHeight}px`, - fontSize: `${theme.editorFontSize}px`, - color: theme.color, - backgroundColor: theme.backgroundColor, - editorTheme: theme.editorTheme, // Defined in theme.js - }; - - if (visiblePanes.indexOf('viewer') < 0) { - // Note: setting webview.display to "none" is currently not supported due - // to this bug: https://github.com/electron/electron/issues/8277 - // So instead setting the width 0. - viewerStyle.width = 0; - editorStyle.width = innerWidth; - } - - if (visiblePanes.indexOf('editor') < 0) { - // Note: Ideally we'd set the display to "none" to take the editor out - // of the DOM but if we do that, certain things won't work, in particular - // things related to scroll, which are based on the editor. See - // editorScrollTop_, restoreScrollTop_, etc. - editorStyle.width = 0; - viewerStyle.width = innerWidth; - } - - if (visiblePanes.indexOf('viewer') >= 0 && visiblePanes.indexOf('editor') >= 0) { - viewerStyle.borderLeft = `1px solid ${theme.dividerColor}`; - } else { - viewerStyle.borderLeft = 'none'; - } - - if (this.state.webviewReady && this.webviewRef_.current) { - const html = this.state.bodyHtml; - - const htmlHasChanged = this.lastSetHtml_ !== html; - if (htmlHasChanged) { - const options = { - pluginAssets: this.state.lastRenderPluginAssets, - downloadResources: Setting.value('sync.resourceDownloadMode'), - }; - this.webviewRef_.current.wrappedInstance.send('setHtml', html, options); - this.lastSetHtml_ = html; - } - - let keywords = []; - const markerOptions = {}; - - if (this.state.showLocalSearch) { - keywords = [ - { - type: 'text', - value: this.state.localSearch.query, - accuracy: 'partially', - }, - ]; - markerOptions.selectedIndex = this.state.localSearch.selectedIndex; - markerOptions.separateWordSearch = false; - markerOptions.searchTimestamp = this.state.localSearch.timestamp; - } else { - const search = BaseModel.byId(this.props.searches, this.props.selectedSearchId); - if (search) { - const parsedQuery = SearchEngine.instance().parseQuery(search.query_pattern); - keywords = SearchEngine.instance().allParsedQueryTerms(parsedQuery); - } - } - - const keywordHash = JSON.stringify(keywords); - if (htmlHasChanged || keywordHash !== this.lastSetMarkers_ || !ObjectUtils.fieldsEqual(this.lastSetMarkersOptions_, markerOptions)) { - this.lastSetMarkers_ = keywordHash; - this.lastSetMarkersOptions_ = Object.assign({}, markerOptions); - this.webviewRef_.current.wrappedInstance.send('setMarkers', keywords, markerOptions); - } - } - - const editorIsVisible = visiblePanes.indexOf('editor') >= 0; - const toolbarItems = this.createToolbarItems(note, editorIsVisible); - - const toolbar = ; - - const titleEditor = ( - { - this.titleField_ = elem; - }} - style={titleEditorStyle} - value={note && note.title ? note.title : ''} - onChange={event => { - this.title_changeText(event); - }} - onKeyDown={this.titleField_keyDown} - placeholder={this.props.provisionalNoteIds.includes(note.id) ? _('Creating new %s...', isTodo ? _('to-do') : _('note')) : ''} - /> - ); - - const tagList = this.canDisplayTagBar() ? : null; - - const titleBarMenuButton = ( - { - this.itemContextMenu(); - }} - /> - ); - - const titleBarDate = {time.formatMsToLocal(note.user_updated_time)}; - - const viewer = ; - - const editorRootStyle = Object.assign({}, editorStyle); - delete editorRootStyle.width; - delete editorRootStyle.height; - delete editorRootStyle.fontSize; - const onBeforeLoad = (ace) => { - const save = () => { - this.saveIfNeeded(); - }; - const VimApi = ace.acequire('ace/keyboard/vim'); - if (VimApi.CodeMirror && VimApi.CodeMirror.Vim) { - VimApi.CodeMirror.Vim.defineEx('write', 'w', save); - } - }; - const onLoad = () => {}; - const editor = ( - { - this.editor_scroll(); - }} - ref={elem => { - this.editor_ref(elem); - }} - onChange={body => { - this.aceEditor_change(body); - }} - showPrintMargin={false} - onSelectionChange={this.aceEditor_selectionChange} - onFocus={this.aceEditor_focus} - readOnly={visiblePanes.indexOf('editor') < 0} - // Enable/Disable the autoclosing braces - setOptions={{ - behavioursEnabled: Setting.value('editor.autoMatchingBraces'), - useSoftTabs: false }} - // Disable warning: "Automatically scrolling cursor into view after - // selection change this will be disabled in the next version set - // editor.$blockScrolling = Infinity to disable this message" - editorProps={{ $blockScrolling: Infinity }} - // This is buggy (gets outside the container) - highlightActiveLine={false} - keyboardHandler={keyboardMode} - onBeforeLoad={onBeforeLoad} - onLoad={onLoad} - /> - ); - - const noteSearchBarComp = !this.state.showLocalSearch ? null : ( - - ); - - return ( -
-
- {titleEditor} - {titleBarDate} - {false ? titleBarMenuButton : null} -
-
- {toolbar} - -
- {tagList} - {editor} - {viewer} -
- {noteSearchBarComp} -
- ); - } -} - -const mapStateToProps = state => { - return { - noteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null, - notes: state.notes, - selectedNoteIds: state.selectedNoteIds, - selectedNoteHash: state.selectedNoteHash, - editorNoteStatuses: state.editorNoteStatuses, - noteTags: state.selectedNoteTags, - folderId: state.selectedFolderId, - itemType: state.selectedItemType, - folders: state.folders, - theme: state.settings.theme, - syncStarted: state.syncStarted, - windowCommand: state.windowCommand, - notesParentType: state.notesParentType, - searches: state.searches, - selectedSearchId: state.selectedSearchId, - watchedNoteFiles: state.watchedNoteFiles, - customCss: state.customCss, - lastEditorScrollPercents: state.lastEditorScrollPercents, - historyNotes: state.historyNotes, - templates: state.templates, - provisionalNoteIds: state.provisionalNoteIds, - }; -}; - -const NoteText = connect(mapStateToProps)(NoteTextComponent); - -module.exports = { NoteText }; diff --git a/ElectronClient/gui/NoteToolbar/NoteToolbar.tsx b/ElectronClient/gui/NoteToolbar/NoteToolbar.tsx index 0483c8e421..f7c4346787 100644 --- a/ElectronClient/gui/NoteToolbar/NoteToolbar.tsx +++ b/ElectronClient/gui/NoteToolbar/NoteToolbar.tsx @@ -1,17 +1,12 @@ import * as React from 'react'; - +const { connect } = require('react-redux'); const { buildStyle } = require('../../theme.js'); const Toolbar = require('../Toolbar.min.js'); const Note = require('lib/models/Note'); +const Folder = require('lib/models/Folder'); const { time } = require('lib/time-utils.js'); const { _ } = require('lib/locale'); - - - - -// const { substrWithEllipsis } = require('lib/string-utils'); -// const Folder = require('lib/models/Folder'); -// const { MarkupToHtml } = require('lib/joplin-renderer'); +const { substrWithEllipsis } = require('lib/string-utils'); interface ButtonClickEvent { name: string, @@ -20,10 +15,14 @@ interface ButtonClickEvent { interface NoteToolbarProps { theme: number, style: any, + selectedFolderId: string, + folders: any[], watchedNoteFiles: string[], + notesParentType: string, note: any, dispatch: Function, onButtonClick(event:ButtonClickEvent):void, + historyNotes: any[], } function styles_(props:NoteToolbarProps) { @@ -31,50 +30,52 @@ function styles_(props:NoteToolbarProps) { return { root: { ...props.style, - + borderBottom: 'none', }, }; }); } -function useToolbarItems(note:any, watchedNoteFiles:string[], dispatch:Function, onButtonClick:Function) { +function useToolbarItems(props:NoteToolbarProps) { + const { note, selectedFolderId, folders, watchedNoteFiles, notesParentType, dispatch, onButtonClick, historyNotes } = props; + const toolbarItems = []; - // TODO: add these two items + const folder = Folder.byId(folders, selectedFolderId); - // if (props.folder && ['Search', 'Tag', 'SmartFilter'].includes(props.notesParentType)) { - // toolbarItems.push({ - // title: _('In: %s', substrWithEllipsis(props.folder.title, 0, 16)), - // iconName: 'fa-book', - // onClick: () => { - // props.dispatch({ - // type: 'FOLDER_AND_NOTE_SELECT', - // folderId: props.folder.id, - // noteId: props.formNote.id, - // }); - // Folder.expandTree(props.folders, props.folder.parent_id); - // }, - // }); - // } + if (folder && ['Search', 'Tag', 'SmartFilter'].includes(notesParentType)) { + toolbarItems.push({ + title: _('In: %s', substrWithEllipsis(folder.title, 0, 16)), + iconName: 'fa-book', + onClick: () => { + props.dispatch({ + type: 'FOLDER_AND_NOTE_SELECT', + folderId: folder.id, + noteId: note.id, + }); + Folder.expandTree(folders, folder.parent_id); + }, + }); + } - // if (props.historyNotes.length) { - // toolbarItems.push({ - // tooltip: _('Back'), - // iconName: 'fa-arrow-left', - // onClick: () => { - // if (!props.historyNotes.length) return; + if (historyNotes.length) { + toolbarItems.push({ + tooltip: _('Back'), + iconName: 'fa-arrow-left', + onClick: () => { + if (!historyNotes.length) return; - // const lastItem = props.historyNotes[props.historyNotes.length - 1]; + const lastItem = historyNotes[historyNotes.length - 1]; - // props.dispatch({ - // type: 'FOLDER_AND_NOTE_SELECT', - // folderId: lastItem.parent_id, - // noteId: lastItem.id, - // historyNoteAction: 'pop', - // }); - // }, - // }); - // } + dispatch({ + type: 'FOLDER_AND_NOTE_SELECT', + folderId: lastItem.parent_id, + noteId: lastItem.id, + historyNoteAction: 'pop', + }); + }, + }); + } if (watchedNoteFiles.indexOf(note.id) >= 0) { toolbarItems.push({ @@ -137,12 +138,20 @@ function useToolbarItems(note:any, watchedNoteFiles:string[], dispatch:Function, return toolbarItems; } -export default function NoteToolbar(props:NoteToolbarProps) { +function NoteToolbar(props:NoteToolbarProps) { const styles = styles_(props); - - const toolbarItems = useToolbarItems(props.note, props.watchedNoteFiles, props.dispatch, props.onButtonClick); - - return ( - - ); + const toolbarItems = useToolbarItems(props); + return ; } + +const mapStateToProps = (state:any) => { + return { + selectedFolderId: state.selectedFolderId, + folders: state.folders, + watchedNoteFiles: state.watchedNoteFiles, + historyNotes: state.historyNotes, + notesParentType: state.notesParentType, + }; +}; + +export default connect(mapStateToProps)(NoteToolbar); diff --git a/ElectronClient/gui/Toolbar.jsx b/ElectronClient/gui/Toolbar.jsx index 48400760e1..25e82526d0 100644 --- a/ElectronClient/gui/Toolbar.jsx +++ b/ElectronClient/gui/Toolbar.jsx @@ -6,13 +6,15 @@ const ToolbarSpace = require('./ToolbarSpace.min.js'); class ToolbarComponent extends React.Component { render() { - const style = Object.assign({}, this.props.style); const theme = themeStyle(this.props.theme); - style.height = theme.toolbarHeight; - style.display = 'flex'; - style.flexDirection = 'row'; - style.borderBottom = `1px solid ${theme.dividerColor}`; - style.boxSizing = 'border-box'; + + const style = Object.assign({ + height: theme.toolbarHeight, + display: 'flex', + flexDirection: 'row', + borderBottom: `1px solid ${theme.dividerColor}`, + boxSizing: 'border-box', + }, this.props.style); const itemComps = []; diff --git a/ElectronClient/gui/utils/NoteText.js b/ElectronClient/gui/utils/NoteText.js deleted file mode 100644 index 0f26a1f4da..0000000000 --- a/ElectronClient/gui/utils/NoteText.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict'; -Object.defineProperty(exports, '__esModule', { value: true }); -const joplinRendererUtils = require('lib/joplin-renderer').utils; -const Resource = require('lib/models/Resource'); -function resourcesStatus(resourceInfos) { - let lowestIndex = joplinRendererUtils.resourceStatusIndex('ready'); - for (const id in resourceInfos) { - const s = joplinRendererUtils.resourceStatus(Resource, resourceInfos[id]); - const idx = joplinRendererUtils.resourceStatusIndex(s); - if (idx < lowestIndex) { lowestIndex = idx; } - } - return joplinRendererUtils.resourceStatusName(lowestIndex); -} -exports.resourcesStatus = resourcesStatus; -// # sourceMappingURL=NoteText.js.map