├── .nvmrc ├── .env ├── src ├── demo │ ├── components │ │ ├── App.css │ │ ├── page-nav.css │ │ ├── App.test.tsx │ │ ├── __snapshots__ │ │ │ ├── Image.test.tsx.snap │ │ │ ├── Snippet.test.tsx.snap │ │ │ ├── Highlight.test.tsx.snap │ │ │ ├── SentryBoundary.test.tsx.snap │ │ │ ├── DemoEditor.test.tsx.snap │ │ │ └── App.test.tsx.snap │ │ ├── Snippet.css │ │ ├── DemoEditor.css │ │ ├── Image.tsx │ │ ├── Highlight.test.tsx │ │ ├── Snippet.tsx │ │ ├── header.css │ │ ├── Highlight.tsx │ │ ├── Link.tsx │ │ ├── SentryBoundary.test.tsx │ │ ├── Image.test.tsx │ │ ├── Snippet.test.tsx │ │ ├── Link.test.tsx │ │ ├── SentryBoundary.tsx │ │ ├── App.tsx │ │ ├── DemoEditor.tsx │ │ └── DemoEditor.test.tsx │ └── utils │ │ ├── utilities.css │ │ ├── layout.css │ │ ├── elements.css │ │ ├── typography.css │ │ ├── objects.css │ │ ├── DraftUtils.ts │ │ └── DraftUtils.test.ts ├── lib │ ├── api │ │ ├── __snapshots__ │ │ │ └── copypaste.test.ts.snap │ │ ├── conversion.ts │ │ ├── conversion.test.ts │ │ ├── lists.ts │ │ ├── lists.test.ts │ │ ├── copypaste.ts │ │ └── copypaste.test.ts │ ├── index.ts │ └── index.test.ts ├── index.test.ts ├── index.tsx └── setupTests.js ├── .eslintrc.js ├── .githooks ├── pre-push ├── commit-msg ├── pre-commit.6.lint.sh ├── pre-commit.8.test.sh ├── pre-commit.5.prettier.sh ├── pre-commit.0.whitespace.sh ├── deploy.sh └── pre-commit ├── .eslintignore ├── public ├── favicon.ico ├── mstile-70x70.png ├── favicon-16x16.png ├── favicon-32x32.png ├── mstile-144x144.png ├── mstile-150x150.png ├── mstile-310x150.png ├── mstile-310x310.png ├── apple-touch-icon.png ├── wysiwyg-magic-wand.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── browserconfig.xml ├── site.webmanifest ├── favicon.svg ├── safari-pinned-tab.svg └── index.html ├── .prettierignore ├── .github ├── repository-social-media.png ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ └── ci.yml └── renovate.json5 ├── commitlint.config.js ├── .env.production ├── prettier.config.js ├── tsconfig.json ├── .editorconfig ├── docs ├── SECURITY.md ├── SUPPORT.md ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md └── flow-typed │ └── npm │ └── draftjs-conductor_v3.0.0.js ├── rollup.config.js ├── LICENSE ├── .gitignore ├── release.config.js ├── package.json ├── dangerfile.js ├── CHANGELOG.md └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | REACT_APP_THEME_COLOR="#002ea2" 2 | REACT_APP_RAVEN="" 3 | -------------------------------------------------------------------------------- /src/demo/components/App.css: -------------------------------------------------------------------------------- 1 | h2 { 2 | margin-top: 3rem; 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: "react-app", 3 | }; 4 | -------------------------------------------------------------------------------- /.githooks/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | npx danger local --base main 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.min.js 3 | coverage/ 4 | dist/ 5 | es/ 6 | *.bundle.js 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thibaudcolas/draftjs-conductor/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thibaudcolas/draftjs-conductor/HEAD/public/mstile-70x70.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thibaudcolas/draftjs-conductor/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thibaudcolas/draftjs-conductor/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thibaudcolas/draftjs-conductor/HEAD/public/mstile-144x144.png -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thibaudcolas/draftjs-conductor/HEAD/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thibaudcolas/draftjs-conductor/HEAD/public/mstile-310x150.png -------------------------------------------------------------------------------- /public/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thibaudcolas/draftjs-conductor/HEAD/public/mstile-310x310.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thibaudcolas/draftjs-conductor/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/wysiwyg-magic-wand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thibaudcolas/draftjs-conductor/HEAD/public/wysiwyg-magic-wand.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.min.js 3 | coverage/ 4 | dist/ 5 | *.bundle.js 6 | public/source-map-explorer.html 7 | build/ 8 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thibaudcolas/draftjs-conductor/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thibaudcolas/draftjs-conductor/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /.github/repository-social-media.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thibaudcolas/draftjs-conductor/HEAD/.github/repository-social-media.png -------------------------------------------------------------------------------- /.githooks/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | COMMIT_EDITMSG="$1" 4 | MESSAGE=$(cat $COMMIT_EDITMSG) 5 | 6 | npx commitlint -e "$COMMIT_EDITMSG" 7 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | // See https://github.com/marionebl/commitlint. 2 | module.exports = { 3 | extends: ["@commitlint/config-conventional"], 4 | }; 5 | -------------------------------------------------------------------------------- /src/demo/utils/utilities.css: -------------------------------------------------------------------------------- 1 | .u-text-center { 2 | text-align: center; 3 | } 4 | 5 | .u-wagtail { 6 | color: #358c8b; 7 | white-space: nowrap; 8 | } 9 | -------------------------------------------------------------------------------- /.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/ $JS_STAGED 6 | fi 7 | -------------------------------------------------------------------------------- /src/demo/components/page-nav.css: -------------------------------------------------------------------------------- 1 | .page-nav { 2 | text-align: center; 3 | } 4 | 5 | @media only screen and (max-width: 480px) { 6 | .page-nav { 7 | text-align: left; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_RAVEN="" 2 | -------------------------------------------------------------------------------- /src/demo/components/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from "enzyme"; 2 | import App from "./App"; 3 | 4 | describe("App", () => { 5 | it("renders", () => { 6 | expect(shallow()).toMatchSnapshot(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /.githooks/pre-commit.8.test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | if [ -n "$JS_STAGED" ] || [ -n "$SNAPSHOT_STAGED" ]; 6 | then 7 | DRAFTJS_VERSION=0.10 npm run test -s 8 | DRAFTJS_VERSION=0.11 npm run test -s 9 | fi 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: "❓ Question" 4 | url: https://github.com/thibaudcolas/draftjs-conductor/discussions 5 | about: Use GitHub Discussions to get help with this project. 6 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #002ea2 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/demo/utils/layout.css: -------------------------------------------------------------------------------- 1 | .page-wrapper { 2 | max-width: 960px; 3 | padding: 10vh 5vw; 4 | margin: 0 auto; 5 | } 6 | 7 | @media only screen and (min-width: 768px) { 8 | .page-wrapper { 9 | padding: 10vh 5rem; 10 | } 11 | } 12 | 13 | .page-section + .page-section { 14 | margin-top: 2rem; 15 | } 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/demo/components/__snapshots__/Image.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Image no entity 1`] = ` 4 | 9 | `; 10 | 11 | exports[`Image renders 1`] = ` 12 | 17 | `; 18 | -------------------------------------------------------------------------------- /src/demo/utils/elements.css: -------------------------------------------------------------------------------- 1 | @import-normalize; 2 | 3 | *, 4 | *::before, 5 | *::after { 6 | box-sizing: inherit; 7 | } 8 | 9 | body { 10 | box-sizing: border-box; 11 | font-size: 1rem; 12 | color: #333; 13 | font-weight: normal; 14 | } 15 | 16 | img { 17 | max-width: 100%; 18 | } 19 | 20 | a { 21 | color: inherit; 22 | text-decoration: none; 23 | } 24 | -------------------------------------------------------------------------------- /.githooks/pre-commit.5.prettier.sh: -------------------------------------------------------------------------------- 1 | 2 | #!/usr/bin/env bash 3 | # Format and re-stage fully staged files only. 4 | 5 | if [ -n "$PRETTIER_FULLY_STAGED" ]; 6 | then 7 | npx prettier --cache --write $PRETTIER_FULLY_STAGED 8 | git add $PRETTIER_FULLY_STAGED 9 | fi 10 | 11 | if [ -n "$PRETTIER_STAGED" ]; 12 | then 13 | npx prettier --cache --check $PRETTIER_STAGED 14 | fi 15 | -------------------------------------------------------------------------------- /src/demo/components/Snippet.css: -------------------------------------------------------------------------------- 1 | .Snippet { 2 | font-size: 20px; 3 | border-radius: 6px; 4 | border: 1px solid gray; 5 | background-color: whitesmoke; 6 | padding: 40px; 7 | } 8 | 9 | .Snippet__text { 10 | font-size: 16px; 11 | font-weight: bold; 12 | border-radius: 6px; 13 | background-color: lightgrey; 14 | margin-top: 10px; 15 | padding: 40px; 16 | } 17 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | _Before_ submitting a pull request, please make sure to: 2 | 3 | 1. Create your branch from the up to date `main` branch. 4 | 2. If you've added code, add tests! 5 | 3. If you've changed APIs, update the documentation. 6 | 4. Ensure that: 7 | 8 | - The test suite passes, with 100% coverage (`npm run test:coverage`) 9 | - The linting passes (`npm run lint`, `npx flow`). 10 | 11 | Thank you! 12 | -------------------------------------------------------------------------------- /src/demo/components/DemoEditor.css: -------------------------------------------------------------------------------- 1 | .DemoEditor { 2 | margin: 1rem 0; 3 | padding: 0.25rem; 4 | border: 1px solid black; 5 | background-color: white; 6 | } 7 | 8 | .DraftEditor-root { 9 | font-size: 1rem; 10 | line-height: 1.5; 11 | font-variant-ligatures: none; 12 | overflow: auto; 13 | } 14 | 15 | .public-DraftEditor-content, 16 | .public-DraftEditorPlaceholder-root, 17 | .EditorToolbar { 18 | padding: 0.25rem; 19 | } 20 | -------------------------------------------------------------------------------- /.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 13 | then 14 | echo "Aborting commit due to whitespace errors." 15 | exit 1 16 | fi 17 | -------------------------------------------------------------------------------- /src/demo/utils/typography.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, 3 | Arial, sans-serif; 4 | } 5 | 6 | code { 7 | font-family: Monaco, Bitstream Vera Sans Mono, Lucida Console, Terminal, 8 | Consolas, Liberation Mono, DejaVu Sans Mono, Courier New, monospace; 9 | } 10 | 11 | h1, 12 | h2, 13 | h3, 14 | h4, 15 | h5, 16 | h6 { 17 | margin: 0 0 1rem; 18 | } 19 | 20 | h1, 21 | h2, 22 | h3 { 23 | line-height: 1.1; 24 | } 25 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Draft.js conductor", 3 | "short_name": "Conductor", 4 | "icons": [ 5 | { 6 | "src": "android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#002EA2", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/api/__snapshots__/copypaste.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`copypaste copy/cut listener works 1`] = `"
"`; 4 | -------------------------------------------------------------------------------- /src/demo/components/Image.tsx: -------------------------------------------------------------------------------- 1 | import { ContentBlock, ContentState } from "draft-js"; 2 | 3 | interface ImageProps { 4 | block: ContentBlock; 5 | contentState: ContentState; 6 | } 7 | 8 | const Image = ({ block, contentState }: ImageProps) => { 9 | const entityKey = block.getEntityAt(0); 10 | const src = entityKey 11 | ? contentState.getEntity(entityKey).getData().src 12 | : "404.svg"; 13 | 14 | return ; 15 | }; 16 | 17 | export default Image; 18 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | getListNestingStyles, 3 | DRAFT_DEFAULT_MAX_DEPTH, 4 | DRAFT_DEFAULT_DEPTH_CLASS, 5 | generateListNestingStyles, 6 | blockDepthStyleFn, 7 | } from "./api/lists"; 8 | 9 | export { 10 | registerCopySource, 11 | onDraftEditorCopy, 12 | onDraftEditorCut, 13 | handleDraftEditorPastedText, 14 | getDraftEditorPastedContent, 15 | } from "./api/copypaste"; 16 | 17 | export { 18 | createEditorStateFromRaw, 19 | serialiseEditorStateToRaw, 20 | } from "./api/conversion"; 21 | -------------------------------------------------------------------------------- /src/demo/components/Highlight.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from "enzyme"; 2 | import Highlight from "./Highlight"; 3 | 4 | describe("Highlight", () => { 5 | it("renders", () => { 6 | expect(shallow()).toMatchSnapshot(); 7 | }); 8 | 9 | it("onCopy", () => { 10 | document.execCommand = jest.fn(); 11 | const wrapper = shallow(); 12 | 13 | wrapper.find("button").simulate("click"); 14 | 15 | expect(document.execCommand).toHaveBeenCalledWith("copy"); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | describe("demo", () => { 4 | beforeEach(() => { 5 | jest.resetModules(); 6 | jest.spyOn(window.sessionStorage, "getItem"); 7 | jest.spyOn(window.sessionStorage, "setItem"); 8 | }); 9 | 10 | it("mount", () => { 11 | document.body.innerHTML = "
"; 12 | require("./index"); 13 | expect(document.body.innerHTML).toContain("App"); 14 | }); 15 | 16 | it("no mount", () => { 17 | document.body.innerHTML = ""; 18 | require("./index"); 19 | expect(document.body.innerHTML).toBe(""); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | "noFallthroughCasesInSwitch": true, 17 | "lib": ["dom", "dom.iterable", "esnext"] 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import "./demo/utils/elements.css"; 5 | import "./demo/utils/typography.css"; 6 | import "./demo/utils/layout.css"; 7 | import "./demo/utils/objects.css"; 8 | 9 | import "draft-js/dist/Draft.css"; 10 | 11 | import "./demo/components/header.css"; 12 | import "./demo/components/page-nav.css"; 13 | 14 | import "./demo/utils/utilities.css"; 15 | 16 | import App from "./demo/components/App"; 17 | 18 | const mount = document.getElementById("root"); 19 | 20 | if (mount) { 21 | ReactDOM.render(, mount); 22 | } 23 | -------------------------------------------------------------------------------- /src/demo/components/__snapshots__/Snippet.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Snippet no entity 1`] = ` 4 |
8 | This is a snippet block: 9 |
13 | Placeholder 14 |
15 |
16 | `; 17 | 18 | exports[`Snippet renders 1`] = ` 19 |
23 | This is a snippet block: 24 |
28 | This is a snippet 29 |
30 |
31 | `; 32 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/demo/components/__snapshots__/Highlight.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Highlight renders 1`] = ` 4 |
11 | 22 | 27 |
28 | ); 29 | 30 | export default Highlight; 31 | -------------------------------------------------------------------------------- /src/demo/utils/DraftUtils.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, Modifier, RichUtils } from "draft-js"; 2 | 3 | const addLineBreak = (editorState: EditorState) => { 4 | const content = editorState.getCurrentContent(); 5 | const selection = editorState.getSelection(); 6 | 7 | if (selection.isCollapsed()) { 8 | return RichUtils.insertSoftNewline(editorState); 9 | } 10 | 11 | let newContent = Modifier.removeRange(content, selection, "forward"); 12 | const fragment = newContent.getSelectionAfter(); 13 | const block = newContent.getBlockForKey(fragment.getStartKey()); 14 | newContent = Modifier.insertText( 15 | newContent, 16 | fragment, 17 | "\n", 18 | block.getInlineStyleAt(fragment.getStartOffset()), 19 | undefined, 20 | ); 21 | return EditorState.push(editorState, newContent, "insert-fragment"); 22 | }; 23 | 24 | const DraftUtils = { 25 | addLineBreak, 26 | }; 27 | 28 | export default DraftUtils; 29 | -------------------------------------------------------------------------------- /src/demo/components/Link.tsx: -------------------------------------------------------------------------------- 1 | import { ContentState, ContentBlock } from "draft-js"; 2 | import { ReactNode } from "react"; 3 | 4 | export const linkStrategy = ( 5 | contentBlock: ContentBlock, 6 | callback: (start: number, end: number) => void, 7 | contentState: ContentState, 8 | ) => { 9 | contentBlock.findEntityRanges((character) => { 10 | const entityKey = character.getEntity(); 11 | return ( 12 | entityKey !== null && 13 | contentState.getEntity(entityKey).getType() === "LINK" 14 | ); 15 | }, callback); 16 | }; 17 | 18 | interface LinkProps { 19 | contentState: ContentState; 20 | entityKey: string; 21 | children: ReactNode; 22 | } 23 | 24 | const Link = ({ contentState, entityKey, children }: LinkProps) => { 25 | const entity = contentState.getEntity(entityKey); 26 | return ( 27 | 28 | {children} 29 | 30 | ); 31 | }; 32 | 33 | export default Link; 34 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2018-current Thibaud Colas 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 | -------------------------------------------------------------------------------- /docs/SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | > This project follows a [Code of Conduct](CODE_OF_CONDUCT.md). 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 within the GitHub repository. Please make sure to have a look at: 11 | 12 | - [`README.md`](../README.md), where most documentation lives 13 | - [`docs/`](../), for things that are too long for the README 14 | 15 | ## Questions 16 | 17 | Please make sure you have read available documentation and have searched for other resources available online before submitting your question here. 18 | 19 | To ask a question, head over to the issues, make sure your question hasn’t already been asked, then create a new issue with the "Question" template. 20 | 21 | Please understand that people involved with this project often do so for fun, next to their day job, and might take a while to respond. 22 | 23 | ## Contributions 24 | 25 | See [`CONTRIBUTING.md`](CONTRIBUTING.md) on how to contribute. 26 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | import { configure } from "enzyme"; 2 | import Adapter from "enzyme-adapter-react-16"; 3 | import { createSerializer } from "enzyme-to-json"; 4 | 5 | configure({ adapter: new Adapter() }); 6 | 7 | expect.addSnapshotSerializer(createSerializer({ mode: "deep" })); 8 | 9 | jest.mock("draft-js", () => { 10 | const packages = { 11 | "0.10": "draft-js-10", 12 | 0.11: "draft-js", 13 | }; 14 | const version = process.env.DRAFTJS_VERSION || "0.11"; 15 | 16 | // Require the original module. 17 | const originalModule = jest.requireActual(packages[version]); 18 | 19 | return { 20 | __esModule: true, 21 | ...originalModule, 22 | }; 23 | }); 24 | 25 | const consoleWarn = console.warn; 26 | 27 | console.warn = function filterWarnings(msg, ...args) { 28 | // Stop logging React warnings we shouldn’t be doing anything about at this time. 29 | const supressedWarnings = [ 30 | "Warning: componentWillMount", 31 | "Warning: componentWillReceiveProps", 32 | "Warning: componentWillUpdate", 33 | ]; 34 | 35 | if (!supressedWarnings.some((entry) => msg.includes(entry))) { 36 | consoleWarn.apply(console, ...args); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/demo/components/SentryBoundary.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from "enzyme"; 2 | import SentryBoundary from "./SentryBoundary"; 3 | 4 | describe("SentryBoundary", () => { 5 | it("renders", () => { 6 | expect( 7 | shallow(Test), 8 | ).toMatchSnapshot(); 9 | }); 10 | 11 | it("componentDidCatch", () => { 12 | const wrapper = shallow( 13 | Test, 14 | ); 15 | 16 | wrapper.instance().componentDidCatch(new Error("test")); 17 | 18 | expect(wrapper.state("error")).not.toBe(null); 19 | }); 20 | 21 | it("#error", () => { 22 | expect( 23 | shallow(Test).setState({ 24 | error: new Error("test"), 25 | }), 26 | ).toMatchSnapshot(); 27 | }); 28 | 29 | it("#error reload", () => { 30 | Object.defineProperty(window, "location", { 31 | configurable: true, 32 | value: { reload: jest.fn() }, 33 | }); 34 | 35 | shallow(Test) 36 | .setState({ 37 | error: new Error("test"), 38 | }) 39 | .find("button") 40 | .simulate("click"); 41 | expect(window.location.reload).toHaveBeenCalled(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/demo/components/__snapshots__/SentryBoundary.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SentryBoundary #error 1`] = ` 4 |
7 |
10 |
13 |
16 |

17 | Oops. The editor just crashed. 18 |

19 |

20 | Our team has been notified. You can provide us with more information if you want to. 21 |

22 |
23 | 33 | Open a GitHub issue 34 | 35 | 36 |   37 | 38 | 44 |
45 |
46 |
47 |
48 |
49 | `; 50 | 51 | exports[`SentryBoundary renders 1`] = `"Test"`; 52 | -------------------------------------------------------------------------------- /.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 '.ts$' -e '.tsx$' <<< "$STAGED" || true) 14 | JS_FULLY_STAGED=$(grep -e '.js$' -e '.ts$' -e '.tsx$' <<< "$FULLY_STAGED" || true) 15 | SNAPSHOT_STAGED=$(grep .snap$ <<< "$STAGED" || true) 16 | PRETTIER_STAGED=$(grep -E '.(md|css|scss|js|ts|tsx|json|yaml|yml|html)$' <<< "$STAGED" || true) 17 | PRETTIER_FULLY_STAGED=$(grep -E '.(md|css|scss|js|ts|tsx|json|yaml|yml|html)$' <<< "$FULLY_STAGED" || true) 18 | 19 | # Uncomment, and add more variables to the list, for debugging help. 20 | # 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" 21 | 22 | # Execute each pre-commit hook. 23 | PROJECT_ROOT=`git rev-parse --show-toplevel` 24 | GIT_ROOT=`git rev-parse --git-dir` 25 | HOOKS=$PROJECT_ROOT/$GIT_ROOT/hooks/pre-commit.* 26 | 27 | for HOOK in $HOOKS 28 | do 29 | source $HOOK 30 | done 31 | -------------------------------------------------------------------------------- /src/demo/components/Image.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from "enzyme"; 2 | import { convertFromRaw } from "draft-js"; 3 | 4 | import Image from "./Image"; 5 | 6 | describe("Image", () => { 7 | it("renders", () => { 8 | const content = convertFromRaw({ 9 | entityMap: { 10 | 0: { 11 | type: "IMAGE", 12 | mutability: "IMMUTABLE", 13 | data: { 14 | src: "/example.png", 15 | }, 16 | }, 17 | }, 18 | blocks: [ 19 | { 20 | key: "a", 21 | text: " ", 22 | type: "atomic", 23 | depth: 0, 24 | inlineStyleRanges: [], 25 | entityRanges: [ 26 | { 27 | offset: 0, 28 | length: 1, 29 | key: 0, 30 | }, 31 | ], 32 | }, 33 | ], 34 | }); 35 | 36 | expect( 37 | shallow(), 38 | ).toMatchSnapshot(); 39 | }); 40 | 41 | it("no entity", () => { 42 | const content = convertFromRaw({ 43 | entityMap: {}, 44 | blocks: [ 45 | { 46 | key: "a", 47 | text: " ", 48 | type: "atomic", 49 | depth: 0, 50 | inlineStyleRanges: [], 51 | entityRanges: [], 52 | }, 53 | ], 54 | }); 55 | 56 | expect( 57 | shallow(), 58 | ).toMatchSnapshot(); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /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](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. 8 | 9 | ## Development 10 | 11 | ### Install 12 | 13 | > Clone the project on your computer. You will also need [Node](https://nodejs.org) and [nvm](https://github.com/creationix/nvm). 14 | 15 | ```sh 16 | nvm install 17 | # Then, install all project dependencies. 18 | npm install 19 | ``` 20 | 21 | ### Working on the project 22 | 23 | > Everything mentioned in the installation process should already be done. 24 | 25 | ```sh 26 | # Make sure you use the right node version. 27 | nvm use 28 | # Start the server and the development tools. 29 | npm run start 30 | # Runs linting. 31 | npm run lint 32 | # Re-formats all of the files in the project (with Prettier). 33 | npm run format 34 | # Run tests in a watcher. 35 | npm run test:watch 36 | # Open the coverage report with: 37 | npm run report:coverage 38 | # Open the build report with: 39 | npm run report:build 40 | # View other available commands with: 41 | npm run 42 | ``` 43 | 44 | ### Code style 45 | 46 | This project uses [Prettier](https://prettier.io/), [ESLint](https://eslint.org/), and [TypeScript](https://www.typescriptlang.org/). All code should always be checked with those tools. 47 | -------------------------------------------------------------------------------- /src/demo/components/Snippet.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import { convertFromRaw, RawDraftContentState } from "draft-js"; 4 | 5 | import Snippet from "./Snippet"; 6 | 7 | describe("Snippet", () => { 8 | it("renders", () => { 9 | const content = convertFromRaw({ 10 | entityMap: { 11 | 0: { 12 | type: "SNIPPET", 13 | mutability: "IMMUTABLE", 14 | data: { 15 | text: "This is a snippet", 16 | }, 17 | }, 18 | }, 19 | blocks: [ 20 | { 21 | key: "a", 22 | text: " ", 23 | type: "unstyled", 24 | depth: 0, 25 | inlineStyleRanges: [], 26 | entityRanges: [ 27 | { 28 | offset: 0, 29 | length: 1, 30 | key: 0, 31 | }, 32 | ], 33 | }, 34 | ], 35 | }); 36 | 37 | expect( 38 | shallow( 39 | , 40 | ), 41 | ).toMatchSnapshot(); 42 | }); 43 | 44 | it("no entity", () => { 45 | const content = convertFromRaw({ 46 | entityMap: {}, 47 | blocks: [ 48 | { 49 | key: "a", 50 | text: " ", 51 | }, 52 | ], 53 | } as RawDraftContentState); 54 | 55 | expect( 56 | shallow( 57 | , 58 | ), 59 | ).toMatchSnapshot(); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/lib/index.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getListNestingStyles, 3 | DRAFT_DEFAULT_MAX_DEPTH, 4 | DRAFT_DEFAULT_DEPTH_CLASS, 5 | generateListNestingStyles, 6 | blockDepthStyleFn, 7 | registerCopySource, 8 | onDraftEditorCopy, 9 | onDraftEditorCut, 10 | handleDraftEditorPastedText, 11 | createEditorStateFromRaw, 12 | serialiseEditorStateToRaw, 13 | } from "./index"; 14 | 15 | const pkg = require("../../package.json"); 16 | 17 | /** 18 | * Makes sure the API shape is validated against. 19 | */ 20 | describe(pkg.name, () => { 21 | it("getListNestingStyles", () => expect(getListNestingStyles).toBeDefined()); 22 | 23 | it("DRAFT_DEFAULT_MAX_DEPTH", () => 24 | expect(DRAFT_DEFAULT_MAX_DEPTH).toBeDefined()); 25 | 26 | it("DRAFT_DEFAULT_DEPTH_CLASS", () => 27 | expect(DRAFT_DEFAULT_DEPTH_CLASS).toBeDefined()); 28 | 29 | it("generateListNestingStyles", () => 30 | expect(generateListNestingStyles).toBeDefined()); 31 | 32 | it("blockDepthStyleFn", () => expect(blockDepthStyleFn).toBeDefined()); 33 | 34 | it("registerCopySource", () => expect(registerCopySource).toBeDefined()); 35 | 36 | it("onDraftEditorCopy", () => expect(onDraftEditorCopy).toBeDefined()); 37 | 38 | it("onDraftEditorCut", () => expect(onDraftEditorCut).toBeDefined()); 39 | 40 | it("handleDraftEditorPastedText", () => 41 | expect(handleDraftEditorPastedText).toBeDefined()); 42 | 43 | it("createEditorStateFromRaw", () => 44 | expect(createEditorStateFromRaw).toBeDefined()); 45 | 46 | it("serialiseEditorStateToRaw", () => 47 | expect(serialiseEditorStateToRaw).toBeDefined()); 48 | }); 49 | -------------------------------------------------------------------------------- /src/demo/utils/DraftUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, convertFromRaw, RawDraftContentState } from "draft-js"; 2 | 3 | import DraftUtils from "./DraftUtils"; 4 | 5 | describe("DraftUtils", () => { 6 | describe("#addLineBreak", () => { 7 | it("works, collapsed", () => { 8 | const contentState = convertFromRaw({ 9 | entityMap: {}, 10 | blocks: [ 11 | { 12 | key: "b0ei9", 13 | text: "test", 14 | type: "header-two", 15 | }, 16 | ], 17 | } as RawDraftContentState); 18 | const editorState = EditorState.createWithContent(contentState); 19 | 20 | expect( 21 | DraftUtils.addLineBreak(editorState) 22 | .getCurrentContent() 23 | .getFirstBlock() 24 | .getText(), 25 | ).toBe("\ntest"); 26 | }); 27 | 28 | it("works, non-collapsed", () => { 29 | const contentState = convertFromRaw({ 30 | entityMap: {}, 31 | blocks: [ 32 | { 33 | key: "a", 34 | text: "test", 35 | type: "header-two", 36 | }, 37 | ], 38 | } as RawDraftContentState); 39 | let editorState = EditorState.createWithContent(contentState); 40 | const selection = editorState.getSelection().merge({ 41 | anchorKey: "a", 42 | focusKey: "a", 43 | anchorOffset: 0, 44 | focusOffset: 2, 45 | }); 46 | editorState = EditorState.forceSelection(editorState, selection); 47 | 48 | expect( 49 | DraftUtils.addLineBreak(editorState) 50 | .getCurrentContent() 51 | .getFirstBlock() 52 | .getText(), 53 | ).toBe("\nst"); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/demo/components/Link.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from "enzyme"; 2 | import { 3 | EditorState, 4 | ContentState, 5 | convertFromHTML, 6 | convertFromRaw, 7 | } from "draft-js"; 8 | 9 | import Link, { linkStrategy } from "./Link"; 10 | 11 | describe("Link", () => { 12 | it("renders", () => { 13 | const contentState = convertFromRaw({ 14 | entityMap: { 15 | 0: { 16 | type: "LINK", 17 | mutability: "MUTABLE", 18 | data: { 19 | url: "www.example.com", 20 | }, 21 | }, 22 | }, 23 | blocks: [ 24 | { 25 | key: "6i47q", 26 | text: "NA link doc", 27 | type: "unstyled", 28 | depth: 0, 29 | inlineStyleRanges: [], 30 | entityRanges: [ 31 | { 32 | offset: 3, 33 | length: 4, 34 | key: 0, 35 | }, 36 | ], 37 | data: {}, 38 | }, 39 | ], 40 | }); 41 | const entityKey = contentState.getFirstBlock().getEntityAt(3); 42 | 43 | expect( 44 | shallow( 45 | 46 | Test 47 | , 48 | ), 49 | ).toMatchInlineSnapshot(` 50 | 54 | Test 55 | 56 | `); 57 | }); 58 | }); 59 | 60 | describe("linkStrategy", () => { 61 | it("works", () => { 62 | const editorState = EditorState.createWithContent( 63 | ContentState.createFromBlockArray( 64 | convertFromHTML(`

Test

`) 65 | .contentBlocks, 66 | ), 67 | ); 68 | const currentContent = editorState.getCurrentContent(); 69 | const callback = jest.fn(); 70 | linkStrategy(currentContent.getFirstBlock(), callback, currentContent); 71 | expect(callback).toHaveBeenCalled(); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/demo/components/SentryBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component, ReactNode } from "react"; 2 | 3 | interface SentryBoundaryProps { 4 | children: ReactNode; 5 | } 6 | 7 | interface SentryBoundaryState { 8 | error: Error | null | undefined; 9 | } 10 | 11 | class SentryBoundary extends Component< 12 | SentryBoundaryProps, 13 | SentryBoundaryState 14 | > { 15 | constructor(props: SentryBoundaryProps) { 16 | super(props); 17 | this.state = { 18 | error: null, 19 | }; 20 | } 21 | 22 | componentDidCatch(error: Error) { 23 | this.setState({ 24 | error, 25 | }); 26 | } 27 | 28 | render() { 29 | const { children } = this.props; 30 | const { error } = this.state; 31 | 32 | return error ? ( 33 |
34 |
35 |
36 |
37 |

Oops. The editor just crashed.

38 |

39 | Our team has been notified. You can provide us with more 40 | information if you want to. 41 |

42 |
43 | 51 | Open a GitHub issue 52 | 53 |   54 | 57 |
58 |
59 |
60 |
61 |
62 | ) : ( 63 | children 64 | ); 65 | } 66 | } 67 | 68 | export default SentryBoundary; 69 | -------------------------------------------------------------------------------- /.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 | docs/pattern-library/index.html 42 | webpack-stats.json 43 | source-map-explorer.html 44 | 45 | # ------------------------------------------------- 46 | # Dependency directories 47 | # ------------------------------------------------- 48 | node_modules* 49 | python_modules* 50 | bower_components 51 | .venv 52 | venv 53 | $virtualenv.tar.gz 54 | $node_modules.tar.gz 55 | 56 | # ------------------------------------------------- 57 | # Users Environment 58 | # ------------------------------------------------- 59 | .lock-wscript 60 | .idea 61 | .installed.cfg 62 | .vagrant 63 | .anaconda 64 | Vagrantfile.local 65 | /local 66 | local.py 67 | *.sublime-project 68 | *.sublime-workspace 69 | .vscode 70 | 71 | # ------------------------------------------------- 72 | # Generated files 73 | # ------------------------------------------------- 74 | dist 75 | build 76 | /var/static/ 77 | /var/media/ 78 | /docs/_build/ 79 | develop-eggs 80 | *.egg-info 81 | downloads 82 | media 83 | eggs 84 | parts 85 | lib64 86 | .sass-cache 87 | 88 | # ------------------------------------------------- 89 | # Your own project's ignores 90 | # ------------------------------------------------- 91 | 92 | .env.local 93 | .env.development.local 94 | .env.test.local 95 | .env.production.local 96 | -------------------------------------------------------------------------------- /src/lib/api/conversion.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EditorState, 3 | convertFromRaw, 4 | convertToRaw, 5 | RawDraftContentState, 6 | ContentBlock, 7 | ContentState, 8 | } from "draft-js"; 9 | 10 | const EMPTY_CONTENT_STATE = null; 11 | 12 | interface DraftDecoratorType { 13 | /** 14 | * Given a `ContentBlock`, return an immutable List of decorator keys. 15 | */ 16 | getDecorations( 17 | block: ContentBlock, 18 | contentState: ContentState, 19 | ): Immutable.List; 20 | 21 | /** 22 | * Given a decorator key, return the component to use when rendering 23 | * this decorated range. 24 | */ 25 | getComponentForKey(key: string): Function; 26 | 27 | /** 28 | * Given a decorator key, optionally return the props to use when rendering 29 | * this decorated range. 30 | */ 31 | getPropsForKey(key: string): any; 32 | } 33 | 34 | /** 35 | * Creates a new EditorState from a RawDraftContentState, or an empty editor state by 36 | * passing `null`. Optionally takes a decorator. 37 | */ 38 | export const createEditorStateFromRaw = ( 39 | rawContentState: RawDraftContentState | null, 40 | decorator?: DraftDecoratorType, 41 | ) => { 42 | let editorState; 43 | 44 | if (rawContentState) { 45 | const contentState = convertFromRaw(rawContentState); 46 | editorState = EditorState.createWithContent(contentState, decorator); 47 | } else { 48 | editorState = EditorState.createEmpty(decorator); 49 | } 50 | 51 | return editorState; 52 | }; 53 | 54 | /** 55 | * Serialises the editorState using `convertToRaw`, but returns `null` if 56 | * the editor content is empty (no text, entities, styles). 57 | */ 58 | export const serialiseEditorStateToRaw = (editorState: EditorState) => { 59 | const contentState = editorState.getCurrentContent(); 60 | const rawContentState = convertToRaw(contentState); 61 | 62 | const isEmpty = rawContentState.blocks.every((block) => { 63 | const isEmptyBlock = 64 | block.text.trim().length === 0 && 65 | (!block.entityRanges || block.entityRanges.length === 0) && 66 | (!block.inlineStyleRanges || block.inlineStyleRanges.length === 0); 67 | return isEmptyBlock; 68 | }); 69 | 70 | return isEmpty ? EMPTY_CONTENT_STATE : rawContentState; 71 | }; 72 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - "renovate/**" 7 | pull_request: 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - run: echo "commits_count_incr=$(($COUNT+1))" >> $GITHUB_ENV 13 | if: ${{ github.event_name == 'pull_request' }} 14 | env: 15 | COUNT: ${{ github.event.pull_request.commits }} 16 | - uses: actions/checkout@v6 17 | if: ${{ github.event_name == 'pull_request' }} 18 | with: 19 | fetch-depth: ${{ env.commits_count_incr }} 20 | - uses: actions/checkout@v6 21 | if: ${{ github.event_name != 'pull_request' }} 22 | - uses: actions/setup-node@v6 23 | with: 24 | node-version-file: ".nvmrc" 25 | - id: node-cache 26 | uses: actions/cache@v5 27 | with: 28 | path: node_modules 29 | key: ${{ runner.os }}-node-${{ hashFiles('**/.nvmrc') }}-${{ hashFiles('**/package-lock.json') }} 30 | restore-keys: | 31 | ${{ runner.os }}-node- 32 | - if: steps.node-cache.outputs.cache-hit != 'true' 33 | run: npm ci 34 | # Test Git hooks in CI, to make sure script upgrades do not break them. 35 | - run: npm run prepare 36 | # Test commit message validation in CI. 37 | - run: git log -1 --pretty=%B >> latest.log && ./.git/hooks/commit-msg latest.log 38 | - run: DRAFTJS_VERSION=0.11 npm run test:ci 39 | - run: DRAFTJS_VERSION=0.10 npm run test 40 | - run: npx commitlint --from HEAD~${{ github.event.pull_request.commits }} --to HEAD 41 | if: ${{ github.event_name == 'pull_request' }} 42 | - run: npx danger ci --verbose 43 | if: ${{ github.event_name == 'pull_request' }} 44 | env: 45 | DANGER_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | - run: npm run report:package 47 | - run: mv coverage/lcov-report build || true 48 | - run: cat ./coverage/lcov.info | npx coveralls || true 49 | - uses: actions/upload-artifact@v6 50 | with: 51 | name: build 52 | path: build 53 | retention-days: 1 54 | - run: npx semantic-release 55 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 59 | GIT_AUTHOR_NAME: ${{ secrets.GIT_AUTHOR_NAME }} 60 | GIT_AUTHOR_EMAIL: ${{ secrets.GIT_AUTHOR_EMAIL }} 61 | deploy: 62 | runs-on: ubuntu-latest 63 | needs: test 64 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 65 | steps: 66 | - uses: actions/checkout@v6 67 | - uses: actions/download-artifact@v7 68 | - uses: JamesIves/github-pages-deploy-action@v4.7.6 69 | with: 70 | branch: gh-pages 71 | folder: build 72 | clean: true 73 | -------------------------------------------------------------------------------- /src/lib/api/conversion.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EditorState, 3 | convertFromRaw, 4 | convertToRaw, 5 | CompositeDecorator, 6 | RawDraftContentState, 7 | } from "draft-js"; 8 | import { 9 | createEditorStateFromRaw, 10 | serialiseEditorStateToRaw, 11 | } from "./conversion"; 12 | 13 | describe("#createEditorStateFromRaw", () => { 14 | it("creates state from real content", () => { 15 | const state = createEditorStateFromRaw({ 16 | entityMap: {}, 17 | blocks: [ 18 | { text: "Hello, World!", type: "unstyled" }, 19 | { text: "This is a title", type: "header-two" }, 20 | ], 21 | } as RawDraftContentState); 22 | const result = convertToRaw(state.getCurrentContent()); 23 | expect(state).toBeInstanceOf(EditorState); 24 | expect(result.blocks.length).toEqual(2); 25 | expect(result.blocks[0].text).toEqual("Hello, World!"); 26 | }); 27 | 28 | it("creates empty state from empty content", () => { 29 | const state = createEditorStateFromRaw(null); 30 | const result = convertToRaw(state.getCurrentContent()); 31 | expect(state).toBeInstanceOf(EditorState); 32 | expect(result.blocks.length).toEqual(1); 33 | expect(result.blocks[0].text).toEqual(""); 34 | }); 35 | 36 | it("takes a decorator", () => { 37 | const decorator = new CompositeDecorator([ 38 | { strategy: () => {}, component: () => {} }, 39 | ]); 40 | const state = createEditorStateFromRaw(null, decorator); 41 | expect(state.getDecorator()).toBe(decorator); 42 | }); 43 | }); 44 | 45 | describe("#serialiseEditorStateToRaw", () => { 46 | it("keeps real content", () => { 47 | const stubContent = { 48 | entityMap: {}, 49 | blocks: [ 50 | { 51 | key: "1dcqo", 52 | text: "Hello, World!", 53 | type: "unstyled", 54 | depth: 0, 55 | inlineStyleRanges: [], 56 | entityRanges: [], 57 | data: {}, 58 | }, 59 | { 60 | key: "dmtba", 61 | text: "This is a title", 62 | type: "header-two", 63 | depth: 0, 64 | inlineStyleRanges: [], 65 | entityRanges: [], 66 | data: {}, 67 | }, 68 | ], 69 | } as RawDraftContentState; 70 | const state = createEditorStateFromRaw(stubContent); 71 | expect(serialiseEditorStateToRaw(state)).toEqual(stubContent); 72 | }); 73 | 74 | it("discards empty content", () => { 75 | const state = createEditorStateFromRaw(null); 76 | expect(serialiseEditorStateToRaw(state)).toBeNull(); 77 | }); 78 | 79 | it("discards content with only empty text", () => { 80 | const editorState = EditorState.createWithContent( 81 | convertFromRaw({ 82 | entityMap: {}, 83 | blocks: [ 84 | { 85 | key: "a", 86 | text: "", 87 | }, 88 | ], 89 | } as RawDraftContentState), 90 | ); 91 | expect(serialiseEditorStateToRaw(editorState)).toBeNull(); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-template-curly-in-string */ 2 | const pkg = require("./package.json"); 3 | 4 | const CHANGELOG_HEADER = `# Changelog 5 | 6 | All notable changes to this project will be documented in this file. 7 | 8 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), enforced with [semantic-release](https://github.com/semantic-release/semantic-release). 9 | `; 10 | 11 | // Inspired by the Danger postfix. 12 | // See https://github.com/danger/danger-js/blob/a6e6b6601a796c2eb0181134a46ffaad0c307529/source/runner/templates/githubIssueTemplate.ts#L69. 13 | const COMMENT_POSTFIX = `

14 | Generated by :package::rocket: semantic-release 15 |

`; 16 | 17 | const SUCCESS_COMMENT = `:tada: This \${issue.pull_request ? 'pull request is included' : 'issue is fixed'} in v\${nextRelease.version}, available on npm: [${pkg.name}@\${nextRelease.version}](https://www.npmjs.com/package/${pkg.name}). 18 | 19 | ${COMMENT_POSTFIX} 20 | `; 21 | 22 | /** 23 | * See: 24 | * https://semantic-release.gitbook.io/semantic-release/ 25 | * https://github.com/semantic-release/npm 26 | * https://github.com/semantic-release/github 27 | * https://github.com/semantic-release/git 28 | * https://github.com/semantic-release/release-notes-generator 29 | * https://github.com/semantic-release/commit-analyzer 30 | * https://github.com/semantic-release/changelog 31 | */ 32 | module.exports = { 33 | branches: "main", 34 | tagFormat: "v${version}", 35 | npmPublish: true, 36 | tarballDir: "dist", 37 | assets: "dist/*.tgz", 38 | verifyConditions: [ 39 | "@semantic-release/changelog", 40 | "@semantic-release/npm", 41 | "@semantic-release/git", 42 | "@semantic-release/github", 43 | ], 44 | analyzeCommits: { 45 | preset: "angular", 46 | }, 47 | verifyRelease: [], 48 | generateNotes: ["@semantic-release/release-notes-generator"], 49 | prepare: [ 50 | { 51 | path: "@semantic-release/changelog", 52 | changelogFile: "CHANGELOG.md", 53 | changelogTitle: CHANGELOG_HEADER, 54 | }, 55 | { 56 | path: "@semantic-release/exec", 57 | cmd: "prettier --write CHANGELOG.md && rm -rf .git/hooks", 58 | }, 59 | "@semantic-release/npm", 60 | { 61 | path: "@semantic-release/git", 62 | message: 63 | "chore(release): v${nextRelease.version} [skip ci]\n\n${nextRelease.notes}", 64 | assets: [ 65 | "README.md", 66 | "CHANGELOG.md", 67 | "package.json", 68 | "package-lock.json", 69 | ], 70 | }, 71 | ], 72 | publish: [ 73 | "@semantic-release/npm", 74 | { 75 | path: "@semantic-release/github", 76 | assets: ["dist/*.tgz"], 77 | }, 78 | ], 79 | success: [ 80 | { 81 | path: "@semantic-release/github", 82 | successComment: SUCCESS_COMMENT, 83 | }, 84 | ], 85 | fail: ["@semantic-release/github"], 86 | }; 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "draftjs-conductor", 3 | "version": "3.0.0", 4 | "description": "📝✨ Little Draft.js helpers to make rich text editors “just work”", 5 | "author": "Thibaud Colas", 6 | "license": "MIT", 7 | "main": "dist/draftjs-conductor.cjs.js", 8 | "module": "dist/draftjs-conductor.esm.js", 9 | "types": "dist/draftjs-conductor.d.ts", 10 | "sideEffects": false, 11 | "keywords": [ 12 | "draftjs", 13 | "draft-js", 14 | "editor", 15 | "react", 16 | "wysiwyg", 17 | "rich text", 18 | "richtext", 19 | "rte" 20 | ], 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/thibaudcolas/draftjs-conductor.git" 24 | }, 25 | "bugs": { 26 | "url": "https://github.com/thibaudcolas/draftjs-conductor/issues" 27 | }, 28 | "homepage": "https://thibaudcolas.github.io/draftjs-conductor/", 29 | "files": [ 30 | "dist/*.js", 31 | "dist/*.d.ts" 32 | ], 33 | "browserslist": "> 1%, not IE 11", 34 | "jest": { 35 | "collectCoverageFrom": [ 36 | "src/lib/**/*.{js,jsx,ts,tsx}", 37 | "!/node_modules/" 38 | ] 39 | }, 40 | "devDependencies": { 41 | "@commitlint/cli": "20.2.0", 42 | "@commitlint/config-conventional": "20.2.0", 43 | "@rollup/plugin-typescript": "8.5.0", 44 | "@semantic-release/changelog": "6.0.2", 45 | "@semantic-release/exec": "6.0.3", 46 | "@semantic-release/git": "10.0.1", 47 | "@types/draft-js": "0.10.45", 48 | "@types/enzyme": "3.10.12", 49 | "@types/jest": "30.0.0", 50 | "@types/react": "16.14.26", 51 | "@types/react-dom": "16.9.16", 52 | "coveralls": "3.1.1", 53 | "danger": "13.0.5", 54 | "draft-js": "0.11.7", 55 | "draft-js-10": "npm:draft-js@0.10.5", 56 | "enzyme": "3.11.0", 57 | "enzyme-adapter-react-16": "1.15.8", 58 | "enzyme-to-json": "3.6.2", 59 | "immutable": "~3.7.6", 60 | "normalize.css": "7.0.0", 61 | "prettier": "2.8.4", 62 | "react": "16.14.0", 63 | "react-dom": "16.14.0", 64 | "react-scripts": "5.0.1", 65 | "react-test-renderer": "16.14.0", 66 | "rollup": "2.79.1", 67 | "rollup-plugin-dts": "4.2.3", 68 | "semantic-release": "20.1.3", 69 | "snapshot-diff": "0.10.0", 70 | "typescript": "4.7.4" 71 | }, 72 | "peerDependencies": { 73 | "draft-js": "^0.10.4 || ^0.11.0 || ^0.12.0" 74 | }, 75 | "scripts": { 76 | "start": "react-scripts start", 77 | "build": "CI=true react-scripts build && rollup -c", 78 | "test": "CI=true react-scripts test --coverage", 79 | "test:watch": "react-scripts test", 80 | "report:coverage": "open coverage/lcov-report/index.html", 81 | "report:package": "npm pack --loglevel notice 2>&1 >/dev/null | sed -e 's/^npm notice //' | tee build/package.txt && rm *.tgz", 82 | "lint": "prettier --cache --check '**/?(.)*.{md,css,scss,js,ts,tsx,json,yaml,yml,html}'", 83 | "format": "prettier --cache --write '**/?(.)*.{md,css,scss,js,ts,tsx,json,yaml,yml,html}'", 84 | "test:ci": "npm run lint -s && npm run build -s && npm run test -s -- --outputFile build/test-results.json --json", 85 | "prepare": "./.githooks/deploy.sh", 86 | "prepublishOnly": "npm run build -s" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/lib/api/lists.ts: -------------------------------------------------------------------------------- 1 | import { ContentBlock } from "draft-js"; 2 | 3 | // Default maximum block depth supported by Draft.js CSS. 4 | export const DRAFT_DEFAULT_MAX_DEPTH = 4; 5 | 6 | // Default depth class prefix from Draft.js CSS. 7 | export const DRAFT_DEFAULT_DEPTH_CLASS = "public-DraftStyleDefault-depth"; 8 | 9 | /** 10 | * Matching the counter styles of Google Docs and Draft.js v0.11. 11 | * See https://github.com/facebook/draft-js/commit/d2a3ae8. 12 | */ 13 | const COUNTER_STYLES = ["decimal", "lower-alpha", "lower-roman"]; 14 | 15 | /** 16 | * Generates CSS styles for list items, for a given selector pattern. 17 | * @deprecated Use getListNestingStyles instead, which has the same signature. 18 | * @param {string} selectorPrefix 19 | * @param {number} minDepth 20 | * @param {number} maxDepth 21 | * @param {Array} counterStyles 22 | */ 23 | export const generateListNestingStyles = ( 24 | selectorPrefix: string, 25 | minDepth: number, 26 | maxDepth: number, 27 | counterStyles: string[], 28 | ) => { 29 | let styles = ` 30 | .${selectorPrefix}1.public-DraftStyleDefault-orderedListItem::before { content: counter(ol1, ${ 31 | counterStyles[1 % counterStyles.length] 32 | }) ". "} 33 | .${selectorPrefix}2.public-DraftStyleDefault-orderedListItem::before { content: counter(ol2, ${ 34 | counterStyles[2 % counterStyles.length] 35 | }) ". "} 36 | .${selectorPrefix}4.public-DraftStyleDefault-orderedListItem::before { content: counter(ol4, ${ 37 | counterStyles[4 % counterStyles.length] 38 | }) ". "} 39 | `; 40 | 41 | for (let depth = minDepth; depth <= maxDepth; depth++) { 42 | const d = String(depth); 43 | const prefix = `${selectorPrefix}${d}`; 44 | const counter = `ol${d}`; 45 | const counterStyle = counterStyles[depth % counterStyles.length]; 46 | const margin = 1.5 * (depth + 1); 47 | const m = String(margin); 48 | 49 | styles += ` 50 | .${prefix}.public-DraftStyleDefault-listLTR { margin-left: ${m}em; } 51 | .${prefix}.public-DraftStyleDefault-listRTL { margin-right: ${m}em; } 52 | .${prefix}.public-DraftStyleDefault-orderedListItem::before { content: counter(${counter}, ${counterStyle}) '. '; counter-increment: ${counter}; } 53 | .${prefix}.public-DraftStyleDefault-reset { counter-reset: ${counter}; }`; 54 | } 55 | 56 | return styles; 57 | }; 58 | 59 | /** 60 | * Dynamically generates the right list nesting styles. 61 | * Can be wrapped as a pure component - to re-render only when `max` changes (eg. never). 62 | */ 63 | export const getListNestingStyles = ( 64 | maxDepth: number, 65 | minDepth: number = DRAFT_DEFAULT_MAX_DEPTH + 1, 66 | selectorPrefix: string = DRAFT_DEFAULT_DEPTH_CLASS, 67 | counterStyles: string[] = COUNTER_STYLES, 68 | ) => { 69 | return generateListNestingStyles( 70 | selectorPrefix, 71 | minDepth, 72 | maxDepth, 73 | counterStyles, 74 | ); 75 | }; 76 | 77 | /** 78 | * Add depth classes that Draft.js doesn't provide. 79 | * See https://github.com/facebook/draft-js/blob/232791a4e92d94a52c869f853f9869367bdabdac/src/component/contents/DraftEditorContents-core.react.js#L58-L62. 80 | * @param {ContentBlock} block 81 | */ 82 | export const blockDepthStyleFn = (block: ContentBlock) => { 83 | const depth = block.getDepth(); 84 | return depth > DRAFT_DEFAULT_MAX_DEPTH 85 | ? `${DRAFT_DEFAULT_DEPTH_CLASS}${String(depth)}` 86 | : ""; 87 | }; 88 | -------------------------------------------------------------------------------- /docs/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [@thibaudcolas](https://github.com/thibaudcolas) via email. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /docs/flow-typed/npm/draftjs-conductor_v3.0.0.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* eslint-disable */ 3 | import type { ElementRef } from "react"; 4 | import type { ContentState, ContentBlock, EditorState } from "draft-js"; 5 | import type { RawDraftContentState } from "draft-js/lib/RawDraftContentState"; 6 | import type { DraftDecoratorType } from "draft-js/lib/DraftDecoratorType"; 7 | import type { BlockNode } from "draft-js/lib/BlockNode"; 8 | 9 | declare module "draftjs-conductor" { 10 | /** 11 | * Generates CSS styles for list items, for a given selector pattern. 12 | * @deprecated Use getListNestingStyles instead, which has the same signature. 13 | * @param {string} selectorPrefix 14 | * @param {number} minDepth 15 | * @param {number} maxDepth 16 | * @param {Array} counterStyles 17 | */ 18 | declare export function generateListNestingStyles( 19 | selectorPrefix: string, 20 | minDepth: number, 21 | maxDepth: number, 22 | counterStyles: string[], 23 | ): string; 24 | /** 25 | * Dynamically generates the right list nesting styles. 26 | * Can be wrapped as a pure component - to re-render only when `max` changes (eg. never). 27 | */ 28 | declare export function getListNestingStyles( 29 | maxDepth: number, 30 | minDepth?: number, 31 | selectorPrefix?: string, 32 | counterStyles?: string[], 33 | ): string; 34 | /** 35 | * Add depth classes that Draft.js doesn't provide. 36 | * See https://github.com/facebook/draft-js/blob/232791a4e92d94a52c869f853f9869367bdabdac/src/component/contents/DraftEditorContents-core.react.js#L58-L62. 37 | * @param {ContentBlock} block 38 | */ 39 | declare export function blockDepthStyleFn(block: BlockNode): string; 40 | declare export function onDraftEditorCopy( 41 | editor: Editor, 42 | e: SyntheticClipboardEvent<>, 43 | ): void; 44 | declare export function onDraftEditorCut( 45 | editor: Editor, 46 | e: SyntheticClipboardEvent<>, 47 | ): void; 48 | /** 49 | * Registers custom copy/cut event listeners on an editor. 50 | */ 51 | declare export function registerCopySource(ref: ElementRef): { 52 | unregister(): void, 53 | }; 54 | /** 55 | * Returns pasted content coming from Draft.js editors set up to serialise 56 | * their Draft.js content within the HTML. 57 | */ 58 | declare export function getDraftEditorPastedContent( 59 | html: string | undefined, 60 | ): ContentState | null; 61 | /** 62 | * Handles pastes coming from Draft.js editors set up to serialise 63 | * their Draft.js content within the HTML. 64 | * This SHOULD NOT be used for stripPastedStyles editor. 65 | */ 66 | declare export function handleDraftEditorPastedText( 67 | html: string | undefined, 68 | editorState: EditorState, 69 | ): false | EditorState; 70 | 71 | /** 72 | * Creates a new EditorState from a RawDraftContentState, or an empty editor state by 73 | * passing `null`. Optionally takes a decorator. 74 | */ 75 | declare export function createEditorStateFromRaw( 76 | rawContentState: ?RawDraftContentState, 77 | decorator?: ?DraftDecoratorType, 78 | ): EditorState; 79 | /** 80 | * Serialises the editorState using `convertToRaw`, but returns `null` if 81 | * the editor content is empty (no text, entities, styles). 82 | */ 83 | declare export function serialiseEditorStateToRaw( 84 | editorState: EditorState, 85 | ): ?RawDraftContentState; 86 | } 87 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | extends: ["config:base"], 3 | // https://renovatebot.com/docs/configuration-options/#commitbodytable 4 | commitBodyTable: true, 5 | // https://docs.renovatebot.com/configuration-options/#dependencydashboard 6 | dependencyDashboard: false, 7 | // https://renovatebot.com/docs/configuration-options/#ignoredeps 8 | ignoreDeps: [ 9 | "immutable", 10 | "@types/draft-js", 11 | "draft-js", 12 | "draft-js-10", 13 | "normalize.css", 14 | "@types/react", 15 | "@types/react-dom", 16 | "react", 17 | "react-dom", 18 | "react-test-renderer", 19 | "jamesives/github-pages-deploy-action", 20 | ], 21 | // https://renovatebot.com/docs/configuration-options/#labels 22 | labels: ["enhancement"], 23 | // https://renovatebot.com/docs/configuration-options/#prcreation 24 | prCreation: "not-pending", 25 | // https://renovatebot.com/docs/configuration-options/#semanticcommits 26 | semanticCommits: true, 27 | // Use shorter commit messages to account for long dependency names. 28 | // https://docs.renovatebot.com/configuration-options/#commitmessagetopic 29 | commitMessageTopic: "{{depName}}", 30 | // https://renovatebot.com/docs/configuration-options/#prbodycolumns 31 | prBodyColumns: ["Package", "Update", "Type", "Change"], 32 | // https://renovatebot.com/docs/configuration-options/#schedule 33 | schedule: ["every weekend"], 34 | // Limit the number of consecutive PRs 35 | prHourlyLimit: 2, 36 | // Silently merge updates without PRs 37 | automergeType: "branch", 38 | node: { 39 | enabled: true, 40 | major: { 41 | enabled: true, 42 | }, 43 | // https://renovatebot.com/docs/node/#configuring-support-policy 44 | supportPolicy: ["current"], 45 | }, 46 | vulnerabilityAlerts: { 47 | automerge: true, 48 | }, 49 | packageRules: [ 50 | { 51 | packageNames: ["prettier"], 52 | groupName: "prettier", 53 | automerge: true, 54 | }, 55 | { 56 | packageNames: ["coveralls"], 57 | groupName: "coveralls", 58 | automerge: true, 59 | }, 60 | { 61 | packageNames: ["danger"], 62 | groupName: "danger", 63 | automerge: true, 64 | }, 65 | { 66 | packageNames: ["@types/jest", "react-scripts"], 67 | groupName: "react-scripts", 68 | automerge: true, 69 | }, 70 | { 71 | packageNames: ["typescript"], 72 | groupName: "typescript", 73 | automerge: true, 74 | }, 75 | { 76 | packagePatterns: ["^@commitlint"], 77 | groupName: "commitlint", 78 | automerge: true, 79 | }, 80 | { 81 | packagePatterns: ["^enzyme"], 82 | groupName: "enzyme", 83 | automerge: true, 84 | automergeType: "branch", 85 | }, 86 | { 87 | packagePatterns: ["^rollup", "^@rollup"], 88 | groupName: "rollup", 89 | automerge: true, 90 | }, 91 | { 92 | packageNames: ["snapshot-diff"], 93 | groupName: "snapshot-diff", 94 | automerge: true, 95 | }, 96 | { 97 | packagePatterns: ["^semantic-release", "^@semantic-release"], 98 | groupName: "semantic-release", 99 | automerge: true, 100 | }, 101 | { 102 | packageNames: ["JamesIves/github-pages-deploy-action"], 103 | groupName: "JamesIves/github-pages-deploy-action", 104 | automerge: true, 105 | }, 106 | { 107 | packagePatterns: ["^actions/"], 108 | groupName: "actions", 109 | automerge: true, 110 | }, 111 | ], 112 | } 113 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /dangerfile.js: -------------------------------------------------------------------------------- 1 | const { danger, message, warn, fail, schedule } = require("danger"); 2 | const semanticRelease = require("semantic-release"); 3 | const envCi = require("env-ci"); 4 | const isLocal = !danger.github; 5 | 6 | const libModifiedFiles = danger.git.modified_files.filter( 7 | (path) => path.startsWith("src/lib") && path.endsWith("js"), 8 | ); 9 | const hasLibChanges = 10 | libModifiedFiles.filter((filepath) => !filepath.endsWith("test.js")).length > 11 | 0; 12 | const hasLibTestChanges = 13 | libModifiedFiles.filter( 14 | (filepath) => 15 | filepath.endsWith("test.js") || filepath.endsWith("test.js.snap"), 16 | ).length > 0; 17 | const hasREADMEChanges = danger.git.modified_files.includes("README.md"); 18 | 19 | if (!isLocal) { 20 | const hasLabels = danger.github.issue.labels.length !== 0; 21 | const isEnhancement = 22 | danger.github.issue.labels.some((l) => l.name === "enhancement") || 23 | danger.github.pr.title.includes("feature"); 24 | const isBug = 25 | danger.github.issue.labels.some((l) => l.name === "bug") || 26 | danger.github.pr.title.includes("fix") || 27 | danger.github.pr.title.includes("bug"); 28 | 29 | if (!hasLabels) { 30 | message("What labels should we add to this PR?"); 31 | } 32 | 33 | // Fails if the description is too short. 34 | if (!danger.github.pr.body || danger.github.pr.body.length < 10) { 35 | fail(":grey_question: This pull request needs a description."); 36 | } 37 | 38 | if (hasLibChanges && !hasLibTestChanges && (isEnhancement || isBug)) { 39 | message("This PR may require new test cases"); 40 | } 41 | 42 | // Warns if the PR title contains [WIP] 43 | const isWIP = danger.github.pr.title.includes("WIP"); 44 | if (isWIP) { 45 | const title = ":construction_worker: Work In Progress"; 46 | const idea = 47 | "This PR appears to be a work in progress, and may not be ready to be merged yet."; 48 | warn(`${title} - ${idea}`); 49 | } 50 | } 51 | 52 | if (hasLibChanges && !hasREADMEChanges) { 53 | warn("This pull request updates the library. Should the docs be updated?"); 54 | } 55 | 56 | const hasPackageChanges = danger.git.modified_files.includes("package.json"); 57 | const hasLockfileChanges = 58 | danger.git.modified_files.includes("package-lock.json"); 59 | 60 | if (hasPackageChanges && !hasLockfileChanges) { 61 | warn("There are package.json changes with no corresponding lockfile changes"); 62 | } 63 | 64 | const linkDep = (dep) => 65 | danger.utils.href(`https://www.npmjs.com/package/${dep}`, dep); 66 | 67 | schedule(async () => { 68 | const packageDiff = await danger.git.JSONDiffForFile("package.json"); 69 | 70 | if (packageDiff.dependencies) { 71 | const added = packageDiff.dependencies.added; 72 | const removed = packageDiff.dependencies.removed; 73 | 74 | if (added.length) { 75 | const deps = danger.utils.sentence(added.map((d) => linkDep(d))); 76 | message(`Adding new dependencies: ${deps}`); 77 | } 78 | 79 | if (removed.length) { 80 | const deps = danger.utils.sentence(removed.map((d) => linkDep(d))); 81 | message(`:tada:, removing dependencies: ${deps}`); 82 | } 83 | 84 | if (added.includes("draft-js")) { 85 | warn( 86 | ":scream: this PR updates Draft.js! Please make sure to review the Draft.js CHANGELOG.", 87 | ); 88 | } 89 | } 90 | }); 91 | 92 | if (!isLocal) { 93 | schedule(async () => { 94 | // Retrieve the current branch so semantic-release is configured as if it was to make a release from it. 95 | const { branch } = envCi(); 96 | 97 | const result = await semanticRelease({ dryRun: true, branch }); 98 | if (result.nextRelease) { 99 | message( 100 | `:tada: Merging this will publish a new ${result.nextRelease.type} release, v${result.nextRelease.version}.`, 101 | ); 102 | } 103 | }); 104 | } 105 | -------------------------------------------------------------------------------- /src/lib/api/lists.test.ts: -------------------------------------------------------------------------------- 1 | import prettier from "prettier"; 2 | import { ContentBlock } from "draft-js"; 3 | 4 | import { 5 | getListNestingStyles, 6 | DRAFT_DEFAULT_MAX_DEPTH, 7 | DRAFT_DEFAULT_DEPTH_CLASS, 8 | generateListNestingStyles, 9 | blockDepthStyleFn, 10 | } from "./lists"; 11 | 12 | describe("generateListNestingStyles", () => { 13 | it("works", () => { 14 | const styles = prettier.format( 15 | generateListNestingStyles("TEST", 0, 2, [ 16 | "decimal", 17 | "lower-alpha", 18 | "lower-roman", 19 | ]), 20 | { 21 | parser: "css", 22 | }, 23 | ); 24 | expect(styles).toMatchInlineSnapshot(` 25 | ".TEST1.public-DraftStyleDefault-orderedListItem::before { 26 | content: counter(ol1, lower-alpha) \\". \\"; 27 | } 28 | .TEST2.public-DraftStyleDefault-orderedListItem::before { 29 | content: counter(ol2, lower-roman) \\". \\"; 30 | } 31 | .TEST4.public-DraftStyleDefault-orderedListItem::before { 32 | content: counter(ol4, lower-alpha) \\". \\"; 33 | } 34 | 35 | .TEST0.public-DraftStyleDefault-listLTR { 36 | margin-left: 1.5em; 37 | } 38 | .TEST0.public-DraftStyleDefault-listRTL { 39 | margin-right: 1.5em; 40 | } 41 | .TEST0.public-DraftStyleDefault-orderedListItem::before { 42 | content: counter(ol0, decimal) \\". \\"; 43 | counter-increment: ol0; 44 | } 45 | .TEST0.public-DraftStyleDefault-reset { 46 | counter-reset: ol0; 47 | } 48 | .TEST1.public-DraftStyleDefault-listLTR { 49 | margin-left: 3em; 50 | } 51 | .TEST1.public-DraftStyleDefault-listRTL { 52 | margin-right: 3em; 53 | } 54 | .TEST1.public-DraftStyleDefault-orderedListItem::before { 55 | content: counter(ol1, lower-alpha) \\". \\"; 56 | counter-increment: ol1; 57 | } 58 | .TEST1.public-DraftStyleDefault-reset { 59 | counter-reset: ol1; 60 | } 61 | .TEST2.public-DraftStyleDefault-listLTR { 62 | margin-left: 4.5em; 63 | } 64 | .TEST2.public-DraftStyleDefault-listRTL { 65 | margin-right: 4.5em; 66 | } 67 | .TEST2.public-DraftStyleDefault-orderedListItem::before { 68 | content: counter(ol2, lower-roman) \\". \\"; 69 | counter-increment: ol2; 70 | } 71 | .TEST2.public-DraftStyleDefault-reset { 72 | counter-reset: ol2; 73 | } 74 | " 75 | `); 76 | }); 77 | }); 78 | 79 | describe("getListNestingStyles", () => { 80 | it("works", () => { 81 | expect(getListNestingStyles(0)).toMatchInlineSnapshot(` 82 | " 83 | .public-DraftStyleDefault-depth1.public-DraftStyleDefault-orderedListItem::before { content: counter(ol1, lower-alpha) \\". \\"} 84 | .public-DraftStyleDefault-depth2.public-DraftStyleDefault-orderedListItem::before { content: counter(ol2, lower-roman) \\". \\"} 85 | .public-DraftStyleDefault-depth4.public-DraftStyleDefault-orderedListItem::before { content: counter(ol4, lower-alpha) \\". \\"} 86 | " 87 | `); 88 | }); 89 | 90 | it("max > DRAFT_DEFAULT_MAX_DEPTH", () => { 91 | expect(getListNestingStyles(DRAFT_DEFAULT_MAX_DEPTH + 1)) 92 | .toMatchInlineSnapshot(` 93 | " 94 | .public-DraftStyleDefault-depth1.public-DraftStyleDefault-orderedListItem::before { content: counter(ol1, lower-alpha) \\". \\"} 95 | .public-DraftStyleDefault-depth2.public-DraftStyleDefault-orderedListItem::before { content: counter(ol2, lower-roman) \\". \\"} 96 | .public-DraftStyleDefault-depth4.public-DraftStyleDefault-orderedListItem::before { content: counter(ol4, lower-alpha) \\". \\"} 97 | 98 | .public-DraftStyleDefault-depth5.public-DraftStyleDefault-listLTR { margin-left: 9em; } 99 | .public-DraftStyleDefault-depth5.public-DraftStyleDefault-listRTL { margin-right: 9em; } 100 | .public-DraftStyleDefault-depth5.public-DraftStyleDefault-orderedListItem::before { content: counter(ol5, lower-roman) '. '; counter-increment: ol5; } 101 | .public-DraftStyleDefault-depth5.public-DraftStyleDefault-reset { counter-reset: ol5; }" 102 | `); 103 | }); 104 | }); 105 | 106 | describe("blockDepthStyleFn", () => { 107 | it("works", () => { 108 | expect( 109 | blockDepthStyleFn( 110 | new ContentBlock({ 111 | depth: 0, 112 | }), 113 | ), 114 | ).toEqual(""); 115 | }); 116 | 117 | it("depth > DRAFT_DEFAULT_MAX_DEPTH", () => { 118 | expect( 119 | blockDepthStyleFn( 120 | new ContentBlock({ 121 | depth: DRAFT_DEFAULT_MAX_DEPTH + 1, 122 | }), 123 | ), 124 | ).toEqual(`${DRAFT_DEFAULT_DEPTH_CLASS}${DRAFT_DEFAULT_MAX_DEPTH + 1}`); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /src/demo/components/__snapshots__/DemoEditor.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`DemoEditor #extended can take predefined content 1`] = ` 4 |
7 | 13 | 19 | 25 | 31 | 37 | 43 | 49 | 55 | 61 | 67 | 73 | 79 | 85 | 90 | 95 |
96 | `; 97 | 98 | exports[`DemoEditor #extended works 1`] = ` 99 |
102 | 108 | 114 | 120 | 126 | 132 | 138 | 144 | 150 | 156 | 162 | 168 | 174 | 180 | 186 | 192 | 198 | 204 | 210 | 216 | 222 | 227 | 232 |
233 | `; 234 | 235 | exports[`DemoEditor renders 1`] = ` 236 |
239 | 245 | 251 | 257 | 263 | 269 | 275 | 281 | 287 | 293 | 299 | 305 | 311 | 317 | 322 | 327 |
328 | `; 329 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | Draft.js conductor 11 | 15 | 16 | 17 | 22 | 28 | 34 | 35 | 40 | 41 | 42 | 43 | 47 | 48 | 49 | 53 | 54 | 55 | 56 | 60 | 64 | 65 | 66 | 70 | 71 | 72 | 102 | 103 |
104 | 136 |
137 |
138 |
139 | 161 |
162 | 163 | %REACT_APP_RAVEN% 164 | 165 | 166 | -------------------------------------------------------------------------------- /src/demo/components/App.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "react"; 2 | 3 | import "./App.css"; 4 | 5 | import DemoEditor from "../components/DemoEditor"; 6 | import { RawDraftContentState } from "draft-js"; 7 | 8 | const copyPasteContent = { 9 | blocks: [ 10 | { 11 | key: "8qfte", 12 | text: "Copy this content", 13 | type: "header-two", 14 | depth: 0, 15 | inlineStyleRanges: [], 16 | entityRanges: [], 17 | }, 18 | { 19 | key: "6m80m", 20 | text: " ", 21 | type: "atomic", 22 | depth: 0, 23 | inlineStyleRanges: [], 24 | entityRanges: [ 25 | { 26 | offset: 0, 27 | length: 1, 28 | key: 0, 29 | }, 30 | ], 31 | }, 32 | { 33 | key: "c0ala", 34 | text: "", 35 | type: "unstyled", 36 | depth: 0, 37 | inlineStyleRanges: [], 38 | entityRanges: [], 39 | }, 40 | { 41 | key: "7knr3", 42 | text: "From here", 43 | type: "header-three", 44 | depth: 0, 45 | inlineStyleRanges: [], 46 | entityRanges: [], 47 | }, 48 | { 49 | key: "er6ke", 50 | text: "To the editor below!", 51 | type: "ordered-list-item", 52 | depth: 0, 53 | inlineStyleRanges: [], 54 | entityRanges: [], 55 | }, 56 | { 57 | key: "47a3o", 58 | text: " ", 59 | type: "atomic", 60 | depth: 0, 61 | inlineStyleRanges: [], 62 | entityRanges: [ 63 | { 64 | offset: 0, 65 | length: 1, 66 | key: 1, 67 | }, 68 | ], 69 | }, 70 | { 71 | key: "826u0", 72 | text: "Numbered list", 73 | type: "ordered-list-item", 74 | depth: 0, 75 | inlineStyleRanges: [], 76 | entityRanges: [], 77 | }, 78 | ], 79 | entityMap: { 80 | 2: { 81 | type: "HORIZONTAL_RULE", 82 | mutability: "IMMUTABLE", 83 | data: {}, 84 | }, 85 | 1: { 86 | type: "SNIPPET", 87 | mutability: "IMMUTABLE", 88 | data: { 89 | text: "Content of the snippet goes here", 90 | }, 91 | }, 92 | }, 93 | } as RawDraftContentState; 94 | 95 | const listNestingContent = { 96 | blocks: [ 97 | { 98 | key: "ako0c", 99 | text: "Infinite", 100 | type: "ordered-list-item", 101 | depth: 0, 102 | entityRanges: [], 103 | inlineStyleRanges: [], 104 | }, 105 | { 106 | key: "adreo", 107 | text: "Nested", 108 | type: "ordered-list-item", 109 | depth: 1, 110 | entityRanges: [], 111 | inlineStyleRanges: [], 112 | }, 113 | { 114 | key: "bm3ec", 115 | text: "List", 116 | type: "ordered-list-item", 117 | depth: 2, 118 | entityRanges: [], 119 | inlineStyleRanges: [], 120 | }, 121 | { 122 | key: "aqg1s", 123 | text: "Nesting", 124 | type: "ordered-list-item", 125 | depth: 3, 126 | entityRanges: [], 127 | inlineStyleRanges: [], 128 | }, 129 | { 130 | key: "4dns4", 131 | text: "Styles", 132 | type: "ordered-list-item", 133 | depth: 4, 134 | entityRanges: [], 135 | inlineStyleRanges: [], 136 | }, 137 | { 138 | key: "5k6tv", 139 | text: "Work", 140 | type: "ordered-list-item", 141 | depth: 5, 142 | entityRanges: [], 143 | inlineStyleRanges: [], 144 | }, 145 | { 146 | key: "9htu8", 147 | text: "For", 148 | type: "ordered-list-item", 149 | depth: 6, 150 | entityRanges: [], 151 | inlineStyleRanges: [], 152 | }, 153 | { 154 | key: "at7om", 155 | text: "As", 156 | type: "ordered-list-item", 157 | depth: 7, 158 | entityRanges: [], 159 | inlineStyleRanges: [], 160 | }, 161 | { 162 | key: "8fddl", 163 | text: "Many", 164 | type: "ordered-list-item", 165 | depth: 8, 166 | entityRanges: [], 167 | inlineStyleRanges: [], 168 | }, 169 | { 170 | key: "2ja3i", 171 | text: "Levels", 172 | type: "ordered-list-item", 173 | depth: 9, 174 | entityRanges: [], 175 | inlineStyleRanges: [], 176 | }, 177 | { 178 | key: "cv49i", 179 | text: "As", 180 | type: "ordered-list-item", 181 | depth: 10, 182 | entityRanges: [], 183 | inlineStyleRanges: [], 184 | }, 185 | { 186 | key: "4aoq9", 187 | text: "Configured", 188 | type: "ordered-list-item", 189 | depth: 11, 190 | entityRanges: [], 191 | inlineStyleRanges: [], 192 | }, 193 | { 194 | key: "d4hhk", 195 | text: "Here", 196 | type: "ordered-list-item", 197 | depth: 12, 198 | entityRanges: [], 199 | inlineStyleRanges: [], 200 | }, 201 | { 202 | key: "bbeuk", 203 | text: "Up", 204 | type: "ordered-list-item", 205 | depth: 13, 206 | entityRanges: [], 207 | inlineStyleRanges: [], 208 | }, 209 | { 210 | key: "6s9a8", 211 | text: "To", 212 | type: "ordered-list-item", 213 | depth: 14, 214 | entityRanges: [], 215 | inlineStyleRanges: [], 216 | }, 217 | { 218 | key: "48sq1", 219 | text: "15!", 220 | type: "ordered-list-item", 221 | depth: 15, 222 | entityRanges: [], 223 | inlineStyleRanges: [], 224 | }, 225 | ], 226 | entityMap: {}, 227 | }; 228 | 229 | class App extends Component<{}> { 230 | render() { 231 | return ( 232 |
233 |

Idempotent copy-paste between editors

234 |

235 | The default Draft.js copy-paste handlers lose a lot of the formatting 236 | when copy-pasting between Draft.js editors. While this might be ok for 237 | some use cases, sites with multiple editors on the same page need them 238 | to reliably support copy-paste. 239 |

240 | 241 | 242 |

Infinite list nesting

243 |

244 | By default, Draft.js only provides support for 5 list levels for 245 | bulleted and numbered lists. While this is often more than enough, 246 | some editors need to go further. This provides infinite list nesting 247 | styles. 248 |

249 | 250 |
251 | ); 252 | } 253 | } 254 | 255 | export default App; 256 | -------------------------------------------------------------------------------- /src/lib/api/copypaste.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error 2 | import getContentStateFragment from "draft-js/lib/getContentStateFragment"; 3 | // @ts-expect-error 4 | import getDraftEditorSelection from "draft-js/lib/getDraftEditorSelection"; 5 | // @ts-expect-error 6 | import editOnCopy from "draft-js/lib/editOnCopy"; 7 | // @ts-expect-error 8 | import editOnCut from "draft-js/lib/editOnCut"; 9 | 10 | import { 11 | Editor, 12 | EditorState, 13 | Modifier, 14 | convertToRaw, 15 | convertFromRaw, 16 | ContentState, 17 | ContentBlock, 18 | } from "draft-js"; 19 | 20 | import React, { ElementRef } from "react"; 21 | 22 | // Custom attribute to store Draft.js content in the HTML clipboard. 23 | const FRAGMENT_ATTR = "data-draftjs-conductor-fragment"; 24 | 25 | const DRAFT_DECORATOR = '[data-contents="true"] [contenteditable="false"]'; 26 | 27 | // Checks whether the selection is inside a decorator or not. 28 | // This is important to change the copy-cut behavior accordingly. 29 | const isSelectionInDecorator = (selection: Selection) => { 30 | const { anchorNode, focusNode } = selection; 31 | if (!anchorNode || !focusNode) { 32 | return false; 33 | } 34 | 35 | const anchor = 36 | anchorNode instanceof Element ? anchorNode : anchorNode.parentElement; 37 | const focus = 38 | focusNode instanceof Element ? focusNode : focusNode.parentElement; 39 | 40 | const anchorDecorator = anchor && anchor.closest(DRAFT_DECORATOR); 41 | const focusDecorator = focus && focus.closest(DRAFT_DECORATOR); 42 | 43 | return ( 44 | anchorDecorator && 45 | focusDecorator && 46 | (anchorDecorator.contains(focusDecorator) || 47 | focusDecorator.contains(anchorDecorator)) 48 | ); 49 | }; 50 | 51 | // Get clipboard content from the selection like Draft.js would. 52 | const getSelectedContent = ( 53 | editorState: EditorState, 54 | editorRoot: HTMLElement, 55 | ) => { 56 | const { selectionState } = getDraftEditorSelection(editorState, editorRoot); 57 | 58 | const fragment = getContentStateFragment( 59 | editorState.getCurrentContent(), 60 | selectionState, 61 | ); 62 | 63 | // If the selection contains no content (according to Draft.js), use the default browser behavior. 64 | // This happens when selecting text that's within contenteditable=false blocks in Draft.js. 65 | // See https://github.com/thibaudcolas/draftjs-conductor/issues/12. 66 | const isEmpty = fragment.every((block: ContentBlock) => { 67 | return block.getText().length === 0; 68 | }); 69 | 70 | return isEmpty ? null : fragment; 71 | }; 72 | 73 | // Overrides the default copy/cut behavior, adding the serialised Draft.js content to the clipboard data. 74 | // See also https://github.com/basecamp/trix/blob/62145978f352b8d971cf009882ba06ca91a16292/src/trix/controllers/input_controller.coffee#L415-L422 75 | // We serialise the editor content within HTML, not as a separate mime type, because Draft.js only allows access 76 | // to HTML in its paste event handler. 77 | const draftEditorCopyCutListener = ( 78 | // @ts-expect-error 79 | ref: ElementRef, 80 | e: React.ClipboardEvent, 81 | ) => { 82 | const selection = window.getSelection() as Selection; 83 | 84 | // Completely skip event handling if clipboardData is not supported (IE11 is out). 85 | // Also skip if there is no selection ranges. 86 | // Or if the selection is fully within a decorator. 87 | if ( 88 | !e.clipboardData || 89 | selection.rangeCount === 0 || 90 | isSelectionInDecorator(selection) 91 | ) { 92 | return; 93 | } 94 | 95 | // @ts-expect-error 96 | const fragment = getSelectedContent(ref._latestEditorState, ref.editor); 97 | 98 | // Override the default behavior if there is selected content. 99 | if (fragment) { 100 | const content = ContentState.createFromBlockArray(fragment.toArray()); 101 | const serialisedContent = JSON.stringify(convertToRaw(content)); 102 | 103 | // Create a temporary element to store the selection’s HTML. 104 | // See also Rangy's implementation: https://github.com/timdown/rangy/blob/1e55169d2e4d1d9458c2a87119addf47a8265276/src/core/domrange.js#L515-L520. 105 | const fragmentElt = document.createElement("div"); 106 | // Modern browsers only support a single range. 107 | fragmentElt.appendChild(selection.getRangeAt(0).cloneContents()); 108 | fragmentElt.setAttribute(FRAGMENT_ATTR, serialisedContent); 109 | // We set the style property to replicate the browser's behavior of inline styles in rich text copy-paste. 110 | // In Draft.js, this is important for line breaks to be interpreted correctly when pasted into another word processor. 111 | // See https://github.com/facebook/draft-js/blob/a1f4593d8fa949954053e5d5840d33ce1d1082c6/src/component/base/DraftEditor.react.js#L328. 112 | fragmentElt.setAttribute("style", "white-space: pre-wrap;"); 113 | 114 | e.clipboardData.setData("text/plain", selection.toString()); 115 | e.clipboardData.setData("text/html", fragmentElt.outerHTML); 116 | 117 | e.preventDefault(); 118 | } 119 | }; 120 | 121 | export const onDraftEditorCopy = ( 122 | editor: Editor, 123 | e: React.ClipboardEvent, 124 | ) => { 125 | // @ts-expect-error 126 | draftEditorCopyCutListener(editor, e); 127 | editOnCopy(editor, e); 128 | }; 129 | 130 | export const onDraftEditorCut = ( 131 | editor: Editor, 132 | e: React.ClipboardEvent, 133 | ) => { 134 | // @ts-expect-error 135 | draftEditorCopyCutListener(editor, e); 136 | editOnCut(editor, e); 137 | }; 138 | 139 | /** 140 | * Registers custom copy/cut event listeners on an editor. 141 | */ 142 | // @ts-expect-error 143 | export const registerCopySource = (ref: ElementRef) => { 144 | // @ts-expect-error 145 | const editorElt = ref.editor; 146 | const onCopyCut = draftEditorCopyCutListener.bind(null, ref); 147 | 148 | editorElt.addEventListener("copy", onCopyCut); 149 | editorElt.addEventListener("cut", onCopyCut); 150 | 151 | return { 152 | unregister() { 153 | editorElt.removeEventListener("copy", onCopyCut); 154 | editorElt.removeEventListener("cut", onCopyCut); 155 | }, 156 | }; 157 | }; 158 | 159 | /** 160 | * Returns pasted content coming from Draft.js editors set up to serialise 161 | * their Draft.js content within the HTML. 162 | */ 163 | export const getDraftEditorPastedContent = (html: string | undefined) => { 164 | // Plain-text pastes are better handled by Draft.js. 165 | if (html === "" || typeof html === "undefined" || html === null) { 166 | return null; 167 | } 168 | 169 | const doc = new DOMParser().parseFromString(html, "text/html"); 170 | const fragmentElt = doc.querySelector(`[${FRAGMENT_ATTR}]`); 171 | 172 | // Handle the paste if it comes from draftjs-conductor. 173 | if (fragmentElt) { 174 | const fragmentAttr = fragmentElt.getAttribute(FRAGMENT_ATTR); 175 | let rawContent; 176 | 177 | try { 178 | // If JSON parsing fails, leave paste handling to Draft.js. 179 | // There is no reason for this to happen, unless the clipboard was altered somehow. 180 | // @ts-expect-error 181 | rawContent = JSON.parse(fragmentAttr); 182 | } catch (error) { 183 | return null; 184 | } 185 | 186 | return convertFromRaw(rawContent); 187 | } 188 | 189 | return null; 190 | }; 191 | 192 | /** 193 | * Handles pastes coming from Draft.js editors set up to serialise 194 | * their Draft.js content within the HTML. 195 | * This SHOULD NOT be used for stripPastedStyles editor. 196 | */ 197 | export const handleDraftEditorPastedText = ( 198 | html: string | undefined, 199 | editorState: EditorState, 200 | ) => { 201 | const pastedContent = getDraftEditorPastedContent(html); 202 | 203 | if (pastedContent) { 204 | const fragment = pastedContent.getBlockMap(); 205 | 206 | const content = Modifier.replaceWithFragment( 207 | editorState.getCurrentContent(), 208 | editorState.getSelection(), 209 | fragment, 210 | ); 211 | return EditorState.push(editorState, content, "insert-fragment"); 212 | } 213 | 214 | return false; 215 | }; 216 | -------------------------------------------------------------------------------- /src/demo/components/__snapshots__/App.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`App renders 1`] = ` 4 |
7 |

8 | Idempotent copy-paste between editors 9 |

10 |

11 | The default Draft.js copy-paste handlers lose a lot of the formatting when copy-pasting between Draft.js editors. While this might be ok for some use cases, sites with multiple editors on the same page need them to reliably support copy-paste. 12 |

13 | 104 | 108 |

109 | Infinite list nesting 110 |

111 |

112 | By default, Draft.js only provides support for 5 list levels for bulleted and numbered lists. While this is often more than enough, some editors need to go further. This provides infinite list nesting styles. 113 |

114 | 252 |
253 | `; 254 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), enforced with [semantic-release](https://github.com/semantic-release/semantic-release). 6 | 7 | # [3.0.0](https://github.com/thibaudcolas/draftjs-conductor/compare/v2.2.0...v3.0.0) (2022-06-13) 8 | 9 | ### Features 10 | 11 | - **api:** convert whole package API to TypeScript ([3fca3fa](https://github.com/thibaudcolas/draftjs-conductor/commit/3fca3fa9002bddd118d19ec0f0b91bb18ec25df9)) 12 | 13 | ### BREAKING CHANGES 14 | 15 | - **api:** All helpers are now written in TypeScript. 16 | 17 | Flow types are no longer available, and TypeScript types are built-in. 18 | 19 | # [2.2.0](https://github.com/thibaudcolas/draftjs-conductor/compare/v2.1.0...v2.2.0) (2021-04-14) 20 | 21 | ### Features 22 | 23 | - **lists:** support specifying an arbitrary number of ol counter styles ([921a5d3](https://github.com/thibaudcolas/draftjs-conductor/commit/921a5d37ac41d34001bfc0e581683c1ad47d9a75)) 24 | 25 | # [2.1.0](https://github.com/thibaudcolas/draftjs-conductor/compare/v2.0.0...v2.1.0) (2021-04-13) 26 | 27 | ### Features 28 | 29 | - **lists:** add different numeral list styles per depth level ([d309382](https://github.com/thibaudcolas/draftjs-conductor/commit/d30938232d881840ba051b15812db3717f66a9b9)) 30 | 31 | # [2.0.0](https://github.com/thibaudcolas/draftjs-conductor/compare/v1.2.0...v2.0.0) (2020-11-19) 32 | 33 | ### Features 34 | 35 | - **api:** replace list nesting APIs with a single getListNestingStyles ([3703b2f](https://github.com/thibaudcolas/draftjs-conductor/commit/3703b2fae1a5e83946041f72b107f58f3b59038b)) 36 | - **deps:** proactively declare support with Draft.js v0.12.0 ([586b385](https://github.com/thibaudcolas/draftjs-conductor/commit/586b38511fa6ca7a32a16d16340640b3d9fc60fd)) 37 | 38 | ### BREAKING CHANGES 39 | 40 | - **api:** The `` component has been removed, 41 | and the `generateListNestingStyles` method is now deprecated and 42 | will be removed in a future release. 43 | 44 | Both are replaced with a `getListNestingStyles` method, which works exactly the same as 45 | `generateListNestingStyles`, but with a different parameter order, and with default values: 46 | 47 | ```js 48 | export const getListNestingStyles = ( 49 | maxDepth: number, 50 | minDepth: number = DRAFT_DEFAULT_MAX_DEPTH + 1, 51 | selectorPrefix: string = DRAFT_DEFAULT_DEPTH_CLASS, 52 | ) => { 53 | return generateListNestingStyles(selectorPrefix, minDepth, maxDepth); 54 | }; 55 | ``` 56 | 57 | This small breaking change allows us to remove this package’s peerDependency on React, 58 | making it easier to upgrade to React 17, and other versions in the future. 59 | 60 | # [1.2.0](https://github.com/thibaudcolas/draftjs-conductor/compare/v1.1.0...v1.2.0) (2020-11-19) 61 | 62 | ### Features 63 | 64 | - **api:** onDraftEditorCopy, onDraftEditorCut for draft-js@0.11 ([#268](https://github.com/thibaudcolas/draftjs-conductor/issues/268)) ([05b31cb](https://github.com/thibaudcolas/draftjs-conductor/commit/05b31cb8bb400ee2fb59cb80a482410fc24506c4)) 65 | 66 | # [1.1.0](https://github.com/thibaudcolas/draftjs-conductor/compare/v1.0.1...v1.1.0) (2020-08-16) 67 | 68 | ### Features 69 | 70 | - **api:** add new getDraftEditorPastedContent method ([#226](https://github.com/thibaudcolas/draftjs-conductor/issues/226)) ([fcaada5](https://github.com/thibaudcolas/draftjs-conductor/commit/fcaada5b74b802863f0cd29be436eb93ccfd22cc)) 71 | 72 | ## [1.0.1](https://github.com/thibaudcolas/draftjs-conductor/compare/v1.0.0...v1.0.1) (2020-01-20) 73 | 74 | ### Bug Fixes 75 | 76 | - **deps:** allow draft-js ^0.11.0 as a peer dependency ([1b0cfa3](https://github.com/thibaudcolas/draftjs-conductor/commit/1b0cfa3490add0307fe2794a20e3eccb3248d41d)) 77 | 78 | # [1.0.0](https://github.com/thibaudcolas/draftjs-conductor/compare/v0.5.2...v1.0.0) (2019-08-14) 79 | 80 | > This release is functionally identical to `v0.5.2`. 81 | 82 | The project has reached a high-enough level of stability to be used in production, and breaking changes will now be reflected via major version changes. 83 | 84 | ## [0.5.2](https://github.com/thibaudcolas/draftjs-conductor/compare/v0.5.1...v0.5.2) (2019-08-13) 85 | 86 | ### Bug Fixes 87 | 88 | - **release:** prevent tarballs from being published in npm tarball ([96d0765](https://github.com/thibaudcolas/draftjs-conductor/commit/96d0765)) 89 | 90 | ## [0.5.1](https://github.com/thibaudcolas/draftjs-conductor/compare/v0.5.0...v0.5.1) (2019-08-13) 91 | 92 | ### Bug Fixes 93 | 94 | - **api:** add .flow typing file to restore type checks on CJS imports ([cb73a81](https://github.com/thibaudcolas/draftjs-conductor/commit/cb73a81)) 95 | 96 | # [0.5.0](https://github.com/thibaudcolas/draftjs-conductor/compare/v0.4.5...v0.5.0) (2019-08-13) 97 | 98 | ### Features 99 | 100 | - **api:** add new data conversion helper methods ([355c88e](https://github.com/thibaudcolas/draftjs-conductor/commit/355c88e)) 101 | 102 | ## [0.4.5](https://github.com/thibaudcolas/draftjs-conductor/compare/v0.4.4...v0.4.5) (2019-07-04) 103 | 104 | ### Bug Fixes 105 | 106 | - **api:** disable Flow types in CommonJS build ([023f6b0](https://github.com/thibaudcolas/draftjs-conductor/commit/023f6b0)) 107 | - **package:** use ES6 import instead of require for draft-js/lib deps ([9bcea6b](https://github.com/thibaudcolas/draftjs-conductor/commit/9bcea6b)) 108 | 109 | ## [0.4.4](https://github.com/thibaudcolas/draftjs-conductor/compare/v0.4.3...v0.4.4) (2019-05-28) 110 | 111 | ### Bug Fixes 112 | 113 | - **copy-paste:** fix partial copy from decorator text. Fix [#12](https://github.com/thibaudcolas/draftjs-conductor/issues/12) ([e043b74](https://github.com/thibaudcolas/draftjs-conductor/commit/e043b74)) 114 | - **copy-paste:** support copy from decorators. Fix [#12](https://github.com/thibaudcolas/draftjs-conductor/issues/12) ([d90bbbc](https://github.com/thibaudcolas/draftjs-conductor/commit/d90bbbc)) 115 | - **release:** remove unneeded react-dom peerDependency ([3e59f05](https://github.com/thibaudcolas/draftjs-conductor/commit/3e59f05)) 116 | 117 | ### Performance Improvements 118 | 119 | - **copy-paste:** completely skip event handling operations in IE11 ([9521758](https://github.com/thibaudcolas/draftjs-conductor/commit/9521758)) 120 | 121 | ## [0.4.3](https://github.com/thibaudcolas/draftjs-conductor/compare/v0.4.2...v0.4.3) (2019-04-21) 122 | 123 | ### Bug Fixes 124 | 125 | - **selection:** use getContentStateFragment for readonly copy. Fix [#14](https://github.com/thibaudcolas/draftjs-conductor/issues/14) ([0483d82](https://github.com/thibaudcolas/draftjs-conductor/commit/0483d82)) 126 | 127 | ## [0.4.2](https://github.com/thibaudcolas/draftjs-conductor/compare/v0.4.1...v0.4.2) (2019-04-21) 128 | 129 | ### Bug Fixes 130 | 131 | - **api:** update typing so compiled code still validates with Flow ([d065ce7](https://github.com/thibaudcolas/draftjs-conductor/commit/d065ce7)) 132 | 133 | ## [0.4.1](https://github.com/thibaudcolas/draftjs-conductor/compare/v0.4.0...v0.4.1) (2019-01-25) 134 | 135 | ### Bug Fixes 136 | 137 | - **copy-paste:** use explicit check for plain text pastes ([02bdc94](https://github.com/thibaudcolas/draftjs-conductor/commit/02bdc94)) 138 | 139 | # [0.4.0](https://github.com/thibaudcolas/draftjs-conductor/compare/v0.3.0...v0.4.0) (2019-01-25) 140 | 141 | ### Features 142 | 143 | - **api:** add WIP publication of flow types ([fb7fa29](https://github.com/thibaudcolas/draftjs-conductor/commit/fb7fa29)) 144 | - **api:** convert ListNestingStyles from PureComponent to function ([44f9a5f](https://github.com/thibaudcolas/draftjs-conductor/commit/44f9a5f)) 145 | - **api:** publish package with Flow annotations built in ([d7e190f](https://github.com/thibaudcolas/draftjs-conductor/commit/d7e190f)) 146 | - **api:** remove (undocumented) prefix prop on ListNestingStyles ([774fe8a](https://github.com/thibaudcolas/draftjs-conductor/commit/774fe8a)) 147 | 148 | # [0.3.0](https://github.com/thibaudcolas/draftjs-conductor/compare/v0.2.1...v0.3.0) (2018-10-27) 149 | 150 | ### Features 151 | 152 | - **release:** mark package as side-effects-free for Webpack ([#11](https://github.com/thibaudcolas/draftjs-conductor/issues/11)) ([5923318](https://github.com/thibaudcolas/draftjs-conductor/commit/5923318)) 153 | 154 | ## [0.2.1](https://github.com/thibaudcolas/draftjs-conductor/compare/v0.2.0...v0.2.1) (2018-06-04) 155 | 156 | ### Bug Fixes 157 | 158 | - **copy-paste:** preserve line breaks for pasting into word processors ([8a09efa](https://github.com/thibaudcolas/draftjs-conductor/commit/8a09efa)) 159 | 160 | # [0.2.0](https://github.com/thibaudcolas/draftjs-conductor/compare/v0.1.0...v0.2.0) (2018-06-03) 161 | 162 | ### Features 163 | 164 | - **copy-paste:** override Draft.js copy-paste to preserve full editor content ([#2](https://github.com/thibaudcolas/draftjs-conductor/pull/2)) 165 | 166 | # 0.1.0 (2018-02-24) 167 | 168 | ### Features 169 | 170 | - **api:** add react and react-dom as peerDependencies ([63acfb3](https://github.com/thibaudcolas/draftjs-conductor/commit/63acfb3)) 171 | - **lists:** add list nesting styles api to package ([8fb7073](https://github.com/thibaudcolas/draftjs-conductor/commit/8fb7073)) 172 | - **lists:** remove whitespace filtering from list styles ([2e29541](https://github.com/thibaudcolas/draftjs-conductor/commit/2e29541)) 173 | -------------------------------------------------------------------------------- /src/demo/components/DemoEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { 3 | Editor, 4 | EditorState, 5 | RichUtils, 6 | CompositeDecorator, 7 | AtomicBlockUtils, 8 | ContentBlock, 9 | getDefaultKeyBinding, 10 | DraftBlockType, 11 | DraftEntityType, 12 | RawDraftContentState, 13 | } from "draft-js"; 14 | 15 | import { 16 | getListNestingStyles, 17 | blockDepthStyleFn, 18 | onDraftEditorCopy, 19 | onDraftEditorCut, 20 | handleDraftEditorPastedText, 21 | createEditorStateFromRaw, 22 | serialiseEditorStateToRaw, 23 | } from "../../lib/index"; 24 | 25 | import SentryBoundary from "./SentryBoundary"; 26 | import Highlight from "./Highlight"; 27 | import Link, { linkStrategy } from "./Link"; 28 | import Image from "./Image"; 29 | import Snippet from "./Snippet"; 30 | 31 | import DraftUtils from "../utils/DraftUtils"; 32 | 33 | import "./DemoEditor.css"; 34 | 35 | const BLOCKS = { 36 | unstyled: "P", 37 | "unordered-list-item": "UL", 38 | "ordered-list-item": "OL", 39 | "header-one": "H1", 40 | "header-two": "H2", 41 | "header-three": "H3", 42 | "code-block": "{ }", 43 | }; 44 | 45 | const BLOCKS_EXTENDED = { 46 | unstyled: "P", 47 | "unordered-list-item": "UL", 48 | "ordered-list-item": "OL", 49 | "header-one": "H1", 50 | "header-two": "H2", 51 | "header-three": "H3", 52 | "header-four": "H4", 53 | "header-five": "H5", 54 | "header-six": "H6", 55 | blockquote: "❝", 56 | "code-block": "{ }", 57 | }; 58 | 59 | const STYLES = { 60 | BOLD: "B", 61 | ITALIC: "I", 62 | }; 63 | 64 | const STYLES_EXTENDED = { 65 | BOLD: "B", 66 | ITALIC: "I", 67 | CODE: "`", 68 | STRIKETHROUGH: "~", 69 | UNDERLINE: "_", 70 | }; 71 | 72 | const ENTITIES = [ 73 | { 74 | type: "LINK", 75 | label: "🔗", 76 | attributes: ["url"], 77 | whitelist: { 78 | href: "^(http:|https:|undefined$)", 79 | }, 80 | }, 81 | { 82 | type: "IMAGE", 83 | label: "📷", 84 | attributes: ["src"], 85 | whitelist: { 86 | src: "^http", 87 | }, 88 | }, 89 | { 90 | type: "SNIPPET", 91 | label: "🌱", 92 | attributes: ["text"], 93 | whitelist: {}, 94 | }, 95 | { 96 | type: "HORIZONTAL_RULE", 97 | label: "HR", 98 | attributes: [], 99 | whitelist: {}, 100 | }, 101 | ]; 102 | 103 | const MAX_LIST_NESTING = 15; 104 | 105 | const listNestingStyles = ( 106 | 107 | ); 108 | 109 | export interface DemoEditorProps { 110 | rawContentState: RawDraftContentState; 111 | extended?: boolean; 112 | } 113 | 114 | export interface DemoEditorState { 115 | editorState: EditorState; 116 | readOnly: boolean; 117 | } 118 | 119 | /** 120 | * Demo editor. 121 | */ 122 | class DemoEditor extends Component { 123 | static defaultProps = { 124 | rawContentState: null, 125 | }; 126 | 127 | constructor(props: DemoEditorProps) { 128 | super(props); 129 | const { rawContentState } = props; 130 | const decorator = new CompositeDecorator([ 131 | { 132 | strategy: linkStrategy, 133 | component: Link, 134 | }, 135 | ]); 136 | this.state = { 137 | editorState: createEditorStateFromRaw(rawContentState, decorator), 138 | readOnly: false, 139 | }; 140 | this.onChange = this.onChange.bind(this); 141 | this.keyBindingFn = this.keyBindingFn.bind(this); 142 | this.addBR = this.addBR.bind(this); 143 | this.toggleReadOnly = this.toggleReadOnly.bind(this); 144 | this.toggleStyle = this.toggleStyle.bind(this); 145 | this.toggleBlock = this.toggleBlock.bind(this); 146 | this.toggleEntity = this.toggleEntity.bind(this); 147 | this.blockRenderer = this.blockRenderer.bind(this); 148 | this.handlePastedText = this.handlePastedText.bind(this); 149 | } 150 | 151 | onChange(nextState: EditorState) { 152 | this.setState({ 153 | editorState: nextState, 154 | }); 155 | } 156 | 157 | toggleStyle(type: string, e: React.MouseEvent) { 158 | const { editorState } = this.state; 159 | this.onChange(RichUtils.toggleInlineStyle(editorState, type)); 160 | e.preventDefault(); 161 | } 162 | 163 | toggleBlock(type: DraftBlockType, e: React.MouseEvent) { 164 | const { editorState } = this.state; 165 | this.onChange(RichUtils.toggleBlockType(editorState, type)); 166 | e.preventDefault(); 167 | } 168 | 169 | toggleEntity(type: DraftEntityType | "HORIZONTAL_RULE" | "SNIPPET") { 170 | const { editorState } = this.state; 171 | let content = editorState.getCurrentContent(); 172 | 173 | if (type === "IMAGE") { 174 | content = content.createEntity(type, "IMMUTABLE", { 175 | src: "https://thibaudcolas.github.io/draftjs-conductor/wysiwyg-magic-wand.png", 176 | }); 177 | const entityKey = content.getLastCreatedEntityKey(); 178 | this.onChange( 179 | AtomicBlockUtils.insertAtomicBlock(editorState, entityKey, " "), 180 | ); 181 | } else if (type === "SNIPPET") { 182 | content = content.createEntity(type, "IMMUTABLE", { 183 | text: "Content of the snippet goes here", 184 | }); 185 | const entityKey = content.getLastCreatedEntityKey(); 186 | this.onChange( 187 | AtomicBlockUtils.insertAtomicBlock(editorState, entityKey, " "), 188 | ); 189 | } else if (type === "HORIZONTAL_RULE") { 190 | content = content.createEntity(type, "IMMUTABLE", {}); 191 | const entityKey = content.getLastCreatedEntityKey(); 192 | this.onChange( 193 | AtomicBlockUtils.insertAtomicBlock(editorState, entityKey, " "), 194 | ); 195 | } else { 196 | content = content.createEntity(type, "MUTABLE", { 197 | url: "http://www.example.com/", 198 | }); 199 | const entityKey = content.getLastCreatedEntityKey(); 200 | const selection = editorState.getSelection(); 201 | this.onChange(RichUtils.toggleLink(editorState, selection, entityKey)); 202 | } 203 | } 204 | 205 | blockRenderer(block: ContentBlock) { 206 | const { editorState } = this.state; 207 | const content = editorState.getCurrentContent(); 208 | 209 | if (block.getType() !== "atomic") { 210 | return null; 211 | } 212 | 213 | const entityKey = block.getEntityAt(0); 214 | 215 | if (!entityKey) { 216 | return { 217 | editable: false, 218 | }; 219 | } 220 | 221 | const entity = content.getEntity(entityKey); 222 | 223 | if (entity.getType() === "HORIZONTAL_RULE") { 224 | return { 225 | component: () =>
, 226 | editable: false, 227 | }; 228 | } 229 | 230 | if (entity.getType() === "SNIPPET") { 231 | return { 232 | component: Snippet, 233 | editable: false, 234 | }; 235 | } 236 | 237 | return { 238 | component: Image, 239 | editable: false, 240 | }; 241 | } 242 | 243 | handlePastedText( 244 | _: string, 245 | html: string | undefined, 246 | editorState: EditorState, 247 | ) { 248 | let newState = handleDraftEditorPastedText(html, editorState); 249 | 250 | if (newState) { 251 | this.onChange(newState); 252 | return "handled"; 253 | } 254 | 255 | return "not-handled"; 256 | } 257 | 258 | keyBindingFn(event: React.KeyboardEvent) { 259 | const TAB = 9; 260 | 261 | switch (event.keyCode) { 262 | case TAB: { 263 | const { editorState } = this.state; 264 | const newState = RichUtils.onTab(event, editorState, MAX_LIST_NESTING); 265 | this.onChange(newState); 266 | return null; 267 | } 268 | 269 | default: { 270 | return getDefaultKeyBinding(event); 271 | } 272 | } 273 | } 274 | 275 | addBR(e: React.MouseEvent) { 276 | const { editorState } = this.state; 277 | this.onChange(DraftUtils.addLineBreak(editorState)); 278 | e.preventDefault(); 279 | } 280 | 281 | toggleReadOnly(e: React.MouseEvent) { 282 | this.setState(({ readOnly }: DemoEditorState) => ({ 283 | readOnly: !readOnly, 284 | })); 285 | e.preventDefault(); 286 | } 287 | 288 | render() { 289 | const { extended } = this.props; 290 | const { editorState, readOnly } = this.state; 291 | const styles = extended ? STYLES_EXTENDED : STYLES; 292 | const blocks = extended ? BLOCKS_EXTENDED : BLOCKS; 293 | return ( 294 |
295 | 296 |
297 | {Object.entries(styles).map(([type, style]) => ( 298 | 304 | ))} 305 | {Object.entries(blocks).map(([type, block]) => ( 306 | 312 | ))} 313 | {ENTITIES.map((type) => ( 314 | 320 | ))} 321 | 322 | 325 |
326 | 339 |
340 | {listNestingStyles} 341 |
342 | 343 | Debug 344 | 345 | 352 |
353 |
354 | ); 355 | } 356 | } 357 | 358 | export default DemoEditor; 359 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Draft.js conductor](https://thibaudcolas.github.io/draftjs-conductor/) [](https://www.draftail.org/) 2 | 3 | [![npm](https://img.shields.io/npm/v/draftjs-conductor.svg)](https://www.npmjs.com/package/draftjs-conductor) [![Build status](https://github.com/thibaudcolas/draftjs-conductor/workflows/CI/badge.svg)](https://github.com/thibaudcolas/draftjs-conductor/actions) [![Coverage Status](https://coveralls.io/repos/github/thibaudcolas/draftjs-conductor/badge.svg)](https://coveralls.io/github/thibaudcolas/draftjs-conductor) 4 | 5 | > 📝✨ Little [Draft.js](https://facebook.github.io/draft-js/) helpers to make rich text editors _just work_. Built for [Draftail](https://www.draftail.org/). 6 | 7 | [![Photoshop’s Magic Wand selection tool applied on a WYSIWYG editor interface](https://thibaudcolas.github.io/draftjs-conductor/wysiwyg-magic-wand.png)](https://thibaudcolas.github.io/draftjs-conductor) 8 | 9 | Check out the [online demo](https://thibaudcolas.github.io/draftjs-conductor)! 10 | 11 | ## Features 12 | 13 | - [Infinite list nesting](#infinite-list-nesting) 14 | - [Idempotent copy-paste between editors](#idempotent-copy-paste-between-editors) 15 | - [Editor state data conversion helpers](#editor-state-data-conversion-helpers) 16 | 17 | --- 18 | 19 | ### Infinite list nesting 20 | 21 | By default, Draft.js only provides support for [5 list levels](https://github.com/facebook/draft-js/blob/232791a4e92d94a52c869f853f9869367bdabdac/src/component/contents/DraftEditorContents-core.react.js#L58-L62) for bulleted and numbered lists. While this is often more than enough, some editors need to go further. 22 | 23 | Instead of manually writing and maintaining the list nesting styles, use those little helpers: 24 | 25 | ```js 26 | import { getListNestingStyles, blockDepthStyleFn } from "draftjs-conductor"; 27 | 28 | 31 | 32 | ``` 33 | 34 | `getListNestingStyles` will generate the necessary CSS for your editor’s lists. `blockDepthStyleFn` will then apply classes to blocks based on their depth, so the styles take effect. Voilà! 35 | 36 | If your editor’s maximum list nesting depth never changes, pre-render the styles as a fragment for better performance: 37 | 38 | ```js 39 | const listNestingStyles = ; 40 | ``` 41 | 42 | You can also leverage [`React.memo`](https://reactjs.org/docs/react-api.html#reactmemo) to speed up re-renders even if `max` was to change: 43 | 44 | ```js 45 | const NestingStyles = React.memo(ListNestingStyles); 46 | 47 | ; 50 | ``` 51 | 52 | Relevant Draft.js issues: 53 | 54 | - [maxDepth param is greater than 4 in RichUtils.onTab – facebook/draft-js#997](https://github.com/facebook/draft-js/issues/997) 55 | - Still problematic: [Nested list styles above 4 levels are not retained when copy-pasting between Draft instances. – facebook/draft-js#1605 (comment)](https://github.com/facebook/draft-js/pull/1605#pullrequestreview-87340460) 56 | 57 | --- 58 | 59 | ### Idempotent copy-paste between editors 60 | 61 | The default Draft.js copy-paste handlers lose a lot of the formatting when copy-pasting between Draft.js editors. While this might be ok for some use cases, sites with multiple editors on the same page need them to reliably support copy-paste. 62 | 63 | Relevant Draft.js issues: 64 | 65 | - [Ability to retain pasted custom entities - facebook/draft-js#380](https://github.com/facebook/draft-js/issues/380) 66 | - [Copy/paste between editors – facebook/draft-js#787](https://github.com/facebook/draft-js/issues/787) 67 | - [Extra newlines added to text pasted between two Draft editors – facebook/draft-js#1389](https://github.com/facebook/draft-js/issues/1389) 68 | - [Copy/paste between editors strips soft returns – facebook/draft-js#1154](https://github.com/facebook/draft-js/issues/1154) 69 | - [Sequential unstyled blocks are merged into the same block on paste – facebook/draft-js#738](https://github.com/facebook/draft-js/issues/738) 70 | - [Nested list styles are not retained when copy-pasting between Draft instances. – facebook/draft-js#1163](https://github.com/facebook/draft-js/issues/1163) 71 | - [Nested list styles above 4 levels are not retained when copy-pasting between Draft instances. – facebook/draft-js#1605 (comment)](https://github.com/facebook/draft-js/pull/1605#pullrequestreview-87340460) 72 | - [Merged `

` tags on paste – facebook/draft-js#523 (comment)](https://github.com/facebook/draft-js/issues/523#issuecomment-371098488) 73 | 74 | All of those problems can be fixed with this library, which overrides the `copy` and `cut` events to transfer more of the editor’s content, and introduces a function to use with the Draft.js [`handlePastedText`](https://draftjs.org/docs/api-reference-editor#handlepastedtext) to retrieve the pasted content. 75 | 76 | **This will paste all copied content, even if the target editor might not support it.** To ensure only supported content is retained, use filters like [draftjs-filters](https://github.com/thibaudcolas/draftjs-filters). 77 | 78 | Note: IE11 isn’t supported, as it doesn't support storing HTML in the clipboard, and we also use the [`Element.closest`](https://developer.mozilla.org/en-US/docs/Web/API/Element/closest) API. 79 | 80 | #### With draft.js 0.11 and above 81 | 82 | Here’s how to use the copy/cut override, and the paste handler: 83 | 84 | ```js 85 | import { 86 | onDraftEditorCopy, 87 | onDraftEditorCut, 88 | handleDraftEditorPastedText, 89 | } from "draftjs-conductor"; 90 | 91 | class MyEditor extends Component { 92 | constructor(props: Props) { 93 | super(props); 94 | 95 | this.state = { 96 | editorState: EditorState.createEmpty(), 97 | }; 98 | 99 | this.onChange = this.onChange.bind(this); 100 | this.handlePastedText = this.handlePastedText.bind(this); 101 | } 102 | 103 | onChange(nextState: EditorState) { 104 | this.setState({ editorState: nextState }); 105 | } 106 | 107 | handlePastedText( 108 | text: string, 109 | html: string | null, 110 | editorState: EditorState, 111 | ) { 112 | let newState = handleDraftEditorPastedText(html, editorState); 113 | 114 | if (newState) { 115 | this.onChange(newState); 116 | return true; 117 | } 118 | 119 | return false; 120 | } 121 | 122 | render() { 123 | const { editorState } = this.state; 124 | 125 | return ( 126 | 133 | ); 134 | } 135 | } 136 | ``` 137 | 138 | The copy/cut event handlers will ensure the clipboard contains a full representation of the Draft.js content state on copy/cut, while `handleDraftEditorPastedText` retrieves Draft.js content state from the clipboard. Voilà! This also changes the HTML clipboard content to be more semantic, with less styles copied to other word processors/editors. 139 | 140 | You can also use `getDraftEditorPastedContent` method and set new EditorState by yourself. It is useful when you need to do some transformation with content (for example filtering unsupported styles), before past it in the state. 141 | 142 | #### With draft.js 0.10 143 | 144 | The above code relies on the `onCopy` and `onCut` event handlers, only available from Draft.js v0.11.0 onwards. For Draft.js v0.10.5, use `registerCopySource` instead, providing a `ref` to the editor: 145 | 146 | ```js 147 | import { 148 | registerCopySource, 149 | handleDraftEditorPastedText, 150 | } from "draftjs-conductor"; 151 | 152 | class MyEditor extends Component { 153 | componentDidMount() { 154 | this.copySource = registerCopySource(this.editorRef); 155 | } 156 | 157 | componentWillUnmount() { 158 | if (this.copySource) { 159 | this.copySource.unregister(); 160 | } 161 | } 162 | 163 | render() { 164 | const { editorState } = this.state; 165 | 166 | return ( 167 | { 169 | this.editorRef = ref; 170 | }} 171 | editorState={editorState} 172 | onChange={this.onChange} 173 | handlePastedText={this.handlePastedText} 174 | /> 175 | ); 176 | } 177 | } 178 | ``` 179 | 180 | #### With draft-js-plugins 181 | 182 | The setup is slightly different with `draft-js-plugins` (and React hooks) – we need to use the provided `getEditorRef` method: 183 | 184 | ```tsx 185 | // reference to the editor 186 | const editor = useRef(null); 187 | 188 | // register code for copying 189 | useEffect(() => { 190 | let unregisterCopySource: undefined | unregisterObject = undefined; 191 | if (editor.current !== null) { 192 | unregisterCopySource = registerCopySource( 193 | editor.current.getEditorRef() as any, 194 | ); 195 | } 196 | return () => { 197 | unregisterCopySource?.unregister(); 198 | }; 199 | }); 200 | ``` 201 | 202 | See [#115](https://github.com/thibaudcolas/draftjs-conductor/issues/115) for further details. 203 | 204 | ### Editor state data conversion helpers 205 | 206 | Draft.js has its own data conversion helpers, [`convertFromRaw`](https://draftjs.org/docs/api-reference-data-conversion#convertfromraw) and [`convertToRaw`](https://draftjs.org/docs/api-reference-data-conversion#converttoraw), which work really well, but aren’t providing that good of an API when initialising or persisting the content of an editor. 207 | 208 | We provide two helper methods to simplify the initialisation and serialisation of content. **`createEditorStateFromRaw`** combines [`EditorState.createWithContent`](https://draftjs.org/docs/api-reference-editor-state#createwithcontent), [`EditorState.createEmpty`](https://draftjs.org/docs/api-reference-editor-state#createempty) and [`convertFromRaw`](https://draftjs.org/docs/api-reference-data-conversion#convertfromraw) as a single method: 209 | 210 | ```js 211 | import { createEditorStateFromRaw } from "draftjs-conductor"; 212 | 213 | // Initialise with `null` if there’s no preexisting state. 214 | const editorState = createEditorStateFromRaw(null); 215 | // Initialise with the raw content state otherwise 216 | const editorState = createEditorStateFromRaw({ entityMap: {}, blocks: [] }); 217 | // Optionally use a decorator, like with Draft.js APIs. 218 | const editorState = createEditorStateFromRaw(null, decorator); 219 | ``` 220 | 221 | To save content, **`serialiseEditorStateToRaw`** combines [`convertToRaw`](https://draftjs.org/docs/api-reference-data-conversion#converttoraw) with checks for empty content – so empty content is saved as `null`, rather than a single text block with empty text as would be the case otherwise. 222 | 223 | ```js 224 | import { serialiseEditorStateToRaw } from "draftjs-conductor"; 225 | 226 | // Content will be `null` if there’s no textual content, or RawDraftContentState otherwise. 227 | const content = serialiseEditorStateToRaw(editorState); 228 | ``` 229 | 230 | ## Contributing 231 | 232 | 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). 233 | 234 | ## Credits 235 | 236 | View the full list of [contributors](https://github.com/thibaudcolas/draftjs-conductor/graphs/contributors). [MIT](LICENSE) licensed. Website content available as [CC0](https://creativecommons.org/publicdomain/zero/1.0/). Image credit: [FirefoxEmoji](https://github.com/mozilla/fxemoji). 237 | -------------------------------------------------------------------------------- /src/demo/components/DemoEditor.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { mount, ReactWrapper } from "enzyme"; 3 | import { 4 | EditorState, 5 | RichUtils, 6 | AtomicBlockUtils, 7 | RawDraftContentState, 8 | ContentBlock, 9 | RawDraftContentBlock, 10 | } from "draft-js"; 11 | 12 | import DemoEditor, { DemoEditorProps, DemoEditorState } from "./DemoEditor"; 13 | import DraftUtils from "../utils/DraftUtils"; 14 | 15 | describe("DemoEditor", () => { 16 | beforeEach(() => { 17 | jest.spyOn(RichUtils, "toggleInlineStyle"); 18 | jest.spyOn(RichUtils, "toggleBlockType"); 19 | jest.spyOn(RichUtils, "toggleLink"); 20 | jest.spyOn(AtomicBlockUtils, "insertAtomicBlock"); 21 | }); 22 | 23 | afterEach(() => { 24 | jest.restoreAllMocks(); 25 | }); 26 | 27 | it("renders", () => { 28 | // Do not snapshot the Draft.js editor, as it contains unstable keys in the content. 29 | expect( 30 | mount().find(".EditorToolbar"), 31 | ).toMatchSnapshot(); 32 | }); 33 | 34 | describe("#extended", () => { 35 | it("works", () => { 36 | expect( 37 | mount().find(".EditorToolbar"), 38 | ).toMatchSnapshot(); 39 | }); 40 | 41 | it("can take predefined content", () => { 42 | expect( 43 | mount( 44 | , 52 | ).find(".EditorToolbar"), 53 | ).toMatchSnapshot(); 54 | }); 55 | }); 56 | 57 | describe("onChange", () => { 58 | it("works", () => { 59 | const state = EditorState.createEmpty(); 60 | const wrapper = mount(); 61 | 62 | wrapper.instance().onChange(state); 63 | 64 | expect(wrapper.state("editorState")).toBe(state); 65 | }); 66 | }); 67 | 68 | it("toggleStyle", () => { 69 | mount() 70 | .instance() 71 | // @ts-expect-error 72 | .toggleStyle("BOLD", new Event("mousedown")); 73 | 74 | expect(RichUtils.toggleInlineStyle).toHaveBeenCalled(); 75 | }); 76 | 77 | it("toggleBlock", () => { 78 | mount() 79 | .instance() 80 | // @ts-expect-error 81 | .toggleBlock("header-two", new Event("mousedown")); 82 | 83 | expect(RichUtils.toggleBlockType).toHaveBeenCalled(); 84 | }); 85 | 86 | describe("toggleEntity", () => { 87 | it("LINK", () => { 88 | mount() 89 | .instance() 90 | .toggleEntity("LINK"); 91 | 92 | expect(RichUtils.toggleLink).toHaveBeenCalled(); 93 | }); 94 | 95 | it("IMAGE", () => { 96 | mount() 97 | .instance() 98 | .toggleEntity("IMAGE"); 99 | 100 | expect(AtomicBlockUtils.insertAtomicBlock).toHaveBeenCalled(); 101 | }); 102 | 103 | it("SNIPPET", () => { 104 | mount() 105 | .instance() 106 | .toggleEntity("SNIPPET"); 107 | 108 | expect(AtomicBlockUtils.insertAtomicBlock).toHaveBeenCalled(); 109 | }); 110 | 111 | it("HORIZONTAL_RULE", () => { 112 | mount() 113 | .instance() 114 | .toggleEntity("HORIZONTAL_RULE"); 115 | 116 | expect(AtomicBlockUtils.insertAtomicBlock).toHaveBeenCalled(); 117 | }); 118 | }); 119 | 120 | describe("blockRenderer", () => { 121 | it("unstyled", () => { 122 | expect( 123 | mount() 124 | .instance() 125 | .blockRenderer({ 126 | getType: () => "unstyled", 127 | } as ContentBlock), 128 | ).toBe(null); 129 | }); 130 | 131 | it("no entity", () => { 132 | const editable = mount( 133 | , 151 | ) 152 | .instance() 153 | .blockRenderer(new ContentBlock({ type: "atomic" }))?.editable; 154 | expect(editable).toBe(false); 155 | }); 156 | 157 | it("HORIZONTAL_RULE", () => { 158 | const rawContentState = { 159 | entityMap: { 160 | 5: { 161 | type: "HORIZONTAL_RULE", 162 | mutability: "IMMUTABLE", 163 | data: {}, 164 | }, 165 | }, 166 | blocks: [ 167 | { 168 | key: "asa", 169 | type: "atomic", 170 | text: " ", 171 | depth: 0, 172 | inlineStyleRanges: [], 173 | entityRanges: [ 174 | { 175 | key: 5, 176 | offset: 0, 177 | length: 1, 178 | }, 179 | ], 180 | }, 181 | ], 182 | } as RawDraftContentState; 183 | const instance = mount( 184 | , 185 | ).instance(); 186 | 187 | const Component = instance.blockRenderer( 188 | instance.state.editorState.getCurrentContent().getFirstBlock(), 189 | )!.component; 190 | // @ts-expect-error 191 | expect(Component()).toEqual(


); 192 | }); 193 | 194 | it("IMAGE", () => { 195 | const rawContentState = { 196 | entityMap: { 197 | 1: { 198 | type: "IMAGE", 199 | mutability: "IMMUTABLE", 200 | data: { 201 | src: "example.png", 202 | }, 203 | }, 204 | }, 205 | blocks: [ 206 | { 207 | key: "ccc", 208 | type: "atomic", 209 | text: " ", 210 | depth: 0, 211 | entityRanges: [ 212 | { 213 | key: 1, 214 | offset: 0, 215 | length: 1, 216 | }, 217 | ], 218 | inlineStyleRanges: [], 219 | }, 220 | ], 221 | } as RawDraftContentState; 222 | 223 | const instance = mount( 224 | , 225 | ).instance(); 226 | 227 | const Component = instance.blockRenderer( 228 | instance.state.editorState.getCurrentContent().getFirstBlock(), 229 | )!.component; 230 | // @ts-expect-error 231 | expect().toMatchInlineSnapshot(``); 232 | }); 233 | 234 | it("SNIPPET", () => { 235 | const rawContentState = { 236 | entityMap: { 237 | 0: { 238 | type: "SNIPPET", 239 | mutability: "IMMUTABLE", 240 | data: { 241 | src: "example.png", 242 | }, 243 | }, 244 | }, 245 | blocks: [ 246 | { 247 | key: "aaa", 248 | type: "atomic", 249 | text: " ", 250 | depth: 0, 251 | entityRanges: [ 252 | { 253 | key: 0, 254 | offset: 0, 255 | length: 1, 256 | }, 257 | ], 258 | inlineStyleRanges: [], 259 | }, 260 | ], 261 | } as RawDraftContentState; 262 | 263 | const instance = mount( 264 | , 265 | ).instance(); 266 | 267 | const Component = instance.blockRenderer( 268 | instance.state.editorState.getCurrentContent().getFirstBlock(), 269 | )!.component; 270 | // @ts-expect-error 271 | expect().toMatchInlineSnapshot(``); 272 | }); 273 | }); 274 | 275 | describe("handlePastedText", () => { 276 | it("handled by handleDraftEditorPastedText", () => { 277 | const wrapper = mount(); 278 | const content = { 279 | blocks: [ 280 | { 281 | data: {}, 282 | depth: 0, 283 | entityRanges: [], 284 | inlineStyleRanges: [], 285 | key: "a", 286 | text: "hello,\nworld!", 287 | type: "unstyled", 288 | }, 289 | ], 290 | entityMap: {}, 291 | } as RawDraftContentState; 292 | const html = `

Hello, world!

`; 295 | 296 | expect( 297 | wrapper 298 | .instance() 299 | .handlePastedText( 300 | "hello,\nworld!", 301 | html, 302 | wrapper.state("editorState"), 303 | ), 304 | ).toBe("handled"); 305 | }); 306 | 307 | it("default handling", () => { 308 | const wrapper = mount(); 309 | 310 | expect( 311 | wrapper 312 | .instance() 313 | .handlePastedText( 314 | "this is plain text paste", 315 | "this is plain text paste", 316 | wrapper.state("editorState"), 317 | ), 318 | ).toBe("not-handled"); 319 | }); 320 | }); 321 | 322 | describe("keyBindingFn", () => { 323 | it("works", () => { 324 | const wrapper = mount(); 325 | 326 | wrapper.instance().onChange = jest.fn(); 327 | // @ts-expect-error 328 | wrapper.instance().keyBindingFn({ keyCode: 9 }); 329 | expect(wrapper.instance().onChange).toHaveBeenCalled(); 330 | }); 331 | 332 | it("does not change state directly with other keys", () => { 333 | const wrapper = mount(); 334 | 335 | wrapper.instance().onChange = jest.fn(); 336 | // @ts-expect-error 337 | wrapper.instance().keyBindingFn({ keyCode: 22 }); 338 | expect(wrapper.instance().onChange).not.toHaveBeenCalled(); 339 | }); 340 | }); 341 | 342 | describe("addBR", () => { 343 | let wrapper: ReactWrapper; 344 | let addLineBreak: jest.SpyInstance; 345 | 346 | beforeEach(() => { 347 | wrapper = mount(); 348 | 349 | addLineBreak = jest.spyOn(DraftUtils, "addLineBreak"); 350 | jest.spyOn(wrapper.instance(), "onChange"); 351 | }); 352 | 353 | afterEach(() => { 354 | addLineBreak.mockRestore(); 355 | }); 356 | 357 | it("works", () => { 358 | // @ts-expect-error 359 | wrapper.instance().addBR(new MouseEvent("click")); 360 | 361 | expect(addLineBreak).toHaveBeenCalled(); 362 | expect(wrapper.instance().onChange).toHaveBeenCalled(); 363 | }); 364 | }); 365 | 366 | describe("toggleReadOnly", () => { 367 | let wrapper: ReactWrapper; 368 | 369 | beforeEach(() => { 370 | wrapper = mount(); 371 | }); 372 | 373 | it("works", () => { 374 | expect(wrapper.instance().state.readOnly).toBe(false); 375 | expect(wrapper.find(".EditorToolbar button:last-child").text()).toBe( 376 | "📖", 377 | ); 378 | wrapper 379 | .instance() 380 | // @ts-expect-error 381 | .toggleReadOnly(new MouseEvent("click")); 382 | expect(wrapper.instance().state.readOnly).toBe(true); 383 | expect(wrapper.find(".EditorToolbar button:last-child").text()).toBe( 384 | "📕", 385 | ); 386 | }); 387 | }); 388 | }); 389 | -------------------------------------------------------------------------------- /src/lib/api/copypaste.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EditorState, 3 | convertFromRaw, 4 | convertToRaw, 5 | ContentState, 6 | RawDraftContentState, 7 | } from "draft-js"; 8 | import { 9 | registerCopySource, 10 | onDraftEditorCopy, 11 | onDraftEditorCut, 12 | handleDraftEditorPastedText, 13 | getDraftEditorPastedContent, 14 | } from "./copypaste"; 15 | 16 | jest.mock("draft-js/lib/generateRandomKey", () => () => "a"); 17 | jest.mock("draft-js/lib/getDraftEditorSelection", () => () => ({})); 18 | jest.mock( 19 | "draft-js/lib/getContentStateFragment", 20 | () => (content: ContentState) => content.getBlockMap(), 21 | ); 22 | 23 | jest.mock("draft-js/lib/editOnCopy", () => jest.fn(() => {})); 24 | jest.mock("draft-js/lib/editOnCut", () => jest.fn(() => {})); 25 | 26 | jest.mock("draft-js-10/lib/generateRandomKey", () => () => "a"); 27 | jest.mock("draft-js-10/lib/getDraftEditorSelection", () => () => ({})); 28 | jest.mock( 29 | "draft-js-10/lib/getContentStateFragment", 30 | () => (content: ContentState) => content.getBlockMap(), 31 | ); 32 | jest.mock("draft-js-10/lib/editOnCopy", () => jest.fn(() => {})); 33 | jest.mock("draft-js-10/lib/editOnCut", () => jest.fn(() => {})); 34 | 35 | const dispatchEvent = ( 36 | editor: HTMLElement, 37 | type: string, 38 | setData?: { [key: string]: any }, 39 | ) => { 40 | const event = Object.assign(new Event(type), { 41 | clipboardData: { setData }, 42 | preventDefault: jest.fn(), 43 | }); 44 | 45 | editor.dispatchEvent(event); 46 | 47 | return event; 48 | }; 49 | 50 | const getSelection = (selection?: Selection) => { 51 | return jest.fn(() => 52 | Object.assign( 53 | { 54 | rangeCount: 0, 55 | toString: () => "toString selection", 56 | getRangeAt() { 57 | return { 58 | cloneContents() { 59 | return document.createElement("div"); 60 | }, 61 | }; 62 | }, 63 | }, 64 | selection, 65 | ), 66 | ); 67 | }; 68 | 69 | describe("copypaste", () => { 70 | describe("registerCopySource", () => { 71 | it("registers and unregisters works for copy", () => { 72 | const editor = document.createElement("div"); 73 | 74 | const copySource = registerCopySource({ 75 | // @ts-expect-error 76 | editor, 77 | // @ts-expect-error 78 | _latestEditorState: EditorState.createEmpty(), 79 | }); 80 | 81 | window.getSelection = getSelection(); 82 | dispatchEvent(editor, "copy"); 83 | expect(window.getSelection).toHaveBeenCalled(); 84 | 85 | copySource.unregister(); 86 | 87 | window.getSelection = getSelection(); 88 | dispatchEvent(editor, "cut"); 89 | expect(window.getSelection).not.toHaveBeenCalled(); 90 | }); 91 | 92 | it("works for cut", () => { 93 | const editor = document.createElement("div"); 94 | 95 | const copySource = registerCopySource({ 96 | // @ts-expect-error 97 | editor, 98 | // @ts-expect-error 99 | _latestEditorState: EditorState.createEmpty(), 100 | }); 101 | 102 | window.getSelection = getSelection(); 103 | dispatchEvent(editor, "cut"); 104 | expect(window.getSelection).toHaveBeenCalled(); 105 | 106 | copySource.unregister(); 107 | 108 | window.getSelection = getSelection(); 109 | dispatchEvent(editor, "cut"); 110 | expect(window.getSelection).not.toHaveBeenCalled(); 111 | }); 112 | }); 113 | 114 | describe("onDraftEditorCopy", () => { 115 | it("calls editOnCopy", () => { 116 | const editor = document.createElement("div"); 117 | window.getSelection = getSelection(); 118 | // @ts-expect-error 119 | onDraftEditorCopy(editor, dispatchEvent(editor, "copy")); 120 | }); 121 | }); 122 | 123 | describe("onDraftEditorCut", () => { 124 | it("does not break", () => { 125 | const editor = document.createElement("div"); 126 | window.getSelection = getSelection(); 127 | // @ts-expect-error 128 | onDraftEditorCut(editor, dispatchEvent(editor, "cut")); 129 | }); 130 | }); 131 | 132 | /** 133 | * jsdom does not implement the DOM selection API, we have to do a lot of overriding. 134 | */ 135 | describe("copy/cut listener", () => { 136 | it("no selection", () => { 137 | const editor = document.createElement("div"); 138 | 139 | registerCopySource({ 140 | // @ts-expect-error 141 | editor, 142 | // @ts-expect-error 143 | _latestEditorState: EditorState.createEmpty(), 144 | }); 145 | 146 | window.getSelection = getSelection(); 147 | 148 | const event = dispatchEvent(editor, "copy"); 149 | expect(event.preventDefault).not.toHaveBeenCalled(); 150 | }); 151 | 152 | it("no clipboardData, IE11", () => { 153 | const editor = document.createElement("div"); 154 | 155 | registerCopySource({ 156 | // @ts-expect-error 157 | editor, 158 | // @ts-expect-error 159 | _latestEditorState: EditorState.createEmpty(), 160 | }); 161 | 162 | // @ts-expect-error 163 | window.getSelection = getSelection({ rangeCount: 1 }); 164 | 165 | const event = new Event("copy"); 166 | event.preventDefault = jest.fn(); 167 | editor.dispatchEvent(event); 168 | 169 | expect(event.preventDefault).not.toHaveBeenCalled(); 170 | }); 171 | 172 | it("works", (done) => { 173 | const editor = document.createElement("div"); 174 | 175 | const content = { 176 | blocks: [ 177 | { 178 | key: "a", 179 | type: "unstyled", 180 | text: "test", 181 | }, 182 | ], 183 | entityMap: {}, 184 | } as RawDraftContentState; 185 | 186 | registerCopySource({ 187 | // @ts-expect-error 188 | editor, 189 | // @ts-expect-error 190 | _latestEditorState: EditorState.createWithContent( 191 | convertFromRaw(content), 192 | ), 193 | }); 194 | 195 | // @ts-expect-error 196 | window.getSelection = getSelection({ rangeCount: 1 }); 197 | 198 | dispatchEvent(editor, "copy", (type: string, data: any) => { 199 | if (type === "text/plain") { 200 | expect(data).toBe("toString selection"); 201 | } else if (type === "text/html") { 202 | expect(data).toMatchSnapshot(); 203 | done(); 204 | } 205 | }); 206 | }); 207 | 208 | it("uses the default copy-paste behavior if content is empty", () => { 209 | const editor = document.createElement("div"); 210 | 211 | const content = { 212 | blocks: [ 213 | { 214 | key: "a", 215 | type: "unstyled", 216 | text: "", 217 | }, 218 | ], 219 | entityMap: {}, 220 | } as RawDraftContentState; 221 | 222 | registerCopySource({ 223 | // @ts-expect-error 224 | editor, 225 | // @ts-expect-error 226 | _latestEditorState: EditorState.createWithContent( 227 | convertFromRaw(content), 228 | ), 229 | }); 230 | 231 | // @ts-expect-error 232 | window.getSelection = getSelection({ rangeCount: 1 }); 233 | 234 | const event = dispatchEvent(editor, "copy", () => {}); 235 | expect(event.preventDefault).not.toHaveBeenCalled(); 236 | }); 237 | 238 | it("supports copy-pasting from decorators content", () => { 239 | const editor = document.createElement("div"); 240 | 241 | const content = { 242 | blocks: [ 243 | { 244 | key: "a", 245 | type: "unstyled", 246 | text: "", 247 | }, 248 | ], 249 | entityMap: {}, 250 | } as RawDraftContentState; 251 | 252 | registerCopySource({ 253 | // @ts-expect-error 254 | editor, 255 | // @ts-expect-error 256 | _latestEditorState: EditorState.createWithContent( 257 | convertFromRaw(content), 258 | ), 259 | }); 260 | 261 | const contents = document.createElement("div"); 262 | contents.setAttribute("data-contents", "true"); 263 | const decorator = document.createElement("div"); 264 | decorator.setAttribute("contenteditable", "false"); 265 | contents.appendChild(decorator); 266 | const anchorNode = document.createElement("div"); 267 | decorator.appendChild(anchorNode); 268 | const focusNode = document.createElement("div"); 269 | anchorNode.appendChild(focusNode); 270 | 271 | // @ts-expect-error 272 | window.getSelection = getSelection({ 273 | rangeCount: 1, 274 | anchorNode, 275 | focusNode, 276 | }); 277 | 278 | const event = dispatchEvent(editor, "copy", () => {}); 279 | expect(event.preventDefault).not.toHaveBeenCalled(); 280 | }); 281 | 282 | it("supports copy-pasting from decorators content #2", () => { 283 | const editor = document.createElement("div"); 284 | 285 | const content = { 286 | blocks: [ 287 | { 288 | key: "a", 289 | type: "unstyled", 290 | text: "", 291 | }, 292 | ], 293 | entityMap: {}, 294 | } as RawDraftContentState; 295 | 296 | registerCopySource({ 297 | // @ts-expect-error 298 | editor, 299 | // @ts-expect-error 300 | _latestEditorState: EditorState.createWithContent( 301 | convertFromRaw(content), 302 | ), 303 | }); 304 | 305 | const contents = document.createElement("div"); 306 | contents.setAttribute("data-contents", "true"); 307 | const focusDecorator = document.createElement("div"); 308 | focusDecorator.setAttribute("contenteditable", "false"); 309 | contents.appendChild(focusDecorator); 310 | const anchorDecorator = document.createElement("div"); 311 | anchorDecorator.setAttribute("contenteditable", "false"); 312 | contents.appendChild(anchorDecorator); 313 | const anchorNode = document.createElement("div"); 314 | const focusNode = document.createElement("div"); 315 | anchorDecorator.appendChild(anchorNode); 316 | focusDecorator.appendChild(focusNode); 317 | focusDecorator.appendChild(anchorDecorator); 318 | 319 | // @ts-expect-error 320 | window.getSelection = getSelection({ 321 | rangeCount: 1, 322 | anchorNode, 323 | focusNode, 324 | }); 325 | 326 | const event = dispatchEvent(editor, "copy", () => {}); 327 | expect(event.preventDefault).not.toHaveBeenCalled(); 328 | }); 329 | 330 | it("supports pasting from text nodes only", () => { 331 | const editor = document.createElement("div"); 332 | 333 | const content = { 334 | blocks: [ 335 | { 336 | key: "a", 337 | type: "unstyled", 338 | text: "", 339 | }, 340 | ], 341 | entityMap: {}, 342 | } as RawDraftContentState; 343 | 344 | registerCopySource({ 345 | // @ts-expect-error 346 | editor, 347 | // @ts-expect-error 348 | _latestEditorState: EditorState.createWithContent( 349 | convertFromRaw(content), 350 | ), 351 | }); 352 | 353 | const contents = document.createElement("div"); 354 | contents.setAttribute("data-contents", "true"); 355 | const decorator = document.createElement("div"); 356 | decorator.setAttribute("contenteditable", "false"); 357 | contents.appendChild(decorator); 358 | const anchorParent = document.createElement("div"); 359 | decorator.appendChild(anchorParent); 360 | const anchorNode = document.createTextNode("this is text"); 361 | anchorParent.appendChild(anchorNode); 362 | const focusNode = document.createTextNode("this is text"); 363 | anchorParent.appendChild(focusNode); 364 | 365 | // @ts-expect-error 366 | window.getSelection = getSelection({ 367 | rangeCount: 1, 368 | anchorNode, 369 | focusNode, 370 | }); 371 | 372 | const event = dispatchEvent(editor, "copy", () => {}); 373 | expect(event.preventDefault).not.toHaveBeenCalled(); 374 | }); 375 | }); 376 | 377 | it("checks whether anchor node is indeed in a decorator", () => { 378 | const editor = document.createElement("div"); 379 | 380 | const content = { 381 | blocks: [ 382 | { 383 | key: "a", 384 | type: "unstyled", 385 | text: "", 386 | }, 387 | ], 388 | entityMap: {}, 389 | } as RawDraftContentState; 390 | 391 | registerCopySource({ 392 | // @ts-expect-error 393 | editor, 394 | // @ts-expect-error 395 | _latestEditorState: EditorState.createWithContent( 396 | convertFromRaw(content), 397 | ), 398 | }); 399 | 400 | const contents = document.createElement("div"); 401 | contents.setAttribute("data-contents", "true"); 402 | const decorator = document.createElement("div"); 403 | decorator.setAttribute("contenteditable", "false"); 404 | contents.appendChild(decorator); 405 | const anchorNode = document.createElement("div"); 406 | decorator.appendChild(anchorNode); 407 | const focusNode = document.createElement("div"); 408 | // focusNode.appendChild(anchorNode); 409 | 410 | // @ts-expect-error 411 | window.getSelection = getSelection({ 412 | rangeCount: 1, 413 | anchorNode, 414 | focusNode, 415 | }); 416 | 417 | const event = dispatchEvent(editor, "copy", () => {}); 418 | expect(event.preventDefault).not.toHaveBeenCalled(); 419 | }); 420 | 421 | it("checks whether focus node is indeed in a decorator", () => { 422 | const editor = document.createElement("div"); 423 | 424 | const content = { 425 | blocks: [ 426 | { 427 | key: "a", 428 | type: "unstyled", 429 | text: "", 430 | }, 431 | ], 432 | entityMap: {}, 433 | } as RawDraftContentState; 434 | 435 | registerCopySource({ 436 | // @ts-expect-error 437 | editor, 438 | // @ts-expect-error 439 | _latestEditorState: EditorState.createWithContent( 440 | convertFromRaw(content), 441 | ), 442 | }); 443 | 444 | const contents = document.createElement("div"); 445 | contents.setAttribute("data-contents", "true"); 446 | const decorator = document.createElement("div"); 447 | decorator.setAttribute("contenteditable", "false"); 448 | contents.appendChild(decorator); 449 | const anchorNode = document.createElement("div"); 450 | const focusNode = document.createElement("div"); 451 | focusNode.appendChild(anchorNode); 452 | 453 | // @ts-expect-error 454 | window.getSelection = getSelection({ 455 | rangeCount: 1, 456 | anchorNode, 457 | focusNode, 458 | }); 459 | 460 | const event = dispatchEvent(editor, "copy", () => {}); 461 | expect(event.preventDefault).not.toHaveBeenCalled(); 462 | }); 463 | 464 | describe("handleDraftEditorPastedText", () => { 465 | it("no HTML", () => { 466 | const editorState = EditorState.createEmpty(); 467 | expect(handleDraftEditorPastedText(undefined, editorState)).toBe(false); 468 | }); 469 | 470 | it("HTML from other app", () => { 471 | const editorState = EditorState.createEmpty(); 472 | const html = `

Hello, world!

`; 473 | expect(handleDraftEditorPastedText(html, editorState)).toBe(false); 474 | }); 475 | 476 | it("HTML from draftjs-conductor", () => { 477 | const content = { 478 | blocks: [ 479 | { 480 | data: {}, 481 | depth: 0, 482 | entityRanges: [], 483 | inlineStyleRanges: [], 484 | key: "a", 485 | text: "hello,\nworld!", 486 | type: "unstyled", 487 | }, 488 | ], 489 | entityMap: {}, 490 | }; 491 | let editorState = EditorState.createEmpty(); 492 | const html = `

Hello, world!

`; 495 | editorState = handleDraftEditorPastedText( 496 | html, 497 | editorState, 498 | ) as EditorState; 499 | expect(convertToRaw(editorState.getCurrentContent())).toEqual(content); 500 | }); 501 | 502 | it("invalid JSON", () => { 503 | const editorState = EditorState.createEmpty(); 504 | const html = `

Hello, world!

`; 505 | expect(handleDraftEditorPastedText(html, editorState)).toBe(false); 506 | }); 507 | }); 508 | 509 | describe("getDraftEditorPastedContent", () => { 510 | it("no HTML", () => { 511 | expect(getDraftEditorPastedContent(undefined)).toEqual(null); 512 | }); 513 | 514 | it("HTML from other app", () => { 515 | expect(getDraftEditorPastedContent("

Hello, world!

")).toEqual(null); 516 | }); 517 | 518 | it("HTML from draftjs-conductor", () => { 519 | const content = { 520 | blocks: [ 521 | { 522 | data: {}, 523 | depth: 0, 524 | entityRanges: [], 525 | inlineStyleRanges: [], 526 | key: "a", 527 | text: "hello,\nworld!", 528 | type: "unstyled", 529 | }, 530 | ], 531 | entityMap: {}, 532 | } as RawDraftContentState; 533 | const html = `

Hello, world!

`; 536 | const pastedContent = getDraftEditorPastedContent(html) as ContentState; 537 | expect(convertToRaw(pastedContent)).toEqual(content); 538 | }); 539 | 540 | it("invalid JSON", () => { 541 | const html = `

Hello, world!

`; 542 | expect(getDraftEditorPastedContent(html)).toBe(null); 543 | }); 544 | }); 545 | }); 546 | --------------------------------------------------------------------------------