1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Refactor note editor

Refactor note editor using React Hooks and TypeScript
and moved editor-specific code to separate files.
Moved business logic into more maintainable custom hooks.

Squashed commit of the following:

commit f243d9bf89bdcfa1849ee26df5c0dd3e33405010
Author: Laurent Cozic <laurent@cozic.net>
Date:   Sat May 2 16:04:14 2020 +0100

    Fixed saving issue

commit 055f68d2e8b6cf6f130336c38ac2ab480887583d
Author: Laurent Cozic <laurent@cozic.net>
Date:   Sat May 2 15:43:38 2020 +0100

    Fixed HTML notes

commit 99a3cf71f58d2fedcdf3001bf4110b6e8e3993da
Merge: 9be85c45f2 b16ebbbf7a
Author: Laurent Cozic <laurent@cozic.net>
Date:   Sat May 2 12:54:42 2020 +0100

    Merge branch 'master' into refactor_note_text

commit 9be85c45f23e5cb1ecd612b0ee631947871ada6f
Author: Laurent Cozic <laurent@cozic.net>
Date:   Sat May 2 12:21:01 2020 +0100

    Ident to space

commit 848dde1869c010fe5851f493ef7287ada5f2991e
Author: Laurent Cozic <laurent@cozic.net>
Date:   Sat May 2 11:28:50 2020 +0100

    Refactor prop types

commit 13c3bbe2b4f9a522ea3f8a25e7e5e7bb026dfd4f
Author: Laurent Cozic <laurent@cozic.net>
Date:   Sat May 2 11:15:45 2020 +0100

    Fixed resource loading issue

commit 50cb38e3f00ef40ea8b6a468eadd66728a3ec332
Author: Laurent Cozic <laurent@cozic.net>
Date:   Fri May 1 23:46:58 2020 +0100

    Fixed resource loading logic

commit bc42ed03735f50c8394d597bb9e67312e55752fe
Author: Laurent Cozic <laurent@cozic.net>
Date:   Fri May 1 23:08:41 2020 +0100

    Various fixes

commit 03c038e6d6cbde03bd474798b96c4eb120fd1647
Author: Laurent Cozic <laurent@cozic.net>
Date:   Wed Apr 29 23:22:49 2020 +0100

    Fixed resource handling

commit dc6c15302fac094c4e7dec5a20c9fcc4edb3d132
Author: Laurent Cozic <laurent@cozic.net>
Date:   Wed Apr 29 22:55:13 2020 +0100

    Moved more code to files

commit 398d5121e53df34de89b4148ef2cfd3a7bbe4feb
Author: Laurent Cozic <laurent@cozic.net>
Date:   Wed Apr 29 00:22:43 2020 +0000

    More fixes

commit 3ebbb80147d7d502fd955776c7fedb743400597f
Author: Laurent Cozic <laurent@cozic.net>
Date:   Wed Apr 29 00:12:44 2020 +0000

    Various improvements and bug fixes

commit 52a65ed3875e0709117ca93ba723e20624577d05
Author: Laurent Cozic <laurent@cozic.net>
Date:   Tue Apr 28 23:51:07 2020 +0000

    Move more code to sub-files

commit 33ccf530fb442d7ddae0852cbab2c335efdbbf33
Author: Laurent Cozic <laurent@cozic.net>
Date:   Tue Apr 28 23:25:12 2020 +0100

    Moved code to sub-files

commit ba3ad2cf9fcc1d7809df4afe93cd9737585a9960
Merge: 445acdab73 150ee14de6
Author: Laurent Cozic <laurent@cozic.net>
Date:   Tue Apr 28 22:28:56 2020 +0100

    Merge branch 'master' into refactor_note_text

commit 445acdab7368345369d7f69b9becd1e77c8383dc
Author: Laurent Cozic <laurent@cozic.net>
Date:   Tue Apr 28 19:01:41 2020 +0100

    Imported more code

commit 772481d3a3ac7f0b0b00e86394c0f4fd2f3a9fa7
Author: Laurent Cozic <laurent@cozic.net>
Date:   Mon Apr 27 23:43:17 2020 +0000

    Handle save/load state

commit b3b92192ae3a1a30e3018810346cebfad47ac5e3
Author: Laurent Cozic <laurent@cozic.net>
Date:   Mon Apr 27 23:11:11 2020 +0000

    Clean up and added back scroll

commit 7a19ecfd0cb7fef1d58ece2e024099c7e40986da
Author: Laurent Cozic <laurent@cozic.net>
Date:   Mon Apr 27 22:29:39 2020 +0100

    More refactoring

commit ac388afd381eaecfa4582b3566d032c9d953c4dc
Author: Laurent Cozic <laurent@cozic.net>
Date:   Sun Apr 26 17:07:01 2020 +0100

    Restored print

commit 1d2c0ed389a5398dacc584d24922c5ea0dda861a
Author: Laurent Cozic <laurent@cozic.net>
Date:   Sun Apr 26 12:03:15 2020 +0100

    Put back search

commit c618cb59d43fa3bb507dbd0b757b302ecfe907b3
Author: Laurent Cozic <laurent@cozic.net>
Date:   Sat Apr 25 18:21:11 2020 +0100

    Restore scrolling behaviour

commit 324e6ea79ebafab1d2bca246ef030751147a47eb
Author: Laurent Cozic <laurent@cozic.net>
Date:   Sat Apr 25 10:22:31 2020 +0100

    Simplified saving notes

commit ef089aaf2289193bf275d94c1f2785f6d88657e4
Author: Laurent Cozic <laurent@cozic.net>
Date:   Sat Apr 25 10:12:16 2020 +0100

    More refactoring

commit 61b102307d5a98d2c1502d7bf073592da21af720
Author: Laurent Cozic <laurent@cozic.net>
Date:   Fri Apr 24 18:04:44 2020 +0100

    Added back note revisions

commit 7d5e3694d0df044b8493d9114e89e2d81c9b69ad
Author: Laurent Cozic <laurent@cozic.net>
Date:   Thu Apr 23 22:51:52 2020 +0000

    More note toolbar refactoring

commit a56d58e7c80d91f29afadaffaaa004f3254482f7
Author: Laurent Cozic <laurent@cozic.net>
Date:   Thu Apr 23 20:54:37 2020 +0100

    Finished toolbar refactoring

commit 6c8ef9f44f880a9569eed5c54c9c47dca2251e5e
Author: Laurent Cozic <laurent@cozic.net>
Date:   Thu Apr 23 19:17:44 2020 +0100

    More refactoring

commit 7de8057158a9256e2e0dcf948081e10a6a642216
Author: Laurent Cozic <laurent@cozic.net>
Date:   Wed Apr 22 23:48:42 2020 +0100

    Started refactoring commands

commit 177263c85e7d17d8ddc01b583738c2ab14b3acd7
Merge: f58f1a06e0 7ceb68d835
Author: Laurent Cozic <laurent@cozic.net>
Date:   Wed Apr 22 20:26:19 2020 +0100

    Merge branch 'master' into refactor_note_text

commit f58f1a06e08b3cf80e2ac7a794b15f4b5caf8932
Author: Laurent Cozic <laurent@cozic.net>
Date:   Wed Apr 22 20:25:43 2020 +0100

    Moving Ace Editor to separate component

commit a83d3a220515137985c0f334f5848c91b8539138
Author: Laurent Cozic <laurent@cozic.net>
Date:   Mon Apr 20 20:33:21 2020 +0000

    Cleaned up directory structure for note editor

commit c6f2e609c9443bac21de5033bbedf86ac6f12cc0
Author: Laurent Cozic <laurent@cozic.net>
Date:   Mon Apr 20 19:23:06 2020 +0100

    Added "note" menu to move note-related items to it

commit 1219465318ae5a7a2c777ae2ec15d3357e1499df
Author: Laurent Cozic <laurent@cozic.net>
Date:   Mon Apr 20 19:05:04 2020 +0100

    Moved note related toolbar to separate component
This commit is contained in:
Laurent Cozic 2020-05-02 16:41:07 +01:00
parent b16ebbbf7a
commit cb8dca747b
60 changed files with 3861 additions and 1392 deletions

View File

@ -27,8 +27,8 @@ Clipper/popup/node_modules
Clipper/popup/scripts/build.js Clipper/popup/scripts/build.js
docs/ docs/
ElectronClient/dist ElectronClient/dist
ElectronClient/gui/editors/TinyMCE/plugins/lists.js
ElectronClient/lib ElectronClient/lib
ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/plugins/lists.js
ElectronClient/lib/vendor/sjcl-rn.js ElectronClient/lib/vendor/sjcl-rn.js
ElectronClient/lib/vendor/sjcl.js ElectronClient/lib/vendor/sjcl.js
ElectronClient/locales ElectronClient/locales
@ -59,15 +59,32 @@ Tools/PortableAppsLauncher
Modules/TinyMCE/IconPack/postinstall.js Modules/TinyMCE/IconPack/postinstall.js
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD # AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
ElectronClient/gui/editors/PlainEditor.js
ElectronClient/gui/editors/TinyMCE.js
ElectronClient/gui/MultiNoteActions.js ElectronClient/gui/MultiNoteActions.js
ElectronClient/gui/NoteContentPropertiesDialog.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/ResourceScreen.js
ElectronClient/gui/ShareNoteDialog.js ElectronClient/gui/ShareNoteDialog.js
ElectronClient/gui/utils/NoteText.js
ReactNativeClient/lib/AsyncActionQueue.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/checkbox.js
ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/fence.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/fence.js
ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js

View File

@ -49,6 +49,7 @@ module.exports = {
"react/jsx-uses-react": "error", "react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error", "react/jsx-uses-vars": "error",
"no-unused-vars": "error", "no-unused-vars": "error",
"@typescript-eslint/no-unused-vars": "error",
"no-constant-condition": 0, "no-constant-condition": 0,
"no-prototype-builtins": 0, "no-prototype-builtins": 0,
// This error is always a false positive so far since it detects // This error is always a false positive so far since it detects

25
.gitignore vendored
View File

@ -50,15 +50,32 @@ Tools/commit_hook.txt
*.map *.map
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD # AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
ElectronClient/gui/editors/PlainEditor.js
ElectronClient/gui/editors/TinyMCE.js
ElectronClient/gui/MultiNoteActions.js ElectronClient/gui/MultiNoteActions.js
ElectronClient/gui/NoteContentPropertiesDialog.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/ResourceScreen.js
ElectronClient/gui/ShareNoteDialog.js ElectronClient/gui/ShareNoteDialog.js
ElectronClient/gui/utils/NoteText.js
ReactNativeClient/lib/AsyncActionQueue.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/checkbox.js
ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/fence.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/fence.js
ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js

View File

@ -36,7 +36,7 @@ const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';
const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false'; const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false';
const imageInlineSizeLimit = parseInt( const imageInlineSizeLimit = parseInt(
process.env.IMAGE_INLINE_SIZE_LIMIT || '10000' process.env.IMAGE_INLINE_SIZE_LIMIT || '10000'
); );
// Check if TypeScript is setup // Check if TypeScript is setup
@ -51,113 +51,113 @@ const sassModuleRegex = /\.module\.(scss|sass)$/;
// This is the production and development configuration. // This is the production and development configuration.
// It is focused on developer experience, fast rebuilds, and a minimal bundle. // It is focused on developer experience, fast rebuilds, and a minimal bundle.
module.exports = function(webpackEnv) { module.exports = function(webpackEnv) {
const isEnvDevelopment = webpackEnv === 'development'; const isEnvDevelopment = webpackEnv === 'development';
const isEnvProduction = webpackEnv === 'production'; const isEnvProduction = webpackEnv === 'production';
// Variable used for enabling profiling in Production // Variable used for enabling profiling in Production
// passed into alias object. Uses a flag if passed into the build command // passed into alias object. Uses a flag if passed into the build command
const isEnvProductionProfile = const isEnvProductionProfile =
isEnvProduction && process.argv.includes('--profile'); isEnvProduction && process.argv.includes('--profile');
// Webpack uses `publicPath` to determine where the app is being served from. // 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. // 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. // In development, we always serve from the root. This makes config easier.
const publicPath = isEnvProduction const publicPath = isEnvProduction
? paths.servedPath ? paths.servedPath
: isEnvDevelopment && '/'; : isEnvDevelopment && '/';
// Some apps do not use client-side routing with pushState. // Some apps do not use client-side routing with pushState.
// For these, "homepage" can be set to "." to enable relative asset paths. // For these, "homepage" can be set to "." to enable relative asset paths.
const shouldUseRelativeAssetPaths = publicPath === './'; const shouldUseRelativeAssetPaths = publicPath === './';
// `publicUrl` is just like `publicPath`, but we will provide it to our app // `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. // 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. // Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
const publicUrl = isEnvProduction const publicUrl = isEnvProduction
? publicPath.slice(0, -1) ? publicPath.slice(0, -1)
: isEnvDevelopment && ''; : isEnvDevelopment && '';
// Get environment variables to inject into our app. // Get environment variables to inject into our app.
const env = getClientEnvironment(publicUrl); const env = getClientEnvironment(publicUrl);
// common function to get style loaders // common function to get style loaders
const getStyleLoaders = (cssOptions, preProcessor) => { const getStyleLoaders = (cssOptions, preProcessor) => {
const loaders = [ const loaders = [
isEnvDevelopment && require.resolve('style-loader'), isEnvDevelopment && require.resolve('style-loader'),
isEnvProduction && { isEnvProduction && {
loader: MiniCssExtractPlugin.loader, loader: MiniCssExtractPlugin.loader,
options: shouldUseRelativeAssetPaths ? { publicPath: '../../' } : {}, options: shouldUseRelativeAssetPaths ? { publicPath: '../../' } : {},
}, },
{ {
loader: require.resolve('css-loader'), loader: require.resolve('css-loader'),
options: cssOptions, options: cssOptions,
}, },
{ {
// Options for PostCSS as we reference these options twice // Options for PostCSS as we reference these options twice
// Adds vendor prefixing based on your specified browser support in // Adds vendor prefixing based on your specified browser support in
// package.json // package.json
loader: require.resolve('postcss-loader'), loader: require.resolve('postcss-loader'),
options: { options: {
// Necessary for external CSS imports to work // Necessary for external CSS imports to work
// https://github.com/facebook/create-react-app/issues/2677 // https://github.com/facebook/create-react-app/issues/2677
ident: 'postcss', ident: 'postcss',
plugins: () => [ plugins: () => [
require('postcss-flexbugs-fixes'), require('postcss-flexbugs-fixes'),
require('postcss-preset-env')({ require('postcss-preset-env')({
autoprefixer: { autoprefixer: {
flexbox: 'no-2009', flexbox: 'no-2009',
}, },
stage: 3, stage: 3,
}), }),
// Adds PostCSS Normalize as the reset css with default options, // Adds PostCSS Normalize as the reset css with default options,
// so that it honors browserslist config in package.json // so that it honors browserslist config in package.json
// which in turn let's users customize the target behavior as per their needs. // which in turn let's users customize the target behavior as per their needs.
postcssNormalize(), postcssNormalize(),
], ],
sourceMap: isEnvProduction && shouldUseSourceMap, sourceMap: isEnvProduction && shouldUseSourceMap,
}, },
}, },
].filter(Boolean); ].filter(Boolean);
if (preProcessor) { if (preProcessor) {
loaders.push( loaders.push(
{ {
loader: require.resolve('resolve-url-loader'), loader: require.resolve('resolve-url-loader'),
options: { options: {
sourceMap: isEnvProduction && shouldUseSourceMap, sourceMap: isEnvProduction && shouldUseSourceMap,
}, },
}, },
{ {
loader: require.resolve(preProcessor), loader: require.resolve(preProcessor),
options: { options: {
sourceMap: true, sourceMap: true,
}, },
} }
); );
} }
return loaders; return loaders;
}; };
return { return {
mode: isEnvProduction ? 'production' : isEnvDevelopment && 'development', mode: isEnvProduction ? 'production' : isEnvDevelopment && 'development',
// Stop compilation early in production // Stop compilation early in production
bail: isEnvProduction, bail: isEnvProduction,
devtool: isEnvProduction devtool: isEnvProduction
? shouldUseSourceMap ? shouldUseSourceMap
? 'source-map' ? 'source-map'
: false : false
: isEnvDevelopment && 'cheap-module-source-map', : isEnvDevelopment && 'cheap-module-source-map',
// These are the "entry points" to our application. // These are the "entry points" to our application.
// This means they will be the "root" imports that are included in JS bundle. // This means they will be the "root" imports that are included in JS bundle.
entry: [ entry: [
// Include an alternative client for WebpackDevServer. A client's job is to // Include an alternative client for WebpackDevServer. A client's job is to
// connect to WebpackDevServer by a socket and get notified about changes. // 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 // 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 // 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. // make a syntax error, this client will display a syntax error overlay.
// Note: instead of the default WebpackDevServer client, we use a custom one // Note: instead of the default WebpackDevServer client, we use a custom one
// to bring better experience for Create React App users. You can replace // 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: // the line below with these two lines if you prefer the stock client:
// require.resolve('webpack-dev-server/client') + '?/', // require.resolve('webpack-dev-server/client') + '?/',
// require.resolve('webpack/hot/dev-server'), // require.resolve('webpack/hot/dev-server'),
isEnvDevelopment && isEnvDevelopment &&
require.resolve('react-dev-utils/webpackHotDevClient'), require.resolve('react-dev-utils/webpackHotDevClient'),
// Finally, this is your app's code: // Finally, this is your app's code:
paths.appIndexJs, paths.appIndexJs,
@ -329,7 +329,6 @@ module.exports = function(webpackEnv) {
rules: [ rules: [
// Disable require.ensure as it's not a standard language feature. // Disable require.ensure as it's not a standard language feature.
{ parser: { requireEnsure: false } }, { parser: { requireEnsure: false } },
// First, run the linter. // First, run the linter.
// It's important to do this before Babel processes the JS. // It's important to do this before Babel processes the JS.
// //
@ -379,8 +378,7 @@ module.exports = function(webpackEnv) {
options: { options: {
customize: require.resolve( customize: require.resolve(
'babel-preset-react-app/webpack-overrides' 'babel-preset-react-app/webpack-overrides'
), ),
plugins: [ plugins: [
[ [
require.resolve('babel-plugin-named-asset-import'), require.resolve('babel-plugin-named-asset-import'),
@ -422,7 +420,6 @@ module.exports = function(webpackEnv) {
cacheDirectory: true, cacheDirectory: true,
// See #6846 for context on why cacheCompression is disabled // See #6846 for context on why cacheCompression is disabled
cacheCompression: false, cacheCompression: false,
// Babel sourcemaps are needed for debugging into node_modules // Babel sourcemaps are needed for debugging into node_modules
// code. Without the options below, debuggers like VSCode // code. Without the options below, debuggers like VSCode
// show incorrect code and set breakpoints on the wrong lines. // show incorrect code and set breakpoints on the wrong lines.
@ -551,131 +548,131 @@ module.exports = function(webpackEnv) {
isEnvProduction && isEnvProduction &&
shouldInlineRuntimeChunk && shouldInlineRuntimeChunk &&
new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime-.+[.]js/]), new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime-.+[.]js/]),
// Makes some environment variables available in index.html. // Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.: // The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="icon" href="%PUBLIC_URL%/favicon.ico"> // <link rel="icon" href="%PUBLIC_URL%/favicon.ico">
// In production, it will be an empty string unless you specify "homepage" // 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 `package.json`, in which case it will be the pathname of that URL.
// In development, this will be an empty string. // In development, this will be an empty string.
new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw), new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw),
// This gives some necessary context to module not found errors, such as // This gives some necessary context to module not found errors, such as
// the requesting resource. // the requesting resource.
new ModuleNotFoundPlugin(paths.appPath), new ModuleNotFoundPlugin(paths.appPath),
// Makes some environment variables available to the JS code, for example: // Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`. // if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
// It is absolutely essential that NODE_ENV is set to production // It is absolutely essential that NODE_ENV is set to production
// during a production build. // during a production build.
// Otherwise React will be compiled in the very slow development mode. // Otherwise React will be compiled in the very slow development mode.
new webpack.DefinePlugin(env.stringified), new webpack.DefinePlugin(env.stringified),
// This is necessary to emit hot updates (currently CSS only): // This is necessary to emit hot updates (currently CSS only):
isEnvDevelopment && new webpack.HotModuleReplacementPlugin(), isEnvDevelopment && new webpack.HotModuleReplacementPlugin(),
// Watcher doesn't work well if you mistype casing in a path so we use // 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. // a plugin that prints an error when you attempt to do this.
// See https://github.com/facebook/create-react-app/issues/240 // See https://github.com/facebook/create-react-app/issues/240
isEnvDevelopment && new CaseSensitivePathsPlugin(), isEnvDevelopment && new CaseSensitivePathsPlugin(),
// If you require a missing module and then `npm install` it, you still have // 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 // to restart the development server for Webpack to discover it. This plugin
// makes the discovery automatic so you don't have to restart. // makes the discovery automatic so you don't have to restart.
// See https://github.com/facebook/create-react-app/issues/186 // See https://github.com/facebook/create-react-app/issues/186
isEnvDevelopment && isEnvDevelopment &&
new WatchMissingNodeModulesPlugin(paths.appNodeModules), new WatchMissingNodeModulesPlugin(paths.appNodeModules),
isEnvProduction && isEnvProduction &&
new MiniCssExtractPlugin({ new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output // Options similar to the same options in webpackOptions.output
// both options are optional // both options are optional
filename: 'static/css/[name].css', filename: 'static/css/[name].css',
chunkFilename: 'static/css/[name].chunk.css', chunkFilename: 'static/css/[name].chunk.css',
}), }),
// Generate an asset manifest file with the following content: // Generate an asset manifest file with the following content:
// - "files" key: Mapping of all asset filenames to their corresponding // - "files" key: Mapping of all asset filenames to their corresponding
// output file so that tools can pick it up without having to parse // output file so that tools can pick it up without having to parse
// `index.html` // `index.html`
// - "entrypoints" key: Array of files which are included in `index.html`, // - "entrypoints" key: Array of files which are included in `index.html`,
// can be used to reconstruct the HTML if necessary // can be used to reconstruct the HTML if necessary
new ManifestPlugin({ new ManifestPlugin({
fileName: 'asset-manifest.json', fileName: 'asset-manifest.json',
publicPath: publicPath, publicPath: publicPath,
generate: (seed, files, entrypoints) => { generate: (seed, files, entrypoints) => {
const manifestFiles = files.reduce((manifest, file) => { const manifestFiles = files.reduce((manifest, file) => {
manifest[file.name] = file.path; manifest[file.name] = file.path;
return manifest; return manifest;
}, seed); }, seed);
const entrypointFiles = entrypoints.main.filter( const entrypointFiles = entrypoints.main.filter(
fileName => !fileName.endsWith('.map') fileName => !fileName.endsWith('.map')
); );
return { return {
files: manifestFiles, files: manifestFiles,
entrypoints: entrypointFiles, entrypoints: entrypointFiles,
}; };
}, },
}), }),
// Moment.js is an extremely popular library that bundles large locale files // 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 // by default due to how Webpack interprets its code. This is a practical
// solution that requires the user to opt into importing specific locales. // solution that requires the user to opt into importing specific locales.
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack // https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
// You can remove this if you don't use Moment.js: // You can remove this if you don't use Moment.js:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
// Generate a service worker script that will precache, and keep up to date, // Generate a service worker script that will precache, and keep up to date,
// the HTML & assets that are part of the Webpack build. // the HTML & assets that are part of the Webpack build.
// isEnvProduction && // isEnvProduction &&
// new WorkboxWebpackPlugin.GenerateSW({ // new WorkboxWebpackPlugin.GenerateSW({
// clientsClaim: true, // clientsClaim: true,
// exclude: [/\.map$/, /asset-manifest\.json$/], // exclude: [/\.map$/, /asset-manifest\.json$/],
// importWorkboxFrom: 'cdn', // importWorkboxFrom: 'cdn',
// navigateFallback: `${publicUrl}/index.html`, // navigateFallback: `${publicUrl}/index.html`,
// navigateFallbackBlacklist: [ // navigateFallbackBlacklist: [
// // Exclude URLs starting with /_, as they're likely an API call // // Exclude URLs starting with /_, as they're likely an API call
// new RegExp('^/_'), // new RegExp('^/_'),
// // Exclude any URLs whose last part seems to be a file extension // // Exclude any URLs whose last part seems to be a file extension
// // as they're likely a resource and not a SPA route. // // as they're likely a resource and not a SPA route.
// // URLs containing a "?" character won't be blacklisted as they're likely // // URLs containing a "?" character won't be blacklisted as they're likely
// // a route with query params (e.g. auth callbacks). // // a route with query params (e.g. auth callbacks).
// new RegExp('/[^/?]+\\.[^/]+$'), // new RegExp('/[^/?]+\\.[^/]+$'),
// ], // ],
// }), // }),
// TypeScript type checking // TypeScript type checking
useTypeScript && useTypeScript &&
new ForkTsCheckerWebpackPlugin({ new ForkTsCheckerWebpackPlugin({
typescript: resolve.sync('typescript', { typescript: resolve.sync('typescript', {
basedir: paths.appNodeModules, basedir: paths.appNodeModules,
}), }),
async: isEnvDevelopment, async: isEnvDevelopment,
useTypescriptIncrementalApi: true, useTypescriptIncrementalApi: true,
checkSyntacticErrors: true, checkSyntacticErrors: true,
resolveModuleNameModule: process.versions.pnp resolveModuleNameModule: process.versions.pnp
? `${__dirname}/pnpTs.js` ? `${__dirname}/pnpTs.js`
: undefined, : undefined,
resolveTypeReferenceDirectiveModule: process.versions.pnp resolveTypeReferenceDirectiveModule: process.versions.pnp
? `${__dirname}/pnpTs.js` ? `${__dirname}/pnpTs.js`
: undefined, : undefined,
tsconfig: paths.appTsConfig, tsconfig: paths.appTsConfig,
reportFiles: [ reportFiles: [
'**', '**',
'!**/__tests__/**', '!**/__tests__/**',
'!**/?(*.)(spec|test).*', '!**/?(*.)(spec|test).*',
'!**/src/setupProxy.*', '!**/src/setupProxy.*',
'!**/src/setupTests.*', '!**/src/setupTests.*',
], ],
silent: true, silent: true,
// The formatter is invoked directly in WebpackDevServerUtils during development // The formatter is invoked directly in WebpackDevServerUtils during development
formatter: isEnvProduction ? typescriptFormatter : undefined, formatter: isEnvProduction ? typescriptFormatter : undefined,
}), }),
].filter(Boolean), ].filter(Boolean),
// Some libraries import Node modules but don't use them in the browser. // 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. // Tell Webpack to provide empty mocks for them so importing them works.
node: { node: {
module: 'empty', module: 'empty',
dgram: 'empty', dgram: 'empty',
dns: 'mock', dns: 'mock',
fs: 'empty', fs: 'empty',
http2: 'empty', http2: 'empty',
net: 'empty', net: 'empty',
tls: 'empty', tls: 'empty',
child_process: 'empty', child_process: 'empty',
}, },
// Turn off performance processing because we utilize // Turn off performance processing because we utilize
// our own hints via the FileSizeReporter // our own hints via the FileSizeReporter
performance: false, performance: false,
}; };
}; };

View File

@ -70,6 +70,8 @@ class InteropServiceHelper {
cleanup(); cleanup();
} }
} else { } else {
// TODO: it is crashing at this point
win.webContents.print(options, (success, reason) => { win.webContents.print(options, (success, reason) => {
// TODO: This is correct but broken in Electron 4. Need to upgrade to 5+ // 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. // It calls the callback right away with "false" even if the document hasn't be print yet.

View File

@ -134,8 +134,6 @@ class Application extends BaseApplication {
paneOptions = ['editor', 'both']; paneOptions = ['editor', 'both'];
} else if (state.settings.layoutButtonSequence === Setting.LAYOUT_VIEWER_SPLIT) { } else if (state.settings.layoutButtonSequence === Setting.LAYOUT_VIEWER_SPLIT) {
paneOptions = ['viewer', 'both']; paneOptions = ['viewer', 'both'];
} else if (state.settings.layoutButtonSequence === Setting.LAYOUT_SPLIT_WYSIWYG) {
paneOptions = ['both', 'wysiwyg'];
} else { } else {
paneOptions = ['editor', 'viewer', 'both']; paneOptions = ['editor', 'viewer', 'both'];
} }
@ -547,6 +545,7 @@ class Application extends BaseApplication {
this.dispatch({ this.dispatch({
type: 'WINDOW_COMMAND', type: 'WINDOW_COMMAND',
name: 'print', name: 'print',
noteIds: this.store().getState().selectedNoteIds,
}); });
}, },
}; };
@ -890,33 +889,6 @@ class Application extends BaseApplication {
}, { }, {
type: 'separator', type: 'separator',
screens: ['Main'], 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', id: 'edit:focusSearch',
label: _('Search in all the notes'), label: _('Search in all the notes'),
@ -1056,6 +1028,46 @@ class Application extends BaseApplication {
accelerator: 'CommandOrControl+-', 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: { tools: {
label: _('&Tools'), label: _('&Tools'),
submenu: toolsItems, submenu: toolsItems,
@ -1136,6 +1148,7 @@ class Application extends BaseApplication {
rootMenus.file, rootMenus.file,
rootMenus.edit, rootMenus.edit,
rootMenus.view, rootMenus.view,
rootMenus.note,
rootMenus.tools, rootMenus.tools,
rootMenus.help, rootMenus.help,
]; ];

View File

@ -18,6 +18,10 @@ class EventManager {
return this.emitter_.removeListener(eventName, callback); return this.emitter_.removeListener(eventName, callback);
} }
off(eventName, callback) {
return this.removeListener(eventName, callback);
}
} }
const eventManager = new EventManager(); const eventManager = new EventManager();

View File

@ -134,6 +134,8 @@ class HeaderComponent extends React.Component {
} }
makeButton(key, style, options) { makeButton(key, style, options) {
const theme = themeStyle(this.props.theme);
let icon = null; let icon = null;
if (options.iconName) { if (options.iconName) {
const iconStyle = { const iconStyle = {
@ -158,6 +160,20 @@ class HeaderComponent extends React.Component {
const title = options.title ? options.title : ''; 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 ( return (
<a <a
className={classes.join(' ')} className={classes.join(' ')}
@ -256,6 +272,8 @@ class HeaderComponent extends React.Component {
height: theme.headerHeight, height: theme.headerHeight,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
paddingTop: 1,
paddingBottom: 1,
paddingLeft: theme.headerButtonHPadding, paddingLeft: theme.headerButtonHPadding,
paddingRight: theme.headerButtonHPadding, paddingRight: theme.headerButtonHPadding,
color: theme.color, color: theme.color,

View File

@ -3,18 +3,19 @@ const { connect } = require('react-redux');
const { Header } = require('./Header.min.js'); const { Header } = require('./Header.min.js');
const { SideBar } = require('./SideBar.min.js'); const { SideBar } = require('./SideBar.min.js');
const { NoteList } = require('./NoteList.min.js'); const { NoteList } = require('./NoteList.min.js');
const { NoteText } = require('./NoteText.min.js'); const NoteEditor = require('./NoteEditor/NoteEditor.js').default;
const NoteText2 = require('./NoteText2.js').default;
const { stateUtils } = require('lib/reducer.js'); const { stateUtils } = require('lib/reducer.js');
const { PromptDialog } = require('./PromptDialog.min.js'); const { PromptDialog } = require('./PromptDialog.min.js');
const NoteContentPropertiesDialog = require('./NoteContentPropertiesDialog.js').default; const NoteContentPropertiesDialog = require('./NoteContentPropertiesDialog.js').default;
const NotePropertiesDialog = require('./NotePropertiesDialog.min.js'); const NotePropertiesDialog = require('./NotePropertiesDialog.min.js');
const ShareNoteDialog = require('./ShareNoteDialog.js').default; const ShareNoteDialog = require('./ShareNoteDialog.js').default;
const InteropServiceHelper = require('../InteropServiceHelper.js');
const Setting = require('lib/models/Setting.js'); const Setting = require('lib/models/Setting.js');
const BaseModel = require('lib/BaseModel.js'); const BaseModel = require('lib/BaseModel.js');
const Tag = require('lib/models/Tag.js'); const Tag = require('lib/models/Tag.js');
const Note = require('lib/models/Note.js'); const Note = require('lib/models/Note.js');
const { uuid } = require('lib/uuid.js'); const { uuid } = require('lib/uuid.js');
const { shim } = require('lib/shim');
const Folder = require('lib/models/Folder.js'); const Folder = require('lib/models/Folder.js');
const { themeStyle } = require('../theme.js'); const { themeStyle } = require('../theme.js');
const { _ } = require('lib/locale.js'); const { _ } = require('lib/locale.js');
@ -25,6 +26,7 @@ const PluginManager = require('lib/services/PluginManager');
const TemplateUtils = require('lib/TemplateUtils'); const TemplateUtils = require('lib/TemplateUtils');
const EncryptionService = require('lib/services/EncryptionService'); const EncryptionService = require('lib/services/EncryptionService');
const ipcRenderer = require('electron').ipcRenderer; const ipcRenderer = require('electron').ipcRenderer;
const { time } = require('lib/time-utils.js');
class MainScreenComponent extends React.Component { class MainScreenComponent extends React.Component {
constructor() { constructor() {
@ -48,6 +50,8 @@ class MainScreenComponent extends React.Component {
this.shareNoteDialog_close = this.shareNoteDialog_close.bind(this); this.shareNoteDialog_close = this.shareNoteDialog_close.bind(this);
this.sidebar_onDrag = this.sidebar_onDrag.bind(this); this.sidebar_onDrag = this.sidebar_onDrag.bind(this);
this.noteList_onDrag = this.noteList_onDrag.bind(this); this.noteList_onDrag = this.noteList_onDrag.bind(this);
this.commandSavePdf = this.commandSavePdf.bind(this);
this.commandPrint = this.commandPrint.bind(this);
} }
setupAppCloseHandling() { setupAppCloseHandling() {
@ -149,6 +153,9 @@ class MainScreenComponent extends React.Component {
let commandProcessed = true; let commandProcessed = true;
let delayedFunction = null;
let delayedArgs = null;
if (command.name === 'newNote') { if (command.name === 'newNote') {
if (!this.props.folders.length) { if (!this.props.folders.length) {
bridge().showErrorMessageBox(_('Please create a notebook first.')); bridge().showErrorMessageBox(_('Please create a notebook first.'));
@ -350,13 +357,16 @@ class MainScreenComponent extends React.Component {
}, },
}); });
} else if (command.name === 'commandContentProperties') { } else if (command.name === 'commandContentProperties') {
this.setState({ const note = await Note.load(this.props.selectedNoteId);
noteContentPropertiesDialogOptions: { if (note) {
visible: true, this.setState({
text: command.text, noteContentPropertiesDialogOptions: {
lines: command.lines, visible: true,
}, text: note.body,
}); // lines: command.lines,
},
});
}
} else if (command.name === 'commandShareNoteDialog') { } else if (command.name === 'commandShareNoteDialog') {
this.setState({ this.setState({
shareNoteDialogOptions: { shareNoteDialogOptions: {
@ -413,7 +423,7 @@ class MainScreenComponent extends React.Component {
if (newNote) { if (newNote) {
await Note.save(newNote); await Note.save(newNote);
eventManager.emit('alarmChange', { noteId: note.id }); eventManager.emit('alarmChange', { noteId: note.id, note: newNote });
} }
this.setState({ promptOptions: null }); this.setState({ promptOptions: null });
@ -444,6 +454,12 @@ class MainScreenComponent extends React.Component {
}, },
}, },
}); });
} else if (command.name === 'exportPdf') {
delayedFunction = this.commandSavePdf;
delayedArgs = { noteIds: command.noteIds };
} else if (command.name === 'print') {
delayedFunction = this.commandPrint;
delayedArgs = { noteIds: command.noteIds };
} else { } else {
commandProcessed = false; commandProcessed = false;
} }
@ -454,6 +470,106 @@ class MainScreenComponent extends React.Component {
name: null, name: null,
}); });
} }
if (delayedFunction) {
requestAnimationFrame(() => {
delayedFunction = delayedFunction.bind(this);
delayedFunction(delayedArgs);
});
}
}
async waitForNoteToSaved(noteId) {
while (noteId && this.props.editorNoteStatuses[noteId] === 'saving') {
console.info('Waiting for note to be saved...', this.props.editorNoteStatuses);
await time.msleep(100);
}
}
async printTo_(target, options) {
// Concurrent print calls are disallowed to avoid incorrect settings being restored upon completion
if (this.isPrinting_) {
console.info(`Printing ${options.path} to ${target} disallowed, already printing.`);
return;
}
this.isPrinting_ = true;
// Need to wait for save because the interop service reloads the note from the database
await this.waitForNoteToSaved(options.noteId);
if (target === 'pdf') {
try {
const pdfData = await InteropServiceHelper.exportNoteToPdf(options.noteId, {
printBackground: true,
pageSize: Setting.value('export.pdfPageSize'),
landscape: Setting.value('export.pdfPageOrientation') === 'landscape',
customCss: this.props.customCss,
});
await shim.fsDriver().writeFile(options.path, pdfData, 'buffer');
} catch (error) {
console.error(error);
bridge().showErrorMessageBox(error.message);
}
} else if (target === 'printer') {
try {
await InteropServiceHelper.printNote(options.noteId, {
printBackground: true,
customCss: this.props.customCss,
});
} catch (error) {
console.error(error);
bridge().showErrorMessageBox(error.message);
}
}
this.isPrinting_ = false;
}
async commandSavePdf(args) {
try {
const noteIds = args.noteIds;
if (!noteIds.length) throw new Error('No notes selected for pdf export');
let path = null;
if (noteIds.length === 1) {
path = bridge().showSaveDialog({
filters: [{ name: _('PDF File'), extensions: ['pdf'] }],
defaultPath: await InteropServiceHelper.defaultFilename(noteIds, 'pdf'),
});
} else {
path = bridge().showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
});
}
if (!path) return;
for (let i = 0; i < noteIds.length; i++) {
const note = await Note.load(noteIds[i]);
const folder = Folder.byId(this.props.folders, note.parent_id);
const pdfPath = (noteIds.length === 1) ? path :
await shim.fsDriver().findUniqueFilename(`${path}/${this.pdfFileName_(note, folder)}`);
await this.printTo_('pdf', { path: pdfPath, noteId: note.id });
}
} catch (error) {
bridge().showErrorMessageBox(error.message);
}
}
async commandPrint(args) {
// TODO: test
try {
const noteIds = args.noteIds;
if (noteIds.length !== 1) throw new Error(_('Only one note can be printed at a time.'));
await this.printTo_('printer', { noteId: noteIds[0] });
} catch (error) {
bridge().showErrorMessageBox(error.message);
}
} }
styles(themeId, width, height, messageBoxVisible, isSidebarVisible, isNoteListVisible, sidebarWidth, noteListWidth) { styles(themeId, width, height, messageBoxVisible, isSidebarVisible, isNoteListVisible, sidebarWidth, noteListWidth) {
@ -683,14 +799,32 @@ class MainScreenComponent extends React.Component {
}); });
headerItems.push({ headerItems.push({
title: _('Layout'), title: _('Code View'),
iconName: 'fa-columns', iconName: 'fa-file-code-o ',
enabled: !!notes.length, enabled: !!notes.length,
type: 'checkbox',
checked: this.props.settingEditorCodeView,
onClick: () => { 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({ headerItems.push({
title: _('Search...'), title: _('Search...'),
iconName: 'fa-search', iconName: 'fa-search',
@ -716,13 +850,9 @@ class MainScreenComponent extends React.Component {
const notePropertiesDialogOptions = this.state.notePropertiesDialogOptions; const notePropertiesDialogOptions = this.state.notePropertiesDialogOptions;
const noteContentPropertiesDialogOptions = this.state.noteContentPropertiesDialogOptions; const noteContentPropertiesDialogOptions = this.state.noteContentPropertiesDialogOptions;
const shareNoteDialogOptions = this.state.shareNoteDialogOptions; const shareNoteDialogOptions = this.state.shareNoteDialogOptions;
const keyboardMode = Setting.value('editor.keyboardMode');
const isWYSIWYG = this.props.noteVisiblePanes.length && this.props.noteVisiblePanes[0] === 'wysiwyg'; const bodyEditor = this.props.settingEditorCodeView ? 'AceEditor' : 'TinyMCE';
const noteTextComp = isWYSIWYG ? const noteTextComp = <NoteEditor bodyEditor={bodyEditor} style={styles.noteText} />;
<NoteText2 editor="TinyMCE" style={styles.noteText} keyboardMode={keyboardMode} visiblePanes={this.props.noteVisiblePanes} />
:
<NoteText style={styles.noteText} keyboardMode={keyboardMode} visiblePanes={this.props.noteVisiblePanes} />;
return ( return (
<div style={style}> <div style={style}>
@ -750,8 +880,8 @@ class MainScreenComponent extends React.Component {
const mapStateToProps = state => { const mapStateToProps = state => {
return { return {
theme: state.settings.theme, theme: state.settings.theme,
settingEditorCodeView: state.settings['editor.codeView'],
windowCommand: state.windowCommand, windowCommand: state.windowCommand,
noteVisiblePanes: state.noteVisiblePanes,
sidebarVisibility: state.sidebarVisibility, sidebarVisibility: state.sidebarVisibility,
noteListVisibility: state.noteListVisibility, noteListVisibility: state.noteListVisibility,
folders: state.folders, folders: state.folders,
@ -767,6 +897,8 @@ const mapStateToProps = state => {
selectedNoteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null, selectedNoteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null,
plugins: state.plugins, plugins: state.plugins,
templates: state.templates, templates: state.templates,
customCss: state.customCss,
editorNoteStatuses: state.editorNoteStatuses,
hasNotesBeingSaved: stateUtils.hasNotesBeingSaved(state), hasNotesBeingSaved: stateUtils.hasNotesBeingSaved(state),
}; };
}; };

View File

@ -74,7 +74,7 @@ export default function NoteContentPropertiesDialog(props:NoteContentPropertiesD
return ( return (
<div style={theme.dialogModalLayer}> <div style={theme.dialogModalLayer}>
<div style={theme.dialogBox}> <div style={theme.dialogBox}>
<div style={theme.dialogTitle}>{_('Content properties')}</div> <div style={theme.dialogTitle}>{_('Statistics')}</div>
<div>{textComps}</div> <div>{textComps}</div>
<DialogButtonRow theme={props.theme} onClick={buttonRow_click} okButtonShow={false} cancelButtonLabel={_('Close')}/> <DialogButtonRow theme={props.theme} onClick={buttonRow_click} okButtonShow={false} cancelButtonLabel={_('Close')}/>
</div> </div>

View File

@ -0,0 +1,686 @@
import * as React from 'react';
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo } from 'react';
// eslint-disable-next-line no-unused-vars
import { EditorCommand, NoteBodyEditorProps } from '../../utils/types';
import { commandAttachFileToBody } from '../../utils/resourceHandling';
import { ScrollOptions, ScrollOptionTypes } from '../../utils/types';
import { textOffsetToCursorPosition, useScrollHandler, usePrevious, lineLeftSpaces, selectionRangeCurrentLine, selectionRangePreviousLine, currentTextOffset, textOffsetSelection, selectedText, useSelectionRange } from './utils';
import Toolbar from './Toolbar';
import styles_ from './styles';
import { RenderedBody, defaultRenderedBody } from './utils/types';
const AceEditorReact = require('react-ace').default;
const { bridge } = require('electron').remote.require('./bridge');
const Note = require('lib/models/Note.js');
const { clipboard } = require('electron');
const mimeUtils = require('lib/mime-utils.js').mime;
const Setting = require('lib/models/Setting.js');
const NoteTextViewer = require('../../../NoteTextViewer.min');
const shared = require('lib/components/shared/note-screen-shared.js');
const md5 = require('md5');
const { shim } = require('lib/shim.js');
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
const markdownUtils = require('lib/markdownUtils');
const { _ } = require('lib/locale');
const { reg } = require('lib/registry.js');
const dialogs = require('../../../dialogs');
require('brace/mode/markdown');
// https://ace.c9.io/build/kitchen-sink.html
// https://highlightjs.org/static/demo/
require('brace/theme/chrome');
require('brace/theme/solarized_light');
require('brace/theme/solarized_dark');
require('brace/theme/twilight');
require('brace/theme/dracula');
require('brace/theme/chaos');
require('brace/keybinding/vim');
require('brace/keybinding/emacs');
// TODO: Could not get below code to work
// @ts-ignore Ace global variable
// const aceGlobal = (ace as any);
// class CustomHighlightRules extends aceGlobal.acequire(
// 'ace/mode/markdown_highlight_rules'
// ).MarkdownHighlightRules {
// constructor() {
// super();
// if (Setting.value('markdown.plugin.mark')) {
// this.$rules.start.push({
// // This is actually a highlight `mark`, but Ace has no token name for
// // this so we made up our own. Reference for common tokens here:
// // https://github.com/ajaxorg/ace/wiki/Creating-or-Extending-an-Edit-Mode#common-tokens
// token: 'highlight_mark',
// regex: '==[^ ](?:.*?[^ ])?==',
// });
// }
// }
// }
// /* eslint-disable-next-line no-undef */
// class CustomMdMode extends aceGlobal.acequire('ace/mode/markdown').Mode {
// constructor() {
// super();
// this.HighlightRules = CustomHighlightRules;
// }
// }
function markupRenderOptions(override: any = null) {
return { ...override };
}
function AceEditor(props: NoteBodyEditorProps, ref: any) {
const styles = styles_(props);
const [renderedBody, setRenderedBody] = useState<RenderedBody>(defaultRenderedBody()); // Viewer content
const [editor, setEditor] = useState(null);
const [lastKeys, setLastKeys] = useState([]);
const [webviewReady, setWebviewReady] = useState(false);
const previousRenderedBody = usePrevious(renderedBody);
const previousSearchMarkers = usePrevious(props.searchMarkers);
const previousContentKey = usePrevious(props.contentKey);
const editorRef = useRef(null);
editorRef.current = editor;
const indentOrig = useRef<any>(null);
const webviewRef = useRef(null);
const props_onChangeRef = useRef<Function>(null);
props_onChangeRef.current = props.onChange;
const contentKeyHasChangedRef = useRef(false);
contentKeyHasChangedRef.current = previousContentKey !== props.contentKey;
// The selection range changes all the time, when the caret moves or
// when the selection changes, so it's best not to make it part of the
// state as it would trigger too many unecessary updates.
const selectionRangeRef = useRef(null);
selectionRangeRef.current = useSelectionRange(editor);
const { resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll } = useScrollHandler(editor, webviewRef, props.onScroll);
const aceEditor_change = useCallback((newBody: string) => {
props_onChangeRef.current({ changeId: null, content: newBody });
}, []);
const wrapSelectionWithStrings = useCallback((string1: string, string2 = '', defaultText = '', replacementText: string = null, byLine = false) => {
if (!editor) return;
const selection = textOffsetSelection(selectionRangeRef.current, props.content);
let newBody = props.content;
if (selection && selection.start !== selection.end) {
const selectedLines = replacementText !== null ? replacementText : props.content.substr(selection.start, selection.end - selection.start);
const selectedStrings = byLine ? selectedLines.split(/\r?\n/) : [selectedLines];
newBody = props.content.substr(0, selection.start);
let startCursorPos, endCursorPos;
for (let i = 0; i < selectedStrings.length; i++) {
if (byLine == false) {
const start = selectedStrings[i].search(/[^\s]/);
const end = selectedStrings[i].search(/[^\s](?=[\s]*$)/);
newBody += selectedStrings[i].substr(0, start) + string1 + selectedStrings[i].substr(start, end - start + 1) + string2 + selectedStrings[i].substr(end + 1);
// Getting position for correcting offset in highlighted text when surrounded by white spaces
startCursorPos = textOffsetToCursorPosition(selection.start + start, newBody);
endCursorPos = textOffsetToCursorPosition(selection.start + end + 1, newBody);
} else { newBody += string1 + selectedStrings[i] + string2; }
}
newBody += props.content.substr(selection.end);
const r = selectionRangeRef.current;
// Because some insertion strings will have newlines, we'll need to account for them
const str1Split = string1.split(/\r?\n/);
// Add the number of newlines to the row
// and add the length of the final line to the column (for strings with no newlines this is the string length)
let newRange: any = {};
if (!byLine) {
// Correcting offset in Highlighted text when surrounded by white spaces
newRange = {
start: {
row: startCursorPos.row,
column: startCursorPos.column + string1.length,
},
end: {
row: endCursorPos.row,
column: endCursorPos.column + string1.length,
},
};
} else {
newRange = {
start: {
row: r.start.row + str1Split.length - 1,
column: r.start.column + str1Split[str1Split.length - 1].length,
},
end: {
row: r.end.row + str1Split.length - 1,
column: r.end.column + str1Split[str1Split.length - 1].length,
},
};
}
if (replacementText !== null) {
const diff = replacementText.length - (selection.end - selection.start);
newRange.end.column += diff;
}
setTimeout(() => {
const range = selectionRangeRef.current;
range.setStart(newRange.start.row, newRange.start.column);
range.setEnd(newRange.end.row, newRange.end.column);
editor.getSession().getSelection().setSelectionRange(range, false);
editor.focus();
}, 10);
} else {
const middleText = replacementText !== null ? replacementText : defaultText;
const textOffset = currentTextOffset(editor, props.content);
const s1 = props.content.substr(0, textOffset);
const s2 = props.content.substr(textOffset);
newBody = s1 + string1 + middleText + string2 + s2;
const p = textOffsetToCursorPosition(textOffset + string1.length, newBody);
const newRange = {
start: { row: p.row, column: p.column },
end: { row: p.row, column: p.column + middleText.length },
};
// BUG!! If replacementText contains newline characters, the logic
// to select the new text will not work.
setTimeout(() => {
if (middleText && newRange) {
const range = selectionRangeRef.current;
range.setStart(newRange.start.row, newRange.start.column);
range.setEnd(newRange.end.row, newRange.end.column);
editor.getSession().getSelection().setSelectionRange(range, false);
} else {
for (let i = 0; i < string1.length; i++) {
editor.getSession().getSelection().moveCursorRight();
}
}
editor.focus();
}, 10);
}
aceEditor_change(newBody);
}, [editor, props.content, aceEditor_change]);
const addListItem = useCallback((string1, string2 = '', defaultText = '', byLine = false) => {
let newLine = '\n';
const range = selectionRangeRef.current;
if (!range || (range.start.row === range.end.row && !selectionRangeCurrentLine(range, props.content))) {
newLine = '';
}
wrapSelectionWithStrings(newLine + string1, string2, defaultText, null, byLine);
}, [wrapSelectionWithStrings, props.content]);
useImperativeHandle(ref, () => {
return {
content: () => props.content,
setContent: (body: string) => {
aceEditor_change(body);
},
resetScroll: () => {
resetScroll();
},
scrollTo: (options:ScrollOptions) => {
if (options.type === ScrollOptionTypes.Hash) {
if (!webviewRef.current) return;
webviewRef.current.wrappedInstance.send('scrollToHash', options.value as string);
} else if (options.type === ScrollOptionTypes.Percent) {
const p = options.value as number;
setEditorPercentScroll(p);
setViewerPercentScroll(p);
} else {
throw new Error(`Unsupported scroll options: ${options.type}`);
}
},
clearState: () => {
if (!editor) return;
editor.clearSelection();
editor.moveCursorTo(0, 0);
},
execCommand: async (cmd: EditorCommand) => {
if (!editor) return false;
reg.logger().debug('AceEditor: execCommand', cmd);
let commandProcessed = true;
if (cmd.name === 'dropItems') {
if (cmd.value.type === 'notes') {
wrapSelectionWithStrings('', '', '', cmd.value.markdownTags.join('\n'));
} else if (cmd.value.type === 'files') {
const newBody = await commandAttachFileToBody(props.content, cmd.value.paths, { createFileURL: !!cmd.value.createFileURL });
aceEditor_change(newBody);
} else {
reg.logger().warn('AceEditor: unsupported drop item: ', cmd);
}
} else if (cmd.name === 'focus') {
editor.focus();
} else {
commandProcessed = false;
}
if (!commandProcessed) {
const commands: any = {
textBold: () => wrapSelectionWithStrings('**', '**', _('strong text')),
textItalic: () => wrapSelectionWithStrings('*', '*', _('emphasized text')),
textLink: async () => {
const url = await dialogs.prompt(_('Insert Hyperlink'));
if (url) wrapSelectionWithStrings('[', `](${url})`);
},
textCode: () => {
const selection = textOffsetSelection(selectionRangeRef.current, props.content);
const string = props.content.substr(selection.start, selection.end - selection.start);
// Look for newlines
const match = string.match(/\r?\n/);
if (match && match.length > 0) {
if (string.startsWith('```') && string.endsWith('```')) {
wrapSelectionWithStrings('', '', '', string.substr(4, selection.end - selection.start - 8));
} else {
wrapSelectionWithStrings(`\`\`\`${match[0]}`, `${match[0]}\`\`\``);
}
} else {
wrapSelectionWithStrings('`', '`', '');
}
},
insertText: (value: any) => wrapSelectionWithStrings(value),
attachFile: async () => {
const selection = textOffsetSelection(selectionRangeRef.current, props.content);
const newBody = await commandAttachFileToBody(props.content, null, { position: selection ? selection.start : 0 });
if (newBody) aceEditor_change(newBody);
},
textNumberedList: () => {
let bulletNumber = markdownUtils.olLineNumber(selectionRangeCurrentLine(selectionRangeRef.current, props.content));
if (!bulletNumber) bulletNumber = markdownUtils.olLineNumber(selectionRangePreviousLine(selectionRangeRef.current, props.content));
if (!bulletNumber) bulletNumber = 0;
addListItem(`${bulletNumber + 1}. `, '', _('List item'), true);
},
textBulletedList: () => addListItem('- ', '', _('List item'), true),
textCheckbox: () => addListItem('- [ ] ', '', _('List item'), true),
textHeading: () => addListItem('## ','','', true),
textHorizontalRule: () => addListItem('* * *'),
};
if (commands[cmd.name]) {
commands[cmd.name](cmd.value);
} else {
reg.logger().warn('AceEditor: unsupported Joplin command: ', cmd);
return false;
}
}
return true;
},
};
}, [editor, props.content, addListItem, wrapSelectionWithStrings, selectionRangeCurrentLine, aceEditor_change, setEditorPercentScroll, setViewerPercentScroll, resetScroll, renderedBody]);
const onEditorPaste = useCallback(async (event: any = null) => {
const formats = clipboard.availableFormats();
for (let i = 0; i < formats.length; i++) {
const format = formats[i].toLowerCase();
const formatType = format.split('/')[0];
const position = currentTextOffset(editor, props.content);
if (formatType === 'image') {
if (event) event.preventDefault();
const image = clipboard.readImage();
const fileExt = mimeUtils.toFileExtension(format);
const filePath = `${Setting.value('tempDir')}/${md5(Date.now())}.${fileExt}`;
await shim.writeImageToFile(image, format, filePath);
const newBody = await commandAttachFileToBody(props.content, [filePath], { position });
await shim.fsDriver().remove(filePath);
aceEditor_change(newBody);
}
}
}, [editor, props.content, aceEditor_change]);
const onEditorKeyDown = useCallback((event: any) => {
setLastKeys(prevLastKeys => {
const keys = prevLastKeys.slice();
keys.push(event.key);
while (keys.length > 2) keys.splice(0, 1);
return keys;
});
}, []);
const editorCutText = useCallback(() => {
const text = selectedText(selectionRangeRef.current, props.content);
if (!text) return;
clipboard.writeText(text);
const s = textOffsetSelection(selectionRangeRef.current, props.content);
if (!s || s.start === s.end) return;
const s1 = props.content.substr(0, s.start);
const s2 = props.content.substr(s.end);
aceEditor_change(s1 + s2);
setTimeout(() => {
const range = selectionRangeRef.current;
range.setStart(range.start.row, range.start.column);
range.setEnd(range.start.row, range.start.column);
editor.getSession().getSelection().setSelectionRange(range, false);
editor.focus();
}, 10);
}, [props.content, editor, aceEditor_change]);
const editorCopyText = useCallback(() => {
const text = selectedText(selectionRangeRef.current, props.content);
clipboard.writeText(text);
}, [props.content]);
const editorPasteText = useCallback(() => {
wrapSelectionWithStrings(clipboard.readText(), '', '', '');
}, [wrapSelectionWithStrings]);
const onEditorContextMenu = useCallback(() => {
const menu = new Menu();
const hasSelectedText = !!selectedText(selectionRangeRef.current, props.content);
const clipboardText = clipboard.readText();
menu.append(
new MenuItem({
label: _('Cut'),
enabled: hasSelectedText,
click: async () => {
editorCutText();
},
})
);
menu.append(
new MenuItem({
label: _('Copy'),
enabled: hasSelectedText,
click: async () => {
editorCopyText();
},
})
);
menu.append(
new MenuItem({
label: _('Paste'),
enabled: true,
click: async () => {
if (clipboardText) {
editorPasteText();
} else {
// To handle pasting images
onEditorPaste();
}
},
})
);
menu.popup(bridge().window());
}, [props.content, editorCutText, editorPasteText, editorCopyText, onEditorPaste]);
function aceEditor_load(editor: any) {
setEditor(editor);
}
useEffect(() => {
if (!editor) return () => {};
editor.indent = indentOrig.current;
const cancelledKeys = [];
const letters = ['F', 'T', 'P', 'Q', 'L', ',', 'G', 'K'];
for (let i = 0; i < letters.length; i++) {
const l = letters[i];
cancelledKeys.push(`Ctrl+${l}`);
cancelledKeys.push(`Command+${l}`);
}
cancelledKeys.push('Alt+E');
for (let i = 0; i < cancelledKeys.length; i++) {
const k = cancelledKeys[i];
editor.commands.bindKey(k, () => {
// HACK: Ace doesn't seem to provide a way to override its shortcuts, but throwing
// an exception from this undocumented function seems to cancel it without any
// side effect.
// https://stackoverflow.com/questions/36075846
throw new Error(`HACK: Overriding Ace Editor shortcut: ${k}`);
});
}
document.querySelector('#note-editor').addEventListener('paste', onEditorPaste, true);
document.querySelector('#note-editor').addEventListener('keydown', onEditorKeyDown);
document.querySelector('#note-editor').addEventListener('contextmenu', onEditorContextMenu);
// Disable Markdown auto-completion (eg. auto-adding a dash after a line with a dash.
// https://github.com/ajaxorg/ace/issues/2754
// @ts-ignore: Keep the function signature as-is despite unusued arguments
editor.getSession().getMode().getNextLineIndent = function(state: any, line: string) {
const ls = lastKeys;
if (ls.length >= 2 && ls[ls.length - 1] === 'Enter' && ls[ls.length - 2] === 'Enter') return this.$getIndent(line);
const leftSpaces = lineLeftSpaces(line);
const lineNoLeftSpaces = line.trimLeft();
if (lineNoLeftSpaces.indexOf('- [ ] ') === 0 || lineNoLeftSpaces.indexOf('- [x] ') === 0 || lineNoLeftSpaces.indexOf('- [X] ') === 0) return `${leftSpaces}- [ ] `;
if (lineNoLeftSpaces.indexOf('- ') === 0) return `${leftSpaces}- `;
if (lineNoLeftSpaces.indexOf('* ') === 0 && line.trim() !== '* * *') return `${leftSpaces}* `;
const bulletNumber = markdownUtils.olLineNumber(lineNoLeftSpaces);
if (bulletNumber) return `${leftSpaces + (bulletNumber + 1)}. `;
return this.$getIndent(line);
};
return () => {
document.querySelector('#note-editor').removeEventListener('paste', onEditorPaste, true);
document.querySelector('#note-editor').removeEventListener('keydown', onEditorKeyDown);
document.querySelector('#note-editor').removeEventListener('contextmenu', onEditorContextMenu);
};
}, [editor, onEditorPaste, onEditorContextMenu, lastKeys]);
useEffect(() => {
if (!editor) return;
// Markdown list indentation. (https://github.com/laurent22/joplin/pull/2713)
// If the current line starts with `markup.list` token,
// hitting `Tab` key indents the line instead of inserting tab at cursor.
indentOrig.current = editor.indent;
const localIndentOrig = indentOrig.current;
editor.indent = function() {
const range = selectionRangeRef.current;
if (range.isEmpty()) {
const row = range.start.row;
const tokens = this.session.getTokens(row);
if (tokens.length > 0 && tokens[0].type == 'markup.list') {
if (tokens[0].value.search(/\d+\./) != -1) {
// Resets numbered list to 1.
this.session.replace({ start: { row, column: 0 }, end: { row, column: tokens[0].value.length } },
tokens[0].value.replace(/\d+\./, '1.'));
}
this.session.indentRows(row, row, '\t');
return;
}
}
localIndentOrig.call(this);
};
}, [editor]);
const webview_domReady = useCallback(() => {
setWebviewReady(true);
}, []);
const webview_ipcMessage = useCallback((event: any) => {
const msg = event.channel ? event.channel : '';
const args = event.args;
const arg0 = args && args.length >= 1 ? args[0] : null;
if (msg.indexOf('checkboxclick:') === 0) {
const newBody = shared.toggleCheckbox(msg, props.content);
aceEditor_change(newBody);
} else if (msg === 'percentScroll') {
setEditorPercentScroll(arg0);
} else {
props.onMessage(event);
}
}, [props.onMessage, props.content, aceEditor_change]);
useEffect(() => {
let cancelled = false;
const interval = contentKeyHasChangedRef.current ? 0 : 500;
const timeoutId = setTimeout(async () => {
let bodyToRender = props.content;
if (!bodyToRender.trim() && props.visiblePanes.indexOf('viewer') >= 0 && props.visiblePanes.indexOf('editor') < 0) {
// Fixes https://github.com/laurent22/joplin/issues/217
bodyToRender = `<i>${_('This note has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout'))}</i>`;
}
const result = await props.markupToHtml(props.contentMarkupLanguage, bodyToRender, markupRenderOptions({ resourceInfos: props.resourceInfos }));
if (cancelled) return;
setRenderedBody(result);
}, interval);
return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [props.content, props.contentMarkupLanguage, props.visiblePanes, props.resourceInfos]);
useEffect(() => {
if (!webviewReady) return;
const options: any = {
pluginAssets: renderedBody.pluginAssets,
downloadResources: Setting.value('sync.resourceDownloadMode'),
};
webviewRef.current.wrappedInstance.send('setHtml', renderedBody.html, options);
}, [renderedBody, webviewReady]);
useEffect(() => {
if (props.searchMarkers !== previousSearchMarkers || renderedBody !== previousRenderedBody) {
webviewRef.current.wrappedInstance.send('setMarkers', props.searchMarkers.keywords, props.searchMarkers.options);
}
}, [props.searchMarkers, renderedBody]);
const cellEditorStyle = useMemo(() => {
const output = { ...styles.cellEditor };
if (!props.visiblePanes.includes('editor')) {
// Note: Ideally we'd set the display to "none" to take the editor out
// of the DOM but if we do that, certain things won't work, in particular
// things related to scroll, which are based on the editor.
output.width = 1;
output.maxWidth = 1;
output.position = 'absolute';
output.left = -100000;
}
return output;
}, [styles.cellEditor, props.visiblePanes]);
const cellViewerStyle = useMemo(() => {
const output = { ...styles.cellViewer };
if (!props.visiblePanes.includes('viewer')) {
// Note: setting webview.display to "none" is currently not supported due
// to this bug: https://github.com/electron/electron/issues/8277
// So instead setting the width 0.
output.width = 1;
output.maxWidth = 1;
} else if (!props.visiblePanes.includes('editor')) {
output.borderLeftStyle = 'none';
}
return output;
}, [styles.cellViewer, props.visiblePanes]);
function renderEditor() {
return (
<div style={cellEditorStyle}>
<AceEditorReact
value={props.content}
mode={props.contentMarkupLanguage === Note.MARKUP_LANGUAGE_HTML ? 'text' : 'markdown'}
theme={styles.editor.editorTheme}
style={styles.editor}
fontSize={styles.editor.fontSize}
showGutter={false}
readOnly={props.visiblePanes.indexOf('editor') < 0}
name="note-editor"
wrapEnabled={true}
onScroll={editor_scroll}
onChange={aceEditor_change}
showPrintMargin={false}
onLoad={aceEditor_load}
// Enable/Disable the autoclosing braces
setOptions={
{
behavioursEnabled: Setting.value('editor.autoMatchingBraces'),
useSoftTabs: false,
}
}
// Disable warning: "Automatically scrolling cursor into view after
// selection change this will be disabled in the next version set
// editor.$blockScrolling = Infinity to disable this message"
editorProps={{ $blockScrolling: Infinity }}
// This is buggy (gets outside the container)
highlightActiveLine={false}
keyboardHandler={props.keyboardMode}
/>
</div>
);
}
function renderViewer() {
return (
<div style={cellViewerStyle}>
<NoteTextViewer
ref={webviewRef}
viewerStyle={styles.viewer}
onIpcMessage={webview_ipcMessage}
onDomReady={webview_domReady}
/>
</div>
);
}
return (
<div style={styles.root}>
<div style={styles.rowToolbar}>
<Toolbar
theme={props.theme}
dispatch={props.dispatch}
/>
{props.noteToolbar}
</div>
<div style={styles.rowEditorViewer}>
{renderEditor()}
{renderViewer()}
</div>
</div>
);
}
export default forwardRef(AceEditor);

View File

@ -0,0 +1,166 @@
import * as React from 'react';
const ToolbarBase = require('../../../Toolbar.min.js');
const { _ } = require('lib/locale');
const { buildStyle } = require('../../../../theme.js');
interface ToolbarProps {
theme: number,
dispatch: Function,
}
function styles_(props:ToolbarProps) {
return buildStyle('AceEditorToolbar', props.theme, (/* theme:any*/) => {
return {
root: {
// marginTop: 4,
marginBottom: 0,
},
};
});
}
export default function Toolbar(props:ToolbarProps) {
const styles = styles_(props);
function createToolbarItems() {
const toolbarItems = [];
toolbarItems.push({
tooltip: _('Bold'),
iconName: 'fa-bold',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'textBold',
});
},
});
toolbarItems.push({
tooltip: _('Italic'),
iconName: 'fa-italic',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'textItalic',
});
},
});
toolbarItems.push({
type: 'separator',
});
toolbarItems.push({
tooltip: _('Hyperlink'),
iconName: 'fa-link',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'textLink',
});
},
});
toolbarItems.push({
tooltip: _('Code'),
iconName: 'fa-code',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'textCode',
});
},
});
toolbarItems.push({
tooltip: _('Attach file'),
iconName: 'fa-paperclip',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'attachFile',
});
},
});
toolbarItems.push({
type: 'separator',
});
toolbarItems.push({
tooltip: _('Numbered List'),
iconName: 'fa-list-ol',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'textNumberedList',
});
},
});
toolbarItems.push({
tooltip: _('Bulleted List'),
iconName: 'fa-list-ul',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'textBulletedList',
});
},
});
toolbarItems.push({
tooltip: _('Checkbox'),
iconName: 'fa-check-square',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'textCheckbox',
});
},
});
toolbarItems.push({
tooltip: _('Heading'),
iconName: 'fa-header',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'textHeading',
});
},
});
toolbarItems.push({
tooltip: _('Horizontal Rule'),
iconName: 'fa-ellipsis-h',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'textHorizontalRule',
});
},
});
toolbarItems.push({
tooltip: _('Insert Date Time'),
iconName: 'fa-calendar-plus-o',
onClick: () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'insertDateTime',
});
},
});
toolbarItems.push({
type: 'separator',
});
return toolbarItems;
}
return <ToolbarBase style={styles.root} items={createToolbarItems()} />;
}

View File

@ -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
},
};
});
}

View File

@ -0,0 +1,241 @@
import { useState, useEffect, useCallback, useRef } from 'react';
export function cursorPositionToTextOffset(cursorPos: any, body: string) {
if (!body) return 0;
const noteLines = body.split('\n');
let pos = 0;
for (let i = 0; i < noteLines.length; i++) {
if (i > 0) pos++; // Need to add the newline that's been removed in the split() call above
if (i === cursorPos.row) {
pos += cursorPos.column;
break;
} else {
pos += noteLines[i].length;
}
}
return pos;
}
export function currentTextOffset(editor: any, body: string) {
return cursorPositionToTextOffset(editor.getCursorPosition(), body);
}
export function rangeToTextOffsets(range: any, body: string) {
return {
start: cursorPositionToTextOffset(range.start, body),
end: cursorPositionToTextOffset(range.end, body),
};
}
export function textOffsetSelection(selectionRange: any, body: string) {
return selectionRange && body ? rangeToTextOffsets(selectionRange, body) : null;
}
export function selectedText(selectionRange: any, body: string) {
const selection = textOffsetSelection(selectionRange, body);
if (!selection || selection.start === selection.end) return '';
return body.substr(selection.start, selection.end - selection.start);
}
export function useSelectionRange(editor: any) {
const [selectionRange, setSelectionRange] = useState(null);
useEffect(() => {
if (!editor) return () => {};
function updateSelection() {
const ranges = editor.getSelection().getAllRanges();
const firstRange = ranges && ranges.length ? ranges[0] : null;
setSelectionRange(firstRange);
// if (process.platform === 'linux') {
// const textRange = this.textOffsetSelection();
// if (textRange.start != textRange.end) {
// clipboard.writeText(this.state.note.body.slice(
// Math.min(textRange.start, textRange.end),
// Math.max(textRange.end, textRange.start)), 'selection');
// }
// }
}
function onSelectionChange() {
updateSelection();
}
function onFocus() {
updateSelection();
}
editor.getSession().selection.on('changeSelection', onSelectionChange);
editor.on('focus', onFocus);
return () => {
editor.getSession().selection.off('changeSelection', onSelectionChange);
editor.off('focus', onFocus);
};
}, [editor]);
return selectionRange;
}
export function textOffsetToCursorPosition(offset: number, body: string) {
const lines = body.split('\n');
let row = 0;
let currentOffset = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (currentOffset + line.length >= offset) {
return {
row: row,
column: offset - currentOffset,
};
}
row++;
currentOffset += line.length + 1;
}
return null;
}
function lineAtRow(body: string, row: number) {
if (!body) return '';
const lines = body.split('\n');
if (row < 0 || row >= lines.length) return '';
return lines[row];
}
export function selectionRangeCurrentLine(selectionRange: any, body: string) {
if (!selectionRange) return '';
return lineAtRow(body, selectionRange.start.row);
}
export function selectionRangePreviousLine(selectionRange: any, body: string) {
if (!selectionRange) return '';
return lineAtRow(body, selectionRange.start.row - 1);
}
export function lineLeftSpaces(line: string) {
let output = '';
for (let i = 0; i < line.length; i++) {
if ([' ', '\t'].indexOf(line[i]) >= 0) {
output += line[i];
} else {
break;
}
}
return output;
}
export function usePrevious(value: any): any {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
export function useScrollHandler(editor: any, webviewRef: any, onScroll: Function) {
const editorMaxScrollTop_ = useRef(0);
const restoreScrollTop_ = useRef<any>(null);
const ignoreNextEditorScrollEvent_ = useRef(false);
const scrollTimeoutId_ = useRef<any>(null);
// TODO: Below is not needed anymore????
//
// this.editorMaxScrollTop_ = 0;
// // HACK: To go around a bug in Ace editor, we first set the scroll position to 1
// // and then (in the renderer callback) to the value we actually need. The first
// // operation helps clear the scroll position cache. See:
// //
// this.editorSetScrollTop(1);
// this.restoreScrollTop_ = 0;
const editorSetScrollTop = useCallback((v) => {
if (!editor) return;
editor.getSession().setScrollTop(v);
}, [editor]);
// Complicated but reliable method to get editor content height
// https://github.com/ajaxorg/ace/issues/2046
const onAfterEditorRender = useCallback(() => {
const r = editor.renderer;
editorMaxScrollTop_.current = Math.max(0, r.layerConfig.maxHeight - r.$size.scrollerHeight);
if (restoreScrollTop_.current !== null) {
editorSetScrollTop(restoreScrollTop_.current);
restoreScrollTop_.current = null;
}
}, [editor, editorSetScrollTop]);
const scheduleOnScroll = useCallback((event: any) => {
if (scrollTimeoutId_.current) {
clearTimeout(scrollTimeoutId_.current);
scrollTimeoutId_.current = null;
}
scrollTimeoutId_.current = setTimeout(() => {
scrollTimeoutId_.current = null;
onScroll(event);
}, 10);
}, [onScroll]);
const setEditorPercentScroll = useCallback((p: number) => {
ignoreNextEditorScrollEvent_.current = true;
editorSetScrollTop(p * editorMaxScrollTop_.current);
scheduleOnScroll({ percent: p });
}, [editorSetScrollTop, scheduleOnScroll]);
const setViewerPercentScroll = useCallback((p: number) => {
if (webviewRef.current) {
webviewRef.current.wrappedInstance.send('setPercentScroll', p);
scheduleOnScroll({ percent: p });
}
}, [scheduleOnScroll]);
const editor_scroll = useCallback(() => {
if (ignoreNextEditorScrollEvent_.current) {
ignoreNextEditorScrollEvent_.current = false;
return;
}
const m = editorMaxScrollTop_.current;
const percent = m ? editor.getSession().getScrollTop() / m : 0;
setViewerPercentScroll(percent);
}, [editor, setViewerPercentScroll]);
const resetScroll = useCallback(() => {
if (!editor) return;
// Ace Editor caches scroll values, which makes
// it hard to reset the scroll position, so we
// need to use this hack.
// https://github.com/ajaxorg/ace/issues/2195
editor.session.$scrollTop = -1;
editor.session.$scrollLeft = -1;
editor.renderer.scrollTop = -1;
editor.renderer.scrollLeft = -1;
editor.renderer.scrollBarV.scrollTop = -1;
editor.renderer.scrollBarH.scrollLeft = -1;
editor.session.setScrollTop(0);
editor.session.setScrollLeft(0);
}, [editorSetScrollTop, editor]);
useEffect(() => {
if (!editor) return () => {};
editor.renderer.on('afterRender', onAfterEditorRender);
return () => {
editor.renderer.off('afterRender', onAfterEditorRender);
};
}, [editor]);
return { resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll };
}

View File

@ -0,0 +1,11 @@
export interface RenderedBody {
html: string;
pluginAssets: any[];
}
export function defaultRenderedBody(): RenderedBody {
return {
html: '',
pluginAssets: [],
};
}

View File

@ -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

View File

@ -1,9 +1,8 @@
// Kept only for reference
import * as React from 'react'; import * as React from 'react';
import { useEffect, useCallback, useRef, forwardRef, useImperativeHandle } 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 { export interface OnChangeEvent {
changeId: number, changeId: number,
content: any, content: any,
@ -13,18 +12,11 @@ interface PlainEditorProps {
style: any, style: any,
onChange(event: OnChangeEvent): void, onChange(event: OnChangeEvent): void,
onWillChange(event:any): void, onWillChange(event:any): void,
defaultEditorState: DefaultEditorState,
markupToHtml: Function, markupToHtml: Function,
attachResources: Function, attachResources: Function,
disabled: boolean, disabled: boolean,
} }
export const utils:TextEditorUtils = {
editorContentToHtml(content:any):Promise<string> {
return content ? content : '';
},
};
const PlainEditor = (props:PlainEditorProps, ref:any) => { const PlainEditor = (props:PlainEditorProps, ref:any) => {
const editorRef = useRef<any>(); const editorRef = useRef<any>();

View File

@ -1,29 +1,13 @@
import * as React from 'react'; import * as React from 'react';
import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react'; import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
import { EditorCommand, NoteBodyEditorProps } from '../../utils/types';
// eslint-disable-next-line no-unused-vars import { resourcesStatus } from '../../utils/resourceHandling';
import { DefaultEditorState, OnChangeEvent, TextEditorUtils, EditorCommand, resourcesStatus } from '../utils/NoteText';
const { MarkupToHtml } = require('lib/joplin-renderer'); const { MarkupToHtml } = require('lib/joplin-renderer');
const taboverride = require('taboverride'); const taboverride = require('taboverride');
const { reg } = require('lib/registry.js'); const { reg } = require('lib/registry.js');
const { _ } = require('lib/locale'); const { _ } = require('lib/locale');
const BaseItem = require('lib/models/BaseItem'); const BaseItem = require('lib/models/BaseItem');
const { themeStyle, buildStyle } = require('../../theme.js'); 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,
}
function markupRenderOptions(override:any = null) { function markupRenderOptions(override:any = null) {
return { return {
@ -35,6 +19,7 @@ function markupRenderOptions(override:any = null) {
linkRenderingType: 2, linkRenderingType: 2,
}, },
}, },
replaceResourceInternalToExternalLinks: true,
...override, ...override,
}; };
} }
@ -104,12 +89,6 @@ function enableTextAreaTab(enable:boolean) {
} }
} }
export const utils:TextEditorUtils = {
editorContentToHtml(content:any):Promise<string> {
return content ? content : '';
},
};
interface TinyMceCommand { interface TinyMceCommand {
name: string, name: string,
value?: any, value?: any,
@ -127,7 +106,7 @@ const joplinCommandToTinyMceCommands:JoplinCommandToTinyMceCommands = {
'search': { name: 'SearchReplace' }, 'search': { name: 'SearchReplace' },
}; };
function styles_(props:TinyMCEProps) { function styles_(props:NoteBodyEditorProps) {
return buildStyle('TinyMCE', props.theme, (/* theme:any */) => { return buildStyle('TinyMCE', props.theme, (/* theme:any */) => {
return { return {
disabledOverlay: { disabledOverlay: {
@ -155,7 +134,7 @@ let loadedAssetFiles_:string[] = [];
let dispatchDidUpdateIID_:any = null; let dispatchDidUpdateIID_:any = null;
let changeId_:number = 1; let changeId_:number = 1;
const TinyMCE = (props:TinyMCEProps, ref:any) => { const TinyMCE = (props:NoteBodyEditorProps, ref:any) => {
const [editor, setEditor] = useState(null); const [editor, setEditor] = useState(null);
const [scriptLoaded, setScriptLoaded] = useState(false); const [scriptLoaded, setScriptLoaded] = useState(false);
const [editorReady, setEditorReady] = useState(false); const [editorReady, setEditorReady] = useState(false);
@ -166,9 +145,6 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => {
const markupToHtml = useRef(null); const markupToHtml = useRef(null);
markupToHtml.current = props.markupToHtml; markupToHtml.current = props.markupToHtml;
const joplinHtml = useRef(null);
joplinHtml.current = props.joplinHtml;
const rootIdRef = useRef<string>(`tinymce-${Date.now()}${Math.round(Math.random() * 10000)}`); const rootIdRef = useRef<string>(`tinymce-${Date.now()}${Math.round(Math.random() * 10000)}`);
const editorRef = useRef<any>(null); const editorRef = useRef<any>(null);
editorRef.current = editor; editorRef.current = editor;
@ -194,16 +170,16 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => {
if (nodeName === 'A' && (event.ctrlKey || event.metaKey)) { if (nodeName === 'A' && (event.ctrlKey || event.metaKey)) {
const href = event.target.getAttribute('href'); const href = event.target.getAttribute('href');
const joplinUrl = href.indexOf('joplin://') === 0 ? href : null; // const joplinUrl = href.indexOf('joplin://') === 0 ? href : null;
if (joplinUrl) { // if (joplinUrl) {
props.onMessage({ // props.onMessage({
name: 'openInternal', // name: 'openInternal',
args: { // args: {
url: joplinUrl, // url: joplinUrl,
}, // },
}); // });
} else if (href.indexOf('#') === 0) { if (href.indexOf('#') === 0) {
const anchorName = href.substr(1); const anchorName = href.substr(1);
const anchor = editor.getDoc().getElementById(anchorName); const anchor = editor.getDoc().getElementById(anchorName);
if (anchor) { if (anchor) {
@ -213,7 +189,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => {
} }
} else { } else {
props.onMessage({ props.onMessage({
name: 'openExternal', name: 'openUrl',
args: { args: {
url: href, url: href,
}, },
@ -224,7 +200,22 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => {
useImperativeHandle(ref, () => { useImperativeHandle(ref, () => {
return { 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) => { execCommand: async (cmd:EditorCommand) => {
if (!editor) return false; if (!editor) return false;
@ -257,7 +248,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => {
return true; return true;
}, },
}; };
}, [editor]); }, [editor, props.contentMarkupLanguage, props.contentOriginalCss]);
// ----------------------------------------------------------------------------------------- // -----------------------------------------------------------------------------------------
// Load the TinyMCE library. The lib loads additional JS and CSS files on startup // 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, loaded: false,
}, },
{ {
src: 'gui/editors/TinyMCE/plugins/lists.js', src: 'gui/NoteEditor/NoteBody/TinyMCE/plugins/lists.js',
id: 'tinyMceListsPluginScript', id: 'tinyMceListsPluginScript',
loaded: false, loaded: false,
}, },
@ -440,7 +431,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => {
height: '100%', height: '100%',
resize: false, resize: false,
icons: 'Joplin', 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', plugins: 'noneditable link joplinLists hr searchreplace codesample table',
noneditable_noneditable_class: 'joplin-editable', // Can be a regex too noneditable_noneditable_class: 'joplin-editable', // Can be a regex too
valid_elements: '*[*]', // We already filter in sanitize_html valid_elements: '*[*]', // We already filter in sanitize_html
@ -524,7 +515,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => {
const html = []; const html = [];
for (const resource of resources) { 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); html.push(result.html);
} }
@ -597,7 +588,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => {
.map((a:any) => a.path) .map((a:any) => a.path)
).filter((path:string) => !loadedAssetFiles_.includes(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 pluginAssets
.filter((a:any) => a.mime === 'application/javascript') .filter((a:any) => a.mime === 'application/javascript')
.map((a:any) => a.path) .map((a:any) => a.path)
@ -628,7 +619,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => {
useEffect(() => { useEffect(() => {
if (!editor) return () => {}; if (!editor) return () => {};
if (resourcesStatus(props.defaultEditorState.resourceInfos) !== 'ready') { if (resourcesStatus(props.resourceInfos) !== 'ready') {
editor.setContent(''); editor.setContent('');
return () => {}; return () => {};
} }
@ -636,12 +627,12 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => {
let cancelled = false; let cancelled = false;
const loadContent = async () => { 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; if (cancelled) return;
editor.setContent(result.html); 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); editor.getDoc().addEventListener('click', onEditorContentClick);
@ -661,7 +652,7 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => {
cancelled = true; cancelled = true;
editor.getDoc().removeEventListener('click', onEditorContentClick); editor.getDoc().removeEventListener('click', onEditorContentClick);
}; };
}, [editor, props.markupToHtml, props.allAssets, props.defaultEditorState, onEditorContentClick]); }, [editor, props.markupToHtml, props.allAssets, onEditorContentClick, props.resourceInfos]);
// ----------------------------------------------------------------------------------------- // -----------------------------------------------------------------------------------------
// Handle onChange event // Handle onChange event
@ -673,6 +664,9 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => {
const props_onChangeRef = useRef<Function>(); const props_onChangeRef = useRef<Function>();
props_onChangeRef.current = props.onChange; props_onChangeRef.current = props.onChange;
const prop_htmlToMarkdownRef = useRef<Function>();
prop_htmlToMarkdownRef.current = props.htmlToMarkdown;
useEffect(() => { useEffect(() => {
if (!editor) return () => {}; if (!editor) return () => {};
@ -684,14 +678,16 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => {
if (onChangeHandlerIID) clearTimeout(onChangeHandlerIID); if (onChangeHandlerIID) clearTimeout(onChangeHandlerIID);
onChangeHandlerIID = setTimeout(() => { onChangeHandlerIID = setTimeout(async () => {
onChangeHandlerIID = null; onChangeHandlerIID = null;
const contentMd = await prop_htmlToMarkdownRef.current(props.contentMarkupLanguage, editor.getContent(), props.contentOriginalCss);
if (!editor) return; if (!editor) return;
props_onChangeRef.current({ props_onChangeRef.current({
changeId: changeId, changeId: changeId,
content: editor.getContent(), content: contentMd,
}); });
dispatchDidUpdate(editor); dispatchDidUpdate(editor);
@ -755,6 +751,8 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => {
editor.on('paste', onPaste); editor.on('paste', onPaste);
editor.on('cut', onChangeHandler); editor.on('cut', onChangeHandler);
editor.on('joplinChange', onChangeHandler); editor.on('joplinChange', onChangeHandler);
editor.on('Undo', onChangeHandler);
editor.on('Redo', onChangeHandler);
editor.on('ExecCommand', onExecCommand); editor.on('ExecCommand', onExecCommand);
return () => { return () => {
@ -764,12 +762,14 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => {
editor.off('paste', onPaste); editor.off('paste', onPaste);
editor.off('cut', onChangeHandler); editor.off('cut', onChangeHandler);
editor.off('joplinChange', onChangeHandler); editor.off('joplinChange', onChangeHandler);
editor.off('Undo', onChangeHandler);
editor.off('Redo', onChangeHandler);
editor.off('ExecCommand', onExecCommand); editor.off('ExecCommand', onExecCommand);
} catch (error) { } catch (error) {
console.warn('Error removing events', 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 // 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() { function renderDisabledOverlay() {
const status = resourcesStatus(props.defaultEditorState.resourceInfos); const status = resourcesStatus(props.resourceInfos);
if (status === 'ready') return null; 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 ( return (
<div style={styles.disabledOverlay}> <div style={styles.disabledOverlay}>
<p style={theme.textStyle}>{message}</p> <p style={theme.textStyle}>{message}</p>

View File

@ -0,0 +1,570 @@
import * as React from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
// eslint-disable-next-line no-unused-vars
import TinyMCE from './NoteBody/TinyMCE/TinyMCE';
import AceEditor from './NoteBody/AceEditor/AceEditor';
import { connect } from 'react-redux';
import MultiNoteActions from '../MultiNoteActions';
import NoteToolbar from '../NoteToolbar/NoteToolbar';
import { htmlToMarkdown, formNoteToNote } from './utils';
import useSearchMarkers from './utils/useSearchMarkers';
import useNoteSearchBar from './utils/useNoteSearchBar';
import useMessageHandler from './utils/useMessageHandler';
import useWindowCommandHandler from './utils/useWindowCommandHandler';
import useDropHandler from './utils/useDropHandler';
import useMarkupToHtml from './utils/useMarkupToHtml';
import useFormNote, { OnLoadEvent } from './utils/useFormNote';
import styles_ from './styles';
import { NoteTextProps, FormNote, ScrollOptions, ScrollOptionTypes, OnChangeEvent, NoteBodyEditorProps } from './utils/types';
import { attachResources } from './utils/resourceHandling';
const { themeStyle } = require('../../theme.js');
const NoteSearchBar = require('../NoteSearchBar.min.js');
const { reg } = require('lib/registry.js');
const { time } = require('lib/time-utils.js');
const markupLanguageUtils = require('lib/markupLanguageUtils');
const usePrevious = require('lib/hooks/usePrevious').default;
const Setting = require('lib/models/Setting');
const { _ } = require('lib/locale');
const Note = require('lib/models/Note.js');
const { bridge } = require('electron').remote.require('./bridge');
const ExternalEditWatcher = require('lib/services/ExternalEditWatcher');
const eventManager = require('../../eventManager');
const NoteRevisionViewer = require('../NoteRevisionViewer.min');
const TagList = require('../TagList.min.js');
function NoteEditor(props: NoteTextProps) {
const [showRevisions, setShowRevisions] = useState(false);
const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false);
const [scrollWhenReady, setScrollWhenReady] = useState<ScrollOptions>(null);
const editorRef = useRef<any>();
const titleInputRef = useRef<any>();
const isMountedRef = useRef(true);
const noteSearchBarRef = useRef(null);
const formNote_beforeLoad = useCallback(async (event:OnLoadEvent) => {
await saveNoteIfWillChange(event.formNote);
setShowRevisions(false);
}, []);
const formNote_afterLoad = useCallback(async () => {
setTitleHasBeenManuallyChanged(false);
}, []);
const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({
syncStarted: props.syncStarted,
noteId: props.noteId,
isProvisional: props.isProvisional,
titleInputRef: titleInputRef,
editorRef: editorRef,
onBeforeLoad: formNote_beforeLoad,
onAfterLoad: formNote_afterLoad,
});
const formNoteRef = useRef<FormNote>();
formNoteRef.current = { ...formNote };
const {
localSearch,
onChange: localSearch_change,
onNext: localSearch_next,
onPrevious: localSearch_previous,
onClose: localSearch_close,
setResultCount: setLocalSearchResultCount,
showLocalSearch,
setShowLocalSearch,
searchMarkers: localSearchMarkerOptions,
} = useNoteSearchBar();
// If the note has been modified in another editor, wait for it to be saved
// before loading it in this editor.
// const waitingToSaveNote = props.noteId && formNote.id !== props.noteId && props.editorNoteStatuses[props.noteId] === 'saving';
const styles = styles_(props);
function scheduleSaveNote(formNote: FormNote) {
if (!formNote.saveActionQueue) throw new Error('saveActionQueue is not set!!'); // Sanity check
reg.logger().debug('Scheduling...', formNote);
const makeAction = (formNote: FormNote) => {
return async function() {
const note = await formNoteToNote(formNote);
reg.logger().debug('Saving note...', note);
const savedNote:any = await Note.save(note);
setFormNote((prev: FormNote) => {
return { ...prev, user_updated_time: savedNote.user_updated_time };
});
ExternalEditWatcher.instance().updateNoteFile(savedNote);
props.dispatch({
type: 'EDITOR_NOTE_STATUS_REMOVE',
id: formNote.id,
});
};
};
formNote.saveActionQueue.push(makeAction(formNote));
}
async function saveNoteIfWillChange(formNote: FormNote) {
if (!formNote.id || !formNote.bodyWillChangeId) return;
const body = await editorRef.current.content();
scheduleSaveNote({
...formNote,
body: body,
bodyWillChangeId: 0,
bodyChangeId: 0,
});
}
async function saveNoteAndWait(formNote: FormNote) {
saveNoteIfWillChange(formNote);
return formNote.saveActionQueue.waitForAllDone();
}
const markupToHtml = useMarkupToHtml({ themeId: props.theme, customCss: props.customCss });
const allAssets = useCallback(async (markupLanguage: number): Promise<any[]> => {
const theme = themeStyle(props.theme);
const markupToHtml = markupLanguageUtils.newMarkupToHtml({
resourceBaseUrl: `file://${Setting.value('resourceDir')}/`,
});
return markupToHtml.allAssets(markupLanguage, theme);
}, [props.theme]);
const handleProvisionalFlag = useCallback(() => {
if (props.isProvisional) {
props.dispatch({
type: 'NOTE_PROVISIONAL_FLAG_CLEAR',
id: formNote.id,
});
}
}, [props.isProvisional, formNote.id]);
useEffect(() => {
// This is not exactly a hack but a bit ugly. If the note was changed (willChangeId > 0) but not
// yet saved, we need to save it now before the component is unmounted. However, we can't put
// formNote in the dependency array or that effect will run every time the note changes. We only
// want to run it once on unmount. So because of that we need to use that formNoteRef.
return () => {
isMountedRef.current = false;
saveNoteIfWillChange(formNoteRef.current);
};
}, []);
const previousNoteId = usePrevious(formNote.id);
useEffect(() => {
if (formNote.id === previousNoteId) return;
if (editorRef.current) {
editorRef.current.resetScroll();
}
setScrollWhenReady({
type: props.selectedNoteHash ? ScrollOptionTypes.Hash : ScrollOptionTypes.Percent,
value: props.selectedNoteHash ? props.selectedNoteHash : props.lastEditorScrollPercents[props.noteId] || 0,
});
}, [formNote.id, previousNoteId]);
const onFieldChange = useCallback((field: string, value: any, changeId = 0) => {
if (!isMountedRef.current) {
// When the component is unmounted, various actions can happen which can
// trigger onChange events, for example the textarea might be cleared.
// We need to ignore these events, otherwise the note is going to be saved
// with an invalid body.
reg.logger().debug('Skipping change event because the component is unmounted');
return;
}
handleProvisionalFlag();
const change = field === 'body' ? {
body: value,
} : {
title: value,
};
const newNote = {
...formNote,
...change,
bodyWillChangeId: 0,
bodyChangeId: 0,
hasChanged: true,
};
if (field === 'title') {
setTitleHasBeenManuallyChanged(true);
}
if (isNewNote && !titleHasBeenManuallyChanged && field === 'body') {
// TODO: Handle HTML/Markdown format
newNote.title = Note.defaultTitle(value);
}
if (changeId !== null && field === 'body' && formNote.bodyWillChangeId !== changeId) {
// Note was changed, but another note was loaded before save - skipping
// The previously loaded note, that was modified, will be saved via saveNoteIfWillChange()
} else {
setFormNote(newNote);
scheduleSaveNote(newNote);
}
}, [handleProvisionalFlag, formNote, isNewNote, titleHasBeenManuallyChanged]);
useWindowCommandHandler({ windowCommand: props.windowCommand, dispatch: props.dispatch, formNote, setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, saveNoteAndWait });
const onDrop = useDropHandler({ editorRef });
const onBodyChange = useCallback((event: OnChangeEvent) => onFieldChange('body', event.content, event.changeId), [onFieldChange]);
const onTitleChange = useCallback((event: any) => onFieldChange('title', event.target.value), [onFieldChange]);
const onTitleKeydown = useCallback((event:any) => {
const keyCode = event.keyCode;
if (keyCode === 9) {
// TAB
event.preventDefault();
if (event.shiftKey) {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'focusElement',
target: 'noteList',
});
} else {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'focusElement',
target: 'noteBody',
});
}
}
}, [props.dispatch]);
const onBodyWillChange = useCallback((event: any) => {
handleProvisionalFlag();
setFormNote(prev => {
return {
...prev,
bodyWillChangeId: event.changeId,
hasChanged: true,
};
});
props.dispatch({
type: 'EDITOR_NOTE_STATUS_SET',
id: formNote.id,
status: 'saving',
});
}, [formNote, handleProvisionalFlag]);
const onMessage = useMessageHandler(scrollWhenReady, setScrollWhenReady, editorRef, setLocalSearchResultCount, props.dispatch);
const introductionPostLinkClick = useCallback(() => {
bridge().openExternal('https://www.patreon.com/posts/34246624');
}, []);
const externalEditWatcher_noteChange = useCallback((event) => {
if (event.id === formNote.id) {
const newFormNote = {
...formNote,
title: event.note.title,
body: event.note.body,
};
setFormNote(newFormNote);
}
}, [formNote]);
const onNotePropertyChange = useCallback((event) => {
setFormNote(formNote => {
if (formNote.id !== event.note.id) return formNote;
const newFormNote: FormNote = { ...formNote };
for (const key in event.note) {
if (key === 'id') continue;
(newFormNote as any)[key] = event.note[key];
}
return newFormNote;
});
}, []);
useEffect(() => {
eventManager.on('alarmChange', onNotePropertyChange);
ExternalEditWatcher.instance().on('noteChange', externalEditWatcher_noteChange);
return () => {
eventManager.off('alarmChange', onNotePropertyChange);
ExternalEditWatcher.instance().off('noteChange', externalEditWatcher_noteChange);
};
}, [externalEditWatcher_noteChange, onNotePropertyChange]);
const noteToolbar_buttonClick = useCallback((event: any) => {
const cases: any = {
'startExternalEditing': async () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'commandStartExternalEditing',
});
},
'stopExternalEditing': () => {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'commandStopExternalEditing',
});
},
'setTags': async () => {
await saveNoteAndWait(formNote);
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'setTags',
noteIds: [formNote.id],
});
},
'setAlarm': async () => {
await saveNoteAndWait(formNote);
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'editAlarm',
noteId: formNote.id,
});
},
'showRevisions': () => {
setShowRevisions(true);
},
};
if (!cases[event.name]) throw new Error(`Unsupported event: ${event.name}`);
cases[event.name]();
}, [formNote]);
const onScroll = useCallback((event: any) => {
props.dispatch({
type: 'EDITOR_SCROLL_PERCENT_SET',
noteId: formNote.id,
percent: event.percent,
});
}, [props.dispatch, formNote]);
function renderNoNotes(rootStyle:any) {
const emptyDivStyle = Object.assign(
{
backgroundColor: 'black',
opacity: 0.1,
},
rootStyle
);
return <div style={emptyDivStyle}></div>;
}
function renderNoteToolbar() {
const toolbarStyle = {
// marginTop: 4,
marginBottom: 0,
flex: 1,
};
return <NoteToolbar
theme={props.theme}
note={formNote}
dispatch={props.dispatch}
style={toolbarStyle}
watchedNoteFiles={props.watchedNoteFiles}
onButtonClick={noteToolbar_buttonClick}
/>;
}
const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId);
const editorProps:NoteBodyEditorProps = {
ref: editorRef,
contentKey: formNote.id,
style: styles.tinyMCE,
onChange: onBodyChange,
onWillChange: onBodyWillChange,
onMessage: onMessage,
content: formNote.body,
contentMarkupLanguage: formNote.markup_language,
contentOriginalCss: formNote.originalCss,
resourceInfos: resourceInfos,
htmlToMarkdown: htmlToMarkdown,
markupToHtml: markupToHtml,
allAssets: allAssets,
attachResources: attachResources,
disabled: false,
theme: props.theme,
dispatch: props.dispatch,
noteToolbar: renderNoteToolbar(),
onScroll: onScroll,
searchMarkers: searchMarkers,
visiblePanes: props.noteVisiblePanes || ['editor', 'viewer'],
keyboardMode: Setting.value('editor.keyboardMode'),
};
let editor = null;
if (props.bodyEditor === 'TinyMCE') {
editor = <TinyMCE {...editorProps}/>;
} else if (props.bodyEditor === 'AceEditor') {
editor = <AceEditor {...editorProps}/>;
} else {
throw new Error(`Invalid editor: ${props.bodyEditor}`);
}
const wysiwygBanner = props.bodyEditor !== 'TinyMCE' ? null : (
<div style={{ ...styles.warningBanner, marginBottom: 10 }}>
This is an experimental WYSIWYG editor for evaluation only. Please do not use with important notes as you may lose some data! See the <a style={styles.urlColor} onClick={introductionPostLinkClick} href="#">introduction post</a> for more information.
</div>
);
const noteRevisionViewer_onBack = useCallback(() => {
setShowRevisions(false);
}, []);
const tagStyle = {
marginBottom: 10,
};
const tagList = props.selectedNoteTags.length ? <TagList style={tagStyle} items={props.selectedNoteTags} /> : null;
if (showRevisions) {
const theme = themeStyle(props.theme);
const revStyle = {
...props.style,
display: 'inline-flex',
padding: theme.margin,
verticalAlign: 'top',
boxSizing: 'border-box',
};
return (
<div style={revStyle}>
<NoteRevisionViewer customCss={props.customCss} noteId={formNote.id} onBack={noteRevisionViewer_onBack} />
</div>
);
}
if (props.selectedNoteIds.length > 1) {
return <MultiNoteActions
theme={props.theme}
selectedNoteIds={props.selectedNoteIds}
notes={props.notes}
dispatch={props.dispatch}
watchedNoteFiles={props.watchedNoteFiles}
style={props.style}
/>;
}
const titleBarDate = <span style={styles.titleDate}>{time.formatMsToLocal(formNote.user_updated_time)}</span>;
function renderSearchBar() {
if (!showLocalSearch) return false;
const theme = themeStyle(props.theme);
return (
<NoteSearchBar
ref={noteSearchBarRef}
style={{
display: 'flex',
height: 35,
borderTop: `1px solid ${theme.dividerColor}`,
}}
query={localSearch.query}
searching={localSearch.searching}
resultCount={localSearch.resultCount}
selectedIndex={localSearch.selectedIndex}
onChange={localSearch_change}
onNext={localSearch_next}
onPrevious={localSearch_previous}
onClose={localSearch_close}
/>
);
}
if (formNote.encryption_applied || !formNote.id) {
return renderNoNotes(styles.root);
}
return (
<div style={styles.root} onDrop={onDrop}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{wysiwygBanner}
{tagList}
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<input
type="text"
ref={titleInputRef}
// disabled={waitingToSaveNote}
placeholder={props.isProvisional ? _('Creating new %s...', formNote.is_todo ? _('to-do') : _('note')) : ''}
style={styles.titleInput}
onChange={onTitleChange}
onKeyDown={onTitleKeydown}
value={formNote.title}
/>
{titleBarDate}
</div>
<div style={{ display: 'flex', flex: 1 }}>
{editor}
</div>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
{renderSearchBar()}
</div>
</div>
</div>
);
}
export {
NoteEditor as NoteEditorComponent,
};
const mapStateToProps = (state: any) => {
const noteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null;
return {
noteId: noteId,
notes: state.notes,
folders: state.folders,
selectedNoteIds: state.selectedNoteIds,
isProvisional: state.provisionalNoteIds.includes(noteId),
editorNoteStatuses: state.editorNoteStatuses,
syncStarted: state.syncStarted,
theme: state.settings.theme,
watchedNoteFiles: state.watchedNoteFiles,
windowCommand: state.windowCommand,
notesParentType: state.notesParentType,
historyNotes: state.historyNotes,
selectedNoteTags: state.selectedNoteTags,
lastEditorScrollPercents: state.lastEditorScrollPercents,
selectedNoteHash: state.selectedNoteHash,
searches: state.searches,
selectedSearchId: state.selectedSearchId,
customCss: state.customCss,
noteVisiblePanes: state.noteVisiblePanes,
};
};
export default connect(mapStateToProps)(NoteEditor);

View File

@ -0,0 +1,53 @@
import { NoteTextProps } from '../utils/types';
const { buildStyle } = require('../../../theme.js');
export default function styles(props: NoteTextProps) {
return buildStyle('NoteEditor', props.theme, (theme: any) => {
return {
root: {
...props.style,
boxSizing: 'border-box',
paddingLeft: 10,
paddingTop: 10,
borderLeftWidth: 1,
borderLeftColor: theme.dividerColor,
borderLeftStyle: 'solid',
},
titleInput: {
flex: 1,
display: 'inline-block',
paddingTop: 5,
paddingBottom: 5,
paddingLeft: 8,
paddingRight: 8,
marginRight: theme.paddingLeft,
color: theme.textStyle.color,
fontSize: theme.textStyle.fontSize * 1.25 * 1.5,
backgroundColor: theme.backgroundColor,
border: '1px solid',
borderColor: theme.dividerColor,
},
warningBanner: {
background: theme.warningBackgroundColor,
fontFamily: theme.fontFamily,
padding: 10,
fontSize: theme.fontSize,
},
tinyMCE: {
width: '100%',
height: '100%',
},
toolbar: {
marginTop: 4,
marginBottom: 0,
},
titleDate: {
...theme.textStyle,
color: theme.colorFaded,
paddingLeft: 10,
paddingRight: 10,
},
};
});
}

View File

@ -0,0 +1,28 @@
import { FormNote } from './types';
const HtmlToMd = require('lib/HtmlToMd');
const Note = require('lib/models/Note');
const { MarkupToHtml } = require('lib/joplin-renderer');
export async function htmlToMarkdown(markupLanguage: number, html: string, originalCss:string): Promise<string> {
let newBody = '';
if (markupLanguage === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN) {
const htmlToMd = new HtmlToMd();
newBody = htmlToMd.parse(html, { preserveImageTagsWithSize: true });
newBody = await Note.replaceResourceExternalToInternalLinks(newBody, { useAbsolutePaths: true });
} else {
newBody = await Note.replaceResourceExternalToInternalLinks(html, { useAbsolutePaths: true });
if (originalCss) newBody = `<style>${originalCss}</style>\n${newBody}`;
}
return newBody;
}
export async function formNoteToNote(formNote: FormNote): Promise<any> {
return {
id: formNote.id,
title: formNote.title,
body: formNote.body,
};
}

View File

@ -0,0 +1,122 @@
const Setting = require('lib/models/Setting');
const Note = require('lib/models/Note.js');
const BaseModel = require('lib/BaseModel.js');
const Resource = require('lib/models/Resource.js');
const { shim } = require('lib/shim');
const { bridge } = require('electron').remote.require('./bridge');
const ResourceFetcher = require('lib/services/ResourceFetcher.js');
const { reg } = require('lib/registry.js');
const joplinRendererUtils = require('lib/joplin-renderer').utils;
export async function handleResourceDownloadMode(noteBody: string) {
if (noteBody && Setting.value('sync.resourceDownloadMode') === 'auto') {
const resourceIds = await Note.linkedResourceIds(noteBody);
await ResourceFetcher.instance().markForDownload(resourceIds);
}
}
let resourceCache_: any = {};
export function clearResourceCache() {
resourceCache_ = {};
}
export async function attachedResources(noteBody: string): Promise<any> {
if (!noteBody) return {};
const resourceIds = await Note.linkedItemIdsByType(BaseModel.TYPE_RESOURCE, noteBody);
const output: any = {};
for (let i = 0; i < resourceIds.length; i++) {
const id = resourceIds[i];
if (resourceCache_[id]) {
output[id] = resourceCache_[id];
} else {
const resource = await Resource.load(id);
const localState = await Resource.localState(resource);
const o = {
item: resource,
localState: localState,
};
// eslint-disable-next-line require-atomic-updates
resourceCache_[id] = o;
output[id] = o;
}
}
return output;
}
export async function attachResources() {
const filePaths = bridge().showOpenDialog({
properties: ['openFile', 'createDirectory', 'multiSelections'],
});
if (!filePaths || !filePaths.length) return [];
const output = [];
for (const filePath of filePaths) {
try {
const resource = await shim.createResourceFromPath(filePath);
output.push({
item: resource,
markdownTag: Resource.markdownTag(resource),
});
} catch (error) {
bridge().showErrorMessageBox(error.message);
}
}
return output;
}
export async function commandAttachFileToBody(body:string, filePaths:string[] = null, options:any = null) {
options = {
createFileURL: false,
position: 0,
...options,
};
if (!filePaths) {
filePaths = bridge().showOpenDialog({
properties: ['openFile', 'createDirectory', 'multiSelections'],
});
if (!filePaths || !filePaths.length) return null;
}
for (let i = 0; i < filePaths.length; i++) {
const filePath = filePaths[i];
try {
reg.logger().info(`Attaching ${filePath}`);
const newBody = await shim.attachFileToNoteBody(body, filePath, options.position, {
createFileURL: options.createFileURL,
resizeLargeImages: 'ask',
});
if (!newBody) {
reg.logger().info('File attachment was cancelled');
return null;
}
body = newBody;
reg.logger().info('File was attached.');
} catch (error) {
reg.logger().error(error);
bridge().showErrorMessageBox(error.message);
}
}
return body;
}
export function resourcesStatus(resourceInfos: any) {
let lowestIndex = joplinRendererUtils.resourceStatusIndex('ready');
for (const id in resourceInfos) {
const s = joplinRendererUtils.resourceStatus(Resource, resourceInfos[id]);
const idx = joplinRendererUtils.resourceStatusIndex(s);
if (idx < lowestIndex) lowestIndex = idx;
}
return joplinRendererUtils.resourceStatusName(lowestIndex);
}

View File

@ -0,0 +1,145 @@
// eslint-disable-next-line no-unused-vars
import AsyncActionQueue from '../../../lib/AsyncActionQueue';
export interface NoteTextProps {
style: any;
noteId: string;
theme: number;
dispatch: Function;
selectedNoteIds: string[];
notes: any[];
watchedNoteFiles: string[];
isProvisional: boolean;
editorNoteStatuses: any;
syncStarted: boolean;
bodyEditor: string;
windowCommand: any;
folders: any[];
notesParentType: string;
historyNotes: any[];
selectedNoteTags: any[];
lastEditorScrollPercents: any;
selectedNoteHash: string;
searches: any[],
selectedSearchId: string,
customCss: string,
noteVisiblePanes: string[],
}
export interface NoteBodyEditorProps {
style: any;
ref: any,
theme: number;
content: string,
contentKey: string,
contentMarkupLanguage: number,
contentOriginalCss: string,
onChange(event: OnChangeEvent): void;
onWillChange(event: any): void;
onMessage(event: any): void;
onScroll(event: any): void;
markupToHtml: Function;
htmlToMarkdown: Function;
allAssets: Function;
attachResources: Function;
disabled: boolean;
dispatch: Function;
noteToolbar: any;
searchMarkers: any,
visiblePanes: string[],
keyboardMode: string,
resourceInfos: ResourceInfos,
}
export interface FormNote {
id: string,
title: string,
body: string,
parent_id: string,
is_todo: number,
bodyEditorContent?: any,
markup_language: number,
user_updated_time: number,
encryption_applied: number,
hasChanged: boolean,
// Getting the content from the editor can be a slow process because that content
// might need to be serialized first. For that reason, the wrapped editor (eg TinyMCE)
// first emits onWillChange when there is a change. That event does not include the
// editor content. After a few milliseconds (eg if the user stops typing for long
// enough), the editor emits onChange, and that event will include the editor content.
//
// Both onWillChange and onChange events include a changeId property which is used
// to link the two events together. It is used for example to detect if a new note
// was loaded before the current note was saved - in that case the changeId will be
// different. The two properties bodyWillChangeId and bodyChangeId are used to save
// this info with the currently loaded note.
//
// The willChange/onChange events also allow us to handle the case where the user
// types something then quickly switch a different note. In that case, bodyWillChangeId
// is set, thus we know we should save the note, even though we won't receive the
// onChange event.
bodyWillChangeId: number
bodyChangeId: number,
saveActionQueue: AsyncActionQueue,
// Note with markup_language = HTML have a block of CSS at the start, which is used
// to preserve the style from the original (web-clipped) page. When sending the note
// content to TinyMCE, we only send the actual HTML, without this CSS. The CSS is passed
// via a file in pluginAssets. This is because TinyMCE would not render the style otherwise.
// However, when we get back the HTML from TinyMCE, we need to reconstruct the original note.
// Since the CSS used by TinyMCE has been lost (since it's in a temp CSS file), we keep that
// original CSS here. It's used in formNoteToNote to rebuild the note body.
// We can keep it here because we know TinyMCE will not modify it anyway.
originalCss: string,
}
export function defaultFormNote():FormNote {
return {
id: '',
parent_id: '',
title: '',
body: '',
is_todo: 0,
markup_language: 1,
bodyWillChangeId: 0,
bodyChangeId: 0,
saveActionQueue: null,
originalCss: '',
hasChanged: false,
user_updated_time: 0,
encryption_applied: 0,
};
}
export interface ResourceInfo {
localState: any,
item: any,
}
export interface ResourceInfos {
[index:string]: ResourceInfo,
}
export enum ScrollOptionTypes {
None = 0,
Hash = 1,
Percent = 2,
}
export interface ScrollOptions {
type: ScrollOptionTypes,
value: any,
}
export interface OnChangeEvent {
changeId: number;
content: any;
}
export interface EditorCommand {
name: string;
value: any;
}

View File

@ -0,0 +1,53 @@
import { useCallback } from 'react';
const Note = require('lib/models/Note.js');
interface HookDependencies {
editorRef:any,
}
export default function useDropHandler(dependencies:HookDependencies) {
const { editorRef } = dependencies;
return useCallback(async (event:any) => {
const dt = event.dataTransfer;
const createFileURL = event.altKey;
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
const noteMarkdownTags = [];
for (let i = 0; i < noteIds.length; i++) {
const note = await Note.load(noteIds[i]);
noteMarkdownTags.push(Note.markdownTag(note));
}
editorRef.current.execCommand({
name: 'dropItems',
value: {
type: 'notes',
markdownTags: noteMarkdownTags,
},
});
return;
}
const files = dt.files;
if (files && files.length) {
const paths = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file.path) continue;
paths.push(file.path);
}
editorRef.current.execCommand({
name: 'dropItems',
value: {
type: 'files',
paths: paths,
createFileURL: createFileURL,
},
});
}
}, []);
}

View File

@ -0,0 +1,208 @@
import { useState, useEffect, useCallback } from 'react';
import { FormNote, defaultFormNote, ResourceInfos } from './types';
import { clearResourceCache, attachedResources } from './resourceHandling';
const { MarkupToHtml } = require('lib/joplin-renderer');
const HtmlToHtml = require('lib/joplin-renderer/HtmlToHtml');
import AsyncActionQueue from '../../../lib/AsyncActionQueue';
import { handleResourceDownloadMode } from './resourceHandling';
const usePrevious = require('lib/hooks/usePrevious').default;
const Note = require('lib/models/Note');
const Setting = require('lib/models/Setting');
const { reg } = require('lib/registry.js');
const ResourceFetcher = require('lib/services/ResourceFetcher.js');
const DecryptionWorker = require('lib/services/DecryptionWorker.js');
export interface OnLoadEvent {
formNote: FormNote,
}
interface HookDependencies {
syncStarted: boolean,
noteId: string,
isProvisional: boolean,
titleInputRef: any,
editorRef: any,
onBeforeLoad(event:OnLoadEvent):void,
onAfterLoad(event:OnLoadEvent):void,
}
function installResourceChangeHandler(onResourceChangeHandler: Function) {
ResourceFetcher.instance().on('downloadComplete', onResourceChangeHandler);
ResourceFetcher.instance().on('downloadStarted', onResourceChangeHandler);
DecryptionWorker.instance().on('resourceDecrypted', onResourceChangeHandler);
}
function uninstallResourceChangeHandler(onResourceChangeHandler: Function) {
ResourceFetcher.instance().off('downloadComplete', onResourceChangeHandler);
ResourceFetcher.instance().off('downloadStarted', onResourceChangeHandler);
DecryptionWorker.instance().off('resourceDecrypted', onResourceChangeHandler);
}
export default function useFormNote(dependencies:HookDependencies) {
const { syncStarted, noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad } = dependencies;
const [formNote, setFormNote] = useState<FormNote>(defaultFormNote());
const [isNewNote, setIsNewNote] = useState(false);
const prevSyncStarted = usePrevious(syncStarted);
const previousNoteId = usePrevious(formNote.id);
const [resourceInfos, setResourceInfos] = useState<ResourceInfos>({});
async function initNoteState(n: any) {
let originalCss = '';
if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) {
const htmlToHtml = new HtmlToHtml();
const splitted = htmlToHtml.splitHtml(n.body);
originalCss = splitted.css;
}
const newFormNote = {
id: n.id,
title: n.title,
body: n.body,
is_todo: n.is_todo,
parent_id: n.parent_id,
bodyWillChangeId: 0,
bodyChangeId: 0,
markup_language: n.markup_language,
saveActionQueue: new AsyncActionQueue(300),
originalCss: originalCss,
hasChanged: false,
user_updated_time: n.user_updated_time,
encryption_applied: n.encryption_applied,
};
// Note that for performance reason,the call to setResourceInfos should
// be first because it loads the resource infos in an async way. If we
// swap them, the formNote will be updated first and rendered, then the
// the resources will load, and the note will be re-rendered.
setResourceInfos(await attachedResources(n.body));
setFormNote(newFormNote);
await handleResourceDownloadMode(n.body);
return newFormNote;
}
useEffect(() => {
// Check that synchronisation has just finished - and
// if the note has never been changed, we reload it.
// If the note has already been changed, it's a conflict
// that's already been handled by the synchronizer.
if (!prevSyncStarted) return () => {};
if (syncStarted) return () => {};
if (formNote.hasChanged) return () => {};
reg.logger().debug('Sync has finished and note has never been changed - reloading it');
let cancelled = false;
const loadNote = async () => {
const n = await Note.load(noteId);
if (cancelled) return;
// Normally should not happened because if the note has been deleted via sync
// it would not have been loaded in the editor (due to note selection changing
// on delete)
if (!n) {
reg.logger().warn('Trying to reload note that has been deleted:', noteId);
return;
}
await initNoteState(n);
};
loadNote();
return () => {
cancelled = true;
};
}, [prevSyncStarted, syncStarted, formNote]);
useEffect(() => {
if (!noteId) return () => {};
if (formNote.id === noteId) return () => {};
let cancelled = false;
reg.logger().debug('Loading existing note', noteId);
function handleAutoFocus(noteIsTodo: boolean) {
if (!isProvisional) return;
const focusSettingName = noteIsTodo ? 'newTodoFocus' : 'newNoteFocus';
requestAnimationFrame(() => {
if (Setting.value(focusSettingName) === 'title') {
if (titleInputRef.current) titleInputRef.current.focus();
} else {
if (editorRef.current) editorRef.current.execCommand({ name: 'focus' });
}
});
}
async function loadNote() {
const n = await Note.load(noteId);
if (cancelled) return;
if (!n) throw new Error(`Cannot find note with ID: ${noteId}`);
reg.logger().debug('Loaded note:', n);
await onBeforeLoad({ formNote });
const newFormNote = await initNoteState(n);
setIsNewNote(isProvisional);
await onAfterLoad({ formNote: newFormNote });
handleAutoFocus(!!n.is_todo);
}
loadNote();
return () => {
cancelled = true;
};
}, [noteId, isProvisional, formNote]);
const onResourceChange = useCallback(async function(event:any = null) {
const resourceIds = await Note.linkedResourceIds(formNote.body);
if (!event || resourceIds.indexOf(event.id) >= 0) {
clearResourceCache();
setResourceInfos(await attachedResources(formNote.body));
}
}, [formNote.body]);
useEffect(() => {
installResourceChangeHandler(onResourceChange);
return () => {
uninstallResourceChangeHandler(onResourceChange);
};
}, [onResourceChange]);
useEffect(() => {
if (previousNoteId !== formNote.id) {
onResourceChange();
}
}, [previousNoteId, formNote.id, onResourceChange]);
useEffect(() => {
let cancelled = false;
async function runEffect() {
const r = await attachedResources(formNote.body);
if (cancelled) return;
setResourceInfos(r);
}
runEffect();
return () => {
cancelled = true;
};
}, [formNote.body]);
return { isNewNote, formNote, setFormNote, resourceInfos };
}

View File

@ -0,0 +1,56 @@
import { useCallback } from 'react';
import { ResourceInfos } from './types';
const { themeStyle } = require('../../../theme.js');
const Note = require('lib/models/Note');
const Setting = require('lib/models/Setting');
const markupLanguageUtils = require('lib/markupLanguageUtils');
interface HookDependencies {
themeId: number,
customCss: string,
}
interface MarkupToHtmlOptions {
replaceResourceInternalToExternalLinks?: boolean,
resourceInfos?: ResourceInfos,
}
export default function useMarkupToHtml(dependencies:HookDependencies) {
const { themeId, customCss } = dependencies;
return useCallback(async (markupLanguage: number, md: string, options: MarkupToHtmlOptions = null): Promise<any> => {
options = {
replaceResourceInternalToExternalLinks: false,
resourceInfos: {},
...options,
};
md = md || '';
const theme = themeStyle(themeId);
let resources = {};
if (options.replaceResourceInternalToExternalLinks) {
md = await Note.replaceResourceInternalToExternalLinks(md, { useAbsolutePaths: true });
} else {
resources = options.resourceInfos;
}
delete options.replaceResourceInternalToExternalLinks;
const markupToHtml = markupLanguageUtils.newMarkupToHtml({
resourceBaseUrl: `file://${Setting.value('resourceDir')}/`,
});
const result = await markupToHtml.render(markupLanguage, md, theme, Object.assign({}, {
codeTheme: theme.codeThemeCss,
userCss: customCss || '',
resources: resources,
postMessageSyntax: 'ipcProxySendToHost',
splitted: true,
externalAssetsOnly: true,
}, options));
return result;
}, [themeId, customCss]);
}

View File

@ -0,0 +1,152 @@
import { useCallback } from 'react';
const BaseItem = require('lib/models/BaseItem');
const { _ } = require('lib/locale');
const BaseModel = require('lib/BaseModel.js');
const Resource = require('lib/models/Resource.js');
const { bridge } = require('electron').remote.require('./bridge');
const { urlDecode } = require('lib/string-utils');
const urlUtils = require('lib/urlUtils');
const ResourceFetcher = require('lib/services/ResourceFetcher.js');
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
const fs = require('fs-extra');
const { clipboard } = require('electron');
const { toSystemSlashes } = require('lib/path-utils');
const { reg } = require('lib/registry.js');
export default function useMessageHandler(scrollWhenReady:any, setScrollWhenReady:Function, editorRef:any, setLocalSearchResultCount:Function, dispatch:Function) {
return useCallback(async (event: any) => {
const msg = event.channel ? event.channel : '';
const args = event.args;
const arg0 = args && args.length >= 1 ? args[0] : null;
if (msg !== 'percentScroll') console.info(`Got ipc-message: ${msg}`, args);
if (msg.indexOf('error:') === 0) {
const s = msg.split(':');
s.splice(0, 1);
reg.logger().error(s.join(':'));
} else if (msg === 'noteRenderComplete') {
if (scrollWhenReady) {
const options = { ...scrollWhenReady };
setScrollWhenReady(null);
editorRef.current.scrollTo(options);
}
} else if (msg === 'setMarkerCount') {
setLocalSearchResultCount(arg0);
} else if (msg.indexOf('markForDownload:') === 0) {
const s = msg.split(':');
if (s.length < 2) throw new Error(`Invalid message: ${msg}`);
ResourceFetcher.instance().markForDownload(s[1]);
} else if (msg === 'contextMenu') {
const itemType = arg0 && arg0.type;
const menu = new Menu();
if (itemType === 'image' || itemType === 'resource') {
const resource = await Resource.load(arg0.resourceId);
const resourcePath = Resource.fullPath(resource);
menu.append(
new MenuItem({
label: _('Open...'),
click: async () => {
const ok = bridge().openExternal(`file://${resourcePath}`);
if (!ok) bridge().showErrorMessageBox(_('This file could not be opened: %s', resourcePath));
},
})
);
menu.append(
new MenuItem({
label: _('Save as...'),
click: async () => {
const filePath = bridge().showSaveDialog({
defaultPath: resource.filename ? resource.filename : resource.title,
});
if (!filePath) return;
await fs.copy(resourcePath, filePath);
},
})
);
menu.append(
new MenuItem({
label: _('Copy path to clipboard'),
click: async () => {
clipboard.writeText(toSystemSlashes(resourcePath));
},
})
);
} else if (itemType === 'text') {
menu.append(
new MenuItem({
label: _('Copy'),
click: async () => {
clipboard.writeText(arg0.textToCopy);
},
})
);
} else if (itemType === 'link') {
menu.append(
new MenuItem({
label: _('Copy Link Address'),
click: async () => {
clipboard.writeText(arg0.textToCopy);
},
})
);
} else {
reg.logger().error(`Unhandled item type: ${itemType}`);
return;
}
menu.popup(bridge().window());
} else if (msg.indexOf('joplin://') === 0) {
const resourceUrlInfo = urlUtils.parseResourceUrl(msg);
const itemId = resourceUrlInfo.itemId;
const item = await BaseItem.loadItemById(itemId);
if (!item) throw new Error(`No item with ID ${itemId}`);
if (item.type_ === BaseModel.TYPE_RESOURCE) {
const localState = await Resource.localState(item);
if (localState.fetch_status !== Resource.FETCH_STATUS_DONE || !!item.encryption_blob_encrypted) {
if (localState.fetch_status === Resource.FETCH_STATUS_ERROR) {
bridge().showErrorMessageBox(`${_('There was an error downloading this attachment:')}\n\n${localState.fetch_error}`);
} else {
bridge().showErrorMessageBox(_('This attachment is not downloaded or not decrypted yet'));
}
return;
}
const filePath = Resource.fullPath(item);
bridge().openItem(filePath);
} else if (item.type_ === BaseModel.TYPE_NOTE) {
dispatch({
type: 'FOLDER_AND_NOTE_SELECT',
folderId: item.parent_id,
noteId: item.id,
hash: resourceUrlInfo.hash,
// historyNoteAction: {
// id: this.state.note.id,
// parent_id: this.state.note.parent_id,
// },
});
} else {
throw new Error(`Unsupported item type: ${item.type_}`);
}
} else if (urlUtils.urlProtocol(msg)) {
if (msg.indexOf('file://') === 0) {
// When using the file:// protocol, openExternal doesn't work (does nothing) with URL-encoded paths
require('electron').shell.openExternal(urlDecode(msg));
} else {
require('electron').shell.openExternal(msg);
}
} else if (msg.indexOf('#') === 0) {
// This is an internal anchor, which is handled by the WebView so skip this case
} else {
bridge().showErrorMessageBox(_('Unsupported link or message: %s', msg));
}
}, [dispatch, setLocalSearchResultCount, scrollWhenReady]);
}

View File

@ -0,0 +1,92 @@
import { useState, useCallback } from 'react';
import { SearchMarkers } from './useSearchMarkers';
interface LocalSearch {
query: string,
selectedIndex: number,
resultCount: number,
searching: boolean,
timestamp: number,
}
function defaultLocalSearch():LocalSearch {
return {
query: '',
selectedIndex: 0,
resultCount: 0,
searching: false,
timestamp: 0,
};
}
export default function useNoteSearchBar() {
const [showLocalSearch, setShowLocalSearch] = useState(false);
const [localSearch, setLocalSearch] = useState<LocalSearch>(defaultLocalSearch());
const onChange = useCallback((query:string) => {
setLocalSearch((prev:LocalSearch) => {
return {
query: query,
selectedIndex: 0,
timestamp: Date.now(),
resultCount: prev.resultCount,
searching: true,
};
});
}, []);
const noteSearchBarNextPrevious = useCallback((inc:number) => {
setLocalSearch((prev:LocalSearch) => {
const ls = Object.assign({}, prev);
ls.selectedIndex += inc;
ls.timestamp = Date.now();
if (ls.selectedIndex < 0) ls.selectedIndex = ls.resultCount - 1;
if (ls.selectedIndex >= ls.resultCount) ls.selectedIndex = 0;
return ls;
});
}, []);
const onNext = useCallback(() => {
noteSearchBarNextPrevious(+1);
}, [noteSearchBarNextPrevious]);
const onPrevious = useCallback(() => {
noteSearchBarNextPrevious(-1);
}, [noteSearchBarNextPrevious]);
const onClose = useCallback(() => {
setShowLocalSearch(false);
setLocalSearch(defaultLocalSearch());
}, []);
const setResultCount = useCallback((count:number) => {
setLocalSearch((prev:LocalSearch) => {
if (prev.resultCount === count && !prev.searching) return prev;
return {
...prev,
resultCount: count,
searching: false,
};
});
}, []);
const searchMarkers = useCallback(():SearchMarkers => {
return {
options: {
selectedIndex: localSearch.selectedIndex,
separateWordSearch: false,
searchTimestamp: localSearch.timestamp,
},
keywords: [
{
type: 'text',
value: localSearch.query,
accuracy: 'partially',
},
],
};
}, [localSearch]);
return { localSearch, onChange, onNext, onPrevious, onClose, setResultCount, showLocalSearch, setShowLocalSearch, searchMarkers };
}

View File

@ -0,0 +1,48 @@
'use strict';
const __awaiter = (this && this.__awaiter) || function(thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function(resolve) { resolve(value); }); }
return new (P || (P = Promise))(function(resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator['throw'](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, '__esModule', { value: true });
const react_1 = require('react');
const resourceHandling_1 = require('./resourceHandling');
const ResourceFetcher = require('lib/services/ResourceFetcher.js');
const DecryptionWorker = require('lib/services/DecryptionWorker.js');
const Note = require('lib/models/Note');
function useResourceInfos(dependencies) {
const { noteBody } = dependencies;
const [resourceInfos, setResourceInfos] = react_1.useState({});
function installResourceHandling(refreshResourceHandler) {
ResourceFetcher.instance().on('downloadComplete', refreshResourceHandler);
ResourceFetcher.instance().on('downloadStarted', refreshResourceHandler);
DecryptionWorker.instance().on('resourceDecrypted', refreshResourceHandler);
}
function uninstallResourceHandling(refreshResourceHandler) {
ResourceFetcher.instance().off('downloadComplete', refreshResourceHandler);
ResourceFetcher.instance().off('downloadStarted', refreshResourceHandler);
DecryptionWorker.instance().off('resourceDecrypted', refreshResourceHandler);
}
const refreshResource = react_1.useCallback(function(event) {
return __awaiter(this, void 0, void 0, function* () {
const resourceIds = yield Note.linkedResourceIds(noteBody);
if (resourceIds.indexOf(event.id) >= 0) {
resourceHandling_1.clearResourceCache();
setResourceInfos(yield resourceHandling_1.attachedResources(noteBody));
}
});
}, [noteBody]);
react_1.useEffect(() => {
installResourceHandling(refreshResource);
return () => {
uninstallResourceHandling(refreshResource);
};
}, [refreshResource]);
return { resourceInfos };
}
exports.default = useResourceInfos;
// # sourceMappingURL=useResourceRefresher.js.map

View File

@ -0,0 +1,42 @@
import { useMemo } from 'react';
const BaseModel = require('lib/BaseModel.js');
const SearchEngine = require('lib/services/SearchEngine');
interface SearchMarkersOptions {
searchTimestamp: number,
selectedIndex: number,
separateWordSearch: boolean,
}
export interface SearchMarkers {
keywords: any[],
options: SearchMarkersOptions,
}
function defaultSearchMarkers():SearchMarkers {
return {
keywords: [],
options: {
searchTimestamp: 0,
selectedIndex: 0,
separateWordSearch: false,
},
};
}
export default function useSearchMarkers(showLocalSearch:boolean, localSearchMarkerOptions:Function, searches:any[], selectedSearchId:string) {
return useMemo(():SearchMarkers => {
if (showLocalSearch) return localSearchMarkerOptions();
const output = defaultSearchMarkers();
const search = BaseModel.byId(searches, selectedSearchId);
if (search) {
const parsedQuery = SearchEngine.instance().parseQuery(search.query_pattern);
output.keywords = SearchEngine.instance().allParsedQueryTerms(parsedQuery);
}
return output;
}, [showLocalSearch, localSearchMarkerOptions, searches, selectedSearchId]);
}

View File

@ -0,0 +1,104 @@
import { useEffect } from 'react';
import { FormNote, EditorCommand } from './types';
const { time } = require('lib/time-utils.js');
const { reg } = require('lib/registry.js');
const NoteListUtils = require('../../utils/NoteListUtils');
interface HookDependencies {
windowCommand: any,
formNote:FormNote,
setShowLocalSearch:Function,
dispatch:Function,
noteSearchBarRef:any,
editorRef:any,
titleInputRef:any,
saveNoteAndWait: Function,
}
export default function useWindowCommandHandler(dependencies:HookDependencies) {
const { windowCommand, dispatch, formNote, setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, saveNoteAndWait } = dependencies;
useEffect(() => {
async function processCommand() {
const command = windowCommand;
if (!command || !formNote) return;
reg.logger().debug('NoteEditor::useWindowCommandHandler:', command);
const editorCmd: EditorCommand = { name: '', value: command.value };
let fn: Function = null;
// These commands can be forwarded directly to the note body editor
// without transformation.
const directMapCommands = [
'textCode',
'textBold',
'textItalic',
'textLink',
'attachFile',
'textNumberedList',
'textBulletedList',
'textCheckbox',
'textHeading',
'textHorizontalRule',
];
if (directMapCommands.includes(command.name)) {
editorCmd.name = command.name;
} else if (command.name === 'commandStartExternalEditing') {
fn = async () => {
await saveNoteAndWait(formNote);
NoteListUtils.startExternalEditing(formNote.id);
};
} else if (command.name === 'commandStopExternalEditing') {
fn = () => {
NoteListUtils.stopExternalEditing(formNote.id);
};
} else if (command.name === 'insertDateTime') {
editorCmd.name = 'insertText',
editorCmd.value = time.formatMsToLocal(new Date().getTime());
} else if (command.name === 'showLocalSearch') {
setShowLocalSearch(true);
if (noteSearchBarRef.current) noteSearchBarRef.current.wrappedInstance.focus();
} else if (command.name === 'insertTemplate') {
editorCmd.name = 'insertText',
editorCmd.value = time.formatMsToLocal(new Date().getTime());
}
if (command.name === 'focusElement' && command.target === 'noteTitle') {
fn = () => {
if (!titleInputRef.current) return;
titleInputRef.current.focus();
};
}
if (command.name === 'focusElement' && command.target === 'noteBody') {
editorCmd.name = 'focus';
}
reg.logger().debug('NoteEditor::useWindowCommandHandler: Dispatch:', editorCmd, fn);
if (!editorCmd.name && !fn) return;
dispatch({
type: 'WINDOW_COMMAND',
name: null,
});
requestAnimationFrame(() => {
if (fn) {
fn();
} else {
if (!editorRef.current.execCommand) {
reg.logger().warn('Received command, but editor cannot execute commands', editorCmd);
} else {
editorRef.current.execCommand(editorCmd);
}
}
});
}
processCommand();
}, [windowCommand, dispatch, formNote, saveNoteAndWait]);
}

View File

@ -145,7 +145,7 @@ class NoteListComponent extends React.Component {
todo_completed: checked ? time.unixMs() : 0, todo_completed: checked ? time.unixMs() : 0,
}; };
await Note.save(newNote, { userSideValidation: true }); await Note.save(newNote, { userSideValidation: true });
eventManager.emit('todoToggle', { noteId: item.id }); eventManager.emit('todoToggle', { noteId: item.id, note: newNote });
}; };
const hPadding = 10; const hPadding = 10;

View File

@ -10,6 +10,7 @@ const InteropServiceHelper = require('../InteropServiceHelper.js');
const { IconButton } = require('./IconButton.min.js'); const { IconButton } = require('./IconButton.min.js');
const { urlDecode, substrWithEllipsis } = require('lib/string-utils'); const { urlDecode, substrWithEllipsis } = require('lib/string-utils');
const Toolbar = require('./Toolbar.min.js'); const Toolbar = require('./Toolbar.min.js');
const NoteToolbar = require('./NoteToolbar/NoteToolbar.js').default;
const TagList = require('./TagList.min.js'); const TagList = require('./TagList.min.js');
const { connect } = require('react-redux'); const { connect } = require('react-redux');
const { _ } = require('lib/locale.js'); const { _ } = require('lib/locale.js');
@ -346,6 +347,36 @@ class NoteTextComponent extends React.Component {
this.webview_ipcMessage = this.webview_ipcMessage.bind(this); this.webview_ipcMessage = this.webview_ipcMessage.bind(this);
this.webview_domReady = this.webview_domReady.bind(this); this.webview_domReady = this.webview_domReady.bind(this);
this.noteRevisionViewer_onBack = this.noteRevisionViewer_onBack.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: // 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; return toolbarItems;
} }
@ -2273,7 +2231,17 @@ class NoteTextComponent extends React.Component {
{titleBarDate} {titleBarDate}
{false ? titleBarMenuButton : null} {false ? titleBarMenuButton : null}
</div> </div>
{toolbar} <div style={{ display: 'flex', flex: 1, flexDirection: 'row' }}>
{toolbar}
<NoteToolbar
theme={this.props.theme}
note={note}
dispatch={this.props.dispatch}
style={toolbarStyle}
watchedNoteFiles={[]}
onButtonClick={this.noteToolbar_buttonClick}
/>
</div>
{tagList} {tagList}
{editor} {editor}
{viewer} {viewer}

View File

@ -1,833 +0,0 @@
import * as React from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
// eslint-disable-next-line no-unused-vars
import TinyMCE, { utils as tinyMceUtils } from './editors/TinyMCE';
import PlainEditor, { utils as plainEditorUtils } from './editors/PlainEditor';
import { connect } from 'react-redux';
import AsyncActionQueue from '../lib/AsyncActionQueue';
import MultiNoteActions from './MultiNoteActions';
// eslint-disable-next-line no-unused-vars
import { DefaultEditorState, OnChangeEvent, TextEditorUtils, EditorCommand } from './utils/NoteText';
const { themeStyle, buildStyle } = require('../theme.js');
const { reg } = require('lib/registry.js');
const { time } = require('lib/time-utils.js');
const markupLanguageUtils = require('lib/markupLanguageUtils');
const HtmlToHtml = require('lib/joplin-renderer/HtmlToHtml');
const Setting = require('lib/models/Setting');
const BaseItem = require('lib/models/BaseItem');
const { MarkupToHtml } = require('lib/joplin-renderer');
const HtmlToMd = require('lib/HtmlToMd');
const { _ } = require('lib/locale');
const Note = require('lib/models/Note.js');
const BaseModel = require('lib/BaseModel.js');
const Resource = require('lib/models/Resource.js');
const { shim } = require('lib/shim');
const TemplateUtils = require('lib/TemplateUtils');
const { bridge } = require('electron').remote.require('./bridge');
const { urlDecode } = require('lib/string-utils');
const urlUtils = require('lib/urlUtils');
const ResourceFetcher = require('lib/services/ResourceFetcher.js');
const DecryptionWorker = require('lib/services/DecryptionWorker.js');
interface NoteTextProps {
style: any,
noteId: string,
theme: number,
dispatch: Function,
selectedNoteIds: string[],
notes:any[],
watchedNoteFiles:string[],
isProvisional: boolean,
editorNoteStatuses: any,
syncStarted: boolean,
editor: string,
windowCommand: any,
}
interface FormNote {
id: string,
title: string,
parent_id: string,
is_todo: number,
bodyEditorContent?: any,
markup_language: number,
hasChanged: boolean,
// Getting the content from the editor can be a slow process because that content
// might need to be serialized first. For that reason, the wrapped editor (eg TinyMCE)
// first emits onWillChange when there is a change. That event does not include the
// editor content. After a few milliseconds (eg if the user stops typing for long
// enough), the editor emits onChange, and that event will include the editor content.
//
// Both onWillChange and onChange events include a changeId property which is used
// to link the two events together. It is used for example to detect if a new note
// was loaded before the current note was saved - in that case the changeId will be
// different. The two properties bodyWillChangeId and bodyChangeId are used to save
// this info with the currently loaded note.
//
// The willChange/onChange events also allow us to handle the case where the user
// types something then quickly switch a different note. In that case, bodyWillChangeId
// is set, thus we know we should save the note, even though we won't receive the
// onChange event.
bodyWillChangeId: number
bodyChangeId: number,
saveActionQueue: AsyncActionQueue,
// Note with markup_language = HTML have a block of CSS at the start, which is used
// to preserve the style from the original (web-clipped) page. When sending the note
// content to TinyMCE, we only send the actual HTML, without this CSS. The CSS is passed
// via a file in pluginAssets. This is because TinyMCE would not render the style otherwise.
// However, when we get back the HTML from TinyMCE, we need to reconstruct the original note.
// Since the CSS used by TinyMCE has been lost (since it's in a temp CSS file), we keep that
// original CSS here. It's used in formNoteToNote to rebuild the note body.
// We can keep it here because we know TinyMCE will not modify it anyway.
originalCss: string,
}
const defaultNote = ():FormNote => {
return {
id: '',
parent_id: '',
title: '',
is_todo: 0,
markup_language: 1,
bodyWillChangeId: 0,
bodyChangeId: 0,
saveActionQueue: null,
originalCss: '',
hasChanged: false,
};
};
function styles_(props:NoteTextProps) {
return buildStyle('NoteText', props.theme, (theme:any) => {
return {
titleInput: {
flex: 1,
display: 'inline-block',
paddingTop: 5,
paddingBottom: 5,
paddingLeft: 8,
paddingRight: 8,
marginRight: theme.paddingLeft,
color: theme.textStyle.color,
fontSize: theme.textStyle.fontSize * 1.25 * 1.5,
backgroundColor: theme.backgroundColor,
border: '1px solid',
borderColor: theme.dividerColor,
},
warningBanner: {
background: theme.warningBackgroundColor,
fontFamily: theme.fontFamily,
padding: 10,
fontSize: theme.fontSize,
},
tinyMCE: {
width: '100%',
height: '100%',
},
};
});
}
let textEditorUtils_:TextEditorUtils = null;
function usePrevious(value:any):any {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
async function initNoteState(n:any, setFormNote:Function, setDefaultEditorState:Function) {
let originalCss = '';
if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) {
const htmlToHtml = new HtmlToHtml();
const splitted = htmlToHtml.splitHtml(n.body);
originalCss = splitted.css;
}
setFormNote({
id: n.id,
title: n.title,
is_todo: n.is_todo,
parent_id: n.parent_id,
bodyWillChangeId: 0,
bodyChangeId: 0,
markup_language: n.markup_language,
saveActionQueue: new AsyncActionQueue(1000),
originalCss: originalCss,
hasChanged: false,
});
setDefaultEditorState({
value: n.body,
markupLanguage: n.markup_language,
resourceInfos: await attachedResources(n.body),
});
await handleResourceDownloadMode(n.body);
}
async function handleResourceDownloadMode(noteBody:string) {
if (noteBody && Setting.value('sync.resourceDownloadMode') === 'auto') {
const resourceIds = await Note.linkedResourceIds(noteBody);
await ResourceFetcher.instance().markForDownload(resourceIds);
}
}
async function htmlToMarkdown(html:string):Promise<string> {
const htmlToMd = new HtmlToMd();
let md = htmlToMd.parse(html, { preserveImageTagsWithSize: true });
md = await Note.replaceResourceExternalToInternalLinks(md, { useAbsolutePaths: true });
return md;
}
async function formNoteToNote(formNote:FormNote):Promise<any> {
const newNote:any = Object.assign({}, formNote);
if ('bodyEditorContent' in formNote) {
const html = await textEditorUtils_.editorContentToHtml(formNote.bodyEditorContent);
if (formNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN) {
newNote.body = await htmlToMarkdown(html);
} else {
newNote.body = html;
newNote.body = await Note.replaceResourceExternalToInternalLinks(newNote.body, { useAbsolutePaths: true });
if (formNote.originalCss) newNote.body = `<style>${formNote.originalCss}</style>\n${newNote.body}`;
}
}
delete newNote.bodyEditorContent;
return newNote;
}
let resourceCache_:any = {};
function clearResourceCache() {
resourceCache_ = {};
}
async function attachedResources(noteBody:string):Promise<any> {
if (!noteBody) return {};
const resourceIds = await Note.linkedItemIdsByType(BaseModel.TYPE_RESOURCE, noteBody);
const output:any = {};
for (let i = 0; i < resourceIds.length; i++) {
const id = resourceIds[i];
if (resourceCache_[id]) {
output[id] = resourceCache_[id];
} else {
const resource = await Resource.load(id);
const localState = await Resource.localState(resource);
const o = {
item: resource,
localState: localState,
};
// eslint-disable-next-line require-atomic-updates
resourceCache_[id] = o;
output[id] = o;
}
}
return output;
}
function installResourceHandling(refreshResourceHandler:Function) {
ResourceFetcher.instance().on('downloadComplete', refreshResourceHandler);
ResourceFetcher.instance().on('downloadStarted', refreshResourceHandler);
DecryptionWorker.instance().on('resourceDecrypted', refreshResourceHandler);
}
function uninstallResourceHandling(refreshResourceHandler:Function) {
ResourceFetcher.instance().off('downloadComplete', refreshResourceHandler);
ResourceFetcher.instance().off('downloadStarted', refreshResourceHandler);
DecryptionWorker.instance().off('resourceDecrypted', refreshResourceHandler);
}
async function attachResources() {
const filePaths = bridge().showOpenDialog({
properties: ['openFile', 'createDirectory', 'multiSelections'],
});
if (!filePaths || !filePaths.length) return [];
const output = [];
for (const filePath of filePaths) {
try {
const resource = await shim.createResourceFromPath(filePath);
output.push({
item: resource,
markdownTag: Resource.markdownTag(resource),
});
} catch (error) {
bridge().showErrorMessageBox(error.message);
}
}
return output;
}
function scheduleSaveNote(formNote:FormNote, dispatch:Function) {
if (!formNote.saveActionQueue) throw new Error('saveActionQueue is not set!!'); // Sanity check
reg.logger().debug('Scheduling...', formNote);
const makeAction = (formNote:FormNote) => {
return async function() {
const note = await formNoteToNote(formNote);
reg.logger().debug('Saving note...', note);
await Note.save(note);
dispatch({
type: 'EDITOR_NOTE_STATUS_REMOVE',
id: formNote.id,
});
};
};
formNote.saveActionQueue.push(makeAction(formNote));
}
function saveNoteIfWillChange(formNote:FormNote, editorRef:any, dispatch:Function) {
if (!formNote.id || !formNote.bodyWillChangeId) return;
scheduleSaveNote({
...formNote,
bodyEditorContent: editorRef.current.content(),
bodyWillChangeId: 0,
bodyChangeId: 0,
}, dispatch);
}
function useWindowCommand(windowCommand:any, dispatch:Function, formNote:FormNote, titleInputRef:React.MutableRefObject<any>, editorRef:React.MutableRefObject<any>) {
useEffect(() => {
const command = windowCommand;
if (!command || !formNote) return;
const editorCmd:EditorCommand = { name: command.name, value: { ...command.value } };
let fn:Function = null;
if (command.name === 'exportPdf') {
// TODO
} else if (command.name === 'print') {
// TODO
} else if (command.name === 'insertDateTime') {
editorCmd.name = 'insertText',
editorCmd.value = time.formatMsToLocal(new Date().getTime());
} else if (command.name === 'commandStartExternalEditing') {
// TODO
} else if (command.name === 'commandStopExternalEditing') {
// TODO
} else if (command.name === 'showLocalSearch') {
editorCmd.name = 'search';
} else if (command.name === 'textCode') {
// TODO
} else if (command.name === 'insertTemplate') {
editorCmd.name = 'insertText',
editorCmd.value = TemplateUtils.render(command.value);
}
if (command.name === 'focusElement' && command.target === 'noteTitle') {
fn = () => {
if (!titleInputRef.current) return;
titleInputRef.current.focus();
};
}
if (command.name === 'focusElement' && command.target === 'noteBody') {
editorCmd.name = 'focus';
}
if (!editorCmd.name && !fn) return;
dispatch({
type: 'WINDOW_COMMAND',
name: null,
});
requestAnimationFrame(() => {
if (fn) {
fn();
} else {
if (!editorRef.current.execCommand) {
reg.logger().warn('Received command, but editor cannot execute commands', editorCmd);
} else {
editorRef.current.execCommand(editorCmd);
}
}
});
}, [windowCommand, dispatch, formNote]);
}
function NoteText2(props:NoteTextProps) {
const [formNote, setFormNote] = useState<FormNote>(defaultNote());
const [defaultEditorState, setDefaultEditorState] = useState<DefaultEditorState>({ value: '', markupLanguage: MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, resourceInfos: {} });
const prevSyncStarted = usePrevious(props.syncStarted);
const editorRef = useRef<any>();
const titleInputRef = useRef<any>();
const formNoteRef = useRef<FormNote>();
formNoteRef.current = { ...formNote };
const isMountedRef = useRef(true);
useWindowCommand(props.windowCommand, props.dispatch, formNote, titleInputRef, editorRef);
// If the note has been modified in another editor, wait for it to be saved
// before loading it in this editor.
const waitingToSaveNote = props.noteId && formNote.id !== props.noteId && props.editorNoteStatuses[props.noteId] === 'saving';
const styles = styles_(props);
const markupToHtml = useCallback(async (markupLanguage:number, md:string, options:any = null):Promise<any> => {
md = md || '';
const theme = themeStyle(props.theme);
md = await Note.replaceResourceInternalToExternalLinks(md, { useAbsolutePaths: true });
const markupToHtml = markupLanguageUtils.newMarkupToHtml({
resourceBaseUrl: `file://${Setting.value('resourceDir')}/`,
});
const result = await markupToHtml.render(markupLanguage, md, theme, Object.assign({}, {
codeTheme: theme.codeThemeCss,
// userCss: this.props.customCss ? this.props.customCss : '',
// resources: await shared.attachedResources(noteBody),
resources: [],
postMessageSyntax: 'ipcProxySendToHost',
splitted: true,
externalAssetsOnly: true,
}, options));
return result;
}, [props.theme]);
const allAssets = useCallback(async (markupLanguage:number):Promise<any[]> => {
const theme = themeStyle(props.theme);
const markupToHtml = markupLanguageUtils.newMarkupToHtml({
resourceBaseUrl: `file://${Setting.value('resourceDir')}/`,
});
return markupToHtml.allAssets(markupLanguage, theme);
}, [props.theme]);
const joplinHtml = useCallback(async (type:string) => {
if (type === 'checkbox') {
const result = await markupToHtml(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, '- [ ] xxxxxREMOVExxxxx', {
bodyOnly: true,
externalAssetsOnly: true,
});
const html = result.html
.replace(/xxxxxREMOVExxxxx/m, ' ')
.replace(/<ul.*?>/, '')
.replace(/<\/ul>/, '');
return { ...result, html: html };
}
throw new Error(`Invalid type:${type}`);
}, [markupToHtml]);
const handleProvisionalFlag = useCallback(() => {
if (props.isProvisional) {
props.dispatch({
type: 'NOTE_PROVISIONAL_FLAG_CLEAR',
id: formNote.id,
});
}
}, [props.isProvisional, formNote.id]);
const refreshResource = useCallback(async function(event) {
if (!defaultEditorState.value) return;
const resourceIds = await Note.linkedResourceIds(defaultEditorState.value);
if (resourceIds.indexOf(event.id) >= 0) {
clearResourceCache();
const e = {
...defaultEditorState,
resourceInfos: await attachedResources(defaultEditorState.value),
};
setDefaultEditorState(e);
}
}, [defaultEditorState]);
useEffect(() => {
installResourceHandling(refreshResource);
return () => {
uninstallResourceHandling(refreshResource);
};
}, [defaultEditorState]);
useEffect(() => {
// This is not exactly a hack but a bit ugly. If the note was changed (willChangeId > 0) but not
// yet saved, we need to save it now before the component is unmounted. However, we can't put
// formNote in the dependency array or that effect will run every time the note changes. We only
// want to run it once on unmount. So because of that we need to use that formNoteRef.
return () => {
isMountedRef.current = false;
saveNoteIfWillChange(formNoteRef.current, editorRef, props.dispatch);
};
}, []);
useEffect(() => {
// Check that synchronisation has just finished - and
// if the note has never been changed, we reload it.
// If the note has already been changed, it's a conflict
// that's already been handled by the synchronizer.
if (!prevSyncStarted) return () => {};
if (props.syncStarted) return () => {};
if (formNote.hasChanged) return () => {};
reg.logger().debug('Sync has finished and note has never been changed - reloading it');
let cancelled = false;
const loadNote = async () => {
const n = await Note.load(props.noteId);
if (cancelled) return;
// Normally should not happened because if the note has been deleted via sync
// it would not have been loaded in the editor (due to note selection changing
// on delete)
if (!n) {
reg.logger().warn('Trying to reload note that has been deleted:', props.noteId);
return;
}
await initNoteState(n, setFormNote, setDefaultEditorState);
};
loadNote();
return () => {
cancelled = true;
};
}, [prevSyncStarted, props.syncStarted, formNote]);
useEffect(() => {
if (!props.noteId) return () => {};
if (formNote.id === props.noteId) return () => {};
if (waitingToSaveNote) return () => {};
let cancelled = false;
reg.logger().debug('Loading existing note', props.noteId);
saveNoteIfWillChange(formNote, editorRef, props.dispatch);
function handleAutoFocus(noteIsTodo:boolean) {
if (!props.isProvisional) return;
const focusSettingName = noteIsTodo ? 'newTodoFocus' : 'newNoteFocus';
requestAnimationFrame(() => {
if (Setting.value(focusSettingName) === 'title') {
if (titleInputRef.current) titleInputRef.current.focus();
} else {
if (editorRef.current) editorRef.current.execCommand({ name: 'focus' });
}
});
}
async function loadNote() {
const n = await Note.load(props.noteId);
if (cancelled) return;
if (!n) throw new Error(`Cannot find note with ID: ${props.noteId}`);
reg.logger().debug('Loaded note:', n);
await initNoteState(n, setFormNote, setDefaultEditorState);
handleAutoFocus(!!n.is_todo);
}
loadNote();
return () => {
cancelled = true;
};
}, [props.noteId, props.isProvisional, formNote, waitingToSaveNote]);
const onFieldChange = useCallback((field:string, value:any, changeId: number = 0) => {
if (!isMountedRef.current) {
// When the component is unmounted, various actions can happen which can
// trigger onChange events, for example the textarea might be cleared.
// We need to ignore these events, otherwise the note is going to be saved
// with an invalid body.
reg.logger().debug('Skipping change event because the component is unmounted');
return;
}
handleProvisionalFlag();
const change = field === 'body' ? {
bodyEditorContent: value,
} : {
title: value,
};
const newNote = {
...formNote,
...change,
bodyWillChangeId: 0,
bodyChangeId: 0,
hasChanged: true,
};
if (changeId !== null && field === 'body' && formNote.bodyWillChangeId !== changeId) {
// Note was changed, but another note was loaded before save - skipping
// The previously loaded note, that was modified, will be saved via saveNoteIfWillChange()
} else {
setFormNote(newNote);
scheduleSaveNote(newNote, props.dispatch);
}
}, [handleProvisionalFlag, formNote]);
const onBodyChange = useCallback((event:OnChangeEvent) => onFieldChange('body', event.content, event.changeId), [onFieldChange]);
const onTitleChange = useCallback((event:any) => onFieldChange('title', event.target.value), [onFieldChange]);
const onBodyWillChange = useCallback((event:any) => {
handleProvisionalFlag();
setFormNote(prev => {
return {
...prev,
bodyWillChangeId: event.changeId,
hasChanged: true,
};
});
props.dispatch({
type: 'EDITOR_NOTE_STATUS_SET',
id: formNote.id,
status: 'saving',
});
}, [formNote, handleProvisionalFlag]);
const onMessage = useCallback(async (event:any) => {
const msg = event.name;
const args = event.args;
console.info('onMessage', msg, args);
if (msg === 'setMarkerCount') {
// const ls = Object.assign({}, this.state.localSearch);
// ls.resultCount = arg0;
// ls.searching = false;
// this.setState({ localSearch: ls });
} else if (msg.indexOf('markForDownload:') === 0) {
// const s = msg.split(':');
// if (s.length < 2) throw new Error(`Invalid message: ${msg}`);
// ResourceFetcher.instance().markForDownload(s[1]);
} else if (msg === 'percentScroll') {
// this.ignoreNextEditorScroll_ = true;
// this.setEditorPercentScroll(arg0);
} else if (msg === 'contextMenu') {
// const itemType = arg0 && arg0.type;
// const menu = new Menu();
// if (itemType === 'image' || itemType === 'resource') {
// const resource = await Resource.load(arg0.resourceId);
// const resourcePath = Resource.fullPath(resource);
// menu.append(
// new MenuItem({
// label: _('Open...'),
// click: async () => {
// const ok = bridge().openExternal(`file://${resourcePath}`);
// if (!ok) bridge().showErrorMessageBox(_('This file could not be opened: %s', resourcePath));
// },
// })
// );
// menu.append(
// new MenuItem({
// label: _('Save as...'),
// click: async () => {
// const filePath = bridge().showSaveDialog({
// defaultPath: resource.filename ? resource.filename : resource.title,
// });
// if (!filePath) return;
// await fs.copy(resourcePath, filePath);
// },
// })
// );
// menu.append(
// new MenuItem({
// label: _('Copy path to clipboard'),
// click: async () => {
// clipboard.writeText(toSystemSlashes(resourcePath));
// },
// })
// );
// } else if (itemType === 'text') {
// menu.append(
// new MenuItem({
// label: _('Copy'),
// click: async () => {
// clipboard.writeText(arg0.textToCopy);
// },
// })
// );
// } else if (itemType === 'link') {
// menu.append(
// new MenuItem({
// label: _('Copy Link Address'),
// click: async () => {
// clipboard.writeText(arg0.textToCopy);
// },
// })
// );
// } else {
// reg.logger().error(`Unhandled item type: ${itemType}`);
// return;
// }
// menu.popup(bridge().window());
} else if (msg === 'openInternal') {
const resourceUrlInfo = urlUtils.parseResourceUrl(args.url);
const itemId = resourceUrlInfo.itemId;
const item = await BaseItem.loadItemById(itemId);
if (!item) throw new Error(`No item with ID ${itemId}`);
if (item.type_ === BaseModel.TYPE_RESOURCE) {
const localState = await Resource.localState(item);
if (localState.fetch_status !== Resource.FETCH_STATUS_DONE || !!item.encryption_blob_encrypted) {
if (localState.fetch_status === Resource.FETCH_STATUS_ERROR) {
bridge().showErrorMessageBox(`${_('There was an error downloading this attachment:')}\n\n${localState.fetch_error}`);
} else {
bridge().showErrorMessageBox(_('This attachment is not downloaded or not decrypted yet'));
}
return;
}
const filePath = Resource.fullPath(item);
bridge().openItem(filePath);
} else if (item.type_ === BaseModel.TYPE_NOTE) {
props.dispatch({
type: 'FOLDER_AND_NOTE_SELECT',
folderId: item.parent_id,
noteId: item.id,
hash: resourceUrlInfo.hash,
historyAction: 'goto',
});
} else {
throw new Error(`Unsupported item type: ${item.type_}`);
}
} else if (msg.indexOf('#') === 0) {
// This is an internal anchor, which is handled by the WebView so skip this case
} else if (msg === 'openExternal') {
if (args.url.indexOf('file://') === 0) {
// When using the file:// protocol, openExternal doesn't work (does nothing) with URL-encoded paths
bridge().openExternal(urlDecode(args.url));
} else {
bridge().openExternal(args.url);
}
} else {
bridge().showErrorMessageBox(_('Unsupported link or message: %s', msg));
}
}, []);
const introductionPostLinkClick = useCallback(() => {
bridge().openExternal('https://www.patreon.com/posts/34246624');
}, []);
if (props.selectedNoteIds.length > 1) {
return <MultiNoteActions
theme={props.theme}
selectedNoteIds={props.selectedNoteIds}
notes={props.notes}
dispatch={props.dispatch}
watchedNoteFiles={props.watchedNoteFiles}
style={props.style}
/>;
}
const editorProps = {
ref: editorRef,
style: styles.tinyMCE,
onChange: onBodyChange,
onWillChange: onBodyWillChange,
onMessage: onMessage,
defaultEditorState: defaultEditorState,
markupToHtml: markupToHtml,
allAssets: allAssets,
attachResources: attachResources,
disabled: waitingToSaveNote,
joplinHtml: joplinHtml,
theme: props.theme,
};
let editor = null;
if (props.editor === 'TinyMCE') {
editor = <TinyMCE {...editorProps}/>;
textEditorUtils_ = tinyMceUtils;
} else if (props.editor === 'PlainEditor') {
editor = <PlainEditor {...editorProps}/>;
textEditorUtils_ = plainEditorUtils;
} else {
throw new Error(`Invalid editor: ${props.editor}`);
}
return (
<div style={props.style}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={styles.warningBanner}>
This is an experimental WYSIWYG editor for evaluation only. Please do not use with important notes as you may lose some data! See the <a style={styles.urlColor} onClick={introductionPostLinkClick} href="#">introduction post</a> for more information.
</div>
<div style={{ display: 'flex' }}>
<input
type="text"
ref={titleInputRef}
disabled={waitingToSaveNote}
placeholder={props.isProvisional ? _('Creating new %s...', formNote.is_todo ? _('to-do') : _('note')) : ''}
style={styles.titleInput}
onChange={onTitleChange}
value={formNote.title}
/>
</div>
<div style={{ display: 'flex', flex: 1 }}>
{editor}
</div>
</div>
</div>
);
}
export {
NoteText2 as NoteText2Component,
};
const mapStateToProps = (state:any) => {
const noteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null;
return {
noteId: noteId,
notes: state.notes,
selectedNoteIds: state.selectedNoteIds,
isProvisional: state.provisionalNoteIds.includes(noteId),
editorNoteStatuses: state.editorNoteStatuses,
syncStarted: state.syncStarted,
theme: state.settings.theme,
watchedNoteFiles: state.watchedNoteFiles,
windowCommand: state.windowCommand,
};
};
export default connect(mapStateToProps)(NoteText2);

View File

@ -162,7 +162,7 @@ class NoteTextViewerComponent extends React.Component {
// ---------------------------------------------------------------- // ----------------------------------------------------------------
render() { render() {
const viewerStyle = Object.assign({}, this.props.viewerStyle, { borderTop: 'none', borderRight: 'none', borderBottom: 'none' }); const viewerStyle = Object.assign({}, { border: 'none' }, this.props.viewerStyle);
return <iframe className="noteTextViewer" ref={this.webviewRef_} style={viewerStyle} src="gui/note-viewer/index.html"></iframe>; return <iframe className="noteTextViewer" ref={this.webviewRef_} style={viewerStyle} src="gui/note-viewer/index.html"></iframe>;
} }
} }

View File

@ -0,0 +1,148 @@
import * as React from 'react';
const { buildStyle } = require('../../theme.js');
const Toolbar = require('../Toolbar.min.js');
const Note = require('lib/models/Note');
const { time } = require('lib/time-utils.js');
const { _ } = require('lib/locale');
// const { substrWithEllipsis } = require('lib/string-utils');
// const Folder = require('lib/models/Folder');
// const { MarkupToHtml } = require('lib/joplin-renderer');
interface ButtonClickEvent {
name: string,
}
interface NoteToolbarProps {
theme: number,
style: any,
watchedNoteFiles: string[],
note: any,
dispatch: Function,
onButtonClick(event:ButtonClickEvent):void,
}
function styles_(props:NoteToolbarProps) {
return buildStyle('NoteToolbar', props.theme, (/* theme:any*/) => {
return {
root: {
...props.style,
},
};
});
}
function useToolbarItems(note:any, watchedNoteFiles:string[], dispatch:Function, onButtonClick:Function) {
const toolbarItems = [];
// TODO: add these two items
// if (props.folder && ['Search', 'Tag', 'SmartFilter'].includes(props.notesParentType)) {
// toolbarItems.push({
// title: _('In: %s', substrWithEllipsis(props.folder.title, 0, 16)),
// iconName: 'fa-book',
// onClick: () => {
// props.dispatch({
// type: 'FOLDER_AND_NOTE_SELECT',
// folderId: props.folder.id,
// noteId: props.formNote.id,
// });
// Folder.expandTree(props.folders, props.folder.parent_id);
// },
// });
// }
// if (props.historyNotes.length) {
// toolbarItems.push({
// tooltip: _('Back'),
// iconName: 'fa-arrow-left',
// onClick: () => {
// if (!props.historyNotes.length) return;
// const lastItem = props.historyNotes[props.historyNotes.length - 1];
// props.dispatch({
// type: 'FOLDER_AND_NOTE_SELECT',
// folderId: lastItem.parent_id,
// noteId: lastItem.id,
// historyNoteAction: 'pop',
// });
// },
// });
// }
if (watchedNoteFiles.indexOf(note.id) >= 0) {
toolbarItems.push({
tooltip: _('Click to stop external editing'),
title: _('Watching...'),
iconName: 'fa-external-link',
onClick: () => {
onButtonClick({ name: 'stopExternalEditing' });
},
});
} else {
toolbarItems.push({
tooltip: _('Edit in external editor'),
iconName: 'fa-external-link',
onClick: () => {
onButtonClick({ name: 'startExternalEditing' });
},
});
}
toolbarItems.push({
tooltip: _('Tags'),
iconName: 'fa-tags',
onClick: () => {
onButtonClick({ name: 'setTags' });
},
});
if (note.is_todo) {
const item:any = {
iconName: 'fa-clock-o',
enabled: !note.todo_completed,
onClick: () => {
onButtonClick({ name: 'setAlarm' });
},
};
if (Note.needAlarm(note)) {
item.title = time.formatMsToLocal(note.todo_due);
} else {
item.tooltip = _('Set alarm');
}
toolbarItems.push(item);
}
toolbarItems.push({
tooltip: _('Note properties'),
iconName: 'fa-info-circle',
onClick: () => {
dispatch({
type: 'WINDOW_COMMAND',
name: 'commandNoteProperties',
noteId: note.id,
onRevisionLinkClick: () => {
onButtonClick({ name: 'showRevisions' });
},
});
},
});
return toolbarItems;
}
export default function NoteToolbar(props:NoteToolbarProps) {
const styles = styles_(props);
const toolbarItems = useToolbarItems(props.note, props.watchedNoteFiles, props.dispatch, props.onButtonClick);
return (
<Toolbar style={styles.root} items={toolbarItems} />
);
}

View File

@ -69,8 +69,9 @@ const ResourceTable: React.FC<ResourceTable> = (props: ResourceTable) => {
}; };
const cellStyle = { const cellStyle = {
...props.theme.textStyleMinor, ...props.theme.textStyle,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
color: props.theme.colorFaded,
width: 1, width: 1,
}; };

View File

@ -11,10 +11,11 @@ class TagListComponent extends React.Component {
style.display = 'flex'; style.display = 'flex';
style.flexDirection = 'row'; style.flexDirection = 'row';
style.borderBottom = `1px solid ${theme.dividerColor}`; // style.borderBottom = `1px solid ${theme.dividerColor}`;
style.boxSizing = 'border-box'; style.boxSizing = 'border-box';
style.fontSize = theme.fontSize; style.fontSize = theme.fontSize;
style.whiteSpace = 'nowrap'; style.whiteSpace = 'nowrap';
style.height = 25;
const tagItems = []; const tagItems = [];
if (tags && tags.length > 0) { if (tags && tags.length > 0) {

View File

@ -6,7 +6,7 @@ const ToolbarSpace = require('./ToolbarSpace.min.js');
class ToolbarComponent extends React.Component { class ToolbarComponent extends React.Component {
render() { render() {
const style = this.props.style; const style = Object.assign({}, this.props.style);
const theme = themeStyle(this.props.theme); const theme = themeStyle(this.props.theme);
style.height = theme.toolbarHeight; style.height = theme.toolbarHeight;
style.display = 'flex'; style.display = 'flex';

View File

@ -105,8 +105,10 @@
for (let i = 0; i < assets.length; i++) { for (let i = 0; i < assets.length; i++) {
const asset = assets[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') { if (asset.mime === 'application/javascript') {
const script = document.createElement('script'); const script = document.createElement('script');
@ -143,6 +145,22 @@
}, 100); }, 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) => { ipc.setHtml = (event) => {
const html = event.html; const html = event.html;
@ -177,6 +195,15 @@
} }
document.dispatchEvent(new Event('joplin-noteDidUpdate')); document.dispatchEvent(new Event('joplin-noteDidUpdate'));
if (checkAllImageLoadedIID_) clearInterval(checkAllImageLoadedIID_);
checkAllImageLoadedIID_ = setInterval(() => {
if (!allImagesLoaded()) return;
clearInterval(checkAllImageLoadedIID_);
ipcProxySendToHost('noteRenderComplete');
}, 100);
} }
let lastScrollEventTime = 0; let lastScrollEventTime = 0;

View File

@ -91,8 +91,14 @@ class NoteListUtils {
click: async () => { click: async () => {
for (let i = 0; i < noteIds.length; i++) { for (let i = 0; i < noteIds.length; i++) {
const note = await Note.load(noteIds[i]); const note = await Note.load(noteIds[i]);
await Note.save(Note.toggleIsTodo(note), { userSideValidation: true }); const newNote = await Note.save(Note.toggleIsTodo(note), { userSideValidation: true });
eventManager.emit('noteTypeToggle', { noteId: note.id }); 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 });
} }
}, },
}) })

View File

@ -0,0 +1,15 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
const joplinRendererUtils = require('lib/joplin-renderer').utils;
const Resource = require('lib/models/Resource');
function resourcesStatus(resourceInfos) {
let lowestIndex = joplinRendererUtils.resourceStatusIndex('ready');
for (const id in resourceInfos) {
const s = joplinRendererUtils.resourceStatus(Resource, resourceInfos[id]);
const idx = joplinRendererUtils.resourceStatusIndex(s);
if (idx < lowestIndex) { lowestIndex = idx; }
}
return joplinRendererUtils.resourceStatusName(lowestIndex);
}
exports.resourcesStatus = resourcesStatus;
// # sourceMappingURL=NoteText.js.map

View File

@ -1,32 +0,0 @@
const joplinRendererUtils = require('lib/joplin-renderer').utils;
const Resource = require('lib/models/Resource');
export interface DefaultEditorState {
value: string,
markupLanguage: number, // MarkupToHtml.MARKUP_LANGUAGE_XXX
resourceInfos: any,
}
export interface OnChangeEvent {
changeId: number,
content: any,
}
export interface TextEditorUtils {
editorContentToHtml(content:any):Promise<string>,
}
export interface EditorCommand {
name: string,
value: any,
}
export function resourcesStatus(resourceInfos:any) {
let lowestIndex = joplinRendererUtils.resourceStatusIndex('ready');
for (const id in resourceInfos) {
const s = joplinRendererUtils.resourceStatus(Resource, resourceInfos[id]);
const idx = joplinRendererUtils.resourceStatusIndex(s);
if (idx < lowestIndex) lowestIndex = idx;
}
return joplinRendererUtils.resourceStatusName(lowestIndex);
}

View File

@ -2771,6 +2771,15 @@
"object-visit": "^1.0.0" "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": { "color-convert": {
"version": "1.9.3", "version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "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", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" "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": { "color-support": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "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", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
"integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=" "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": { "slash": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz",

View File

@ -91,6 +91,7 @@
"base64-stream": "^1.0.0", "base64-stream": "^1.0.0",
"chokidar": "^3.0.0", "chokidar": "^3.0.0",
"clean-html": "^1.5.0", "clean-html": "^1.5.0",
"color": "^3.1.2",
"compare-versions": "^3.2.1", "compare-versions": "^3.2.1",
"countable": "^3.0.1", "countable": "^3.0.1",
"diacritics": "^1.3.0", "diacritics": "^1.3.0",

View File

@ -1,4 +1,5 @@
const Setting = require('lib/models/Setting.js'); const Setting = require('lib/models/Setting.js');
const Color = require('color');
const themes = { const themes = {
[Setting.THEME_LIGHT]: require('./gui/style/theme/light'), [Setting.THEME_LIGHT]: require('./gui/style/theme/light'),
@ -29,7 +30,6 @@ const globalStyle = {
headerButtonHPadding: 6, headerButtonHPadding: 6,
toolbarHeight: 35, toolbarHeight: 35,
tagItemPadding: 3,
}; };
globalStyle.marginRight = globalStyle.margin; globalStyle.marginRight = globalStyle.margin;
@ -83,18 +83,21 @@ globalStyle.buttonStyle = {
}; };
function addExtraStyles(style) { function addExtraStyles(style) {
style.selectedDividerColor = Color(style.dividerColor).darken(0.2).hex();
style.tagStyle = { style.tagStyle = {
fontSize: style.fontSize, fontSize: style.fontSize,
fontFamily: style.fontFamily, fontFamily: style.fontFamily,
marginTop: style.itemMarginTop * 0.4, paddingTop: 3,
marginBottom: style.itemMarginBottom * 0.4, paddingBottom: 3,
marginRight: style.margin * 0.3, paddingRight: 8,
paddingTop: style.tagItemPadding, paddingLeft: 8,
paddingBottom: style.tagItemPadding,
paddingRight: style.tagItemPadding * 2,
paddingLeft: style.tagItemPadding * 2,
backgroundColor: style.raisedBackgroundColor, backgroundColor: style.raisedBackgroundColor,
color: style.raisedColor, color: style.raisedColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginRight: 5,
}; };
style.toolbarStyle = { style.toolbarStyle = {

View File

@ -13,7 +13,7 @@ gulp.task('icon-packager', function() {
}); });
gulp.task('deploy', 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(); return Promise.resolve();
}); });

View File

@ -125,7 +125,7 @@ module.exports = function(grunt) {
// { src: ['changelog.txt'], dest: 'dist', expand: true }, // { src: ['changelog.txt'], dest: 'dist', expand: true },
{ {
src: ['dist/joplinLists.js'], src: ['dist/joplinLists.js'],
dest: '../../../ElectronClient/gui/editors/TinyMCE/plugins/lists.js', dest: '../../../ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/plugins/lists.js',
}, },
], ],
}, },

View File

@ -64,7 +64,7 @@ shared.saveNoteButton_press = async function(comp, folderId = null, options = nu
const hasAutoTitle = comp.state.newAndNoTitleChangeNoteId || (isProvisionalNote && !note.title); const hasAutoTitle = comp.state.newAndNoTitleChangeNoteId || (isProvisionalNote && !note.title);
if (hasAutoTitle && options.autoTitle) { 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'); if (saveOptions.fields && saveOptions.fields.indexOf('title') < 0) saveOptions.fields.push('title');
} }

View File

@ -0,0 +1,27 @@
import usePrevious from './usePrevious';
import { useImperativeHandle } from 'react';
export default function useImperativeHandleDebugger(ref:any, effectHook:any, dependencies:any, dependencyNames:any[] = []) {
const previousDeps = usePrevious(dependencies, []);
const changedDeps = dependencies.reduce((accum:any, dependency:any, index:any) => {
if (dependency !== previousDeps[index]) {
const keyName = dependencyNames[index] || index;
return {
...accum,
[keyName]: {
before: previousDeps[index],
after: dependency,
},
};
}
return accum;
}, {});
if (Object.keys(changedDeps).length) {
console.log('[use-imperativeHandler-debugger] ', changedDeps);
}
useImperativeHandle(ref, effectHook, dependencies);
}

View File

@ -0,0 +1,9 @@
import { useEffect, useRef } from 'react';
export default function usePrevious(value: any, initialValue:any = null): any {
const ref = useRef(initialValue);
useEffect(() => {
ref.current = value;
});
return ref.current;
}

View File

@ -29,10 +29,10 @@ class HtmlToHtml {
splitHtml(html) { splitHtml(html) {
const trimmedHtml = html.trimStart(); const trimmedHtml = html.trimStart();
if (trimmedHtml.indexOf('<style>') !== 0) return { html: html, cssStrings: [], originalCssHtml: '' }; if (trimmedHtml.indexOf('<style>') !== 0) return { html: html, css: '' };
const closingIndex = trimmedHtml.indexOf('</style>'); const closingIndex = trimmedHtml.indexOf('</style>');
if (closingIndex < 0) return { html: html, cssStrings: [], originalCssHtml: '' }; if (closingIndex < 0) return { html: html, css: '' };
return { return {
html: trimmedHtml.substr(closingIndex + 8), html: trimmedHtml.substr(closingIndex + 8),

View File

@ -77,8 +77,8 @@ class Note extends BaseItem {
return super.serialize(n, fieldNames); return super.serialize(n, fieldNames);
} }
static defaultTitle(note) { static defaultTitle(noteBody) {
return this.defaultTitleFromBody(note.body); return this.defaultTitleFromBody(noteBody);
} }
static defaultTitleFromBody(body) { static defaultTitleFromBody(body) {

View File

@ -43,20 +43,11 @@ class Setting extends BaseModel {
type: Setting.TYPE_STRING, type: Setting.TYPE_STRING,
public: false, public: false,
}, },
'editor.keyboardMode': { 'editor.codeView': {
value: 'default', value: false,
type: Setting.TYPE_STRING, type: Setting.TYPE_BOOL,
public: true, public: false,
appTypes: ['desktop'], appTypes: ['desktop'],
isEnum: true,
label: () => _('Keyboard Mode'),
options: () => {
const output = {};
output['default'] = _('Default');
output['emacs'] = _('Emacs');
output['vim'] = _('Vim');
return output;
},
}, },
'sync.target': { 'sync.target': {
value: SyncTargetRegistry.nameToId('dropbox'), value: SyncTargetRegistry.nameToId('dropbox'),
@ -261,7 +252,7 @@ class Setting extends BaseModel {
return output; 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: { layoutButtonSequence: {
value: Setting.LAYOUT_ALL, value: Setting.LAYOUT_ALL,
type: Setting.TYPE_INT, type: Setting.TYPE_INT,
@ -273,7 +264,6 @@ class Setting extends BaseModel {
[Setting.LAYOUT_EDITOR_VIEWER]: _('%s / %s', _('Editor'), _('Viewer')), [Setting.LAYOUT_EDITOR_VIEWER]: _('%s / %s', _('Editor'), _('Viewer')),
[Setting.LAYOUT_EDITOR_SPLIT]: _('%s / %s', _('Editor'), _('Split View')), [Setting.LAYOUT_EDITOR_SPLIT]: _('%s / %s', _('Editor'), _('Split View')),
[Setting.LAYOUT_VIEWER_SPLIT]: _('%s / %s', _('Viewer'), _('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') }, 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'] }, tagHeaderIsExpanded: { value: true, type: Setting.TYPE_BOOL, public: false, appTypes: ['desktop'] },
folderHeaderIsExpanded: { 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.') }, 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 { return {
'A4': _('A4'), 'A4': _('A4'),
'Letter': _('Letter'), 'Letter': _('Letter'),
@ -538,13 +528,29 @@ class Setting extends BaseModel {
'Legal': _('Legal'), '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 { return {
'portrait': _('Portrait'), 'portrait': _('Portrait'),
'landscape': _('Landscape'), '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': { 'net.customCertificates': {
value: '', value: '',
@ -771,6 +777,10 @@ class Setting extends BaseModel {
return this.setValue(key, this.value(key) + inc); return this.setValue(key, this.value(key) + inc);
} }
static toggle(key) {
return this.setValue(key, !this.value(key));
}
static setObjectKey(settingKey, objectKey, value) { static setObjectKey(settingKey, objectKey, value) {
let o = this.value(settingKey); let o = this.value(settingKey);
if (typeof o !== 'object') o = {}; if (typeof o !== 'object') o = {};
@ -1080,7 +1090,6 @@ Setting.LAYOUT_ALL = 0;
Setting.LAYOUT_EDITOR_VIEWER = 1; Setting.LAYOUT_EDITOR_VIEWER = 1;
Setting.LAYOUT_EDITOR_SPLIT = 2; Setting.LAYOUT_EDITOR_SPLIT = 2;
Setting.LAYOUT_VIEWER_SPLIT = 3; Setting.LAYOUT_VIEWER_SPLIT = 3;
Setting.LAYOUT_SPLIT_WYSIWYG = 4;
Setting.DATE_FORMAT_1 = 'DD/MM/YYYY'; Setting.DATE_FORMAT_1 = 'DD/MM/YYYY';
Setting.DATE_FORMAT_2 = 'DD/MM/YY'; Setting.DATE_FORMAT_2 = 'DD/MM/YY';

View File

@ -107,7 +107,7 @@ class ExternalEditWatcher {
updatedNote.id = id; updatedNote.id = id;
updatedNote.parent_id = note.parent_id; updatedNote.parent_id = note.parent_id;
await Note.save(updatedNote); await Note.save(updatedNote);
this.eventEmitter_.emit('noteChange', { id: updatedNote.id }); this.eventEmitter_.emit('noteChange', { id: updatedNote.id, note: updatedNote });
} }
this.skipNextChangeEvent_ = {}; this.skipNextChangeEvent_ = {};

View File

@ -205,7 +205,7 @@ function shimInit() {
return Resource.save(resource, { isNew: true }); 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({}, { options = Object.assign({}, {
createFileURL: false, createFileURL: false,
}, options); }, options);
@ -223,10 +223,10 @@ function shimInit() {
const newBody = []; const newBody = [];
if (position === null) { 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) { if (!options.createFileURL) {
newBody.push(Resource.markdownTag(resource)); newBody.push(Resource.markdownTag(resource));
@ -236,10 +236,17 @@ function shimInit() {
newBody.push(fileURL); 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, { const newNote = Object.assign({}, note, {
body: newBody.join('\n\n'), body: newBody,
}); });
return await Note.save(newNote); return await Note.save(newNote);
}; };

View File

@ -4,59 +4,82 @@
{ {
"file_exclude_patterns": "file_exclude_patterns":
[ [
"*.base64",
"*.bundle.js",
"*.eps",
"*.icns",
"*.jar", "*.jar",
"*.map", "*.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.css",
"*.min.js", "*.min.js",
"*.bundle.js", "*.po",
"yarn.lock", "*.pot",
"*.icns", "_mydocs/EnexSamples/*.enex",
"*.base64", "CliClient/app/lib",
"Podfile.lock", "CliClient/app/src",
"ReactNativeClient/PluginAssetsLoader.js", "docs/*.html",
"ElectronClient/gui/NoteText2.js", "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/ResourceScreen.js",
"ElectronClient/gui/ShareNoteDialog.js", "ElectronClient/gui/ShareNoteDialog.js",
"ElectronClient/gui/TinyMCE.js", "ElectronClient/gui/TinyMCE.js",
"ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js", "ElectronClient/gui/utils/NoteText.js",
"ReactNativeClient/setUpQuickActions.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/android/app/joplin.keystore",
"ReactNativeClient/lib/AsyncActionHandler.js", "ReactNativeClient/lib/AsyncActionHandler.js",
"*.eps", "ReactNativeClient/lib/AsyncActionQueue.js",
"ElectronClient/gui/editors/TinyMCE.js", "ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/checkbox.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/joplin-renderer/MdToHtml/rules/fence.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": "folder_exclude_patterns":
[ [
"_mydocs/mdtest", "_mydocs/mdtest",
"_releases",
"_vieux", "_vieux",
"ElectronClient/fonts",
"CliClient/app/lib", "CliClient/app/lib",
"CliClient/app/src", "CliClient/app/src",
"CliClient/build", "CliClient/build",
"CliClient/locales-build",
"CliClient/node_modules", "CliClient/node_modules",
"CliClient/tests-build", "CliClient/tests-build",
"CliClient/tests-build/lib", "CliClient/tests-build/lib",
@ -64,40 +87,40 @@
"CliClient/tests/fuzzing", "CliClient/tests/fuzzing",
"CliClient/tests/src", "CliClient/tests/src",
"CliClient/tests/sync", "CliClient/tests/sync",
"ElectronClient/dist", "CliClient/tests/tmp",
"Clipper/dist",
"Clipper/popup/build",
"ElectronClient/build", "ElectronClient/build",
"ElectronClient/dist",
"ElectronClient/dist",
"ElectronClient/dist",
"ElectronClient/fonts",
"ElectronClient/gui/note-viewer/highlight/styles",
"ElectronClient/lib", "ElectronClient/lib",
"ElectronClient/locale", "ElectronClient/locale",
"ElectronClient/dist", "ElectronClient/pluginAssets",
"Modules/TinyMCE/JoplinLists/dist",
"Modules/TinyMCE/JoplinLists/lib",
"Modules/TinyMCE/JoplinLists/scratch",
"node_modules", "node_modules",
"ReactNativeClient/android/.gradle", "ReactNativeClient/android/.gradle",
"ReactNativeClient/android/.idea", "ReactNativeClient/android/.idea",
"ReactNativeClient/android/app/build", "ReactNativeClient/android/app/build",
"ReactNativeClient/android/build", "ReactNativeClient/android/build",
"ReactNativeClient/android/local.properties", "ReactNativeClient/android/local.properties",
"ReactNativeClient/node_modules",
"ReactNativeClient/pluginAssets",
"ElectronClient/gui/note-viewer/highlight/styles",
"tests/logs",
"ReactNativeClient/ios/build", "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-tvOS",
"ReactNativeClient/ios/Joplin.xcodeproj/project.xcworkspace", "ReactNativeClient/ios/Joplin.xcodeproj/project.xcworkspace",
"ReactNativeClient/ios/Joplin.xcworkspace/xcuserdata",
"ReactNativeClient/ios/Joplin.xcodeproj/xcuserdata", "ReactNativeClient/ios/Joplin.xcodeproj/xcuserdata",
"ElectronClient/pluginAssets", "ReactNativeClient/ios/Joplin.xcworkspace/xcuserdata",
"Modules/TinyMCE/JoplinLists/dist", "ReactNativeClient/ios/Pods",
"Modules/TinyMCE/JoplinLists/lib", "ReactNativeClient/lib/csstojs",
"Modules/TinyMCE/JoplinLists/scratch", "ReactNativeClient/lib/rnInjectedJs",
"CliClient/tests/tmp" "ReactNativeClient/lib/vendor",
"ReactNativeClient/node_modules",
"ReactNativeClient/pluginAssets",
"tests/logs",
"ElectronClient/gui/note-viewer/pluginAssets"
], ],
"path": "." "path": "."
} }