You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	* Trying to get TuiEditor to work * Tests with TinyMCE * Fixed build * Improved asset loading * Added support for Joplin source blocks * Added support for Joplin source blocks * Better integration * Make sure noteDidUpdate event is always dispatched at the right time * Minor tweaks * Fixed tests * Add support for checkboxes * Minor refactoring * Added support for file attachments * Add support for fenced code blocks * Fix new line issue on code block * Added support for Fountain scripts * Refactoring * Better handling of saving and loading notes * Fix saving and loading ntoes * Handle multi-note selection and fixed new note creation issue * Fixed newline issue in test * Fixed newline issue in test * Improve saving and loading * Improve saving and loading note * Removed undeeded prop * Fixed issue when new note being saved is incorrectly reloaded * Refactoring and improve saving of note when unmounting component * Fixed TypeScript error * Small changes * Improved further handling of saving and loading notes * Handle provisional notes and fixed various saving and loading bugs * Adding back support for HTML notes * Added support for HTML notes * Better handling of editable nodes * Preserve image HTML tag when the size is set * Handle switching between editor when the note has note finished saving * Handle templates * Handle templates * Handle loading note that is being saved * Handle note being reloaded via sync * Clean up * Clean up and improved logging * Fixed TS error * Fixed a few issues * Fixed test * Logging * Various improvements * Add blockquote support * Moved CWD operation to shim * Removed deleted files * Added support for Joplin commands
This commit is contained in:
		| @@ -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 @@ | ||||
|  | ||||
							
								
								
									
										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'); | ||||
|   | ||||
| @@ -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": "." | ||||
| 		} | ||||
|   | ||||
							
								
								
									
										57
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										57
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -59,6 +59,16 @@ | ||||
|         "any-observable": "^0.3.0" | ||||
|       } | ||||
|     }, | ||||
|     "@types/draft-js": { | ||||
|       "version": "0.10.38", | ||||
|       "resolved": "https://registry.npmjs.org/@types/draft-js/-/draft-js-0.10.38.tgz", | ||||
|       "integrity": "sha512-iDg8fJATjGVfQVv/dysAMMcPwORyJix2AyAm8OdwseteYh1jV+Xwlz+1HddoCWxQWqzWf/MDkNQH5sLYG47e0w==", | ||||
|       "dev": true, | ||||
|       "requires": { | ||||
|         "@types/react": "*", | ||||
|         "immutable": "~3.7.4" | ||||
|       } | ||||
|     }, | ||||
|     "@types/eslint-visitor-keys": { | ||||
|       "version": "1.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", | ||||
| @@ -82,6 +92,16 @@ | ||||
|         "@types/node": "*" | ||||
|       } | ||||
|     }, | ||||
|     "@types/hoist-non-react-statics": { | ||||
|       "version": "3.3.1", | ||||
|       "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", | ||||
|       "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", | ||||
|       "dev": true, | ||||
|       "requires": { | ||||
|         "@types/react": "*", | ||||
|         "hoist-non-react-statics": "^3.3.0" | ||||
|       } | ||||
|     }, | ||||
|     "@types/json-schema": { | ||||
|       "version": "7.0.3", | ||||
|       "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.3.tgz", | ||||
| @@ -131,6 +151,18 @@ | ||||
|         "@types/react": "*" | ||||
|       } | ||||
|     }, | ||||
|     "@types/react-redux": { | ||||
|       "version": "7.1.7", | ||||
|       "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.7.tgz", | ||||
|       "integrity": "sha512-U+WrzeFfI83+evZE2dkZ/oF/1vjIYgqrb5dGgedkqVV8HEfDFujNgWCwHL89TDuWKb47U0nTBT6PLGq4IIogWg==", | ||||
|       "dev": true, | ||||
|       "requires": { | ||||
|         "@types/hoist-non-react-statics": "^3.3.0", | ||||
|         "@types/react": "*", | ||||
|         "hoist-non-react-statics": "^3.3.0", | ||||
|         "redux": "^4.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "@typescript-eslint/eslint-plugin": { | ||||
|       "version": "2.10.0", | ||||
|       "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.10.0.tgz", | ||||
| @@ -3204,6 +3236,15 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "hoist-non-react-statics": { | ||||
|       "version": "3.3.2", | ||||
|       "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", | ||||
|       "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", | ||||
|       "dev": true, | ||||
|       "requires": { | ||||
|         "react-is": "^16.7.0" | ||||
|       } | ||||
|     }, | ||||
|     "homedir-polyfill": { | ||||
|       "version": "1.0.3", | ||||
|       "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", | ||||
| @@ -3261,6 +3302,12 @@ | ||||
|       "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "immutable": { | ||||
|       "version": "3.7.6", | ||||
|       "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz", | ||||
|       "integrity": "sha1-E7TTyxK++hVIKib+Gy665kAHHks=", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "import-fresh": { | ||||
|       "version": "3.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.1.0.tgz", | ||||
| @@ -5111,6 +5158,16 @@ | ||||
|         "resolve": "^1.1.6" | ||||
|       } | ||||
|     }, | ||||
|     "redux": { | ||||
|       "version": "4.0.5", | ||||
|       "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz", | ||||
|       "integrity": "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==", | ||||
|       "dev": true, | ||||
|       "requires": { | ||||
|         "loose-envify": "^1.4.0", | ||||
|         "symbol-observable": "^1.2.0" | ||||
|       } | ||||
|     }, | ||||
|     "regex-not": { | ||||
|       "version": "1.0.2", | ||||
|       "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user