mirror of
https://github.com/facebook/lexical.git
synced 2025-05-17 15:18:47 +08:00
Refactor repo
WIP WIP Cleanup Cleanup eslint Fix Fix selection issues + text removal Fix a few bugs and add GCC compilation Remove node_modules Remove node_modules
This commit is contained in:

committed by
acywatson

parent
f62961a30d
commit
ce3520006e
3
.eslintignore
Normal file
3
.eslintignore
Normal file
@ -0,0 +1,3 @@
|
||||
packages/**/dist/*.js
|
||||
packages/**/config/*.js
|
||||
**/node_modules
|
119
.eslintrc.js
Normal file
119
.eslintrc.js
Normal file
@ -0,0 +1,119 @@
|
||||
'use strict';
|
||||
|
||||
const restrictedGlobals = require('confusing-browser-globals');
|
||||
|
||||
const OFF = 0;
|
||||
const ERROR = 2
|
||||
|
||||
module.exports = {
|
||||
extends: ['fbjs', 'prettier'],
|
||||
|
||||
// Stop ESLint from looking for a configuration file in parent folders
|
||||
root: true,
|
||||
|
||||
plugins: [
|
||||
'jest',
|
||||
'no-for-of-loops',
|
||||
'no-function-declare-after-return',
|
||||
'react',
|
||||
],
|
||||
|
||||
parser: 'babel-eslint',
|
||||
parserOptions: {
|
||||
ecmaVersion: 8,
|
||||
sourceType: 'script',
|
||||
ecmaFeatures: {
|
||||
experimentalObjectRestSpread: true,
|
||||
},
|
||||
},
|
||||
// We're stricter than the default config, mostly. We'll override a few rules
|
||||
// and then enable some React specific ones.
|
||||
rules: {
|
||||
'accessor-pairs': OFF,
|
||||
'brace-style': [ERROR, '1tbs'],
|
||||
'consistent-return': OFF,
|
||||
'dot-location': [ERROR, 'property'],
|
||||
// We use console['error']() as a signal to not transform it:
|
||||
'dot-notation': [ERROR, {allowPattern: '^(error|warn)$'}],
|
||||
'eol-last': ERROR,
|
||||
eqeqeq: [ERROR, 'allow-null'],
|
||||
indent: OFF,
|
||||
'jsx-quotes': [ERROR, 'prefer-double'],
|
||||
'keyword-spacing': [ERROR, {after: true, before: true}],
|
||||
'no-bitwise': OFF,
|
||||
'no-console': OFF,
|
||||
'no-inner-declarations': [ERROR, 'functions'],
|
||||
'no-multi-spaces': ERROR,
|
||||
'no-restricted-globals': [ERROR].concat(restrictedGlobals),
|
||||
'no-restricted-syntax': [ERROR, 'WithStatement'],
|
||||
'no-shadow': ERROR,
|
||||
'no-unused-expressions': ERROR,
|
||||
'no-unused-vars': [ERROR, {args: 'none'}],
|
||||
'no-use-before-define': OFF,
|
||||
'no-useless-concat': OFF,
|
||||
quotes: [ERROR, 'single', {avoidEscape: true, allowTemplateLiterals: true}],
|
||||
'space-before-blocks': ERROR,
|
||||
'space-before-function-paren': OFF,
|
||||
'valid-typeof': [ERROR, {requireStringLiterals: true}],
|
||||
// Flow fails with with non-string literal keys
|
||||
'no-useless-computed-key': OFF,
|
||||
|
||||
// We apply these settings to files that should run on Node.
|
||||
// They can't use JSX or ES6 modules, and must be in strict mode.
|
||||
// They can, however, use other ES6 features.
|
||||
// (Note these rules are overridden later for source files.)
|
||||
'no-var': ERROR,
|
||||
strict: ERROR,
|
||||
|
||||
// Enforced by Prettier
|
||||
// TODO: Prettier doesn't handle long strings or long comments. Not a big
|
||||
// deal. But I turned it off because loading the plugin causes some obscure
|
||||
// syntax error and it didn't seem worth investigating.
|
||||
'max-len': OFF,
|
||||
// Prettier forces semicolons in a few places
|
||||
'flowtype/object-type-delimiter': OFF,
|
||||
|
||||
// React & JSX
|
||||
// Our transforms set this automatically
|
||||
'react/jsx-boolean-value': [ERROR, 'always'],
|
||||
'react/jsx-no-undef': ERROR,
|
||||
// We don't care to do this
|
||||
'react/jsx-sort-prop-types': OFF,
|
||||
'react/jsx-space-before-closing': ERROR,
|
||||
'react/jsx-uses-react': ERROR,
|
||||
'react/no-is-mounted': OFF,
|
||||
// This isn't useful in our test code
|
||||
'react/react-in-jsx-scope': ERROR,
|
||||
'react/self-closing-comp': ERROR,
|
||||
// We don't care to do this
|
||||
'react/jsx-wrap-multilines': [
|
||||
ERROR,
|
||||
{declaration: false, assignment: false},
|
||||
],
|
||||
|
||||
// Prevent for...of loops because they require a Symbol polyfill.
|
||||
// You can disable this rule for code that isn't shipped (e.g. build scripts and tests).
|
||||
'no-for-of-loops/no-for-of-loops': ERROR,
|
||||
|
||||
// Prevent function declarations after return statements
|
||||
'no-function-declare-after-return/no-function-declare-after-return': ERROR,
|
||||
},
|
||||
|
||||
overrides: [
|
||||
{
|
||||
// We apply these settings to the source files that get compiled.
|
||||
// They can use all features including JSX (but shouldn't use `var`).
|
||||
files: 'packages/*/src/**/*.js',
|
||||
parser: 'babel-eslint',
|
||||
parserOptions: {
|
||||
ecmaVersion: 8,
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
'no-var': ERROR,
|
||||
'prefer-const': ERROR,
|
||||
strict: OFF,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
14
.gitignore
vendored
14
.gitignore
vendored
@ -1,17 +1,9 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
build
|
||||
dist
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
|
11
.prettierrc.js
Normal file
11
.prettierrc.js
Normal file
@ -0,0 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
bracketSpacing: false,
|
||||
singleQuote: true,
|
||||
jsxBracketSameLine: true,
|
||||
trailingComma: 'es5',
|
||||
printWidth: 80,
|
||||
parser: 'babel',
|
||||
trailingComma: 'all',
|
||||
};
|
69
README.md
69
README.md
@ -1,68 +1,3 @@
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
# Outline JS
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `yarn start`
|
||||
|
||||
Runs the app in the development mode.<br />
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.<br />
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `yarn test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.<br />
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `yarn build`
|
||||
|
||||
Builds the app for production to the `build` folder.<br />
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.<br />
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `yarn eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
|
||||
|
||||
### `yarn build` fails to minify
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify
|
||||
Outline is a fast, lightweight, rich-text editor library for React.
|
60
package.json
60
package.json
@ -1,34 +1,38 @@
|
||||
{
|
||||
"name": "outline",
|
||||
"version": "0.1.0",
|
||||
"name": "outline-example",
|
||||
"description": "Outline is a fast, lightweight, rich-text editor for React",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^7.1.2",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-scripts": "4.0.0"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
"start": "npm run build && npm start --prefix packages/outline-example",
|
||||
"build": "node scripts/build.js",
|
||||
"build-prod": "node scripts/build.js --prod",
|
||||
"watch": "node scripts/build.js --watch"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
"devDependencies": {
|
||||
"@babel/plugin-transform-flow-strip-types": "^7.12.1",
|
||||
"@babel/preset-react": "^7.12.5",
|
||||
"@rollup/plugin-babel": "^5.2.1",
|
||||
"@rollup/plugin-node-resolve": "^10.0.0",
|
||||
"confusing-browser-globals": "^1.0.10",
|
||||
"eslint": "^7.12.1",
|
||||
"eslint-config-fbjs": "^3.1.1",
|
||||
"eslint-config-prettier": "^6.15.0",
|
||||
"eslint-plugin-babel": "^5.3.1",
|
||||
"eslint-plugin-flowtype": "^5.2.0",
|
||||
"eslint-plugin-jest": "^24.1.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-no-for-of-loops": "^1.0.1",
|
||||
"eslint-plugin-no-function-declare-after-return": "^1.1.0",
|
||||
"eslint-plugin-react": "^7.21.5",
|
||||
"flow": "^0.2.3",
|
||||
"google-closure-compiler": "^20201006.0.0",
|
||||
"jest": "26.6.0",
|
||||
"minimist": "^1.2.5",
|
||||
"prettier": "^2.1.2",
|
||||
"rollup": "^2.33.1"
|
||||
}
|
||||
}
|
||||
|
5
packages/outline-emoji-plugin/package.json
Normal file
5
packages/outline-emoji-plugin/package.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "outline-emoji-plugin",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js"
|
||||
}
|
@ -1,36 +1,36 @@
|
||||
import {useEffect} from 'react';
|
||||
|
||||
const baseEmojiStyle =
|
||||
"background-size: 16px 16px;" +
|
||||
"height: 16px;" +
|
||||
"width: 16px;" +
|
||||
"background-position: center;" +
|
||||
"background-repeat: no-repeat;" +
|
||||
"display: inline-block;" +
|
||||
"margin: 0 1px;" +
|
||||
"text-align: center;" +
|
||||
"vertical-align: middle;";
|
||||
'background-size: 16px 16px;' +
|
||||
'height: 16px;' +
|
||||
'width: 16px;' +
|
||||
'background-position: center;' +
|
||||
'background-repeat: no-repeat;' +
|
||||
'display: inline-block;' +
|
||||
'margin: 0 1px;' +
|
||||
'text-align: center;' +
|
||||
'vertical-align: middle;';
|
||||
|
||||
const happySmile =
|
||||
baseEmojiStyle +
|
||||
"background-image: url(https://static.xx.fbcdn.net/images/emoji.php/v9/t4c/1/16/1f642.png);";
|
||||
'background-image: url(https://static.xx.fbcdn.net/images/emoji.php/v9/t4c/1/16/1f642.png);';
|
||||
const veryHappySmile =
|
||||
baseEmojiStyle +
|
||||
"background-image: url(https://static.xx.fbcdn.net/images/emoji.php/v9/t51/1/16/1f603.png);";
|
||||
'background-image: url(https://static.xx.fbcdn.net/images/emoji.php/v9/t51/1/16/1f603.png);';
|
||||
const unhappySmile =
|
||||
baseEmojiStyle +
|
||||
"background-image: url(https://static.xx.fbcdn.net/images/emoji.php/v9/tcb/1/16/1f641.png);";
|
||||
const heart =
|
||||
'background-image: url(https://static.xx.fbcdn.net/images/emoji.php/v9/tcb/1/16/1f641.png);';
|
||||
const heart =
|
||||
baseEmojiStyle +
|
||||
"background-image: url(https://static.xx.fbcdn.net/images/emoji.php/v9/t6c/1/16/2764.png);";
|
||||
'background-image: url(https://static.xx.fbcdn.net/images/emoji.php/v9/t6c/1/16/2764.png);';
|
||||
|
||||
const specialSpace = " ";
|
||||
const specialSpace = ' ';
|
||||
|
||||
const emojis = {
|
||||
":)": happySmile,
|
||||
":D": veryHappySmile,
|
||||
":(": unhappySmile,
|
||||
"<3": heart,
|
||||
':)': happySmile,
|
||||
':D': veryHappySmile,
|
||||
':(': unhappySmile,
|
||||
'<3': heart,
|
||||
};
|
||||
|
||||
function textNodeTransform(node, view) {
|
||||
@ -49,7 +49,7 @@ function textNodeTransform(node, view) {
|
||||
const emojiInline = view
|
||||
.createText(specialSpace)
|
||||
.setStyle(emojiStyle)
|
||||
.makeImmutable()
|
||||
.makeImmutable();
|
||||
|
||||
targetNode.replace(emojiInline);
|
||||
emojiInline.select();
|
||||
@ -62,7 +62,7 @@ function textNodeTransform(node, view) {
|
||||
export function useEmojiPlugin(outlineEditor) {
|
||||
useEffect(() => {
|
||||
if (outlineEditor !== null) {
|
||||
return outlineEditor.addTextTransform(textNodeTransform)
|
||||
return outlineEditor.addTextTransform(textNodeTransform);
|
||||
}
|
||||
}, [outlineEditor])
|
||||
}
|
||||
}, [outlineEditor]);
|
||||
}
|
736
packages/outline-example/config/webpack.config.js
Normal file
736
packages/outline-example/config/webpack.config.js
Normal file
@ -0,0 +1,736 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const resolve = require('resolve');
|
||||
const PnpWebpackPlugin = require('pnp-webpack-plugin');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
|
||||
const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
|
||||
const safePostCssParser = require('postcss-safe-parser');
|
||||
const ManifestPlugin = require('webpack-manifest-plugin');
|
||||
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
|
||||
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
|
||||
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
|
||||
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
|
||||
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
|
||||
const ESLintPlugin = require('eslint-webpack-plugin');
|
||||
const paths = require('./paths');
|
||||
const modules = require('./modules');
|
||||
const getClientEnvironment = require('./env');
|
||||
const ModuleNotFoundPlugin = require('react-dev-utils/ModuleNotFoundPlugin');
|
||||
const ForkTsCheckerWebpackPlugin = require('react-dev-utils/ForkTsCheckerWebpackPlugin');
|
||||
const typescriptFormatter = require('react-dev-utils/typescriptFormatter');
|
||||
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
|
||||
|
||||
const postcssNormalize = require('postcss-normalize');
|
||||
|
||||
const appPackageJson = require(paths.appPackageJson);
|
||||
|
||||
// Source maps are resource heavy and can cause out of memory issue for large source files.
|
||||
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';
|
||||
|
||||
const webpackDevClientEntry = require.resolve(
|
||||
'react-dev-utils/webpackHotDevClient'
|
||||
);
|
||||
const reactRefreshOverlayEntry = require.resolve(
|
||||
'react-dev-utils/refreshOverlayInterop'
|
||||
);
|
||||
|
||||
// Some apps do not need the benefits of saving a web request, so not inlining the chunk
|
||||
// makes for a smoother build process.
|
||||
const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false';
|
||||
|
||||
const imageInlineSizeLimit = parseInt(
|
||||
process.env.IMAGE_INLINE_SIZE_LIMIT || '10000'
|
||||
);
|
||||
|
||||
// Check if TypeScript is setup
|
||||
const useTypeScript = fs.existsSync(paths.appTsConfig);
|
||||
|
||||
// Get the path to the uncompiled service worker (if it exists).
|
||||
const swSrc = paths.swSrc;
|
||||
|
||||
// style files regexes
|
||||
const cssRegex = /\.css$/;
|
||||
const cssModuleRegex = /\.module\.css$/;
|
||||
const sassRegex = /\.(scss|sass)$/;
|
||||
const sassModuleRegex = /\.module\.(scss|sass)$/;
|
||||
|
||||
const hasJsxRuntime = (() => {
|
||||
if (process.env.DISABLE_NEW_JSX_TRANSFORM === 'true') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
require.resolve('react/jsx-runtime');
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
// 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';
|
||||
|
||||
// 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');
|
||||
|
||||
// We will provide `paths.publicUrlOrPath` 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.
|
||||
// Get environment variables to inject into our app.
|
||||
const env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1));
|
||||
|
||||
const shouldUseReactRefresh = env.raw.FAST_REFRESH;
|
||||
|
||||
// common function to get style loaders
|
||||
const getStyleLoaders = (cssOptions, preProcessor) => {
|
||||
const loaders = [
|
||||
isEnvDevelopment && require.resolve('style-loader'),
|
||||
isEnvProduction && {
|
||||
loader: MiniCssExtractPlugin.loader,
|
||||
// css is located in `static/css`, use '../../' to locate index.html folder
|
||||
// in production `paths.publicUrlOrPath` can be a relative path
|
||||
options: paths.publicUrlOrPath.startsWith('.')
|
||||
? { 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 : isEnvDevelopment,
|
||||
},
|
||||
},
|
||||
].filter(Boolean);
|
||||
if (preProcessor) {
|
||||
loaders.push(
|
||||
{
|
||||
loader: require.resolve('resolve-url-loader'),
|
||||
options: {
|
||||
sourceMap: isEnvProduction ? shouldUseSourceMap : isEnvDevelopment,
|
||||
root: paths.appSrc,
|
||||
},
|
||||
},
|
||||
{
|
||||
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:
|
||||
isEnvDevelopment && !shouldUseReactRefresh
|
||||
? [
|
||||
// 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'),
|
||||
//
|
||||
// When using the experimental react-refresh integration,
|
||||
// the webpack plugin takes care of injecting the dev client for us.
|
||||
webpackDevClientEntry,
|
||||
// Finally, this is your app's code:
|
||||
paths.appIndexJs,
|
||||
// We include the app code last so that if there is a runtime error during
|
||||
// initialization, it doesn't blow up the WebpackDevServer client, and
|
||||
// changing JS code would still trigger a refresh.
|
||||
]
|
||||
: paths.appIndexJs,
|
||||
output: {
|
||||
// The build folder.
|
||||
path: isEnvProduction ? paths.appBuild : undefined,
|
||||
// Add /* filename */ comments to generated require()s in the output.
|
||||
pathinfo: isEnvDevelopment,
|
||||
// There will be one main bundle, and one file per asynchronous chunk.
|
||||
// In development, it does not produce real files.
|
||||
filename: isEnvProduction
|
||||
? 'static/js/[name].[contenthash:8].js'
|
||||
: isEnvDevelopment && 'static/js/bundle.js',
|
||||
// TODO: remove this when upgrading to webpack 5
|
||||
futureEmitAssets: true,
|
||||
// There are also additional JS chunk files if you use code splitting.
|
||||
chunkFilename: isEnvProduction
|
||||
? 'static/js/[name].[contenthash:8].chunk.js'
|
||||
: isEnvDevelopment && 'static/js/[name].chunk.js',
|
||||
// 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.
|
||||
// We inferred the "public path" (such as / or /my-project) from homepage.
|
||||
publicPath: paths.publicUrlOrPath,
|
||||
// Point sourcemap entries to original disk location (format as URL on Windows)
|
||||
devtoolModuleFilenameTemplate: isEnvProduction
|
||||
? info =>
|
||||
path
|
||||
.relative(paths.appSrc, info.absoluteResourcePath)
|
||||
.replace(/\\/g, '/')
|
||||
: isEnvDevelopment &&
|
||||
(info => path.resolve(info.absoluteResourcePath).replace(/\\/g, '/')),
|
||||
// Prevents conflicts when multiple webpack runtimes (from different apps)
|
||||
// are used on the same page.
|
||||
jsonpFunction: `webpackJsonp${appPackageJson.name}`,
|
||||
// this defaults to 'window', but by setting it to 'this' then
|
||||
// module chunks which are built will work in web workers as well.
|
||||
globalObject: 'this',
|
||||
},
|
||||
optimization: {
|
||||
minimize: isEnvProduction,
|
||||
minimizer: [
|
||||
// This is only used in production mode
|
||||
new TerserPlugin({
|
||||
terserOptions: {
|
||||
parse: {
|
||||
// We want terser to parse ecma 8 code. However, we don't want it
|
||||
// to apply any minification steps that turns valid ecma 5 code
|
||||
// into invalid ecma 5 code. This is why the 'compress' and 'output'
|
||||
// sections only apply transformations that are ecma 5 safe
|
||||
// https://github.com/facebook/create-react-app/pull/4234
|
||||
ecma: 8,
|
||||
},
|
||||
compress: {
|
||||
ecma: 5,
|
||||
warnings: false,
|
||||
// Disabled because of an issue with Uglify breaking seemingly valid code:
|
||||
// https://github.com/facebook/create-react-app/issues/2376
|
||||
// Pending further investigation:
|
||||
// https://github.com/mishoo/UglifyJS2/issues/2011
|
||||
comparisons: false,
|
||||
// Disabled because of an issue with Terser breaking valid code:
|
||||
// https://github.com/facebook/create-react-app/issues/5250
|
||||
// Pending further investigation:
|
||||
// https://github.com/terser-js/terser/issues/120
|
||||
inline: 2,
|
||||
},
|
||||
mangle: {
|
||||
safari10: true,
|
||||
},
|
||||
// Added for profiling in devtools
|
||||
keep_classnames: isEnvProductionProfile,
|
||||
keep_fnames: isEnvProductionProfile,
|
||||
output: {
|
||||
ecma: 5,
|
||||
comments: false,
|
||||
// Turned on because emoji and regex is not minified properly using default
|
||||
// https://github.com/facebook/create-react-app/issues/2488
|
||||
ascii_only: true,
|
||||
},
|
||||
},
|
||||
sourceMap: shouldUseSourceMap,
|
||||
}),
|
||||
// This is only used in production mode
|
||||
new OptimizeCSSAssetsPlugin({
|
||||
cssProcessorOptions: {
|
||||
parser: safePostCssParser,
|
||||
map: shouldUseSourceMap
|
||||
? {
|
||||
// `inline: false` forces the sourcemap to be output into a
|
||||
// separate file
|
||||
inline: false,
|
||||
// `annotation: true` appends the sourceMappingURL to the end of
|
||||
// the css file, helping the browser find the sourcemap
|
||||
annotation: true,
|
||||
}
|
||||
: false,
|
||||
},
|
||||
cssProcessorPluginOptions: {
|
||||
preset: ['default', { minifyFontValues: { removeQuotes: false } }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
// Automatically split vendor and commons
|
||||
// https://twitter.com/wSokra/status/969633336732905474
|
||||
// https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366
|
||||
splitChunks: {
|
||||
chunks: 'all',
|
||||
name: false,
|
||||
},
|
||||
// Keep the runtime chunk separated to enable long term caching
|
||||
// https://twitter.com/wSokra/status/969679223278505985
|
||||
// https://github.com/facebook/create-react-app/issues/5358
|
||||
runtimeChunk: {
|
||||
name: entrypoint => `runtime-${entrypoint.name}`,
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
// This allows you to set a fallback for where webpack should look for modules.
|
||||
// We placed these paths second because we want `node_modules` to "win"
|
||||
// if there are any conflicts. This matches Node resolution mechanism.
|
||||
// https://github.com/facebook/create-react-app/issues/253
|
||||
modules: ['node_modules', paths.appNodeModules].concat(
|
||||
modules.additionalModulePaths || []
|
||||
),
|
||||
// These are the reasonable defaults supported by the Node ecosystem.
|
||||
// We also include JSX as a common component filename extension to support
|
||||
// some tools, although we do not recommend using it, see:
|
||||
// https://github.com/facebook/create-react-app/issues/290
|
||||
// `web` extension prefixes have been added for better support
|
||||
// for React Native Web.
|
||||
extensions: paths.moduleFileExtensions
|
||||
.map(ext => `.${ext}`)
|
||||
.filter(ext => useTypeScript || !ext.includes('ts')),
|
||||
alias: {
|
||||
// Support React Native Web
|
||||
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
|
||||
'react-native': 'react-native-web',
|
||||
// Allows for better profiling with ReactDevTools
|
||||
...(isEnvProductionProfile && {
|
||||
'react-dom$': 'react-dom/profiling',
|
||||
'scheduler/tracing': 'scheduler/tracing-profiling',
|
||||
}),
|
||||
...(modules.webpackAliases || {}),
|
||||
},
|
||||
plugins: [
|
||||
// Adds support for installing with Plug'n'Play, leading to faster installs and adding
|
||||
// guards against forgotten dependencies and such.
|
||||
PnpWebpackPlugin,
|
||||
// Prevents users from importing files from outside of src/ (or node_modules/).
|
||||
// This often causes confusion because we only process files within src/ with babel.
|
||||
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
|
||||
// please link the files into your node_modules/ and let module-resolution kick in.
|
||||
// Make sure your source files are compiled, as they will not be processed in any way.
|
||||
new ModuleScopePlugin(paths.appSrc, [
|
||||
paths.appPackageJson,
|
||||
paths.packages,
|
||||
reactRefreshOverlayEntry,
|
||||
]),
|
||||
],
|
||||
},
|
||||
resolveLoader: {
|
||||
plugins: [
|
||||
// Also related to Plug'n'Play, but this time it tells webpack to load its loaders
|
||||
// from the current package.
|
||||
PnpWebpackPlugin.moduleLoader(module),
|
||||
],
|
||||
},
|
||||
module: {
|
||||
strictExportPresence: true,
|
||||
rules: [
|
||||
// Disable require.ensure as it's not a standard language feature.
|
||||
{ parser: { requireEnsure: false } },
|
||||
{
|
||||
// "oneOf" will traverse all following loaders until one will
|
||||
// match the requirements. When no loader matches it will fall
|
||||
// back to the "file" loader at the end of the loader list.
|
||||
oneOf: [
|
||||
// TODO: Merge this config once `image/avif` is in the mime-db
|
||||
// https://github.com/jshttp/mime-db
|
||||
{
|
||||
test: [/\.avif$/],
|
||||
loader: require.resolve('url-loader'),
|
||||
options: {
|
||||
limit: imageInlineSizeLimit,
|
||||
mimetype: 'image/avif',
|
||||
name: 'static/media/[name].[hash:8].[ext]',
|
||||
},
|
||||
},
|
||||
// "url" loader works like "file" loader except that it embeds assets
|
||||
// smaller than specified limit in bytes as data URLs to avoid requests.
|
||||
// A missing `test` is equivalent to a match.
|
||||
{
|
||||
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
|
||||
loader: require.resolve('url-loader'),
|
||||
options: {
|
||||
limit: imageInlineSizeLimit,
|
||||
name: 'static/media/[name].[hash:8].[ext]',
|
||||
},
|
||||
},
|
||||
// Process application JS with Babel.
|
||||
// The preset includes JSX, Flow, TypeScript, and some ESnext features.
|
||||
{
|
||||
test: /\.(js|mjs|jsx|ts|tsx)$/,
|
||||
include: [paths.appSrc, paths.packages],
|
||||
loader: require.resolve('babel-loader'),
|
||||
options: {
|
||||
customize: require.resolve(
|
||||
'babel-preset-react-app/webpack-overrides'
|
||||
),
|
||||
|
||||
plugins: [
|
||||
[
|
||||
require.resolve('babel-plugin-named-asset-import'),
|
||||
{
|
||||
loaderMap: {
|
||||
svg: {
|
||||
ReactComponent:
|
||||
'@svgr/webpack?-svgo,+titleProp,+ref![path]',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
isEnvDevelopment &&
|
||||
shouldUseReactRefresh &&
|
||||
require.resolve('react-refresh/babel'),
|
||||
].filter(Boolean),
|
||||
// This is a feature of `babel-loader` for webpack (not Babel itself).
|
||||
// It enables caching results in ./node_modules/.cache/babel-loader/
|
||||
// directory for faster rebuilds.
|
||||
cacheDirectory: true,
|
||||
// See #6846 for context on why cacheCompression is disabled
|
||||
cacheCompression: false,
|
||||
compact: isEnvProduction,
|
||||
},
|
||||
},
|
||||
// Process any JS outside of the app with Babel.
|
||||
// Unlike the application JS, we only compile the standard ES features.
|
||||
{
|
||||
test: /\.(js|mjs)$/,
|
||||
exclude: /@babel(?:\/|\\{1,2})runtime/,
|
||||
loader: require.resolve('babel-loader'),
|
||||
options: {
|
||||
babelrc: false,
|
||||
configFile: false,
|
||||
compact: false,
|
||||
presets: [
|
||||
[
|
||||
require.resolve('babel-preset-react-app/dependencies'),
|
||||
{ helpers: true },
|
||||
],
|
||||
],
|
||||
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.
|
||||
sourceMaps: shouldUseSourceMap,
|
||||
inputSourceMap: shouldUseSourceMap,
|
||||
},
|
||||
},
|
||||
// "postcss" loader applies autoprefixer to our CSS.
|
||||
// "css" loader resolves paths in CSS and adds assets as dependencies.
|
||||
// "style" loader turns CSS into JS modules that inject <style> tags.
|
||||
// In production, we use MiniCSSExtractPlugin to extract that CSS
|
||||
// to a file, but in development "style" loader enables hot editing
|
||||
// of CSS.
|
||||
// By default we support CSS Modules with the extension .module.css
|
||||
{
|
||||
test: cssRegex,
|
||||
exclude: cssModuleRegex,
|
||||
use: getStyleLoaders({
|
||||
importLoaders: 1,
|
||||
sourceMap: isEnvProduction
|
||||
? shouldUseSourceMap
|
||||
: isEnvDevelopment,
|
||||
}),
|
||||
// Don't consider CSS imports dead code even if the
|
||||
// containing package claims to have no side effects.
|
||||
// Remove this when webpack adds a warning or an error for this.
|
||||
// See https://github.com/webpack/webpack/issues/6571
|
||||
sideEffects: true,
|
||||
},
|
||||
// Adds support for CSS Modules (https://github.com/css-modules/css-modules)
|
||||
// using the extension .module.css
|
||||
{
|
||||
test: cssModuleRegex,
|
||||
use: getStyleLoaders({
|
||||
importLoaders: 1,
|
||||
sourceMap: isEnvProduction
|
||||
? shouldUseSourceMap
|
||||
: isEnvDevelopment,
|
||||
modules: {
|
||||
getLocalIdent: getCSSModuleLocalIdent,
|
||||
},
|
||||
}),
|
||||
},
|
||||
// Opt-in support for SASS (using .scss or .sass extensions).
|
||||
// By default we support SASS Modules with the
|
||||
// extensions .module.scss or .module.sass
|
||||
{
|
||||
test: sassRegex,
|
||||
exclude: sassModuleRegex,
|
||||
use: getStyleLoaders(
|
||||
{
|
||||
importLoaders: 3,
|
||||
sourceMap: isEnvProduction
|
||||
? shouldUseSourceMap
|
||||
: isEnvDevelopment,
|
||||
},
|
||||
'sass-loader'
|
||||
),
|
||||
// Don't consider CSS imports dead code even if the
|
||||
// containing package claims to have no side effects.
|
||||
// Remove this when webpack adds a warning or an error for this.
|
||||
// See https://github.com/webpack/webpack/issues/6571
|
||||
sideEffects: true,
|
||||
},
|
||||
// Adds support for CSS Modules, but using SASS
|
||||
// using the extension .module.scss or .module.sass
|
||||
{
|
||||
test: sassModuleRegex,
|
||||
use: getStyleLoaders(
|
||||
{
|
||||
importLoaders: 3,
|
||||
sourceMap: isEnvProduction
|
||||
? shouldUseSourceMap
|
||||
: isEnvDevelopment,
|
||||
modules: {
|
||||
getLocalIdent: getCSSModuleLocalIdent,
|
||||
},
|
||||
},
|
||||
'sass-loader'
|
||||
),
|
||||
},
|
||||
// "file" loader makes sure those assets get served by WebpackDevServer.
|
||||
// When you `import` an asset, you get its (virtual) filename.
|
||||
// In production, they would get copied to the `build` folder.
|
||||
// This loader doesn't use a "test" so it will catch all modules
|
||||
// that fall through the other loaders.
|
||||
{
|
||||
loader: require.resolve('file-loader'),
|
||||
// Exclude `js` files to keep "css" loader working as it injects
|
||||
// its runtime that would otherwise be processed through "file" loader.
|
||||
// Also exclude `html` and `json` extensions so they get processed
|
||||
// by webpacks internal loaders.
|
||||
exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
|
||||
options: {
|
||||
name: 'static/media/[name].[hash:8].[ext]',
|
||||
},
|
||||
},
|
||||
// ** STOP ** Are you adding a new loader?
|
||||
// Make sure to add the new loader(s) before the "file" loader.
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
// Generates an `index.html` file with the <script> injected.
|
||||
new HtmlWebpackPlugin(
|
||||
Object.assign(
|
||||
{},
|
||||
{
|
||||
inject: true,
|
||||
template: paths.appHtml,
|
||||
},
|
||||
isEnvProduction
|
||||
? {
|
||||
minify: {
|
||||
removeComments: true,
|
||||
collapseWhitespace: true,
|
||||
removeRedundantAttributes: true,
|
||||
useShortDoctype: true,
|
||||
removeEmptyAttributes: true,
|
||||
removeStyleLinkTypeAttributes: true,
|
||||
keepClosingSlash: true,
|
||||
minifyJS: true,
|
||||
minifyCSS: true,
|
||||
minifyURLs: true,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
),
|
||||
// Inlines the webpack runtime script. This script is too small to warrant
|
||||
// a network request.
|
||||
// https://github.com/facebook/create-react-app/issues/5358
|
||||
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">
|
||||
// It will be an empty string unless you specify "homepage"
|
||||
// in `package.json`, in which case it will be the pathname of that URL.
|
||||
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 (CSS and Fast Refresh):
|
||||
isEnvDevelopment && new webpack.HotModuleReplacementPlugin(),
|
||||
// Experimental hot reloading for React .
|
||||
// https://github.com/facebook/react/tree/master/packages/react-refresh
|
||||
isEnvDevelopment &&
|
||||
shouldUseReactRefresh &&
|
||||
new ReactRefreshWebpackPlugin({
|
||||
overlay: {
|
||||
entry: webpackDevClientEntry,
|
||||
// The expected exports are slightly different from what the overlay exports,
|
||||
// so an interop is included here to enable feedback on module-level errors.
|
||||
module: reactRefreshOverlayEntry,
|
||||
// Since we ship a custom dev client and overlay integration,
|
||||
// the bundled socket handling logic can be eliminated.
|
||||
sockIntegration: false,
|
||||
},
|
||||
}),
|
||||
// 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 &&
|
||||
new MiniCssExtractPlugin({
|
||||
// Options similar to the same options in webpackOptions.output
|
||||
// both options are optional
|
||||
filename: 'static/css/[name].[contenthash:8].css',
|
||||
chunkFilename: 'static/css/[name].[contenthash:8].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: paths.publicUrlOrPath,
|
||||
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 &&
|
||||
fs.existsSync(swSrc) &&
|
||||
new WorkboxWebpackPlugin.InjectManifest({
|
||||
swSrc,
|
||||
dontCacheBustURLsMatching: /\.[0-9a-f]{8}\./,
|
||||
exclude: [/\.map$/, /asset-manifest\.json$/, /LICENSE/],
|
||||
}),
|
||||
// TypeScript type checking
|
||||
useTypeScript &&
|
||||
new ForkTsCheckerWebpackPlugin({
|
||||
typescript: resolve.sync('typescript', {
|
||||
basedir: paths.appNodeModules,
|
||||
}),
|
||||
async: isEnvDevelopment,
|
||||
checkSyntacticErrors: true,
|
||||
resolveModuleNameModule: process.versions.pnp
|
||||
? `${__dirname}/pnpTs.js`
|
||||
: undefined,
|
||||
resolveTypeReferenceDirectiveModule: process.versions.pnp
|
||||
? `${__dirname}/pnpTs.js`
|
||||
: undefined,
|
||||
tsconfig: paths.appTsConfig,
|
||||
reportFiles: [
|
||||
// This one is specifically to match during CI tests,
|
||||
// as micromatch doesn't match
|
||||
// '../cra-template-typescript/template/src/App.tsx'
|
||||
// otherwise.
|
||||
'../**/src/**/*.{ts,tsx}',
|
||||
'**/src/**/*.{ts,tsx}',
|
||||
'!**/src/**/__tests__/**',
|
||||
'!**/src/**/?(*.)(spec|test).*',
|
||||
'!**/src/setupProxy.*',
|
||||
'!**/src/setupTests.*',
|
||||
],
|
||||
silent: true,
|
||||
// The formatter is invoked directly in WebpackDevServerUtils during development
|
||||
formatter: isEnvProduction ? typescriptFormatter : undefined,
|
||||
}),
|
||||
new ESLintPlugin({
|
||||
// Plugin options
|
||||
extensions: ['js', 'mjs', 'jsx', 'ts', 'tsx'],
|
||||
formatter: require.resolve('react-dev-utils/eslintFormatter'),
|
||||
eslintPath: require.resolve('eslint'),
|
||||
context: paths.appSrc,
|
||||
// ESLint class options
|
||||
cwd: paths.appPath,
|
||||
resolvePluginsRelativeTo: __dirname,
|
||||
baseConfig: {
|
||||
extends: [require.resolve('eslint-config-react-app/base')],
|
||||
rules: {
|
||||
...(!hasJsxRuntime && {
|
||||
'react/react-in-jsx-scope': 'error',
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
].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,
|
||||
};
|
||||
};
|
31
packages/outline-example/package.json
Normal file
31
packages/outline-example/package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "outline-example",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-scripts": "4.0.0",
|
||||
"outline": "1.0.0",
|
||||
"outline-plain-text-plugin": "1.0.0",
|
||||
"outline-rich-text-plugin": "1.0.0",
|
||||
"outline-emoji-plugin": "1.0.0",
|
||||
"outline-mentions-plugin": "1.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { useState } from "react";
|
||||
import Editor from "./Editor";
|
||||
import React from 'react';
|
||||
import {useState} from 'react';
|
||||
import Editor from './Editor';
|
||||
|
||||
function App() {
|
||||
const [viewModel, setViewModel] = useState(null);
|
||||
@ -9,9 +9,7 @@ function App() {
|
||||
<>
|
||||
<h1>OutlineJS Demo</h1>
|
||||
<div className="editor-shell">
|
||||
<Editor
|
||||
onChange={setViewModel}
|
||||
/>
|
||||
<Editor onChange={setViewModel} />
|
||||
</div>
|
||||
<pre>{JSON.stringify(viewModel, null, 2)}</pre>
|
||||
</>
|
48
packages/outline-example/src/Editor.js
Normal file
48
packages/outline-example/src/Editor.js
Normal file
@ -0,0 +1,48 @@
|
||||
import * as React from 'react';
|
||||
import {useMemo, useRef} from 'react';
|
||||
import {useOutlineEditor} from 'outline';
|
||||
import {useEmojiPlugin} from 'outline-emoji-plugin';
|
||||
import {useMentionsPlugin} from 'outline-mentions-plugin';
|
||||
// import {usePlainTextPlugin} from 'outline-plain-text-plugin';
|
||||
import {useRichTextPlugin} from 'outline-rich-text-plugin';
|
||||
|
||||
const editorStyle = {
|
||||
outline: 0,
|
||||
overflowWrap: 'break-word',
|
||||
padding: '10px',
|
||||
userSelect: 'text',
|
||||
whiteSpace: 'pre-wrap',
|
||||
};
|
||||
|
||||
// An example of a custom editor using Outline.
|
||||
export default function Editor({onChange, isReadOnly}) {
|
||||
const editorElementRef = useRef(null);
|
||||
const outlineEditor = useOutlineEditor(editorElementRef, onChange);
|
||||
const portalTargetElement = useMemo(
|
||||
() => document.getElementById('portal'),
|
||||
[],
|
||||
);
|
||||
|
||||
// usePlainTextPlugin(outlineEditor, isReadOnly);
|
||||
useRichTextPlugin(outlineEditor, isReadOnly);
|
||||
useEmojiPlugin(outlineEditor);
|
||||
const mentionsTypeahead = useMentionsPlugin(
|
||||
outlineEditor,
|
||||
portalTargetElement,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="editor"
|
||||
contentEditable={isReadOnly !== true}
|
||||
role="textbox"
|
||||
ref={editorElementRef}
|
||||
spellCheck={true}
|
||||
style={editorStyle}
|
||||
tabIndex={0}
|
||||
/>
|
||||
{mentionsTypeahead}
|
||||
</>
|
||||
);
|
||||
}
|
11320
packages/outline-example/yarn.lock
Normal file
11320
packages/outline-example/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
5
packages/outline-mentions-plugin/package.json
Normal file
5
packages/outline-mentions-plugin/package.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "outline-mentions-plugin",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js"
|
||||
}
|
872
packages/outline-mentions-plugin/src/index.js
Normal file
872
packages/outline-mentions-plugin/src/index.js
Normal file
@ -0,0 +1,872 @@
|
||||
import React, {useCallback, useLayoutEffect, useMemo, useRef} from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
|
||||
const mentionStyle = 'background-color: rgba(24, 119, 232, 0.2)';
|
||||
|
||||
const PUNCTUATION =
|
||||
'\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;';
|
||||
const NAME = '\\b[A-Z][^\\s' + PUNCTUATION + ']';
|
||||
|
||||
const DocumentMentionsRegex = {
|
||||
PUNCTUATION,
|
||||
NAME,
|
||||
};
|
||||
|
||||
const CaptitalizedNameMentionsRegex = new RegExp(
|
||||
'(^|[^#])((?:' + DocumentMentionsRegex.NAME + '{' + 1 + ',})$)',
|
||||
);
|
||||
|
||||
const PUNC = DocumentMentionsRegex.PUNCTUATION;
|
||||
|
||||
const TRIGGERS = ['@', '\\uff20'].join('');
|
||||
|
||||
// Chars we expect to see in a mention (non-space, non-punctuation).
|
||||
const VALID_CHARS = '[^' + TRIGGERS + PUNC + '\\s]';
|
||||
|
||||
// Non-standard series of chars. Each series must be preceded and followed by
|
||||
// a valid char.
|
||||
const VALID_JOINS =
|
||||
'(?:' +
|
||||
'\\.[ |$]|' + // E.g. "r. " in "Mr. Smith"
|
||||
' |' + // E.g. " " in "Josh Duck"
|
||||
'[' +
|
||||
PUNC +
|
||||
']|' + // E.g. "-' in "Salier-Hellendag"
|
||||
')';
|
||||
|
||||
const LENGTH_LIMIT = 75;
|
||||
|
||||
const AtSignMentionsRegex = new RegExp(
|
||||
'(^|\\s|\\()(' +
|
||||
'[' +
|
||||
TRIGGERS +
|
||||
']' +
|
||||
'((?:' +
|
||||
VALID_CHARS +
|
||||
VALID_JOINS +
|
||||
'){0,' +
|
||||
LENGTH_LIMIT +
|
||||
'})' +
|
||||
')$',
|
||||
);
|
||||
|
||||
// 50 is the longest alias length limit.
|
||||
const ALIAS_LENGTH_LIMIT = 50;
|
||||
|
||||
// Regex used to match alias.
|
||||
const AtSignMentionsRegexAliasRegex = new RegExp(
|
||||
'(^|\\s|\\()(' +
|
||||
'[' +
|
||||
TRIGGERS +
|
||||
']' +
|
||||
'((?:' +
|
||||
VALID_CHARS +
|
||||
'){0,' +
|
||||
ALIAS_LENGTH_LIMIT +
|
||||
'})' +
|
||||
')$',
|
||||
);
|
||||
|
||||
const mentionsCache = new Map();
|
||||
|
||||
function useMentionLookupService(mentionString) {
|
||||
const [results, setResults] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const cachedResults = mentionsCache.get(mentionString);
|
||||
|
||||
if (cachedResults === null) {
|
||||
return;
|
||||
} else if (cachedResults !== undefined) {
|
||||
setResults(cachedResults);
|
||||
return;
|
||||
}
|
||||
|
||||
mentionsCache.set(mentionString, null);
|
||||
dummyLookupService.search(mentionString, (newResults) => {
|
||||
mentionsCache.set(mentionString, newResults);
|
||||
setResults(newResults);
|
||||
});
|
||||
}, [mentionString]);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function MentionsTypeaheadItem({
|
||||
index,
|
||||
isHovered,
|
||||
isSelected,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
result,
|
||||
}) {
|
||||
const liRef = useRef(null);
|
||||
|
||||
let className = 'item';
|
||||
if (isSelected) {
|
||||
className += ' selected';
|
||||
}
|
||||
if (isHovered) {
|
||||
className += ' hovered';
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
key={result}
|
||||
tabIndex={-1}
|
||||
className={className}
|
||||
ref={liRef}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
id={'typeahead-item-' + index}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={onClick}>
|
||||
{result}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function MentionsTypeahead({close, editor, match, nodeKey, registerKeys}) {
|
||||
const divRef = useRef(null);
|
||||
const results = useMentionLookupService(match.matchingString);
|
||||
const [selectedIndex, setSelectedIndex] = useState(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const div = divRef.current;
|
||||
if (results !== null && div !== null) {
|
||||
const mentionsElement = editor.getElementByKey(nodeKey);
|
||||
|
||||
if (mentionsElement !== null && mentionsElement.nodeType === 1) {
|
||||
const {x, y} = mentionsElement.getBoundingClientRect();
|
||||
div.style.top = `${y + 22}px`;
|
||||
div.style.left = `${x}px`;
|
||||
mentionsElement.setAttribute('aria-controls', 'mentions-typeahead');
|
||||
|
||||
return () => {
|
||||
mentionsElement.removeAttribute('aria-controls');
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [editor, nodeKey, results]);
|
||||
|
||||
const applyCurrentSelected = useCallback(() => {
|
||||
const selectedResult = results[selectedIndex];
|
||||
const viewModel = editor.createViewModel((view) => {
|
||||
const mentionsNode = view.getNodeByKey(nodeKey);
|
||||
mentionsNode
|
||||
.setTextContent(selectedResult)
|
||||
.setStyle(mentionStyle)
|
||||
.setData({
|
||||
type: 'MENTION',
|
||||
name: selectedResult,
|
||||
})
|
||||
.makeSegmented()
|
||||
.select();
|
||||
});
|
||||
close();
|
||||
if (!editor.isUpdating()) {
|
||||
editor.update(viewModel);
|
||||
}
|
||||
}, [close, editor, nodeKey, results, selectedIndex]);
|
||||
|
||||
const updateSelectedIndex = useCallback(
|
||||
(index) => {
|
||||
const editorElem = editor.getEditorElement();
|
||||
editorElem.setAttribute(
|
||||
'aria-activedescendant',
|
||||
'typeahead-item-' + index,
|
||||
);
|
||||
setSelectedIndex(index);
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const editorElem = editor.getEditorElement();
|
||||
if (editorElem !== null) {
|
||||
editorElem.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (results === null) {
|
||||
setSelectedIndex(null);
|
||||
} else if (selectedIndex === null) {
|
||||
updateSelectedIndex(0);
|
||||
}
|
||||
}, [results, selectedIndex, updateSelectedIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
return registerKeys({
|
||||
ArrowDown(event, view) {
|
||||
if (results !== null && selectedIndex !== null) {
|
||||
if (selectedIndex !== results.length - 1) {
|
||||
updateSelectedIndex(selectedIndex + 1);
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
ArrowUp(event, view) {
|
||||
if (results !== null && selectedIndex !== null) {
|
||||
if (selectedIndex !== 0) {
|
||||
updateSelectedIndex(selectedIndex - 1);
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
Escape(event, view) {
|
||||
if (results === null || selectedIndex === null) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
Tab(event, view) {
|
||||
if (results === null || selectedIndex === null) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
Enter(event, view) {
|
||||
if (results === null || selectedIndex === null) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
applyCurrentSelected();
|
||||
},
|
||||
});
|
||||
}, [
|
||||
applyCurrentSelected,
|
||||
registerKeys,
|
||||
results,
|
||||
selectedIndex,
|
||||
updateSelectedIndex,
|
||||
]);
|
||||
|
||||
if (results === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label="Suggested mentions"
|
||||
id="mentions-typeahead"
|
||||
ref={divRef}
|
||||
role="listbox">
|
||||
<ul>
|
||||
{results.slice(0, 5).map((result, i) => (
|
||||
<MentionsTypeaheadItem
|
||||
index={i}
|
||||
isHovered={i === hoveredIndex}
|
||||
isSelected={i === selectedIndex}
|
||||
onClick={() => {
|
||||
setSelectedIndex(i);
|
||||
applyCurrentSelected();
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHoveredIndex(i);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredIndex(null);
|
||||
}}
|
||||
key={result}
|
||||
result={result}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function checkForCaptitalizedNameMentions(text, minMatchLength) {
|
||||
const match = CaptitalizedNameMentionsRegex.exec(text);
|
||||
if (match !== null) {
|
||||
// The strategy ignores leading whitespace but we need to know it's
|
||||
// length to add it to the leadOffset
|
||||
const maybeLeadingWhitespace = match[1];
|
||||
|
||||
const matchingString = match[2];
|
||||
if (matchingString != null && matchingString.length >= minMatchLength) {
|
||||
return {
|
||||
leadOffset: match.index + maybeLeadingWhitespace.length,
|
||||
matchingString,
|
||||
replaceableString: matchingString,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function checkForAtSignMentions(text, minMatchLength) {
|
||||
let match = AtSignMentionsRegex.exec(text);
|
||||
|
||||
if (match === null) {
|
||||
match = AtSignMentionsRegexAliasRegex.exec(text);
|
||||
}
|
||||
if (match !== null) {
|
||||
// The strategy ignores leading whitespace but we need to know it's
|
||||
// length to add it to the leadOffset
|
||||
const maybeLeadingWhitespace = match[1];
|
||||
|
||||
const matchingString = match[3];
|
||||
if (matchingString.length >= minMatchLength) {
|
||||
return {
|
||||
leadOffset: match.index + maybeLeadingWhitespace.length,
|
||||
matchingString,
|
||||
replaceableString: match[2],
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPossibleMentionMatch(text) {
|
||||
const match = checkForAtSignMentions(text, 1);
|
||||
return match === null ? checkForCaptitalizedNameMentions(text, 3) : match;
|
||||
}
|
||||
|
||||
function useEvent(editor, eventName, handler, pluginStateRef) {
|
||||
useEffect(() => {
|
||||
const state = pluginStateRef && pluginStateRef.current;
|
||||
if (state !== null && editor !== null) {
|
||||
const target =
|
||||
eventName === 'selectionchange' ? document : editor.getEditorElement();
|
||||
const wrapper = (event) => {
|
||||
const viewModel = editor.createViewModel((view) =>
|
||||
handler(event, view, state, editor),
|
||||
);
|
||||
// Uncomment to see how diffs might work:
|
||||
// if (viewModel !== outlineEditor.getCurrentViewModel()) {
|
||||
// const diff = outlineEditor.getDiffFromViewModel(viewModel);
|
||||
// debugger;
|
||||
// }
|
||||
if (!editor.isUpdating()) {
|
||||
editor.update(viewModel);
|
||||
}
|
||||
};
|
||||
target.addEventListener(eventName, wrapper);
|
||||
return () => {
|
||||
target.removeEventListener(eventName, wrapper);
|
||||
};
|
||||
}
|
||||
}, [eventName, handler, editor, pluginStateRef]);
|
||||
}
|
||||
|
||||
export function useMentionsPlugin(outlineEditor, portalTargetElement) {
|
||||
const [mentionMatch, setMentionMatch] = useState(null);
|
||||
const [nodeKey, setNodeKey] = useState(null);
|
||||
const registeredKeys = useMemo(() => new Set(), []);
|
||||
const registerKeys = useMemo(
|
||||
() => (keys) => {
|
||||
registeredKeys.add(keys);
|
||||
return () => {
|
||||
registeredKeys.delete(keys);
|
||||
};
|
||||
},
|
||||
[registeredKeys],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (outlineEditor !== null) {
|
||||
const textNodeTransform = (node, view) => {
|
||||
const selection = view.getSelection();
|
||||
if (
|
||||
selection.getAnchorNode() !== node ||
|
||||
node.isImmutable() ||
|
||||
node.isSegmented()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const anchorOffset = view.anchorOffset;
|
||||
const text = node.getTextContent().slice(0, anchorOffset);
|
||||
|
||||
if (text !== '') {
|
||||
const match = getPossibleMentionMatch(text);
|
||||
if (match !== null) {
|
||||
const {leadOffset, replaceableString} = match;
|
||||
const splitNodes = node.splitText(
|
||||
leadOffset,
|
||||
leadOffset + replaceableString.length,
|
||||
);
|
||||
const target = leadOffset === 0 ? splitNodes[0] : splitNodes[1];
|
||||
target.setTextContent(replaceableString);
|
||||
target.select();
|
||||
// We shouldn't do updates to React until this view is actually
|
||||
// reconciled.
|
||||
window.requestAnimationFrame(() => {
|
||||
setNodeKey(target.getKey());
|
||||
setMentionMatch(match);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
setMentionMatch(null);
|
||||
};
|
||||
return outlineEditor.addTextTransform(textNodeTransform);
|
||||
}
|
||||
}, [outlineEditor]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event, view) => {
|
||||
const key = event.key;
|
||||
registeredKeys.forEach((registeredKeyMap) => {
|
||||
const controlFunction = registeredKeyMap[key];
|
||||
if (typeof controlFunction === 'function') {
|
||||
controlFunction(event, view);
|
||||
}
|
||||
});
|
||||
},
|
||||
[registeredKeys],
|
||||
);
|
||||
|
||||
const closeTypeahead = useCallback(() => {
|
||||
setMentionMatch(null);
|
||||
setNodeKey(null);
|
||||
}, []);
|
||||
|
||||
useEvent(outlineEditor, 'keydown', onKeyDown);
|
||||
|
||||
return mentionMatch === null || nodeKey === null
|
||||
? null
|
||||
: createPortal(
|
||||
<MentionsTypeahead
|
||||
close={closeTypeahead}
|
||||
match={mentionMatch}
|
||||
nodeKey={nodeKey}
|
||||
editor={outlineEditor}
|
||||
registerKeys={registerKeys}
|
||||
/>,
|
||||
portalTargetElement,
|
||||
);
|
||||
}
|
||||
|
||||
const dummyLookupService = {
|
||||
search(string, callback) {
|
||||
setTimeout(() => {
|
||||
const results = dummyMentionsData.filter((mention) =>
|
||||
mention.toLowerCase().includes(string.toLowerCase()),
|
||||
);
|
||||
if (results.length === 0) {
|
||||
callback(null);
|
||||
} else {
|
||||
callback(results);
|
||||
}
|
||||
}, 500);
|
||||
},
|
||||
};
|
||||
|
||||
const dummyMentionsData = [
|
||||
'Aayla Secura',
|
||||
'Adi Gallia',
|
||||
'Admiral Dodd Rancit',
|
||||
'Admiral Firmus Piett',
|
||||
'Admiral Gial Ackbar',
|
||||
'Admiral Ozzel',
|
||||
'Admiral Raddus',
|
||||
'Admiral Terrinald Screed',
|
||||
'Admiral Trench',
|
||||
'Admiral U.O. Statura',
|
||||
'Agen Kolar',
|
||||
'Agent Kallus',
|
||||
'Aiolin and Morit Astarte',
|
||||
'Aks Moe',
|
||||
'Almec',
|
||||
'Alton Kastle',
|
||||
'Amee',
|
||||
'AP-5',
|
||||
'Armitage Hux',
|
||||
'Artoo',
|
||||
'Arvel Crynyd',
|
||||
'Asajj Ventress',
|
||||
'Aurra Sing',
|
||||
'AZI-3',
|
||||
'Bala-Tik',
|
||||
'Barada',
|
||||
'Bargwill Tomder',
|
||||
'Baron Papanoida',
|
||||
'Barriss Offee',
|
||||
'Baze Malbus',
|
||||
'Bazine Netal',
|
||||
'BB-8',
|
||||
'BB-9E',
|
||||
'Ben Quadinaros',
|
||||
'Berch Teller',
|
||||
'Beru Lars',
|
||||
'Bib Fortuna',
|
||||
'Biggs Darklighter',
|
||||
'Black Krrsantan',
|
||||
'Bo-Katan Kryze',
|
||||
'Boba Fett',
|
||||
'Bobbajo',
|
||||
'Bodhi Rook',
|
||||
'Borvo the Hutt',
|
||||
'Boss Nass',
|
||||
'Bossk',
|
||||
'Breha Antilles-Organa',
|
||||
'Bren Derlin',
|
||||
'Brendol Hux',
|
||||
'BT-1',
|
||||
'C-3PO',
|
||||
'C1-10P',
|
||||
'Cad Bane',
|
||||
'Caluan Ematt',
|
||||
'Captain Gregor',
|
||||
'Captain Phasma',
|
||||
'Captain Quarsh Panaka',
|
||||
'Captain Rex',
|
||||
'Carlist Rieekan',
|
||||
'Casca Panzoro',
|
||||
'Cassian Andor',
|
||||
'Cassio Tagge',
|
||||
'Cham Syndulla',
|
||||
'Che Amanwe Papanoida',
|
||||
'Chewbacca',
|
||||
'Chi Eekway Papanoida',
|
||||
'Chief Chirpa',
|
||||
'Chirrut Îmwe',
|
||||
'Ciena Ree',
|
||||
'Cin Drallig',
|
||||
'Clegg Holdfast',
|
||||
'Cliegg Lars',
|
||||
'Coleman Kcaj',
|
||||
'Coleman Trebor',
|
||||
'Colonel Kaplan',
|
||||
'Commander Bly',
|
||||
'Commander Cody (CC-2224)',
|
||||
'Commander Fil (CC-3714)',
|
||||
'Commander Fox',
|
||||
'Commander Gree',
|
||||
'Commander Jet',
|
||||
'Commander Wolffe',
|
||||
'Conan Antonio Motti',
|
||||
'Conder Kyl',
|
||||
'Constable Zuvio',
|
||||
'Cordé',
|
||||
'Cpatain Typho',
|
||||
'Crix Madine',
|
||||
'Cut Lawquane',
|
||||
'Dak Ralter',
|
||||
'Dapp',
|
||||
'Darth Bane',
|
||||
'Darth Maul',
|
||||
'Darth Tyranus',
|
||||
'Daultay Dofine',
|
||||
'Del Meeko',
|
||||
'Delian Mors',
|
||||
'Dengar',
|
||||
'Depa Billaba',
|
||||
'Derek Klivian',
|
||||
'Dexter Jettster',
|
||||
'Dineé Ellberger',
|
||||
'DJ',
|
||||
'Doctor Aphra',
|
||||
'Doctor Evazan',
|
||||
'Dogma',
|
||||
'Dormé',
|
||||
'Dr. Cylo',
|
||||
'Droidbait',
|
||||
'Droopy McCool',
|
||||
'Dryden Vos',
|
||||
'Dud Bolt',
|
||||
'Ebe E. Endocott',
|
||||
'Echuu Shen-Jon',
|
||||
'Eeth Koth',
|
||||
'Eighth Brother',
|
||||
'Eirtaé',
|
||||
'Eli Vanto',
|
||||
'Ellé',
|
||||
'Ello Asty',
|
||||
'Embo',
|
||||
'Eneb Ray',
|
||||
'Enfys Nest',
|
||||
'EV-9D9',
|
||||
'Evaan Verlaine',
|
||||
'Even Piell',
|
||||
'Ezra Bridger',
|
||||
'Faro Argyus',
|
||||
'Feral',
|
||||
'Fifth Brother',
|
||||
'Finis Valorum',
|
||||
'Finn',
|
||||
'Fives',
|
||||
'FN-1824',
|
||||
'FN-2003',
|
||||
'Fodesinbeed Annodue',
|
||||
'Fulcrum',
|
||||
'FX-7',
|
||||
'GA-97',
|
||||
'Galen Erso',
|
||||
'Gallius Rax',
|
||||
'Garazeb "Zeb" Orrelios',
|
||||
'Gardulla the Hutt',
|
||||
'Garrick Versio',
|
||||
'Garven Dreis',
|
||||
'Gavyn Sykes',
|
||||
'Gideon Hask',
|
||||
'Gizor Dellso',
|
||||
'Gonk droid',
|
||||
'Grand Inquisitor',
|
||||
'Greeata Jendowanian',
|
||||
'Greedo',
|
||||
'Greer Sonnel',
|
||||
'Grievous',
|
||||
'Grummgar',
|
||||
'Gungi',
|
||||
'Hammerhead',
|
||||
'Han Solo',
|
||||
'Harter Kalonia',
|
||||
'Has Obbit',
|
||||
'Hera Syndulla',
|
||||
'Hevy',
|
||||
'Hondo Ohnaka',
|
||||
'Huyang',
|
||||
'Iden Versio',
|
||||
'IG-88',
|
||||
'Ima-Gun Di',
|
||||
'Inquisitors',
|
||||
'Inspector Thanoth',
|
||||
'Jabba',
|
||||
'Jacen Syndulla',
|
||||
'Jan Dodonna',
|
||||
'Jango Fett',
|
||||
'Janus Greejatus',
|
||||
'Jar Jar Binks',
|
||||
'Jas Emari',
|
||||
'Jaxxon',
|
||||
'Jek Tono Porkins',
|
||||
'Jeremoch Colton',
|
||||
'Jira',
|
||||
'Jobal Naberrie',
|
||||
'Jocasta Nu',
|
||||
'Joclad Danva',
|
||||
'Joh Yowza',
|
||||
'Jom Barell',
|
||||
'Joph Seastriker',
|
||||
'Jova Tarkin',
|
||||
'Jubnuk',
|
||||
'Jyn Erso',
|
||||
'K-2SO',
|
||||
'Kanan Jarrus',
|
||||
'Karbin',
|
||||
'Karina the Great',
|
||||
'Kes Dameron',
|
||||
'Ketsu Onyo',
|
||||
'Ki-Adi-Mundi',
|
||||
'King Katuunko',
|
||||
'Kit Fisto',
|
||||
'Kitster Banai',
|
||||
'Klaatu',
|
||||
'Klik-Klak',
|
||||
'Korr Sella',
|
||||
'Kylo Ren',
|
||||
'L3-37',
|
||||
'Lama Su',
|
||||
'Lando Calrissian',
|
||||
'Lanever Villecham',
|
||||
'Leia Organa',
|
||||
'Letta Turmond',
|
||||
'Lieutenant Kaydel Ko Connix',
|
||||
'Lieutenant Thire',
|
||||
'Lobot',
|
||||
'Logray',
|
||||
'Lok Durd',
|
||||
'Longo Two-Guns',
|
||||
'Lor San Tekka',
|
||||
'Lorth Needa',
|
||||
'Lott Dod',
|
||||
'Luke Skywalker',
|
||||
'Lumat',
|
||||
'Luminara Unduli',
|
||||
'Lux Bonteri',
|
||||
'Lyn Me',
|
||||
'Lyra Erso',
|
||||
'Mace Windu',
|
||||
'Malakili',
|
||||
'Mama the Hutt',
|
||||
'Mars Guo',
|
||||
'Mas Amedda',
|
||||
'Mawhonic',
|
||||
'Max Rebo',
|
||||
'Maximilian Veers',
|
||||
'Maz Kanata',
|
||||
'ME-8D9',
|
||||
'Meena Tills',
|
||||
'Mercurial Swift',
|
||||
'Mina Bonteri',
|
||||
'Miraj Scintel',
|
||||
'Mister Bones',
|
||||
'Mod Terrik',
|
||||
'Moden Canady',
|
||||
'Mon Mothma',
|
||||
'Moradmin Bast',
|
||||
'Moralo Eval',
|
||||
'Morley',
|
||||
'Mother Talzin',
|
||||
'Nahdar Vebb',
|
||||
'Nahdonnis Praji',
|
||||
'Nien Nunb',
|
||||
'Niima the Hutt',
|
||||
'Nines',
|
||||
'Norra Wexley',
|
||||
'Nute Gunray',
|
||||
'Nuvo Vindi',
|
||||
'Obi-Wan Kenobi',
|
||||
'Odd Ball',
|
||||
'Ody Mandrell',
|
||||
'Omi',
|
||||
'Onaconda Farr',
|
||||
'Oola',
|
||||
'OOM-9',
|
||||
'Oppo Rancisis',
|
||||
'Orn Free Taa',
|
||||
'Oro Dassyne',
|
||||
'Orrimarko',
|
||||
'Osi Sobeck',
|
||||
'Owen Lars',
|
||||
'Pablo-Jill',
|
||||
'Padmé Amidala',
|
||||
'Pagetti Rook',
|
||||
'Paige Tico',
|
||||
'Paploo',
|
||||
'Petty Officer Thanisson',
|
||||
'Pharl McQuarrie',
|
||||
'Plo Koon',
|
||||
'Po Nudo',
|
||||
'Poe Dameron',
|
||||
'Poggle the Lesser',
|
||||
'Pong Krell',
|
||||
'Pooja Naberrie',
|
||||
'PZ-4CO',
|
||||
'Quarrie',
|
||||
'Quay Tolsite',
|
||||
'Queen Apailana',
|
||||
'Queen Jamillia',
|
||||
'Queen Neeyutnee',
|
||||
'Qui-Gon Jinn',
|
||||
'Quiggold',
|
||||
'Quinlan Vos',
|
||||
'R2-D2',
|
||||
'R2-KT',
|
||||
'R3-S6',
|
||||
'R4-P17',
|
||||
'R5-D4',
|
||||
'RA-7',
|
||||
'Rabé',
|
||||
'Rako Hardeen',
|
||||
'Ransolm Casterfo',
|
||||
'Rappertunie',
|
||||
'Ratts Tyerell',
|
||||
'Raymus Antilles',
|
||||
'Ree-Yees',
|
||||
'Reeve Panzoro',
|
||||
'Rey',
|
||||
'Ric Olié',
|
||||
'Riff Tamson',
|
||||
'Riley',
|
||||
'Rinnriyin Di',
|
||||
'Rio Durant',
|
||||
'Rogue Squadron',
|
||||
'Romba',
|
||||
'Roos Tarpals',
|
||||
'Rose Tico',
|
||||
'Rotta the Hutt',
|
||||
'Rukh',
|
||||
'Rune Haako',
|
||||
'Rush Clovis',
|
||||
'Ruwee Naberrie',
|
||||
'Ryoo Naberrie',
|
||||
'Sabé',
|
||||
'Sabine Wren',
|
||||
'Saché',
|
||||
'Saelt-Marae',
|
||||
'Saesee Tiin',
|
||||
'Salacious B. Crumb',
|
||||
'San Hill',
|
||||
'Sana Starros',
|
||||
'Sarco Plank',
|
||||
'Sarkli',
|
||||
'Satine Kryze',
|
||||
'Savage Opress',
|
||||
'Sebulba',
|
||||
'Senator Organa',
|
||||
'Sergeant Kreel',
|
||||
'Seventh Sister',
|
||||
'Shaak Ti',
|
||||
'Shara Bey',
|
||||
'Shmi Skywalker',
|
||||
'Shu Mai',
|
||||
'Sidon Ithano',
|
||||
'Sifo-Dyas',
|
||||
'Sim Aloo',
|
||||
'Siniir Rath Velus',
|
||||
'Sio Bibble',
|
||||
'Sixth Brother',
|
||||
'Slowen Lo',
|
||||
'Sly Moore',
|
||||
'Snaggletooth',
|
||||
'Snap Wexley',
|
||||
'Snoke',
|
||||
'Sola Naberrie',
|
||||
'Sora Bulq',
|
||||
'Strono Tuggs',
|
||||
'Sy Snootles',
|
||||
'Tallissan Lintra',
|
||||
'Tarfful',
|
||||
'Tasu Leech',
|
||||
'Taun We',
|
||||
'TC-14',
|
||||
'Tee Watt Kaa',
|
||||
'Teebo',
|
||||
'Teedo',
|
||||
'Teemto Pagalies',
|
||||
'Temiri Blagg',
|
||||
'Tessek',
|
||||
'Tey How',
|
||||
'Thane Kyrell',
|
||||
'The Bendu',
|
||||
'The Smuggler',
|
||||
'Thrawn',
|
||||
'Tiaan Jerjerrod',
|
||||
'Tion Medon',
|
||||
'Tobias Beckett',
|
||||
'Tulon Voidgazer',
|
||||
'Tup',
|
||||
'U9-C4',
|
||||
'Unkar Plutt',
|
||||
'Val Beckett',
|
||||
'Vanden Willard',
|
||||
'Vice Admiral Amilyn Holdo',
|
||||
'Vober Dand',
|
||||
'WAC-47',
|
||||
'Wag Too',
|
||||
'Wald',
|
||||
'Walrus Man',
|
||||
'Warok',
|
||||
'Wat Tambor',
|
||||
'Watto',
|
||||
'Wedge Antilles',
|
||||
'Wes Janson',
|
||||
'Wicket W. Warrick',
|
||||
'Wilhuff Tarkin',
|
||||
'Wollivan',
|
||||
'Wuher',
|
||||
'Wullf Yularen',
|
||||
'Xamuel Lennox',
|
||||
'Yaddle',
|
||||
'Yarael Poof',
|
||||
'Yoda',
|
||||
'Zam Wesell',
|
||||
'Zev Senesca',
|
||||
'Ziro the Hutt',
|
||||
'Zuckuss',
|
||||
];
|
5
packages/outline-plain-text-plugin/package.json
Normal file
5
packages/outline-plain-text-plugin/package.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "outline-plain-text-plugin",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js"
|
||||
}
|
169
packages/outline-plain-text-plugin/src/index.js
Normal file
169
packages/outline-plain-text-plugin/src/index.js
Normal file
@ -0,0 +1,169 @@
|
||||
import {useEffect, useRef} from 'react';
|
||||
|
||||
const isBrowserFirefox =
|
||||
typeof navigator !== 'undefined' &&
|
||||
/^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent);
|
||||
|
||||
const isBrowserSafari =
|
||||
typeof navigator !== 'undefined' &&
|
||||
/Version\/[\d.]+.*Safari/.test(navigator.userAgent);
|
||||
|
||||
function insertFromDataTransfer(event, editor) {
|
||||
const items = event.dataTransfer.items;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'string' && item.type === 'text/plain') {
|
||||
item.getAsString((text) => {
|
||||
const viewModel = editor.createViewModel((view) => {
|
||||
view.getSelection().insertText(text);
|
||||
});
|
||||
if (!editor.isUpdating()) {
|
||||
editor.update(viewModel);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onBeforeInput(event, view, state, editor) {
|
||||
const inputType = event.inputType;
|
||||
|
||||
if (
|
||||
inputType !== 'insertCompositionText' &&
|
||||
inputType !== 'deleteCompositionText'
|
||||
) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const selection = view.getSelection();
|
||||
|
||||
switch (inputType) {
|
||||
case 'insertFromComposition': {
|
||||
const data = event.data;
|
||||
if (data) {
|
||||
selection.insertText(data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'insertFromPaste': {
|
||||
insertFromDataTransfer(event, editor);
|
||||
break;
|
||||
}
|
||||
case 'insertLineBreak': {
|
||||
selection.insertText('\n');
|
||||
break;
|
||||
}
|
||||
case 'insertText': {
|
||||
selection.insertText(event.data);
|
||||
break;
|
||||
}
|
||||
case 'deleteByCut': {
|
||||
selection.removeText();
|
||||
break;
|
||||
}
|
||||
case 'deleteContentBackward': {
|
||||
selection.deleteBackward();
|
||||
break;
|
||||
}
|
||||
case 'deleteContentForward': {
|
||||
selection.deleteForward();
|
||||
break;
|
||||
}
|
||||
case 'insertFromDrop': {
|
||||
insertFromDataTransfer(event, editor);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// NO-OP
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onCompositionEnd(event, view, state) {
|
||||
const data = event.data;
|
||||
// Only do this for Chrome
|
||||
state.isComposing = false;
|
||||
if (data && !isBrowserSafari && !isBrowserFirefox) {
|
||||
view.getSelection().insertText(data);
|
||||
}
|
||||
}
|
||||
|
||||
function onCompositionStart(event, view, state) {
|
||||
state.isComposing = true;
|
||||
}
|
||||
|
||||
function onFocusIn(event, viewModel) {
|
||||
const body = viewModel.getBody();
|
||||
|
||||
if (body.getFirstChild() === null) {
|
||||
const text = viewModel.createText();
|
||||
body.append(viewModel.createBlock('p').append(text));
|
||||
text.select();
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
function onSelectionChange(event, helpers) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
function useEvent(editor, eventName, handler, pluginStateRef) {
|
||||
useEffect(() => {
|
||||
const state = pluginStateRef.current;
|
||||
if (state !== null && editor !== null) {
|
||||
const target =
|
||||
eventName === 'selectionchange' ? document : editor.getEditorElement();
|
||||
const wrapper = (event) => {
|
||||
const viewModel = editor.createViewModel((view) =>
|
||||
handler(event, view, state, editor),
|
||||
);
|
||||
// Uncomment to see how diffs might work:
|
||||
// if (viewModel !== outlineEditor.getCurrentViewModel()) {
|
||||
// const diff = outlineEditor.getDiffFromViewModel(viewModel);
|
||||
// debugger;
|
||||
// }
|
||||
if (!editor.isUpdating()) {
|
||||
editor.update(viewModel);
|
||||
}
|
||||
};
|
||||
target.addEventListener(eventName, wrapper);
|
||||
return () => {
|
||||
target.removeEventListener(eventName, wrapper);
|
||||
};
|
||||
}
|
||||
}, [eventName, handler, editor, pluginStateRef]);
|
||||
}
|
||||
|
||||
export function usePlainTextPlugin(outlineEditor, isReadOnly = false) {
|
||||
const pluginStateRef = useRef(null);
|
||||
|
||||
// Handle event plugin state
|
||||
useEffect(() => {
|
||||
const pluginsState = pluginStateRef.current;
|
||||
|
||||
if (pluginsState === null) {
|
||||
pluginStateRef.current = {
|
||||
isComposing: false,
|
||||
isReadOnly,
|
||||
};
|
||||
} else {
|
||||
pluginsState.isReadOnly = isReadOnly;
|
||||
}
|
||||
}, [isReadOnly]);
|
||||
|
||||
useEvent(outlineEditor, 'beforeinput', onBeforeInput, pluginStateRef);
|
||||
useEvent(outlineEditor, 'focusin', onFocusIn, pluginStateRef);
|
||||
useEvent(
|
||||
outlineEditor,
|
||||
'compositionstart',
|
||||
onCompositionStart,
|
||||
pluginStateRef,
|
||||
);
|
||||
useEvent(outlineEditor, 'compositionend', onCompositionEnd, pluginStateRef);
|
||||
useEvent(outlineEditor, 'keydown', onKeyDown, pluginStateRef);
|
||||
useEvent(outlineEditor, 'selectionchange', onSelectionChange, pluginStateRef);
|
||||
}
|
5
packages/outline-rich-text-plugin/package.json
Normal file
5
packages/outline-rich-text-plugin/package.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "outline-rich-text-plugin",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js"
|
||||
}
|
205
packages/outline-rich-text-plugin/src/index.js
Normal file
205
packages/outline-rich-text-plugin/src/index.js
Normal file
@ -0,0 +1,205 @@
|
||||
import {useEffect, useRef} from 'react';
|
||||
|
||||
const isBrowserFirefox =
|
||||
typeof navigator !== 'undefined' &&
|
||||
/^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent);
|
||||
|
||||
const isBrowserSafari =
|
||||
typeof navigator !== 'undefined' &&
|
||||
/Version\/[\d.]+.*Safari/.test(navigator.userAgent);
|
||||
|
||||
const FORMAT_BOLD = 0;
|
||||
const FORMAT_ITALIC = 1;
|
||||
const FORMAT_STRIKETHROUGH = 2;
|
||||
const FORMAT_UNDERLINE = 3;
|
||||
|
||||
function insertFromDataTransfer(event, editor) {
|
||||
const items = event.dataTransfer.items;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'string' && item.type === 'text/plain') {
|
||||
item.getAsString((text) => {
|
||||
const viewModel = editor.createViewModel((view) => {
|
||||
view.getSelection().insertText(text);
|
||||
});
|
||||
if (!editor.isUpdating()) {
|
||||
editor.update(viewModel);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onBeforeInput(event, view, state, editor) {
|
||||
const inputType = event.inputType;
|
||||
|
||||
if (
|
||||
inputType !== 'insertCompositionText' &&
|
||||
inputType !== 'deleteCompositionText'
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
const selection = view.getSelection();
|
||||
|
||||
switch (inputType) {
|
||||
case 'formatBold': {
|
||||
selection.formatText(FORMAT_BOLD);
|
||||
break;
|
||||
}
|
||||
case 'formatItalic': {
|
||||
selection.formatText(FORMAT_ITALIC);
|
||||
break;
|
||||
}
|
||||
case 'formatStrikeThrough': {
|
||||
selection.formatText(FORMAT_STRIKETHROUGH);
|
||||
break;
|
||||
}
|
||||
case 'formatUnderline': {
|
||||
selection.formatText(FORMAT_UNDERLINE);
|
||||
break;
|
||||
}
|
||||
case 'insertFromComposition': {
|
||||
const data = event.data;
|
||||
if (data) {
|
||||
selection.insertText(data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'insertFromPaste': {
|
||||
insertFromDataTransfer(event, editor);
|
||||
break;
|
||||
}
|
||||
case 'insertLineBreak': {
|
||||
selection.insertText('\n');
|
||||
break;
|
||||
}
|
||||
case 'insertParagraph': {
|
||||
selection.insertParagraph();
|
||||
break;
|
||||
}
|
||||
case 'insertText': {
|
||||
selection.insertText(event.data);
|
||||
break;
|
||||
}
|
||||
case 'deleteByCut': {
|
||||
selection.removeText();
|
||||
break;
|
||||
}
|
||||
case 'deleteContentBackward': {
|
||||
selection.deleteBackward();
|
||||
break;
|
||||
}
|
||||
case 'deleteContentForward': {
|
||||
selection.deleteForward();
|
||||
break;
|
||||
}
|
||||
case 'insertFromDrop': {
|
||||
insertFromDataTransfer(event, editor);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.log('TODO?', inputType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onCompositionEnd(event, view, state) {
|
||||
const data = event.data;
|
||||
// Only do this for Chrome
|
||||
state.isComposing = false;
|
||||
if (data && !isBrowserSafari && !isBrowserFirefox) {
|
||||
// The selection we get here will be with the composition text
|
||||
// already applied to the DOM, so it's too late. So instead we
|
||||
// restore the selection from when composition started.
|
||||
const selection = view.getSelection();
|
||||
const compositionSelection = state.compositionSelection;
|
||||
if (compositionSelection !== null) {
|
||||
selection.anchorOffset = compositionSelection.anchorOffset;
|
||||
selection.focusOffset = compositionSelection.focusOffset;
|
||||
state.compositionSelection = null;
|
||||
}
|
||||
selection.insertText(data);
|
||||
}
|
||||
}
|
||||
|
||||
function onCompositionStart(event, view, state) {
|
||||
state.isComposing = true;
|
||||
state.compositionSelection = view.getSelection();
|
||||
}
|
||||
|
||||
function onFocusIn(event, viewModel) {
|
||||
const body = viewModel.getBody();
|
||||
|
||||
if (body.getFirstChild() === null) {
|
||||
const text = viewModel.createText();
|
||||
body.append(viewModel.createBlock('p').append(text));
|
||||
text.select();
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
function onSelectionChange(event, helpers) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
function useEvent(editor, eventName, handler, pluginStateRef) {
|
||||
useEffect(() => {
|
||||
const state = pluginStateRef.current;
|
||||
if (state !== null && editor !== null) {
|
||||
const target =
|
||||
eventName === 'selectionchange' ? document : editor.getEditorElement();
|
||||
const wrapper = (event) => {
|
||||
const viewModel = editor.createViewModel((view) =>
|
||||
handler(event, view, state, editor),
|
||||
);
|
||||
// Uncomment to see how diffs might work:
|
||||
// if (viewModel !== outlineEditor.getCurrentViewModel()) {
|
||||
// const diff = outlineEditor.getDiffFromViewModel(viewModel);
|
||||
// debugger;
|
||||
// }
|
||||
if (!editor.isUpdating()) {
|
||||
editor.update(viewModel);
|
||||
}
|
||||
};
|
||||
target.addEventListener(eventName, wrapper);
|
||||
return () => {
|
||||
target.removeEventListener(eventName, wrapper);
|
||||
};
|
||||
}
|
||||
}, [eventName, handler, editor, pluginStateRef]);
|
||||
}
|
||||
|
||||
export function useRichTextPlugin(outlineEditor, isReadOnly = false) {
|
||||
const pluginStateRef = useRef(null);
|
||||
|
||||
// Handle event plugin state
|
||||
useEffect(() => {
|
||||
const pluginsState = pluginStateRef.current;
|
||||
|
||||
if (pluginsState === null) {
|
||||
pluginStateRef.current = {
|
||||
compositionSelection: null,
|
||||
isComposing: false,
|
||||
isReadOnly,
|
||||
};
|
||||
} else {
|
||||
pluginsState.isReadOnly = isReadOnly;
|
||||
}
|
||||
}, [isReadOnly]);
|
||||
|
||||
useEvent(outlineEditor, 'beforeinput', onBeforeInput, pluginStateRef);
|
||||
useEvent(outlineEditor, 'focusin', onFocusIn, pluginStateRef);
|
||||
useEvent(
|
||||
outlineEditor,
|
||||
'compositionstart',
|
||||
onCompositionStart,
|
||||
pluginStateRef,
|
||||
);
|
||||
useEvent(outlineEditor, 'compositionend', onCompositionEnd, pluginStateRef);
|
||||
useEvent(outlineEditor, 'keydown', onKeyDown, pluginStateRef);
|
||||
useEvent(outlineEditor, 'selectionchange', onSelectionChange, pluginStateRef);
|
||||
}
|
5
packages/outline/package.json
Normal file
5
packages/outline/package.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "outline",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js"
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createBodyNode } from "./OutlineNode";
|
||||
import { createViewModel, updateViewModel, ViewModel } from "./OutlineView";
|
||||
import {useEffect, useState} from 'react';
|
||||
import {createBodyNode} from './OutlineNode';
|
||||
import {createViewModel, updateViewModel, ViewModel} from './OutlineView';
|
||||
|
||||
function createOutlineEditor(editorElement, onChange) {
|
||||
const viewModel = new ViewModel();
|
||||
@ -8,8 +8,8 @@ function createOutlineEditor(editorElement, onChange) {
|
||||
viewModel.body = body;
|
||||
viewModel.nodeMap.body = body;
|
||||
const outlineEditor = new OutlineEditor(editorElement, viewModel, onChange);
|
||||
outlineEditor._keyToDOMMap.set("body", editorElement);
|
||||
if (typeof onChange === "function") {
|
||||
outlineEditor._keyToDOMMap.set('body', editorElement);
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(viewModel);
|
||||
}
|
||||
return outlineEditor;
|
||||
@ -46,7 +46,7 @@ Object.assign(OutlineEditor.prototype, {
|
||||
getElementByKey(key) {
|
||||
const element = this._keyToDOMMap.get(key);
|
||||
if (element === undefined) {
|
||||
throw new Error("getElementByKey failed for key " + key);
|
||||
throw new Error('getElementByKey failed for key ' + key);
|
||||
}
|
||||
return element;
|
||||
},
|
||||
@ -60,13 +60,13 @@ Object.assign(OutlineEditor.prototype, {
|
||||
|
||||
if (dirtyNodes === null || dirtySubTrees === null) {
|
||||
throw new Error(
|
||||
"getDiffFromViewModel: unable to get diff from view mode"
|
||||
'getDiffFromViewModel: unable to get diff from view mode',
|
||||
);
|
||||
}
|
||||
return {
|
||||
dirtySubTrees: dirtySubTrees,
|
||||
nodes: Array.from(dirtyNodes).map((nodeKey) => nodeMap[nodeKey]),
|
||||
selection: { ...viewModel.selection },
|
||||
selection: {...viewModel.selection},
|
||||
timeStamp: Date.now(),
|
||||
};
|
||||
},
|
||||
@ -75,7 +75,7 @@ Object.assign(OutlineEditor.prototype, {
|
||||
},
|
||||
update(viewModel, forceSync) {
|
||||
if (this._isUpdating) {
|
||||
throw new Error("TODOL: Should never occur?");
|
||||
throw new Error('TODOL: Should never occur?');
|
||||
}
|
||||
if (viewModel === this._viewModel) {
|
||||
return;
|
@ -1,5 +1,5 @@
|
||||
import { getActiveViewModel, markParentsAsDirty } from "./OutlineView";
|
||||
import { getSelection } from "./OutlineSelection";
|
||||
import {getActiveViewModel, markParentsAsDirty} from './OutlineView';
|
||||
import {getSelection} from './OutlineSelection';
|
||||
|
||||
let nodeKeyCounter = 0;
|
||||
|
||||
@ -8,10 +8,11 @@ export const IS_BLOCK = 1 << 1;
|
||||
export const IS_TEXT = 1 << 2;
|
||||
export const IS_IMMUTABLE = 1 << 3;
|
||||
export const IS_SEGMENTED = 1 << 4;
|
||||
export const IS_BOLD = 1 << 5;
|
||||
export const IS_ITALIC = 1 << 6;
|
||||
export const IS_STRIKETHROUGH = 1 << 7;
|
||||
export const IS_UNDERLINE = 1 << 8;
|
||||
export const IS_LINK = 1 << 5;
|
||||
export const IS_BOLD = 1 << 6;
|
||||
export const IS_ITALIC = 1 << 7;
|
||||
export const IS_STRIKETHROUGH = 1 << 8;
|
||||
export const IS_UNDERLINE = 1 << 9;
|
||||
|
||||
export const FORMAT_BOLD = 0;
|
||||
export const FORMAT_ITALIC = 1;
|
||||
@ -82,8 +83,8 @@ function combineAdjacentTextNodes(textNodes, restoreSelection) {
|
||||
// Merge all text nodes into the first node
|
||||
const writableMergeToNode = getWritableNode(textNodes[0]);
|
||||
let textLength = writableMergeToNode.getTextContent().length;
|
||||
let restoreAnchorOffset = 0;
|
||||
let restoreFocusOffset = 0;
|
||||
let restoreAnchorOffset = anchorOffset;
|
||||
let restoreFocusOffset = focusOffset;
|
||||
for (let i = 1; i < textNodes.length; i++) {
|
||||
const textNode = textNodes[i];
|
||||
const siblingText = textNode.getTextContent();
|
||||
@ -104,22 +105,22 @@ function combineAdjacentTextNodes(textNodes, restoreSelection) {
|
||||
|
||||
function splitText(node, splitOffsets) {
|
||||
if (!node.isText() || node.isImmutable()) {
|
||||
throw new Error("splitText: can only be used on non-immutable text nodes");
|
||||
throw new Error('splitText: can only be used on non-immutable text nodes');
|
||||
}
|
||||
const textContent = node.getTextContent();
|
||||
const key = node._key;
|
||||
const offsetsSet = new Set(splitOffsets);
|
||||
const parts = [];
|
||||
const textLength = textContent.length;
|
||||
let string = "";
|
||||
let string = '';
|
||||
for (let i = 0; i < textLength; i++) {
|
||||
if (string !== "" && offsetsSet.has(i)) {
|
||||
if (string !== '' && offsetsSet.has(i)) {
|
||||
parts.push(string);
|
||||
string = "";
|
||||
string = '';
|
||||
}
|
||||
string += textContent[i];
|
||||
}
|
||||
if (string !== "") {
|
||||
if (string !== '') {
|
||||
parts.push(string);
|
||||
}
|
||||
const partsLength = parts.length;
|
||||
@ -137,7 +138,7 @@ function splitText(node, splitOffsets) {
|
||||
|
||||
// Handle selection
|
||||
const selection = getSelection();
|
||||
const { anchorKey, anchorOffset, focusKey, focusOffset } = selection;
|
||||
const {anchorKey, anchorOffset, focusKey, focusOffset} = selection;
|
||||
|
||||
// Then handle all other parts
|
||||
const splitNodes = [writableNode];
|
||||
@ -189,6 +190,7 @@ function Node(flags, children) {
|
||||
this._key = null;
|
||||
this._parent = null;
|
||||
this._style = null;
|
||||
this._url = null;
|
||||
}
|
||||
|
||||
Object.assign(Node.prototype, {
|
||||
@ -199,9 +201,13 @@ Object.assign(Node.prototype, {
|
||||
const flags = self._flags;
|
||||
return getNodeType(self, flags);
|
||||
},
|
||||
getFlags() {
|
||||
const self = this.getLatest();
|
||||
return self._flags;
|
||||
},
|
||||
getChildren() {
|
||||
if (this.isText()) {
|
||||
throw new Error("getChildren: can only be used on body/block nodes");
|
||||
throw new Error('getChildren: can only be used on body/block nodes');
|
||||
}
|
||||
const self = this.getLatest();
|
||||
const children = self._children;
|
||||
@ -219,7 +225,7 @@ Object.assign(Node.prototype, {
|
||||
if (this.isText()) {
|
||||
return self._children;
|
||||
}
|
||||
let textContent = "";
|
||||
let textContent = '';
|
||||
const children = this.getChildren();
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
textContent += children[i].getTextContent();
|
||||
@ -228,7 +234,7 @@ Object.assign(Node.prototype, {
|
||||
},
|
||||
getBlockType() {
|
||||
if (this.isText()) {
|
||||
throw new Error("getChildrenDeep: can only be used on block nodes");
|
||||
throw new Error('getChildrenDeep: can only be used on block nodes');
|
||||
}
|
||||
return this.getLatest()._type;
|
||||
},
|
||||
@ -279,6 +285,12 @@ Object.assign(Node.prototype, {
|
||||
}
|
||||
return getNodeByKey(children[index + 1]);
|
||||
},
|
||||
getNextSiblings() {
|
||||
const parent = this.getParent();
|
||||
const children = parent._children;
|
||||
const index = children.indexOf(this._key);
|
||||
return children.slice(index + 1).map((childKey) => getNodeByKey(childKey));
|
||||
},
|
||||
getCommonAncestor(node) {
|
||||
const a = this.getParents();
|
||||
const b = node.getParents();
|
||||
@ -484,7 +496,7 @@ Object.assign(Node.prototype, {
|
||||
|
||||
setFlags(flags) {
|
||||
if (this.isImmutable()) {
|
||||
throw new Error("setFlags: can only be used on non-immutable nodes");
|
||||
throw new Error('setFlags: can only be used on non-immutable nodes');
|
||||
}
|
||||
const self = getWritableNode(this);
|
||||
self._flags = flags;
|
||||
@ -492,7 +504,7 @@ Object.assign(Node.prototype, {
|
||||
},
|
||||
setData(data) {
|
||||
if (this.isImmutable()) {
|
||||
throw new Error("setData: can only be used on non-immutable nodes");
|
||||
throw new Error('setData: can only be used on non-immutable nodes');
|
||||
}
|
||||
const self = getWritableNode(this);
|
||||
self._data = data;
|
||||
@ -500,7 +512,7 @@ Object.assign(Node.prototype, {
|
||||
},
|
||||
setStyle(style) {
|
||||
if (this.isImmutable()) {
|
||||
throw new Error("setStyle: can only be used on non-immutable nodes");
|
||||
throw new Error('setStyle: can only be used on non-immutable nodes');
|
||||
}
|
||||
const self = getWritableNode(this);
|
||||
self._style = style;
|
||||
@ -508,7 +520,7 @@ Object.assign(Node.prototype, {
|
||||
},
|
||||
makeBold() {
|
||||
if (!this.isText() || this.isImmutable()) {
|
||||
throw new Error("makeBold: can only be used on non-immutable text nodes");
|
||||
throw new Error('makeBold: can only be used on non-immutable text nodes');
|
||||
}
|
||||
const self = getWritableNode(this);
|
||||
self._flags |= IS_BOLD;
|
||||
@ -526,7 +538,7 @@ Object.assign(Node.prototype, {
|
||||
},
|
||||
makeNormal() {
|
||||
if (!this.isText() || this.isImmutable()) {
|
||||
throw new Error("select: can only be used on non-immutable text nodes");
|
||||
throw new Error('select: can only be used on non-immutable text nodes');
|
||||
}
|
||||
const self = getWritableNode(this);
|
||||
self._flags = IS_TEXT;
|
||||
@ -534,14 +546,14 @@ Object.assign(Node.prototype, {
|
||||
},
|
||||
select(anchorOffset, focusOffset, isCollapsed = false) {
|
||||
if (!this.isText()) {
|
||||
throw new Error("select: can only be used on text nodes");
|
||||
throw new Error('select: can only be used on text nodes');
|
||||
}
|
||||
const selection = getSelection();
|
||||
const text = this.getTextContent();
|
||||
const key = this._key;
|
||||
selection.anchorKey = key;
|
||||
selection.focusKey = key;
|
||||
if (typeof text === "string") {
|
||||
if (typeof text === 'string') {
|
||||
const lastOffset = text.length;
|
||||
if (anchorOffset === undefined) {
|
||||
anchorOffset = lastOffset;
|
||||
@ -572,17 +584,17 @@ Object.assign(Node.prototype, {
|
||||
insertAfter(nodeToInsert) {
|
||||
const writableSelf = getWritableNode(this);
|
||||
const writableNodeToInsert = getWritableNode(nodeToInsert);
|
||||
const writableParent = getWritableNode(this.getParent());
|
||||
const nodeToInsertParent = nodeToInsert.getParent();
|
||||
const insertKey = nodeToInsert._key;
|
||||
if (nodeToInsertParent !== null) {
|
||||
const writableNodeToInsertParent = getWritableNode(nodeToInsertParent);
|
||||
const parentChildren = writableNodeToInsertParent._children;
|
||||
const index = parentChildren.indexOf(insertKey);
|
||||
const oldParent = writableNodeToInsert.getParent();
|
||||
if (oldParent !== null) {
|
||||
const writableParent = getWritableNode(oldParent);
|
||||
const children = writableParent._children;
|
||||
const index = children.indexOf(writableNodeToInsert._key);
|
||||
if (index > -1) {
|
||||
parentChildren.splice(index, 1);
|
||||
children.splice(index, 1);
|
||||
}
|
||||
}
|
||||
const writableParent = getWritableNode(this.getParent());
|
||||
const insertKey = nodeToInsert._key;
|
||||
writableNodeToInsert._parent = writableSelf._parent;
|
||||
const children = writableParent._children;
|
||||
const index = children.indexOf(writableSelf._key);
|
||||
@ -595,6 +607,15 @@ Object.assign(Node.prototype, {
|
||||
insertBefore(nodeToInsert) {
|
||||
const writableSelf = getWritableNode(this);
|
||||
const writableNodeToInsert = getWritableNode(nodeToInsert);
|
||||
const oldParent = writableNodeToInsert.getParent();
|
||||
if (oldParent !== null) {
|
||||
const writableParent = getWritableNode(oldParent);
|
||||
const children = writableParent._children;
|
||||
const index = children.indexOf(writableNodeToInsert._key);
|
||||
if (index > -1) {
|
||||
children.splice(index, 1);
|
||||
}
|
||||
}
|
||||
const writableParent = getWritableNode(this.getParent());
|
||||
const insertKey = nodeToInsert._key;
|
||||
writableNodeToInsert._parent = writableSelf._parent;
|
||||
@ -608,10 +629,20 @@ Object.assign(Node.prototype, {
|
||||
// TODO add support for appending multiple nodes?
|
||||
append(nodeToAppend) {
|
||||
if (this.isText()) {
|
||||
throw new Error("append(): can only used on body/block nodes");
|
||||
throw new Error('append(): can only used on body/block nodes');
|
||||
}
|
||||
const writableSelf = getWritableNode(this);
|
||||
const writableNodeToAppend = getWritableNode(nodeToAppend);
|
||||
// Remove node from previous parent
|
||||
const oldParent = writableNodeToAppend.getParent();
|
||||
if (oldParent !== null) {
|
||||
const writableParent = getWritableNode(oldParent);
|
||||
const children = writableParent._children;
|
||||
const index = children.indexOf(writableNodeToAppend._key);
|
||||
if (index > -1) {
|
||||
children.splice(index, 1);
|
||||
}
|
||||
}
|
||||
// Set child parent to self
|
||||
writableNodeToAppend._parent = writableSelf._key;
|
||||
// Append children.
|
||||
@ -621,7 +652,7 @@ Object.assign(Node.prototype, {
|
||||
setTextContent(text) {
|
||||
if (!this.isText() || this.isImmutable()) {
|
||||
throw new Error(
|
||||
"spliceText: can only be used on non-immutable text nodes"
|
||||
'spliceText: can only be used on non-immutable text nodes',
|
||||
);
|
||||
}
|
||||
const writableSelf = getWritableNode(this);
|
||||
@ -631,7 +662,7 @@ Object.assign(Node.prototype, {
|
||||
spliceText(offset, delCount, newText, restoreSelection) {
|
||||
if (!this.isText() || this.isImmutable()) {
|
||||
throw new Error(
|
||||
"spliceText: can only be used on non-immutable text nodes"
|
||||
'spliceText: can only be used on non-immutable text nodes',
|
||||
);
|
||||
}
|
||||
const writableSelf = getWritableNode(this);
|
||||
@ -689,12 +720,12 @@ Object.assign(Node.prototype, {
|
||||
export function getNodeType(node, flags) {
|
||||
if (flags & IS_TEXT) {
|
||||
if (flags & IS_BOLD) {
|
||||
return "strong";
|
||||
return 'strong';
|
||||
}
|
||||
if (flags & IS_ITALIC) {
|
||||
return "em";
|
||||
return 'em';
|
||||
}
|
||||
return "span";
|
||||
return 'span';
|
||||
}
|
||||
return node._type;
|
||||
}
|
||||
@ -709,6 +740,7 @@ export function cloneNode(node) {
|
||||
clonedNode._style = node._style;
|
||||
clonedNode._parent = node._parent;
|
||||
clonedNode._data = node._data;
|
||||
clonedNode._url = node._url;
|
||||
clonedNode._key = key;
|
||||
return clonedNode;
|
||||
}
|
||||
@ -749,7 +781,7 @@ function getWritableNode(node, skipKeyGeneration) {
|
||||
return mutableNode;
|
||||
}
|
||||
|
||||
export function createBlockNode(blockType = "div") {
|
||||
export function createBlockNode(blockType = 'div') {
|
||||
const node = new Node(IS_BLOCK, []);
|
||||
node._type = blockType;
|
||||
return node;
|
||||
@ -757,11 +789,11 @@ export function createBlockNode(blockType = "div") {
|
||||
|
||||
export function createBodyNode() {
|
||||
const body = new Node(IS_BODY, []);
|
||||
body._key = "body";
|
||||
body._key = 'body';
|
||||
return body;
|
||||
}
|
||||
|
||||
export function createTextNode(text = "") {
|
||||
export function createTextNode(text = '') {
|
||||
const node = new Node(IS_TEXT);
|
||||
node._children = text;
|
||||
return node;
|
@ -5,29 +5,29 @@ import {
|
||||
IS_STRIKETHROUGH,
|
||||
IS_TEXT,
|
||||
IS_UNDERLINE,
|
||||
} from "./OutlineNode";
|
||||
} from './OutlineNode';
|
||||
|
||||
let subTreeTextContent = "";
|
||||
let subTreeTextContent = '';
|
||||
let forceTextDirection = null;
|
||||
|
||||
const RTL = "\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC";
|
||||
const RTL = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC';
|
||||
const LTR =
|
||||
"A-Za-z\u00C0-\u00D6\u00D8-\u00F6" +
|
||||
"\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u200E\u2C00-\uFB1C" +
|
||||
"\uFE00-\uFE6F\uFEFD-\uFFFF";
|
||||
'A-Za-z\u00C0-\u00D6\u00D8-\u00F6' +
|
||||
'\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u200E\u2C00-\uFB1C' +
|
||||
'\uFE00-\uFE6F\uFEFD-\uFFFF';
|
||||
|
||||
const rtl = new RegExp("^[^" + LTR + "]*[" + RTL + "]");
|
||||
const ltr = new RegExp("^[^" + RTL + "]*[" + LTR + "]");
|
||||
const zeroWidthString = "\uFEFF";
|
||||
const rtl = new RegExp('^[^' + LTR + ']*[' + RTL + ']');
|
||||
const ltr = new RegExp('^[^' + RTL + ']*[' + LTR + ']');
|
||||
const zeroWidthString = '\uFEFF';
|
||||
|
||||
function getTextDirection(text) {
|
||||
if (rtl.test(text)) {
|
||||
return "rtl";
|
||||
return 'rtl';
|
||||
}
|
||||
if (ltr.test(text)) {
|
||||
return "ltr";
|
||||
return 'ltr';
|
||||
}
|
||||
return "";
|
||||
return '';
|
||||
}
|
||||
|
||||
function handleBlockTextDirection(dom) {
|
||||
@ -45,7 +45,7 @@ function setTextContent(prevText, nextText, dom, node) {
|
||||
const hasBreakNode = firstChild && firstChild.nextSibling;
|
||||
// Check if we are on an empty line
|
||||
if (node.getNextSibling() === null) {
|
||||
if (nextText === "") {
|
||||
if (nextText === '') {
|
||||
if (firstChild === null) {
|
||||
// We use a zero width string so that the browser moves
|
||||
// the cursor into the text node. It won't move the cursor
|
||||
@ -54,22 +54,18 @@ function setTextContent(prevText, nextText, dom, node) {
|
||||
// to ensure we take up a full line, as we don't have any
|
||||
// characters taking up the full height yet.
|
||||
dom.appendChild(document.createTextNode(zeroWidthString));
|
||||
dom.appendChild(document.createElement("br"));
|
||||
dom.appendChild(document.createElement('br'));
|
||||
} else if (!hasBreakNode) {
|
||||
firstChild.nodeValue = zeroWidthString;
|
||||
dom.appendChild(document.createElement("br"));
|
||||
dom.appendChild(document.createElement('br'));
|
||||
}
|
||||
return;
|
||||
} else if (nextText.endsWith("\n")) {
|
||||
nextText = nextText + "\n";
|
||||
} else if (nextText.endsWith('\n')) {
|
||||
nextText += '\n';
|
||||
}
|
||||
}
|
||||
if (firstChild === null || hasBreakNode) {
|
||||
if (nextText === '') {
|
||||
dom.appendChild(document.createTextNode(zeroWidthString));
|
||||
} else {
|
||||
dom.textContent = nextText;
|
||||
}
|
||||
dom.textContent = nextText === '' ? zeroWidthString : nextText;
|
||||
} else if (prevText !== nextText) {
|
||||
firstChild.nodeValue = nextText;
|
||||
}
|
||||
@ -94,7 +90,7 @@ function destroyNode(key, parentDOM, prevNodeMap, nextNodeMap, editor) {
|
||||
null,
|
||||
prevNodeMap,
|
||||
nextNodeMap,
|
||||
editor
|
||||
editor,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -106,7 +102,7 @@ function destroyChildren(
|
||||
dom,
|
||||
prevNodeMap,
|
||||
nextNodeMap,
|
||||
editor
|
||||
editor,
|
||||
) {
|
||||
for (; startIndex <= endIndex; ++startIndex) {
|
||||
destroyNode(children[startIndex], dom, prevNodeMap, nextNodeMap, editor);
|
||||
@ -134,13 +130,13 @@ function buildNode(key, parentDOM, insertDOM, nodeMap, editor) {
|
||||
subTreeTextContent += children;
|
||||
setTextContent(null, children, dom, node);
|
||||
// add data-text attribute
|
||||
dom.setAttribute("data-text", true);
|
||||
dom.setAttribute('data-text', true);
|
||||
if (flags & IS_SEGMENTED) {
|
||||
dom.setAttribute("spellcheck", "false");
|
||||
dom.setAttribute('spellcheck', 'false');
|
||||
}
|
||||
} else {
|
||||
let previousSubTreeTextContent = subTreeTextContent;
|
||||
subTreeTextContent = "";
|
||||
const previousSubTreeTextContent = subTreeTextContent;
|
||||
subTreeTextContent = '';
|
||||
const childrenLength = children.length;
|
||||
buildChildren(children, 0, childrenLength - 1, dom, null, nodeMap, editor);
|
||||
handleBlockTextDirection(dom);
|
||||
@ -163,7 +159,7 @@ function buildChildren(
|
||||
dom,
|
||||
insertDOM,
|
||||
nodeMap,
|
||||
editor
|
||||
editor,
|
||||
) {
|
||||
for (; startIndex <= endIndex; ++startIndex) {
|
||||
buildNode(children[startIndex], dom, insertDOM, nodeMap, editor);
|
||||
@ -171,15 +167,15 @@ function buildChildren(
|
||||
}
|
||||
|
||||
function setTextStyling(domStyle, type, prevFlags, nextFlags) {
|
||||
if (type === "strong") {
|
||||
if (type === 'strong') {
|
||||
if (nextFlags & IS_ITALIC) {
|
||||
// When prev is not italic, but next is
|
||||
if ((prevFlags & IS_ITALIC) === 0) {
|
||||
domStyle.setProperty("font-style", "italic");
|
||||
domStyle.setProperty('font-style', 'italic');
|
||||
}
|
||||
} else if (prevFlags & IS_ITALIC) {
|
||||
// When prev was italic, but the next is not
|
||||
domStyle.setProperty("font-style", "normal");
|
||||
domStyle.setProperty('font-style', 'normal');
|
||||
}
|
||||
}
|
||||
const prevIsNotStrikeThrough = (prevFlags & IS_STRIKETHROUGH) === 0;
|
||||
@ -188,18 +184,18 @@ function setTextStyling(domStyle, type, prevFlags, nextFlags) {
|
||||
const nextIsUnderline = nextFlags & IS_UNDERLINE;
|
||||
if (nextIsStrikeThrough && nextIsUnderline) {
|
||||
if (prevIsNotStrikeThrough || prevIsNotUnderline) {
|
||||
domStyle.setProperty("text-decoration", "underline line-through");
|
||||
domStyle.setProperty('text-decoration', 'underline line-through');
|
||||
}
|
||||
} else if (nextIsStrikeThrough) {
|
||||
if (prevIsNotStrikeThrough) {
|
||||
domStyle.setProperty("text-decoration", "line-through");
|
||||
domStyle.setProperty('text-decoration', 'line-through');
|
||||
}
|
||||
} else if (nextIsUnderline) {
|
||||
if (prevIsNotUnderline) {
|
||||
domStyle.setProperty("text-decoration", "underline");
|
||||
domStyle.setProperty('text-decoration', 'underline');
|
||||
}
|
||||
} else {
|
||||
domStyle.setProperty("text-decoration", "initial");
|
||||
domStyle.setProperty('text-decoration', 'initial');
|
||||
}
|
||||
}
|
||||
|
||||
@ -209,7 +205,7 @@ function reconcileNode(
|
||||
prevNodeMap,
|
||||
nextNodeMap,
|
||||
editor,
|
||||
dirtySubTrees
|
||||
dirtySubTrees,
|
||||
) {
|
||||
const prevNode = prevNodeMap[key];
|
||||
const nextNode = nextNodeMap[key];
|
||||
@ -252,7 +248,7 @@ function reconcileNode(
|
||||
|
||||
if (prevStyle !== nextStyle) {
|
||||
if (nextStyle === null) {
|
||||
domStyle.cssText = "";
|
||||
domStyle.cssText = '';
|
||||
} else {
|
||||
domStyle.cssText = nextStyle;
|
||||
}
|
||||
@ -266,11 +262,11 @@ function reconcileNode(
|
||||
setTextContent(prevChildren, nextChildren, dom, nextNode);
|
||||
if (nextFlags & IS_SEGMENTED) {
|
||||
if ((prevFlags & IS_SEGMENTED) === 0) {
|
||||
dom.setAttribute("spellcheck", "false");
|
||||
dom.setAttribute('spellcheck', 'false');
|
||||
}
|
||||
} else {
|
||||
if (prevFlags & IS_SEGMENTED) {
|
||||
dom.removeAttribute("spellcheck");
|
||||
dom.removeAttribute('spellcheck');
|
||||
}
|
||||
}
|
||||
return;
|
||||
@ -281,8 +277,8 @@ function reconcileNode(
|
||||
if (childrenAreDifferent || hasDirtySubTree) {
|
||||
const prevChildrenLength = prevChildren.length;
|
||||
const nextChildrenLength = nextChildren.length;
|
||||
let previousSubTreeTextContent = subTreeTextContent;
|
||||
subTreeTextContent = "";
|
||||
const previousSubTreeTextContent = subTreeTextContent;
|
||||
subTreeTextContent = '';
|
||||
|
||||
if (prevChildrenLength === 1 && nextChildrenLength === 1) {
|
||||
const prevChildKey = prevChildren[0];
|
||||
@ -294,7 +290,7 @@ function reconcileNode(
|
||||
prevNodeMap,
|
||||
nextNodeMap,
|
||||
editor,
|
||||
dirtySubTrees
|
||||
dirtySubTrees,
|
||||
);
|
||||
} else {
|
||||
const lastDOM = editor.getElementByKey(prevChildKey);
|
||||
@ -303,7 +299,7 @@ function reconcileNode(
|
||||
null,
|
||||
null,
|
||||
nextNodeMap,
|
||||
editor
|
||||
editor,
|
||||
);
|
||||
dom.replaceChild(replacementDOM, lastDOM);
|
||||
destroyNode(prevChildKey, null, prevNodeMap, nextNodeMap, editor);
|
||||
@ -317,7 +313,7 @@ function reconcileNode(
|
||||
dom,
|
||||
null,
|
||||
nextNodeMap,
|
||||
editor
|
||||
editor,
|
||||
);
|
||||
}
|
||||
} else if (nextChildrenLength === 0) {
|
||||
@ -329,10 +325,10 @@ function reconcileNode(
|
||||
null,
|
||||
prevNodeMap,
|
||||
nextNodeMap,
|
||||
editor
|
||||
editor,
|
||||
);
|
||||
// Fast path for removing DOM nodes
|
||||
dom.textContent = "";
|
||||
dom.textContent = '';
|
||||
}
|
||||
} else {
|
||||
reconcileNodeChildren(
|
||||
@ -344,7 +340,7 @@ function reconcileNode(
|
||||
prevNodeMap,
|
||||
nextNodeMap,
|
||||
editor,
|
||||
dirtySubTrees
|
||||
dirtySubTrees,
|
||||
);
|
||||
}
|
||||
handleBlockTextDirection(dom);
|
||||
@ -368,7 +364,7 @@ function findIndexInPrevChildren(
|
||||
targetKey,
|
||||
prevChildren,
|
||||
startIndex,
|
||||
endIndex
|
||||
endIndex,
|
||||
) {
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const c = prevChildren[i];
|
||||
@ -390,7 +386,7 @@ function reconcileNodeChildren(
|
||||
prevNodeMap,
|
||||
nextNodeMap,
|
||||
editor,
|
||||
dirtySubTrees
|
||||
dirtySubTrees,
|
||||
) {
|
||||
let hasClonedPrevChildren = false;
|
||||
let prevStartIndex = 0;
|
||||
@ -415,7 +411,7 @@ function reconcileNodeChildren(
|
||||
prevNodeMap,
|
||||
nextNodeMap,
|
||||
editor,
|
||||
dirtySubTrees
|
||||
dirtySubTrees,
|
||||
);
|
||||
prevStartKey = prevChildren[++prevStartIndex];
|
||||
nextStartKey = nextChildren[++nextStartIndex];
|
||||
@ -426,7 +422,7 @@ function reconcileNodeChildren(
|
||||
prevNodeMap,
|
||||
nextNodeMap,
|
||||
editor,
|
||||
dirtySubTrees
|
||||
dirtySubTrees,
|
||||
);
|
||||
prevEndKey = prevChildren[--prevEndIndex];
|
||||
nextEndKey = nextChildren[--nextEndIndex];
|
||||
@ -437,11 +433,11 @@ function reconcileNodeChildren(
|
||||
prevNodeMap,
|
||||
nextNodeMap,
|
||||
editor,
|
||||
dirtySubTrees
|
||||
dirtySubTrees,
|
||||
);
|
||||
dom.insertBefore(
|
||||
editor.getElementByKey(prevStartKey),
|
||||
editor.getElementByKey(prevEndKey).nextSibling
|
||||
editor.getElementByKey(prevEndKey).nextSibling,
|
||||
);
|
||||
prevStartKey = prevChildren[++prevStartIndex];
|
||||
nextEndKey = nextChildren[--nextEndIndex];
|
||||
@ -452,11 +448,11 @@ function reconcileNodeChildren(
|
||||
prevNodeMap,
|
||||
nextNodeMap,
|
||||
editor,
|
||||
dirtySubTrees
|
||||
dirtySubTrees,
|
||||
);
|
||||
dom.insertBefore(
|
||||
editor.getElementByKey(prevEndKey),
|
||||
editor.getElementByKey(prevStartKey)
|
||||
editor.getElementByKey(prevStartKey),
|
||||
);
|
||||
prevEndKey = prevChildren[--prevEndIndex];
|
||||
nextStartKey = nextChildren[++nextStartIndex];
|
||||
@ -466,7 +462,7 @@ function reconcileNodeChildren(
|
||||
prevKeyToIndexMap = createKeyToIndexMap(
|
||||
prevChildren,
|
||||
prevStartIndex,
|
||||
prevEndIndex
|
||||
prevEndIndex,
|
||||
);
|
||||
}
|
||||
const indexInPrevChildren =
|
||||
@ -476,7 +472,7 @@ function reconcileNodeChildren(
|
||||
nextStartKey,
|
||||
prevChildren,
|
||||
prevStartIndex,
|
||||
prevEndIndex
|
||||
prevEndIndex,
|
||||
);
|
||||
if (indexInPrevChildren === undefined) {
|
||||
buildNode(
|
||||
@ -484,7 +480,7 @@ function reconcileNodeChildren(
|
||||
dom,
|
||||
editor.getElementByKey(prevStartKey),
|
||||
nextNodeMap,
|
||||
editor
|
||||
editor,
|
||||
);
|
||||
} else {
|
||||
const keyToMove = prevChildren[indexInPrevChildren];
|
||||
@ -495,7 +491,7 @@ function reconcileNodeChildren(
|
||||
prevNodeMap,
|
||||
nextNodeMap,
|
||||
editor,
|
||||
dirtySubTrees
|
||||
dirtySubTrees,
|
||||
);
|
||||
if (hasClonedPrevChildren) {
|
||||
hasClonedPrevChildren = true;
|
||||
@ -504,10 +500,10 @@ function reconcileNodeChildren(
|
||||
prevChildren[indexInPrevChildren] = undefined;
|
||||
dom.insertBefore(
|
||||
editor.getElementByKey(keyToMove),
|
||||
editor.getElementByKey(prevStartKey)
|
||||
editor.getElementByKey(prevStartKey),
|
||||
);
|
||||
} else {
|
||||
throw new Error("TODO: Should this ever happen?");
|
||||
throw new Error('TODO: Should this ever happen?');
|
||||
}
|
||||
}
|
||||
nextStartKey = nextChildren[++nextStartIndex];
|
||||
@ -524,7 +520,7 @@ function reconcileNodeChildren(
|
||||
dom,
|
||||
insertDOM,
|
||||
nextNodeMap,
|
||||
editor
|
||||
editor,
|
||||
);
|
||||
} else if (nextStartIndex > nextEndIndex) {
|
||||
destroyChildren(
|
||||
@ -534,7 +530,7 @@ function reconcileNodeChildren(
|
||||
dom,
|
||||
prevNodeMap,
|
||||
nextNodeMap,
|
||||
editor
|
||||
editor,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -544,16 +540,16 @@ export function reconcileViewModel(nextViewModel, editor) {
|
||||
// TODO: take this value from Editor props, default to null;
|
||||
// This will over-ride any sub-tree text direction properties.
|
||||
forceTextDirection = null;
|
||||
subTreeTextContent = "";
|
||||
subTreeTextContent = '';
|
||||
const dirtySubTrees = nextViewModel._dirtySubTrees;
|
||||
|
||||
reconcileNode(
|
||||
"body",
|
||||
'body',
|
||||
null,
|
||||
prevViewModel.nodeMap,
|
||||
nextViewModel.nodeMap,
|
||||
editor,
|
||||
dirtySubTrees
|
||||
dirtySubTrees,
|
||||
);
|
||||
|
||||
const nextSelection = nextViewModel.selection;
|
||||
@ -579,7 +575,7 @@ function getSelectionElement(key, editor) {
|
||||
|
||||
export function storeDOMWithKey(key, dom, editor) {
|
||||
if (key === null) {
|
||||
throw new Error("storeDOMWithNodeKey failed");
|
||||
throw new Error('storeDOMWithNodeKey failed');
|
||||
}
|
||||
const keyToDOMMap = editor._keyToDOMMap;
|
||||
dom.__outlineInternalRef = key;
|
@ -1,5 +1,6 @@
|
||||
import { getActiveViewModel } from "./OutlineView";
|
||||
import {getActiveViewModel} from './OutlineView';
|
||||
import {
|
||||
createBlockNode,
|
||||
createTextNode,
|
||||
FORMAT_BOLD,
|
||||
FORMAT_ITALIC,
|
||||
@ -9,17 +10,16 @@ import {
|
||||
IS_BOLD,
|
||||
IS_ITALIC,
|
||||
IS_STRIKETHROUGH,
|
||||
IS_TEXT,
|
||||
IS_UNDERLINE,
|
||||
} from "./OutlineNode";
|
||||
import { getNodeKeyFromDOM } from "./OutlineReconciler";
|
||||
} from './OutlineNode';
|
||||
import {getNodeKeyFromDOM} from './OutlineReconciler';
|
||||
|
||||
function Selection(
|
||||
anchorKey,
|
||||
anchorOffset,
|
||||
focusKey,
|
||||
focusOffset,
|
||||
isCollapsed
|
||||
isCollapsed,
|
||||
) {
|
||||
this.anchorKey = anchorKey;
|
||||
this.anchorOffset = anchorOffset;
|
||||
@ -28,6 +28,27 @@ function Selection(
|
||||
this.isCollapsed = isCollapsed;
|
||||
}
|
||||
|
||||
function removeLastSegment(node) {
|
||||
const currentBlock = node.getParentBlock();
|
||||
const ancestor = node.getParentBefore(currentBlock);
|
||||
const textContent = node.getTextContent();
|
||||
const lastSpaceIndex = textContent.lastIndexOf(' ');
|
||||
if (lastSpaceIndex > -1) {
|
||||
node.spliceText(
|
||||
lastSpaceIndex,
|
||||
textContent.length - lastSpaceIndex,
|
||||
'',
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
const textNode = createTextNode('');
|
||||
ancestor.insertAfter(textNode);
|
||||
node.remove();
|
||||
textNode.select();
|
||||
currentBlock.normalizeTextNodes(true);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(Selection.prototype, {
|
||||
isCaret() {
|
||||
return (
|
||||
@ -70,7 +91,7 @@ Object.assign(Selection.prototype, {
|
||||
if (firstNodeTextLength === 0) {
|
||||
firstNode.setFlags(firstNextFlags);
|
||||
} else {
|
||||
const textNode = createTextNode("");
|
||||
const textNode = createTextNode('');
|
||||
textNode.setFlags(firstNextFlags);
|
||||
firstNode.insertAfter(textNode);
|
||||
textNode.select();
|
||||
@ -113,7 +134,7 @@ Object.assign(Selection.prototype, {
|
||||
const lastNextFlags = getTextNodeFormatFlags(
|
||||
lastNode,
|
||||
formatType,
|
||||
firstNextFlags
|
||||
firstNextFlags,
|
||||
);
|
||||
const lastNodeText = lastNode.getTextContent();
|
||||
const lastNodeTextLength = lastNodeText.length;
|
||||
@ -129,7 +150,7 @@ Object.assign(Selection.prototype, {
|
||||
const selectedNextFlags = getTextNodeFormatFlags(
|
||||
lastNode,
|
||||
formatType,
|
||||
firstNextFlags
|
||||
firstNextFlags,
|
||||
);
|
||||
selectedNode.setFlags(selectedNextFlags);
|
||||
}
|
||||
@ -138,7 +159,7 @@ Object.assign(Selection.prototype, {
|
||||
firstNode.getKey(),
|
||||
startOffset,
|
||||
lastNode.getKey(),
|
||||
endOffset
|
||||
endOffset,
|
||||
);
|
||||
currentBlock.normalizeTextNodes(true);
|
||||
}
|
||||
@ -147,7 +168,54 @@ Object.assign(Selection.prototype, {
|
||||
if (!this.isCaret()) {
|
||||
this.removeText();
|
||||
}
|
||||
throw new Error("TODO");
|
||||
const anchorNode = this.getAnchorNode();
|
||||
if (!anchorNode.isText()) {
|
||||
throw new Error('How is this possible?');
|
||||
}
|
||||
const currentBlock = anchorNode.getParentBlock();
|
||||
const textContent = anchorNode.getTextContent();
|
||||
const textContentLength = textContent.length;
|
||||
const nodesToMove = anchorNode.getNextSiblings().reverse();
|
||||
let anchorOffset = this.anchorOffset;
|
||||
|
||||
if (anchorOffset === 0) {
|
||||
nodesToMove.push(anchorNode);
|
||||
} else if (anchorOffset === textContentLength) {
|
||||
const clonedNode = createTextNode('');
|
||||
clonedNode.setFlags(anchorNode.getFlags());
|
||||
nodesToMove.push(clonedNode);
|
||||
anchorOffset = 0;
|
||||
} else if (!anchorNode.isImmutable() && !anchorNode.isSegmented()) {
|
||||
const [, splitNode] = anchorNode.splitText(anchorOffset);
|
||||
nodesToMove.push(splitNode);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
let nextBlock = currentBlock.getNextSibling();
|
||||
|
||||
if (nextBlock === null) {
|
||||
nextBlock = createBlockNode(currentBlock.getType());
|
||||
currentBlock.insertAfter(nextBlock);
|
||||
}
|
||||
const nodesToMoveLength = nodesToMove.length;
|
||||
let firstChild = nextBlock.getFirstChild();
|
||||
|
||||
for (let i = 0; i < nodesToMoveLength; i++) {
|
||||
const nodeToMove = nodesToMove[i];
|
||||
if (firstChild === null) {
|
||||
nextBlock.append(nodeToMove);
|
||||
} else {
|
||||
firstChild.insertBefore(nodeToMove);
|
||||
}
|
||||
firstChild = nodeToMove;
|
||||
}
|
||||
nodesToMove[nodesToMoveLength - 1].select(anchorOffset, anchorOffset);
|
||||
if (currentBlock.getFirstChild() === null) {
|
||||
const textNode = createTextNode('');
|
||||
currentBlock.append(textNode);
|
||||
}
|
||||
currentBlock.normalizeTextNodes();
|
||||
nextBlock.normalizeTextNodes(true);
|
||||
},
|
||||
deleteBackward() {
|
||||
if (!this.isCaret()) {
|
||||
@ -164,52 +232,45 @@ Object.assign(Selection.prototype, {
|
||||
if (prevSibling === null) {
|
||||
const prevBlock = currentBlock.getPreviousSibling();
|
||||
if (prevBlock !== null) {
|
||||
// Remove block
|
||||
debugger;
|
||||
const nodesToMove = [anchorNode, ...anchorNode.getNextSiblings()];
|
||||
let lastChild = prevBlock.getLastChild();
|
||||
for (let i = 0; i < nodesToMove.length; i++) {
|
||||
const nodeToMove = nodesToMove[i];
|
||||
lastChild.insertAfter(nodeToMove);
|
||||
lastChild = nodeToMove;
|
||||
}
|
||||
nodesToMove[0].select(0, 0);
|
||||
currentBlock.remove();
|
||||
prevBlock.normalizeTextNodes(true);
|
||||
}
|
||||
} else if (prevSibling.isText()) {
|
||||
if (prevSibling.isImmutable()) {
|
||||
debugger;
|
||||
prevSibling.remove();
|
||||
} else if (prevSibling.isSegmented()) {
|
||||
debugger;
|
||||
removeLastSegment(prevSibling);
|
||||
} else {
|
||||
const textContent = prevSibling.getTextContent();
|
||||
prevSibling.spliceText(textContent.length - 1, 1, "", true);
|
||||
prevSibling.spliceText(textContent.length - 1, 1, '', true);
|
||||
}
|
||||
} else {
|
||||
throw new Error("TODO");
|
||||
throw new Error('TODO');
|
||||
}
|
||||
} else if (anchorNode.isImmutable()) {
|
||||
if (prevSibling === null) {
|
||||
const textNode = createTextNode("");
|
||||
const textNode = createTextNode('');
|
||||
ancestor.insertBefore(textNode);
|
||||
textNode.select();
|
||||
currentBlock.normalizeTextNodes(true);
|
||||
} else if (prevSibling.isText()) {
|
||||
prevSibling.select();
|
||||
} else {
|
||||
throw new Error("TODO");
|
||||
throw new Error('TODO');
|
||||
}
|
||||
anchorNode.remove();
|
||||
} else if (anchorNode.isSegmented()) {
|
||||
const textContent = anchorNode.getTextContent();
|
||||
const lastSpaceIndex = textContent.lastIndexOf(" ");
|
||||
if (lastSpaceIndex > -1) {
|
||||
anchorNode.spliceText(
|
||||
lastSpaceIndex,
|
||||
textContent.length - lastSpaceIndex,
|
||||
"",
|
||||
true
|
||||
);
|
||||
} else {
|
||||
const textNode = createTextNode("");
|
||||
ancestor.insertAfter(textNode);
|
||||
anchorNode.remove();
|
||||
textNode.select();
|
||||
currentBlock.normalizeTextNodes(true);
|
||||
}
|
||||
removeLastSegment(anchorNode);
|
||||
} else {
|
||||
anchorNode.spliceText(anchorOffset - 1, 1, "", true);
|
||||
anchorNode.spliceText(anchorOffset - 1, 1, '', true);
|
||||
}
|
||||
},
|
||||
deleteForward() {
|
||||
@ -217,15 +278,10 @@ Object.assign(Selection.prototype, {
|
||||
this.removeText();
|
||||
return;
|
||||
}
|
||||
throw new Error("TODO");
|
||||
throw new Error('TODO');
|
||||
},
|
||||
removeText() {
|
||||
const selectedNodes = this.getNodes();
|
||||
const selectedNodesLength = selectedNodes.length;
|
||||
|
||||
if (selectedNodesLength === 1) {
|
||||
|
||||
}
|
||||
this.insertText('');
|
||||
},
|
||||
insertText(text) {
|
||||
const selectedNodes = this.getNodes();
|
||||
@ -258,6 +314,7 @@ Object.assign(Selection.prototype, {
|
||||
}
|
||||
|
||||
textNode.select();
|
||||
currentBlock.normalizeTextNodes(true);
|
||||
} else {
|
||||
startOffset = anchorOffset > focusOffset ? focusOffset : anchorOffset;
|
||||
endOffset = anchorOffset > focusOffset ? anchorOffset : focusOffset;
|
||||
@ -269,8 +326,8 @@ Object.assign(Selection.prototype, {
|
||||
const lastIndex = selectedNodesLength - 1;
|
||||
const lastNode = selectedNodes[lastIndex];
|
||||
const isBefore = firstNode === this.getAnchorNode();
|
||||
const endOffset = isBefore ? focusOffset : anchorOffset;
|
||||
startOffset = isBefore ? anchorOffset : focusOffset;
|
||||
endOffset = isBefore ? focusOffset : anchorOffset;
|
||||
let removeFirstNode = false;
|
||||
|
||||
if (firstNode.isImmutable()) {
|
||||
@ -280,11 +337,11 @@ Object.assign(Selection.prototype, {
|
||||
startOffset,
|
||||
firstNodeTextLength - startOffset,
|
||||
text,
|
||||
true
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (!firstNode.isImmutable() && lastNode.isText()) {
|
||||
lastNode.spliceText(0, endOffset, "", false);
|
||||
lastNode.spliceText(0, endOffset, '', false);
|
||||
firstNode.insertAfter(lastNode);
|
||||
} else if (!lastNode.isParentOf(firstNode)) {
|
||||
lastNode.remove();
|
||||
@ -302,8 +359,8 @@ Object.assign(Selection.prototype, {
|
||||
firstNode.remove();
|
||||
textNode.select();
|
||||
}
|
||||
currentBlock.normalizeTextNodes(true);
|
||||
}
|
||||
currentBlock.normalizeTextNodes(true);
|
||||
},
|
||||
setRange(anchorKey, anchorOffset, focusKey, focusOffset) {
|
||||
this.anchorOffset = anchorOffset;
|
||||
@ -375,25 +432,26 @@ export function getSelection() {
|
||||
const viewModel = getActiveViewModel();
|
||||
let selection = viewModel.selection;
|
||||
if (selection === null) {
|
||||
let nodeMap = viewModel.nodeMap;
|
||||
let {
|
||||
anchorNode: anchorDOM,
|
||||
anchorOffset,
|
||||
focusNode: focusDOM,
|
||||
focusOffset,
|
||||
isCollapsed,
|
||||
} = window.getSelection();
|
||||
const nodeMap = viewModel.nodeMap;
|
||||
const windowSelection = window.getSelection();
|
||||
const isCollapsed = windowSelection.isCollapsed;
|
||||
const anchorDOM = windowSelection.anchorNode;
|
||||
const focusDOM = windowSelection.focusNode;
|
||||
let anchorOffset = windowSelection.anchorOffset;
|
||||
let focusOffset = windowSelection.focusOffset;
|
||||
|
||||
const anchorKey =
|
||||
anchorDOM !== null ? getNodeKeyFromDOM(anchorDOM, nodeMap) : null;
|
||||
const focusKey =
|
||||
focusDOM !== null ? getNodeKeyFromDOM(focusDOM, nodeMap) : null;
|
||||
|
||||
const anchorNode = getNodeByKey(anchorKey);
|
||||
const focusNode = getNodeByKey(focusKey);
|
||||
|
||||
if (anchorNode !== null && anchorNode._children === "") {
|
||||
if (anchorNode !== null && anchorNode._children === '') {
|
||||
anchorOffset = 0;
|
||||
}
|
||||
if (focusNode !== null && focusNode._children === "") {
|
||||
if (focusNode !== null && focusNode._children === '') {
|
||||
focusOffset = 0;
|
||||
}
|
||||
|
||||
@ -402,7 +460,7 @@ export function getSelection() {
|
||||
anchorOffset,
|
||||
focusKey,
|
||||
focusOffset,
|
||||
isCollapsed
|
||||
isCollapsed,
|
||||
);
|
||||
}
|
||||
return selection;
|
@ -1,15 +1,20 @@
|
||||
import { cloneNode, createBlockNode, createTextNode, getNodeByKey } from "./OutlineNode";
|
||||
import { reconcileViewModel } from "./OutlineReconciler";
|
||||
import { getSelection } from "./OutlineSelection";
|
||||
import {
|
||||
cloneNode,
|
||||
createBlockNode,
|
||||
createTextNode,
|
||||
getNodeByKey,
|
||||
} from './OutlineNode';
|
||||
import {reconcileViewModel} from './OutlineReconciler';
|
||||
import {getSelection} from './OutlineSelection';
|
||||
|
||||
let activeViewModel = null;
|
||||
|
||||
export function getActiveViewModel() {
|
||||
if (activeViewModel === null) {
|
||||
throw new Error(
|
||||
"Unable to find an active draft view model. " +
|
||||
"Editor helpers or node methods can only be used " +
|
||||
"synchronously during the callback of editor.createViewModel()."
|
||||
'Unable to find an active draft view model. ' +
|
||||
'Editor helpers or node methods can only be used ' +
|
||||
'synchronously during the callback of editor.createViewModel().',
|
||||
);
|
||||
}
|
||||
return activeViewModel;
|
||||
@ -18,7 +23,7 @@ export function getActiveViewModel() {
|
||||
const view = {
|
||||
cloneText(node, text) {
|
||||
if (node.isImmutable()) {
|
||||
throw new Error('cloneText: cannot clone an immutable node')
|
||||
throw new Error('cloneText: cannot clone an immutable node');
|
||||
}
|
||||
const clone = cloneNode(node);
|
||||
clone._key = null;
|
||||
@ -38,7 +43,9 @@ const view = {
|
||||
|
||||
export function createViewModel(currentViewModel, callbackFn, outlineEditor) {
|
||||
const hasActiveViewModel = activeViewModel !== null;
|
||||
const viewModel = hasActiveViewModel ? activeViewModel : cloneViewModel(currentViewModel);
|
||||
const viewModel = hasActiveViewModel
|
||||
? activeViewModel
|
||||
: cloneViewModel(currentViewModel);
|
||||
activeViewModel = viewModel;
|
||||
// Setup the dirty nodes Set, which is required by the
|
||||
// view model logic during createViewModel(). This is also used by
|
||||
@ -96,7 +103,7 @@ export function updateViewModel(viewModel, outlineEditor) {
|
||||
activeViewModel = null;
|
||||
outlineEditor._viewModel = viewModel;
|
||||
viewModel._dirtySubTrees = null;
|
||||
if (typeof onChange === "function") {
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(viewModel);
|
||||
}
|
||||
}
|
||||
@ -113,7 +120,7 @@ export function markParentsAsDirty(parentKey, nodeMap, dirtySubTrees) {
|
||||
|
||||
export function cloneViewModel(current) {
|
||||
const draft = new ViewModel();
|
||||
draft.nodeMap = { ...current.nodeMap };
|
||||
draft.nodeMap = {...current.nodeMap};
|
||||
draft.body = current.body;
|
||||
return draft;
|
||||
}
|
||||
@ -135,4 +142,3 @@ export function ViewModel() {
|
||||
// and is remove after being passed to editor.update();
|
||||
this._dirtySubTrees = new Set();
|
||||
}
|
||||
|
3
packages/outline/src/index.js
Normal file
3
packages/outline/src/index.js
Normal file
@ -0,0 +1,3 @@
|
||||
import {useOutlineEditor} from './OutlineEditor';
|
||||
|
||||
export {useOutlineEditor};
|
81
scripts/build.js
Normal file
81
scripts/build.js
Normal file
@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
const rollup = require('rollup');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const argv = require('minimist')(process.argv.slice(2));
|
||||
const babel = require('@rollup/plugin-babel').default;
|
||||
const closure = require('./plugins/closure-plugin');
|
||||
|
||||
const isWatchMode = argv.watch;
|
||||
const isProduction = argv.prod;
|
||||
|
||||
const closureOptions = {
|
||||
compilation_level: 'SIMPLE',
|
||||
language_in: 'ECMASCRIPT_2018',
|
||||
language_out: 'ECMASCRIPT_2018',
|
||||
env: 'CUSTOM',
|
||||
warning_level: 'QUIET',
|
||||
apply_input_source_maps: false,
|
||||
use_types_for_optimization: false,
|
||||
process_common_js_modules: false,
|
||||
rewrite_polyfills: false,
|
||||
inject_libraries: false,
|
||||
};
|
||||
|
||||
async function build(packageFolder) {
|
||||
if (packageFolder === 'outline-example') {
|
||||
return;
|
||||
}
|
||||
const inputOptions = {
|
||||
input: path.resolve(`./packages/${packageFolder}/src/index.js`),
|
||||
external(id) {
|
||||
if (id === 'react' || id === 'react-dom') {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
babel({
|
||||
babelHelpers: 'bundled',
|
||||
exclude: '/**/node_modules/**',
|
||||
babelrc: false,
|
||||
configFile: false,
|
||||
presets: ['@babel/preset-react'],
|
||||
plugins: ['@babel/plugin-transform-flow-strip-types'],
|
||||
}),
|
||||
isProduction &&
|
||||
closure(
|
||||
closureOptions
|
||||
),
|
||||
],
|
||||
};
|
||||
const outputOptions = {
|
||||
file: path.resolve(`./packages/${packageFolder}/dist/index.js`),
|
||||
format: 'cjs',
|
||||
freeze: false,
|
||||
interop: false,
|
||||
esModule: false,
|
||||
};
|
||||
if (isWatchMode) {
|
||||
const watcher = rollup.watch({...inputOptions, output: outputOptions});
|
||||
watcher.on('event', async (event) => {
|
||||
switch (event.code) {
|
||||
case 'BUNDLE_START':
|
||||
console.log(`Building ${packageFolder}...`);
|
||||
break;
|
||||
case 'BUNDLE_END':
|
||||
console.log(`Built ${packageFolder}`);
|
||||
break;
|
||||
case 'ERROR':
|
||||
case 'FATAL':
|
||||
console.error(`Build failed for ${packageFolder}:\n\n${event.error}`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const result = await rollup.rollup(inputOptions);
|
||||
await result.write(outputOptions);
|
||||
}
|
||||
}
|
||||
|
||||
fs.readdirSync('./packages').forEach(build);
|
35
scripts/plugins/closure-plugin.js
Normal file
35
scripts/plugins/closure-plugin.js
Normal file
@ -0,0 +1,35 @@
|
||||
'use strict';
|
||||
|
||||
const ClosureCompiler = require('google-closure-compiler').compiler;
|
||||
const {promisify} = require('util');
|
||||
const fs = require('fs');
|
||||
const tmp = require('tmp');
|
||||
const writeFileAsync = promisify(fs.writeFile);
|
||||
|
||||
function compile(flags) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const closureCompiler = new ClosureCompiler(flags);
|
||||
closureCompiler.run(function(exitCode, stdOut, stdErr) {
|
||||
if (!stdErr) {
|
||||
resolve(stdOut);
|
||||
} else {
|
||||
reject(new Error(stdErr));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = function closure(flags = {}) {
|
||||
return {
|
||||
name: 'scripts/plugins/closure-plugin',
|
||||
async renderChunk(code) {
|
||||
const inputFile = tmp.fileSync();
|
||||
const tempPath = inputFile.name;
|
||||
flags = Object.assign({}, flags, {js: tempPath});
|
||||
await writeFileAsync(tempPath, code, 'utf8');
|
||||
const compiledCode = await compile(flags);
|
||||
inputFile.removeCallback();
|
||||
return {code: compiledCode};
|
||||
},
|
||||
};
|
||||
};
|
@ -1,42 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useOutlineEditor } from "./outline/OutlineEditor";
|
||||
import { useEmojiPlugin } from "./plugins/EmojiPlugin";
|
||||
import { useMentionsPlugin } from "./plugins/MentionsPlugin";
|
||||
// import { usePlainTextPlugin } from "./plugins/PlainTextPlugin";
|
||||
import { useRichTextPlugin } from "./plugins/RichTextPlugin";
|
||||
|
||||
const editorStyle = {
|
||||
outline: 0,
|
||||
overflowWrap: "break-word",
|
||||
padding: "10px",
|
||||
userSelect: "text",
|
||||
whiteSpace: "pre-wrap",
|
||||
};
|
||||
|
||||
// An example of a custom editor using Outline.
|
||||
export default function Editor({ onChange, isReadOnly }) {
|
||||
const editorElementRef = useRef(null);
|
||||
const outlineEditor = useOutlineEditor(editorElementRef, onChange);
|
||||
const portalTargetElement = useMemo(() => document.getElementById("portal"), []);
|
||||
|
||||
// usePlainTextPlugin(outlineEditor, isReadOnly);
|
||||
useRichTextPlugin(outlineEditor, isReadOnly);
|
||||
useEmojiPlugin(outlineEditor);
|
||||
const mentionsTypeahead = useMentionsPlugin(outlineEditor, portalTargetElement);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="editor"
|
||||
contentEditable={isReadOnly === true ? false : true}
|
||||
role="textbox"
|
||||
ref={editorElementRef}
|
||||
spellCheck={true}
|
||||
style={editorStyle}
|
||||
tabIndex={0}
|
||||
/>
|
||||
{mentionsTypeahead}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,834 +0,0 @@
|
||||
import React, { useCallback, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEvent } from "./PluginShared";
|
||||
|
||||
const mentionStyle = "background-color: rgba(24, 119, 232, 0.2)";
|
||||
|
||||
const PUNCTUATION =
|
||||
"\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%'\"~=<>_:;";
|
||||
const NAME = "\\b[A-Z][^\\s" + PUNCTUATION + "]";
|
||||
|
||||
const DocumentMentionsRegex = {
|
||||
PUNCTUATION,
|
||||
NAME,
|
||||
};
|
||||
|
||||
const CaptitalizedNameMentionsRegex = new RegExp(
|
||||
"(^|[^#])((?:" + DocumentMentionsRegex.NAME + "{" + 1 + ",})$)"
|
||||
);
|
||||
|
||||
const PUNC = DocumentMentionsRegex.PUNCTUATION;
|
||||
|
||||
const TRIGGERS = ["@", "\\uff20"].join("");
|
||||
|
||||
// Chars we expect to see in a mention (non-space, non-punctuation).
|
||||
const VALID_CHARS = "[^" + TRIGGERS + PUNC + "\\s]";
|
||||
|
||||
// Non-standard series of chars. Each series must be preceded and followed by
|
||||
// a valid char.
|
||||
const VALID_JOINS =
|
||||
"(?:" +
|
||||
"\\.[ |$]|" + // E.g. "r. " in "Mr. Smith"
|
||||
" |" + // E.g. " " in "Josh Duck"
|
||||
"[" +
|
||||
PUNC +
|
||||
"]|" + // E.g. "-' in "Salier-Hellendag"
|
||||
")";
|
||||
|
||||
const LENGTH_LIMIT = 75;
|
||||
|
||||
const AtSignMentionsRegex = new RegExp(
|
||||
"(^|\\s|\\()(" +
|
||||
"[" +
|
||||
TRIGGERS +
|
||||
"]" +
|
||||
"((?:" +
|
||||
VALID_CHARS +
|
||||
VALID_JOINS +
|
||||
"){0," +
|
||||
LENGTH_LIMIT +
|
||||
"})" +
|
||||
")$"
|
||||
);
|
||||
|
||||
// 50 is the longest alias length limit.
|
||||
const ALIAS_LENGTH_LIMIT = 50;
|
||||
|
||||
// Regex used to match alias.
|
||||
const AtSignMentionsRegexAliasRegex = new RegExp(
|
||||
"(^|\\s|\\()(" +
|
||||
"[" +
|
||||
TRIGGERS +
|
||||
"]" +
|
||||
"((?:" +
|
||||
VALID_CHARS +
|
||||
"){0," +
|
||||
ALIAS_LENGTH_LIMIT +
|
||||
"})" +
|
||||
")$"
|
||||
);
|
||||
|
||||
const mentionsCache = new Map();
|
||||
|
||||
function useMentionLookupService(mentionString) {
|
||||
const [results, setResults] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const results = mentionsCache.get(mentionString);
|
||||
|
||||
if (results === null) {
|
||||
return;
|
||||
} else if (results !== undefined) {
|
||||
setResults(results);
|
||||
return;
|
||||
}
|
||||
|
||||
mentionsCache.set(mentionString, null);
|
||||
dummyLookupService.search(mentionString, (results) => {
|
||||
mentionsCache.set(mentionString, results);
|
||||
setResults(results);
|
||||
});
|
||||
}, [mentionString]);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function MentionsTypeaheadItem({
|
||||
index,
|
||||
isHovered,
|
||||
isSelected,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
result,
|
||||
}) {
|
||||
const liRef = useRef(null);
|
||||
|
||||
let className = "item";
|
||||
if (isSelected) {
|
||||
className += " selected";
|
||||
}
|
||||
if (isHovered) {
|
||||
className += " hovered";
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
key={result}
|
||||
tabIndex={-1}
|
||||
className={className}
|
||||
ref={liRef}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
id={"typeahead-item-" + index}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onClick={onClick}
|
||||
>
|
||||
{result}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function MentionsTypeahead({ close, editor, match, nodeKey, registerKeys }) {
|
||||
const divRef = useRef(null);
|
||||
const results = useMentionLookupService(match.matchingString);
|
||||
const [selectedIndex, setSelectedIndex] = useState(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const div = divRef.current;
|
||||
if (results !== null && div !== null) {
|
||||
const mentionsElement = editor.getElementByKey(nodeKey);
|
||||
|
||||
if (mentionsElement !== null && mentionsElement.nodeType === 1) {
|
||||
const { x, y } = mentionsElement.getBoundingClientRect();
|
||||
div.style.top = `${y + 22}px`;
|
||||
div.style.left = `${x}px`;
|
||||
mentionsElement.setAttribute("aria-controls", "mentions-typeahead");
|
||||
|
||||
return () => {
|
||||
mentionsElement.removeAttribute("aria-controls");
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [editor, nodeKey, results]);
|
||||
|
||||
const applyCurrentSelected = useCallback(() => {
|
||||
const selectedResult = results[selectedIndex];
|
||||
const viewModel = editor.createViewModel((view) => {
|
||||
const mentionsNode = view.getNodeByKey(nodeKey);
|
||||
mentionsNode
|
||||
.setTextContent(selectedResult)
|
||||
.setStyle(mentionStyle)
|
||||
.setData({
|
||||
type: "MENTION",
|
||||
name: selectedResult,
|
||||
})
|
||||
.makeSegmented()
|
||||
.select();
|
||||
});
|
||||
close();
|
||||
if (!editor.isUpdating()) {
|
||||
editor.update(viewModel);
|
||||
}
|
||||
}, [close, editor, nodeKey, results, selectedIndex]);
|
||||
|
||||
const updateSelectedIndex = useCallback(
|
||||
(index) => {
|
||||
const editorElem = editor.getEditorElement();
|
||||
editorElem.setAttribute(
|
||||
"aria-activedescendant",
|
||||
"typeahead-item-" + index
|
||||
);
|
||||
setSelectedIndex(index);
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const editorElem = editor.getEditorElement();
|
||||
if (editorElem !== null) {
|
||||
editorElem.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (results === null) {
|
||||
setSelectedIndex(null);
|
||||
} else if (selectedIndex === null) {
|
||||
updateSelectedIndex(0);
|
||||
}
|
||||
}, [results, selectedIndex, updateSelectedIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
return registerKeys({
|
||||
ArrowDown(event, view) {
|
||||
if (results !== null && selectedIndex !== null) {
|
||||
if (selectedIndex !== results.length - 1) {
|
||||
updateSelectedIndex(selectedIndex + 1);
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
ArrowUp(event, view) {
|
||||
if (results !== null && selectedIndex !== null) {
|
||||
if (selectedIndex !== 0) {
|
||||
updateSelectedIndex(selectedIndex - 1);
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
Escape(event, view) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
Tab(event, view) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
Enter(event, view) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
applyCurrentSelected();
|
||||
},
|
||||
});
|
||||
}, [
|
||||
applyCurrentSelected,
|
||||
registerKeys,
|
||||
results,
|
||||
selectedIndex,
|
||||
updateSelectedIndex,
|
||||
]);
|
||||
|
||||
if (results === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label="Suggested mentions"
|
||||
id="mentions-typeahead"
|
||||
ref={divRef}
|
||||
role="listbox"
|
||||
>
|
||||
<ul>
|
||||
{results.slice(0, 5).map((result, i) => (
|
||||
<MentionsTypeaheadItem
|
||||
index={i}
|
||||
isHovered={i === hoveredIndex}
|
||||
isSelected={i === selectedIndex}
|
||||
onClick={() => {
|
||||
setSelectedIndex(i);
|
||||
applyCurrentSelected();
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHoveredIndex(i);
|
||||
}}
|
||||
key={result}
|
||||
result={result}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function checkForCaptitalizedNameMentions(text, minMatchLength) {
|
||||
const match = CaptitalizedNameMentionsRegex.exec(text);
|
||||
if (match !== null) {
|
||||
// The strategy ignores leading whitespace but we need to know it's
|
||||
// length to add it to the leadOffset
|
||||
const maybeLeadingWhitespace = match[1];
|
||||
|
||||
const matchingString = match[2];
|
||||
if (matchingString != null && matchingString.length >= minMatchLength) {
|
||||
return {
|
||||
leadOffset: match.index + maybeLeadingWhitespace.length,
|
||||
matchingString,
|
||||
replaceableString: matchingString,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function checkForAtSignMentions(text, minMatchLength) {
|
||||
let match = AtSignMentionsRegex.exec(text);
|
||||
|
||||
if (match === null) {
|
||||
match = AtSignMentionsRegexAliasRegex.exec(text);
|
||||
}
|
||||
if (match !== null) {
|
||||
// The strategy ignores leading whitespace but we need to know it's
|
||||
// length to add it to the leadOffset
|
||||
const maybeLeadingWhitespace = match[1];
|
||||
|
||||
const matchingString = match[3];
|
||||
if (matchingString.length >= minMatchLength) {
|
||||
return {
|
||||
leadOffset: match.index + maybeLeadingWhitespace.length,
|
||||
matchingString,
|
||||
replaceableString: match[2],
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPossibleMentionMatch(text) {
|
||||
const match = checkForAtSignMentions(text, 1);
|
||||
return match === null ? checkForCaptitalizedNameMentions(text, 3) : match;
|
||||
}
|
||||
|
||||
export function useMentionsPlugin(outlineEditor, portalTargetElement) {
|
||||
const [mentionMatch, setMentionMatch] = useState(null);
|
||||
const [nodeKey, setNodeKey] = useState(null);
|
||||
const registeredKeys = useMemo(() => new Set(), []);
|
||||
const registerKeys = useMemo(
|
||||
() => (keys) => {
|
||||
registeredKeys.add(keys);
|
||||
return () => {
|
||||
registeredKeys.delete(keys);
|
||||
};
|
||||
},
|
||||
[registeredKeys]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (outlineEditor !== null) {
|
||||
const textNodeTransform = (node, view) => {
|
||||
const selection = view.getSelection();
|
||||
if (
|
||||
selection.getAnchorNode() !== node ||
|
||||
node.isImmutable() ||
|
||||
node.isSegmented()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const anchorOffset = view.anchorOffset;
|
||||
const text = node.getTextContent().slice(0, anchorOffset);
|
||||
|
||||
if (text !== "") {
|
||||
const match = getPossibleMentionMatch(text);
|
||||
if (match !== null) {
|
||||
const { leadOffset, replaceableString } = match;
|
||||
const splitNodes = node.splitText(
|
||||
leadOffset,
|
||||
leadOffset + replaceableString.length
|
||||
);
|
||||
const target = leadOffset === 0 ? splitNodes[0] : splitNodes[1];
|
||||
target.setTextContent(replaceableString);
|
||||
target.select();
|
||||
// We shouldn't do updates to React until this view is actually
|
||||
// reconciled.
|
||||
window.requestAnimationFrame(() => {
|
||||
setNodeKey(target.getKey());
|
||||
setMentionMatch(match);
|
||||
})
|
||||
return;
|
||||
}
|
||||
}
|
||||
setMentionMatch(null);
|
||||
};
|
||||
return outlineEditor.addTextTransform(textNodeTransform);
|
||||
}
|
||||
}, [outlineEditor]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event, view) => {
|
||||
const key = event.key;
|
||||
registeredKeys.forEach((registeredKeyMap) => {
|
||||
const controlFunction = registeredKeyMap[key];
|
||||
if (typeof controlFunction === "function") {
|
||||
controlFunction(event, view);
|
||||
}
|
||||
});
|
||||
},
|
||||
[registeredKeys]
|
||||
);
|
||||
|
||||
const closeTypeahead = useCallback(() => {
|
||||
setMentionMatch(null);
|
||||
setNodeKey(null);
|
||||
}, []);
|
||||
|
||||
useEvent(outlineEditor, "keydown", onKeyDown);
|
||||
|
||||
return mentionMatch === null || nodeKey === null
|
||||
? null
|
||||
: createPortal(
|
||||
<MentionsTypeahead
|
||||
close={closeTypeahead}
|
||||
match={mentionMatch}
|
||||
nodeKey={nodeKey}
|
||||
editor={outlineEditor}
|
||||
registerKeys={registerKeys}
|
||||
/>,
|
||||
portalTargetElement
|
||||
);
|
||||
}
|
||||
|
||||
const dummyLookupService = {
|
||||
search(string, callback) {
|
||||
setTimeout(() => {
|
||||
const results = dummyMentionsData.filter((mention) =>
|
||||
mention.toLowerCase().includes(string.toLowerCase())
|
||||
);
|
||||
if (results.length === 0) {
|
||||
callback(null);
|
||||
} else {
|
||||
callback(results);
|
||||
}
|
||||
}, 500);
|
||||
},
|
||||
};
|
||||
|
||||
const dummyMentionsData = [
|
||||
"Aayla Secura",
|
||||
"Adi Gallia",
|
||||
"Admiral Dodd Rancit",
|
||||
"Admiral Firmus Piett",
|
||||
"Admiral Gial Ackbar",
|
||||
"Admiral Ozzel",
|
||||
"Admiral Raddus",
|
||||
"Admiral Terrinald Screed",
|
||||
"Admiral Trench",
|
||||
"Admiral U.O. Statura",
|
||||
"Agen Kolar",
|
||||
"Agent Kallus",
|
||||
"Aiolin and Morit Astarte",
|
||||
"Aks Moe",
|
||||
"Almec",
|
||||
"Alton Kastle",
|
||||
"Amee",
|
||||
"AP-5",
|
||||
"Armitage Hux",
|
||||
"Artoo",
|
||||
"Arvel Crynyd",
|
||||
"Asajj Ventress",
|
||||
"Aurra Sing",
|
||||
"AZI-3",
|
||||
"Bala-Tik",
|
||||
"Barada",
|
||||
"Bargwill Tomder",
|
||||
"Baron Papanoida",
|
||||
"Barriss Offee",
|
||||
"Baze Malbus",
|
||||
"Bazine Netal",
|
||||
"BB-8",
|
||||
"BB-9E",
|
||||
"Ben Quadinaros",
|
||||
"Berch Teller",
|
||||
"Beru Lars",
|
||||
"Bib Fortuna",
|
||||
"Biggs Darklighter",
|
||||
"Black Krrsantan",
|
||||
"Bo-Katan Kryze",
|
||||
"Boba Fett",
|
||||
"Bobbajo",
|
||||
"Bodhi Rook",
|
||||
"Borvo the Hutt",
|
||||
"Boss Nass",
|
||||
"Bossk",
|
||||
"Breha Antilles-Organa",
|
||||
"Bren Derlin",
|
||||
"Brendol Hux",
|
||||
"BT-1",
|
||||
"C-3PO",
|
||||
"C1-10P",
|
||||
"Cad Bane",
|
||||
"Caluan Ematt",
|
||||
"Captain Gregor",
|
||||
"Captain Phasma",
|
||||
"Captain Quarsh Panaka",
|
||||
"Captain Rex",
|
||||
"Carlist Rieekan",
|
||||
"Casca Panzoro",
|
||||
"Cassian Andor",
|
||||
"Cassio Tagge",
|
||||
"Cham Syndulla",
|
||||
"Che Amanwe Papanoida",
|
||||
"Chewbacca",
|
||||
"Chi Eekway Papanoida",
|
||||
"Chief Chirpa",
|
||||
"Chirrut Îmwe",
|
||||
"Ciena Ree",
|
||||
"Cin Drallig",
|
||||
"Clegg Holdfast",
|
||||
"Cliegg Lars",
|
||||
"Coleman Kcaj",
|
||||
"Coleman Trebor",
|
||||
"Colonel Kaplan",
|
||||
"Commander Bly",
|
||||
"Commander Cody (CC-2224)",
|
||||
"Commander Fil (CC-3714)",
|
||||
"Commander Fox",
|
||||
"Commander Gree",
|
||||
"Commander Jet",
|
||||
"Commander Wolffe",
|
||||
"Conan Antonio Motti",
|
||||
"Conder Kyl",
|
||||
"Constable Zuvio",
|
||||
"Cordé",
|
||||
"Cpatain Typho",
|
||||
"Crix Madine",
|
||||
"Cut Lawquane",
|
||||
"Dak Ralter",
|
||||
"Dapp",
|
||||
"Darth Bane",
|
||||
"Darth Maul",
|
||||
"Darth Tyranus",
|
||||
"Daultay Dofine",
|
||||
"Del Meeko",
|
||||
"Delian Mors",
|
||||
"Dengar",
|
||||
"Depa Billaba",
|
||||
"Derek Klivian",
|
||||
"Dexter Jettster",
|
||||
"Dineé Ellberger",
|
||||
"DJ",
|
||||
"Doctor Aphra",
|
||||
"Doctor Evazan",
|
||||
"Dogma",
|
||||
"Dormé",
|
||||
"Dr. Cylo",
|
||||
"Droidbait",
|
||||
"Droopy McCool",
|
||||
"Dryden Vos",
|
||||
"Dud Bolt",
|
||||
"Ebe E. Endocott",
|
||||
"Echuu Shen-Jon",
|
||||
"Eeth Koth",
|
||||
"Eighth Brother",
|
||||
"Eirtaé",
|
||||
"Eli Vanto",
|
||||
"Ellé",
|
||||
"Ello Asty",
|
||||
"Embo",
|
||||
"Eneb Ray",
|
||||
"Enfys Nest",
|
||||
"EV-9D9",
|
||||
"Evaan Verlaine",
|
||||
"Even Piell",
|
||||
"Ezra Bridger",
|
||||
"Faro Argyus",
|
||||
"Feral",
|
||||
"Fifth Brother",
|
||||
"Finis Valorum",
|
||||
"Finn",
|
||||
"Fives",
|
||||
"FN-1824",
|
||||
"FN-2003",
|
||||
"Fodesinbeed Annodue",
|
||||
"Fulcrum",
|
||||
"FX-7",
|
||||
"GA-97",
|
||||
"Galen Erso",
|
||||
"Gallius Rax",
|
||||
'Garazeb "Zeb" Orrelios',
|
||||
"Gardulla the Hutt",
|
||||
"Garrick Versio",
|
||||
"Garven Dreis",
|
||||
"Gavyn Sykes",
|
||||
"Gideon Hask",
|
||||
"Gizor Dellso",
|
||||
"Gonk droid",
|
||||
"Grand Inquisitor",
|
||||
"Greeata Jendowanian",
|
||||
"Greedo",
|
||||
"Greer Sonnel",
|
||||
"Grievous",
|
||||
"Grummgar",
|
||||
"Gungi",
|
||||
"Hammerhead",
|
||||
"Han Solo",
|
||||
"Harter Kalonia",
|
||||
"Has Obbit",
|
||||
"Hera Syndulla",
|
||||
"Hevy",
|
||||
"Hondo Ohnaka",
|
||||
"Huyang",
|
||||
"Iden Versio",
|
||||
"IG-88",
|
||||
"Ima-Gun Di",
|
||||
"Inquisitors",
|
||||
"Inspector Thanoth",
|
||||
"Jabba",
|
||||
"Jacen Syndulla",
|
||||
"Jan Dodonna",
|
||||
"Jango Fett",
|
||||
"Janus Greejatus",
|
||||
"Jar Jar Binks",
|
||||
"Jas Emari",
|
||||
"Jaxxon",
|
||||
"Jek Tono Porkins",
|
||||
"Jeremoch Colton",
|
||||
"Jira",
|
||||
"Jobal Naberrie",
|
||||
"Jocasta Nu",
|
||||
"Joclad Danva",
|
||||
"Joh Yowza",
|
||||
"Jom Barell",
|
||||
"Joph Seastriker",
|
||||
"Jova Tarkin",
|
||||
"Jubnuk",
|
||||
"Jyn Erso",
|
||||
"K-2SO",
|
||||
"Kanan Jarrus",
|
||||
"Karbin",
|
||||
"Karina the Great",
|
||||
"Kes Dameron",
|
||||
"Ketsu Onyo",
|
||||
"Ki-Adi-Mundi",
|
||||
"King Katuunko",
|
||||
"Kit Fisto",
|
||||
"Kitster Banai",
|
||||
"Klaatu",
|
||||
"Klik-Klak",
|
||||
"Korr Sella",
|
||||
"Kylo Ren",
|
||||
"L3-37",
|
||||
"Lama Su",
|
||||
"Lando Calrissian",
|
||||
"Lanever Villecham",
|
||||
"Leia Organa",
|
||||
"Letta Turmond",
|
||||
"Lieutenant Kaydel Ko Connix",
|
||||
"Lieutenant Thire",
|
||||
"Lobot",
|
||||
"Logray",
|
||||
"Lok Durd",
|
||||
"Longo Two-Guns",
|
||||
"Lor San Tekka",
|
||||
"Lorth Needa",
|
||||
"Lott Dod",
|
||||
"Luke Skywalker",
|
||||
"Lumat",
|
||||
"Luminara Unduli",
|
||||
"Lux Bonteri",
|
||||
"Lyn Me",
|
||||
"Lyra Erso",
|
||||
"Mace Windu",
|
||||
"Malakili",
|
||||
"Mama the Hutt",
|
||||
"Mars Guo",
|
||||
"Mas Amedda",
|
||||
"Mawhonic",
|
||||
"Max Rebo",
|
||||
"Maximilian Veers",
|
||||
"Maz Kanata",
|
||||
"ME-8D9",
|
||||
"Meena Tills",
|
||||
"Mercurial Swift",
|
||||
"Mina Bonteri",
|
||||
"Miraj Scintel",
|
||||
"Mister Bones",
|
||||
"Mod Terrik",
|
||||
"Moden Canady",
|
||||
"Mon Mothma",
|
||||
"Moradmin Bast",
|
||||
"Moralo Eval",
|
||||
"Morley",
|
||||
"Mother Talzin",
|
||||
"Nahdar Vebb",
|
||||
"Nahdonnis Praji",
|
||||
"Nien Nunb",
|
||||
"Niima the Hutt",
|
||||
"Nines",
|
||||
"Norra Wexley",
|
||||
"Nute Gunray",
|
||||
"Nuvo Vindi",
|
||||
"Obi-Wan Kenobi",
|
||||
"Odd Ball",
|
||||
"Ody Mandrell",
|
||||
"Omi",
|
||||
"Onaconda Farr",
|
||||
"Oola",
|
||||
"OOM-9",
|
||||
"Oppo Rancisis",
|
||||
"Orn Free Taa",
|
||||
"Oro Dassyne",
|
||||
"Orrimarko",
|
||||
"Osi Sobeck",
|
||||
"Owen Lars",
|
||||
"Pablo-Jill",
|
||||
"Padmé Amidala",
|
||||
"Pagetti Rook",
|
||||
"Paige Tico",
|
||||
"Paploo",
|
||||
"Petty Officer Thanisson",
|
||||
"Pharl McQuarrie",
|
||||
"Plo Koon",
|
||||
"Po Nudo",
|
||||
"Poe Dameron",
|
||||
"Poggle the Lesser",
|
||||
"Pong Krell",
|
||||
"Pooja Naberrie",
|
||||
"PZ-4CO",
|
||||
"Quarrie",
|
||||
"Quay Tolsite",
|
||||
"Queen Apailana",
|
||||
"Queen Jamillia",
|
||||
"Queen Neeyutnee",
|
||||
"Qui-Gon Jinn",
|
||||
"Quiggold",
|
||||
"Quinlan Vos",
|
||||
"R2-D2",
|
||||
"R2-KT",
|
||||
"R3-S6",
|
||||
"R4-P17",
|
||||
"R5-D4",
|
||||
"RA-7",
|
||||
"Rabé",
|
||||
"Rako Hardeen",
|
||||
"Ransolm Casterfo",
|
||||
"Rappertunie",
|
||||
"Ratts Tyerell",
|
||||
"Raymus Antilles",
|
||||
"Ree-Yees",
|
||||
"Reeve Panzoro",
|
||||
"Rey",
|
||||
"Ric Olié",
|
||||
"Riff Tamson",
|
||||
"Riley",
|
||||
"Rinnriyin Di",
|
||||
"Rio Durant",
|
||||
"Rogue Squadron",
|
||||
"Romba",
|
||||
"Roos Tarpals",
|
||||
"Rose Tico",
|
||||
"Rotta the Hutt",
|
||||
"Rukh",
|
||||
"Rune Haako",
|
||||
"Rush Clovis",
|
||||
"Ruwee Naberrie",
|
||||
"Ryoo Naberrie",
|
||||
"Sabé",
|
||||
"Sabine Wren",
|
||||
"Saché",
|
||||
"Saelt-Marae",
|
||||
"Saesee Tiin",
|
||||
"Salacious B. Crumb",
|
||||
"San Hill",
|
||||
"Sana Starros",
|
||||
"Sarco Plank",
|
||||
"Sarkli",
|
||||
"Satine Kryze",
|
||||
"Savage Opress",
|
||||
"Sebulba",
|
||||
"Senator Organa",
|
||||
"Sergeant Kreel",
|
||||
"Seventh Sister",
|
||||
"Shaak Ti",
|
||||
"Shara Bey",
|
||||
"Shmi Skywalker",
|
||||
"Shu Mai",
|
||||
"Sidon Ithano",
|
||||
"Sifo-Dyas",
|
||||
"Sim Aloo",
|
||||
"Siniir Rath Velus",
|
||||
"Sio Bibble",
|
||||
"Sixth Brother",
|
||||
"Slowen Lo",
|
||||
"Sly Moore",
|
||||
"Snaggletooth",
|
||||
"Snap Wexley",
|
||||
"Snoke",
|
||||
"Sola Naberrie",
|
||||
"Sora Bulq",
|
||||
"Strono Tuggs",
|
||||
"Sy Snootles",
|
||||
"Tallissan Lintra",
|
||||
"Tarfful",
|
||||
"Tasu Leech",
|
||||
"Taun We",
|
||||
"TC-14",
|
||||
"Tee Watt Kaa",
|
||||
"Teebo",
|
||||
"Teedo",
|
||||
"Teemto Pagalies",
|
||||
"Temiri Blagg",
|
||||
"Tessek",
|
||||
"Tey How",
|
||||
"Thane Kyrell",
|
||||
"The Bendu",
|
||||
"The Smuggler",
|
||||
"Thrawn",
|
||||
"Tiaan Jerjerrod",
|
||||
"Tion Medon",
|
||||
"Tobias Beckett",
|
||||
"Tulon Voidgazer",
|
||||
"Tup",
|
||||
"U9-C4",
|
||||
"Unkar Plutt",
|
||||
"Val Beckett",
|
||||
"Vanden Willard",
|
||||
"Vice Admiral Amilyn Holdo",
|
||||
"Vober Dand",
|
||||
"WAC-47",
|
||||
"Wag Too",
|
||||
"Wald",
|
||||
"Walrus Man",
|
||||
"Warok",
|
||||
"Wat Tambor",
|
||||
"Watto",
|
||||
"Wedge Antilles",
|
||||
"Wes Janson",
|
||||
"Wicket W. Warrick",
|
||||
"Wilhuff Tarkin",
|
||||
"Wollivan",
|
||||
"Wuher",
|
||||
"Wullf Yularen",
|
||||
"Xamuel Lennox",
|
||||
"Yaddle",
|
||||
"Yarael Poof",
|
||||
"Yoda",
|
||||
"Zam Wesell",
|
||||
"Zev Senesca",
|
||||
"Ziro the Hutt",
|
||||
"Zuckuss",
|
||||
];
|
@ -1,93 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
onCompositionEnd,
|
||||
onCompositionStart,
|
||||
onFocusIn,
|
||||
insertFromDataTransfer,
|
||||
onKeyDown,
|
||||
onSelectionChange,
|
||||
useEvent,
|
||||
} from "./PluginShared";
|
||||
|
||||
function onBeforeInput(event, view, state, editor) {
|
||||
const inputType = event.inputType;
|
||||
|
||||
if (
|
||||
inputType !== "insertCompositionText" &&
|
||||
inputType !== "deleteCompositionText"
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
const selection = view.getSelection()
|
||||
|
||||
switch (inputType) {
|
||||
case "insertFromComposition": {
|
||||
const data = event.data;
|
||||
if (data) {
|
||||
selection.insertText(data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "insertFromPaste": {
|
||||
insertFromDataTransfer(event, editor);
|
||||
break;
|
||||
}
|
||||
case "insertLineBreak": {
|
||||
selection.insertText('\n');
|
||||
break;
|
||||
}
|
||||
case "insertText": {
|
||||
selection.insertText(event.data);
|
||||
break;
|
||||
}
|
||||
case "deleteByCut": {
|
||||
selection.removeText();
|
||||
break;
|
||||
}
|
||||
case "deleteContentBackward": {
|
||||
selection.deleteBackward();
|
||||
break;
|
||||
}
|
||||
case "deleteContentForward": {
|
||||
selection.deleteForward();
|
||||
break;
|
||||
}
|
||||
case "insertFromDrop": {
|
||||
insertFromDataTransfer(event, editor);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// NO-OP
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function usePlainTextPlugin(outlineEditor, isReadOnly = false) {
|
||||
const pluginStateRef = useRef(null);
|
||||
|
||||
// Handle event plugin state
|
||||
useEffect(() => {
|
||||
const pluginsState = pluginStateRef.current;
|
||||
|
||||
if (pluginsState === null) {
|
||||
pluginStateRef.current = {
|
||||
isComposing: false,
|
||||
isReadOnly,
|
||||
};
|
||||
} else {
|
||||
pluginsState.isReadOnly = isReadOnly;
|
||||
}
|
||||
}, [isReadOnly]);
|
||||
|
||||
useEvent(outlineEditor, "beforeinput", onBeforeInput, pluginStateRef);
|
||||
useEvent(outlineEditor, "focusin", onFocusIn, pluginStateRef);
|
||||
useEvent(
|
||||
outlineEditor,
|
||||
"compositionstart",
|
||||
onCompositionStart,
|
||||
pluginStateRef
|
||||
);
|
||||
useEvent(outlineEditor, "compositionend", onCompositionEnd, pluginStateRef);
|
||||
useEvent(outlineEditor, "keydown", onKeyDown, pluginStateRef);
|
||||
useEvent(outlineEditor, "selectionchange", onSelectionChange, pluginStateRef);
|
||||
}
|
@ -1,379 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
const isBrowserFirefox =
|
||||
typeof navigator !== "undefined" &&
|
||||
/^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent);
|
||||
|
||||
const isBrowserSafari =
|
||||
typeof navigator !== "undefined" &&
|
||||
/Version\/[\d.]+.*Safari/.test(navigator.userAgent);
|
||||
|
||||
// export function normalizeCursorSelectionOffsets(selection) {
|
||||
// const [anchorOffset, focusOffset] = selection.getRangeOffsets();
|
||||
// const selectionLeftToRight = focusOffset > anchorOffset;
|
||||
// const startOffset = selectionLeftToRight ? anchorOffset : focusOffset;
|
||||
// const endOffset = selectionLeftToRight ? focusOffset : anchorOffset;
|
||||
// const offsetDifference = endOffset - startOffset;
|
||||
// return [startOffset, offsetDifference];
|
||||
// }
|
||||
|
||||
// export function normalizeRangeSelectionOffsets(selection) {
|
||||
// const [anchorOffset, focusOffset] = selection.getRangeOffsets();
|
||||
// const anchorNode = selection.getAnchorNode();
|
||||
// const focusNode = selection.getFocusNode();
|
||||
// if (anchorNode.isBefore(focusNode)) {
|
||||
// return [anchorOffset, focusOffset];
|
||||
// } else {
|
||||
// return [focusOffset, anchorOffset];
|
||||
// }
|
||||
// }
|
||||
|
||||
// export function getParentBeforeBlock(startNode) {
|
||||
// let node = startNode;
|
||||
// while (node !== null) {
|
||||
// const parent = node.getParent();
|
||||
// if (parent.isBlock()) {
|
||||
// return node;
|
||||
// }
|
||||
// node = parent;
|
||||
// }
|
||||
// return null;
|
||||
// }
|
||||
|
||||
// export function getParentBlock(startNode) {
|
||||
// let node = startNode;
|
||||
// while (node !== null) {
|
||||
// if (node.isBlock()) {
|
||||
// return node;
|
||||
// }
|
||||
// node = node.getParent();
|
||||
// }
|
||||
// return null;
|
||||
// }
|
||||
|
||||
// export function getNextSiblings(startNode) {
|
||||
// const siblings = [];
|
||||
// let node = startNode.getNextSibling();
|
||||
// while (node !== null) {
|
||||
// siblings.push(node);
|
||||
// node = node.getNextSibling();
|
||||
// }
|
||||
// return siblings;
|
||||
// }
|
||||
|
||||
// export function createTextWithStyling(text, view, state, targetToClone) {
|
||||
// const textNode =
|
||||
// targetToClone && !targetToClone.isImmutable()
|
||||
// ? view.cloneText(targetToClone, text)
|
||||
// : view.createText(text);
|
||||
// if (state.isBoldMode) {
|
||||
// textNode.makeBold();
|
||||
// } else {
|
||||
// textNode.makeNormal();
|
||||
// }
|
||||
// return textNode;
|
||||
// }
|
||||
|
||||
// export function spliceTextAtCusor(
|
||||
// selectedNode,
|
||||
// caretOffset,
|
||||
// delCount,
|
||||
// text,
|
||||
// view,
|
||||
// state
|
||||
// ) {
|
||||
// if (selectedNode.isImmutable()) {
|
||||
// const ancestor = getParentBeforeBlock(selectedNode);
|
||||
// const currentBlock = ancestor.getParent();
|
||||
|
||||
// if (caretOffset === 0) {
|
||||
// const textNode = createTextWithStyling(text, view, state, selectedNode);
|
||||
// ancestor.insertBefore(textNode);
|
||||
// textNode.select();
|
||||
// } else {
|
||||
// const nextSibling = ancestor.getNextSibling();
|
||||
// if (nextSibling === null) {
|
||||
// const textNode = createTextWithStyling(text, view, state, selectedNode);
|
||||
// ancestor.insertAfter(textNode);
|
||||
// textNode.select();
|
||||
// } else {
|
||||
// const textNode = createTextWithStyling(text, view, state, selectedNode);
|
||||
// nextSibling.insertBefore(textNode);
|
||||
// textNode.select();
|
||||
// }
|
||||
// }
|
||||
// currentBlock.normalizeTextNodes(true);
|
||||
// } else {
|
||||
// const isBold = selectedNode.isBold();
|
||||
// selectedNode.spliceText(caretOffset, delCount, text, true, false);
|
||||
|
||||
// if ((!isBold && state.isBoldMode) || (isBold && !state.isBoldMode)) {
|
||||
// let textContent = selectedNode.getTextContent();
|
||||
// let targetNode;
|
||||
|
||||
// if (caretOffset === 0) {
|
||||
// targetNode = selectedNode;
|
||||
// } else {
|
||||
// [, targetNode] = selectedNode.splitText(
|
||||
// caretOffset,
|
||||
// textContent.length - 1
|
||||
// );
|
||||
// textContent = targetNode.getTextContent();
|
||||
// }
|
||||
// const replacementNode = createTextWithStyling(
|
||||
// text,
|
||||
// view,
|
||||
// state,
|
||||
// selectedNode
|
||||
// );
|
||||
// targetNode.replace(replacementNode);
|
||||
// replacementNode.select();
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// function spliceTextAtRange(text, selection, selectedNodes, view, state) {
|
||||
// const [firstNode, ...nodesToRemove] = selectedNodes;
|
||||
// if (firstNode.isImmutable()) {
|
||||
// const ancestor = getParentBeforeBlock(firstNode);
|
||||
// const currentBlock = ancestor.getParent();
|
||||
// const textNode = view.createText(text);
|
||||
// ancestor.insertBefore(textNode);
|
||||
// textNode.select();
|
||||
// selectedNodes.forEach((node) => {
|
||||
// if (!node.isParentOf(firstNode)) {
|
||||
// node.remove();
|
||||
// }
|
||||
// });
|
||||
// if (firstNode.isImmutable()) {
|
||||
// ancestor.remove();
|
||||
// }
|
||||
// currentBlock.normalizeTextNodes(true);
|
||||
// } else {
|
||||
// const [startOffset, endOffset] = normalizeRangeSelectionOffsets(selection);
|
||||
// nodesToRemove.forEach((node) => {
|
||||
// if (!node.isParentOf(firstNode)) {
|
||||
// node.remove();
|
||||
// }
|
||||
// });
|
||||
// const delCount = firstNode.getTextContent().length - startOffset;
|
||||
// const lastNode = selectedNodes[selectedNodes.length - 1];
|
||||
// if (lastNode.isText()) {
|
||||
// text += lastNode.getTextContent().slice(endOffset);
|
||||
// }
|
||||
// spliceTextAtCusor(firstNode, startOffset, delCount, text, view, state);
|
||||
// }
|
||||
// }
|
||||
|
||||
// function removeBlock(blockToRemove, previousBlock, view) {
|
||||
// const firstNode = blockToRemove.getFirstChild();
|
||||
// const siblings = getNextSiblings(firstNode);
|
||||
// siblings.unshift(firstNode);
|
||||
// const textNode = view.createText("");
|
||||
// previousBlock.getLastChild().insertAfter(textNode);
|
||||
// textNode.select(0, 0);
|
||||
// let nodeToInsertAfter = textNode;
|
||||
// siblings.forEach((sibling) => {
|
||||
// nodeToInsertAfter.insertAfter(sibling);
|
||||
// nodeToInsertAfter = sibling;
|
||||
// });
|
||||
// blockToRemove.remove();
|
||||
// previousBlock.normalizeTextNodes(true);
|
||||
// }
|
||||
|
||||
// export function removeText(backward, view, state) {
|
||||
// const selection = view.getSelection();
|
||||
// const selectedNodes = selection.getNodes();
|
||||
|
||||
// if (selection.isCaret()) {
|
||||
// const firstNode = selectedNodes[0];
|
||||
// const caretOffset = selection.anchorOffset;
|
||||
// const currentBlock = getParentBlock(firstNode);
|
||||
// const previousBlock = currentBlock.getPreviousSibling();
|
||||
// const nextBlock = currentBlock.getNextSibling();
|
||||
// const ancestor = getParentBeforeBlock(firstNode);
|
||||
|
||||
// if (firstNode.isImmutable()) {
|
||||
// if (caretOffset === 0 && previousBlock !== null) {
|
||||
// removeBlock(currentBlock, previousBlock, view);
|
||||
// } else {
|
||||
// const textNode = view.createText("");
|
||||
// ancestor.insertBefore(textNode);
|
||||
// textNode.select();
|
||||
// ancestor.remove();
|
||||
// currentBlock.normalizeTextNodes(true);
|
||||
// }
|
||||
// } else if (firstNode.isSegmented() && firstNode.isText()) {
|
||||
// const textContent = firstNode.getTextContent();
|
||||
// if (!backward && caretOffset === 0) {
|
||||
// const firstSpaceIndex = textContent.indexOf(' ')
|
||||
// if (firstSpaceIndex > -1) {
|
||||
// firstNode.spliceText(0, firstSpaceIndex + 1, '', true);
|
||||
// } else {
|
||||
// const textNode = view.createText("");
|
||||
// ancestor.insertBefore(textNode);
|
||||
// firstNode.remove();
|
||||
// textNode.select();
|
||||
// currentBlock.normalizeTextNodes(true);
|
||||
// }
|
||||
// } else {
|
||||
// const lastSpaceIndex = textContent.lastIndexOf(' ')
|
||||
// if (lastSpaceIndex > -1) {
|
||||
// firstNode.spliceText(lastSpaceIndex, textContent.length - lastSpaceIndex, '', true);
|
||||
// } else {
|
||||
// const textNode = view.createText("");
|
||||
// ancestor.insertAfter(textNode);
|
||||
// firstNode.remove();
|
||||
// textNode.select();
|
||||
// currentBlock.normalizeTextNodes(true);
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// if (caretOffset > 0) {
|
||||
// const offsetAtEnd =
|
||||
// firstNode.isText() &&
|
||||
// caretOffset === firstNode.getTextContent().length;
|
||||
// if (backward || !offsetAtEnd) {
|
||||
// const offset = backward ? caretOffset - 1 : caretOffset;
|
||||
// spliceTextAtCusor(firstNode, offset, 1, "", view, state);
|
||||
// } else {
|
||||
// const nextSibling = firstNode.getNextSibling();
|
||||
// if (nextSibling === null) {
|
||||
// if (nextBlock !== null) {
|
||||
// removeBlock(nextBlock, currentBlock, view);
|
||||
// }
|
||||
// } else {
|
||||
// const textNode = view.createText("");
|
||||
// nextSibling.insertAfter(textNode);
|
||||
// textNode.select();
|
||||
// if (nextSibling.isImmutable()) {
|
||||
// nextSibling.remove();
|
||||
// }
|
||||
// currentBlock.normalizeTextNodes(true);
|
||||
// }
|
||||
// }
|
||||
// } else if (backward) {
|
||||
// const prevSibling = firstNode.getPreviousSibling();
|
||||
// if (prevSibling === null) {
|
||||
// if (previousBlock !== null) {
|
||||
// removeBlock(currentBlock, previousBlock, view);
|
||||
// }
|
||||
// } else {
|
||||
// const textNode = view.createText("");
|
||||
// prevSibling.insertAfter(textNode);
|
||||
// textNode.select();
|
||||
// if (prevSibling.isImmutable()) {
|
||||
// prevSibling.remove();
|
||||
// }
|
||||
// currentBlock.normalizeTextNodes(true);
|
||||
// }
|
||||
// } else {
|
||||
// spliceTextAtCusor(firstNode, caretOffset, 1, "", view, state);
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// const [startOffset, offsetDifference] = normalizeCursorSelectionOffsets(
|
||||
// selection
|
||||
// );
|
||||
// // We're selecting a single node treat it like a cursor
|
||||
// if (selectedNodes.length === 1) {
|
||||
// const firstNode = selectedNodes[0];
|
||||
// if (firstNode.isImmutable()) {
|
||||
// const ancestor = getParentBeforeBlock(firstNode);
|
||||
// const textNode = view.createText("");
|
||||
// ancestor.insertBefore(textNode);
|
||||
// textNode.select();
|
||||
// ancestor.remove();
|
||||
// } else {
|
||||
// spliceTextAtCusor(
|
||||
// firstNode,
|
||||
// startOffset,
|
||||
// offsetDifference,
|
||||
// "",
|
||||
// view,
|
||||
// state
|
||||
// );
|
||||
// }
|
||||
// } else {
|
||||
// spliceTextAtRange("", selection, selectedNodes, view, state);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
export function onCompositionStart(event, view, state) {
|
||||
state.isComposing = true;
|
||||
}
|
||||
|
||||
export function onCompositionEnd(event, view, state) {
|
||||
const data = event.data;
|
||||
// Only do this for Chrome
|
||||
state.isComposing = false;
|
||||
if (data && !isBrowserSafari && !isBrowserFirefox) {
|
||||
view.getSelection().insertText(data);
|
||||
}
|
||||
}
|
||||
|
||||
export function insertFromDataTransfer(event, editor) {
|
||||
const items = event.dataTransfer.items;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === "string" && item.type === "text/plain") {
|
||||
item.getAsString((text) => {
|
||||
const viewModel = editor.createViewModel((view) => {
|
||||
view.getSelection().insertText(text);
|
||||
});
|
||||
if (!editor.isUpdating()) {
|
||||
editor.update(viewModel);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function onFocusIn(event, viewModel) {
|
||||
const body = viewModel.getBody();
|
||||
|
||||
if (body.getFirstChild() === null) {
|
||||
const text = viewModel.createText();
|
||||
body.append(viewModel.createBlock('p').append(text));
|
||||
text.select();
|
||||
}
|
||||
}
|
||||
|
||||
export function onKeyDown() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
export function onSelectionChange(event, helpers) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
export function useEvent(editor, eventName, handler, pluginStateRef) {
|
||||
useEffect(() => {
|
||||
const state = pluginStateRef?.current;
|
||||
if (state !== null && editor !== null) {
|
||||
const target =
|
||||
eventName === "selectionchange"
|
||||
? document
|
||||
: editor.getEditorElement();
|
||||
const wrapper = (event) => {
|
||||
const viewModel = editor.createViewModel((editor) =>
|
||||
handler(event, editor, state, editor)
|
||||
);
|
||||
// Uncomment to see how diffs might work:
|
||||
// if (viewModel !== outlineEditor.getCurrentViewModel()) {
|
||||
// const diff = outlineEditor.getDiffFromViewModel(viewModel);
|
||||
// debugger;
|
||||
// }
|
||||
if (!editor.isUpdating()) {
|
||||
editor.update(viewModel);
|
||||
}
|
||||
};
|
||||
target.addEventListener(eventName, wrapper);
|
||||
return () => {
|
||||
target.removeEventListener(eventName, wrapper);
|
||||
};
|
||||
}
|
||||
}, [eventName, handler, editor, pluginStateRef]);
|
||||
}
|
@ -1,158 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
insertText,
|
||||
onCompositionEnd,
|
||||
onCompositionStart,
|
||||
onFocusIn,
|
||||
insertFromDataTransfer,
|
||||
onKeyDown,
|
||||
onSelectionChange,
|
||||
useEvent,
|
||||
} from "./PluginShared";
|
||||
|
||||
const FORMAT_BOLD = 0;
|
||||
const FORMAT_ITALIC = 1;
|
||||
const FORMAT_STRIKETHROUGH = 2;
|
||||
const FORMAT_UNDERLINE = 3;
|
||||
|
||||
// function onInsertParagraph(event, view, state) {
|
||||
// const selection = view.getSelection();
|
||||
|
||||
// if (selection.isCaret()) {
|
||||
// const [startOffset] = normalizeCursorSelectionOffsets(selection);
|
||||
// const anchorNode = selection.getAnchorNode();
|
||||
// let text = "";
|
||||
|
||||
// if (anchorNode.isText()) {
|
||||
// const currentText = anchorNode.getTextContent();
|
||||
// text = currentText.slice(startOffset);
|
||||
// spliceTextAtCusor(
|
||||
// anchorNode,
|
||||
// startOffset,
|
||||
// currentText.length - startOffset,
|
||||
// "",
|
||||
// view,
|
||||
// state
|
||||
// );
|
||||
// }
|
||||
// const currentBlock = getParentBlock(anchorNode);
|
||||
// const ancestor = getParentBeforeBlock(anchorNode);
|
||||
// const siblings = getNextSiblings(ancestor);
|
||||
// const textNode = anchorNode.isImmutable()
|
||||
// ? view.createText(text)
|
||||
// : view.cloneText(anchorNode, text);
|
||||
// const paragraph = view.createBlock('p').append(textNode);
|
||||
// currentBlock.insertAfter(paragraph);
|
||||
// let nodeToInsertAfter = textNode;
|
||||
// siblings.forEach((sibling) => {
|
||||
// nodeToInsertAfter.insertAfter(sibling);
|
||||
// nodeToInsertAfter = sibling;
|
||||
// });
|
||||
// textNode.select(0, 0);
|
||||
// } else {
|
||||
// console.log("TODO");
|
||||
// }
|
||||
// }
|
||||
|
||||
function onBeforeInput(event, view, state, editor) {
|
||||
const inputType = event.inputType;
|
||||
|
||||
if (
|
||||
inputType !== "insertCompositionText" &&
|
||||
inputType !== "deleteCompositionText"
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
const selection = view.getSelection();
|
||||
|
||||
switch (inputType) {
|
||||
case "formatBold": {
|
||||
selection.formatText(FORMAT_BOLD);
|
||||
break;
|
||||
}
|
||||
case "formatItalic": {
|
||||
selection.formatText(FORMAT_ITALIC);
|
||||
break;
|
||||
}
|
||||
case "formatStrikeThrough": {
|
||||
selection.formatText(FORMAT_STRIKETHROUGH);
|
||||
break;
|
||||
}
|
||||
case "formatUnderline": {
|
||||
selection.formatText(FORMAT_UNDERLINE);
|
||||
break;
|
||||
}
|
||||
case "insertFromComposition": {
|
||||
const data = event.data;
|
||||
if (data) {
|
||||
selection.insertText(data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "insertFromPaste": {
|
||||
insertFromDataTransfer(event, editor);
|
||||
break;
|
||||
}
|
||||
case "insertLineBreak": {
|
||||
selection.insertText('\n');
|
||||
break;
|
||||
}
|
||||
case "insertParagraph": {
|
||||
selection.insertParagraph();
|
||||
break;
|
||||
}
|
||||
case "insertText": {
|
||||
selection.insertText(event.data);
|
||||
break;
|
||||
}
|
||||
case "deleteByCut": {
|
||||
selection.removeText();
|
||||
break;
|
||||
}
|
||||
case "deleteContentBackward": {
|
||||
selection.deleteBackward();
|
||||
break;
|
||||
}
|
||||
case "deleteContentForward": {
|
||||
selection.deleteForward();
|
||||
break;
|
||||
}
|
||||
case "insertFromDrop": {
|
||||
insertFromDataTransfer(event, editor);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.log("TODO?", inputType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useRichTextPlugin(outlineEditor, isReadOnly = false) {
|
||||
const pluginStateRef = useRef(null);
|
||||
|
||||
// Handle event plugin state
|
||||
useEffect(() => {
|
||||
const pluginsState = pluginStateRef.current;
|
||||
|
||||
if (pluginsState === null) {
|
||||
pluginStateRef.current = {
|
||||
isComposing: false,
|
||||
isReadOnly,
|
||||
};
|
||||
} else {
|
||||
pluginsState.isReadOnly = isReadOnly;
|
||||
}
|
||||
}, [isReadOnly]);
|
||||
|
||||
useEvent(outlineEditor, "beforeinput", onBeforeInput, pluginStateRef);
|
||||
useEvent(outlineEditor, "focusin", onFocusIn, pluginStateRef);
|
||||
useEvent(
|
||||
outlineEditor,
|
||||
"compositionstart",
|
||||
onCompositionStart,
|
||||
pluginStateRef
|
||||
);
|
||||
useEvent(outlineEditor, "compositionend", onCompositionEnd, pluginStateRef);
|
||||
useEvent(outlineEditor, "keydown", onKeyDown, pluginStateRef);
|
||||
useEvent(outlineEditor, "selectionchange", onSelectionChange, pluginStateRef);
|
||||
}
|
Reference in New Issue
Block a user