1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-10-31 00:07:48 +02:00

Desktop: Resolves #176: Added experimental WYSIWYG editor (#2556)

* Trying to get TuiEditor to work

* Tests with TinyMCE

* Fixed build

* Improved asset loading

* Added support for Joplin source blocks

* Added support for Joplin source blocks

* Better integration

* Make sure noteDidUpdate event is always dispatched at the right time

* Minor tweaks

* Fixed tests

* Add support for checkboxes

* Minor refactoring

* Added support for file attachments

* Add support for fenced code blocks

* Fix new line issue on code block

* Added support for Fountain scripts

* Refactoring

* Better handling of saving and loading notes

* Fix saving and loading ntoes

* Handle multi-note selection and fixed new note creation issue

* Fixed newline issue in test

* Fixed newline issue in test

* Improve saving and loading

* Improve saving and loading note

* Removed undeeded prop

* Fixed issue when new note being saved is incorrectly reloaded

* Refactoring and improve saving of note when unmounting component

* Fixed TypeScript error

* Small changes

* Improved further handling of saving and loading notes

* Handle provisional notes and fixed various saving and loading bugs

* Adding back support for HTML notes

* Added support for HTML notes

* Better handling of editable nodes

* Preserve image HTML tag when the size is set

* Handle switching between editor when the note has note finished saving

* Handle templates

* Handle templates

* Handle loading note that is being saved

* Handle note being reloaded via sync

* Clean up

* Clean up and improved logging

* Fixed TS error

* Fixed a few issues

* Fixed test

* Logging

* Various improvements

* Add blockquote support

* Moved CWD operation to shim

* Removed deleted files

* Added support for Joplin commands
This commit is contained in:
Laurent Cozic
2020-03-09 23:24:57 +00:00
committed by GitHub
parent ab2c8e3826
commit 84c3ef144d
61 changed files with 2127 additions and 309 deletions

View File

@@ -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

View File

@@ -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

7
.gitignore vendored
View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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);

View File

@@ -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'),
// });
// }));
});

View File

@@ -0,0 +1 @@
<img src=":/0415d61cc33e47afa6dde45948c3177a"/>

View File

@@ -0,0 +1 @@
![](:/0415d61cc33e47afa6dde45948c3177a)

View File

@@ -0,0 +1 @@
<img src=":/0415d61cc33e47afa6dde45948c3177a" width="500" height="500">

View File

@@ -0,0 +1 @@
<img src=":/0415d61cc33e47afa6dde45948c3177a" width="500" height="500">

View File

@@ -0,0 +1,53 @@
<ul>
<li class="md-checkbox joplin-checkbox"><div class="checkbox-wrapper"><input type="checkbox" id="md-checkbox-7" onclick="
try {
if (this.checked) {
this.setAttribute('checked', 'checked');
} else {
this.removeAttribute('checked');
}
ipcProxySendToHost('checkboxclick:checked:0');
const label = document.getElementById(&quot;cb-label-md-checkbox-7&quot;);
label.classList.remove(this.checked ? 'checkbox-label-unchecked' : 'checkbox-label-checked');
label.classList.add(this.checked ? 'checkbox-label-checked' : 'checkbox-label-unchecked');
} catch (error) {
console.warn('Checkbox checked:0 error', error);
}
return true;
" checked="checked"><label id="cb-label-md-checkbox-7" for="md-checkbox-7" class="checkbox-label-checked">one</label></div></li>
<li class="md-checkbox joplin-checkbox"><div class="checkbox-wrapper"><input type="checkbox" id="md-checkbox-8" onclick="
try {
if (this.checked) {
this.setAttribute('checked', 'checked');
} else {
this.removeAttribute('checked');
}
ipcProxySendToHost('checkboxclick:unchecked:1');
const label = document.getElementById(&quot;cb-label-md-checkbox-8&quot;);
label.classList.remove(this.checked ? 'checkbox-label-unchecked' : 'checkbox-label-checked');
label.classList.add(this.checked ? 'checkbox-label-checked' : 'checkbox-label-unchecked');
} catch (error) {
console.warn('Checkbox unchecked:1 error', error);
}
return true;
"><label id="cb-label-md-checkbox-8" for="md-checkbox-8" class="checkbox-label-unchecked">two</label></div></li>
<li class="md-checkbox joplin-checkbox"><div class="checkbox-wrapper"><input type="checkbox" id="md-checkbox-9" onclick="
try {
if (this.checked) {
this.setAttribute('checked', 'checked');
} else {
this.removeAttribute('checked');
}
ipcProxySendToHost('checkboxclick:unchecked:2');
const label = document.getElementById(&quot;cb-label-md-checkbox-9&quot;);
label.classList.remove(this.checked ? 'checkbox-label-unchecked' : 'checkbox-label-checked');
label.classList.add(this.checked ? 'checkbox-label-checked' : 'checkbox-label-unchecked');
} catch (error) {
console.warn('Checkbox unchecked:2 error', error);
}
return true;
"><label id="cb-label-md-checkbox-9" for="md-checkbox-9" class="checkbox-label-unchecked">with <strong>bold</strong> text</label></div></li>
</ul>

View File

@@ -0,0 +1,3 @@
- [x] one
- [ ] two
- [ ] with **bold** text

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
$katexcode$

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
$$
katexcode
$$

View File

@@ -0,0 +1,5 @@
<div class="joplin-editable"><pre class="joplin-source" data-joplin-source-open="```javascript&#10;" data-joplin-source-close="&#10;```">function() {
console.info('bonjour');
}</pre><pre class="hljs"><code><span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params"></span>) </span>{
<span class="hljs-built_in">console</span>.info(<span class="hljs-string">'bonjour'</span>);
}</code></pre></div>

View File

@@ -0,0 +1,5 @@
```javascript
function() {
console.info('bonjour');
}
```

View File

@@ -1,2 +1 @@
<pre class="hljs"><code><span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">"#"</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">"leavethisalone"</span>&gt;</span>testing fence<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span>
</code></pre>
<div class="joplin-editable"><pre class="joplin-source" data-joplin-source-open="```html&#10;" data-joplin-source-close="&#10;```">&lt;a href=&quot;#&quot; onclick=&quot;leavethisalone&quot;&gt;testing fence&lt;/a&gt;</pre><pre class="hljs"><code><span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">"#"</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">"leavethisalone"</span>&gt;</span>testing fence<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span></code></pre></div>

View File

@@ -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);

View File

@@ -5,4 +5,5 @@ lib/
gui/*.min.js
plugins/*.min.js
.DS_Store
gui/note-viewer/pluginAssets/
gui/note-viewer/pluginAssets/
pluginAssets/

View File

@@ -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'];
}

View File

@@ -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 ?
<NoteText2 editor="TinyMCE" style={styles.noteText} keyboardMode={keyboardMode} visiblePanes={this.props.noteVisiblePanes} />
:
<NoteText style={styles.noteText} keyboardMode={keyboardMode} visiblePanes={this.props.noteVisiblePanes} />;
return (
<div style={style}>
<div style={modalLayerStyle}>{this.state.modalLayer.message}</div>
@@ -648,8 +655,7 @@ class MainScreenComponent extends React.Component {
<VerticalResizer style={styles.verticalResizer} onDrag={this.sidebar_onDrag} />
<NoteList style={styles.noteList} />
<VerticalResizer style={styles.verticalResizer} onDrag={this.noteList_onDrag} />
<NoteText style={styles.noteText} keyboardMode={keyboardMode} visiblePanes={this.props.noteVisiblePanes} />
{noteTextComp}
{pluginDialog}
</div>
);

View File

@@ -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(
<button key={item.label} style={styles.button} onClick={() => multiNotesButton_click(item)}>
{item.label}
</button>
);
}
return (
<div style={styles.root}>
<div style={styles.itemList}>{itemComps}</div>
</div>
);
}

View File

@@ -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,

View File

@@ -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<string> {
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<any> {
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 = `<style>${formNote.originalCss}</style>\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<any>, editorRef:React.MutableRefObject<any>) {
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<FormNote>(defaultNote());
const [defaultEditorState, setDefaultEditorState] = useState<DefaultEditorState>({ value: '', markupLanguage: MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN });
const prevSyncStarted = usePrevious(props.syncStarted);
const editorRef = useRef<any>();
const titleInputRef = useRef<any>();
const formNoteRef = useRef<FormNote>();
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<any> => {
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 <MultiNoteActions
theme={props.theme}
selectedNoteIds={props.selectedNoteIds}
notes={props.notes}
dispatch={props.dispatch}
watchedNoteFiles={props.watchedNoteFiles}
style={props.style}
/>;
}
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 = <TinyMCE {...editorProps}/>;
textEditorUtils_ = tinyMceUtils;
} else if (props.editor === 'PlainEditor') {
editor = <PlainEditor {...editorProps}/>;
textEditorUtils_ = plainEditorUtils;
} else {
throw new Error(`Invalid editor: ${props.editor}`);
}
return (
<div style={props.style}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={styles.warningBanner}>
This is an experimental WYSIWYG editor for evaluation only. Please do not use with important notes as you may lose some data! See the <a style={styles.urlColor} onClick={introductionPostLinkClick} href="#">introduction post</a> for more information.
</div>
<div style={{ display: 'flex' }}>
<input
type="text"
ref={titleInputRef}
disabled={waitingToSaveNote}
placeholder={props.isProvisional ? _('Creating new %s...', formNote.is_todo ? _('to-do') : _('note')) : ''}
style={styles.titleInput}
onChange={onTitleChange}
value={formNote.title}
/>
</div>
<div style={{ display: 'flex', flex: 1 }}>
{editor}
</div>
</div>
</div>
);
}
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);

View File

@@ -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<string> {
return content ? content : '';
},
};
const PlainEditor = (props:PlainEditorProps, ref:any) => {
const editorRef = useRef<any>();
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 (
<div style={props.style}>
<textarea
ref={editorRef}
style={{ width: '100%', height: '100%' }}
defaultValue={props.defaultEditorState.value}
onChange={onChange}
/>;
</div>
);
};
export default forwardRef(PlainEditor);

View File

@@ -0,0 +1,465 @@
import * as React from 'react';
import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
// eslint-disable-next-line no-unused-vars
import { DefaultEditorState, OnChangeEvent, TextEditorUtils, EditorCommand } from '../utils/NoteText';
const { MarkupToHtml } = require('lib/joplin-renderer');
const taboverride = require('taboverride');
const { reg } = require('lib/registry.js');
interface TinyMCEProps {
style: any,
onChange(event: OnChangeEvent): void,
onWillChange(event:any): void,
defaultEditorState: DefaultEditorState,
markupToHtml: Function,
attachResources: Function,
disabled: boolean,
}
function findBlockSource(node:any) {
const sources = node.getElementsByClassName('joplin-source');
if (!sources.length) throw new Error('No source for node');
const source = sources[0];
return {
openCharacters: source.getAttribute('data-joplin-source-open'),
closeCharacters: source.getAttribute('data-joplin-source-close'),
content: source.textContent,
node: source,
};
}
function findEditableContainer(node:any):any {
while (node) {
if (node.classList && node.classList.contains('joplin-editable')) return node;
node = node.parentNode;
}
return null;
}
function editableInnerHtml(html:string):string {
const temp = document.createElement('div');
temp.innerHTML = html;
const editable = temp.getElementsByClassName('joplin-editable');
if (!editable.length) throw new Error(`Invalid joplin-editable: ${html}`);
return editable[0].innerHTML;
}
function dialogTextArea_keyDown(event:any) {
if (event.key === 'Tab') {
window.requestAnimationFrame(() => event.target.focus());
}
}
// Allows pressing tab in a textarea to input an actual tab (instead of changing focus)
// taboverride will take care of actually inserting the tab character, while the keydown
// event listener will override the default behaviour, which is to focus the next field.
function enableTextAreaTab(enable:boolean) {
const textAreas = document.getElementsByClassName('tox-textarea');
for (const textArea of textAreas) {
taboverride.set(textArea, enable);
if (enable) {
textArea.addEventListener('keydown', dialogTextArea_keyDown);
} else {
textArea.removeEventListener('keydown', dialogTextArea_keyDown);
}
}
}
export const utils:TextEditorUtils = {
editorContentToHtml(content:any):Promise<string> {
return content ? content : '';
},
};
interface TinyMceCommand {
name: string,
value?: any,
ui?: boolean
}
interface JoplinCommandToTinyMceCommands {
[key:string]: TinyMceCommand,
}
const joplinCommandToTinyMceCommands:JoplinCommandToTinyMceCommands = {
'textBold': { name: 'mceToggleFormat', value: 'bold' },
'textItalic': { name: 'mceToggleFormat', value: 'italic' },
'textLink': { name: 'mceLink' },
'search': { name: 'SearchReplace' },
};
let loadedAssetFiles_:string[] = [];
let dispatchDidUpdateIID_:any = null;
let changeId_:number = 1;
const TinyMCE = (props:TinyMCEProps, ref:any) => {
const [editor, setEditor] = useState(null);
const [scriptLoaded, setScriptLoaded] = useState(false);
const attachResources = useRef(null);
attachResources.current = props.attachResources;
const markupToHtml = useRef(null);
markupToHtml.current = props.markupToHtml;
const rootIdRef = useRef<string>(`tinymce-${Date.now()}${Math.round(Math.random() * 10000)}`);
const dispatchDidUpdate = (editor:any) => {
if (dispatchDidUpdateIID_) clearTimeout(dispatchDidUpdateIID_);
dispatchDidUpdateIID_ = setTimeout(() => {
dispatchDidUpdateIID_ = null;
editor.getDoc().dispatchEvent(new Event('joplin-noteDidUpdate'));
}, 10);
};
const onEditorContentClick = useCallback((event:any) => {
if (event.target && event.target.nodeName === 'INPUT' && event.target.getAttribute('type') === 'checkbox') {
editor.fire('joplinChange');
dispatchDidUpdate(editor);
}
}, [editor]);
useImperativeHandle(ref, () => {
return {
content: () => editor ? editor.getContent() : '',
execCommand: async (cmd:EditorCommand) => {
if (!editor) return false;
reg.logger().debug('TinyMce: execCommand', cmd);
let commandProcessed = true;
if (cmd.name === 'insertText') {
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, cmd.value, { bodyOnly: true });
editor.insertContent(result.html);
} else if (cmd.name === 'focus') {
editor.focus();
} else {
commandProcessed = false;
}
if (commandProcessed) return true;
if (!joplinCommandToTinyMceCommands[cmd.name]) {
reg.logger().warn('TinyMCE: unsupported Joplin command: ', cmd);
return false;
}
const tinyMceCmd:TinyMceCommand = { ...joplinCommandToTinyMceCommands[cmd.name] };
if (!('ui' in tinyMceCmd)) tinyMceCmd.ui = false;
if (!('value' in tinyMceCmd)) tinyMceCmd.value = null;
editor.execCommand(tinyMceCmd.name, tinyMceCmd.ui, tinyMceCmd.value);
return true;
},
};
}, [editor]);
// -----------------------------------------------------------------------------------------
// Load the TinyMCE library. The lib loads additional JS and CSS files on startup
// (for themes), and so it needs to be loaded via <script> tag. Requiring it from the
// module would not load these extra files.
// -----------------------------------------------------------------------------------------
useEffect(() => {
if (document.getElementById('tinyMceScript')) {
setScriptLoaded(true);
return () => {};
}
let cancelled = false;
const script = document.createElement('script');
script.src = 'node_modules/tinymce/tinymce.min.js';
script.id = 'tinyMceScript';
script.onload = () => {
if (cancelled) return;
setScriptLoaded(true);
};
document.getElementsByTagName('head')[0].appendChild(script);
return () => {
cancelled = true;
};
}, []);
// -----------------------------------------------------------------------------------------
// Enable or disable the editor
// -----------------------------------------------------------------------------------------
useEffect(() => {
if (!editor) return;
editor.setMode(props.disabled ? 'readonly' : 'design');
}, [editor, props.disabled]);
// -----------------------------------------------------------------------------------------
// Create and setup the editor
// -----------------------------------------------------------------------------------------
useEffect(() => {
if (!scriptLoaded) return;
loadedAssetFiles_ = [];
const loadEditor = async () => {
const editors = await (window as any).tinymce.init({
selector: `#${rootIdRef.current}`,
width: '100%',
height: '100%',
resize: false,
plugins: 'noneditable link lists hr searchreplace',
noneditable_noneditable_class: 'joplin-editable', // Can be a regex too
valid_elements: '*[*]', // We already filter in sanitize_html
menubar: false,
branding: false,
toolbar: 'bold italic | link codeformat customAttach | numlist bullist h1 h2 h3 hr blockquote',
setup: (editor:any) => {
function openEditDialog(editable:any) {
const source = findBlockSource(editable);
editor.windowManager.open({
title: 'Edit',
size: 'large',
initialData: {
codeTextArea: source.content,
},
onSubmit: async (dialogApi:any) => {
const newSource = dialogApi.getData().codeTextArea;
const md = `${source.openCharacters}${newSource.trim()}${source.closeCharacters}`;
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, md, { bodyOnly: true });
// markupToHtml will return the complete editable HTML, but we only
// want to update the inner HTML, so as not to break additional props that
// are added by TinyMCE on the main node.
editable.innerHTML = editableInnerHtml(result.html);
dialogApi.close();
editor.fire('joplinChange');
dispatchDidUpdate(editor);
},
onClose: () => {
enableTextAreaTab(false);
},
body: {
type: 'panel',
items: [
{
type: 'textarea',
name: 'codeTextArea',
value: source.content,
},
],
},
buttons: [
{
type: 'submit',
text: 'OK',
},
],
});
window.requestAnimationFrame(() => {
enableTextAreaTab(true);
});
}
editor.ui.registry.addButton('customAttach', {
tooltip: 'Attach...',
icon: 'upload',
onAction: async function() {
const resources = await attachResources.current();
if (!resources.length) return;
const html = [];
for (const resource of resources) {
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, resource.markdownTag, { bodyOnly: true });
html.push(result.html);
}
editor.insertContent(html.join('\n'));
editor.fire('joplinChange');
dispatchDidUpdate(editor);
},
});
// TODO: remove event on unmount?
editor.on('DblClick', (event:any) => {
const editable = findEditableContainer(event.target);
if (editable) openEditDialog(editable);
});
editor.on('ObjectResized', function(event:any) {
if (event.target.nodeName === 'IMG') {
editor.fire('joplinChange');
dispatchDidUpdate(editor);
}
});
},
});
setEditor(editors[0]);
};
loadEditor();
}, [scriptLoaded]);
// -----------------------------------------------------------------------------------------
// Set the initial content and load the plugin CSS and JS files
// -----------------------------------------------------------------------------------------
useEffect(() => {
if (!editor) return () => {};
let cancelled = false;
const loadContent = async () => {
const result = await props.markupToHtml(props.defaultEditorState.markupLanguage, props.defaultEditorState.value);
if (cancelled) return;
editor.setContent(result.html);
const cssFiles = result.pluginAssets
.filter((a:any) => a.mime === 'text/css' && !loadedAssetFiles_.includes(a.path))
.map((a:any) => a.path);
const jsFiles = result.pluginAssets
.filter((a:any) => a.mime === 'application/javascript' && !loadedAssetFiles_.includes(a.path))
.map((a:any) => a.path);
for (const cssFile of cssFiles) loadedAssetFiles_.push(cssFile);
for (const jsFile of jsFiles) loadedAssetFiles_.push(jsFile);
if (cssFiles.length) editor.dom.loadCSS(cssFiles.join(','));
if (jsFiles.length) {
const editorElementId = editor.dom.uniqueId();
for (const jsFile of jsFiles) {
const script = editor.dom.create('script', {
id: editorElementId,
type: 'text/javascript',
src: jsFile,
});
editor.getDoc().getElementsByTagName('head')[0].appendChild(script);
}
}
editor.getDoc().addEventListener('click', onEditorContentClick);
dispatchDidUpdate(editor);
};
loadContent();
return () => {
cancelled = true;
editor.getDoc().removeEventListener('click', onEditorContentClick);
};
}, [editor, props.markupToHtml, props.defaultEditorState, onEditorContentClick]);
// -----------------------------------------------------------------------------------------
// Handle onChange event
// -----------------------------------------------------------------------------------------
// Need to save the onChange handler to a ref to make sure
// we call the current one from setTimeout.
// https://github.com/facebook/react/issues/14010#issuecomment-433788147
const props_onChangeRef = useRef<Function>();
props_onChangeRef.current = props.onChange;
useEffect(() => {
if (!editor) return () => {};
let onChangeHandlerIID:any = null;
const onChangeHandler = () => {
const changeId = changeId_++;
props.onWillChange({ changeId: changeId });
if (onChangeHandlerIID) clearTimeout(onChangeHandlerIID);
onChangeHandlerIID = setTimeout(() => {
onChangeHandlerIID = null;
if (!editor) return;
props_onChangeRef.current({
changeId: changeId,
content: editor.getContent(),
});
dispatchDidUpdate(editor);
}, 1000);
};
const onExecCommand = (event:any) => {
const c:string = event.command;
if (!c) return;
// We need to dispatch onChange for these commands:
//
// InsertHorizontalRule
// InsertOrderedList
// InsertUnorderedList
// mceInsertContent
// mceToggleFormat
//
// Any maybe others, so to catch them all we only check the prefix
const changeCommands = ['mceBlockQuote'];
if (changeCommands.includes(c) || c.indexOf('Insert') === 0 || c.indexOf('mceToggle') === 0 || c.indexOf('mceInsert') === 0) {
onChangeHandler();
}
};
// Keypress means that a printable key (letter, digit, etc.) has been
// pressed so we want to always trigger onChange in this case
const onKeypress = () => {
onChangeHandler();
};
// KeyUp is triggered for any keypress, including Control, Shift, etc.
// so most of the time we don't want to trigger onChange. We trigger
// it however for the keys that might change text, such as Delete or
// Backspace. It's not completely accurate though because if user presses
// Backspace at the beginning of a note or Delete at the end, we trigger
// onChange even though nothing is changed. The alternative would be to
// check the content before and after, but this is too slow, so let's
// keep it this way for now.
const onKeyUp = (event:any) => {
if (['Backspace', 'Delete', 'Enter', 'Tab'].includes(event.key)) {
onChangeHandler();
}
};
editor.on('keyup', onKeyUp);
editor.on('keypress', onKeypress);
editor.on('paste', onChangeHandler);
editor.on('cut', onChangeHandler);
editor.on('joplinChange', onChangeHandler);
editor.on('ExecCommand', onExecCommand);
return () => {
try {
editor.off('keyup', onKeyUp);
editor.off('keypress', onKeypress);
editor.off('paste', onChangeHandler);
editor.off('cut', onChangeHandler);
editor.off('joplinChange', onChangeHandler);
editor.off('ExecCommand', onExecCommand);
} catch (error) {
console.warn('Error removing events', error);
}
};
}, [props.onWillChange, props.onChange, editor]);
return <div style={props.style} id={rootIdRef.current}/>;
};
export default forwardRef(TinyMCE);

View File

@@ -0,0 +1,18 @@
export interface DefaultEditorState {
value: string,
markupLanguage: number, // MarkupToHtml.MARKUP_LANGUAGE_XXX
}
export interface OnChangeEvent {
changeId: number,
content: any,
}
export interface TextEditorUtils {
editorContentToHtml(content:any):Promise<string>,
}
export interface EditorCommand {
name: string,
value: any,
}

View File

@@ -12,6 +12,7 @@
<link rel="stylesheet" href="css/fork-awesome.min.css">
<link rel="stylesheet" href="node_modules/react-datetime/css/react-datetime.css">
<link rel="stylesheet" href="node_modules/smalltalk/css/smalltalk.css">
<style>
.smalltalk {
background-color: rgba(0,0,0,.5);

File diff suppressed because it is too large Load Diff

View File

@@ -109,7 +109,7 @@
"html-minifier": "^4.0.0",
"htmlparser2": "^4.1.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.1",
@@ -152,6 +152,7 @@
"read-chunk": "^2.1.0",
"readability-node": "^0.1.0",
"redux": "^3.7.2",
"relative": "^3.0.2",
"reselect": "^4.0.0",
"sax": "^1.2.4",
"server-destroy": "^1.0.1",
@@ -161,8 +162,10 @@
"string-padding": "^1.0.2",
"string-to-stream": "^1.1.1",
"syswide-cas": "^5.1.0",
"taboverride": "^4.0.3",
"tar": "^4.4.4",
"tcp-port-used": "^0.1.2",
"tinymce": "^5.2.0",
"uglifycss": "0.0.29",
"url-parse": "^1.4.3",
"uslug": "^1.0.4",

View File

@@ -1,15 +1,19 @@
require('app-module-path').addPath(`${__dirname}`);
const fs = require('fs-extra');
const rootDir = `${__dirname}/..`;
const sourceDir = `${rootDir}/../ReactNativeClient/lib/joplin-renderer/assets`;
const destDir = `${rootDir}/gui/note-viewer/pluginAssets`;
async function main() {
await fs.remove(destDir);
await fs.mkdirp(destDir);
await fs.copy(sourceDir, destDir);
const rootDir = `${__dirname}/..`;
const sourceDir = `${rootDir}/../ReactNativeClient/lib/joplin-renderer/assets`;
const destDirs = [
`${rootDir}/gui/note-viewer/pluginAssets`,
`${rootDir}/pluginAssets`,
];
for (const destDir of destDirs) {
console.info(`Copying to ${destDir}`);
await fs.remove(destDir);
await fs.mkdirp(destDir);
await fs.copy(sourceDir, destDir);
}
}
module.exports = main;

View File

@@ -9,8 +9,9 @@ ArrayUtils.unique = function(array) {
ArrayUtils.removeElement = function(array, element) {
const index = array.indexOf(element);
if (index < 0) return array;
array.splice(index, 1);
return array;
const newArray = array.slice();
newArray.splice(index, 1);
return newArray;
};
// https://stackoverflow.com/a/10264318/561309

View File

@@ -0,0 +1,81 @@
export interface QueueItemAction {
(): void,
}
export interface QueueItem {
action: QueueItemAction,
context: any,
}
export default class AsyncActionQueue {
queue_:QueueItem[] = [];
interval_:number;
scheduleProcessingIID_:any = null;
processing_ = false;
needProcessing_ = false;
constructor(interval:number = 100) {
this.interval_ = interval;
}
push(action:QueueItemAction, context:any = null) {
this.queue_.push({
action: action,
context: context,
});
this.scheduleProcessing();
}
get queue():QueueItem[] {
return this.queue_;
}
private scheduleProcessing(interval:number = null) {
if (interval === null) interval = this.interval_;
if (this.scheduleProcessingIID_) {
clearTimeout(this.scheduleProcessingIID_);
}
this.scheduleProcessingIID_ = setTimeout(() => {
this.scheduleProcessingIID_ = null;
this.processQueue();
}, interval);
}
private async processQueue() {
if (this.processing_) {
this.scheduleProcessing();
return;
}
this.processing_ = true;
const itemCount = this.queue_.length;
if (itemCount) {
const item = this.queue_[itemCount - 1];
await item.action();
this.queue_.splice(0, itemCount);
}
this.processing_ = false;
}
waitForAllDone() {
this.scheduleProcessing(1);
return new Promise((resolve) => {
const iid = setInterval(() => {
if (this.processing_) return;
if (!this.queue_.length) {
clearInterval(iid);
resolve();
}
}, 100);
});
}
}

View File

@@ -622,7 +622,7 @@ class BaseApplication {
initArgs = Object.assign(initArgs, extraFlags);
this.logger_.addTarget('file', { path: `${profileDir}/log.txt` });
if (Setting.value('env') === 'dev') this.logger_.addTarget('console', { level: Logger.LEVEL_WARN });
if (Setting.value('env') === 'dev') this.logger_.addTarget('console', { level: Logger.LEVEL_DEBUG });
this.logger_.setLevel(initArgs.logLevel);
reg.setLogger(this.logger_);

View File

@@ -8,6 +8,7 @@ class HtmlToMd {
headingStyle: 'atx',
anchorNames: options.anchorNames ? options.anchorNames.map(n => n.trim().toLowerCase()) : [],
codeBlockStyle: 'fenced',
preserveImageTagsWithSize: !!options.preserveImageTagsWithSize,
});
turndown.use(turndownPluginGfm);
turndown.remove('script');

View File

@@ -35,7 +35,12 @@ class Database {
}
async open(options) {
await this.driver().open(options);
try {
await this.driver().open(options);
} catch (error) {
throw new Error(`Cannot open database: ${error.message}: ${JSON.stringify(options)}`);
}
this.logger().info('Database was open successfully');
}

View File

@@ -1,5 +1,7 @@
const { filename, fileExtension } = require('lib/path-utils');
const { time } = require('lib/time-utils.js');
const Setting = require('lib/models/Setting');
const md5 = require('md5');
class FsDriverBase {
async isDirectory(path) {
@@ -67,6 +69,21 @@ class FsDriverBase {
await time.msleep(100);
}
}
// TODO: move out of here and make it part of joplin-renderer
// or assign to option using .bind(fsDriver())
async cacheCssToFile(cssStrings) {
const cssString = cssStrings.join('\n');
const cssFilePath = `${Setting.value('tempDir')}/${md5(escape(cssString))}.css`;
if (!(await this.exists(cssFilePath))) {
await this.writeFile(cssFilePath, cssString, 'utf8');
}
return {
path: cssFilePath,
mime: 'text/css',
};
}
}
module.exports = FsDriverBase;

View File

@@ -10,9 +10,41 @@ class HtmlToHtml {
this.resourceBaseUrl_ = 'resourceBaseUrl' in options ? options.resourceBaseUrl : null;
this.ResourceModel_ = options.ResourceModel;
this.cache_ = new memoryCache.Cache();
this.fsDriver_ = {
writeFile: (/* path, content, encoding = 'base64'*/) => { throw new Error('writeFile not set'); },
exists: (/* path*/) => { throw new Error('exists not set'); },
cacheCssToFile: (/* cssStrings*/) => { throw new Error('cacheCssToFile not set'); },
};
if (options.fsDriver) {
if (options.fsDriver.writeFile) this.fsDriver_.writeFile = options.fsDriver.writeFile;
if (options.fsDriver.exists) this.fsDriver_.exists = options.fsDriver.exists;
if (options.fsDriver.cacheCssToFile) this.fsDriver_.cacheCssToFile = options.fsDriver.cacheCssToFile;
}
}
fsDriver() {
return this.fsDriver_;
}
splitHtml(html) {
const trimmedHtml = html.trimStart();
if (trimmedHtml.indexOf('<style>') !== 0) return { html: html, cssStrings: [], originalCssHtml: '' };
const closingIndex = trimmedHtml.indexOf('</style>');
if (closingIndex < 0) return { html: html, cssStrings: [], originalCssHtml: '' };
return {
html: trimmedHtml.substr(closingIndex + 8),
css: trimmedHtml.substr(7, closingIndex),
};
}
async render(markup, theme, options) {
options = Object.assign({}, {
splitted: false,
}, options);
const cacheKey = md5(escape(markup));
let html = this.cache_.get(cacheKey);
@@ -39,14 +71,31 @@ class HtmlToHtml {
});
}
this.cache_.put(cacheKey, html, 1000 * 60 * 10);
if (options.bodyOnly) return {
html: html,
pluginAssets: [],
};
this.cache_.put(cacheKey, html, 1000 * 60 * 10);
let cssStrings = noteStyle(theme, options);
if (options.splitted) {
const splitted = this.splitHtml(html);
cssStrings = [splitted.css].concat(cssStrings);
const output = {
html: splitted.html,
pluginAssets: [],
};
if (options.externalAssetsOnly) {
output.pluginAssets.push(await this.fsDriver().cacheCssToFile(cssStrings));
}
return output;
}
const cssStrings = noteStyle(theme, options);
const styleHtml = `<style>${cssStrings.join('\n')}</style>`;
return {

View File

@@ -11,6 +11,7 @@ const rules = {
html_image: require('./MdToHtml/rules/html_image'),
highlight_keywords: require('./MdToHtml/rules/highlight_keywords'),
code_inline: require('./MdToHtml/rules/code_inline'),
fence: require('./MdToHtml/rules/fence').default,
fountain: require('./MdToHtml/rules/fountain'),
mermaid: require('./MdToHtml/rules/mermaid').default,
sanitize_html: require('./MdToHtml/rules/sanitize_html').default,
@@ -53,6 +54,27 @@ class MdToHtml {
this.ResourceModel_ = options.ResourceModel;
this.pluginOptions_ = options.pluginOptions ? options.pluginOptions : {};
this.contextCache_ = new memoryCache.Cache();
this.tempDir_ = options.tempDir;
this.fsDriver_ = {
writeFile: (/* path, content, encoding = 'base64'*/) => { throw new Error('writeFile not set'); },
exists: (/* path*/) => { throw new Error('exists not set'); },
cacheCssToFile: (/* cssStrings*/) => { throw new Error('cacheCssToFile not set'); },
};
if (options.fsDriver) {
if (options.fsDriver.writeFile) this.fsDriver_.writeFile = options.fsDriver.writeFile;
if (options.fsDriver.exists) this.fsDriver_.exists = options.fsDriver.exists;
if (options.fsDriver.cacheCssToFile) this.fsDriver_.cacheCssToFile = options.fsDriver.cacheCssToFile;
}
}
fsDriver() {
return this.fsDriver_;
}
tempDir() {
return this.tempDir_;
}
pluginOptions(name) {
@@ -108,14 +130,16 @@ class MdToHtml {
}
async render(body, style = null, options = null) {
if (!options) options = {};
if (!('bodyOnly' in options)) options.bodyOnly = false;
if (!options.postMessageSyntax) options.postMessageSyntax = 'postMessage';
if (!options.paddingBottom) options.paddingBottom = '0';
if (!options.highlightedKeywords) options.highlightedKeywords = [];
if (!options.codeTheme) options.codeTheme = 'atom-one-light.css';
if (!style) style = Object.assign({}, defaultNoteStyle);
options = Object.assign({}, {
bodyOnly: false,
splitted: false,
externalAssetsOnly: false,
postMessageSyntax: 'postMessage',
paddingBottom: '0',
highlightedKeywords: [],
codeTheme: 'atom-one-light.css',
style: Object.assign({}, defaultNoteStyle),
}, options);
// The "codeHighlightCacheKey" option indicates what set of cached object should be
// associated with this particular Markdown body. It is only used to allow us to
@@ -147,6 +171,13 @@ class MdToHtml {
linkify: true,
html: true,
highlight: (str, lang) => {
let outputCodeHtml = '';
// The strings includes the last \n that is part of the fence,
// so we remove it because we need the exact code in the source block
const trimmedStr = str.replace(/(.*)\n$/, '$1');
const sourceBlockHtml = `<pre class="joplin-source" data-joplin-source-open="\`\`\`${lang}&#10;" data-joplin-source-close="&#10;\`\`\`">${markdownIt.utils.escapeHtml(trimmedStr)}</pre>`;
try {
let hlCode = '';
@@ -156,9 +187,9 @@ class MdToHtml {
hlCode = this.cachedHighlightedCode_[cacheKey];
} else {
if (lang && hljs.getLanguage(lang)) {
hlCode = hljs.highlight(lang, str, true).value;
hlCode = hljs.highlight(lang, trimmedStr, true).value;
} else {
hlCode = hljs.highlightAuto(str).value;
hlCode = hljs.highlightAuto(trimmedStr).value;
}
this.cachedHighlightedCode_[cacheKey] = hlCode;
}
@@ -167,10 +198,15 @@ class MdToHtml {
{ name: options.codeTheme },
];
return `<pre class="hljs"><code>${hlCode}</code></pre>`;
outputCodeHtml = hlCode;
} catch (error) {
return `<pre class="hljs"><code>${markdownIt.utils.escapeHtml(str)}</code></pre>`;
outputCodeHtml = markdownIt.utils.escapeHtml(trimmedStr);
}
return {
wrapCode: false,
html: `<div class="joplin-editable">${sourceBlockHtml}<pre class="hljs"><code>${outputCodeHtml}</code></pre></div>`,
};
},
});
@@ -201,6 +237,9 @@ class MdToHtml {
// Using the `context` object, a plugin can define what additional assets they need (css, fonts, etc.) using context.pluginAssets.
// The calling application will need to handle loading these assets.
// /!\/!\ Note: the order of rules is important!! /!\/!\
markdownIt.use(rules.fence(context, ruleOptions));
markdownIt.use(rules.sanitize_html(context, ruleOptions));
markdownIt.use(rules.image(context, ruleOptions));
markdownIt.use(rules.checkbox(context, ruleOptions));
@@ -247,6 +286,15 @@ class MdToHtml {
output.html = html;
if (options.splitted) {
output.cssStrings = cssStrings;
output.html = `<div id="rendered-md">${renderedBody}</div>`;
if (options.externalAssetsOnly) {
output.pluginAssets.push(await this.fsDriver().cacheCssToFile(cssStrings));
}
}
// Fow now, we keep only the last entry in the cache
this.cachedOutputs_ = {};
this.cachedOutputs_[cacheKey] = output;

View File

@@ -33,10 +33,20 @@ function createPrefixTokens(Token, id, checked, label, postMessageSyntax, source
const labelId = `cb-label-${id}`;
const js = `
${postMessageSyntax}('checkboxclick:${checkedString}:${lineIndex}');
const label = document.getElementById("${labelId}");
label.classList.remove(this.checked ? 'checkbox-label-unchecked' : 'checkbox-label-checked');
label.classList.add(this.checked ? 'checkbox-label-checked' : 'checkbox-label-unchecked');
try {
if (this.checked) {
this.setAttribute('checked', 'checked');
} else {
this.removeAttribute('checked');
}
${postMessageSyntax}('checkboxclick:${checkedString}:${lineIndex}');
const label = document.getElementById("${labelId}");
label.classList.remove(this.checked ? 'checkbox-label-unchecked' : 'checkbox-label-checked');
label.classList.add(this.checked ? 'checkbox-label-checked' : 'checkbox-label-unchecked');
} catch (error) {
console.warn('Checkbox ${checkedString}:${lineIndex} error', error);
}
return true;
`;
@@ -46,7 +56,7 @@ function createPrefixTokens(Token, id, checked, label, postMessageSyntax, source
token = new Token('checkbox_input', 'input', 0);
token.attrs = [['type', 'checkbox'], ['id', id], ['onclick', js]];
if (checked) token.attrs.push(['checked', 'true']);
if (checked) token.attrs.push(['checked', 'checked']);
tokens.push(token);
token = new Token('label_open', 'label', 1);
@@ -119,7 +129,7 @@ function installRule(markdownIt, mdOptions, ruleOptions, context) {
let itemClass = currentListItem.attrGet('class');
if (!itemClass) itemClass = '';
itemClass += ' md-checkbox';
itemClass += ' md-checkbox joplin-checkbox';
currentListItem.attrSet('class', itemClass.trim());
if (!('checkbox' in context.pluginAssets)) {

View File

@@ -0,0 +1,68 @@
// Note: this is copied from https://github.com/markdown-it/markdown-it/blob/master/lib/renderer.js
// Markdown-it assigns a special meaning to code returned from highlight() when it starts with PRE or not.
// If it starts with PRE, the highlited code is returned as-is. If it does not, it is wrapped in <PRE><CODE>
// This is a bit of a hack and magic behaviour, and it prevents us from returning a DIV from the highlight
// function.
// So we modify the code below to allow highlight() to return an object that tells how to render
// the code.
function installRule(markdownIt:any) {
// @ts-ignore: Keep the function signature as-is despite unusued arguments
markdownIt.renderer.rules.fence = function(tokens:any[], idx:number, options:any, env:any, slf:any) {
var token = tokens[idx],
info = token.info ? markdownIt.utils.unescapeAll(token.info).trim() : '',
langName = '',
highlighted, i, tmpAttrs, tmpToken;
if (info) {
langName = info.split(/\s+/g)[0];
}
if (options.highlight) {
highlighted = options.highlight(token.content, langName) || markdownIt.utils.escapeHtml(token.content);
} else {
highlighted = markdownIt.utils.escapeHtml(token.content);
}
const wrapCode = highlighted && highlighted.wrapCode !== false;
highlighted = typeof highlighted !== 'string' ? highlighted.html : highlighted;
if (highlighted.indexOf('<pre') === 0 || !wrapCode) {
return `${highlighted}\n`;
}
// If language exists, inject class gently, without modifying original token.
// May be, one day we will add .clone() for token and simplify this part, but
// now we prefer to keep things local.
if (info) {
i = token.attrIndex('class');
tmpAttrs = token.attrs ? token.attrs.slice() : [];
if (i < 0) {
tmpAttrs.push(['class', options.langPrefix + langName]);
} else {
tmpAttrs[i][1] += ` ${options.langPrefix}${langName}`;
}
// Fake token just to render attributes
tmpToken = {
attrs: tmpAttrs,
};
return `<pre><code${slf.renderAttrs(tmpToken)}>${
highlighted
}</code></pre>\n`;
}
return `<pre><code${slf.renderAttrs(token)}>${
highlighted
}</code></pre>\n`;
};
}
export default function() {
return function(md:any) {
installRule(md);
};
}

View File

@@ -95,10 +95,12 @@ const fountainCss = `
}
`;
function renderFountainScript(content) {
function renderFountainScript(markdownIt, content) {
const result = fountain.parse(content);
return `
<div class="fountain">
<div class="fountain joplin-editable">
<pre class="joplin-source" data-joplin-source-open="\`\`\`fountain&#10;" data-joplin-source-close="&#10;\`\`\`&#10;">${markdownIt.utils.escapeHtml(content)}</pre>
<div class="title-page">
${result.html.title_page}
</div>
@@ -130,7 +132,7 @@ function installRule(markdownIt, mdOptions, ruleOptions, context) {
const token = tokens[idx];
if (token.info !== 'fountain') return defaultRender(tokens, idx, options, env, self);
addContextAssets(context);
return renderFountainScript(token.content);
return renderFountainScript(markdownIt, token.content);
};
}

View File

@@ -241,7 +241,7 @@ module.exports = function(context) {
var katexInline = function(latex) {
options.displayMode = false;
try {
return renderToStringWithCache(latex, options);
return `<span class="joplin-editable"><pre class="joplin-source" data-joplin-source-open="$" data-joplin-source-close="$">${latex}</pre>${renderToStringWithCache(latex, options)}</span>`;
} catch (error) {
console.error('Katex error for:', latex, error);
return latex;
@@ -256,7 +256,7 @@ module.exports = function(context) {
var katexBlock = function(latex) {
options.displayMode = true;
try {
return `<p>${renderToStringWithCache(latex, options)}</p>`;
return `<div class="joplin-editable"><pre class="joplin-source" data-joplin-source-open="$$&#10;" data-joplin-source-close="&#10;$$&#10;">${latex}</pre>${renderToStringWithCache(latex, options)}</div>`;
} catch (error) {
console.error('Katex error for:', latex, error);
return latex;

View File

@@ -25,7 +25,13 @@ function installRule(markdownIt:any, mdOptions:any, ruleOptions:any, context:any
const token = tokens[idx];
if (token.info !== 'mermaid') return defaultRender(tokens, idx, options, env, self);
addContextAssets(context);
return `<div class="mermaid">${token.content}</div>`;
const contentHtml = markdownIt.utils.escapeHtml(token.content);
return `
<div class="joplin-editable">
<pre class="joplin-source" data-joplin-source-open="\`\`\`mermaid&#10;" data-joplin-source-close="&#10;\`\`\`&#10;">${contentHtml}</pre>
<div class="mermaid">${contentHtml}</div>
</div>
`;
};
}

View File

@@ -1,4 +1,6 @@
module.exports = function(style, options) {
style = style ? style : {};
// https://necolas.github.io/normalize.css/
const normalizeCss = `
html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
@@ -266,6 +268,31 @@ module.exports = function(style, options) {
padding: 1em;
}
.joplin-editable .joplin-source {
display: none;
}
/* For TinyMCE */
.mce-content-body {
padding: 5px 10px 10px 10px;
}
.mce-content-body code {
background-color: transparent;
}
.mce-content-body [data-mce-selected=inline-boundary] {
background-color: transparent;
}
.mce-content-body .joplin-editable {
cursor: pointer !important;
}
.mce-content-body.mce-content-readonly {
opacity: 0.5;
}
@media print {
body {
height: auto !important;

View File

@@ -108,7 +108,8 @@ class Logger {
log(level, ...object) {
if (!this.targets_.length) return;
let line = `${moment().format('YYYY-MM-DD HH:mm:ss')}: `;
const timestamp = moment().format('YYYY-MM-DD HH:mm:ss');
let line = `${timestamp}: `;
for (let i = 0; i < this.targets_.length; i++) {
let target = this.targets_[i];
@@ -121,7 +122,8 @@ class Logger {
if (level == Logger.LEVEL_WARN) fn = 'warn';
if (level == Logger.LEVEL_INFO) fn = 'info';
const consoleObj = target.console ? target.console : console;
consoleObj[fn](line + this.objectsToString(...object));
const items = [moment().format('HH:mm:ss')].concat(object);
consoleObj[fn](...items);
} else if (target.type == 'file') {
let serializedObject = this.objectsToString(...object);
try {

View File

@@ -2,6 +2,7 @@ const markdownUtils = require('lib/markdownUtils');
const htmlUtils = require('lib/htmlUtils');
const Setting = require('lib/models/Setting');
const Resource = require('lib/models/Resource');
const { shim } = require('lib/shim');
const { MarkupToHtml } = require('lib/joplin-renderer');
class MarkupLanguageUtils {
@@ -27,6 +28,8 @@ class MarkupLanguageUtils {
options = Object.assign({
ResourceModel: Resource,
pluginOptions: pluginOptions,
tempDir: Setting.value('tempDir'),
fsDriver: shim.fsDriver(),
}, options);
return new MarkupToHtml(options);

View File

@@ -148,7 +148,11 @@ class Note extends BaseItem {
return this.linkedItemIdsByType(BaseModel.TYPE_NOTE, body);
}
static async replaceResourceInternalToExternalLinks(body) {
static async replaceResourceInternalToExternalLinks(body, options = null) {
options = Object.assign({}, {
useAbsolutePaths: false,
}, options);
const resourceIds = await this.linkedResourceIds(body);
const Resource = this.getClass('Resource');
@@ -156,20 +160,35 @@ class Note extends BaseItem {
const id = resourceIds[i];
const resource = await Resource.load(id);
if (!resource) continue;
const resourcePath = Resource.relativePath(resource);
const resourcePath = options.useAbsolutePaths ? Resource.fullPath(resource) : Resource.relativePath(resource);
body = body.replace(new RegExp(`:/${id}`, 'gi'), resourcePath);
}
return body;
}
static async replaceResourceExternalToInternalLinks(body) {
const reString = `${pregQuote(`${Resource.baseRelativeDirectoryPath()}/`)}[a-zA-Z0-9.]+`;
const re = new RegExp(reString, 'gi');
body = body.replace(re, match => {
const id = Resource.pathToId(match);
return `:/${id}`;
});
static async replaceResourceExternalToInternalLinks(body, options = null) {
options = Object.assign({}, {
useAbsolutePaths: false,
}, options);
const pathsToTry = [];
if (options.useAbsolutePaths) {
pathsToTry.push(Setting.value('resourceDir'));
pathsToTry.push(shim.pathRelativeToCwd(Setting.value('resourceDir')));
} else {
pathsToTry.push(Resource.baseRelativeDirectoryPath());
}
for (const basePath of pathsToTry) {
const reString = `${pregQuote(`${basePath}/`)}[a-zA-Z0-9.]+`;
const re = new RegExp(reString, 'gi');
body = body.replace(re, match => {
const id = Resource.pathToId(match);
return `:/${id}`;
});
}
return body;
}

View File

@@ -265,6 +265,7 @@ class Setting extends BaseModel {
[Setting.LAYOUT_EDITOR_VIEWER]: _('%s / %s', _('Editor'), _('Viewer')),
[Setting.LAYOUT_EDITOR_SPLIT]: _('%s / %s', _('Editor'), _('Split View')),
[Setting.LAYOUT_VIEWER_SPLIT]: _('%s / %s', _('Viewer'), _('Split View')),
[Setting.LAYOUT_SPLIT_WYSIWYG]: _('%s / %s', _('Split'), 'WYSIWYG (Experimental)'),
}),
},
uncompletedTodosOnTop: { value: true, type: Setting.TYPE_BOOL, section: 'note', public: true, appTypes: ['cli'], label: () => _('Uncompleted to-dos on top') },
@@ -1039,6 +1040,7 @@ Setting.LAYOUT_ALL = 0;
Setting.LAYOUT_EDITOR_VIEWER = 1;
Setting.LAYOUT_EDITOR_SPLIT = 2;
Setting.LAYOUT_VIEWER_SPLIT = 3;
Setting.LAYOUT_SPLIT_WYSIWYG = 4;
Setting.DATE_FORMAT_1 = 'DD/MM/YYYY';
Setting.DATE_FORMAT_2 = 'DD/MM/YY';

View File

@@ -51,6 +51,7 @@ const defaultState = {
historyNotes: [],
plugins: {},
provisionalNoteIds: [],
editorNoteStatuses: {},
};
const stateUtils = {};
@@ -439,6 +440,16 @@ const reducer = (state = defaultState, action) => {
}
break;
case 'NOTE_PROVISIONAL_FLAG_CLEAR':
{
const newIds = ArrayUtils.removeElement(state.provisionalNoteIds, action.id);
if (newIds !== state.provisionalNoteIds) {
newState = Object.assign({}, state, { provisionalNoteIds: newIds });
}
}
break;
// Replace all the notes with the provided array
case 'NOTE_UPDATE_ALL':
newState = Object.assign({}, state);
@@ -589,6 +600,24 @@ const reducer = (state = defaultState, action) => {
}
break;
case 'EDITOR_NOTE_STATUS_SET':
{
const newStatuses = Object.assign({}, state.editorNoteStatuses);
newStatuses[action.id] = action.status;
newState = Object.assign({}, state, { editorNoteStatuses: newStatuses });
}
break;
case 'EDITOR_NOTE_STATUS_REMOVE':
{
const newStatuses = Object.assign({}, state.editorNoteStatuses);
delete newStatuses[action.id];
newState = Object.assign({}, state, { editorNoteStatuses: newStatuses });
}
break;
case 'FOLDER_UPDATE_ONE':
case 'MASTERKEY_UPDATE_ONE':
newState = updateOneItem(state, action);

View File

@@ -11,6 +11,7 @@ const urlValidator = require('valid-url');
const { _ } = require('lib/locale.js');
const http = require('http');
const https = require('https');
const toRelative = require('relative');
function shimInit() {
shim.fsDriver = () => {
@@ -407,6 +408,10 @@ function shimInit() {
const p = require('../package.json');
return p.version;
};
shim.pathRelativeToCwd = (path) => {
return toRelative(process.cwd(), path);
};
}
module.exports = { shimInit };

View File

@@ -216,4 +216,8 @@ shim.setIsTestingEnv = (v) => {
isTestingEnv_ = v;
};
shim.pathRelativeToCwd = (path) => {
throw new Error('Not implemented');
};
module.exports = { shim };

View File

@@ -5,7 +5,7 @@ const rootDir = utils.rootDir();
module.exports = {
src: `${rootDir}/ReactNativeClient/lib/**/*`,
fn: async function() {
await utils.copyDir(`${rootDir}/ReactNativeClient/lib`, `${rootDir}/CliClient/build/lib`);
await utils.copyDir(`${rootDir}/ReactNativeClient/lib`, `${rootDir}/ElectronClient/lib`);
await utils.copyDir(`${rootDir}/ReactNativeClient/lib`, `${rootDir}/CliClient/build/lib`, { delete: false });
await utils.copyDir(`${rootDir}/ReactNativeClient/lib`, `${rootDir}/ElectronClient/lib`, { delete: false });
},
};

View File

@@ -80,6 +80,7 @@ utils.copyDir = async function(src, dest, options) {
options = Object.assign({}, {
excluded: [],
delete: true,
}, options);
src = utils.toSystemSlashes(src);
@@ -96,6 +97,8 @@ utils.copyDir = async function(src, dest, options) {
excludedFlag = `/EXCLUDE:${tempFile}`;
}
// TODO: add support for delete flag
await utils.execCommand(`xcopy /C /I /H /R /Y /S ${excludedFlag} "${src}" ${dest}`);
if (tempFile) await fs.remove(tempFile);
@@ -107,7 +110,10 @@ utils.copyDir = async function(src, dest, options) {
}).join(' ');
}
await utils.execCommand(`rsync -a --delete ${excludedFlag} "${src}/" "${dest}/"`);
let deleteFlag = '';
if (options.delete) deleteFlag = '--delete';
await utils.execCommand(`rsync -a ${deleteFlag} ${excludedFlag} "${src}/" "${dest}/"`);
}
};

View File

@@ -886,7 +886,7 @@
},
"wrap-ansi": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz",
"resolved": "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz",
"integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=",
"requires": {
"string-width": "^1.0.1",
@@ -918,7 +918,7 @@
},
"strip-ansi": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
"resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
"requires": {
"ansi-regex": "^2.0.0"

View File

@@ -36,7 +36,14 @@
"ElectronClient/gui/TinyMCE.js",
"ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js",
"ReactNativeClient/setUpQuickActions.js",
"ReactNativeClient/android/app/joplin.keystore"
"ReactNativeClient/android/app/joplin.keystore",
"ReactNativeClient/lib/AsyncActionHandler.js",
"*.eps",
"ElectronClient/gui/editors/TinyMCE.js",
"ElectronClient/gui/editors/PlainEditor.js",
"ElectronClient/gui/MultiNoteActions.js",
"ElectronClient/gui/NoteContentPropertiesDialog.js",
"ElectronClient/gui/utils/NoteText.js"
],
"folder_exclude_patterns":
[
@@ -78,7 +85,11 @@
"ReactNativeClient/ios/Pods",
"CliClient/locales-build",
"ReactNativeClient/lib/vendor",
"ReactNativeClient/ios/Joplin-tvOS"
"ReactNativeClient/ios/Joplin-tvOS",
"ReactNativeClient/ios/Joplin.xcodeproj/project.xcworkspace",
"ReactNativeClient/ios/Joplin.xcworkspace/xcuserdata",
"ReactNativeClient/ios/Joplin.xcodeproj/xcuserdata",
"ElectronClient/pluginAssets"
],
"path": "."
}

57
package-lock.json generated
View File

@@ -59,6 +59,16 @@
"any-observable": "^0.3.0"
}
},
"@types/draft-js": {
"version": "0.10.38",
"resolved": "https://registry.npmjs.org/@types/draft-js/-/draft-js-0.10.38.tgz",
"integrity": "sha512-iDg8fJATjGVfQVv/dysAMMcPwORyJix2AyAm8OdwseteYh1jV+Xwlz+1HddoCWxQWqzWf/MDkNQH5sLYG47e0w==",
"dev": true,
"requires": {
"@types/react": "*",
"immutable": "~3.7.4"
}
},
"@types/eslint-visitor-keys": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz",
@@ -82,6 +92,16 @@
"@types/node": "*"
}
},
"@types/hoist-non-react-statics": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
"integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==",
"dev": true,
"requires": {
"@types/react": "*",
"hoist-non-react-statics": "^3.3.0"
}
},
"@types/json-schema": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.3.tgz",
@@ -131,6 +151,18 @@
"@types/react": "*"
}
},
"@types/react-redux": {
"version": "7.1.7",
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.7.tgz",
"integrity": "sha512-U+WrzeFfI83+evZE2dkZ/oF/1vjIYgqrb5dGgedkqVV8HEfDFujNgWCwHL89TDuWKb47U0nTBT6PLGq4IIogWg==",
"dev": true,
"requires": {
"@types/hoist-non-react-statics": "^3.3.0",
"@types/react": "*",
"hoist-non-react-statics": "^3.3.0",
"redux": "^4.0.0"
}
},
"@typescript-eslint/eslint-plugin": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.10.0.tgz",
@@ -3204,6 +3236,15 @@
}
}
},
"hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"dev": true,
"requires": {
"react-is": "^16.7.0"
}
},
"homedir-polyfill": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz",
@@ -3261,6 +3302,12 @@
"integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
"dev": true
},
"immutable": {
"version": "3.7.6",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz",
"integrity": "sha1-E7TTyxK++hVIKib+Gy665kAHHks=",
"dev": true
},
"import-fresh": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.1.0.tgz",
@@ -5111,6 +5158,16 @@
"resolve": "^1.1.6"
}
},
"redux": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz",
"integrity": "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==",
"dev": true,
"requires": {
"loose-envify": "^1.4.0",
"symbol-observable": "^1.2.0"
}
},
"regex-not": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz",

View File

@@ -28,6 +28,7 @@
"devDependencies": {
"@types/react": "^16.9.0",
"@types/react-dom": "^16.9.0",
"@types/react-redux": "^7.1.7",
"@typescript-eslint/eslint-plugin": "^2.10.0",
"@typescript-eslint/parser": "^2.10.0",
"eslint": "^6.1.0",