├── .babelrc.js ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .githooks ├── deploy.sh ├── pre-commit ├── pre-commit.0.whitespace.sh ├── pre-commit.5.prettier.sh ├── pre-commit.6.lint.sh ├── pre-commit.8.test.sh └── pre-commit.9.build.sh ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md ├── PULL_REQUEST_TEMPLATE.md ├── repository-social-media.png └── workflows │ └── ci.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .storybook ├── main.js ├── preview-head.html └── preview.js ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── README.md ├── SECURITY.md ├── SUPPORT.md └── user-guide │ └── README.md ├── examples ├── .eslintrc.js ├── blocks │ ├── EmbedBlock.scss │ ├── EmbedBlock.test.tsx │ ├── EmbedBlock.tsx │ ├── ImageBlock.scss │ ├── ImageBlock.test.tsx │ ├── ImageBlock.tsx │ ├── MediaBlock.scss │ ├── MediaBlock.test.tsx │ ├── MediaBlock.tsx │ └── __snapshots__ │ │ └── MediaBlock.test.tsx.snap ├── components │ ├── BenchmarkResults.tsx │ ├── BlockPicker.tsx │ ├── CharCount.scss │ ├── CharCount.test.tsx │ ├── CharCount.tsx │ ├── ColorPicker.scss │ ├── ColorPicker.tsx │ ├── EditorBenchmark.tsx │ ├── EditorWrapper.scss │ ├── EditorWrapper.tsx │ ├── FontIcon.tsx │ ├── Highlight.tsx │ ├── Modal.scss │ ├── Modal.test.tsx │ ├── Modal.tsx │ ├── PrismDecorator.scss │ ├── PrismDecorator.tsx │ ├── ReadingTime.tsx │ ├── SentryBoundary.tsx │ ├── _editor.scss │ ├── _header.scss │ └── _page-nav.scss ├── constants │ ├── allContentState.ts │ ├── customContentState.ts │ ├── icons.svg │ ├── indexContentState.ts │ └── ui.tsx ├── docs.story.tsx ├── entities │ ├── Document.tsx │ ├── Link.test.ts │ ├── Link.tsx │ ├── TooltipEntity.scss │ └── TooltipEntity.tsx ├── examples.story.tsx ├── home.story.tsx ├── main.scss ├── performance.story.tsx ├── plugins.story.tsx ├── plugins │ ├── actionBlockPlugin.scss │ ├── actionBlockPlugin.tsx │ ├── autoEmbedPlugin.js │ ├── draft-js-focus-plugin │ │ ├── .eslintrc.js │ │ ├── createDecorator.js │ │ ├── index.js │ │ ├── modifiers │ │ │ ├── insertNewLine.js │ │ │ ├── removeBlock.js │ │ │ ├── setSelection.js │ │ │ └── setSelectionToBlock.js │ │ └── utils │ │ │ ├── blockInSelection.js │ │ │ ├── createBlockKeyStore.js │ │ │ ├── getBlockMapKeys.js │ │ │ └── getSelectedBlocksMapKeys.js │ ├── linkifyPlugin.js │ ├── sectionBreakPlugin.scss │ └── sectionBreakPlugin.tsx ├── simple.story.tsx ├── sources │ ├── DocumentSource.scss │ ├── DocumentSource.tsx │ ├── EmbedSource.scss │ ├── EmbedSource.tsx │ ├── EmojiSource.tsx │ ├── ImageSource.scss │ ├── ImageSource.tsx │ ├── LinkSource.scss │ └── LinkSource.tsx ├── tests.story.tsx └── utils │ ├── _breakpoints.scss │ ├── _elements.scss │ ├── _forms.scss │ ├── _layout.scss │ ├── _objects.scss │ ├── _typography.scss │ ├── _utilities.scss │ └── embedly.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── prettier.config.js ├── public ├── examples │ └── index.html ├── index.html └── static │ ├── example-lowres-image.jpg │ ├── example-lowres-image2.jpg │ └── icomoon │ ├── icomoon.css │ └── selection.json ├── rollup.config.js ├── src ├── api │ ├── DraftUtils.test.ts │ ├── DraftUtils.ts │ ├── _constants.scss │ ├── behavior.test.ts │ ├── behavior.ts │ ├── constants.test.ts │ ├── constants.ts │ ├── types.ts │ └── ui.ts ├── blocks │ ├── DividerBlock.scss │ ├── DividerBlock.test.tsx │ ├── DividerBlock.tsx │ └── __snapshots__ │ │ └── DividerBlock.test.tsx.snap ├── components │ ├── ComboBox │ │ ├── ComboBox.scss │ │ ├── ComboBox.tsx │ │ └── findMatches.ts │ ├── CommandPalette │ │ └── CommandPalette.tsx │ ├── DraftailEditor.scss │ ├── DraftailEditor.test.tsx │ ├── DraftailEditor.tsx │ ├── Icon.scss │ ├── Icon.test.tsx │ ├── Icon.tsx │ ├── ListNestingStyles.tsx │ ├── PlaceholderStyles │ │ ├── PlaceholderStyles.scss │ │ ├── PlaceholderStyles.test.tsx │ │ └── PlaceholderStyles.tsx │ ├── Toolbar │ │ ├── BlockToolbar │ │ │ ├── BlockToolbar.scss │ │ │ └── BlockToolbar.tsx │ │ ├── FloatingToolbar │ │ │ ├── FloatingToolbar.scss │ │ │ ├── FloatingToolbar.tsx │ │ │ ├── ToolbarDefaults.test.tsx │ │ │ ├── ToolbarDefaults.tsx │ │ │ └── __snapshots__ │ │ │ │ └── ToolbarDefaults.test.tsx.snap │ │ ├── InlineToolbar.scss │ │ ├── InlineToolbar.tsx │ │ ├── MetaToolbar.scss │ │ ├── MetaToolbar.tsx │ │ ├── Toolbar.scss │ │ ├── Toolbar.test.tsx │ │ ├── Toolbar.tsx │ │ ├── ToolbarButton.scss │ │ ├── ToolbarButton.test.tsx │ │ ├── ToolbarButton.tsx │ │ ├── ToolbarDefaults.test.tsx │ │ ├── ToolbarDefaults.tsx │ │ ├── ToolbarGroup.scss │ │ ├── ToolbarGroup.test.tsx │ │ ├── ToolbarGroup.tsx │ │ ├── ToolbarTooltip.scss │ │ └── __snapshots__ │ │ │ ├── Toolbar.test.tsx.snap │ │ │ ├── ToolbarButton.test.tsx.snap │ │ │ ├── ToolbarDefaults.test.tsx.snap │ │ │ └── ToolbarGroup.test.tsx.snap │ ├── Tooltip │ │ ├── Tooltip.scss │ │ └── Tooltip.tsx │ └── __snapshots__ │ │ ├── DraftailEditor.test.tsx.snap │ │ └── Icon.test.tsx.snap ├── index.scss ├── index.test.ts └── index.ts ├── stylelint.config.js ├── tests ├── environment.js ├── integration │ ├── PuppeteerEnvironment.js │ ├── __image_snapshots__ │ │ └── regression-test-js-regression-simple-editor-renders-1-snap.png │ ├── __snapshots__ │ │ └── examples.test.js.snap │ ├── examples.test.js │ ├── jest.config.js │ ├── normalize-rendering.css │ ├── performance.test.js │ ├── regression.test.js │ ├── setup.js │ └── teardown.js ├── performance │ ├── MarkovBenchmark.js │ ├── markov_draftjs_41.js │ └── markov_draftjs_41.test.js ├── rtl.test.js ├── setupTest.js ├── smoke │ └── storyshots.test.js └── styleMock.js └── tsconfig.json /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | modules: false, 7 | }, 8 | ], 9 | "@babel/preset-react", 10 | ], 11 | env: { 12 | test: { 13 | presets: [ 14 | [ 15 | "@babel/preset-env", 16 | { 17 | targets: { 18 | node: "current", 19 | }, 20 | }, 21 | ], 22 | "@babel/preset-react", 23 | ], 24 | }, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Defines the coding style for different editors and IDEs. 2 | # http://editorconfig.org 3 | 4 | # top-most EditorConfig file 5 | root = true 6 | 7 | # Rules for source code. 8 | [*] 9 | charset = utf-8 10 | end_of_line = lf 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 2 15 | max_line_length = 80 16 | 17 | # Documentation. 18 | [*.md] 19 | max_line_length = 0 20 | trim_trailing_whitespace = false 21 | 22 | # Git commit messages. 23 | [COMMIT_EDITMSG] 24 | max_line_length = 0 25 | trim_trailing_whitespace = false 26 | 27 | # Makefiles require tabs. 28 | [Makefile] 29 | indent_style = tab 30 | indent_size = 8 31 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.min.js 3 | coverage/ 4 | dist/ 5 | *.bundle.js 6 | public/storybook 7 | storybook-static 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["torchbox/typescript"], 3 | rules: { 4 | "@typescript-eslint/no-non-null-assertion": "off", 5 | "jsx-a11y/label-has-associated-control": "off", 6 | "react/default-props-match-prop-types": [ 7 | "error", 8 | { allowRequiredDefaults: true }, 9 | ], 10 | }, 11 | overrides: [ 12 | { 13 | files: ["*.stories.tsx", "*.story.tsx", "*.test.tsx", "tests/**/*"], 14 | rules: { 15 | "react/jsx-props-no-spreading": "off", 16 | "max-classes-per-file": "off", 17 | "jsx-a11y/label-has-associated-control": "off", 18 | // Don’t mandate typing for Storybook stories. 19 | // '@typescript-eslint/explicit-module-boundary-types': 0, 20 | // '@typescript-eslint/explicit-function-return-type': 0, 21 | }, 22 | }, 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /.githooks/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # https://gist.github.com/apexskier/efb7c1aaa6e77e8127a8 4 | # Deploy hooks stored in your git repo to everyone! 5 | # 6 | # I keep this in $ROOT/$HOOK_DIR/deploy 7 | # From the top level of your git repo, run ./hook/deploy (or equivalent) after 8 | # cloning or adding a new hook. 9 | # No output is good output. 10 | 11 | BASE=`git rev-parse --git-dir` 12 | ROOT=`git rev-parse --show-toplevel` 13 | HOOK_DIR=.githooks 14 | HOOKS=$ROOT/$HOOK_DIR/* 15 | 16 | if [ ! -d "$ROOT/$HOOK_DIR" ] 17 | then 18 | echo "Couldn't find hooks dir." 19 | exit 1 20 | fi 21 | 22 | # Clean up existing hooks. 23 | rm -f $BASE/hooks/* 24 | 25 | # Synlink new hooks. 26 | for HOOK in $HOOKS 27 | do 28 | (cd $BASE/hooks ; ln -s $HOOK `basename $HOOK` || echo "Failed to link $HOOK to `basename $HOOK`.") 29 | done 30 | 31 | echo "Git hooks deployed to $BASE/hooks. The hooks automatically check your code on every commit." 32 | echo "To bypass them for a single commit, use: git commit --no-verify" 33 | 34 | exit 0 35 | -------------------------------------------------------------------------------- /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Fail on first line that fails. 4 | set -e 5 | 6 | # Only keep staged files that are added (A), copied (C) or modified (M). 7 | STAGED=$(git --no-pager diff --name-only --cached --diff-filter=ACM) 8 | # Files which are only partly staged (eg. git add --patch). 9 | PATCH_STAGED=$(git --no-pager diff --name-only --diff-filter=ACM $STAGED) 10 | # Files which are fully staged. 11 | FULLY_STAGED=$(comm -23 <(echo "$STAGED") <(echo "$PATCH_STAGED")) 12 | 13 | JS_STAGED=$(grep -e '.js$' -e '.jsx$' -e '.ts$' -e '.tsx$' <<< "$STAGED" || true) 14 | JS_FULLY_STAGED=$(grep -e '.js$' -e '.jsx$' -e '.ts$' -e '.tsx$' <<< "$FULLY_STAGED" || true) 15 | SCSS_STAGED=$(grep .scss$ <<< "$STAGED" || true) 16 | SCSS_FULLY_STAGED=$(grep .scss$ <<< "$FULLY_STAGED" || true) 17 | SNAPSHOT_STAGED=$(grep .snap$ <<< "$STAGED" || true) 18 | HTML_STAGED=$(grep .html$ <<< "$STAGED" || true) 19 | HTML_FULLY_STAGED=$(grep .html$ <<< "$FULLY_STAGED" || true) 20 | PRETTIER_STAGED=$(grep -E '.(md|css|scss|js|ts|tsx|json|yaml|yml|html)$' <<< "$STAGED" || true) 21 | PRETTIER_FULLY_STAGED=$(grep -E '.(md|css|scss|js|ts|tsx|json|yaml|yml|html)$' <<< "$FULLY_STAGED" || true) 22 | 23 | # Uncomment, and add more variables to the list, for debugging help. 24 | # tr ' ' '\n' <<< "STAGED $STAGED PATCH_STAGED $PATCH_STAGED FULLY_STAGED $FULLY_STAGED JS_STAGED $JS_STAGED JS_FULLY_STAGED $JS_FULLY_STAGED SNAPSHOT_STAGED $SNAPSHOT_STAGED" 25 | 26 | # Execute each pre-commit hook. 27 | PROJECT_ROOT=`git rev-parse --show-toplevel` 28 | GIT_ROOT=`git rev-parse --git-dir` 29 | HOOKS=$PROJECT_ROOT/$GIT_ROOT/hooks/pre-commit.* 30 | 31 | for HOOK in $HOOKS 32 | do 33 | source $HOOK 34 | done 35 | -------------------------------------------------------------------------------- /.githooks/pre-commit.0.whitespace.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Check if this is the initial commit 4 | if git rev-parse --verify HEAD >/dev/null 2>&1 5 | then 6 | against=HEAD 7 | else 8 | against=4b825dc642cb6eb9a060e54bf8d69288fbee4904 9 | fi 10 | 11 | # Use git diff-index to check for whitespace errors 12 | if ! git diff-index --check --cached $against -- ':!*.js.snap' . 13 | then 14 | echo "Aborting commit due to whitespace errors." 15 | exit 1 16 | fi 17 | -------------------------------------------------------------------------------- /.githooks/pre-commit.5.prettier.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Format and re-stage fully staged files only. 4 | if [ -n "$PRETTIER_FULLY_STAGED" ]; 5 | then 6 | npx prettier --cache --write $PRETTIER_FULLY_STAGED 7 | git add $PRETTIER_FULLY_STAGED 8 | fi 9 | 10 | if [ -n "$PRETTIER_STAGED" ]; 11 | then 12 | npx prettier --cache --check $PRETTIER_STAGED 13 | fi 14 | -------------------------------------------------------------------------------- /.githooks/pre-commit.6.lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -n "$JS_STAGED" ]; 4 | then 5 | npx eslint --cache --cache-location ./node_modules/.cache/.eslintcache $JS_STAGED 6 | fi 7 | 8 | if [ -n "$SCSS_STAGED" ]; 9 | then 10 | npx stylelint $SCSS_STAGED 11 | fi 12 | -------------------------------------------------------------------------------- /.githooks/pre-commit.8.test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -n "$JS_STAGED" ] || [ -n "$SNAPSHOT_STAGED" ]; 4 | then 5 | npm run test:coverage -s 6 | fi 7 | -------------------------------------------------------------------------------- /.githooks/pre-commit.9.build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -n "$JS_STAGED" ] || [ -n "$SCSS_STAGED" ] || [ -n "$HTML_STAGED" ]; 4 | then 5 | npm run dist -s 6 | npm run test:integration -s 7 | npm run test:performance -s 8 | npm run report:size -s 9 | fi 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🐞 Bug report" 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | 18 | 19 | ### Describe the bug 20 | 21 | (Write your answer here.) 22 | 23 | ### Which terms did you search for in the documentation and issue tracker? 24 | 25 | 31 | 32 | (Write your answer here if relevant.) 33 | 34 | ### Environment 35 | 36 | 42 | 43 | (Write your answer here if relevant.) 44 | 45 | ### Steps to reproduce 46 | 47 | 51 | 52 | (Write your steps here:) 53 | 54 | 1. First, 55 | 2. Then, 56 | 3. Finally, 57 | 58 | ### Expected behavior 59 | 60 | 65 | 66 | (Write what you thought would happen.) 67 | 68 | ### Actual behavior 69 | 70 | 75 | 76 | (Write what happened. Please add screenshots!) 77 | 78 | ### Reproducible demo 79 | 80 | 94 | 95 | (Paste the link to an example project and exact instructions to reproduce the issue.) 96 | 97 | 109 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🚀 Feature request" 3 | about: Suggest an idea for improving this project 4 | title: "" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | ### Is your proposal related to a problem? 10 | 11 | 15 | 16 | (Write your answer here.) 17 | 18 | ### Describe the solution you’d like 19 | 20 | 23 | 24 | (Describe your proposed solution here.) 25 | 26 | ### Describe alternatives you’ve considered 27 | 28 | 31 | 32 | (Write your answer here.) 33 | 34 | ### Additional context 35 | 36 | 40 | 41 | (Write your answer here.) 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "❓ Question" 3 | about: Get help with this project 4 | title: "" 5 | labels: question 6 | assignees: "" 7 | --- 8 | 9 | Please make sure you have read available documentation and have searched for other resources available online before submitting your question here. Please also have a look at [Draftail’s Help resources](https://www.draftail.org/help) 10 | 11 | Thanks! 12 | 13 | (Write your answer here.) 14 | 15 | ### Which terms did you search for in the docs, issues, Stack Overflow? 16 | 17 | 23 | 24 | - [ ] In the https://www.draftail.org/ documentation I searched for: (Write your answers here). 25 | - [ ] In the issues / pull requests, I searched for: (Write your answers here). 26 | - [ ] In Stack Overflow, I searched for: (Write your answers here). 27 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Here, please add a description of your pull request and instructions for the reviewer to verify your work. Pull requests without an adequate description will not be reviewed until one is added. 4 | 5 | 6 | 7 | --- 8 | 9 | 10 | 11 | - [ ] Stay on point and keep it small so it can be easily reviewed. For example, try to apply any general refactoring separately outside of the PR. 12 | - [ ] Consider adding unit tests, especially for bug fixes. If you don't, tell us why. 13 | - [ ] All new and existing tests pass, with 100% test coverage (`npm run test:coverage`) 14 | - [ ] Linting passes (`npm run lint`) 15 | - [ ] Consider updating documentation. If you don't, tell us why. 16 | - [ ] List the environments / platforms in which you tested your changes. 17 | 18 | Thanks for contributing to Draftail! 19 | -------------------------------------------------------------------------------- /.github/repository-social-media.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/springload/draftail/3569db9cc4d4537d0e756a34117192270349d2c4/.github/repository-social-media.png -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version-file: .nvmrc 17 | - id: node-cache 18 | uses: actions/cache@v4 19 | with: 20 | path: node_modules 21 | key: ${{ runner.os }}-node-${{ hashFiles('**/.nvmrc') }}-${{ hashFiles('**/package-lock.json') }} 22 | - if: steps.node-cache.outputs.cache-hit != 'true' 23 | run: npm ci 24 | - run: npm run lint -s 25 | - run: npm run dist -s && mv storybook-static public/storybook 26 | - run: npm run report:size 27 | - run: npm run report:package 28 | - run: npm run test:coverage -s --json --runInBand 29 | # - run: npm run test:integration -s 30 | - run: npm run test:performance -s 31 | - run: base64 < ./tests/integration/__image_snapshots__/__diff_output__/* || true 32 | - run: mv coverage/lcov-report build || true 33 | - uses: coverallsapp/github-action@v2 34 | with: 35 | github-token: ${{ secrets.GITHUB_TOKEN }} 36 | - uses: actions/upload-artifact@v4 37 | with: 38 | name: reports 39 | path: public 40 | retention-days: 1 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # ------------------------------------------------- 4 | # OS files 5 | # ------------------------------------------------- 6 | .DS_Store 7 | .DS_Store? 8 | ._* 9 | .Spotlight-V100 10 | .Trashes 11 | ehthumbs.db 12 | Thumbs.db 13 | 14 | # ------------------------------------------------- 15 | # Logs and databases 16 | # ------------------------------------------------- 17 | logs 18 | *.log 19 | npm-debug.log* 20 | *.sql 21 | *.sqlite3 22 | 23 | # ------------------------------------------------- 24 | # Runtime data and caches 25 | # ------------------------------------------------- 26 | pids 27 | *.pid 28 | *.seed 29 | *.pyc 30 | *.pyo 31 | *.pot 32 | 33 | # ------------------------------------------------- 34 | # Instrumentation and tooling 35 | # ------------------------------------------------- 36 | lib-cov 37 | coverage 38 | .coverage 39 | .grunt 40 | .bundle 41 | .eslintcache 42 | docs/pattern-library/index.html 43 | webpack-stats.json 44 | source-map-explorer.html 45 | 46 | # ------------------------------------------------- 47 | # Dependency directories 48 | # ------------------------------------------------- 49 | node_modules* 50 | python_modules* 51 | bower_components 52 | .venv 53 | venv 54 | $virtualenv.tar.gz 55 | $node_modules.tar.gz 56 | 57 | # ------------------------------------------------- 58 | # Users Environment 59 | # ------------------------------------------------- 60 | .lock-wscript 61 | .idea 62 | .installed.cfg 63 | .vagrant 64 | .anaconda 65 | Vagrantfile.local 66 | /local 67 | local.py 68 | *.sublime-project 69 | *.sublime-workspace 70 | .vscode 71 | 72 | # ------------------------------------------------- 73 | # Generated files 74 | # ------------------------------------------------- 75 | dist 76 | build 77 | /var/static/ 78 | /var/media/ 79 | /docs/_build/ 80 | develop-eggs 81 | *.egg-info 82 | downloads 83 | media 84 | eggs 85 | parts 86 | lib64 87 | .sass-cache 88 | .cache 89 | 90 | # ------------------------------------------------- 91 | # Your own project's ignores 92 | # ------------------------------------------------- 93 | 94 | .env 95 | webpack-stats.html 96 | 97 | # jest-image-snapshot diffs 98 | __diff_output__ 99 | 100 | public/*.css 101 | public/*.map 102 | public/*.html 103 | !public/index.html 104 | public/*.js 105 | public/storybook 106 | public/package.txt 107 | public/benchmark.txt 108 | public/size.txt 109 | storybook-static 110 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.min.js 3 | coverage/ 4 | dist/ 5 | 6 | webpack-stats.json 7 | public/webpack-stats.html 8 | public/storybook 9 | storybook-static 10 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const webpack = require("webpack"); 4 | const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer"); 5 | 6 | const pkg = require("../package.json"); 7 | 8 | const examplesPath = path.join(__dirname, "..", "examples"); 9 | const SVG_ICONS = fs.readFileSync( 10 | path.join(examplesPath, "constants", "icons.svg"), 11 | "utf-8", 12 | ); 13 | 14 | // Key is hard-coded because it will be public on the demo site anyway. 15 | // Key usage is limited to allowed Referrers. 16 | const EMBEDLY_API_KEY_PROD = "d23c29a928fe4d89bda46b0291914c9c"; 17 | const EMBEDLY_API_KEY = process.env.EMBEDLY_API_KEY || EMBEDLY_API_KEY_PROD; 18 | 19 | module.exports = { 20 | stories: ["../examples/**/*.story.*"], 21 | // See https://github.com/storybookjs/storybook/issues/18662. 22 | addons: [], 23 | framework: "@storybook/react", 24 | core: { 25 | builder: { 26 | name: "webpack5", 27 | options: { 28 | lazyCompilation: true, 29 | fsCache: true, 30 | }, 31 | }, 32 | disableTelemetry: true, 33 | }, 34 | staticDirs: ["../public"], 35 | webpackFinal: (config, { configType }) => { 36 | const isProduction = configType === "PRODUCTION"; 37 | 38 | config.devtool = "source-map"; 39 | 40 | config.resolve.alias = { 41 | ...config.resolve.alias, 42 | // "react-dom": path.resolve("..", "node_modules", "react-dom"), 43 | // "react-dom/client": path.resolve("..", "node_modules", "react-dom"), 44 | }; 45 | 46 | config.module.rules.push({ 47 | test: /\.(scss|css)$/, 48 | use: ["style-loader", "css-loader", "sass-loader"], 49 | }); 50 | 51 | config.plugins.push( 52 | new webpack.DefinePlugin({ 53 | "process.env.NODE_ENV": JSON.stringify(configType.toLowerCase()), 54 | EMBEDLY_API_KEY: JSON.stringify( 55 | isProduction ? EMBEDLY_API_KEY_PROD : EMBEDLY_API_KEY, 56 | ), 57 | PKG_VERSION: JSON.stringify(pkg.version), 58 | SVG_ICONS: JSON.stringify(SVG_ICONS), 59 | }), 60 | ); 61 | 62 | config.plugins.push( 63 | new BundleAnalyzerPlugin({ 64 | // Can be `server`, `static` or `disabled`. 65 | analyzerMode: "static", 66 | // Path to bundle report file that will be generated in `static` mode. 67 | reportFilename: path.join( 68 | __dirname, 69 | "..", 70 | "public", 71 | "webpack-stats.html", 72 | ), 73 | // Automatically open report in default browser 74 | openAnalyzer: false, 75 | logLevel: isProduction ? "info" : "warn", 76 | }), 77 | ); 78 | 79 | return config; 80 | }, 81 | }; 82 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import "../examples/main.scss"; 2 | 3 | const iconWrapper = document.createElement("div"); 4 | iconWrapper.innerHTML = SVG_ICONS; 5 | document.body.appendChild(iconWrapper); 6 | 7 | document.querySelector("html").setAttribute("lang", "en"); 8 | 9 | const consoleWarn = console.warn; 10 | 11 | console.warn = function filterWarnings(msg, ...args) { 12 | // Stop logging React warnings we shouldn’t be doing anything about at this time. 13 | const supressedWarnings = [ 14 | "Warning: componentWillMount", 15 | "Warning: componentWillReceiveProps", 16 | "Warning: componentWillUpdate", 17 | ]; 18 | 19 | if (!supressedWarnings.some((entry) => msg.includes(entry))) { 20 | consoleWarn.apply(console, ...args); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2016-current Springload 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Draftail](https://www.draftail.org/) [](https://www.draftail.org/) 2 | 3 | [![npm](https://img.shields.io/npm/v/draftail.svg)](https://www.npmjs.com/package/draftail) [![Build status](https://github.com/springload/draftail/workflows/CI/badge.svg)](https://github.com/springload/draftail/actions) [![Coverage Status](https://coveralls.io/repos/github/springload/draftail/badge.svg)](https://coveralls.io/github/springload/draftail) [![Netlify Status](https://api.netlify.com/api/v1/badges/cb4f2732-c1c5-4566-b22e-ce491f504a82/deploy-status)](https://app.netlify.com/sites/draftail-demos/deploys) 4 | 5 | > :memo::cocktail: A configurable rich text editor built with [Draft.js](https://draftjs.org/). Check out our [demos](https://www.draftail.org/examples)! 6 | 7 | [![Screenshot of Draftail](https://www.draftail.org/img/draftail-ui-screenshot.png)](https://www.draftail.org/) 8 | 9 | > [!NOTE] 10 | > While Draftail itself is maintained, the underlying Draft.js library has been archived and no longer receives updates. For more information, see [Draft.js no longer maintained #456](https://github.com/springload/draftail/issues/456). 11 | 12 | ## Features 13 | 14 | Draftail aims for a mouse-free, keyboard-centric experience. Here are important features worth highlighting: 15 | 16 | - Support for [keyboard shortcuts](https://www.draftail.org/docs/keyboard-shortcuts). Lots of them! 17 | - Paste from Word. Or any other editor. It just works. 18 | - Autolists – start a line with `-` , `*` , `1.` to create a list item. 19 | - Shortcuts for heading levels `##`, code blocks ` ``` `, text formats `**`, and more. 20 | - Undo / redo – until the end of times. 21 | - Common text types: headings, paragraphs, quotes, lists. 22 | - Common text styles: Bold, italic, and many more. 23 | - API to build custom controls for links, images, and more. 24 | - Compatibility with the [`draft-js-plugins`](https://www.draft-js-plugins.com) ecosystem to build more advanced extensions. 25 | 26 | > This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html), and measures performance and [code coverage](https://coveralls.io/github/springload/draftail). We also try to follow accessibility best practices (tested with [aXe](https://www.axe-core.org/)) – please [get in touch](https://github.com/springload/draftail/issues/149#issuecomment-389476151) if you can help us do better in this area. 27 | 28 | ## Documentation 29 | 30 | - [Getting started](https://www.draftail.org/docs/getting-started) 31 | - [API reference](https://www.draftail.org/docs/api) 32 | - [User guide](https://www.draftail.org/docs/user-guide) 33 | - [Getting started with extensions](https://www.draftail.org/docs/getting-started-with-extensions) 34 | 35 | ## Contributing 36 | 37 | See anything you like in here? Anything missing? We welcome all support, whether on bug reports, feature requests, code, design, reviews, tests, documentation, and more. Please have a look at our [contribution guidelines](docs/CONTRIBUTING.md). 38 | 39 | If you just want to set up the project on your own computer, the contribution guidelines also contain all of the setup commands. 40 | 41 | ## Credits 42 | 43 | Draftail is made possible by the work of [Springload](https://github.com/springload/). View the full list of [contributors](https://github.com/springload/draftail/graphs/contributors). [MIT](LICENSE) licensed. The [draftail.org](https://github.com/thibaudcolas/draftail.org) documentation and demos are powered by [Netlify](https://www.netlify.com/). 44 | -------------------------------------------------------------------------------- /docs/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | Please refer to the code of conduct from our website: [Contributor Covenant Code of Conduct](https://www.draftail.org/code-of-conduct). 4 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | Thank you for considering to help this project. 4 | 5 | We welcome all support, whether on bug reports, feature requests, code, design, reviews, tests, documentation, and more. 6 | 7 | Please note that this project is released with a [Contributor Code of Conduct](/docs/CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. 8 | 9 | ## Discussing the editor's behavior 10 | 11 | The behavior of this editor is heavily inspired by prior art. If you want to discuss changing how the editor behaves, please take some time to consider how other editors operate. We specifically refer to: 12 | 13 | - [ ] [Microsoft Word](https://products.office.com/en/word) 14 | - [ ] [Microsoft Word Online](https://office.live.com/start/Word.aspx) 15 | - [ ] [Google Docs](https://docs.google.com/) 16 | - [ ] [Apple Pages](https://www.apple.com/lae/pages/) 17 | - [ ] [Dropbox Paper](https://www.dropbox.com/paper) 18 | - [ ] [Gmail](https://www.google.com/gmail/) 19 | - [ ] [TinyMCE](https://www.tinymce.com/) 20 | - [ ] [CKEditor](https://ckeditor.com) 21 | - [ ] [Trix](https://trix-editor.org) 22 | - [ ] [Quill](https://quilljs.com/) 23 | - [ ] [Slate](http://slatejs.org/) 24 | - [ ] Other [Draft.js editors](https://github.com/nikgraf/awesome-draft-js) 25 | 26 | ## Development 27 | 28 | ### Install 29 | 30 | > Clone the project on your computer, and install [Node](https://nodejs.org). This project also uses [nvm](https://github.com/creationix/nvm). 31 | 32 | ```sh 33 | nvm install 34 | # Then, install all project dependencies. 35 | npm install 36 | # Set up a `.env` file with the appropriate secrets. 37 | touch .env 38 | ``` 39 | 40 | ### Working on the project 41 | 42 | > Everything mentioned in the installation process should already be done. 43 | 44 | ```sh 45 | # Make sure you use the correct node version. 46 | nvm use 47 | # Start the server and the development tools. 48 | npm run start 49 | # Runs linting. 50 | npm run lint 51 | # Re-formats all of the files in the project (with Prettier). 52 | npm run format 53 | # Run tests in a watcher. 54 | npm run test:watch 55 | # Run test coverage 56 | npm run test:coverage 57 | # Open the coverage report with: 58 | npm run report:coverage 59 | # Open the build report with: 60 | npm run report:build 61 | # Open the file size report with: 62 | npm run report:size 63 | # Open the package contents report with: 64 | npm run report:package 65 | # View other available commands with: 66 | npm run 67 | ``` 68 | 69 | ### Releases 70 | 71 | - Make a new branch for the release of the new version. 72 | - Update the [CHANGELOG](CHANGELOG.md). 73 | - In the CHANGELOG, update documentation links to point at the correct version. 74 | - Update the version number in `package.json` and `package-lock.json`, following semver. 75 | - Make a PR and squash merge it. 76 | - Back on the main branch with the PR merged, follow the instructions below. 77 | 78 | ```sh 79 | npm run dist 80 | npm run report:size 81 | npm run report:package 82 | npm publish 83 | ``` 84 | 85 | - Finally, go to GitHub and create a release and a tag for the new version. 86 | - Done! 87 | 88 | > As a last step, you may want to go update the [Draftail Playground](https://github.com/thibaudcolas/draftail-playground) to this new release to check that all is well in a fully separate project. 89 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | The documentation for this project lives on the Draftail website: [draftail.org](https://www.draftail.org/). 2 | -------------------------------------------------------------------------------- /docs/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security policy 2 | 3 | We take security issues seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. 4 | 5 | ## Supported versions 6 | 7 | This project doesn’t have formal support targets for non-latest versions. Backporting security fixes to affected releases will be decided on a case-by-case basis, based on effort involved and known usage of affected versions. 8 | 9 | ### Reporting a vulnerability 10 | 11 | To report a vulnerability, please contact [@thibaudcolas](https://github.com/thibaudcolas) via email. If unresponsive, you may also go through npm’s [vulnerability report process](https://docs.npmjs.com/reporting-a-vulnerability-in-an-npm-package). 12 | -------------------------------------------------------------------------------- /docs/SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | > This project follows a [Code of Conduct](https://www.draftail.org/code-of-conduct). 4 | > By interacting with this project, you agree to abide by its terms. 5 | 6 | Hi! 👋 To help us help you, please read through the following guidelines. 7 | 8 | ## Documentation 9 | 10 | Documentation for this project is all on [draftail.org](https://www.draftail.org). Please make sure to have a look at: 11 | 12 | - [draftail.org/docs](https://www.draftail.org) 13 | - The [official blog](https://www.draftail.org/blog/), for project updates, releases, and more. 14 | - The [Draft.js docs](https://draftjs.org/) 15 | 16 | ## Questions 17 | 18 | Please make sure you have read available documentation and have searched for other resources available online before submitting your question. 19 | 20 | To ask a question, there are a few options: 21 | 22 | ### Chat: #draftail 23 | 24 | You can join the #draftail channel on [Wagtail’s Slack](https://github.com/wagtail/wagtail/wiki/Slack), where a lot of people using Wagtail often share tips and provide feedback. 25 | 26 | ### Stack Overflow 27 | 28 | There are some questions & answers on Stack Overflow – either searching for [draftail](https://stackoverflow.com/search?q=draftail), or using the [draftjs](https://stackoverflow.com/questions/tagged/draftjs) tag. 29 | 30 | ## Contributions 31 | 32 | See [`CONTRIBUTING.md`](CONTRIBUTING.md) on how to contribute. 33 | -------------------------------------------------------------------------------- /docs/user-guide/README.md: -------------------------------------------------------------------------------- 1 | # Moved to [https://www.draftail.org/docs/user-guide](https://www.draftail.org/docs/user-guide) 2 | -------------------------------------------------------------------------------- /examples/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | "no-alert": [0], 4 | "import/no-extraneous-dependencies": [ 5 | "error", 6 | { 7 | devDependencies: true, 8 | }, 9 | ], 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /examples/blocks/EmbedBlock.scss: -------------------------------------------------------------------------------- 1 | .EmbedBlock__link { 2 | display: block; 3 | margin-bottom: $button-spacing; 4 | } 5 | -------------------------------------------------------------------------------- /examples/blocks/EmbedBlock.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | 4 | import { ContentBlock, EditorState } from "draft-js"; 5 | import EmbedBlock from "./EmbedBlock"; 6 | import EmbedSource from "../sources/EmbedSource"; 7 | 8 | describe("EmbedBlock", () => { 9 | it("renders", () => { 10 | expect( 11 | shallow( 12 | {}, 25 | unlockEditor: () => {}, 26 | onChange: () => {}, 27 | onEditEntity: () => {}, 28 | onRemoveEntity: () => {}, 29 | entity: { 30 | getType: () => "type", 31 | getMutability: () => "MUTABLE", 32 | getData: () => ({ 33 | url: "http://www.example.com/", 34 | title: "Test title", 35 | thumbnail: "http://www.example.com/example.png", 36 | }), 37 | }, 38 | }} 39 | />, 40 | ).length, 41 | ).toBe(1); 42 | }); 43 | 44 | it("no data", () => { 45 | expect( 46 | shallow( 47 | {}, 60 | unlockEditor: () => {}, 61 | onChange: () => {}, 62 | onEditEntity: () => {}, 63 | onRemoveEntity: () => {}, 64 | entity: { 65 | getType: () => "type", 66 | getMutability: () => "MUTABLE", 67 | getData: () => ({}), 68 | }, 69 | }} 70 | />, 71 | ).length, 72 | ).toBe(1); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /examples/blocks/EmbedBlock.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { EntityBlockProps } from "../../src"; 3 | 4 | import MediaBlock from "./MediaBlock"; 5 | 6 | /** 7 | * Editor block to display media and edit content. 8 | */ 9 | const EmbedBlock = (props: EntityBlockProps) => { 10 | const { blockProps } = props; 11 | const { entity, onEditEntity, onRemoveEntity } = blockProps; 12 | const { url, title, thumbnail } = entity.getData(); 13 | const isLoading = !url && !title && !thumbnail; 14 | 15 | return ( 16 | // eslint-disable-next-line react/jsx-props-no-spreading 17 | 18 | 25 | {title} 26 | 27 | 28 | 35 | 36 | 43 | 44 | ); 45 | }; 46 | 47 | export default EmbedBlock; 48 | -------------------------------------------------------------------------------- /examples/blocks/ImageBlock.scss: -------------------------------------------------------------------------------- 1 | .ImageBlock__field { 2 | display: block; 3 | cursor: pointer; 4 | margin-bottom: $button-spacing; 5 | 6 | > [type="text"] { 7 | background-color: $WHITE; 8 | color: $TEXT_COLOR; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /examples/blocks/ImageBlock.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ContentBlock, EditorState } from "draft-js"; 3 | import { shallow } from "enzyme"; 4 | 5 | import { DraftUtils } from "../../src/index"; 6 | 7 | import ImageBlock from "./ImageBlock"; 8 | import EmbedSource from "../sources/EmbedSource"; 9 | 10 | describe("ImageBlock", () => { 11 | it("renders", () => { 12 | expect( 13 | shallow( 14 | {}, 27 | unlockEditor: () => {}, 28 | onChange: () => {}, 29 | onEditEntity: () => {}, 30 | onRemoveEntity: () => {}, 31 | entity: { 32 | getType: () => "type", 33 | getMutability: () => "MUTABLE", 34 | getData: () => ({ 35 | src: "example.png", 36 | }), 37 | }, 38 | }} 39 | />, 40 | ).length, 41 | ).toBe(1); 42 | }); 43 | 44 | it("alt", () => { 45 | expect( 46 | shallow( 47 | {}, 60 | unlockEditor: () => {}, 61 | onChange: () => {}, 62 | onEditEntity: () => {}, 63 | onRemoveEntity: () => {}, 64 | entity: { 65 | getType: () => "type", 66 | getMutability: () => "MUTABLE", 67 | getData: () => ({ 68 | src: "example.png", 69 | alt: "Test", 70 | }), 71 | }, 72 | }} 73 | />, 74 | ).length, 75 | ).toBe(1); 76 | }); 77 | 78 | it("changeAlt", () => { 79 | const updateBlockEntity = jest 80 | .spyOn(DraftUtils, "updateBlockEntity") 81 | .mockImplementation((e) => e); 82 | 83 | const onChange = jest.fn(); 84 | const wrapper = shallow( 85 | {}, 98 | unlockEditor: () => {}, 99 | onEditEntity: () => {}, 100 | onRemoveEntity: () => {}, 101 | entity: { 102 | getType: () => "type", 103 | getMutability: () => "MUTABLE", 104 | getData: () => ({ 105 | src: "example.png", 106 | alt: "Test", 107 | }), 108 | }, 109 | onChange, 110 | }} 111 | />, 112 | ); 113 | 114 | const currentTarget = document.createElement("input"); 115 | currentTarget.value = "new alt"; 116 | 117 | wrapper.find('[type="text"]').simulate("change", { currentTarget }); 118 | 119 | expect(onChange).toHaveBeenCalled(); 120 | expect(updateBlockEntity).toHaveBeenCalledWith( 121 | expect.any(Object), 122 | expect.any(Object), 123 | expect.objectContaining({ alt: "new alt" }), 124 | ); 125 | 126 | jest.restoreAllMocks(); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /examples/blocks/ImageBlock.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import MediaBlock from "./MediaBlock"; 4 | import { DraftUtils, EntityBlockProps } from "../../src/index"; 5 | 6 | /** 7 | * Editor block to preview and edit images. 8 | */ 9 | class ImageBlock extends Component { 10 | constructor(props: EntityBlockProps) { 11 | super(props); 12 | 13 | this.changeAlt = this.changeAlt.bind(this); 14 | } 15 | 16 | changeAlt(e: React.ChangeEvent) { 17 | const { block, blockProps } = this.props; 18 | const { editorState, onChange } = blockProps; 19 | 20 | if (e.currentTarget instanceof HTMLInputElement) { 21 | const data = { 22 | alt: e.currentTarget.value, 23 | }; 24 | 25 | onChange(DraftUtils.updateBlockEntity(editorState, block, data)); 26 | } 27 | } 28 | 29 | render() { 30 | const { blockProps } = this.props; 31 | const { entity, onEditEntity, onRemoveEntity } = blockProps; 32 | const { src, alt } = entity.getData(); 33 | 34 | return ( 35 | // eslint-disable-next-line react/jsx-props-no-spreading 36 | 37 | 41 | 48 | 49 | 56 | 57 | ); 58 | } 59 | } 60 | 61 | export default ImageBlock; 62 | -------------------------------------------------------------------------------- /examples/blocks/MediaBlock.scss: -------------------------------------------------------------------------------- 1 | .MediaBlock { 2 | display: inline-block; 3 | position: relative; 4 | padding: 0; 5 | cursor: pointer; 6 | outline: $draftail-contrast-outline; 7 | 8 | // stylelint-disable 9 | &:focus { 10 | outline: none; 11 | } 12 | // stylelint-enable 13 | 14 | &__icon-wrapper { 15 | position: absolute; 16 | top: 0; 17 | inset-inline-end: 0; 18 | background: $draftail-editor-chrome; 19 | color: $draftail-editor-chrome-text; 20 | line-height: 1; 21 | padding: $controls-spacing * 2 $controls-spacing * 3; 22 | pointer-events: none; 23 | } 24 | 25 | &__icon { 26 | $icon-size: 1.5rem; 27 | 28 | width: $icon-size; 29 | height: $icon-size; 30 | font-size: $icon-size; 31 | } 32 | 33 | @mixin invalid-image-fallback { 34 | min-width: 256px; 35 | min-height: 100px; 36 | object-fit: contain; 37 | background-color: $GREY_333; 38 | } 39 | 40 | &__img { 41 | @include invalid-image-fallback(); 42 | 43 | display: block; 44 | width: 256px; 45 | height: auto; 46 | pointer-events: none; 47 | } 48 | 49 | &--loading { 50 | .MediaBlock__img { 51 | animation-duration: 1.25s; 52 | animation-fill-mode: forwards; 53 | animation-iteration-count: infinite; 54 | animation-name: placeHolderShimmer; 55 | animation-timing-function: linear; 56 | background: linear-gradient( 57 | 90deg, 58 | $draftail-editor-chrome 10%, 59 | $draftail-editor-chrome-accent 18%, 60 | $draftail-editor-chrome 33% 61 | ); 62 | background-size: 800px 104px; 63 | height: 100px; 64 | } 65 | 66 | .MediaBlock__icon-wrapper { 67 | background-color: transparent; 68 | } 69 | } 70 | } 71 | 72 | @keyframes placeHolderShimmer { 73 | 0% { 74 | background-position: -468px 0; 75 | } 76 | 100% { 77 | background-position: 468px 0; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /examples/blocks/MediaBlock.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import { EntityBlockProps, Icon, Tooltip } from "../../src/index"; 4 | 5 | // Constraints the maximum size of the tooltip. 6 | const OPTIONS_MAX_WIDTH = 300; 7 | 8 | export interface MediaBlockProps extends EntityBlockProps { 9 | src: string; 10 | label: string; 11 | isLoading: boolean; 12 | children: React.ReactNode; 13 | } 14 | 15 | export interface MediaBlockState { 16 | tooltip: { 17 | target: DOMRect; 18 | containerWidth: number; 19 | } | null; 20 | } 21 | 22 | /** 23 | * Editor block to preview and edit images. 24 | */ 25 | class MediaBlock extends Component { 26 | constructor(props: MediaBlockProps) { 27 | super(props); 28 | 29 | this.state = { 30 | tooltip: null, 31 | }; 32 | 33 | this.openTooltip = this.openTooltip.bind(this); 34 | this.closeTooltip = this.closeTooltip.bind(this); 35 | this.renderTooltip = this.renderTooltip.bind(this); 36 | } 37 | 38 | openTooltip(e: React.MouseEvent) { 39 | const trigger = e.target; 40 | 41 | if ( 42 | trigger instanceof Element && 43 | trigger.parentNode instanceof HTMLElement 44 | ) { 45 | const containerWidth = trigger.parentNode.offsetWidth; 46 | 47 | this.setState({ 48 | tooltip: { 49 | target: trigger.getBoundingClientRect(), 50 | containerWidth, 51 | }, 52 | }); 53 | } 54 | } 55 | 56 | closeTooltip() { 57 | this.setState({ tooltip: null }); 58 | } 59 | 60 | renderTooltip() { 61 | const { children } = this.props; 62 | const { tooltip } = this.state; 63 | 64 | if (!tooltip) { 65 | return null; 66 | } 67 | 68 | return ( 69 | { 74 | if (!tooltip) { 75 | return null; 76 | } 77 | 78 | return { 79 | left: tooltip.target.left - editorRect.left, 80 | top: 0, 81 | }; 82 | }} 83 | content={ 84 |
89 | {children} 90 |
91 | } 92 | /> 93 | ); 94 | } 95 | 96 | render() { 97 | const { blockProps, src, label, isLoading } = this.props; 98 | const { entityType } = blockProps; 99 | 100 | return ( 101 | 116 | ); 117 | } 118 | } 119 | 120 | export default MediaBlock; 121 | -------------------------------------------------------------------------------- /examples/blocks/__snapshots__/MediaBlock.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`MediaBlock isLoading 1`] = ` 4 | 27 | `; 28 | 29 | exports[`MediaBlock renders 1`] = ` 30 | 53 | `; 54 | -------------------------------------------------------------------------------- /examples/components/BenchmarkResults.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export interface BenchResultsType { 4 | mean: number; 5 | min: number; 6 | median: number; 7 | p70: number; 8 | p95: number; 9 | p99: number; 10 | max: number; 11 | stdDev: number; 12 | } 13 | 14 | const BenchmarkResults = ({ results }: { results: BenchResultsType }) => ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
meanminmedianp70p95p99maxstdDev
{results.mean.toFixed(1)}{results.min.toFixed(1)}{results.median.toFixed(1)}{results.p70.toFixed(1)}{results.p95.toFixed(1)}{results.p99.toFixed(1)}{results.max.toFixed(1)}{results.stdDev.toFixed(1)}
41 | ); 42 | 43 | export default BenchmarkResults; 44 | -------------------------------------------------------------------------------- /examples/components/BlockPicker.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { EditorState, RichUtils } from "draft-js"; 3 | 4 | import { BLOCK_TYPE } from "../../src/index"; 5 | 6 | type Props = { 7 | getEditorState: () => EditorState; 8 | onChange: (state: EditorState) => void; 9 | }; 10 | 11 | /** 12 | * A traditional text style picker. 13 | */ 14 | const BlockPicker = ({ getEditorState, onChange }: Props) => { 15 | const editorState = getEditorState(); 16 | 17 | return ( 18 | 40 | ); 41 | }; 42 | 43 | export default BlockPicker; 44 | -------------------------------------------------------------------------------- /examples/components/CharCount.scss: -------------------------------------------------------------------------------- 1 | .CharCount { 2 | display: inline-block; 3 | padding: $button-spacing; 4 | pointer-events: none; 5 | font-variant-numeric: tabular-nums; 6 | } 7 | -------------------------------------------------------------------------------- /examples/components/CharCount.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import { EditorState, convertFromHTML, ContentState } from "draft-js"; 4 | 5 | import CharCount, { countChars } from "./CharCount"; 6 | 7 | describe("CharCount", () => { 8 | it("works", () => { 9 | const { contentBlocks } = convertFromHTML("

hello

"); 10 | const contentState = ContentState.createFromBlockArray(contentBlocks); 11 | const editorState = EditorState.createWithContent(contentState); 12 | 13 | expect(shallow( editorState} />)) 14 | .toMatchInlineSnapshot(` 15 |
18 | 5 19 |
20 | `); 21 | }); 22 | 23 | it("supports 0", () => { 24 | expect( 25 | shallow( EditorState.createEmpty()} />), 26 | ).toMatchInlineSnapshot(` 27 |
30 | 0 31 |
32 | `); 33 | }); 34 | }); 35 | 36 | describe.each` 37 | text | length | segmenterLength 38 | ${"123456"} | ${6} | ${6} 39 | ${"123 45"} | ${6} | ${6} 40 | ${"123\n45"} | ${6} | ${6} 41 | ${"\n"} | ${1} | ${1} 42 | ${""} | ${0} | ${0} 43 | ${" "} | ${1} | ${1} 44 | ${"❤️"} | ${2} | ${1} 45 | ${"👨‍👨‍👧"} | ${5} | ${1} 46 | `("countChars", ({ text, length, segmenterLength }) => { 47 | test(text, () => { 48 | expect(countChars(text)).toBe(length); 49 | const s = new Intl.Segmenter("en", { granularity: "grapheme" }); 50 | expect(Array.from(s.segment(text))).toHaveLength(segmenterLength); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /examples/components/CharCount.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { EditorState } from "draft-js"; 3 | 4 | type Props = { 5 | getEditorState: () => EditorState; 6 | maxLength?: number | null; 7 | }; 8 | 9 | // const countParagraphs = (str) => (str ? str.match(/\n+/g).length + 1 : 0); 10 | // const countSentences = (str) => 11 | // str ? (str.match(/[.?!…]+./g) || []).length + 1 : 0; 12 | // const countWords = (str) => 13 | // str ? (str.replace(/['";:,.?¿\-!¡]+/g, "").match(/\S+/g) || []).length : 0; 14 | /** 15 | * Count characters in a string, with special processing to account for astral symbols in UCS-2. See: 16 | * - https://github.com/RadLikeWhoa/Countable/blob/master/Countable.js#L29 17 | * - https://mathiasbynens.be/notes/javascript-unicode 18 | * - https://github.com/tc39/proposal-intl-segmenter 19 | */ 20 | export const countChars = (text: string) => { 21 | if (text) { 22 | // Find as many matches as there are (g), matching newlines as characters (s), as unicode code points (u). 23 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#advanced_searching_with_flags. 24 | const matches = text.match(/./gsu); 25 | return matches ? matches.length : 0; 26 | } 27 | 28 | return 0; 29 | }; 30 | 31 | /** 32 | * Shows the editor’s character count, with a calculation of unicode characters 33 | * matching that of `maxlength` attributes. 34 | */ 35 | const CharCount = ({ getEditorState, maxLength = null }: Props) => { 36 | const editorState = getEditorState(); 37 | const content = editorState.getCurrentContent(); 38 | const text = content.getPlainText(); 39 | const suffix = maxLength ? `/${maxLength}` : ""; 40 | 41 | return ( 42 |
{`${countChars( 43 | text, 44 | )}${suffix}`}
45 | ); 46 | }; 47 | 48 | export default CharCount; 49 | -------------------------------------------------------------------------------- /examples/components/ColorPicker.scss: -------------------------------------------------------------------------------- 1 | .ColorPicker { 2 | padding-inline-end: 1rem; 3 | padding-inline-start: 1rem; 4 | padding-block-start: 1rem; 5 | padding-block-end: 1rem; 6 | } 7 | -------------------------------------------------------------------------------- /examples/components/EditorBenchmark.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 3 | // @ts-ignore 4 | import Benchmark, { BenchmarkType } from "react-component-benchmark"; 5 | 6 | import { DraftailEditor } from "../../src/index"; 7 | 8 | import BenchmarkResults, { BenchResultsType } from "./BenchmarkResults"; 9 | 10 | type Props = { 11 | componentProps: unknown; 12 | runOnMount?: boolean; 13 | }; 14 | 15 | type State = { 16 | results: BenchResultsType | null; 17 | }; 18 | 19 | class EditorBenchmark extends Component { 20 | benchmark?: Benchmark; 21 | 22 | constructor(props: Props) { 23 | super(props); 24 | 25 | this.state = { 26 | results: null, 27 | }; 28 | 29 | this.startBenchmark = this.startBenchmark.bind(this); 30 | this.onBenchmarkComplete = this.onBenchmarkComplete.bind(this); 31 | } 32 | 33 | componentDidMount() { 34 | const { runOnMount } = this.props; 35 | 36 | if (runOnMount) { 37 | this.startBenchmark(); 38 | } 39 | } 40 | 41 | onBenchmarkComplete(results: BenchResultsType) { 42 | this.setState({ results }); 43 | } 44 | 45 | startBenchmark() { 46 | if (this.benchmark) { 47 | this.benchmark.start(); 48 | } 49 | } 50 | 51 | render() { 52 | const { componentProps } = this.props; 53 | const { results } = this.state; 54 | 55 | return ( 56 | <> 57 | 60 | ) => { 65 | this.benchmark = ref; 66 | }} 67 | samples={25} 68 | timeout={10000} 69 | type={BenchmarkType.MOUNT} 70 | /> 71 | {results ? : null} 72 | 73 | ); 74 | } 75 | } 76 | 77 | export default EditorBenchmark; 78 | -------------------------------------------------------------------------------- /examples/components/EditorWrapper.scss: -------------------------------------------------------------------------------- 1 | .EditorWrapper--content-awareness { 2 | padding: 3em; 3 | 4 | .EditorWrapper__details { 5 | opacity: 0; 6 | 7 | &:hover { 8 | opacity: 1; 9 | } 10 | } 11 | } 12 | 13 | .EditorWrapper--floating { 14 | // Height of the sticky toolbar when displayed on a single line. 15 | margin-top: 45px; 16 | } 17 | -------------------------------------------------------------------------------- /examples/components/FontIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type Props = { 4 | icon: string; 5 | }; 6 | 7 | const FontIcon = ({ icon }: Props) => ( 8 | 9 | ); 10 | 11 | export default FontIcon; 12 | -------------------------------------------------------------------------------- /examples/components/Highlight.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const onCopy = (value: string) => { 4 | const hidden = document.createElement("textarea"); 5 | hidden.value = value; 6 | document.body.appendChild(hidden); 7 | hidden.select(); 8 | document.execCommand("copy"); 9 | document.body.removeChild(hidden); 10 | }; 11 | 12 | type Props = { 13 | value: string; 14 | }; 15 | 16 | const Highlight = ({ value }: Props) => ( 17 |
18 |     
25 |     {value}
26 |   
27 | ); 28 | 29 | export default Highlight; 30 | -------------------------------------------------------------------------------- /examples/components/Modal.scss: -------------------------------------------------------------------------------- 1 | $easing-circ: cubic-bezier(0.075, 0.82, 0.165, 1); 2 | $color-bg-modal__overlay: rgba(0, 0, 0, 0.5); 3 | $color-bg-modal: #fff; 4 | 5 | $modal__overlay-z-index: 110; 6 | $modal-z-index: $modal__overlay-z-index + 1; 7 | 8 | $spacing-modal: 0.5rem; 9 | 10 | .modal__container--open { 11 | @include small() { 12 | overflow: hidden; 13 | } 14 | } 15 | 16 | .modal { 17 | --draftail-text-direction: 1; 18 | position: absolute; 19 | inset-inline-start: 50%; 20 | top: 25vh; 21 | min-width: 320px; 22 | transform: translate(-50%, -50%); 23 | overflow-y: auto; 24 | -webkit-overflow-scrolling: touch; 25 | z-index: $modal-z-index; 26 | background-color: $color-bg-modal; 27 | animation: modal-in 0.2s $easing-circ 0s backwards; 28 | outline: $draftail-contrast-outline-modal; 29 | 30 | &[dir="rtl"], 31 | [dir="rtl"] & { 32 | --draftail-text-direction: -1; 33 | } 34 | } 35 | 36 | .modal--exit { 37 | animation: modal-out 0.2s $easing-circ 0s forwards; 38 | 39 | .modal__content { 40 | animation: affordance-out 0.2s ease-in 0s forwards; 41 | } 42 | } 43 | 44 | .modal__overlay { 45 | position: fixed; 46 | top: 0; 47 | inset-inline-end: 0; 48 | bottom: 0; 49 | inset-inline-start: 0; 50 | background-color: $color-bg-modal__overlay; 51 | z-index: $modal__overlay-z-index; 52 | cursor: pointer; 53 | } 54 | 55 | @keyframes modal-in { 56 | 0% { 57 | opacity: 0; 58 | } 59 | 60 | 100% { 61 | opacity: 1; 62 | } 63 | } 64 | 65 | @keyframes modal-out { 66 | 0% { 67 | opacity: 1; 68 | } 69 | 70 | 100% { 71 | opacity: 0; 72 | } 73 | } 74 | 75 | @keyframes affordance-in { 76 | 0% { 77 | opacity: 0; 78 | transform: translateY(5%); 79 | } 80 | 81 | 100% { 82 | opacity: 1; 83 | transform: translateY(0); 84 | } 85 | } 86 | 87 | @keyframes affordance-out { 88 | 0% { 89 | opacity: 1; 90 | transform: translateY(0); 91 | } 92 | 93 | 100% { 94 | opacity: 0; 95 | transform: translateY(5%); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /examples/components/Modal.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | 4 | import Modal from "./Modal"; 5 | 6 | describe("Modal", () => { 7 | it("has defaults", () => { 8 | expect(shallow(Test)).toMatchInlineSnapshot(` 9 | 39 | Test 40 | 41 | `); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /examples/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactModal from "react-modal"; 3 | 4 | const className = { 5 | base: "modal", 6 | afterOpen: "modal--open", 7 | beforeClose: "modal--before-close", 8 | }; 9 | 10 | const overlayClassName = { 11 | base: "modal__overlay", 12 | afterOpen: "modal__overlay--open", 13 | beforeClose: "modal__overlay--before-close", 14 | }; 15 | 16 | const Modal = (props: ReactModal.Props) => ( 17 | 26 | ); 27 | 28 | export default Modal; 29 | -------------------------------------------------------------------------------- /examples/components/PrismDecorator.scss: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/PrismJS/prism-themes/blob/master/themes/prism-ghcolors.css to be accessible. 2 | /* stylelint-disable scale-unlimited/declaration-strict-value */ 3 | .Draftail-block--code-block, 4 | .public-DraftStyleDefault-pre { 5 | color: #393a34; 6 | font-family: Consolas, Menlo, Monaco, Lucida Console, Liberation Mono, 7 | DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace, 8 | sans-serif; 9 | direction: ltr; 10 | text-align: start; 11 | white-space: pre; 12 | word-spacing: normal; 13 | word-break: normal; 14 | font-size: 0.9em; 15 | line-height: 1.2em; 16 | tab-size: 4; 17 | hyphens: none; 18 | } 19 | 20 | /* Code blocks */ 21 | .public-DraftStyleDefault-pre { 22 | padding: 1em; 23 | margin: 0.5em 0; 24 | overflow: auto; 25 | border: 1px solid #ddd; 26 | background-color: #fff; 27 | } 28 | 29 | /* Inline code */ 30 | :not(pre) > .Draftail-block--code-block { 31 | padding: 0.2em; 32 | padding-top: 1px; 33 | padding-bottom: 1px; 34 | background: #f8f8f8; 35 | border: 1px solid #ddd; 36 | } 37 | 38 | .token.comment, 39 | .token.prolog, 40 | .token.doctype, 41 | .token.cdata { 42 | color: #998; 43 | font-style: italic; 44 | } 45 | 46 | .token.namespace { 47 | opacity: 0.7; 48 | } 49 | 50 | .token.string, 51 | .token.attr-value { 52 | color: #e3116c; 53 | } 54 | .token.punctuation, 55 | .token.operator { 56 | color: #393a34; /* no highlight */ 57 | } 58 | 59 | .token.entity, 60 | .token.url, 61 | .token.symbol, 62 | .token.number, 63 | .token.boolean, 64 | .token.variable, 65 | .token.constant, 66 | .token.property, 67 | .token.regex, 68 | .token.inserted { 69 | color: #1e6260; 70 | } 71 | 72 | .token.atrule, 73 | .token.keyword, 74 | .token.attr-name, 75 | .token.selector { 76 | color: #007da7; 77 | } 78 | 79 | .token.function, 80 | .token.deleted, 81 | .token.tag { 82 | color: #9a050f; 83 | } 84 | 85 | .token.tag, 86 | .token.selector, 87 | .token.keyword { 88 | color: #00009f; 89 | } 90 | 91 | .token.important, 92 | .token.function, 93 | .token.bold { 94 | font-weight: bold; 95 | } 96 | 97 | .token.italic { 98 | font-style: italic; 99 | } 100 | -------------------------------------------------------------------------------- /examples/components/PrismDecorator.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ContentBlock } from "draft-js"; 3 | import Prism, { Token } from "prismjs"; 4 | 5 | import { BLOCK_TYPE } from "../../src/index"; 6 | 7 | type Options = { 8 | defaultLanguage: "javascript" | "css"; 9 | }; 10 | 11 | /** 12 | * Syntax highlighting with Prism as a Draft.js decorator. 13 | * This code is an adaptation of https://github.com/SamyPesse/draft-js-prism 14 | * to use the CompositeDecorator strategy API. 15 | */ 16 | class PrismDecorator { 17 | options: Options; 18 | 19 | highlighted: Record> = {}; 20 | 21 | component: ({ 22 | children, 23 | offsetKey, 24 | }: { 25 | children: React.ReactNode; 26 | offsetKey: string; 27 | }) => JSX.Element; 28 | 29 | strategy: ( 30 | block: ContentBlock, 31 | callback: (start: number, end: number) => void, 32 | ) => void; 33 | 34 | constructor(options: Options) { 35 | this.options = options; 36 | this.highlighted = {}; 37 | 38 | this.component = this.renderToken.bind(this); 39 | this.strategy = this.getDecorations.bind(this); 40 | } 41 | 42 | // Renders the decorated tokens. 43 | renderToken({ 44 | children, 45 | offsetKey, 46 | }: { 47 | children: React.ReactNode; 48 | offsetKey: string; 49 | }) { 50 | const type = this.getTokenTypeForKey(offsetKey); 51 | return {children}; 52 | } 53 | 54 | getTokenTypeForKey(key: string) { 55 | const [blockKey, tokId] = key.split("-"); 56 | const token = this.highlighted[blockKey][tokId]; 57 | 58 | return token ? token.type : ""; 59 | } 60 | 61 | getDecorations( 62 | block: ContentBlock, 63 | callback: (start: number, end: number) => void, 64 | ) { 65 | // Only process code blocks. 66 | if (block.getType() !== BLOCK_TYPE.CODE) { 67 | return; 68 | } 69 | 70 | const language = block 71 | .getData() 72 | .get("language", this.options.defaultLanguage); 73 | 74 | // Allow for no syntax highlighting 75 | if (language == null) { 76 | return; 77 | } 78 | 79 | const blockKey = block.getKey(); 80 | const blockText = block.getText(); 81 | 82 | let tokens; 83 | 84 | try { 85 | tokens = Prism.tokenize(blockText, Prism.languages[language]); 86 | } catch (e) { 87 | console.error(e); 88 | return; 89 | } 90 | 91 | this.highlighted[blockKey] = {}; 92 | 93 | let tokenCount = 0; 94 | tokens.reduce((startOffset: number, token: string | Token) => { 95 | const endOffset = startOffset + token.length; 96 | 97 | if (typeof token !== "string") { 98 | tokenCount += 1; 99 | this.highlighted[blockKey][tokenCount] = token; 100 | callback(startOffset, endOffset); 101 | } 102 | 103 | return endOffset; 104 | }, 0); 105 | } 106 | } 107 | 108 | export default PrismDecorator; 109 | -------------------------------------------------------------------------------- /examples/components/ReadingTime.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { ControlComponentProps, ToolbarButton } from "../../src/index"; 4 | 5 | const CLOCK_ICON = 6 | "M658.744 749.256l-210.744-210.746v-282.51h128v229.49l173.256 173.254zM512 0c-282.77 0-512 229.23-512 512s229.23 512 512 512 512-229.23 512-512-229.23-512-512-512zM512 896c-212.078 0-384-171.922-384-384s171.922-384 384-384c212.078 0 384 171.922 384 384s-171.922 384-384 384z"; 7 | 8 | /** 9 | * A basic control showing the reading time / content length for the editor’s content. 10 | */ 11 | const ReadingTime = ({ getEditorState }: ControlComponentProps) => { 12 | const editorState = getEditorState(); 13 | const content = editorState.getCurrentContent(); 14 | const text = content.getPlainText(); 15 | return ( 16 | { 21 | window.alert(`${text.length} characters, a few words`); 22 | }} 23 | /> 24 | ); 25 | }; 26 | 27 | export default ReadingTime; 28 | -------------------------------------------------------------------------------- /examples/components/SentryBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, ReactNode } from "react"; 2 | 3 | type Props = { 4 | children: ReactNode; 5 | }; 6 | type State = { 7 | error: Error | null | undefined; 8 | reloads: number; 9 | }; 10 | 11 | class SentryBoundary extends Component { 12 | constructor(props: Props) { 13 | super(props); 14 | this.state = { 15 | error: null, 16 | reloads: 0, 17 | }; 18 | this.onAttemptReload = this.onAttemptReload.bind(this); 19 | } 20 | 21 | componentDidCatch(error: Error) { 22 | this.setState({ 23 | error, 24 | }); 25 | } 26 | 27 | onAttemptReload() { 28 | const { reloads } = this.state; 29 | 30 | if (reloads > 2) { 31 | window.location.reload(); 32 | } else { 33 | this.setState({ 34 | error: null, 35 | reloads: reloads + 1, 36 | }); 37 | } 38 | } 39 | 40 | render() { 41 | const { children } = this.props; 42 | const { reloads, error } = this.state; 43 | return error ? ( 44 |
45 |
46 |
47 | 55 |
56 |
57 |
58 |
59 |
60 |
61 |

Oops. The editor just crashed.

62 |

63 | Our team has been notified. You can provide us with more 64 | information if you want to. 65 |

66 |
67 | 75 | Open a GitHub issue 76 | 77 | or 78 | 81 |
82 |
83 |
84 |
85 |
86 |
87 | ) : ( 88 | children 89 | ); 90 | } 91 | } 92 | 93 | export default SentryBoundary; 94 | -------------------------------------------------------------------------------- /examples/components/_editor.scss: -------------------------------------------------------------------------------- 1 | @include draftail-richtext-styles() { 2 | blockquote { 3 | border-inline-start: 3px solid #e5e5e5; 4 | padding: 0 0 0 20px; 5 | margin-inline-start: 0; 6 | font-style: italic; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/components/_header.scss: -------------------------------------------------------------------------------- 1 | .page-header { 2 | > a { 3 | color: inherit; 4 | text-decoration: none; 5 | } 6 | 7 | margin-bottom: 5vh; 8 | text-align: center; 9 | 10 | @include xsmall() { 11 | text-align: start; 12 | } 13 | } 14 | 15 | .page-title { 16 | @include font-smoothing(); 17 | 18 | font-size: $FONT_SIZE_H1; 19 | letter-spacing: 2px; 20 | 21 | @include medium() { 22 | font-size: $FONT_SIZE_H1_MEDIUM; 23 | } 24 | } 25 | 26 | .page-description { 27 | margin-top: 1.5rem; 28 | margin-bottom: 0; 29 | font-size: $FONT_SIZE_H3; 30 | 31 | img { 32 | vertical-align: middle; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/components/_page-nav.scss: -------------------------------------------------------------------------------- 1 | .page-nav { 2 | text-align: center; 3 | 4 | @include xsmall() { 5 | text-align: start; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/constants/customContentState.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | blocks: [ 3 | { 4 | key: "c1gc9", 5 | text: "You can implement custom block types as required, and inline styles too, or entities.", 6 | type: "tiny-text", 7 | depth: 0, 8 | inlineStyleRanges: [ 9 | { 10 | offset: 37, 11 | length: 11, 12 | style: "COLOR_D30A64", 13 | }, 14 | { 15 | offset: 54, 16 | length: 13, 17 | style: "REDACTED", 18 | }, 19 | ], 20 | entityRanges: [ 21 | { 22 | offset: 76, 23 | length: 8, 24 | key: 0, 25 | }, 26 | ], 27 | data: {}, 28 | }, 29 | { 30 | key: "7dtlg", 31 | text: "Draftail also supports the #plugins architecture of draft-js-plugins.", 32 | type: "unstyled", 33 | depth: 0, 34 | inlineStyleRanges: [ 35 | { 36 | offset: 52, 37 | length: 16, 38 | style: "BOLD", 39 | }, 40 | ], 41 | entityRanges: [], 42 | data: {}, 43 | }, 44 | { 45 | key: "affm4", 46 | text: " ", 47 | type: "atomic", 48 | depth: 0, 49 | inlineStyleRanges: [], 50 | entityRanges: [ 51 | { 52 | offset: 0, 53 | length: 1, 54 | key: 1, 55 | }, 56 | ], 57 | data: {}, 58 | }, 59 | { 60 | key: "2uo5o", 61 | text: ".media .img {", 62 | type: "code-block", 63 | depth: 0, 64 | inlineStyleRanges: [], 65 | entityRanges: [], 66 | data: {}, 67 | }, 68 | { 69 | key: "9cgaa", 70 | text: " margin-inline-end: 10px;", 71 | type: "code-block", 72 | depth: 0, 73 | inlineStyleRanges: [], 74 | entityRanges: [], 75 | data: {}, 76 | }, 77 | { 78 | key: "3dhtn", 79 | text: "}", 80 | type: "code-block", 81 | depth: 0, 82 | inlineStyleRanges: [], 83 | entityRanges: [], 84 | data: {}, 85 | }, 86 | ], 87 | entityMap: { 88 | 0: { 89 | type: "DOCUMENT", 90 | mutability: "MUTABLE", 91 | data: { 92 | url: "docs.pdf", 93 | }, 94 | }, 95 | 1: { 96 | type: "EMBED", 97 | mutability: "IMMUTABLE", 98 | data: { 99 | url: "http://www.youtube.com/watch?v=y8Kyi0WNg40", 100 | title: "Dramatic Look", 101 | thumbnail: "/static/example-lowres-image2.jpg", 102 | }, 103 | }, 104 | }, 105 | }; 106 | -------------------------------------------------------------------------------- /examples/entities/Document.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import FontIcon from "../components/FontIcon"; 4 | import TooltipEntity from "./TooltipEntity"; 5 | import { EntityDecoratorProps } from "../../src"; 6 | 7 | export const DOCUMENT_ICON = ; 8 | 9 | const Document = ({ 10 | entityKey, 11 | contentState, 12 | children, 13 | onEdit, 14 | onRemove, 15 | textDirectionality, 16 | }: EntityDecoratorProps) => { 17 | const { url, id } = contentState.getEntity(entityKey).getData(); 18 | // Supports documents defined based on a URL, and id. 19 | const label = url ? url.replace(/(^\w+:|^)\/\//, "").split("/")[0] : id; 20 | return ( 21 | 30 | {children} 31 | 32 | ); 33 | }; 34 | 35 | export default Document; 36 | -------------------------------------------------------------------------------- /examples/entities/TooltipEntity.scss: -------------------------------------------------------------------------------- 1 | .TooltipEntity { 2 | cursor: pointer; 3 | 4 | @media (forced-colors: active) { 5 | color: LinkText; 6 | } 7 | 8 | &__icon { 9 | color: $TEAL_WAGTAIL; 10 | margin-inline-end: 0.2em; 11 | width: 1em; 12 | height: 1em; 13 | 14 | @media (forced-colors: active) { 15 | color: currentColor; 16 | } 17 | } 18 | 19 | &__text { 20 | background: $LIGHT_BLUE_EEF8FF; 21 | border-bottom: 1px dotted $TEAL_WAGTAIL; 22 | 23 | @media (forced-colors: active) { 24 | border-bottom-color: currentColor; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/entities/TooltipEntity.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import { EntityDecoratorProps, Icon, IconProp, Tooltip } from "../../src/index"; 4 | 5 | interface TooltipEntityProps extends EntityDecoratorProps { 6 | icon: IconProp; 7 | label: string; 8 | } 9 | 10 | interface TooltipEntityState { 11 | showTooltipAt: DOMRect | null; 12 | } 13 | 14 | class TooltipEntity extends Component { 15 | constructor(props: TooltipEntityProps) { 16 | super(props); 17 | 18 | this.state = { 19 | showTooltipAt: null, 20 | }; 21 | 22 | this.openTooltip = this.openTooltip.bind(this); 23 | this.closeTooltip = this.closeTooltip.bind(this); 24 | } 25 | 26 | openTooltip(e: React.MouseEvent) { 27 | const trigger = e.target; 28 | 29 | if (trigger instanceof Element) { 30 | this.setState({ showTooltipAt: trigger.getBoundingClientRect() }); 31 | } 32 | } 33 | 34 | closeTooltip() { 35 | this.setState({ showTooltipAt: null }); 36 | } 37 | 38 | render() { 39 | const { entityKey, contentState, children, onEdit, onRemove, icon, label } = 40 | this.props; 41 | const { showTooltipAt } = this.state; 42 | const { url } = contentState.getEntity(entityKey).getData(); 43 | 44 | // Contrary to what JSX A11Y says, this should be a button but it shouldn't be focusable. 45 | /* eslint-disable jsx-a11y/interactive-supports-focus, jsx-a11y/anchor-is-valid */ 46 | return ( 47 | 48 | 49 | {children} 50 | this.setState({ showTooltipAt: null })} 53 | getTargetPosition={(editorRect) => { 54 | if (!showTooltipAt) { 55 | return null; 56 | } 57 | 58 | return { 59 | left: showTooltipAt.left - editorRect.left, 60 | top: 0, 61 | }; 62 | }} 63 | content={ 64 |
65 | 72 | {label} 73 | 74 | 75 | 82 | 83 | 90 |
91 | } 92 | /> 93 | 94 | ); 95 | } 96 | } 97 | 98 | export default TooltipEntity; 99 | -------------------------------------------------------------------------------- /examples/home.story.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from "@storybook/react"; 2 | import { RawDraftContentState } from "draft-js"; 3 | import React from "react"; 4 | 5 | import { 6 | INLINE_CONTROL, 7 | BLOCK_CONTROL, 8 | ENTITY_CONTROL, 9 | BR_ICON, 10 | } from "./constants/ui"; 11 | 12 | import indexContentState from "./constants/indexContentState"; 13 | 14 | import PrismDecorator from "./components/PrismDecorator"; 15 | import EditorWrapper from "./components/EditorWrapper"; 16 | 17 | storiesOf("Draftail", module).add("Home", () => ( 18 | 47 | )); 48 | -------------------------------------------------------------------------------- /examples/main.scss: -------------------------------------------------------------------------------- 1 | @use "sass:color"; 2 | 3 | @import "../node_modules/normalize.css"; 4 | @import "../node_modules/draft-js-emoji-plugin/lib/plugin.css"; 5 | 6 | $WHITE: #fff; 7 | $BLACK: #000; 8 | $GREY_333: #333; 9 | $GREY_999: #999; 10 | $TEAL_WAGTAIL: #43b1b0; 11 | $TEAL_WAGTAIL_DARK: #358c8b; 12 | $LIGHT_BLUE_EEF8FF: #eef8ff; 13 | 14 | $TEXT_COLOR: $GREY_333; 15 | 16 | $FONT_SIZE_H1: 3.5rem; 17 | $FONT_SIZE_H1_MEDIUM: 5rem; 18 | $FONT_SIZE_H2: 2.5rem; 19 | $FONT_SIZE_H3: 1.5rem; 20 | $FONT_SIZE_TEXT: 1rem; 21 | 22 | @import "./utils/breakpoints"; 23 | @import "./utils/elements"; 24 | @import "./utils/typography"; 25 | @import "./utils/layout"; 26 | @import "./utils/objects"; 27 | @import "./utils/forms"; 28 | 29 | $draftail-editor-chrome: $GREY_333; 30 | $draftail-editor-chrome-text: $WHITE; 31 | $draftail-editor-chrome-active: $WHITE; 32 | $draftail-editor-chrome-accent: color.adjust( 33 | $color: $draftail-editor-chrome, 34 | $lightness: 20%, 35 | ); 36 | 37 | $draftail-editor-font-family: $FONT_FAMILY_SANS; 38 | 39 | @import "../node_modules/draft-js/dist/Draft"; 40 | @import "../node_modules/draft-js-hashtag-plugin/lib/plugin"; 41 | @import "../node_modules/draft-js-inline-toolbar-plugin/lib/plugin"; 42 | @import "../node_modules/draft-js-side-toolbar-plugin/lib/plugin"; 43 | @import "../src/index"; 44 | 45 | @import "./components/editor"; 46 | @import "./components/header"; 47 | @import "./components/page-nav"; 48 | @import "./components/Modal"; 49 | @import "./components/CharCount"; 50 | @import "./components/EditorWrapper"; 51 | @import "./components/PrismDecorator"; 52 | @import "./components/ColorPicker"; 53 | @import "./entities/TooltipEntity"; 54 | @import "./sources/LinkSource"; 55 | @import "./sources/DocumentSource"; 56 | @import "./sources/ImageSource"; 57 | @import "./sources/EmbedSource"; 58 | @import "./blocks/MediaBlock"; 59 | @import "./blocks/EmbedBlock"; 60 | @import "./blocks/ImageBlock"; 61 | 62 | @import "./plugins/actionBlockPlugin"; 63 | @import "./plugins/sectionBreakPlugin"; 64 | 65 | @import "./utils/utilities"; 66 | -------------------------------------------------------------------------------- /examples/performance.story.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from "@storybook/react"; 2 | import React from "react"; 3 | import { RawDraftContentState } from "draft-js"; 4 | import markovContentStates from "markov_draftjs"; 5 | 6 | import { DraftailEditor } from "../src/index"; 7 | 8 | import EditorBenchmark from "./components/EditorBenchmark"; 9 | import { benchmarkProps } from "../tests/performance/MarkovBenchmark"; 10 | 11 | const contentStates = markovContentStates as RawDraftContentState[]; 12 | 13 | const NB_EDITORS = 50; 14 | const NB_EDITORS_LOW = 5; 15 | const MAX_EDITORS = contentStates.length; 16 | 17 | const header = ( 18 |

19 | Enable the{" "} 20 | 24 | Chrome DevTools FPS meter 25 | 26 |

27 | ); 28 | 29 | storiesOf("Performance", module) 30 | .add(`markov_draftjs 41`, () => ( 31 | <> 32 | {header} 33 | 40 | 41 | 42 | )) 43 | .add(`markov_draftjs 0-${NB_EDITORS_LOW}`, () => ( 44 | <> 45 | {header} 46 | <> 47 | {contentStates.slice(0, NB_EDITORS_LOW).map((contentState, i) => ( 48 | 54 | ))} 55 | 56 | 57 | )) 58 | .add(`markov_draftjs 0-${NB_EDITORS}`, () => ( 59 | <> 60 | {header} 61 | <> 62 | {contentStates.slice(0, NB_EDITORS).map((contentState, i) => ( 63 | 69 | ))} 70 | 71 | 72 | )) 73 | .add(`markov_draftjs ${MAX_EDITORS - NB_EDITORS_LOW}-${MAX_EDITORS}`, () => ( 74 | <> 75 | {header} 76 | <> 77 | {contentStates.slice(-NB_EDITORS_LOW).map((contentState, i) => ( 78 | 84 | ))} 85 | 86 | 87 | )) 88 | .add(`markov_draftjs ${MAX_EDITORS - NB_EDITORS}-${MAX_EDITORS}`, () => ( 89 | <> 90 | {header} 91 | <> 92 | {contentStates.slice(-NB_EDITORS).map((contentState, i) => ( 93 | 99 | ))} 100 | 101 | 102 | )); 103 | -------------------------------------------------------------------------------- /examples/plugins/actionBlockPlugin.scss: -------------------------------------------------------------------------------- 1 | .Draftail-block--action-list-item { 2 | display: flex; 3 | 4 | [type="checkbox"] { 5 | cursor: pointer; 6 | } 7 | 8 | .public-DraftStyleDefault-block { 9 | $default-list-item-spacing: 0.6825em; 10 | 11 | padding-inline-start: $default-list-item-spacing; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/plugins/draft-js-focus-plugin/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | "no-warning-comments": [0], 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /examples/plugins/draft-js-focus-plugin/createDecorator.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import React, { Component } from "react"; 3 | 4 | // Get a component's display name 5 | const getDisplayName = (WrappedComponent) => { 6 | const component = WrappedComponent.WrappedComponent || WrappedComponent; 7 | return component.displayName || component.name || "Component"; 8 | }; 9 | 10 | export default ({ blockKeyStore }) => 11 | (WrappedComponent) => { 12 | class BlockFocusDecorator extends Component { 13 | componentDidMount() { 14 | const { block } = this.props; 15 | blockKeyStore.add(block.getKey()); 16 | } 17 | 18 | componentWillUnmount() { 19 | const { block } = this.props; 20 | blockKeyStore.remove(block.getKey()); 21 | } 22 | 23 | onClick(e) { 24 | const { blockProps } = this.props; 25 | 26 | e.preventDefault(); 27 | 28 | if (!blockProps.isFocused) { 29 | blockProps.setFocusToBlock(); 30 | } 31 | } 32 | 33 | render() { 34 | const { blockProps } = this.props; 35 | const { isFocused } = blockProps; 36 | return React.createElement(WrappedComponent, { 37 | ...this.props, 38 | onClick: this.onClick.bind(this), 39 | isFocused, 40 | }); 41 | } 42 | } 43 | 44 | BlockFocusDecorator.WrappedComponent = 45 | WrappedComponent.WrappedComponent || WrappedComponent; 46 | 47 | BlockFocusDecorator.displayName = `BlockFocus(${getDisplayName( 48 | WrappedComponent, 49 | )})`; 50 | 51 | return BlockFocusDecorator; 52 | }; 53 | -------------------------------------------------------------------------------- /examples/plugins/draft-js-focus-plugin/modifiers/insertNewLine.js: -------------------------------------------------------------------------------- 1 | import { List } from "immutable"; 2 | import { 3 | ContentBlock, 4 | EditorState, 5 | BlockMapBuilder, 6 | genKey as generateRandomKey, 7 | } from "draft-js"; 8 | 9 | const insertBlockAfterSelection = (contentState, selectionState, newBlock) => { 10 | const targetKey = selectionState.getStartKey(); 11 | const array = []; 12 | contentState.getBlockMap().forEach((block, blockKey) => { 13 | array.push(block); 14 | if (blockKey !== targetKey) return; 15 | array.push(newBlock); 16 | }); 17 | return contentState.merge({ 18 | blockMap: BlockMapBuilder.createFromArray(array), 19 | selectionBefore: selectionState, 20 | selectionAfter: selectionState.merge({ 21 | anchorKey: newBlock.getKey(), 22 | anchorOffset: newBlock.getLength(), 23 | focusKey: newBlock.getKey(), 24 | focusOffset: newBlock.getLength(), 25 | isBackward: false, 26 | }), 27 | }); 28 | }; 29 | 30 | export default function insertNewLine(editorState) { 31 | const contentState = editorState.getCurrentContent(); 32 | const selectionState = editorState.getSelection(); 33 | const newLineBlock = new ContentBlock({ 34 | key: generateRandomKey(), 35 | type: "unstyled", 36 | text: "", 37 | characterList: List(), 38 | }); 39 | const withNewLine = insertBlockAfterSelection( 40 | contentState, 41 | selectionState, 42 | newLineBlock, 43 | ); 44 | const newContent = withNewLine.merge({ 45 | selectionAfter: withNewLine.getSelectionAfter().set("hasFocus", true), 46 | }); 47 | return EditorState.push(editorState, newContent, "insert-fragment"); 48 | } 49 | -------------------------------------------------------------------------------- /examples/plugins/draft-js-focus-plugin/modifiers/removeBlock.js: -------------------------------------------------------------------------------- 1 | import { Modifier, EditorState, SelectionState } from "draft-js"; 2 | 3 | /* NOT USED at the moment, but might be valuable if we want to fix atomic block behaviour */ 4 | 5 | export default function removeBlock(editorState, blockKey) { 6 | let content = editorState.getCurrentContent(); 7 | 8 | const beforeKey = content.getKeyBefore(blockKey); 9 | const beforeBlock = content.getBlockForKey(beforeKey); 10 | 11 | // Note: if the focused block is the first block then it is reduced to an 12 | // unstyled block with no character 13 | if (beforeBlock === undefined) { 14 | const targetRange = new SelectionState({ 15 | anchorKey: blockKey, 16 | anchorOffset: 0, 17 | focusKey: blockKey, 18 | focusOffset: 1, 19 | }); 20 | // change the blocktype and remove the characterList entry with the sticker 21 | content = Modifier.removeRange(content, targetRange, "backward"); 22 | content = Modifier.setBlockType(content, targetRange, "unstyled"); 23 | // TODO Investigate. 24 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 25 | // @ts-ignore 26 | const newState = EditorState.push(editorState, content, "remove-block"); 27 | 28 | // force to new selection 29 | const newSelection = new SelectionState({ 30 | anchorKey: blockKey, 31 | anchorOffset: 0, 32 | focusKey: blockKey, 33 | focusOffset: 0, 34 | }); 35 | return EditorState.forceSelection(newState, newSelection); 36 | } 37 | 38 | const targetRange = new SelectionState({ 39 | anchorKey: beforeKey, 40 | anchorOffset: beforeBlock.getLength(), 41 | focusKey: blockKey, 42 | focusOffset: 1, 43 | }); 44 | 45 | content = Modifier.removeRange(content, targetRange, "backward"); 46 | // TODO Investigate. 47 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 48 | // @ts-ignore 49 | const newState = EditorState.push(editorState, content, "remove-block"); 50 | 51 | // force to new selection 52 | const newSelection = new SelectionState({ 53 | anchorKey: beforeKey, 54 | anchorOffset: beforeBlock.getLength(), 55 | focusKey: beforeKey, 56 | focusOffset: beforeBlock.getLength(), 57 | }); 58 | return EditorState.forceSelection(newState, newSelection); 59 | } 60 | -------------------------------------------------------------------------------- /examples/plugins/draft-js-focus-plugin/modifiers/setSelection.js: -------------------------------------------------------------------------------- 1 | import { SelectionState, EditorState } from "draft-js"; 2 | // Set selection of editor to next/previous block 3 | import DraftOffsetKey from "draft-js/lib/DraftOffsetKey"; 4 | 5 | export default (getEditorState, setEditorState, mode, event) => { 6 | const editorState = getEditorState(); 7 | const selectionKey = editorState.getSelection().getAnchorKey(); 8 | const newActiveBlock = 9 | mode === "up" 10 | ? editorState.getCurrentContent().getBlockBefore(selectionKey) 11 | : editorState.getCurrentContent().getBlockAfter(selectionKey); 12 | 13 | if (newActiveBlock && newActiveBlock.get("key") === selectionKey) { 14 | return; 15 | } 16 | 17 | if (newActiveBlock) { 18 | // TODO verify that always a key-0-0 exists 19 | const offsetKey = DraftOffsetKey.encode(newActiveBlock.getKey(), 0, 0); 20 | const node = document.querySelectorAll( 21 | `[data-offset-key="${offsetKey}"]`, 22 | )[0]; 23 | // set the native selection to the node so the caret is not in the text and 24 | // the selectionState matches the native selection 25 | const selection = window.getSelection(); 26 | const range = document.createRange(); 27 | range.setStart(node, 0); 28 | range.setEnd(node, 0); 29 | selection.removeAllRanges(); 30 | selection.addRange(range); 31 | 32 | const offset = mode === "up" ? newActiveBlock.getLength() : 0; 33 | event.preventDefault(); 34 | setEditorState( 35 | EditorState.forceSelection( 36 | editorState, 37 | new SelectionState({ 38 | anchorKey: newActiveBlock.getKey(), 39 | anchorOffset: offset, 40 | focusKey: newActiveBlock.getKey(), 41 | focusOffset: offset, 42 | isBackward: false, 43 | }), 44 | ), 45 | ); 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /examples/plugins/draft-js-focus-plugin/modifiers/setSelectionToBlock.js: -------------------------------------------------------------------------------- 1 | import { SelectionState, EditorState } from "draft-js"; 2 | import DraftOffsetKey from "draft-js/lib/DraftOffsetKey"; 3 | 4 | // Set selection of editor to next/previous block 5 | export default (getEditorState, setEditorState, newActiveBlock) => { 6 | const editorState = getEditorState(); 7 | 8 | // TODO verify that always a key-0-0 exists 9 | const offsetKey = DraftOffsetKey.encode(newActiveBlock.getKey(), 0, 0); 10 | const node = document.querySelectorAll(`[data-offset-key="${offsetKey}"]`)[0]; 11 | // set the native selection to the node so the caret is not in the text and 12 | // the selectionState matches the native selection 13 | const selection = window.getSelection(); 14 | const range = document.createRange(); 15 | range.setStart(node, 0); 16 | range.setEnd(node, 0); 17 | selection.removeAllRanges(); 18 | selection.addRange(range); 19 | 20 | setEditorState( 21 | EditorState.forceSelection( 22 | editorState, 23 | new SelectionState({ 24 | anchorKey: newActiveBlock.getKey(), 25 | anchorOffset: 0, 26 | focusKey: newActiveBlock.getKey(), 27 | focusOffset: 0, 28 | isBackward: false, 29 | }), 30 | ), 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /examples/plugins/draft-js-focus-plugin/utils/blockInSelection.js: -------------------------------------------------------------------------------- 1 | import getSelectedBlocksMapKeys from "./getSelectedBlocksMapKeys"; 2 | 3 | export default (editorState, blockKey) => { 4 | const selectedBlocksKeys = getSelectedBlocksMapKeys(editorState); 5 | return selectedBlocksKeys.includes(blockKey); 6 | }; 7 | -------------------------------------------------------------------------------- /examples/plugins/draft-js-focus-plugin/utils/createBlockKeyStore.js: -------------------------------------------------------------------------------- 1 | import { List } from "immutable"; 2 | 3 | const createBlockKeyStore = () => { 4 | let keys = List(); 5 | 6 | const add = (key) => { 7 | keys = keys.push(key); 8 | return keys; 9 | }; 10 | 11 | const remove = (key) => { 12 | keys = keys.filter((item) => item !== key); 13 | return keys; 14 | }; 15 | 16 | return { 17 | add, 18 | remove, 19 | includes: (key) => keys.includes(key), 20 | getAll: () => keys, 21 | }; 22 | }; 23 | 24 | export default createBlockKeyStore; 25 | -------------------------------------------------------------------------------- /examples/plugins/draft-js-focus-plugin/utils/getBlockMapKeys.js: -------------------------------------------------------------------------------- 1 | export default (contentState, startKey, endKey) => { 2 | const blockMapKeys = contentState.getBlockMap().keySeq(); 3 | return blockMapKeys 4 | .skipUntil((key) => key === startKey) 5 | .takeUntil((key) => key === endKey) 6 | .concat([endKey]); 7 | }; 8 | -------------------------------------------------------------------------------- /examples/plugins/draft-js-focus-plugin/utils/getSelectedBlocksMapKeys.js: -------------------------------------------------------------------------------- 1 | import getBlockMapKeys from "./getBlockMapKeys"; 2 | 3 | export default (editorState) => { 4 | const selectionState = editorState.getSelection(); 5 | const contentState = editorState.getCurrentContent(); 6 | return getBlockMapKeys( 7 | contentState, 8 | selectionState.getStartKey(), 9 | selectionState.getEndKey(), 10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /examples/plugins/linkifyPlugin.js: -------------------------------------------------------------------------------- 1 | import { Modifier, RichUtils, EditorState } from "draft-js"; 2 | 3 | const createEntity = ( 4 | editorState, 5 | entityType, 6 | entityData, 7 | entityText, 8 | entityMutability, 9 | ) => { 10 | const contentState = editorState.getCurrentContent(); 11 | const selection = editorState.getSelection(); 12 | const contentStateWithEntity = contentState.createEntity( 13 | entityType, 14 | entityMutability, 15 | entityData, 16 | ); 17 | const entityKey = contentStateWithEntity.getLastCreatedEntityKey(); 18 | 19 | let nextContentState; 20 | 21 | if (selection.isCollapsed()) { 22 | nextContentState = Modifier.insertText( 23 | contentState, 24 | selection, 25 | entityText, 26 | undefined, 27 | entityKey, 28 | ); 29 | } else { 30 | nextContentState = Modifier.replaceText( 31 | contentState, 32 | selection, 33 | entityText, 34 | undefined, 35 | entityKey, 36 | ); 37 | } 38 | 39 | const nextState = EditorState.push( 40 | editorState, 41 | nextContentState, 42 | "insert-fragment", 43 | ); 44 | 45 | return nextState; 46 | }; 47 | 48 | // https://gist.github.com/dperini/729294 49 | const LINKIFY_PATTERN = // protocol identifier (optional) 50 | // short syntax // still required 51 | "(?:(?:(?:https?|ftp):)?\\/\\/)" + 52 | // user:pass BasicAuth (optional) 53 | "(?:\\S+(?::\\S*)?@)?" + 54 | "(?:" + 55 | // IP address exclusion 56 | // private & local networks 57 | "(?!(?:10|127)(?:\\.\\d{1,3}){3})" + 58 | "(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})" + 59 | "(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})" + 60 | // IP address dotted notation octets 61 | // excludes loopback network 0.0.0.0 62 | // excludes reserved space >= 224.0.0.0 63 | // excludes network & broacast addresses 64 | // (first & last IP address of each class) 65 | "(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" + 66 | "(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" + 67 | "(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" + 68 | "|" + 69 | // host & domain names, may end with dot 70 | // can be replaced by a shortest alternative 71 | // (?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.)+ 72 | "(?:" + 73 | "(?:" + 74 | "[a-z0-9\\u00a1-\\uffff]" + 75 | "[a-z0-9\\u00a1-\\uffff_-]{0,62}" + 76 | ")?" + 77 | "[a-z0-9\\u00a1-\\uffff]\\." + 78 | ")+" + 79 | // TLD identifier name, may end with dot 80 | "(?:[a-z\\u00a1-\\uffff]{2,}\\.?)" + 81 | ")" + 82 | // port number (optional) 83 | "(?::\\d{2,5})?" + 84 | // resource path (optional) 85 | "(?:[/?#]\\S*)?"; 86 | 87 | export const LINKIFY_REGEX_EXACT = new RegExp(`^${LINKIFY_PATTERN}$`, "ig"); 88 | 89 | const linkifyPlugin = () => ({ 90 | handlePastedText(text, html, editorState, { setEditorState }) { 91 | let nextState = editorState; 92 | 93 | if (text.match(LINKIFY_REGEX_EXACT)) { 94 | const selection = nextState.getSelection(); 95 | 96 | if (selection.isCollapsed()) { 97 | nextState = createEntity( 98 | nextState, 99 | "LINK", 100 | { url: text }, 101 | text, 102 | "MUTABLE", 103 | ); 104 | } else { 105 | const content = nextState.getCurrentContent(); 106 | const contentWithEntity = content.createEntity("LINK", "MUTABLE", { 107 | url: text, 108 | }); 109 | const entityKey = contentWithEntity.getLastCreatedEntityKey(); 110 | nextState = RichUtils.toggleLink(nextState, selection, entityKey); 111 | } 112 | 113 | setEditorState(nextState); 114 | return "handled"; 115 | } 116 | 117 | return "not-handled"; 118 | }, 119 | }); 120 | 121 | export default linkifyPlugin; 122 | -------------------------------------------------------------------------------- /examples/plugins/sectionBreakPlugin.scss: -------------------------------------------------------------------------------- 1 | .SectionBreak { 2 | border-bottom: 1px solid #aaa; 3 | text-align: center; 4 | line-height: 0.1em; 5 | margin: 10px 0 20px; 6 | 7 | &__label { 8 | background: #fff; 9 | padding: 0 10px; 10 | } 11 | 12 | &--unfocused:hover { 13 | cursor: default; 14 | border-radius: 2px; 15 | box-shadow: 0 0 0 3px #d2e3f7; 16 | } 17 | 18 | &--focused { 19 | cursor: default; 20 | border-radius: 2px; 21 | box-shadow: 0 0 0 3px #accef7; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/plugins/sectionBreakPlugin.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { 3 | ContentBlock, 4 | ContentState, 5 | EditorState, 6 | KeyBindingUtil, 7 | Modifier, 8 | } from "draft-js"; 9 | 10 | import { ToolbarButton } from "../../src/index"; 11 | 12 | type PluginFunctions = { 13 | setEditorState: (editorState: EditorState) => void; 14 | }; 15 | 16 | const BREAK_ICON = 17 | "M0 16h4v2h-4zM6 16h6v2h-6zM14 16h4v2h-4zM20 16h6v2h-6zM28 16h4v2h-4zM27.5 0l0.5 14h-24l0.5-14h1l0.5 12h20l0.5-12zM4.5 32l-0.5-12h24l-0.5 12h-1l-0.5-10h-20l-0.5 10zM0 512h128v64H0v-64zm192 0h192v64H192v-64zm256 0h128v64H448v-64zm192 0h192v64H640v-64zm256 0h128v64H896v-64zM880 0l16 448H128L144 0h32l16 384h640L848 0h32zM144 1024l-16-384h768l-16 384h-32l-16-320H192l-16 320h-32z"; 18 | 19 | const { isOptionKeyCommand } = KeyBindingUtil; 20 | // Copied from behavior.js. 21 | // Hack relying on the internals of Draft.js. 22 | // See https://github.com/facebook/draft-js/pull/869 23 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 24 | // @ts-expect-error 25 | const IS_MAC_OS = isOptionKeyCommand({ altKey: "test" }) === "test"; 26 | 27 | const insertSectionBreak = (editorState: EditorState) => { 28 | const content = editorState.getCurrentContent(); 29 | 30 | const selection = editorState.getSelection(); 31 | let newContent = Modifier.splitBlock(content, selection); 32 | const blockMap = newContent.getBlockMap(); 33 | const blockKey = selection.getStartKey(); 34 | const insertedBlockKey = newContent.getKeyAfter(blockKey); 35 | 36 | const newBlock = blockMap 37 | .get(insertedBlockKey) 38 | .set("type", "section-break") as ContentBlock; 39 | 40 | newContent = newContent.merge({ 41 | blockMap: blockMap.set(insertedBlockKey, newBlock), 42 | }) as ContentState; 43 | 44 | return EditorState.push(editorState, newContent, "split-block"); 45 | }; 46 | 47 | type Props = { 48 | getEditorState: () => EditorState; 49 | onChange: (state: EditorState) => void; 50 | }; 51 | 52 | export const SectionBreakControl = ({ getEditorState, onChange }: Props) => ( 53 | { 58 | onChange(insertSectionBreak(getEditorState())); 59 | }} 60 | /> 61 | ); 62 | 63 | type SectionBreakProps = { 64 | isFocused: boolean; 65 | }; 66 | 67 | const SectionBreak = ({ isFocused }: SectionBreakProps) => ( 68 |
73 | Section break 74 |
75 | ); 76 | 77 | const sectionBreakPlugin = (config: { 78 | decorator: ( 79 | component: React.ComponentType, 80 | ) => Component; 81 | }) => { 82 | const component = config.decorator(SectionBreak); 83 | return { 84 | blockRendererFn(block: ContentBlock) { 85 | if (block.getType() === "section-break") { 86 | return { 87 | component, 88 | editable: false, 89 | }; 90 | } 91 | 92 | return null; 93 | }, 94 | 95 | keyBindingFn(e: React.KeyboardEvent) { 96 | const KeyS = 83; 97 | 98 | if ((e.metaKey || e.ctrlKey) && e.altKey && e.keyCode === KeyS) { 99 | return "section-break"; 100 | } 101 | 102 | return undefined; 103 | }, 104 | 105 | handleKeyCommand( 106 | command: string, 107 | editorState: EditorState, 108 | { setEditorState }: PluginFunctions, 109 | ) { 110 | if (command === "section-break") { 111 | setEditorState(insertSectionBreak(editorState)); 112 | return "handled"; 113 | } 114 | return "not-handled"; 115 | }, 116 | }; 117 | }; 118 | 119 | export default sectionBreakPlugin; 120 | -------------------------------------------------------------------------------- /examples/simple.story.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from "@storybook/react"; 2 | import { RawDraftContentState } from "draft-js"; 3 | import React from "react"; 4 | 5 | import { DraftailEditor, BLOCK_TYPE, INLINE_STYLE } from "../src/index"; 6 | 7 | const initial = JSON.parse(sessionStorage.getItem("content") || "null"); 8 | 9 | const onSave = (content: RawDraftContentState | null) => { 10 | // eslint-disable-next-line no-console 11 | console.log("saving", content); 12 | sessionStorage.setItem("content", JSON.stringify(content)); 13 | }; 14 | 15 | storiesOf("Draftail", module).add("Simple", () => ( 16 | 25 | )); 26 | -------------------------------------------------------------------------------- /examples/sources/DocumentSource.scss: -------------------------------------------------------------------------------- 1 | .DocumentSource { 2 | padding: 1rem; 3 | } 4 | -------------------------------------------------------------------------------- /examples/sources/DocumentSource.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { RichUtils } from "draft-js"; 3 | 4 | import { EntitySourceProps } from "../../src"; 5 | 6 | import Modal from "../components/Modal"; 7 | 8 | type State = { 9 | url: string; 10 | }; 11 | 12 | class DocumentSource extends Component { 13 | inputRef?: HTMLInputElement | null; 14 | 15 | constructor(props: EntitySourceProps) { 16 | super(props); 17 | 18 | const { entity } = this.props; 19 | const state = { 20 | url: "", 21 | }; 22 | 23 | if (entity) { 24 | const data = entity.getData(); 25 | state.url = data.url; 26 | } 27 | 28 | this.state = state; 29 | 30 | this.onRequestClose = this.onRequestClose.bind(this); 31 | this.onAfterOpen = this.onAfterOpen.bind(this); 32 | this.onConfirm = this.onConfirm.bind(this); 33 | this.onChangeURL = this.onChangeURL.bind(this); 34 | } 35 | 36 | onConfirm(e: React.FormEvent) { 37 | const { editorState, entityType, onComplete } = this.props; 38 | const { url } = this.state; 39 | 40 | e.preventDefault(); 41 | 42 | const contentState = editorState.getCurrentContent(); 43 | 44 | const data = { 45 | url: url.replace(/\s/g, ""), 46 | }; 47 | const contentStateWithEntity = contentState.createEntity( 48 | entityType.type, 49 | "MUTABLE", 50 | data, 51 | ); 52 | const entityKey = contentStateWithEntity.getLastCreatedEntityKey(); 53 | const nextState = RichUtils.toggleLink( 54 | editorState, 55 | editorState.getSelection(), 56 | entityKey, 57 | ); 58 | 59 | onComplete(nextState); 60 | } 61 | 62 | onRequestClose(e: React.SyntheticEvent) { 63 | const { onClose } = this.props; 64 | e.preventDefault(); 65 | 66 | onClose(); 67 | } 68 | 69 | onAfterOpen() { 70 | const input = this.inputRef; 71 | 72 | if (input) { 73 | input.focus(); 74 | input.select(); 75 | } 76 | } 77 | 78 | onChangeURL(e: React.ChangeEvent) { 79 | if (e.target instanceof HTMLInputElement) { 80 | const url = e.target.value; 81 | this.setState({ url }); 82 | } 83 | } 84 | 85 | render() { 86 | const { textDirectionality } = this.props; 87 | const { url } = this.state; 88 | return ( 89 | 95 |
100 | 112 | 113 | 114 |
115 |
116 | ); 117 | } 118 | } 119 | 120 | export default DocumentSource; 121 | -------------------------------------------------------------------------------- /examples/sources/EmbedSource.scss: -------------------------------------------------------------------------------- 1 | .EmbedSource { 2 | padding: 1rem; 3 | } 4 | -------------------------------------------------------------------------------- /examples/sources/EmojiSource.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { EditorState, Modifier } from "draft-js"; 3 | 4 | import { EntitySourceProps } from "../../src"; 5 | 6 | class EmojiSource extends React.Component { 7 | componentDidMount() { 8 | const { editorState, onComplete } = this.props; 9 | 10 | const content = editorState.getCurrentContent(); 11 | const selection = editorState.getSelection(); 12 | 13 | const newContent = Modifier.replaceText( 14 | content, 15 | selection, 16 | "🙂", 17 | undefined, 18 | undefined, 19 | ); 20 | const nextState = EditorState.push( 21 | editorState, 22 | newContent, 23 | "insert-characters", 24 | ); 25 | 26 | onComplete(nextState); 27 | } 28 | 29 | render() { 30 | return null; 31 | } 32 | } 33 | 34 | export default EmojiSource; 35 | -------------------------------------------------------------------------------- /examples/sources/ImageSource.scss: -------------------------------------------------------------------------------- 1 | .ImageSource { 2 | padding: 1rem; 3 | } 4 | -------------------------------------------------------------------------------- /examples/sources/ImageSource.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import { AtomicBlockUtils, EditorState } from "draft-js"; 4 | 5 | import Modal from "../components/Modal"; 6 | import { EntitySourceProps } from "../../src"; 7 | 8 | type State = { 9 | src: string; 10 | }; 11 | 12 | class ImageSource extends Component { 13 | inputRef?: HTMLInputElement | null; 14 | 15 | constructor(props: EntitySourceProps) { 16 | super(props); 17 | 18 | const { entity } = this.props; 19 | const state = { 20 | src: "", 21 | }; 22 | 23 | if (entity) { 24 | const data = entity.getData(); 25 | state.src = data.src; 26 | } 27 | 28 | this.state = state; 29 | 30 | this.onRequestClose = this.onRequestClose.bind(this); 31 | this.onAfterOpen = this.onAfterOpen.bind(this); 32 | this.onConfirm = this.onConfirm.bind(this); 33 | this.onChangeSource = this.onChangeSource.bind(this); 34 | } 35 | 36 | onConfirm(e: React.FormEvent) { 37 | const { editorState, entity, entityKey, entityType, onComplete } = 38 | this.props; 39 | const { src } = this.state; 40 | const content = editorState.getCurrentContent(); 41 | let nextState; 42 | 43 | e.preventDefault(); 44 | 45 | if (entity && entityKey) { 46 | const nextContent = content.mergeEntityData(entityKey, { src }); 47 | nextState = EditorState.push(editorState, nextContent, "apply-entity"); 48 | } else { 49 | const contentWithEntity = content.createEntity( 50 | entityType.type, 51 | "MUTABLE", 52 | { 53 | alt: "", 54 | src, 55 | }, 56 | ); 57 | nextState = AtomicBlockUtils.insertAtomicBlock( 58 | editorState, 59 | contentWithEntity.getLastCreatedEntityKey(), 60 | " ", 61 | ); 62 | } 63 | 64 | onComplete(nextState); 65 | } 66 | 67 | onRequestClose(e: React.SyntheticEvent) { 68 | const { onClose } = this.props; 69 | e.preventDefault(); 70 | 71 | onClose(); 72 | } 73 | 74 | onAfterOpen() { 75 | const input = this.inputRef; 76 | 77 | if (input) { 78 | input.focus(); 79 | input.select(); 80 | } 81 | } 82 | 83 | onChangeSource(e: React.ChangeEvent) { 84 | const src = e.target.value; 85 | this.setState({ src }); 86 | } 87 | 88 | render() { 89 | const { textDirectionality } = this.props; 90 | const { src } = this.state; 91 | return ( 92 | 98 |
103 | 115 | 116 | 119 |
120 |
121 | ); 122 | } 123 | } 124 | 125 | export default ImageSource; 126 | -------------------------------------------------------------------------------- /examples/sources/LinkSource.scss: -------------------------------------------------------------------------------- 1 | .LinkSource { 2 | padding: 1rem; 3 | } 4 | -------------------------------------------------------------------------------- /examples/sources/LinkSource.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import { EditorState, Modifier, RichUtils } from "draft-js"; 4 | 5 | import { EntitySourceProps } from "../../src/api/types"; 6 | 7 | import Modal from "../components/Modal"; 8 | 9 | type State = { 10 | url: string; 11 | }; 12 | 13 | class LinkSource extends Component { 14 | inputRef?: HTMLInputElement | null; 15 | 16 | constructor(props: EntitySourceProps) { 17 | super(props); 18 | 19 | const { entity } = this.props; 20 | const state = { 21 | url: "", 22 | }; 23 | 24 | if (entity) { 25 | const data = entity.getData(); 26 | state.url = data.url; 27 | } 28 | 29 | this.state = state; 30 | 31 | this.onRequestClose = this.onRequestClose.bind(this); 32 | this.onAfterOpen = this.onAfterOpen.bind(this); 33 | this.onConfirm = this.onConfirm.bind(this); 34 | this.onChangeURL = this.onChangeURL.bind(this); 35 | } 36 | 37 | onConfirm(e: React.FormEvent) { 38 | const { editorState, entityType, onComplete } = this.props; 39 | const { url } = this.state; 40 | 41 | e.preventDefault(); 42 | 43 | const contentState = editorState.getCurrentContent(); 44 | 45 | const data = { 46 | url: url.replace(/\s/g, ""), 47 | }; 48 | const contentStateWithEntity = contentState.createEntity( 49 | entityType.type, 50 | "MUTABLE", 51 | data, 52 | ); 53 | const entityKey = contentStateWithEntity.getLastCreatedEntityKey(); 54 | const selection = editorState.getSelection(); 55 | const shouldReplaceText = selection.isCollapsed(); 56 | 57 | let nextState; 58 | if (shouldReplaceText) { 59 | // If there is a title attribute, use it. Otherwise we inject the URL. 60 | const newText = data.url; 61 | const newContent = Modifier.replaceText( 62 | contentStateWithEntity, 63 | selection, 64 | newText, 65 | undefined, 66 | entityKey, 67 | ); 68 | nextState = EditorState.push( 69 | editorState, 70 | newContent, 71 | "insert-characters", 72 | ); 73 | } else { 74 | nextState = RichUtils.toggleLink(editorState, selection, entityKey); 75 | } 76 | 77 | onComplete(nextState); 78 | } 79 | 80 | onRequestClose(e: React.SyntheticEvent) { 81 | const { onClose } = this.props; 82 | e.preventDefault(); 83 | 84 | onClose(); 85 | } 86 | 87 | onAfterOpen() { 88 | const input = this.inputRef; 89 | 90 | if (input) { 91 | input.focus(); 92 | input.select(); 93 | } 94 | } 95 | 96 | onChangeURL(e: React.ChangeEvent) { 97 | if (e.target instanceof HTMLInputElement) { 98 | const url = e.target.value; 99 | this.setState({ url }); 100 | } 101 | } 102 | 103 | render() { 104 | const { textDirectionality } = this.props; 105 | const { url } = this.state; 106 | return ( 107 | 113 |
118 | 130 | 131 | 132 |
133 |
134 | ); 135 | } 136 | } 137 | 138 | export default LinkSource; 139 | -------------------------------------------------------------------------------- /examples/utils/_breakpoints.scss: -------------------------------------------------------------------------------- 1 | @use "sass:math"; 2 | // ---------------------------------------------------------------------------- 3 | // Breakpoints and simple media query mixins 4 | // ---------------------------------------------------------------------------- 5 | 6 | $breakpoint-xsmall: 480; 7 | $breakpoint-small: 767; 8 | $breakpoint-medium: $breakpoint-small + 1; 9 | $breakpoint-large: 1024; 10 | $breakpoint-xlarge: 1280; 11 | $breakpoint-xxlarge: 1380; 12 | 13 | @function em($size, $em-base: 16) { 14 | $em-size: math.div($size, $em-base); 15 | @return $em-size * 1em; 16 | } 17 | 18 | $width-xsmall: em( 19 | $size: $breakpoint-xsmall, 20 | ); 21 | $width-small: em( 22 | $size: $breakpoint-small, 23 | ); 24 | $width-medium: em( 25 | $size: $breakpoint-medium, 26 | ); 27 | $width-large: em( 28 | $size: $breakpoint-large, 29 | ); 30 | $width-xlarge: em( 31 | $size: $breakpoint-xlarge, 32 | ); 33 | $width-xxlarge: em( 34 | $size: $breakpoint-xxlarge, 35 | ); 36 | 37 | @mixin xsmall { 38 | @media only screen and (max-width: $width-xsmall) { 39 | @content; 40 | } 41 | } 42 | 43 | @mixin small { 44 | @media only screen and (max-width: $width-small) { 45 | @content; 46 | } 47 | } 48 | 49 | @mixin medium { 50 | @media only screen and (min-width: $width-medium) { 51 | @content; 52 | } 53 | } 54 | 55 | @mixin large { 56 | @media only screen and (min-width: $width-large) { 57 | @content; 58 | } 59 | } 60 | 61 | @mixin xlarge { 62 | @media only screen and (min-width: $width-xlarge) { 63 | @content; 64 | } 65 | } 66 | 67 | @mixin xxlarge { 68 | @media only screen and (min-width: $width-xxlarge) { 69 | @content; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /examples/utils/_elements.scss: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: inherit; 5 | } 6 | 7 | body { 8 | box-sizing: border-box; 9 | font-size: $FONT_SIZE_TEXT; 10 | color: $TEXT_COLOR; 11 | font-weight: normal; 12 | } 13 | 14 | img { 15 | max-width: 100%; 16 | } 17 | 18 | a { 19 | color: inherit; 20 | text-decoration: none; 21 | } 22 | 23 | summary { 24 | cursor: pointer; 25 | } 26 | 27 | button { 28 | cursor: pointer; 29 | font-family: inherit; 30 | } 31 | -------------------------------------------------------------------------------- /examples/utils/_forms.scss: -------------------------------------------------------------------------------- 1 | $color-error: #dc143c; 2 | 3 | fieldset { 4 | padding: 0; 5 | margin: 0; 6 | border: 0; 7 | } 8 | 9 | @mixin inputs { 10 | [type="text"], 11 | [type="password"], 12 | [type="email"], 13 | [type="number"], 14 | [type="tel"], 15 | [type="file"], 16 | [type="url"], 17 | [type="search"], 18 | textarea, 19 | select { 20 | @content; 21 | } 22 | } 23 | 24 | @include inputs() { 25 | width: 100%; 26 | padding: 0.75rem; 27 | background-color: transparent; 28 | border-radius: 3px; 29 | border: 1px solid currentColor; 30 | color: inherit; 31 | outline: none; 32 | appearance: none; 33 | 34 | label + & { 35 | margin-top: 0.25rem; 36 | } 37 | 38 | &[disabled] { 39 | opacity: 0.5; 40 | cursor: not-allowed; 41 | } 42 | } 43 | 44 | .form-field { 45 | display: block; 46 | cursor: pointer; 47 | 48 | @include inputs() { 49 | display: block; 50 | } 51 | 52 | & + & { 53 | margin-top: 1rem; 54 | } 55 | 56 | + button { 57 | margin-top: 0.25rem; 58 | } 59 | } 60 | 61 | .form-field__label { 62 | display: block; 63 | margin-bottom: 1rem; 64 | } 65 | 66 | .form-field [role="alert"] { 67 | color: $color-error; 68 | min-height: 18px; 69 | } 70 | -------------------------------------------------------------------------------- /examples/utils/_layout.scss: -------------------------------------------------------------------------------- 1 | .page-wrapper { 2 | max-width: 960px; 3 | padding: 10vh 5vw; 4 | margin: 0 auto; 5 | 6 | @include medium() { 7 | padding: 10vh 5rem; 8 | } 9 | } 10 | 11 | .page-section { 12 | & + & { 13 | margin-top: 2rem; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/utils/_objects.scss: -------------------------------------------------------------------------------- 1 | .link { 2 | border-bottom: 1px dotted $TEAL_WAGTAIL; 3 | } 4 | 5 | .example { 6 | display: block; 7 | margin-top: 2rem; 8 | } 9 | 10 | .anchor { 11 | display: none; 12 | 13 | h4:hover & { 14 | display: inline; 15 | } 16 | } 17 | 18 | .loading { 19 | opacity: 0.7; 20 | 21 | .js & { 22 | opacity: 0.3; 23 | user-select: none; 24 | cursor: not-allowed; 25 | } 26 | } 27 | 28 | .list-inline { 29 | margin: 0; 30 | padding: 0; 31 | list-style-type: none; 32 | 33 | > li { 34 | display: inline-block; 35 | margin: 0; 36 | padding: 0; 37 | 38 | + li { 39 | margin-inline-start: 0.5rem; 40 | } 41 | } 42 | } 43 | 44 | // Icomoon icons. 45 | [class^="icon-"], 46 | [class*=" icon-"] { 47 | vertical-align: middle; 48 | } 49 | -------------------------------------------------------------------------------- /examples/utils/_typography.scss: -------------------------------------------------------------------------------- 1 | $FONT_FAMILY_SANS: system-ui, -apple-system, BlinkMacSystemFont, 2 | "Helvetica Neue", Helvetica, Arial, sans-serif; 3 | $FONT_FAMILY_MONOSPACE: Monaco, Bitstream Vera Sans Mono, Lucida Console, 4 | Terminal, Consolas, Liberation Mono, DejaVu Sans Mono, Courier New, monospace; 5 | 6 | body { 7 | font-family: $FONT_FAMILY_SANS; 8 | } 9 | 10 | code { 11 | font-family: $FONT_FAMILY_MONOSPACE; 12 | } 13 | 14 | h1, 15 | h2, 16 | h3, 17 | h4, 18 | h5, 19 | h6 { 20 | margin: 0 0 1rem; 21 | } 22 | 23 | h1, 24 | h2, 25 | h3 { 26 | line-height: 1.1; 27 | } 28 | 29 | .page-section > h2 { 30 | font-size: $FONT_SIZE_H2; 31 | } 32 | 33 | .example > h3 { 34 | font-size: $FONT_SIZE_H3; 35 | } 36 | -------------------------------------------------------------------------------- /examples/utils/_utilities.scss: -------------------------------------------------------------------------------- 1 | .u-text-center { 2 | text-align: center; 3 | } 4 | 5 | .u-wagtail { 6 | color: $TEAL_WAGTAIL_DARK; 7 | white-space: nowrap; 8 | } 9 | 10 | .Draftail-block--tiny-text { 11 | $tiny-text: 11px; 12 | 13 | font-size: $tiny-text; 14 | font-style: italic; 15 | } 16 | 17 | .docs-rtl-support { 18 | // Flip icons that are directional. 19 | [name="ordered-list-item"], 20 | [name="unordered-list-item"], 21 | [name="ITALIC"], 22 | [name="BOLD"] { 23 | .Draftail-Icon { 24 | transform: scaleX(-1); 25 | } 26 | } 27 | } 28 | 29 | .docs-ui-theming .Draftail-Toolbar, 30 | .docs-custom-toolbars .Draftail-Toolbar--bottom { 31 | padding-inline: 0; 32 | border: 0; 33 | background: transparent; 34 | color: $GREY_999; 35 | } 36 | 37 | .docs-custom-toolbars .Draftail-Toolbar--bottom, 38 | .docs-floating-toolbars .Draftail-Toolbar--bottom { 39 | display: flex; 40 | flex-direction: row-reverse; 41 | font-variant-numeric: tabular-nums; 42 | 43 | select { 44 | max-width: 50px; 45 | } 46 | } 47 | 48 | .docs-floating-toolbars { 49 | .Draftail-Editor { 50 | border-color: transparent; 51 | } 52 | 53 | .DraftEditor-root { 54 | border: $draftail-editor-border; 55 | border-top-color: transparent; 56 | } 57 | 58 | .EditorWrapper--floating .DraftEditor-root { 59 | border-top: $draftail-editor-border; 60 | } 61 | } 62 | 63 | .custom-toolbar-plugins { 64 | margin: 2rem 1rem 0 8rem; 65 | } 66 | -------------------------------------------------------------------------------- /examples/utils/embedly.ts: -------------------------------------------------------------------------------- 1 | type Embed = { 2 | url: string; 3 | title: string; 4 | author_name: string; 5 | thumbnail_url: string; 6 | html: string; 7 | }; 8 | 9 | const getJSON = (endpoint: string, successCallback: (embed: Embed) => void) => { 10 | const request = new XMLHttpRequest(); 11 | request.open("GET", endpoint, true); 12 | request.setRequestHeader("Content-Type", "application/json; charset=UTF-8"); 13 | request.onload = () => { 14 | if (request.status >= 200 && request.status < 400) { 15 | successCallback(JSON.parse(request.responseText)); 16 | } 17 | }; 18 | request.send(null); 19 | }; 20 | 21 | /* global EMBEDLY_API_KEY */ 22 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 23 | // @ts-ignore 24 | const key = typeof EMBEDLY_API_KEY === "undefined" ? "key" : EMBEDLY_API_KEY; 25 | const EMBEDLY_ENDPOINT = `https://api.embedly.com/1/oembed?key=${key}`; 26 | 27 | const get = (url: string, callback: (embed: Embed) => void) => { 28 | getJSON(`${EMBEDLY_ENDPOINT}&url=${encodeURIComponent(url)}`, callback); 29 | }; 30 | 31 | export default { 32 | get, 33 | }; 34 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleNameMapper: { 3 | "\\.scss$": "/tests/styleMock.js", 4 | }, 5 | moduleFileExtensions: ["js", "ts", "tsx", "json"], 6 | transform: { 7 | "\\.(js|ts|tsx)$": "ts-jest", 8 | }, 9 | testEnvironment: "jsdom", 10 | collectCoverageFrom: ["/src/**/*.{js,ts,tsx}"], 11 | testPathIgnorePatterns: ["/node_modules/", "/dist/", "/es/", "/integration/"], 12 | coveragePathIgnorePatterns: ["/tests", "/examples/", "/.storybook/"], 13 | snapshotSerializers: ["enzyme-to-json/serializer"], 14 | setupFiles: ["/tests/environment.js"], 15 | setupFilesAfterEnv: ["/tests/setupTest.js"], 16 | globals: { 17 | "ts-jest": { 18 | isolatedModules: true, 19 | }, 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // See https://prettier.io/docs/en/options.html. 2 | module.exports = { 3 | printWidth: 80, 4 | tabWidth: 2, 5 | useTabs: false, 6 | semi: true, 7 | singleQuote: false, 8 | trailingComma: "all", 9 | bracketSpacing: true, 10 | arrowParens: "always", 11 | proseWrap: "preserve", 12 | }; 13 | -------------------------------------------------------------------------------- /public/examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Redirecting… 5 | 6 | 10 | 11 |

Redirecting…

12 | Click here if you are not redirected. 15 | 18 | 19 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Redirecting… 5 | 6 | 7 | 8 |

Redirecting…

9 | Click here if you are not redirected. 10 | 13 | 14 | -------------------------------------------------------------------------------- /public/static/example-lowres-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/springload/draftail/3569db9cc4d4537d0e756a34117192270349d2c4/public/static/example-lowres-image.jpg -------------------------------------------------------------------------------- /public/static/example-lowres-image2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/springload/draftail/3569db9cc4d4537d0e756a34117192270349d2c4/public/static/example-lowres-image2.jpg -------------------------------------------------------------------------------- /public/static/icomoon/icomoon.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "icomoon"; 3 | src: url("data:application/font-woff;base64,d09GRgABAAAAAAYoAAsAAAAABdwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABPUy8yAAABCAAAAGAAAABgDxIGlGNtYXAAAAFoAAAAXAAAAFzqUenYZ2FzcAAAAcQAAAAIAAAACAAAABBnbHlmAAABzAAAAhAAAAIQqZIUPmhlYWQAAAPcAAAANgAAADYP4RNbaGhlYQAABBQAAAAkAAAAJAfCA8dobXR4AAAEOAAAABgAAAAYDgAAQGxvY2EAAARQAAAADgAAAA4BMAC+bWF4cAAABGAAAAAgAAAAIAANAGZuYW1lAAAEgAAAAYYAAAGGmUoJ+3Bvc3QAAAYIAAAAIAAAACAAAwAAAAMDVQGQAAUAAAKZAswAAACPApkCzAAAAesAMwEJAAAAAAAAAAAAAAAAAAAAARAAAAAAAAAAAAAAAAAAAAAAQAAA6hUDwP/AAEADwABAAAAAAQAAAAAAAAAAAAAAIAAAAAAAAwAAAAMAAAAcAAEAAwAAABwAAwABAAAAHAAEAEAAAAAMAAgAAgAEAAEAIOkm6hX//f//AAAAAAAg6SbqFf/9//8AAf/jFt4V8AADAAEAAAAAAAAAAAAAAAAAAQAB//8ADwABAAAAAAAAAAAAAgAANzkBAAAAAAEAAAAAAAAAAAACAAA3OQEAAAAAAQAAAAAAAAAAAAIAADc5AQAAAAAGAED/wAPAA8AAGQAhADkARwBVAGMAAAEuAScuAScuASMhIgYVERQWMyEyNjURNCYnJx4BFyM1HgETFAYjISImNRE0NjMwMzoBMzIxFRQWOwEDISImNTQ2MyEyFhUUBichIiY1NDYzITIWFRQGJyEiJjU0NjMhMhYVFAYDlhEtGRozFycpC/4QIS8vIQLgIS8OHIUXJQ2aESmGCQf9IAcJCQdNTrpNThMN4KD+QA0TEw0BwA0TEw3+QA0TEw0BwA0TEw3+QA0TEw0BwA0TEwLbFzMaGS0RHA4vIfygIS8vIQJwCyknNhcpEZoNJfzoBwkJBwNgBwngDRP+ABMNDRMTDQ0TgBMNDRMTDQ0TgBMNDRMTDQ0TAAAAAwAA/8AEAAPAABsANwA6AAABIgcOAQcGFRQXHgEXFjMyNz4BNzY1NCcuAScmAyInLgEnJjU0Nz4BNzYzMhceARcWFRQHDgEHBgMNAQIAal1eiygoKCiLXl1qal1eiygoKCiLXl1qVkxMcSAhISBxTExWVkxMcSAhISBxTEzWAYD+gAPAKCiLXl1qal1eiygoKCiLXl1qal1eiygo/GAhIHFMTFZWTExxICEhIHFMTFZWTExxICECgODgAAABAAAAAAAAd+/D/18PPPUACwQAAAAAANZhZ28AAAAA1mFnbwAA/8AEAAPAAAAACAACAAAAAAAAAAEAAAPA/8AAAAQAAAAAAAQAAAEAAAAAAAAAAAAAAAAAAAAGBAAAAAAAAAAAAAAAAgAAAAQAAEAEAAAAAAAAAAAKABQAHgCqAQgAAAABAAAABgBkAAYAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAADgCuAAEAAAAAAAEABwAAAAEAAAAAAAIABwBgAAEAAAAAAAMABwA2AAEAAAAAAAQABwB1AAEAAAAAAAUACwAVAAEAAAAAAAYABwBLAAEAAAAAAAoAGgCKAAMAAQQJAAEADgAHAAMAAQQJAAIADgBnAAMAAQQJAAMADgA9AAMAAQQJAAQADgB8AAMAAQQJAAUAFgAgAAMAAQQJAAYADgBSAAMAAQQJAAoANACkaWNvbW9vbgBpAGMAbwBtAG8AbwBuVmVyc2lvbiAxLjAAVgBlAHIAcwBpAG8AbgAgADEALgAwaWNvbW9vbgBpAGMAbwBtAG8AbwBuaWNvbW9vbgBpAGMAbwBtAG8AbwBuUmVndWxhcgBSAGUAZwB1AGwAYQByaWNvbW9vbgBpAGMAbwBtAG8AbwBuRm9udCBnZW5lcmF0ZWQgYnkgSWNvTW9vbi4ARgBvAG4AdAAgAGcAZQBuAGUAcgBhAHQAZQBkACAAYgB5ACAASQBjAG8ATQBvAG8AbgAuAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==") 4 | format("woff"); 5 | font-weight: normal; 6 | font-style: normal; 7 | } 8 | 9 | [class^="icon-"], 10 | [class*=" icon-"] { 11 | /* use !important to prevent issues with browser extensions that change fonts */ 12 | font-family: "icomoon" !important; 13 | speak: none; 14 | font-style: normal; 15 | font-weight: normal; 16 | font-variant: normal; 17 | text-transform: none; 18 | line-height: 1; 19 | } 20 | 21 | .icon-document:before { 22 | content: "\e926"; 23 | } 24 | .icon-embed:before { 25 | content: "\ea15"; 26 | } 27 | -------------------------------------------------------------------------------- /public/static/icomoon/selection.json: -------------------------------------------------------------------------------- 1 | { 2 | "IcoMoonType": "selection", 3 | "icons": [ 4 | { 5 | "icon": { 6 | "paths": [ 7 | "M917.806 229.076c-22.212-30.292-53.174-65.7-87.178-99.704s-69.412-64.964-99.704-87.178c-51.574-37.82-76.592-42.194-90.924-42.194h-496c-44.112 0-80 35.888-80 80v864c0 44.112 35.888 80 80 80h736c44.112 0 80-35.888 80-80v-624c0-14.332-4.372-39.35-42.194-90.924zM785.374 174.626c30.7 30.7 54.8 58.398 72.58 81.374h-153.954v-153.946c22.984 17.78 50.678 41.878 81.374 72.572zM896 944c0 8.672-7.328 16-16 16h-736c-8.672 0-16-7.328-16-16v-864c0-8.672 7.328-16 16-16 0 0 495.956-0.002 496 0v224c0 17.672 14.326 32 32 32h224v624z", 8 | "M736 832h-448c-17.672 0-32-14.326-32-32s14.328-32 32-32h448c17.674 0 32 14.326 32 32s-14.326 32-32 32z", 9 | "M736 704h-448c-17.672 0-32-14.326-32-32s14.328-32 32-32h448c17.674 0 32 14.326 32 32s-14.326 32-32 32z", 10 | "M736 576h-448c-17.672 0-32-14.326-32-32s14.328-32 32-32h448c17.674 0 32 14.326 32 32s-14.326 32-32 32z" 11 | ], 12 | "attrs": [], 13 | "isMulticolor": false, 14 | "isMulticolor2": false, 15 | "tags": ["file-text", "file", "document", "list", "paper", "page"], 16 | "defaultCode": 59686, 17 | "grid": 16 18 | }, 19 | "attrs": [], 20 | "properties": { 21 | "ligatures": "file-text2, file4", 22 | "name": "document", 23 | "id": 38, 24 | "order": 2, 25 | "prevSize": 32, 26 | "code": 59686 27 | }, 28 | "setIdx": 0, 29 | "setId": 1, 30 | "iconIdx": 38 31 | }, 32 | { 33 | "icon": { 34 | "paths": [ 35 | "M512 0c-282.77 0-512 229.23-512 512s229.23 512 512 512 512-229.23 512-512-229.23-512-512-512zM512 928c-229.75 0-416-186.25-416-416s186.25-416 416-416 416 186.25 416 416-186.25 416-416 416zM384 288l384 224-384 224z" 36 | ], 37 | "attrs": [], 38 | "isMulticolor": false, 39 | "isMulticolor2": false, 40 | "tags": ["play", "player"], 41 | "defaultCode": 59925, 42 | "grid": 16 43 | }, 44 | "attrs": [], 45 | "properties": { 46 | "ligatures": "play2, player", 47 | "name": "embed", 48 | "id": 277, 49 | "order": 3, 50 | "prevSize": 32, 51 | "code": 59925 52 | }, 53 | "setIdx": 0, 54 | "setId": 1, 55 | "iconIdx": 277 56 | } 57 | ], 58 | "height": 1024, 59 | "metadata": { 60 | "name": "icomoon" 61 | }, 62 | "preferences": { 63 | "showGlyphs": true, 64 | "showCodes": true, 65 | "showQuickUse": true, 66 | "showQuickUse2": true, 67 | "showSVGs": true, 68 | "fontPref": { 69 | "prefix": "icon-", 70 | "metadata": { 71 | "fontFamily": "icomoon" 72 | }, 73 | "metrics": { 74 | "emSize": 1024, 75 | "baseline": 6.25, 76 | "whitespace": 50 77 | }, 78 | "embed": false 79 | }, 80 | "imagePref": { 81 | "prefix": "icon-", 82 | "png": true, 83 | "useClassSelector": true, 84 | "color": 0, 85 | "bgColor": 16777215 86 | }, 87 | "historySize": 50 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | import dts from "rollup-plugin-dts"; 3 | 4 | import pkg from "./package.json"; 5 | 6 | const config = [ 7 | { 8 | input: "./src/index.ts", 9 | external: [ 10 | "react/jsx-runtime", 11 | "draft-js/lib/isSoftNewlineEvent", 12 | "draft-js/lib/DraftEditorBlock.react", 13 | ] 14 | .concat(Object.keys(pkg.dependencies)) 15 | .concat(Object.keys(pkg.peerDependencies)), 16 | output: [ 17 | { file: pkg.main, format: "cjs" }, 18 | { file: pkg.module, format: "es" }, 19 | ], 20 | plugins: [typescript()], 21 | }, 22 | { 23 | input: "./src/index.ts", 24 | output: [{ file: pkg.types, format: "es" }], 25 | plugins: [dts()], 26 | }, 27 | ]; 28 | 29 | export default config; 30 | -------------------------------------------------------------------------------- /src/api/_constants.scss: -------------------------------------------------------------------------------- 1 | @use "sass:color"; 2 | 3 | // Namespace for all classes, reduces the risk of style clashes. 4 | $DRAFTAIL: "Draftail-"; 5 | 6 | // Overridable variables meant to be used for theming, and easier integration. 7 | 8 | $draftail-base-spacing: 0.25rem !default; 9 | 10 | $button-spacing: $draftail-base-spacing * 2; 11 | $controls-spacing: $draftail-base-spacing; 12 | $draftail-editor-padding: $button-spacing + $controls-spacing !default; 13 | 14 | $color-white: #fff; 15 | $color-black: #000; 16 | $color-grey: #333; 17 | // As-light-as-allowed grey for placeholder text. 18 | $color-grey-757575: #757575; 19 | $color-light-grey: #ddd; 20 | 21 | $draftail-editor-text: $color-grey !default; 22 | $draftail-editor-background: $color-white !default; 23 | $draftail-editor-readonly-opacity: 0.5 !default; 24 | $draftail-editor-chrome: $color-light-grey !default; 25 | $draftail-editor-chrome-text: $color-grey !default; 26 | $draftail-editor-chrome-active: $color-black !default; 27 | $draftail-editor-chrome-accent: color.adjust( 28 | $color: $draftail-editor-chrome, 29 | $lightness: -10%, 30 | ) !default; 31 | $draftail-tooltip-chrome: $color-grey !default; 32 | $draftail-tooltip-chrome-text: $color-white !default; 33 | 34 | $draftail-editor-font-family: sans-serif !default; 35 | $draftail-editor-font-size: 1rem !default; 36 | $draftail-editor-line-height: 1.5 !default; 37 | 38 | $draftail-placeholder-text: $color-grey-757575 !default; 39 | 40 | $draftail-editor-border: 1px solid $draftail-editor-chrome !default; 41 | $draftail-contrast-outline: var( 42 | --draftail-contrast-outline, 43 | 2px solid transparent 44 | ) !default; 45 | $draftail-contrast-outline-modal: var( 46 | --draftail-contrast-outline-modal, 47 | 10px solid transparent 48 | ) !default; 49 | 50 | $draftail-editor-radius: 5px !default; 51 | $draftail-tooltip-radius: 5px !default; 52 | $draftail-toolbar-radius: 0 !default; 53 | $draftail-toolbar-tooltip-radius: 4px !default; 54 | $draftail-toolbar-border-bottom: $draftail-editor-border !default; 55 | 56 | $draftail-toolbar-tooltip-duration: 0.1s !default; 57 | $draftail-toolbar-tooltip-delay: 1s !default; 58 | 59 | $draftail-block-spacing: $draftail-base-spacing * 2 !default; 60 | 61 | $draftail-editor-z-index: 1 !default; 62 | $draftail-tooltip-z-index: $draftail-editor-z-index + 10 !default; 63 | $draftail-overlay-z-index: $draftail-tooltip-z-index + 10 !default; 64 | $draftail-toolbar-z-index: $draftail-overlay-z-index + 10 !default; 65 | $draftail-toolbar-tooltip-z-index: $draftail-toolbar-z-index + 10 !default; 66 | $draftail-page-overlay-z-index: $draftail-toolbar-z-index + 10 !default; 67 | 68 | @mixin font-smoothing { 69 | -webkit-font-smoothing: antialiased; 70 | -moz-osx-font-smoothing: grayscale; 71 | } 72 | 73 | // This makes it possible to add styles to rich text content within the editor, 74 | // without Draftail users having to know exactly which Draft.js class to use. 75 | @mixin draftail-richtext-styles { 76 | .#{$DRAFTAIL}Editor .DraftEditor-editorContainer { 77 | @content; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/api/ui.ts: -------------------------------------------------------------------------------- 1 | import { DESCRIPTIONS, KnownFormatType, LABELS } from "./constants"; 2 | import { BoolControl, Control } from "./types"; 3 | 4 | export const getControlLabel = ( 5 | type: string | undefined, 6 | config: BoolControl, 7 | ) => { 8 | const predefinedType = type as KnownFormatType; 9 | 10 | if (typeof config === "boolean") { 11 | return LABELS[predefinedType]; 12 | } 13 | 14 | if (typeof config.label === "string" || config.label === null) { 15 | return config.label; 16 | } 17 | 18 | if (typeof config.icon !== "undefined") { 19 | return null; 20 | } 21 | 22 | return LABELS[predefinedType]; 23 | }; 24 | 25 | export const getControlDescription = (control: Control) => { 26 | const predefinedType = control.type as KnownFormatType; 27 | const useDefaultDescription = typeof control.description === "undefined"; 28 | const defaultDescription = DESCRIPTIONS[predefinedType]; 29 | const description = useDefaultDescription 30 | ? defaultDescription 31 | : control.description; 32 | const useDefaultLabel = typeof control.label === "undefined"; 33 | const defaultLabel = LABELS[predefinedType]; 34 | const label = useDefaultLabel ? defaultLabel : control.label; 35 | 36 | return description || label; 37 | }; 38 | 39 | export const getControlSearchFields = (control: Control) => [ 40 | control.label || "", 41 | control.description || "", 42 | control.type ? LABELS[control.type as KnownFormatType] : "", 43 | control.type ? DESCRIPTIONS[control.type as KnownFormatType] : "", 44 | control.type || "", 45 | ]; 46 | 47 | export const showControl = (config: Control) => 48 | Boolean(config.icon) || Boolean(getControlLabel(config.type, config)); 49 | 50 | export const showControlDesc = (config: Control) => 51 | showControl(config) || Boolean(getControlDescription(config)); 52 | -------------------------------------------------------------------------------- /src/blocks/DividerBlock.scss: -------------------------------------------------------------------------------- 1 | @use "../api/constants" as *; 2 | 3 | .#{$DRAFTAIL}DividerBlock { 4 | border: 0; 5 | background: $color-light-grey; 6 | height: 1px; 7 | margin: 10px 0; 8 | 9 | @media (forced-colors: active) { 10 | background: CanvasText; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/blocks/DividerBlock.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import DividerBlock from "./DividerBlock"; 4 | 5 | describe("DividerBlock", () => { 6 | it("basic", () => { 7 | expect(shallow()).toMatchSnapshot(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/blocks/DividerBlock.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | /** 4 | * An
in the editor. 5 | */ 6 | const DividerBlock = () =>
; 7 | 8 | export default DividerBlock; 9 | -------------------------------------------------------------------------------- /src/blocks/__snapshots__/DividerBlock.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`DividerBlock basic 1`] = ` 4 |
7 | `; 8 | -------------------------------------------------------------------------------- /src/components/ComboBox/ComboBox.scss: -------------------------------------------------------------------------------- 1 | @use "../../api/constants" as *; 2 | 3 | @mixin sr-only() { 4 | position: absolute; 5 | width: 1px; 6 | height: 1px; 7 | padding: 0; 8 | margin: -1px; 9 | overflow: hidden; 10 | clip: rect(0, 0, 0, 0); 11 | white-space: nowrap; 12 | border: 0; 13 | } 14 | 15 | .#{$DRAFTAIL}ComboBox { 16 | min-width: 240px; 17 | background: $draftail-editor-background; 18 | color: $draftail-editor-text; 19 | border-radius: $draftail-tooltip-radius; 20 | font-size: $draftail-editor-font-size; 21 | box-shadow: 5px 5px 20px rgba(0, 0, 0, 0.1); 22 | outline: $draftail-contrast-outline-modal; 23 | } 24 | 25 | .#{$DRAFTAIL}ComboBox__label { 26 | @include sr-only(); 27 | } 28 | 29 | .#{$DRAFTAIL}ComboBox__field { 30 | padding: 10px; 31 | } 32 | 33 | .#{$DRAFTAIL}ComboBox--inline input[disabled] { 34 | @include sr-only(); 35 | } 36 | 37 | .#{$DRAFTAIL}ComboBox [aria-autocomplete="list"] { 38 | width: 100%; 39 | padding: 7px 20px; 40 | line-height: 1.5; 41 | border: $draftail-editor-border; 42 | border-radius: $draftail-editor-radius; 43 | 44 | &::placeholder { 45 | color: $draftail-placeholder-text; 46 | } 47 | } 48 | 49 | .#{$DRAFTAIL}ComboBox__optgroup-label { 50 | padding: 10px; 51 | font-size: 1rem; 52 | font-weight: 700; 53 | border-top: $draftail-contrast-outline; 54 | 55 | @media (forced-colors: active) { 56 | color: GrayText; 57 | } 58 | } 59 | 60 | .#{$DRAFTAIL}ComboBox__menu { 61 | max-height: 70vh; 62 | overflow-y: scroll; 63 | } 64 | 65 | .#{$DRAFTAIL}ComboBox__option { 66 | cursor: pointer; 67 | } 68 | 69 | .#{$DRAFTAIL}ComboBox__status, 70 | .#{$DRAFTAIL}ComboBox__option { 71 | padding: 10px; 72 | border: $draftail-contrast-outline; 73 | font-size: 0.875rem; 74 | font-weight: 700; 75 | line-height: 1.4; 76 | display: flex; 77 | align-items: center; 78 | 79 | &[aria-selected="true"] { 80 | background-color: $color-light-grey; 81 | border-color: currentColor; 82 | 83 | @media (forced-colors: active) { 84 | background: Highlight; 85 | color: HighlightText; 86 | } 87 | } 88 | } 89 | 90 | .#{$DRAFTAIL}ComboBox__option-icon { 91 | width: 1.25rem; 92 | height: 1.25rem; 93 | margin-inline-start: 6px; 94 | margin-inline-end: 6px; 95 | } 96 | 97 | .#{$DRAFTAIL}ComboBox__option-icon, 98 | .#{$DRAFTAIL}ComboBox__option-text { 99 | // Force the text to CanvasText as forced colors automatically 100 | // adds a Canvas outline behind text. 101 | @media (forced-colors: active) { 102 | color: CanvasText; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/components/ComboBox/findMatches.ts: -------------------------------------------------------------------------------- 1 | // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator/Collator. 2 | const collator = new Intl.Collator(undefined, { 3 | usage: "search", 4 | sensitivity: "base", 5 | ignorePunctuation: true, 6 | }); 7 | 8 | /** 9 | * See https://github.com/adobe/react-spectrum/blob/70e769acf639fc4ef3a704cb8fad81349cb4137a/packages/%40react-aria/i18n/src/useFilter.ts#L57. 10 | * See also https://github.com/arty-name/locale-index-of, 11 | * and https://github.com/tc39/ecma402/issues/506. 12 | */ 13 | const contains = (string: string, substring: string) => { 14 | if (substring.length === 0) { 15 | return true; 16 | } 17 | 18 | const haystack = string.normalize("NFC"); 19 | const needle = substring.normalize("NFC"); 20 | 21 | for (let scan = 0; scan + needle.length <= haystack.length; scan += 1) { 22 | const slice = haystack.slice(scan, scan + needle.length); 23 | if (collator.compare(needle, slice) === 0) { 24 | return true; 25 | } 26 | } 27 | 28 | return false; 29 | }; 30 | 31 | /** 32 | * Find all items where the label or description matches the inputValue. 33 | */ 34 | const findMatches = ( 35 | items: T[], 36 | getSearchFields: (item: T) => string[], 37 | input: string, 38 | ) => 39 | items.filter((item) => { 40 | const matches = getSearchFields(item); 41 | 42 | return matches.some((match) => match && contains(match, input)); 43 | }); 44 | 45 | export default findMatches; 46 | -------------------------------------------------------------------------------- /src/components/DraftailEditor.scss: -------------------------------------------------------------------------------- 1 | @use "../api/constants" as *; 2 | 3 | .#{$DRAFTAIL}Editor { 4 | --draftail-offset-inline-start: 2rem; 5 | --draftail-text-direction: 1; 6 | 7 | background-color: $draftail-editor-background; 8 | border: $draftail-editor-border; 9 | border-radius: $draftail-editor-radius; 10 | position: relative; 11 | 12 | &[dir="rtl"], 13 | [dir="rtl"] & { 14 | --draftail-text-direction: -1; 15 | } 16 | 17 | &--readonly { 18 | pointer-events: none; 19 | 20 | .DraftEditor-editorContainer { 21 | opacity: $draftail-editor-readonly-opacity; 22 | 23 | &::before { 24 | content: ""; 25 | display: block; 26 | position: absolute; 27 | top: 0; 28 | inset-inline-start: 0; 29 | height: 100%; 30 | width: 100%; 31 | z-index: $draftail-overlay-z-index; 32 | } 33 | } 34 | } 35 | 36 | .DraftEditor-root { 37 | color: $draftail-editor-text; 38 | font-size: $draftail-editor-font-size; 39 | line-height: $draftail-editor-line-height; 40 | font-family: $draftail-editor-font-family; 41 | // Ligatures can make cursor behavior harder to understand. 42 | font-variant-ligatures: none; 43 | // Fix editor scrolling in the wrong position when breaking a big block. 44 | // See https://github.com/facebook/draft-js/issues/304#issuecomment-327606596. 45 | overflow: auto; 46 | } 47 | 48 | .public-DraftEditor-content, 49 | .public-DraftEditorPlaceholder-root { 50 | padding: $draftail-editor-padding; 51 | } 52 | 53 | // Remove default margins on atomic blocks because of the figure element. 54 | .public-DraftEditor-content > * > figure { 55 | margin: 0; 56 | } 57 | 58 | .public-DraftEditorPlaceholder-inner { 59 | color: $draftail-placeholder-text; 60 | } 61 | } 62 | 63 | // Give each block some spacing so we don't end up with empty paragraphs 64 | // in code when user double enters because they think there are no paragraphs. 65 | // Also add the same styles to the placeholder for alignment. 66 | .#{$DRAFTAIL}block--unstyled, 67 | .#{$DRAFTAIL}Editor .public-DraftEditorPlaceholder-inner { 68 | margin: $draftail-block-spacing 0; 69 | } 70 | -------------------------------------------------------------------------------- /src/components/Icon.scss: -------------------------------------------------------------------------------- 1 | @use "../api/constants" as *; 2 | 3 | .#{$DRAFTAIL}Icon { 4 | fill: currentColor; 5 | pointer-events: none; 6 | vertical-align: middle; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/Icon.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import Icon from "./Icon"; 4 | 5 | const SQUARE = "M10 10 H 90 V 90 H 10 Z"; 6 | 7 | const CustomIcon = ({ icon }: { icon: string }) => ( 8 | 9 | ); 10 | 11 | describe("Icon", () => { 12 | it("#className", () => { 13 | expect( 14 | shallow(), 15 | ).toMatchSnapshot(); 16 | }); 17 | 18 | it("#title", () => { 19 | expect( 20 | shallow(), 21 | ).toMatchSnapshot(); 22 | }); 23 | 24 | describe("#icon", () => { 25 | it("svg path", () => { 26 | expect(shallow()).toMatchSnapshot(); 27 | }); 28 | 29 | it("svg path array", () => { 30 | expect(shallow()).toMatchSnapshot(); 31 | }); 32 | 33 | it("inline svg ref", () => { 34 | expect(shallow()).toMatchSnapshot(); 35 | }); 36 | 37 | it("external svg ref", () => { 38 | expect(shallow()).toMatchSnapshot(); 39 | }); 40 | 41 | it("custom", () => { 42 | expect( 43 | shallow(} />), 44 | ).toMatchSnapshot(); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { IconProp } from "../api/types"; 3 | 4 | export interface IconProps { 5 | icon?: IconProp; 6 | title?: string | null; 7 | className?: string | null; 8 | } 9 | 10 | export { IconProp }; 11 | 12 | /** 13 | * Icon as SVG element. Can optionally render a React element instead. 14 | */ 15 | const Icon = ({ icon, title, className }: IconProps): JSX.Element => { 16 | let children; 17 | 18 | if (typeof icon === "string") { 19 | if (icon.includes("#")) { 20 | children = ; 21 | } else { 22 | children = ; 23 | } 24 | } else if (Array.isArray(icon)) { 25 | // eslint-disable-next-line react/no-array-index-key 26 | children = icon.map((d, i) => ); 27 | } else { 28 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 29 | // @ts-expect-error 30 | return icon; 31 | } 32 | 33 | return ( 34 | 43 | {children} 44 | 45 | ); 46 | }; 47 | 48 | export default Icon; 49 | -------------------------------------------------------------------------------- /src/components/ListNestingStyles.tsx: -------------------------------------------------------------------------------- 1 | import { getListNestingStyles } from "draftjs-conductor"; 2 | import React from "react"; 3 | 4 | interface ListNestingStylesProps { 5 | max?: number; 6 | } 7 | 8 | /** 9 | * Generates CSS styles for list items, for a given depth. 10 | */ 11 | function Styles({ max }: ListNestingStylesProps) { 12 | return max ? : null; 13 | } 14 | 15 | const ListNestingStyles = React.memo(Styles); 16 | 17 | export default ListNestingStyles; 18 | -------------------------------------------------------------------------------- /src/components/PlaceholderStyles/PlaceholderStyles.scss: -------------------------------------------------------------------------------- 1 | @use "../../api/constants" as *; 2 | 3 | // Avoid styling list items as they commonly have markers in the ::before pseudo element. 4 | .#{$DRAFTAIL}block--empty:not([class*="-list-item"])::before { 5 | position: absolute; 6 | pointer-events: none; 7 | user-select: none; 8 | color: $draftail-placeholder-text; 9 | 10 | @media (forced-colors: active) { 11 | color: GrayText; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/PlaceholderStyles/PlaceholderStyles.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | 4 | import PlaceholderStyles from "./PlaceholderStyles"; 5 | 6 | describe("PlaceholderStyles", () => { 7 | it("empty", () => { 8 | expect( 9 | shallow( 10 | , 15 | ), 16 | ).toMatchInlineSnapshot(` 56 | `); 57 | }); 58 | 59 | it("block descriptions", () => { 60 | expect( 61 | shallow( 62 | , 71 | ), 72 | ).toMatchInlineSnapshot(` 73 | 76 | `); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/components/PlaceholderStyles/PlaceholderStyles.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BLOCK_TYPE } from "../../api/constants"; 3 | import { BlockTypeControl } from "../../api/types"; 4 | import { getControlDescription } from "../../api/ui"; 5 | 6 | interface PlaceholderStylesProps { 7 | blockKey: string | null; 8 | blockTypes: ReadonlyArray; 9 | placeholder?: string | null; 10 | } 11 | 12 | /** 13 | * Adds placeholder "Write here…" text to the currently-focused block, and to empty non-list-item blocks. 14 | * Done with CSS so this can switch blocks without re-rendering the whole editor. 15 | */ 16 | function Styles({ blockKey, blockTypes, placeholder }: PlaceholderStylesProps) { 17 | let placeholderStyle = ""; 18 | if (blockKey && placeholder) { 19 | placeholderStyle = `.Draftail-block--unstyled.Draftail-block--empty[data-offset-key="${blockKey}-0-0"]::before { content: "${placeholder}"; }`; 20 | } 21 | 22 | const blockPlaceholders = blockTypes 23 | .map((blockType) => { 24 | // Skips paragraph blocks as they are too common and don't need a placeholder, 25 | // and list items blocks as the placeholder clashes with list markers. 26 | if ( 27 | blockType.type === BLOCK_TYPE.UNSTYLED || 28 | blockType.type.endsWith("-list-item") 29 | ) { 30 | return ""; 31 | } 32 | 33 | const description = getControlDescription(blockType); 34 | return description 35 | ? `.Draftail-block--${blockType.type}.Draftail-block--empty::before { content: "${description}"; }` 36 | : ""; 37 | }) 38 | .join(""); 39 | 40 | return ; 41 | } 42 | 43 | const PlaceholderStyles = React.memo(Styles); 44 | 45 | export default PlaceholderStyles; 46 | -------------------------------------------------------------------------------- /src/components/Toolbar/BlockToolbar/BlockToolbar.scss: -------------------------------------------------------------------------------- 1 | @use "../../../api/constants" as *; 2 | 3 | .#{$DRAFTAIL}BlockToolbar { 4 | // stylelint-disable-next-line property-disallowed-list 5 | float: left; 6 | 7 | [dir="rtl"] & { 8 | // stylelint-disable-next-line property-disallowed-list 9 | float: right; 10 | } 11 | 12 | ~ .#{$DRAFTAIL}Toolbar, 13 | ~ .DraftEditor-root { 14 | margin-inline-start: var(--draftail-offset-inline-start); 15 | } 16 | } 17 | 18 | .#{$DRAFTAIL}BlockToolbar__trigger { 19 | appearance: none; 20 | background: $draftail-editor-background; 21 | color: inherit; 22 | border-radius: 50%; 23 | border: 1px solid currentColor; 24 | width: 1.5rem; 25 | height: 1.5rem; 26 | padding: 0; 27 | margin: 0; 28 | margin-inline-end: 0.5rem; 29 | display: grid; 30 | align-items: center; 31 | justify-content: center; 32 | // top offset will be set with JS. 33 | position: absolute; 34 | // Make sure the trigger is horizontally centred. 35 | transform: translateY(-50%); 36 | visibility: hidden; 37 | 38 | .#{$DRAFTAIL}Editor--focus &, 39 | .#{$DRAFTAIL}Editor:focus-within &, 40 | &:hover, 41 | &:focus { 42 | visibility: visible; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/components/Toolbar/FloatingToolbar/FloatingToolbar.scss: -------------------------------------------------------------------------------- 1 | @use "../../../api/constants" as *; 2 | 3 | .#{$DRAFTAIL}FloatingToolbar { 4 | padding: $controls-spacing; 5 | background-color: $draftail-editor-chrome; 6 | color: $draftail-editor-chrome-text; 7 | border-radius: $draftail-toolbar-radius; 8 | border: $draftail-toolbar-border-bottom; 9 | outline: $draftail-contrast-outline-modal; 10 | } 11 | 12 | .#{$DRAFTAIL}FloatingToolbar__target { 13 | position: absolute; 14 | pointer-events: none; 15 | visibility: hidden; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/Toolbar/FloatingToolbar/FloatingToolbar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { getVisibleSelectionRect } from "draft-js"; 4 | 5 | import Tooltip, { TooltipPlacement } from "../../Tooltip/Tooltip"; 6 | import { ToolbarProps } from "../Toolbar"; 7 | import ToolbarDefaults from "../ToolbarDefaults"; 8 | import ToolbarGroup from "../ToolbarGroup"; 9 | 10 | export interface FloatingToolbarProps extends ToolbarProps { 11 | tooltipPlacement?: TooltipPlacement; 12 | tooltipZIndex?: number; 13 | className?: string; 14 | } 15 | 16 | /** 17 | * Position the tooltip according to the current selection, relative to the editor, with an offset. 18 | */ 19 | const getTargetPosition = (editorRect: DOMRect) => { 20 | const clientRect = getVisibleSelectionRect(window); 21 | if (clientRect) { 22 | return { 23 | top: clientRect.top - editorRect.top, 24 | left: `calc(${ 25 | clientRect.left - editorRect.left 26 | }px + var(--draftail-offset-inline-start, 0))`, 27 | }; 28 | } 29 | 30 | return null; 31 | }; 32 | 33 | const FloatingToolbar = ({ 34 | controls, 35 | getEditorState, 36 | onChange, 37 | tooltipZIndex = 100, 38 | tooltipPlacement = "top" as TooltipPlacement, 39 | className, 40 | ...otherProps 41 | }: FloatingToolbarProps) => { 42 | const editorState = getEditorState(); 43 | const selection = editorState.getSelection(); 44 | 45 | return ( 46 | 56 | {/* eslint-disable-next-line react/jsx-props-no-spreading */} 57 | 58 | 59 | 60 | {controls.map((control, i) => { 61 | const Control = control.inline; 62 | 63 | if (!Control) { 64 | return null; 65 | } 66 | 67 | return ( 68 | 74 | ); 75 | })} 76 | 77 | 78 | } 79 | /> 80 | ); 81 | }; 82 | 83 | export default FloatingToolbar; 84 | -------------------------------------------------------------------------------- /src/components/Toolbar/FloatingToolbar/ToolbarDefaults.tsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | 3 | import ToolbarButton from "../ToolbarButton"; 4 | import ToolbarGroup from "../ToolbarGroup"; 5 | 6 | import { EntityTypeControl } from "../../../api/types"; 7 | import { getControlLabel, showControl } from "../../../api/ui"; 8 | import { getButtonTitle, ToolbarDefaultProps } from "../ToolbarDefaults"; 9 | 10 | const showEntityButton = (config: EntityTypeControl) => 11 | showControl(config) && Boolean(config.decorator); 12 | 13 | class ToolbarDefaults extends PureComponent { 14 | render() { 15 | const { 16 | currentStyles, 17 | currentBlock, 18 | blockTypes, 19 | inlineStyles, 20 | entityTypes, 21 | toggleBlockType, 22 | toggleInlineStyle, 23 | onRequestSource, 24 | } = this.props; 25 | return [ 26 | 27 | {inlineStyles.filter(showControl).map((t) => ( 28 | 37 | ))} 38 | , 39 | 40 | 41 | {blockTypes.filter(showControl).map((t) => ( 42 | 51 | ))} 52 | , 53 | 54 | 55 | {entityTypes.filter(showEntityButton).map((t) => ( 56 | 64 | ))} 65 | , 66 | ]; 67 | } 68 | } 69 | 70 | export default ToolbarDefaults; 71 | -------------------------------------------------------------------------------- /src/components/Toolbar/InlineToolbar.scss: -------------------------------------------------------------------------------- 1 | @use "../../api/constants" as *; 2 | 3 | $button-size: 1.5rem; 4 | 5 | .#{$DRAFTAIL}ToolbarButton--pin { 6 | position: absolute; 7 | top: 0; 8 | inset-inline-end: 0; 9 | padding: 0; 10 | min-width: 1.5rem; 11 | height: 1.5rem; 12 | color: $draftail-editor-chrome; 13 | background-color: $draftail-editor-chrome-text; 14 | // Remove once we drop support for Safari 14. 15 | // stylelint-disable-next-line property-disallowed-list 16 | border-top-left-radius: 0; 17 | border-start-start-radius: 0; 18 | // Remove once we drop support for Safari 14. 19 | // stylelint-disable-next-line property-disallowed-list 20 | border-bottom-right-radius: 0; 21 | border-end-end-radius: 0; 22 | border: $draftail-toolbar-border-bottom; 23 | 24 | .#{$DRAFTAIL}Icon { 25 | width: 0.75rem; 26 | height: 0.75rem; 27 | } 28 | 29 | .#{$DRAFTAIL}ToolbarButton__label { 30 | vertical-align: top; 31 | } 32 | 33 | &::before { 34 | margin-bottom: 2px; 35 | } 36 | 37 | &::after { 38 | margin-bottom: 8px; 39 | } 40 | } 41 | 42 | .#{$DRAFTAIL}Toolbar--pin { 43 | padding-inline-end: $button-size; 44 | } 45 | -------------------------------------------------------------------------------- /src/components/Toolbar/MetaToolbar.scss: -------------------------------------------------------------------------------- 1 | @use "../../api/constants" as *; 2 | 3 | .#{$DRAFTAIL}MetaToolbar { 4 | position: relative; 5 | display: flex; 6 | justify-content: flex-end; 7 | padding: $controls-spacing; 8 | background-color: $draftail-editor-background; 9 | color: $draftail-editor-chrome; 10 | border-top: $draftail-contrast-outline; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/Toolbar/MetaToolbar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { LegacyControlControl } from "../../api/types"; 4 | import { getControlLabel, showControl } from "../../api/ui"; 5 | import ToolbarButton from "./ToolbarButton"; 6 | import ToolbarGroup from "./ToolbarGroup"; 7 | import { getButtonTitle } from "./ToolbarDefaults"; 8 | import { ToolbarProps } from "./Toolbar"; 9 | 10 | export interface MetaToolbarProps extends ToolbarProps { 11 | showBlockEntities?: boolean; 12 | } 13 | 14 | const MetaToolbar = ({ 15 | showBlockEntities, 16 | entityTypes, 17 | controls, 18 | getEditorState, 19 | onChange, 20 | onRequestSource, 21 | }: MetaToolbarProps) => ( 22 |
23 | {showBlockEntities ? ( 24 | 25 | {entityTypes 26 | .filter((entityType) => showControl(entityType) && entityType.block) 27 | .map((t) => ( 28 | 36 | ))} 37 | 38 | ) : null} 39 | 40 | {controls.map((control, i) => { 41 | if (control.inline || control.block) { 42 | return null; 43 | } 44 | 45 | // Support the legacy and current controls APIs. 46 | const Control = control.meta || (control as LegacyControlControl); 47 | 48 | return ( 49 | 55 | ); 56 | })} 57 | 58 |
59 | ); 60 | 61 | export default MetaToolbar; 62 | -------------------------------------------------------------------------------- /src/components/Toolbar/Toolbar.scss: -------------------------------------------------------------------------------- 1 | @use "../../api/constants" as *; 2 | 3 | .#{$DRAFTAIL}Toolbar { 4 | position: relative; 5 | padding: $controls-spacing; 6 | background-color: $draftail-editor-chrome; 7 | color: $draftail-editor-chrome-text; 8 | border-radius: $draftail-toolbar-radius; 9 | border-bottom: $draftail-toolbar-border-bottom; 10 | 11 | .#{$DRAFTAIL}Editor--focus & { 12 | position: sticky; 13 | top: 0; 14 | z-index: $draftail-toolbar-z-index; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/Toolbar/Toolbar.test.tsx: -------------------------------------------------------------------------------- 1 | import { OrderedSet } from "immutable"; 2 | import React from "react"; 3 | import { EditorState } from "draft-js"; 4 | import { shallow } from "enzyme"; 5 | 6 | import Toolbar from "./Toolbar"; 7 | 8 | const mockProps = { 9 | currentStyles: OrderedSet(), 10 | currentBlock: "unstyled", 11 | currentBlockKey: "abcd", 12 | enableHorizontalRule: false, 13 | enableLineBreak: false, 14 | showUndoControl: false, 15 | showRedoControl: false, 16 | entityTypes: [], 17 | blockTypes: [], 18 | inlineStyles: [], 19 | commands: false, 20 | toggleBlockType: () => {}, 21 | toggleInlineStyle: () => {}, 22 | addHR: () => {}, 23 | addBR: () => {}, 24 | onUndoRedo: () => {}, 25 | onRequestSource: () => {}, 26 | onCompleteSource: () => {}, 27 | focus: () => {}, 28 | getEditorState: () => EditorState.createEmpty(), 29 | onChange: () => {}, 30 | controls: [], 31 | }; 32 | 33 | describe("Toolbar", () => { 34 | it("empty", () => { 35 | expect(shallow()).toMatchSnapshot(); 36 | }); 37 | 38 | it("#controls", () => { 39 | expect( 40 | shallow( 41 | Test }]} 44 | />, 45 | ), 46 | ).toMatchSnapshot(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/components/Toolbar/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { EditorState } from "draft-js"; 3 | 4 | import { ControlControl, LegacyControlControl } from "../../api/types"; 5 | 6 | import ToolbarDefaults, { ToolbarDefaultProps } from "./ToolbarDefaults"; 7 | import ToolbarGroup from "./ToolbarGroup"; 8 | 9 | export interface ToolbarProps extends ToolbarDefaultProps { 10 | controls: ReadonlyArray; 11 | getEditorState: () => EditorState; 12 | onChange: (state: EditorState) => void; 13 | className?: string; 14 | } 15 | 16 | const Toolbar = ({ 17 | controls, 18 | getEditorState, 19 | onChange, 20 | className, 21 | ...otherProps 22 | }: ToolbarProps) => ( 23 |
24 | {/* eslint-disable-next-line react/jsx-props-no-spreading */} 25 | 26 | 27 | 28 | {controls.map((control, i) => { 29 | if (control.meta) { 30 | return null; 31 | } 32 | 33 | const Control = 34 | control.block || control.inline || (control as LegacyControlControl); 35 | 36 | return ( 37 | 43 | ); 44 | })} 45 | 46 |
47 | ); 48 | 49 | export default Toolbar; 50 | -------------------------------------------------------------------------------- /src/components/Toolbar/ToolbarButton.scss: -------------------------------------------------------------------------------- 1 | @use "sass:color"; 2 | @use "../../api/constants" as *; 3 | 4 | $button-active-color: color.adjust( 5 | $color: $draftail-editor-chrome-active, 6 | $alpha: -0.9, 7 | ); 8 | $button-active-border-color: color.adjust( 9 | $color: $draftail-editor-chrome-active, 10 | $alpha: -0.8, 11 | ); 12 | $button-font-size: 1rem; 13 | 14 | .#{$DRAFTAIL}ToolbarButton { 15 | // Guarantee buttons are a predictable round-number size. 16 | $size: $button-font-size + $button-spacing * 2 + $draftail-base-spacing; 17 | @include font-smoothing(); 18 | 19 | display: inline-block; 20 | padding: $button-spacing; 21 | min-width: $size; 22 | max-height: $size; 23 | border-radius: $draftail-toolbar-radius; 24 | background: transparent; 25 | color: inherit; 26 | border: 1px solid transparent; 27 | outline: $draftail-contrast-outline; 28 | font-size: $button-font-size; 29 | font-weight: 600; 30 | cursor: pointer; 31 | user-select: none; 32 | 33 | &--active { 34 | background-color: $button-active-color; 35 | border: 1px solid $button-active-border-color; 36 | 37 | @media (forced-colors: active) { 38 | // Force the text to CanvasText as forced colors automatically 39 | // adds a Canvas outline behind text. 40 | background: Highlight; 41 | color: HighlightText; 42 | border-color: currentColor; 43 | } 44 | } 45 | 46 | &__label { 47 | display: inline-block; 48 | vertical-align: middle; 49 | height: 1em; 50 | 51 | @media (forced-colors: active) { 52 | color: CanvasText; 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/components/Toolbar/ToolbarButton.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import ToolbarButton from "./ToolbarButton"; 4 | 5 | describe("ToolbarButton", () => { 6 | it("empty", () => { 7 | expect(shallow()).toMatchSnapshot(); 8 | }); 9 | 10 | it("#name", () => { 11 | expect(shallow()).toMatchSnapshot(); 12 | }); 13 | 14 | it("#label", () => { 15 | expect(shallow()).toMatchSnapshot(); 16 | }); 17 | 18 | it("#icon", () => { 19 | expect(shallow()).toMatchSnapshot(); 20 | }); 21 | 22 | it("#active", () => { 23 | expect(shallow()).toMatchSnapshot(); 24 | }); 25 | 26 | it("#title", () => { 27 | expect(shallow()).toMatchSnapshot(); 28 | }); 29 | 30 | it("#onClick", () => { 31 | const onClick = jest.fn(); 32 | const event = { preventDefault: jest.fn() }; 33 | shallow().simulate("mousedown", event); 34 | expect(onClick).toHaveBeenCalledTimes(1); 35 | expect(event.preventDefault).toHaveBeenCalledTimes(1); 36 | }); 37 | 38 | it("stops showing tooltip on click", () => { 39 | const wrapper = shallow(); 40 | expect(wrapper.state("showTooltipOnHover")).toBe(true); 41 | 42 | wrapper.simulate("mousedown", { 43 | preventDefault: jest.fn(), 44 | }); 45 | expect(wrapper.state("showTooltipOnHover")).toBe(false); 46 | 47 | wrapper.simulate("mouseleave", null); 48 | expect(wrapper.state("showTooltipOnHover")).toBe(true); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/components/Toolbar/ToolbarButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import { IconProp } from "../../api/types"; 3 | import Icon from "../Icon"; 4 | 5 | export interface ToolbarButtonProps { 6 | name?: string; 7 | active?: boolean; 8 | label?: string | null; 9 | title?: string | null; 10 | icon?: IconProp | null; 11 | className?: string | null; 12 | tooltipDirection?: "up" | "down"; 13 | onClick?: ((name: string) => void) | null; 14 | } 15 | 16 | interface ToolbarButtonState { 17 | showTooltipOnHover: boolean; 18 | } 19 | /** 20 | * Displays a basic button, with optional active variant, 21 | * enriched with a tooltip. The tooltip stops showing on click. 22 | */ 23 | class ToolbarButton extends PureComponent< 24 | ToolbarButtonProps, 25 | ToolbarButtonState 26 | > { 27 | constructor(props: ToolbarButtonProps) { 28 | super(props); 29 | 30 | this.state = { 31 | showTooltipOnHover: true, 32 | }; 33 | 34 | this.onMouseDown = this.onMouseDown.bind(this); 35 | this.onMouseLeave = this.onMouseLeave.bind(this); 36 | } 37 | 38 | onMouseDown(e: React.MouseEvent) { 39 | const { name, onClick } = this.props; 40 | 41 | e.preventDefault(); 42 | 43 | this.setState({ 44 | showTooltipOnHover: false, 45 | }); 46 | 47 | if (onClick) { 48 | onClick(name || ""); 49 | } 50 | } 51 | 52 | onMouseLeave() { 53 | this.setState({ 54 | showTooltipOnHover: true, 55 | }); 56 | } 57 | 58 | render() { 59 | const { name, active, label, title, icon, className, tooltipDirection } = 60 | this.props; 61 | const { showTooltipOnHover } = this.state; 62 | const showTooltip = title && showTooltipOnHover; 63 | 64 | return ( 65 | 82 | ); 83 | } 84 | } 85 | 86 | export default ToolbarButton; 87 | -------------------------------------------------------------------------------- /src/components/Toolbar/ToolbarGroup.scss: -------------------------------------------------------------------------------- 1 | @use "../../api/constants" as *; 2 | 3 | .#{$DRAFTAIL}ToolbarGroup { 4 | display: inline-block; 5 | 6 | &::before { 7 | content: ""; 8 | display: inline-block; 9 | width: 0.0625rem; 10 | height: 1rem; 11 | vertical-align: middle; 12 | margin: 0 $controls-spacing * 0.5; 13 | background-color: $draftail-editor-chrome-accent; 14 | } 15 | 16 | &:first-of-type::before { 17 | display: none; 18 | } 19 | 20 | .#{$DRAFTAIL}Editor--readonly & { 21 | opacity: $draftail-editor-readonly-opacity; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/components/Toolbar/ToolbarGroup.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import ToolbarGroup from "./ToolbarGroup"; 4 | 5 | describe("ToolbarGroup", () => { 6 | it("children", () => { 7 | expect( 8 | shallow( 9 | 10 | Test child 11 | , 12 | ), 13 | ).toMatchSnapshot(); 14 | }); 15 | 16 | it("empty", () => { 17 | expect(shallow()).toMatchSnapshot(); 18 | }); 19 | 20 | it("empty children", () => { 21 | expect( 22 | shallow( 23 | 24 | {null} 25 | {null} 26 | , 27 | ), 28 | ).toMatchSnapshot(); 29 | }); 30 | 31 | it("empty array children", () => { 32 | expect( 33 | shallow({[]}), 34 | ).toMatchSnapshot(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/components/Toolbar/ToolbarGroup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface ToolbarGroupProps { 4 | name: string; 5 | children?: React.ReactNode; 6 | } 7 | 8 | const ToolbarGroup = ({ name, children }: ToolbarGroupProps) => { 9 | const hasChildren = React.Children.toArray(children).some((c) => c !== null); 10 | return hasChildren ? ( 11 |
12 | {children} 13 |
14 | ) : null; 15 | }; 16 | 17 | export default ToolbarGroup; 18 | -------------------------------------------------------------------------------- /src/components/Toolbar/ToolbarTooltip.scss: -------------------------------------------------------------------------------- 1 | @use "sass:math"; 2 | @use "sass:color"; 3 | @use "../../api/constants" as *; 4 | 5 | // Code initially taken from Balloon.css 6 | // See https://github.com/kazzkiq/balloon.css. 7 | // 8 | // Variables 9 | // ----------------------------------------- 10 | 11 | $balloon-bg: color.adjust( 12 | $color: #111, 13 | $alpha: -0.1, 14 | ) !default; 15 | $balloon-text-color: $color-white; 16 | $arrow-height: 6px; 17 | $arrow-width: 18px; 18 | $arrow-offset: 5px; 19 | $balloon-radius: $draftail-toolbar-tooltip-radius; 20 | $balloon-transition-duration: $draftail-toolbar-tooltip-duration; 21 | $balloon-transition-delay: $draftail-toolbar-tooltip-delay; 22 | $balloon-font-size: 0.875em; 23 | 24 | // 25 | // Mixins 26 | // ----------------------------------------- 27 | 28 | @mixin arrow($color, $direction) { 29 | border: math.div($arrow-width, 2) solid transparent; 30 | @if $direction == up { 31 | border-top-width: 0; 32 | border-bottom: $arrow-height solid $color; 33 | } @else if $direction == down { 34 | border-bottom-width: 0; 35 | border-top: $arrow-height solid $color; 36 | } @else { 37 | @error "Unknown direction #{$direction}."; 38 | } 39 | } 40 | 41 | @mixin balloon-position($direction) { 42 | $prop: null; 43 | $factor: null; 44 | 45 | @if $direction == up { 46 | $prop: top; 47 | $factor: -1; 48 | } @else if $direction == down { 49 | $prop: bottom; 50 | $factor: 1; 51 | } @else { 52 | @error "Unknown direction #{$direction}."; 53 | } 54 | 55 | &::after, 56 | &::before { 57 | inset-inline-start: 50%; 58 | #{$prop}: 100%; 59 | transform: translate( 60 | calc(-50% * var(--draftail-text-direction)), 61 | $factor * 10px 62 | ); 63 | } 64 | 65 | &:hover { 66 | &::after, 67 | &::before { 68 | transform: translate(calc(-50% * var(--draftail-text-direction)), 0); 69 | } 70 | } 71 | 72 | &::after { 73 | margin-#{$prop}: $arrow-offset + $arrow-height; 74 | } 75 | 76 | &::before { 77 | @include arrow($color: $balloon-bg, $direction: $direction); 78 | margin-#{$prop}: $arrow-offset; 79 | } 80 | } 81 | 82 | // 83 | // Styles 84 | // ----------------------------------------- 85 | 86 | [data-draftail-balloon] { 87 | position: relative; 88 | 89 | // Fixing iOS Safari event issue. 90 | // More info at: https://goo.gl/w8JF4W 91 | cursor: pointer; 92 | 93 | &::before, 94 | &::after { 95 | position: absolute; 96 | z-index: $draftail-toolbar-tooltip-z-index; 97 | opacity: 0; 98 | pointer-events: none; 99 | } 100 | 101 | &::before { 102 | content: ""; 103 | } 104 | 105 | &::after { 106 | @include font-smoothing(); 107 | 108 | background: $balloon-bg; 109 | border-radius: $balloon-radius; 110 | outline: $draftail-contrast-outline-modal; 111 | color: $balloon-text-color; 112 | content: attr(aria-label); 113 | padding: 0.5em 1em; 114 | white-space: pre; 115 | font-size: $balloon-font-size; 116 | } 117 | 118 | &:hover { 119 | &::before, 120 | &::after { 121 | opacity: 1; 122 | transition: all $balloon-transition-duration ease-out 123 | $balloon-transition-delay; 124 | } 125 | } 126 | } 127 | 128 | [data-draftail-balloon="up"] { 129 | @include balloon-position($direction: up); 130 | } 131 | 132 | [data-draftail-balloon="down"] { 133 | @include balloon-position($direction: down); 134 | } 135 | -------------------------------------------------------------------------------- /src/components/Toolbar/__snapshots__/Toolbar.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Toolbar #controls 1`] = ` 4 |
8 | 29 | 32 | 37 | 38 |
39 | `; 40 | 41 | exports[`Toolbar empty 1`] = ` 42 |
46 | 67 | 70 |
71 | `; 72 | -------------------------------------------------------------------------------- /src/components/Toolbar/__snapshots__/ToolbarButton.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ToolbarButton #active 1`] = ` 4 | 27 | `; 28 | 29 | exports[`ToolbarButton #label 1`] = ` 30 | 44 | `; 45 | 46 | exports[`ToolbarButton #name 1`] = ` 47 |