diff --git a/.eslintignore b/.eslintignore
index 496440d9d..4a8e6c5b0 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -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
diff --git a/.eslintrc.js b/.eslintrc.js
index 47d4070bd..189eb673a 100644
--- a/.eslintrc.js
+++ b/.eslintrc.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
diff --git a/.gitignore b/.gitignore
index 05b38f09a..c0a82137e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/Clipper/popup/config/webpack.config.js b/Clipper/popup/config/webpack.config.js
index 50b745f30..46fd4dbf9 100644
--- a/Clipper/popup/config/webpack.config.js
+++ b/Clipper/popup/config/webpack.config.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.:
- //
- // 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.:
+ //
+ // 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,
+ };
};
diff --git a/ElectronClient/InteropServiceHelper.js b/ElectronClient/InteropServiceHelper.js
index 340db816d..741f85b95 100644
--- a/ElectronClient/InteropServiceHelper.js
+++ b/ElectronClient/InteropServiceHelper.js
@@ -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.
diff --git a/ElectronClient/app.js b/ElectronClient/app.js
index ed22a4818..3949eb9cb 100644
--- a/ElectronClient/app.js
+++ b/ElectronClient/app.js
@@ -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,
];
diff --git a/ElectronClient/eventManager.js b/ElectronClient/eventManager.js
index 0df229b39..3ea0e9e3e 100644
--- a/ElectronClient/eventManager.js
+++ b/ElectronClient/eventManager.js
@@ -18,6 +18,10 @@ class EventManager {
return this.emitter_.removeListener(eventName, callback);
}
+ off(eventName, callback) {
+ return this.removeListener(eventName, callback);
+ }
+
}
const eventManager = new EventManager();
diff --git a/ElectronClient/gui/Header.jsx b/ElectronClient/gui/Header.jsx
index 402ed38f6..bb129fb6c 100644
--- a/ElectronClient/gui/Header.jsx
+++ b/ElectronClient/gui/Header.jsx
@@ -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 (
{
+ 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 ?
-
- :
- ;
+ const bodyEditor = this.props.settingEditorCodeView ? 'AceEditor' : 'TinyMCE';
+ const noteTextComp = ;
return (
+ );
+}
+
+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);
diff --git a/ElectronClient/gui/NoteEditor/styles/index.ts b/ElectronClient/gui/NoteEditor/styles/index.ts
new file mode 100644
index 000000000..700d77d65
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/styles/index.ts
@@ -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,
+ },
+ };
+ });
+}
diff --git a/ElectronClient/gui/NoteEditor/utils/index.ts b/ElectronClient/gui/NoteEditor/utils/index.ts
new file mode 100644
index 000000000..9557a4dd7
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/utils/index.ts
@@ -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 {
+ 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 = `\n${newBody}`;
+ }
+
+ return newBody;
+}
+
+export async function formNoteToNote(formNote: FormNote): Promise {
+ return {
+ id: formNote.id,
+ title: formNote.title,
+ body: formNote.body,
+ };
+}
diff --git a/ElectronClient/gui/NoteEditor/utils/resourceHandling.ts b/ElectronClient/gui/NoteEditor/utils/resourceHandling.ts
new file mode 100644
index 000000000..293e15403
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/utils/resourceHandling.ts
@@ -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 {
+ 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);
+}
diff --git a/ElectronClient/gui/NoteEditor/utils/types.ts b/ElectronClient/gui/NoteEditor/utils/types.ts
new file mode 100644
index 000000000..0014cd86d
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/utils/types.ts
@@ -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;
+}
diff --git a/ElectronClient/gui/NoteEditor/utils/useDropHandler.ts b/ElectronClient/gui/NoteEditor/utils/useDropHandler.ts
new file mode 100644
index 000000000..b4a751d1c
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/utils/useDropHandler.ts
@@ -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,
+ },
+ });
+ }
+ }, []);
+}
diff --git a/ElectronClient/gui/NoteEditor/utils/useFormNote.ts b/ElectronClient/gui/NoteEditor/utils/useFormNote.ts
new file mode 100644
index 000000000..fdea86a0a
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/utils/useFormNote.ts
@@ -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(defaultFormNote());
+ const [isNewNote, setIsNewNote] = useState(false);
+ const prevSyncStarted = usePrevious(syncStarted);
+ const previousNoteId = usePrevious(formNote.id);
+ const [resourceInfos, setResourceInfos] = useState({});
+
+ 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 };
+}
diff --git a/ElectronClient/gui/NoteEditor/utils/useMarkupToHtml.ts b/ElectronClient/gui/NoteEditor/utils/useMarkupToHtml.ts
new file mode 100644
index 000000000..e1d7394d3
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/utils/useMarkupToHtml.ts
@@ -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 => {
+ 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]);
+}
diff --git a/ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts b/ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts
new file mode 100644
index 000000000..79df65667
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts
@@ -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]);
+}
diff --git a/ElectronClient/gui/NoteEditor/utils/useNoteSearchBar.ts b/ElectronClient/gui/NoteEditor/utils/useNoteSearchBar.ts
new file mode 100644
index 000000000..c618aa832
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/utils/useNoteSearchBar.ts
@@ -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(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 };
+}
diff --git a/ElectronClient/gui/NoteEditor/utils/useResourceRefresher.js b/ElectronClient/gui/NoteEditor/utils/useResourceRefresher.js
new file mode 100644
index 000000000..2553db78f
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/utils/useResourceRefresher.js
@@ -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
diff --git a/ElectronClient/gui/NoteEditor/utils/useSearchMarkers.ts b/ElectronClient/gui/NoteEditor/utils/useSearchMarkers.ts
new file mode 100644
index 000000000..a168bb93c
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/utils/useSearchMarkers.ts
@@ -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]);
+}
diff --git a/ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.ts b/ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.ts
new file mode 100644
index 000000000..9a3f5787b
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.ts
@@ -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]);
+}
diff --git a/ElectronClient/gui/NoteList.jsx b/ElectronClient/gui/NoteList.jsx
index f31b52014..51cb78aaa 100644
--- a/ElectronClient/gui/NoteList.jsx
+++ b/ElectronClient/gui/NoteList.jsx
@@ -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;
diff --git a/ElectronClient/gui/NoteText.jsx b/ElectronClient/gui/NoteText.jsx
index 2f81afd74..3a0817164 100644
--- a/ElectronClient/gui/NoteText.jsx
+++ b/ElectronClient/gui/NoteText.jsx
@@ -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}
- {toolbar}
+
+ {toolbar}
+
+
{tagList}
{editor}
{viewer}
diff --git a/ElectronClient/gui/NoteText2.tsx b/ElectronClient/gui/NoteText2.tsx
deleted file mode 100644
index e88d36a14..000000000
--- a/ElectronClient/gui/NoteText2.tsx
+++ /dev/null
@@ -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 {
- 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 {
- 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 = `\n${newNote.body}`;
- }
- }
-
- delete newNote.bodyEditorContent;
-
- return newNote;
-}
-
-let resourceCache_:any = {};
-
-function clearResourceCache() {
- resourceCache_ = {};
-}
-
-async function attachedResources(noteBody:string):Promise {
- 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, editorRef:React.MutableRefObject) {
- 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(defaultNote());
- const [defaultEditorState, setDefaultEditorState] = useState({ value: '', markupLanguage: MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, resourceInfos: {} });
- const prevSyncStarted = usePrevious(props.syncStarted);
-
- const editorRef = useRef();
- const titleInputRef = useRef();
- const formNoteRef = useRef();
- 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 => {
- 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 => {
- 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(//, '')
- .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 ;
- }
-
- 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 = ;
- textEditorUtils_ = tinyMceUtils;
- } else if (props.editor === 'PlainEditor') {
- editor = ;
- textEditorUtils_ = plainEditorUtils;
- } else {
- throw new Error(`Invalid editor: ${props.editor}`);
- }
-
- return (
-
-
-
- This is an experimental WYSIWYG editor for evaluation only. Please do not use with important notes as you may lose some data! See the introduction post for more information.
-