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: 9be85c45f2b16ebbbf7a
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: 445acdab73150ee14de6
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: f58f1a06e07ceb68d835
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:
parent
b16ebbbf7a
commit
cb8dca747b
@ -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
|
||||||
|
@ -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
25
.gitignore
vendored
@ -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
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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.
|
||||||
|
@ -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,
|
||||||
];
|
];
|
||||||
|
@ -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();
|
||||||
|
@ -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,
|
||||||
|
@ -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),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
|
686
ElectronClient/gui/NoteEditor/NoteBody/AceEditor/AceEditor.tsx
Normal file
686
ElectronClient/gui/NoteEditor/NoteBody/AceEditor/AceEditor.tsx
Normal file
@ -0,0 +1,686 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo } from 'react';
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
import { EditorCommand, NoteBodyEditorProps } from '../../utils/types';
|
||||||
|
import { commandAttachFileToBody } from '../../utils/resourceHandling';
|
||||||
|
import { ScrollOptions, ScrollOptionTypes } from '../../utils/types';
|
||||||
|
import { textOffsetToCursorPosition, useScrollHandler, usePrevious, lineLeftSpaces, selectionRangeCurrentLine, selectionRangePreviousLine, currentTextOffset, textOffsetSelection, selectedText, useSelectionRange } from './utils';
|
||||||
|
import Toolbar from './Toolbar';
|
||||||
|
import styles_ from './styles';
|
||||||
|
import { RenderedBody, defaultRenderedBody } from './utils/types';
|
||||||
|
|
||||||
|
const AceEditorReact = require('react-ace').default;
|
||||||
|
const { bridge } = require('electron').remote.require('./bridge');
|
||||||
|
const Note = require('lib/models/Note.js');
|
||||||
|
const { clipboard } = require('electron');
|
||||||
|
const mimeUtils = require('lib/mime-utils.js').mime;
|
||||||
|
const Setting = require('lib/models/Setting.js');
|
||||||
|
const NoteTextViewer = require('../../../NoteTextViewer.min');
|
||||||
|
const shared = require('lib/components/shared/note-screen-shared.js');
|
||||||
|
const md5 = require('md5');
|
||||||
|
const { shim } = require('lib/shim.js');
|
||||||
|
const Menu = bridge().Menu;
|
||||||
|
const MenuItem = bridge().MenuItem;
|
||||||
|
const markdownUtils = require('lib/markdownUtils');
|
||||||
|
const { _ } = require('lib/locale');
|
||||||
|
const { reg } = require('lib/registry.js');
|
||||||
|
const dialogs = require('../../../dialogs');
|
||||||
|
|
||||||
|
require('brace/mode/markdown');
|
||||||
|
// https://ace.c9.io/build/kitchen-sink.html
|
||||||
|
// https://highlightjs.org/static/demo/
|
||||||
|
require('brace/theme/chrome');
|
||||||
|
require('brace/theme/solarized_light');
|
||||||
|
require('brace/theme/solarized_dark');
|
||||||
|
require('brace/theme/twilight');
|
||||||
|
require('brace/theme/dracula');
|
||||||
|
require('brace/theme/chaos');
|
||||||
|
require('brace/keybinding/vim');
|
||||||
|
require('brace/keybinding/emacs');
|
||||||
|
|
||||||
|
// TODO: Could not get below code to work
|
||||||
|
|
||||||
|
// @ts-ignore Ace global variable
|
||||||
|
// const aceGlobal = (ace as any);
|
||||||
|
|
||||||
|
// class CustomHighlightRules extends aceGlobal.acequire(
|
||||||
|
// 'ace/mode/markdown_highlight_rules'
|
||||||
|
// ).MarkdownHighlightRules {
|
||||||
|
// constructor() {
|
||||||
|
// super();
|
||||||
|
// if (Setting.value('markdown.plugin.mark')) {
|
||||||
|
// this.$rules.start.push({
|
||||||
|
// // This is actually a highlight `mark`, but Ace has no token name for
|
||||||
|
// // this so we made up our own. Reference for common tokens here:
|
||||||
|
// // https://github.com/ajaxorg/ace/wiki/Creating-or-Extending-an-Edit-Mode#common-tokens
|
||||||
|
// token: 'highlight_mark',
|
||||||
|
// regex: '==[^ ](?:.*?[^ ])?==',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /* eslint-disable-next-line no-undef */
|
||||||
|
// class CustomMdMode extends aceGlobal.acequire('ace/mode/markdown').Mode {
|
||||||
|
// constructor() {
|
||||||
|
// super();
|
||||||
|
// this.HighlightRules = CustomHighlightRules;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
function markupRenderOptions(override: any = null) {
|
||||||
|
return { ...override };
|
||||||
|
}
|
||||||
|
|
||||||
|
function AceEditor(props: NoteBodyEditorProps, ref: any) {
|
||||||
|
const styles = styles_(props);
|
||||||
|
|
||||||
|
const [renderedBody, setRenderedBody] = useState<RenderedBody>(defaultRenderedBody()); // Viewer content
|
||||||
|
const [editor, setEditor] = useState(null);
|
||||||
|
const [lastKeys, setLastKeys] = useState([]);
|
||||||
|
const [webviewReady, setWebviewReady] = useState(false);
|
||||||
|
|
||||||
|
const previousRenderedBody = usePrevious(renderedBody);
|
||||||
|
const previousSearchMarkers = usePrevious(props.searchMarkers);
|
||||||
|
const previousContentKey = usePrevious(props.contentKey);
|
||||||
|
|
||||||
|
const editorRef = useRef(null);
|
||||||
|
editorRef.current = editor;
|
||||||
|
const indentOrig = useRef<any>(null);
|
||||||
|
const webviewRef = useRef(null);
|
||||||
|
const props_onChangeRef = useRef<Function>(null);
|
||||||
|
props_onChangeRef.current = props.onChange;
|
||||||
|
const contentKeyHasChangedRef = useRef(false);
|
||||||
|
contentKeyHasChangedRef.current = previousContentKey !== props.contentKey;
|
||||||
|
|
||||||
|
// The selection range changes all the time, when the caret moves or
|
||||||
|
// when the selection changes, so it's best not to make it part of the
|
||||||
|
// state as it would trigger too many unecessary updates.
|
||||||
|
const selectionRangeRef = useRef(null);
|
||||||
|
selectionRangeRef.current = useSelectionRange(editor);
|
||||||
|
|
||||||
|
const { resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll } = useScrollHandler(editor, webviewRef, props.onScroll);
|
||||||
|
|
||||||
|
const aceEditor_change = useCallback((newBody: string) => {
|
||||||
|
props_onChangeRef.current({ changeId: null, content: newBody });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const wrapSelectionWithStrings = useCallback((string1: string, string2 = '', defaultText = '', replacementText: string = null, byLine = false) => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
const selection = textOffsetSelection(selectionRangeRef.current, props.content);
|
||||||
|
|
||||||
|
let newBody = props.content;
|
||||||
|
|
||||||
|
if (selection && selection.start !== selection.end) {
|
||||||
|
const selectedLines = replacementText !== null ? replacementText : props.content.substr(selection.start, selection.end - selection.start);
|
||||||
|
const selectedStrings = byLine ? selectedLines.split(/\r?\n/) : [selectedLines];
|
||||||
|
|
||||||
|
newBody = props.content.substr(0, selection.start);
|
||||||
|
|
||||||
|
let startCursorPos, endCursorPos;
|
||||||
|
|
||||||
|
for (let i = 0; i < selectedStrings.length; i++) {
|
||||||
|
if (byLine == false) {
|
||||||
|
const start = selectedStrings[i].search(/[^\s]/);
|
||||||
|
const end = selectedStrings[i].search(/[^\s](?=[\s]*$)/);
|
||||||
|
newBody += selectedStrings[i].substr(0, start) + string1 + selectedStrings[i].substr(start, end - start + 1) + string2 + selectedStrings[i].substr(end + 1);
|
||||||
|
// Getting position for correcting offset in highlighted text when surrounded by white spaces
|
||||||
|
startCursorPos = textOffsetToCursorPosition(selection.start + start, newBody);
|
||||||
|
endCursorPos = textOffsetToCursorPosition(selection.start + end + 1, newBody);
|
||||||
|
|
||||||
|
} else { newBody += string1 + selectedStrings[i] + string2; }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
newBody += props.content.substr(selection.end);
|
||||||
|
|
||||||
|
const r = selectionRangeRef.current;
|
||||||
|
|
||||||
|
// Because some insertion strings will have newlines, we'll need to account for them
|
||||||
|
const str1Split = string1.split(/\r?\n/);
|
||||||
|
|
||||||
|
// Add the number of newlines to the row
|
||||||
|
// and add the length of the final line to the column (for strings with no newlines this is the string length)
|
||||||
|
|
||||||
|
let newRange: any = {};
|
||||||
|
if (!byLine) {
|
||||||
|
// Correcting offset in Highlighted text when surrounded by white spaces
|
||||||
|
newRange = {
|
||||||
|
start: {
|
||||||
|
row: startCursorPos.row,
|
||||||
|
column: startCursorPos.column + string1.length,
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
row: endCursorPos.row,
|
||||||
|
column: endCursorPos.column + string1.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
newRange = {
|
||||||
|
start: {
|
||||||
|
row: r.start.row + str1Split.length - 1,
|
||||||
|
column: r.start.column + str1Split[str1Split.length - 1].length,
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
row: r.end.row + str1Split.length - 1,
|
||||||
|
column: r.end.column + str1Split[str1Split.length - 1].length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replacementText !== null) {
|
||||||
|
const diff = replacementText.length - (selection.end - selection.start);
|
||||||
|
newRange.end.column += diff;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const range = selectionRangeRef.current;
|
||||||
|
range.setStart(newRange.start.row, newRange.start.column);
|
||||||
|
range.setEnd(newRange.end.row, newRange.end.column);
|
||||||
|
editor.getSession().getSelection().setSelectionRange(range, false);
|
||||||
|
editor.focus();
|
||||||
|
}, 10);
|
||||||
|
} else {
|
||||||
|
const middleText = replacementText !== null ? replacementText : defaultText;
|
||||||
|
const textOffset = currentTextOffset(editor, props.content);
|
||||||
|
const s1 = props.content.substr(0, textOffset);
|
||||||
|
const s2 = props.content.substr(textOffset);
|
||||||
|
newBody = s1 + string1 + middleText + string2 + s2;
|
||||||
|
|
||||||
|
const p = textOffsetToCursorPosition(textOffset + string1.length, newBody);
|
||||||
|
const newRange = {
|
||||||
|
start: { row: p.row, column: p.column },
|
||||||
|
end: { row: p.row, column: p.column + middleText.length },
|
||||||
|
};
|
||||||
|
|
||||||
|
// BUG!! If replacementText contains newline characters, the logic
|
||||||
|
// to select the new text will not work.
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (middleText && newRange) {
|
||||||
|
const range = selectionRangeRef.current;
|
||||||
|
range.setStart(newRange.start.row, newRange.start.column);
|
||||||
|
range.setEnd(newRange.end.row, newRange.end.column);
|
||||||
|
editor.getSession().getSelection().setSelectionRange(range, false);
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < string1.length; i++) {
|
||||||
|
editor.getSession().getSelection().moveCursorRight();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editor.focus();
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
aceEditor_change(newBody);
|
||||||
|
}, [editor, props.content, aceEditor_change]);
|
||||||
|
|
||||||
|
const addListItem = useCallback((string1, string2 = '', defaultText = '', byLine = false) => {
|
||||||
|
let newLine = '\n';
|
||||||
|
const range = selectionRangeRef.current;
|
||||||
|
if (!range || (range.start.row === range.end.row && !selectionRangeCurrentLine(range, props.content))) {
|
||||||
|
newLine = '';
|
||||||
|
}
|
||||||
|
wrapSelectionWithStrings(newLine + string1, string2, defaultText, null, byLine);
|
||||||
|
}, [wrapSelectionWithStrings, props.content]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => {
|
||||||
|
return {
|
||||||
|
content: () => props.content,
|
||||||
|
setContent: (body: string) => {
|
||||||
|
aceEditor_change(body);
|
||||||
|
},
|
||||||
|
resetScroll: () => {
|
||||||
|
resetScroll();
|
||||||
|
},
|
||||||
|
scrollTo: (options:ScrollOptions) => {
|
||||||
|
if (options.type === ScrollOptionTypes.Hash) {
|
||||||
|
if (!webviewRef.current) return;
|
||||||
|
webviewRef.current.wrappedInstance.send('scrollToHash', options.value as string);
|
||||||
|
} else if (options.type === ScrollOptionTypes.Percent) {
|
||||||
|
const p = options.value as number;
|
||||||
|
setEditorPercentScroll(p);
|
||||||
|
setViewerPercentScroll(p);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported scroll options: ${options.type}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clearState: () => {
|
||||||
|
if (!editor) return;
|
||||||
|
editor.clearSelection();
|
||||||
|
editor.moveCursorTo(0, 0);
|
||||||
|
},
|
||||||
|
execCommand: async (cmd: EditorCommand) => {
|
||||||
|
if (!editor) return false;
|
||||||
|
|
||||||
|
reg.logger().debug('AceEditor: execCommand', cmd);
|
||||||
|
|
||||||
|
let commandProcessed = true;
|
||||||
|
|
||||||
|
if (cmd.name === 'dropItems') {
|
||||||
|
if (cmd.value.type === 'notes') {
|
||||||
|
wrapSelectionWithStrings('', '', '', cmd.value.markdownTags.join('\n'));
|
||||||
|
} else if (cmd.value.type === 'files') {
|
||||||
|
const newBody = await commandAttachFileToBody(props.content, cmd.value.paths, { createFileURL: !!cmd.value.createFileURL });
|
||||||
|
aceEditor_change(newBody);
|
||||||
|
} else {
|
||||||
|
reg.logger().warn('AceEditor: unsupported drop item: ', cmd);
|
||||||
|
}
|
||||||
|
} else if (cmd.name === 'focus') {
|
||||||
|
editor.focus();
|
||||||
|
} else {
|
||||||
|
commandProcessed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!commandProcessed) {
|
||||||
|
const commands: any = {
|
||||||
|
textBold: () => wrapSelectionWithStrings('**', '**', _('strong text')),
|
||||||
|
textItalic: () => wrapSelectionWithStrings('*', '*', _('emphasized text')),
|
||||||
|
textLink: async () => {
|
||||||
|
const url = await dialogs.prompt(_('Insert Hyperlink'));
|
||||||
|
if (url) wrapSelectionWithStrings('[', `](${url})`);
|
||||||
|
},
|
||||||
|
textCode: () => {
|
||||||
|
const selection = textOffsetSelection(selectionRangeRef.current, props.content);
|
||||||
|
const string = props.content.substr(selection.start, selection.end - selection.start);
|
||||||
|
|
||||||
|
// Look for newlines
|
||||||
|
const match = string.match(/\r?\n/);
|
||||||
|
|
||||||
|
if (match && match.length > 0) {
|
||||||
|
if (string.startsWith('```') && string.endsWith('```')) {
|
||||||
|
wrapSelectionWithStrings('', '', '', string.substr(4, selection.end - selection.start - 8));
|
||||||
|
} else {
|
||||||
|
wrapSelectionWithStrings(`\`\`\`${match[0]}`, `${match[0]}\`\`\``);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wrapSelectionWithStrings('`', '`', '');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
insertText: (value: any) => wrapSelectionWithStrings(value),
|
||||||
|
attachFile: async () => {
|
||||||
|
const selection = textOffsetSelection(selectionRangeRef.current, props.content);
|
||||||
|
const newBody = await commandAttachFileToBody(props.content, null, { position: selection ? selection.start : 0 });
|
||||||
|
if (newBody) aceEditor_change(newBody);
|
||||||
|
},
|
||||||
|
textNumberedList: () => {
|
||||||
|
let bulletNumber = markdownUtils.olLineNumber(selectionRangeCurrentLine(selectionRangeRef.current, props.content));
|
||||||
|
if (!bulletNumber) bulletNumber = markdownUtils.olLineNumber(selectionRangePreviousLine(selectionRangeRef.current, props.content));
|
||||||
|
if (!bulletNumber) bulletNumber = 0;
|
||||||
|
addListItem(`${bulletNumber + 1}. `, '', _('List item'), true);
|
||||||
|
},
|
||||||
|
textBulletedList: () => addListItem('- ', '', _('List item'), true),
|
||||||
|
textCheckbox: () => addListItem('- [ ] ', '', _('List item'), true),
|
||||||
|
textHeading: () => addListItem('## ','','', true),
|
||||||
|
textHorizontalRule: () => addListItem('* * *'),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (commands[cmd.name]) {
|
||||||
|
commands[cmd.name](cmd.value);
|
||||||
|
} else {
|
||||||
|
reg.logger().warn('AceEditor: unsupported Joplin command: ', cmd);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [editor, props.content, addListItem, wrapSelectionWithStrings, selectionRangeCurrentLine, aceEditor_change, setEditorPercentScroll, setViewerPercentScroll, resetScroll, renderedBody]);
|
||||||
|
|
||||||
|
const onEditorPaste = useCallback(async (event: any = null) => {
|
||||||
|
const formats = clipboard.availableFormats();
|
||||||
|
for (let i = 0; i < formats.length; i++) {
|
||||||
|
const format = formats[i].toLowerCase();
|
||||||
|
const formatType = format.split('/')[0];
|
||||||
|
|
||||||
|
const position = currentTextOffset(editor, props.content);
|
||||||
|
|
||||||
|
if (formatType === 'image') {
|
||||||
|
if (event) event.preventDefault();
|
||||||
|
|
||||||
|
const image = clipboard.readImage();
|
||||||
|
|
||||||
|
const fileExt = mimeUtils.toFileExtension(format);
|
||||||
|
const filePath = `${Setting.value('tempDir')}/${md5(Date.now())}.${fileExt}`;
|
||||||
|
|
||||||
|
await shim.writeImageToFile(image, format, filePath);
|
||||||
|
const newBody = await commandAttachFileToBody(props.content, [filePath], { position });
|
||||||
|
await shim.fsDriver().remove(filePath);
|
||||||
|
|
||||||
|
aceEditor_change(newBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [editor, props.content, aceEditor_change]);
|
||||||
|
|
||||||
|
const onEditorKeyDown = useCallback((event: any) => {
|
||||||
|
setLastKeys(prevLastKeys => {
|
||||||
|
const keys = prevLastKeys.slice();
|
||||||
|
keys.push(event.key);
|
||||||
|
while (keys.length > 2) keys.splice(0, 1);
|
||||||
|
return keys;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const editorCutText = useCallback(() => {
|
||||||
|
const text = selectedText(selectionRangeRef.current, props.content);
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
clipboard.writeText(text);
|
||||||
|
|
||||||
|
const s = textOffsetSelection(selectionRangeRef.current, props.content);
|
||||||
|
if (!s || s.start === s.end) return;
|
||||||
|
|
||||||
|
const s1 = props.content.substr(0, s.start);
|
||||||
|
const s2 = props.content.substr(s.end);
|
||||||
|
|
||||||
|
aceEditor_change(s1 + s2);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const range = selectionRangeRef.current;
|
||||||
|
range.setStart(range.start.row, range.start.column);
|
||||||
|
range.setEnd(range.start.row, range.start.column);
|
||||||
|
editor.getSession().getSelection().setSelectionRange(range, false);
|
||||||
|
editor.focus();
|
||||||
|
}, 10);
|
||||||
|
}, [props.content, editor, aceEditor_change]);
|
||||||
|
|
||||||
|
const editorCopyText = useCallback(() => {
|
||||||
|
const text = selectedText(selectionRangeRef.current, props.content);
|
||||||
|
clipboard.writeText(text);
|
||||||
|
}, [props.content]);
|
||||||
|
|
||||||
|
const editorPasteText = useCallback(() => {
|
||||||
|
wrapSelectionWithStrings(clipboard.readText(), '', '', '');
|
||||||
|
}, [wrapSelectionWithStrings]);
|
||||||
|
|
||||||
|
const onEditorContextMenu = useCallback(() => {
|
||||||
|
const menu = new Menu();
|
||||||
|
|
||||||
|
const hasSelectedText = !!selectedText(selectionRangeRef.current, props.content);
|
||||||
|
const clipboardText = clipboard.readText();
|
||||||
|
|
||||||
|
menu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: _('Cut'),
|
||||||
|
enabled: hasSelectedText,
|
||||||
|
click: async () => {
|
||||||
|
editorCutText();
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
menu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: _('Copy'),
|
||||||
|
enabled: hasSelectedText,
|
||||||
|
click: async () => {
|
||||||
|
editorCopyText();
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
menu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: _('Paste'),
|
||||||
|
enabled: true,
|
||||||
|
click: async () => {
|
||||||
|
if (clipboardText) {
|
||||||
|
editorPasteText();
|
||||||
|
} else {
|
||||||
|
// To handle pasting images
|
||||||
|
onEditorPaste();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
menu.popup(bridge().window());
|
||||||
|
}, [props.content, editorCutText, editorPasteText, editorCopyText, onEditorPaste]);
|
||||||
|
|
||||||
|
function aceEditor_load(editor: any) {
|
||||||
|
setEditor(editor);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return () => {};
|
||||||
|
|
||||||
|
editor.indent = indentOrig.current;
|
||||||
|
|
||||||
|
const cancelledKeys = [];
|
||||||
|
const letters = ['F', 'T', 'P', 'Q', 'L', ',', 'G', 'K'];
|
||||||
|
for (let i = 0; i < letters.length; i++) {
|
||||||
|
const l = letters[i];
|
||||||
|
cancelledKeys.push(`Ctrl+${l}`);
|
||||||
|
cancelledKeys.push(`Command+${l}`);
|
||||||
|
}
|
||||||
|
cancelledKeys.push('Alt+E');
|
||||||
|
|
||||||
|
for (let i = 0; i < cancelledKeys.length; i++) {
|
||||||
|
const k = cancelledKeys[i];
|
||||||
|
editor.commands.bindKey(k, () => {
|
||||||
|
// HACK: Ace doesn't seem to provide a way to override its shortcuts, but throwing
|
||||||
|
// an exception from this undocumented function seems to cancel it without any
|
||||||
|
// side effect.
|
||||||
|
// https://stackoverflow.com/questions/36075846
|
||||||
|
throw new Error(`HACK: Overriding Ace Editor shortcut: ${k}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector('#note-editor').addEventListener('paste', onEditorPaste, true);
|
||||||
|
document.querySelector('#note-editor').addEventListener('keydown', onEditorKeyDown);
|
||||||
|
document.querySelector('#note-editor').addEventListener('contextmenu', onEditorContextMenu);
|
||||||
|
|
||||||
|
// Disable Markdown auto-completion (eg. auto-adding a dash after a line with a dash.
|
||||||
|
// https://github.com/ajaxorg/ace/issues/2754
|
||||||
|
// @ts-ignore: Keep the function signature as-is despite unusued arguments
|
||||||
|
editor.getSession().getMode().getNextLineIndent = function(state: any, line: string) {
|
||||||
|
const ls = lastKeys;
|
||||||
|
if (ls.length >= 2 && ls[ls.length - 1] === 'Enter' && ls[ls.length - 2] === 'Enter') return this.$getIndent(line);
|
||||||
|
|
||||||
|
const leftSpaces = lineLeftSpaces(line);
|
||||||
|
const lineNoLeftSpaces = line.trimLeft();
|
||||||
|
|
||||||
|
if (lineNoLeftSpaces.indexOf('- [ ] ') === 0 || lineNoLeftSpaces.indexOf('- [x] ') === 0 || lineNoLeftSpaces.indexOf('- [X] ') === 0) return `${leftSpaces}- [ ] `;
|
||||||
|
if (lineNoLeftSpaces.indexOf('- ') === 0) return `${leftSpaces}- `;
|
||||||
|
if (lineNoLeftSpaces.indexOf('* ') === 0 && line.trim() !== '* * *') return `${leftSpaces}* `;
|
||||||
|
|
||||||
|
const bulletNumber = markdownUtils.olLineNumber(lineNoLeftSpaces);
|
||||||
|
if (bulletNumber) return `${leftSpaces + (bulletNumber + 1)}. `;
|
||||||
|
|
||||||
|
return this.$getIndent(line);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.querySelector('#note-editor').removeEventListener('paste', onEditorPaste, true);
|
||||||
|
document.querySelector('#note-editor').removeEventListener('keydown', onEditorKeyDown);
|
||||||
|
document.querySelector('#note-editor').removeEventListener('contextmenu', onEditorContextMenu);
|
||||||
|
};
|
||||||
|
}, [editor, onEditorPaste, onEditorContextMenu, lastKeys]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
// Markdown list indentation. (https://github.com/laurent22/joplin/pull/2713)
|
||||||
|
// If the current line starts with `markup.list` token,
|
||||||
|
// hitting `Tab` key indents the line instead of inserting tab at cursor.
|
||||||
|
indentOrig.current = editor.indent;
|
||||||
|
const localIndentOrig = indentOrig.current;
|
||||||
|
editor.indent = function() {
|
||||||
|
const range = selectionRangeRef.current;
|
||||||
|
if (range.isEmpty()) {
|
||||||
|
const row = range.start.row;
|
||||||
|
const tokens = this.session.getTokens(row);
|
||||||
|
|
||||||
|
if (tokens.length > 0 && tokens[0].type == 'markup.list') {
|
||||||
|
if (tokens[0].value.search(/\d+\./) != -1) {
|
||||||
|
// Resets numbered list to 1.
|
||||||
|
this.session.replace({ start: { row, column: 0 }, end: { row, column: tokens[0].value.length } },
|
||||||
|
tokens[0].value.replace(/\d+\./, '1.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.session.indentRows(row, row, '\t');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localIndentOrig.call(this);
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const webview_domReady = useCallback(() => {
|
||||||
|
setWebviewReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const webview_ipcMessage = useCallback((event: any) => {
|
||||||
|
const msg = event.channel ? event.channel : '';
|
||||||
|
const args = event.args;
|
||||||
|
const arg0 = args && args.length >= 1 ? args[0] : null;
|
||||||
|
|
||||||
|
if (msg.indexOf('checkboxclick:') === 0) {
|
||||||
|
const newBody = shared.toggleCheckbox(msg, props.content);
|
||||||
|
aceEditor_change(newBody);
|
||||||
|
} else if (msg === 'percentScroll') {
|
||||||
|
setEditorPercentScroll(arg0);
|
||||||
|
} else {
|
||||||
|
props.onMessage(event);
|
||||||
|
}
|
||||||
|
}, [props.onMessage, props.content, aceEditor_change]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const interval = contentKeyHasChangedRef.current ? 0 : 500;
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(async () => {
|
||||||
|
let bodyToRender = props.content;
|
||||||
|
|
||||||
|
if (!bodyToRender.trim() && props.visiblePanes.indexOf('viewer') >= 0 && props.visiblePanes.indexOf('editor') < 0) {
|
||||||
|
// Fixes https://github.com/laurent22/joplin/issues/217
|
||||||
|
bodyToRender = `<i>${_('This note has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout'))}</i>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await props.markupToHtml(props.contentMarkupLanguage, bodyToRender, markupRenderOptions({ resourceInfos: props.resourceInfos }));
|
||||||
|
if (cancelled) return;
|
||||||
|
setRenderedBody(result);
|
||||||
|
}, interval);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [props.content, props.contentMarkupLanguage, props.visiblePanes, props.resourceInfos]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!webviewReady) return;
|
||||||
|
|
||||||
|
const options: any = {
|
||||||
|
pluginAssets: renderedBody.pluginAssets,
|
||||||
|
downloadResources: Setting.value('sync.resourceDownloadMode'),
|
||||||
|
};
|
||||||
|
webviewRef.current.wrappedInstance.send('setHtml', renderedBody.html, options);
|
||||||
|
}, [renderedBody, webviewReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.searchMarkers !== previousSearchMarkers || renderedBody !== previousRenderedBody) {
|
||||||
|
webviewRef.current.wrappedInstance.send('setMarkers', props.searchMarkers.keywords, props.searchMarkers.options);
|
||||||
|
}
|
||||||
|
}, [props.searchMarkers, renderedBody]);
|
||||||
|
|
||||||
|
const cellEditorStyle = useMemo(() => {
|
||||||
|
const output = { ...styles.cellEditor };
|
||||||
|
if (!props.visiblePanes.includes('editor')) {
|
||||||
|
// Note: Ideally we'd set the display to "none" to take the editor out
|
||||||
|
// of the DOM but if we do that, certain things won't work, in particular
|
||||||
|
// things related to scroll, which are based on the editor.
|
||||||
|
output.width = 1;
|
||||||
|
output.maxWidth = 1;
|
||||||
|
output.position = 'absolute';
|
||||||
|
output.left = -100000;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}, [styles.cellEditor, props.visiblePanes]);
|
||||||
|
|
||||||
|
const cellViewerStyle = useMemo(() => {
|
||||||
|
const output = { ...styles.cellViewer };
|
||||||
|
if (!props.visiblePanes.includes('viewer')) {
|
||||||
|
// Note: setting webview.display to "none" is currently not supported due
|
||||||
|
// to this bug: https://github.com/electron/electron/issues/8277
|
||||||
|
// So instead setting the width 0.
|
||||||
|
output.width = 1;
|
||||||
|
output.maxWidth = 1;
|
||||||
|
} else if (!props.visiblePanes.includes('editor')) {
|
||||||
|
output.borderLeftStyle = 'none';
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}, [styles.cellViewer, props.visiblePanes]);
|
||||||
|
|
||||||
|
function renderEditor() {
|
||||||
|
return (
|
||||||
|
<div style={cellEditorStyle}>
|
||||||
|
<AceEditorReact
|
||||||
|
value={props.content}
|
||||||
|
mode={props.contentMarkupLanguage === Note.MARKUP_LANGUAGE_HTML ? 'text' : 'markdown'}
|
||||||
|
theme={styles.editor.editorTheme}
|
||||||
|
style={styles.editor}
|
||||||
|
fontSize={styles.editor.fontSize}
|
||||||
|
showGutter={false}
|
||||||
|
readOnly={props.visiblePanes.indexOf('editor') < 0}
|
||||||
|
name="note-editor"
|
||||||
|
wrapEnabled={true}
|
||||||
|
onScroll={editor_scroll}
|
||||||
|
onChange={aceEditor_change}
|
||||||
|
showPrintMargin={false}
|
||||||
|
onLoad={aceEditor_load}
|
||||||
|
// Enable/Disable the autoclosing braces
|
||||||
|
setOptions={
|
||||||
|
{
|
||||||
|
behavioursEnabled: Setting.value('editor.autoMatchingBraces'),
|
||||||
|
useSoftTabs: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Disable warning: "Automatically scrolling cursor into view after
|
||||||
|
// selection change this will be disabled in the next version set
|
||||||
|
// editor.$blockScrolling = Infinity to disable this message"
|
||||||
|
editorProps={{ $blockScrolling: Infinity }}
|
||||||
|
// This is buggy (gets outside the container)
|
||||||
|
highlightActiveLine={false}
|
||||||
|
keyboardHandler={props.keyboardMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderViewer() {
|
||||||
|
return (
|
||||||
|
<div style={cellViewerStyle}>
|
||||||
|
<NoteTextViewer
|
||||||
|
ref={webviewRef}
|
||||||
|
viewerStyle={styles.viewer}
|
||||||
|
onIpcMessage={webview_ipcMessage}
|
||||||
|
onDomReady={webview_domReady}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.root}>
|
||||||
|
<div style={styles.rowToolbar}>
|
||||||
|
<Toolbar
|
||||||
|
theme={props.theme}
|
||||||
|
dispatch={props.dispatch}
|
||||||
|
/>
|
||||||
|
{props.noteToolbar}
|
||||||
|
</div>
|
||||||
|
<div style={styles.rowEditorViewer}>
|
||||||
|
{renderEditor()}
|
||||||
|
{renderViewer()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default forwardRef(AceEditor);
|
||||||
|
|
166
ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.tsx
Normal file
166
ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
const ToolbarBase = require('../../../Toolbar.min.js');
|
||||||
|
const { _ } = require('lib/locale');
|
||||||
|
const { buildStyle } = require('../../../../theme.js');
|
||||||
|
|
||||||
|
interface ToolbarProps {
|
||||||
|
theme: number,
|
||||||
|
dispatch: Function,
|
||||||
|
}
|
||||||
|
|
||||||
|
function styles_(props:ToolbarProps) {
|
||||||
|
return buildStyle('AceEditorToolbar', props.theme, (/* theme:any*/) => {
|
||||||
|
return {
|
||||||
|
root: {
|
||||||
|
// marginTop: 4,
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Toolbar(props:ToolbarProps) {
|
||||||
|
const styles = styles_(props);
|
||||||
|
|
||||||
|
function createToolbarItems() {
|
||||||
|
const toolbarItems = [];
|
||||||
|
|
||||||
|
toolbarItems.push({
|
||||||
|
tooltip: _('Bold'),
|
||||||
|
iconName: 'fa-bold',
|
||||||
|
onClick: () => {
|
||||||
|
props.dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: 'textBold',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toolbarItems.push({
|
||||||
|
tooltip: _('Italic'),
|
||||||
|
iconName: 'fa-italic',
|
||||||
|
onClick: () => {
|
||||||
|
props.dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: 'textItalic',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toolbarItems.push({
|
||||||
|
type: 'separator',
|
||||||
|
});
|
||||||
|
|
||||||
|
toolbarItems.push({
|
||||||
|
tooltip: _('Hyperlink'),
|
||||||
|
iconName: 'fa-link',
|
||||||
|
onClick: () => {
|
||||||
|
props.dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: 'textLink',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toolbarItems.push({
|
||||||
|
tooltip: _('Code'),
|
||||||
|
iconName: 'fa-code',
|
||||||
|
onClick: () => {
|
||||||
|
props.dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: 'textCode',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toolbarItems.push({
|
||||||
|
tooltip: _('Attach file'),
|
||||||
|
iconName: 'fa-paperclip',
|
||||||
|
onClick: () => {
|
||||||
|
props.dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: 'attachFile',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toolbarItems.push({
|
||||||
|
type: 'separator',
|
||||||
|
});
|
||||||
|
|
||||||
|
toolbarItems.push({
|
||||||
|
tooltip: _('Numbered List'),
|
||||||
|
iconName: 'fa-list-ol',
|
||||||
|
onClick: () => {
|
||||||
|
props.dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: 'textNumberedList',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toolbarItems.push({
|
||||||
|
tooltip: _('Bulleted List'),
|
||||||
|
iconName: 'fa-list-ul',
|
||||||
|
onClick: () => {
|
||||||
|
props.dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: 'textBulletedList',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toolbarItems.push({
|
||||||
|
tooltip: _('Checkbox'),
|
||||||
|
iconName: 'fa-check-square',
|
||||||
|
onClick: () => {
|
||||||
|
props.dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: 'textCheckbox',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toolbarItems.push({
|
||||||
|
tooltip: _('Heading'),
|
||||||
|
iconName: 'fa-header',
|
||||||
|
onClick: () => {
|
||||||
|
props.dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: 'textHeading',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toolbarItems.push({
|
||||||
|
tooltip: _('Horizontal Rule'),
|
||||||
|
iconName: 'fa-ellipsis-h',
|
||||||
|
onClick: () => {
|
||||||
|
props.dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: 'textHorizontalRule',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toolbarItems.push({
|
||||||
|
tooltip: _('Insert Date Time'),
|
||||||
|
iconName: 'fa-calendar-plus-o',
|
||||||
|
onClick: () => {
|
||||||
|
props.dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: 'insertDateTime',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toolbarItems.push({
|
||||||
|
type: 'separator',
|
||||||
|
});
|
||||||
|
|
||||||
|
return toolbarItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ToolbarBase style={styles.root} items={createToolbarItems()} />;
|
||||||
|
}
|
@ -0,0 +1,60 @@
|
|||||||
|
import { NoteBodyEditorProps } from '../../../utils/types';
|
||||||
|
const { buildStyle } = require('../../../../../theme.js');
|
||||||
|
|
||||||
|
export default function styles(props: NoteBodyEditorProps) {
|
||||||
|
return buildStyle('AceEditor', props.theme, (theme: any) => {
|
||||||
|
return {
|
||||||
|
root: {
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
...props.style,
|
||||||
|
},
|
||||||
|
rowToolbar: {
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
},
|
||||||
|
rowEditorViewer: {
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
flex: 1,
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
cellEditor: {
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
cellViewer: {
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flex: 1,
|
||||||
|
borderLeftWidth: 1,
|
||||||
|
borderLeftColor: theme.dividerColor,
|
||||||
|
borderLeftStyle: 'solid',
|
||||||
|
},
|
||||||
|
viewer: {
|
||||||
|
display: 'flex',
|
||||||
|
overflow: 'hidden',
|
||||||
|
verticalAlign: 'top',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
display: 'flex',
|
||||||
|
width: 'auto',
|
||||||
|
height: 'auto',
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'hidden',
|
||||||
|
paddingTop: 0,
|
||||||
|
lineHeight: `${theme.textAreaLineHeight}px`,
|
||||||
|
fontSize: `${theme.editorFontSize}px`,
|
||||||
|
color: theme.color,
|
||||||
|
backgroundColor: theme.backgroundColor,
|
||||||
|
editorTheme: theme.editorTheme, // Defined in theme.js
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
241
ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/index.ts
Normal file
241
ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/index.ts
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
export function cursorPositionToTextOffset(cursorPos: any, body: string) {
|
||||||
|
if (!body) return 0;
|
||||||
|
|
||||||
|
const noteLines = body.split('\n');
|
||||||
|
|
||||||
|
let pos = 0;
|
||||||
|
for (let i = 0; i < noteLines.length; i++) {
|
||||||
|
if (i > 0) pos++; // Need to add the newline that's been removed in the split() call above
|
||||||
|
|
||||||
|
if (i === cursorPos.row) {
|
||||||
|
pos += cursorPos.column;
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
pos += noteLines[i].length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentTextOffset(editor: any, body: string) {
|
||||||
|
return cursorPositionToTextOffset(editor.getCursorPosition(), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rangeToTextOffsets(range: any, body: string) {
|
||||||
|
return {
|
||||||
|
start: cursorPositionToTextOffset(range.start, body),
|
||||||
|
end: cursorPositionToTextOffset(range.end, body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function textOffsetSelection(selectionRange: any, body: string) {
|
||||||
|
return selectionRange && body ? rangeToTextOffsets(selectionRange, body) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectedText(selectionRange: any, body: string) {
|
||||||
|
const selection = textOffsetSelection(selectionRange, body);
|
||||||
|
if (!selection || selection.start === selection.end) return '';
|
||||||
|
|
||||||
|
return body.substr(selection.start, selection.end - selection.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSelectionRange(editor: any) {
|
||||||
|
const [selectionRange, setSelectionRange] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return () => {};
|
||||||
|
|
||||||
|
function updateSelection() {
|
||||||
|
const ranges = editor.getSelection().getAllRanges();
|
||||||
|
const firstRange = ranges && ranges.length ? ranges[0] : null;
|
||||||
|
setSelectionRange(firstRange);
|
||||||
|
|
||||||
|
// if (process.platform === 'linux') {
|
||||||
|
// const textRange = this.textOffsetSelection();
|
||||||
|
// if (textRange.start != textRange.end) {
|
||||||
|
// clipboard.writeText(this.state.note.body.slice(
|
||||||
|
// Math.min(textRange.start, textRange.end),
|
||||||
|
// Math.max(textRange.end, textRange.start)), 'selection');
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectionChange() {
|
||||||
|
updateSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFocus() {
|
||||||
|
updateSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.getSession().selection.on('changeSelection', onSelectionChange);
|
||||||
|
editor.on('focus', onFocus);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
editor.getSession().selection.off('changeSelection', onSelectionChange);
|
||||||
|
editor.off('focus', onFocus);
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
return selectionRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function textOffsetToCursorPosition(offset: number, body: string) {
|
||||||
|
const lines = body.split('\n');
|
||||||
|
let row = 0;
|
||||||
|
let currentOffset = 0;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (currentOffset + line.length >= offset) {
|
||||||
|
return {
|
||||||
|
row: row,
|
||||||
|
column: offset - currentOffset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
row++;
|
||||||
|
currentOffset += line.length + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineAtRow(body: string, row: number) {
|
||||||
|
if (!body) return '';
|
||||||
|
const lines = body.split('\n');
|
||||||
|
if (row < 0 || row >= lines.length) return '';
|
||||||
|
return lines[row];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectionRangeCurrentLine(selectionRange: any, body: string) {
|
||||||
|
if (!selectionRange) return '';
|
||||||
|
return lineAtRow(body, selectionRange.start.row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectionRangePreviousLine(selectionRange: any, body: string) {
|
||||||
|
if (!selectionRange) return '';
|
||||||
|
return lineAtRow(body, selectionRange.start.row - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lineLeftSpaces(line: string) {
|
||||||
|
let output = '';
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
if ([' ', '\t'].indexOf(line[i]) >= 0) {
|
||||||
|
output += line[i];
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePrevious(value: any): any {
|
||||||
|
const ref = useRef();
|
||||||
|
useEffect(() => {
|
||||||
|
ref.current = value;
|
||||||
|
});
|
||||||
|
return ref.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScrollHandler(editor: any, webviewRef: any, onScroll: Function) {
|
||||||
|
const editorMaxScrollTop_ = useRef(0);
|
||||||
|
const restoreScrollTop_ = useRef<any>(null);
|
||||||
|
const ignoreNextEditorScrollEvent_ = useRef(false);
|
||||||
|
const scrollTimeoutId_ = useRef<any>(null);
|
||||||
|
|
||||||
|
// TODO: Below is not needed anymore????
|
||||||
|
//
|
||||||
|
// this.editorMaxScrollTop_ = 0;
|
||||||
|
// // HACK: To go around a bug in Ace editor, we first set the scroll position to 1
|
||||||
|
// // and then (in the renderer callback) to the value we actually need. The first
|
||||||
|
// // operation helps clear the scroll position cache. See:
|
||||||
|
// //
|
||||||
|
// this.editorSetScrollTop(1);
|
||||||
|
// this.restoreScrollTop_ = 0;
|
||||||
|
|
||||||
|
const editorSetScrollTop = useCallback((v) => {
|
||||||
|
if (!editor) return;
|
||||||
|
editor.getSession().setScrollTop(v);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
// Complicated but reliable method to get editor content height
|
||||||
|
// https://github.com/ajaxorg/ace/issues/2046
|
||||||
|
const onAfterEditorRender = useCallback(() => {
|
||||||
|
const r = editor.renderer;
|
||||||
|
editorMaxScrollTop_.current = Math.max(0, r.layerConfig.maxHeight - r.$size.scrollerHeight);
|
||||||
|
|
||||||
|
if (restoreScrollTop_.current !== null) {
|
||||||
|
editorSetScrollTop(restoreScrollTop_.current);
|
||||||
|
restoreScrollTop_.current = null;
|
||||||
|
}
|
||||||
|
}, [editor, editorSetScrollTop]);
|
||||||
|
|
||||||
|
const scheduleOnScroll = useCallback((event: any) => {
|
||||||
|
if (scrollTimeoutId_.current) {
|
||||||
|
clearTimeout(scrollTimeoutId_.current);
|
||||||
|
scrollTimeoutId_.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollTimeoutId_.current = setTimeout(() => {
|
||||||
|
scrollTimeoutId_.current = null;
|
||||||
|
onScroll(event);
|
||||||
|
}, 10);
|
||||||
|
}, [onScroll]);
|
||||||
|
|
||||||
|
const setEditorPercentScroll = useCallback((p: number) => {
|
||||||
|
ignoreNextEditorScrollEvent_.current = true;
|
||||||
|
editorSetScrollTop(p * editorMaxScrollTop_.current);
|
||||||
|
scheduleOnScroll({ percent: p });
|
||||||
|
}, [editorSetScrollTop, scheduleOnScroll]);
|
||||||
|
|
||||||
|
const setViewerPercentScroll = useCallback((p: number) => {
|
||||||
|
if (webviewRef.current) {
|
||||||
|
webviewRef.current.wrappedInstance.send('setPercentScroll', p);
|
||||||
|
scheduleOnScroll({ percent: p });
|
||||||
|
}
|
||||||
|
}, [scheduleOnScroll]);
|
||||||
|
|
||||||
|
const editor_scroll = useCallback(() => {
|
||||||
|
if (ignoreNextEditorScrollEvent_.current) {
|
||||||
|
ignoreNextEditorScrollEvent_.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = editorMaxScrollTop_.current;
|
||||||
|
const percent = m ? editor.getSession().getScrollTop() / m : 0;
|
||||||
|
|
||||||
|
setViewerPercentScroll(percent);
|
||||||
|
}, [editor, setViewerPercentScroll]);
|
||||||
|
|
||||||
|
const resetScroll = useCallback(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
// Ace Editor caches scroll values, which makes
|
||||||
|
// it hard to reset the scroll position, so we
|
||||||
|
// need to use this hack.
|
||||||
|
// https://github.com/ajaxorg/ace/issues/2195
|
||||||
|
editor.session.$scrollTop = -1;
|
||||||
|
editor.session.$scrollLeft = -1;
|
||||||
|
editor.renderer.scrollTop = -1;
|
||||||
|
editor.renderer.scrollLeft = -1;
|
||||||
|
editor.renderer.scrollBarV.scrollTop = -1;
|
||||||
|
editor.renderer.scrollBarH.scrollLeft = -1;
|
||||||
|
editor.session.setScrollTop(0);
|
||||||
|
editor.session.setScrollLeft(0);
|
||||||
|
}, [editorSetScrollTop, editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return () => {};
|
||||||
|
|
||||||
|
editor.renderer.on('afterRender', onAfterEditorRender);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
editor.renderer.off('afterRender', onAfterEditorRender);
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
return { resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll };
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
export interface RenderedBody {
|
||||||
|
html: string;
|
||||||
|
pluginAssets: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultRenderedBody(): RenderedBody {
|
||||||
|
return {
|
||||||
|
html: '',
|
||||||
|
pluginAssets: [],
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
'use strict';
|
||||||
|
Object.defineProperty(exports, '__esModule', { value: true });
|
||||||
|
const React = require('react');
|
||||||
|
const react_1 = require('react');
|
||||||
|
const PlainEditor = (props, ref) => {
|
||||||
|
const editorRef = react_1.useRef();
|
||||||
|
react_1.useImperativeHandle(ref, () => {
|
||||||
|
return {
|
||||||
|
content: () => '',
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
react_1.useEffect(() => {
|
||||||
|
if (!editorRef.current) { return; }
|
||||||
|
editorRef.current.value = props.defaultEditorState.value;
|
||||||
|
}, [props.defaultEditorState]);
|
||||||
|
const onChange = react_1.useCallback((event) => {
|
||||||
|
props.onChange({ changeId: null, content: event.target.value });
|
||||||
|
}, [props.onWillChange, props.onChange]);
|
||||||
|
return (React.createElement('div', { style: props.style },
|
||||||
|
React.createElement('textarea', { ref: editorRef, style: { width: '100%', height: '100%' }, defaultValue: props.defaultEditorState.value, onChange: onChange }),
|
||||||
|
';'));
|
||||||
|
};
|
||||||
|
exports.default = react_1.forwardRef(PlainEditor);
|
||||||
|
// # sourceMappingURL=PlainEditor.js.map
|
@ -1,9 +1,8 @@
|
|||||||
|
// Kept only for reference
|
||||||
|
|
||||||
import * as React from 'react';
|
import * 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>();
|
||||||
|
|
@ -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>
|
570
ElectronClient/gui/NoteEditor/NoteEditor.tsx
Normal file
570
ElectronClient/gui/NoteEditor/NoteEditor.tsx
Normal file
@ -0,0 +1,570 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
import TinyMCE from './NoteBody/TinyMCE/TinyMCE';
|
||||||
|
import AceEditor from './NoteBody/AceEditor/AceEditor';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import MultiNoteActions from '../MultiNoteActions';
|
||||||
|
import NoteToolbar from '../NoteToolbar/NoteToolbar';
|
||||||
|
import { htmlToMarkdown, formNoteToNote } from './utils';
|
||||||
|
import useSearchMarkers from './utils/useSearchMarkers';
|
||||||
|
import useNoteSearchBar from './utils/useNoteSearchBar';
|
||||||
|
import useMessageHandler from './utils/useMessageHandler';
|
||||||
|
import useWindowCommandHandler from './utils/useWindowCommandHandler';
|
||||||
|
import useDropHandler from './utils/useDropHandler';
|
||||||
|
import useMarkupToHtml from './utils/useMarkupToHtml';
|
||||||
|
import useFormNote, { OnLoadEvent } from './utils/useFormNote';
|
||||||
|
import styles_ from './styles';
|
||||||
|
import { NoteTextProps, FormNote, ScrollOptions, ScrollOptionTypes, OnChangeEvent, NoteBodyEditorProps } from './utils/types';
|
||||||
|
import { attachResources } from './utils/resourceHandling';
|
||||||
|
|
||||||
|
const { themeStyle } = require('../../theme.js');
|
||||||
|
const NoteSearchBar = require('../NoteSearchBar.min.js');
|
||||||
|
const { reg } = require('lib/registry.js');
|
||||||
|
const { time } = require('lib/time-utils.js');
|
||||||
|
const markupLanguageUtils = require('lib/markupLanguageUtils');
|
||||||
|
const usePrevious = require('lib/hooks/usePrevious').default;
|
||||||
|
const Setting = require('lib/models/Setting');
|
||||||
|
const { _ } = require('lib/locale');
|
||||||
|
const Note = require('lib/models/Note.js');
|
||||||
|
const { bridge } = require('electron').remote.require('./bridge');
|
||||||
|
const ExternalEditWatcher = require('lib/services/ExternalEditWatcher');
|
||||||
|
const eventManager = require('../../eventManager');
|
||||||
|
const NoteRevisionViewer = require('../NoteRevisionViewer.min');
|
||||||
|
const TagList = require('../TagList.min.js');
|
||||||
|
|
||||||
|
function NoteEditor(props: NoteTextProps) {
|
||||||
|
const [showRevisions, setShowRevisions] = useState(false);
|
||||||
|
const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false);
|
||||||
|
const [scrollWhenReady, setScrollWhenReady] = useState<ScrollOptions>(null);
|
||||||
|
|
||||||
|
const editorRef = useRef<any>();
|
||||||
|
const titleInputRef = useRef<any>();
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
const noteSearchBarRef = useRef(null);
|
||||||
|
|
||||||
|
const formNote_beforeLoad = useCallback(async (event:OnLoadEvent) => {
|
||||||
|
await saveNoteIfWillChange(event.formNote);
|
||||||
|
setShowRevisions(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formNote_afterLoad = useCallback(async () => {
|
||||||
|
setTitleHasBeenManuallyChanged(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({
|
||||||
|
syncStarted: props.syncStarted,
|
||||||
|
noteId: props.noteId,
|
||||||
|
isProvisional: props.isProvisional,
|
||||||
|
titleInputRef: titleInputRef,
|
||||||
|
editorRef: editorRef,
|
||||||
|
onBeforeLoad: formNote_beforeLoad,
|
||||||
|
onAfterLoad: formNote_afterLoad,
|
||||||
|
});
|
||||||
|
|
||||||
|
const formNoteRef = useRef<FormNote>();
|
||||||
|
formNoteRef.current = { ...formNote };
|
||||||
|
|
||||||
|
const {
|
||||||
|
localSearch,
|
||||||
|
onChange: localSearch_change,
|
||||||
|
onNext: localSearch_next,
|
||||||
|
onPrevious: localSearch_previous,
|
||||||
|
onClose: localSearch_close,
|
||||||
|
setResultCount: setLocalSearchResultCount,
|
||||||
|
showLocalSearch,
|
||||||
|
setShowLocalSearch,
|
||||||
|
searchMarkers: localSearchMarkerOptions,
|
||||||
|
} = useNoteSearchBar();
|
||||||
|
|
||||||
|
// If the note has been modified in another editor, wait for it to be saved
|
||||||
|
// before loading it in this editor.
|
||||||
|
// const waitingToSaveNote = props.noteId && formNote.id !== props.noteId && props.editorNoteStatuses[props.noteId] === 'saving';
|
||||||
|
|
||||||
|
const styles = styles_(props);
|
||||||
|
|
||||||
|
function scheduleSaveNote(formNote: FormNote) {
|
||||||
|
if (!formNote.saveActionQueue) throw new Error('saveActionQueue is not set!!'); // Sanity check
|
||||||
|
|
||||||
|
reg.logger().debug('Scheduling...', formNote);
|
||||||
|
|
||||||
|
const makeAction = (formNote: FormNote) => {
|
||||||
|
return async function() {
|
||||||
|
const note = await formNoteToNote(formNote);
|
||||||
|
reg.logger().debug('Saving note...', note);
|
||||||
|
const savedNote:any = await Note.save(note);
|
||||||
|
|
||||||
|
setFormNote((prev: FormNote) => {
|
||||||
|
return { ...prev, user_updated_time: savedNote.user_updated_time };
|
||||||
|
});
|
||||||
|
|
||||||
|
ExternalEditWatcher.instance().updateNoteFile(savedNote);
|
||||||
|
|
||||||
|
props.dispatch({
|
||||||
|
type: 'EDITOR_NOTE_STATUS_REMOVE',
|
||||||
|
id: formNote.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
formNote.saveActionQueue.push(makeAction(formNote));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNoteIfWillChange(formNote: FormNote) {
|
||||||
|
if (!formNote.id || !formNote.bodyWillChangeId) return;
|
||||||
|
|
||||||
|
const body = await editorRef.current.content();
|
||||||
|
|
||||||
|
scheduleSaveNote({
|
||||||
|
...formNote,
|
||||||
|
body: body,
|
||||||
|
bodyWillChangeId: 0,
|
||||||
|
bodyChangeId: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNoteAndWait(formNote: FormNote) {
|
||||||
|
saveNoteIfWillChange(formNote);
|
||||||
|
return formNote.saveActionQueue.waitForAllDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
const markupToHtml = useMarkupToHtml({ themeId: props.theme, customCss: props.customCss });
|
||||||
|
|
||||||
|
const allAssets = useCallback(async (markupLanguage: number): Promise<any[]> => {
|
||||||
|
const theme = themeStyle(props.theme);
|
||||||
|
|
||||||
|
const markupToHtml = markupLanguageUtils.newMarkupToHtml({
|
||||||
|
resourceBaseUrl: `file://${Setting.value('resourceDir')}/`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return markupToHtml.allAssets(markupLanguage, theme);
|
||||||
|
}, [props.theme]);
|
||||||
|
|
||||||
|
const handleProvisionalFlag = useCallback(() => {
|
||||||
|
if (props.isProvisional) {
|
||||||
|
props.dispatch({
|
||||||
|
type: 'NOTE_PROVISIONAL_FLAG_CLEAR',
|
||||||
|
id: formNote.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [props.isProvisional, formNote.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// This is not exactly a hack but a bit ugly. If the note was changed (willChangeId > 0) but not
|
||||||
|
// yet saved, we need to save it now before the component is unmounted. However, we can't put
|
||||||
|
// formNote in the dependency array or that effect will run every time the note changes. We only
|
||||||
|
// want to run it once on unmount. So because of that we need to use that formNoteRef.
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
saveNoteIfWillChange(formNoteRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const previousNoteId = usePrevious(formNote.id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (formNote.id === previousNoteId) return;
|
||||||
|
|
||||||
|
if (editorRef.current) {
|
||||||
|
editorRef.current.resetScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
setScrollWhenReady({
|
||||||
|
type: props.selectedNoteHash ? ScrollOptionTypes.Hash : ScrollOptionTypes.Percent,
|
||||||
|
value: props.selectedNoteHash ? props.selectedNoteHash : props.lastEditorScrollPercents[props.noteId] || 0,
|
||||||
|
});
|
||||||
|
}, [formNote.id, previousNoteId]);
|
||||||
|
|
||||||
|
const onFieldChange = useCallback((field: string, value: any, changeId = 0) => {
|
||||||
|
if (!isMountedRef.current) {
|
||||||
|
// When the component is unmounted, various actions can happen which can
|
||||||
|
// trigger onChange events, for example the textarea might be cleared.
|
||||||
|
// We need to ignore these events, otherwise the note is going to be saved
|
||||||
|
// with an invalid body.
|
||||||
|
reg.logger().debug('Skipping change event because the component is unmounted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleProvisionalFlag();
|
||||||
|
|
||||||
|
const change = field === 'body' ? {
|
||||||
|
body: value,
|
||||||
|
} : {
|
||||||
|
title: value,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newNote = {
|
||||||
|
...formNote,
|
||||||
|
...change,
|
||||||
|
bodyWillChangeId: 0,
|
||||||
|
bodyChangeId: 0,
|
||||||
|
hasChanged: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (field === 'title') {
|
||||||
|
setTitleHasBeenManuallyChanged(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNewNote && !titleHasBeenManuallyChanged && field === 'body') {
|
||||||
|
// TODO: Handle HTML/Markdown format
|
||||||
|
newNote.title = Note.defaultTitle(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changeId !== null && field === 'body' && formNote.bodyWillChangeId !== changeId) {
|
||||||
|
// Note was changed, but another note was loaded before save - skipping
|
||||||
|
// The previously loaded note, that was modified, will be saved via saveNoteIfWillChange()
|
||||||
|
} else {
|
||||||
|
setFormNote(newNote);
|
||||||
|
scheduleSaveNote(newNote);
|
||||||
|
}
|
||||||
|
}, [handleProvisionalFlag, formNote, isNewNote, titleHasBeenManuallyChanged]);
|
||||||
|
|
||||||
|
useWindowCommandHandler({ windowCommand: props.windowCommand, dispatch: props.dispatch, formNote, setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, saveNoteAndWait });
|
||||||
|
|
||||||
|
const onDrop = useDropHandler({ editorRef });
|
||||||
|
|
||||||
|
const onBodyChange = useCallback((event: OnChangeEvent) => onFieldChange('body', event.content, event.changeId), [onFieldChange]);
|
||||||
|
|
||||||
|
const onTitleChange = useCallback((event: any) => onFieldChange('title', event.target.value), [onFieldChange]);
|
||||||
|
|
||||||
|
const onTitleKeydown = useCallback((event:any) => {
|
||||||
|
const keyCode = event.keyCode;
|
||||||
|
|
||||||
|
if (keyCode === 9) {
|
||||||
|
// TAB
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (event.shiftKey) {
|
||||||
|
props.dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: 'focusElement',
|
||||||
|
target: 'noteList',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
props.dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: 'focusElement',
|
||||||
|
target: 'noteBody',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [props.dispatch]);
|
||||||
|
|
||||||
|
const onBodyWillChange = useCallback((event: any) => {
|
||||||
|
handleProvisionalFlag();
|
||||||
|
|
||||||
|
setFormNote(prev => {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
bodyWillChangeId: event.changeId,
|
||||||
|
hasChanged: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
props.dispatch({
|
||||||
|
type: 'EDITOR_NOTE_STATUS_SET',
|
||||||
|
id: formNote.id,
|
||||||
|
status: 'saving',
|
||||||
|
});
|
||||||
|
}, [formNote, handleProvisionalFlag]);
|
||||||
|
|
||||||
|
const onMessage = useMessageHandler(scrollWhenReady, setScrollWhenReady, editorRef, setLocalSearchResultCount, props.dispatch);
|
||||||
|
|
||||||
|
const introductionPostLinkClick = useCallback(() => {
|
||||||
|
bridge().openExternal('https://www.patreon.com/posts/34246624');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const externalEditWatcher_noteChange = useCallback((event) => {
|
||||||
|
if (event.id === formNote.id) {
|
||||||
|
const newFormNote = {
|
||||||
|
...formNote,
|
||||||
|
title: event.note.title,
|
||||||
|
body: event.note.body,
|
||||||
|
};
|
||||||
|
|
||||||
|
setFormNote(newFormNote);
|
||||||
|
}
|
||||||
|
}, [formNote]);
|
||||||
|
|
||||||
|
const onNotePropertyChange = useCallback((event) => {
|
||||||
|
setFormNote(formNote => {
|
||||||
|
if (formNote.id !== event.note.id) return formNote;
|
||||||
|
|
||||||
|
const newFormNote: FormNote = { ...formNote };
|
||||||
|
|
||||||
|
for (const key in event.note) {
|
||||||
|
if (key === 'id') continue;
|
||||||
|
(newFormNote as any)[key] = event.note[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return newFormNote;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
eventManager.on('alarmChange', onNotePropertyChange);
|
||||||
|
ExternalEditWatcher.instance().on('noteChange', externalEditWatcher_noteChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventManager.off('alarmChange', onNotePropertyChange);
|
||||||
|
ExternalEditWatcher.instance().off('noteChange', externalEditWatcher_noteChange);
|
||||||
|
};
|
||||||
|
}, [externalEditWatcher_noteChange, onNotePropertyChange]);
|
||||||
|
|
||||||
|
const noteToolbar_buttonClick = useCallback((event: any) => {
|
||||||
|
const cases: any = {
|
||||||
|
|
||||||
|
'startExternalEditing': async () => {
|
||||||
|
props.dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: 'commandStartExternalEditing',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
'stopExternalEditing': () => {
|
||||||
|
props.dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: 'commandStopExternalEditing',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
'setTags': async () => {
|
||||||
|
await saveNoteAndWait(formNote);
|
||||||
|
|
||||||
|
props.dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: 'setTags',
|
||||||
|
noteIds: [formNote.id],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
'setAlarm': async () => {
|
||||||
|
await saveNoteAndWait(formNote);
|
||||||
|
|
||||||
|
props.dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: 'editAlarm',
|
||||||
|
noteId: formNote.id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
'showRevisions': () => {
|
||||||
|
setShowRevisions(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cases[event.name]) throw new Error(`Unsupported event: ${event.name}`);
|
||||||
|
|
||||||
|
cases[event.name]();
|
||||||
|
}, [formNote]);
|
||||||
|
|
||||||
|
const onScroll = useCallback((event: any) => {
|
||||||
|
props.dispatch({
|
||||||
|
type: 'EDITOR_SCROLL_PERCENT_SET',
|
||||||
|
noteId: formNote.id,
|
||||||
|
percent: event.percent,
|
||||||
|
});
|
||||||
|
}, [props.dispatch, formNote]);
|
||||||
|
|
||||||
|
function renderNoNotes(rootStyle:any) {
|
||||||
|
const emptyDivStyle = Object.assign(
|
||||||
|
{
|
||||||
|
backgroundColor: 'black',
|
||||||
|
opacity: 0.1,
|
||||||
|
},
|
||||||
|
rootStyle
|
||||||
|
);
|
||||||
|
return <div style={emptyDivStyle}></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNoteToolbar() {
|
||||||
|
const toolbarStyle = {
|
||||||
|
// marginTop: 4,
|
||||||
|
marginBottom: 0,
|
||||||
|
flex: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <NoteToolbar
|
||||||
|
theme={props.theme}
|
||||||
|
note={formNote}
|
||||||
|
dispatch={props.dispatch}
|
||||||
|
style={toolbarStyle}
|
||||||
|
watchedNoteFiles={props.watchedNoteFiles}
|
||||||
|
onButtonClick={noteToolbar_buttonClick}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId);
|
||||||
|
|
||||||
|
const editorProps:NoteBodyEditorProps = {
|
||||||
|
ref: editorRef,
|
||||||
|
contentKey: formNote.id,
|
||||||
|
style: styles.tinyMCE,
|
||||||
|
onChange: onBodyChange,
|
||||||
|
onWillChange: onBodyWillChange,
|
||||||
|
onMessage: onMessage,
|
||||||
|
content: formNote.body,
|
||||||
|
contentMarkupLanguage: formNote.markup_language,
|
||||||
|
contentOriginalCss: formNote.originalCss,
|
||||||
|
resourceInfos: resourceInfos,
|
||||||
|
htmlToMarkdown: htmlToMarkdown,
|
||||||
|
markupToHtml: markupToHtml,
|
||||||
|
allAssets: allAssets,
|
||||||
|
attachResources: attachResources,
|
||||||
|
disabled: false,
|
||||||
|
theme: props.theme,
|
||||||
|
dispatch: props.dispatch,
|
||||||
|
noteToolbar: renderNoteToolbar(),
|
||||||
|
onScroll: onScroll,
|
||||||
|
searchMarkers: searchMarkers,
|
||||||
|
visiblePanes: props.noteVisiblePanes || ['editor', 'viewer'],
|
||||||
|
keyboardMode: Setting.value('editor.keyboardMode'),
|
||||||
|
};
|
||||||
|
|
||||||
|
let editor = null;
|
||||||
|
|
||||||
|
if (props.bodyEditor === 'TinyMCE') {
|
||||||
|
editor = <TinyMCE {...editorProps}/>;
|
||||||
|
} else if (props.bodyEditor === 'AceEditor') {
|
||||||
|
editor = <AceEditor {...editorProps}/>;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Invalid editor: ${props.bodyEditor}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wysiwygBanner = props.bodyEditor !== 'TinyMCE' ? null : (
|
||||||
|
<div style={{ ...styles.warningBanner, marginBottom: 10 }}>
|
||||||
|
This is an experimental WYSIWYG editor for evaluation only. Please do not use with important notes as you may lose some data! See the <a style={styles.urlColor} onClick={introductionPostLinkClick} href="#">introduction post</a> for more information.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const noteRevisionViewer_onBack = useCallback(() => {
|
||||||
|
setShowRevisions(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tagStyle = {
|
||||||
|
marginBottom: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tagList = props.selectedNoteTags.length ? <TagList style={tagStyle} items={props.selectedNoteTags} /> : null;
|
||||||
|
|
||||||
|
if (showRevisions) {
|
||||||
|
const theme = themeStyle(props.theme);
|
||||||
|
|
||||||
|
const revStyle = {
|
||||||
|
...props.style,
|
||||||
|
display: 'inline-flex',
|
||||||
|
padding: theme.margin,
|
||||||
|
verticalAlign: 'top',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={revStyle}>
|
||||||
|
<NoteRevisionViewer customCss={props.customCss} noteId={formNote.id} onBack={noteRevisionViewer_onBack} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.selectedNoteIds.length > 1) {
|
||||||
|
return <MultiNoteActions
|
||||||
|
theme={props.theme}
|
||||||
|
selectedNoteIds={props.selectedNoteIds}
|
||||||
|
notes={props.notes}
|
||||||
|
dispatch={props.dispatch}
|
||||||
|
watchedNoteFiles={props.watchedNoteFiles}
|
||||||
|
style={props.style}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleBarDate = <span style={styles.titleDate}>{time.formatMsToLocal(formNote.user_updated_time)}</span>;
|
||||||
|
|
||||||
|
function renderSearchBar() {
|
||||||
|
if (!showLocalSearch) return false;
|
||||||
|
|
||||||
|
const theme = themeStyle(props.theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NoteSearchBar
|
||||||
|
ref={noteSearchBarRef}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
height: 35,
|
||||||
|
borderTop: `1px solid ${theme.dividerColor}`,
|
||||||
|
}}
|
||||||
|
query={localSearch.query}
|
||||||
|
searching={localSearch.searching}
|
||||||
|
resultCount={localSearch.resultCount}
|
||||||
|
selectedIndex={localSearch.selectedIndex}
|
||||||
|
onChange={localSearch_change}
|
||||||
|
onNext={localSearch_next}
|
||||||
|
onPrevious={localSearch_previous}
|
||||||
|
onClose={localSearch_close}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formNote.encryption_applied || !formNote.id) {
|
||||||
|
return renderNoNotes(styles.root);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.root} onDrop={onDrop}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{wysiwygBanner}
|
||||||
|
{tagList}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
ref={titleInputRef}
|
||||||
|
// disabled={waitingToSaveNote}
|
||||||
|
placeholder={props.isProvisional ? _('Creating new %s...', formNote.is_todo ? _('to-do') : _('note')) : ''}
|
||||||
|
style={styles.titleInput}
|
||||||
|
onChange={onTitleChange}
|
||||||
|
onKeyDown={onTitleKeydown}
|
||||||
|
value={formNote.title}
|
||||||
|
/>
|
||||||
|
{titleBarDate}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flex: 1 }}>
|
||||||
|
{editor}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
{renderSearchBar()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
NoteEditor as NoteEditorComponent,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStateToProps = (state: any) => {
|
||||||
|
const noteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
noteId: noteId,
|
||||||
|
notes: state.notes,
|
||||||
|
folders: state.folders,
|
||||||
|
selectedNoteIds: state.selectedNoteIds,
|
||||||
|
isProvisional: state.provisionalNoteIds.includes(noteId),
|
||||||
|
editorNoteStatuses: state.editorNoteStatuses,
|
||||||
|
syncStarted: state.syncStarted,
|
||||||
|
theme: state.settings.theme,
|
||||||
|
watchedNoteFiles: state.watchedNoteFiles,
|
||||||
|
windowCommand: state.windowCommand,
|
||||||
|
notesParentType: state.notesParentType,
|
||||||
|
historyNotes: state.historyNotes,
|
||||||
|
selectedNoteTags: state.selectedNoteTags,
|
||||||
|
lastEditorScrollPercents: state.lastEditorScrollPercents,
|
||||||
|
selectedNoteHash: state.selectedNoteHash,
|
||||||
|
searches: state.searches,
|
||||||
|
selectedSearchId: state.selectedSearchId,
|
||||||
|
customCss: state.customCss,
|
||||||
|
noteVisiblePanes: state.noteVisiblePanes,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(NoteEditor);
|
53
ElectronClient/gui/NoteEditor/styles/index.ts
Normal file
53
ElectronClient/gui/NoteEditor/styles/index.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { NoteTextProps } from '../utils/types';
|
||||||
|
|
||||||
|
const { buildStyle } = require('../../../theme.js');
|
||||||
|
|
||||||
|
export default function styles(props: NoteTextProps) {
|
||||||
|
return buildStyle('NoteEditor', props.theme, (theme: any) => {
|
||||||
|
return {
|
||||||
|
root: {
|
||||||
|
...props.style,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
paddingLeft: 10,
|
||||||
|
paddingTop: 10,
|
||||||
|
borderLeftWidth: 1,
|
||||||
|
borderLeftColor: theme.dividerColor,
|
||||||
|
borderLeftStyle: 'solid',
|
||||||
|
},
|
||||||
|
titleInput: {
|
||||||
|
flex: 1,
|
||||||
|
display: 'inline-block',
|
||||||
|
paddingTop: 5,
|
||||||
|
paddingBottom: 5,
|
||||||
|
paddingLeft: 8,
|
||||||
|
paddingRight: 8,
|
||||||
|
marginRight: theme.paddingLeft,
|
||||||
|
color: theme.textStyle.color,
|
||||||
|
fontSize: theme.textStyle.fontSize * 1.25 * 1.5,
|
||||||
|
backgroundColor: theme.backgroundColor,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: theme.dividerColor,
|
||||||
|
},
|
||||||
|
warningBanner: {
|
||||||
|
background: theme.warningBackgroundColor,
|
||||||
|
fontFamily: theme.fontFamily,
|
||||||
|
padding: 10,
|
||||||
|
fontSize: theme.fontSize,
|
||||||
|
},
|
||||||
|
tinyMCE: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
toolbar: {
|
||||||
|
marginTop: 4,
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
titleDate: {
|
||||||
|
...theme.textStyle,
|
||||||
|
color: theme.colorFaded,
|
||||||
|
paddingLeft: 10,
|
||||||
|
paddingRight: 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
28
ElectronClient/gui/NoteEditor/utils/index.ts
Normal file
28
ElectronClient/gui/NoteEditor/utils/index.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { FormNote } from './types';
|
||||||
|
|
||||||
|
const HtmlToMd = require('lib/HtmlToMd');
|
||||||
|
const Note = require('lib/models/Note');
|
||||||
|
const { MarkupToHtml } = require('lib/joplin-renderer');
|
||||||
|
|
||||||
|
export async function htmlToMarkdown(markupLanguage: number, html: string, originalCss:string): Promise<string> {
|
||||||
|
let newBody = '';
|
||||||
|
|
||||||
|
if (markupLanguage === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN) {
|
||||||
|
const htmlToMd = new HtmlToMd();
|
||||||
|
newBody = htmlToMd.parse(html, { preserveImageTagsWithSize: true });
|
||||||
|
newBody = await Note.replaceResourceExternalToInternalLinks(newBody, { useAbsolutePaths: true });
|
||||||
|
} else {
|
||||||
|
newBody = await Note.replaceResourceExternalToInternalLinks(html, { useAbsolutePaths: true });
|
||||||
|
if (originalCss) newBody = `<style>${originalCss}</style>\n${newBody}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function formNoteToNote(formNote: FormNote): Promise<any> {
|
||||||
|
return {
|
||||||
|
id: formNote.id,
|
||||||
|
title: formNote.title,
|
||||||
|
body: formNote.body,
|
||||||
|
};
|
||||||
|
}
|
122
ElectronClient/gui/NoteEditor/utils/resourceHandling.ts
Normal file
122
ElectronClient/gui/NoteEditor/utils/resourceHandling.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
const Setting = require('lib/models/Setting');
|
||||||
|
const Note = require('lib/models/Note.js');
|
||||||
|
const BaseModel = require('lib/BaseModel.js');
|
||||||
|
const Resource = require('lib/models/Resource.js');
|
||||||
|
const { shim } = require('lib/shim');
|
||||||
|
const { bridge } = require('electron').remote.require('./bridge');
|
||||||
|
const ResourceFetcher = require('lib/services/ResourceFetcher.js');
|
||||||
|
const { reg } = require('lib/registry.js');
|
||||||
|
const joplinRendererUtils = require('lib/joplin-renderer').utils;
|
||||||
|
|
||||||
|
export async function handleResourceDownloadMode(noteBody: string) {
|
||||||
|
if (noteBody && Setting.value('sync.resourceDownloadMode') === 'auto') {
|
||||||
|
const resourceIds = await Note.linkedResourceIds(noteBody);
|
||||||
|
await ResourceFetcher.instance().markForDownload(resourceIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resourceCache_: any = {};
|
||||||
|
|
||||||
|
export function clearResourceCache() {
|
||||||
|
resourceCache_ = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function attachedResources(noteBody: string): Promise<any> {
|
||||||
|
if (!noteBody) return {};
|
||||||
|
const resourceIds = await Note.linkedItemIdsByType(BaseModel.TYPE_RESOURCE, noteBody);
|
||||||
|
|
||||||
|
const output: any = {};
|
||||||
|
for (let i = 0; i < resourceIds.length; i++) {
|
||||||
|
const id = resourceIds[i];
|
||||||
|
|
||||||
|
if (resourceCache_[id]) {
|
||||||
|
output[id] = resourceCache_[id];
|
||||||
|
} else {
|
||||||
|
const resource = await Resource.load(id);
|
||||||
|
const localState = await Resource.localState(resource);
|
||||||
|
|
||||||
|
const o = {
|
||||||
|
item: resource,
|
||||||
|
localState: localState,
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line require-atomic-updates
|
||||||
|
resourceCache_[id] = o;
|
||||||
|
output[id] = o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function attachResources() {
|
||||||
|
const filePaths = bridge().showOpenDialog({
|
||||||
|
properties: ['openFile', 'createDirectory', 'multiSelections'],
|
||||||
|
});
|
||||||
|
if (!filePaths || !filePaths.length) return [];
|
||||||
|
|
||||||
|
const output = [];
|
||||||
|
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
try {
|
||||||
|
const resource = await shim.createResourceFromPath(filePath);
|
||||||
|
output.push({
|
||||||
|
item: resource,
|
||||||
|
markdownTag: Resource.markdownTag(resource),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
bridge().showErrorMessageBox(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function commandAttachFileToBody(body:string, filePaths:string[] = null, options:any = null) {
|
||||||
|
options = {
|
||||||
|
createFileURL: false,
|
||||||
|
position: 0,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!filePaths) {
|
||||||
|
filePaths = bridge().showOpenDialog({
|
||||||
|
properties: ['openFile', 'createDirectory', 'multiSelections'],
|
||||||
|
});
|
||||||
|
if (!filePaths || !filePaths.length) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < filePaths.length; i++) {
|
||||||
|
const filePath = filePaths[i];
|
||||||
|
try {
|
||||||
|
reg.logger().info(`Attaching ${filePath}`);
|
||||||
|
const newBody = await shim.attachFileToNoteBody(body, filePath, options.position, {
|
||||||
|
createFileURL: options.createFileURL,
|
||||||
|
resizeLargeImages: 'ask',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!newBody) {
|
||||||
|
reg.logger().info('File attachment was cancelled');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
body = newBody;
|
||||||
|
reg.logger().info('File was attached.');
|
||||||
|
} catch (error) {
|
||||||
|
reg.logger().error(error);
|
||||||
|
bridge().showErrorMessageBox(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resourcesStatus(resourceInfos: any) {
|
||||||
|
let lowestIndex = joplinRendererUtils.resourceStatusIndex('ready');
|
||||||
|
for (const id in resourceInfos) {
|
||||||
|
const s = joplinRendererUtils.resourceStatus(Resource, resourceInfos[id]);
|
||||||
|
const idx = joplinRendererUtils.resourceStatusIndex(s);
|
||||||
|
if (idx < lowestIndex) lowestIndex = idx;
|
||||||
|
}
|
||||||
|
return joplinRendererUtils.resourceStatusName(lowestIndex);
|
||||||
|
}
|
145
ElectronClient/gui/NoteEditor/utils/types.ts
Normal file
145
ElectronClient/gui/NoteEditor/utils/types.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
import AsyncActionQueue from '../../../lib/AsyncActionQueue';
|
||||||
|
|
||||||
|
export interface NoteTextProps {
|
||||||
|
style: any;
|
||||||
|
noteId: string;
|
||||||
|
theme: number;
|
||||||
|
dispatch: Function;
|
||||||
|
selectedNoteIds: string[];
|
||||||
|
notes: any[];
|
||||||
|
watchedNoteFiles: string[];
|
||||||
|
isProvisional: boolean;
|
||||||
|
editorNoteStatuses: any;
|
||||||
|
syncStarted: boolean;
|
||||||
|
bodyEditor: string;
|
||||||
|
windowCommand: any;
|
||||||
|
folders: any[];
|
||||||
|
notesParentType: string;
|
||||||
|
historyNotes: any[];
|
||||||
|
selectedNoteTags: any[];
|
||||||
|
lastEditorScrollPercents: any;
|
||||||
|
selectedNoteHash: string;
|
||||||
|
searches: any[],
|
||||||
|
selectedSearchId: string,
|
||||||
|
customCss: string,
|
||||||
|
noteVisiblePanes: string[],
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteBodyEditorProps {
|
||||||
|
style: any;
|
||||||
|
ref: any,
|
||||||
|
theme: number;
|
||||||
|
content: string,
|
||||||
|
contentKey: string,
|
||||||
|
contentMarkupLanguage: number,
|
||||||
|
contentOriginalCss: string,
|
||||||
|
onChange(event: OnChangeEvent): void;
|
||||||
|
onWillChange(event: any): void;
|
||||||
|
onMessage(event: any): void;
|
||||||
|
onScroll(event: any): void;
|
||||||
|
markupToHtml: Function;
|
||||||
|
htmlToMarkdown: Function;
|
||||||
|
allAssets: Function;
|
||||||
|
attachResources: Function;
|
||||||
|
disabled: boolean;
|
||||||
|
dispatch: Function;
|
||||||
|
noteToolbar: any;
|
||||||
|
searchMarkers: any,
|
||||||
|
visiblePanes: string[],
|
||||||
|
keyboardMode: string,
|
||||||
|
resourceInfos: ResourceInfos,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormNote {
|
||||||
|
id: string,
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
parent_id: string,
|
||||||
|
is_todo: number,
|
||||||
|
bodyEditorContent?: any,
|
||||||
|
markup_language: number,
|
||||||
|
user_updated_time: number,
|
||||||
|
encryption_applied: number,
|
||||||
|
|
||||||
|
hasChanged: boolean,
|
||||||
|
|
||||||
|
// Getting the content from the editor can be a slow process because that content
|
||||||
|
// might need to be serialized first. For that reason, the wrapped editor (eg TinyMCE)
|
||||||
|
// first emits onWillChange when there is a change. That event does not include the
|
||||||
|
// editor content. After a few milliseconds (eg if the user stops typing for long
|
||||||
|
// enough), the editor emits onChange, and that event will include the editor content.
|
||||||
|
//
|
||||||
|
// Both onWillChange and onChange events include a changeId property which is used
|
||||||
|
// to link the two events together. It is used for example to detect if a new note
|
||||||
|
// was loaded before the current note was saved - in that case the changeId will be
|
||||||
|
// different. The two properties bodyWillChangeId and bodyChangeId are used to save
|
||||||
|
// this info with the currently loaded note.
|
||||||
|
//
|
||||||
|
// The willChange/onChange events also allow us to handle the case where the user
|
||||||
|
// types something then quickly switch a different note. In that case, bodyWillChangeId
|
||||||
|
// is set, thus we know we should save the note, even though we won't receive the
|
||||||
|
// onChange event.
|
||||||
|
bodyWillChangeId: number
|
||||||
|
bodyChangeId: number,
|
||||||
|
|
||||||
|
saveActionQueue: AsyncActionQueue,
|
||||||
|
|
||||||
|
// Note with markup_language = HTML have a block of CSS at the start, which is used
|
||||||
|
// to preserve the style from the original (web-clipped) page. When sending the note
|
||||||
|
// content to TinyMCE, we only send the actual HTML, without this CSS. The CSS is passed
|
||||||
|
// via a file in pluginAssets. This is because TinyMCE would not render the style otherwise.
|
||||||
|
// However, when we get back the HTML from TinyMCE, we need to reconstruct the original note.
|
||||||
|
// Since the CSS used by TinyMCE has been lost (since it's in a temp CSS file), we keep that
|
||||||
|
// original CSS here. It's used in formNoteToNote to rebuild the note body.
|
||||||
|
// We can keep it here because we know TinyMCE will not modify it anyway.
|
||||||
|
originalCss: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultFormNote():FormNote {
|
||||||
|
return {
|
||||||
|
id: '',
|
||||||
|
parent_id: '',
|
||||||
|
title: '',
|
||||||
|
body: '',
|
||||||
|
is_todo: 0,
|
||||||
|
markup_language: 1,
|
||||||
|
bodyWillChangeId: 0,
|
||||||
|
bodyChangeId: 0,
|
||||||
|
saveActionQueue: null,
|
||||||
|
originalCss: '',
|
||||||
|
hasChanged: false,
|
||||||
|
user_updated_time: 0,
|
||||||
|
encryption_applied: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceInfo {
|
||||||
|
localState: any,
|
||||||
|
item: any,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceInfos {
|
||||||
|
[index:string]: ResourceInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ScrollOptionTypes {
|
||||||
|
None = 0,
|
||||||
|
Hash = 1,
|
||||||
|
Percent = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScrollOptions {
|
||||||
|
type: ScrollOptionTypes,
|
||||||
|
value: any,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnChangeEvent {
|
||||||
|
changeId: number;
|
||||||
|
content: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorCommand {
|
||||||
|
name: string;
|
||||||
|
value: any;
|
||||||
|
}
|
53
ElectronClient/gui/NoteEditor/utils/useDropHandler.ts
Normal file
53
ElectronClient/gui/NoteEditor/utils/useDropHandler.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
const Note = require('lib/models/Note.js');
|
||||||
|
|
||||||
|
interface HookDependencies {
|
||||||
|
editorRef:any,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useDropHandler(dependencies:HookDependencies) {
|
||||||
|
const { editorRef } = dependencies;
|
||||||
|
|
||||||
|
return useCallback(async (event:any) => {
|
||||||
|
const dt = event.dataTransfer;
|
||||||
|
const createFileURL = event.altKey;
|
||||||
|
|
||||||
|
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
|
||||||
|
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
||||||
|
const noteMarkdownTags = [];
|
||||||
|
for (let i = 0; i < noteIds.length; i++) {
|
||||||
|
const note = await Note.load(noteIds[i]);
|
||||||
|
noteMarkdownTags.push(Note.markdownTag(note));
|
||||||
|
}
|
||||||
|
|
||||||
|
editorRef.current.execCommand({
|
||||||
|
name: 'dropItems',
|
||||||
|
value: {
|
||||||
|
type: 'notes',
|
||||||
|
markdownTags: noteMarkdownTags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = dt.files;
|
||||||
|
if (files && files.length) {
|
||||||
|
const paths = [];
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
if (!file.path) continue;
|
||||||
|
paths.push(file.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
editorRef.current.execCommand({
|
||||||
|
name: 'dropItems',
|
||||||
|
value: {
|
||||||
|
type: 'files',
|
||||||
|
paths: paths,
|
||||||
|
createFileURL: createFileURL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
}
|
208
ElectronClient/gui/NoteEditor/utils/useFormNote.ts
Normal file
208
ElectronClient/gui/NoteEditor/utils/useFormNote.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { FormNote, defaultFormNote, ResourceInfos } from './types';
|
||||||
|
import { clearResourceCache, attachedResources } from './resourceHandling';
|
||||||
|
const { MarkupToHtml } = require('lib/joplin-renderer');
|
||||||
|
const HtmlToHtml = require('lib/joplin-renderer/HtmlToHtml');
|
||||||
|
import AsyncActionQueue from '../../../lib/AsyncActionQueue';
|
||||||
|
import { handleResourceDownloadMode } from './resourceHandling';
|
||||||
|
const usePrevious = require('lib/hooks/usePrevious').default;
|
||||||
|
const Note = require('lib/models/Note');
|
||||||
|
const Setting = require('lib/models/Setting');
|
||||||
|
const { reg } = require('lib/registry.js');
|
||||||
|
const ResourceFetcher = require('lib/services/ResourceFetcher.js');
|
||||||
|
const DecryptionWorker = require('lib/services/DecryptionWorker.js');
|
||||||
|
|
||||||
|
export interface OnLoadEvent {
|
||||||
|
formNote: FormNote,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HookDependencies {
|
||||||
|
syncStarted: boolean,
|
||||||
|
noteId: string,
|
||||||
|
isProvisional: boolean,
|
||||||
|
titleInputRef: any,
|
||||||
|
editorRef: any,
|
||||||
|
onBeforeLoad(event:OnLoadEvent):void,
|
||||||
|
onAfterLoad(event:OnLoadEvent):void,
|
||||||
|
}
|
||||||
|
|
||||||
|
function installResourceChangeHandler(onResourceChangeHandler: Function) {
|
||||||
|
ResourceFetcher.instance().on('downloadComplete', onResourceChangeHandler);
|
||||||
|
ResourceFetcher.instance().on('downloadStarted', onResourceChangeHandler);
|
||||||
|
DecryptionWorker.instance().on('resourceDecrypted', onResourceChangeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uninstallResourceChangeHandler(onResourceChangeHandler: Function) {
|
||||||
|
ResourceFetcher.instance().off('downloadComplete', onResourceChangeHandler);
|
||||||
|
ResourceFetcher.instance().off('downloadStarted', onResourceChangeHandler);
|
||||||
|
DecryptionWorker.instance().off('resourceDecrypted', onResourceChangeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useFormNote(dependencies:HookDependencies) {
|
||||||
|
const { syncStarted, noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad } = dependencies;
|
||||||
|
|
||||||
|
const [formNote, setFormNote] = useState<FormNote>(defaultFormNote());
|
||||||
|
const [isNewNote, setIsNewNote] = useState(false);
|
||||||
|
const prevSyncStarted = usePrevious(syncStarted);
|
||||||
|
const previousNoteId = usePrevious(formNote.id);
|
||||||
|
const [resourceInfos, setResourceInfos] = useState<ResourceInfos>({});
|
||||||
|
|
||||||
|
async function initNoteState(n: any) {
|
||||||
|
let originalCss = '';
|
||||||
|
|
||||||
|
if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) {
|
||||||
|
const htmlToHtml = new HtmlToHtml();
|
||||||
|
const splitted = htmlToHtml.splitHtml(n.body);
|
||||||
|
originalCss = splitted.css;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFormNote = {
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
body: n.body,
|
||||||
|
is_todo: n.is_todo,
|
||||||
|
parent_id: n.parent_id,
|
||||||
|
bodyWillChangeId: 0,
|
||||||
|
bodyChangeId: 0,
|
||||||
|
markup_language: n.markup_language,
|
||||||
|
saveActionQueue: new AsyncActionQueue(300),
|
||||||
|
originalCss: originalCss,
|
||||||
|
hasChanged: false,
|
||||||
|
user_updated_time: n.user_updated_time,
|
||||||
|
encryption_applied: n.encryption_applied,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Note that for performance reason,the call to setResourceInfos should
|
||||||
|
// be first because it loads the resource infos in an async way. If we
|
||||||
|
// swap them, the formNote will be updated first and rendered, then the
|
||||||
|
// the resources will load, and the note will be re-rendered.
|
||||||
|
setResourceInfos(await attachedResources(n.body));
|
||||||
|
setFormNote(newFormNote);
|
||||||
|
|
||||||
|
await handleResourceDownloadMode(n.body);
|
||||||
|
|
||||||
|
return newFormNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check that synchronisation has just finished - and
|
||||||
|
// if the note has never been changed, we reload it.
|
||||||
|
// If the note has already been changed, it's a conflict
|
||||||
|
// that's already been handled by the synchronizer.
|
||||||
|
|
||||||
|
if (!prevSyncStarted) return () => {};
|
||||||
|
if (syncStarted) return () => {};
|
||||||
|
if (formNote.hasChanged) return () => {};
|
||||||
|
|
||||||
|
reg.logger().debug('Sync has finished and note has never been changed - reloading it');
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadNote = async () => {
|
||||||
|
const n = await Note.load(noteId);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
// Normally should not happened because if the note has been deleted via sync
|
||||||
|
// it would not have been loaded in the editor (due to note selection changing
|
||||||
|
// on delete)
|
||||||
|
if (!n) {
|
||||||
|
reg.logger().warn('Trying to reload note that has been deleted:', noteId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await initNoteState(n);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadNote();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [prevSyncStarted, syncStarted, formNote]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!noteId) return () => {};
|
||||||
|
|
||||||
|
if (formNote.id === noteId) return () => {};
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
reg.logger().debug('Loading existing note', noteId);
|
||||||
|
|
||||||
|
function handleAutoFocus(noteIsTodo: boolean) {
|
||||||
|
if (!isProvisional) return;
|
||||||
|
|
||||||
|
const focusSettingName = noteIsTodo ? 'newTodoFocus' : 'newNoteFocus';
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (Setting.value(focusSettingName) === 'title') {
|
||||||
|
if (titleInputRef.current) titleInputRef.current.focus();
|
||||||
|
} else {
|
||||||
|
if (editorRef.current) editorRef.current.execCommand({ name: 'focus' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNote() {
|
||||||
|
const n = await Note.load(noteId);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!n) throw new Error(`Cannot find note with ID: ${noteId}`);
|
||||||
|
reg.logger().debug('Loaded note:', n);
|
||||||
|
|
||||||
|
await onBeforeLoad({ formNote });
|
||||||
|
|
||||||
|
const newFormNote = await initNoteState(n);
|
||||||
|
|
||||||
|
setIsNewNote(isProvisional);
|
||||||
|
|
||||||
|
await onAfterLoad({ formNote: newFormNote });
|
||||||
|
|
||||||
|
handleAutoFocus(!!n.is_todo);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadNote();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [noteId, isProvisional, formNote]);
|
||||||
|
|
||||||
|
const onResourceChange = useCallback(async function(event:any = null) {
|
||||||
|
const resourceIds = await Note.linkedResourceIds(formNote.body);
|
||||||
|
if (!event || resourceIds.indexOf(event.id) >= 0) {
|
||||||
|
clearResourceCache();
|
||||||
|
setResourceInfos(await attachedResources(formNote.body));
|
||||||
|
}
|
||||||
|
}, [formNote.body]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
installResourceChangeHandler(onResourceChange);
|
||||||
|
return () => {
|
||||||
|
uninstallResourceChangeHandler(onResourceChange);
|
||||||
|
};
|
||||||
|
}, [onResourceChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousNoteId !== formNote.id) {
|
||||||
|
onResourceChange();
|
||||||
|
}
|
||||||
|
}, [previousNoteId, formNote.id, onResourceChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function runEffect() {
|
||||||
|
const r = await attachedResources(formNote.body);
|
||||||
|
if (cancelled) return;
|
||||||
|
setResourceInfos(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
runEffect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [formNote.body]);
|
||||||
|
|
||||||
|
return { isNewNote, formNote, setFormNote, resourceInfos };
|
||||||
|
}
|
56
ElectronClient/gui/NoteEditor/utils/useMarkupToHtml.ts
Normal file
56
ElectronClient/gui/NoteEditor/utils/useMarkupToHtml.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { ResourceInfos } from './types';
|
||||||
|
const { themeStyle } = require('../../../theme.js');
|
||||||
|
const Note = require('lib/models/Note');
|
||||||
|
const Setting = require('lib/models/Setting');
|
||||||
|
const markupLanguageUtils = require('lib/markupLanguageUtils');
|
||||||
|
|
||||||
|
interface HookDependencies {
|
||||||
|
themeId: number,
|
||||||
|
customCss: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkupToHtmlOptions {
|
||||||
|
replaceResourceInternalToExternalLinks?: boolean,
|
||||||
|
resourceInfos?: ResourceInfos,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useMarkupToHtml(dependencies:HookDependencies) {
|
||||||
|
const { themeId, customCss } = dependencies;
|
||||||
|
|
||||||
|
return useCallback(async (markupLanguage: number, md: string, options: MarkupToHtmlOptions = null): Promise<any> => {
|
||||||
|
options = {
|
||||||
|
replaceResourceInternalToExternalLinks: false,
|
||||||
|
resourceInfos: {},
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
md = md || '';
|
||||||
|
|
||||||
|
const theme = themeStyle(themeId);
|
||||||
|
let resources = {};
|
||||||
|
|
||||||
|
if (options.replaceResourceInternalToExternalLinks) {
|
||||||
|
md = await Note.replaceResourceInternalToExternalLinks(md, { useAbsolutePaths: true });
|
||||||
|
} else {
|
||||||
|
resources = options.resourceInfos;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete options.replaceResourceInternalToExternalLinks;
|
||||||
|
|
||||||
|
const markupToHtml = markupLanguageUtils.newMarkupToHtml({
|
||||||
|
resourceBaseUrl: `file://${Setting.value('resourceDir')}/`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await markupToHtml.render(markupLanguage, md, theme, Object.assign({}, {
|
||||||
|
codeTheme: theme.codeThemeCss,
|
||||||
|
userCss: customCss || '',
|
||||||
|
resources: resources,
|
||||||
|
postMessageSyntax: 'ipcProxySendToHost',
|
||||||
|
splitted: true,
|
||||||
|
externalAssetsOnly: true,
|
||||||
|
}, options));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [themeId, customCss]);
|
||||||
|
}
|
152
ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts
Normal file
152
ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
const BaseItem = require('lib/models/BaseItem');
|
||||||
|
const { _ } = require('lib/locale');
|
||||||
|
const BaseModel = require('lib/BaseModel.js');
|
||||||
|
const Resource = require('lib/models/Resource.js');
|
||||||
|
const { bridge } = require('electron').remote.require('./bridge');
|
||||||
|
const { urlDecode } = require('lib/string-utils');
|
||||||
|
const urlUtils = require('lib/urlUtils');
|
||||||
|
const ResourceFetcher = require('lib/services/ResourceFetcher.js');
|
||||||
|
const Menu = bridge().Menu;
|
||||||
|
const MenuItem = bridge().MenuItem;
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const { clipboard } = require('electron');
|
||||||
|
const { toSystemSlashes } = require('lib/path-utils');
|
||||||
|
const { reg } = require('lib/registry.js');
|
||||||
|
|
||||||
|
export default function useMessageHandler(scrollWhenReady:any, setScrollWhenReady:Function, editorRef:any, setLocalSearchResultCount:Function, dispatch:Function) {
|
||||||
|
return useCallback(async (event: any) => {
|
||||||
|
const msg = event.channel ? event.channel : '';
|
||||||
|
const args = event.args;
|
||||||
|
const arg0 = args && args.length >= 1 ? args[0] : null;
|
||||||
|
|
||||||
|
if (msg !== 'percentScroll') console.info(`Got ipc-message: ${msg}`, args);
|
||||||
|
|
||||||
|
if (msg.indexOf('error:') === 0) {
|
||||||
|
const s = msg.split(':');
|
||||||
|
s.splice(0, 1);
|
||||||
|
reg.logger().error(s.join(':'));
|
||||||
|
} else if (msg === 'noteRenderComplete') {
|
||||||
|
if (scrollWhenReady) {
|
||||||
|
const options = { ...scrollWhenReady };
|
||||||
|
setScrollWhenReady(null);
|
||||||
|
editorRef.current.scrollTo(options);
|
||||||
|
}
|
||||||
|
} else if (msg === 'setMarkerCount') {
|
||||||
|
setLocalSearchResultCount(arg0);
|
||||||
|
} else if (msg.indexOf('markForDownload:') === 0) {
|
||||||
|
const s = msg.split(':');
|
||||||
|
if (s.length < 2) throw new Error(`Invalid message: ${msg}`);
|
||||||
|
ResourceFetcher.instance().markForDownload(s[1]);
|
||||||
|
} else if (msg === 'contextMenu') {
|
||||||
|
const itemType = arg0 && arg0.type;
|
||||||
|
|
||||||
|
const menu = new Menu();
|
||||||
|
|
||||||
|
if (itemType === 'image' || itemType === 'resource') {
|
||||||
|
const resource = await Resource.load(arg0.resourceId);
|
||||||
|
const resourcePath = Resource.fullPath(resource);
|
||||||
|
|
||||||
|
menu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: _('Open...'),
|
||||||
|
click: async () => {
|
||||||
|
const ok = bridge().openExternal(`file://${resourcePath}`);
|
||||||
|
if (!ok) bridge().showErrorMessageBox(_('This file could not be opened: %s', resourcePath));
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
menu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: _('Save as...'),
|
||||||
|
click: async () => {
|
||||||
|
const filePath = bridge().showSaveDialog({
|
||||||
|
defaultPath: resource.filename ? resource.filename : resource.title,
|
||||||
|
});
|
||||||
|
if (!filePath) return;
|
||||||
|
await fs.copy(resourcePath, filePath);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
menu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: _('Copy path to clipboard'),
|
||||||
|
click: async () => {
|
||||||
|
clipboard.writeText(toSystemSlashes(resourcePath));
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (itemType === 'text') {
|
||||||
|
menu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: _('Copy'),
|
||||||
|
click: async () => {
|
||||||
|
clipboard.writeText(arg0.textToCopy);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (itemType === 'link') {
|
||||||
|
menu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: _('Copy Link Address'),
|
||||||
|
click: async () => {
|
||||||
|
clipboard.writeText(arg0.textToCopy);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
reg.logger().error(`Unhandled item type: ${itemType}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.popup(bridge().window());
|
||||||
|
} else if (msg.indexOf('joplin://') === 0) {
|
||||||
|
const resourceUrlInfo = urlUtils.parseResourceUrl(msg);
|
||||||
|
const itemId = resourceUrlInfo.itemId;
|
||||||
|
const item = await BaseItem.loadItemById(itemId);
|
||||||
|
|
||||||
|
if (!item) throw new Error(`No item with ID ${itemId}`);
|
||||||
|
|
||||||
|
if (item.type_ === BaseModel.TYPE_RESOURCE) {
|
||||||
|
const localState = await Resource.localState(item);
|
||||||
|
if (localState.fetch_status !== Resource.FETCH_STATUS_DONE || !!item.encryption_blob_encrypted) {
|
||||||
|
if (localState.fetch_status === Resource.FETCH_STATUS_ERROR) {
|
||||||
|
bridge().showErrorMessageBox(`${_('There was an error downloading this attachment:')}\n\n${localState.fetch_error}`);
|
||||||
|
} else {
|
||||||
|
bridge().showErrorMessageBox(_('This attachment is not downloaded or not decrypted yet'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filePath = Resource.fullPath(item);
|
||||||
|
bridge().openItem(filePath);
|
||||||
|
} else if (item.type_ === BaseModel.TYPE_NOTE) {
|
||||||
|
dispatch({
|
||||||
|
type: 'FOLDER_AND_NOTE_SELECT',
|
||||||
|
folderId: item.parent_id,
|
||||||
|
noteId: item.id,
|
||||||
|
hash: resourceUrlInfo.hash,
|
||||||
|
// historyNoteAction: {
|
||||||
|
// id: this.state.note.id,
|
||||||
|
// parent_id: this.state.note.parent_id,
|
||||||
|
// },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported item type: ${item.type_}`);
|
||||||
|
}
|
||||||
|
} else if (urlUtils.urlProtocol(msg)) {
|
||||||
|
if (msg.indexOf('file://') === 0) {
|
||||||
|
// When using the file:// protocol, openExternal doesn't work (does nothing) with URL-encoded paths
|
||||||
|
require('electron').shell.openExternal(urlDecode(msg));
|
||||||
|
} else {
|
||||||
|
require('electron').shell.openExternal(msg);
|
||||||
|
}
|
||||||
|
} else if (msg.indexOf('#') === 0) {
|
||||||
|
// This is an internal anchor, which is handled by the WebView so skip this case
|
||||||
|
} else {
|
||||||
|
bridge().showErrorMessageBox(_('Unsupported link or message: %s', msg));
|
||||||
|
}
|
||||||
|
}, [dispatch, setLocalSearchResultCount, scrollWhenReady]);
|
||||||
|
}
|
92
ElectronClient/gui/NoteEditor/utils/useNoteSearchBar.ts
Normal file
92
ElectronClient/gui/NoteEditor/utils/useNoteSearchBar.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { SearchMarkers } from './useSearchMarkers';
|
||||||
|
|
||||||
|
interface LocalSearch {
|
||||||
|
query: string,
|
||||||
|
selectedIndex: number,
|
||||||
|
resultCount: number,
|
||||||
|
searching: boolean,
|
||||||
|
timestamp: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultLocalSearch():LocalSearch {
|
||||||
|
return {
|
||||||
|
query: '',
|
||||||
|
selectedIndex: 0,
|
||||||
|
resultCount: 0,
|
||||||
|
searching: false,
|
||||||
|
timestamp: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useNoteSearchBar() {
|
||||||
|
const [showLocalSearch, setShowLocalSearch] = useState(false);
|
||||||
|
const [localSearch, setLocalSearch] = useState<LocalSearch>(defaultLocalSearch());
|
||||||
|
|
||||||
|
const onChange = useCallback((query:string) => {
|
||||||
|
setLocalSearch((prev:LocalSearch) => {
|
||||||
|
return {
|
||||||
|
query: query,
|
||||||
|
selectedIndex: 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
resultCount: prev.resultCount,
|
||||||
|
searching: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const noteSearchBarNextPrevious = useCallback((inc:number) => {
|
||||||
|
setLocalSearch((prev:LocalSearch) => {
|
||||||
|
const ls = Object.assign({}, prev);
|
||||||
|
ls.selectedIndex += inc;
|
||||||
|
ls.timestamp = Date.now();
|
||||||
|
if (ls.selectedIndex < 0) ls.selectedIndex = ls.resultCount - 1;
|
||||||
|
if (ls.selectedIndex >= ls.resultCount) ls.selectedIndex = 0;
|
||||||
|
return ls;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onNext = useCallback(() => {
|
||||||
|
noteSearchBarNextPrevious(+1);
|
||||||
|
}, [noteSearchBarNextPrevious]);
|
||||||
|
|
||||||
|
const onPrevious = useCallback(() => {
|
||||||
|
noteSearchBarNextPrevious(-1);
|
||||||
|
}, [noteSearchBarNextPrevious]);
|
||||||
|
|
||||||
|
const onClose = useCallback(() => {
|
||||||
|
setShowLocalSearch(false);
|
||||||
|
setLocalSearch(defaultLocalSearch());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setResultCount = useCallback((count:number) => {
|
||||||
|
setLocalSearch((prev:LocalSearch) => {
|
||||||
|
if (prev.resultCount === count && !prev.searching) return prev;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
resultCount: count,
|
||||||
|
searching: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const searchMarkers = useCallback(():SearchMarkers => {
|
||||||
|
return {
|
||||||
|
options: {
|
||||||
|
selectedIndex: localSearch.selectedIndex,
|
||||||
|
separateWordSearch: false,
|
||||||
|
searchTimestamp: localSearch.timestamp,
|
||||||
|
},
|
||||||
|
keywords: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
value: localSearch.query,
|
||||||
|
accuracy: 'partially',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}, [localSearch]);
|
||||||
|
|
||||||
|
return { localSearch, onChange, onNext, onPrevious, onClose, setResultCount, showLocalSearch, setShowLocalSearch, searchMarkers };
|
||||||
|
}
|
48
ElectronClient/gui/NoteEditor/utils/useResourceRefresher.js
Normal file
48
ElectronClient/gui/NoteEditor/utils/useResourceRefresher.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
'use strict';
|
||||||
|
const __awaiter = (this && this.__awaiter) || function(thisArg, _arguments, P, generator) {
|
||||||
|
function adopt(value) { return value instanceof P ? value : new P(function(resolve) { resolve(value); }); }
|
||||||
|
return new (P || (P = Promise))(function(resolve, reject) {
|
||||||
|
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||||
|
function rejected(value) { try { step(generator['throw'](value)); } catch (e) { reject(e); } }
|
||||||
|
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||||
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, '__esModule', { value: true });
|
||||||
|
const react_1 = require('react');
|
||||||
|
const resourceHandling_1 = require('./resourceHandling');
|
||||||
|
const ResourceFetcher = require('lib/services/ResourceFetcher.js');
|
||||||
|
const DecryptionWorker = require('lib/services/DecryptionWorker.js');
|
||||||
|
const Note = require('lib/models/Note');
|
||||||
|
function useResourceInfos(dependencies) {
|
||||||
|
const { noteBody } = dependencies;
|
||||||
|
const [resourceInfos, setResourceInfos] = react_1.useState({});
|
||||||
|
function installResourceHandling(refreshResourceHandler) {
|
||||||
|
ResourceFetcher.instance().on('downloadComplete', refreshResourceHandler);
|
||||||
|
ResourceFetcher.instance().on('downloadStarted', refreshResourceHandler);
|
||||||
|
DecryptionWorker.instance().on('resourceDecrypted', refreshResourceHandler);
|
||||||
|
}
|
||||||
|
function uninstallResourceHandling(refreshResourceHandler) {
|
||||||
|
ResourceFetcher.instance().off('downloadComplete', refreshResourceHandler);
|
||||||
|
ResourceFetcher.instance().off('downloadStarted', refreshResourceHandler);
|
||||||
|
DecryptionWorker.instance().off('resourceDecrypted', refreshResourceHandler);
|
||||||
|
}
|
||||||
|
const refreshResource = react_1.useCallback(function(event) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const resourceIds = yield Note.linkedResourceIds(noteBody);
|
||||||
|
if (resourceIds.indexOf(event.id) >= 0) {
|
||||||
|
resourceHandling_1.clearResourceCache();
|
||||||
|
setResourceInfos(yield resourceHandling_1.attachedResources(noteBody));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [noteBody]);
|
||||||
|
react_1.useEffect(() => {
|
||||||
|
installResourceHandling(refreshResource);
|
||||||
|
return () => {
|
||||||
|
uninstallResourceHandling(refreshResource);
|
||||||
|
};
|
||||||
|
}, [refreshResource]);
|
||||||
|
return { resourceInfos };
|
||||||
|
}
|
||||||
|
exports.default = useResourceInfos;
|
||||||
|
// # sourceMappingURL=useResourceRefresher.js.map
|
42
ElectronClient/gui/NoteEditor/utils/useSearchMarkers.ts
Normal file
42
ElectronClient/gui/NoteEditor/utils/useSearchMarkers.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
const BaseModel = require('lib/BaseModel.js');
|
||||||
|
const SearchEngine = require('lib/services/SearchEngine');
|
||||||
|
|
||||||
|
interface SearchMarkersOptions {
|
||||||
|
searchTimestamp: number,
|
||||||
|
selectedIndex: number,
|
||||||
|
separateWordSearch: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchMarkers {
|
||||||
|
keywords: any[],
|
||||||
|
options: SearchMarkersOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultSearchMarkers():SearchMarkers {
|
||||||
|
return {
|
||||||
|
keywords: [],
|
||||||
|
options: {
|
||||||
|
searchTimestamp: 0,
|
||||||
|
selectedIndex: 0,
|
||||||
|
separateWordSearch: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useSearchMarkers(showLocalSearch:boolean, localSearchMarkerOptions:Function, searches:any[], selectedSearchId:string) {
|
||||||
|
return useMemo(():SearchMarkers => {
|
||||||
|
if (showLocalSearch) return localSearchMarkerOptions();
|
||||||
|
|
||||||
|
const output = defaultSearchMarkers();
|
||||||
|
|
||||||
|
const search = BaseModel.byId(searches, selectedSearchId);
|
||||||
|
if (search) {
|
||||||
|
const parsedQuery = SearchEngine.instance().parseQuery(search.query_pattern);
|
||||||
|
output.keywords = SearchEngine.instance().allParsedQueryTerms(parsedQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}, [showLocalSearch, localSearchMarkerOptions, searches, selectedSearchId]);
|
||||||
|
}
|
104
ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.ts
Normal file
104
ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { FormNote, EditorCommand } from './types';
|
||||||
|
const { time } = require('lib/time-utils.js');
|
||||||
|
const { reg } = require('lib/registry.js');
|
||||||
|
const NoteListUtils = require('../../utils/NoteListUtils');
|
||||||
|
|
||||||
|
interface HookDependencies {
|
||||||
|
windowCommand: any,
|
||||||
|
formNote:FormNote,
|
||||||
|
setShowLocalSearch:Function,
|
||||||
|
dispatch:Function,
|
||||||
|
noteSearchBarRef:any,
|
||||||
|
editorRef:any,
|
||||||
|
titleInputRef:any,
|
||||||
|
saveNoteAndWait: Function,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useWindowCommandHandler(dependencies:HookDependencies) {
|
||||||
|
const { windowCommand, dispatch, formNote, setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, saveNoteAndWait } = dependencies;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function processCommand() {
|
||||||
|
const command = windowCommand;
|
||||||
|
|
||||||
|
if (!command || !formNote) return;
|
||||||
|
|
||||||
|
reg.logger().debug('NoteEditor::useWindowCommandHandler:', command);
|
||||||
|
|
||||||
|
const editorCmd: EditorCommand = { name: '', value: command.value };
|
||||||
|
let fn: Function = null;
|
||||||
|
|
||||||
|
// These commands can be forwarded directly to the note body editor
|
||||||
|
// without transformation.
|
||||||
|
const directMapCommands = [
|
||||||
|
'textCode',
|
||||||
|
'textBold',
|
||||||
|
'textItalic',
|
||||||
|
'textLink',
|
||||||
|
'attachFile',
|
||||||
|
'textNumberedList',
|
||||||
|
'textBulletedList',
|
||||||
|
'textCheckbox',
|
||||||
|
'textHeading',
|
||||||
|
'textHorizontalRule',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (directMapCommands.includes(command.name)) {
|
||||||
|
editorCmd.name = command.name;
|
||||||
|
} else if (command.name === 'commandStartExternalEditing') {
|
||||||
|
fn = async () => {
|
||||||
|
await saveNoteAndWait(formNote);
|
||||||
|
NoteListUtils.startExternalEditing(formNote.id);
|
||||||
|
};
|
||||||
|
} else if (command.name === 'commandStopExternalEditing') {
|
||||||
|
fn = () => {
|
||||||
|
NoteListUtils.stopExternalEditing(formNote.id);
|
||||||
|
};
|
||||||
|
} else if (command.name === 'insertDateTime') {
|
||||||
|
editorCmd.name = 'insertText',
|
||||||
|
editorCmd.value = time.formatMsToLocal(new Date().getTime());
|
||||||
|
} else if (command.name === 'showLocalSearch') {
|
||||||
|
setShowLocalSearch(true);
|
||||||
|
if (noteSearchBarRef.current) noteSearchBarRef.current.wrappedInstance.focus();
|
||||||
|
} else if (command.name === 'insertTemplate') {
|
||||||
|
editorCmd.name = 'insertText',
|
||||||
|
editorCmd.value = time.formatMsToLocal(new Date().getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.name === 'focusElement' && command.target === 'noteTitle') {
|
||||||
|
fn = () => {
|
||||||
|
if (!titleInputRef.current) return;
|
||||||
|
titleInputRef.current.focus();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.name === 'focusElement' && command.target === 'noteBody') {
|
||||||
|
editorCmd.name = 'focus';
|
||||||
|
}
|
||||||
|
|
||||||
|
reg.logger().debug('NoteEditor::useWindowCommandHandler: Dispatch:', editorCmd, fn);
|
||||||
|
|
||||||
|
if (!editorCmd.name && !fn) return;
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (fn) {
|
||||||
|
fn();
|
||||||
|
} else {
|
||||||
|
if (!editorRef.current.execCommand) {
|
||||||
|
reg.logger().warn('Received command, but editor cannot execute commands', editorCmd);
|
||||||
|
} else {
|
||||||
|
editorRef.current.execCommand(editorCmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
processCommand();
|
||||||
|
}, [windowCommand, dispatch, formNote, saveNoteAndWait]);
|
||||||
|
}
|
@ -145,7 +145,7 @@ class NoteListComponent extends React.Component {
|
|||||||
todo_completed: checked ? time.unixMs() : 0,
|
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;
|
||||||
|
@ -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}
|
||||||
|
@ -1,833 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
import TinyMCE, { utils as tinyMceUtils } from './editors/TinyMCE';
|
|
||||||
import PlainEditor, { utils as plainEditorUtils } from './editors/PlainEditor';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import AsyncActionQueue from '../lib/AsyncActionQueue';
|
|
||||||
import MultiNoteActions from './MultiNoteActions';
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
import { DefaultEditorState, OnChangeEvent, TextEditorUtils, EditorCommand } from './utils/NoteText';
|
|
||||||
const { themeStyle, buildStyle } = require('../theme.js');
|
|
||||||
const { reg } = require('lib/registry.js');
|
|
||||||
const { time } = require('lib/time-utils.js');
|
|
||||||
const markupLanguageUtils = require('lib/markupLanguageUtils');
|
|
||||||
const HtmlToHtml = require('lib/joplin-renderer/HtmlToHtml');
|
|
||||||
const Setting = require('lib/models/Setting');
|
|
||||||
const BaseItem = require('lib/models/BaseItem');
|
|
||||||
const { MarkupToHtml } = require('lib/joplin-renderer');
|
|
||||||
const HtmlToMd = require('lib/HtmlToMd');
|
|
||||||
const { _ } = require('lib/locale');
|
|
||||||
const Note = require('lib/models/Note.js');
|
|
||||||
const BaseModel = require('lib/BaseModel.js');
|
|
||||||
const Resource = require('lib/models/Resource.js');
|
|
||||||
const { shim } = require('lib/shim');
|
|
||||||
const TemplateUtils = require('lib/TemplateUtils');
|
|
||||||
const { bridge } = require('electron').remote.require('./bridge');
|
|
||||||
const { urlDecode } = require('lib/string-utils');
|
|
||||||
const urlUtils = require('lib/urlUtils');
|
|
||||||
const ResourceFetcher = require('lib/services/ResourceFetcher.js');
|
|
||||||
const DecryptionWorker = require('lib/services/DecryptionWorker.js');
|
|
||||||
|
|
||||||
interface NoteTextProps {
|
|
||||||
style: any,
|
|
||||||
noteId: string,
|
|
||||||
theme: number,
|
|
||||||
dispatch: Function,
|
|
||||||
selectedNoteIds: string[],
|
|
||||||
notes:any[],
|
|
||||||
watchedNoteFiles:string[],
|
|
||||||
isProvisional: boolean,
|
|
||||||
editorNoteStatuses: any,
|
|
||||||
syncStarted: boolean,
|
|
||||||
editor: string,
|
|
||||||
windowCommand: any,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormNote {
|
|
||||||
id: string,
|
|
||||||
title: string,
|
|
||||||
parent_id: string,
|
|
||||||
is_todo: number,
|
|
||||||
bodyEditorContent?: any,
|
|
||||||
markup_language: number,
|
|
||||||
|
|
||||||
hasChanged: boolean,
|
|
||||||
|
|
||||||
// Getting the content from the editor can be a slow process because that content
|
|
||||||
// might need to be serialized first. For that reason, the wrapped editor (eg TinyMCE)
|
|
||||||
// first emits onWillChange when there is a change. That event does not include the
|
|
||||||
// editor content. After a few milliseconds (eg if the user stops typing for long
|
|
||||||
// enough), the editor emits onChange, and that event will include the editor content.
|
|
||||||
//
|
|
||||||
// Both onWillChange and onChange events include a changeId property which is used
|
|
||||||
// to link the two events together. It is used for example to detect if a new note
|
|
||||||
// was loaded before the current note was saved - in that case the changeId will be
|
|
||||||
// different. The two properties bodyWillChangeId and bodyChangeId are used to save
|
|
||||||
// this info with the currently loaded note.
|
|
||||||
//
|
|
||||||
// The willChange/onChange events also allow us to handle the case where the user
|
|
||||||
// types something then quickly switch a different note. In that case, bodyWillChangeId
|
|
||||||
// is set, thus we know we should save the note, even though we won't receive the
|
|
||||||
// onChange event.
|
|
||||||
bodyWillChangeId: number
|
|
||||||
bodyChangeId: number,
|
|
||||||
|
|
||||||
saveActionQueue: AsyncActionQueue,
|
|
||||||
|
|
||||||
// Note with markup_language = HTML have a block of CSS at the start, which is used
|
|
||||||
// to preserve the style from the original (web-clipped) page. When sending the note
|
|
||||||
// content to TinyMCE, we only send the actual HTML, without this CSS. The CSS is passed
|
|
||||||
// via a file in pluginAssets. This is because TinyMCE would not render the style otherwise.
|
|
||||||
// However, when we get back the HTML from TinyMCE, we need to reconstruct the original note.
|
|
||||||
// Since the CSS used by TinyMCE has been lost (since it's in a temp CSS file), we keep that
|
|
||||||
// original CSS here. It's used in formNoteToNote to rebuild the note body.
|
|
||||||
// We can keep it here because we know TinyMCE will not modify it anyway.
|
|
||||||
originalCss: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultNote = ():FormNote => {
|
|
||||||
return {
|
|
||||||
id: '',
|
|
||||||
parent_id: '',
|
|
||||||
title: '',
|
|
||||||
is_todo: 0,
|
|
||||||
markup_language: 1,
|
|
||||||
bodyWillChangeId: 0,
|
|
||||||
bodyChangeId: 0,
|
|
||||||
saveActionQueue: null,
|
|
||||||
originalCss: '',
|
|
||||||
hasChanged: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function styles_(props:NoteTextProps) {
|
|
||||||
return buildStyle('NoteText', props.theme, (theme:any) => {
|
|
||||||
return {
|
|
||||||
titleInput: {
|
|
||||||
flex: 1,
|
|
||||||
display: 'inline-block',
|
|
||||||
paddingTop: 5,
|
|
||||||
paddingBottom: 5,
|
|
||||||
paddingLeft: 8,
|
|
||||||
paddingRight: 8,
|
|
||||||
marginRight: theme.paddingLeft,
|
|
||||||
color: theme.textStyle.color,
|
|
||||||
fontSize: theme.textStyle.fontSize * 1.25 * 1.5,
|
|
||||||
backgroundColor: theme.backgroundColor,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: theme.dividerColor,
|
|
||||||
},
|
|
||||||
warningBanner: {
|
|
||||||
background: theme.warningBackgroundColor,
|
|
||||||
fontFamily: theme.fontFamily,
|
|
||||||
padding: 10,
|
|
||||||
fontSize: theme.fontSize,
|
|
||||||
},
|
|
||||||
tinyMCE: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let textEditorUtils_:TextEditorUtils = null;
|
|
||||||
|
|
||||||
function usePrevious(value:any):any {
|
|
||||||
const ref = useRef();
|
|
||||||
useEffect(() => {
|
|
||||||
ref.current = value;
|
|
||||||
});
|
|
||||||
return ref.current;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initNoteState(n:any, setFormNote:Function, setDefaultEditorState:Function) {
|
|
||||||
let originalCss = '';
|
|
||||||
if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) {
|
|
||||||
const htmlToHtml = new HtmlToHtml();
|
|
||||||
const splitted = htmlToHtml.splitHtml(n.body);
|
|
||||||
originalCss = splitted.css;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormNote({
|
|
||||||
id: n.id,
|
|
||||||
title: n.title,
|
|
||||||
is_todo: n.is_todo,
|
|
||||||
parent_id: n.parent_id,
|
|
||||||
bodyWillChangeId: 0,
|
|
||||||
bodyChangeId: 0,
|
|
||||||
markup_language: n.markup_language,
|
|
||||||
saveActionQueue: new AsyncActionQueue(1000),
|
|
||||||
originalCss: originalCss,
|
|
||||||
hasChanged: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
setDefaultEditorState({
|
|
||||||
value: n.body,
|
|
||||||
markupLanguage: n.markup_language,
|
|
||||||
resourceInfos: await attachedResources(n.body),
|
|
||||||
});
|
|
||||||
|
|
||||||
await handleResourceDownloadMode(n.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResourceDownloadMode(noteBody:string) {
|
|
||||||
if (noteBody && Setting.value('sync.resourceDownloadMode') === 'auto') {
|
|
||||||
const resourceIds = await Note.linkedResourceIds(noteBody);
|
|
||||||
await ResourceFetcher.instance().markForDownload(resourceIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function htmlToMarkdown(html:string):Promise<string> {
|
|
||||||
const htmlToMd = new HtmlToMd();
|
|
||||||
let md = htmlToMd.parse(html, { preserveImageTagsWithSize: true });
|
|
||||||
md = await Note.replaceResourceExternalToInternalLinks(md, { useAbsolutePaths: true });
|
|
||||||
return md;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function formNoteToNote(formNote:FormNote):Promise<any> {
|
|
||||||
const newNote:any = Object.assign({}, formNote);
|
|
||||||
|
|
||||||
if ('bodyEditorContent' in formNote) {
|
|
||||||
const html = await textEditorUtils_.editorContentToHtml(formNote.bodyEditorContent);
|
|
||||||
if (formNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN) {
|
|
||||||
newNote.body = await htmlToMarkdown(html);
|
|
||||||
} else {
|
|
||||||
newNote.body = html;
|
|
||||||
newNote.body = await Note.replaceResourceExternalToInternalLinks(newNote.body, { useAbsolutePaths: true });
|
|
||||||
if (formNote.originalCss) newNote.body = `<style>${formNote.originalCss}</style>\n${newNote.body}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
delete newNote.bodyEditorContent;
|
|
||||||
|
|
||||||
return newNote;
|
|
||||||
}
|
|
||||||
|
|
||||||
let resourceCache_:any = {};
|
|
||||||
|
|
||||||
function clearResourceCache() {
|
|
||||||
resourceCache_ = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function attachedResources(noteBody:string):Promise<any> {
|
|
||||||
if (!noteBody) return {};
|
|
||||||
const resourceIds = await Note.linkedItemIdsByType(BaseModel.TYPE_RESOURCE, noteBody);
|
|
||||||
|
|
||||||
const output:any = {};
|
|
||||||
for (let i = 0; i < resourceIds.length; i++) {
|
|
||||||
const id = resourceIds[i];
|
|
||||||
|
|
||||||
if (resourceCache_[id]) {
|
|
||||||
output[id] = resourceCache_[id];
|
|
||||||
} else {
|
|
||||||
const resource = await Resource.load(id);
|
|
||||||
const localState = await Resource.localState(resource);
|
|
||||||
|
|
||||||
const o = {
|
|
||||||
item: resource,
|
|
||||||
localState: localState,
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line require-atomic-updates
|
|
||||||
resourceCache_[id] = o;
|
|
||||||
output[id] = o;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
function installResourceHandling(refreshResourceHandler:Function) {
|
|
||||||
ResourceFetcher.instance().on('downloadComplete', refreshResourceHandler);
|
|
||||||
ResourceFetcher.instance().on('downloadStarted', refreshResourceHandler);
|
|
||||||
DecryptionWorker.instance().on('resourceDecrypted', refreshResourceHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
function uninstallResourceHandling(refreshResourceHandler:Function) {
|
|
||||||
ResourceFetcher.instance().off('downloadComplete', refreshResourceHandler);
|
|
||||||
ResourceFetcher.instance().off('downloadStarted', refreshResourceHandler);
|
|
||||||
DecryptionWorker.instance().off('resourceDecrypted', refreshResourceHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function attachResources() {
|
|
||||||
const filePaths = bridge().showOpenDialog({
|
|
||||||
properties: ['openFile', 'createDirectory', 'multiSelections'],
|
|
||||||
});
|
|
||||||
if (!filePaths || !filePaths.length) return [];
|
|
||||||
|
|
||||||
const output = [];
|
|
||||||
|
|
||||||
for (const filePath of filePaths) {
|
|
||||||
try {
|
|
||||||
const resource = await shim.createResourceFromPath(filePath);
|
|
||||||
output.push({
|
|
||||||
item: resource,
|
|
||||||
markdownTag: Resource.markdownTag(resource),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
bridge().showErrorMessageBox(error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleSaveNote(formNote:FormNote, dispatch:Function) {
|
|
||||||
if (!formNote.saveActionQueue) throw new Error('saveActionQueue is not set!!'); // Sanity check
|
|
||||||
|
|
||||||
reg.logger().debug('Scheduling...', formNote);
|
|
||||||
|
|
||||||
const makeAction = (formNote:FormNote) => {
|
|
||||||
return async function() {
|
|
||||||
const note = await formNoteToNote(formNote);
|
|
||||||
reg.logger().debug('Saving note...', note);
|
|
||||||
await Note.save(note);
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: 'EDITOR_NOTE_STATUS_REMOVE',
|
|
||||||
id: formNote.id,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
formNote.saveActionQueue.push(makeAction(formNote));
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveNoteIfWillChange(formNote:FormNote, editorRef:any, dispatch:Function) {
|
|
||||||
if (!formNote.id || !formNote.bodyWillChangeId) return;
|
|
||||||
|
|
||||||
scheduleSaveNote({
|
|
||||||
...formNote,
|
|
||||||
bodyEditorContent: editorRef.current.content(),
|
|
||||||
bodyWillChangeId: 0,
|
|
||||||
bodyChangeId: 0,
|
|
||||||
}, dispatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useWindowCommand(windowCommand:any, dispatch:Function, formNote:FormNote, titleInputRef:React.MutableRefObject<any>, editorRef:React.MutableRefObject<any>) {
|
|
||||||
useEffect(() => {
|
|
||||||
const command = windowCommand;
|
|
||||||
if (!command || !formNote) return;
|
|
||||||
|
|
||||||
const editorCmd:EditorCommand = { name: command.name, value: { ...command.value } };
|
|
||||||
let fn:Function = null;
|
|
||||||
|
|
||||||
if (command.name === 'exportPdf') {
|
|
||||||
// TODO
|
|
||||||
} else if (command.name === 'print') {
|
|
||||||
// TODO
|
|
||||||
} else if (command.name === 'insertDateTime') {
|
|
||||||
editorCmd.name = 'insertText',
|
|
||||||
editorCmd.value = time.formatMsToLocal(new Date().getTime());
|
|
||||||
} else if (command.name === 'commandStartExternalEditing') {
|
|
||||||
// TODO
|
|
||||||
} else if (command.name === 'commandStopExternalEditing') {
|
|
||||||
// TODO
|
|
||||||
} else if (command.name === 'showLocalSearch') {
|
|
||||||
editorCmd.name = 'search';
|
|
||||||
} else if (command.name === 'textCode') {
|
|
||||||
// TODO
|
|
||||||
} else if (command.name === 'insertTemplate') {
|
|
||||||
editorCmd.name = 'insertText',
|
|
||||||
editorCmd.value = TemplateUtils.render(command.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.name === 'focusElement' && command.target === 'noteTitle') {
|
|
||||||
fn = () => {
|
|
||||||
if (!titleInputRef.current) return;
|
|
||||||
titleInputRef.current.focus();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.name === 'focusElement' && command.target === 'noteBody') {
|
|
||||||
editorCmd.name = 'focus';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!editorCmd.name && !fn) return;
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: 'WINDOW_COMMAND',
|
|
||||||
name: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (fn) {
|
|
||||||
fn();
|
|
||||||
} else {
|
|
||||||
if (!editorRef.current.execCommand) {
|
|
||||||
reg.logger().warn('Received command, but editor cannot execute commands', editorCmd);
|
|
||||||
} else {
|
|
||||||
editorRef.current.execCommand(editorCmd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [windowCommand, dispatch, formNote]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NoteText2(props:NoteTextProps) {
|
|
||||||
const [formNote, setFormNote] = useState<FormNote>(defaultNote());
|
|
||||||
const [defaultEditorState, setDefaultEditorState] = useState<DefaultEditorState>({ value: '', markupLanguage: MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, resourceInfos: {} });
|
|
||||||
const prevSyncStarted = usePrevious(props.syncStarted);
|
|
||||||
|
|
||||||
const editorRef = useRef<any>();
|
|
||||||
const titleInputRef = useRef<any>();
|
|
||||||
const formNoteRef = useRef<FormNote>();
|
|
||||||
formNoteRef.current = { ...formNote };
|
|
||||||
const isMountedRef = useRef(true);
|
|
||||||
|
|
||||||
useWindowCommand(props.windowCommand, props.dispatch, formNote, titleInputRef, editorRef);
|
|
||||||
|
|
||||||
// If the note has been modified in another editor, wait for it to be saved
|
|
||||||
// before loading it in this editor.
|
|
||||||
const waitingToSaveNote = props.noteId && formNote.id !== props.noteId && props.editorNoteStatuses[props.noteId] === 'saving';
|
|
||||||
|
|
||||||
const styles = styles_(props);
|
|
||||||
|
|
||||||
const markupToHtml = useCallback(async (markupLanguage:number, md:string, options:any = null):Promise<any> => {
|
|
||||||
md = md || '';
|
|
||||||
|
|
||||||
const theme = themeStyle(props.theme);
|
|
||||||
|
|
||||||
md = await Note.replaceResourceInternalToExternalLinks(md, { useAbsolutePaths: true });
|
|
||||||
|
|
||||||
const markupToHtml = markupLanguageUtils.newMarkupToHtml({
|
|
||||||
resourceBaseUrl: `file://${Setting.value('resourceDir')}/`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await markupToHtml.render(markupLanguage, md, theme, Object.assign({}, {
|
|
||||||
codeTheme: theme.codeThemeCss,
|
|
||||||
// userCss: this.props.customCss ? this.props.customCss : '',
|
|
||||||
// resources: await shared.attachedResources(noteBody),
|
|
||||||
resources: [],
|
|
||||||
postMessageSyntax: 'ipcProxySendToHost',
|
|
||||||
splitted: true,
|
|
||||||
externalAssetsOnly: true,
|
|
||||||
}, options));
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [props.theme]);
|
|
||||||
|
|
||||||
const allAssets = useCallback(async (markupLanguage:number):Promise<any[]> => {
|
|
||||||
const theme = themeStyle(props.theme);
|
|
||||||
|
|
||||||
const markupToHtml = markupLanguageUtils.newMarkupToHtml({
|
|
||||||
resourceBaseUrl: `file://${Setting.value('resourceDir')}/`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return markupToHtml.allAssets(markupLanguage, theme);
|
|
||||||
}, [props.theme]);
|
|
||||||
|
|
||||||
const joplinHtml = useCallback(async (type:string) => {
|
|
||||||
if (type === 'checkbox') {
|
|
||||||
const result = await markupToHtml(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, '- [ ] xxxxxREMOVExxxxx', {
|
|
||||||
bodyOnly: true,
|
|
||||||
externalAssetsOnly: true,
|
|
||||||
});
|
|
||||||
const html = result.html
|
|
||||||
.replace(/xxxxxREMOVExxxxx/m, ' ')
|
|
||||||
.replace(/<ul.*?>/, '')
|
|
||||||
.replace(/<\/ul>/, '');
|
|
||||||
return { ...result, html: html };
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Invalid type:${type}`);
|
|
||||||
}, [markupToHtml]);
|
|
||||||
|
|
||||||
const handleProvisionalFlag = useCallback(() => {
|
|
||||||
if (props.isProvisional) {
|
|
||||||
props.dispatch({
|
|
||||||
type: 'NOTE_PROVISIONAL_FLAG_CLEAR',
|
|
||||||
id: formNote.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [props.isProvisional, formNote.id]);
|
|
||||||
|
|
||||||
const refreshResource = useCallback(async function(event) {
|
|
||||||
if (!defaultEditorState.value) return;
|
|
||||||
|
|
||||||
const resourceIds = await Note.linkedResourceIds(defaultEditorState.value);
|
|
||||||
if (resourceIds.indexOf(event.id) >= 0) {
|
|
||||||
clearResourceCache();
|
|
||||||
const e = {
|
|
||||||
...defaultEditorState,
|
|
||||||
resourceInfos: await attachedResources(defaultEditorState.value),
|
|
||||||
};
|
|
||||||
setDefaultEditorState(e);
|
|
||||||
}
|
|
||||||
}, [defaultEditorState]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
installResourceHandling(refreshResource);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
uninstallResourceHandling(refreshResource);
|
|
||||||
};
|
|
||||||
}, [defaultEditorState]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// This is not exactly a hack but a bit ugly. If the note was changed (willChangeId > 0) but not
|
|
||||||
// yet saved, we need to save it now before the component is unmounted. However, we can't put
|
|
||||||
// formNote in the dependency array or that effect will run every time the note changes. We only
|
|
||||||
// want to run it once on unmount. So because of that we need to use that formNoteRef.
|
|
||||||
return () => {
|
|
||||||
isMountedRef.current = false;
|
|
||||||
saveNoteIfWillChange(formNoteRef.current, editorRef, props.dispatch);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Check that synchronisation has just finished - and
|
|
||||||
// if the note has never been changed, we reload it.
|
|
||||||
// If the note has already been changed, it's a conflict
|
|
||||||
// that's already been handled by the synchronizer.
|
|
||||||
|
|
||||||
if (!prevSyncStarted) return () => {};
|
|
||||||
if (props.syncStarted) return () => {};
|
|
||||||
if (formNote.hasChanged) return () => {};
|
|
||||||
|
|
||||||
reg.logger().debug('Sync has finished and note has never been changed - reloading it');
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const loadNote = async () => {
|
|
||||||
const n = await Note.load(props.noteId);
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
// Normally should not happened because if the note has been deleted via sync
|
|
||||||
// it would not have been loaded in the editor (due to note selection changing
|
|
||||||
// on delete)
|
|
||||||
if (!n) {
|
|
||||||
reg.logger().warn('Trying to reload note that has been deleted:', props.noteId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await initNoteState(n, setFormNote, setDefaultEditorState);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadNote();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [prevSyncStarted, props.syncStarted, formNote]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!props.noteId) return () => {};
|
|
||||||
|
|
||||||
if (formNote.id === props.noteId) return () => {};
|
|
||||||
|
|
||||||
if (waitingToSaveNote) return () => {};
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
reg.logger().debug('Loading existing note', props.noteId);
|
|
||||||
|
|
||||||
saveNoteIfWillChange(formNote, editorRef, props.dispatch);
|
|
||||||
|
|
||||||
function handleAutoFocus(noteIsTodo:boolean) {
|
|
||||||
if (!props.isProvisional) return;
|
|
||||||
|
|
||||||
const focusSettingName = noteIsTodo ? 'newTodoFocus' : 'newNoteFocus';
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (Setting.value(focusSettingName) === 'title') {
|
|
||||||
if (titleInputRef.current) titleInputRef.current.focus();
|
|
||||||
} else {
|
|
||||||
if (editorRef.current) editorRef.current.execCommand({ name: 'focus' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadNote() {
|
|
||||||
const n = await Note.load(props.noteId);
|
|
||||||
if (cancelled) return;
|
|
||||||
if (!n) throw new Error(`Cannot find note with ID: ${props.noteId}`);
|
|
||||||
reg.logger().debug('Loaded note:', n);
|
|
||||||
await initNoteState(n, setFormNote, setDefaultEditorState);
|
|
||||||
|
|
||||||
handleAutoFocus(!!n.is_todo);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadNote();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [props.noteId, props.isProvisional, formNote, waitingToSaveNote]);
|
|
||||||
|
|
||||||
const onFieldChange = useCallback((field:string, value:any, changeId: number = 0) => {
|
|
||||||
if (!isMountedRef.current) {
|
|
||||||
// When the component is unmounted, various actions can happen which can
|
|
||||||
// trigger onChange events, for example the textarea might be cleared.
|
|
||||||
// We need to ignore these events, otherwise the note is going to be saved
|
|
||||||
// with an invalid body.
|
|
||||||
reg.logger().debug('Skipping change event because the component is unmounted');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleProvisionalFlag();
|
|
||||||
|
|
||||||
const change = field === 'body' ? {
|
|
||||||
bodyEditorContent: value,
|
|
||||||
} : {
|
|
||||||
title: value,
|
|
||||||
};
|
|
||||||
|
|
||||||
const newNote = {
|
|
||||||
...formNote,
|
|
||||||
...change,
|
|
||||||
bodyWillChangeId: 0,
|
|
||||||
bodyChangeId: 0,
|
|
||||||
hasChanged: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (changeId !== null && field === 'body' && formNote.bodyWillChangeId !== changeId) {
|
|
||||||
// Note was changed, but another note was loaded before save - skipping
|
|
||||||
// The previously loaded note, that was modified, will be saved via saveNoteIfWillChange()
|
|
||||||
} else {
|
|
||||||
setFormNote(newNote);
|
|
||||||
scheduleSaveNote(newNote, props.dispatch);
|
|
||||||
}
|
|
||||||
}, [handleProvisionalFlag, formNote]);
|
|
||||||
|
|
||||||
const onBodyChange = useCallback((event:OnChangeEvent) => onFieldChange('body', event.content, event.changeId), [onFieldChange]);
|
|
||||||
|
|
||||||
const onTitleChange = useCallback((event:any) => onFieldChange('title', event.target.value), [onFieldChange]);
|
|
||||||
|
|
||||||
const onBodyWillChange = useCallback((event:any) => {
|
|
||||||
handleProvisionalFlag();
|
|
||||||
|
|
||||||
setFormNote(prev => {
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
bodyWillChangeId: event.changeId,
|
|
||||||
hasChanged: true,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
props.dispatch({
|
|
||||||
type: 'EDITOR_NOTE_STATUS_SET',
|
|
||||||
id: formNote.id,
|
|
||||||
status: 'saving',
|
|
||||||
});
|
|
||||||
}, [formNote, handleProvisionalFlag]);
|
|
||||||
|
|
||||||
const onMessage = useCallback(async (event:any) => {
|
|
||||||
const msg = event.name;
|
|
||||||
const args = event.args;
|
|
||||||
|
|
||||||
console.info('onMessage', msg, args);
|
|
||||||
|
|
||||||
if (msg === 'setMarkerCount') {
|
|
||||||
// const ls = Object.assign({}, this.state.localSearch);
|
|
||||||
// ls.resultCount = arg0;
|
|
||||||
// ls.searching = false;
|
|
||||||
// this.setState({ localSearch: ls });
|
|
||||||
} else if (msg.indexOf('markForDownload:') === 0) {
|
|
||||||
// const s = msg.split(':');
|
|
||||||
// if (s.length < 2) throw new Error(`Invalid message: ${msg}`);
|
|
||||||
// ResourceFetcher.instance().markForDownload(s[1]);
|
|
||||||
} else if (msg === 'percentScroll') {
|
|
||||||
// this.ignoreNextEditorScroll_ = true;
|
|
||||||
// this.setEditorPercentScroll(arg0);
|
|
||||||
} else if (msg === 'contextMenu') {
|
|
||||||
// const itemType = arg0 && arg0.type;
|
|
||||||
|
|
||||||
// const menu = new Menu();
|
|
||||||
|
|
||||||
// if (itemType === 'image' || itemType === 'resource') {
|
|
||||||
// const resource = await Resource.load(arg0.resourceId);
|
|
||||||
// const resourcePath = Resource.fullPath(resource);
|
|
||||||
|
|
||||||
// menu.append(
|
|
||||||
// new MenuItem({
|
|
||||||
// label: _('Open...'),
|
|
||||||
// click: async () => {
|
|
||||||
// const ok = bridge().openExternal(`file://${resourcePath}`);
|
|
||||||
// if (!ok) bridge().showErrorMessageBox(_('This file could not be opened: %s', resourcePath));
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
// );
|
|
||||||
|
|
||||||
// menu.append(
|
|
||||||
// new MenuItem({
|
|
||||||
// label: _('Save as...'),
|
|
||||||
// click: async () => {
|
|
||||||
// const filePath = bridge().showSaveDialog({
|
|
||||||
// defaultPath: resource.filename ? resource.filename : resource.title,
|
|
||||||
// });
|
|
||||||
// if (!filePath) return;
|
|
||||||
// await fs.copy(resourcePath, filePath);
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
// );
|
|
||||||
|
|
||||||
// menu.append(
|
|
||||||
// new MenuItem({
|
|
||||||
// label: _('Copy path to clipboard'),
|
|
||||||
// click: async () => {
|
|
||||||
// clipboard.writeText(toSystemSlashes(resourcePath));
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
// );
|
|
||||||
// } else if (itemType === 'text') {
|
|
||||||
// menu.append(
|
|
||||||
// new MenuItem({
|
|
||||||
// label: _('Copy'),
|
|
||||||
// click: async () => {
|
|
||||||
// clipboard.writeText(arg0.textToCopy);
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
// );
|
|
||||||
// } else if (itemType === 'link') {
|
|
||||||
// menu.append(
|
|
||||||
// new MenuItem({
|
|
||||||
// label: _('Copy Link Address'),
|
|
||||||
// click: async () => {
|
|
||||||
// clipboard.writeText(arg0.textToCopy);
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
// );
|
|
||||||
// } else {
|
|
||||||
// reg.logger().error(`Unhandled item type: ${itemType}`);
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// menu.popup(bridge().window());
|
|
||||||
} else if (msg === 'openInternal') {
|
|
||||||
const resourceUrlInfo = urlUtils.parseResourceUrl(args.url);
|
|
||||||
const itemId = resourceUrlInfo.itemId;
|
|
||||||
const item = await BaseItem.loadItemById(itemId);
|
|
||||||
|
|
||||||
if (!item) throw new Error(`No item with ID ${itemId}`);
|
|
||||||
|
|
||||||
if (item.type_ === BaseModel.TYPE_RESOURCE) {
|
|
||||||
const localState = await Resource.localState(item);
|
|
||||||
if (localState.fetch_status !== Resource.FETCH_STATUS_DONE || !!item.encryption_blob_encrypted) {
|
|
||||||
if (localState.fetch_status === Resource.FETCH_STATUS_ERROR) {
|
|
||||||
bridge().showErrorMessageBox(`${_('There was an error downloading this attachment:')}\n\n${localState.fetch_error}`);
|
|
||||||
} else {
|
|
||||||
bridge().showErrorMessageBox(_('This attachment is not downloaded or not decrypted yet'));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const filePath = Resource.fullPath(item);
|
|
||||||
bridge().openItem(filePath);
|
|
||||||
} else if (item.type_ === BaseModel.TYPE_NOTE) {
|
|
||||||
props.dispatch({
|
|
||||||
type: 'FOLDER_AND_NOTE_SELECT',
|
|
||||||
folderId: item.parent_id,
|
|
||||||
noteId: item.id,
|
|
||||||
hash: resourceUrlInfo.hash,
|
|
||||||
historyAction: 'goto',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error(`Unsupported item type: ${item.type_}`);
|
|
||||||
}
|
|
||||||
} else if (msg.indexOf('#') === 0) {
|
|
||||||
// This is an internal anchor, which is handled by the WebView so skip this case
|
|
||||||
} else if (msg === 'openExternal') {
|
|
||||||
if (args.url.indexOf('file://') === 0) {
|
|
||||||
// When using the file:// protocol, openExternal doesn't work (does nothing) with URL-encoded paths
|
|
||||||
bridge().openExternal(urlDecode(args.url));
|
|
||||||
} else {
|
|
||||||
bridge().openExternal(args.url);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
bridge().showErrorMessageBox(_('Unsupported link or message: %s', msg));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const introductionPostLinkClick = useCallback(() => {
|
|
||||||
bridge().openExternal('https://www.patreon.com/posts/34246624');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (props.selectedNoteIds.length > 1) {
|
|
||||||
return <MultiNoteActions
|
|
||||||
theme={props.theme}
|
|
||||||
selectedNoteIds={props.selectedNoteIds}
|
|
||||||
notes={props.notes}
|
|
||||||
dispatch={props.dispatch}
|
|
||||||
watchedNoteFiles={props.watchedNoteFiles}
|
|
||||||
style={props.style}
|
|
||||||
/>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const editorProps = {
|
|
||||||
ref: editorRef,
|
|
||||||
style: styles.tinyMCE,
|
|
||||||
onChange: onBodyChange,
|
|
||||||
onWillChange: onBodyWillChange,
|
|
||||||
onMessage: onMessage,
|
|
||||||
defaultEditorState: defaultEditorState,
|
|
||||||
markupToHtml: markupToHtml,
|
|
||||||
allAssets: allAssets,
|
|
||||||
attachResources: attachResources,
|
|
||||||
disabled: waitingToSaveNote,
|
|
||||||
joplinHtml: joplinHtml,
|
|
||||||
theme: props.theme,
|
|
||||||
};
|
|
||||||
|
|
||||||
let editor = null;
|
|
||||||
|
|
||||||
if (props.editor === 'TinyMCE') {
|
|
||||||
editor = <TinyMCE {...editorProps}/>;
|
|
||||||
textEditorUtils_ = tinyMceUtils;
|
|
||||||
} else if (props.editor === 'PlainEditor') {
|
|
||||||
editor = <PlainEditor {...editorProps}/>;
|
|
||||||
textEditorUtils_ = plainEditorUtils;
|
|
||||||
} else {
|
|
||||||
throw new Error(`Invalid editor: ${props.editor}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={props.style}>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
|
||||||
<div style={styles.warningBanner}>
|
|
||||||
This is an experimental WYSIWYG editor for evaluation only. Please do not use with important notes as you may lose some data! See the <a style={styles.urlColor} onClick={introductionPostLinkClick} href="#">introduction post</a> for more information.
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex' }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
ref={titleInputRef}
|
|
||||||
disabled={waitingToSaveNote}
|
|
||||||
placeholder={props.isProvisional ? _('Creating new %s...', formNote.is_todo ? _('to-do') : _('note')) : ''}
|
|
||||||
style={styles.titleInput}
|
|
||||||
onChange={onTitleChange}
|
|
||||||
value={formNote.title}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flex: 1 }}>
|
|
||||||
{editor}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
NoteText2 as NoteText2Component,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapStateToProps = (state:any) => {
|
|
||||||
const noteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
noteId: noteId,
|
|
||||||
notes: state.notes,
|
|
||||||
selectedNoteIds: state.selectedNoteIds,
|
|
||||||
isProvisional: state.provisionalNoteIds.includes(noteId),
|
|
||||||
editorNoteStatuses: state.editorNoteStatuses,
|
|
||||||
syncStarted: state.syncStarted,
|
|
||||||
theme: state.settings.theme,
|
|
||||||
watchedNoteFiles: state.watchedNoteFiles,
|
|
||||||
windowCommand: state.windowCommand,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(NoteText2);
|
|
@ -162,7 +162,7 @@ class NoteTextViewerComponent extends React.Component {
|
|||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
render() {
|
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>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
148
ElectronClient/gui/NoteToolbar/NoteToolbar.tsx
Normal file
148
ElectronClient/gui/NoteToolbar/NoteToolbar.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
const { buildStyle } = require('../../theme.js');
|
||||||
|
const Toolbar = require('../Toolbar.min.js');
|
||||||
|
const Note = require('lib/models/Note');
|
||||||
|
const { time } = require('lib/time-utils.js');
|
||||||
|
const { _ } = require('lib/locale');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// const { substrWithEllipsis } = require('lib/string-utils');
|
||||||
|
// const Folder = require('lib/models/Folder');
|
||||||
|
// const { MarkupToHtml } = require('lib/joplin-renderer');
|
||||||
|
|
||||||
|
interface ButtonClickEvent {
|
||||||
|
name: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NoteToolbarProps {
|
||||||
|
theme: number,
|
||||||
|
style: any,
|
||||||
|
watchedNoteFiles: string[],
|
||||||
|
note: any,
|
||||||
|
dispatch: Function,
|
||||||
|
onButtonClick(event:ButtonClickEvent):void,
|
||||||
|
}
|
||||||
|
|
||||||
|
function styles_(props:NoteToolbarProps) {
|
||||||
|
return buildStyle('NoteToolbar', props.theme, (/* theme:any*/) => {
|
||||||
|
return {
|
||||||
|
root: {
|
||||||
|
...props.style,
|
||||||
|
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToolbarItems(note:any, watchedNoteFiles:string[], dispatch:Function, onButtonClick:Function) {
|
||||||
|
const toolbarItems = [];
|
||||||
|
|
||||||
|
// TODO: add these two items
|
||||||
|
|
||||||
|
// if (props.folder && ['Search', 'Tag', 'SmartFilter'].includes(props.notesParentType)) {
|
||||||
|
// toolbarItems.push({
|
||||||
|
// title: _('In: %s', substrWithEllipsis(props.folder.title, 0, 16)),
|
||||||
|
// iconName: 'fa-book',
|
||||||
|
// onClick: () => {
|
||||||
|
// props.dispatch({
|
||||||
|
// type: 'FOLDER_AND_NOTE_SELECT',
|
||||||
|
// folderId: props.folder.id,
|
||||||
|
// noteId: props.formNote.id,
|
||||||
|
// });
|
||||||
|
// Folder.expandTree(props.folders, props.folder.parent_id);
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (props.historyNotes.length) {
|
||||||
|
// toolbarItems.push({
|
||||||
|
// tooltip: _('Back'),
|
||||||
|
// iconName: 'fa-arrow-left',
|
||||||
|
// onClick: () => {
|
||||||
|
// if (!props.historyNotes.length) return;
|
||||||
|
|
||||||
|
// const lastItem = props.historyNotes[props.historyNotes.length - 1];
|
||||||
|
|
||||||
|
// props.dispatch({
|
||||||
|
// type: 'FOLDER_AND_NOTE_SELECT',
|
||||||
|
// folderId: lastItem.parent_id,
|
||||||
|
// noteId: lastItem.id,
|
||||||
|
// historyNoteAction: 'pop',
|
||||||
|
// });
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (watchedNoteFiles.indexOf(note.id) >= 0) {
|
||||||
|
toolbarItems.push({
|
||||||
|
tooltip: _('Click to stop external editing'),
|
||||||
|
title: _('Watching...'),
|
||||||
|
iconName: 'fa-external-link',
|
||||||
|
onClick: () => {
|
||||||
|
onButtonClick({ name: 'stopExternalEditing' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toolbarItems.push({
|
||||||
|
tooltip: _('Edit in external editor'),
|
||||||
|
iconName: 'fa-external-link',
|
||||||
|
onClick: () => {
|
||||||
|
onButtonClick({ name: 'startExternalEditing' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toolbarItems.push({
|
||||||
|
tooltip: _('Tags'),
|
||||||
|
iconName: 'fa-tags',
|
||||||
|
onClick: () => {
|
||||||
|
onButtonClick({ name: 'setTags' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (note.is_todo) {
|
||||||
|
const item:any = {
|
||||||
|
iconName: 'fa-clock-o',
|
||||||
|
enabled: !note.todo_completed,
|
||||||
|
onClick: () => {
|
||||||
|
onButtonClick({ name: 'setAlarm' });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (Note.needAlarm(note)) {
|
||||||
|
item.title = time.formatMsToLocal(note.todo_due);
|
||||||
|
} else {
|
||||||
|
item.tooltip = _('Set alarm');
|
||||||
|
}
|
||||||
|
toolbarItems.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
toolbarItems.push({
|
||||||
|
tooltip: _('Note properties'),
|
||||||
|
iconName: 'fa-info-circle',
|
||||||
|
onClick: () => {
|
||||||
|
dispatch({
|
||||||
|
type: 'WINDOW_COMMAND',
|
||||||
|
name: 'commandNoteProperties',
|
||||||
|
noteId: note.id,
|
||||||
|
onRevisionLinkClick: () => {
|
||||||
|
onButtonClick({ name: 'showRevisions' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return toolbarItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NoteToolbar(props:NoteToolbarProps) {
|
||||||
|
const styles = styles_(props);
|
||||||
|
|
||||||
|
const toolbarItems = useToolbarItems(props.note, props.watchedNoteFiles, props.dispatch, props.onButtonClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Toolbar style={styles.root} items={toolbarItems} />
|
||||||
|
);
|
||||||
|
}
|
@ -69,8 +69,9 @@ const ResourceTable: React.FC<ResourceTable> = (props: ResourceTable) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cellStyle = {
|
const cellStyle = {
|
||||||
...props.theme.textStyleMinor,
|
...props.theme.textStyle,
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
color: props.theme.colorFaded,
|
||||||
width: 1,
|
width: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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) {
|
||||||
|
@ -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';
|
||||||
|
@ -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;
|
||||||
|
@ -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 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
15
ElectronClient/gui/utils/NoteText.js
Normal file
15
ElectronClient/gui/utils/NoteText.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
'use strict';
|
||||||
|
Object.defineProperty(exports, '__esModule', { value: true });
|
||||||
|
const joplinRendererUtils = require('lib/joplin-renderer').utils;
|
||||||
|
const Resource = require('lib/models/Resource');
|
||||||
|
function resourcesStatus(resourceInfos) {
|
||||||
|
let lowestIndex = joplinRendererUtils.resourceStatusIndex('ready');
|
||||||
|
for (const id in resourceInfos) {
|
||||||
|
const s = joplinRendererUtils.resourceStatus(Resource, resourceInfos[id]);
|
||||||
|
const idx = joplinRendererUtils.resourceStatusIndex(s);
|
||||||
|
if (idx < lowestIndex) { lowestIndex = idx; }
|
||||||
|
}
|
||||||
|
return joplinRendererUtils.resourceStatusName(lowestIndex);
|
||||||
|
}
|
||||||
|
exports.resourcesStatus = resourcesStatus;
|
||||||
|
// # sourceMappingURL=NoteText.js.map
|
@ -1,32 +0,0 @@
|
|||||||
const joplinRendererUtils = require('lib/joplin-renderer').utils;
|
|
||||||
const Resource = require('lib/models/Resource');
|
|
||||||
|
|
||||||
export interface DefaultEditorState {
|
|
||||||
value: string,
|
|
||||||
markupLanguage: number, // MarkupToHtml.MARKUP_LANGUAGE_XXX
|
|
||||||
resourceInfos: any,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OnChangeEvent {
|
|
||||||
changeId: number,
|
|
||||||
content: any,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextEditorUtils {
|
|
||||||
editorContentToHtml(content:any):Promise<string>,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EditorCommand {
|
|
||||||
name: string,
|
|
||||||
value: any,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resourcesStatus(resourceInfos:any) {
|
|
||||||
let lowestIndex = joplinRendererUtils.resourceStatusIndex('ready');
|
|
||||||
for (const id in resourceInfos) {
|
|
||||||
const s = joplinRendererUtils.resourceStatus(Resource, resourceInfos[id]);
|
|
||||||
const idx = joplinRendererUtils.resourceStatusIndex(s);
|
|
||||||
if (idx < lowestIndex) lowestIndex = idx;
|
|
||||||
}
|
|
||||||
return joplinRendererUtils.resourceStatusName(lowestIndex);
|
|
||||||
}
|
|
33
ElectronClient/package-lock.json
generated
33
ElectronClient/package-lock.json
generated
@ -2771,6 +2771,15 @@
|
|||||||
"object-visit": "^1.0.0"
|
"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",
|
||||||
|
@ -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",
|
||||||
|
@ -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 = {
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
27
ReactNativeClient/lib/hooks/useImperativeHandlerDebugger.ts
Normal file
27
ReactNativeClient/lib/hooks/useImperativeHandlerDebugger.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import usePrevious from './usePrevious';
|
||||||
|
import { useImperativeHandle } from 'react';
|
||||||
|
|
||||||
|
export default function useImperativeHandleDebugger(ref:any, effectHook:any, dependencies:any, dependencyNames:any[] = []) {
|
||||||
|
const previousDeps = usePrevious(dependencies, []);
|
||||||
|
|
||||||
|
const changedDeps = dependencies.reduce((accum:any, dependency:any, index:any) => {
|
||||||
|
if (dependency !== previousDeps[index]) {
|
||||||
|
const keyName = dependencyNames[index] || index;
|
||||||
|
return {
|
||||||
|
...accum,
|
||||||
|
[keyName]: {
|
||||||
|
before: previousDeps[index],
|
||||||
|
after: dependency,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return accum;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
if (Object.keys(changedDeps).length) {
|
||||||
|
console.log('[use-imperativeHandler-debugger] ', changedDeps);
|
||||||
|
}
|
||||||
|
|
||||||
|
useImperativeHandle(ref, effectHook, dependencies);
|
||||||
|
}
|
9
ReactNativeClient/lib/hooks/usePrevious.ts
Normal file
9
ReactNativeClient/lib/hooks/usePrevious.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export default function usePrevious(value: any, initialValue:any = null): any {
|
||||||
|
const ref = useRef(initialValue);
|
||||||
|
useEffect(() => {
|
||||||
|
ref.current = value;
|
||||||
|
});
|
||||||
|
return ref.current;
|
||||||
|
}
|
@ -29,10 +29,10 @@ class HtmlToHtml {
|
|||||||
|
|
||||||
splitHtml(html) {
|
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),
|
||||||
|
@ -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) {
|
||||||
|
@ -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';
|
||||||
|
@ -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_ = {};
|
||||||
|
@ -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);
|
||||||
};
|
};
|
||||||
|
@ -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": "."
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user