1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-11-27 08:21:03 +02:00
This commit is contained in:
fhfuih 2020-03-09 20:42:02 -04:00
commit 57d71712e1
63 changed files with 2126 additions and 318 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

@ -1,12 +1,18 @@
const React = require('react');
import { View, Platform, SafeAreaView, StyleSheet, StatusBar } from 'react-native';
import DeviceInfo from 'react-native-device-info';
import { View, Platform, SafeAreaView, StyleSheet /* , StatusBar */ } from 'react-native';
// import DeviceInfo from 'react-native-device-info';
// Untested! This should check if the device has a notch and, if it does, apply
// an extra padding on top of the screen.
const styles = StyleSheet.create({
AndroidSafeArea: {
paddingTop: Platform.OS === 'android' && DeviceInfo.hasNotch() ? StatusBar.currentHeight : 0,
// Disabled for now because it seems that even when there's a notch the system status bar
// covers it, and thus we should add our own gap.
// Can only test on emulator though
// Fixes: https://github.com/laurent22/joplin/issues/2694
// paddingTop: Platform.OS === 'android' && DeviceInfo.hasNotch() ? StatusBar.currentHeight : 0,
paddingTop: 0,
},
});

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": "."
}

41
package-lock.json generated
View File

@ -82,6 +82,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 +141,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 +3226,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",
@ -5111,6 +5142,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",

View File

@ -46,7 +46,7 @@ Skills Required: JavaScript; React; React Native (for mobile)
Potential Mentor(s): [Roeland Jago Douma](mailto:roeland.douma@nextcloud.com), [laurent22](https://github.com/laurent22/)
More info: [Forum thread about Joplin Web API for Nextcloud](https://discourse.joplinapp.org/t/joplin-api-in-nextcloud-prototype/)
More info: [Forum thread about Joplin Web API for Nextcloud](https://discourse.joplinapp.org/t/joplin-web-api-for-nextcloud/4491)
## 3. Hierarchical Tags
@ -101,7 +101,7 @@ Potential Mentor(s): [Roeland Jago Douma](mailto:roeland.douma@nextcloud.com), [
More info: [GitHub: Nextcloud notes integration (Web client)](https://github.com/laurent22/joplin/issues/228)
## 5. OCR support
## 6. OCR support
It is possible to add support for OCR content in Joplin via the [Tesseract library](http://tesseract.projectnaptha.com/). A first step would be to assess the feasibility of this project by integrating the lib in the desktop app and trying to OCR an image. OCR support should be implemented as a service of the desktop app. It would extract the text from the images, and append the content as plain text to the notes.
@ -113,7 +113,7 @@ Skills Required: JavaScript
Potential Mentor(s): [CalebJohn](https://github.com/CalebJohn/), [laurent22](https://github.com/laurent22/)
## 6. Password-protected notes
## 7. Password-protected notes
We would like to add an option to allow encrypting a note or a notebook with a password. When opening the note, the password must be provided to reveal the content.
@ -125,7 +125,7 @@ Skills Required: JavaScript; React
Potential Mentor(s): [PackElend](https://github.com/PackElend), [laurent22](https://github.com/laurent22/)
## 7. Search
## 8. Search
The current search engine is built on top of SQLite FTS. An index of the notes is built and this is what is used by FTS when searching.
@ -151,7 +151,7 @@ Potential Mentor(s): [laurent22](https://github.com/laurent22/)
More info: [Search engine improvements](https://github.com/laurent22/joplin/issues/1877)
## 8. WYSIWYG Editor
## 9. WYSIWYG Editor
The current editor shows the Markdown text on the left side and the rendered HTML on the right side (a split view). We would like to add another editor option, which would be a WYSIWYG editor, where the user can directly edit formatted text. This would not replace the split view but rather be an alternative editor and the user can choose either split view or WYSIWYG.
@ -165,7 +165,7 @@ Potential Mentor(s): [CalebJohn](https://github.com/CalebJohn/), [PackElend](htt
More info: [WYSIWYG thread on the forum](https://discourse.joplinapp.org/t/wysiwyg-editor-in-joplin/2253)
# 9. Custom keyboard shortcuts
# 10. Custom keyboard shortcuts
The CLI application allows setting custom keyboard shortcuts, however this feature is currently missing from the desktop application. We would like to let the user set shortcuts for the menu items in particular, but also potentially any other Joplin action. There should be a shortcut editor in the Config panel to do this.