\ 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ξ
\ 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ξ
\ 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 @@
-
+ );
+}
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.
+