You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Refactor note editor
Refactor note editor using React Hooks and TypeScript and moved editor-specific code to separate files. Moved business logic into more maintainable custom hooks. Squashed commit of the following: commit f243d9bf89bdcfa1849ee26df5c0dd3e33405010 Author: Laurent Cozic <laurent@cozic.net> Date: Sat May 2 16:04:14 2020 +0100 Fixed saving issue commit 055f68d2e8b6cf6f130336c38ac2ab480887583d Author: Laurent Cozic <laurent@cozic.net> Date: Sat May 2 15:43:38 2020 +0100 Fixed HTML notes commit 99a3cf71f58d2fedcdf3001bf4110b6e8e3993da Merge: 9be85c45f2b16ebbbf7aAuthor: Laurent Cozic <laurent@cozic.net> Date: Sat May 2 12:54:42 2020 +0100 Merge branch 'master' into refactor_note_text commit 9be85c45f23e5cb1ecd612b0ee631947871ada6f Author: Laurent Cozic <laurent@cozic.net> Date: Sat May 2 12:21:01 2020 +0100 Ident to space commit 848dde1869c010fe5851f493ef7287ada5f2991e Author: Laurent Cozic <laurent@cozic.net> Date: Sat May 2 11:28:50 2020 +0100 Refactor prop types commit 13c3bbe2b4f9a522ea3f8a25e7e5e7bb026dfd4f Author: Laurent Cozic <laurent@cozic.net> Date: Sat May 2 11:15:45 2020 +0100 Fixed resource loading issue commit 50cb38e3f00ef40ea8b6a468eadd66728a3ec332 Author: Laurent Cozic <laurent@cozic.net> Date: Fri May 1 23:46:58 2020 +0100 Fixed resource loading logic commit bc42ed03735f50c8394d597bb9e67312e55752fe Author: Laurent Cozic <laurent@cozic.net> Date: Fri May 1 23:08:41 2020 +0100 Various fixes commit 03c038e6d6cbde03bd474798b96c4eb120fd1647 Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 29 23:22:49 2020 +0100 Fixed resource handling commit dc6c15302fac094c4e7dec5a20c9fcc4edb3d132 Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 29 22:55:13 2020 +0100 Moved more code to files commit 398d5121e53df34de89b4148ef2cfd3a7bbe4feb Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 29 00:22:43 2020 +0000 More fixes commit 3ebbb80147d7d502fd955776c7fedb743400597f Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 29 00:12:44 2020 +0000 Various improvements and bug fixes commit 52a65ed3875e0709117ca93ba723e20624577d05 Author: Laurent Cozic <laurent@cozic.net> Date: Tue Apr 28 23:51:07 2020 +0000 Move more code to sub-files commit 33ccf530fb442d7ddae0852cbab2c335efdbbf33 Author: Laurent Cozic <laurent@cozic.net> Date: Tue Apr 28 23:25:12 2020 +0100 Moved code to sub-files commit ba3ad2cf9fcc1d7809df4afe93cd9737585a9960 Merge: 445acdab73150ee14de6Author: Laurent Cozic <laurent@cozic.net> Date: Tue Apr 28 22:28:56 2020 +0100 Merge branch 'master' into refactor_note_text commit 445acdab7368345369d7f69b9becd1e77c8383dc Author: Laurent Cozic <laurent@cozic.net> Date: Tue Apr 28 19:01:41 2020 +0100 Imported more code commit 772481d3a3ac7f0b0b00e86394c0f4fd2f3a9fa7 Author: Laurent Cozic <laurent@cozic.net> Date: Mon Apr 27 23:43:17 2020 +0000 Handle save/load state commit b3b92192ae3a1a30e3018810346cebfad47ac5e3 Author: Laurent Cozic <laurent@cozic.net> Date: Mon Apr 27 23:11:11 2020 +0000 Clean up and added back scroll commit 7a19ecfd0cb7fef1d58ece2e024099c7e40986da Author: Laurent Cozic <laurent@cozic.net> Date: Mon Apr 27 22:29:39 2020 +0100 More refactoring commit ac388afd381eaecfa4582b3566d032c9d953c4dc Author: Laurent Cozic <laurent@cozic.net> Date: Sun Apr 26 17:07:01 2020 +0100 Restored print commit 1d2c0ed389a5398dacc584d24922c5ea0dda861a Author: Laurent Cozic <laurent@cozic.net> Date: Sun Apr 26 12:03:15 2020 +0100 Put back search commit c618cb59d43fa3bb507dbd0b757b302ecfe907b3 Author: Laurent Cozic <laurent@cozic.net> Date: Sat Apr 25 18:21:11 2020 +0100 Restore scrolling behaviour commit 324e6ea79ebafab1d2bca246ef030751147a47eb Author: Laurent Cozic <laurent@cozic.net> Date: Sat Apr 25 10:22:31 2020 +0100 Simplified saving notes commit ef089aaf2289193bf275d94c1f2785f6d88657e4 Author: Laurent Cozic <laurent@cozic.net> Date: Sat Apr 25 10:12:16 2020 +0100 More refactoring commit 61b102307d5a98d2c1502d7bf073592da21af720 Author: Laurent Cozic <laurent@cozic.net> Date: Fri Apr 24 18:04:44 2020 +0100 Added back note revisions commit 7d5e3694d0df044b8493d9114e89e2d81c9b69ad Author: Laurent Cozic <laurent@cozic.net> Date: Thu Apr 23 22:51:52 2020 +0000 More note toolbar refactoring commit a56d58e7c80d91f29afadaffaaa004f3254482f7 Author: Laurent Cozic <laurent@cozic.net> Date: Thu Apr 23 20:54:37 2020 +0100 Finished toolbar refactoring commit 6c8ef9f44f880a9569eed5c54c9c47dca2251e5e Author: Laurent Cozic <laurent@cozic.net> Date: Thu Apr 23 19:17:44 2020 +0100 More refactoring commit 7de8057158a9256e2e0dcf948081e10a6a642216 Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 22 23:48:42 2020 +0100 Started refactoring commands commit 177263c85e7d17d8ddc01b583738c2ab14b3acd7 Merge: f58f1a06e07ceb68d835Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 22 20:26:19 2020 +0100 Merge branch 'master' into refactor_note_text commit f58f1a06e08b3cf80e2ac7a794b15f4b5caf8932 Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 22 20:25:43 2020 +0100 Moving Ace Editor to separate component commit a83d3a220515137985c0f334f5848c91b8539138 Author: Laurent Cozic <laurent@cozic.net> Date: Mon Apr 20 20:33:21 2020 +0000 Cleaned up directory structure for note editor commit c6f2e609c9443bac21de5033bbedf86ac6f12cc0 Author: Laurent Cozic <laurent@cozic.net> Date: Mon Apr 20 19:23:06 2020 +0100 Added "note" menu to move note-related items to it commit 1219465318ae5a7a2c777ae2ec15d3357e1499df Author: Laurent Cozic <laurent@cozic.net> Date: Mon Apr 20 19:05:04 2020 +0100 Moved note related toolbar to separate component
This commit is contained in:
		| @@ -27,8 +27,8 @@ Clipper/popup/node_modules | ||||
| Clipper/popup/scripts/build.js | ||||
| docs/ | ||||
| ElectronClient/dist | ||||
| ElectronClient/gui/editors/TinyMCE/plugins/lists.js | ||||
| ElectronClient/lib | ||||
| ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/plugins/lists.js | ||||
| ElectronClient/lib/vendor/sjcl-rn.js | ||||
| ElectronClient/lib/vendor/sjcl.js | ||||
| ElectronClient/locales | ||||
| @@ -59,15 +59,32 @@ Tools/PortableAppsLauncher | ||||
| Modules/TinyMCE/IconPack/postinstall.js | ||||
|  | ||||
| # 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/NoteEditor/NoteBody/AceEditor/AceEditor.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/AceEditor/styles/index.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/index.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/types.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js | ||||
| ElectronClient/gui/NoteEditor/NoteEditor.js | ||||
| ElectronClient/gui/NoteEditor/styles/index.js | ||||
| ElectronClient/gui/NoteEditor/utils/index.js | ||||
| ElectronClient/gui/NoteEditor/utils/resourceHandling.js | ||||
| ElectronClient/gui/NoteEditor/utils/types.js | ||||
| ElectronClient/gui/NoteEditor/utils/useDropHandler.js | ||||
| ElectronClient/gui/NoteEditor/utils/useFormNote.js | ||||
| ElectronClient/gui/NoteEditor/utils/useMarkupToHtml.js | ||||
| ElectronClient/gui/NoteEditor/utils/useMessageHandler.js | ||||
| ElectronClient/gui/NoteEditor/utils/useNoteSearchBar.js | ||||
| ElectronClient/gui/NoteEditor/utils/useSearchMarkers.js | ||||
| ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.js | ||||
| ElectronClient/gui/NoteToolbar/NoteToolbar.js | ||||
| ElectronClient/gui/ResourceScreen.js | ||||
| ElectronClient/gui/ShareNoteDialog.js | ||||
| ElectronClient/gui/utils/NoteText.js | ||||
| ReactNativeClient/lib/AsyncActionQueue.js | ||||
| ReactNativeClient/lib/hooks/useImperativeHandlerDebugger.js | ||||
| ReactNativeClient/lib/hooks/usePrevious.js | ||||
| ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/checkbox.js | ||||
| ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/fence.js | ||||
| ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js | ||||
|   | ||||
| @@ -49,6 +49,7 @@ module.exports = { | ||||
| 		"react/jsx-uses-react": "error", | ||||
| 		"react/jsx-uses-vars": "error", | ||||
| 		"no-unused-vars": "error", | ||||
| 		"@typescript-eslint/no-unused-vars": "error", | ||||
| 		"no-constant-condition": 0, | ||||
| 		"no-prototype-builtins": 0, | ||||
| 		// This error is always a false positive so far since it detects | ||||
|   | ||||
							
								
								
									
										25
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										25
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -50,15 +50,32 @@ 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/NoteEditor/NoteBody/AceEditor/AceEditor.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/AceEditor/styles/index.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/index.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/types.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js | ||||
| ElectronClient/gui/NoteEditor/NoteEditor.js | ||||
| ElectronClient/gui/NoteEditor/styles/index.js | ||||
| ElectronClient/gui/NoteEditor/utils/index.js | ||||
| ElectronClient/gui/NoteEditor/utils/resourceHandling.js | ||||
| ElectronClient/gui/NoteEditor/utils/types.js | ||||
| ElectronClient/gui/NoteEditor/utils/useDropHandler.js | ||||
| ElectronClient/gui/NoteEditor/utils/useFormNote.js | ||||
| ElectronClient/gui/NoteEditor/utils/useMarkupToHtml.js | ||||
| ElectronClient/gui/NoteEditor/utils/useMessageHandler.js | ||||
| ElectronClient/gui/NoteEditor/utils/useNoteSearchBar.js | ||||
| ElectronClient/gui/NoteEditor/utils/useSearchMarkers.js | ||||
| ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.js | ||||
| ElectronClient/gui/NoteToolbar/NoteToolbar.js | ||||
| ElectronClient/gui/ResourceScreen.js | ||||
| ElectronClient/gui/ShareNoteDialog.js | ||||
| ElectronClient/gui/utils/NoteText.js | ||||
| ReactNativeClient/lib/AsyncActionQueue.js | ||||
| ReactNativeClient/lib/hooks/useImperativeHandlerDebugger.js | ||||
| ReactNativeClient/lib/hooks/usePrevious.js | ||||
| ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/checkbox.js | ||||
| ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/fence.js | ||||
| ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js | ||||
|   | ||||
| @@ -36,7 +36,7 @@ const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false'; | ||||
| const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false'; | ||||
|  | ||||
| const imageInlineSizeLimit = parseInt( | ||||
| 	process.env.IMAGE_INLINE_SIZE_LIMIT || '10000' | ||||
|   process.env.IMAGE_INLINE_SIZE_LIMIT || '10000' | ||||
| ); | ||||
|  | ||||
| // Check if TypeScript is setup | ||||
| @@ -51,113 +51,113 @@ const sassModuleRegex = /\.module\.(scss|sass)$/; | ||||
| // This is the production and development configuration. | ||||
| // It is focused on developer experience, fast rebuilds, and a minimal bundle. | ||||
| module.exports = function(webpackEnv) { | ||||
| 	const isEnvDevelopment = webpackEnv === 'development'; | ||||
| 	const isEnvProduction = webpackEnv === 'production'; | ||||
|   const isEnvDevelopment = webpackEnv === 'development'; | ||||
|   const isEnvProduction = webpackEnv === 'production'; | ||||
|  | ||||
| 	// Variable used for enabling profiling in Production | ||||
| 	// passed into alias object. Uses a flag if passed into the build command | ||||
| 	const isEnvProductionProfile = | ||||
|   // Variable used for enabling profiling in Production | ||||
|   // passed into alias object. Uses a flag if passed into the build command | ||||
|   const isEnvProductionProfile = | ||||
|     isEnvProduction && process.argv.includes('--profile'); | ||||
|  | ||||
| 	// Webpack uses `publicPath` to determine where the app is being served from. | ||||
| 	// It requires a trailing slash, or the file assets will get an incorrect path. | ||||
| 	// In development, we always serve from the root. This makes config easier. | ||||
| 	const publicPath = isEnvProduction | ||||
| 		? paths.servedPath | ||||
| 		: isEnvDevelopment && '/'; | ||||
| 	// Some apps do not use client-side routing with pushState. | ||||
| 	// For these, "homepage" can be set to "." to enable relative asset paths. | ||||
| 	const shouldUseRelativeAssetPaths = publicPath === './'; | ||||
|   // Webpack uses `publicPath` to determine where the app is being served from. | ||||
|   // It requires a trailing slash, or the file assets will get an incorrect path. | ||||
|   // In development, we always serve from the root. This makes config easier. | ||||
|   const publicPath = isEnvProduction | ||||
|     ? paths.servedPath | ||||
|     : isEnvDevelopment && '/'; | ||||
|   // Some apps do not use client-side routing with pushState. | ||||
|   // For these, "homepage" can be set to "." to enable relative asset paths. | ||||
|   const shouldUseRelativeAssetPaths = publicPath === './'; | ||||
|  | ||||
| 	// `publicUrl` is just like `publicPath`, but we will provide it to our app | ||||
| 	// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript. | ||||
| 	// Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz. | ||||
| 	const publicUrl = isEnvProduction | ||||
| 		? publicPath.slice(0, -1) | ||||
| 		: isEnvDevelopment && ''; | ||||
| 	// Get environment variables to inject into our app. | ||||
| 	const env = getClientEnvironment(publicUrl); | ||||
|   // `publicUrl` is just like `publicPath`, but we will provide it to our app | ||||
|   // as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript. | ||||
|   // Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz. | ||||
|   const publicUrl = isEnvProduction | ||||
|     ? publicPath.slice(0, -1) | ||||
|     : isEnvDevelopment && ''; | ||||
|   // Get environment variables to inject into our app. | ||||
|   const env = getClientEnvironment(publicUrl); | ||||
|  | ||||
| 	// common function to get style loaders | ||||
| 	const getStyleLoaders = (cssOptions, preProcessor) => { | ||||
| 		const loaders = [ | ||||
| 			isEnvDevelopment && require.resolve('style-loader'), | ||||
| 			isEnvProduction && { | ||||
| 				loader: MiniCssExtractPlugin.loader, | ||||
| 				options: shouldUseRelativeAssetPaths ? { publicPath: '../../' } : {}, | ||||
| 			}, | ||||
| 			{ | ||||
| 				loader: require.resolve('css-loader'), | ||||
| 				options: cssOptions, | ||||
| 			}, | ||||
| 			{ | ||||
| 				// Options for PostCSS as we reference these options twice | ||||
| 				// Adds vendor prefixing based on your specified browser support in | ||||
| 				// package.json | ||||
| 				loader: require.resolve('postcss-loader'), | ||||
| 				options: { | ||||
| 					// Necessary for external CSS imports to work | ||||
| 					// https://github.com/facebook/create-react-app/issues/2677 | ||||
| 					ident: 'postcss', | ||||
| 					plugins: () => [ | ||||
| 						require('postcss-flexbugs-fixes'), | ||||
| 						require('postcss-preset-env')({ | ||||
| 							autoprefixer: { | ||||
| 								flexbox: 'no-2009', | ||||
| 							}, | ||||
| 							stage: 3, | ||||
| 						}), | ||||
| 						// Adds PostCSS Normalize as the reset css with default options, | ||||
| 						// so that it honors browserslist config in package.json | ||||
| 						// which in turn let's users customize the target behavior as per their needs. | ||||
| 						postcssNormalize(), | ||||
| 					], | ||||
| 					sourceMap: isEnvProduction && shouldUseSourceMap, | ||||
| 				}, | ||||
| 			}, | ||||
| 		].filter(Boolean); | ||||
| 		if (preProcessor) { | ||||
| 			loaders.push( | ||||
| 				{ | ||||
| 					loader: require.resolve('resolve-url-loader'), | ||||
| 					options: { | ||||
| 						sourceMap: isEnvProduction && shouldUseSourceMap, | ||||
| 					}, | ||||
| 				}, | ||||
| 				{ | ||||
| 					loader: require.resolve(preProcessor), | ||||
| 					options: { | ||||
| 						sourceMap: true, | ||||
| 					}, | ||||
| 				} | ||||
| 			); | ||||
| 		} | ||||
| 		return loaders; | ||||
| 	}; | ||||
|   // common function to get style loaders | ||||
|   const getStyleLoaders = (cssOptions, preProcessor) => { | ||||
|     const loaders = [ | ||||
|       isEnvDevelopment && require.resolve('style-loader'), | ||||
|       isEnvProduction && { | ||||
|         loader: MiniCssExtractPlugin.loader, | ||||
|         options: shouldUseRelativeAssetPaths ? { publicPath: '../../' } : {}, | ||||
|       }, | ||||
|       { | ||||
|         loader: require.resolve('css-loader'), | ||||
|         options: cssOptions, | ||||
|       }, | ||||
|       { | ||||
|         // Options for PostCSS as we reference these options twice | ||||
|         // Adds vendor prefixing based on your specified browser support in | ||||
|         // package.json | ||||
|         loader: require.resolve('postcss-loader'), | ||||
|         options: { | ||||
|           // Necessary for external CSS imports to work | ||||
|           // https://github.com/facebook/create-react-app/issues/2677 | ||||
|           ident: 'postcss', | ||||
|           plugins: () => [ | ||||
|             require('postcss-flexbugs-fixes'), | ||||
|             require('postcss-preset-env')({ | ||||
|               autoprefixer: { | ||||
|                 flexbox: 'no-2009', | ||||
|               }, | ||||
|               stage: 3, | ||||
|             }), | ||||
|             // Adds PostCSS Normalize as the reset css with default options, | ||||
|             // so that it honors browserslist config in package.json | ||||
|             // which in turn let's users customize the target behavior as per their needs. | ||||
|             postcssNormalize(), | ||||
|           ], | ||||
|           sourceMap: isEnvProduction && shouldUseSourceMap, | ||||
|         }, | ||||
|       }, | ||||
|     ].filter(Boolean); | ||||
|     if (preProcessor) { | ||||
|       loaders.push( | ||||
|         { | ||||
|           loader: require.resolve('resolve-url-loader'), | ||||
|           options: { | ||||
|             sourceMap: isEnvProduction && shouldUseSourceMap, | ||||
|           }, | ||||
|         }, | ||||
|         { | ||||
|           loader: require.resolve(preProcessor), | ||||
|           options: { | ||||
|             sourceMap: true, | ||||
|           }, | ||||
|         } | ||||
|       ); | ||||
|     } | ||||
|     return loaders; | ||||
|   }; | ||||
|  | ||||
| 	return { | ||||
| 		mode: isEnvProduction ? 'production' : isEnvDevelopment && 'development', | ||||
| 		// Stop compilation early in production | ||||
| 		bail: isEnvProduction, | ||||
| 		devtool: isEnvProduction | ||||
| 			? shouldUseSourceMap | ||||
| 				? 'source-map' | ||||
| 				: false | ||||
| 			: isEnvDevelopment && 'cheap-module-source-map', | ||||
| 		// These are the "entry points" to our application. | ||||
| 		// This means they will be the "root" imports that are included in JS bundle. | ||||
| 		entry: [ | ||||
| 			// Include an alternative client for WebpackDevServer. A client's job is to | ||||
| 			// connect to WebpackDevServer by a socket and get notified about changes. | ||||
| 			// When you save a file, the client will either apply hot updates (in case | ||||
| 			// of CSS changes), or refresh the page (in case of JS changes). When you | ||||
| 			// make a syntax error, this client will display a syntax error overlay. | ||||
| 			// Note: instead of the default WebpackDevServer client, we use a custom one | ||||
| 			// to bring better experience for Create React App users. You can replace | ||||
| 			// the line below with these two lines if you prefer the stock client: | ||||
| 			// require.resolve('webpack-dev-server/client') + '?/', | ||||
| 			// require.resolve('webpack/hot/dev-server'), | ||||
| 			isEnvDevelopment && | ||||
|   return { | ||||
|     mode: isEnvProduction ? 'production' : isEnvDevelopment && 'development', | ||||
|     // Stop compilation early in production | ||||
|     bail: isEnvProduction, | ||||
|     devtool: isEnvProduction | ||||
|       ? shouldUseSourceMap | ||||
|         ? 'source-map' | ||||
|         : false | ||||
|       : isEnvDevelopment && 'cheap-module-source-map', | ||||
|     // These are the "entry points" to our application. | ||||
|     // This means they will be the "root" imports that are included in JS bundle. | ||||
|     entry: [ | ||||
|       // Include an alternative client for WebpackDevServer. A client's job is to | ||||
|       // connect to WebpackDevServer by a socket and get notified about changes. | ||||
|       // When you save a file, the client will either apply hot updates (in case | ||||
|       // of CSS changes), or refresh the page (in case of JS changes). When you | ||||
|       // make a syntax error, this client will display a syntax error overlay. | ||||
|       // Note: instead of the default WebpackDevServer client, we use a custom one | ||||
|       // to bring better experience for Create React App users. You can replace | ||||
|       // the line below with these two lines if you prefer the stock client: | ||||
|       // require.resolve('webpack-dev-server/client') + '?/', | ||||
|       // require.resolve('webpack/hot/dev-server'), | ||||
|       isEnvDevelopment && | ||||
|         require.resolve('react-dev-utils/webpackHotDevClient'), | ||||
|       // Finally, this is your app's code: | ||||
|       paths.appIndexJs, | ||||
| @@ -329,7 +329,6 @@ module.exports = function(webpackEnv) { | ||||
|       rules: [ | ||||
|         // Disable require.ensure as it's not a standard language feature. | ||||
|         { parser: { requireEnsure: false } }, | ||||
|  | ||||
|         // First, run the linter. | ||||
|         // It's important to do this before Babel processes the JS. | ||||
|         //  | ||||
| @@ -379,8 +378,7 @@ module.exports = function(webpackEnv) { | ||||
|               options: { | ||||
|                 customize: require.resolve( | ||||
|                   'babel-preset-react-app/webpack-overrides' | ||||
|                 ), | ||||
|                  | ||||
|                 ),                 | ||||
|                 plugins: [ | ||||
|                   [ | ||||
|                     require.resolve('babel-plugin-named-asset-import'), | ||||
| @@ -422,7 +420,6 @@ module.exports = function(webpackEnv) { | ||||
|                 cacheDirectory: true, | ||||
|                 // See #6846 for context on why cacheCompression is disabled | ||||
|                 cacheCompression: false, | ||||
|                  | ||||
|                 // Babel sourcemaps are needed for debugging into node_modules | ||||
|                 // code.  Without the options below, debuggers like VSCode | ||||
|                 // show incorrect code and set breakpoints on the wrong lines. | ||||
| @@ -551,131 +548,131 @@ module.exports = function(webpackEnv) { | ||||
|       isEnvProduction && | ||||
|         shouldInlineRuntimeChunk && | ||||
|         new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime-.+[.]js/]), | ||||
| 			// Makes some environment variables available in index.html. | ||||
| 			// The public URL is available as %PUBLIC_URL% in index.html, e.g.: | ||||
| 			// <link rel="icon" href="%PUBLIC_URL%/favicon.ico"> | ||||
| 			// In production, it will be an empty string unless you specify "homepage" | ||||
| 			// in `package.json`, in which case it will be the pathname of that URL. | ||||
| 			// In development, this will be an empty string. | ||||
| 			new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw), | ||||
| 			// This gives some necessary context to module not found errors, such as | ||||
| 			// the requesting resource. | ||||
| 			new ModuleNotFoundPlugin(paths.appPath), | ||||
| 			// Makes some environment variables available to the JS code, for example: | ||||
| 			// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`. | ||||
| 			// It is absolutely essential that NODE_ENV is set to production | ||||
| 			// during a production build. | ||||
| 			// Otherwise React will be compiled in the very slow development mode. | ||||
| 			new webpack.DefinePlugin(env.stringified), | ||||
| 			// This is necessary to emit hot updates (currently CSS only): | ||||
| 			isEnvDevelopment && new webpack.HotModuleReplacementPlugin(), | ||||
| 			// Watcher doesn't work well if you mistype casing in a path so we use | ||||
| 			// a plugin that prints an error when you attempt to do this. | ||||
| 			// See https://github.com/facebook/create-react-app/issues/240 | ||||
| 			isEnvDevelopment && new CaseSensitivePathsPlugin(), | ||||
| 			// If you require a missing module and then `npm install` it, you still have | ||||
| 			// to restart the development server for Webpack to discover it. This plugin | ||||
| 			// makes the discovery automatic so you don't have to restart. | ||||
| 			// See https://github.com/facebook/create-react-app/issues/186 | ||||
| 			isEnvDevelopment && | ||||
|       // Makes some environment variables available in index.html. | ||||
|       // The public URL is available as %PUBLIC_URL% in index.html, e.g.: | ||||
|       // <link rel="icon" href="%PUBLIC_URL%/favicon.ico"> | ||||
|       // In production, it will be an empty string unless you specify "homepage" | ||||
|       // in `package.json`, in which case it will be the pathname of that URL. | ||||
|       // In development, this will be an empty string. | ||||
|       new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw), | ||||
|       // This gives some necessary context to module not found errors, such as | ||||
|       // the requesting resource. | ||||
|       new ModuleNotFoundPlugin(paths.appPath), | ||||
|       // Makes some environment variables available to the JS code, for example: | ||||
|       // if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`. | ||||
|       // It is absolutely essential that NODE_ENV is set to production | ||||
|       // during a production build. | ||||
|       // Otherwise React will be compiled in the very slow development mode. | ||||
|       new webpack.DefinePlugin(env.stringified), | ||||
|       // This is necessary to emit hot updates (currently CSS only): | ||||
|       isEnvDevelopment && new webpack.HotModuleReplacementPlugin(), | ||||
|       // Watcher doesn't work well if you mistype casing in a path so we use | ||||
|       // a plugin that prints an error when you attempt to do this. | ||||
|       // See https://github.com/facebook/create-react-app/issues/240 | ||||
|       isEnvDevelopment && new CaseSensitivePathsPlugin(), | ||||
|       // If you require a missing module and then `npm install` it, you still have | ||||
|       // to restart the development server for Webpack to discover it. This plugin | ||||
|       // makes the discovery automatic so you don't have to restart. | ||||
|       // See https://github.com/facebook/create-react-app/issues/186 | ||||
|       isEnvDevelopment && | ||||
|         new WatchMissingNodeModulesPlugin(paths.appNodeModules), | ||||
| 			isEnvProduction && | ||||
|       isEnvProduction && | ||||
|         new MiniCssExtractPlugin({ | ||||
|         	// Options similar to the same options in webpackOptions.output | ||||
|         	// both options are optional | ||||
|         	filename: 'static/css/[name].css', | ||||
|         	chunkFilename: 'static/css/[name].chunk.css', | ||||
|           // Options similar to the same options in webpackOptions.output | ||||
|           // both options are optional | ||||
|           filename: 'static/css/[name].css', | ||||
|           chunkFilename: 'static/css/[name].chunk.css', | ||||
|         }), | ||||
| 			// Generate an asset manifest file with the following content: | ||||
| 			// - "files" key: Mapping of all asset filenames to their corresponding | ||||
| 			//   output file so that tools can pick it up without having to parse | ||||
| 			//   `index.html` | ||||
| 			// - "entrypoints" key: Array of files which are included in `index.html`, | ||||
| 			//   can be used to reconstruct the HTML if necessary | ||||
| 			new ManifestPlugin({ | ||||
| 				fileName: 'asset-manifest.json', | ||||
| 				publicPath: publicPath, | ||||
| 				generate: (seed, files, entrypoints) => { | ||||
| 					const manifestFiles = files.reduce((manifest, file) => { | ||||
| 						manifest[file.name] = file.path; | ||||
| 						return manifest; | ||||
| 					}, seed); | ||||
| 					const entrypointFiles = entrypoints.main.filter( | ||||
| 						fileName => !fileName.endsWith('.map') | ||||
| 					); | ||||
|       // Generate an asset manifest file with the following content: | ||||
|       // - "files" key: Mapping of all asset filenames to their corresponding | ||||
|       //   output file so that tools can pick it up without having to parse | ||||
|       //   `index.html` | ||||
|       // - "entrypoints" key: Array of files which are included in `index.html`, | ||||
|       //   can be used to reconstruct the HTML if necessary | ||||
|       new ManifestPlugin({ | ||||
|         fileName: 'asset-manifest.json', | ||||
|         publicPath: publicPath, | ||||
|         generate: (seed, files, entrypoints) => { | ||||
|           const manifestFiles = files.reduce((manifest, file) => { | ||||
|             manifest[file.name] = file.path; | ||||
|             return manifest; | ||||
|           }, seed); | ||||
|           const entrypointFiles = entrypoints.main.filter( | ||||
|             fileName => !fileName.endsWith('.map') | ||||
|           ); | ||||
|  | ||||
| 					return { | ||||
| 						files: manifestFiles, | ||||
| 						entrypoints: entrypointFiles, | ||||
| 					}; | ||||
| 				}, | ||||
| 			}), | ||||
| 			// Moment.js is an extremely popular library that bundles large locale files | ||||
| 			// by default due to how Webpack interprets its code. This is a practical | ||||
| 			// solution that requires the user to opt into importing specific locales. | ||||
| 			// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack | ||||
| 			// You can remove this if you don't use Moment.js: | ||||
| 			new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), | ||||
| 			// Generate a service worker script that will precache, and keep up to date, | ||||
| 			// the HTML & assets that are part of the Webpack build. | ||||
| 			// isEnvProduction && | ||||
|           return { | ||||
|             files: manifestFiles, | ||||
|             entrypoints: entrypointFiles, | ||||
|           }; | ||||
|         }, | ||||
|       }), | ||||
|       // Moment.js is an extremely popular library that bundles large locale files | ||||
|       // by default due to how Webpack interprets its code. This is a practical | ||||
|       // solution that requires the user to opt into importing specific locales. | ||||
|       // https://github.com/jmblog/how-to-optimize-momentjs-with-webpack | ||||
|       // You can remove this if you don't use Moment.js: | ||||
|       new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), | ||||
|       // Generate a service worker script that will precache, and keep up to date, | ||||
|       // the HTML & assets that are part of the Webpack build. | ||||
|       // isEnvProduction && | ||||
|    //      new WorkboxWebpackPlugin.GenerateSW({ | ||||
|    //      	clientsClaim: true, | ||||
|    //      	exclude: [/\.map$/, /asset-manifest\.json$/], | ||||
|    //      	importWorkboxFrom: 'cdn', | ||||
|    //      	navigateFallback: `${publicUrl}/index.html`, | ||||
|    //      	navigateFallbackBlacklist: [ | ||||
|    //      		// Exclude URLs starting with /_, as they're likely an API call | ||||
|    //      		new RegExp('^/_'), | ||||
|    //      		// Exclude any URLs whose last part seems to be a file extension | ||||
|    //      		// as they're likely a resource and not a SPA route. | ||||
|    //      		// URLs containing a "?" character won't be blacklisted as they're likely | ||||
|    //      		// a route with query params (e.g. auth callbacks). | ||||
|    //      		new RegExp('/[^/?]+\\.[^/]+$'), | ||||
|    //      	], | ||||
|    //       clientsClaim: true, | ||||
|    //       exclude: [/\.map$/, /asset-manifest\.json$/], | ||||
|    //       importWorkboxFrom: 'cdn', | ||||
|    //       navigateFallback: `${publicUrl}/index.html`, | ||||
|    //       navigateFallbackBlacklist: [ | ||||
|    //         // Exclude URLs starting with /_, as they're likely an API call | ||||
|    //         new RegExp('^/_'), | ||||
|    //         // Exclude any URLs whose last part seems to be a file extension | ||||
|    //         // as they're likely a resource and not a SPA route. | ||||
|    //         // URLs containing a "?" character won't be blacklisted as they're likely | ||||
|    //         // a route with query params (e.g. auth callbacks). | ||||
|    //         new RegExp('/[^/?]+\\.[^/]+$'), | ||||
|    //       ], | ||||
|    //      }), | ||||
| 			// TypeScript type checking | ||||
| 			useTypeScript && | ||||
|       // TypeScript type checking | ||||
|       useTypeScript && | ||||
|         new ForkTsCheckerWebpackPlugin({ | ||||
|         	typescript: resolve.sync('typescript', { | ||||
|         		basedir: paths.appNodeModules, | ||||
|         	}), | ||||
|         	async: isEnvDevelopment, | ||||
|         	useTypescriptIncrementalApi: true, | ||||
|         	checkSyntacticErrors: true, | ||||
|         	resolveModuleNameModule: process.versions.pnp | ||||
|         		? `${__dirname}/pnpTs.js` | ||||
|         		: undefined, | ||||
|         	resolveTypeReferenceDirectiveModule: process.versions.pnp | ||||
|         		? `${__dirname}/pnpTs.js` | ||||
|         		: undefined, | ||||
|         	tsconfig: paths.appTsConfig, | ||||
|         	reportFiles: [ | ||||
|         		'**', | ||||
|         		'!**/__tests__/**', | ||||
|         		'!**/?(*.)(spec|test).*', | ||||
|         		'!**/src/setupProxy.*', | ||||
|         		'!**/src/setupTests.*', | ||||
|         	], | ||||
|         	silent: true, | ||||
|         	// The formatter is invoked directly in WebpackDevServerUtils during development | ||||
|         	formatter: isEnvProduction ? typescriptFormatter : undefined, | ||||
|           typescript: resolve.sync('typescript', { | ||||
|             basedir: paths.appNodeModules, | ||||
|           }), | ||||
|           async: isEnvDevelopment, | ||||
|           useTypescriptIncrementalApi: true, | ||||
|           checkSyntacticErrors: true, | ||||
|           resolveModuleNameModule: process.versions.pnp | ||||
|             ? `${__dirname}/pnpTs.js` | ||||
|             : undefined, | ||||
|           resolveTypeReferenceDirectiveModule: process.versions.pnp | ||||
|             ? `${__dirname}/pnpTs.js` | ||||
|             : undefined, | ||||
|           tsconfig: paths.appTsConfig, | ||||
|           reportFiles: [ | ||||
|             '**', | ||||
|             '!**/__tests__/**', | ||||
|             '!**/?(*.)(spec|test).*', | ||||
|             '!**/src/setupProxy.*', | ||||
|             '!**/src/setupTests.*', | ||||
|           ], | ||||
|           silent: true, | ||||
|           // The formatter is invoked directly in WebpackDevServerUtils during development | ||||
|           formatter: isEnvProduction ? typescriptFormatter : undefined, | ||||
|         }), | ||||
| 		].filter(Boolean), | ||||
| 		// Some libraries import Node modules but don't use them in the browser. | ||||
| 		// Tell Webpack to provide empty mocks for them so importing them works. | ||||
| 		node: { | ||||
| 			module: 'empty', | ||||
| 			dgram: 'empty', | ||||
| 			dns: 'mock', | ||||
| 			fs: 'empty', | ||||
| 			http2: 'empty', | ||||
| 			net: 'empty', | ||||
| 			tls: 'empty', | ||||
| 			child_process: 'empty', | ||||
| 		}, | ||||
| 		// Turn off performance processing because we utilize | ||||
| 		// our own hints via the FileSizeReporter | ||||
| 		performance: false, | ||||
| 	}; | ||||
|     ].filter(Boolean), | ||||
|     // Some libraries import Node modules but don't use them in the browser. | ||||
|     // Tell Webpack to provide empty mocks for them so importing them works. | ||||
|     node: { | ||||
|       module: 'empty', | ||||
|       dgram: 'empty', | ||||
|       dns: 'mock', | ||||
|       fs: 'empty', | ||||
|       http2: 'empty', | ||||
|       net: 'empty', | ||||
|       tls: 'empty', | ||||
|       child_process: 'empty', | ||||
|     }, | ||||
|     // Turn off performance processing because we utilize | ||||
|     // our own hints via the FileSizeReporter | ||||
|     performance: false, | ||||
|   }; | ||||
| }; | ||||
|   | ||||
| @@ -70,6 +70,8 @@ class InteropServiceHelper { | ||||
| 								cleanup(); | ||||
| 							} | ||||
| 						} else { | ||||
| 							// TODO: it is crashing at this point | ||||
|  | ||||
| 							win.webContents.print(options, (success, reason) => { | ||||
| 								// TODO: This is correct but broken in Electron 4. Need to upgrade to 5+ | ||||
| 								// It calls the callback right away with "false" even if the document hasn't be print yet. | ||||
|   | ||||
| @@ -134,8 +134,6 @@ 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']; | ||||
| 						} | ||||
| @@ -547,6 +545,7 @@ class Application extends BaseApplication { | ||||
| 				this.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'print', | ||||
| 					noteIds: this.store().getState().selectedNoteIds, | ||||
| 				}); | ||||
| 			}, | ||||
| 		}; | ||||
| @@ -890,33 +889,6 @@ class Application extends BaseApplication { | ||||
| 				}, { | ||||
| 					type: 'separator', | ||||
| 					screens: ['Main'], | ||||
| 				}, { | ||||
| 					id: 'edit:commandStartExternalEditing', | ||||
| 					label: _('Edit in external editor'), | ||||
| 					screens: ['Main'], | ||||
| 					accelerator: 'CommandOrControl+E', | ||||
| 					click: () => { | ||||
| 						this.dispatch({ | ||||
| 							type: 'WINDOW_COMMAND', | ||||
| 							name: 'commandStartExternalEditing', | ||||
| 						}); | ||||
| 					}, | ||||
| 				}, { | ||||
| 					id: 'edit:setTags', | ||||
| 					label: _('Tags'), | ||||
| 					screens: ['Main'], | ||||
| 					accelerator: 'CommandOrControl+Alt+T', | ||||
| 					click: () => { | ||||
| 						const selectedNoteIds = this.store().getState().selectedNoteIds; | ||||
| 						this.dispatch({ | ||||
| 							type: 'WINDOW_COMMAND', | ||||
| 							name: 'setTags', | ||||
| 							noteIds: selectedNoteIds, | ||||
| 						}); | ||||
| 					}, | ||||
| 				}, { | ||||
| 					type: 'separator', | ||||
| 					screens: ['Main'], | ||||
| 				}, { | ||||
| 					id: 'edit:focusSearch', | ||||
| 					label: _('Search in all the notes'), | ||||
| @@ -1056,6 +1028,46 @@ class Application extends BaseApplication { | ||||
| 					accelerator: 'CommandOrControl+-', | ||||
| 				}], | ||||
| 			}, | ||||
| 			note: { | ||||
| 				label: _('&Note'), | ||||
| 				submenu: [{ | ||||
| 					id: 'edit:commandStartExternalEditing', | ||||
| 					label: _('Edit in external editor'), | ||||
| 					screens: ['Main'], | ||||
| 					accelerator: 'CommandOrControl+E', | ||||
| 					click: () => { | ||||
| 						this.dispatch({ | ||||
| 							type: 'WINDOW_COMMAND', | ||||
| 							name: 'commandStartExternalEditing', | ||||
| 						}); | ||||
| 					}, | ||||
| 				}, { | ||||
| 					id: 'edit:setTags', | ||||
| 					label: _('Tags'), | ||||
| 					screens: ['Main'], | ||||
| 					accelerator: 'CommandOrControl+Alt+T', | ||||
| 					click: () => { | ||||
| 						const selectedNoteIds = this.store().getState().selectedNoteIds; | ||||
| 						this.dispatch({ | ||||
| 							type: 'WINDOW_COMMAND', | ||||
| 							name: 'setTags', | ||||
| 							noteIds: selectedNoteIds, | ||||
| 						}); | ||||
| 					}, | ||||
| 				}, { | ||||
| 					type: 'separator', | ||||
| 					screens: ['Main'], | ||||
| 				}, { | ||||
| 					label: _('Statistics...'), | ||||
| 					click: () => { | ||||
| 						this.dispatch({ | ||||
| 							type: 'WINDOW_COMMAND', | ||||
| 							name: 'commandContentProperties', | ||||
| 							// text: this.state.note.body, | ||||
| 						}); | ||||
| 					}, | ||||
| 				}], | ||||
| 			}, | ||||
| 			tools: { | ||||
| 				label: _('&Tools'), | ||||
| 				submenu: toolsItems, | ||||
| @@ -1136,6 +1148,7 @@ class Application extends BaseApplication { | ||||
| 			rootMenus.file, | ||||
| 			rootMenus.edit, | ||||
| 			rootMenus.view, | ||||
| 			rootMenus.note, | ||||
| 			rootMenus.tools, | ||||
| 			rootMenus.help, | ||||
| 		]; | ||||
|   | ||||
| @@ -18,6 +18,10 @@ class EventManager { | ||||
| 		return this.emitter_.removeListener(eventName, callback); | ||||
| 	} | ||||
|  | ||||
| 	off(eventName, callback) { | ||||
| 		return this.removeListener(eventName, callback); | ||||
| 	} | ||||
|  | ||||
| } | ||||
|  | ||||
| const eventManager = new EventManager(); | ||||
|   | ||||
| @@ -134,6 +134,8 @@ class HeaderComponent extends React.Component { | ||||
| 	} | ||||
|  | ||||
| 	makeButton(key, style, options) { | ||||
| 		const theme = themeStyle(this.props.theme); | ||||
|  | ||||
| 		let icon = null; | ||||
| 		if (options.iconName) { | ||||
| 			const iconStyle = { | ||||
| @@ -158,6 +160,20 @@ class HeaderComponent extends React.Component { | ||||
|  | ||||
| 		const title = options.title ? options.title : ''; | ||||
|  | ||||
| 		if (options.type === 'checkbox' && options.checked) { | ||||
| 			finalStyle.backgroundColor = theme.selectedColor; | ||||
| 			finalStyle.borderWidth = 1; | ||||
| 			finalStyle.borderTopColor = theme.selectedDividerColor; | ||||
| 			finalStyle.borderLeftColor = theme.selectedDividerColor; | ||||
| 			finalStyle.borderTopStyle = 'solid'; | ||||
| 			finalStyle.borderLeftStyle = 'solid'; | ||||
| 			finalStyle.paddingLeft++; | ||||
| 			finalStyle.paddingTop++; | ||||
| 			finalStyle.paddingBottom--; | ||||
| 			finalStyle.paddingRight--; | ||||
| 			finalStyle.boxSizing = 'border-box'; | ||||
| 		} | ||||
|  | ||||
| 		return ( | ||||
| 			<a | ||||
| 				className={classes.join(' ')} | ||||
| @@ -256,6 +272,8 @@ class HeaderComponent extends React.Component { | ||||
| 			height: theme.headerHeight, | ||||
| 			display: 'flex', | ||||
| 			alignItems: 'center', | ||||
| 			paddingTop: 1, | ||||
| 			paddingBottom: 1, | ||||
| 			paddingLeft: theme.headerButtonHPadding, | ||||
| 			paddingRight: theme.headerButtonHPadding, | ||||
| 			color: theme.color, | ||||
|   | ||||
| @@ -3,18 +3,19 @@ const { connect } = require('react-redux'); | ||||
| 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 NoteEditor = require('./NoteEditor/NoteEditor.js').default; | ||||
| const { stateUtils } = require('lib/reducer.js'); | ||||
| const { PromptDialog } = require('./PromptDialog.min.js'); | ||||
| const NoteContentPropertiesDialog = require('./NoteContentPropertiesDialog.js').default; | ||||
| const NotePropertiesDialog = require('./NotePropertiesDialog.min.js'); | ||||
| const ShareNoteDialog = require('./ShareNoteDialog.js').default; | ||||
| const InteropServiceHelper = require('../InteropServiceHelper.js'); | ||||
| const Setting = require('lib/models/Setting.js'); | ||||
| const BaseModel = require('lib/BaseModel.js'); | ||||
| const Tag = require('lib/models/Tag.js'); | ||||
| const Note = require('lib/models/Note.js'); | ||||
| const { uuid } = require('lib/uuid.js'); | ||||
| const { shim } = require('lib/shim'); | ||||
| const Folder = require('lib/models/Folder.js'); | ||||
| const { themeStyle } = require('../theme.js'); | ||||
| const { _ } = require('lib/locale.js'); | ||||
| @@ -25,6 +26,7 @@ const PluginManager = require('lib/services/PluginManager'); | ||||
| const TemplateUtils = require('lib/TemplateUtils'); | ||||
| const EncryptionService = require('lib/services/EncryptionService'); | ||||
| const ipcRenderer = require('electron').ipcRenderer; | ||||
| const { time } = require('lib/time-utils.js'); | ||||
|  | ||||
| class MainScreenComponent extends React.Component { | ||||
| 	constructor() { | ||||
| @@ -48,6 +50,8 @@ class MainScreenComponent extends React.Component { | ||||
| 		this.shareNoteDialog_close = this.shareNoteDialog_close.bind(this); | ||||
| 		this.sidebar_onDrag = this.sidebar_onDrag.bind(this); | ||||
| 		this.noteList_onDrag = this.noteList_onDrag.bind(this); | ||||
| 		this.commandSavePdf = this.commandSavePdf.bind(this); | ||||
| 		this.commandPrint = this.commandPrint.bind(this); | ||||
| 	} | ||||
|  | ||||
| 	setupAppCloseHandling() { | ||||
| @@ -149,6 +153,9 @@ class MainScreenComponent extends React.Component { | ||||
|  | ||||
| 		let commandProcessed = true; | ||||
|  | ||||
| 		let delayedFunction = null; | ||||
| 		let delayedArgs = null; | ||||
|  | ||||
| 		if (command.name === 'newNote') { | ||||
| 			if (!this.props.folders.length) { | ||||
| 				bridge().showErrorMessageBox(_('Please create a notebook first.')); | ||||
| @@ -350,13 +357,16 @@ class MainScreenComponent extends React.Component { | ||||
| 				}, | ||||
| 			}); | ||||
| 		} else if (command.name === 'commandContentProperties') { | ||||
| 			this.setState({ | ||||
| 				noteContentPropertiesDialogOptions: { | ||||
| 					visible: true, | ||||
| 					text: command.text, | ||||
| 					lines: command.lines, | ||||
| 				}, | ||||
| 			}); | ||||
| 			const note = await Note.load(this.props.selectedNoteId); | ||||
| 			if (note) { | ||||
| 				this.setState({ | ||||
| 					noteContentPropertiesDialogOptions: { | ||||
| 						visible: true, | ||||
| 						text: note.body, | ||||
| 						// lines: command.lines, | ||||
| 					}, | ||||
| 				}); | ||||
| 			} | ||||
| 		} else if (command.name === 'commandShareNoteDialog') { | ||||
| 			this.setState({ | ||||
| 				shareNoteDialogOptions: { | ||||
| @@ -413,7 +423,7 @@ class MainScreenComponent extends React.Component { | ||||
|  | ||||
| 						if (newNote) { | ||||
| 							await Note.save(newNote); | ||||
| 							eventManager.emit('alarmChange', { noteId: note.id }); | ||||
| 							eventManager.emit('alarmChange', { noteId: note.id, note: newNote }); | ||||
| 						} | ||||
|  | ||||
| 						this.setState({ promptOptions: null }); | ||||
| @@ -444,6 +454,12 @@ class MainScreenComponent extends React.Component { | ||||
| 					}, | ||||
| 				}, | ||||
| 			}); | ||||
| 		} else if (command.name === 'exportPdf') { | ||||
| 			delayedFunction = this.commandSavePdf; | ||||
| 			delayedArgs = { noteIds: command.noteIds }; | ||||
| 		} else if (command.name === 'print') { | ||||
| 			delayedFunction = this.commandPrint; | ||||
| 			delayedArgs = { noteIds: command.noteIds }; | ||||
| 		} else { | ||||
| 			commandProcessed = false; | ||||
| 		} | ||||
| @@ -454,6 +470,106 @@ class MainScreenComponent extends React.Component { | ||||
| 				name: null, | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		if (delayedFunction) { | ||||
| 			requestAnimationFrame(() => { | ||||
| 				delayedFunction = delayedFunction.bind(this); | ||||
| 				delayedFunction(delayedArgs); | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async waitForNoteToSaved(noteId) { | ||||
| 		while (noteId && this.props.editorNoteStatuses[noteId] === 'saving') { | ||||
| 			console.info('Waiting for note to be saved...', this.props.editorNoteStatuses); | ||||
| 			await time.msleep(100); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async printTo_(target, options) { | ||||
| 		// Concurrent print calls are disallowed to avoid incorrect settings being restored upon completion | ||||
| 		if (this.isPrinting_) { | ||||
| 			console.info(`Printing ${options.path} to ${target} disallowed, already printing.`); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		this.isPrinting_ = true; | ||||
|  | ||||
| 		// Need to wait for save because the interop service reloads the note from the database | ||||
| 		await this.waitForNoteToSaved(options.noteId); | ||||
|  | ||||
| 		if (target === 'pdf') { | ||||
| 			try { | ||||
| 				const pdfData = await InteropServiceHelper.exportNoteToPdf(options.noteId, { | ||||
| 					printBackground: true, | ||||
| 					pageSize: Setting.value('export.pdfPageSize'), | ||||
| 					landscape: Setting.value('export.pdfPageOrientation') === 'landscape', | ||||
| 					customCss: this.props.customCss, | ||||
| 				}); | ||||
| 				await shim.fsDriver().writeFile(options.path, pdfData, 'buffer'); | ||||
| 			} catch (error) { | ||||
| 				console.error(error); | ||||
| 				bridge().showErrorMessageBox(error.message); | ||||
| 			} | ||||
| 		} else if (target === 'printer') { | ||||
| 			try { | ||||
| 				await InteropServiceHelper.printNote(options.noteId, { | ||||
| 					printBackground: true, | ||||
| 					customCss: this.props.customCss, | ||||
| 				}); | ||||
| 			} catch (error) { | ||||
| 				console.error(error); | ||||
| 				bridge().showErrorMessageBox(error.message); | ||||
| 			} | ||||
| 		} | ||||
| 		this.isPrinting_ = false; | ||||
| 	} | ||||
|  | ||||
| 	async commandSavePdf(args) { | ||||
| 		try { | ||||
| 			const noteIds = args.noteIds; | ||||
|  | ||||
| 			if (!noteIds.length) throw new Error('No notes selected for pdf export'); | ||||
|  | ||||
| 			let path = null; | ||||
| 			if (noteIds.length === 1) { | ||||
| 				path = bridge().showSaveDialog({ | ||||
| 					filters: [{ name: _('PDF File'), extensions: ['pdf'] }], | ||||
| 					defaultPath: await InteropServiceHelper.defaultFilename(noteIds, 'pdf'), | ||||
| 				}); | ||||
|  | ||||
| 			} else { | ||||
| 				path = bridge().showOpenDialog({ | ||||
| 					properties: ['openDirectory', 'createDirectory'], | ||||
| 				}); | ||||
| 			} | ||||
|  | ||||
| 			if (!path) return; | ||||
|  | ||||
| 			for (let i = 0; i < noteIds.length; i++) { | ||||
| 				const note = await Note.load(noteIds[i]); | ||||
| 				const folder = Folder.byId(this.props.folders, note.parent_id); | ||||
|  | ||||
| 				const pdfPath = (noteIds.length === 1) ? path : | ||||
| 					await shim.fsDriver().findUniqueFilename(`${path}/${this.pdfFileName_(note, folder)}`); | ||||
|  | ||||
| 				await this.printTo_('pdf', { path: pdfPath, noteId: note.id }); | ||||
| 			} | ||||
| 		} catch (error) { | ||||
| 			bridge().showErrorMessageBox(error.message); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async commandPrint(args) { | ||||
| 		// TODO: test | ||||
| 		try { | ||||
| 			const noteIds = args.noteIds; | ||||
| 			if (noteIds.length !== 1) throw new Error(_('Only one note can be printed at a time.')); | ||||
|  | ||||
| 			await this.printTo_('printer', { noteId: noteIds[0] }); | ||||
| 		} catch (error) { | ||||
| 			bridge().showErrorMessageBox(error.message); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	styles(themeId, width, height, messageBoxVisible, isSidebarVisible, isNoteListVisible, sidebarWidth, noteListWidth) { | ||||
| @@ -683,14 +799,32 @@ class MainScreenComponent extends React.Component { | ||||
| 		}); | ||||
|  | ||||
| 		headerItems.push({ | ||||
| 			title: _('Layout'), | ||||
| 			iconName: 'fa-columns', | ||||
| 			title: _('Code View'), | ||||
| 			iconName: 'fa-file-code-o ', | ||||
| 			enabled: !!notes.length, | ||||
| 			type: 'checkbox', | ||||
| 			checked: this.props.settingEditorCodeView, | ||||
| 			onClick: () => { | ||||
| 				this.doCommand({ name: 'toggleVisiblePanes' }); | ||||
| 				// A bit of a hack, but for now don't allow changing code view | ||||
| 				// while a note is being saved as it will cause a problem with | ||||
| 				// TinyMCE because it won't have time to send its content before | ||||
| 				// being switch to Ace Editor. | ||||
| 				if (this.props.hasNotesBeingSaved) return; | ||||
| 				Setting.toggle('editor.codeView'); | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		if (this.props.settingEditorCodeView) { | ||||
| 			headerItems.push({ | ||||
| 				title: _('Layout'), | ||||
| 				iconName: 'fa-columns', | ||||
| 				enabled: !!notes.length, | ||||
| 				onClick: () => { | ||||
| 					this.doCommand({ name: 'toggleVisiblePanes' }); | ||||
| 				}, | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		headerItems.push({ | ||||
| 			title: _('Search...'), | ||||
| 			iconName: 'fa-search', | ||||
| @@ -716,13 +850,9 @@ class MainScreenComponent extends React.Component { | ||||
| 		const notePropertiesDialogOptions = this.state.notePropertiesDialogOptions; | ||||
| 		const noteContentPropertiesDialogOptions = this.state.noteContentPropertiesDialogOptions; | ||||
| 		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} />; | ||||
| 		const bodyEditor = this.props.settingEditorCodeView ? 'AceEditor' : 'TinyMCE'; | ||||
| 		const noteTextComp = <NoteEditor bodyEditor={bodyEditor} style={styles.noteText} />; | ||||
|  | ||||
| 		return ( | ||||
| 			<div style={style}> | ||||
| @@ -750,8 +880,8 @@ class MainScreenComponent extends React.Component { | ||||
| const mapStateToProps = state => { | ||||
| 	return { | ||||
| 		theme: state.settings.theme, | ||||
| 		settingEditorCodeView: state.settings['editor.codeView'], | ||||
| 		windowCommand: state.windowCommand, | ||||
| 		noteVisiblePanes: state.noteVisiblePanes, | ||||
| 		sidebarVisibility: state.sidebarVisibility, | ||||
| 		noteListVisibility: state.noteListVisibility, | ||||
| 		folders: state.folders, | ||||
| @@ -767,6 +897,8 @@ const mapStateToProps = state => { | ||||
| 		selectedNoteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null, | ||||
| 		plugins: state.plugins, | ||||
| 		templates: state.templates, | ||||
| 		customCss: state.customCss, | ||||
| 		editorNoteStatuses: state.editorNoteStatuses, | ||||
| 		hasNotesBeingSaved: stateUtils.hasNotesBeingSaved(state), | ||||
| 	}; | ||||
| }; | ||||
|   | ||||
| @@ -74,7 +74,7 @@ export default function NoteContentPropertiesDialog(props:NoteContentPropertiesD | ||||
| 	return ( | ||||
| 		<div style={theme.dialogModalLayer}> | ||||
| 			<div style={theme.dialogBox}> | ||||
| 				<div style={theme.dialogTitle}>{_('Content properties')}</div> | ||||
| 				<div style={theme.dialogTitle}>{_('Statistics')}</div> | ||||
| 				<div>{textComps}</div> | ||||
| 				<DialogButtonRow theme={props.theme} onClick={buttonRow_click} okButtonShow={false} cancelButtonLabel={_('Close')}/> | ||||
| 			</div> | ||||
|   | ||||
							
								
								
									
										686
									
								
								ElectronClient/gui/NoteEditor/NoteBody/AceEditor/AceEditor.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										686
									
								
								ElectronClient/gui/NoteEditor/NoteBody/AceEditor/AceEditor.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,686 @@ | ||||
| import * as React from 'react'; | ||||
| import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo } from 'react'; | ||||
|  | ||||
| // eslint-disable-next-line no-unused-vars | ||||
| import { EditorCommand, NoteBodyEditorProps } from '../../utils/types'; | ||||
| import { commandAttachFileToBody } from '../../utils/resourceHandling'; | ||||
| import { ScrollOptions, ScrollOptionTypes } from '../../utils/types'; | ||||
| import { textOffsetToCursorPosition, useScrollHandler, usePrevious, lineLeftSpaces, selectionRangeCurrentLine, selectionRangePreviousLine, currentTextOffset, textOffsetSelection, selectedText, useSelectionRange } from './utils'; | ||||
| import Toolbar from './Toolbar'; | ||||
| import styles_ from './styles'; | ||||
| import { RenderedBody, defaultRenderedBody } from './utils/types'; | ||||
|  | ||||
| const AceEditorReact = require('react-ace').default; | ||||
| const { bridge } = require('electron').remote.require('./bridge'); | ||||
| const Note = require('lib/models/Note.js'); | ||||
| const { clipboard } = require('electron'); | ||||
| const mimeUtils = require('lib/mime-utils.js').mime; | ||||
| const Setting = require('lib/models/Setting.js'); | ||||
| const NoteTextViewer = require('../../../NoteTextViewer.min'); | ||||
| const shared = require('lib/components/shared/note-screen-shared.js'); | ||||
| const md5 = require('md5'); | ||||
| const { shim } = require('lib/shim.js'); | ||||
| const Menu = bridge().Menu; | ||||
| const MenuItem = bridge().MenuItem; | ||||
| const markdownUtils = require('lib/markdownUtils'); | ||||
| const { _ } = require('lib/locale'); | ||||
| const { reg } = require('lib/registry.js'); | ||||
| const dialogs = require('../../../dialogs'); | ||||
|  | ||||
| require('brace/mode/markdown'); | ||||
| // https://ace.c9.io/build/kitchen-sink.html | ||||
| // https://highlightjs.org/static/demo/ | ||||
| require('brace/theme/chrome'); | ||||
| require('brace/theme/solarized_light'); | ||||
| require('brace/theme/solarized_dark'); | ||||
| require('brace/theme/twilight'); | ||||
| require('brace/theme/dracula'); | ||||
| require('brace/theme/chaos'); | ||||
| require('brace/keybinding/vim'); | ||||
| require('brace/keybinding/emacs'); | ||||
|  | ||||
| // TODO: Could not get below code to work | ||||
|  | ||||
| // @ts-ignore Ace global variable | ||||
| // const aceGlobal = (ace as any); | ||||
|  | ||||
| // class CustomHighlightRules extends aceGlobal.acequire( | ||||
| // 	'ace/mode/markdown_highlight_rules' | ||||
| // ).MarkdownHighlightRules { | ||||
| // 	constructor() { | ||||
| // 		super(); | ||||
| // 		if (Setting.value('markdown.plugin.mark')) { | ||||
| // 			this.$rules.start.push({ | ||||
| // 				// This is actually a highlight `mark`, but Ace has no token name for | ||||
| // 				// this so we made up our own. Reference for common tokens here: | ||||
| // 				// https://github.com/ajaxorg/ace/wiki/Creating-or-Extending-an-Edit-Mode#common-tokens | ||||
| // 				token: 'highlight_mark', | ||||
| // 				regex: '==[^ ](?:.*?[^ ])?==', | ||||
| // 			}); | ||||
| // 		} | ||||
| // 	} | ||||
| // } | ||||
|  | ||||
| // /* eslint-disable-next-line no-undef */ | ||||
| // class CustomMdMode extends aceGlobal.acequire('ace/mode/markdown').Mode { | ||||
| // 	constructor() { | ||||
| // 		super(); | ||||
| // 		this.HighlightRules = CustomHighlightRules; | ||||
| // 	} | ||||
| // } | ||||
|  | ||||
| function markupRenderOptions(override: any = null) { | ||||
| 	return { ...override }; | ||||
| } | ||||
|  | ||||
| function AceEditor(props: NoteBodyEditorProps, ref: any) { | ||||
| 	const styles = styles_(props); | ||||
|  | ||||
| 	const [renderedBody, setRenderedBody] = useState<RenderedBody>(defaultRenderedBody()); // Viewer content | ||||
| 	const [editor, setEditor] = useState(null); | ||||
| 	const [lastKeys, setLastKeys] = useState([]); | ||||
| 	const [webviewReady, setWebviewReady] = useState(false); | ||||
|  | ||||
| 	const previousRenderedBody = usePrevious(renderedBody); | ||||
| 	const previousSearchMarkers = usePrevious(props.searchMarkers); | ||||
| 	const previousContentKey = usePrevious(props.contentKey); | ||||
|  | ||||
| 	const editorRef = useRef(null); | ||||
| 	editorRef.current = editor; | ||||
| 	const indentOrig = useRef<any>(null); | ||||
| 	const webviewRef = useRef(null); | ||||
| 	const props_onChangeRef = useRef<Function>(null); | ||||
| 	props_onChangeRef.current = props.onChange; | ||||
| 	const contentKeyHasChangedRef = useRef(false); | ||||
| 	contentKeyHasChangedRef.current = previousContentKey !== props.contentKey; | ||||
|  | ||||
| 	// The selection range changes all the time, when the caret moves or | ||||
| 	// when the selection changes, so it's best not to make it part of the | ||||
| 	// state as it would trigger too many unecessary updates. | ||||
| 	const selectionRangeRef = useRef(null); | ||||
| 	selectionRangeRef.current = useSelectionRange(editor); | ||||
|  | ||||
| 	const { resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll } = useScrollHandler(editor, webviewRef, props.onScroll); | ||||
|  | ||||
| 	const aceEditor_change = useCallback((newBody: string) => { | ||||
| 		props_onChangeRef.current({ changeId: null, content: newBody }); | ||||
| 	}, []); | ||||
|  | ||||
| 	const wrapSelectionWithStrings = useCallback((string1: string, string2 = '', defaultText = '', replacementText: string = null, byLine = false) => { | ||||
| 		if (!editor) return; | ||||
|  | ||||
| 		const selection = textOffsetSelection(selectionRangeRef.current, props.content); | ||||
|  | ||||
| 		let newBody = props.content; | ||||
|  | ||||
| 		if (selection && selection.start !== selection.end) { | ||||
| 			const selectedLines = replacementText !== null ? replacementText : props.content.substr(selection.start, selection.end - selection.start); | ||||
| 			const selectedStrings = byLine ? selectedLines.split(/\r?\n/) : [selectedLines]; | ||||
|  | ||||
| 			newBody = props.content.substr(0, selection.start); | ||||
|  | ||||
| 			let startCursorPos, endCursorPos; | ||||
|  | ||||
| 			for (let i = 0; i < selectedStrings.length; i++) { | ||||
| 				if (byLine == false) { | ||||
| 					const start = selectedStrings[i].search(/[^\s]/); | ||||
| 					const end = selectedStrings[i].search(/[^\s](?=[\s]*$)/); | ||||
| 					newBody += selectedStrings[i].substr(0, start) + string1 + selectedStrings[i].substr(start, end - start + 1) + string2 + selectedStrings[i].substr(end + 1); | ||||
| 					// Getting position for correcting offset in highlighted text when surrounded by white spaces | ||||
| 					startCursorPos = textOffsetToCursorPosition(selection.start + start, newBody); | ||||
| 					endCursorPos = textOffsetToCursorPosition(selection.start + end + 1, newBody); | ||||
|  | ||||
| 				} else { newBody += string1 + selectedStrings[i] + string2; } | ||||
|  | ||||
| 			} | ||||
|  | ||||
| 			newBody += props.content.substr(selection.end); | ||||
|  | ||||
| 			const r = selectionRangeRef.current; | ||||
|  | ||||
| 			// Because some insertion strings will have newlines, we'll need to account for them | ||||
| 			const str1Split = string1.split(/\r?\n/); | ||||
|  | ||||
| 			// Add the number of newlines to the row | ||||
| 			// and add the length of the final line to the column (for strings with no newlines this is the string length) | ||||
|  | ||||
| 			let newRange: any = {}; | ||||
| 			if (!byLine) { | ||||
| 				// Correcting offset in Highlighted text when surrounded by white spaces | ||||
| 				newRange = { | ||||
| 					start: { | ||||
| 						row: startCursorPos.row, | ||||
| 						column: startCursorPos.column + string1.length, | ||||
| 					}, | ||||
| 					end: { | ||||
| 						row: endCursorPos.row, | ||||
| 						column: endCursorPos.column + string1.length, | ||||
| 					}, | ||||
| 				}; | ||||
| 			} else { | ||||
| 				newRange = { | ||||
| 					start: { | ||||
| 						row: r.start.row + str1Split.length - 1, | ||||
| 						column: r.start.column + str1Split[str1Split.length - 1].length, | ||||
| 					}, | ||||
| 					end: { | ||||
| 						row: r.end.row + str1Split.length - 1, | ||||
| 						column: r.end.column + str1Split[str1Split.length - 1].length, | ||||
| 					}, | ||||
| 				}; | ||||
| 			} | ||||
|  | ||||
| 			if (replacementText !== null) { | ||||
| 				const diff = replacementText.length - (selection.end - selection.start); | ||||
| 				newRange.end.column += diff; | ||||
| 			} | ||||
|  | ||||
| 			setTimeout(() => { | ||||
| 				const range = selectionRangeRef.current; | ||||
| 				range.setStart(newRange.start.row, newRange.start.column); | ||||
| 				range.setEnd(newRange.end.row, newRange.end.column); | ||||
| 				editor.getSession().getSelection().setSelectionRange(range, false); | ||||
| 				editor.focus(); | ||||
| 			}, 10); | ||||
| 		} else { | ||||
| 			const middleText = replacementText !== null ? replacementText : defaultText; | ||||
| 			const textOffset = currentTextOffset(editor, props.content); | ||||
| 			const s1 = props.content.substr(0, textOffset); | ||||
| 			const s2 = props.content.substr(textOffset); | ||||
| 			newBody = s1 + string1 + middleText + string2 + s2; | ||||
|  | ||||
| 			const p = textOffsetToCursorPosition(textOffset + string1.length, newBody); | ||||
| 			const newRange = { | ||||
| 				start: { row: p.row, column: p.column }, | ||||
| 				end: { row: p.row, column: p.column + middleText.length }, | ||||
| 			}; | ||||
|  | ||||
| 			// BUG!! If replacementText contains newline characters, the logic | ||||
| 			// to select the new text will not work. | ||||
|  | ||||
| 			setTimeout(() => { | ||||
| 				if (middleText && newRange) { | ||||
| 					const range = selectionRangeRef.current; | ||||
| 					range.setStart(newRange.start.row, newRange.start.column); | ||||
| 					range.setEnd(newRange.end.row, newRange.end.column); | ||||
| 					editor.getSession().getSelection().setSelectionRange(range, false); | ||||
| 				} else { | ||||
| 					for (let i = 0; i < string1.length; i++) { | ||||
| 						editor.getSession().getSelection().moveCursorRight(); | ||||
| 					} | ||||
| 				} | ||||
| 				editor.focus(); | ||||
| 			}, 10); | ||||
| 		} | ||||
|  | ||||
| 		aceEditor_change(newBody); | ||||
| 	}, [editor, props.content, aceEditor_change]); | ||||
|  | ||||
| 	const addListItem = useCallback((string1, string2 = '', defaultText = '', byLine = false) => { | ||||
| 		let newLine = '\n'; | ||||
| 		const range = selectionRangeRef.current; | ||||
| 		if (!range || (range.start.row === range.end.row && !selectionRangeCurrentLine(range, props.content))) { | ||||
| 			newLine = ''; | ||||
| 		} | ||||
| 		wrapSelectionWithStrings(newLine + string1, string2, defaultText, null, byLine); | ||||
| 	}, [wrapSelectionWithStrings, props.content]); | ||||
|  | ||||
| 	useImperativeHandle(ref, () => { | ||||
| 		return { | ||||
| 			content: () => props.content, | ||||
| 			setContent: (body: string) => { | ||||
| 				aceEditor_change(body); | ||||
| 			}, | ||||
| 			resetScroll: () => { | ||||
| 				resetScroll(); | ||||
| 			}, | ||||
| 			scrollTo: (options:ScrollOptions) => { | ||||
| 				if (options.type === ScrollOptionTypes.Hash) { | ||||
| 					if (!webviewRef.current) return; | ||||
| 					webviewRef.current.wrappedInstance.send('scrollToHash', options.value as string); | ||||
| 				} else if (options.type === ScrollOptionTypes.Percent) { | ||||
| 					const p = options.value as number; | ||||
| 					setEditorPercentScroll(p); | ||||
| 					setViewerPercentScroll(p); | ||||
| 				} else { | ||||
| 					throw new Error(`Unsupported scroll options: ${options.type}`); | ||||
| 				} | ||||
| 			}, | ||||
| 			clearState: () => { | ||||
| 				if (!editor) return; | ||||
| 				editor.clearSelection(); | ||||
| 				editor.moveCursorTo(0, 0); | ||||
| 			}, | ||||
| 			execCommand: async (cmd: EditorCommand) => { | ||||
| 				if (!editor) return false; | ||||
|  | ||||
| 				reg.logger().debug('AceEditor: execCommand', cmd); | ||||
|  | ||||
| 				let commandProcessed = true; | ||||
|  | ||||
| 				if (cmd.name === 'dropItems') { | ||||
| 					if (cmd.value.type === 'notes') { | ||||
| 						wrapSelectionWithStrings('', '', '', cmd.value.markdownTags.join('\n')); | ||||
| 					} else if (cmd.value.type === 'files') { | ||||
| 						const newBody = await commandAttachFileToBody(props.content, cmd.value.paths, { createFileURL: !!cmd.value.createFileURL }); | ||||
| 						aceEditor_change(newBody); | ||||
| 					} else { | ||||
| 						reg.logger().warn('AceEditor: unsupported drop item: ', cmd); | ||||
| 					} | ||||
| 				} else if (cmd.name === 'focus') { | ||||
| 					editor.focus(); | ||||
| 				} else { | ||||
| 					commandProcessed = false; | ||||
| 				} | ||||
|  | ||||
| 				if (!commandProcessed) { | ||||
| 					const commands: any = { | ||||
| 						textBold: () => wrapSelectionWithStrings('**', '**', _('strong text')), | ||||
| 						textItalic: () => wrapSelectionWithStrings('*', '*', _('emphasized text')), | ||||
| 						textLink: async () => { | ||||
| 							const url = await dialogs.prompt(_('Insert Hyperlink')); | ||||
| 							if (url) wrapSelectionWithStrings('[', `](${url})`); | ||||
| 						}, | ||||
| 						textCode: () => { | ||||
| 							const selection = textOffsetSelection(selectionRangeRef.current, props.content); | ||||
| 							const string = props.content.substr(selection.start, selection.end - selection.start); | ||||
|  | ||||
| 							// Look for newlines | ||||
| 							const match = string.match(/\r?\n/); | ||||
|  | ||||
| 							if (match && match.length > 0) { | ||||
| 								if (string.startsWith('```') && string.endsWith('```')) { | ||||
| 									wrapSelectionWithStrings('', '', '', string.substr(4, selection.end - selection.start - 8)); | ||||
| 								} else { | ||||
| 									wrapSelectionWithStrings(`\`\`\`${match[0]}`, `${match[0]}\`\`\``); | ||||
| 								} | ||||
| 							} else { | ||||
| 								wrapSelectionWithStrings('`', '`', ''); | ||||
| 							} | ||||
| 						}, | ||||
| 						insertText: (value: any) => wrapSelectionWithStrings(value), | ||||
| 						attachFile: async () => { | ||||
| 							const selection = textOffsetSelection(selectionRangeRef.current, props.content); | ||||
| 							const newBody = await commandAttachFileToBody(props.content, null, { position: selection ? selection.start : 0 }); | ||||
| 							if (newBody) aceEditor_change(newBody); | ||||
| 						}, | ||||
| 						textNumberedList: () => { | ||||
| 							let bulletNumber = markdownUtils.olLineNumber(selectionRangeCurrentLine(selectionRangeRef.current, props.content)); | ||||
| 							if (!bulletNumber) bulletNumber = markdownUtils.olLineNumber(selectionRangePreviousLine(selectionRangeRef.current, props.content)); | ||||
| 							if (!bulletNumber) bulletNumber = 0; | ||||
| 							addListItem(`${bulletNumber + 1}. `, '', _('List item'), true); | ||||
| 						}, | ||||
| 						textBulletedList: () => addListItem('- ', '', _('List item'), true), | ||||
| 						textCheckbox: () => addListItem('- [ ] ', '', _('List item'), true), | ||||
| 						textHeading: () => addListItem('## ','','', true), | ||||
| 						textHorizontalRule: () => addListItem('* * *'), | ||||
| 					}; | ||||
|  | ||||
| 					if (commands[cmd.name]) { | ||||
| 						commands[cmd.name](cmd.value); | ||||
| 					} else { | ||||
| 						reg.logger().warn('AceEditor: unsupported Joplin command: ', cmd); | ||||
| 						return false; | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				return true; | ||||
| 			}, | ||||
| 		}; | ||||
| 	}, [editor, props.content, addListItem, wrapSelectionWithStrings, selectionRangeCurrentLine, aceEditor_change, setEditorPercentScroll, setViewerPercentScroll, resetScroll, renderedBody]); | ||||
|  | ||||
| 	const onEditorPaste = useCallback(async (event: any = null) => { | ||||
| 		const formats = clipboard.availableFormats(); | ||||
| 		for (let i = 0; i < formats.length; i++) { | ||||
| 			const format = formats[i].toLowerCase(); | ||||
| 			const formatType = format.split('/')[0]; | ||||
|  | ||||
| 			const position = currentTextOffset(editor, props.content); | ||||
|  | ||||
| 			if (formatType === 'image') { | ||||
| 				if (event) event.preventDefault(); | ||||
|  | ||||
| 				const image = clipboard.readImage(); | ||||
|  | ||||
| 				const fileExt = mimeUtils.toFileExtension(format); | ||||
| 				const filePath = `${Setting.value('tempDir')}/${md5(Date.now())}.${fileExt}`; | ||||
|  | ||||
| 				await shim.writeImageToFile(image, format, filePath); | ||||
| 				const newBody = await commandAttachFileToBody(props.content, [filePath], { position }); | ||||
| 				await shim.fsDriver().remove(filePath); | ||||
|  | ||||
| 				aceEditor_change(newBody); | ||||
| 			} | ||||
| 		} | ||||
| 	}, [editor, props.content, aceEditor_change]); | ||||
|  | ||||
| 	const onEditorKeyDown = useCallback((event: any) => { | ||||
| 		setLastKeys(prevLastKeys => { | ||||
| 			const keys = prevLastKeys.slice(); | ||||
| 			keys.push(event.key); | ||||
| 			while (keys.length > 2) keys.splice(0, 1); | ||||
| 			return keys; | ||||
| 		}); | ||||
| 	}, []); | ||||
|  | ||||
| 	const editorCutText = useCallback(() => { | ||||
| 		const text = selectedText(selectionRangeRef.current, props.content); | ||||
| 		if (!text) return; | ||||
|  | ||||
| 		clipboard.writeText(text); | ||||
|  | ||||
| 		const s = textOffsetSelection(selectionRangeRef.current, props.content); | ||||
| 		if (!s || s.start === s.end) return; | ||||
|  | ||||
| 		const s1 = props.content.substr(0, s.start); | ||||
| 		const s2 = props.content.substr(s.end); | ||||
|  | ||||
| 		aceEditor_change(s1 + s2); | ||||
|  | ||||
| 		setTimeout(() => { | ||||
| 			const range = selectionRangeRef.current; | ||||
| 			range.setStart(range.start.row, range.start.column); | ||||
| 			range.setEnd(range.start.row, range.start.column); | ||||
| 			editor.getSession().getSelection().setSelectionRange(range, false); | ||||
| 			editor.focus(); | ||||
| 		}, 10); | ||||
| 	}, [props.content, editor, aceEditor_change]); | ||||
|  | ||||
| 	const editorCopyText = useCallback(() => { | ||||
| 		const text = selectedText(selectionRangeRef.current, props.content); | ||||
| 		clipboard.writeText(text); | ||||
| 	}, [props.content]); | ||||
|  | ||||
| 	const editorPasteText = useCallback(() => { | ||||
| 		wrapSelectionWithStrings(clipboard.readText(), '', '', ''); | ||||
| 	}, [wrapSelectionWithStrings]); | ||||
|  | ||||
| 	const onEditorContextMenu = useCallback(() => { | ||||
| 		const menu = new Menu(); | ||||
|  | ||||
| 		const hasSelectedText = !!selectedText(selectionRangeRef.current, props.content); | ||||
| 		const clipboardText = clipboard.readText(); | ||||
|  | ||||
| 		menu.append( | ||||
| 			new MenuItem({ | ||||
| 				label: _('Cut'), | ||||
| 				enabled: hasSelectedText, | ||||
| 				click: async () => { | ||||
| 					editorCutText(); | ||||
| 				}, | ||||
| 			}) | ||||
| 		); | ||||
|  | ||||
| 		menu.append( | ||||
| 			new MenuItem({ | ||||
| 				label: _('Copy'), | ||||
| 				enabled: hasSelectedText, | ||||
| 				click: async () => { | ||||
| 					editorCopyText(); | ||||
| 				}, | ||||
| 			}) | ||||
| 		); | ||||
|  | ||||
| 		menu.append( | ||||
| 			new MenuItem({ | ||||
| 				label: _('Paste'), | ||||
| 				enabled: true, | ||||
| 				click: async () => { | ||||
| 					if (clipboardText) { | ||||
| 						editorPasteText(); | ||||
| 					} else { | ||||
| 						// To handle pasting images | ||||
| 						onEditorPaste(); | ||||
| 					} | ||||
| 				}, | ||||
| 			}) | ||||
| 		); | ||||
|  | ||||
| 		menu.popup(bridge().window()); | ||||
| 	}, [props.content, editorCutText, editorPasteText, editorCopyText, onEditorPaste]); | ||||
|  | ||||
| 	function aceEditor_load(editor: any) { | ||||
| 		setEditor(editor); | ||||
| 	} | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (!editor) return () => {}; | ||||
|  | ||||
| 		editor.indent = indentOrig.current; | ||||
|  | ||||
| 		const cancelledKeys = []; | ||||
| 		const letters = ['F', 'T', 'P', 'Q', 'L', ',', 'G', 'K']; | ||||
| 		for (let i = 0; i < letters.length; i++) { | ||||
| 			const l = letters[i]; | ||||
| 			cancelledKeys.push(`Ctrl+${l}`); | ||||
| 			cancelledKeys.push(`Command+${l}`); | ||||
| 		} | ||||
| 		cancelledKeys.push('Alt+E'); | ||||
|  | ||||
| 		for (let i = 0; i < cancelledKeys.length; i++) { | ||||
| 			const k = cancelledKeys[i]; | ||||
| 			editor.commands.bindKey(k, () => { | ||||
| 				// HACK: Ace doesn't seem to provide a way to override its shortcuts, but throwing | ||||
| 				// an exception from this undocumented function seems to cancel it without any | ||||
| 				// side effect. | ||||
| 				// https://stackoverflow.com/questions/36075846 | ||||
| 				throw new Error(`HACK: Overriding Ace Editor shortcut: ${k}`); | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		document.querySelector('#note-editor').addEventListener('paste', onEditorPaste, true); | ||||
| 		document.querySelector('#note-editor').addEventListener('keydown', onEditorKeyDown); | ||||
| 		document.querySelector('#note-editor').addEventListener('contextmenu', onEditorContextMenu); | ||||
|  | ||||
| 		// Disable Markdown auto-completion (eg. auto-adding a dash after a line with a dash. | ||||
| 		// https://github.com/ajaxorg/ace/issues/2754 | ||||
| 		// @ts-ignore: Keep the function signature as-is despite unusued arguments | ||||
| 		editor.getSession().getMode().getNextLineIndent = function(state: any, line: string) { | ||||
| 			const ls = lastKeys; | ||||
| 			if (ls.length >= 2 && ls[ls.length - 1] === 'Enter' && ls[ls.length - 2] === 'Enter') return this.$getIndent(line); | ||||
|  | ||||
| 			const leftSpaces = lineLeftSpaces(line); | ||||
| 			const lineNoLeftSpaces = line.trimLeft(); | ||||
|  | ||||
| 			if (lineNoLeftSpaces.indexOf('- [ ] ') === 0 || lineNoLeftSpaces.indexOf('- [x] ') === 0 || lineNoLeftSpaces.indexOf('- [X] ') === 0) return `${leftSpaces}- [ ] `; | ||||
| 			if (lineNoLeftSpaces.indexOf('- ') === 0) return `${leftSpaces}- `; | ||||
| 			if (lineNoLeftSpaces.indexOf('* ') === 0 && line.trim() !== '* * *') return `${leftSpaces}* `; | ||||
|  | ||||
| 			const bulletNumber = markdownUtils.olLineNumber(lineNoLeftSpaces); | ||||
| 			if (bulletNumber) return `${leftSpaces + (bulletNumber + 1)}. `; | ||||
|  | ||||
| 			return this.$getIndent(line); | ||||
| 		}; | ||||
|  | ||||
| 		return () => { | ||||
| 			document.querySelector('#note-editor').removeEventListener('paste', onEditorPaste, true); | ||||
| 			document.querySelector('#note-editor').removeEventListener('keydown', onEditorKeyDown); | ||||
| 			document.querySelector('#note-editor').removeEventListener('contextmenu', onEditorContextMenu); | ||||
| 		}; | ||||
| 	}, [editor, onEditorPaste, onEditorContextMenu, lastKeys]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (!editor) return; | ||||
|  | ||||
| 		// Markdown list indentation. (https://github.com/laurent22/joplin/pull/2713) | ||||
| 		// If the current line starts with `markup.list` token, | ||||
| 		// hitting `Tab` key indents the line instead of inserting tab at cursor. | ||||
| 		indentOrig.current = editor.indent; | ||||
| 		const localIndentOrig = indentOrig.current; | ||||
| 		editor.indent = function() { | ||||
| 			const range = selectionRangeRef.current; | ||||
| 			if (range.isEmpty()) { | ||||
| 				const row = range.start.row; | ||||
| 				const tokens = this.session.getTokens(row); | ||||
|  | ||||
| 				if (tokens.length > 0 && tokens[0].type == 'markup.list') { | ||||
| 					if (tokens[0].value.search(/\d+\./) != -1) { | ||||
| 						// Resets numbered list to 1. | ||||
| 						this.session.replace({ start: { row, column: 0 }, end: { row, column: tokens[0].value.length } }, | ||||
| 							tokens[0].value.replace(/\d+\./, '1.')); | ||||
| 					} | ||||
|  | ||||
| 					this.session.indentRows(row, row, '\t'); | ||||
| 					return; | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			localIndentOrig.call(this); | ||||
| 		}; | ||||
| 	}, [editor]); | ||||
|  | ||||
| 	const webview_domReady = useCallback(() => { | ||||
| 		setWebviewReady(true); | ||||
| 	}, []); | ||||
|  | ||||
| 	const webview_ipcMessage = useCallback((event: any) => { | ||||
| 		const msg = event.channel ? event.channel : ''; | ||||
| 		const args = event.args; | ||||
| 		const arg0 = args && args.length >= 1 ? args[0] : null; | ||||
|  | ||||
| 		if (msg.indexOf('checkboxclick:') === 0) { | ||||
| 			const newBody = shared.toggleCheckbox(msg, props.content); | ||||
| 			aceEditor_change(newBody); | ||||
| 		} else if (msg === 'percentScroll') { | ||||
| 			setEditorPercentScroll(arg0); | ||||
| 		} else { | ||||
| 			props.onMessage(event); | ||||
| 		} | ||||
| 	}, [props.onMessage, props.content, aceEditor_change]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		let cancelled = false; | ||||
|  | ||||
| 		const interval = contentKeyHasChangedRef.current ? 0 : 500; | ||||
|  | ||||
| 		const timeoutId = setTimeout(async () => { | ||||
| 			let bodyToRender = props.content; | ||||
|  | ||||
| 			if (!bodyToRender.trim() && props.visiblePanes.indexOf('viewer') >= 0 && props.visiblePanes.indexOf('editor') < 0) { | ||||
| 				// Fixes https://github.com/laurent22/joplin/issues/217 | ||||
| 				bodyToRender = `<i>${_('This note has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout'))}</i>`; | ||||
| 			} | ||||
|  | ||||
| 			const result = await props.markupToHtml(props.contentMarkupLanguage, bodyToRender, markupRenderOptions({ resourceInfos: props.resourceInfos })); | ||||
| 			if (cancelled) return; | ||||
| 			setRenderedBody(result); | ||||
| 		}, interval); | ||||
|  | ||||
| 		return () => { | ||||
| 			cancelled = true; | ||||
| 			clearTimeout(timeoutId); | ||||
| 		}; | ||||
| 	}, [props.content, props.contentMarkupLanguage, props.visiblePanes, props.resourceInfos]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (!webviewReady) return; | ||||
|  | ||||
| 		const options: any = { | ||||
| 			pluginAssets: renderedBody.pluginAssets, | ||||
| 			downloadResources: Setting.value('sync.resourceDownloadMode'), | ||||
| 		}; | ||||
| 		webviewRef.current.wrappedInstance.send('setHtml', renderedBody.html, options); | ||||
| 	}, [renderedBody, webviewReady]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (props.searchMarkers !== previousSearchMarkers || renderedBody !== previousRenderedBody) { | ||||
| 			webviewRef.current.wrappedInstance.send('setMarkers', props.searchMarkers.keywords, props.searchMarkers.options); | ||||
| 		} | ||||
| 	}, [props.searchMarkers, renderedBody]); | ||||
|  | ||||
| 	const cellEditorStyle = useMemo(() => { | ||||
| 		const output = { ...styles.cellEditor }; | ||||
| 		if (!props.visiblePanes.includes('editor')) { | ||||
| 			// Note: Ideally we'd set the display to "none" to take the editor out | ||||
| 			// of the DOM but if we do that, certain things won't work, in particular | ||||
| 			// things related to scroll, which are based on the editor. | ||||
| 			output.width = 1; | ||||
| 			output.maxWidth = 1; | ||||
| 			output.position = 'absolute'; | ||||
| 			output.left = -100000; | ||||
| 		} | ||||
| 		return output; | ||||
| 	}, [styles.cellEditor, props.visiblePanes]); | ||||
|  | ||||
| 	const cellViewerStyle = useMemo(() => { | ||||
| 		const output = { ...styles.cellViewer }; | ||||
| 		if (!props.visiblePanes.includes('viewer')) { | ||||
| 			// Note: setting webview.display to "none" is currently not supported due | ||||
| 			// to this bug: https://github.com/electron/electron/issues/8277 | ||||
| 			// So instead setting the width 0. | ||||
| 			output.width = 1; | ||||
| 			output.maxWidth = 1; | ||||
| 		} else if (!props.visiblePanes.includes('editor')) { | ||||
| 			output.borderLeftStyle = 'none'; | ||||
| 		} | ||||
| 		return output; | ||||
| 	}, [styles.cellViewer, props.visiblePanes]); | ||||
|  | ||||
| 	function renderEditor() { | ||||
| 		return ( | ||||
| 			<div style={cellEditorStyle}> | ||||
| 				<AceEditorReact | ||||
| 					value={props.content} | ||||
| 					mode={props.contentMarkupLanguage === Note.MARKUP_LANGUAGE_HTML ? 'text' : 'markdown'} | ||||
| 					theme={styles.editor.editorTheme} | ||||
| 					style={styles.editor} | ||||
| 					fontSize={styles.editor.fontSize} | ||||
| 					showGutter={false} | ||||
| 					readOnly={props.visiblePanes.indexOf('editor') < 0} | ||||
| 					name="note-editor" | ||||
| 					wrapEnabled={true} | ||||
| 					onScroll={editor_scroll} | ||||
| 					onChange={aceEditor_change} | ||||
| 					showPrintMargin={false} | ||||
| 					onLoad={aceEditor_load} | ||||
| 					// Enable/Disable the autoclosing braces | ||||
| 					setOptions={ | ||||
| 						{ | ||||
| 							behavioursEnabled: Setting.value('editor.autoMatchingBraces'), | ||||
| 							useSoftTabs: false, | ||||
| 						} | ||||
| 					} | ||||
| 					// Disable warning: "Automatically scrolling cursor into view after | ||||
| 					// selection change this will be disabled in the next version set | ||||
| 					// editor.$blockScrolling = Infinity to disable this message" | ||||
| 					editorProps={{ $blockScrolling: Infinity }} | ||||
| 					// This is buggy (gets outside the container) | ||||
| 					highlightActiveLine={false} | ||||
| 					keyboardHandler={props.keyboardMode} | ||||
| 				/> | ||||
| 			</div> | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	function renderViewer() { | ||||
| 		return ( | ||||
| 			<div style={cellViewerStyle}> | ||||
| 				<NoteTextViewer | ||||
| 					ref={webviewRef} | ||||
| 					viewerStyle={styles.viewer} | ||||
| 					onIpcMessage={webview_ipcMessage} | ||||
| 					onDomReady={webview_domReady} | ||||
| 				/> | ||||
| 			</div> | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<div style={styles.root}> | ||||
| 			<div style={styles.rowToolbar}> | ||||
| 				<Toolbar | ||||
| 					theme={props.theme} | ||||
| 					dispatch={props.dispatch} | ||||
| 				/> | ||||
| 				{props.noteToolbar} | ||||
| 			</div> | ||||
| 			<div style={styles.rowEditorViewer}> | ||||
| 				{renderEditor()} | ||||
| 				{renderViewer()} | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	); | ||||
| } | ||||
|  | ||||
| export default forwardRef(AceEditor); | ||||
|  | ||||
							
								
								
									
										166
									
								
								ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,166 @@ | ||||
| import * as React from 'react'; | ||||
|  | ||||
| const ToolbarBase = require('../../../Toolbar.min.js'); | ||||
| const { _ } = require('lib/locale'); | ||||
| const { buildStyle } = require('../../../../theme.js'); | ||||
|  | ||||
| interface ToolbarProps { | ||||
| 	theme: number, | ||||
| 	dispatch: Function, | ||||
| } | ||||
|  | ||||
| function styles_(props:ToolbarProps) { | ||||
| 	return buildStyle('AceEditorToolbar', props.theme, (/* theme:any*/) => { | ||||
| 		return { | ||||
| 			root: { | ||||
| 				// marginTop: 4, | ||||
| 				marginBottom: 0, | ||||
| 			}, | ||||
| 		}; | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| export default function Toolbar(props:ToolbarProps) { | ||||
| 	const styles = styles_(props); | ||||
|  | ||||
| 	function createToolbarItems() { | ||||
| 		const toolbarItems = []; | ||||
|  | ||||
| 		toolbarItems.push({ | ||||
| 			tooltip: _('Bold'), | ||||
| 			iconName: 'fa-bold', | ||||
| 			onClick: () => { | ||||
| 				props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'textBold', | ||||
| 				}); | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		toolbarItems.push({ | ||||
| 			tooltip: _('Italic'), | ||||
| 			iconName: 'fa-italic', | ||||
| 			onClick: () => { | ||||
| 				props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'textItalic', | ||||
| 				}); | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		toolbarItems.push({ | ||||
| 			type: 'separator', | ||||
| 		}); | ||||
|  | ||||
| 		toolbarItems.push({ | ||||
| 			tooltip: _('Hyperlink'), | ||||
| 			iconName: 'fa-link', | ||||
| 			onClick: () => { | ||||
| 				props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'textLink', | ||||
| 				}); | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		toolbarItems.push({ | ||||
| 			tooltip: _('Code'), | ||||
| 			iconName: 'fa-code', | ||||
| 			onClick: () => { | ||||
| 				props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'textCode', | ||||
| 				}); | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		toolbarItems.push({ | ||||
| 			tooltip: _('Attach file'), | ||||
| 			iconName: 'fa-paperclip', | ||||
| 			onClick: () => { | ||||
| 				props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'attachFile', | ||||
| 				}); | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		toolbarItems.push({ | ||||
| 			type: 'separator', | ||||
| 		}); | ||||
|  | ||||
| 		toolbarItems.push({ | ||||
| 			tooltip: _('Numbered List'), | ||||
| 			iconName: 'fa-list-ol', | ||||
| 			onClick: () => { | ||||
| 				props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'textNumberedList', | ||||
| 				}); | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		toolbarItems.push({ | ||||
| 			tooltip: _('Bulleted List'), | ||||
| 			iconName: 'fa-list-ul', | ||||
| 			onClick: () => { | ||||
| 				props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'textBulletedList', | ||||
| 				}); | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		toolbarItems.push({ | ||||
| 			tooltip: _('Checkbox'), | ||||
| 			iconName: 'fa-check-square', | ||||
| 			onClick: () => { | ||||
| 				props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'textCheckbox', | ||||
| 				}); | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		toolbarItems.push({ | ||||
| 			tooltip: _('Heading'), | ||||
| 			iconName: 'fa-header', | ||||
| 			onClick: () => { | ||||
| 				props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'textHeading', | ||||
| 				}); | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		toolbarItems.push({ | ||||
| 			tooltip: _('Horizontal Rule'), | ||||
| 			iconName: 'fa-ellipsis-h', | ||||
| 			onClick: () => { | ||||
| 				props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'textHorizontalRule', | ||||
| 				}); | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		toolbarItems.push({ | ||||
| 			tooltip: _('Insert Date Time'), | ||||
| 			iconName: 'fa-calendar-plus-o', | ||||
| 			onClick: () => { | ||||
| 				props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'insertDateTime', | ||||
| 				}); | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		toolbarItems.push({ | ||||
| 			type: 'separator', | ||||
| 		}); | ||||
|  | ||||
| 		return toolbarItems; | ||||
| 	} | ||||
|  | ||||
| 	return <ToolbarBase style={styles.root} items={createToolbarItems()} />; | ||||
| } | ||||
| @@ -0,0 +1,60 @@ | ||||
| import { NoteBodyEditorProps } from '../../../utils/types'; | ||||
| const { buildStyle } = require('../../../../../theme.js'); | ||||
|  | ||||
| export default function styles(props: NoteBodyEditorProps) { | ||||
| 	return buildStyle('AceEditor', props.theme, (theme: any) => { | ||||
| 		return { | ||||
| 			root: { | ||||
| 				position: 'relative', | ||||
| 				display: 'flex', | ||||
| 				flexDirection: 'column', | ||||
| 				...props.style, | ||||
| 			}, | ||||
| 			rowToolbar: { | ||||
| 				position: 'relative', | ||||
| 				display: 'flex', | ||||
| 				flexDirection: 'row', | ||||
| 			}, | ||||
| 			rowEditorViewer: { | ||||
| 				position: 'relative', | ||||
| 				display: 'flex', | ||||
| 				flexDirection: 'row', | ||||
| 				flex: 1, | ||||
| 				paddingTop: 10, | ||||
| 			}, | ||||
| 			cellEditor: { | ||||
| 				position: 'relative', | ||||
| 				display: 'flex', | ||||
| 				flex: 1, | ||||
| 			}, | ||||
| 			cellViewer: { | ||||
| 				position: 'relative', | ||||
| 				display: 'flex', | ||||
| 				flex: 1, | ||||
| 				borderLeftWidth: 1, | ||||
| 				borderLeftColor: theme.dividerColor, | ||||
| 				borderLeftStyle: 'solid', | ||||
| 			}, | ||||
| 			viewer: { | ||||
| 				display: 'flex', | ||||
| 				overflow: 'hidden', | ||||
| 				verticalAlign: 'top', | ||||
| 				boxSizing: 'border-box', | ||||
| 				width: '100%', | ||||
| 			}, | ||||
| 			editor: { | ||||
| 				display: 'flex', | ||||
| 				width: 'auto', | ||||
| 				height: 'auto', | ||||
| 				flex: 1, | ||||
| 				overflowY: 'hidden', | ||||
| 				paddingTop: 0, | ||||
| 				lineHeight: `${theme.textAreaLineHeight}px`, | ||||
| 				fontSize: `${theme.editorFontSize}px`, | ||||
| 				color: theme.color, | ||||
| 				backgroundColor: theme.backgroundColor, | ||||
| 				editorTheme: theme.editorTheme, // Defined in theme.js | ||||
| 			}, | ||||
| 		}; | ||||
| 	}); | ||||
| } | ||||
							
								
								
									
										241
									
								
								ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										241
									
								
								ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,241 @@ | ||||
| import { useState, useEffect, useCallback, useRef } from 'react'; | ||||
|  | ||||
| export function cursorPositionToTextOffset(cursorPos: any, body: string) { | ||||
| 	if (!body) return 0; | ||||
|  | ||||
| 	const noteLines = body.split('\n'); | ||||
|  | ||||
| 	let pos = 0; | ||||
| 	for (let i = 0; i < noteLines.length; i++) { | ||||
| 		if (i > 0) pos++; // Need to add the newline that's been removed in the split() call above | ||||
|  | ||||
| 		if (i === cursorPos.row) { | ||||
| 			pos += cursorPos.column; | ||||
| 			break; | ||||
| 		} else { | ||||
| 			pos += noteLines[i].length; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return pos; | ||||
| } | ||||
|  | ||||
| export function currentTextOffset(editor: any, body: string) { | ||||
| 	return cursorPositionToTextOffset(editor.getCursorPosition(), body); | ||||
| } | ||||
|  | ||||
| export function rangeToTextOffsets(range: any, body: string) { | ||||
| 	return { | ||||
| 		start: cursorPositionToTextOffset(range.start, body), | ||||
| 		end: cursorPositionToTextOffset(range.end, body), | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| export function textOffsetSelection(selectionRange: any, body: string) { | ||||
| 	return selectionRange && body ? rangeToTextOffsets(selectionRange, body) : null; | ||||
| } | ||||
|  | ||||
| export function selectedText(selectionRange: any, body: string) { | ||||
| 	const selection = textOffsetSelection(selectionRange, body); | ||||
| 	if (!selection || selection.start === selection.end) return ''; | ||||
|  | ||||
| 	return body.substr(selection.start, selection.end - selection.start); | ||||
| } | ||||
|  | ||||
| export function useSelectionRange(editor: any) { | ||||
| 	const [selectionRange, setSelectionRange] = useState(null); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (!editor) return () => {}; | ||||
|  | ||||
| 		function updateSelection() { | ||||
| 			const ranges = editor.getSelection().getAllRanges(); | ||||
| 			const firstRange = ranges && ranges.length ? ranges[0] : null; | ||||
| 			setSelectionRange(firstRange); | ||||
|  | ||||
| 			// if (process.platform === 'linux') { | ||||
| 			// 	const textRange = this.textOffsetSelection(); | ||||
| 			// 	if (textRange.start != textRange.end) { | ||||
| 			// 		clipboard.writeText(this.state.note.body.slice( | ||||
| 			// 			Math.min(textRange.start, textRange.end), | ||||
| 			// 			Math.max(textRange.end, textRange.start)), 'selection'); | ||||
| 			// 	} | ||||
| 			// } | ||||
| 		} | ||||
|  | ||||
| 		function onSelectionChange() { | ||||
| 			updateSelection(); | ||||
| 		} | ||||
|  | ||||
| 		function onFocus() { | ||||
| 			updateSelection(); | ||||
| 		} | ||||
|  | ||||
| 		editor.getSession().selection.on('changeSelection', onSelectionChange); | ||||
| 		editor.on('focus', onFocus); | ||||
|  | ||||
| 		return () => { | ||||
| 			editor.getSession().selection.off('changeSelection', onSelectionChange); | ||||
| 			editor.off('focus', onFocus); | ||||
| 		}; | ||||
| 	}, [editor]); | ||||
|  | ||||
| 	return selectionRange; | ||||
| } | ||||
|  | ||||
| export function textOffsetToCursorPosition(offset: number, body: string) { | ||||
| 	const lines = body.split('\n'); | ||||
| 	let row = 0; | ||||
| 	let currentOffset = 0; | ||||
| 	for (let i = 0; i < lines.length; i++) { | ||||
| 		const line = lines[i]; | ||||
| 		if (currentOffset + line.length >= offset) { | ||||
| 			return { | ||||
| 				row: row, | ||||
| 				column: offset - currentOffset, | ||||
| 			}; | ||||
| 		} | ||||
|  | ||||
| 		row++; | ||||
| 		currentOffset += line.length + 1; | ||||
| 	} | ||||
|  | ||||
| 	return null; | ||||
| } | ||||
|  | ||||
| function lineAtRow(body: string, row: number) { | ||||
| 	if (!body) return ''; | ||||
| 	const lines = body.split('\n'); | ||||
| 	if (row < 0 || row >= lines.length) return ''; | ||||
| 	return lines[row]; | ||||
| } | ||||
|  | ||||
| export function selectionRangeCurrentLine(selectionRange: any, body: string) { | ||||
| 	if (!selectionRange) return ''; | ||||
| 	return lineAtRow(body, selectionRange.start.row); | ||||
| } | ||||
|  | ||||
| export function selectionRangePreviousLine(selectionRange: any, body: string) { | ||||
| 	if (!selectionRange) return ''; | ||||
| 	return lineAtRow(body, selectionRange.start.row - 1); | ||||
| } | ||||
|  | ||||
| export function lineLeftSpaces(line: string) { | ||||
| 	let output = ''; | ||||
| 	for (let i = 0; i < line.length; i++) { | ||||
| 		if ([' ', '\t'].indexOf(line[i]) >= 0) { | ||||
| 			output += line[i]; | ||||
| 		} else { | ||||
| 			break; | ||||
| 		} | ||||
| 	} | ||||
| 	return output; | ||||
| } | ||||
|  | ||||
| export function usePrevious(value: any): any { | ||||
| 	const ref = useRef(); | ||||
| 	useEffect(() => { | ||||
| 		ref.current = value; | ||||
| 	}); | ||||
| 	return ref.current; | ||||
| } | ||||
|  | ||||
| export function useScrollHandler(editor: any, webviewRef: any, onScroll: Function) { | ||||
| 	const editorMaxScrollTop_ = useRef(0); | ||||
| 	const restoreScrollTop_ = useRef<any>(null); | ||||
| 	const ignoreNextEditorScrollEvent_ = useRef(false); | ||||
| 	const scrollTimeoutId_ = useRef<any>(null); | ||||
|  | ||||
| 	// TODO: Below is not needed anymore???? | ||||
| 	// | ||||
| 	// this.editorMaxScrollTop_ = 0; | ||||
| 	// // HACK: To go around a bug in Ace editor, we first set the scroll position to 1 | ||||
| 	// // and then (in the renderer callback) to the value we actually need. The first | ||||
| 	// // operation helps clear the scroll position cache. See: | ||||
| 	// // | ||||
| 	// this.editorSetScrollTop(1); | ||||
| 	// this.restoreScrollTop_ = 0; | ||||
|  | ||||
| 	const editorSetScrollTop = useCallback((v) => { | ||||
| 		if (!editor) return; | ||||
| 		editor.getSession().setScrollTop(v); | ||||
| 	}, [editor]); | ||||
|  | ||||
| 	// Complicated but reliable method to get editor content height | ||||
| 	// https://github.com/ajaxorg/ace/issues/2046 | ||||
| 	const onAfterEditorRender = useCallback(() => { | ||||
| 		const r = editor.renderer; | ||||
| 		editorMaxScrollTop_.current = Math.max(0, r.layerConfig.maxHeight - r.$size.scrollerHeight); | ||||
|  | ||||
| 		if (restoreScrollTop_.current !== null) { | ||||
| 			editorSetScrollTop(restoreScrollTop_.current); | ||||
| 			restoreScrollTop_.current = null; | ||||
| 		} | ||||
| 	}, [editor, editorSetScrollTop]); | ||||
|  | ||||
| 	const scheduleOnScroll = useCallback((event: any) => { | ||||
| 		if (scrollTimeoutId_.current) { | ||||
| 			clearTimeout(scrollTimeoutId_.current); | ||||
| 			scrollTimeoutId_.current = null; | ||||
| 		} | ||||
|  | ||||
| 		scrollTimeoutId_.current = setTimeout(() => { | ||||
| 			scrollTimeoutId_.current = null; | ||||
| 			onScroll(event); | ||||
| 		}, 10); | ||||
| 	}, [onScroll]); | ||||
|  | ||||
| 	const setEditorPercentScroll = useCallback((p: number) => { | ||||
| 		ignoreNextEditorScrollEvent_.current = true; | ||||
| 		editorSetScrollTop(p * editorMaxScrollTop_.current); | ||||
| 		scheduleOnScroll({ percent: p }); | ||||
| 	}, [editorSetScrollTop, scheduleOnScroll]); | ||||
|  | ||||
| 	const setViewerPercentScroll = useCallback((p: number) => { | ||||
| 		if (webviewRef.current) { | ||||
| 			webviewRef.current.wrappedInstance.send('setPercentScroll', p); | ||||
| 			scheduleOnScroll({ percent: p }); | ||||
| 		} | ||||
| 	}, [scheduleOnScroll]); | ||||
|  | ||||
| 	const editor_scroll = useCallback(() => { | ||||
| 		if (ignoreNextEditorScrollEvent_.current) { | ||||
| 			ignoreNextEditorScrollEvent_.current = false; | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		const m = editorMaxScrollTop_.current; | ||||
| 		const percent = m ? editor.getSession().getScrollTop() / m : 0; | ||||
|  | ||||
| 		setViewerPercentScroll(percent); | ||||
| 	}, [editor, setViewerPercentScroll]); | ||||
|  | ||||
| 	const resetScroll = useCallback(() => { | ||||
| 		if (!editor) return; | ||||
|  | ||||
| 		// Ace Editor caches scroll values, which makes | ||||
| 		// it hard to reset the scroll position, so we | ||||
| 		// need to use this hack. | ||||
| 		// https://github.com/ajaxorg/ace/issues/2195 | ||||
| 		editor.session.$scrollTop = -1; | ||||
| 		editor.session.$scrollLeft = -1; | ||||
| 		editor.renderer.scrollTop = -1; | ||||
| 		editor.renderer.scrollLeft = -1; | ||||
| 		editor.renderer.scrollBarV.scrollTop = -1; | ||||
| 		editor.renderer.scrollBarH.scrollLeft = -1; | ||||
| 		editor.session.setScrollTop(0); | ||||
| 		editor.session.setScrollLeft(0); | ||||
| 	}, [editorSetScrollTop, editor]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (!editor) return () => {}; | ||||
|  | ||||
| 		editor.renderer.on('afterRender', onAfterEditorRender); | ||||
|  | ||||
| 		return () => { | ||||
| 			editor.renderer.off('afterRender', onAfterEditorRender); | ||||
| 		}; | ||||
| 	}, [editor]); | ||||
|  | ||||
| 	return { resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll }; | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| export interface RenderedBody { | ||||
| 	html: string; | ||||
| 	pluginAssets: any[]; | ||||
| } | ||||
|  | ||||
| export function defaultRenderedBody(): RenderedBody { | ||||
| 	return { | ||||
| 		html: '', | ||||
| 		pluginAssets: [], | ||||
| 	}; | ||||
| } | ||||
| @@ -0,0 +1,24 @@ | ||||
| 'use strict'; | ||||
| Object.defineProperty(exports, '__esModule', { value: true }); | ||||
| const React = require('react'); | ||||
| const react_1 = require('react'); | ||||
| const PlainEditor = (props, ref) => { | ||||
| 	const editorRef = react_1.useRef(); | ||||
| 	react_1.useImperativeHandle(ref, () => { | ||||
| 		return { | ||||
| 			content: () => '', | ||||
| 		}; | ||||
| 	}, []); | ||||
| 	react_1.useEffect(() => { | ||||
| 		if (!editorRef.current) { return; } | ||||
| 		editorRef.current.value = props.defaultEditorState.value; | ||||
| 	}, [props.defaultEditorState]); | ||||
| 	const onChange = react_1.useCallback((event) => { | ||||
| 		props.onChange({ changeId: null, content: event.target.value }); | ||||
| 	}, [props.onWillChange, props.onChange]); | ||||
| 	return (React.createElement('div', { style: props.style }, | ||||
| 		React.createElement('textarea', { ref: editorRef, style: { width: '100%', height: '100%' }, defaultValue: props.defaultEditorState.value, onChange: onChange }), | ||||
| 		';')); | ||||
| }; | ||||
| exports.default = react_1.forwardRef(PlainEditor); | ||||
| // # sourceMappingURL=PlainEditor.js.map | ||||
| @@ -1,9 +1,8 @@ | ||||
| // Kept only for reference | ||||
| 
 | ||||
| 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, | ||||
| @@ -13,18 +12,11 @@ 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>(); | ||||
| 
 | ||||
| @@ -1,29 +1,13 @@ | ||||
| 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, resourcesStatus } from '../utils/NoteText'; | ||||
| 
 | ||||
| import { EditorCommand, NoteBodyEditorProps } from '../../utils/types'; | ||||
| import { resourcesStatus } from '../../utils/resourceHandling'; | ||||
| const { MarkupToHtml } = require('lib/joplin-renderer'); | ||||
| const taboverride = require('taboverride'); | ||||
| const { reg } = require('lib/registry.js'); | ||||
| const { _ } = require('lib/locale'); | ||||
| const BaseItem = require('lib/models/BaseItem'); | ||||
| const { themeStyle, buildStyle } = require('../../theme.js'); | ||||
| 
 | ||||
| interface TinyMCEProps { | ||||
| 	style: any, | ||||
| 	theme: number, | ||||
| 	onChange(event: OnChangeEvent): void, | ||||
| 	onWillChange(event:any): void, | ||||
| 	onMessage(event:any): void, | ||||
| 	defaultEditorState: DefaultEditorState, | ||||
| 	markupToHtml: Function, | ||||
| 	allAssets: Function, | ||||
| 	attachResources: Function, | ||||
| 	joplinHtml: Function, | ||||
| 	disabled: boolean, | ||||
| } | ||||
| const { themeStyle, buildStyle } = require('../../../../theme.js'); | ||||
| 
 | ||||
| function markupRenderOptions(override:any = null) { | ||||
| 	return { | ||||
| @@ -35,6 +19,7 @@ function markupRenderOptions(override:any = null) { | ||||
| 				linkRenderingType: 2, | ||||
| 			}, | ||||
| 		}, | ||||
| 		replaceResourceInternalToExternalLinks: true, | ||||
| 		...override, | ||||
| 	}; | ||||
| } | ||||
| @@ -104,12 +89,6 @@ function enableTextAreaTab(enable:boolean) { | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| export const utils:TextEditorUtils = { | ||||
| 	editorContentToHtml(content:any):Promise<string> { | ||||
| 		return content ? content : ''; | ||||
| 	}, | ||||
| }; | ||||
| 
 | ||||
| interface TinyMceCommand { | ||||
| 	name: string, | ||||
| 	value?: any, | ||||
| @@ -127,7 +106,7 @@ const joplinCommandToTinyMceCommands:JoplinCommandToTinyMceCommands = { | ||||
| 	'search': { name: 'SearchReplace' }, | ||||
| }; | ||||
| 
 | ||||
| function styles_(props:TinyMCEProps) { | ||||
| function styles_(props:NoteBodyEditorProps) { | ||||
| 	return buildStyle('TinyMCE', props.theme, (/* theme:any */) => { | ||||
| 		return { | ||||
| 			disabledOverlay: { | ||||
| @@ -155,7 +134,7 @@ let loadedAssetFiles_:string[] = []; | ||||
| let dispatchDidUpdateIID_:any = null; | ||||
| let changeId_:number = 1; | ||||
| 
 | ||||
| const TinyMCE = (props:TinyMCEProps, ref:any) => { | ||||
| const TinyMCE = (props:NoteBodyEditorProps, ref:any) => { | ||||
| 	const [editor, setEditor] = useState(null); | ||||
| 	const [scriptLoaded, setScriptLoaded] = useState(false); | ||||
| 	const [editorReady, setEditorReady] = useState(false); | ||||
| @@ -166,9 +145,6 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { | ||||
| 	const markupToHtml = useRef(null); | ||||
| 	markupToHtml.current = props.markupToHtml; | ||||
| 
 | ||||
| 	const joplinHtml = useRef(null); | ||||
| 	joplinHtml.current = props.joplinHtml; | ||||
| 
 | ||||
| 	const rootIdRef = useRef<string>(`tinymce-${Date.now()}${Math.round(Math.random() * 10000)}`); | ||||
| 	const editorRef = useRef<any>(null); | ||||
| 	editorRef.current = editor; | ||||
| @@ -194,16 +170,16 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { | ||||
| 
 | ||||
| 		if (nodeName === 'A' && (event.ctrlKey || event.metaKey)) { | ||||
| 			const href = event.target.getAttribute('href'); | ||||
| 			const joplinUrl = href.indexOf('joplin://') === 0 ? href : null; | ||||
| 			// const joplinUrl = href.indexOf('joplin://') === 0 ? href : null;
 | ||||
| 
 | ||||
| 			if (joplinUrl) { | ||||
| 				props.onMessage({ | ||||
| 					name: 'openInternal', | ||||
| 					args: { | ||||
| 						url: joplinUrl, | ||||
| 					}, | ||||
| 				}); | ||||
| 			} else if (href.indexOf('#') === 0) { | ||||
| 			// if (joplinUrl) {
 | ||||
| 			// 	props.onMessage({
 | ||||
| 			// 		name: 'openInternal',
 | ||||
| 			// 		args: {
 | ||||
| 			// 			url: joplinUrl,
 | ||||
| 			// 		},
 | ||||
| 			// 	});
 | ||||
| 			if (href.indexOf('#') === 0) { | ||||
| 				const anchorName = href.substr(1); | ||||
| 				const anchor = editor.getDoc().getElementById(anchorName); | ||||
| 				if (anchor) { | ||||
| @@ -213,7 +189,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { | ||||
| 				} | ||||
| 			} else { | ||||
| 				props.onMessage({ | ||||
| 					name: 'openExternal', | ||||
| 					name: 'openUrl', | ||||
| 					args: { | ||||
| 						url: href, | ||||
| 					}, | ||||
| @@ -224,7 +200,22 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { | ||||
| 
 | ||||
| 	useImperativeHandle(ref, () => { | ||||
| 		return { | ||||
| 			content: () => editor ? editor.getContent() : '', | ||||
| 			content: async () => { | ||||
| 				if (!editorRef.current) return ''; | ||||
| 				return prop_htmlToMarkdownRef.current(props.contentMarkupLanguage, editorRef.current.getContent(), props.contentOriginalCss); | ||||
| 			}, | ||||
| 			setContent: (/* body: string*/) => { | ||||
| 				console.warn('TinyMCE::setContent - not implemented'); | ||||
| 			}, | ||||
| 			resetScroll: () => { | ||||
| 				console.warn('TinyMCE::resetScroll - not implemented'); | ||||
| 			}, | ||||
| 			scrollTo: (/* options:ScrollOptions*/) => { | ||||
| 				console.warn('TinyMCE::scrollTo - not implemented'); | ||||
| 			}, | ||||
| 			clearState: () => { | ||||
| 				console.warn('TinyMCE::clearState - not implemented'); | ||||
| 			}, | ||||
| 			execCommand: async (cmd:EditorCommand) => { | ||||
| 				if (!editor) return false; | ||||
| 
 | ||||
| @@ -257,7 +248,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { | ||||
| 				return true; | ||||
| 			}, | ||||
| 		}; | ||||
| 	}, [editor]); | ||||
| 	}, [editor, props.contentMarkupLanguage, props.contentOriginalCss]); | ||||
| 
 | ||||
| 	// -----------------------------------------------------------------------------------------
 | ||||
| 	// Load the TinyMCE library. The lib loads additional JS and CSS files on startup
 | ||||
| @@ -303,7 +294,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { | ||||
| 					loaded: false, | ||||
| 				}, | ||||
| 				{ | ||||
| 					src: 'gui/editors/TinyMCE/plugins/lists.js', | ||||
| 					src: 'gui/NoteEditor/NoteBody/TinyMCE/plugins/lists.js', | ||||
| 					id: 'tinyMceListsPluginScript', | ||||
| 					loaded: false, | ||||
| 				}, | ||||
| @@ -440,7 +431,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { | ||||
| 				height: '100%', | ||||
| 				resize: false, | ||||
| 				icons: 'Joplin', | ||||
| 				icons_url: 'gui/editors/TinyMCE/icons.js', | ||||
| 				icons_url: 'gui/NoteEditor/NoteBody/TinyMCE/icons.js', | ||||
| 				plugins: 'noneditable link joplinLists hr searchreplace codesample table', | ||||
| 				noneditable_noneditable_class: 'joplin-editable', // Can be a regex too
 | ||||
| 				valid_elements: '*[*]', // We already filter in sanitize_html
 | ||||
| @@ -524,7 +515,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { | ||||
| 
 | ||||
| 							const html = []; | ||||
| 							for (const resource of resources) { | ||||
| 								const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, resource.markdownTag, { bodyOnly: true }); | ||||
| 								const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, resource.markdownTag, markupRenderOptions({ bodyOnly: true })); | ||||
| 								html.push(result.html); | ||||
| 							} | ||||
| 
 | ||||
| @@ -597,7 +588,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { | ||||
| 				.map((a:any) => a.path) | ||||
| 		).filter((path:string) => !loadedAssetFiles_.includes(path)); | ||||
| 
 | ||||
| 		const jsFiles = ['gui/editors/TinyMCE/content_script.js'].concat( | ||||
| 		const jsFiles = ['gui/NoteEditor/NoteBody/TinyMCE/content_script.js'].concat( | ||||
| 			pluginAssets | ||||
| 				.filter((a:any) => a.mime === 'application/javascript') | ||||
| 				.map((a:any) => a.path) | ||||
| @@ -628,7 +619,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { | ||||
| 	useEffect(() => { | ||||
| 		if (!editor) return () => {}; | ||||
| 
 | ||||
| 		if (resourcesStatus(props.defaultEditorState.resourceInfos) !== 'ready') { | ||||
| 		if (resourcesStatus(props.resourceInfos) !== 'ready') { | ||||
| 			editor.setContent(''); | ||||
| 			return () => {}; | ||||
| 		} | ||||
| @@ -636,12 +627,12 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { | ||||
| 		let cancelled = false; | ||||
| 
 | ||||
| 		const loadContent = async () => { | ||||
| 			const result = await props.markupToHtml(props.defaultEditorState.markupLanguage, props.defaultEditorState.value, markupRenderOptions()); | ||||
| 			const result = await props.markupToHtml(props.contentMarkupLanguage, props.content, markupRenderOptions({ resourceInfos: props.resourceInfos })); | ||||
| 			if (cancelled) return; | ||||
| 
 | ||||
| 			editor.setContent(result.html); | ||||
| 
 | ||||
| 			await loadDocumentAssets(editor, await props.allAssets(props.defaultEditorState.markupLanguage)); | ||||
| 			await loadDocumentAssets(editor, await props.allAssets(props.contentMarkupLanguage)); | ||||
| 
 | ||||
| 			editor.getDoc().addEventListener('click', onEditorContentClick); | ||||
| 
 | ||||
| @@ -661,7 +652,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { | ||||
| 			cancelled = true; | ||||
| 			editor.getDoc().removeEventListener('click', onEditorContentClick); | ||||
| 		}; | ||||
| 	}, [editor, props.markupToHtml, props.allAssets, props.defaultEditorState, onEditorContentClick]); | ||||
| 	}, [editor, props.markupToHtml, props.allAssets, onEditorContentClick, props.resourceInfos]); | ||||
| 
 | ||||
| 	// -----------------------------------------------------------------------------------------
 | ||||
| 	// Handle onChange event
 | ||||
| @@ -673,6 +664,9 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { | ||||
| 	const props_onChangeRef = useRef<Function>(); | ||||
| 	props_onChangeRef.current = props.onChange; | ||||
| 
 | ||||
| 	const prop_htmlToMarkdownRef = useRef<Function>(); | ||||
| 	prop_htmlToMarkdownRef.current = props.htmlToMarkdown; | ||||
| 
 | ||||
| 	useEffect(() => { | ||||
| 		if (!editor) return () => {}; | ||||
| 
 | ||||
| @@ -684,14 +678,16 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { | ||||
| 
 | ||||
| 			if (onChangeHandlerIID) clearTimeout(onChangeHandlerIID); | ||||
| 
 | ||||
| 			onChangeHandlerIID = setTimeout(() => { | ||||
| 			onChangeHandlerIID = setTimeout(async () => { | ||||
| 				onChangeHandlerIID = null; | ||||
| 
 | ||||
| 				const contentMd = await prop_htmlToMarkdownRef.current(props.contentMarkupLanguage, editor.getContent(), props.contentOriginalCss); | ||||
| 
 | ||||
| 				if (!editor) return; | ||||
| 
 | ||||
| 				props_onChangeRef.current({ | ||||
| 					changeId: changeId, | ||||
| 					content: editor.getContent(), | ||||
| 					content: contentMd, | ||||
| 				}); | ||||
| 
 | ||||
| 				dispatchDidUpdate(editor); | ||||
| @@ -755,6 +751,8 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { | ||||
| 		editor.on('paste', onPaste); | ||||
| 		editor.on('cut', onChangeHandler); | ||||
| 		editor.on('joplinChange', onChangeHandler); | ||||
| 		editor.on('Undo', onChangeHandler); | ||||
| 		editor.on('Redo', onChangeHandler); | ||||
| 		editor.on('ExecCommand', onExecCommand); | ||||
| 
 | ||||
| 		return () => { | ||||
| @@ -764,12 +762,14 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { | ||||
| 				editor.off('paste', onPaste); | ||||
| 				editor.off('cut', onChangeHandler); | ||||
| 				editor.off('joplinChange', onChangeHandler); | ||||
| 				editor.off('Undo', onChangeHandler); | ||||
| 				editor.off('Redo', onChangeHandler); | ||||
| 				editor.off('ExecCommand', onExecCommand); | ||||
| 			} catch (error) { | ||||
| 				console.warn('Error removing events', error); | ||||
| 			} | ||||
| 		}; | ||||
| 	}, [props.onWillChange, props.onChange, editor]); | ||||
| 	}, [props.onWillChange, props.onChange, props.contentMarkupLanguage, props.contentOriginalCss, editor]); | ||||
| 
 | ||||
| 	// -----------------------------------------------------------------------------------------
 | ||||
| 	// Destroy the editor when unmounting
 | ||||
| @@ -783,11 +783,13 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { | ||||
| 		}; | ||||
| 	}, []); | ||||
| 
 | ||||
| 	// Currently we don't handle resource "auto" and "manual" mode with TinyMCE
 | ||||
| 	// as it is quite complex and probably rarely used.
 | ||||
| 	function renderDisabledOverlay() { | ||||
| 		const status = resourcesStatus(props.defaultEditorState.resourceInfos); | ||||
| 		const status = resourcesStatus(props.resourceInfos); | ||||
| 		if (status === 'ready') return null; | ||||
| 
 | ||||
| 		const message = _('Please wait for all attachments to be downloaded and decrypted. You may also switch the layout and edit the note in Markdown mode.'); | ||||
| 		const message = _('Please wait for all attachments to be downloaded and decrypted. You may also switch to %s to edit the note.', _('Code View')); | ||||
| 		return ( | ||||
| 			<div style={styles.disabledOverlay}> | ||||
| 				<p style={theme.textStyle}>{message}</p> | ||||
							
								
								
									
										570
									
								
								ElectronClient/gui/NoteEditor/NoteEditor.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										570
									
								
								ElectronClient/gui/NoteEditor/NoteEditor.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,570 @@ | ||||
| import * as React from 'react'; | ||||
| import { useState, useEffect, useCallback, useRef } from 'react'; | ||||
| // eslint-disable-next-line no-unused-vars | ||||
| import TinyMCE from './NoteBody/TinyMCE/TinyMCE'; | ||||
| import AceEditor  from './NoteBody/AceEditor/AceEditor'; | ||||
| import { connect } from 'react-redux'; | ||||
| import MultiNoteActions from '../MultiNoteActions'; | ||||
| import NoteToolbar from '../NoteToolbar/NoteToolbar'; | ||||
| import { htmlToMarkdown, formNoteToNote } from './utils'; | ||||
| import useSearchMarkers from './utils/useSearchMarkers'; | ||||
| import useNoteSearchBar from './utils/useNoteSearchBar'; | ||||
| import useMessageHandler from './utils/useMessageHandler'; | ||||
| import useWindowCommandHandler from './utils/useWindowCommandHandler'; | ||||
| import useDropHandler from './utils/useDropHandler'; | ||||
| import useMarkupToHtml from './utils/useMarkupToHtml'; | ||||
| import useFormNote, { OnLoadEvent } from './utils/useFormNote'; | ||||
| import styles_ from './styles'; | ||||
| import { NoteTextProps, FormNote, ScrollOptions, ScrollOptionTypes, OnChangeEvent, NoteBodyEditorProps } from './utils/types'; | ||||
| import { attachResources } from './utils/resourceHandling'; | ||||
|  | ||||
| const { themeStyle } = require('../../theme.js'); | ||||
| const NoteSearchBar = require('../NoteSearchBar.min.js'); | ||||
| const { reg } = require('lib/registry.js'); | ||||
| const { time } = require('lib/time-utils.js'); | ||||
| const markupLanguageUtils = require('lib/markupLanguageUtils'); | ||||
| const usePrevious = require('lib/hooks/usePrevious').default; | ||||
| const Setting = require('lib/models/Setting'); | ||||
| const { _ } = require('lib/locale'); | ||||
| const Note = require('lib/models/Note.js'); | ||||
| const { bridge } = require('electron').remote.require('./bridge'); | ||||
| const ExternalEditWatcher = require('lib/services/ExternalEditWatcher'); | ||||
| const eventManager = require('../../eventManager'); | ||||
| const NoteRevisionViewer = require('../NoteRevisionViewer.min'); | ||||
| const TagList = require('../TagList.min.js'); | ||||
|  | ||||
| function NoteEditor(props: NoteTextProps) { | ||||
| 	const [showRevisions, setShowRevisions] = useState(false); | ||||
| 	const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false); | ||||
| 	const [scrollWhenReady, setScrollWhenReady] = useState<ScrollOptions>(null); | ||||
|  | ||||
| 	const editorRef = useRef<any>(); | ||||
| 	const titleInputRef = useRef<any>(); | ||||
| 	const isMountedRef = useRef(true); | ||||
| 	const noteSearchBarRef = useRef(null); | ||||
|  | ||||
| 	const formNote_beforeLoad = useCallback(async (event:OnLoadEvent) => { | ||||
| 		await saveNoteIfWillChange(event.formNote); | ||||
| 		setShowRevisions(false); | ||||
| 	}, []); | ||||
|  | ||||
| 	const formNote_afterLoad = useCallback(async () => { | ||||
| 		setTitleHasBeenManuallyChanged(false); | ||||
| 	}, []); | ||||
|  | ||||
| 	const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({ | ||||
| 		syncStarted: props.syncStarted, | ||||
| 		noteId: props.noteId, | ||||
| 		isProvisional: props.isProvisional, | ||||
| 		titleInputRef: titleInputRef, | ||||
| 		editorRef: editorRef, | ||||
| 		onBeforeLoad: formNote_beforeLoad, | ||||
| 		onAfterLoad: formNote_afterLoad, | ||||
| 	}); | ||||
|  | ||||
| 	const formNoteRef = useRef<FormNote>(); | ||||
| 	formNoteRef.current = { ...formNote }; | ||||
|  | ||||
| 	const { | ||||
| 		localSearch, | ||||
| 		onChange: localSearch_change, | ||||
| 		onNext: localSearch_next, | ||||
| 		onPrevious: localSearch_previous, | ||||
| 		onClose: localSearch_close, | ||||
| 		setResultCount: setLocalSearchResultCount, | ||||
| 		showLocalSearch, | ||||
| 		setShowLocalSearch, | ||||
| 		searchMarkers: localSearchMarkerOptions, | ||||
| 	} = useNoteSearchBar(); | ||||
|  | ||||
| 	// 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); | ||||
|  | ||||
| 	function scheduleSaveNote(formNote: FormNote) { | ||||
| 		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); | ||||
| 				const savedNote:any = await Note.save(note); | ||||
|  | ||||
| 				setFormNote((prev: FormNote) => { | ||||
| 					return { ...prev, user_updated_time: savedNote.user_updated_time }; | ||||
| 				}); | ||||
|  | ||||
| 				ExternalEditWatcher.instance().updateNoteFile(savedNote); | ||||
|  | ||||
| 				props.dispatch({ | ||||
| 					type: 'EDITOR_NOTE_STATUS_REMOVE', | ||||
| 					id: formNote.id, | ||||
| 				}); | ||||
| 			}; | ||||
| 		}; | ||||
|  | ||||
| 		formNote.saveActionQueue.push(makeAction(formNote)); | ||||
| 	} | ||||
|  | ||||
| 	async function saveNoteIfWillChange(formNote: FormNote) { | ||||
| 		if (!formNote.id || !formNote.bodyWillChangeId) return; | ||||
|  | ||||
| 		const body = await editorRef.current.content(); | ||||
|  | ||||
| 		scheduleSaveNote({ | ||||
| 			...formNote, | ||||
| 			body: body, | ||||
| 			bodyWillChangeId: 0, | ||||
| 			bodyChangeId: 0, | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	async function saveNoteAndWait(formNote: FormNote) { | ||||
| 		saveNoteIfWillChange(formNote); | ||||
| 		return formNote.saveActionQueue.waitForAllDone(); | ||||
| 	} | ||||
|  | ||||
| 	const markupToHtml = useMarkupToHtml({ themeId: props.theme, customCss: props.customCss }); | ||||
|  | ||||
| 	const allAssets = useCallback(async (markupLanguage: number): Promise<any[]> => { | ||||
| 		const theme = themeStyle(props.theme); | ||||
|  | ||||
| 		const markupToHtml = markupLanguageUtils.newMarkupToHtml({ | ||||
| 			resourceBaseUrl: `file://${Setting.value('resourceDir')}/`, | ||||
| 		}); | ||||
|  | ||||
| 		return markupToHtml.allAssets(markupLanguage, theme); | ||||
| 	}, [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 () => { | ||||
| 			isMountedRef.current = false; | ||||
| 			saveNoteIfWillChange(formNoteRef.current); | ||||
| 		}; | ||||
| 	}, []); | ||||
|  | ||||
| 	const previousNoteId = usePrevious(formNote.id); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (formNote.id === previousNoteId) return; | ||||
|  | ||||
| 		if (editorRef.current) { | ||||
| 			editorRef.current.resetScroll(); | ||||
| 		} | ||||
|  | ||||
| 		setScrollWhenReady({ | ||||
| 			type: props.selectedNoteHash ? ScrollOptionTypes.Hash : ScrollOptionTypes.Percent, | ||||
| 			value: props.selectedNoteHash ? props.selectedNoteHash : props.lastEditorScrollPercents[props.noteId] || 0, | ||||
| 		}); | ||||
| 	}, [formNote.id, previousNoteId]); | ||||
|  | ||||
| 	const onFieldChange = useCallback((field: string, value: any, changeId = 0) => { | ||||
| 		if (!isMountedRef.current) { | ||||
| 			// When the component is unmounted, various actions can happen which can | ||||
| 			// trigger onChange events, for example the textarea might be cleared. | ||||
| 			// We need to ignore these events, otherwise the note is going to be saved | ||||
| 			// with an invalid body. | ||||
| 			reg.logger().debug('Skipping change event because the component is unmounted'); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		handleProvisionalFlag(); | ||||
|  | ||||
| 		const change = field === 'body' ? { | ||||
| 			body: value, | ||||
| 		} : { | ||||
| 			title: value, | ||||
| 		}; | ||||
|  | ||||
| 		const newNote = { | ||||
| 			...formNote, | ||||
| 			...change, | ||||
| 			bodyWillChangeId: 0, | ||||
| 			bodyChangeId: 0, | ||||
| 			hasChanged: true, | ||||
| 		}; | ||||
|  | ||||
| 		if (field === 'title') { | ||||
| 			setTitleHasBeenManuallyChanged(true); | ||||
| 		} | ||||
|  | ||||
| 		if (isNewNote && !titleHasBeenManuallyChanged && field === 'body') { | ||||
| 			// TODO: Handle HTML/Markdown format | ||||
| 			newNote.title = Note.defaultTitle(value); | ||||
| 		} | ||||
|  | ||||
| 		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); | ||||
| 		} | ||||
| 	}, [handleProvisionalFlag, formNote, isNewNote, titleHasBeenManuallyChanged]); | ||||
|  | ||||
| 	useWindowCommandHandler({ windowCommand: props.windowCommand, dispatch: props.dispatch, formNote, setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, saveNoteAndWait }); | ||||
|  | ||||
| 	const onDrop = useDropHandler({ editorRef }); | ||||
|  | ||||
| 	const onBodyChange = useCallback((event: OnChangeEvent) => onFieldChange('body', event.content, event.changeId), [onFieldChange]); | ||||
|  | ||||
| 	const onTitleChange = useCallback((event: any) => onFieldChange('title', event.target.value), [onFieldChange]); | ||||
|  | ||||
| 	const onTitleKeydown = useCallback((event:any) => { | ||||
| 		const keyCode = event.keyCode; | ||||
|  | ||||
| 		if (keyCode === 9) { | ||||
| 			// TAB | ||||
| 			event.preventDefault(); | ||||
|  | ||||
| 			if (event.shiftKey) { | ||||
| 				props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'focusElement', | ||||
| 					target: 'noteList', | ||||
| 				}); | ||||
| 			} else { | ||||
| 				props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'focusElement', | ||||
| 					target: 'noteBody', | ||||
| 				}); | ||||
| 			} | ||||
| 		} | ||||
| 	}, [props.dispatch]); | ||||
|  | ||||
| 	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 onMessage = useMessageHandler(scrollWhenReady, setScrollWhenReady, editorRef, setLocalSearchResultCount, props.dispatch); | ||||
|  | ||||
| 	const introductionPostLinkClick = useCallback(() => { | ||||
| 		bridge().openExternal('https://www.patreon.com/posts/34246624'); | ||||
| 	}, []); | ||||
|  | ||||
| 	const externalEditWatcher_noteChange = useCallback((event) => { | ||||
| 		if (event.id === formNote.id) { | ||||
| 			const newFormNote = { | ||||
| 				...formNote, | ||||
| 				title: event.note.title, | ||||
| 				body: event.note.body, | ||||
| 			}; | ||||
|  | ||||
| 			setFormNote(newFormNote); | ||||
| 		} | ||||
| 	}, [formNote]); | ||||
|  | ||||
| 	const onNotePropertyChange = useCallback((event) => { | ||||
| 		setFormNote(formNote => { | ||||
| 			if (formNote.id !== event.note.id) return formNote; | ||||
|  | ||||
| 			const newFormNote: FormNote = { ...formNote }; | ||||
|  | ||||
| 			for (const key in event.note) { | ||||
| 				if (key === 'id') continue; | ||||
| 				(newFormNote as any)[key] = event.note[key]; | ||||
| 			} | ||||
|  | ||||
| 			return newFormNote; | ||||
| 		}); | ||||
| 	}, []); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		eventManager.on('alarmChange', onNotePropertyChange); | ||||
| 		ExternalEditWatcher.instance().on('noteChange', externalEditWatcher_noteChange); | ||||
|  | ||||
| 		return () => { | ||||
| 			eventManager.off('alarmChange', onNotePropertyChange); | ||||
| 			ExternalEditWatcher.instance().off('noteChange', externalEditWatcher_noteChange); | ||||
| 		}; | ||||
| 	}, [externalEditWatcher_noteChange, onNotePropertyChange]); | ||||
|  | ||||
| 	const noteToolbar_buttonClick = useCallback((event: any) => { | ||||
| 		const cases: any = { | ||||
|  | ||||
| 			'startExternalEditing': async () => { | ||||
| 				props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'commandStartExternalEditing', | ||||
| 				}); | ||||
| 			}, | ||||
|  | ||||
| 			'stopExternalEditing': () => { | ||||
| 				props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'commandStopExternalEditing', | ||||
| 				}); | ||||
| 			}, | ||||
|  | ||||
| 			'setTags': async () => { | ||||
| 				await saveNoteAndWait(formNote); | ||||
|  | ||||
| 				props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'setTags', | ||||
| 					noteIds: [formNote.id], | ||||
| 				}); | ||||
| 			}, | ||||
|  | ||||
| 			'setAlarm': async () => { | ||||
| 				await saveNoteAndWait(formNote); | ||||
|  | ||||
| 				props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'editAlarm', | ||||
| 					noteId: formNote.id, | ||||
| 				}); | ||||
| 			}, | ||||
|  | ||||
| 			'showRevisions': () => { | ||||
| 				setShowRevisions(true); | ||||
| 			}, | ||||
| 		}; | ||||
|  | ||||
| 		if (!cases[event.name]) throw new Error(`Unsupported event: ${event.name}`); | ||||
|  | ||||
| 		cases[event.name](); | ||||
| 	}, [formNote]); | ||||
|  | ||||
| 	const onScroll = useCallback((event: any) => { | ||||
| 		props.dispatch({ | ||||
| 			type: 'EDITOR_SCROLL_PERCENT_SET', | ||||
| 			noteId: formNote.id, | ||||
| 			percent: event.percent, | ||||
| 		}); | ||||
| 	}, [props.dispatch, formNote]); | ||||
|  | ||||
| 	function renderNoNotes(rootStyle:any) { | ||||
| 		const emptyDivStyle = Object.assign( | ||||
| 			{ | ||||
| 				backgroundColor: 'black', | ||||
| 				opacity: 0.1, | ||||
| 			}, | ||||
| 			rootStyle | ||||
| 		); | ||||
| 		return <div style={emptyDivStyle}></div>; | ||||
| 	} | ||||
|  | ||||
| 	function renderNoteToolbar() { | ||||
| 		const toolbarStyle = { | ||||
| 			// marginTop: 4, | ||||
| 			marginBottom: 0, | ||||
| 			flex: 1, | ||||
| 		}; | ||||
|  | ||||
| 		return <NoteToolbar | ||||
| 			theme={props.theme} | ||||
| 			note={formNote} | ||||
| 			dispatch={props.dispatch} | ||||
| 			style={toolbarStyle} | ||||
| 			watchedNoteFiles={props.watchedNoteFiles} | ||||
| 			onButtonClick={noteToolbar_buttonClick} | ||||
| 		/>; | ||||
| 	} | ||||
|  | ||||
| 	const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId); | ||||
|  | ||||
| 	const editorProps:NoteBodyEditorProps = { | ||||
| 		ref: editorRef, | ||||
| 		contentKey: formNote.id, | ||||
| 		style: styles.tinyMCE, | ||||
| 		onChange: onBodyChange, | ||||
| 		onWillChange: onBodyWillChange, | ||||
| 		onMessage: onMessage, | ||||
| 		content: formNote.body, | ||||
| 		contentMarkupLanguage: formNote.markup_language, | ||||
| 		contentOriginalCss: formNote.originalCss, | ||||
| 		resourceInfos: resourceInfos, | ||||
| 		htmlToMarkdown: htmlToMarkdown, | ||||
| 		markupToHtml: markupToHtml, | ||||
| 		allAssets: allAssets, | ||||
| 		attachResources: attachResources, | ||||
| 		disabled: false, | ||||
| 		theme: props.theme, | ||||
| 		dispatch: props.dispatch, | ||||
| 		noteToolbar: renderNoteToolbar(), | ||||
| 		onScroll: onScroll, | ||||
| 		searchMarkers: searchMarkers, | ||||
| 		visiblePanes: props.noteVisiblePanes || ['editor', 'viewer'], | ||||
| 		keyboardMode: Setting.value('editor.keyboardMode'), | ||||
| 	}; | ||||
|  | ||||
| 	let editor = null; | ||||
|  | ||||
| 	if (props.bodyEditor === 'TinyMCE') { | ||||
| 		editor = <TinyMCE {...editorProps}/>; | ||||
| 	} else if (props.bodyEditor === 'AceEditor') { | ||||
| 		editor = <AceEditor {...editorProps}/>; | ||||
| 	} else { | ||||
| 		throw new Error(`Invalid editor: ${props.bodyEditor}`); | ||||
| 	} | ||||
|  | ||||
| 	const wysiwygBanner = props.bodyEditor !== 'TinyMCE' ? null : ( | ||||
| 		<div style={{ ...styles.warningBanner, marginBottom: 10 }}> | ||||
| 			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> | ||||
| 	); | ||||
|  | ||||
| 	const noteRevisionViewer_onBack = useCallback(() => { | ||||
| 		setShowRevisions(false); | ||||
| 	}, []); | ||||
|  | ||||
| 	const tagStyle = { | ||||
| 		marginBottom: 10, | ||||
| 	}; | ||||
|  | ||||
| 	const tagList = props.selectedNoteTags.length ? <TagList style={tagStyle} items={props.selectedNoteTags} /> : null; | ||||
|  | ||||
| 	if (showRevisions) { | ||||
| 		const theme = themeStyle(props.theme); | ||||
|  | ||||
| 		const revStyle = { | ||||
| 			...props.style, | ||||
| 			display: 'inline-flex', | ||||
| 			padding: theme.margin, | ||||
| 			verticalAlign: 'top', | ||||
| 			boxSizing: 'border-box', | ||||
|  | ||||
| 		}; | ||||
|  | ||||
| 		return ( | ||||
| 			<div style={revStyle}> | ||||
| 				<NoteRevisionViewer customCss={props.customCss} noteId={formNote.id} onBack={noteRevisionViewer_onBack} /> | ||||
| 			</div> | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	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 titleBarDate = <span style={styles.titleDate}>{time.formatMsToLocal(formNote.user_updated_time)}</span>; | ||||
|  | ||||
| 	function renderSearchBar() { | ||||
| 		if (!showLocalSearch) return false; | ||||
|  | ||||
| 		const theme = themeStyle(props.theme); | ||||
|  | ||||
| 		return ( | ||||
| 			<NoteSearchBar | ||||
| 				ref={noteSearchBarRef} | ||||
| 				style={{ | ||||
| 					display: 'flex', | ||||
| 					height: 35, | ||||
| 					borderTop: `1px solid ${theme.dividerColor}`, | ||||
| 				}} | ||||
| 				query={localSearch.query} | ||||
| 				searching={localSearch.searching} | ||||
| 				resultCount={localSearch.resultCount} | ||||
| 				selectedIndex={localSearch.selectedIndex} | ||||
| 				onChange={localSearch_change} | ||||
| 				onNext={localSearch_next} | ||||
| 				onPrevious={localSearch_previous} | ||||
| 				onClose={localSearch_close} | ||||
| 			/> | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	if (formNote.encryption_applied || !formNote.id) { | ||||
| 		return renderNoNotes(styles.root); | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<div style={styles.root} onDrop={onDrop}> | ||||
| 			<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}> | ||||
| 				{wysiwygBanner} | ||||
| 				{tagList} | ||||
| 				<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}> | ||||
| 					<input | ||||
| 						type="text" | ||||
| 						ref={titleInputRef} | ||||
| 						// disabled={waitingToSaveNote} | ||||
| 						placeholder={props.isProvisional ? _('Creating new %s...', formNote.is_todo ? _('to-do') : _('note')) : ''} | ||||
| 						style={styles.titleInput} | ||||
| 						onChange={onTitleChange} | ||||
| 						onKeyDown={onTitleKeydown} | ||||
| 						value={formNote.title} | ||||
| 					/> | ||||
| 					{titleBarDate} | ||||
| 				</div> | ||||
| 				<div style={{ display: 'flex', flex: 1 }}> | ||||
| 					{editor} | ||||
| 				</div> | ||||
| 				<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}> | ||||
| 					{renderSearchBar()} | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	); | ||||
| } | ||||
|  | ||||
| export { | ||||
| 	NoteEditor as NoteEditorComponent, | ||||
| }; | ||||
|  | ||||
| const mapStateToProps = (state: any) => { | ||||
| 	const noteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null; | ||||
|  | ||||
| 	return { | ||||
| 		noteId: noteId, | ||||
| 		notes: state.notes, | ||||
| 		folders: state.folders, | ||||
| 		selectedNoteIds: state.selectedNoteIds, | ||||
| 		isProvisional: state.provisionalNoteIds.includes(noteId), | ||||
| 		editorNoteStatuses: state.editorNoteStatuses, | ||||
| 		syncStarted: state.syncStarted, | ||||
| 		theme: state.settings.theme, | ||||
| 		watchedNoteFiles: state.watchedNoteFiles, | ||||
| 		windowCommand: state.windowCommand, | ||||
| 		notesParentType: state.notesParentType, | ||||
| 		historyNotes: state.historyNotes, | ||||
| 		selectedNoteTags: state.selectedNoteTags, | ||||
| 		lastEditorScrollPercents: state.lastEditorScrollPercents, | ||||
| 		selectedNoteHash: state.selectedNoteHash, | ||||
| 		searches: state.searches, | ||||
| 		selectedSearchId: state.selectedSearchId, | ||||
| 		customCss: state.customCss, | ||||
| 		noteVisiblePanes: state.noteVisiblePanes, | ||||
| 	}; | ||||
| }; | ||||
|  | ||||
| export default connect(mapStateToProps)(NoteEditor); | ||||
							
								
								
									
										53
									
								
								ElectronClient/gui/NoteEditor/styles/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								ElectronClient/gui/NoteEditor/styles/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | ||||
| import { NoteTextProps } from '../utils/types'; | ||||
|  | ||||
| const { buildStyle } = require('../../../theme.js'); | ||||
|  | ||||
| export default function styles(props: NoteTextProps) { | ||||
| 	return buildStyle('NoteEditor', props.theme, (theme: any) => { | ||||
| 		return { | ||||
| 			root: { | ||||
| 				...props.style, | ||||
| 				boxSizing: 'border-box', | ||||
| 				paddingLeft: 10, | ||||
| 				paddingTop: 10, | ||||
| 				borderLeftWidth: 1, | ||||
| 				borderLeftColor: theme.dividerColor, | ||||
| 				borderLeftStyle: 'solid', | ||||
| 			}, | ||||
| 			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%', | ||||
| 			}, | ||||
| 			toolbar: { | ||||
| 				marginTop: 4, | ||||
| 				marginBottom: 0, | ||||
| 			}, | ||||
| 			titleDate: { | ||||
| 				...theme.textStyle, | ||||
| 				color: theme.colorFaded, | ||||
| 				paddingLeft: 10, | ||||
| 				paddingRight: 10, | ||||
| 			}, | ||||
| 		}; | ||||
| 	}); | ||||
| } | ||||
							
								
								
									
										28
									
								
								ElectronClient/gui/NoteEditor/utils/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								ElectronClient/gui/NoteEditor/utils/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| import { FormNote } from './types'; | ||||
|  | ||||
| const HtmlToMd = require('lib/HtmlToMd'); | ||||
| const Note = require('lib/models/Note'); | ||||
| const { MarkupToHtml } = require('lib/joplin-renderer'); | ||||
|  | ||||
| export async function htmlToMarkdown(markupLanguage: number, html: string, originalCss:string): Promise<string> { | ||||
| 	let newBody = ''; | ||||
|  | ||||
| 	if (markupLanguage === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN) { | ||||
| 		const htmlToMd = new HtmlToMd(); | ||||
| 		newBody = htmlToMd.parse(html, { preserveImageTagsWithSize: true }); | ||||
| 		newBody = await Note.replaceResourceExternalToInternalLinks(newBody, { useAbsolutePaths: true }); | ||||
| 	} else { | ||||
| 		newBody = await Note.replaceResourceExternalToInternalLinks(html, { useAbsolutePaths: true }); | ||||
| 		if (originalCss) newBody = `<style>${originalCss}</style>\n${newBody}`; | ||||
| 	} | ||||
|  | ||||
| 	return newBody; | ||||
| } | ||||
|  | ||||
| export async function formNoteToNote(formNote: FormNote): Promise<any> { | ||||
| 	return { | ||||
| 		id: formNote.id, | ||||
| 		title: formNote.title, | ||||
| 		body: formNote.body, | ||||
| 	}; | ||||
| } | ||||
							
								
								
									
										122
									
								
								ElectronClient/gui/NoteEditor/utils/resourceHandling.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								ElectronClient/gui/NoteEditor/utils/resourceHandling.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,122 @@ | ||||
| const Setting = require('lib/models/Setting'); | ||||
| const Note = require('lib/models/Note.js'); | ||||
| const BaseModel = require('lib/BaseModel.js'); | ||||
| const Resource = require('lib/models/Resource.js'); | ||||
| const { shim } = require('lib/shim'); | ||||
| const { bridge } = require('electron').remote.require('./bridge'); | ||||
| const ResourceFetcher = require('lib/services/ResourceFetcher.js'); | ||||
| const { reg } = require('lib/registry.js'); | ||||
| const joplinRendererUtils = require('lib/joplin-renderer').utils; | ||||
|  | ||||
| export async function handleResourceDownloadMode(noteBody: string) { | ||||
| 	if (noteBody && Setting.value('sync.resourceDownloadMode') === 'auto') { | ||||
| 		const resourceIds = await Note.linkedResourceIds(noteBody); | ||||
| 		await ResourceFetcher.instance().markForDownload(resourceIds); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| let resourceCache_: any = {}; | ||||
|  | ||||
| export function clearResourceCache() { | ||||
| 	resourceCache_ = {}; | ||||
| } | ||||
|  | ||||
| export async function attachedResources(noteBody: string): Promise<any> { | ||||
| 	if (!noteBody) return {}; | ||||
| 	const resourceIds = await Note.linkedItemIdsByType(BaseModel.TYPE_RESOURCE, noteBody); | ||||
|  | ||||
| 	const output: any = {}; | ||||
| 	for (let i = 0; i < resourceIds.length; i++) { | ||||
| 		const id = resourceIds[i]; | ||||
|  | ||||
| 		if (resourceCache_[id]) { | ||||
| 			output[id] = resourceCache_[id]; | ||||
| 		} else { | ||||
| 			const resource = await Resource.load(id); | ||||
| 			const localState = await Resource.localState(resource); | ||||
|  | ||||
| 			const o = { | ||||
| 				item: resource, | ||||
| 				localState: localState, | ||||
| 			}; | ||||
|  | ||||
| 			// eslint-disable-next-line require-atomic-updates | ||||
| 			resourceCache_[id] = o; | ||||
| 			output[id] = o; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return output; | ||||
| } | ||||
|  | ||||
| export 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; | ||||
| } | ||||
|  | ||||
| export async function commandAttachFileToBody(body:string, filePaths:string[] = null, options:any = null) { | ||||
| 	options = { | ||||
| 		createFileURL: false, | ||||
| 		position: 0, | ||||
| 		...options, | ||||
| 	}; | ||||
|  | ||||
| 	if (!filePaths) { | ||||
| 		filePaths = bridge().showOpenDialog({ | ||||
| 			properties: ['openFile', 'createDirectory', 'multiSelections'], | ||||
| 		}); | ||||
| 		if (!filePaths || !filePaths.length) return null; | ||||
| 	} | ||||
|  | ||||
| 	for (let i = 0; i < filePaths.length; i++) { | ||||
| 		const filePath = filePaths[i]; | ||||
| 		try { | ||||
| 			reg.logger().info(`Attaching ${filePath}`); | ||||
| 			const newBody = await shim.attachFileToNoteBody(body, filePath, options.position, { | ||||
| 				createFileURL: options.createFileURL, | ||||
| 				resizeLargeImages: 'ask', | ||||
| 			}); | ||||
|  | ||||
| 			if (!newBody) { | ||||
| 				reg.logger().info('File attachment was cancelled'); | ||||
| 				return null; | ||||
| 			} | ||||
|  | ||||
| 			body = newBody; | ||||
| 			reg.logger().info('File was attached.'); | ||||
| 		} catch (error) { | ||||
| 			reg.logger().error(error); | ||||
| 			bridge().showErrorMessageBox(error.message); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return body; | ||||
| } | ||||
|  | ||||
| export function resourcesStatus(resourceInfos: any) { | ||||
| 	let lowestIndex = joplinRendererUtils.resourceStatusIndex('ready'); | ||||
| 	for (const id in resourceInfos) { | ||||
| 		const s = joplinRendererUtils.resourceStatus(Resource, resourceInfos[id]); | ||||
| 		const idx = joplinRendererUtils.resourceStatusIndex(s); | ||||
| 		if (idx < lowestIndex) lowestIndex = idx; | ||||
| 	} | ||||
| 	return joplinRendererUtils.resourceStatusName(lowestIndex); | ||||
| } | ||||
							
								
								
									
										145
									
								
								ElectronClient/gui/NoteEditor/utils/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								ElectronClient/gui/NoteEditor/utils/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,145 @@ | ||||
| // eslint-disable-next-line no-unused-vars | ||||
| import AsyncActionQueue from '../../../lib/AsyncActionQueue'; | ||||
|  | ||||
| export interface NoteTextProps { | ||||
| 	style: any; | ||||
| 	noteId: string; | ||||
| 	theme: number; | ||||
| 	dispatch: Function; | ||||
| 	selectedNoteIds: string[]; | ||||
| 	notes: any[]; | ||||
| 	watchedNoteFiles: string[]; | ||||
| 	isProvisional: boolean; | ||||
| 	editorNoteStatuses: any; | ||||
| 	syncStarted: boolean; | ||||
| 	bodyEditor: string; | ||||
| 	windowCommand: any; | ||||
| 	folders: any[]; | ||||
| 	notesParentType: string; | ||||
| 	historyNotes: any[]; | ||||
| 	selectedNoteTags: any[]; | ||||
| 	lastEditorScrollPercents: any; | ||||
| 	selectedNoteHash: string; | ||||
| 	searches: any[], | ||||
| 	selectedSearchId: string, | ||||
| 	customCss: string, | ||||
| 	noteVisiblePanes: string[], | ||||
| } | ||||
|  | ||||
| export interface NoteBodyEditorProps { | ||||
| 	style: any; | ||||
| 	ref: any, | ||||
| 	theme: number; | ||||
| 	content: string, | ||||
| 	contentKey: string, | ||||
| 	contentMarkupLanguage: number, | ||||
| 	contentOriginalCss: string, | ||||
| 	onChange(event: OnChangeEvent): void; | ||||
| 	onWillChange(event: any): void; | ||||
| 	onMessage(event: any): void; | ||||
| 	onScroll(event: any): void; | ||||
| 	markupToHtml: Function; | ||||
| 	htmlToMarkdown: Function; | ||||
| 	allAssets: Function; | ||||
| 	attachResources: Function; | ||||
| 	disabled: boolean; | ||||
| 	dispatch: Function; | ||||
| 	noteToolbar: any; | ||||
| 	searchMarkers: any, | ||||
| 	visiblePanes: string[], | ||||
| 	keyboardMode: string, | ||||
| 	resourceInfos: ResourceInfos, | ||||
| } | ||||
|  | ||||
| export interface FormNote { | ||||
| 	id: string, | ||||
| 	title: string, | ||||
| 	body: string, | ||||
| 	parent_id: string, | ||||
| 	is_todo: number, | ||||
| 	bodyEditorContent?: any, | ||||
| 	markup_language: number, | ||||
| 	user_updated_time: number, | ||||
| 	encryption_applied: 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, | ||||
| } | ||||
|  | ||||
| export function defaultFormNote():FormNote { | ||||
| 	return { | ||||
| 		id: '', | ||||
| 		parent_id: '', | ||||
| 		title: '', | ||||
| 		body: '', | ||||
| 		is_todo: 0, | ||||
| 		markup_language: 1, | ||||
| 		bodyWillChangeId: 0, | ||||
| 		bodyChangeId: 0, | ||||
| 		saveActionQueue: null, | ||||
| 		originalCss: '', | ||||
| 		hasChanged: false, | ||||
| 		user_updated_time: 0, | ||||
| 		encryption_applied: 0, | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| export interface ResourceInfo { | ||||
| 	localState: any, | ||||
| 	item: any, | ||||
| } | ||||
|  | ||||
| export interface ResourceInfos { | ||||
| 	[index:string]: ResourceInfo, | ||||
| } | ||||
|  | ||||
| export enum ScrollOptionTypes { | ||||
| 	None = 0, | ||||
| 	Hash = 1, | ||||
| 	Percent = 2, | ||||
| } | ||||
|  | ||||
| export interface ScrollOptions { | ||||
| 	type: ScrollOptionTypes, | ||||
| 	value: any, | ||||
| } | ||||
|  | ||||
| export interface OnChangeEvent { | ||||
| 	changeId: number; | ||||
| 	content: any; | ||||
| } | ||||
|  | ||||
| export interface EditorCommand { | ||||
| 	name: string; | ||||
| 	value: any; | ||||
| } | ||||
							
								
								
									
										53
									
								
								ElectronClient/gui/NoteEditor/utils/useDropHandler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								ElectronClient/gui/NoteEditor/utils/useDropHandler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | ||||
| import { useCallback } from 'react'; | ||||
| const Note = require('lib/models/Note.js'); | ||||
|  | ||||
| interface HookDependencies { | ||||
| 	editorRef:any, | ||||
| } | ||||
|  | ||||
| export default function useDropHandler(dependencies:HookDependencies) { | ||||
| 	const { editorRef } = dependencies; | ||||
|  | ||||
| 	return useCallback(async (event:any) => { | ||||
| 		const dt = event.dataTransfer; | ||||
| 		const createFileURL = event.altKey; | ||||
|  | ||||
| 		if (dt.types.indexOf('text/x-jop-note-ids') >= 0) { | ||||
| 			const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids')); | ||||
| 			const noteMarkdownTags = []; | ||||
| 			for (let i = 0; i < noteIds.length; i++) { | ||||
| 				const note = await Note.load(noteIds[i]); | ||||
| 				noteMarkdownTags.push(Note.markdownTag(note)); | ||||
| 			} | ||||
|  | ||||
| 			editorRef.current.execCommand({ | ||||
| 				name: 'dropItems', | ||||
| 				value: { | ||||
| 					type: 'notes', | ||||
| 					markdownTags: noteMarkdownTags, | ||||
| 				}, | ||||
| 			}); | ||||
|  | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		const files = dt.files; | ||||
| 		if (files && files.length) { | ||||
| 			const paths = []; | ||||
| 			for (let i = 0; i < files.length; i++) { | ||||
| 				const file = files[i]; | ||||
| 				if (!file.path) continue; | ||||
| 				paths.push(file.path); | ||||
| 			} | ||||
|  | ||||
| 			editorRef.current.execCommand({ | ||||
| 				name: 'dropItems', | ||||
| 				value: { | ||||
| 					type: 'files', | ||||
| 					paths: paths, | ||||
| 					createFileURL: createFileURL, | ||||
| 				}, | ||||
| 			}); | ||||
| 		} | ||||
| 	}, []); | ||||
| } | ||||
							
								
								
									
										208
									
								
								ElectronClient/gui/NoteEditor/utils/useFormNote.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										208
									
								
								ElectronClient/gui/NoteEditor/utils/useFormNote.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,208 @@ | ||||
| import { useState, useEffect, useCallback } from 'react'; | ||||
| import { FormNote, defaultFormNote, ResourceInfos } from './types'; | ||||
| import { clearResourceCache, attachedResources } from './resourceHandling'; | ||||
| const { MarkupToHtml } = require('lib/joplin-renderer'); | ||||
| const HtmlToHtml = require('lib/joplin-renderer/HtmlToHtml'); | ||||
| import AsyncActionQueue from '../../../lib/AsyncActionQueue'; | ||||
| import { handleResourceDownloadMode } from './resourceHandling'; | ||||
| const usePrevious = require('lib/hooks/usePrevious').default; | ||||
| const Note = require('lib/models/Note'); | ||||
| const Setting = require('lib/models/Setting'); | ||||
| const { reg } = require('lib/registry.js'); | ||||
| const ResourceFetcher = require('lib/services/ResourceFetcher.js'); | ||||
| const DecryptionWorker = require('lib/services/DecryptionWorker.js'); | ||||
|  | ||||
| export interface OnLoadEvent { | ||||
| 	formNote: FormNote, | ||||
| } | ||||
|  | ||||
| interface HookDependencies { | ||||
| 	syncStarted: boolean, | ||||
| 	noteId: string, | ||||
| 	isProvisional: boolean, | ||||
| 	titleInputRef: any, | ||||
| 	editorRef: any, | ||||
| 	onBeforeLoad(event:OnLoadEvent):void, | ||||
| 	onAfterLoad(event:OnLoadEvent):void, | ||||
| } | ||||
|  | ||||
| function installResourceChangeHandler(onResourceChangeHandler: Function) { | ||||
| 	ResourceFetcher.instance().on('downloadComplete', onResourceChangeHandler); | ||||
| 	ResourceFetcher.instance().on('downloadStarted', onResourceChangeHandler); | ||||
| 	DecryptionWorker.instance().on('resourceDecrypted', onResourceChangeHandler); | ||||
| } | ||||
|  | ||||
| function uninstallResourceChangeHandler(onResourceChangeHandler: Function) { | ||||
| 	ResourceFetcher.instance().off('downloadComplete', onResourceChangeHandler); | ||||
| 	ResourceFetcher.instance().off('downloadStarted', onResourceChangeHandler); | ||||
| 	DecryptionWorker.instance().off('resourceDecrypted', onResourceChangeHandler); | ||||
| } | ||||
|  | ||||
| export default function useFormNote(dependencies:HookDependencies) { | ||||
| 	const { syncStarted, noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad } = dependencies; | ||||
|  | ||||
| 	const [formNote, setFormNote] = useState<FormNote>(defaultFormNote()); | ||||
| 	const [isNewNote, setIsNewNote] = useState(false); | ||||
| 	const prevSyncStarted = usePrevious(syncStarted); | ||||
| 	const previousNoteId = usePrevious(formNote.id); | ||||
| 	const [resourceInfos, setResourceInfos] = useState<ResourceInfos>({}); | ||||
|  | ||||
| 	async function initNoteState(n: any) { | ||||
| 		let originalCss = ''; | ||||
|  | ||||
| 		if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) { | ||||
| 			const htmlToHtml = new HtmlToHtml(); | ||||
| 			const splitted = htmlToHtml.splitHtml(n.body); | ||||
| 			originalCss = splitted.css; | ||||
| 		} | ||||
|  | ||||
| 		const newFormNote = { | ||||
| 			id: n.id, | ||||
| 			title: n.title, | ||||
| 			body: n.body, | ||||
| 			is_todo: n.is_todo, | ||||
| 			parent_id: n.parent_id, | ||||
| 			bodyWillChangeId: 0, | ||||
| 			bodyChangeId: 0, | ||||
| 			markup_language: n.markup_language, | ||||
| 			saveActionQueue: new AsyncActionQueue(300), | ||||
| 			originalCss: originalCss, | ||||
| 			hasChanged: false, | ||||
| 			user_updated_time: n.user_updated_time, | ||||
| 			encryption_applied: n.encryption_applied, | ||||
| 		}; | ||||
|  | ||||
| 		// Note that for performance reason,the call to setResourceInfos should | ||||
| 		// be first because it loads the resource infos in an async way. If we | ||||
| 		// swap them, the formNote will be updated first and rendered, then the | ||||
| 		// the resources will load, and the note will be re-rendered. | ||||
| 		setResourceInfos(await attachedResources(n.body)); | ||||
| 		setFormNote(newFormNote); | ||||
|  | ||||
| 		await handleResourceDownloadMode(n.body); | ||||
|  | ||||
| 		return newFormNote; | ||||
| 	} | ||||
|  | ||||
| 	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 (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(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:', noteId); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			await initNoteState(n); | ||||
| 		}; | ||||
|  | ||||
| 		loadNote(); | ||||
|  | ||||
| 		return () => { | ||||
| 			cancelled = true; | ||||
| 		}; | ||||
| 	}, [prevSyncStarted, syncStarted, formNote]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (!noteId) return () => {}; | ||||
|  | ||||
| 		if (formNote.id === noteId) return () => {}; | ||||
|  | ||||
| 		let cancelled = false; | ||||
|  | ||||
| 		reg.logger().debug('Loading existing note', noteId); | ||||
|  | ||||
| 		function handleAutoFocus(noteIsTodo: boolean) { | ||||
| 			if (!isProvisional) return; | ||||
|  | ||||
| 			const focusSettingName = noteIsTodo ? 'newTodoFocus' : 'newNoteFocus'; | ||||
|  | ||||
| 			requestAnimationFrame(() => { | ||||
| 				if (Setting.value(focusSettingName) === 'title') { | ||||
| 					if (titleInputRef.current) titleInputRef.current.focus(); | ||||
| 				} else { | ||||
| 					if (editorRef.current) editorRef.current.execCommand({ name: 'focus' }); | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		async function loadNote() { | ||||
| 			const n = await Note.load(noteId); | ||||
| 			if (cancelled) return; | ||||
| 			if (!n) throw new Error(`Cannot find note with ID: ${noteId}`); | ||||
| 			reg.logger().debug('Loaded note:', n); | ||||
|  | ||||
| 			await onBeforeLoad({ formNote }); | ||||
|  | ||||
| 			const newFormNote = await initNoteState(n); | ||||
|  | ||||
| 			setIsNewNote(isProvisional); | ||||
|  | ||||
| 			await onAfterLoad({ formNote: newFormNote }); | ||||
|  | ||||
| 			handleAutoFocus(!!n.is_todo); | ||||
| 		} | ||||
|  | ||||
| 		loadNote(); | ||||
|  | ||||
| 		return () => { | ||||
| 			cancelled = true; | ||||
| 		}; | ||||
| 	}, [noteId, isProvisional, formNote]); | ||||
|  | ||||
| 	const onResourceChange = useCallback(async function(event:any = null) { | ||||
| 		const resourceIds = await Note.linkedResourceIds(formNote.body); | ||||
| 		if (!event || resourceIds.indexOf(event.id) >= 0) { | ||||
| 			clearResourceCache(); | ||||
| 			setResourceInfos(await attachedResources(formNote.body)); | ||||
| 		} | ||||
| 	}, [formNote.body]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		installResourceChangeHandler(onResourceChange); | ||||
| 		return () => { | ||||
| 			uninstallResourceChangeHandler(onResourceChange); | ||||
| 		}; | ||||
| 	}, [onResourceChange]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (previousNoteId !== formNote.id) { | ||||
| 			onResourceChange(); | ||||
| 		} | ||||
| 	}, [previousNoteId, formNote.id, onResourceChange]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		let cancelled = false; | ||||
|  | ||||
| 		async function runEffect() { | ||||
| 			const r = await attachedResources(formNote.body); | ||||
| 			if (cancelled) return; | ||||
| 			setResourceInfos(r); | ||||
| 		} | ||||
|  | ||||
| 		runEffect(); | ||||
|  | ||||
| 		return () => { | ||||
| 			cancelled = true; | ||||
| 		}; | ||||
| 	}, [formNote.body]); | ||||
|  | ||||
| 	return { isNewNote, formNote, setFormNote, resourceInfos }; | ||||
| } | ||||
							
								
								
									
										56
									
								
								ElectronClient/gui/NoteEditor/utils/useMarkupToHtml.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								ElectronClient/gui/NoteEditor/utils/useMarkupToHtml.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | ||||
| import { useCallback } from 'react'; | ||||
| import { ResourceInfos } from './types'; | ||||
| const { themeStyle } = require('../../../theme.js'); | ||||
| const Note = require('lib/models/Note'); | ||||
| const Setting = require('lib/models/Setting'); | ||||
| const markupLanguageUtils = require('lib/markupLanguageUtils'); | ||||
|  | ||||
| interface HookDependencies { | ||||
| 	themeId: number, | ||||
| 	customCss: string, | ||||
| } | ||||
|  | ||||
| interface MarkupToHtmlOptions { | ||||
| 	replaceResourceInternalToExternalLinks?: boolean, | ||||
| 	resourceInfos?: ResourceInfos, | ||||
| } | ||||
|  | ||||
| export default function useMarkupToHtml(dependencies:HookDependencies) { | ||||
| 	const { themeId, customCss } = dependencies; | ||||
|  | ||||
| 	return useCallback(async (markupLanguage: number, md: string, options: MarkupToHtmlOptions = null): Promise<any> => { | ||||
| 		options = { | ||||
| 			replaceResourceInternalToExternalLinks: false, | ||||
| 			resourceInfos: {}, | ||||
| 			...options, | ||||
| 		}; | ||||
|  | ||||
| 		md = md || ''; | ||||
|  | ||||
| 		const theme = themeStyle(themeId); | ||||
| 		let resources = {}; | ||||
|  | ||||
| 		if (options.replaceResourceInternalToExternalLinks) { | ||||
| 			md = await Note.replaceResourceInternalToExternalLinks(md, { useAbsolutePaths: true }); | ||||
| 		} else { | ||||
| 			resources = options.resourceInfos; | ||||
| 		} | ||||
|  | ||||
| 		delete options.replaceResourceInternalToExternalLinks; | ||||
|  | ||||
| 		const markupToHtml = markupLanguageUtils.newMarkupToHtml({ | ||||
| 			resourceBaseUrl: `file://${Setting.value('resourceDir')}/`, | ||||
| 		}); | ||||
|  | ||||
| 		const result = await markupToHtml.render(markupLanguage, md, theme, Object.assign({}, { | ||||
| 			codeTheme: theme.codeThemeCss, | ||||
| 			userCss: customCss || '', | ||||
| 			resources: resources, | ||||
| 			postMessageSyntax: 'ipcProxySendToHost', | ||||
| 			splitted: true, | ||||
| 			externalAssetsOnly: true, | ||||
| 		}, options)); | ||||
|  | ||||
| 		return result; | ||||
| 	}, [themeId, customCss]); | ||||
| } | ||||
							
								
								
									
										152
									
								
								ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,152 @@ | ||||
| import { useCallback } from 'react'; | ||||
|  | ||||
| const BaseItem = require('lib/models/BaseItem'); | ||||
| const { _ } = require('lib/locale'); | ||||
| const BaseModel = require('lib/BaseModel.js'); | ||||
| const Resource = require('lib/models/Resource.js'); | ||||
| const { bridge } = require('electron').remote.require('./bridge'); | ||||
| const { urlDecode } = require('lib/string-utils'); | ||||
| const urlUtils = require('lib/urlUtils'); | ||||
| const ResourceFetcher = require('lib/services/ResourceFetcher.js'); | ||||
| const Menu = bridge().Menu; | ||||
| const MenuItem = bridge().MenuItem; | ||||
| const fs = require('fs-extra'); | ||||
| const { clipboard } = require('electron'); | ||||
| const { toSystemSlashes } = require('lib/path-utils'); | ||||
| const { reg } = require('lib/registry.js'); | ||||
|  | ||||
| export default function useMessageHandler(scrollWhenReady:any, setScrollWhenReady:Function, editorRef:any, setLocalSearchResultCount:Function, dispatch:Function) { | ||||
| 	return useCallback(async (event: any) => { | ||||
| 		const msg = event.channel ? event.channel : ''; | ||||
| 		const args = event.args; | ||||
| 		const arg0 = args && args.length >= 1 ? args[0] : null; | ||||
|  | ||||
| 		if (msg !== 'percentScroll') console.info(`Got ipc-message: ${msg}`, args); | ||||
|  | ||||
| 		if (msg.indexOf('error:') === 0) { | ||||
| 			const s = msg.split(':'); | ||||
| 			s.splice(0, 1); | ||||
| 			reg.logger().error(s.join(':')); | ||||
| 		} else if (msg === 'noteRenderComplete') { | ||||
| 			if (scrollWhenReady) { | ||||
| 				const options = { ...scrollWhenReady }; | ||||
| 				setScrollWhenReady(null); | ||||
| 				editorRef.current.scrollTo(options); | ||||
| 			} | ||||
| 		} else if (msg === 'setMarkerCount') { | ||||
| 			setLocalSearchResultCount(arg0); | ||||
| 		} else if (msg.indexOf('markForDownload:') === 0) { | ||||
| 			const s = msg.split(':'); | ||||
| 			if (s.length < 2) throw new Error(`Invalid message: ${msg}`); | ||||
| 			ResourceFetcher.instance().markForDownload(s[1]); | ||||
| 		} else if (msg === 'contextMenu') { | ||||
| 			const itemType = arg0 && arg0.type; | ||||
|  | ||||
| 			const menu = new Menu(); | ||||
|  | ||||
| 			if (itemType === 'image' || itemType === 'resource') { | ||||
| 				const resource = await Resource.load(arg0.resourceId); | ||||
| 				const resourcePath = Resource.fullPath(resource); | ||||
|  | ||||
| 				menu.append( | ||||
| 					new MenuItem({ | ||||
| 						label: _('Open...'), | ||||
| 						click: async () => { | ||||
| 							const ok = bridge().openExternal(`file://${resourcePath}`); | ||||
| 							if (!ok) bridge().showErrorMessageBox(_('This file could not be opened: %s', resourcePath)); | ||||
| 						}, | ||||
| 					}) | ||||
| 				); | ||||
|  | ||||
| 				menu.append( | ||||
| 					new MenuItem({ | ||||
| 						label: _('Save as...'), | ||||
| 						click: async () => { | ||||
| 							const filePath = bridge().showSaveDialog({ | ||||
| 								defaultPath: resource.filename ? resource.filename : resource.title, | ||||
| 							}); | ||||
| 							if (!filePath) return; | ||||
| 							await fs.copy(resourcePath, filePath); | ||||
| 						}, | ||||
| 					}) | ||||
| 				); | ||||
|  | ||||
| 				menu.append( | ||||
| 					new MenuItem({ | ||||
| 						label: _('Copy path to clipboard'), | ||||
| 						click: async () => { | ||||
| 							clipboard.writeText(toSystemSlashes(resourcePath)); | ||||
| 						}, | ||||
| 					}) | ||||
| 				); | ||||
| 			} else if (itemType === 'text') { | ||||
| 				menu.append( | ||||
| 					new MenuItem({ | ||||
| 						label: _('Copy'), | ||||
| 						click: async () => { | ||||
| 							clipboard.writeText(arg0.textToCopy); | ||||
| 						}, | ||||
| 					}) | ||||
| 				); | ||||
| 			} else if (itemType === 'link') { | ||||
| 				menu.append( | ||||
| 					new MenuItem({ | ||||
| 						label: _('Copy Link Address'), | ||||
| 						click: async () => { | ||||
| 							clipboard.writeText(arg0.textToCopy); | ||||
| 						}, | ||||
| 					}) | ||||
| 				); | ||||
| 			} else { | ||||
| 				reg.logger().error(`Unhandled item type: ${itemType}`); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			menu.popup(bridge().window()); | ||||
| 		} else if (msg.indexOf('joplin://') === 0) { | ||||
| 			const resourceUrlInfo = urlUtils.parseResourceUrl(msg); | ||||
| 			const itemId = resourceUrlInfo.itemId; | ||||
| 			const item = await BaseItem.loadItemById(itemId); | ||||
|  | ||||
| 			if (!item) throw new Error(`No item with ID ${itemId}`); | ||||
|  | ||||
| 			if (item.type_ === BaseModel.TYPE_RESOURCE) { | ||||
| 				const localState = await Resource.localState(item); | ||||
| 				if (localState.fetch_status !== Resource.FETCH_STATUS_DONE || !!item.encryption_blob_encrypted) { | ||||
| 					if (localState.fetch_status === Resource.FETCH_STATUS_ERROR) { | ||||
| 						bridge().showErrorMessageBox(`${_('There was an error downloading this attachment:')}\n\n${localState.fetch_error}`); | ||||
| 					} else { | ||||
| 						bridge().showErrorMessageBox(_('This attachment is not downloaded or not decrypted yet')); | ||||
| 					} | ||||
| 					return; | ||||
| 				} | ||||
| 				const filePath = Resource.fullPath(item); | ||||
| 				bridge().openItem(filePath); | ||||
| 			} else if (item.type_ === BaseModel.TYPE_NOTE) { | ||||
| 				dispatch({ | ||||
| 					type: 'FOLDER_AND_NOTE_SELECT', | ||||
| 					folderId: item.parent_id, | ||||
| 					noteId: item.id, | ||||
| 					hash: resourceUrlInfo.hash, | ||||
| 					// historyNoteAction: { | ||||
| 					// 	id: this.state.note.id, | ||||
| 					// 	parent_id: this.state.note.parent_id, | ||||
| 					// }, | ||||
| 				}); | ||||
| 			} else { | ||||
| 				throw new Error(`Unsupported item type: ${item.type_}`); | ||||
| 			} | ||||
| 		} else if (urlUtils.urlProtocol(msg)) { | ||||
| 			if (msg.indexOf('file://') === 0) { | ||||
| 				// When using the file:// protocol, openExternal doesn't work (does nothing) with URL-encoded paths | ||||
| 				require('electron').shell.openExternal(urlDecode(msg)); | ||||
| 			} else { | ||||
| 				require('electron').shell.openExternal(msg); | ||||
| 			} | ||||
| 		} else if (msg.indexOf('#') === 0) { | ||||
| 			// This is an internal anchor, which is handled by the WebView so skip this case | ||||
| 		} else { | ||||
| 			bridge().showErrorMessageBox(_('Unsupported link or message: %s', msg)); | ||||
| 		} | ||||
| 	}, [dispatch, setLocalSearchResultCount, scrollWhenReady]); | ||||
| } | ||||
							
								
								
									
										92
									
								
								ElectronClient/gui/NoteEditor/utils/useNoteSearchBar.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								ElectronClient/gui/NoteEditor/utils/useNoteSearchBar.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | ||||
| import { useState, useCallback } from 'react'; | ||||
| import { SearchMarkers } from './useSearchMarkers'; | ||||
|  | ||||
| interface LocalSearch { | ||||
| 	query: string, | ||||
| 	selectedIndex: number, | ||||
| 	resultCount: number, | ||||
| 	searching: boolean, | ||||
| 	timestamp: number, | ||||
| } | ||||
|  | ||||
| function defaultLocalSearch():LocalSearch { | ||||
| 	return { | ||||
| 		query: '', | ||||
| 		selectedIndex: 0, | ||||
| 		resultCount: 0, | ||||
| 		searching: false, | ||||
| 		timestamp: 0, | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| export default function useNoteSearchBar() { | ||||
| 	const [showLocalSearch, setShowLocalSearch] = useState(false); | ||||
| 	const [localSearch, setLocalSearch] = useState<LocalSearch>(defaultLocalSearch()); | ||||
|  | ||||
| 	const onChange = useCallback((query:string) => { | ||||
| 		setLocalSearch((prev:LocalSearch) => { | ||||
| 			return { | ||||
| 				query: query, | ||||
| 				selectedIndex: 0, | ||||
| 				timestamp: Date.now(), | ||||
| 				resultCount: prev.resultCount, | ||||
| 				searching: true, | ||||
| 			}; | ||||
| 		}); | ||||
| 	}, []); | ||||
|  | ||||
| 	const noteSearchBarNextPrevious = useCallback((inc:number) => { | ||||
| 		setLocalSearch((prev:LocalSearch) => { | ||||
| 			const ls = Object.assign({}, prev); | ||||
| 			ls.selectedIndex += inc; | ||||
| 			ls.timestamp = Date.now(); | ||||
| 			if (ls.selectedIndex < 0) ls.selectedIndex = ls.resultCount - 1; | ||||
| 			if (ls.selectedIndex >= ls.resultCount) ls.selectedIndex = 0; | ||||
| 			return ls; | ||||
| 		}); | ||||
| 	}, []); | ||||
|  | ||||
| 	const onNext = useCallback(() => { | ||||
| 		noteSearchBarNextPrevious(+1); | ||||
| 	}, [noteSearchBarNextPrevious]); | ||||
|  | ||||
| 	const onPrevious = useCallback(() => { | ||||
| 		noteSearchBarNextPrevious(-1); | ||||
| 	}, [noteSearchBarNextPrevious]); | ||||
|  | ||||
| 	const onClose = useCallback(() => { | ||||
| 		setShowLocalSearch(false); | ||||
| 		setLocalSearch(defaultLocalSearch()); | ||||
| 	}, []); | ||||
|  | ||||
| 	const setResultCount = useCallback((count:number) => { | ||||
| 		setLocalSearch((prev:LocalSearch) => { | ||||
| 			if (prev.resultCount === count && !prev.searching) return prev; | ||||
|  | ||||
| 			return { | ||||
| 				...prev, | ||||
| 				resultCount: count, | ||||
| 				searching: false, | ||||
| 			}; | ||||
| 		}); | ||||
| 	}, []); | ||||
|  | ||||
| 	const searchMarkers = useCallback(():SearchMarkers => { | ||||
| 		return { | ||||
| 			options: { | ||||
| 				selectedIndex: localSearch.selectedIndex, | ||||
| 				separateWordSearch: false, | ||||
| 				searchTimestamp: localSearch.timestamp, | ||||
| 			}, | ||||
| 			keywords: [ | ||||
| 				{ | ||||
| 					type: 'text', | ||||
| 					value: localSearch.query, | ||||
| 					accuracy: 'partially', | ||||
| 				}, | ||||
| 			], | ||||
| 		}; | ||||
| 	}, [localSearch]); | ||||
|  | ||||
| 	return { localSearch, onChange, onNext, onPrevious, onClose, setResultCount, showLocalSearch, setShowLocalSearch, searchMarkers }; | ||||
| } | ||||
							
								
								
									
										48
									
								
								ElectronClient/gui/NoteEditor/utils/useResourceRefresher.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								ElectronClient/gui/NoteEditor/utils/useResourceRefresher.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| 'use strict'; | ||||
| const __awaiter = (this && this.__awaiter) || function(thisArg, _arguments, P, generator) { | ||||
| 	function adopt(value) { return value instanceof P ? value : new P(function(resolve) { resolve(value); }); } | ||||
| 	return new (P || (P = Promise))(function(resolve, reject) { | ||||
| 		function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } | ||||
| 		function rejected(value) { try { step(generator['throw'](value)); } catch (e) { reject(e); } } | ||||
| 		function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } | ||||
| 		step((generator = generator.apply(thisArg, _arguments || [])).next()); | ||||
| 	}); | ||||
| }; | ||||
| Object.defineProperty(exports, '__esModule', { value: true }); | ||||
| const react_1 = require('react'); | ||||
| const resourceHandling_1 = require('./resourceHandling'); | ||||
| const ResourceFetcher = require('lib/services/ResourceFetcher.js'); | ||||
| const DecryptionWorker = require('lib/services/DecryptionWorker.js'); | ||||
| const Note = require('lib/models/Note'); | ||||
| function useResourceInfos(dependencies) { | ||||
| 	const { noteBody } = dependencies; | ||||
| 	const [resourceInfos, setResourceInfos] = react_1.useState({}); | ||||
| 	function installResourceHandling(refreshResourceHandler) { | ||||
| 		ResourceFetcher.instance().on('downloadComplete', refreshResourceHandler); | ||||
| 		ResourceFetcher.instance().on('downloadStarted', refreshResourceHandler); | ||||
| 		DecryptionWorker.instance().on('resourceDecrypted', refreshResourceHandler); | ||||
| 	} | ||||
| 	function uninstallResourceHandling(refreshResourceHandler) { | ||||
| 		ResourceFetcher.instance().off('downloadComplete', refreshResourceHandler); | ||||
| 		ResourceFetcher.instance().off('downloadStarted', refreshResourceHandler); | ||||
| 		DecryptionWorker.instance().off('resourceDecrypted', refreshResourceHandler); | ||||
| 	} | ||||
| 	const refreshResource = react_1.useCallback(function(event) { | ||||
| 		return __awaiter(this, void 0, void 0, function* () { | ||||
| 			const resourceIds = yield Note.linkedResourceIds(noteBody); | ||||
| 			if (resourceIds.indexOf(event.id) >= 0) { | ||||
| 				resourceHandling_1.clearResourceCache(); | ||||
| 				setResourceInfos(yield resourceHandling_1.attachedResources(noteBody)); | ||||
| 			} | ||||
| 		}); | ||||
| 	}, [noteBody]); | ||||
| 	react_1.useEffect(() => { | ||||
| 		installResourceHandling(refreshResource); | ||||
| 		return () => { | ||||
| 			uninstallResourceHandling(refreshResource); | ||||
| 		}; | ||||
| 	}, [refreshResource]); | ||||
| 	return { resourceInfos }; | ||||
| } | ||||
| exports.default = useResourceInfos; | ||||
| // # sourceMappingURL=useResourceRefresher.js.map | ||||
							
								
								
									
										42
									
								
								ElectronClient/gui/NoteEditor/utils/useSearchMarkers.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								ElectronClient/gui/NoteEditor/utils/useSearchMarkers.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| import { useMemo } from 'react'; | ||||
|  | ||||
| const BaseModel = require('lib/BaseModel.js'); | ||||
| const SearchEngine = require('lib/services/SearchEngine'); | ||||
|  | ||||
| interface SearchMarkersOptions { | ||||
| 	searchTimestamp: number, | ||||
| 	selectedIndex: number, | ||||
| 	separateWordSearch: boolean, | ||||
| } | ||||
|  | ||||
| export interface SearchMarkers { | ||||
| 	keywords: any[], | ||||
| 	options: SearchMarkersOptions, | ||||
| } | ||||
|  | ||||
| function defaultSearchMarkers():SearchMarkers { | ||||
| 	return { | ||||
| 		keywords: [], | ||||
| 		options: { | ||||
| 			searchTimestamp: 0, | ||||
| 			selectedIndex: 0, | ||||
| 			separateWordSearch: false, | ||||
| 		}, | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| export default function useSearchMarkers(showLocalSearch:boolean, localSearchMarkerOptions:Function, searches:any[], selectedSearchId:string) { | ||||
| 	return useMemo(():SearchMarkers => { | ||||
| 		if (showLocalSearch) return localSearchMarkerOptions(); | ||||
|  | ||||
| 		const output = defaultSearchMarkers(); | ||||
|  | ||||
| 		const search = BaseModel.byId(searches, selectedSearchId); | ||||
| 		if (search) { | ||||
| 			const parsedQuery = SearchEngine.instance().parseQuery(search.query_pattern); | ||||
| 			output.keywords = SearchEngine.instance().allParsedQueryTerms(parsedQuery); | ||||
| 		} | ||||
|  | ||||
| 		return output; | ||||
| 	}, [showLocalSearch, localSearchMarkerOptions, searches, selectedSearchId]); | ||||
| } | ||||
							
								
								
									
										104
									
								
								ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,104 @@ | ||||
| import { useEffect } from 'react'; | ||||
| import { FormNote, EditorCommand } from './types'; | ||||
| const { time } = require('lib/time-utils.js'); | ||||
| const { reg } = require('lib/registry.js'); | ||||
| const NoteListUtils = require('../../utils/NoteListUtils'); | ||||
|  | ||||
| interface HookDependencies { | ||||
| 	windowCommand: any, | ||||
| 	formNote:FormNote, | ||||
| 	setShowLocalSearch:Function, | ||||
| 	dispatch:Function, | ||||
| 	noteSearchBarRef:any, | ||||
| 	editorRef:any, | ||||
| 	titleInputRef:any, | ||||
| 	saveNoteAndWait: Function, | ||||
| } | ||||
|  | ||||
| export default function useWindowCommandHandler(dependencies:HookDependencies) { | ||||
| 	const { windowCommand, dispatch, formNote, setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, saveNoteAndWait } = dependencies; | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		async function processCommand() { | ||||
| 			const command = windowCommand; | ||||
|  | ||||
| 			if (!command || !formNote) return; | ||||
|  | ||||
| 			reg.logger().debug('NoteEditor::useWindowCommandHandler:', command); | ||||
|  | ||||
| 			const editorCmd: EditorCommand = { name: '', value: command.value }; | ||||
| 			let fn: Function = null; | ||||
|  | ||||
| 			// These commands can be forwarded directly to the note body editor | ||||
| 			// without transformation. | ||||
| 			const directMapCommands = [ | ||||
| 				'textCode', | ||||
| 				'textBold', | ||||
| 				'textItalic', | ||||
| 				'textLink', | ||||
| 				'attachFile', | ||||
| 				'textNumberedList', | ||||
| 				'textBulletedList', | ||||
| 				'textCheckbox', | ||||
| 				'textHeading', | ||||
| 				'textHorizontalRule', | ||||
| 			]; | ||||
|  | ||||
| 			if (directMapCommands.includes(command.name)) { | ||||
| 				editorCmd.name = command.name; | ||||
| 			} else if (command.name === 'commandStartExternalEditing') { | ||||
| 				fn = async () => { | ||||
| 					await saveNoteAndWait(formNote); | ||||
| 					NoteListUtils.startExternalEditing(formNote.id); | ||||
| 				}; | ||||
| 			} else if (command.name === 'commandStopExternalEditing') { | ||||
| 				fn = () => { | ||||
| 					NoteListUtils.stopExternalEditing(formNote.id); | ||||
| 				}; | ||||
| 			} else if (command.name === 'insertDateTime') { | ||||
| 				editorCmd.name = 'insertText', | ||||
| 				editorCmd.value = time.formatMsToLocal(new Date().getTime()); | ||||
| 			} else if (command.name === 'showLocalSearch') { | ||||
| 				setShowLocalSearch(true); | ||||
| 				if (noteSearchBarRef.current) noteSearchBarRef.current.wrappedInstance.focus(); | ||||
| 			} else if (command.name === 'insertTemplate') { | ||||
| 				editorCmd.name = 'insertText', | ||||
| 				editorCmd.value = time.formatMsToLocal(new Date().getTime()); | ||||
| 			} | ||||
|  | ||||
| 			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'; | ||||
| 			} | ||||
|  | ||||
| 			reg.logger().debug('NoteEditor::useWindowCommandHandler: Dispatch:', editorCmd, fn); | ||||
|  | ||||
| 			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); | ||||
| 					} | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		processCommand(); | ||||
| 	}, [windowCommand, dispatch, formNote, saveNoteAndWait]); | ||||
| } | ||||
| @@ -145,7 +145,7 @@ class NoteListComponent extends React.Component { | ||||
| 				todo_completed: checked ? time.unixMs() : 0, | ||||
| 			}; | ||||
| 			await Note.save(newNote, { userSideValidation: true }); | ||||
| 			eventManager.emit('todoToggle', { noteId: item.id }); | ||||
| 			eventManager.emit('todoToggle', { noteId: item.id, note: newNote }); | ||||
| 		}; | ||||
|  | ||||
| 		const hPadding = 10; | ||||
|   | ||||
| @@ -10,6 +10,7 @@ const InteropServiceHelper = require('../InteropServiceHelper.js'); | ||||
| const { IconButton } = require('./IconButton.min.js'); | ||||
| const { urlDecode, substrWithEllipsis } = require('lib/string-utils'); | ||||
| const Toolbar = require('./Toolbar.min.js'); | ||||
| const NoteToolbar = require('./NoteToolbar/NoteToolbar.js').default; | ||||
| const TagList = require('./TagList.min.js'); | ||||
| const { connect } = require('react-redux'); | ||||
| const { _ } = require('lib/locale.js'); | ||||
| @@ -346,6 +347,36 @@ class NoteTextComponent extends React.Component { | ||||
| 		this.webview_ipcMessage = this.webview_ipcMessage.bind(this); | ||||
| 		this.webview_domReady = this.webview_domReady.bind(this); | ||||
| 		this.noteRevisionViewer_onBack = this.noteRevisionViewer_onBack.bind(this); | ||||
| 		this.noteToolbar_buttonClick = this.noteToolbar_buttonClick.bind(this); | ||||
| 	} | ||||
|  | ||||
| 	noteToolbar_buttonClick(event) { | ||||
| 		const cases = { | ||||
|  | ||||
| 			'startExternalEditing': () => { | ||||
| 				this.commandStartExternalEditing(); | ||||
| 			}, | ||||
|  | ||||
| 			'stopExternalEditing': () => { | ||||
| 				this.commandStopExternalEditing(); | ||||
| 			}, | ||||
|  | ||||
| 			'setTags': () => { | ||||
| 				this.commandSetTags(); | ||||
| 			}, | ||||
|  | ||||
| 			'setAlarm': () => { | ||||
| 				this.commandSetAlarm(); | ||||
| 			}, | ||||
|  | ||||
| 			'showRevisions': () => { | ||||
| 				this.setState({ showRevisions: true }); | ||||
| 			}, | ||||
| 		}; | ||||
|  | ||||
| 		if (!cases[event.name]) throw new Error(`Unsupported event: ${event.name}`); | ||||
|  | ||||
| 		cases[event.name](); | ||||
| 	} | ||||
|  | ||||
| 	// Note: | ||||
| @@ -1831,79 +1862,6 @@ class NoteTextComponent extends React.Component { | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		if (note && this.props.watchedNoteFiles.indexOf(note.id) >= 0) { | ||||
| 			toolbarItems.push({ | ||||
| 				tooltip: _('Click to stop external editing'), | ||||
| 				title: _('Watching...'), | ||||
| 				iconName: 'fa-external-link', | ||||
| 				onClick: () => { | ||||
| 					return this.commandStopExternalEditing(); | ||||
| 				}, | ||||
| 			}); | ||||
| 		} else { | ||||
| 			toolbarItems.push({ | ||||
| 				tooltip: _('Edit in external editor'), | ||||
| 				iconName: 'fa-external-link', | ||||
| 				onClick: () => { | ||||
| 					return this.commandStartExternalEditing(); | ||||
| 				}, | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		toolbarItems.push({ | ||||
| 			tooltip: _('Tags'), | ||||
| 			iconName: 'fa-tags', | ||||
| 			onClick: () => { | ||||
| 				return this.commandSetTags(); | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		if (note.is_todo) { | ||||
| 			const item = { | ||||
| 				iconName: 'fa-clock-o', | ||||
| 				enabled: !note.todo_completed, | ||||
| 				onClick: () => { | ||||
| 					return this.commandSetAlarm(); | ||||
| 				}, | ||||
| 			}; | ||||
| 			if (Note.needAlarm(note)) { | ||||
| 				item.title = time.formatMsToLocal(note.todo_due); | ||||
| 			} else { | ||||
| 				item.tooltip = _('Set alarm'); | ||||
| 			} | ||||
| 			toolbarItems.push(item); | ||||
| 		} | ||||
|  | ||||
| 		toolbarItems.push({ | ||||
| 			tooltip: _('Note properties'), | ||||
| 			iconName: 'fa-info-circle', | ||||
| 			onClick: () => { | ||||
| 				const n = this.state.note; | ||||
| 				if (!n || !n.id) return; | ||||
|  | ||||
| 				this.props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'commandNoteProperties', | ||||
| 					noteId: n.id, | ||||
| 					onRevisionLinkClick: () => { | ||||
| 						this.setState({ showRevisions: true }); | ||||
| 					}, | ||||
| 				}); | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		toolbarItems.push({ | ||||
| 			tooltip: _('Content Properties'), | ||||
| 			iconName: 'fa-sticky-note', | ||||
| 			onClick: () => { | ||||
| 				this.props.dispatch({ | ||||
| 					type: 'WINDOW_COMMAND', | ||||
| 					name: 'commandContentProperties', | ||||
| 					text: this.state.note.body, | ||||
| 				}); | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		return toolbarItems; | ||||
| 	} | ||||
|  | ||||
| @@ -2273,7 +2231,17 @@ class NoteTextComponent extends React.Component { | ||||
| 					{titleBarDate} | ||||
| 					{false ? titleBarMenuButton : null} | ||||
| 				</div> | ||||
| 				{toolbar} | ||||
| 				<div style={{ display: 'flex', flex: 1, flexDirection: 'row' }}> | ||||
| 					{toolbar} | ||||
| 					<NoteToolbar | ||||
| 						theme={this.props.theme} | ||||
| 						note={note} | ||||
| 						dispatch={this.props.dispatch} | ||||
| 						style={toolbarStyle} | ||||
| 						watchedNoteFiles={[]} | ||||
| 						onButtonClick={this.noteToolbar_buttonClick} | ||||
| 					/> | ||||
| 				</div> | ||||
| 				{tagList} | ||||
| 				{editor} | ||||
| 				{viewer} | ||||
|   | ||||
| @@ -1,833 +0,0 @@ | ||||
| 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 BaseItem = require('lib/models/BaseItem'); | ||||
| const { MarkupToHtml } = require('lib/joplin-renderer'); | ||||
| const HtmlToMd = require('lib/HtmlToMd'); | ||||
| const { _ } = require('lib/locale'); | ||||
| const Note = require('lib/models/Note.js'); | ||||
| const BaseModel = require('lib/BaseModel.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'); | ||||
| const { urlDecode } = require('lib/string-utils'); | ||||
| const urlUtils = require('lib/urlUtils'); | ||||
| const ResourceFetcher = require('lib/services/ResourceFetcher.js'); | ||||
| const DecryptionWorker = require('lib/services/DecryptionWorker.js'); | ||||
|  | ||||
| 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; | ||||
| } | ||||
|  | ||||
| async 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, | ||||
| 		resourceInfos: await attachedResources(n.body), | ||||
| 	}); | ||||
|  | ||||
| 	await handleResourceDownloadMode(n.body); | ||||
| } | ||||
|  | ||||
| async function handleResourceDownloadMode(noteBody:string) { | ||||
| 	if (noteBody && Setting.value('sync.resourceDownloadMode') === 'auto') { | ||||
| 		const resourceIds = await Note.linkedResourceIds(noteBody); | ||||
| 		await ResourceFetcher.instance().markForDownload(resourceIds); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| 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; | ||||
| } | ||||
|  | ||||
| let resourceCache_:any = {}; | ||||
|  | ||||
| function clearResourceCache() { | ||||
| 	resourceCache_ = {}; | ||||
| } | ||||
|  | ||||
| async function attachedResources(noteBody:string):Promise<any> { | ||||
| 	if (!noteBody) return {}; | ||||
| 	const resourceIds = await Note.linkedItemIdsByType(BaseModel.TYPE_RESOURCE, noteBody); | ||||
|  | ||||
| 	const output:any = {}; | ||||
| 	for (let i = 0; i < resourceIds.length; i++) { | ||||
| 		const id = resourceIds[i]; | ||||
|  | ||||
| 		if (resourceCache_[id]) { | ||||
| 			output[id] = resourceCache_[id]; | ||||
| 		} else { | ||||
| 			const resource = await Resource.load(id); | ||||
| 			const localState = await Resource.localState(resource); | ||||
|  | ||||
| 			const o = { | ||||
| 				item: resource, | ||||
| 				localState: localState, | ||||
| 			}; | ||||
|  | ||||
| 			// eslint-disable-next-line require-atomic-updates | ||||
| 			resourceCache_[id] = o; | ||||
| 			output[id] = o; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return output; | ||||
| } | ||||
|  | ||||
| function installResourceHandling(refreshResourceHandler:Function) { | ||||
| 	ResourceFetcher.instance().on('downloadComplete', refreshResourceHandler); | ||||
| 	ResourceFetcher.instance().on('downloadStarted', refreshResourceHandler); | ||||
| 	DecryptionWorker.instance().on('resourceDecrypted', refreshResourceHandler); | ||||
| } | ||||
|  | ||||
| function uninstallResourceHandling(refreshResourceHandler:Function) { | ||||
| 	ResourceFetcher.instance().off('downloadComplete', refreshResourceHandler); | ||||
| 	ResourceFetcher.instance().off('downloadStarted', refreshResourceHandler); | ||||
| 	DecryptionWorker.instance().off('resourceDecrypted', refreshResourceHandler); | ||||
| } | ||||
|  | ||||
| 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, resourceInfos: {} }); | ||||
| 	const prevSyncStarted = usePrevious(props.syncStarted); | ||||
|  | ||||
| 	const editorRef = useRef<any>(); | ||||
| 	const titleInputRef = useRef<any>(); | ||||
| 	const formNoteRef = useRef<FormNote>(); | ||||
| 	formNoteRef.current = { ...formNote }; | ||||
| 	const isMountedRef = useRef(true); | ||||
|  | ||||
| 	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 allAssets = useCallback(async (markupLanguage:number):Promise<any[]> => { | ||||
| 		const theme = themeStyle(props.theme); | ||||
|  | ||||
| 		const markupToHtml = markupLanguageUtils.newMarkupToHtml({ | ||||
| 			resourceBaseUrl: `file://${Setting.value('resourceDir')}/`, | ||||
| 		}); | ||||
|  | ||||
| 		return markupToHtml.allAssets(markupLanguage, theme); | ||||
| 	}, [props.theme]); | ||||
|  | ||||
| 	const joplinHtml = useCallback(async (type:string) => { | ||||
| 		if (type === 'checkbox') { | ||||
| 			const result = await markupToHtml(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, '- [ ] xxxxxREMOVExxxxx', { | ||||
| 				bodyOnly: true, | ||||
| 				externalAssetsOnly: true, | ||||
| 			}); | ||||
| 			const html = result.html | ||||
| 				.replace(/xxxxxREMOVExxxxx/m, ' ') | ||||
| 				.replace(/<ul.*?>/, '') | ||||
| 				.replace(/<\/ul>/, ''); | ||||
| 			return { ...result, html: html }; | ||||
| 		} | ||||
|  | ||||
| 		throw new Error(`Invalid type:${type}`); | ||||
| 	}, [markupToHtml]); | ||||
|  | ||||
| 	const handleProvisionalFlag = useCallback(() => { | ||||
| 		if (props.isProvisional) { | ||||
| 			props.dispatch({ | ||||
| 				type: 'NOTE_PROVISIONAL_FLAG_CLEAR', | ||||
| 				id: formNote.id, | ||||
| 			}); | ||||
| 		} | ||||
| 	}, [props.isProvisional, formNote.id]); | ||||
|  | ||||
| 	const refreshResource = useCallback(async function(event) { | ||||
| 		if (!defaultEditorState.value) return; | ||||
|  | ||||
| 		const resourceIds = await Note.linkedResourceIds(defaultEditorState.value); | ||||
| 		if (resourceIds.indexOf(event.id) >= 0) { | ||||
| 			clearResourceCache(); | ||||
| 			const e = { | ||||
| 				...defaultEditorState, | ||||
| 				resourceInfos: await attachedResources(defaultEditorState.value), | ||||
| 			}; | ||||
| 			setDefaultEditorState(e); | ||||
| 		} | ||||
| 	}, [defaultEditorState]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		installResourceHandling(refreshResource); | ||||
|  | ||||
| 		return () => { | ||||
| 			uninstallResourceHandling(refreshResource); | ||||
| 		}; | ||||
| 	}, [defaultEditorState]); | ||||
|  | ||||
| 	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 () => { | ||||
| 			isMountedRef.current = false; | ||||
| 			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; | ||||
| 			} | ||||
|  | ||||
| 			await 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); | ||||
|  | ||||
| 		function handleAutoFocus(noteIsTodo:boolean) { | ||||
| 			if (!props.isProvisional) return; | ||||
|  | ||||
| 			const focusSettingName = noteIsTodo ? 'newTodoFocus' : 'newNoteFocus'; | ||||
|  | ||||
| 			requestAnimationFrame(() => { | ||||
| 				if (Setting.value(focusSettingName) === 'title') { | ||||
| 					if (titleInputRef.current) titleInputRef.current.focus(); | ||||
| 				} else { | ||||
| 					if (editorRef.current) editorRef.current.execCommand({ name: 'focus' }); | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		async function loadNote() { | ||||
| 			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); | ||||
| 			await initNoteState(n, setFormNote, setDefaultEditorState); | ||||
|  | ||||
| 			handleAutoFocus(!!n.is_todo); | ||||
| 		} | ||||
|  | ||||
| 		loadNote(); | ||||
|  | ||||
| 		return () => { | ||||
| 			cancelled = true; | ||||
| 		}; | ||||
| 	}, [props.noteId, props.isProvisional, formNote, waitingToSaveNote]); | ||||
|  | ||||
| 	const onFieldChange = useCallback((field:string, value:any, changeId: number = 0) => { | ||||
| 		if (!isMountedRef.current) { | ||||
| 			// When the component is unmounted, various actions can happen which can | ||||
| 			// trigger onChange events, for example the textarea might be cleared. | ||||
| 			// We need to ignore these events, otherwise the note is going to be saved | ||||
| 			// with an invalid body. | ||||
| 			reg.logger().debug('Skipping change event because the component is unmounted'); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		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 onMessage = useCallback(async (event:any) => { | ||||
| 		const msg = event.name; | ||||
| 		const args = event.args; | ||||
|  | ||||
| 		console.info('onMessage', msg, args); | ||||
|  | ||||
| 		if (msg === 'setMarkerCount') { | ||||
| 			// const ls = Object.assign({}, this.state.localSearch); | ||||
| 			// ls.resultCount = arg0; | ||||
| 			// ls.searching = false; | ||||
| 			// this.setState({ localSearch: ls }); | ||||
| 		} else if (msg.indexOf('markForDownload:') === 0) { | ||||
| 			// const s = msg.split(':'); | ||||
| 			// if (s.length < 2) throw new Error(`Invalid message: ${msg}`); | ||||
| 			// ResourceFetcher.instance().markForDownload(s[1]); | ||||
| 		} else if (msg === 'percentScroll') { | ||||
| 			// this.ignoreNextEditorScroll_ = true; | ||||
| 			// this.setEditorPercentScroll(arg0); | ||||
| 		} else if (msg === 'contextMenu') { | ||||
| 			// const itemType = arg0 && arg0.type; | ||||
|  | ||||
| 			// const menu = new Menu(); | ||||
|  | ||||
| 			// if (itemType === 'image' || itemType === 'resource') { | ||||
| 			// 	const resource = await Resource.load(arg0.resourceId); | ||||
| 			// 	const resourcePath = Resource.fullPath(resource); | ||||
|  | ||||
| 			// 	menu.append( | ||||
| 			// 		new MenuItem({ | ||||
| 			// 			label: _('Open...'), | ||||
| 			// 			click: async () => { | ||||
| 			// 				const ok = bridge().openExternal(`file://${resourcePath}`); | ||||
| 			// 				if (!ok) bridge().showErrorMessageBox(_('This file could not be opened: %s', resourcePath)); | ||||
| 			// 			}, | ||||
| 			// 		}) | ||||
| 			// 	); | ||||
|  | ||||
| 			// 	menu.append( | ||||
| 			// 		new MenuItem({ | ||||
| 			// 			label: _('Save as...'), | ||||
| 			// 			click: async () => { | ||||
| 			// 				const filePath = bridge().showSaveDialog({ | ||||
| 			// 					defaultPath: resource.filename ? resource.filename : resource.title, | ||||
| 			// 				}); | ||||
| 			// 				if (!filePath) return; | ||||
| 			// 				await fs.copy(resourcePath, filePath); | ||||
| 			// 			}, | ||||
| 			// 		}) | ||||
| 			// 	); | ||||
|  | ||||
| 			// 	menu.append( | ||||
| 			// 		new MenuItem({ | ||||
| 			// 			label: _('Copy path to clipboard'), | ||||
| 			// 			click: async () => { | ||||
| 			// 				clipboard.writeText(toSystemSlashes(resourcePath)); | ||||
| 			// 			}, | ||||
| 			// 		}) | ||||
| 			// 	); | ||||
| 			// } else if (itemType === 'text') { | ||||
| 			// 	menu.append( | ||||
| 			// 		new MenuItem({ | ||||
| 			// 			label: _('Copy'), | ||||
| 			// 			click: async () => { | ||||
| 			// 				clipboard.writeText(arg0.textToCopy); | ||||
| 			// 			}, | ||||
| 			// 		}) | ||||
| 			// 	); | ||||
| 			// } else if (itemType === 'link') { | ||||
| 			// 	menu.append( | ||||
| 			// 		new MenuItem({ | ||||
| 			// 			label: _('Copy Link Address'), | ||||
| 			// 			click: async () => { | ||||
| 			// 				clipboard.writeText(arg0.textToCopy); | ||||
| 			// 			}, | ||||
| 			// 		}) | ||||
| 			// 	); | ||||
| 			// } else { | ||||
| 			// 	reg.logger().error(`Unhandled item type: ${itemType}`); | ||||
| 			// 	return; | ||||
| 			// } | ||||
|  | ||||
| 			// menu.popup(bridge().window()); | ||||
| 		} else if (msg === 'openInternal') { | ||||
| 			const resourceUrlInfo = urlUtils.parseResourceUrl(args.url); | ||||
| 			const itemId = resourceUrlInfo.itemId; | ||||
| 			const item = await BaseItem.loadItemById(itemId); | ||||
|  | ||||
| 			if (!item) throw new Error(`No item with ID ${itemId}`); | ||||
|  | ||||
| 			if (item.type_ === BaseModel.TYPE_RESOURCE) { | ||||
| 				const localState = await Resource.localState(item); | ||||
| 				if (localState.fetch_status !== Resource.FETCH_STATUS_DONE || !!item.encryption_blob_encrypted) { | ||||
| 					if (localState.fetch_status === Resource.FETCH_STATUS_ERROR) { | ||||
| 						bridge().showErrorMessageBox(`${_('There was an error downloading this attachment:')}\n\n${localState.fetch_error}`); | ||||
| 					} else { | ||||
| 						bridge().showErrorMessageBox(_('This attachment is not downloaded or not decrypted yet')); | ||||
| 					} | ||||
| 					return; | ||||
| 				} | ||||
| 				const filePath = Resource.fullPath(item); | ||||
| 				bridge().openItem(filePath); | ||||
| 			} else if (item.type_ === BaseModel.TYPE_NOTE) { | ||||
| 				props.dispatch({ | ||||
| 					type: 'FOLDER_AND_NOTE_SELECT', | ||||
| 					folderId: item.parent_id, | ||||
| 					noteId: item.id, | ||||
| 					hash: resourceUrlInfo.hash, | ||||
| 					historyAction: 'goto', | ||||
| 				}); | ||||
| 			} else { | ||||
| 				throw new Error(`Unsupported item type: ${item.type_}`); | ||||
| 			} | ||||
| 		} else if (msg.indexOf('#') === 0) { | ||||
| 			// This is an internal anchor, which is handled by the WebView so skip this case | ||||
| 		} else if (msg === 'openExternal') { | ||||
| 			if (args.url.indexOf('file://') === 0) { | ||||
| 				// When using the file:// protocol, openExternal doesn't work (does nothing) with URL-encoded paths | ||||
| 				bridge().openExternal(urlDecode(args.url)); | ||||
| 			} else { | ||||
| 				bridge().openExternal(args.url); | ||||
| 			} | ||||
| 		} else { | ||||
| 			bridge().showErrorMessageBox(_('Unsupported link or message: %s', msg)); | ||||
| 		} | ||||
| 	}, []); | ||||
|  | ||||
| 	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, | ||||
| 		onMessage: onMessage, | ||||
| 		defaultEditorState: defaultEditorState, | ||||
| 		markupToHtml: markupToHtml, | ||||
| 		allAssets: allAssets, | ||||
| 		attachResources: attachResources, | ||||
| 		disabled: waitingToSaveNote, | ||||
| 		joplinHtml: joplinHtml, | ||||
| 		theme: props.theme, | ||||
| 	}; | ||||
|  | ||||
| 	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); | ||||
| @@ -162,7 +162,7 @@ class NoteTextViewerComponent extends React.Component { | ||||
| 	// ---------------------------------------------------------------- | ||||
|  | ||||
| 	render() { | ||||
| 		const viewerStyle = Object.assign({}, this.props.viewerStyle, { borderTop: 'none', borderRight: 'none', borderBottom: 'none' }); | ||||
| 		const viewerStyle = Object.assign({}, { border: 'none' }, this.props.viewerStyle); | ||||
| 		return <iframe className="noteTextViewer" ref={this.webviewRef_} style={viewerStyle} src="gui/note-viewer/index.html"></iframe>; | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										148
									
								
								ElectronClient/gui/NoteToolbar/NoteToolbar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								ElectronClient/gui/NoteToolbar/NoteToolbar.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,148 @@ | ||||
| import * as React from 'react'; | ||||
|  | ||||
| const { buildStyle } = require('../../theme.js'); | ||||
| const Toolbar = require('../Toolbar.min.js'); | ||||
| const Note = require('lib/models/Note'); | ||||
| const { time } = require('lib/time-utils.js'); | ||||
| const { _ } = require('lib/locale'); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| // const { substrWithEllipsis } = require('lib/string-utils'); | ||||
| // const Folder = require('lib/models/Folder'); | ||||
| // const { MarkupToHtml } = require('lib/joplin-renderer'); | ||||
|  | ||||
| interface ButtonClickEvent { | ||||
| 	name: string, | ||||
| } | ||||
|  | ||||
| interface NoteToolbarProps { | ||||
| 	theme: number, | ||||
| 	style: any, | ||||
| 	watchedNoteFiles: string[], | ||||
| 	note: any, | ||||
| 	dispatch: Function, | ||||
| 	onButtonClick(event:ButtonClickEvent):void, | ||||
| } | ||||
|  | ||||
| function styles_(props:NoteToolbarProps) { | ||||
| 	return buildStyle('NoteToolbar', props.theme, (/* theme:any*/) => { | ||||
| 		return { | ||||
| 			root: { | ||||
| 				...props.style, | ||||
|  | ||||
| 			}, | ||||
| 		}; | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function useToolbarItems(note:any, watchedNoteFiles:string[], dispatch:Function, onButtonClick:Function) { | ||||
| 	const toolbarItems = []; | ||||
|  | ||||
| 	// TODO: add these two items | ||||
|  | ||||
| 	// if (props.folder && ['Search', 'Tag', 'SmartFilter'].includes(props.notesParentType)) { | ||||
| 	// 	toolbarItems.push({ | ||||
| 	// 		title: _('In: %s', substrWithEllipsis(props.folder.title, 0, 16)), | ||||
| 	// 		iconName: 'fa-book', | ||||
| 	// 		onClick: () => { | ||||
| 	// 			props.dispatch({ | ||||
| 	// 				type: 'FOLDER_AND_NOTE_SELECT', | ||||
| 	// 				folderId: props.folder.id, | ||||
| 	// 				noteId: props.formNote.id, | ||||
| 	// 			}); | ||||
| 	// 			Folder.expandTree(props.folders, props.folder.parent_id); | ||||
| 	// 		}, | ||||
| 	// 	}); | ||||
| 	// } | ||||
|  | ||||
| 	// if (props.historyNotes.length) { | ||||
| 	// 	toolbarItems.push({ | ||||
| 	// 		tooltip: _('Back'), | ||||
| 	// 		iconName: 'fa-arrow-left', | ||||
| 	// 		onClick: () => { | ||||
| 	// 			if (!props.historyNotes.length) return; | ||||
|  | ||||
| 	// 			const lastItem = props.historyNotes[props.historyNotes.length - 1]; | ||||
|  | ||||
| 	// 			props.dispatch({ | ||||
| 	// 				type: 'FOLDER_AND_NOTE_SELECT', | ||||
| 	// 				folderId: lastItem.parent_id, | ||||
| 	// 				noteId: lastItem.id, | ||||
| 	// 				historyNoteAction: 'pop', | ||||
| 	// 			}); | ||||
| 	// 		}, | ||||
| 	// 	}); | ||||
| 	// } | ||||
|  | ||||
| 	if (watchedNoteFiles.indexOf(note.id) >= 0) { | ||||
| 		toolbarItems.push({ | ||||
| 			tooltip: _('Click to stop external editing'), | ||||
| 			title: _('Watching...'), | ||||
| 			iconName: 'fa-external-link', | ||||
| 			onClick: () => { | ||||
| 				onButtonClick({ name: 'stopExternalEditing' }); | ||||
| 			}, | ||||
| 		}); | ||||
| 	} else { | ||||
| 		toolbarItems.push({ | ||||
| 			tooltip: _('Edit in external editor'), | ||||
| 			iconName: 'fa-external-link', | ||||
| 			onClick: () => { | ||||
| 				onButtonClick({ name: 'startExternalEditing' }); | ||||
| 			}, | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	toolbarItems.push({ | ||||
| 		tooltip: _('Tags'), | ||||
| 		iconName: 'fa-tags', | ||||
| 		onClick: () => { | ||||
| 			onButtonClick({ name: 'setTags' }); | ||||
| 		}, | ||||
| 	}); | ||||
|  | ||||
| 	if (note.is_todo) { | ||||
| 		const item:any = { | ||||
| 			iconName: 'fa-clock-o', | ||||
| 			enabled: !note.todo_completed, | ||||
| 			onClick: () => { | ||||
| 				onButtonClick({ name: 'setAlarm' }); | ||||
| 			}, | ||||
| 		}; | ||||
| 		if (Note.needAlarm(note)) { | ||||
| 			item.title = time.formatMsToLocal(note.todo_due); | ||||
| 		} else { | ||||
| 			item.tooltip = _('Set alarm'); | ||||
| 		} | ||||
| 		toolbarItems.push(item); | ||||
| 	} | ||||
|  | ||||
| 	toolbarItems.push({ | ||||
| 		tooltip: _('Note properties'), | ||||
| 		iconName: 'fa-info-circle', | ||||
| 		onClick: () => { | ||||
| 			dispatch({ | ||||
| 				type: 'WINDOW_COMMAND', | ||||
| 				name: 'commandNoteProperties', | ||||
| 				noteId: note.id, | ||||
| 				onRevisionLinkClick: () => { | ||||
| 					onButtonClick({ name: 'showRevisions' }); | ||||
| 				}, | ||||
| 			}); | ||||
| 		}, | ||||
| 	}); | ||||
|  | ||||
| 	return toolbarItems; | ||||
| } | ||||
|  | ||||
| export default function NoteToolbar(props:NoteToolbarProps) { | ||||
| 	const styles = styles_(props); | ||||
|  | ||||
| 	const toolbarItems = useToolbarItems(props.note, props.watchedNoteFiles, props.dispatch, props.onButtonClick); | ||||
|  | ||||
| 	return ( | ||||
| 		<Toolbar style={styles.root} items={toolbarItems} /> | ||||
| 	); | ||||
| } | ||||
| @@ -69,8 +69,9 @@ const ResourceTable: React.FC<ResourceTable> = (props: ResourceTable) => { | ||||
| 	}; | ||||
|  | ||||
| 	const cellStyle = { | ||||
| 		...props.theme.textStyleMinor, | ||||
| 		...props.theme.textStyle, | ||||
| 		whiteSpace: 'nowrap', | ||||
| 		color: props.theme.colorFaded, | ||||
| 		width: 1, | ||||
| 	}; | ||||
|  | ||||
|   | ||||
| @@ -11,10 +11,11 @@ class TagListComponent extends React.Component { | ||||
|  | ||||
| 		style.display = 'flex'; | ||||
| 		style.flexDirection = 'row'; | ||||
| 		style.borderBottom = `1px solid ${theme.dividerColor}`; | ||||
| 		// style.borderBottom = `1px solid ${theme.dividerColor}`; | ||||
| 		style.boxSizing = 'border-box'; | ||||
| 		style.fontSize = theme.fontSize; | ||||
| 		style.whiteSpace = 'nowrap'; | ||||
| 		style.height = 25; | ||||
|  | ||||
| 		const tagItems = []; | ||||
| 		if (tags && tags.length > 0) { | ||||
|   | ||||
| @@ -6,7 +6,7 @@ const ToolbarSpace = require('./ToolbarSpace.min.js'); | ||||
|  | ||||
| class ToolbarComponent extends React.Component { | ||||
| 	render() { | ||||
| 		const style = this.props.style; | ||||
| 		const style = Object.assign({}, this.props.style); | ||||
| 		const theme = themeStyle(this.props.theme); | ||||
| 		style.height = theme.toolbarHeight; | ||||
| 		style.display = 'flex'; | ||||
|   | ||||
| @@ -105,8 +105,10 @@ | ||||
|  | ||||
| 			for (let i = 0; i < assets.length; i++) { | ||||
| 				const asset = assets[i]; | ||||
| 				if (pluginAssetsAdded_[asset.name]) continue; | ||||
| 				pluginAssetsAdded_[asset.name] = true; | ||||
|  | ||||
| 				const assetId = asset.name ? asset.name : asset.path; | ||||
| 				if (pluginAssetsAdded_[assetId]) continue; | ||||
| 				pluginAssetsAdded_[assetId] = true; | ||||
|  | ||||
| 				if (asset.mime === 'application/javascript') { | ||||
| 					const script = document.createElement('script'); | ||||
| @@ -143,6 +145,22 @@ | ||||
| 			}, 100); | ||||
| 		} | ||||
|  | ||||
| 		// https://stackoverflow.com/a/1977898/561309 | ||||
| 		function isImageReady(img) { | ||||
| 			if (!img.complete) return false; | ||||
| 			if (!img.naturalWidth || !img.naturalHeight) return false; | ||||
| 			return true; | ||||
| 		} | ||||
|  | ||||
| 		function allImagesLoaded() { | ||||
| 			for (const image of document.images) { | ||||
| 				if (!isImageReady(image)) return false; | ||||
| 			} | ||||
| 			return true; | ||||
| 		} | ||||
|  | ||||
| 		let checkAllImageLoadedIID_ = null; | ||||
|  | ||||
| 		ipc.setHtml = (event) => { | ||||
| 			const html = event.html; | ||||
|  | ||||
| @@ -177,6 +195,15 @@ | ||||
| 			} | ||||
|  | ||||
| 			document.dispatchEvent(new Event('joplin-noteDidUpdate')); | ||||
|  | ||||
| 			if (checkAllImageLoadedIID_) clearInterval(checkAllImageLoadedIID_); | ||||
|  | ||||
| 			checkAllImageLoadedIID_ = setInterval(() => { | ||||
| 				if (!allImagesLoaded()) return; | ||||
|  | ||||
| 				clearInterval(checkAllImageLoadedIID_); | ||||
| 				ipcProxySendToHost('noteRenderComplete'); | ||||
| 			}, 100); | ||||
| 		} | ||||
|  | ||||
| 		let lastScrollEventTime = 0; | ||||
|   | ||||
| @@ -91,8 +91,14 @@ class NoteListUtils { | ||||
| 						click: async () => { | ||||
| 							for (let i = 0; i < noteIds.length; i++) { | ||||
| 								const note = await Note.load(noteIds[i]); | ||||
| 								await Note.save(Note.toggleIsTodo(note), { userSideValidation: true }); | ||||
| 								eventManager.emit('noteTypeToggle', { noteId: note.id }); | ||||
| 								const newNote = await Note.save(Note.toggleIsTodo(note), { userSideValidation: true }); | ||||
| 								const eventNote = { | ||||
| 									id: newNote.id, | ||||
| 									is_todo: newNote.is_todo, | ||||
| 									todo_due: newNote.todo_due, | ||||
| 									todo_completed: newNote.todo_completed, | ||||
| 								}; | ||||
| 								eventManager.emit('noteTypeToggle', { noteId: note.id, note: eventNote }); | ||||
| 							} | ||||
| 						}, | ||||
| 					}) | ||||
|   | ||||
							
								
								
									
										15
									
								
								ElectronClient/gui/utils/NoteText.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								ElectronClient/gui/utils/NoteText.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| 'use strict'; | ||||
| Object.defineProperty(exports, '__esModule', { value: true }); | ||||
| const joplinRendererUtils = require('lib/joplin-renderer').utils; | ||||
| const Resource = require('lib/models/Resource'); | ||||
| function resourcesStatus(resourceInfos) { | ||||
| 	let lowestIndex = joplinRendererUtils.resourceStatusIndex('ready'); | ||||
| 	for (const id in resourceInfos) { | ||||
| 		const s = joplinRendererUtils.resourceStatus(Resource, resourceInfos[id]); | ||||
| 		const idx = joplinRendererUtils.resourceStatusIndex(s); | ||||
| 		if (idx < lowestIndex) { lowestIndex = idx; } | ||||
| 	} | ||||
| 	return joplinRendererUtils.resourceStatusName(lowestIndex); | ||||
| } | ||||
| exports.resourcesStatus = resourcesStatus; | ||||
| // # sourceMappingURL=NoteText.js.map | ||||
| @@ -1,32 +0,0 @@ | ||||
| const joplinRendererUtils = require('lib/joplin-renderer').utils; | ||||
| const Resource = require('lib/models/Resource'); | ||||
|  | ||||
| export interface DefaultEditorState { | ||||
| 	value: string, | ||||
| 	markupLanguage: number, // MarkupToHtml.MARKUP_LANGUAGE_XXX | ||||
| 	resourceInfos: any, | ||||
| } | ||||
|  | ||||
| export interface OnChangeEvent { | ||||
| 	changeId: number, | ||||
| 	content: any, | ||||
| } | ||||
|  | ||||
| export interface TextEditorUtils { | ||||
| 	editorContentToHtml(content:any):Promise<string>, | ||||
| } | ||||
|  | ||||
| export interface EditorCommand { | ||||
| 	name: string, | ||||
| 	value: any, | ||||
| } | ||||
|  | ||||
| export function resourcesStatus(resourceInfos:any) { | ||||
| 	let lowestIndex = joplinRendererUtils.resourceStatusIndex('ready'); | ||||
| 	for (const id in resourceInfos) { | ||||
| 		const s = joplinRendererUtils.resourceStatus(Resource, resourceInfos[id]); | ||||
| 		const idx = joplinRendererUtils.resourceStatusIndex(s); | ||||
| 		if (idx < lowestIndex) lowestIndex = idx; | ||||
| 	} | ||||
| 	return joplinRendererUtils.resourceStatusName(lowestIndex); | ||||
| } | ||||
							
								
								
									
										33
									
								
								ElectronClient/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										33
									
								
								ElectronClient/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -2771,6 +2771,15 @@ | ||||
|         "object-visit": "^1.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "color": { | ||||
|       "version": "3.1.2", | ||||
|       "resolved": "https://registry.npmjs.org/color/-/color-3.1.2.tgz", | ||||
|       "integrity": "sha512-vXTJhHebByxZn3lDvDJYw4lR5+uB3vuoHsuYA5AKuxRVn5wzzIfQKGLBmgdVRHKTJYeK5rvJcHnrd0Li49CFpg==", | ||||
|       "requires": { | ||||
|         "color-convert": "^1.9.1", | ||||
|         "color-string": "^1.5.2" | ||||
|       } | ||||
|     }, | ||||
|     "color-convert": { | ||||
|       "version": "1.9.3", | ||||
|       "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", | ||||
| @@ -2784,6 +2793,15 @@ | ||||
|       "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", | ||||
|       "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" | ||||
|     }, | ||||
|     "color-string": { | ||||
|       "version": "1.5.3", | ||||
|       "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.3.tgz", | ||||
|       "integrity": "sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==", | ||||
|       "requires": { | ||||
|         "color-name": "^1.0.0", | ||||
|         "simple-swizzle": "^0.2.2" | ||||
|       } | ||||
|     }, | ||||
|     "color-support": { | ||||
|       "version": "1.1.3", | ||||
|       "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", | ||||
| @@ -10242,6 +10260,21 @@ | ||||
|       "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", | ||||
|       "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=" | ||||
|     }, | ||||
|     "simple-swizzle": { | ||||
|       "version": "0.2.2", | ||||
|       "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", | ||||
|       "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", | ||||
|       "requires": { | ||||
|         "is-arrayish": "^0.3.1" | ||||
|       }, | ||||
|       "dependencies": { | ||||
|         "is-arrayish": { | ||||
|           "version": "0.3.2", | ||||
|           "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", | ||||
|           "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "slash": { | ||||
|       "version": "1.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", | ||||
|   | ||||
| @@ -91,6 +91,7 @@ | ||||
|     "base64-stream": "^1.0.0", | ||||
|     "chokidar": "^3.0.0", | ||||
|     "clean-html": "^1.5.0", | ||||
|     "color": "^3.1.2", | ||||
|     "compare-versions": "^3.2.1", | ||||
|     "countable": "^3.0.1", | ||||
|     "diacritics": "^1.3.0", | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| const Setting = require('lib/models/Setting.js'); | ||||
| const Color = require('color'); | ||||
|  | ||||
| const themes = { | ||||
| 	[Setting.THEME_LIGHT]: require('./gui/style/theme/light'), | ||||
| @@ -29,7 +30,6 @@ const globalStyle = { | ||||
| 	headerButtonHPadding: 6, | ||||
|  | ||||
| 	toolbarHeight: 35, | ||||
| 	tagItemPadding: 3, | ||||
| }; | ||||
|  | ||||
| globalStyle.marginRight = globalStyle.margin; | ||||
| @@ -83,18 +83,21 @@ globalStyle.buttonStyle = { | ||||
| }; | ||||
|  | ||||
| function addExtraStyles(style) { | ||||
| 	style.selectedDividerColor = Color(style.dividerColor).darken(0.2).hex(); | ||||
|  | ||||
| 	style.tagStyle = { | ||||
| 		fontSize: style.fontSize, | ||||
| 		fontFamily: style.fontFamily, | ||||
| 		marginTop: style.itemMarginTop * 0.4, | ||||
| 		marginBottom: style.itemMarginBottom * 0.4, | ||||
| 		marginRight: style.margin * 0.3, | ||||
| 		paddingTop: style.tagItemPadding, | ||||
| 		paddingBottom: style.tagItemPadding, | ||||
| 		paddingRight: style.tagItemPadding * 2, | ||||
| 		paddingLeft: style.tagItemPadding * 2, | ||||
| 		paddingTop: 3, | ||||
| 		paddingBottom: 3, | ||||
| 		paddingRight: 8, | ||||
| 		paddingLeft: 8, | ||||
| 		backgroundColor: style.raisedBackgroundColor, | ||||
| 		color: style.raisedColor, | ||||
| 		display: 'flex', | ||||
| 		alignItems: 'center', | ||||
| 		justifyContent: 'center', | ||||
| 		marginRight: 5, | ||||
| 	}; | ||||
|  | ||||
| 	style.toolbarStyle = { | ||||
|   | ||||
| @@ -13,7 +13,7 @@ gulp.task('icon-packager', function() { | ||||
| }); | ||||
|  | ||||
| gulp.task('deploy', function() { | ||||
| 	fs.copyFileSync(`${__dirname}/dist/icons/Joplin/icons.js`, `${__dirname}/../../../ElectronClient/gui/editors/TinyMCE/icons.js`); | ||||
| 	fs.copyFileSync(`${__dirname}/dist/icons/Joplin/icons.js`, `${__dirname}/../../../ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/icons.js`); | ||||
| 	return Promise.resolve(); | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -125,7 +125,7 @@ module.exports = function(grunt) { | ||||
| 					// { src: ['changelog.txt'], dest: 'dist', expand: true }, | ||||
| 					{ | ||||
| 						src: ['dist/joplinLists.js'], | ||||
| 						dest: '../../../ElectronClient/gui/editors/TinyMCE/plugins/lists.js', | ||||
| 						dest: '../../../ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/plugins/lists.js', | ||||
| 					}, | ||||
| 				], | ||||
| 			}, | ||||
|   | ||||
| @@ -64,7 +64,7 @@ shared.saveNoteButton_press = async function(comp, folderId = null, options = nu | ||||
|  | ||||
| 	const hasAutoTitle = comp.state.newAndNoTitleChangeNoteId || (isProvisionalNote && !note.title); | ||||
| 	if (hasAutoTitle && options.autoTitle) { | ||||
| 		note.title = Note.defaultTitle(note); | ||||
| 		note.title = Note.defaultTitle(note.body); | ||||
| 		if (saveOptions.fields && saveOptions.fields.indexOf('title') < 0) saveOptions.fields.push('title'); | ||||
| 	} | ||||
|  | ||||
|   | ||||
							
								
								
									
										27
									
								
								ReactNativeClient/lib/hooks/useImperativeHandlerDebugger.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								ReactNativeClient/lib/hooks/useImperativeHandlerDebugger.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| import usePrevious from './usePrevious'; | ||||
| import { useImperativeHandle } from 'react'; | ||||
|  | ||||
| export default function useImperativeHandleDebugger(ref:any, effectHook:any, dependencies:any, dependencyNames:any[] = []) { | ||||
| 	const previousDeps = usePrevious(dependencies, []); | ||||
|  | ||||
| 	const changedDeps = dependencies.reduce((accum:any, dependency:any, index:any) => { | ||||
| 		if (dependency !== previousDeps[index]) { | ||||
| 			const keyName = dependencyNames[index] || index; | ||||
| 			return { | ||||
| 				...accum, | ||||
| 				[keyName]: { | ||||
| 					before: previousDeps[index], | ||||
| 					after: dependency, | ||||
| 				}, | ||||
| 			}; | ||||
| 		} | ||||
|  | ||||
| 		return accum; | ||||
| 	}, {}); | ||||
|  | ||||
| 	if (Object.keys(changedDeps).length) { | ||||
| 		console.log('[use-imperativeHandler-debugger] ', changedDeps); | ||||
| 	} | ||||
|  | ||||
| 	useImperativeHandle(ref, effectHook, dependencies); | ||||
| } | ||||
							
								
								
									
										9
									
								
								ReactNativeClient/lib/hooks/usePrevious.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								ReactNativeClient/lib/hooks/usePrevious.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| import { useEffect, useRef } from 'react'; | ||||
|  | ||||
| export default function usePrevious(value: any, initialValue:any = null): any { | ||||
| 	const ref = useRef(initialValue); | ||||
| 	useEffect(() => { | ||||
| 		ref.current = value; | ||||
| 	}); | ||||
| 	return ref.current; | ||||
| } | ||||
| @@ -29,10 +29,10 @@ class HtmlToHtml { | ||||
|  | ||||
| 	splitHtml(html) { | ||||
| 		const trimmedHtml = html.trimStart(); | ||||
| 		if (trimmedHtml.indexOf('<style>') !== 0) return { html: html, cssStrings: [], originalCssHtml: '' }; | ||||
| 		if (trimmedHtml.indexOf('<style>') !== 0) return { html: html, css: '' }; | ||||
|  | ||||
| 		const closingIndex = trimmedHtml.indexOf('</style>'); | ||||
| 		if (closingIndex < 0) return { html: html, cssStrings: [], originalCssHtml: '' }; | ||||
| 		if (closingIndex < 0) return { html: html, css: '' }; | ||||
|  | ||||
| 		return { | ||||
| 			html: trimmedHtml.substr(closingIndex + 8), | ||||
|   | ||||
| @@ -77,8 +77,8 @@ class Note extends BaseItem { | ||||
| 		return super.serialize(n, fieldNames); | ||||
| 	} | ||||
|  | ||||
| 	static defaultTitle(note) { | ||||
| 		return this.defaultTitleFromBody(note.body); | ||||
| 	static defaultTitle(noteBody) { | ||||
| 		return this.defaultTitleFromBody(noteBody); | ||||
| 	} | ||||
|  | ||||
| 	static defaultTitleFromBody(body) { | ||||
|   | ||||
| @@ -43,20 +43,11 @@ class Setting extends BaseModel { | ||||
| 				type: Setting.TYPE_STRING, | ||||
| 				public: false, | ||||
| 			}, | ||||
| 			'editor.keyboardMode': { | ||||
| 				value: 'default', | ||||
| 				type: Setting.TYPE_STRING, | ||||
| 				public: true, | ||||
| 			'editor.codeView': { | ||||
| 				value: false, | ||||
| 				type: Setting.TYPE_BOOL, | ||||
| 				public: false, | ||||
| 				appTypes: ['desktop'], | ||||
| 				isEnum: true, | ||||
| 				label: () => _('Keyboard Mode'), | ||||
| 				options: () => { | ||||
| 					const output = {}; | ||||
| 					output['default'] = _('Default'); | ||||
| 					output['emacs'] = _('Emacs'); | ||||
| 					output['vim'] = _('Vim'); | ||||
| 					return output; | ||||
| 				}, | ||||
| 			}, | ||||
| 			'sync.target': { | ||||
| 				value: SyncTargetRegistry.nameToId('dropbox'), | ||||
| @@ -261,7 +252,7 @@ class Setting extends BaseModel { | ||||
| 					return output; | ||||
| 				}, | ||||
| 			}, | ||||
| 			showNoteCounts: { value: true, type: Setting.TYPE_BOOL, public: true, appTypes: ['desktop'], label: () => _('Show note counts') }, | ||||
| 			showNoteCounts: { value: true, type: Setting.TYPE_BOOL, public: true, advanced: true, appTypes: ['desktop'], label: () => _('Show note counts') }, | ||||
| 			layoutButtonSequence: { | ||||
| 				value: Setting.LAYOUT_ALL, | ||||
| 				type: Setting.TYPE_INT, | ||||
| @@ -273,7 +264,6 @@ 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') }, | ||||
| @@ -528,7 +518,7 @@ class Setting extends BaseModel { | ||||
| 			tagHeaderIsExpanded: { value: true, type: Setting.TYPE_BOOL, public: false, appTypes: ['desktop'] }, | ||||
| 			folderHeaderIsExpanded: { value: true, type: Setting.TYPE_BOOL, public: false, appTypes: ['desktop'] }, | ||||
| 			editor: { value: '', type: Setting.TYPE_STRING, subType: 'file_path_and_args', public: true, appTypes: ['cli', 'desktop'], label: () => _('Text editor command'), description: () => _('The editor command (may include arguments) that will be used to open a note. If none is provided it will try to auto-detect the default editor.') }, | ||||
| 			'export.pdfPageSize': { value: 'A4', type: Setting.TYPE_STRING, isEnum: true, public: true, appTypes: ['desktop'], label: () => _('Page size for PDF export'), options: () => { | ||||
| 			'export.pdfPageSize': { value: 'A4', type: Setting.TYPE_STRING, advanced: true, isEnum: true, public: true, appTypes: ['desktop'], label: () => _('Page size for PDF export'), options: () => { | ||||
| 				return { | ||||
| 					'A4': _('A4'), | ||||
| 					'Letter': _('Letter'), | ||||
| @@ -538,13 +528,29 @@ class Setting extends BaseModel { | ||||
| 					'Legal': _('Legal'), | ||||
| 				}; | ||||
| 			} }, | ||||
| 			'export.pdfPageOrientation': { value: 'portrait', type: Setting.TYPE_STRING, isEnum: true, public: true, appTypes: ['desktop'], label: () => _('Page orientation for PDF export'), options: () => { | ||||
| 			'export.pdfPageOrientation': { value: 'portrait', type: Setting.TYPE_STRING, advanced: true, isEnum: true, public: true, appTypes: ['desktop'], label: () => _('Page orientation for PDF export'), options: () => { | ||||
| 				return { | ||||
| 					'portrait': _('Portrait'), | ||||
| 					'landscape': _('Landscape'), | ||||
| 				}; | ||||
| 			} }, | ||||
|  | ||||
| 			'editor.keyboardMode': { | ||||
| 				value: '', | ||||
| 				type: Setting.TYPE_STRING, | ||||
| 				public: true, | ||||
| 				appTypes: ['desktop'], | ||||
| 				isEnum: true, | ||||
| 				advanced: true, | ||||
| 				label: () => _('Keyboard Mode'), | ||||
| 				options: () => { | ||||
| 					const output = {}; | ||||
| 					output[''] = _('Default'); | ||||
| 					output['emacs'] = _('Emacs'); | ||||
| 					output['vim'] = _('Vim'); | ||||
| 					return output; | ||||
| 				}, | ||||
| 			}, | ||||
|  | ||||
| 			'net.customCertificates': { | ||||
| 				value: '', | ||||
| @@ -771,6 +777,10 @@ class Setting extends BaseModel { | ||||
| 		return this.setValue(key, this.value(key) + inc); | ||||
| 	} | ||||
|  | ||||
| 	static toggle(key) { | ||||
| 		return this.setValue(key, !this.value(key)); | ||||
| 	} | ||||
|  | ||||
| 	static setObjectKey(settingKey, objectKey, value) { | ||||
| 		let o = this.value(settingKey); | ||||
| 		if (typeof o !== 'object') o = {}; | ||||
| @@ -1080,7 +1090,6 @@ 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'; | ||||
|   | ||||
| @@ -107,7 +107,7 @@ class ExternalEditWatcher { | ||||
| 						updatedNote.id = id; | ||||
| 						updatedNote.parent_id = note.parent_id; | ||||
| 						await Note.save(updatedNote); | ||||
| 						this.eventEmitter_.emit('noteChange', { id: updatedNote.id }); | ||||
| 						this.eventEmitter_.emit('noteChange', { id: updatedNote.id, note: updatedNote }); | ||||
| 					} | ||||
|  | ||||
| 					this.skipNextChangeEvent_ = {}; | ||||
|   | ||||
| @@ -205,7 +205,7 @@ function shimInit() { | ||||
| 		return Resource.save(resource, { isNew: true }); | ||||
| 	}; | ||||
|  | ||||
| 	shim.attachFileToNote = async function(note, filePath, position = null, options = null) { | ||||
| 	shim.attachFileToNoteBody = async function(noteBody, filePath, position = null, options = null) { | ||||
| 		options = Object.assign({}, { | ||||
| 			createFileURL: false, | ||||
| 		}, options); | ||||
| @@ -223,10 +223,10 @@ function shimInit() { | ||||
| 		const newBody = []; | ||||
|  | ||||
| 		if (position === null) { | ||||
| 			position = note.body ? note.body.length : 0; | ||||
| 			position = noteBody ? noteBody.length : 0; | ||||
| 		} | ||||
|  | ||||
| 		if (note.body && position) newBody.push(note.body.substr(0, position)); | ||||
| 		if (noteBody && position) newBody.push(noteBody.substr(0, position)); | ||||
|  | ||||
| 		if (!options.createFileURL) { | ||||
| 			newBody.push(Resource.markdownTag(resource)); | ||||
| @@ -236,10 +236,17 @@ function shimInit() { | ||||
| 			newBody.push(fileURL); | ||||
| 		} | ||||
|  | ||||
| 		if (note.body) newBody.push(note.body.substr(position)); | ||||
| 		if (noteBody) newBody.push(noteBody.substr(position)); | ||||
|  | ||||
| 		return newBody.join('\n\n'); | ||||
| 	}; | ||||
|  | ||||
| 	shim.attachFileToNote = async function(note, filePath, position = null, options = null) { | ||||
| 		const newBody = await shim.attachFileToNoteBody(note.body, filePath, position, options); | ||||
| 		if (!newBody) return null; | ||||
|  | ||||
| 		const newNote = Object.assign({}, note, { | ||||
| 			body: newBody.join('\n\n'), | ||||
| 			body: newBody, | ||||
| 		}); | ||||
| 		return await Note.save(newNote); | ||||
| 	}; | ||||
|   | ||||
| @@ -4,59 +4,82 @@ | ||||
| 		{ | ||||
| 			"file_exclude_patterns": | ||||
| 			[ | ||||
| 				"*.base64", | ||||
| 				"*.bundle.js", | ||||
| 				"*.eps", | ||||
| 				"*.icns", | ||||
| 				"*.jar", | ||||
| 				"*.map", | ||||
| 				"*.po", | ||||
| 				"*.pot", | ||||
| 				"CliClient/app/lib", | ||||
| 				"CliClient/app/src", | ||||
| 				"locales/*.json", | ||||
| 				"log.txt", | ||||
| 				"package-lock.json", | ||||
| 				"ReactNativeClient/locales/*", | ||||
| 				"src/log.txt", | ||||
| 				"*.min.js", | ||||
| 				"ElectronClient/gui/note-viewer/highlight/*.pack.js", | ||||
| 				"ElectronClient/css/font-awesome.min.css", | ||||
| 				"docs/*.html", | ||||
| 				"docs/*.svg", | ||||
| 				"ReactNativeClient/lib/mime-utils.js", | ||||
| 				"_mydocs/EnexSamples/*.enex", | ||||
| 				"*.min.css", | ||||
| 				"*.min.js", | ||||
| 				"*.bundle.js", | ||||
| 				"yarn.lock", | ||||
| 				"*.icns", | ||||
| 				"*.base64", | ||||
| 				"Podfile.lock", | ||||
| 				"ReactNativeClient/PluginAssetsLoader.js", | ||||
| 				"ElectronClient/gui/NoteText2.js", | ||||
| 				"*.po", | ||||
| 				"*.pot", | ||||
| 				"_mydocs/EnexSamples/*.enex", | ||||
| 				"CliClient/app/lib", | ||||
| 				"CliClient/app/src", | ||||
| 				"docs/*.html", | ||||
| 				"docs/*.svg", | ||||
| 				"ElectronClient/css/font-awesome.min.css", | ||||
| 				"ElectronClient/gui/MultiNoteActions.js", | ||||
| 				"ElectronClient/gui/note-viewer/highlight/*.pack.js", | ||||
| 				"ElectronClient/gui/NoteContentPropertiesDialog.js", | ||||
| 				"ElectronClient/gui/NoteEditor/NoteBody/AceEditor/AceEditor.js", | ||||
| 				"ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/index.js", | ||||
| 				"ElectronClient/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor.js", | ||||
| 				"ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/plugins/lists.js", | ||||
| 				"ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js", | ||||
| 				"ElectronClient/gui/NoteEditor/NoteEditor.js", | ||||
| 				"ElectronClient/gui/NoteEditor/utils/index.js", | ||||
| 				"ElectronClient/gui/NoteToolbar/NoteToolbar.js", | ||||
| 				"ElectronClient/gui/ResourceScreen.js", | ||||
| 				"ElectronClient/gui/ShareNoteDialog.js", | ||||
| 				"ElectronClient/gui/TinyMCE.js", | ||||
| 				"ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js", | ||||
| 				"ReactNativeClient/setUpQuickActions.js", | ||||
| 				"ElectronClient/gui/utils/NoteText.js", | ||||
| 				"ElectronClient/gui/NoteEditor/utils/resourceHandling.js", | ||||
| 				"ElectronClient/gui/NoteEditor/utils/types.js", | ||||
| 				"ElectronClient/gui/NoteEditor/utils/useNoteSearchBar.js", | ||||
| 				"ElectronClient/gui/NoteEditor/utils/useSearchMarkers.js", | ||||
| 				"ElectronClient/gui/NoteEditor/styles/index.js", | ||||
| 				"ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.js", | ||||
| 				"ElectronClient/gui/NoteEditor/utils/useDropHandler.js", | ||||
| 				"ElectronClient/gui/NoteEditor/utils/useMessageHandler.js", | ||||
| 				"ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.js", | ||||
| 				"locales/*.json", | ||||
| 				"log.txt", | ||||
| 				"package-lock.json", | ||||
| 				"Podfile.lock", | ||||
| 				"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", | ||||
| 				"ElectronClient/gui/editors/TinyMCE/plugins/lists.js", | ||||
| 				"ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js", | ||||
| 				"ReactNativeClient/lib/AsyncActionQueue.js", | ||||
| 				"ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/checkbox.js", | ||||
| 				"ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/fence.js", | ||||
| 				"ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/checkbox.js" | ||||
| 				"ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js", | ||||
| 				"ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js", | ||||
| 				"ReactNativeClient/lib/JoplinServerApi.js", | ||||
| 				"ReactNativeClient/lib/mime-utils.js", | ||||
| 				"ReactNativeClient/locales/*", | ||||
| 				"ReactNativeClient/PluginAssetsLoader.js", | ||||
| 				"ReactNativeClient/setUpQuickActions.js", | ||||
| 				"src/log.txt", | ||||
| 				"yarn.lock", | ||||
| 				"ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/types.js", | ||||
| 				"ElectronClient/gui/NoteEditor/NoteBody/AceEditor/styles/index.js", | ||||
| 				"ElectronClient/gui/note-viewer/lib.js", | ||||
| 				"ReactNativeClient/lib/hooks/useImperativeHandlerDebugger.js", | ||||
| 				"ElectronClient/gui/NoteEditor/utils/useMarkupToHtml.js", | ||||
| 				"ElectronClient/gui/NoteEditor/utils/useResourceRefresher.js", | ||||
| 				"ElectronClient/gui/NoteEditor/utils/useFormNote.js", | ||||
| 				"ElectronClient/gui/NoteEditor/utils/useResourceInfos.js" | ||||
| 			], | ||||
| 			"folder_exclude_patterns": | ||||
| 			[ | ||||
| 				"_mydocs/mdtest", | ||||
| 				"_releases", | ||||
| 				"_vieux", | ||||
| 				"ElectronClient/fonts", | ||||
| 				"CliClient/app/lib", | ||||
| 				"CliClient/app/src", | ||||
| 				"CliClient/build", | ||||
| 				"CliClient/locales-build", | ||||
| 				"CliClient/node_modules", | ||||
| 				"CliClient/tests-build", | ||||
| 				"CliClient/tests-build/lib", | ||||
| @@ -64,40 +87,40 @@ | ||||
| 				"CliClient/tests/fuzzing", | ||||
| 				"CliClient/tests/src", | ||||
| 				"CliClient/tests/sync", | ||||
| 				"ElectronClient/dist", | ||||
| 				"CliClient/tests/tmp", | ||||
| 				"Clipper/dist", | ||||
| 				"Clipper/popup/build", | ||||
| 				"ElectronClient/build", | ||||
| 				"ElectronClient/dist", | ||||
| 				"ElectronClient/dist", | ||||
| 				"ElectronClient/dist", | ||||
| 				"ElectronClient/fonts", | ||||
| 				"ElectronClient/gui/note-viewer/highlight/styles", | ||||
| 				"ElectronClient/lib", | ||||
| 				"ElectronClient/locale", | ||||
| 				"ElectronClient/dist", | ||||
| 				"ElectronClient/pluginAssets", | ||||
| 				"Modules/TinyMCE/JoplinLists/dist", | ||||
| 				"Modules/TinyMCE/JoplinLists/lib", | ||||
| 				"Modules/TinyMCE/JoplinLists/scratch", | ||||
| 				"node_modules", | ||||
| 				"ReactNativeClient/android/.gradle", | ||||
| 				"ReactNativeClient/android/.idea", | ||||
| 				"ReactNativeClient/android/app/build", | ||||
| 				"ReactNativeClient/android/build", | ||||
| 				"ReactNativeClient/android/local.properties", | ||||
| 				"ReactNativeClient/node_modules", | ||||
| 				"ReactNativeClient/pluginAssets", | ||||
| 				"ElectronClient/gui/note-viewer/highlight/styles", | ||||
| 				"tests/logs", | ||||
| 				"ReactNativeClient/ios/build", | ||||
| 				"ElectronClient/dist", | ||||
| 				"_releases", | ||||
| 				"ReactNativeClient/lib/csstojs", | ||||
| 				"Clipper/popup/build", | ||||
| 				"Clipper/dist", | ||||
| 				"ReactNativeClient/lib/rnInjectedJs", | ||||
| 				"ReactNativeClient/ios/Pods", | ||||
| 				"CliClient/locales-build", | ||||
| 				"ReactNativeClient/lib/vendor", | ||||
| 				"ReactNativeClient/ios/Joplin-tvOS", | ||||
| 				"ReactNativeClient/ios/Joplin.xcodeproj/project.xcworkspace", | ||||
| 				"ReactNativeClient/ios/Joplin.xcworkspace/xcuserdata", | ||||
| 				"ReactNativeClient/ios/Joplin.xcodeproj/xcuserdata", | ||||
| 				"ElectronClient/pluginAssets", | ||||
| 				"Modules/TinyMCE/JoplinLists/dist", | ||||
| 				"Modules/TinyMCE/JoplinLists/lib", | ||||
| 				"Modules/TinyMCE/JoplinLists/scratch", | ||||
| 				"CliClient/tests/tmp" | ||||
| 				"ReactNativeClient/ios/Joplin.xcworkspace/xcuserdata", | ||||
| 				"ReactNativeClient/ios/Pods", | ||||
| 				"ReactNativeClient/lib/csstojs", | ||||
| 				"ReactNativeClient/lib/rnInjectedJs", | ||||
| 				"ReactNativeClient/lib/vendor", | ||||
| 				"ReactNativeClient/node_modules", | ||||
| 				"ReactNativeClient/pluginAssets", | ||||
| 				"tests/logs", | ||||
| 				"ElectronClient/gui/note-viewer/pluginAssets" | ||||
| 			], | ||||
| 			"path": "." | ||||
| 		} | ||||
|   | ||||
		Reference in New Issue
	
	Block a user