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 (
@@ -750,8 +880,8 @@ class MainScreenComponent extends React.Component { const mapStateToProps = state => { return { theme: state.settings.theme, + settingEditorCodeView: state.settings['editor.codeView'], windowCommand: state.windowCommand, - noteVisiblePanes: state.noteVisiblePanes, sidebarVisibility: state.sidebarVisibility, noteListVisibility: state.noteListVisibility, folders: state.folders, @@ -767,6 +897,8 @@ const mapStateToProps = state => { selectedNoteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null, plugins: state.plugins, templates: state.templates, + customCss: state.customCss, + editorNoteStatuses: state.editorNoteStatuses, hasNotesBeingSaved: stateUtils.hasNotesBeingSaved(state), }; }; diff --git a/ElectronClient/gui/NoteContentPropertiesDialog.tsx b/ElectronClient/gui/NoteContentPropertiesDialog.tsx index 80183be97..9e174fd46 100644 --- a/ElectronClient/gui/NoteContentPropertiesDialog.tsx +++ b/ElectronClient/gui/NoteContentPropertiesDialog.tsx @@ -74,7 +74,7 @@ export default function NoteContentPropertiesDialog(props:NoteContentPropertiesD return (
-
{_('Content properties')}
+
{_('Statistics')}
{textComps}
diff --git a/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/AceEditor.tsx b/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/AceEditor.tsx new file mode 100644 index 000000000..c9bde429d --- /dev/null +++ b/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/AceEditor.tsx @@ -0,0 +1,686 @@ +import * as React from 'react'; +import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo } from 'react'; + +// eslint-disable-next-line no-unused-vars +import { EditorCommand, NoteBodyEditorProps } from '../../utils/types'; +import { commandAttachFileToBody } from '../../utils/resourceHandling'; +import { ScrollOptions, ScrollOptionTypes } from '../../utils/types'; +import { textOffsetToCursorPosition, useScrollHandler, usePrevious, lineLeftSpaces, selectionRangeCurrentLine, selectionRangePreviousLine, currentTextOffset, textOffsetSelection, selectedText, useSelectionRange } from './utils'; +import Toolbar from './Toolbar'; +import styles_ from './styles'; +import { RenderedBody, defaultRenderedBody } from './utils/types'; + +const AceEditorReact = require('react-ace').default; +const { bridge } = require('electron').remote.require('./bridge'); +const Note = require('lib/models/Note.js'); +const { clipboard } = require('electron'); +const mimeUtils = require('lib/mime-utils.js').mime; +const Setting = require('lib/models/Setting.js'); +const NoteTextViewer = require('../../../NoteTextViewer.min'); +const shared = require('lib/components/shared/note-screen-shared.js'); +const md5 = require('md5'); +const { shim } = require('lib/shim.js'); +const Menu = bridge().Menu; +const MenuItem = bridge().MenuItem; +const markdownUtils = require('lib/markdownUtils'); +const { _ } = require('lib/locale'); +const { reg } = require('lib/registry.js'); +const dialogs = require('../../../dialogs'); + +require('brace/mode/markdown'); +// https://ace.c9.io/build/kitchen-sink.html +// https://highlightjs.org/static/demo/ +require('brace/theme/chrome'); +require('brace/theme/solarized_light'); +require('brace/theme/solarized_dark'); +require('brace/theme/twilight'); +require('brace/theme/dracula'); +require('brace/theme/chaos'); +require('brace/keybinding/vim'); +require('brace/keybinding/emacs'); + +// TODO: Could not get below code to work + +// @ts-ignore Ace global variable +// const aceGlobal = (ace as any); + +// class CustomHighlightRules extends aceGlobal.acequire( +// 'ace/mode/markdown_highlight_rules' +// ).MarkdownHighlightRules { +// constructor() { +// super(); +// if (Setting.value('markdown.plugin.mark')) { +// this.$rules.start.push({ +// // This is actually a highlight `mark`, but Ace has no token name for +// // this so we made up our own. Reference for common tokens here: +// // https://github.com/ajaxorg/ace/wiki/Creating-or-Extending-an-Edit-Mode#common-tokens +// token: 'highlight_mark', +// regex: '==[^ ](?:.*?[^ ])?==', +// }); +// } +// } +// } + +// /* eslint-disable-next-line no-undef */ +// class CustomMdMode extends aceGlobal.acequire('ace/mode/markdown').Mode { +// constructor() { +// super(); +// this.HighlightRules = CustomHighlightRules; +// } +// } + +function markupRenderOptions(override: any = null) { + return { ...override }; +} + +function AceEditor(props: NoteBodyEditorProps, ref: any) { + const styles = styles_(props); + + const [renderedBody, setRenderedBody] = useState(defaultRenderedBody()); // Viewer content + const [editor, setEditor] = useState(null); + const [lastKeys, setLastKeys] = useState([]); + const [webviewReady, setWebviewReady] = useState(false); + + const previousRenderedBody = usePrevious(renderedBody); + const previousSearchMarkers = usePrevious(props.searchMarkers); + const previousContentKey = usePrevious(props.contentKey); + + const editorRef = useRef(null); + editorRef.current = editor; + const indentOrig = useRef(null); + const webviewRef = useRef(null); + const props_onChangeRef = useRef(null); + props_onChangeRef.current = props.onChange; + const contentKeyHasChangedRef = useRef(false); + contentKeyHasChangedRef.current = previousContentKey !== props.contentKey; + + // The selection range changes all the time, when the caret moves or + // when the selection changes, so it's best not to make it part of the + // state as it would trigger too many unecessary updates. + const selectionRangeRef = useRef(null); + selectionRangeRef.current = useSelectionRange(editor); + + const { resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll } = useScrollHandler(editor, webviewRef, props.onScroll); + + const aceEditor_change = useCallback((newBody: string) => { + props_onChangeRef.current({ changeId: null, content: newBody }); + }, []); + + const wrapSelectionWithStrings = useCallback((string1: string, string2 = '', defaultText = '', replacementText: string = null, byLine = false) => { + if (!editor) return; + + const selection = textOffsetSelection(selectionRangeRef.current, props.content); + + let newBody = props.content; + + if (selection && selection.start !== selection.end) { + const selectedLines = replacementText !== null ? replacementText : props.content.substr(selection.start, selection.end - selection.start); + const selectedStrings = byLine ? selectedLines.split(/\r?\n/) : [selectedLines]; + + newBody = props.content.substr(0, selection.start); + + let startCursorPos, endCursorPos; + + for (let i = 0; i < selectedStrings.length; i++) { + if (byLine == false) { + const start = selectedStrings[i].search(/[^\s]/); + const end = selectedStrings[i].search(/[^\s](?=[\s]*$)/); + newBody += selectedStrings[i].substr(0, start) + string1 + selectedStrings[i].substr(start, end - start + 1) + string2 + selectedStrings[i].substr(end + 1); + // Getting position for correcting offset in highlighted text when surrounded by white spaces + startCursorPos = textOffsetToCursorPosition(selection.start + start, newBody); + endCursorPos = textOffsetToCursorPosition(selection.start + end + 1, newBody); + + } else { newBody += string1 + selectedStrings[i] + string2; } + + } + + newBody += props.content.substr(selection.end); + + const r = selectionRangeRef.current; + + // Because some insertion strings will have newlines, we'll need to account for them + const str1Split = string1.split(/\r?\n/); + + // Add the number of newlines to the row + // and add the length of the final line to the column (for strings with no newlines this is the string length) + + let newRange: any = {}; + if (!byLine) { + // Correcting offset in Highlighted text when surrounded by white spaces + newRange = { + start: { + row: startCursorPos.row, + column: startCursorPos.column + string1.length, + }, + end: { + row: endCursorPos.row, + column: endCursorPos.column + string1.length, + }, + }; + } else { + newRange = { + start: { + row: r.start.row + str1Split.length - 1, + column: r.start.column + str1Split[str1Split.length - 1].length, + }, + end: { + row: r.end.row + str1Split.length - 1, + column: r.end.column + str1Split[str1Split.length - 1].length, + }, + }; + } + + if (replacementText !== null) { + const diff = replacementText.length - (selection.end - selection.start); + newRange.end.column += diff; + } + + setTimeout(() => { + const range = selectionRangeRef.current; + range.setStart(newRange.start.row, newRange.start.column); + range.setEnd(newRange.end.row, newRange.end.column); + editor.getSession().getSelection().setSelectionRange(range, false); + editor.focus(); + }, 10); + } else { + const middleText = replacementText !== null ? replacementText : defaultText; + const textOffset = currentTextOffset(editor, props.content); + const s1 = props.content.substr(0, textOffset); + const s2 = props.content.substr(textOffset); + newBody = s1 + string1 + middleText + string2 + s2; + + const p = textOffsetToCursorPosition(textOffset + string1.length, newBody); + const newRange = { + start: { row: p.row, column: p.column }, + end: { row: p.row, column: p.column + middleText.length }, + }; + + // BUG!! If replacementText contains newline characters, the logic + // to select the new text will not work. + + setTimeout(() => { + if (middleText && newRange) { + const range = selectionRangeRef.current; + range.setStart(newRange.start.row, newRange.start.column); + range.setEnd(newRange.end.row, newRange.end.column); + editor.getSession().getSelection().setSelectionRange(range, false); + } else { + for (let i = 0; i < string1.length; i++) { + editor.getSession().getSelection().moveCursorRight(); + } + } + editor.focus(); + }, 10); + } + + aceEditor_change(newBody); + }, [editor, props.content, aceEditor_change]); + + const addListItem = useCallback((string1, string2 = '', defaultText = '', byLine = false) => { + let newLine = '\n'; + const range = selectionRangeRef.current; + if (!range || (range.start.row === range.end.row && !selectionRangeCurrentLine(range, props.content))) { + newLine = ''; + } + wrapSelectionWithStrings(newLine + string1, string2, defaultText, null, byLine); + }, [wrapSelectionWithStrings, props.content]); + + useImperativeHandle(ref, () => { + return { + content: () => props.content, + setContent: (body: string) => { + aceEditor_change(body); + }, + resetScroll: () => { + resetScroll(); + }, + scrollTo: (options:ScrollOptions) => { + if (options.type === ScrollOptionTypes.Hash) { + if (!webviewRef.current) return; + webviewRef.current.wrappedInstance.send('scrollToHash', options.value as string); + } else if (options.type === ScrollOptionTypes.Percent) { + const p = options.value as number; + setEditorPercentScroll(p); + setViewerPercentScroll(p); + } else { + throw new Error(`Unsupported scroll options: ${options.type}`); + } + }, + clearState: () => { + if (!editor) return; + editor.clearSelection(); + editor.moveCursorTo(0, 0); + }, + execCommand: async (cmd: EditorCommand) => { + if (!editor) return false; + + reg.logger().debug('AceEditor: execCommand', cmd); + + let commandProcessed = true; + + if (cmd.name === 'dropItems') { + if (cmd.value.type === 'notes') { + wrapSelectionWithStrings('', '', '', cmd.value.markdownTags.join('\n')); + } else if (cmd.value.type === 'files') { + const newBody = await commandAttachFileToBody(props.content, cmd.value.paths, { createFileURL: !!cmd.value.createFileURL }); + aceEditor_change(newBody); + } else { + reg.logger().warn('AceEditor: unsupported drop item: ', cmd); + } + } else if (cmd.name === 'focus') { + editor.focus(); + } else { + commandProcessed = false; + } + + if (!commandProcessed) { + const commands: any = { + textBold: () => wrapSelectionWithStrings('**', '**', _('strong text')), + textItalic: () => wrapSelectionWithStrings('*', '*', _('emphasized text')), + textLink: async () => { + const url = await dialogs.prompt(_('Insert Hyperlink')); + if (url) wrapSelectionWithStrings('[', `](${url})`); + }, + textCode: () => { + const selection = textOffsetSelection(selectionRangeRef.current, props.content); + const string = props.content.substr(selection.start, selection.end - selection.start); + + // Look for newlines + const match = string.match(/\r?\n/); + + if (match && match.length > 0) { + if (string.startsWith('```') && string.endsWith('```')) { + wrapSelectionWithStrings('', '', '', string.substr(4, selection.end - selection.start - 8)); + } else { + wrapSelectionWithStrings(`\`\`\`${match[0]}`, `${match[0]}\`\`\``); + } + } else { + wrapSelectionWithStrings('`', '`', ''); + } + }, + insertText: (value: any) => wrapSelectionWithStrings(value), + attachFile: async () => { + const selection = textOffsetSelection(selectionRangeRef.current, props.content); + const newBody = await commandAttachFileToBody(props.content, null, { position: selection ? selection.start : 0 }); + if (newBody) aceEditor_change(newBody); + }, + textNumberedList: () => { + let bulletNumber = markdownUtils.olLineNumber(selectionRangeCurrentLine(selectionRangeRef.current, props.content)); + if (!bulletNumber) bulletNumber = markdownUtils.olLineNumber(selectionRangePreviousLine(selectionRangeRef.current, props.content)); + if (!bulletNumber) bulletNumber = 0; + addListItem(`${bulletNumber + 1}. `, '', _('List item'), true); + }, + textBulletedList: () => addListItem('- ', '', _('List item'), true), + textCheckbox: () => addListItem('- [ ] ', '', _('List item'), true), + textHeading: () => addListItem('## ','','', true), + textHorizontalRule: () => addListItem('* * *'), + }; + + if (commands[cmd.name]) { + commands[cmd.name](cmd.value); + } else { + reg.logger().warn('AceEditor: unsupported Joplin command: ', cmd); + return false; + } + } + + return true; + }, + }; + }, [editor, props.content, addListItem, wrapSelectionWithStrings, selectionRangeCurrentLine, aceEditor_change, setEditorPercentScroll, setViewerPercentScroll, resetScroll, renderedBody]); + + const onEditorPaste = useCallback(async (event: any = null) => { + const formats = clipboard.availableFormats(); + for (let i = 0; i < formats.length; i++) { + const format = formats[i].toLowerCase(); + const formatType = format.split('/')[0]; + + const position = currentTextOffset(editor, props.content); + + if (formatType === 'image') { + if (event) event.preventDefault(); + + const image = clipboard.readImage(); + + const fileExt = mimeUtils.toFileExtension(format); + const filePath = `${Setting.value('tempDir')}/${md5(Date.now())}.${fileExt}`; + + await shim.writeImageToFile(image, format, filePath); + const newBody = await commandAttachFileToBody(props.content, [filePath], { position }); + await shim.fsDriver().remove(filePath); + + aceEditor_change(newBody); + } + } + }, [editor, props.content, aceEditor_change]); + + const onEditorKeyDown = useCallback((event: any) => { + setLastKeys(prevLastKeys => { + const keys = prevLastKeys.slice(); + keys.push(event.key); + while (keys.length > 2) keys.splice(0, 1); + return keys; + }); + }, []); + + const editorCutText = useCallback(() => { + const text = selectedText(selectionRangeRef.current, props.content); + if (!text) return; + + clipboard.writeText(text); + + const s = textOffsetSelection(selectionRangeRef.current, props.content); + if (!s || s.start === s.end) return; + + const s1 = props.content.substr(0, s.start); + const s2 = props.content.substr(s.end); + + aceEditor_change(s1 + s2); + + setTimeout(() => { + const range = selectionRangeRef.current; + range.setStart(range.start.row, range.start.column); + range.setEnd(range.start.row, range.start.column); + editor.getSession().getSelection().setSelectionRange(range, false); + editor.focus(); + }, 10); + }, [props.content, editor, aceEditor_change]); + + const editorCopyText = useCallback(() => { + const text = selectedText(selectionRangeRef.current, props.content); + clipboard.writeText(text); + }, [props.content]); + + const editorPasteText = useCallback(() => { + wrapSelectionWithStrings(clipboard.readText(), '', '', ''); + }, [wrapSelectionWithStrings]); + + const onEditorContextMenu = useCallback(() => { + const menu = new Menu(); + + const hasSelectedText = !!selectedText(selectionRangeRef.current, props.content); + const clipboardText = clipboard.readText(); + + menu.append( + new MenuItem({ + label: _('Cut'), + enabled: hasSelectedText, + click: async () => { + editorCutText(); + }, + }) + ); + + menu.append( + new MenuItem({ + label: _('Copy'), + enabled: hasSelectedText, + click: async () => { + editorCopyText(); + }, + }) + ); + + menu.append( + new MenuItem({ + label: _('Paste'), + enabled: true, + click: async () => { + if (clipboardText) { + editorPasteText(); + } else { + // To handle pasting images + onEditorPaste(); + } + }, + }) + ); + + menu.popup(bridge().window()); + }, [props.content, editorCutText, editorPasteText, editorCopyText, onEditorPaste]); + + function aceEditor_load(editor: any) { + setEditor(editor); + } + + useEffect(() => { + if (!editor) return () => {}; + + editor.indent = indentOrig.current; + + const cancelledKeys = []; + const letters = ['F', 'T', 'P', 'Q', 'L', ',', 'G', 'K']; + for (let i = 0; i < letters.length; i++) { + const l = letters[i]; + cancelledKeys.push(`Ctrl+${l}`); + cancelledKeys.push(`Command+${l}`); + } + cancelledKeys.push('Alt+E'); + + for (let i = 0; i < cancelledKeys.length; i++) { + const k = cancelledKeys[i]; + editor.commands.bindKey(k, () => { + // HACK: Ace doesn't seem to provide a way to override its shortcuts, but throwing + // an exception from this undocumented function seems to cancel it without any + // side effect. + // https://stackoverflow.com/questions/36075846 + throw new Error(`HACK: Overriding Ace Editor shortcut: ${k}`); + }); + } + + document.querySelector('#note-editor').addEventListener('paste', onEditorPaste, true); + document.querySelector('#note-editor').addEventListener('keydown', onEditorKeyDown); + document.querySelector('#note-editor').addEventListener('contextmenu', onEditorContextMenu); + + // Disable Markdown auto-completion (eg. auto-adding a dash after a line with a dash. + // https://github.com/ajaxorg/ace/issues/2754 + // @ts-ignore: Keep the function signature as-is despite unusued arguments + editor.getSession().getMode().getNextLineIndent = function(state: any, line: string) { + const ls = lastKeys; + if (ls.length >= 2 && ls[ls.length - 1] === 'Enter' && ls[ls.length - 2] === 'Enter') return this.$getIndent(line); + + const leftSpaces = lineLeftSpaces(line); + const lineNoLeftSpaces = line.trimLeft(); + + if (lineNoLeftSpaces.indexOf('- [ ] ') === 0 || lineNoLeftSpaces.indexOf('- [x] ') === 0 || lineNoLeftSpaces.indexOf('- [X] ') === 0) return `${leftSpaces}- [ ] `; + if (lineNoLeftSpaces.indexOf('- ') === 0) return `${leftSpaces}- `; + if (lineNoLeftSpaces.indexOf('* ') === 0 && line.trim() !== '* * *') return `${leftSpaces}* `; + + const bulletNumber = markdownUtils.olLineNumber(lineNoLeftSpaces); + if (bulletNumber) return `${leftSpaces + (bulletNumber + 1)}. `; + + return this.$getIndent(line); + }; + + return () => { + document.querySelector('#note-editor').removeEventListener('paste', onEditorPaste, true); + document.querySelector('#note-editor').removeEventListener('keydown', onEditorKeyDown); + document.querySelector('#note-editor').removeEventListener('contextmenu', onEditorContextMenu); + }; + }, [editor, onEditorPaste, onEditorContextMenu, lastKeys]); + + useEffect(() => { + if (!editor) return; + + // Markdown list indentation. (https://github.com/laurent22/joplin/pull/2713) + // If the current line starts with `markup.list` token, + // hitting `Tab` key indents the line instead of inserting tab at cursor. + indentOrig.current = editor.indent; + const localIndentOrig = indentOrig.current; + editor.indent = function() { + const range = selectionRangeRef.current; + if (range.isEmpty()) { + const row = range.start.row; + const tokens = this.session.getTokens(row); + + if (tokens.length > 0 && tokens[0].type == 'markup.list') { + if (tokens[0].value.search(/\d+\./) != -1) { + // Resets numbered list to 1. + this.session.replace({ start: { row, column: 0 }, end: { row, column: tokens[0].value.length } }, + tokens[0].value.replace(/\d+\./, '1.')); + } + + this.session.indentRows(row, row, '\t'); + return; + } + } + + localIndentOrig.call(this); + }; + }, [editor]); + + const webview_domReady = useCallback(() => { + setWebviewReady(true); + }, []); + + const webview_ipcMessage = useCallback((event: any) => { + const msg = event.channel ? event.channel : ''; + const args = event.args; + const arg0 = args && args.length >= 1 ? args[0] : null; + + if (msg.indexOf('checkboxclick:') === 0) { + const newBody = shared.toggleCheckbox(msg, props.content); + aceEditor_change(newBody); + } else if (msg === 'percentScroll') { + setEditorPercentScroll(arg0); + } else { + props.onMessage(event); + } + }, [props.onMessage, props.content, aceEditor_change]); + + useEffect(() => { + let cancelled = false; + + const interval = contentKeyHasChangedRef.current ? 0 : 500; + + const timeoutId = setTimeout(async () => { + let bodyToRender = props.content; + + if (!bodyToRender.trim() && props.visiblePanes.indexOf('viewer') >= 0 && props.visiblePanes.indexOf('editor') < 0) { + // Fixes https://github.com/laurent22/joplin/issues/217 + bodyToRender = `${_('This note has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout'))}`; + } + + const result = await props.markupToHtml(props.contentMarkupLanguage, bodyToRender, markupRenderOptions({ resourceInfos: props.resourceInfos })); + if (cancelled) return; + setRenderedBody(result); + }, interval); + + return () => { + cancelled = true; + clearTimeout(timeoutId); + }; + }, [props.content, props.contentMarkupLanguage, props.visiblePanes, props.resourceInfos]); + + useEffect(() => { + if (!webviewReady) return; + + const options: any = { + pluginAssets: renderedBody.pluginAssets, + downloadResources: Setting.value('sync.resourceDownloadMode'), + }; + webviewRef.current.wrappedInstance.send('setHtml', renderedBody.html, options); + }, [renderedBody, webviewReady]); + + useEffect(() => { + if (props.searchMarkers !== previousSearchMarkers || renderedBody !== previousRenderedBody) { + webviewRef.current.wrappedInstance.send('setMarkers', props.searchMarkers.keywords, props.searchMarkers.options); + } + }, [props.searchMarkers, renderedBody]); + + const cellEditorStyle = useMemo(() => { + const output = { ...styles.cellEditor }; + if (!props.visiblePanes.includes('editor')) { + // Note: Ideally we'd set the display to "none" to take the editor out + // of the DOM but if we do that, certain things won't work, in particular + // things related to scroll, which are based on the editor. + output.width = 1; + output.maxWidth = 1; + output.position = 'absolute'; + output.left = -100000; + } + return output; + }, [styles.cellEditor, props.visiblePanes]); + + const cellViewerStyle = useMemo(() => { + const output = { ...styles.cellViewer }; + if (!props.visiblePanes.includes('viewer')) { + // Note: setting webview.display to "none" is currently not supported due + // to this bug: https://github.com/electron/electron/issues/8277 + // So instead setting the width 0. + output.width = 1; + output.maxWidth = 1; + } else if (!props.visiblePanes.includes('editor')) { + output.borderLeftStyle = 'none'; + } + return output; + }, [styles.cellViewer, props.visiblePanes]); + + function renderEditor() { + return ( +
+ +
+ ); + } + + function renderViewer() { + return ( +
+ +
+ ); + } + + return ( +
+
+ + {props.noteToolbar} +
+
+ {renderEditor()} + {renderViewer()} +
+
+ ); +} + +export default forwardRef(AceEditor); + diff --git a/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.tsx b/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.tsx new file mode 100644 index 000000000..685f760a2 --- /dev/null +++ b/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.tsx @@ -0,0 +1,166 @@ +import * as React from 'react'; + +const ToolbarBase = require('../../../Toolbar.min.js'); +const { _ } = require('lib/locale'); +const { buildStyle } = require('../../../../theme.js'); + +interface ToolbarProps { + theme: number, + dispatch: Function, +} + +function styles_(props:ToolbarProps) { + return buildStyle('AceEditorToolbar', props.theme, (/* theme:any*/) => { + return { + root: { + // marginTop: 4, + marginBottom: 0, + }, + }; + }); +} + +export default function Toolbar(props:ToolbarProps) { + const styles = styles_(props); + + function createToolbarItems() { + const toolbarItems = []; + + toolbarItems.push({ + tooltip: _('Bold'), + iconName: 'fa-bold', + onClick: () => { + props.dispatch({ + type: 'WINDOW_COMMAND', + name: 'textBold', + }); + }, + }); + + toolbarItems.push({ + tooltip: _('Italic'), + iconName: 'fa-italic', + onClick: () => { + props.dispatch({ + type: 'WINDOW_COMMAND', + name: 'textItalic', + }); + }, + }); + + toolbarItems.push({ + type: 'separator', + }); + + toolbarItems.push({ + tooltip: _('Hyperlink'), + iconName: 'fa-link', + onClick: () => { + props.dispatch({ + type: 'WINDOW_COMMAND', + name: 'textLink', + }); + }, + }); + + toolbarItems.push({ + tooltip: _('Code'), + iconName: 'fa-code', + onClick: () => { + props.dispatch({ + type: 'WINDOW_COMMAND', + name: 'textCode', + }); + }, + }); + + toolbarItems.push({ + tooltip: _('Attach file'), + iconName: 'fa-paperclip', + onClick: () => { + props.dispatch({ + type: 'WINDOW_COMMAND', + name: 'attachFile', + }); + }, + }); + + toolbarItems.push({ + type: 'separator', + }); + + toolbarItems.push({ + tooltip: _('Numbered List'), + iconName: 'fa-list-ol', + onClick: () => { + props.dispatch({ + type: 'WINDOW_COMMAND', + name: 'textNumberedList', + }); + }, + }); + + toolbarItems.push({ + tooltip: _('Bulleted List'), + iconName: 'fa-list-ul', + onClick: () => { + props.dispatch({ + type: 'WINDOW_COMMAND', + name: 'textBulletedList', + }); + }, + }); + + toolbarItems.push({ + tooltip: _('Checkbox'), + iconName: 'fa-check-square', + onClick: () => { + props.dispatch({ + type: 'WINDOW_COMMAND', + name: 'textCheckbox', + }); + }, + }); + + toolbarItems.push({ + tooltip: _('Heading'), + iconName: 'fa-header', + onClick: () => { + props.dispatch({ + type: 'WINDOW_COMMAND', + name: 'textHeading', + }); + }, + }); + + toolbarItems.push({ + tooltip: _('Horizontal Rule'), + iconName: 'fa-ellipsis-h', + onClick: () => { + props.dispatch({ + type: 'WINDOW_COMMAND', + name: 'textHorizontalRule', + }); + }, + }); + + toolbarItems.push({ + tooltip: _('Insert Date Time'), + iconName: 'fa-calendar-plus-o', + onClick: () => { + props.dispatch({ + type: 'WINDOW_COMMAND', + name: 'insertDateTime', + }); + }, + }); + + toolbarItems.push({ + type: 'separator', + }); + + return toolbarItems; + } + + return ; +} diff --git a/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/styles/index.ts b/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/styles/index.ts new file mode 100644 index 000000000..b5e421027 --- /dev/null +++ b/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/styles/index.ts @@ -0,0 +1,60 @@ +import { NoteBodyEditorProps } from '../../../utils/types'; +const { buildStyle } = require('../../../../../theme.js'); + +export default function styles(props: NoteBodyEditorProps) { + return buildStyle('AceEditor', props.theme, (theme: any) => { + return { + root: { + position: 'relative', + display: 'flex', + flexDirection: 'column', + ...props.style, + }, + rowToolbar: { + position: 'relative', + display: 'flex', + flexDirection: 'row', + }, + rowEditorViewer: { + position: 'relative', + display: 'flex', + flexDirection: 'row', + flex: 1, + paddingTop: 10, + }, + cellEditor: { + position: 'relative', + display: 'flex', + flex: 1, + }, + cellViewer: { + position: 'relative', + display: 'flex', + flex: 1, + borderLeftWidth: 1, + borderLeftColor: theme.dividerColor, + borderLeftStyle: 'solid', + }, + viewer: { + display: 'flex', + overflow: 'hidden', + verticalAlign: 'top', + boxSizing: 'border-box', + width: '100%', + }, + editor: { + display: 'flex', + width: 'auto', + height: 'auto', + flex: 1, + overflowY: 'hidden', + paddingTop: 0, + lineHeight: `${theme.textAreaLineHeight}px`, + fontSize: `${theme.editorFontSize}px`, + color: theme.color, + backgroundColor: theme.backgroundColor, + editorTheme: theme.editorTheme, // Defined in theme.js + }, + }; + }); +} diff --git a/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/index.ts b/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/index.ts new file mode 100644 index 000000000..eb7815238 --- /dev/null +++ b/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/index.ts @@ -0,0 +1,241 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; + +export function cursorPositionToTextOffset(cursorPos: any, body: string) { + if (!body) return 0; + + const noteLines = body.split('\n'); + + let pos = 0; + for (let i = 0; i < noteLines.length; i++) { + if (i > 0) pos++; // Need to add the newline that's been removed in the split() call above + + if (i === cursorPos.row) { + pos += cursorPos.column; + break; + } else { + pos += noteLines[i].length; + } + } + + return pos; +} + +export function currentTextOffset(editor: any, body: string) { + return cursorPositionToTextOffset(editor.getCursorPosition(), body); +} + +export function rangeToTextOffsets(range: any, body: string) { + return { + start: cursorPositionToTextOffset(range.start, body), + end: cursorPositionToTextOffset(range.end, body), + }; +} + +export function textOffsetSelection(selectionRange: any, body: string) { + return selectionRange && body ? rangeToTextOffsets(selectionRange, body) : null; +} + +export function selectedText(selectionRange: any, body: string) { + const selection = textOffsetSelection(selectionRange, body); + if (!selection || selection.start === selection.end) return ''; + + return body.substr(selection.start, selection.end - selection.start); +} + +export function useSelectionRange(editor: any) { + const [selectionRange, setSelectionRange] = useState(null); + + useEffect(() => { + if (!editor) return () => {}; + + function updateSelection() { + const ranges = editor.getSelection().getAllRanges(); + const firstRange = ranges && ranges.length ? ranges[0] : null; + setSelectionRange(firstRange); + + // if (process.platform === 'linux') { + // const textRange = this.textOffsetSelection(); + // if (textRange.start != textRange.end) { + // clipboard.writeText(this.state.note.body.slice( + // Math.min(textRange.start, textRange.end), + // Math.max(textRange.end, textRange.start)), 'selection'); + // } + // } + } + + function onSelectionChange() { + updateSelection(); + } + + function onFocus() { + updateSelection(); + } + + editor.getSession().selection.on('changeSelection', onSelectionChange); + editor.on('focus', onFocus); + + return () => { + editor.getSession().selection.off('changeSelection', onSelectionChange); + editor.off('focus', onFocus); + }; + }, [editor]); + + return selectionRange; +} + +export function textOffsetToCursorPosition(offset: number, body: string) { + const lines = body.split('\n'); + let row = 0; + let currentOffset = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (currentOffset + line.length >= offset) { + return { + row: row, + column: offset - currentOffset, + }; + } + + row++; + currentOffset += line.length + 1; + } + + return null; +} + +function lineAtRow(body: string, row: number) { + if (!body) return ''; + const lines = body.split('\n'); + if (row < 0 || row >= lines.length) return ''; + return lines[row]; +} + +export function selectionRangeCurrentLine(selectionRange: any, body: string) { + if (!selectionRange) return ''; + return lineAtRow(body, selectionRange.start.row); +} + +export function selectionRangePreviousLine(selectionRange: any, body: string) { + if (!selectionRange) return ''; + return lineAtRow(body, selectionRange.start.row - 1); +} + +export function lineLeftSpaces(line: string) { + let output = ''; + for (let i = 0; i < line.length; i++) { + if ([' ', '\t'].indexOf(line[i]) >= 0) { + output += line[i]; + } else { + break; + } + } + return output; +} + +export function usePrevious(value: any): any { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; +} + +export function useScrollHandler(editor: any, webviewRef: any, onScroll: Function) { + const editorMaxScrollTop_ = useRef(0); + const restoreScrollTop_ = useRef(null); + const ignoreNextEditorScrollEvent_ = useRef(false); + const scrollTimeoutId_ = useRef(null); + + // TODO: Below is not needed anymore???? + // + // this.editorMaxScrollTop_ = 0; + // // HACK: To go around a bug in Ace editor, we first set the scroll position to 1 + // // and then (in the renderer callback) to the value we actually need. The first + // // operation helps clear the scroll position cache. See: + // // + // this.editorSetScrollTop(1); + // this.restoreScrollTop_ = 0; + + const editorSetScrollTop = useCallback((v) => { + if (!editor) return; + editor.getSession().setScrollTop(v); + }, [editor]); + + // Complicated but reliable method to get editor content height + // https://github.com/ajaxorg/ace/issues/2046 + const onAfterEditorRender = useCallback(() => { + const r = editor.renderer; + editorMaxScrollTop_.current = Math.max(0, r.layerConfig.maxHeight - r.$size.scrollerHeight); + + if (restoreScrollTop_.current !== null) { + editorSetScrollTop(restoreScrollTop_.current); + restoreScrollTop_.current = null; + } + }, [editor, editorSetScrollTop]); + + const scheduleOnScroll = useCallback((event: any) => { + if (scrollTimeoutId_.current) { + clearTimeout(scrollTimeoutId_.current); + scrollTimeoutId_.current = null; + } + + scrollTimeoutId_.current = setTimeout(() => { + scrollTimeoutId_.current = null; + onScroll(event); + }, 10); + }, [onScroll]); + + const setEditorPercentScroll = useCallback((p: number) => { + ignoreNextEditorScrollEvent_.current = true; + editorSetScrollTop(p * editorMaxScrollTop_.current); + scheduleOnScroll({ percent: p }); + }, [editorSetScrollTop, scheduleOnScroll]); + + const setViewerPercentScroll = useCallback((p: number) => { + if (webviewRef.current) { + webviewRef.current.wrappedInstance.send('setPercentScroll', p); + scheduleOnScroll({ percent: p }); + } + }, [scheduleOnScroll]); + + const editor_scroll = useCallback(() => { + if (ignoreNextEditorScrollEvent_.current) { + ignoreNextEditorScrollEvent_.current = false; + return; + } + + const m = editorMaxScrollTop_.current; + const percent = m ? editor.getSession().getScrollTop() / m : 0; + + setViewerPercentScroll(percent); + }, [editor, setViewerPercentScroll]); + + const resetScroll = useCallback(() => { + if (!editor) return; + + // Ace Editor caches scroll values, which makes + // it hard to reset the scroll position, so we + // need to use this hack. + // https://github.com/ajaxorg/ace/issues/2195 + editor.session.$scrollTop = -1; + editor.session.$scrollLeft = -1; + editor.renderer.scrollTop = -1; + editor.renderer.scrollLeft = -1; + editor.renderer.scrollBarV.scrollTop = -1; + editor.renderer.scrollBarH.scrollLeft = -1; + editor.session.setScrollTop(0); + editor.session.setScrollLeft(0); + }, [editorSetScrollTop, editor]); + + useEffect(() => { + if (!editor) return () => {}; + + editor.renderer.on('afterRender', onAfterEditorRender); + + return () => { + editor.renderer.off('afterRender', onAfterEditorRender); + }; + }, [editor]); + + return { resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll }; +} diff --git a/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/types.ts b/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/types.ts new file mode 100644 index 000000000..79c6bb8e3 --- /dev/null +++ b/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/types.ts @@ -0,0 +1,11 @@ +export interface RenderedBody { + html: string; + pluginAssets: any[]; +} + +export function defaultRenderedBody(): RenderedBody { + return { + html: '', + pluginAssets: [], + }; +} diff --git a/ElectronClient/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor.js b/ElectronClient/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor.js new file mode 100644 index 000000000..21c57cb4a --- /dev/null +++ b/ElectronClient/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor.js @@ -0,0 +1,24 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { value: true }); +const React = require('react'); +const react_1 = require('react'); +const PlainEditor = (props, ref) => { + const editorRef = react_1.useRef(); + react_1.useImperativeHandle(ref, () => { + return { + content: () => '', + }; + }, []); + react_1.useEffect(() => { + if (!editorRef.current) { return; } + editorRef.current.value = props.defaultEditorState.value; + }, [props.defaultEditorState]); + const onChange = react_1.useCallback((event) => { + props.onChange({ changeId: null, content: event.target.value }); + }, [props.onWillChange, props.onChange]); + return (React.createElement('div', { style: props.style }, + React.createElement('textarea', { ref: editorRef, style: { width: '100%', height: '100%' }, defaultValue: props.defaultEditorState.value, onChange: onChange }), + ';')); +}; +exports.default = react_1.forwardRef(PlainEditor); +// # sourceMappingURL=PlainEditor.js.map diff --git a/ElectronClient/gui/editors/PlainEditor.tsx b/ElectronClient/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor_tsx.txt similarity index 79% rename from ElectronClient/gui/editors/PlainEditor.tsx rename to ElectronClient/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor_tsx.txt index 0aeb55487..c090f4960 100644 --- a/ElectronClient/gui/editors/PlainEditor.tsx +++ b/ElectronClient/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor_tsx.txt @@ -1,9 +1,8 @@ +// Kept only for reference + import * as React from 'react'; import { useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react'; -// eslint-disable-next-line no-unused-vars -import { DefaultEditorState, TextEditorUtils } from '../utils/NoteText'; - export interface OnChangeEvent { changeId: number, content: any, @@ -13,18 +12,11 @@ interface PlainEditorProps { style: any, onChange(event: OnChangeEvent): void, onWillChange(event:any): void, - defaultEditorState: DefaultEditorState, markupToHtml: Function, attachResources: Function, disabled: boolean, } -export const utils:TextEditorUtils = { - editorContentToHtml(content:any):Promise { - return content ? content : ''; - }, -}; - const PlainEditor = (props:PlainEditorProps, ref:any) => { const editorRef = useRef(); diff --git a/ElectronClient/gui/editors/TinyMCE.tsx b/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx similarity index 88% rename from ElectronClient/gui/editors/TinyMCE.tsx rename to ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx index c65350d72..89650939d 100644 --- a/ElectronClient/gui/editors/TinyMCE.tsx +++ b/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx @@ -1,29 +1,13 @@ import * as React from 'react'; import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react'; - -// eslint-disable-next-line no-unused-vars -import { DefaultEditorState, OnChangeEvent, TextEditorUtils, EditorCommand, resourcesStatus } from '../utils/NoteText'; - +import { EditorCommand, NoteBodyEditorProps } from '../../utils/types'; +import { resourcesStatus } from '../../utils/resourceHandling'; const { MarkupToHtml } = require('lib/joplin-renderer'); const taboverride = require('taboverride'); const { reg } = require('lib/registry.js'); const { _ } = require('lib/locale'); const BaseItem = require('lib/models/BaseItem'); -const { themeStyle, buildStyle } = require('../../theme.js'); - -interface TinyMCEProps { - style: any, - theme: number, - onChange(event: OnChangeEvent): void, - onWillChange(event:any): void, - onMessage(event:any): void, - defaultEditorState: DefaultEditorState, - markupToHtml: Function, - allAssets: Function, - attachResources: Function, - joplinHtml: Function, - disabled: boolean, -} +const { themeStyle, buildStyle } = require('../../../../theme.js'); function markupRenderOptions(override:any = null) { return { @@ -35,6 +19,7 @@ function markupRenderOptions(override:any = null) { linkRenderingType: 2, }, }, + replaceResourceInternalToExternalLinks: true, ...override, }; } @@ -104,12 +89,6 @@ function enableTextAreaTab(enable:boolean) { } } -export const utils:TextEditorUtils = { - editorContentToHtml(content:any):Promise { - return content ? content : ''; - }, -}; - interface TinyMceCommand { name: string, value?: any, @@ -127,7 +106,7 @@ const joplinCommandToTinyMceCommands:JoplinCommandToTinyMceCommands = { 'search': { name: 'SearchReplace' }, }; -function styles_(props:TinyMCEProps) { +function styles_(props:NoteBodyEditorProps) { return buildStyle('TinyMCE', props.theme, (/* theme:any */) => { return { disabledOverlay: { @@ -155,7 +134,7 @@ let loadedAssetFiles_:string[] = []; let dispatchDidUpdateIID_:any = null; let changeId_:number = 1; -const TinyMCE = (props:TinyMCEProps, ref:any) => { +const TinyMCE = (props:NoteBodyEditorProps, ref:any) => { const [editor, setEditor] = useState(null); const [scriptLoaded, setScriptLoaded] = useState(false); const [editorReady, setEditorReady] = useState(false); @@ -166,9 +145,6 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { const markupToHtml = useRef(null); markupToHtml.current = props.markupToHtml; - const joplinHtml = useRef(null); - joplinHtml.current = props.joplinHtml; - const rootIdRef = useRef(`tinymce-${Date.now()}${Math.round(Math.random() * 10000)}`); const editorRef = useRef(null); editorRef.current = editor; @@ -194,16 +170,16 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { if (nodeName === 'A' && (event.ctrlKey || event.metaKey)) { const href = event.target.getAttribute('href'); - const joplinUrl = href.indexOf('joplin://') === 0 ? href : null; + // const joplinUrl = href.indexOf('joplin://') === 0 ? href : null; - if (joplinUrl) { - props.onMessage({ - name: 'openInternal', - args: { - url: joplinUrl, - }, - }); - } else if (href.indexOf('#') === 0) { + // if (joplinUrl) { + // props.onMessage({ + // name: 'openInternal', + // args: { + // url: joplinUrl, + // }, + // }); + if (href.indexOf('#') === 0) { const anchorName = href.substr(1); const anchor = editor.getDoc().getElementById(anchorName); if (anchor) { @@ -213,7 +189,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { } } else { props.onMessage({ - name: 'openExternal', + name: 'openUrl', args: { url: href, }, @@ -224,7 +200,22 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { useImperativeHandle(ref, () => { return { - content: () => editor ? editor.getContent() : '', + content: async () => { + if (!editorRef.current) return ''; + return prop_htmlToMarkdownRef.current(props.contentMarkupLanguage, editorRef.current.getContent(), props.contentOriginalCss); + }, + setContent: (/* body: string*/) => { + console.warn('TinyMCE::setContent - not implemented'); + }, + resetScroll: () => { + console.warn('TinyMCE::resetScroll - not implemented'); + }, + scrollTo: (/* options:ScrollOptions*/) => { + console.warn('TinyMCE::scrollTo - not implemented'); + }, + clearState: () => { + console.warn('TinyMCE::clearState - not implemented'); + }, execCommand: async (cmd:EditorCommand) => { if (!editor) return false; @@ -257,7 +248,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { return true; }, }; - }, [editor]); + }, [editor, props.contentMarkupLanguage, props.contentOriginalCss]); // ----------------------------------------------------------------------------------------- // Load the TinyMCE library. The lib loads additional JS and CSS files on startup @@ -303,7 +294,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { loaded: false, }, { - src: 'gui/editors/TinyMCE/plugins/lists.js', + src: 'gui/NoteEditor/NoteBody/TinyMCE/plugins/lists.js', id: 'tinyMceListsPluginScript', loaded: false, }, @@ -440,7 +431,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { height: '100%', resize: false, icons: 'Joplin', - icons_url: 'gui/editors/TinyMCE/icons.js', + icons_url: 'gui/NoteEditor/NoteBody/TinyMCE/icons.js', plugins: 'noneditable link joplinLists hr searchreplace codesample table', noneditable_noneditable_class: 'joplin-editable', // Can be a regex too valid_elements: '*[*]', // We already filter in sanitize_html @@ -524,7 +515,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { const html = []; for (const resource of resources) { - const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, resource.markdownTag, { bodyOnly: true }); + const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, resource.markdownTag, markupRenderOptions({ bodyOnly: true })); html.push(result.html); } @@ -597,7 +588,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { .map((a:any) => a.path) ).filter((path:string) => !loadedAssetFiles_.includes(path)); - const jsFiles = ['gui/editors/TinyMCE/content_script.js'].concat( + const jsFiles = ['gui/NoteEditor/NoteBody/TinyMCE/content_script.js'].concat( pluginAssets .filter((a:any) => a.mime === 'application/javascript') .map((a:any) => a.path) @@ -628,7 +619,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { useEffect(() => { if (!editor) return () => {}; - if (resourcesStatus(props.defaultEditorState.resourceInfos) !== 'ready') { + if (resourcesStatus(props.resourceInfos) !== 'ready') { editor.setContent(''); return () => {}; } @@ -636,12 +627,12 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { let cancelled = false; const loadContent = async () => { - const result = await props.markupToHtml(props.defaultEditorState.markupLanguage, props.defaultEditorState.value, markupRenderOptions()); + const result = await props.markupToHtml(props.contentMarkupLanguage, props.content, markupRenderOptions({ resourceInfos: props.resourceInfos })); if (cancelled) return; editor.setContent(result.html); - await loadDocumentAssets(editor, await props.allAssets(props.defaultEditorState.markupLanguage)); + await loadDocumentAssets(editor, await props.allAssets(props.contentMarkupLanguage)); editor.getDoc().addEventListener('click', onEditorContentClick); @@ -661,7 +652,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { cancelled = true; editor.getDoc().removeEventListener('click', onEditorContentClick); }; - }, [editor, props.markupToHtml, props.allAssets, props.defaultEditorState, onEditorContentClick]); + }, [editor, props.markupToHtml, props.allAssets, onEditorContentClick, props.resourceInfos]); // ----------------------------------------------------------------------------------------- // Handle onChange event @@ -673,6 +664,9 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { const props_onChangeRef = useRef(); props_onChangeRef.current = props.onChange; + const prop_htmlToMarkdownRef = useRef(); + prop_htmlToMarkdownRef.current = props.htmlToMarkdown; + useEffect(() => { if (!editor) return () => {}; @@ -684,14 +678,16 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { if (onChangeHandlerIID) clearTimeout(onChangeHandlerIID); - onChangeHandlerIID = setTimeout(() => { + onChangeHandlerIID = setTimeout(async () => { onChangeHandlerIID = null; + const contentMd = await prop_htmlToMarkdownRef.current(props.contentMarkupLanguage, editor.getContent(), props.contentOriginalCss); + if (!editor) return; props_onChangeRef.current({ changeId: changeId, - content: editor.getContent(), + content: contentMd, }); dispatchDidUpdate(editor); @@ -755,6 +751,8 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { editor.on('paste', onPaste); editor.on('cut', onChangeHandler); editor.on('joplinChange', onChangeHandler); + editor.on('Undo', onChangeHandler); + editor.on('Redo', onChangeHandler); editor.on('ExecCommand', onExecCommand); return () => { @@ -764,12 +762,14 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { editor.off('paste', onPaste); editor.off('cut', onChangeHandler); editor.off('joplinChange', onChangeHandler); + editor.off('Undo', onChangeHandler); + editor.off('Redo', onChangeHandler); editor.off('ExecCommand', onExecCommand); } catch (error) { console.warn('Error removing events', error); } }; - }, [props.onWillChange, props.onChange, editor]); + }, [props.onWillChange, props.onChange, props.contentMarkupLanguage, props.contentOriginalCss, editor]); // ----------------------------------------------------------------------------------------- // Destroy the editor when unmounting @@ -783,11 +783,13 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { }; }, []); + // Currently we don't handle resource "auto" and "manual" mode with TinyMCE + // as it is quite complex and probably rarely used. function renderDisabledOverlay() { - const status = resourcesStatus(props.defaultEditorState.resourceInfos); + const status = resourcesStatus(props.resourceInfos); if (status === 'ready') return null; - const message = _('Please wait for all attachments to be downloaded and decrypted. You may also switch the layout and edit the note in Markdown mode.'); + const message = _('Please wait for all attachments to be downloaded and decrypted. You may also switch to %s to edit the note.', _('Code View')); return (

{message}

diff --git a/ElectronClient/gui/editors/TinyMCE/content_script.js b/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/content_script.js similarity index 100% rename from ElectronClient/gui/editors/TinyMCE/content_script.js rename to ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/content_script.js diff --git a/ElectronClient/gui/editors/TinyMCE/icons.js b/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/icons.js similarity index 100% rename from ElectronClient/gui/editors/TinyMCE/icons.js rename to ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/icons.js diff --git a/ElectronClient/gui/editors/TinyMCE/plugins/lists.js b/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/plugins/lists.js similarity index 100% rename from ElectronClient/gui/editors/TinyMCE/plugins/lists.js rename to ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/plugins/lists.js diff --git a/ElectronClient/gui/NoteEditor/NoteEditor.tsx b/ElectronClient/gui/NoteEditor/NoteEditor.tsx new file mode 100644 index 000000000..d819c39b2 --- /dev/null +++ b/ElectronClient/gui/NoteEditor/NoteEditor.tsx @@ -0,0 +1,570 @@ +import * as React from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; +// eslint-disable-next-line no-unused-vars +import TinyMCE from './NoteBody/TinyMCE/TinyMCE'; +import AceEditor from './NoteBody/AceEditor/AceEditor'; +import { connect } from 'react-redux'; +import MultiNoteActions from '../MultiNoteActions'; +import NoteToolbar from '../NoteToolbar/NoteToolbar'; +import { htmlToMarkdown, formNoteToNote } from './utils'; +import useSearchMarkers from './utils/useSearchMarkers'; +import useNoteSearchBar from './utils/useNoteSearchBar'; +import useMessageHandler from './utils/useMessageHandler'; +import useWindowCommandHandler from './utils/useWindowCommandHandler'; +import useDropHandler from './utils/useDropHandler'; +import useMarkupToHtml from './utils/useMarkupToHtml'; +import useFormNote, { OnLoadEvent } from './utils/useFormNote'; +import styles_ from './styles'; +import { NoteTextProps, FormNote, ScrollOptions, ScrollOptionTypes, OnChangeEvent, NoteBodyEditorProps } from './utils/types'; +import { attachResources } from './utils/resourceHandling'; + +const { themeStyle } = require('../../theme.js'); +const NoteSearchBar = require('../NoteSearchBar.min.js'); +const { reg } = require('lib/registry.js'); +const { time } = require('lib/time-utils.js'); +const markupLanguageUtils = require('lib/markupLanguageUtils'); +const usePrevious = require('lib/hooks/usePrevious').default; +const Setting = require('lib/models/Setting'); +const { _ } = require('lib/locale'); +const Note = require('lib/models/Note.js'); +const { bridge } = require('electron').remote.require('./bridge'); +const ExternalEditWatcher = require('lib/services/ExternalEditWatcher'); +const eventManager = require('../../eventManager'); +const NoteRevisionViewer = require('../NoteRevisionViewer.min'); +const TagList = require('../TagList.min.js'); + +function NoteEditor(props: NoteTextProps) { + const [showRevisions, setShowRevisions] = useState(false); + const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false); + const [scrollWhenReady, setScrollWhenReady] = useState(null); + + const editorRef = useRef(); + const titleInputRef = useRef(); + const isMountedRef = useRef(true); + const noteSearchBarRef = useRef(null); + + const formNote_beforeLoad = useCallback(async (event:OnLoadEvent) => { + await saveNoteIfWillChange(event.formNote); + setShowRevisions(false); + }, []); + + const formNote_afterLoad = useCallback(async () => { + setTitleHasBeenManuallyChanged(false); + }, []); + + const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({ + syncStarted: props.syncStarted, + noteId: props.noteId, + isProvisional: props.isProvisional, + titleInputRef: titleInputRef, + editorRef: editorRef, + onBeforeLoad: formNote_beforeLoad, + onAfterLoad: formNote_afterLoad, + }); + + const formNoteRef = useRef(); + formNoteRef.current = { ...formNote }; + + const { + localSearch, + onChange: localSearch_change, + onNext: localSearch_next, + onPrevious: localSearch_previous, + onClose: localSearch_close, + setResultCount: setLocalSearchResultCount, + showLocalSearch, + setShowLocalSearch, + searchMarkers: localSearchMarkerOptions, + } = useNoteSearchBar(); + + // If the note has been modified in another editor, wait for it to be saved + // before loading it in this editor. + // const waitingToSaveNote = props.noteId && formNote.id !== props.noteId && props.editorNoteStatuses[props.noteId] === 'saving'; + + const styles = styles_(props); + + function scheduleSaveNote(formNote: FormNote) { + if (!formNote.saveActionQueue) throw new Error('saveActionQueue is not set!!'); // Sanity check + + reg.logger().debug('Scheduling...', formNote); + + const makeAction = (formNote: FormNote) => { + return async function() { + const note = await formNoteToNote(formNote); + reg.logger().debug('Saving note...', note); + const savedNote:any = await Note.save(note); + + setFormNote((prev: FormNote) => { + return { ...prev, user_updated_time: savedNote.user_updated_time }; + }); + + ExternalEditWatcher.instance().updateNoteFile(savedNote); + + props.dispatch({ + type: 'EDITOR_NOTE_STATUS_REMOVE', + id: formNote.id, + }); + }; + }; + + formNote.saveActionQueue.push(makeAction(formNote)); + } + + async function saveNoteIfWillChange(formNote: FormNote) { + if (!formNote.id || !formNote.bodyWillChangeId) return; + + const body = await editorRef.current.content(); + + scheduleSaveNote({ + ...formNote, + body: body, + bodyWillChangeId: 0, + bodyChangeId: 0, + }); + } + + async function saveNoteAndWait(formNote: FormNote) { + saveNoteIfWillChange(formNote); + return formNote.saveActionQueue.waitForAllDone(); + } + + const markupToHtml = useMarkupToHtml({ themeId: props.theme, customCss: props.customCss }); + + const allAssets = useCallback(async (markupLanguage: number): Promise => { + const theme = themeStyle(props.theme); + + const markupToHtml = markupLanguageUtils.newMarkupToHtml({ + resourceBaseUrl: `file://${Setting.value('resourceDir')}/`, + }); + + return markupToHtml.allAssets(markupLanguage, theme); + }, [props.theme]); + + const handleProvisionalFlag = useCallback(() => { + if (props.isProvisional) { + props.dispatch({ + type: 'NOTE_PROVISIONAL_FLAG_CLEAR', + id: formNote.id, + }); + } + }, [props.isProvisional, formNote.id]); + + useEffect(() => { + // This is not exactly a hack but a bit ugly. If the note was changed (willChangeId > 0) but not + // yet saved, we need to save it now before the component is unmounted. However, we can't put + // formNote in the dependency array or that effect will run every time the note changes. We only + // want to run it once on unmount. So because of that we need to use that formNoteRef. + return () => { + isMountedRef.current = false; + saveNoteIfWillChange(formNoteRef.current); + }; + }, []); + + const previousNoteId = usePrevious(formNote.id); + + useEffect(() => { + if (formNote.id === previousNoteId) return; + + if (editorRef.current) { + editorRef.current.resetScroll(); + } + + setScrollWhenReady({ + type: props.selectedNoteHash ? ScrollOptionTypes.Hash : ScrollOptionTypes.Percent, + value: props.selectedNoteHash ? props.selectedNoteHash : props.lastEditorScrollPercents[props.noteId] || 0, + }); + }, [formNote.id, previousNoteId]); + + const onFieldChange = useCallback((field: string, value: any, changeId = 0) => { + if (!isMountedRef.current) { + // When the component is unmounted, various actions can happen which can + // trigger onChange events, for example the textarea might be cleared. + // We need to ignore these events, otherwise the note is going to be saved + // with an invalid body. + reg.logger().debug('Skipping change event because the component is unmounted'); + return; + } + + handleProvisionalFlag(); + + const change = field === 'body' ? { + body: value, + } : { + title: value, + }; + + const newNote = { + ...formNote, + ...change, + bodyWillChangeId: 0, + bodyChangeId: 0, + hasChanged: true, + }; + + if (field === 'title') { + setTitleHasBeenManuallyChanged(true); + } + + if (isNewNote && !titleHasBeenManuallyChanged && field === 'body') { + // TODO: Handle HTML/Markdown format + newNote.title = Note.defaultTitle(value); + } + + if (changeId !== null && field === 'body' && formNote.bodyWillChangeId !== changeId) { + // Note was changed, but another note was loaded before save - skipping + // The previously loaded note, that was modified, will be saved via saveNoteIfWillChange() + } else { + setFormNote(newNote); + scheduleSaveNote(newNote); + } + }, [handleProvisionalFlag, formNote, isNewNote, titleHasBeenManuallyChanged]); + + useWindowCommandHandler({ windowCommand: props.windowCommand, dispatch: props.dispatch, formNote, setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, saveNoteAndWait }); + + const onDrop = useDropHandler({ editorRef }); + + const onBodyChange = useCallback((event: OnChangeEvent) => onFieldChange('body', event.content, event.changeId), [onFieldChange]); + + const onTitleChange = useCallback((event: any) => onFieldChange('title', event.target.value), [onFieldChange]); + + const onTitleKeydown = useCallback((event:any) => { + const keyCode = event.keyCode; + + if (keyCode === 9) { + // TAB + event.preventDefault(); + + if (event.shiftKey) { + props.dispatch({ + type: 'WINDOW_COMMAND', + name: 'focusElement', + target: 'noteList', + }); + } else { + props.dispatch({ + type: 'WINDOW_COMMAND', + name: 'focusElement', + target: 'noteBody', + }); + } + } + }, [props.dispatch]); + + const onBodyWillChange = useCallback((event: any) => { + handleProvisionalFlag(); + + setFormNote(prev => { + return { + ...prev, + bodyWillChangeId: event.changeId, + hasChanged: true, + }; + }); + + props.dispatch({ + type: 'EDITOR_NOTE_STATUS_SET', + id: formNote.id, + status: 'saving', + }); + }, [formNote, handleProvisionalFlag]); + + const onMessage = useMessageHandler(scrollWhenReady, setScrollWhenReady, editorRef, setLocalSearchResultCount, props.dispatch); + + const introductionPostLinkClick = useCallback(() => { + bridge().openExternal('https://www.patreon.com/posts/34246624'); + }, []); + + const externalEditWatcher_noteChange = useCallback((event) => { + if (event.id === formNote.id) { + const newFormNote = { + ...formNote, + title: event.note.title, + body: event.note.body, + }; + + setFormNote(newFormNote); + } + }, [formNote]); + + const onNotePropertyChange = useCallback((event) => { + setFormNote(formNote => { + if (formNote.id !== event.note.id) return formNote; + + const newFormNote: FormNote = { ...formNote }; + + for (const key in event.note) { + if (key === 'id') continue; + (newFormNote as any)[key] = event.note[key]; + } + + return newFormNote; + }); + }, []); + + useEffect(() => { + eventManager.on('alarmChange', onNotePropertyChange); + ExternalEditWatcher.instance().on('noteChange', externalEditWatcher_noteChange); + + return () => { + eventManager.off('alarmChange', onNotePropertyChange); + ExternalEditWatcher.instance().off('noteChange', externalEditWatcher_noteChange); + }; + }, [externalEditWatcher_noteChange, onNotePropertyChange]); + + const noteToolbar_buttonClick = useCallback((event: any) => { + const cases: any = { + + 'startExternalEditing': async () => { + props.dispatch({ + type: 'WINDOW_COMMAND', + name: 'commandStartExternalEditing', + }); + }, + + 'stopExternalEditing': () => { + props.dispatch({ + type: 'WINDOW_COMMAND', + name: 'commandStopExternalEditing', + }); + }, + + 'setTags': async () => { + await saveNoteAndWait(formNote); + + props.dispatch({ + type: 'WINDOW_COMMAND', + name: 'setTags', + noteIds: [formNote.id], + }); + }, + + 'setAlarm': async () => { + await saveNoteAndWait(formNote); + + props.dispatch({ + type: 'WINDOW_COMMAND', + name: 'editAlarm', + noteId: formNote.id, + }); + }, + + 'showRevisions': () => { + setShowRevisions(true); + }, + }; + + if (!cases[event.name]) throw new Error(`Unsupported event: ${event.name}`); + + cases[event.name](); + }, [formNote]); + + const onScroll = useCallback((event: any) => { + props.dispatch({ + type: 'EDITOR_SCROLL_PERCENT_SET', + noteId: formNote.id, + percent: event.percent, + }); + }, [props.dispatch, formNote]); + + function renderNoNotes(rootStyle:any) { + const emptyDivStyle = Object.assign( + { + backgroundColor: 'black', + opacity: 0.1, + }, + rootStyle + ); + return
; + } + + function renderNoteToolbar() { + const toolbarStyle = { + // marginTop: 4, + marginBottom: 0, + flex: 1, + }; + + return ; + } + + const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId); + + const editorProps:NoteBodyEditorProps = { + ref: editorRef, + contentKey: formNote.id, + style: styles.tinyMCE, + onChange: onBodyChange, + onWillChange: onBodyWillChange, + onMessage: onMessage, + content: formNote.body, + contentMarkupLanguage: formNote.markup_language, + contentOriginalCss: formNote.originalCss, + resourceInfos: resourceInfos, + htmlToMarkdown: htmlToMarkdown, + markupToHtml: markupToHtml, + allAssets: allAssets, + attachResources: attachResources, + disabled: false, + theme: props.theme, + dispatch: props.dispatch, + noteToolbar: renderNoteToolbar(), + onScroll: onScroll, + searchMarkers: searchMarkers, + visiblePanes: props.noteVisiblePanes || ['editor', 'viewer'], + keyboardMode: Setting.value('editor.keyboardMode'), + }; + + let editor = null; + + if (props.bodyEditor === 'TinyMCE') { + editor = ; + } else if (props.bodyEditor === 'AceEditor') { + editor = ; + } else { + throw new Error(`Invalid editor: ${props.bodyEditor}`); + } + + const wysiwygBanner = props.bodyEditor !== 'TinyMCE' ? null : ( +
+ 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. +
+ ); + + const noteRevisionViewer_onBack = useCallback(() => { + setShowRevisions(false); + }, []); + + const tagStyle = { + marginBottom: 10, + }; + + const tagList = props.selectedNoteTags.length ? : null; + + if (showRevisions) { + const theme = themeStyle(props.theme); + + const revStyle = { + ...props.style, + display: 'inline-flex', + padding: theme.margin, + verticalAlign: 'top', + boxSizing: 'border-box', + + }; + + return ( +
+ +
+ ); + } + + if (props.selectedNoteIds.length > 1) { + return ; + } + + const titleBarDate = {time.formatMsToLocal(formNote.user_updated_time)}; + + function renderSearchBar() { + if (!showLocalSearch) return false; + + const theme = themeStyle(props.theme); + + return ( + + ); + } + + if (formNote.encryption_applied || !formNote.id) { + return renderNoNotes(styles.root); + } + + return ( +
+
+ {wysiwygBanner} + {tagList} +
+ + {titleBarDate} +
+
+ {editor} +
+
+ {renderSearchBar()} +
+
+
+ ); +} + +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. -
-
- -
-
- {editor} -
-
-
- - ); -} - -export { - NoteText2 as NoteText2Component, -}; - -const mapStateToProps = (state:any) => { - const noteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null; - - return { - noteId: noteId, - notes: state.notes, - selectedNoteIds: state.selectedNoteIds, - isProvisional: state.provisionalNoteIds.includes(noteId), - editorNoteStatuses: state.editorNoteStatuses, - syncStarted: state.syncStarted, - theme: state.settings.theme, - watchedNoteFiles: state.watchedNoteFiles, - windowCommand: state.windowCommand, - }; -}; - -export default connect(mapStateToProps)(NoteText2); diff --git a/ElectronClient/gui/NoteTextViewer.jsx b/ElectronClient/gui/NoteTextViewer.jsx index a301c97ab..f5bb54fe1 100644 --- a/ElectronClient/gui/NoteTextViewer.jsx +++ b/ElectronClient/gui/NoteTextViewer.jsx @@ -162,7 +162,7 @@ class NoteTextViewerComponent extends React.Component { // ---------------------------------------------------------------- render() { - const viewerStyle = Object.assign({}, this.props.viewerStyle, { borderTop: 'none', borderRight: 'none', borderBottom: 'none' }); + const viewerStyle = Object.assign({}, { border: 'none' }, this.props.viewerStyle); return ; } } diff --git a/ElectronClient/gui/NoteToolbar/NoteToolbar.tsx b/ElectronClient/gui/NoteToolbar/NoteToolbar.tsx new file mode 100644 index 000000000..0483c8e42 --- /dev/null +++ b/ElectronClient/gui/NoteToolbar/NoteToolbar.tsx @@ -0,0 +1,148 @@ +import * as React from 'react'; + +const { buildStyle } = require('../../theme.js'); +const Toolbar = require('../Toolbar.min.js'); +const Note = require('lib/models/Note'); +const { time } = require('lib/time-utils.js'); +const { _ } = require('lib/locale'); + + + + +// const { substrWithEllipsis } = require('lib/string-utils'); +// const Folder = require('lib/models/Folder'); +// const { MarkupToHtml } = require('lib/joplin-renderer'); + +interface ButtonClickEvent { + name: string, +} + +interface NoteToolbarProps { + theme: number, + style: any, + watchedNoteFiles: string[], + note: any, + dispatch: Function, + onButtonClick(event:ButtonClickEvent):void, +} + +function styles_(props:NoteToolbarProps) { + return buildStyle('NoteToolbar', props.theme, (/* theme:any*/) => { + return { + root: { + ...props.style, + + }, + }; + }); +} + +function useToolbarItems(note:any, watchedNoteFiles:string[], dispatch:Function, onButtonClick:Function) { + const toolbarItems = []; + + // TODO: add these two items + + // if (props.folder && ['Search', 'Tag', 'SmartFilter'].includes(props.notesParentType)) { + // toolbarItems.push({ + // title: _('In: %s', substrWithEllipsis(props.folder.title, 0, 16)), + // iconName: 'fa-book', + // onClick: () => { + // props.dispatch({ + // type: 'FOLDER_AND_NOTE_SELECT', + // folderId: props.folder.id, + // noteId: props.formNote.id, + // }); + // Folder.expandTree(props.folders, props.folder.parent_id); + // }, + // }); + // } + + // if (props.historyNotes.length) { + // toolbarItems.push({ + // tooltip: _('Back'), + // iconName: 'fa-arrow-left', + // onClick: () => { + // if (!props.historyNotes.length) return; + + // const lastItem = props.historyNotes[props.historyNotes.length - 1]; + + // props.dispatch({ + // type: 'FOLDER_AND_NOTE_SELECT', + // folderId: lastItem.parent_id, + // noteId: lastItem.id, + // historyNoteAction: 'pop', + // }); + // }, + // }); + // } + + if (watchedNoteFiles.indexOf(note.id) >= 0) { + toolbarItems.push({ + tooltip: _('Click to stop external editing'), + title: _('Watching...'), + iconName: 'fa-external-link', + onClick: () => { + onButtonClick({ name: 'stopExternalEditing' }); + }, + }); + } else { + toolbarItems.push({ + tooltip: _('Edit in external editor'), + iconName: 'fa-external-link', + onClick: () => { + onButtonClick({ name: 'startExternalEditing' }); + }, + }); + } + + toolbarItems.push({ + tooltip: _('Tags'), + iconName: 'fa-tags', + onClick: () => { + onButtonClick({ name: 'setTags' }); + }, + }); + + if (note.is_todo) { + const item:any = { + iconName: 'fa-clock-o', + enabled: !note.todo_completed, + onClick: () => { + onButtonClick({ name: 'setAlarm' }); + }, + }; + if (Note.needAlarm(note)) { + item.title = time.formatMsToLocal(note.todo_due); + } else { + item.tooltip = _('Set alarm'); + } + toolbarItems.push(item); + } + + toolbarItems.push({ + tooltip: _('Note properties'), + iconName: 'fa-info-circle', + onClick: () => { + dispatch({ + type: 'WINDOW_COMMAND', + name: 'commandNoteProperties', + noteId: note.id, + onRevisionLinkClick: () => { + onButtonClick({ name: 'showRevisions' }); + }, + }); + }, + }); + + return toolbarItems; +} + +export default function NoteToolbar(props:NoteToolbarProps) { + const styles = styles_(props); + + const toolbarItems = useToolbarItems(props.note, props.watchedNoteFiles, props.dispatch, props.onButtonClick); + + return ( + + ); +} diff --git a/ElectronClient/gui/ResourceScreen.tsx b/ElectronClient/gui/ResourceScreen.tsx index d1543cd9d..04027dd40 100644 --- a/ElectronClient/gui/ResourceScreen.tsx +++ b/ElectronClient/gui/ResourceScreen.tsx @@ -69,8 +69,9 @@ const ResourceTable: React.FC = (props: ResourceTable) => { }; const cellStyle = { - ...props.theme.textStyleMinor, + ...props.theme.textStyle, whiteSpace: 'nowrap', + color: props.theme.colorFaded, width: 1, }; diff --git a/ElectronClient/gui/TagList.jsx b/ElectronClient/gui/TagList.jsx index b1d37b591..c5330b7ed 100644 --- a/ElectronClient/gui/TagList.jsx +++ b/ElectronClient/gui/TagList.jsx @@ -11,10 +11,11 @@ class TagListComponent extends React.Component { style.display = 'flex'; style.flexDirection = 'row'; - style.borderBottom = `1px solid ${theme.dividerColor}`; + // style.borderBottom = `1px solid ${theme.dividerColor}`; style.boxSizing = 'border-box'; style.fontSize = theme.fontSize; style.whiteSpace = 'nowrap'; + style.height = 25; const tagItems = []; if (tags && tags.length > 0) { diff --git a/ElectronClient/gui/Toolbar.jsx b/ElectronClient/gui/Toolbar.jsx index 8b8753533..48400760e 100644 --- a/ElectronClient/gui/Toolbar.jsx +++ b/ElectronClient/gui/Toolbar.jsx @@ -6,7 +6,7 @@ const ToolbarSpace = require('./ToolbarSpace.min.js'); class ToolbarComponent extends React.Component { render() { - const style = this.props.style; + const style = Object.assign({}, this.props.style); const theme = themeStyle(this.props.theme); style.height = theme.toolbarHeight; style.display = 'flex'; diff --git a/ElectronClient/gui/note-viewer/index.html b/ElectronClient/gui/note-viewer/index.html index d8bc5f8d1..b0f198ccc 100644 --- a/ElectronClient/gui/note-viewer/index.html +++ b/ElectronClient/gui/note-viewer/index.html @@ -105,8 +105,10 @@ for (let i = 0; i < assets.length; i++) { const asset = assets[i]; - if (pluginAssetsAdded_[asset.name]) continue; - pluginAssetsAdded_[asset.name] = true; + + const assetId = asset.name ? asset.name : asset.path; + if (pluginAssetsAdded_[assetId]) continue; + pluginAssetsAdded_[assetId] = true; if (asset.mime === 'application/javascript') { const script = document.createElement('script'); @@ -143,6 +145,22 @@ }, 100); } + // https://stackoverflow.com/a/1977898/561309 + function isImageReady(img) { + if (!img.complete) return false; + if (!img.naturalWidth || !img.naturalHeight) return false; + return true; + } + + function allImagesLoaded() { + for (const image of document.images) { + if (!isImageReady(image)) return false; + } + return true; + } + + let checkAllImageLoadedIID_ = null; + ipc.setHtml = (event) => { const html = event.html; @@ -177,6 +195,15 @@ } document.dispatchEvent(new Event('joplin-noteDidUpdate')); + + if (checkAllImageLoadedIID_) clearInterval(checkAllImageLoadedIID_); + + checkAllImageLoadedIID_ = setInterval(() => { + if (!allImagesLoaded()) return; + + clearInterval(checkAllImageLoadedIID_); + ipcProxySendToHost('noteRenderComplete'); + }, 100); } let lastScrollEventTime = 0; diff --git a/ElectronClient/gui/utils/NoteListUtils.js b/ElectronClient/gui/utils/NoteListUtils.js index 9784a973e..58e698159 100644 --- a/ElectronClient/gui/utils/NoteListUtils.js +++ b/ElectronClient/gui/utils/NoteListUtils.js @@ -91,8 +91,14 @@ class NoteListUtils { click: async () => { for (let i = 0; i < noteIds.length; i++) { const note = await Note.load(noteIds[i]); - await Note.save(Note.toggleIsTodo(note), { userSideValidation: true }); - eventManager.emit('noteTypeToggle', { noteId: note.id }); + const newNote = await Note.save(Note.toggleIsTodo(note), { userSideValidation: true }); + const eventNote = { + id: newNote.id, + is_todo: newNote.is_todo, + todo_due: newNote.todo_due, + todo_completed: newNote.todo_completed, + }; + eventManager.emit('noteTypeToggle', { noteId: note.id, note: eventNote }); } }, }) diff --git a/ElectronClient/gui/utils/NoteText.js b/ElectronClient/gui/utils/NoteText.js new file mode 100644 index 000000000..0f26a1f4d --- /dev/null +++ b/ElectronClient/gui/utils/NoteText.js @@ -0,0 +1,15 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { value: true }); +const joplinRendererUtils = require('lib/joplin-renderer').utils; +const Resource = require('lib/models/Resource'); +function resourcesStatus(resourceInfos) { + let lowestIndex = joplinRendererUtils.resourceStatusIndex('ready'); + for (const id in resourceInfos) { + const s = joplinRendererUtils.resourceStatus(Resource, resourceInfos[id]); + const idx = joplinRendererUtils.resourceStatusIndex(s); + if (idx < lowestIndex) { lowestIndex = idx; } + } + return joplinRendererUtils.resourceStatusName(lowestIndex); +} +exports.resourcesStatus = resourcesStatus; +// # sourceMappingURL=NoteText.js.map diff --git a/ElectronClient/gui/utils/NoteText.ts b/ElectronClient/gui/utils/NoteText.ts deleted file mode 100644 index 53ebd95a6..000000000 --- a/ElectronClient/gui/utils/NoteText.ts +++ /dev/null @@ -1,32 +0,0 @@ -const joplinRendererUtils = require('lib/joplin-renderer').utils; -const Resource = require('lib/models/Resource'); - -export interface DefaultEditorState { - value: string, - markupLanguage: number, // MarkupToHtml.MARKUP_LANGUAGE_XXX - resourceInfos: any, -} - -export interface OnChangeEvent { - changeId: number, - content: any, -} - -export interface TextEditorUtils { - editorContentToHtml(content:any):Promise, -} - -export interface EditorCommand { - name: string, - value: any, -} - -export function resourcesStatus(resourceInfos:any) { - let lowestIndex = joplinRendererUtils.resourceStatusIndex('ready'); - for (const id in resourceInfos) { - const s = joplinRendererUtils.resourceStatus(Resource, resourceInfos[id]); - const idx = joplinRendererUtils.resourceStatusIndex(s); - if (idx < lowestIndex) lowestIndex = idx; - } - return joplinRendererUtils.resourceStatusName(lowestIndex); -} diff --git a/ElectronClient/package-lock.json b/ElectronClient/package-lock.json index f8ac91f5a..328be12e9 100644 --- a/ElectronClient/package-lock.json +++ b/ElectronClient/package-lock.json @@ -2771,6 +2771,15 @@ "object-visit": "^1.0.0" } }, + "color": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/color/-/color-3.1.2.tgz", + "integrity": "sha512-vXTJhHebByxZn3lDvDJYw4lR5+uB3vuoHsuYA5AKuxRVn5wzzIfQKGLBmgdVRHKTJYeK5rvJcHnrd0Li49CFpg==", + "requires": { + "color-convert": "^1.9.1", + "color-string": "^1.5.2" + } + }, "color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -2784,6 +2793,15 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, + "color-string": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.3.tgz", + "integrity": "sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==", + "requires": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -10242,6 +10260,21 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=" }, + "simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", + "requires": { + "is-arrayish": "^0.3.1" + }, + "dependencies": { + "is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + } + } + }, "slash": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", diff --git a/ElectronClient/package.json b/ElectronClient/package.json index 511467a09..d1997a751 100644 --- a/ElectronClient/package.json +++ b/ElectronClient/package.json @@ -91,6 +91,7 @@ "base64-stream": "^1.0.0", "chokidar": "^3.0.0", "clean-html": "^1.5.0", + "color": "^3.1.2", "compare-versions": "^3.2.1", "countable": "^3.0.1", "diacritics": "^1.3.0", diff --git a/ElectronClient/theme.js b/ElectronClient/theme.js index fd652d6e6..93a68a672 100644 --- a/ElectronClient/theme.js +++ b/ElectronClient/theme.js @@ -1,4 +1,5 @@ const Setting = require('lib/models/Setting.js'); +const Color = require('color'); const themes = { [Setting.THEME_LIGHT]: require('./gui/style/theme/light'), @@ -29,7 +30,6 @@ const globalStyle = { headerButtonHPadding: 6, toolbarHeight: 35, - tagItemPadding: 3, }; globalStyle.marginRight = globalStyle.margin; @@ -83,18 +83,21 @@ globalStyle.buttonStyle = { }; function addExtraStyles(style) { + style.selectedDividerColor = Color(style.dividerColor).darken(0.2).hex(); + style.tagStyle = { fontSize: style.fontSize, fontFamily: style.fontFamily, - marginTop: style.itemMarginTop * 0.4, - marginBottom: style.itemMarginBottom * 0.4, - marginRight: style.margin * 0.3, - paddingTop: style.tagItemPadding, - paddingBottom: style.tagItemPadding, - paddingRight: style.tagItemPadding * 2, - paddingLeft: style.tagItemPadding * 2, + paddingTop: 3, + paddingBottom: 3, + paddingRight: 8, + paddingLeft: 8, backgroundColor: style.raisedBackgroundColor, color: style.raisedColor, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, }; style.toolbarStyle = { diff --git a/Modules/TinyMCE/IconPack/gulpfile.js b/Modules/TinyMCE/IconPack/gulpfile.js index f3f84cc88..3d022cbae 100644 --- a/Modules/TinyMCE/IconPack/gulpfile.js +++ b/Modules/TinyMCE/IconPack/gulpfile.js @@ -13,7 +13,7 @@ gulp.task('icon-packager', function() { }); gulp.task('deploy', function() { - fs.copyFileSync(`${__dirname}/dist/icons/Joplin/icons.js`, `${__dirname}/../../../ElectronClient/gui/editors/TinyMCE/icons.js`); + fs.copyFileSync(`${__dirname}/dist/icons/Joplin/icons.js`, `${__dirname}/../../../ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/icons.js`); return Promise.resolve(); }); diff --git a/Modules/TinyMCE/JoplinLists/Gruntfile.js b/Modules/TinyMCE/JoplinLists/Gruntfile.js index fb0644bf2..faf2d44af 100644 --- a/Modules/TinyMCE/JoplinLists/Gruntfile.js +++ b/Modules/TinyMCE/JoplinLists/Gruntfile.js @@ -125,7 +125,7 @@ module.exports = function(grunt) { // { src: ['changelog.txt'], dest: 'dist', expand: true }, { src: ['dist/joplinLists.js'], - dest: '../../../ElectronClient/gui/editors/TinyMCE/plugins/lists.js', + dest: '../../../ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/plugins/lists.js', }, ], }, diff --git a/ReactNativeClient/lib/components/shared/note-screen-shared.js b/ReactNativeClient/lib/components/shared/note-screen-shared.js index 02b9c5dfd..31129cd84 100644 --- a/ReactNativeClient/lib/components/shared/note-screen-shared.js +++ b/ReactNativeClient/lib/components/shared/note-screen-shared.js @@ -64,7 +64,7 @@ shared.saveNoteButton_press = async function(comp, folderId = null, options = nu const hasAutoTitle = comp.state.newAndNoTitleChangeNoteId || (isProvisionalNote && !note.title); if (hasAutoTitle && options.autoTitle) { - note.title = Note.defaultTitle(note); + note.title = Note.defaultTitle(note.body); if (saveOptions.fields && saveOptions.fields.indexOf('title') < 0) saveOptions.fields.push('title'); } diff --git a/ReactNativeClient/lib/hooks/useImperativeHandlerDebugger.ts b/ReactNativeClient/lib/hooks/useImperativeHandlerDebugger.ts new file mode 100644 index 000000000..a97f3451b --- /dev/null +++ b/ReactNativeClient/lib/hooks/useImperativeHandlerDebugger.ts @@ -0,0 +1,27 @@ +import usePrevious from './usePrevious'; +import { useImperativeHandle } from 'react'; + +export default function useImperativeHandleDebugger(ref:any, effectHook:any, dependencies:any, dependencyNames:any[] = []) { + const previousDeps = usePrevious(dependencies, []); + + const changedDeps = dependencies.reduce((accum:any, dependency:any, index:any) => { + if (dependency !== previousDeps[index]) { + const keyName = dependencyNames[index] || index; + return { + ...accum, + [keyName]: { + before: previousDeps[index], + after: dependency, + }, + }; + } + + return accum; + }, {}); + + if (Object.keys(changedDeps).length) { + console.log('[use-imperativeHandler-debugger] ', changedDeps); + } + + useImperativeHandle(ref, effectHook, dependencies); +} diff --git a/ReactNativeClient/lib/hooks/usePrevious.ts b/ReactNativeClient/lib/hooks/usePrevious.ts new file mode 100644 index 000000000..8da1e96e5 --- /dev/null +++ b/ReactNativeClient/lib/hooks/usePrevious.ts @@ -0,0 +1,9 @@ +import { useEffect, useRef } from 'react'; + +export default function usePrevious(value: any, initialValue:any = null): any { + const ref = useRef(initialValue); + useEffect(() => { + ref.current = value; + }); + return ref.current; +} diff --git a/ReactNativeClient/lib/joplin-renderer/HtmlToHtml.js b/ReactNativeClient/lib/joplin-renderer/HtmlToHtml.js index 40845f043..803b27b9f 100644 --- a/ReactNativeClient/lib/joplin-renderer/HtmlToHtml.js +++ b/ReactNativeClient/lib/joplin-renderer/HtmlToHtml.js @@ -29,10 +29,10 @@ class HtmlToHtml { splitHtml(html) { const trimmedHtml = html.trimStart(); - if (trimmedHtml.indexOf(''); - if (closingIndex < 0) return { html: html, cssStrings: [], originalCssHtml: '' }; + if (closingIndex < 0) return { html: html, css: '' }; return { html: trimmedHtml.substr(closingIndex + 8), diff --git a/ReactNativeClient/lib/models/Note.js b/ReactNativeClient/lib/models/Note.js index f36f24b3a..26e03697a 100644 --- a/ReactNativeClient/lib/models/Note.js +++ b/ReactNativeClient/lib/models/Note.js @@ -77,8 +77,8 @@ class Note extends BaseItem { return super.serialize(n, fieldNames); } - static defaultTitle(note) { - return this.defaultTitleFromBody(note.body); + static defaultTitle(noteBody) { + return this.defaultTitleFromBody(noteBody); } static defaultTitleFromBody(body) { diff --git a/ReactNativeClient/lib/models/Setting.js b/ReactNativeClient/lib/models/Setting.js index 3da0e2b0c..0a1ec29c5 100644 --- a/ReactNativeClient/lib/models/Setting.js +++ b/ReactNativeClient/lib/models/Setting.js @@ -43,20 +43,11 @@ class Setting extends BaseModel { type: Setting.TYPE_STRING, public: false, }, - 'editor.keyboardMode': { - value: 'default', - type: Setting.TYPE_STRING, - public: true, + 'editor.codeView': { + value: false, + type: Setting.TYPE_BOOL, + public: false, appTypes: ['desktop'], - isEnum: true, - label: () => _('Keyboard Mode'), - options: () => { - const output = {}; - output['default'] = _('Default'); - output['emacs'] = _('Emacs'); - output['vim'] = _('Vim'); - return output; - }, }, 'sync.target': { value: SyncTargetRegistry.nameToId('dropbox'), @@ -261,7 +252,7 @@ class Setting extends BaseModel { return output; }, }, - showNoteCounts: { value: true, type: Setting.TYPE_BOOL, public: true, appTypes: ['desktop'], label: () => _('Show note counts') }, + showNoteCounts: { value: true, type: Setting.TYPE_BOOL, public: true, advanced: true, appTypes: ['desktop'], label: () => _('Show note counts') }, layoutButtonSequence: { value: Setting.LAYOUT_ALL, type: Setting.TYPE_INT, @@ -273,7 +264,6 @@ class Setting extends BaseModel { [Setting.LAYOUT_EDITOR_VIEWER]: _('%s / %s', _('Editor'), _('Viewer')), [Setting.LAYOUT_EDITOR_SPLIT]: _('%s / %s', _('Editor'), _('Split View')), [Setting.LAYOUT_VIEWER_SPLIT]: _('%s / %s', _('Viewer'), _('Split View')), - [Setting.LAYOUT_SPLIT_WYSIWYG]: _('%s / %s', _('Split'), 'WYSIWYG (Experimental)'), }), }, uncompletedTodosOnTop: { value: true, type: Setting.TYPE_BOOL, section: 'note', public: true, appTypes: ['cli'], label: () => _('Uncompleted to-dos on top') }, @@ -528,7 +518,7 @@ class Setting extends BaseModel { tagHeaderIsExpanded: { value: true, type: Setting.TYPE_BOOL, public: false, appTypes: ['desktop'] }, folderHeaderIsExpanded: { value: true, type: Setting.TYPE_BOOL, public: false, appTypes: ['desktop'] }, editor: { value: '', type: Setting.TYPE_STRING, subType: 'file_path_and_args', public: true, appTypes: ['cli', 'desktop'], label: () => _('Text editor command'), description: () => _('The editor command (may include arguments) that will be used to open a note. If none is provided it will try to auto-detect the default editor.') }, - 'export.pdfPageSize': { value: 'A4', type: Setting.TYPE_STRING, isEnum: true, public: true, appTypes: ['desktop'], label: () => _('Page size for PDF export'), options: () => { + 'export.pdfPageSize': { value: 'A4', type: Setting.TYPE_STRING, advanced: true, isEnum: true, public: true, appTypes: ['desktop'], label: () => _('Page size for PDF export'), options: () => { return { 'A4': _('A4'), 'Letter': _('Letter'), @@ -538,13 +528,29 @@ class Setting extends BaseModel { 'Legal': _('Legal'), }; } }, - 'export.pdfPageOrientation': { value: 'portrait', type: Setting.TYPE_STRING, isEnum: true, public: true, appTypes: ['desktop'], label: () => _('Page orientation for PDF export'), options: () => { + 'export.pdfPageOrientation': { value: 'portrait', type: Setting.TYPE_STRING, advanced: true, isEnum: true, public: true, appTypes: ['desktop'], label: () => _('Page orientation for PDF export'), options: () => { return { 'portrait': _('Portrait'), 'landscape': _('Landscape'), }; } }, + 'editor.keyboardMode': { + value: '', + type: Setting.TYPE_STRING, + public: true, + appTypes: ['desktop'], + isEnum: true, + advanced: true, + label: () => _('Keyboard Mode'), + options: () => { + const output = {}; + output[''] = _('Default'); + output['emacs'] = _('Emacs'); + output['vim'] = _('Vim'); + return output; + }, + }, 'net.customCertificates': { value: '', @@ -771,6 +777,10 @@ class Setting extends BaseModel { return this.setValue(key, this.value(key) + inc); } + static toggle(key) { + return this.setValue(key, !this.value(key)); + } + static setObjectKey(settingKey, objectKey, value) { let o = this.value(settingKey); if (typeof o !== 'object') o = {}; @@ -1080,7 +1090,6 @@ Setting.LAYOUT_ALL = 0; Setting.LAYOUT_EDITOR_VIEWER = 1; Setting.LAYOUT_EDITOR_SPLIT = 2; Setting.LAYOUT_VIEWER_SPLIT = 3; -Setting.LAYOUT_SPLIT_WYSIWYG = 4; Setting.DATE_FORMAT_1 = 'DD/MM/YYYY'; Setting.DATE_FORMAT_2 = 'DD/MM/YY'; diff --git a/ReactNativeClient/lib/services/ExternalEditWatcher.js b/ReactNativeClient/lib/services/ExternalEditWatcher.js index 173405f21..8e77fca7a 100644 --- a/ReactNativeClient/lib/services/ExternalEditWatcher.js +++ b/ReactNativeClient/lib/services/ExternalEditWatcher.js @@ -107,7 +107,7 @@ class ExternalEditWatcher { updatedNote.id = id; updatedNote.parent_id = note.parent_id; await Note.save(updatedNote); - this.eventEmitter_.emit('noteChange', { id: updatedNote.id }); + this.eventEmitter_.emit('noteChange', { id: updatedNote.id, note: updatedNote }); } this.skipNextChangeEvent_ = {}; diff --git a/ReactNativeClient/lib/shim-init-node.js b/ReactNativeClient/lib/shim-init-node.js index 91622ec84..d1e4ffb39 100644 --- a/ReactNativeClient/lib/shim-init-node.js +++ b/ReactNativeClient/lib/shim-init-node.js @@ -205,7 +205,7 @@ function shimInit() { return Resource.save(resource, { isNew: true }); }; - shim.attachFileToNote = async function(note, filePath, position = null, options = null) { + shim.attachFileToNoteBody = async function(noteBody, filePath, position = null, options = null) { options = Object.assign({}, { createFileURL: false, }, options); @@ -223,10 +223,10 @@ function shimInit() { const newBody = []; if (position === null) { - position = note.body ? note.body.length : 0; + position = noteBody ? noteBody.length : 0; } - if (note.body && position) newBody.push(note.body.substr(0, position)); + if (noteBody && position) newBody.push(noteBody.substr(0, position)); if (!options.createFileURL) { newBody.push(Resource.markdownTag(resource)); @@ -236,10 +236,17 @@ function shimInit() { newBody.push(fileURL); } - if (note.body) newBody.push(note.body.substr(position)); + if (noteBody) newBody.push(noteBody.substr(position)); + + return newBody.join('\n\n'); + }; + + shim.attachFileToNote = async function(note, filePath, position = null, options = null) { + const newBody = await shim.attachFileToNoteBody(note.body, filePath, position, options); + if (!newBody) return null; const newNote = Object.assign({}, note, { - body: newBody.join('\n\n'), + body: newBody, }); return await Note.save(newNote); }; diff --git a/joplin.sublime-project b/joplin.sublime-project index 00460caa3..2d08118a0 100644 --- a/joplin.sublime-project +++ b/joplin.sublime-project @@ -4,59 +4,82 @@ { "file_exclude_patterns": [ + "*.base64", + "*.bundle.js", + "*.eps", + "*.icns", "*.jar", "*.map", - "*.po", - "*.pot", - "CliClient/app/lib", - "CliClient/app/src", - "locales/*.json", - "log.txt", - "package-lock.json", - "ReactNativeClient/locales/*", - "src/log.txt", - "*.min.js", - "ElectronClient/gui/note-viewer/highlight/*.pack.js", - "ElectronClient/css/font-awesome.min.css", - "docs/*.html", - "docs/*.svg", - "ReactNativeClient/lib/mime-utils.js", - "_mydocs/EnexSamples/*.enex", "*.min.css", "*.min.js", - "*.bundle.js", - "yarn.lock", - "*.icns", - "*.base64", - "Podfile.lock", - "ReactNativeClient/PluginAssetsLoader.js", - "ElectronClient/gui/NoteText2.js", + "*.po", + "*.pot", + "_mydocs/EnexSamples/*.enex", + "CliClient/app/lib", + "CliClient/app/src", + "docs/*.html", + "docs/*.svg", + "ElectronClient/css/font-awesome.min.css", + "ElectronClient/gui/MultiNoteActions.js", + "ElectronClient/gui/note-viewer/highlight/*.pack.js", + "ElectronClient/gui/NoteContentPropertiesDialog.js", + "ElectronClient/gui/NoteEditor/NoteBody/AceEditor/AceEditor.js", + "ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/index.js", + "ElectronClient/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor.js", + "ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/plugins/lists.js", + "ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js", + "ElectronClient/gui/NoteEditor/NoteEditor.js", + "ElectronClient/gui/NoteEditor/utils/index.js", + "ElectronClient/gui/NoteToolbar/NoteToolbar.js", "ElectronClient/gui/ResourceScreen.js", "ElectronClient/gui/ShareNoteDialog.js", "ElectronClient/gui/TinyMCE.js", - "ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js", - "ReactNativeClient/setUpQuickActions.js", + "ElectronClient/gui/utils/NoteText.js", + "ElectronClient/gui/NoteEditor/utils/resourceHandling.js", + "ElectronClient/gui/NoteEditor/utils/types.js", + "ElectronClient/gui/NoteEditor/utils/useNoteSearchBar.js", + "ElectronClient/gui/NoteEditor/utils/useSearchMarkers.js", + "ElectronClient/gui/NoteEditor/styles/index.js", + "ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.js", + "ElectronClient/gui/NoteEditor/utils/useDropHandler.js", + "ElectronClient/gui/NoteEditor/utils/useMessageHandler.js", + "ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.js", + "locales/*.json", + "log.txt", + "package-lock.json", + "Podfile.lock", "ReactNativeClient/android/app/joplin.keystore", "ReactNativeClient/lib/AsyncActionHandler.js", - "*.eps", - "ElectronClient/gui/editors/TinyMCE.js", - "ElectronClient/gui/editors/PlainEditor.js", - "ElectronClient/gui/MultiNoteActions.js", - "ElectronClient/gui/NoteContentPropertiesDialog.js", - "ElectronClient/gui/utils/NoteText.js", - "ElectronClient/gui/editors/TinyMCE/plugins/lists.js", - "ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js", + "ReactNativeClient/lib/AsyncActionQueue.js", + "ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/checkbox.js", "ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/fence.js", - "ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/checkbox.js" + "ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js", + "ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js", + "ReactNativeClient/lib/JoplinServerApi.js", + "ReactNativeClient/lib/mime-utils.js", + "ReactNativeClient/locales/*", + "ReactNativeClient/PluginAssetsLoader.js", + "ReactNativeClient/setUpQuickActions.js", + "src/log.txt", + "yarn.lock", + "ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/types.js", + "ElectronClient/gui/NoteEditor/NoteBody/AceEditor/styles/index.js", + "ElectronClient/gui/note-viewer/lib.js", + "ReactNativeClient/lib/hooks/useImperativeHandlerDebugger.js", + "ElectronClient/gui/NoteEditor/utils/useMarkupToHtml.js", + "ElectronClient/gui/NoteEditor/utils/useResourceRefresher.js", + "ElectronClient/gui/NoteEditor/utils/useFormNote.js", + "ElectronClient/gui/NoteEditor/utils/useResourceInfos.js" ], "folder_exclude_patterns": [ "_mydocs/mdtest", + "_releases", "_vieux", - "ElectronClient/fonts", "CliClient/app/lib", "CliClient/app/src", "CliClient/build", + "CliClient/locales-build", "CliClient/node_modules", "CliClient/tests-build", "CliClient/tests-build/lib", @@ -64,40 +87,40 @@ "CliClient/tests/fuzzing", "CliClient/tests/src", "CliClient/tests/sync", - "ElectronClient/dist", + "CliClient/tests/tmp", + "Clipper/dist", + "Clipper/popup/build", "ElectronClient/build", + "ElectronClient/dist", + "ElectronClient/dist", + "ElectronClient/dist", + "ElectronClient/fonts", + "ElectronClient/gui/note-viewer/highlight/styles", "ElectronClient/lib", "ElectronClient/locale", - "ElectronClient/dist", + "ElectronClient/pluginAssets", + "Modules/TinyMCE/JoplinLists/dist", + "Modules/TinyMCE/JoplinLists/lib", + "Modules/TinyMCE/JoplinLists/scratch", "node_modules", "ReactNativeClient/android/.gradle", "ReactNativeClient/android/.idea", "ReactNativeClient/android/app/build", "ReactNativeClient/android/build", "ReactNativeClient/android/local.properties", - "ReactNativeClient/node_modules", - "ReactNativeClient/pluginAssets", - "ElectronClient/gui/note-viewer/highlight/styles", - "tests/logs", "ReactNativeClient/ios/build", - "ElectronClient/dist", - "_releases", - "ReactNativeClient/lib/csstojs", - "Clipper/popup/build", - "Clipper/dist", - "ReactNativeClient/lib/rnInjectedJs", - "ReactNativeClient/ios/Pods", - "CliClient/locales-build", - "ReactNativeClient/lib/vendor", "ReactNativeClient/ios/Joplin-tvOS", "ReactNativeClient/ios/Joplin.xcodeproj/project.xcworkspace", - "ReactNativeClient/ios/Joplin.xcworkspace/xcuserdata", "ReactNativeClient/ios/Joplin.xcodeproj/xcuserdata", - "ElectronClient/pluginAssets", - "Modules/TinyMCE/JoplinLists/dist", - "Modules/TinyMCE/JoplinLists/lib", - "Modules/TinyMCE/JoplinLists/scratch", - "CliClient/tests/tmp" + "ReactNativeClient/ios/Joplin.xcworkspace/xcuserdata", + "ReactNativeClient/ios/Pods", + "ReactNativeClient/lib/csstojs", + "ReactNativeClient/lib/rnInjectedJs", + "ReactNativeClient/lib/vendor", + "ReactNativeClient/node_modules", + "ReactNativeClient/pluginAssets", + "tests/logs", + "ElectronClient/gui/note-viewer/pluginAssets" ], "path": "." }