diff --git a/.eslintignore b/.eslintignore index 2de1de3a14..936cf5ec6f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -53,9 +53,16 @@ ReactNativeClient/lib/joplin-renderer/assets/ ReactNativeClient/lib/rnInjectedJs/ # AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD +ElectronClient/gui/editors/PlainEditor.js +ElectronClient/gui/editors/TinyMCE.js +ElectronClient/gui/MultiNoteActions.js ElectronClient/gui/NoteContentPropertiesDialog.js +ElectronClient/gui/NoteText2.js ElectronClient/gui/ResourceScreen.js ElectronClient/gui/ShareNoteDialog.js +ElectronClient/gui/utils/NoteText.js +ReactNativeClient/lib/AsyncActionQueue.js +ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/fence.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js ReactNativeClient/lib/JoplinServerApi.js diff --git a/.eslintrc.js b/.eslintrc.js index 6d25e87a46..858f521293 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -32,6 +32,8 @@ module.exports = { 'browserSupportsPromises_': true, 'chrome': 'readonly', 'browser': 'readonly', + + 'tinymce': 'readonly', }, 'parserOptions': { 'ecmaVersion': 2018, @@ -56,7 +58,7 @@ module.exports = { // Checks rules of Hooks "react-hooks/rules-of-hooks": "error", // Checks effect dependencies - "react-hooks/exhaustive-deps": "error", + "react-hooks/exhaustive-deps": "warn", // ------------------------------- // Formatting diff --git a/.gitignore b/.gitignore index 353e773164..7cab88f7d9 100644 --- a/.gitignore +++ b/.gitignore @@ -50,9 +50,16 @@ Tools/commit_hook.txt *.map # AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD +ElectronClient/gui/editors/PlainEditor.js +ElectronClient/gui/editors/TinyMCE.js +ElectronClient/gui/MultiNoteActions.js ElectronClient/gui/NoteContentPropertiesDialog.js +ElectronClient/gui/NoteText2.js ElectronClient/gui/ResourceScreen.js ElectronClient/gui/ShareNoteDialog.js +ElectronClient/gui/utils/NoteText.js +ReactNativeClient/lib/AsyncActionQueue.js +ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/fence.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js ReactNativeClient/lib/JoplinServerApi.js diff --git a/CliClient/package-lock.json b/CliClient/package-lock.json index 7cf8a2b060..8e62b4decd 100644 --- a/CliClient/package-lock.json +++ b/CliClient/package-lock.json @@ -23,9 +23,9 @@ "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==" }, "abab": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.2.tgz", - "integrity": "sha512-2scffjvioEmNz0OyDSLGWDfKCVwaKc6l9Pm9kOIREU13ClXZvHpg/nRL5xyjSSSLhOnXqft2HpsAzNEEA8cFFg==" + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.3.tgz", + "integrity": "sha512-tsFzPpcttalNjFBCFMqsKYQcWxxen1pgJR56by//QwvJc4/OUS3kPOOttx2tSIfjsylB0pYu7f5D3K1RCxUnUg==" }, "abbrev": { "version": "1.1.1", @@ -47,9 +47,9 @@ }, "dependencies": { "acorn": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.3.0.tgz", - "integrity": "sha512-/czfa8BwS88b9gWQVhc8eknunSA2DoJpJyTQkhheIf5E48u1N0R4q/YxxsAeqRrmK9TQ/uYfgLDfZo91UlANIA==" + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.0.tgz", + "integrity": "sha512-gac8OEcQ2Li1dxIEWGZzsp2BitJxwkwcOm0zHAJLcPJaVvm58FRnk6RkuLRpU1EujipU2ZFODv2P9DLMfnV8mw==" } } }, @@ -1604,11 +1604,11 @@ "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, "escodegen": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.12.0.tgz", - "integrity": "sha512-TuA+EhsanGcme5T3R0L80u4t8CpbXQjegRmf7+FPTJrtCTErXFeelblRgHQa1FofEzqYYJmJ/OqjTwREp9qgmg==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.1.tgz", + "integrity": "sha512-Bmt7NcRySdIfNPfU2ZoXDrrXsG9ZjvDxcAlMfDUgRBjLOWTuIACXPBFJH7Z+cLb40JeQco5toikyc9t9P8E9SQ==", "requires": { - "esprima": "^3.1.3", + "esprima": "^4.0.1", "estraverse": "^4.2.0", "esutils": "^2.0.2", "optionator": "^0.8.1", @@ -1624,9 +1624,9 @@ } }, "esprima": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz", - "integrity": "sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=" + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" }, "estraverse": { "version": "4.3.0", @@ -3754,9 +3754,9 @@ "dev": true }, "joplin-turndown": { - "version": "4.0.19", - "resolved": "https://registry.npmjs.org/joplin-turndown/-/joplin-turndown-4.0.19.tgz", - "integrity": "sha512-B9XeR7bjsPWhwevnCk+EN8VQmaesDqGP3sjkk+ROMuNoQAj0p0RMkZB3actv6Ej6Q9EnRJm3JokfM3Ua4TVYvA==", + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/joplin-turndown/-/joplin-turndown-4.0.23.tgz", + "integrity": "sha512-Dh93R7G/S/KRbOu4/+FIxoUcUDcoUL4QDsqGhperOi/cUxUeg8fngrmEzdP8kEpQzqm5+8jkq9Cc1w6695owpQ==", "requires": { "css": "^2.2.4", "html-entities": "^1.2.1", @@ -5509,6 +5509,24 @@ "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=" }, + "relative": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/relative/-/relative-3.0.2.tgz", + "integrity": "sha1-Dc2OxUpdNaPBXhBFA9ZTdbWlNn8=", + "requires": { + "isobject": "^2.0.0" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "requires": { + "isarray": "1.0.0" + } + } + } + }, "remove-bom-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz", diff --git a/CliClient/package.json b/CliClient/package.json index 2d1aa2ecad..1866f95b25 100644 --- a/CliClient/package.json +++ b/CliClient/package.json @@ -55,7 +55,7 @@ "htmlparser2": "^4.1.0", "image-data-uri": "^2.0.0", "image-type": "^3.0.0", - "joplin-turndown": "^4.0.19", + "joplin-turndown": "^4.0.23", "joplin-turndown-plugin-gfm": "^1.0.12", "json-stringify-safe": "^5.0.1", "jssha": "^2.3.0", @@ -89,6 +89,7 @@ "query-string": "4.3.4", "read-chunk": "^2.1.0", "redux": "^3.7.2", + "relative": "^3.0.2", "request": "^2.88.0", "sax": "^1.2.4", "server-destroy": "^1.0.1", diff --git a/CliClient/tests/HtmlToMd.js b/CliClient/tests/HtmlToMd.js index baba195c12..5d8ded29c8 100644 --- a/CliClient/tests/HtmlToMd.js +++ b/CliClient/tests/HtmlToMd.js @@ -39,7 +39,9 @@ describe('HtmlToMd', function() { const htmlPath = `${basePath}/${htmlFilename}`; const mdPath = `${basePath}/${filename(htmlFilename)}.md`; - // if (htmlFilename !== 'table_with_header.html') continue; + // if (htmlFilename !== 'joplin_source_2.html') continue; + + // if (htmlFilename.indexOf('image_preserve_size') !== 0) continue; const htmlToMdOptions = {}; @@ -51,6 +53,10 @@ describe('HtmlToMd', function() { htmlToMdOptions.anchorNames = ['first', 'second', 'fourth']; } + if (htmlFilename.indexOf('image_preserve_size') === 0) { + htmlToMdOptions.preserveImageTagsWithSize = true; + } + const html = await shim.fsDriver().readFile(htmlPath); let expectedMd = await shim.fsDriver().readFile(mdPath); diff --git a/CliClient/tests/MdToHtml.js b/CliClient/tests/MdToHtml.js index 8ab3adfff6..f5e2413c09 100644 --- a/CliClient/tests/MdToHtml.js +++ b/CliClient/tests/MdToHtml.js @@ -80,4 +80,12 @@ describe('MdToHtml', function() { } })); + // it('should write CSS to an external file', asyncTest(async () => { + // const mdToHtml = new MdToHtml({ + // fsDriver: shim.fsDriver(), + // tempDir: Setting.value('tempDir'), + // }); + + // })); + }); diff --git a/CliClient/tests/html_to_md/image_preserve_size_1.html b/CliClient/tests/html_to_md/image_preserve_size_1.html new file mode 100644 index 0000000000..e8b829ca37 --- /dev/null +++ b/CliClient/tests/html_to_md/image_preserve_size_1.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/CliClient/tests/html_to_md/image_preserve_size_1.md b/CliClient/tests/html_to_md/image_preserve_size_1.md new file mode 100644 index 0000000000..4e76340d96 --- /dev/null +++ b/CliClient/tests/html_to_md/image_preserve_size_1.md @@ -0,0 +1 @@ +![](:/0415d61cc33e47afa6dde45948c3177a) \ No newline at end of file diff --git a/CliClient/tests/html_to_md/image_preserve_size_2.html b/CliClient/tests/html_to_md/image_preserve_size_2.html new file mode 100644 index 0000000000..411f35c7bf --- /dev/null +++ b/CliClient/tests/html_to_md/image_preserve_size_2.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/CliClient/tests/html_to_md/image_preserve_size_2.md b/CliClient/tests/html_to_md/image_preserve_size_2.md new file mode 100644 index 0000000000..411f35c7bf --- /dev/null +++ b/CliClient/tests/html_to_md/image_preserve_size_2.md @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/CliClient/tests/html_to_md/joplin_checkboxes.html b/CliClient/tests/html_to_md/joplin_checkboxes.html new file mode 100644 index 0000000000..116609b8af --- /dev/null +++ b/CliClient/tests/html_to_md/joplin_checkboxes.html @@ -0,0 +1,53 @@ + \ No newline at end of file diff --git a/CliClient/tests/html_to_md/joplin_checkboxes.md b/CliClient/tests/html_to_md/joplin_checkboxes.md new file mode 100644 index 0000000000..ba9ba48065 --- /dev/null +++ b/CliClient/tests/html_to_md/joplin_checkboxes.md @@ -0,0 +1,3 @@ +- [x] one +- [ ] two +- [ ] with **bold** text \ No newline at end of file diff --git a/CliClient/tests/html_to_md/joplin_source_1.html b/CliClient/tests/html_to_md/joplin_source_1.html new file mode 100644 index 0000000000..b063e72627 --- /dev/null +++ b/CliClient/tests/html_to_md/joplin_source_1.html @@ -0,0 +1 @@ +
katexcode
f(x)=f^(ξ)e2πiξxdξf(x) = \int_{-\infty}^\infty \hat f(\xi)\,e^{2 \pi i \xi x} \,d\xi
\ No newline at end of file diff --git a/CliClient/tests/html_to_md/joplin_source_1.md b/CliClient/tests/html_to_md/joplin_source_1.md new file mode 100644 index 0000000000..5e53b8c9dc --- /dev/null +++ b/CliClient/tests/html_to_md/joplin_source_1.md @@ -0,0 +1 @@ +$katexcode$ \ No newline at end of file diff --git a/CliClient/tests/html_to_md/joplin_source_2.html b/CliClient/tests/html_to_md/joplin_source_2.html new file mode 100644 index 0000000000..92b3e00f28 --- /dev/null +++ b/CliClient/tests/html_to_md/joplin_source_2.html @@ -0,0 +1 @@ +
katexcode
f(x)=f^(ξ)e2πiξxdξf(x) = \int_{-\infty}^\infty \hat f(\xi)\,e^{2 \pi i \xi x} \,d\xi
\ No newline at end of file diff --git a/CliClient/tests/html_to_md/joplin_source_2.md b/CliClient/tests/html_to_md/joplin_source_2.md new file mode 100644 index 0000000000..3cca001b07 --- /dev/null +++ b/CliClient/tests/html_to_md/joplin_source_2.md @@ -0,0 +1,3 @@ +$$ +katexcode +$$ \ No newline at end of file diff --git a/CliClient/tests/md_to_html/code_block.html b/CliClient/tests/md_to_html/code_block.html new file mode 100644 index 0000000000..80cdd7e46c --- /dev/null +++ b/CliClient/tests/md_to_html/code_block.html @@ -0,0 +1,5 @@ +
function() {
+    console.info('bonjour');
+}
function() {
+    console.info('bonjour');
+}
diff --git a/CliClient/tests/md_to_html/code_block.md b/CliClient/tests/md_to_html/code_block.md new file mode 100644 index 0000000000..ca460a04b7 --- /dev/null +++ b/CliClient/tests/md_to_html/code_block.md @@ -0,0 +1,5 @@ +```javascript +function() { + console.info('bonjour'); +} +``` \ No newline at end of file diff --git a/CliClient/tests/md_to_html/sanitize_8.html b/CliClient/tests/md_to_html/sanitize_8.html index 775977ea1e..13af79dea3 100644 --- a/CliClient/tests/md_to_html/sanitize_8.html +++ b/CliClient/tests/md_to_html/sanitize_8.html @@ -1,2 +1 @@ -
<a href="#" onclick="leavethisalone">testing fence</a>
-
+
<a href="#" onclick="leavethisalone">testing fence</a>
<a href="#" onclick="leavethisalone">testing fence</a>
diff --git a/CliClient/tests/test-utils.js b/CliClient/tests/test-utils.js index d26aedb276..809a802c6e 100644 --- a/CliClient/tests/test-utils.js +++ b/CliClient/tests/test-utils.js @@ -70,6 +70,7 @@ const logDir = `${__dirname}/../tests/logs`; const tempDir = `${__dirname}/../tests/tmp`; fs.mkdirpSync(logDir, 0o755); fs.mkdirpSync(tempDir, 0o755); +fs.mkdirpSync(`${__dirname}/data`); SyncTargetRegistry.addClass(SyncTargetMemory); SyncTargetRegistry.addClass(SyncTargetFilesystem); diff --git a/ElectronClient/.gitignore b/ElectronClient/.gitignore index 5e454a38b5..53f188b4d1 100644 --- a/ElectronClient/.gitignore +++ b/ElectronClient/.gitignore @@ -5,4 +5,5 @@ lib/ gui/*.min.js plugins/*.min.js .DS_Store -gui/note-viewer/pluginAssets/ \ No newline at end of file +gui/note-viewer/pluginAssets/ +pluginAssets/ \ No newline at end of file diff --git a/ElectronClient/app.js b/ElectronClient/app.js index 642e9ba164..892fd303e7 100644 --- a/ElectronClient/app.js +++ b/ElectronClient/app.js @@ -117,7 +117,7 @@ class Application extends BaseApplication { newState = Object.assign({}, state); let command = Object.assign({}, action); delete command.type; - newState.windowCommand = command; + newState.windowCommand = command.name ? command : null; } break; @@ -134,6 +134,8 @@ class Application extends BaseApplication { paneOptions = ['editor', 'both']; } else if (state.settings.layoutButtonSequence === Setting.LAYOUT_VIEWER_SPLIT) { paneOptions = ['viewer', 'both']; + } else if (state.settings.layoutButtonSequence === Setting.LAYOUT_SPLIT_WYSIWYG) { + paneOptions = ['both', 'wysiwyg']; } else { paneOptions = ['editor', 'viewer', 'both']; } diff --git a/ElectronClient/gui/MainScreen.jsx b/ElectronClient/gui/MainScreen.jsx index 34a28d8643..1c762bdc06 100644 --- a/ElectronClient/gui/MainScreen.jsx +++ b/ElectronClient/gui/MainScreen.jsx @@ -4,6 +4,7 @@ const { Header } = require('./Header.min.js'); const { SideBar } = require('./SideBar.min.js'); const { NoteList } = require('./NoteList.min.js'); const { NoteText } = require('./NoteText.min.js'); +const NoteText2 = require('./NoteText2.js').default; const { PromptDialog } = require('./PromptDialog.min.js'); const NoteContentPropertiesDialog = require('./NoteContentPropertiesDialog.js').default; const NotePropertiesDialog = require('./NotePropertiesDialog.min.js'); @@ -632,6 +633,12 @@ class MainScreenComponent extends React.Component { const shareNoteDialogOptions = this.state.shareNoteDialogOptions; const keyboardMode = Setting.value('editor.keyboardMode'); + const isWYSIWYG = this.props.noteVisiblePanes.length && this.props.noteVisiblePanes[0] === 'wysiwyg'; + const noteTextComp = isWYSIWYG ? + + : + ; + return (
{this.state.modalLayer.message}
@@ -648,8 +655,7 @@ class MainScreenComponent extends React.Component { - - + {noteTextComp} {pluginDialog}
); diff --git a/ElectronClient/gui/MultiNoteActions.tsx b/ElectronClient/gui/MultiNoteActions.tsx new file mode 100644 index 0000000000..ec229aef19 --- /dev/null +++ b/ElectronClient/gui/MultiNoteActions.tsx @@ -0,0 +1,73 @@ +import * as React from 'react'; + +const { buildStyle } = require('../theme.js'); +const { bridge } = require('electron').remote.require('./bridge'); +const NoteListUtils = require('./utils/NoteListUtils'); + +interface MultiNoteActionsProps { + theme: number, + selectedNoteIds: string[], + notes: any[], + dispatch: Function, + watchedNoteFiles: string[], + style: any, +} + +function styles_(props:MultiNoteActionsProps) { + return buildStyle('MultiNoteActions', props.theme, (theme:any) => { + return { + root: { + ...props.style, + display: 'inline-flex', + justifyContent: 'center', + paddingTop: theme.marginTop, + }, + itemList: { + display: 'flex', + flexDirection: 'column', + }, + button: { + ...theme.buttonStyle, + marginBottom: 10, + }, + }; + }); +} + +export default function MultiNoteActions(props:MultiNoteActionsProps) { + const styles = styles_(props); + + const multiNotesButton_click = (item:any) => { + if (item.submenu) { + item.submenu.popup(bridge().window()); + } else { + item.click(); + } + }; + + const menu = NoteListUtils.makeContextMenu(props.selectedNoteIds, { + notes: props.notes, + dispatch: props.dispatch, + watchedNoteFiles: props.watchedNoteFiles, + }); + + const itemComps = []; + const menuItems = menu.items; + + for (let i = 0; i < menuItems.length; i++) { + const item = menuItems[i]; + if (!item.enabled) continue; + + itemComps.push( + + ); + } + + return ( +
+
{itemComps}
+
+ ); +} diff --git a/ElectronClient/gui/NoteText.jsx b/ElectronClient/gui/NoteText.jsx index 31a61c3bad..704bb09d1b 100644 --- a/ElectronClient/gui/NoteText.jsx +++ b/ElectronClient/gui/NoteText.jsx @@ -413,6 +413,14 @@ class NoteTextComponent extends React.Component { } 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 = []; @@ -2228,6 +2236,7 @@ const mapStateToProps = state => { notes: state.notes, selectedNoteIds: state.selectedNoteIds, selectedNoteHash: state.selectedNoteHash, + editorNoteStatuses: state.editorNoteStatuses, noteTags: state.selectedNoteTags, folderId: state.selectedFolderId, itemType: state.selectedItemType, diff --git a/ElectronClient/gui/NoteText2.tsx b/ElectronClient/gui/NoteText2.tsx new file mode 100644 index 0000000000..184667dab1 --- /dev/null +++ b/ElectronClient/gui/NoteText2.tsx @@ -0,0 +1,566 @@ +import * as React from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; + +// eslint-disable-next-line no-unused-vars +import TinyMCE, { utils as tinyMceUtils } from './editors/TinyMCE'; +import PlainEditor, { utils as plainEditorUtils } from './editors/PlainEditor'; +import { connect } from 'react-redux'; +import AsyncActionQueue from '../lib/AsyncActionQueue'; +import MultiNoteActions from './MultiNoteActions'; + +// eslint-disable-next-line no-unused-vars +import { DefaultEditorState, OnChangeEvent, TextEditorUtils, EditorCommand } from './utils/NoteText'; +const { themeStyle, buildStyle } = require('../theme.js'); +const { reg } = require('lib/registry.js'); +const { time } = require('lib/time-utils.js'); +const markupLanguageUtils = require('lib/markupLanguageUtils'); +const HtmlToHtml = require('lib/joplin-renderer/HtmlToHtml'); +const Setting = require('lib/models/Setting'); +const { MarkupToHtml } = require('lib/joplin-renderer'); +const HtmlToMd = require('lib/HtmlToMd'); +const { _ } = require('lib/locale'); +const Note = require('lib/models/Note.js'); +const Resource = require('lib/models/Resource.js'); +const { shim } = require('lib/shim'); +const TemplateUtils = require('lib/TemplateUtils'); +const { bridge } = require('electron').remote.require('./bridge'); + +interface NoteTextProps { + style: any, + noteId: string, + theme: number, + dispatch: Function, + selectedNoteIds: string[], + notes:any[], + watchedNoteFiles:string[], + isProvisional: boolean, + editorNoteStatuses: any, + syncStarted: boolean, + editor: string, + windowCommand: any, +} + +interface FormNote { + id: string, + title: string, + parent_id: string, + is_todo: number, + bodyEditorContent?: any, + markup_language: number, + + hasChanged: boolean, + + // Getting the content from the editor can be a slow process because that content + // might need to be serialized first. For that reason, the wrapped editor (eg TinyMCE) + // first emits onWillChange when there is a change. That event does not include the + // editor content. After a few milliseconds (eg if the user stops typing for long + // enough), the editor emits onChange, and that event will include the editor content. + // + // Both onWillChange and onChange events include a changeId property which is used + // to link the two events together. It is used for example to detect if a new note + // was loaded before the current note was saved - in that case the changeId will be + // different. The two properties bodyWillChangeId and bodyChangeId are used to save + // this info with the currently loaded note. + // + // The willChange/onChange events also allow us to handle the case where the user + // types something then quickly switch a different note. In that case, bodyWillChangeId + // is set, thus we know we should save the note, even though we won't receive the + // onChange event. + bodyWillChangeId: number + bodyChangeId: number, + + saveActionQueue: AsyncActionQueue, + + // Note with markup_language = HTML have a block of CSS at the start, which is used + // to preserve the style from the original (web-clipped) page. When sending the note + // content to TinyMCE, we only send the actual HTML, without this CSS. The CSS is passed + // via a file in pluginAssets. This is because TinyMCE would not render the style otherwise. + // However, when we get back the HTML from TinyMCE, we need to reconstruct the original note. + // Since the CSS used by TinyMCE has been lost (since it's in a temp CSS file), we keep that + // original CSS here. It's used in formNoteToNote to rebuild the note body. + // We can keep it here because we know TinyMCE will not modify it anyway. + originalCss: string, +} + +const defaultNote = ():FormNote => { + return { + id: '', + parent_id: '', + title: '', + is_todo: 0, + markup_language: 1, + bodyWillChangeId: 0, + bodyChangeId: 0, + saveActionQueue: null, + originalCss: '', + hasChanged: false, + }; +}; + +function styles_(props:NoteTextProps) { + return buildStyle('NoteText', props.theme, (theme:any) => { + return { + titleInput: { + flex: 1, + display: 'inline-block', + paddingTop: 5, + paddingBottom: 5, + paddingLeft: 8, + paddingRight: 8, + marginRight: theme.paddingLeft, + color: theme.textStyle.color, + fontSize: theme.textStyle.fontSize * 1.25 *1.5, + backgroundColor: theme.backgroundColor, + border: '1px solid', + borderColor: theme.dividerColor, + }, + warningBanner: { + background: theme.warningBackgroundColor, + fontFamily: theme.fontFamily, + padding: 10, + fontSize: theme.fontSize, + }, + tinyMCE: { + width: '100%', + height: '100%', + }, + }; + }); +} + +let textEditorUtils_:TextEditorUtils = null; + +function usePrevious(value:any):any { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; +} + +function initNoteState(n:any, setFormNote:Function, setDefaultEditorState:Function) { + let originalCss = ''; + if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) { + const htmlToHtml = new HtmlToHtml(); + const splitted = htmlToHtml.splitHtml(n.body); + originalCss = splitted.css; + } + + setFormNote({ + id: n.id, + title: n.title, + is_todo: n.is_todo, + parent_id: n.parent_id, + bodyWillChangeId: 0, + bodyChangeId: 0, + markup_language: n.markup_language, + saveActionQueue: new AsyncActionQueue(1000), + originalCss: originalCss, + hasChanged: false, + }); + + setDefaultEditorState({ + value: n.body, + markupLanguage: n.markup_language, + }); +} + +async function htmlToMarkdown(html:string):Promise { + const htmlToMd = new HtmlToMd(); + let md = htmlToMd.parse(html, { preserveImageTagsWithSize: true }); + md = await Note.replaceResourceExternalToInternalLinks(md, { useAbsolutePaths: true }); + return md; +} + +async function formNoteToNote(formNote:FormNote):Promise { + const newNote:any = Object.assign({}, formNote); + + if ('bodyEditorContent' in formNote) { + const html = await textEditorUtils_.editorContentToHtml(formNote.bodyEditorContent); + if (formNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN) { + newNote.body = await htmlToMarkdown(html); + } else { + newNote.body = html; + newNote.body = await Note.replaceResourceExternalToInternalLinks(newNote.body, { useAbsolutePaths: true }); + if (formNote.originalCss) newNote.body = `\n${newNote.body}`; + } + } + + delete newNote.bodyEditorContent; + + return newNote; +} + +async function attachResources() { + const filePaths = bridge().showOpenDialog({ + properties: ['openFile', 'createDirectory', 'multiSelections'], + }); + if (!filePaths || !filePaths.length) return []; + + const output = []; + + for (const filePath of filePaths) { + try { + const resource = await shim.createResourceFromPath(filePath); + output.push({ + item: resource, + markdownTag: Resource.markdownTag(resource), + }); + } catch (error) { + bridge().showErrorMessageBox(error.message); + } + } + + return output; +} + +function scheduleSaveNote(formNote:FormNote, dispatch:Function) { + 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); + await Note.save(note); + + dispatch({ + type: 'EDITOR_NOTE_STATUS_REMOVE', + id: formNote.id, + }); + }; + }; + + formNote.saveActionQueue.push(makeAction(formNote)); +} + +function saveNoteIfWillChange(formNote:FormNote, editorRef:any, dispatch:Function) { + if (!formNote.id || !formNote.bodyWillChangeId) return; + + scheduleSaveNote({ + ...formNote, + bodyEditorContent: editorRef.current.content(), + bodyWillChangeId: 0, + bodyChangeId: 0, + }, dispatch); +} + +function useWindowCommand(windowCommand:any, dispatch:Function, formNote:FormNote, titleInputRef:React.MutableRefObject, editorRef:React.MutableRefObject) { + useEffect(() => { + const command = windowCommand; + if (!command || !formNote) return; + + const editorCmd:EditorCommand = { name: command.name, value: { ...command.value } }; + let fn:Function = null; + + if (command.name === 'exportPdf') { + // TODO + } else if (command.name === 'print') { + // TODO + } else if (command.name === 'insertDateTime') { + editorCmd.name = 'insertText', + editorCmd.value = time.formatMsToLocal(new Date().getTime()); + } else if (command.name === 'commandStartExternalEditing') { + // TODO + } else if (command.name === 'commandStopExternalEditing') { + // TODO + } else if (command.name === 'showLocalSearch') { + editorCmd.name = 'search'; + } else if (command.name === 'textCode') { + // TODO + } else if (command.name === 'insertTemplate') { + editorCmd.name = 'insertText', + editorCmd.value = TemplateUtils.render(command.value); + } + + if (command.name === 'focusElement' && command.target === 'noteTitle') { + fn = () => { + if (!titleInputRef.current) return; + titleInputRef.current.focus(); + }; + } + + if (command.name === 'focusElement' && command.target === 'noteBody') { + editorCmd.name = 'focus'; + } + + if (!editorCmd.name && !fn) return; + + dispatch({ + type: 'WINDOW_COMMAND', + name: null, + }); + + requestAnimationFrame(() => { + if (fn) { + fn(); + } else { + if (!editorRef.current.execCommand) { + reg.logger().warn('Received command, but editor cannot execute commands', editorCmd); + } else { + editorRef.current.execCommand(editorCmd); + } + } + }); + }, [windowCommand, dispatch, formNote]); +} + +function NoteText2(props:NoteTextProps) { + const [formNote, setFormNote] = useState(defaultNote()); + const [defaultEditorState, setDefaultEditorState] = useState({ value: '', markupLanguage: MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN }); + const prevSyncStarted = usePrevious(props.syncStarted); + + const editorRef = useRef(); + const titleInputRef = useRef(); + const formNoteRef = useRef(); + formNoteRef.current = { ...formNote }; + + useWindowCommand(props.windowCommand, props.dispatch, formNote, titleInputRef, editorRef); + + // If the note has been modified in another editor, wait for it to be saved + // before loading it in this editor. + const waitingToSaveNote = props.noteId && formNote.id !== props.noteId && props.editorNoteStatuses[props.noteId] === 'saving'; + + const styles = styles_(props); + + const markupToHtml = useCallback(async (markupLanguage:number, md:string, options:any = null):Promise => { + md = md || ''; + + const theme = themeStyle(props.theme); + + md = await Note.replaceResourceInternalToExternalLinks(md, { useAbsolutePaths: true }); + + const markupToHtml = markupLanguageUtils.newMarkupToHtml({ + resourceBaseUrl: `file://${Setting.value('resourceDir')}/`, + }); + + const result = await markupToHtml.render(markupLanguage, md, theme, Object.assign({}, { + codeTheme: theme.codeThemeCss, + // userCss: this.props.customCss ? this.props.customCss : '', + // resources: await shared.attachedResources(noteBody), + resources: [], + postMessageSyntax: 'ipcProxySendToHost', + splitted: true, + externalAssetsOnly: true, + }, options)); + + return result; + }, [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 () => { + saveNoteIfWillChange(formNoteRef.current, editorRef, props.dispatch); + }; + }, []); + + useEffect(() => { + // Check that synchronisation has just finished - and + // if the note has never been changed, we reload it. + // If the note has already been changed, it's a conflict + // that's already been handled by the synchronizer. + + if (!prevSyncStarted) return () => {}; + if (props.syncStarted) return () => {}; + if (formNote.hasChanged) return () => {}; + + reg.logger().debug('Sync has finished and note has never been changed - reloading it'); + + let cancelled = false; + + const loadNote = async () => { + const n = await Note.load(props.noteId); + if (cancelled) return; + + // Normally should not happened because if the note has been deleted via sync + // it would not have been loaded in the editor (due to note selection changing + // on delete) + if (!n) { + reg.logger().warn('Trying to reload note that has been deleted:', props.noteId); + return; + } + + initNoteState(n, setFormNote, setDefaultEditorState); + }; + + loadNote(); + + return () => { + cancelled = true; + }; + }, [prevSyncStarted, props.syncStarted, formNote]); + + useEffect(() => { + if (!props.noteId) return () => {}; + + if (formNote.id === props.noteId) return () => {}; + + if (waitingToSaveNote) return () => {}; + + let cancelled = false; + + reg.logger().debug('Loading existing note', props.noteId); + + saveNoteIfWillChange(formNote, editorRef, props.dispatch); + + const loadNote = async () => { + const n = await Note.load(props.noteId); + if (cancelled) return; + if (!n) throw new Error(`Cannot find note with ID: ${props.noteId}`); + reg.logger().debug('Loaded note:', n); + initNoteState(n, setFormNote, setDefaultEditorState); + }; + + loadNote(); + + return () => { + cancelled = true; + }; + }, [props.noteId, formNote, waitingToSaveNote]); + + const onFieldChange = useCallback((field:string, value:any, changeId: number = 0) => { + handleProvisionalFlag(); + + const change = field === 'body' ? { + bodyEditorContent: value, + } : { + title: value, + }; + + const newNote = { + ...formNote, + ...change, + bodyWillChangeId: 0, + bodyChangeId: 0, + hasChanged: true, + }; + + 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, props.dispatch); + } + }, [handleProvisionalFlag, formNote]); + + const onBodyChange = useCallback((event:OnChangeEvent) => onFieldChange('body', event.content, event.changeId), [onFieldChange]); + + const onTitleChange = useCallback((event:any) => onFieldChange('title', event.target.value), [onFieldChange]); + + 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 introductionPostLinkClick = useCallback(() => { + bridge().openExternal('https://www.patreon.com/posts/34246624'); + }, []); + + if (props.selectedNoteIds.length > 1) { + return ; + } + + const editorProps = { + ref: editorRef, + style: styles.tinyMCE, + onChange: onBodyChange, + onWillChange: onBodyWillChange, + defaultEditorState: defaultEditorState, + markupToHtml: markupToHtml, + attachResources: attachResources, + disabled: waitingToSaveNote, + }; + + let editor = null; + + if (props.editor === 'TinyMCE') { + editor = ; + textEditorUtils_ = tinyMceUtils; + } else if (props.editor === 'PlainEditor') { + editor = ; + textEditorUtils_ = plainEditorUtils; + } else { + throw new Error(`Invalid editor: ${props.editor}`); + } + + return ( +
+
+
+ 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. +
+
+ +
+
+ {editor} +
+
+
+ + ); +} + +export { + NoteText2 as NoteText2Component, +}; + +const mapStateToProps = (state:any) => { + const noteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null; + + return { + noteId: noteId, + notes: state.notes, + selectedNoteIds: state.selectedNoteIds, + isProvisional: state.provisionalNoteIds.includes(noteId), + editorNoteStatuses: state.editorNoteStatuses, + syncStarted: state.syncStarted, + theme: state.settings.theme, + watchedNoteFiles: state.watchedNoteFiles, + windowCommand: state.windowCommand, + }; +}; + +export default connect(mapStateToProps)(NoteText2); diff --git a/ElectronClient/gui/editors/PlainEditor.tsx b/ElectronClient/gui/editors/PlainEditor.tsx new file mode 100644 index 0000000000..0aeb55487c --- /dev/null +++ b/ElectronClient/gui/editors/PlainEditor.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react'; + +// eslint-disable-next-line no-unused-vars +import { DefaultEditorState, TextEditorUtils } from '../utils/NoteText'; + +export interface OnChangeEvent { + changeId: number, + content: any, +} + +interface PlainEditorProps { + style: any, + onChange(event: OnChangeEvent): void, + onWillChange(event:any): void, + defaultEditorState: DefaultEditorState, + markupToHtml: Function, + attachResources: Function, + disabled: boolean, +} + +export const utils:TextEditorUtils = { + editorContentToHtml(content:any):Promise { + return content ? content : ''; + }, +}; + +const PlainEditor = (props:PlainEditorProps, ref:any) => { + const editorRef = useRef(); + + useImperativeHandle(ref, () => { + return { + content: () => '', + }; + }, []); + + useEffect(() => { + if (!editorRef.current) return; + editorRef.current.value = props.defaultEditorState.value; + }, [props.defaultEditorState]); + + const onChange = useCallback((event:any) => { + props.onChange({ changeId: null, content: event.target.value }); + }, [props.onWillChange, props.onChange]); + + return ( +
+