mirror of
https://github.com/laurent22/joplin.git
synced 2024-11-27 08:21:03 +02:00
Merge branch 'master' of https://github.com/fhfuih/joplin
This commit is contained in:
commit
57d71712e1
@ -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
|
||||
|
@ -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
7
.gitignore
vendored
@ -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
|
||||
|
50
CliClient/package-lock.json
generated
50
CliClient/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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'),
|
||||
// });
|
||||
|
||||
// }));
|
||||
|
||||
});
|
||||
|
1
CliClient/tests/html_to_md/image_preserve_size_1.html
Normal file
1
CliClient/tests/html_to_md/image_preserve_size_1.html
Normal file
@ -0,0 +1 @@
|
||||
<img src=":/0415d61cc33e47afa6dde45948c3177a"/>
|
1
CliClient/tests/html_to_md/image_preserve_size_1.md
Normal file
1
CliClient/tests/html_to_md/image_preserve_size_1.md
Normal file
@ -0,0 +1 @@
|
||||
![](:/0415d61cc33e47afa6dde45948c3177a)
|
1
CliClient/tests/html_to_md/image_preserve_size_2.html
Normal file
1
CliClient/tests/html_to_md/image_preserve_size_2.html
Normal file
@ -0,0 +1 @@
|
||||
<img src=":/0415d61cc33e47afa6dde45948c3177a" width="500" height="500">
|
1
CliClient/tests/html_to_md/image_preserve_size_2.md
Normal file
1
CliClient/tests/html_to_md/image_preserve_size_2.md
Normal file
@ -0,0 +1 @@
|
||||
<img src=":/0415d61cc33e47afa6dde45948c3177a" width="500" height="500">
|
53
CliClient/tests/html_to_md/joplin_checkboxes.html
Normal file
53
CliClient/tests/html_to_md/joplin_checkboxes.html
Normal 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("cb-label-md-checkbox-7");
|
||||
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("cb-label-md-checkbox-8");
|
||||
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("cb-label-md-checkbox-9");
|
||||
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>
|
3
CliClient/tests/html_to_md/joplin_checkboxes.md
Normal file
3
CliClient/tests/html_to_md/joplin_checkboxes.md
Normal file
@ -0,0 +1,3 @@
|
||||
- [x] one
|
||||
- [ ] two
|
||||
- [ ] with **bold** text
|
1
CliClient/tests/html_to_md/joplin_source_1.html
Normal file
1
CliClient/tests/html_to_md/joplin_source_1.html
Normal file
File diff suppressed because one or more lines are too long
1
CliClient/tests/html_to_md/joplin_source_1.md
Normal file
1
CliClient/tests/html_to_md/joplin_source_1.md
Normal file
@ -0,0 +1 @@
|
||||
$katexcode$
|
1
CliClient/tests/html_to_md/joplin_source_2.html
Normal file
1
CliClient/tests/html_to_md/joplin_source_2.html
Normal file
File diff suppressed because one or more lines are too long
3
CliClient/tests/html_to_md/joplin_source_2.md
Normal file
3
CliClient/tests/html_to_md/joplin_source_2.md
Normal file
@ -0,0 +1,3 @@
|
||||
$$
|
||||
katexcode
|
||||
$$
|
5
CliClient/tests/md_to_html/code_block.html
Normal file
5
CliClient/tests/md_to_html/code_block.html
Normal file
@ -0,0 +1,5 @@
|
||||
<div class="joplin-editable"><pre class="joplin-source" data-joplin-source-open="```javascript " data-joplin-source-close=" ```">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>
|
5
CliClient/tests/md_to_html/code_block.md
Normal file
5
CliClient/tests/md_to_html/code_block.md
Normal file
@ -0,0 +1,5 @@
|
||||
```javascript
|
||||
function() {
|
||||
console.info('bonjour');
|
||||
}
|
||||
```
|
@ -1,2 +1 @@
|
||||
<pre class="hljs"><code><span class="hljs-tag"><<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>></span>testing fence<span class="hljs-tag"></<span class="hljs-name">a</span>></span>
|
||||
</code></pre>
|
||||
<div class="joplin-editable"><pre class="joplin-source" data-joplin-source-open="```html " data-joplin-source-close=" ```"><a href="#" onclick="leavethisalone">testing fence</a></pre><pre class="hljs"><code><span class="hljs-tag"><<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>></span>testing fence<span class="hljs-tag"></<span class="hljs-name">a</span>></span></code></pre></div>
|
||||
|
@ -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);
|
||||
|
3
ElectronClient/.gitignore
vendored
3
ElectronClient/.gitignore
vendored
@ -5,4 +5,5 @@ lib/
|
||||
gui/*.min.js
|
||||
plugins/*.min.js
|
||||
.DS_Store
|
||||
gui/note-viewer/pluginAssets/
|
||||
gui/note-viewer/pluginAssets/
|
||||
pluginAssets/
|
@ -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'];
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
|
73
ElectronClient/gui/MultiNoteActions.tsx
Normal file
73
ElectronClient/gui/MultiNoteActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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,
|
||||
|
566
ElectronClient/gui/NoteText2.tsx
Normal file
566
ElectronClient/gui/NoteText2.tsx
Normal 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);
|
59
ElectronClient/gui/editors/PlainEditor.tsx
Normal file
59
ElectronClient/gui/editors/PlainEditor.tsx
Normal 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);
|
||||
|
465
ElectronClient/gui/editors/TinyMCE.tsx
Normal file
465
ElectronClient/gui/editors/TinyMCE.tsx
Normal 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);
|
||||
|
18
ElectronClient/gui/utils/NoteText.ts
Normal file
18
ElectronClient/gui/utils/NoteText.ts
Normal 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,
|
||||
}
|
@ -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);
|
||||
|
484
ElectronClient/package-lock.json
generated
484
ElectronClient/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
81
ReactNativeClient/lib/AsyncActionQueue.ts
Normal file
81
ReactNativeClient/lib/AsyncActionQueue.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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_);
|
||||
|
@ -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');
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -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');
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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} " data-joplin-source-close=" \`\`\`">${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;
|
||||
|
@ -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)) {
|
||||
|
@ -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);
|
||||
};
|
||||
}
|
@ -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 " data-joplin-source-close=" \`\`\` ">${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);
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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="$$ " data-joplin-source-close=" $$ ">${latex}</pre>${renderToStringWithCache(latex, options)}</div>`;
|
||||
} catch (error) {
|
||||
console.error('Katex error for:', latex, error);
|
||||
return latex;
|
||||
|
@ -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 " data-joplin-source-close=" \`\`\` ">${contentHtml}</pre>
|
||||
<div class="mermaid">${contentHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
|
@ -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 };
|
||||
|
@ -216,4 +216,8 @@ shim.setIsTestingEnv = (v) => {
|
||||
isTestingEnv_ = v;
|
||||
};
|
||||
|
||||
shim.pathRelativeToCwd = (path) => {
|
||||
throw new Error('Not implemented');
|
||||
};
|
||||
|
||||
module.exports = { shim };
|
||||
|
@ -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 });
|
||||
},
|
||||
};
|
||||
|
@ -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}/"`);
|
||||
}
|
||||
};
|
||||
|
||||
|
4
Tools/package-lock.json
generated
4
Tools/package-lock.json
generated
@ -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"
|
||||
|
@ -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
41
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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.
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user