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