├── .circleci └── config.yml ├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── config.yml ├── .gitignore ├── .npmignore ├── .storybook ├── main.js └── preview.js ├── LICENSE ├── README.md ├── babel.config.js ├── package.json ├── src ├── commands │ ├── README.md │ ├── backspaceToParagraph.ts │ ├── createAndInsertLink.ts │ ├── insertFiles.ts │ ├── moveLeft.ts │ ├── moveRight.ts │ ├── splitHeading.ts │ ├── toggleBlockType.ts │ ├── toggleList.ts │ └── toggleWrap.ts ├── components │ ├── BlockMenu.tsx │ ├── BlockMenuItem.tsx │ ├── CommandMenu.tsx │ ├── EmojiMenu.tsx │ ├── EmojiMenuItem.tsx │ ├── Flex.tsx │ ├── FloatingToolbar.tsx │ ├── Input.tsx │ ├── LinkEditor.tsx │ ├── LinkSearchResult.tsx │ ├── LinkToolbar.tsx │ ├── SelectionToolbar.tsx │ ├── ToolbarButton.tsx │ ├── ToolbarMenu.tsx │ ├── ToolbarSeparator.tsx │ ├── Tooltip.tsx │ └── VisuallyHidden.tsx ├── dictionary.ts ├── hooks │ ├── useComponentSize.ts │ ├── useMediaQuery.ts │ └── useViewportHeight.ts ├── index.tsx ├── lib │ ├── ComponentView.tsx │ ├── Extension.ts │ ├── ExtensionManager.ts │ ├── __snapshots__ │ │ └── renderToHtml.test.ts.snap │ ├── filterExcessSeparators.ts │ ├── getDataTransferFiles.ts │ ├── getHeadings.ts │ ├── getMarkAttrs.ts │ ├── headingToSlug.ts │ ├── isMarkdown.test.ts │ ├── isMarkdown.ts │ ├── isModKey.ts │ ├── isUrl.ts │ ├── markInputRule.ts │ ├── markdown │ │ ├── rules.ts │ │ └── serializer.js │ ├── renderToHtml.test.ts │ ├── renderToHtml.ts │ └── uploadPlaceholder.ts ├── marks │ ├── Bold.ts │ ├── Code.ts │ ├── Highlight.ts │ ├── Italic.ts │ ├── Link.ts │ ├── Mark.ts │ ├── Placeholder.ts │ ├── Strikethrough.ts │ └── Underline.ts ├── menus │ ├── block.ts │ ├── divider.tsx │ ├── formatting.ts │ ├── image.tsx │ ├── table.tsx │ ├── tableCol.tsx │ └── tableRow.tsx ├── nodes │ ├── Blockquote.ts │ ├── BulletList.ts │ ├── CheckboxItem.ts │ ├── CheckboxList.ts │ ├── CodeBlock.ts │ ├── CodeFence.ts │ ├── Doc.ts │ ├── Embed.tsx │ ├── Emoji.tsx │ ├── HardBreak.ts │ ├── Heading.ts │ ├── HorizontalRule.ts │ ├── Image.tsx │ ├── ListItem.ts │ ├── Node.ts │ ├── Notice.tsx │ ├── OrderedList.ts │ ├── Paragraph.ts │ ├── ReactNode.ts │ ├── Table.ts │ ├── TableCell.ts │ ├── TableHeadCell.ts │ ├── TableRow.ts │ └── Text.ts ├── plugins │ ├── BlockMenuTrigger.tsx │ ├── EmojiTrigger.tsx │ ├── Folding.tsx │ ├── History.ts │ ├── Keys.ts │ ├── MaxLength.ts │ ├── PasteHandler.ts │ ├── Placeholder.ts │ ├── Prism.ts │ ├── SmartText.ts │ └── TrailingNode.ts ├── queries │ ├── findCollapsedNodes.ts │ ├── getColumnIndex.ts │ ├── getMarkRange.ts │ ├── getParentListItem.ts │ ├── getRowIndex.ts │ ├── isInCode.ts │ ├── isInList.ts │ ├── isList.ts │ ├── isMarkActive.ts │ └── isNodeActive.ts ├── rules │ ├── breaks.ts │ ├── checkboxes.ts │ ├── embeds.ts │ ├── emoji.ts │ ├── mark.ts │ ├── notices.ts │ ├── tables.ts │ └── underlines.ts ├── server.test.ts ├── server.ts ├── stories │ ├── index.stories.tsx │ └── index.tsx ├── styles │ ├── editor.ts │ └── theme.ts └── types │ ├── gemoji.d.ts │ ├── index.ts │ ├── markdown-it-mark.d.ts │ └── prosemirror-model.d.ts ├── tsconfig.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | - image: circleci/node:14 10 | 11 | working_directory: ~/repo 12 | 13 | steps: 14 | - checkout 15 | 16 | # Download and cache dependencies 17 | - restore_cache: 18 | keys: 19 | - v2-dependencies-{{ checksum "package.json" }} 20 | # fallback to using the latest cache if no exact match is found 21 | - v2-dependencies- 22 | 23 | - run: yarn install --pure-lockfile 24 | 25 | - save_cache: 26 | paths: 27 | - node_modules 28 | key: v2-dependencies-{{ checksum "package.json" }} 29 | 30 | - run: yarn lint 31 | - run: yarn test 32 | 33 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "plugin:react/recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "prettier/@typescript-eslint", 7 | "plugin:prettier/recommended", 8 | "plugin:import/errors", 9 | "plugin:import/warnings", 10 | "plugin:import/typescript" 11 | ], 12 | "plugins": [ 13 | "jsx-a11y" 14 | ], 15 | "rules": { 16 | "eqeqeq": 2, 17 | "no-unused-vars": "off", 18 | "no-mixed-operators": "off", 19 | "jsx-a11y/href-no-hash": "off", 20 | "react/prop-types": "off", 21 | "@typescript-eslint/no-unused-vars": [ 22 | "error" 23 | ], 24 | "@typescript-eslint/explicit-function-return-type": "off", 25 | "prettier/prettier": [ 26 | "error", 27 | { 28 | "printWidth": 80, 29 | "trailingComma": "es5" 30 | } 31 | ] 32 | }, 33 | "settings": { 34 | "react": { 35 | "version": "detect" 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [outline] 4 | -------------------------------------------------------------------------------- /.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 | 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 1. Go to '...' 15 | 2. Click on '....' 16 | 3. Scroll down to '....' 17 | 4. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Version** 23 | If known, what is the version of the module in use. 24 | 25 | **Screenshots** 26 | If applicable, add screenshots or videos to help explain your problem. 27 | 28 | **Desktop (please complete the following information):** 29 | - OS: [e.g. iOS] 30 | - Browser [e.g. chrome, safari] 31 | - Version [e.g. 22] 32 | 33 | **Smartphone (please complete the following information):** 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Feature Request 4 | url: https://github.com/outline/rich-markdown-editor/discussions/new 5 | about: Request a feature to be added to the project 6 | - name: Ask a Question 7 | url: https://github.com/outline/rich-markdown-editor/discussions/new 8 | about: Ask questions and discuss with other community members 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules/* 3 | .log 4 | .DS_Store 5 | .idea 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .test.js 3 | example 4 | .circleci 5 | .github 6 | .eslintignore 7 | .eslintrc 8 | .map 9 | dist/stories -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "stories": [ 3 | "../src/**/*.stories.mdx", 4 | "../src/**/*.stories.@(js|jsx|ts|tsx)" 5 | ], 6 | "addons": [ 7 | "@storybook/addon-links", 8 | "@storybook/addon-essentials" 9 | ] 10 | } -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | 2 | export const parameters = { 3 | layout: "padded", 4 | actions: { argTypesRegex: "^on[A-Z].*" }, 5 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 General Outline, Inc (https://www.getoutline.com/) and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the 9 | documentation and/or other materials provided with the distribution. 10 | 11 | 3. Neither the name of the Outline nor the names of its contributors may be used to endorse or promote products derived from this software 12 | without specific prior written permission. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 15 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 16 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 17 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 18 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 19 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 20 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // This config is only used for TS support in Jest 2 | module.exports = { 3 | presets: [ 4 | "@babel/preset-react", 5 | ["@babel/preset-env", { targets: { node: "current" } }], 6 | "@babel/preset-typescript", 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rich-markdown-editor", 3 | "description": "A rich text editor with Markdown shortcuts", 4 | "version": "11.21.3", 5 | "main": "dist/index.js", 6 | "typings": "dist/index.d.ts", 7 | "license": "BSD-3-Clause", 8 | "funding": "https://github.com/sponsors/outline", 9 | "scripts": { 10 | "test": "jest", 11 | "lint": "tsc --noEmit && eslint '*/**/*.{js,ts,tsx}' --quiet", 12 | "start": "start-storybook -p 6006", 13 | "build": "tsc", 14 | "preversion": "yarn build", 15 | "watch": "yarn tsc-watch", 16 | "build-storybook": "build-storybook" 17 | }, 18 | "serve": { 19 | "open": true, 20 | "static": "example/dist" 21 | }, 22 | "jest": { 23 | "testPathIgnorePatterns": [ 24 | "dist" 25 | ] 26 | }, 27 | "dependencies": { 28 | "copy-to-clipboard": "^3.0.8", 29 | "fuzzy-search": "^3.2.1", 30 | "gemoji": "6.x", 31 | "lodash": "^4.17.11", 32 | "markdown-it": "^12.2.0", 33 | "markdown-it-container": "^3.0.0", 34 | "markdown-it-emoji": "^2.0.0", 35 | "outline-icons": "^1.38.1", 36 | "prosemirror-commands": "^1.1.6", 37 | "prosemirror-dropcursor": "^1.3.3", 38 | "prosemirror-gapcursor": "^1.1.5", 39 | "prosemirror-history": "^1.1.3", 40 | "prosemirror-inputrules": "^1.1.3", 41 | "prosemirror-keymap": "^1.1.4", 42 | "prosemirror-markdown": "^1.5.2", 43 | "prosemirror-model": "^1.13.3", 44 | "prosemirror-schema-list": "^1.1.2", 45 | "prosemirror-state": "^1.3.4", 46 | "prosemirror-tables": "^1.1.1", 47 | "prosemirror-transform": "1.2.5", 48 | "prosemirror-utils": "^0.9.6", 49 | "prosemirror-view": "1.18.1", 50 | "react-medium-image-zoom": "^3.1.3", 51 | "react-portal": "^4.2.1", 52 | "refractor": "^3.3.1", 53 | "resize-observer-polyfill": "^1.5.1", 54 | "slugify": "^1.4.0", 55 | "smooth-scroll-into-view-if-needed": "^1.1.29" 56 | }, 57 | "peerDependencies": { 58 | "react": "^16.0.0 || ^17.0.0", 59 | "react-dom": "^16.0.0 || ^17.0.0", 60 | "styled-components": "^5.0.0" 61 | }, 62 | "devDependencies": { 63 | "@babel/core": "^7.12.16", 64 | "@babel/preset-env": "^7.12.11", 65 | "@babel/preset-react": "^7.14.5", 66 | "@babel/preset-typescript": "^7.12.7", 67 | "@storybook/addon-actions": "^6.4.9", 68 | "@storybook/addon-essentials": "^6.4.9", 69 | "@storybook/addon-links": "^6.4.9", 70 | "@storybook/react": "^6.4.9", 71 | "@types/fuzzy-search": "^2.1.1", 72 | "@types/jest": "^26.0.20", 73 | "@types/lodash": "^4.14.149", 74 | "@types/markdown-it": "^10.0.1", 75 | "@types/prosemirror-commands": "^1.0.1", 76 | "@types/prosemirror-dropcursor": "^1.0.0", 77 | "@types/prosemirror-gapcursor": "^1.0.1", 78 | "@types/prosemirror-history": "^1.0.1", 79 | "@types/prosemirror-inputrules": "^1.0.2", 80 | "@types/prosemirror-keymap": "^1.0.1", 81 | "@types/prosemirror-markdown": "^1.0.3", 82 | "@types/prosemirror-model": "^1.7.2", 83 | "@types/prosemirror-schema-list": "^1.0.1", 84 | "@types/prosemirror-state": "^1.2.4", 85 | "@types/prosemirror-view": "^1.11.4", 86 | "@types/react": "^16.9.19", 87 | "@types/react-dom": "^16.9.5", 88 | "@types/refractor": "^2.8.0", 89 | "@types/styled-components": "^5.1.7", 90 | "@typescript-eslint/eslint-plugin": "^4.15.2", 91 | "@typescript-eslint/parser": "^4.15.2", 92 | "babel-jest": "^26.6.3", 93 | "babel-loader": "^8.2.2", 94 | "eslint": "^7.20.0", 95 | "eslint-config-prettier": "^6.15.0", 96 | "eslint-config-react-app": "^6.0.0", 97 | "eslint-plugin-import": "^2.22.0", 98 | "eslint-plugin-jsx-a11y": "^6.4.1", 99 | "eslint-plugin-prettier": "^3.1.4", 100 | "eslint-plugin-react": "^7.21.5", 101 | "eslint-plugin-react-hooks": "^4.0.8", 102 | "jest": "^26.6.3", 103 | "prettier": "^1.19.1", 104 | "react": "^17.0.0", 105 | "react-dom": "^17.0.0", 106 | "source-map-loader": "^0.2.4", 107 | "styled-components": "^5.2.1", 108 | "ts-loader": "^6.2.1", 109 | "tsc-watch": "^4.2.9", 110 | "typescript": "4.1.6" 111 | }, 112 | "resolutions": { 113 | "markdown-it": "^12.2.0", 114 | "prosemirror-transform": "1.2.5", 115 | "yargs-parser": "^15.0.1" 116 | }, 117 | "repository": { 118 | "type": "git", 119 | "url": "git+https://github.com/outline/rich-markdown-editor.git" 120 | }, 121 | "keywords": [ 122 | "editor", 123 | "markdown", 124 | "text", 125 | "wysiwyg" 126 | ], 127 | "author": "Tom Moor ", 128 | "bugs": { 129 | "url": "https://github.com/outline/rich-markdown-editor/issues" 130 | }, 131 | "homepage": "https://github.com/outline/rich-markdown-editor#readme" 132 | } 133 | -------------------------------------------------------------------------------- /src/commands/README.md: -------------------------------------------------------------------------------- 1 | https://prosemirror.net/docs/ref/#commands 2 | 3 | Commands are building block functions that encapsulate an editing action. A command function takes an editor state, optionally a dispatch function that it can use to dispatch a transaction and optionally an EditorView instance. It should return a boolean that indicates whether it could perform any action. 4 | 5 | Additional commands that are not included as part of prosemirror-commands, but are often reused can be found in this folder. -------------------------------------------------------------------------------- /src/commands/backspaceToParagraph.ts: -------------------------------------------------------------------------------- 1 | export default function backspaceToParagraph(type) { 2 | return (state, dispatch) => { 3 | const { $from, from, to, empty } = state.selection; 4 | 5 | // if the selection has anything in it then use standard delete behavior 6 | if (!empty) return null; 7 | 8 | // check we're in a matching node 9 | if ($from.parent.type !== type) return null; 10 | 11 | // check if we're at the beginning of the heading 12 | const $pos = state.doc.resolve(from - 1); 13 | if ($pos.parent === $from.parent) return null; 14 | 15 | // okay, replace it with a paragraph 16 | dispatch( 17 | state.tr 18 | .setBlockType(from, to, type.schema.nodes.paragraph) 19 | .scrollIntoView() 20 | ); 21 | return true; 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/commands/createAndInsertLink.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "prosemirror-view"; 2 | import baseDictionary from "../dictionary"; 3 | import { ToastType } from "../types"; 4 | 5 | function findPlaceholderLink(doc, href) { 6 | let result; 7 | 8 | function findLinks(node, pos = 0) { 9 | // get text nodes 10 | if (node.type.name === "text") { 11 | // get marks for text nodes 12 | node.marks.forEach(mark => { 13 | // any of the marks links? 14 | if (mark.type.name === "link") { 15 | // any of the links to other docs? 16 | if (mark.attrs.href === href) { 17 | result = { node, pos }; 18 | if (result) return false; 19 | } 20 | } 21 | }); 22 | } 23 | 24 | if (!node.content.size) { 25 | return; 26 | } 27 | 28 | node.descendants(findLinks); 29 | } 30 | 31 | findLinks(doc); 32 | return result; 33 | } 34 | 35 | const createAndInsertLink = async function( 36 | view: EditorView, 37 | title: string, 38 | href: string, 39 | options: { 40 | dictionary: typeof baseDictionary; 41 | onCreateLink: (title: string) => Promise; 42 | onShowToast?: (message: string, code: string) => void; 43 | } 44 | ) { 45 | const { dispatch, state } = view; 46 | const { onCreateLink, onShowToast } = options; 47 | 48 | try { 49 | const url = await onCreateLink(title); 50 | const result = findPlaceholderLink(view.state.doc, href); 51 | 52 | if (!result) return; 53 | 54 | dispatch( 55 | view.state.tr 56 | .removeMark( 57 | result.pos, 58 | result.pos + result.node.nodeSize, 59 | state.schema.marks.link 60 | ) 61 | .addMark( 62 | result.pos, 63 | result.pos + result.node.nodeSize, 64 | state.schema.marks.link.create({ href: url }) 65 | ) 66 | ); 67 | } catch (err) { 68 | const result = findPlaceholderLink(view.state.doc, href); 69 | if (!result) return; 70 | 71 | dispatch( 72 | view.state.tr.removeMark( 73 | result.pos, 74 | result.pos + result.node.nodeSize, 75 | state.schema.marks.link 76 | ) 77 | ); 78 | 79 | // let the user know 80 | if (onShowToast) { 81 | onShowToast(options.dictionary.createLinkError, ToastType.Error); 82 | } 83 | } 84 | }; 85 | 86 | export default createAndInsertLink; 87 | -------------------------------------------------------------------------------- /src/commands/insertFiles.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "prosemirror-view"; 2 | import uploadPlaceholderPlugin, { 3 | findPlaceholder, 4 | } from "../lib/uploadPlaceholder"; 5 | import { ToastType } from "../types"; 6 | import baseDictionary from "../dictionary"; 7 | import { NodeSelection } from "prosemirror-state"; 8 | 9 | let uploadId = 0; 10 | 11 | const insertFiles = function( 12 | view: EditorView, 13 | event: Event, 14 | pos: number, 15 | files: File[], 16 | options: { 17 | dictionary: typeof baseDictionary; 18 | replaceExisting?: boolean; 19 | uploadImage: (file: File) => Promise; 20 | onImageUploadStart?: () => void; 21 | onImageUploadStop?: () => void; 22 | onShowToast?: (message: string, code: string) => void; 23 | } 24 | ): void { 25 | // filter to only include image files 26 | const images = files.filter(file => /image/i.test(file.type)); 27 | if (images.length === 0) return; 28 | 29 | const { 30 | dictionary, 31 | uploadImage, 32 | onImageUploadStart, 33 | onImageUploadStop, 34 | onShowToast, 35 | } = options; 36 | 37 | if (!uploadImage) { 38 | console.warn( 39 | "uploadImage callback must be defined to handle image uploads." 40 | ); 41 | return; 42 | } 43 | 44 | // okay, we have some dropped images and a handler – lets stop this 45 | // event going any further up the stack 46 | event.preventDefault(); 47 | 48 | // let the user know we're starting to process the images 49 | if (onImageUploadStart) onImageUploadStart(); 50 | 51 | const { schema } = view.state; 52 | 53 | // we'll use this to track of how many images have succeeded or failed 54 | let complete = 0; 55 | 56 | // the user might have dropped multiple images at once, we need to loop 57 | for (const file of images) { 58 | const id = `upload-${uploadId++}`; 59 | 60 | const { tr } = view.state; 61 | 62 | // insert a placeholder at this position, or mark an existing image as being 63 | // replaced 64 | tr.setMeta(uploadPlaceholderPlugin, { 65 | add: { 66 | id, 67 | file, 68 | pos, 69 | replaceExisting: options.replaceExisting, 70 | }, 71 | }); 72 | view.dispatch(tr); 73 | 74 | // start uploading the image file to the server. Using "then" syntax 75 | // to allow all placeholders to be entered at once with the uploads 76 | // happening in the background in parallel. 77 | uploadImage(file) 78 | .then(src => { 79 | // otherwise, insert it at the placeholder's position, and remove 80 | // the placeholder itself 81 | const newImg = new Image(); 82 | 83 | newImg.onload = () => { 84 | const result = findPlaceholder(view.state, id); 85 | 86 | // if the content around the placeholder has been deleted 87 | // then forget about inserting this image 88 | if (result === null) { 89 | return; 90 | } 91 | 92 | const [from, to] = result; 93 | view.dispatch( 94 | view.state.tr 95 | .replaceWith(from, to || from, schema.nodes.image.create({ src })) 96 | .setMeta(uploadPlaceholderPlugin, { remove: { id } }) 97 | ); 98 | 99 | // If the users selection is still at the image then make sure to select 100 | // the entire node once done. Otherwise, if the selection has moved 101 | // elsewhere then we don't want to modify it 102 | if (view.state.selection.from === from) { 103 | view.dispatch( 104 | view.state.tr.setSelection( 105 | new NodeSelection(view.state.doc.resolve(from)) 106 | ) 107 | ); 108 | } 109 | }; 110 | 111 | newImg.onerror = error => { 112 | throw error; 113 | }; 114 | 115 | newImg.src = src; 116 | }) 117 | .catch(error => { 118 | console.error(error); 119 | 120 | // cleanup the placeholder if there is a failure 121 | const transaction = view.state.tr.setMeta(uploadPlaceholderPlugin, { 122 | remove: { id }, 123 | }); 124 | view.dispatch(transaction); 125 | 126 | // let the user know 127 | if (onShowToast) { 128 | onShowToast(dictionary.imageUploadError, ToastType.Error); 129 | } 130 | }) 131 | .finally(() => { 132 | complete++; 133 | 134 | // once everything is done, let the user know 135 | if (complete === images.length && onImageUploadStop) { 136 | onImageUploadStop(); 137 | } 138 | }); 139 | } 140 | }; 141 | 142 | export default insertFiles; 143 | -------------------------------------------------------------------------------- /src/commands/moveLeft.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Atlassian Pty Ltd 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // This file is based on the implementation found here: 18 | // https://bitbucket.org/atlassian/design-system-mirror/src/master/editor/editor-core/src/plugins/text-formatting/commands/text-formatting.ts 19 | 20 | import { 21 | Selection, 22 | EditorState, 23 | Transaction, 24 | TextSelection, 25 | } from "prosemirror-state"; 26 | import isMarkActive from "../queries/isMarkActive"; 27 | 28 | function hasCode(state: EditorState, pos: number) { 29 | const { code_inline } = state.schema.marks; 30 | const node = pos >= 0 && state.doc.nodeAt(pos); 31 | 32 | return node 33 | ? !!node.marks.filter(mark => mark.type === code_inline).length 34 | : false; 35 | } 36 | 37 | export default function moveLeft() { 38 | return (state: EditorState, dispatch: (tr: Transaction) => void): boolean => { 39 | const { code_inline } = state.schema.marks; 40 | const { empty, $cursor } = state.selection as TextSelection; 41 | if (!empty || !$cursor) { 42 | return false; 43 | } 44 | 45 | const { storedMarks } = state.tr; 46 | 47 | if (code_inline) { 48 | const insideCode = code_inline && isMarkActive(code_inline)(state); 49 | const currentPosHasCode = hasCode(state, $cursor.pos); 50 | const nextPosHasCode = hasCode(state, $cursor.pos - 1); 51 | const nextNextPosHasCode = hasCode(state, $cursor.pos - 2); 52 | 53 | const exitingCode = 54 | currentPosHasCode && !nextPosHasCode && Array.isArray(storedMarks); 55 | const atLeftEdge = 56 | nextPosHasCode && 57 | !nextNextPosHasCode && 58 | (storedMarks === null || 59 | (Array.isArray(storedMarks) && !!storedMarks.length)); 60 | const atRightEdge = 61 | ((exitingCode && Array.isArray(storedMarks) && !storedMarks.length) || 62 | (!exitingCode && storedMarks === null)) && 63 | !nextPosHasCode && 64 | nextNextPosHasCode; 65 | const enteringCode = 66 | !currentPosHasCode && 67 | nextPosHasCode && 68 | Array.isArray(storedMarks) && 69 | !storedMarks.length; 70 | 71 | // at the right edge: remove code mark and move the cursor to the left 72 | if (!insideCode && atRightEdge) { 73 | const tr = state.tr.setSelection( 74 | Selection.near(state.doc.resolve($cursor.pos - 1)) 75 | ); 76 | 77 | dispatch(tr.removeStoredMark(code_inline)); 78 | 79 | return true; 80 | } 81 | 82 | // entering code mark (from right edge): don't move the cursor, just add the mark 83 | if (!insideCode && enteringCode) { 84 | dispatch(state.tr.addStoredMark(code_inline.create())); 85 | return true; 86 | } 87 | 88 | // at the left edge: add code mark and move the cursor to the left 89 | if (insideCode && atLeftEdge) { 90 | const tr = state.tr.setSelection( 91 | Selection.near(state.doc.resolve($cursor.pos - 1)) 92 | ); 93 | 94 | dispatch(tr.addStoredMark(code_inline.create())); 95 | return true; 96 | } 97 | 98 | // exiting code mark (or at the beginning of the line): don't move the cursor, just remove the mark 99 | const isFirstChild = $cursor.index($cursor.depth - 1) === 0; 100 | if ( 101 | insideCode && 102 | (exitingCode || (!$cursor.nodeBefore && isFirstChild)) 103 | ) { 104 | dispatch(state.tr.removeStoredMark(code_inline)); 105 | return true; 106 | } 107 | } 108 | 109 | return false; 110 | }; 111 | } 112 | -------------------------------------------------------------------------------- /src/commands/moveRight.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Atlassian Pty Ltd 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // This file is based on the implementation found here: 18 | // https://bitbucket.org/atlassian/design-system-mirror/src/master/editor/editor-core/src/plugins/text-formatting/commands/text-formatting.ts 19 | 20 | import { EditorState, Transaction, TextSelection } from "prosemirror-state"; 21 | import isMarkActive from "../queries/isMarkActive"; 22 | 23 | export default function moveRight() { 24 | return (state: EditorState, dispatch: (tr: Transaction) => void): boolean => { 25 | const { code_inline } = state.schema.marks; 26 | const { empty, $cursor } = state.selection as TextSelection; 27 | if (!empty || !$cursor) { 28 | return false; 29 | } 30 | 31 | const { storedMarks } = state.tr; 32 | if (code_inline) { 33 | const insideCode = isMarkActive(code_inline)(state); 34 | const currentPosHasCode = state.doc.rangeHasMark( 35 | $cursor.pos, 36 | $cursor.pos, 37 | code_inline 38 | ); 39 | const nextPosHasCode = state.doc.rangeHasMark( 40 | $cursor.pos, 41 | $cursor.pos + 1, 42 | code_inline 43 | ); 44 | 45 | const exitingCode = 46 | !currentPosHasCode && 47 | !nextPosHasCode && 48 | (!storedMarks || !!storedMarks.length); 49 | const enteringCode = 50 | !currentPosHasCode && 51 | nextPosHasCode && 52 | (!storedMarks || !storedMarks.length); 53 | 54 | // entering code mark (from the left edge): don't move the cursor, just add the mark 55 | if (!insideCode && enteringCode) { 56 | dispatch(state.tr.addStoredMark(code_inline.create())); 57 | 58 | return true; 59 | } 60 | 61 | // exiting code mark: don't move the cursor, just remove the mark 62 | if (insideCode && exitingCode) { 63 | dispatch(state.tr.removeStoredMark(code_inline)); 64 | 65 | return true; 66 | } 67 | } 68 | 69 | return false; 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /src/commands/splitHeading.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, TextSelection } from "prosemirror-state"; 2 | import { findBlockNodes } from "prosemirror-utils"; 3 | import { NodeType } from "prosemirror-model"; 4 | import findCollapsedNodes from "../queries/findCollapsedNodes"; 5 | 6 | export default function splitHeading(type: NodeType) { 7 | return (state: EditorState, dispatch): boolean => { 8 | const { $from, from, $to, to } = state.selection; 9 | 10 | // check we're in a matching heading node 11 | if ($from.parent.type !== type) return false; 12 | 13 | // check that the caret is at the end of the content, if it isn't then 14 | // standard node splitting behaviour applies 15 | const endPos = $to.after() - 1; 16 | if (endPos !== to) return false; 17 | 18 | // If the node isn't collapsed standard behavior applies 19 | if (!$from.parent.attrs.collapsed) return false; 20 | 21 | // Find the next visible block after this one. It takes into account nested 22 | // collapsed headings and reaching the end of the document 23 | const allBlocks = findBlockNodes(state.doc); 24 | const collapsedBlocks = findCollapsedNodes(state.doc); 25 | const visibleBlocks = allBlocks.filter( 26 | a => !collapsedBlocks.find(b => b.pos === a.pos) 27 | ); 28 | const nextVisibleBlock = visibleBlocks.find(a => a.pos > from); 29 | const pos = nextVisibleBlock 30 | ? nextVisibleBlock.pos 31 | : state.doc.content.size; 32 | 33 | // Insert our new heading directly before the next visible block 34 | const transaction = state.tr.insert( 35 | pos, 36 | type.create({ ...$from.parent.attrs, collapsed: false }) 37 | ); 38 | 39 | // Move the selection into the new heading node and make sure it's on screen 40 | dispatch( 41 | transaction 42 | .setSelection( 43 | TextSelection.near( 44 | transaction.doc.resolve( 45 | Math.min(pos + 1, transaction.doc.content.size) 46 | ) 47 | ) 48 | ) 49 | .scrollIntoView() 50 | ); 51 | 52 | return true; 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /src/commands/toggleBlockType.ts: -------------------------------------------------------------------------------- 1 | import { setBlockType } from "prosemirror-commands"; 2 | import isNodeActive from "../queries/isNodeActive"; 3 | 4 | export default function toggleBlockType(type, toggleType, attrs = {}) { 5 | return (state, dispatch) => { 6 | const isActive = isNodeActive(type, attrs)(state); 7 | 8 | if (isActive) { 9 | return setBlockType(toggleType)(state, dispatch); 10 | } 11 | 12 | return setBlockType(type, attrs)(state, dispatch); 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/commands/toggleList.ts: -------------------------------------------------------------------------------- 1 | import { NodeType } from "prosemirror-model"; 2 | import { EditorState, Transaction } from "prosemirror-state"; 3 | import { wrapInList, liftListItem } from "prosemirror-schema-list"; 4 | import { findParentNode } from "prosemirror-utils"; 5 | import isList from "../queries/isList"; 6 | 7 | export default function toggleList(listType: NodeType, itemType: NodeType) { 8 | return (state: EditorState, dispatch: (tr: Transaction) => void) => { 9 | const { schema, selection } = state; 10 | const { $from, $to } = selection; 11 | const range = $from.blockRange($to); 12 | 13 | if (!range) { 14 | return false; 15 | } 16 | 17 | const parentList = findParentNode(node => isList(node, schema))(selection); 18 | 19 | if (range.depth >= 1 && parentList && range.depth - parentList.depth <= 1) { 20 | if (parentList.node.type === listType) { 21 | return liftListItem(itemType)(state, dispatch); 22 | } 23 | 24 | if ( 25 | isList(parentList.node, schema) && 26 | listType.validContent(parentList.node.content) 27 | ) { 28 | const { tr } = state; 29 | tr.setNodeMarkup(parentList.pos, listType); 30 | 31 | if (dispatch) { 32 | dispatch(tr); 33 | } 34 | 35 | return false; 36 | } 37 | } 38 | 39 | return wrapInList(listType)(state, dispatch); 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/commands/toggleWrap.ts: -------------------------------------------------------------------------------- 1 | import { wrapIn, lift } from "prosemirror-commands"; 2 | import isNodeActive from "../queries/isNodeActive"; 3 | 4 | export default function toggleWrap(type, attrs?: Record) { 5 | return (state, dispatch) => { 6 | const isActive = isNodeActive(type)(state); 7 | 8 | if (isActive) { 9 | return lift(state, dispatch); 10 | } 11 | 12 | return wrapIn(type, attrs)(state, dispatch); 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/BlockMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { findParentNode } from "prosemirror-utils"; 3 | import CommandMenu, { Props } from "./CommandMenu"; 4 | import BlockMenuItem from "./BlockMenuItem"; 5 | import getMenuItems from "../menus/block"; 6 | 7 | type BlockMenuProps = Omit< 8 | Props, 9 | "renderMenuItem" | "items" | "onClearSearch" 10 | > & 11 | Required>; 12 | 13 | class BlockMenu extends React.Component { 14 | get items() { 15 | return getMenuItems(this.props.dictionary); 16 | } 17 | 18 | clearSearch = () => { 19 | const { state, dispatch } = this.props.view; 20 | const parent = findParentNode(node => !!node)(state.selection); 21 | 22 | if (parent) { 23 | dispatch(state.tr.insertText("", parent.pos, state.selection.to)); 24 | } 25 | }; 26 | 27 | render() { 28 | return ( 29 | { 34 | return ( 35 | 42 | ); 43 | }} 44 | items={this.items} 45 | /> 46 | ); 47 | } 48 | } 49 | 50 | export default BlockMenu; 51 | -------------------------------------------------------------------------------- /src/components/BlockMenuItem.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import scrollIntoView from "smooth-scroll-into-view-if-needed"; 3 | import styled, { withTheme } from "styled-components"; 4 | import theme from "../styles/theme"; 5 | 6 | export type Props = { 7 | selected: boolean; 8 | disabled?: boolean; 9 | onClick: () => void; 10 | theme: typeof theme; 11 | icon?: typeof React.Component | React.FC; 12 | title: React.ReactNode; 13 | shortcut?: string; 14 | containerId?: string; 15 | }; 16 | 17 | function BlockMenuItem({ 18 | selected, 19 | disabled, 20 | onClick, 21 | title, 22 | shortcut, 23 | icon, 24 | containerId = "block-menu-container", 25 | }: Props) { 26 | const Icon = icon; 27 | 28 | const ref = React.useCallback( 29 | node => { 30 | if (selected && node) { 31 | scrollIntoView(node, { 32 | scrollMode: "if-needed", 33 | block: "center", 34 | boundary: parent => { 35 | // All the parent elements of your target are checked until they 36 | // reach the #block-menu-container. Prevents body and other parent 37 | // elements from being scrolled 38 | return parent.id !== containerId; 39 | }, 40 | }); 41 | } 42 | }, 43 | [selected, containerId] 44 | ); 45 | 46 | return ( 47 | 52 | {Icon && ( 53 | <> 54 | 59 |    60 | 61 | )} 62 | {title} 63 | {shortcut && {shortcut}} 64 | 65 | ); 66 | } 67 | 68 | const MenuItem = styled.button<{ 69 | selected: boolean; 70 | }>` 71 | display: flex; 72 | align-items: center; 73 | justify-content: flex-start; 74 | font-weight: 500; 75 | font-size: 14px; 76 | line-height: 1; 77 | width: 100%; 78 | height: 36px; 79 | cursor: pointer; 80 | border: none; 81 | opacity: ${props => (props.disabled ? ".5" : "1")}; 82 | color: ${props => 83 | props.selected 84 | ? props.theme.blockToolbarTextSelected 85 | : props.theme.blockToolbarText}; 86 | background: ${props => 87 | props.selected 88 | ? props.theme.blockToolbarSelectedBackground || 89 | props.theme.blockToolbarTrigger 90 | : "none"}; 91 | padding: 0 16px; 92 | outline: none; 93 | 94 | &:hover, 95 | &:active { 96 | color: ${props => props.theme.blockToolbarTextSelected}; 97 | background: ${props => 98 | props.selected 99 | ? props.theme.blockToolbarSelectedBackground || 100 | props.theme.blockToolbarTrigger 101 | : props.theme.blockToolbarHoverBackground}; 102 | } 103 | `; 104 | 105 | const Shortcut = styled.span` 106 | color: ${props => props.theme.textSecondary}; 107 | flex-grow: 1; 108 | text-align: right; 109 | `; 110 | 111 | export default withTheme(BlockMenuItem); 112 | -------------------------------------------------------------------------------- /src/components/EmojiMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gemojies from "gemoji"; 3 | import FuzzySearch from "fuzzy-search"; 4 | import CommandMenu, { Props } from "./CommandMenu"; 5 | import EmojiMenuItem from "./EmojiMenuItem"; 6 | 7 | type Emoji = { 8 | name: string; 9 | title: string; 10 | emoji: string; 11 | description: string; 12 | attrs: { markup: string; "data-name": string }; 13 | }; 14 | 15 | const searcher = new FuzzySearch<{ 16 | names: string[]; 17 | description: string; 18 | emoji: string; 19 | }>(gemojies, ["names"], { 20 | caseSensitive: true, 21 | sort: true, 22 | }); 23 | 24 | class EmojiMenu extends React.Component< 25 | Omit< 26 | Props, 27 | | "renderMenuItem" 28 | | "items" 29 | | "onLinkToolbarOpen" 30 | | "embeds" 31 | | "onClearSearch" 32 | > 33 | > { 34 | get items(): Emoji[] { 35 | const { search = "" } = this.props; 36 | 37 | const n = search.toLowerCase(); 38 | const result = searcher.search(n).map(item => { 39 | const description = item.description; 40 | const name = item.names[0]; 41 | return { 42 | ...item, 43 | name: "emoji", 44 | title: name, 45 | description, 46 | attrs: { markup: name, "data-name": name }, 47 | }; 48 | }); 49 | 50 | return result.slice(0, 10); 51 | } 52 | 53 | clearSearch = () => { 54 | const { state, dispatch } = this.props.view; 55 | 56 | // clear search input 57 | dispatch( 58 | state.tr.insertText( 59 | "", 60 | state.selection.$from.pos - (this.props.search ?? "").length - 1, 61 | state.selection.to 62 | ) 63 | ); 64 | }; 65 | 66 | render() { 67 | return ( 68 | { 74 | return ( 75 | 82 | ); 83 | }} 84 | items={this.items} 85 | /> 86 | ); 87 | } 88 | } 89 | 90 | export default EmojiMenu; 91 | -------------------------------------------------------------------------------- /src/components/EmojiMenuItem.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import BlockMenuItem, { Props as BlockMenuItemProps } from "./BlockMenuItem"; 3 | import styled from "styled-components"; 4 | 5 | const Emoji = styled.span` 6 | font-size: 16px; 7 | `; 8 | 9 | const EmojiTitle = ({ 10 | emoji, 11 | title, 12 | }: { 13 | emoji: React.ReactNode; 14 | title: React.ReactNode; 15 | }) => { 16 | return ( 17 |

18 | {emoji} 19 |    20 | {title} 21 |

22 | ); 23 | }; 24 | 25 | type EmojiMenuItemProps = Omit & { 26 | emoji: string; 27 | }; 28 | 29 | export default function EmojiMenuItem(props: EmojiMenuItemProps) { 30 | return ( 31 | } 34 | /> 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/Flex.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | type JustifyValues = 5 | | "center" 6 | | "space-around" 7 | | "space-between" 8 | | "flex-start" 9 | | "flex-end"; 10 | 11 | type AlignValues = 12 | | "stretch" 13 | | "center" 14 | | "baseline" 15 | | "flex-start" 16 | | "flex-end"; 17 | 18 | type Props = { 19 | style?: React.CSSProperties; 20 | column?: boolean; 21 | align?: AlignValues; 22 | justify?: JustifyValues; 23 | auto?: boolean; 24 | className?: string; 25 | children?: React.ReactNode; 26 | }; 27 | 28 | const Flex = styled.div` 29 | display: flex; 30 | flex: ${({ auto }: Props) => (auto ? "1 1 auto" : "initial")}; 31 | flex-direction: ${({ column }: Props) => (column ? "column" : "row")}; 32 | align-items: ${({ align }: Props) => align}; 33 | justify-content: ${({ justify }: Props) => justify}; 34 | `; 35 | 36 | export default Flex; 37 | -------------------------------------------------------------------------------- /src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const Input = styled.input` 4 | font-size: 15px; 5 | background: ${props => props.theme.toolbarInput}; 6 | color: ${props => props.theme.toolbarItem}; 7 | border-radius: 2px; 8 | padding: 3px 8px; 9 | border: 0; 10 | margin: 0; 11 | outline: none; 12 | flex-grow: 1; 13 | 14 | @media (hover: none) and (pointer: coarse) { 15 | font-size: 16px; 16 | } 17 | `; 18 | 19 | export default Input; 20 | -------------------------------------------------------------------------------- /src/components/LinkSearchResult.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import scrollIntoView from "smooth-scroll-into-view-if-needed"; 3 | import styled from "styled-components"; 4 | 5 | type Props = { 6 | onClick: (event: React.MouseEvent) => void; 7 | onMouseOver: (event: React.MouseEvent) => void; 8 | icon: React.ReactNode; 9 | selected: boolean; 10 | title: string; 11 | subtitle?: string; 12 | }; 13 | 14 | function LinkSearchResult({ title, subtitle, selected, icon, ...rest }: Props) { 15 | const ref = React.useCallback( 16 | node => { 17 | if (selected && node) { 18 | scrollIntoView(node, { 19 | scrollMode: "if-needed", 20 | block: "center", 21 | boundary: parent => { 22 | // All the parent elements of your target are checked until they 23 | // reach the #link-search-results. Prevents body and other parent 24 | // elements from being scrolled 25 | return parent.id !== "link-search-results"; 26 | }, 27 | }); 28 | } 29 | }, 30 | [selected] 31 | ); 32 | 33 | return ( 34 | 35 | {icon} 36 |
37 | {title} 38 | {subtitle ? {subtitle} : null} 39 |
40 |
41 | ); 42 | } 43 | 44 | const IconWrapper = styled.span` 45 | flex-shrink: 0; 46 | margin-right: 4px; 47 | opacity: 0.8; 48 | `; 49 | 50 | const ListItem = styled.li<{ 51 | selected: boolean; 52 | compact: boolean; 53 | }>` 54 | display: flex; 55 | align-items: center; 56 | padding: 8px; 57 | border-radius: 2px; 58 | color: ${props => props.theme.toolbarItem}; 59 | background: ${props => 60 | props.selected ? props.theme.toolbarHoverBackground : "transparent"}; 61 | font-family: ${props => props.theme.fontFamily}; 62 | text-decoration: none; 63 | overflow: hidden; 64 | white-space: nowrap; 65 | cursor: pointer; 66 | user-select: none; 67 | line-height: ${props => (props.compact ? "inherit" : "1.2")}; 68 | height: ${props => (props.compact ? "28px" : "auto")}; 69 | `; 70 | 71 | const Title = styled.div` 72 | font-size: 14px; 73 | font-weight: 500; 74 | `; 75 | 76 | const Subtitle = styled.div<{ 77 | selected: boolean; 78 | }>` 79 | font-size: 13px; 80 | opacity: ${props => (props.selected ? 0.75 : 0.5)}; 81 | `; 82 | 83 | export default LinkSearchResult; 84 | -------------------------------------------------------------------------------- /src/components/LinkToolbar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { EditorView } from "prosemirror-view"; 3 | import LinkEditor, { SearchResult } from "./LinkEditor"; 4 | import FloatingToolbar from "./FloatingToolbar"; 5 | import createAndInsertLink from "../commands/createAndInsertLink"; 6 | import baseDictionary from "../dictionary"; 7 | 8 | type Props = { 9 | isActive: boolean; 10 | view: EditorView; 11 | tooltip: typeof React.Component | React.FC; 12 | dictionary: typeof baseDictionary; 13 | onCreateLink?: (title: string) => Promise; 14 | onSearchLink?: (term: string) => Promise; 15 | onClickLink: (href: string, event: MouseEvent) => void; 16 | onShowToast?: (msg: string, code: string) => void; 17 | onClose: () => void; 18 | }; 19 | 20 | function isActive(props: Props) { 21 | const { view } = props; 22 | const { selection } = view.state; 23 | 24 | try { 25 | const paragraph = view.domAtPos(selection.from); 26 | return props.isActive && !!paragraph.node; 27 | } catch (err) { 28 | return false; 29 | } 30 | } 31 | 32 | export default class LinkToolbar extends React.Component { 33 | menuRef = React.createRef(); 34 | 35 | state = { 36 | left: -1000, 37 | top: undefined, 38 | }; 39 | 40 | componentDidMount() { 41 | window.addEventListener("mousedown", this.handleClickOutside); 42 | } 43 | 44 | componentWillUnmount() { 45 | window.removeEventListener("mousedown", this.handleClickOutside); 46 | } 47 | 48 | handleClickOutside = ev => { 49 | if ( 50 | ev.target && 51 | this.menuRef.current && 52 | this.menuRef.current.contains(ev.target) 53 | ) { 54 | return; 55 | } 56 | 57 | this.props.onClose(); 58 | }; 59 | 60 | handleOnCreateLink = async (title: string) => { 61 | const { dictionary, onCreateLink, view, onClose, onShowToast } = this.props; 62 | 63 | onClose(); 64 | this.props.view.focus(); 65 | 66 | if (!onCreateLink) { 67 | return; 68 | } 69 | 70 | const { dispatch, state } = view; 71 | const { from, to } = state.selection; 72 | if (from !== to) { 73 | // selection must be collapsed 74 | return; 75 | } 76 | 77 | const href = `creating#${title}…`; 78 | 79 | // Insert a placeholder link 80 | dispatch( 81 | view.state.tr 82 | .insertText(title, from, to) 83 | .addMark( 84 | from, 85 | to + title.length, 86 | state.schema.marks.link.create({ href }) 87 | ) 88 | ); 89 | 90 | createAndInsertLink(view, title, href, { 91 | onCreateLink, 92 | onShowToast, 93 | dictionary, 94 | }); 95 | }; 96 | 97 | handleOnSelectLink = ({ 98 | href, 99 | title, 100 | }: { 101 | href: string; 102 | title: string; 103 | from: number; 104 | to: number; 105 | }) => { 106 | const { view, onClose } = this.props; 107 | 108 | onClose(); 109 | this.props.view.focus(); 110 | 111 | const { dispatch, state } = view; 112 | const { from, to } = state.selection; 113 | if (from !== to) { 114 | // selection must be collapsed 115 | return; 116 | } 117 | 118 | dispatch( 119 | view.state.tr 120 | .insertText(title, from, to) 121 | .addMark( 122 | from, 123 | to + title.length, 124 | state.schema.marks.link.create({ href }) 125 | ) 126 | ); 127 | }; 128 | 129 | render() { 130 | const { onCreateLink, onClose, ...rest } = this.props; 131 | const { selection } = this.props.view.state; 132 | const active = isActive(this.props); 133 | 134 | return ( 135 | 136 | {active && ( 137 | 145 | )} 146 | 147 | ); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/components/ToolbarButton.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | type Props = { active?: boolean; disabled?: boolean }; 4 | 5 | export default styled.button` 6 | display: inline-block; 7 | flex: 0; 8 | width: 24px; 9 | height: 24px; 10 | cursor: pointer; 11 | margin-left: 8px; 12 | border: none; 13 | background: none; 14 | transition: opacity 100ms ease-in-out; 15 | padding: 0; 16 | opacity: 0.7; 17 | outline: none; 18 | pointer-events: all; 19 | position: relative; 20 | 21 | &:first-child { 22 | margin-left: 0; 23 | } 24 | 25 | &:hover { 26 | opacity: 1; 27 | } 28 | 29 | &:disabled { 30 | opacity: 0.3; 31 | cursor: default; 32 | } 33 | 34 | &:before { 35 | position: absolute; 36 | content: ""; 37 | top: -4px; 38 | right: -4px; 39 | left: -4px; 40 | bottom: -4px; 41 | } 42 | 43 | ${props => props.active && "opacity: 1;"}; 44 | `; 45 | -------------------------------------------------------------------------------- /src/components/ToolbarMenu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { EditorView } from "prosemirror-view"; 3 | import styled, { withTheme } from "styled-components"; 4 | import ToolbarButton from "./ToolbarButton"; 5 | import ToolbarSeparator from "./ToolbarSeparator"; 6 | import theme from "../styles/theme"; 7 | import { MenuItem } from "../types"; 8 | 9 | type Props = { 10 | tooltip: typeof React.Component | React.FC; 11 | commands: Record; 12 | view: EditorView; 13 | theme: typeof theme; 14 | items: MenuItem[]; 15 | }; 16 | 17 | const FlexibleWrapper = styled.div` 18 | display: flex; 19 | `; 20 | 21 | class ToolbarMenu extends React.Component { 22 | render() { 23 | const { view, items } = this.props; 24 | const { state } = view; 25 | const Tooltip = this.props.tooltip; 26 | 27 | return ( 28 | 29 | {items.map((item, index) => { 30 | if (item.name === "separator" && item.visible !== false) { 31 | return ; 32 | } 33 | if (item.visible === false || !item.icon) { 34 | return null; 35 | } 36 | const Icon = item.icon; 37 | const isActive = item.active ? item.active(state) : false; 38 | 39 | return ( 40 | 43 | item.name && this.props.commands[item.name](item.attrs) 44 | } 45 | active={isActive} 46 | > 47 | 48 | 49 | 50 | 51 | ); 52 | })} 53 | 54 | ); 55 | } 56 | } 57 | 58 | export default withTheme(ToolbarMenu); 59 | -------------------------------------------------------------------------------- /src/components/ToolbarSeparator.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const Separator = styled.div` 4 | height: 24px; 5 | width: 2px; 6 | background: ${props => props.theme.toolbarItem}; 7 | opacity: 0.3; 8 | display: inline-block; 9 | margin-left: 8px; 10 | `; 11 | 12 | export default Separator; 13 | -------------------------------------------------------------------------------- /src/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | type Props = { 4 | tooltip: string; 5 | children: React.ReactNode; 6 | }; 7 | 8 | export default function Tooltip({ tooltip, children }: Props) { 9 | return {children}; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/VisuallyHidden.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const VisuallyHidden = styled.span` 4 | position: absolute !important; 5 | height: 1px; 6 | width: 1px; 7 | overflow: hidden; 8 | clip: rect(1px 1px 1px 1px); /* IE6, IE7 */ 9 | clip: rect(1px, 1px, 1px, 1px); 10 | `; 11 | 12 | export default VisuallyHidden; 13 | -------------------------------------------------------------------------------- /src/dictionary.ts: -------------------------------------------------------------------------------- 1 | export const base = { 2 | addColumnAfter: "Insert column after", 3 | addColumnBefore: "Insert column before", 4 | addRowAfter: "Insert row after", 5 | addRowBefore: "Insert row before", 6 | alignCenter: "Align center", 7 | alignLeft: "Align left", 8 | alignRight: "Align right", 9 | bulletList: "Bulleted list", 10 | checkboxList: "Todo list", 11 | codeBlock: "Code block", 12 | codeCopied: "Copied to clipboard", 13 | codeInline: "Code", 14 | createLink: "Create link", 15 | createLinkError: "Sorry, an error occurred creating the link", 16 | createNewDoc: "Create a new doc", 17 | deleteColumn: "Delete column", 18 | deleteRow: "Delete row", 19 | deleteTable: "Delete table", 20 | deleteImage: "Delete image", 21 | downloadImage: "Download image", 22 | replaceImage: "Replace image", 23 | alignImageLeft: "Float left half width", 24 | alignImageRight: "Float right half width", 25 | alignImageDefault: "Center large", 26 | em: "Italic", 27 | embedInvalidLink: "Sorry, that link won’t work for this embed type", 28 | findOrCreateDoc: "Find or create a doc…", 29 | h1: "Big heading", 30 | h2: "Medium heading", 31 | h3: "Small heading", 32 | heading: "Heading", 33 | hr: "Divider", 34 | image: "Image", 35 | imageUploadError: "Sorry, an error occurred uploading the image", 36 | imageCaptionPlaceholder: "Write a caption", 37 | info: "Info", 38 | infoNotice: "Info notice", 39 | link: "Link", 40 | linkCopied: "Link copied to clipboard", 41 | mark: "Highlight", 42 | newLineEmpty: "Type '/' to insert…", 43 | newLineWithSlash: "Keep typing to filter…", 44 | noResults: "No results", 45 | openLink: "Open link", 46 | orderedList: "Ordered list", 47 | pageBreak: "Page break", 48 | pasteLink: "Paste a link…", 49 | pasteLinkWithTitle: (title: string): string => `Paste a ${title} link…`, 50 | placeholder: "Placeholder", 51 | quote: "Quote", 52 | removeLink: "Remove link", 53 | searchOrPasteLink: "Search or paste a link…", 54 | strikethrough: "Strikethrough", 55 | strong: "Bold", 56 | subheading: "Subheading", 57 | table: "Table", 58 | tip: "Tip", 59 | tipNotice: "Tip notice", 60 | warning: "Warning", 61 | warningNotice: "Warning notice", 62 | }; 63 | 64 | export default base; 65 | -------------------------------------------------------------------------------- /src/hooks/useComponentSize.ts: -------------------------------------------------------------------------------- 1 | import ResizeObserver from "resize-observer-polyfill"; 2 | import { useState, useEffect } from "react"; 3 | 4 | export default function useComponentSize( 5 | ref 6 | ): { width: number; height: number } { 7 | const [size, setSize] = useState({ 8 | width: 0, 9 | height: 0, 10 | }); 11 | 12 | useEffect(() => { 13 | const sizeObserver = new ResizeObserver(entries => { 14 | entries.forEach(({ target }) => { 15 | if ( 16 | size.width !== target.clientWidth || 17 | size.height !== target.clientHeight 18 | ) { 19 | setSize({ width: target.clientWidth, height: target.clientHeight }); 20 | } 21 | }); 22 | }); 23 | sizeObserver.observe(ref.current); 24 | 25 | return () => sizeObserver.disconnect(); 26 | }, [ref]); 27 | 28 | return size; 29 | } 30 | -------------------------------------------------------------------------------- /src/hooks/useMediaQuery.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | export default function useMediaQuery(query: string): boolean { 4 | const [matches, setMatches] = useState(false); 5 | 6 | useEffect(() => { 7 | if (window.matchMedia) { 8 | const media = window.matchMedia(query); 9 | if (media.matches !== matches) { 10 | setMatches(media.matches); 11 | } 12 | const listener = () => { 13 | setMatches(media.matches); 14 | }; 15 | media.addListener(listener); 16 | return () => media.removeListener(listener); 17 | } 18 | }, [matches, query]); 19 | 20 | return matches; 21 | } 22 | -------------------------------------------------------------------------------- /src/hooks/useViewportHeight.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useState } from "react"; 2 | 3 | export default function useViewportHeight(): number | void { 4 | // https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport#browser_compatibility 5 | // Note: No support in Firefox at time of writing, however this mainly exists 6 | // for virtual keyboards on mobile devices, so that's okay. 7 | const [height, setHeight] = useState( 8 | () => window.visualViewport?.height || window.innerHeight 9 | ); 10 | 11 | useLayoutEffect(() => { 12 | const handleResize = () => { 13 | setHeight(() => window.visualViewport?.height || window.innerHeight); 14 | }; 15 | 16 | window.visualViewport?.addEventListener("resize", handleResize); 17 | 18 | return () => { 19 | window.visualViewport?.removeEventListener("resize", handleResize); 20 | }; 21 | }, []); 22 | 23 | return height; 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/ComponentView.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { ThemeProvider } from "styled-components"; 4 | import { EditorView, Decoration } from "prosemirror-view"; 5 | import Extension from "../lib/Extension"; 6 | import Node from "../nodes/Node"; 7 | import { light as lightTheme, dark as darkTheme } from "../styles/theme"; 8 | import Editor from "../"; 9 | 10 | type Component = (options: { 11 | node: Node; 12 | theme: typeof lightTheme; 13 | isSelected: boolean; 14 | isEditable: boolean; 15 | getPos: () => number; 16 | }) => React.ReactElement; 17 | 18 | export default class ComponentView { 19 | component: Component; 20 | editor: Editor; 21 | extension: Extension; 22 | node: Node; 23 | view: EditorView; 24 | getPos: () => number; 25 | decorations: Decoration<{ [key: string]: any }>[]; 26 | isSelected = false; 27 | dom: HTMLElement | null; 28 | 29 | // See https://prosemirror.net/docs/ref/#view.NodeView 30 | constructor( 31 | component, 32 | { editor, extension, node, view, getPos, decorations } 33 | ) { 34 | this.component = component; 35 | this.editor = editor; 36 | this.extension = extension; 37 | this.getPos = getPos; 38 | this.decorations = decorations; 39 | this.node = node; 40 | this.view = view; 41 | this.dom = node.type.spec.inline 42 | ? document.createElement("span") 43 | : document.createElement("div"); 44 | 45 | this.renderElement(); 46 | } 47 | 48 | renderElement() { 49 | const { dark } = this.editor.props; 50 | const theme = this.editor.props.theme || (dark ? darkTheme : lightTheme); 51 | 52 | const children = this.component({ 53 | theme, 54 | node: this.node, 55 | isSelected: this.isSelected, 56 | isEditable: this.view.editable, 57 | getPos: this.getPos, 58 | }); 59 | 60 | ReactDOM.render( 61 | {children}, 62 | this.dom 63 | ); 64 | } 65 | 66 | update(node) { 67 | if (node.type !== this.node.type) { 68 | return false; 69 | } 70 | 71 | this.node = node; 72 | this.renderElement(); 73 | return true; 74 | } 75 | 76 | selectNode() { 77 | if (this.view.editable) { 78 | this.isSelected = true; 79 | this.renderElement(); 80 | } 81 | } 82 | 83 | deselectNode() { 84 | if (this.view.editable) { 85 | this.isSelected = false; 86 | this.renderElement(); 87 | } 88 | } 89 | 90 | stopEvent() { 91 | return true; 92 | } 93 | 94 | destroy() { 95 | if (this.dom) { 96 | ReactDOM.unmountComponentAtNode(this.dom); 97 | } 98 | this.dom = null; 99 | } 100 | 101 | ignoreMutation() { 102 | return true; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/lib/Extension.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { InputRule } from "prosemirror-inputrules"; 3 | import { Plugin } from "prosemirror-state"; 4 | import Editor from "../"; 5 | import { PluginSimple } from "markdown-it"; 6 | 7 | type Command = (attrs) => (state, dispatch) => any; 8 | 9 | export default class Extension { 10 | options: Record; 11 | editor: Editor; 12 | 13 | constructor(options: Record = {}) { 14 | this.options = { 15 | ...this.defaultOptions, 16 | ...options, 17 | }; 18 | } 19 | 20 | bindEditor(editor: Editor) { 21 | this.editor = editor; 22 | } 23 | 24 | get type() { 25 | return "extension"; 26 | } 27 | 28 | get name() { 29 | return ""; 30 | } 31 | 32 | get plugins(): Plugin[] { 33 | return []; 34 | } 35 | 36 | get rulePlugins(): PluginSimple[] { 37 | return []; 38 | } 39 | 40 | keys(options) { 41 | return {}; 42 | } 43 | 44 | inputRules(options): InputRule[] { 45 | return []; 46 | } 47 | 48 | commands(options): Record | Command { 49 | return attrs => () => false; 50 | } 51 | 52 | get defaultOptions() { 53 | return {}; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/__snapshots__/renderToHtml.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders blockquote 1`] = ` 4 | "
5 |

blockquote

6 |
" 7 | `; 8 | 9 | exports[`renders bold marks 1`] = `"

this is bold text

"`; 10 | 11 | exports[`renders bullet list 1`] = ` 12 | "
    13 |
  • item one
  • 14 |
  • item two 15 |
      16 |
    • nested item
    • 17 |
    18 |
  • 19 |
" 20 | `; 21 | 22 | exports[`renders checkbox list 1`] = ` 23 | "
    24 |
  • [ ]unchecked
  • 25 |
  • [x]checked
  • 26 |
" 27 | `; 28 | 29 | exports[`renders code block 1`] = ` 30 | "
this is indented code
 31 | 
" 32 | `; 33 | 34 | exports[`renders code fence 1`] = ` 35 | "
this is code
 36 | 
" 37 | `; 38 | 39 | exports[`renders code marks 1`] = `"

this is inline code text

"`; 40 | 41 | exports[`renders headings 1`] = ` 42 | "

Heading 1

43 |

Heading 2

44 |

Heading 3

45 |

Heading 4

" 46 | `; 47 | 48 | exports[`renders highlight marks 1`] = `"

this is highlighted text

"`; 49 | 50 | exports[`renders horizontal rule 1`] = `"
"`; 51 | 52 | exports[`renders image 1`] = `"

\\"caption\\"

"`; 53 | 54 | exports[`renders image with alignment 1`] = `"

\\"caption\\"

"`; 55 | 56 | exports[`renders info notice 1`] = ` 57 | "
58 |

content of notice

59 |
" 60 | `; 61 | 62 | exports[`renders italic marks 1`] = `"

this is italic text

"`; 63 | 64 | exports[`renders italic marks 2`] = `"

this is also italic text

"`; 65 | 66 | exports[`renders link marks 1`] = `"

this is linked text

"`; 67 | 68 | exports[`renders ordered list 1`] = ` 69 | "
    70 |
  1. item one
  2. 71 |
  3. item two
  4. 72 |
" 73 | `; 74 | 75 | exports[`renders ordered list 2`] = ` 76 | "
    77 |
  1. item one
  2. 78 |
  3. item two
  4. 79 |
" 80 | `; 81 | 82 | exports[`renders plain text as paragraph 1`] = `"

plain text

"`; 83 | 84 | exports[`renders table 1`] = ` 85 | " 86 | 87 | 89 | 91 | 93 | 94 | 95 | 97 | 99 | 101 | 102 | 103 | 105 | 107 | 109 | 110 |
88 |

heading

90 |

centered

92 |

right aligned

96 |

98 |

center

100 |

104 |

106 |

108 |

bottom r

" 111 | `; 112 | 113 | exports[`renders template placeholder marks 1`] = `"

this is a placeholder

"`; 114 | 115 | exports[`renders tip notice 1`] = ` 116 | "
117 |

content of notice

118 |
" 119 | `; 120 | 121 | exports[`renders underline marks 1`] = `"

this is underlined text

"`; 122 | 123 | exports[`renders underline marks 2`] = `"

this is strikethrough text

"`; 124 | 125 | exports[`renders warning notice 1`] = ` 126 | "
127 |

content of notice

128 |
" 129 | `; 130 | -------------------------------------------------------------------------------- /src/lib/filterExcessSeparators.ts: -------------------------------------------------------------------------------- 1 | import { EmbedDescriptor, MenuItem } from "../types"; 2 | 3 | export default function filterExcessSeparators( 4 | items: (MenuItem | EmbedDescriptor)[] 5 | ): (MenuItem | EmbedDescriptor)[] { 6 | return items.reduce((acc, item, index) => { 7 | // trim separators from start / end 8 | if (item.name === "separator" && index === 0) return acc; 9 | if (item.name === "separator" && index === items.length - 1) return acc; 10 | 11 | // trim double separators looking ahead / behind 12 | const prev = items[index - 1]; 13 | if (prev && prev.name === "separator" && item.name === "separator") 14 | return acc; 15 | 16 | const next = items[index + 1]; 17 | if (next && next.name === "separator" && item.name === "separator") 18 | return acc; 19 | 20 | // otherwise, continue 21 | return [...acc, item]; 22 | }, []); 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/getDataTransferFiles.ts: -------------------------------------------------------------------------------- 1 | export default function getDataTransferFiles(event) { 2 | let dataTransferItemsList = []; 3 | 4 | if (event.dataTransfer) { 5 | const dt = event.dataTransfer; 6 | if (dt.files && dt.files.length) { 7 | dataTransferItemsList = dt.files; 8 | } else if (dt.items && dt.items.length) { 9 | // During the drag even the dataTransfer.files is null 10 | // but Chrome implements some drag store, which is accesible via dataTransfer.items 11 | dataTransferItemsList = dt.items; 12 | } 13 | } else if (event.target && event.target.files) { 14 | dataTransferItemsList = event.target.files; 15 | } 16 | // Convert from DataTransferItemsList to the native Array 17 | return Array.prototype.slice.call(dataTransferItemsList); 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/getHeadings.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "prosemirror-view"; 2 | import headingToSlug from "./headingToSlug"; 3 | 4 | export default function getHeadings(view: EditorView) { 5 | const headings: { title: string; level: number; id: string }[] = []; 6 | const previouslySeen = {}; 7 | 8 | view.state.doc.forEach(node => { 9 | if (node.type.name === "heading") { 10 | // calculate the optimal id 11 | const id = headingToSlug(node); 12 | let name = id; 13 | 14 | // check if we've already used it, and if so how many times? 15 | // Make the new id based on that number ensuring that we have 16 | // unique ID's even when headings are identical 17 | if (previouslySeen[id] > 0) { 18 | name = headingToSlug(node, previouslySeen[id]); 19 | } 20 | 21 | // record that we've seen this id for the next loop 22 | previouslySeen[id] = 23 | previouslySeen[id] !== undefined ? previouslySeen[id] + 1 : 1; 24 | 25 | headings.push({ 26 | title: node.textContent, 27 | level: node.attrs.level, 28 | id: name, 29 | }); 30 | } 31 | }); 32 | return headings; 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/getMarkAttrs.ts: -------------------------------------------------------------------------------- 1 | import { Node as PMNode, Mark } from "prosemirror-model"; 2 | import { EditorState } from "prosemirror-state"; 3 | import Node from "../nodes/Node"; 4 | 5 | export default function getMarkAttrs(state: EditorState, type: Node) { 6 | const { from, to } = state.selection; 7 | let marks: Mark[] = []; 8 | 9 | state.doc.nodesBetween(from, to, (node: PMNode) => { 10 | marks = [...marks, ...node.marks]; 11 | 12 | if (node.content) { 13 | node.content.forEach(content => { 14 | marks = [...marks, ...content.marks]; 15 | }); 16 | } 17 | }); 18 | 19 | const mark = marks.find(markItem => markItem.type.name === type.name); 20 | 21 | if (mark) { 22 | return mark.attrs; 23 | } 24 | 25 | return {}; 26 | } 27 | -------------------------------------------------------------------------------- /src/lib/headingToSlug.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "prosemirror-model"; 2 | import escape from "lodash/escape"; 3 | import slugify from "slugify"; 4 | 5 | // Slugify, escape, and remove periods from headings so that they are 6 | // compatible with both url hashes AND dom ID's (querySelector does not like 7 | // ID's that begin with a number or a period, for example). 8 | function safeSlugify(text: string) { 9 | return `h-${escape( 10 | slugify(text, { 11 | remove: /[!"#$%&'\.()*+,\/:;<=>?@\[\]\\^_`{|}~]/g, 12 | lower: true, 13 | }) 14 | )}`; 15 | } 16 | 17 | // calculates a unique slug for this heading based on it's text and position 18 | // in the document that is as stable as possible 19 | export default function headingToSlug(node: Node, index = 0) { 20 | const slugified = safeSlugify(node.textContent); 21 | if (index === 0) return slugified; 22 | return `${slugified}-${index}`; 23 | } 24 | 25 | export function headingToPersistenceKey(node: Node, id?: string) { 26 | const slug = headingToSlug(node); 27 | return `rme-${id || window?.location.pathname}–${slug}`; 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/isMarkdown.test.ts: -------------------------------------------------------------------------------- 1 | import isMarkdown from "./isMarkdown"; 2 | 3 | test("returns false for an empty string", () => { 4 | expect(isMarkdown("")).toBe(false); 5 | }); 6 | 7 | test("returns false for plain text", () => { 8 | expect(isMarkdown("plain text")).toBe(false); 9 | }); 10 | 11 | test("returns true for bullet list", () => { 12 | expect( 13 | isMarkdown(`- item one 14 | - item two 15 | - nested item`) 16 | ).toBe(true); 17 | }); 18 | 19 | test("returns true for numbered list", () => { 20 | expect( 21 | isMarkdown(`1. item one 22 | 1. item two`) 23 | ).toBe(true); 24 | expect( 25 | isMarkdown(`1. item one 26 | 2. item two`) 27 | ).toBe(true); 28 | }); 29 | 30 | test("returns true for code fence", () => { 31 | expect( 32 | isMarkdown(`\`\`\`javascript 33 | this is code 34 | \`\`\``) 35 | ).toBe(true); 36 | }); 37 | 38 | test("returns false for non-closed fence", () => { 39 | expect( 40 | isMarkdown(`\`\`\` 41 | this is not code 42 | `) 43 | ).toBe(false); 44 | }); 45 | 46 | test("returns true for heading", () => { 47 | expect(isMarkdown(`# Heading 1`)).toBe(true); 48 | expect(isMarkdown(`## Heading 2`)).toBe(true); 49 | expect(isMarkdown(`### Heading 3`)).toBe(true); 50 | }); 51 | 52 | test("returns false for hashtag", () => { 53 | expect(isMarkdown(`Test #hashtag`)).toBe(false); 54 | expect(isMarkdown(` #hashtag`)).toBe(false); 55 | }); 56 | 57 | test("returns true for absolute link", () => { 58 | expect(isMarkdown(`[title](http://www.google.com)`)).toBe(true); 59 | }); 60 | 61 | test("returns true for relative link", () => { 62 | expect(isMarkdown(`[title](/doc/mydoc-234tnes)`)).toBe(true); 63 | }); 64 | 65 | test("returns true for relative image", () => { 66 | expect(isMarkdown(`![alt](/coolimage.png)`)).toBe(true); 67 | }); 68 | 69 | test("returns true for absolute image", () => { 70 | expect(isMarkdown(`![alt](https://www.google.com/coolimage.png)`)).toBe(true); 71 | }); 72 | -------------------------------------------------------------------------------- /src/lib/isMarkdown.ts: -------------------------------------------------------------------------------- 1 | export default function isMarkdown(text: string): boolean { 2 | // code-ish 3 | const fences = text.match(/^```/gm); 4 | if (fences && fences.length > 1) return true; 5 | 6 | // link-ish 7 | if (text.match(/\[[^]+\]\(https?:\/\/\S+\)/gm)) return true; 8 | if (text.match(/\[[^]+\]\(\/\S+\)/gm)) return true; 9 | 10 | // heading-ish 11 | if (text.match(/^#{1,6}\s+\S+/gm)) return true; 12 | 13 | // list-ish 14 | const listItems = text.match(/^[\d-*].?\s\S+/gm); 15 | if (listItems && listItems.length > 1) return true; 16 | 17 | return false; 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/isModKey.ts: -------------------------------------------------------------------------------- 1 | const SSR = typeof window === "undefined"; 2 | const isMac = !SSR && window.navigator.platform === "MacIntel"; 3 | 4 | export default function isModKey(event: KeyboardEvent | MouseEvent): boolean { 5 | return isMac ? event.metaKey : event.ctrlKey; 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/isUrl.ts: -------------------------------------------------------------------------------- 1 | export default function isUrl(text: string) { 2 | if (text.match(/\n/)) { 3 | return false; 4 | } 5 | 6 | try { 7 | const url = new URL(text); 8 | return url.hostname !== ""; 9 | } catch (err) { 10 | return false; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/markInputRule.ts: -------------------------------------------------------------------------------- 1 | import { MarkType, Mark } from "prosemirror-model"; 2 | import { InputRule } from "prosemirror-inputrules"; 3 | import { EditorState } from "prosemirror-state"; 4 | 5 | function getMarksBetween(start: number, end: number, state: EditorState) { 6 | let marks: { start: number; end: number; mark: Mark }[] = []; 7 | 8 | state.doc.nodesBetween(start, end, (node, pos) => { 9 | marks = [ 10 | ...marks, 11 | ...node.marks.map(mark => ({ 12 | start: pos, 13 | end: pos + node.nodeSize, 14 | mark, 15 | })), 16 | ]; 17 | }); 18 | 19 | return marks; 20 | } 21 | 22 | export default function( 23 | regexp: RegExp, 24 | markType: MarkType, 25 | getAttrs?: (match) => Record 26 | ): InputRule { 27 | return new InputRule( 28 | regexp, 29 | (state: EditorState, match: string[], start: number, end: number) => { 30 | const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs; 31 | const { tr } = state; 32 | const m = match.length - 1; 33 | let markEnd = end; 34 | let markStart = start; 35 | 36 | if (match[m]) { 37 | const matchStart = start + match[0].indexOf(match[m - 1]); 38 | const matchEnd = matchStart + match[m - 1].length - 1; 39 | const textStart = matchStart + match[m - 1].lastIndexOf(match[m]); 40 | const textEnd = textStart + match[m].length; 41 | 42 | const excludedMarks = getMarksBetween(start, end, state) 43 | .filter(item => item.mark.type.excludes(markType)) 44 | .filter(item => item.end > matchStart); 45 | 46 | if (excludedMarks.length) { 47 | return null; 48 | } 49 | 50 | if (textEnd < matchEnd) { 51 | tr.delete(textEnd, matchEnd); 52 | } 53 | if (textStart > matchStart) { 54 | tr.delete(matchStart, textStart); 55 | } 56 | markStart = matchStart; 57 | markEnd = markStart + match[m].length; 58 | } 59 | 60 | tr.addMark(markStart, markEnd, markType.create(attrs)); 61 | tr.removeStoredMark(markType); 62 | return tr; 63 | } 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/markdown/rules.ts: -------------------------------------------------------------------------------- 1 | import markdownit, { PluginSimple } from "markdown-it"; 2 | 3 | export default function rules({ 4 | rules = {}, 5 | plugins = [], 6 | }: { 7 | rules?: Record; 8 | plugins?: PluginSimple[]; 9 | }) { 10 | const markdownIt = markdownit("default", { 11 | breaks: false, 12 | html: false, 13 | linkify: false, 14 | ...rules, 15 | }); 16 | plugins.forEach(plugin => markdownIt.use(plugin)); 17 | return markdownIt; 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/renderToHtml.test.ts: -------------------------------------------------------------------------------- 1 | import renderToHtml from "./renderToHtml"; 2 | 3 | test("renders an empty string", () => { 4 | expect(renderToHtml("")).toBe(""); 5 | }); 6 | 7 | test("renders plain text as paragraph", () => { 8 | expect(renderToHtml("plain text")).toMatchSnapshot(); 9 | }); 10 | 11 | test("renders blockquote", () => { 12 | expect(renderToHtml("> blockquote")).toMatchSnapshot(); 13 | }); 14 | 15 | test("renders code block", () => { 16 | expect( 17 | renderToHtml(` 18 | this is indented code 19 | `) 20 | ).toMatchSnapshot(); 21 | }); 22 | 23 | test("renders code fence", () => { 24 | expect( 25 | renderToHtml(`\`\`\`javascript 26 | this is code 27 | \`\`\``) 28 | ).toMatchSnapshot(); 29 | }); 30 | 31 | test("renders checkbox list", () => { 32 | expect( 33 | renderToHtml(`- [ ] unchecked 34 | - [x] checked`) 35 | ).toMatchSnapshot(); 36 | }); 37 | 38 | test("renders bullet list", () => { 39 | expect( 40 | renderToHtml(`- item one 41 | - item two 42 | - nested item`) 43 | ).toMatchSnapshot(); 44 | }); 45 | 46 | test("renders info notice", () => { 47 | expect( 48 | renderToHtml(`:::info 49 | content of notice 50 | :::`) 51 | ).toMatchSnapshot(); 52 | }); 53 | 54 | test("renders warning notice", () => { 55 | expect( 56 | renderToHtml(`:::warning 57 | content of notice 58 | :::`) 59 | ).toMatchSnapshot(); 60 | }); 61 | 62 | test("renders tip notice", () => { 63 | expect( 64 | renderToHtml(`:::tip 65 | content of notice 66 | :::`) 67 | ).toMatchSnapshot(); 68 | }); 69 | 70 | test("renders headings", () => { 71 | expect( 72 | renderToHtml(`# Heading 1 73 | 74 | ## Heading 2 75 | 76 | ### Heading 3 77 | 78 | #### Heading 4`) 79 | ).toMatchSnapshot(); 80 | }); 81 | 82 | test("renders horizontal rule", () => { 83 | expect(renderToHtml(`---`)).toMatchSnapshot(); 84 | }); 85 | 86 | test("renders image", () => { 87 | expect( 88 | renderToHtml(`![caption](https://lorempixel.com/200/200)`) 89 | ).toMatchSnapshot(); 90 | }); 91 | 92 | test("renders image with alignment", () => { 93 | expect( 94 | renderToHtml(`![caption](https://lorempixel.com/200/200 "left-40")`) 95 | ).toMatchSnapshot(); 96 | }); 97 | 98 | test("renders table", () => { 99 | expect( 100 | renderToHtml(` 101 | | heading | centered | right aligned | 102 | |---------|:--------:|--------------:| 103 | | | center | | 104 | | | | bottom r | 105 | `) 106 | ).toMatchSnapshot(); 107 | }); 108 | 109 | test("renders bold marks", () => { 110 | expect(renderToHtml(`this is **bold** text`)).toMatchSnapshot(); 111 | }); 112 | 113 | test("renders code marks", () => { 114 | expect(renderToHtml(`this is \`inline code\` text`)).toMatchSnapshot(); 115 | }); 116 | 117 | test("renders highlight marks", () => { 118 | expect(renderToHtml(`this is ==highlighted== text`)).toMatchSnapshot(); 119 | }); 120 | 121 | test("renders italic marks", () => { 122 | expect(renderToHtml(`this is *italic* text`)).toMatchSnapshot(); 123 | expect(renderToHtml(`this is _also italic_ text`)).toMatchSnapshot(); 124 | }); 125 | 126 | test("renders template placeholder marks", () => { 127 | expect(renderToHtml(`this is !!a placeholder!!`)).toMatchSnapshot(); 128 | }); 129 | 130 | test("renders underline marks", () => { 131 | expect(renderToHtml(`this is __underlined__ text`)).toMatchSnapshot(); 132 | }); 133 | 134 | test("renders link marks", () => { 135 | expect( 136 | renderToHtml(`this is [linked](https://www.example.com) text`) 137 | ).toMatchSnapshot(); 138 | }); 139 | 140 | test("renders underline marks", () => { 141 | expect(renderToHtml(`this is ~~strikethrough~~ text`)).toMatchSnapshot(); 142 | }); 143 | 144 | test("renders ordered list", () => { 145 | expect( 146 | renderToHtml(`1. item one 147 | 1. item two`) 148 | ).toMatchSnapshot(); 149 | 150 | expect( 151 | renderToHtml(`1. item one 152 | 2. item two`) 153 | ).toMatchSnapshot(); 154 | }); 155 | -------------------------------------------------------------------------------- /src/lib/renderToHtml.ts: -------------------------------------------------------------------------------- 1 | import createMarkdown from "./markdown/rules"; 2 | import { PluginSimple } from "markdown-it"; 3 | import markRule from "../rules/mark"; 4 | import checkboxRule from "../rules/checkboxes"; 5 | import embedsRule from "../rules/embeds"; 6 | import breakRule from "../rules/breaks"; 7 | import tablesRule from "../rules/tables"; 8 | import noticesRule from "../rules/notices"; 9 | import underlinesRule from "../rules/underlines"; 10 | import emojiRule from "../rules/emoji"; 11 | 12 | const defaultRules = [ 13 | embedsRule, 14 | breakRule, 15 | checkboxRule, 16 | markRule({ delim: "==", mark: "highlight" }), 17 | markRule({ delim: "!!", mark: "placeholder" }), 18 | underlinesRule, 19 | tablesRule, 20 | noticesRule, 21 | emojiRule, 22 | ]; 23 | 24 | export default function renderToHtml( 25 | markdown: string, 26 | rulePlugins: PluginSimple[] = defaultRules 27 | ): string { 28 | return createMarkdown({ plugins: rulePlugins }) 29 | .render(markdown) 30 | .trim(); 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/uploadPlaceholder.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, Plugin } from "prosemirror-state"; 2 | import { Decoration, DecorationSet } from "prosemirror-view"; 3 | 4 | // based on the example at: https://prosemirror.net/examples/upload/ 5 | const uploadPlaceholder = new Plugin({ 6 | state: { 7 | init() { 8 | return DecorationSet.empty; 9 | }, 10 | apply(tr, set) { 11 | // Adjust decoration positions to changes made by the transaction 12 | set = set.map(tr.mapping, tr.doc); 13 | 14 | // See if the transaction adds or removes any placeholders 15 | const action = tr.getMeta(this); 16 | 17 | if (action?.add) { 18 | if (action.add.replaceExisting) { 19 | const $pos = tr.doc.resolve(action.add.pos); 20 | 21 | if ($pos.nodeAfter?.type.name === "image") { 22 | const deco = Decoration.node( 23 | $pos.pos, 24 | $pos.pos + $pos.nodeAfter.nodeSize, 25 | { 26 | class: "image-replacement-uploading", 27 | }, 28 | { 29 | id: action.add.id, 30 | } 31 | ); 32 | set = set.add(tr.doc, [deco]); 33 | } 34 | } else { 35 | const element = document.createElement("div"); 36 | element.className = "image placeholder"; 37 | 38 | const img = document.createElement("img"); 39 | img.src = URL.createObjectURL(action.add.file); 40 | 41 | element.appendChild(img); 42 | 43 | const deco = Decoration.widget(action.add.pos, element, { 44 | id: action.add.id, 45 | }); 46 | set = set.add(tr.doc, [deco]); 47 | } 48 | } 49 | 50 | if (action?.remove) { 51 | set = set.remove( 52 | set.find(null, null, spec => spec.id === action.remove.id) 53 | ); 54 | } 55 | return set; 56 | }, 57 | }, 58 | props: { 59 | decorations(state) { 60 | return this.getState(state); 61 | }, 62 | }, 63 | }); 64 | 65 | export default uploadPlaceholder; 66 | 67 | export function findPlaceholder( 68 | state: EditorState, 69 | id: string 70 | ): [number, number] | null { 71 | const decos = uploadPlaceholder.getState(state); 72 | const found = decos.find(null, null, spec => spec.id === id); 73 | return found.length ? [found[0].from, found[0].to] : null; 74 | } 75 | -------------------------------------------------------------------------------- /src/marks/Bold.ts: -------------------------------------------------------------------------------- 1 | import { toggleMark } from "prosemirror-commands"; 2 | import markInputRule from "../lib/markInputRule"; 3 | import Mark from "./Mark"; 4 | 5 | export default class Bold extends Mark { 6 | get name() { 7 | return "strong"; 8 | } 9 | 10 | get schema() { 11 | return { 12 | parseDOM: [ 13 | { tag: "b" }, 14 | { tag: "strong" }, 15 | { style: "font-style", getAttrs: value => value === "bold" }, 16 | ], 17 | toDOM: () => ["strong"], 18 | }; 19 | } 20 | 21 | inputRules({ type }) { 22 | return [markInputRule(/(?:\*\*)([^*]+)(?:\*\*)$/, type)]; 23 | } 24 | 25 | keys({ type }) { 26 | return { 27 | "Mod-b": toggleMark(type), 28 | "Mod-B": toggleMark(type), 29 | }; 30 | } 31 | 32 | get toMarkdown() { 33 | return { 34 | open: "**", 35 | close: "**", 36 | mixable: true, 37 | expelEnclosingWhitespace: true, 38 | }; 39 | } 40 | 41 | parseMarkdown() { 42 | return { mark: "strong" }; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/marks/Code.ts: -------------------------------------------------------------------------------- 1 | import { toggleMark } from "prosemirror-commands"; 2 | import markInputRule from "../lib/markInputRule"; 3 | import moveLeft from "../commands/moveLeft"; 4 | import moveRight from "../commands/moveRight"; 5 | import Mark from "./Mark"; 6 | 7 | function backticksFor(node, side) { 8 | const ticks = /`+/g; 9 | let match: RegExpMatchArray | null; 10 | let len = 0; 11 | 12 | if (node.isText) { 13 | while ((match = ticks.exec(node.text))) { 14 | len = Math.max(len, match[0].length); 15 | } 16 | } 17 | 18 | let result = len > 0 && side > 0 ? " `" : "`"; 19 | for (let i = 0; i < len; i++) { 20 | result += "`"; 21 | } 22 | if (len > 0 && side < 0) { 23 | result += " "; 24 | } 25 | return result; 26 | } 27 | 28 | export default class Code extends Mark { 29 | get name() { 30 | return "code_inline"; 31 | } 32 | 33 | get schema() { 34 | return { 35 | excludes: "_", 36 | parseDOM: [{ tag: "code", preserveWhitespace: true }], 37 | toDOM: () => ["code", { spellCheck: false }], 38 | }; 39 | } 40 | 41 | inputRules({ type }) { 42 | return [markInputRule(/(?:^|[^`])(`([^`]+)`)$/, type)]; 43 | } 44 | 45 | keys({ type }) { 46 | // Note: This key binding only works on non-Mac platforms 47 | // https://github.com/ProseMirror/prosemirror/issues/515 48 | return { 49 | "Mod`": toggleMark(type), 50 | ArrowLeft: moveLeft(), 51 | ArrowRight: moveRight(), 52 | }; 53 | } 54 | 55 | get toMarkdown() { 56 | return { 57 | open(_state, _mark, parent, index) { 58 | return backticksFor(parent.child(index), -1); 59 | }, 60 | close(_state, _mark, parent, index) { 61 | return backticksFor(parent.child(index - 1), 1); 62 | }, 63 | escape: false, 64 | }; 65 | } 66 | 67 | parseMarkdown() { 68 | return { mark: "code_inline" }; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/marks/Highlight.ts: -------------------------------------------------------------------------------- 1 | import { toggleMark } from "prosemirror-commands"; 2 | import markInputRule from "../lib/markInputRule"; 3 | import Mark from "./Mark"; 4 | import markRule from "../rules/mark"; 5 | 6 | export default class Highlight extends Mark { 7 | get name() { 8 | return "highlight"; 9 | } 10 | 11 | get schema() { 12 | return { 13 | parseDOM: [{ tag: "mark" }], 14 | toDOM: () => ["mark"], 15 | }; 16 | } 17 | 18 | inputRules({ type }) { 19 | return [markInputRule(/(?:==)([^=]+)(?:==)$/, type)]; 20 | } 21 | 22 | keys({ type }) { 23 | return { 24 | "Mod-Ctrl-h": toggleMark(type), 25 | }; 26 | } 27 | 28 | get rulePlugins() { 29 | return [markRule({ delim: "==", mark: "highlight" })]; 30 | } 31 | 32 | get toMarkdown() { 33 | return { 34 | open: "==", 35 | close: "==", 36 | mixable: true, 37 | expelEnclosingWhitespace: true, 38 | }; 39 | } 40 | 41 | parseMarkdown() { 42 | return { mark: "highlight" }; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/marks/Italic.ts: -------------------------------------------------------------------------------- 1 | import { toggleMark } from "prosemirror-commands"; 2 | import markInputRule from "../lib/markInputRule"; 3 | import Mark from "./Mark"; 4 | 5 | export default class Italic extends Mark { 6 | get name() { 7 | return "em"; 8 | } 9 | 10 | get schema() { 11 | return { 12 | parseDOM: [ 13 | { tag: "i" }, 14 | { tag: "em" }, 15 | { style: "font-style", getAttrs: value => value === "italic" }, 16 | ], 17 | toDOM: () => ["em"], 18 | }; 19 | } 20 | 21 | inputRules({ type }) { 22 | return [ 23 | markInputRule(/(?:^|[\s])(_([^_]+)_)$/, type), 24 | markInputRule(/(?:^|[^*])(\*([^*]+)\*)$/, type), 25 | ]; 26 | } 27 | 28 | keys({ type }) { 29 | return { 30 | "Mod-i": toggleMark(type), 31 | "Mod-I": toggleMark(type), 32 | }; 33 | } 34 | 35 | get toMarkdown() { 36 | return { 37 | open: "*", 38 | close: "*", 39 | mixable: true, 40 | expelEnclosingWhitespace: true, 41 | }; 42 | } 43 | 44 | parseMarkdown() { 45 | return { mark: "em" }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/marks/Link.ts: -------------------------------------------------------------------------------- 1 | import { toggleMark } from "prosemirror-commands"; 2 | import { Plugin } from "prosemirror-state"; 3 | import { InputRule } from "prosemirror-inputrules"; 4 | import Mark from "./Mark"; 5 | 6 | const LINK_INPUT_REGEX = /\[([^[]+)]\((\S+)\)$/; 7 | 8 | function isPlainURL(link, parent, index, side) { 9 | if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) { 10 | return false; 11 | } 12 | 13 | const content = parent.child(index + (side < 0 ? -1 : 0)); 14 | if ( 15 | !content.isText || 16 | content.text !== link.attrs.href || 17 | content.marks[content.marks.length - 1] !== link 18 | ) { 19 | return false; 20 | } 21 | 22 | if (index === (side < 0 ? 1 : parent.childCount - 1)) { 23 | return true; 24 | } 25 | 26 | const next = parent.child(index + (side < 0 ? -2 : 1)); 27 | return !link.isInSet(next.marks); 28 | } 29 | 30 | export default class Link extends Mark { 31 | get name() { 32 | return "link"; 33 | } 34 | 35 | get schema() { 36 | return { 37 | attrs: { 38 | href: { 39 | default: "", 40 | }, 41 | }, 42 | inclusive: false, 43 | parseDOM: [ 44 | { 45 | tag: "a[href]", 46 | getAttrs: (dom: HTMLElement) => ({ 47 | href: dom.getAttribute("href"), 48 | }), 49 | }, 50 | ], 51 | toDOM: node => [ 52 | "a", 53 | { 54 | ...node.attrs, 55 | rel: "noopener noreferrer nofollow", 56 | }, 57 | 0, 58 | ], 59 | }; 60 | } 61 | 62 | inputRules({ type }) { 63 | return [ 64 | new InputRule(LINK_INPUT_REGEX, (state, match, start, end) => { 65 | const [okay, alt, href] = match; 66 | const { tr } = state; 67 | 68 | if (okay) { 69 | tr.replaceWith(start, end, this.editor.schema.text(alt)).addMark( 70 | start, 71 | start + alt.length, 72 | type.create({ href }) 73 | ); 74 | } 75 | 76 | return tr; 77 | }), 78 | ]; 79 | } 80 | 81 | commands({ type }) { 82 | return ({ href } = { href: "" }) => toggleMark(type, { href }); 83 | } 84 | 85 | keys({ type }) { 86 | return { 87 | "Mod-k": (state, dispatch) => { 88 | if (state.selection.empty) { 89 | this.options.onKeyboardShortcut(); 90 | return true; 91 | } 92 | 93 | return toggleMark(type, { href: "" })(state, dispatch); 94 | }, 95 | }; 96 | } 97 | 98 | get plugins() { 99 | return [ 100 | new Plugin({ 101 | props: { 102 | handleDOMEvents: { 103 | mouseover: (_view, event: MouseEvent) => { 104 | if ( 105 | event.target instanceof HTMLAnchorElement && 106 | !event.target.className.includes("ProseMirror-widget") 107 | ) { 108 | if (this.options.onHoverLink) { 109 | return this.options.onHoverLink(event); 110 | } 111 | } 112 | return false; 113 | }, 114 | click: (_view, event: MouseEvent) => { 115 | if (event.target instanceof HTMLAnchorElement) { 116 | const href = 117 | event.target.href || 118 | (event.target.parentNode instanceof HTMLAnchorElement 119 | ? event.target.parentNode.href 120 | : ""); 121 | 122 | const isHashtag = href.startsWith("#"); 123 | if (isHashtag && this.options.onClickHashtag) { 124 | event.stopPropagation(); 125 | event.preventDefault(); 126 | this.options.onClickHashtag(href, event); 127 | return true; 128 | } 129 | 130 | if (this.options.onClickLink) { 131 | event.stopPropagation(); 132 | event.preventDefault(); 133 | this.options.onClickLink(href, event); 134 | return true; 135 | } 136 | } 137 | 138 | return false; 139 | }, 140 | }, 141 | }, 142 | }), 143 | ]; 144 | } 145 | 146 | get toMarkdown() { 147 | return { 148 | open(_state, mark, parent, index) { 149 | return isPlainURL(mark, parent, index, 1) ? "<" : "["; 150 | }, 151 | close(state, mark, parent, index) { 152 | return isPlainURL(mark, parent, index, -1) 153 | ? ">" 154 | : "](" + 155 | state.esc(mark.attrs.href) + 156 | (mark.attrs.title ? " " + state.quote(mark.attrs.title) : "") + 157 | ")"; 158 | }, 159 | }; 160 | } 161 | 162 | parseMarkdown() { 163 | return { 164 | mark: "link", 165 | getAttrs: tok => ({ 166 | href: tok.attrGet("href"), 167 | title: tok.attrGet("title") || null, 168 | }), 169 | }; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/marks/Mark.ts: -------------------------------------------------------------------------------- 1 | import { toggleMark } from "prosemirror-commands"; 2 | import Extension from "../lib/Extension"; 3 | 4 | export default abstract class Mark extends Extension { 5 | get type() { 6 | return "mark"; 7 | } 8 | 9 | abstract get schema(); 10 | 11 | get markdownToken(): string { 12 | return ""; 13 | } 14 | 15 | get toMarkdown(): Record { 16 | return {}; 17 | } 18 | 19 | parseMarkdown() { 20 | return {}; 21 | } 22 | 23 | commands({ type }) { 24 | return () => toggleMark(type); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/marks/Placeholder.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, TextSelection } from "prosemirror-state"; 2 | import getMarkRange from "../queries/getMarkRange"; 3 | import Mark from "./Mark"; 4 | import markRule from "../rules/mark"; 5 | 6 | export default class Placeholder extends Mark { 7 | get name() { 8 | return "placeholder"; 9 | } 10 | 11 | get schema() { 12 | return { 13 | parseDOM: [{ tag: "span.template-placeholder" }], 14 | toDOM: () => ["span", { class: "template-placeholder" }], 15 | }; 16 | } 17 | 18 | get rulePlugins() { 19 | return [markRule({ delim: "!!", mark: "placeholder" })]; 20 | } 21 | 22 | get toMarkdown() { 23 | return { 24 | open: "!!", 25 | close: "!!", 26 | mixable: true, 27 | expelEnclosingWhitespace: true, 28 | }; 29 | } 30 | 31 | parseMarkdown() { 32 | return { mark: "placeholder" }; 33 | } 34 | 35 | get plugins() { 36 | return [ 37 | new Plugin({ 38 | props: { 39 | handleTextInput: (view, from, to, text) => { 40 | if (this.editor.props.template) { 41 | return false; 42 | } 43 | 44 | const { state, dispatch } = view; 45 | const $from = state.doc.resolve(from); 46 | 47 | const range = getMarkRange($from, state.schema.marks.placeholder); 48 | if (!range) return false; 49 | 50 | const selectionStart = Math.min(from, range.from); 51 | const selectionEnd = Math.max(to, range.to); 52 | 53 | dispatch( 54 | state.tr 55 | .removeMark( 56 | range.from, 57 | range.to, 58 | state.schema.marks.placeholder 59 | ) 60 | .insertText(text, selectionStart, selectionEnd) 61 | ); 62 | 63 | const $to = view.state.doc.resolve(selectionStart + text.length); 64 | dispatch(view.state.tr.setSelection(TextSelection.near($to))); 65 | 66 | return true; 67 | }, 68 | handleKeyDown: (view, event: KeyboardEvent) => { 69 | if (!view.props.editable || !view.props.editable(view.state)) { 70 | return false; 71 | } 72 | if (this.editor.props.template) { 73 | return false; 74 | } 75 | if ( 76 | event.key !== "ArrowLeft" && 77 | event.key !== "ArrowRight" && 78 | event.key !== "Backspace" 79 | ) { 80 | return false; 81 | } 82 | 83 | const { state, dispatch } = view; 84 | 85 | if (event.key === "Backspace") { 86 | const range = getMarkRange( 87 | state.doc.resolve(Math.max(0, state.selection.from - 1)), 88 | state.schema.marks.placeholder 89 | ); 90 | if (!range) return false; 91 | 92 | dispatch( 93 | state.tr 94 | .removeMark( 95 | range.from, 96 | range.to, 97 | state.schema.marks.placeholder 98 | ) 99 | .insertText("", range.from, range.to) 100 | ); 101 | return true; 102 | } 103 | 104 | if (event.key === "ArrowLeft") { 105 | const range = getMarkRange( 106 | state.doc.resolve(Math.max(0, state.selection.from - 1)), 107 | state.schema.marks.placeholder 108 | ); 109 | if (!range) return false; 110 | 111 | const startOfMark = state.doc.resolve(range.from); 112 | dispatch(state.tr.setSelection(TextSelection.near(startOfMark))); 113 | return true; 114 | } 115 | 116 | if (event.key === "ArrowRight") { 117 | const range = getMarkRange( 118 | state.selection.$from, 119 | state.schema.marks.placeholder 120 | ); 121 | if (!range) return false; 122 | 123 | const endOfMark = state.doc.resolve(range.to); 124 | dispatch(state.tr.setSelection(TextSelection.near(endOfMark))); 125 | return true; 126 | } 127 | 128 | return false; 129 | }, 130 | handleClick: (view, pos, event: MouseEvent) => { 131 | if (!view.props.editable || !view.props.editable(view.state)) { 132 | return false; 133 | } 134 | if (this.editor.props.template) { 135 | return false; 136 | } 137 | 138 | if ( 139 | event.target instanceof HTMLSpanElement && 140 | event.target.className.includes("template-placeholder") 141 | ) { 142 | const { state, dispatch } = view; 143 | const range = getMarkRange( 144 | state.selection.$from, 145 | state.schema.marks.placeholder 146 | ); 147 | if (!range) return false; 148 | 149 | event.stopPropagation(); 150 | event.preventDefault(); 151 | const startOfMark = state.doc.resolve(range.from); 152 | dispatch(state.tr.setSelection(TextSelection.near(startOfMark))); 153 | 154 | return true; 155 | } 156 | return false; 157 | }, 158 | }, 159 | }), 160 | ]; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/marks/Strikethrough.ts: -------------------------------------------------------------------------------- 1 | import { toggleMark } from "prosemirror-commands"; 2 | import markInputRule from "../lib/markInputRule"; 3 | import Mark from "./Mark"; 4 | 5 | export default class Strikethrough extends Mark { 6 | get name() { 7 | return "strikethrough"; 8 | } 9 | 10 | get schema() { 11 | return { 12 | parseDOM: [ 13 | { 14 | tag: "s", 15 | }, 16 | { 17 | tag: "del", 18 | }, 19 | { 20 | tag: "strike", 21 | }, 22 | ], 23 | toDOM: () => ["del", 0], 24 | }; 25 | } 26 | 27 | keys({ type }) { 28 | return { 29 | "Mod-d": toggleMark(type), 30 | }; 31 | } 32 | 33 | inputRules({ type }) { 34 | return [markInputRule(/~([^~]+)~$/, type)]; 35 | } 36 | 37 | get toMarkdown() { 38 | return { 39 | open: "~~", 40 | close: "~~", 41 | mixable: true, 42 | expelEnclosingWhitespace: true, 43 | }; 44 | } 45 | 46 | get markdownToken() { 47 | return "s"; 48 | } 49 | 50 | parseMarkdown() { 51 | return { mark: "strikethrough" }; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/marks/Underline.ts: -------------------------------------------------------------------------------- 1 | import { toggleMark } from "prosemirror-commands"; 2 | import markInputRule from "../lib/markInputRule"; 3 | import Mark from "./Mark"; 4 | import underlinesRule from "../rules/underlines"; 5 | 6 | export default class Underline extends Mark { 7 | get name() { 8 | return "underline"; 9 | } 10 | 11 | get schema() { 12 | return { 13 | parseDOM: [ 14 | { tag: "u" }, 15 | { 16 | style: "text-decoration", 17 | getAttrs: value => value === "underline", 18 | }, 19 | ], 20 | toDOM: () => ["u", 0], 21 | }; 22 | } 23 | 24 | get rulePlugins() { 25 | return [underlinesRule]; 26 | } 27 | 28 | inputRules({ type }) { 29 | return [markInputRule(/(?:__)([^_]+)(?:__)$/, type)]; 30 | } 31 | 32 | keys({ type }) { 33 | return { 34 | "Mod-u": toggleMark(type), 35 | }; 36 | } 37 | 38 | get toMarkdown() { 39 | return { 40 | open: "__", 41 | close: "__", 42 | mixable: true, 43 | expelEnclosingWhitespace: true, 44 | }; 45 | } 46 | 47 | parseMarkdown() { 48 | return { mark: "underline" }; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/menus/block.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BlockQuoteIcon, 3 | BulletedListIcon, 4 | CodeIcon, 5 | Heading1Icon, 6 | Heading2Icon, 7 | Heading3Icon, 8 | HorizontalRuleIcon, 9 | OrderedListIcon, 10 | PageBreakIcon, 11 | TableIcon, 12 | TodoListIcon, 13 | ImageIcon, 14 | StarredIcon, 15 | WarningIcon, 16 | InfoIcon, 17 | LinkIcon, 18 | } from "outline-icons"; 19 | import { MenuItem } from "../types"; 20 | import baseDictionary from "../dictionary"; 21 | 22 | const SSR = typeof window === "undefined"; 23 | const isMac = !SSR && window.navigator.platform === "MacIntel"; 24 | const mod = isMac ? "⌘" : "ctrl"; 25 | 26 | export default function blockMenuItems( 27 | dictionary: typeof baseDictionary 28 | ): MenuItem[] { 29 | return [ 30 | { 31 | name: "heading", 32 | title: dictionary.h1, 33 | keywords: "h1 heading1 title", 34 | icon: Heading1Icon, 35 | shortcut: "^ ⇧ 1", 36 | attrs: { level: 1 }, 37 | }, 38 | { 39 | name: "heading", 40 | title: dictionary.h2, 41 | keywords: "h2 heading2", 42 | icon: Heading2Icon, 43 | shortcut: "^ ⇧ 2", 44 | attrs: { level: 2 }, 45 | }, 46 | { 47 | name: "heading", 48 | title: dictionary.h3, 49 | keywords: "h3 heading3", 50 | icon: Heading3Icon, 51 | shortcut: "^ ⇧ 3", 52 | attrs: { level: 3 }, 53 | }, 54 | { 55 | name: "separator", 56 | }, 57 | { 58 | name: "checkbox_list", 59 | title: dictionary.checkboxList, 60 | icon: TodoListIcon, 61 | keywords: "checklist checkbox task", 62 | shortcut: "^ ⇧ 7", 63 | }, 64 | { 65 | name: "bullet_list", 66 | title: dictionary.bulletList, 67 | icon: BulletedListIcon, 68 | shortcut: "^ ⇧ 8", 69 | }, 70 | { 71 | name: "ordered_list", 72 | title: dictionary.orderedList, 73 | icon: OrderedListIcon, 74 | shortcut: "^ ⇧ 9", 75 | }, 76 | { 77 | name: "separator", 78 | }, 79 | { 80 | name: "table", 81 | title: dictionary.table, 82 | icon: TableIcon, 83 | attrs: { rowsCount: 3, colsCount: 3 }, 84 | }, 85 | { 86 | name: "blockquote", 87 | title: dictionary.quote, 88 | icon: BlockQuoteIcon, 89 | shortcut: `${mod} ]`, 90 | }, 91 | { 92 | name: "code_block", 93 | title: dictionary.codeBlock, 94 | icon: CodeIcon, 95 | shortcut: "^ ⇧ \\", 96 | keywords: "script", 97 | }, 98 | { 99 | name: "hr", 100 | title: dictionary.hr, 101 | icon: HorizontalRuleIcon, 102 | shortcut: `${mod} _`, 103 | keywords: "horizontal rule break line", 104 | }, 105 | { 106 | name: "hr", 107 | title: dictionary.pageBreak, 108 | icon: PageBreakIcon, 109 | keywords: "page print break line", 110 | attrs: { markup: "***" }, 111 | }, 112 | { 113 | name: "image", 114 | title: dictionary.image, 115 | icon: ImageIcon, 116 | keywords: "picture photo", 117 | }, 118 | { 119 | name: "link", 120 | title: dictionary.link, 121 | icon: LinkIcon, 122 | shortcut: `${mod} k`, 123 | keywords: "link url uri href", 124 | }, 125 | { 126 | name: "separator", 127 | }, 128 | { 129 | name: "container_notice", 130 | title: dictionary.infoNotice, 131 | icon: InfoIcon, 132 | keywords: "container_notice card information", 133 | attrs: { style: "info" }, 134 | }, 135 | { 136 | name: "container_notice", 137 | title: dictionary.warningNotice, 138 | icon: WarningIcon, 139 | keywords: "container_notice card error", 140 | attrs: { style: "warning" }, 141 | }, 142 | { 143 | name: "container_notice", 144 | title: dictionary.tipNotice, 145 | icon: StarredIcon, 146 | keywords: "container_notice card suggestion", 147 | attrs: { style: "tip" }, 148 | }, 149 | ]; 150 | } 151 | -------------------------------------------------------------------------------- /src/menus/divider.tsx: -------------------------------------------------------------------------------- 1 | import { EditorState } from "prosemirror-state"; 2 | import { PageBreakIcon, HorizontalRuleIcon } from "outline-icons"; 3 | import isNodeActive from "../queries/isNodeActive"; 4 | import { MenuItem } from "../types"; 5 | import baseDictionary from "../dictionary"; 6 | 7 | export default function dividerMenuItems( 8 | state: EditorState, 9 | dictionary: typeof baseDictionary 10 | ): MenuItem[] { 11 | const { schema } = state; 12 | 13 | return [ 14 | { 15 | name: "hr", 16 | tooltip: dictionary.pageBreak, 17 | attrs: { markup: "***" }, 18 | active: isNodeActive(schema.nodes.hr, { markup: "***" }), 19 | icon: PageBreakIcon, 20 | }, 21 | { 22 | name: "hr", 23 | tooltip: dictionary.hr, 24 | attrs: { markup: "---" }, 25 | active: isNodeActive(schema.nodes.hr, { markup: "---" }), 26 | icon: HorizontalRuleIcon, 27 | }, 28 | ]; 29 | } 30 | -------------------------------------------------------------------------------- /src/menus/formatting.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BoldIcon, 3 | CodeIcon, 4 | Heading1Icon, 5 | Heading2Icon, 6 | BlockQuoteIcon, 7 | LinkIcon, 8 | StrikethroughIcon, 9 | OrderedListIcon, 10 | BulletedListIcon, 11 | TodoListIcon, 12 | InputIcon, 13 | HighlightIcon, 14 | } from "outline-icons"; 15 | import { isInTable } from "prosemirror-tables"; 16 | import { EditorState } from "prosemirror-state"; 17 | import isInList from "../queries/isInList"; 18 | import isMarkActive from "../queries/isMarkActive"; 19 | import isNodeActive from "../queries/isNodeActive"; 20 | import { MenuItem } from "../types"; 21 | import baseDictionary from "../dictionary"; 22 | 23 | export default function formattingMenuItems( 24 | state: EditorState, 25 | isTemplate: boolean, 26 | dictionary: typeof baseDictionary 27 | ): MenuItem[] { 28 | const { schema } = state; 29 | const isTable = isInTable(state); 30 | const isList = isInList(state); 31 | const allowBlocks = !isTable && !isList; 32 | 33 | return [ 34 | { 35 | name: "placeholder", 36 | tooltip: dictionary.placeholder, 37 | icon: InputIcon, 38 | active: isMarkActive(schema.marks.placeholder), 39 | visible: isTemplate, 40 | }, 41 | { 42 | name: "separator", 43 | visible: isTemplate, 44 | }, 45 | { 46 | name: "strong", 47 | tooltip: dictionary.strong, 48 | icon: BoldIcon, 49 | active: isMarkActive(schema.marks.strong), 50 | }, 51 | { 52 | name: "strikethrough", 53 | tooltip: dictionary.strikethrough, 54 | icon: StrikethroughIcon, 55 | active: isMarkActive(schema.marks.strikethrough), 56 | }, 57 | { 58 | name: "highlight", 59 | tooltip: dictionary.mark, 60 | icon: HighlightIcon, 61 | active: isMarkActive(schema.marks.highlight), 62 | visible: !isTemplate, 63 | }, 64 | { 65 | name: "code_inline", 66 | tooltip: dictionary.codeInline, 67 | icon: CodeIcon, 68 | active: isMarkActive(schema.marks.code_inline), 69 | }, 70 | { 71 | name: "separator", 72 | visible: allowBlocks, 73 | }, 74 | { 75 | name: "heading", 76 | tooltip: dictionary.heading, 77 | icon: Heading1Icon, 78 | active: isNodeActive(schema.nodes.heading, { level: 1 }), 79 | attrs: { level: 1 }, 80 | visible: allowBlocks, 81 | }, 82 | { 83 | name: "heading", 84 | tooltip: dictionary.subheading, 85 | icon: Heading2Icon, 86 | active: isNodeActive(schema.nodes.heading, { level: 2 }), 87 | attrs: { level: 2 }, 88 | visible: allowBlocks, 89 | }, 90 | { 91 | name: "blockquote", 92 | tooltip: dictionary.quote, 93 | icon: BlockQuoteIcon, 94 | active: isNodeActive(schema.nodes.blockquote), 95 | attrs: { level: 2 }, 96 | visible: allowBlocks, 97 | }, 98 | { 99 | name: "separator", 100 | visible: allowBlocks || isList, 101 | }, 102 | { 103 | name: "checkbox_list", 104 | tooltip: dictionary.checkboxList, 105 | icon: TodoListIcon, 106 | keywords: "checklist checkbox task", 107 | active: isNodeActive(schema.nodes.checkbox_list), 108 | visible: allowBlocks || isList, 109 | }, 110 | { 111 | name: "bullet_list", 112 | tooltip: dictionary.bulletList, 113 | icon: BulletedListIcon, 114 | active: isNodeActive(schema.nodes.bullet_list), 115 | visible: allowBlocks || isList, 116 | }, 117 | { 118 | name: "ordered_list", 119 | tooltip: dictionary.orderedList, 120 | icon: OrderedListIcon, 121 | active: isNodeActive(schema.nodes.ordered_list), 122 | visible: allowBlocks || isList, 123 | }, 124 | { 125 | name: "separator", 126 | }, 127 | { 128 | name: "link", 129 | tooltip: dictionary.createLink, 130 | icon: LinkIcon, 131 | active: isMarkActive(schema.marks.link), 132 | attrs: { href: "" }, 133 | }, 134 | ]; 135 | } 136 | -------------------------------------------------------------------------------- /src/menus/image.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | TrashIcon, 3 | DownloadIcon, 4 | ReplaceIcon, 5 | AlignImageLeftIcon, 6 | AlignImageRightIcon, 7 | AlignImageCenterIcon, 8 | } from "outline-icons"; 9 | import isNodeActive from "../queries/isNodeActive"; 10 | import { MenuItem } from "../types"; 11 | import baseDictionary from "../dictionary"; 12 | import { EditorState } from "prosemirror-state"; 13 | 14 | export default function imageMenuItems( 15 | state: EditorState, 16 | dictionary: typeof baseDictionary 17 | ): MenuItem[] { 18 | const { schema } = state; 19 | const isLeftAligned = isNodeActive(schema.nodes.image, { 20 | layoutClass: "left-50", 21 | }); 22 | const isRightAligned = isNodeActive(schema.nodes.image, { 23 | layoutClass: "right-50", 24 | }); 25 | 26 | return [ 27 | { 28 | name: "alignLeft", 29 | tooltip: dictionary.alignLeft, 30 | icon: AlignImageLeftIcon, 31 | visible: true, 32 | active: isLeftAligned, 33 | }, 34 | { 35 | name: "alignCenter", 36 | tooltip: dictionary.alignCenter, 37 | icon: AlignImageCenterIcon, 38 | visible: true, 39 | active: state => 40 | isNodeActive(schema.nodes.image)(state) && 41 | !isLeftAligned(state) && 42 | !isRightAligned(state), 43 | }, 44 | { 45 | name: "alignRight", 46 | tooltip: dictionary.alignRight, 47 | icon: AlignImageRightIcon, 48 | visible: true, 49 | active: isRightAligned, 50 | }, 51 | { 52 | name: "separator", 53 | visible: true, 54 | }, 55 | { 56 | name: "downloadImage", 57 | tooltip: dictionary.downloadImage, 58 | icon: DownloadIcon, 59 | visible: !!fetch, 60 | active: () => false, 61 | }, 62 | { 63 | name: "replaceImage", 64 | tooltip: dictionary.replaceImage, 65 | icon: ReplaceIcon, 66 | visible: true, 67 | active: () => false, 68 | }, 69 | { 70 | name: "deleteImage", 71 | tooltip: dictionary.deleteImage, 72 | icon: TrashIcon, 73 | visible: true, 74 | active: () => false, 75 | }, 76 | ]; 77 | } 78 | -------------------------------------------------------------------------------- /src/menus/table.tsx: -------------------------------------------------------------------------------- 1 | import { TrashIcon } from "outline-icons"; 2 | import { MenuItem } from "../types"; 3 | import baseDictionary from "../dictionary"; 4 | 5 | export default function tableMenuItems( 6 | dictionary: typeof baseDictionary 7 | ): MenuItem[] { 8 | return [ 9 | { 10 | name: "deleteTable", 11 | tooltip: dictionary.deleteTable, 12 | icon: TrashIcon, 13 | active: () => false, 14 | }, 15 | ]; 16 | } 17 | -------------------------------------------------------------------------------- /src/menus/tableCol.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | TrashIcon, 3 | AlignLeftIcon, 4 | AlignRightIcon, 5 | AlignCenterIcon, 6 | InsertLeftIcon, 7 | InsertRightIcon, 8 | } from "outline-icons"; 9 | import { EditorState } from "prosemirror-state"; 10 | import isNodeActive from "../queries/isNodeActive"; 11 | import { MenuItem } from "../types"; 12 | import baseDictionary from "../dictionary"; 13 | 14 | export default function tableColMenuItems( 15 | state: EditorState, 16 | index: number, 17 | rtl: boolean, 18 | dictionary: typeof baseDictionary 19 | ): MenuItem[] { 20 | const { schema } = state; 21 | 22 | return [ 23 | { 24 | name: "setColumnAttr", 25 | tooltip: dictionary.alignLeft, 26 | icon: AlignLeftIcon, 27 | attrs: { index, alignment: "left" }, 28 | active: isNodeActive(schema.nodes.th, { 29 | colspan: 1, 30 | rowspan: 1, 31 | alignment: "left", 32 | }), 33 | }, 34 | { 35 | name: "setColumnAttr", 36 | tooltip: dictionary.alignCenter, 37 | icon: AlignCenterIcon, 38 | attrs: { index, alignment: "center" }, 39 | active: isNodeActive(schema.nodes.th, { 40 | colspan: 1, 41 | rowspan: 1, 42 | alignment: "center", 43 | }), 44 | }, 45 | { 46 | name: "setColumnAttr", 47 | tooltip: dictionary.alignRight, 48 | icon: AlignRightIcon, 49 | attrs: { index, alignment: "right" }, 50 | active: isNodeActive(schema.nodes.th, { 51 | colspan: 1, 52 | rowspan: 1, 53 | alignment: "right", 54 | }), 55 | }, 56 | { 57 | name: "separator", 58 | }, 59 | { 60 | name: rtl ? "addColumnAfter" : "addColumnBefore", 61 | tooltip: rtl ? dictionary.addColumnAfter : dictionary.addColumnBefore, 62 | icon: InsertLeftIcon, 63 | active: () => false, 64 | }, 65 | { 66 | name: rtl ? "addColumnBefore" : "addColumnAfter", 67 | tooltip: rtl ? dictionary.addColumnBefore : dictionary.addColumnAfter, 68 | icon: InsertRightIcon, 69 | active: () => false, 70 | }, 71 | { 72 | name: "separator", 73 | }, 74 | { 75 | name: "deleteColumn", 76 | tooltip: dictionary.deleteColumn, 77 | icon: TrashIcon, 78 | active: () => false, 79 | }, 80 | ]; 81 | } 82 | -------------------------------------------------------------------------------- /src/menus/tableRow.tsx: -------------------------------------------------------------------------------- 1 | import { EditorState } from "prosemirror-state"; 2 | import { TrashIcon, InsertAboveIcon, InsertBelowIcon } from "outline-icons"; 3 | import { MenuItem } from "../types"; 4 | import baseDictionary from "../dictionary"; 5 | 6 | export default function tableRowMenuItems( 7 | state: EditorState, 8 | index: number, 9 | dictionary: typeof baseDictionary 10 | ): MenuItem[] { 11 | return [ 12 | { 13 | name: "addRowAfter", 14 | tooltip: dictionary.addRowBefore, 15 | icon: InsertAboveIcon, 16 | attrs: { index: index - 1 }, 17 | active: () => false, 18 | visible: index !== 0, 19 | }, 20 | { 21 | name: "addRowAfter", 22 | tooltip: dictionary.addRowAfter, 23 | icon: InsertBelowIcon, 24 | attrs: { index }, 25 | active: () => false, 26 | }, 27 | { 28 | name: "separator", 29 | }, 30 | { 31 | name: "deleteRow", 32 | tooltip: dictionary.deleteRow, 33 | icon: TrashIcon, 34 | active: () => false, 35 | }, 36 | ]; 37 | } 38 | -------------------------------------------------------------------------------- /src/nodes/Blockquote.ts: -------------------------------------------------------------------------------- 1 | import { wrappingInputRule } from "prosemirror-inputrules"; 2 | import Node from "./Node"; 3 | import toggleWrap from "../commands/toggleWrap"; 4 | import isNodeActive from "../queries/isNodeActive"; 5 | 6 | export default class Blockquote extends Node { 7 | get name() { 8 | return "blockquote"; 9 | } 10 | 11 | get schema() { 12 | return { 13 | content: "block+", 14 | group: "block", 15 | defining: true, 16 | parseDOM: [{ tag: "blockquote" }], 17 | toDOM: () => ["blockquote", 0], 18 | }; 19 | } 20 | 21 | inputRules({ type }) { 22 | return [wrappingInputRule(/^\s*>\s$/, type)]; 23 | } 24 | 25 | commands({ type }) { 26 | return () => toggleWrap(type); 27 | } 28 | 29 | keys({ type }) { 30 | return { 31 | "Ctrl->": toggleWrap(type), 32 | "Mod-]": toggleWrap(type), 33 | "Shift-Enter": (state, dispatch) => { 34 | if (!isNodeActive(type)(state)) { 35 | return false; 36 | } 37 | 38 | const { tr, selection } = state; 39 | dispatch(tr.split(selection.to)); 40 | return true; 41 | }, 42 | }; 43 | } 44 | 45 | toMarkdown(state, node) { 46 | state.wrapBlock("> ", null, node, () => state.renderContent(node)); 47 | } 48 | 49 | parseMarkdown() { 50 | return { block: "blockquote" }; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/nodes/BulletList.ts: -------------------------------------------------------------------------------- 1 | import { wrappingInputRule } from "prosemirror-inputrules"; 2 | import toggleList from "../commands/toggleList"; 3 | import Node from "./Node"; 4 | 5 | export default class BulletList extends Node { 6 | get name() { 7 | return "bullet_list"; 8 | } 9 | 10 | get schema() { 11 | return { 12 | content: "list_item+", 13 | group: "block", 14 | parseDOM: [{ tag: "ul" }], 15 | toDOM: () => ["ul", 0], 16 | }; 17 | } 18 | 19 | commands({ type, schema }) { 20 | return () => toggleList(type, schema.nodes.list_item); 21 | } 22 | 23 | keys({ type, schema }) { 24 | return { 25 | "Shift-Ctrl-8": toggleList(type, schema.nodes.list_item), 26 | }; 27 | } 28 | 29 | inputRules({ type }) { 30 | return [wrappingInputRule(/^\s*([-+*])\s$/, type)]; 31 | } 32 | 33 | toMarkdown(state, node) { 34 | state.renderList(node, " ", () => (node.attrs.bullet || "*") + " "); 35 | } 36 | 37 | parseMarkdown() { 38 | return { block: "bullet_list" }; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/nodes/CheckboxItem.ts: -------------------------------------------------------------------------------- 1 | import { 2 | splitListItem, 3 | sinkListItem, 4 | liftListItem, 5 | } from "prosemirror-schema-list"; 6 | import Node from "./Node"; 7 | import checkboxRule from "../rules/checkboxes"; 8 | 9 | export default class CheckboxItem extends Node { 10 | get name() { 11 | return "checkbox_item"; 12 | } 13 | 14 | get schema() { 15 | return { 16 | attrs: { 17 | checked: { 18 | default: false, 19 | }, 20 | }, 21 | content: "paragraph block*", 22 | defining: true, 23 | draggable: true, 24 | parseDOM: [ 25 | { 26 | tag: `li[data-type="${this.name}"]`, 27 | getAttrs: (dom: HTMLLIElement) => ({ 28 | checked: dom.className.includes("checked"), 29 | }), 30 | }, 31 | ], 32 | toDOM: node => { 33 | const input = document.createElement("input"); 34 | input.type = "checkbox"; 35 | input.tabIndex = -1; 36 | input.addEventListener("change", this.handleChange); 37 | 38 | if (node.attrs.checked) { 39 | input.checked = true; 40 | } 41 | 42 | return [ 43 | "li", 44 | { 45 | "data-type": this.name, 46 | class: node.attrs.checked ? "checked" : undefined, 47 | }, 48 | [ 49 | "span", 50 | { 51 | contentEditable: false, 52 | }, 53 | input, 54 | ], 55 | ["div", 0], 56 | ]; 57 | }, 58 | }; 59 | } 60 | 61 | get rulePlugins() { 62 | return [checkboxRule]; 63 | } 64 | 65 | handleChange = event => { 66 | const { view } = this.editor; 67 | const { tr } = view.state; 68 | const { top, left } = event.target.getBoundingClientRect(); 69 | const result = view.posAtCoords({ top, left }); 70 | 71 | if (result) { 72 | const transaction = tr.setNodeMarkup(result.inside, undefined, { 73 | checked: event.target.checked, 74 | }); 75 | view.dispatch(transaction); 76 | } 77 | }; 78 | 79 | keys({ type }) { 80 | return { 81 | Enter: splitListItem(type), 82 | Tab: sinkListItem(type), 83 | "Shift-Tab": liftListItem(type), 84 | "Mod-]": sinkListItem(type), 85 | "Mod-[": liftListItem(type), 86 | }; 87 | } 88 | 89 | toMarkdown(state, node) { 90 | state.write(node.attrs.checked ? "[x] " : "[ ] "); 91 | state.renderContent(node); 92 | } 93 | 94 | parseMarkdown() { 95 | return { 96 | block: "checkbox_item", 97 | getAttrs: tok => ({ 98 | checked: tok.attrGet("checked") ? true : undefined, 99 | }), 100 | }; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/nodes/CheckboxList.ts: -------------------------------------------------------------------------------- 1 | import { wrappingInputRule } from "prosemirror-inputrules"; 2 | import toggleList from "../commands/toggleList"; 3 | import Node from "./Node"; 4 | 5 | export default class CheckboxList extends Node { 6 | get name() { 7 | return "checkbox_list"; 8 | } 9 | 10 | get schema() { 11 | return { 12 | group: "block", 13 | content: "checkbox_item+", 14 | toDOM: () => ["ul", { class: this.name }, 0], 15 | parseDOM: [ 16 | { 17 | tag: `[class="${this.name}"]`, 18 | }, 19 | ], 20 | }; 21 | } 22 | 23 | keys({ type, schema }) { 24 | return { 25 | "Shift-Ctrl-7": toggleList(type, schema.nodes.checkbox_item), 26 | }; 27 | } 28 | 29 | commands({ type, schema }) { 30 | return () => toggleList(type, schema.nodes.checkbox_item); 31 | } 32 | 33 | inputRules({ type }) { 34 | return [wrappingInputRule(/^-?\s*(\[ \])\s$/i, type)]; 35 | } 36 | 37 | toMarkdown(state, node) { 38 | state.renderList(node, " ", () => "- "); 39 | } 40 | 41 | parseMarkdown() { 42 | return { block: "checkbox_list" }; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/nodes/CodeBlock.ts: -------------------------------------------------------------------------------- 1 | import CodeFence from "./CodeFence"; 2 | 3 | export default class CodeBlock extends CodeFence { 4 | get name() { 5 | return "code_block"; 6 | } 7 | 8 | get markdownToken() { 9 | return "code_block"; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/nodes/Doc.ts: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | 3 | export default class Doc extends Node { 4 | get name() { 5 | return "doc"; 6 | } 7 | 8 | get schema() { 9 | return { 10 | content: "block+", 11 | }; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/nodes/Embed.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Node from "./Node"; 3 | import embedsRule from "../rules/embeds"; 4 | 5 | const cache = {}; 6 | 7 | export default class Embed extends Node { 8 | get name() { 9 | return "embed"; 10 | } 11 | 12 | get schema() { 13 | return { 14 | content: "inline*", 15 | group: "block", 16 | atom: true, 17 | attrs: { 18 | href: {}, 19 | }, 20 | parseDOM: [ 21 | { 22 | tag: "iframe[class=embed]", 23 | getAttrs: (dom: HTMLIFrameElement) => { 24 | const { embeds } = this.editor.props; 25 | const href = dom.getAttribute("src") || ""; 26 | 27 | if (embeds) { 28 | for (const embed of embeds) { 29 | const matches = embed.matcher(href); 30 | if (matches) { 31 | return { 32 | href, 33 | }; 34 | } 35 | } 36 | } 37 | 38 | return {}; 39 | }, 40 | }, 41 | ], 42 | toDOM: node => [ 43 | "iframe", 44 | { class: "embed", src: node.attrs.href, contentEditable: false }, 45 | 0, 46 | ], 47 | }; 48 | } 49 | 50 | get rulePlugins() { 51 | return [embedsRule(this.options.embeds)]; 52 | } 53 | 54 | component({ isEditable, isSelected, theme, node }) { 55 | const { embeds } = this.editor.props; 56 | 57 | // matches are cached in module state to avoid re running loops and regex 58 | // here. Unfortuantely this function is not compatible with React.memo or 59 | // we would use that instead. 60 | const hit = cache[node.attrs.href]; 61 | let Component = hit ? hit.Component : undefined; 62 | let matches = hit ? hit.matches : undefined; 63 | 64 | if (!Component) { 65 | for (const embed of embeds) { 66 | const m = embed.matcher(node.attrs.href); 67 | if (m) { 68 | Component = embed.component; 69 | matches = m; 70 | cache[node.attrs.href] = { Component, matches }; 71 | } 72 | } 73 | } 74 | 75 | if (!Component) { 76 | return null; 77 | } 78 | 79 | return ( 80 | 86 | ); 87 | } 88 | 89 | commands({ type }) { 90 | return attrs => (state, dispatch) => { 91 | dispatch( 92 | state.tr.replaceSelectionWith(type.create(attrs)).scrollIntoView() 93 | ); 94 | return true; 95 | }; 96 | } 97 | 98 | toMarkdown(state, node) { 99 | state.ensureNewLine(); 100 | state.write( 101 | "[" + state.esc(node.attrs.href) + "](" + state.esc(node.attrs.href) + ")" 102 | ); 103 | state.write("\n\n"); 104 | } 105 | 106 | parseMarkdown() { 107 | return { 108 | node: "embed", 109 | getAttrs: token => ({ 110 | href: token.attrGet("href"), 111 | }), 112 | }; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/nodes/Emoji.tsx: -------------------------------------------------------------------------------- 1 | import { InputRule } from "prosemirror-inputrules"; 2 | import nameToEmoji from "gemoji/name-to-emoji.json"; 3 | import Node from "./Node"; 4 | import emojiRule from "../rules/emoji"; 5 | 6 | export default class Emoji extends Node { 7 | get name() { 8 | return "emoji"; 9 | } 10 | 11 | get schema() { 12 | return { 13 | attrs: { 14 | style: { 15 | default: "", 16 | }, 17 | "data-name": { 18 | default: undefined, 19 | }, 20 | }, 21 | inline: true, 22 | content: "text*", 23 | marks: "", 24 | group: "inline", 25 | selectable: false, 26 | parseDOM: [ 27 | { 28 | tag: "span.emoji", 29 | preserveWhitespace: "full", 30 | getAttrs: (dom: HTMLDivElement) => ({ 31 | "data-name": dom.dataset.name, 32 | }), 33 | }, 34 | ], 35 | toDOM: node => { 36 | if (nameToEmoji[node.attrs["data-name"]]) { 37 | const text = document.createTextNode( 38 | nameToEmoji[node.attrs["data-name"]] 39 | ); 40 | return [ 41 | "span", 42 | { 43 | class: `emoji ${node.attrs["data-name"]}`, 44 | "data-name": node.attrs["data-name"], 45 | }, 46 | text, 47 | ]; 48 | } 49 | const text = document.createTextNode(`:${node.attrs["data-name"]}:`); 50 | return ["span", { class: "emoji" }, text]; 51 | }, 52 | }; 53 | } 54 | 55 | get rulePlugins() { 56 | return [emojiRule]; 57 | } 58 | 59 | commands({ type }) { 60 | return attrs => (state, dispatch) => { 61 | const { selection } = state; 62 | const position = selection.$cursor 63 | ? selection.$cursor.pos 64 | : selection.$to.pos; 65 | const node = type.create(attrs); 66 | const transaction = state.tr.insert(position, node); 67 | dispatch(transaction); 68 | return true; 69 | }; 70 | } 71 | 72 | inputRules({ type }) { 73 | return [ 74 | new InputRule(/^\:([a-zA-Z0-9_+-]+)\:$/, (state, match, start, end) => { 75 | const [okay, markup] = match; 76 | const { tr } = state; 77 | if (okay) { 78 | tr.replaceWith( 79 | start - 1, 80 | end, 81 | type.create({ 82 | "data-name": markup, 83 | markup, 84 | }) 85 | ); 86 | } 87 | 88 | return tr; 89 | }), 90 | ]; 91 | } 92 | 93 | toMarkdown(state, node) { 94 | const name = node.attrs["data-name"]; 95 | if (name) { 96 | state.write(`:${name}:`); 97 | } 98 | } 99 | 100 | parseMarkdown() { 101 | return { 102 | node: "emoji", 103 | getAttrs: tok => { 104 | return { "data-name": tok.markup.trim() }; 105 | }, 106 | }; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/nodes/HardBreak.ts: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | import { isInTable } from "prosemirror-tables"; 3 | import breakRule from "../rules/breaks"; 4 | 5 | export default class HardBreak extends Node { 6 | get name() { 7 | return "br"; 8 | } 9 | 10 | get schema() { 11 | return { 12 | inline: true, 13 | group: "inline", 14 | selectable: false, 15 | parseDOM: [{ tag: "br" }], 16 | toDOM() { 17 | return ["br"]; 18 | }, 19 | }; 20 | } 21 | 22 | get rulePlugins() { 23 | return [breakRule]; 24 | } 25 | 26 | commands({ type }) { 27 | return () => (state, dispatch) => { 28 | dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); 29 | return true; 30 | }; 31 | } 32 | 33 | keys({ type }) { 34 | return { 35 | "Shift-Enter": (state, dispatch) => { 36 | if (!isInTable(state)) return false; 37 | dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); 38 | return true; 39 | }, 40 | }; 41 | } 42 | 43 | toMarkdown(state) { 44 | state.write(" \\n "); 45 | } 46 | 47 | parseMarkdown() { 48 | return { node: "br" }; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/nodes/HorizontalRule.ts: -------------------------------------------------------------------------------- 1 | import { InputRule } from "prosemirror-inputrules"; 2 | import Node from "./Node"; 3 | 4 | export default class HorizontalRule extends Node { 5 | get name() { 6 | return "hr"; 7 | } 8 | 9 | get schema() { 10 | return { 11 | attrs: { 12 | markup: { 13 | default: "---", 14 | }, 15 | }, 16 | group: "block", 17 | parseDOM: [{ tag: "hr" }], 18 | toDOM: node => { 19 | return [ 20 | "hr", 21 | { class: node.attrs.markup === "***" ? "page-break" : "" }, 22 | ]; 23 | }, 24 | }; 25 | } 26 | 27 | commands({ type }) { 28 | return attrs => (state, dispatch) => { 29 | dispatch( 30 | state.tr.replaceSelectionWith(type.create(attrs)).scrollIntoView() 31 | ); 32 | return true; 33 | }; 34 | } 35 | 36 | keys({ type }) { 37 | return { 38 | "Mod-_": (state, dispatch) => { 39 | dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); 40 | return true; 41 | }, 42 | }; 43 | } 44 | 45 | inputRules({ type }) { 46 | return [ 47 | new InputRule(/^(?:---|___\s|\*\*\*\s)$/, (state, match, start, end) => { 48 | const { tr } = state; 49 | 50 | if (match[0]) { 51 | const markup = match[0].trim(); 52 | tr.replaceWith(start - 1, end, type.create({ markup })); 53 | } 54 | 55 | return tr; 56 | }), 57 | ]; 58 | } 59 | 60 | toMarkdown(state, node) { 61 | state.write(`\n${node.attrs.markup}`); 62 | state.closeBlock(node); 63 | } 64 | 65 | parseMarkdown() { 66 | return { 67 | node: "hr", 68 | getAttrs: tok => ({ 69 | markup: tok.markup, 70 | }), 71 | }; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/nodes/Node.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownSerializerState } from "prosemirror-markdown"; 2 | import { Node as ProsemirrorNode } from "prosemirror-model"; 3 | import Extension from "../lib/Extension"; 4 | 5 | export default abstract class Node extends Extension { 6 | get type() { 7 | return "node"; 8 | } 9 | 10 | abstract get schema(); 11 | 12 | get markdownToken(): string { 13 | return ""; 14 | } 15 | 16 | toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { 17 | console.error("toMarkdown not implemented", state, node); 18 | } 19 | 20 | parseMarkdown() { 21 | return; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/nodes/Notice.tsx: -------------------------------------------------------------------------------- 1 | import { wrappingInputRule } from "prosemirror-inputrules"; 2 | import toggleWrap from "../commands/toggleWrap"; 3 | import { WarningIcon, InfoIcon, StarredIcon } from "outline-icons"; 4 | import * as React from "react"; 5 | import ReactDOM from "react-dom"; 6 | import Node from "./Node"; 7 | import noticesRule from "../rules/notices"; 8 | 9 | export default class Notice extends Node { 10 | get styleOptions() { 11 | return Object.entries({ 12 | info: this.options.dictionary.info, 13 | warning: this.options.dictionary.warning, 14 | tip: this.options.dictionary.tip, 15 | }); 16 | } 17 | 18 | get name() { 19 | return "container_notice"; 20 | } 21 | 22 | get rulePlugins() { 23 | return [noticesRule]; 24 | } 25 | 26 | get schema() { 27 | return { 28 | attrs: { 29 | style: { 30 | default: "info", 31 | }, 32 | }, 33 | content: "block+", 34 | group: "block", 35 | defining: true, 36 | draggable: true, 37 | parseDOM: [ 38 | { 39 | tag: "div.notice-block", 40 | preserveWhitespace: "full", 41 | contentElement: "div:last-child", 42 | getAttrs: (dom: HTMLDivElement) => ({ 43 | style: dom.className.includes("tip") 44 | ? "tip" 45 | : dom.className.includes("warning") 46 | ? "warning" 47 | : undefined, 48 | }), 49 | }, 50 | ], 51 | toDOM: node => { 52 | const select = document.createElement("select"); 53 | select.addEventListener("change", this.handleStyleChange); 54 | 55 | this.styleOptions.forEach(([key, label]) => { 56 | const option = document.createElement("option"); 57 | option.value = key; 58 | option.innerText = label; 59 | option.selected = node.attrs.style === key; 60 | select.appendChild(option); 61 | }); 62 | 63 | let component; 64 | 65 | if (node.attrs.style === "tip") { 66 | component = ; 67 | } else if (node.attrs.style === "warning") { 68 | component = ; 69 | } else { 70 | component = ; 71 | } 72 | 73 | const icon = document.createElement("div"); 74 | icon.className = "icon"; 75 | ReactDOM.render(component, icon); 76 | 77 | return [ 78 | "div", 79 | { class: `notice-block ${node.attrs.style}` }, 80 | icon, 81 | ["div", { contentEditable: false }, select], 82 | ["div", { class: "content" }, 0], 83 | ]; 84 | }, 85 | }; 86 | } 87 | 88 | commands({ type }) { 89 | return attrs => toggleWrap(type, attrs); 90 | } 91 | 92 | handleStyleChange = event => { 93 | const { view } = this.editor; 94 | const { tr } = view.state; 95 | const element = event.target; 96 | const { top, left } = element.getBoundingClientRect(); 97 | const result = view.posAtCoords({ top, left }); 98 | 99 | if (result) { 100 | const transaction = tr.setNodeMarkup(result.inside, undefined, { 101 | style: element.value, 102 | }); 103 | view.dispatch(transaction); 104 | } 105 | }; 106 | 107 | inputRules({ type }) { 108 | return [wrappingInputRule(/^:::$/, type)]; 109 | } 110 | 111 | toMarkdown(state, node) { 112 | state.write("\n:::" + (node.attrs.style || "info") + "\n"); 113 | state.renderContent(node); 114 | state.ensureNewLine(); 115 | state.write(":::"); 116 | state.closeBlock(node); 117 | } 118 | 119 | parseMarkdown() { 120 | return { 121 | block: "container_notice", 122 | getAttrs: tok => ({ style: tok.info }), 123 | }; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/nodes/OrderedList.ts: -------------------------------------------------------------------------------- 1 | import { wrappingInputRule } from "prosemirror-inputrules"; 2 | import toggleList from "../commands/toggleList"; 3 | import Node from "./Node"; 4 | 5 | export default class OrderedList extends Node { 6 | get name() { 7 | return "ordered_list"; 8 | } 9 | 10 | get schema() { 11 | return { 12 | attrs: { 13 | order: { 14 | default: 1, 15 | }, 16 | }, 17 | content: "list_item+", 18 | group: "block", 19 | parseDOM: [ 20 | { 21 | tag: "ol", 22 | getAttrs: (dom: HTMLOListElement) => ({ 23 | order: dom.hasAttribute("start") 24 | ? parseInt(dom.getAttribute("start") || "1", 10) 25 | : 1, 26 | }), 27 | }, 28 | ], 29 | toDOM: node => 30 | node.attrs.order === 1 31 | ? ["ol", 0] 32 | : ["ol", { start: node.attrs.order }, 0], 33 | }; 34 | } 35 | 36 | commands({ type, schema }) { 37 | return () => toggleList(type, schema.nodes.list_item); 38 | } 39 | 40 | keys({ type, schema }) { 41 | return { 42 | "Shift-Ctrl-9": toggleList(type, schema.nodes.list_item), 43 | }; 44 | } 45 | 46 | inputRules({ type }) { 47 | return [ 48 | wrappingInputRule( 49 | /^(\d+)\.\s$/, 50 | type, 51 | match => ({ order: +match[1] }), 52 | (match, node) => node.childCount + node.attrs.order === +match[1] 53 | ), 54 | ]; 55 | } 56 | 57 | toMarkdown(state, node) { 58 | state.write("\n"); 59 | 60 | const start = node.attrs.order !== undefined ? node.attrs.order : 1; 61 | const maxW = `${start + node.childCount - 1}`.length; 62 | const space = state.repeat(" ", maxW + 2); 63 | 64 | state.renderList(node, space, i => { 65 | const nStr = `${start + i}`; 66 | return state.repeat(" ", maxW - nStr.length) + nStr + ". "; 67 | }); 68 | } 69 | 70 | parseMarkdown() { 71 | return { 72 | block: "ordered_list", 73 | getAttrs: tok => ({ 74 | order: parseInt(tok.attrGet("start") || "1", 10), 75 | }), 76 | }; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/nodes/Paragraph.ts: -------------------------------------------------------------------------------- 1 | import { setBlockType } from "prosemirror-commands"; 2 | import Node from "./Node"; 3 | 4 | export default class Paragraph extends Node { 5 | get name() { 6 | return "paragraph"; 7 | } 8 | 9 | get schema() { 10 | return { 11 | content: "inline*", 12 | group: "block", 13 | parseDOM: [{ tag: "p" }], 14 | toDOM: () => ["p", 0], 15 | }; 16 | } 17 | 18 | keys({ type }) { 19 | return { 20 | "Shift-Ctrl-0": setBlockType(type), 21 | }; 22 | } 23 | 24 | commands({ type }) { 25 | return () => setBlockType(type); 26 | } 27 | 28 | toMarkdown(state, node) { 29 | // render empty paragraphs as hard breaks to ensure that newlines are 30 | // persisted between reloads (this breaks from markdown tradition) 31 | if ( 32 | node.textContent.trim() === "" && 33 | node.childCount === 0 && 34 | !state.inTable 35 | ) { 36 | state.write("\\\n"); 37 | } else { 38 | state.renderInline(node); 39 | state.closeBlock(node); 40 | } 41 | } 42 | 43 | parseMarkdown() { 44 | return { block: "paragraph" }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/nodes/ReactNode.ts: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | 3 | export default abstract class ReactNode extends Node { 4 | abstract component({ 5 | node, 6 | isSelected, 7 | isEditable, 8 | innerRef, 9 | }): React.ReactElement; 10 | } 11 | -------------------------------------------------------------------------------- /src/nodes/Table.ts: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | import { Decoration, DecorationSet } from "prosemirror-view"; 3 | import { 4 | addColumnAfter, 5 | addColumnBefore, 6 | deleteColumn, 7 | deleteRow, 8 | deleteTable, 9 | fixTables, 10 | goToNextCell, 11 | isInTable, 12 | setCellAttr, 13 | tableEditing, 14 | toggleHeaderCell, 15 | toggleHeaderColumn, 16 | toggleHeaderRow, 17 | } from "prosemirror-tables"; 18 | import { 19 | addRowAt, 20 | createTable, 21 | getCellsInColumn, 22 | moveRow, 23 | } from "prosemirror-utils"; 24 | import { Plugin, TextSelection } from "prosemirror-state"; 25 | import tablesRule from "../rules/tables"; 26 | 27 | export default class Table extends Node { 28 | get name() { 29 | return "table"; 30 | } 31 | 32 | get schema() { 33 | return { 34 | content: "tr+", 35 | tableRole: "table", 36 | isolating: true, 37 | group: "block", 38 | parseDOM: [{ tag: "table" }], 39 | toDOM() { 40 | return [ 41 | "div", 42 | { class: "scrollable-wrapper" }, 43 | [ 44 | "div", 45 | { class: "scrollable" }, 46 | ["table", { class: "rme-table" }, ["tbody", 0]], 47 | ], 48 | ]; 49 | }, 50 | }; 51 | } 52 | 53 | get rulePlugins() { 54 | return [tablesRule]; 55 | } 56 | 57 | commands({ schema }) { 58 | return { 59 | createTable: ({ rowsCount, colsCount }) => (state, dispatch) => { 60 | const offset = state.tr.selection.anchor + 1; 61 | const nodes = createTable(schema, rowsCount, colsCount); 62 | const tr = state.tr.replaceSelectionWith(nodes).scrollIntoView(); 63 | const resolvedPos = tr.doc.resolve(offset); 64 | 65 | tr.setSelection(TextSelection.near(resolvedPos)); 66 | dispatch(tr); 67 | }, 68 | setColumnAttr: ({ index, alignment }) => (state, dispatch) => { 69 | const cells = getCellsInColumn(index)(state.selection) || []; 70 | let transaction = state.tr; 71 | cells.forEach(({ pos }) => { 72 | transaction = transaction.setNodeMarkup(pos, null, { 73 | alignment, 74 | }); 75 | }); 76 | dispatch(transaction); 77 | }, 78 | addColumnBefore: () => addColumnBefore, 79 | addColumnAfter: () => addColumnAfter, 80 | deleteColumn: () => deleteColumn, 81 | addRowAfter: ({ index }) => (state, dispatch) => { 82 | if (index === 0) { 83 | // A little hack to avoid cloning the heading row by cloning the row 84 | // beneath and then moving it to the right index. 85 | const tr = addRowAt(index + 2, true)(state.tr); 86 | dispatch(moveRow(index + 2, index + 1)(tr)); 87 | } else { 88 | dispatch(addRowAt(index + 1, true)(state.tr)); 89 | } 90 | }, 91 | deleteRow: () => deleteRow, 92 | deleteTable: () => deleteTable, 93 | toggleHeaderColumn: () => toggleHeaderColumn, 94 | toggleHeaderRow: () => toggleHeaderRow, 95 | toggleHeaderCell: () => toggleHeaderCell, 96 | setCellAttr: () => setCellAttr, 97 | fixTables: () => fixTables, 98 | }; 99 | } 100 | 101 | keys() { 102 | return { 103 | Tab: goToNextCell(1), 104 | "Shift-Tab": goToNextCell(-1), 105 | Enter: (state, dispatch) => { 106 | if (!isInTable(state)) return false; 107 | 108 | // TODO: Adding row at the end for now, can we find the current cell 109 | // row index and add the row below that? 110 | const cells = getCellsInColumn(0)(state.selection) || []; 111 | 112 | dispatch(addRowAt(cells.length, true)(state.tr)); 113 | return true; 114 | }, 115 | }; 116 | } 117 | 118 | toMarkdown(state, node) { 119 | state.renderTable(node); 120 | state.closeBlock(node); 121 | } 122 | 123 | parseMarkdown() { 124 | return { block: "table" }; 125 | } 126 | 127 | get plugins() { 128 | return [ 129 | tableEditing(), 130 | new Plugin({ 131 | props: { 132 | decorations: state => { 133 | const { doc } = state; 134 | const decorations: Decoration[] = []; 135 | let index = 0; 136 | 137 | doc.descendants((node, pos) => { 138 | if (node.type.name !== this.name) return; 139 | 140 | const elements = document.getElementsByClassName("rme-table"); 141 | const table = elements[index]; 142 | if (!table) return; 143 | 144 | const element = table.parentElement; 145 | const shadowRight = !!( 146 | element && element.scrollWidth > element.clientWidth 147 | ); 148 | 149 | if (shadowRight) { 150 | decorations.push( 151 | Decoration.widget(pos + 1, () => { 152 | const shadow = document.createElement("div"); 153 | shadow.className = "scrollable-shadow right"; 154 | return shadow; 155 | }) 156 | ); 157 | } 158 | index++; 159 | }); 160 | 161 | return DecorationSet.create(doc, decorations); 162 | }, 163 | }, 164 | }), 165 | ]; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/nodes/TableCell.ts: -------------------------------------------------------------------------------- 1 | import { DecorationSet, Decoration } from "prosemirror-view"; 2 | import { Plugin } from "prosemirror-state"; 3 | import { 4 | isTableSelected, 5 | isRowSelected, 6 | getCellsInColumn, 7 | } from "prosemirror-utils"; 8 | import Node from "./Node"; 9 | 10 | export default class TableCell extends Node { 11 | get name() { 12 | return "td"; 13 | } 14 | 15 | get schema() { 16 | return { 17 | content: "paragraph+", 18 | tableRole: "cell", 19 | isolating: true, 20 | parseDOM: [{ tag: "td" }], 21 | toDOM(node) { 22 | return [ 23 | "td", 24 | node.attrs.alignment 25 | ? { style: `text-align: ${node.attrs.alignment}` } 26 | : {}, 27 | 0, 28 | ]; 29 | }, 30 | attrs: { 31 | colspan: { default: 1 }, 32 | rowspan: { default: 1 }, 33 | alignment: { default: null }, 34 | }, 35 | }; 36 | } 37 | 38 | toMarkdown() { 39 | // see: renderTable 40 | } 41 | 42 | parseMarkdown() { 43 | return { 44 | block: "td", 45 | getAttrs: tok => ({ alignment: tok.info }), 46 | }; 47 | } 48 | 49 | get plugins() { 50 | return [ 51 | new Plugin({ 52 | props: { 53 | decorations: state => { 54 | const { doc, selection } = state; 55 | const decorations: Decoration[] = []; 56 | const cells = getCellsInColumn(0)(selection); 57 | 58 | if (cells) { 59 | cells.forEach(({ pos }, index) => { 60 | if (index === 0) { 61 | decorations.push( 62 | Decoration.widget(pos + 1, () => { 63 | let className = "grip-table"; 64 | const selected = isTableSelected(selection); 65 | if (selected) { 66 | className += " selected"; 67 | } 68 | const grip = document.createElement("a"); 69 | grip.className = className; 70 | grip.addEventListener("mousedown", event => { 71 | event.preventDefault(); 72 | event.stopImmediatePropagation(); 73 | this.options.onSelectTable(state); 74 | }); 75 | return grip; 76 | }) 77 | ); 78 | } 79 | decorations.push( 80 | Decoration.widget(pos + 1, () => { 81 | const rowSelected = isRowSelected(index)(selection); 82 | 83 | let className = "grip-row"; 84 | if (rowSelected) { 85 | className += " selected"; 86 | } 87 | if (index === 0) { 88 | className += " first"; 89 | } 90 | if (index === cells.length - 1) { 91 | className += " last"; 92 | } 93 | const grip = document.createElement("a"); 94 | grip.className = className; 95 | grip.addEventListener("mousedown", event => { 96 | event.preventDefault(); 97 | event.stopImmediatePropagation(); 98 | this.options.onSelectRow(index, state); 99 | }); 100 | return grip; 101 | }) 102 | ); 103 | }); 104 | } 105 | 106 | return DecorationSet.create(doc, decorations); 107 | }, 108 | }, 109 | }), 110 | ]; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/nodes/TableHeadCell.ts: -------------------------------------------------------------------------------- 1 | import { DecorationSet, Decoration } from "prosemirror-view"; 2 | import { Plugin } from "prosemirror-state"; 3 | import { isColumnSelected, getCellsInRow } from "prosemirror-utils"; 4 | import Node from "./Node"; 5 | 6 | export default class TableHeadCell extends Node { 7 | get name() { 8 | return "th"; 9 | } 10 | 11 | get schema() { 12 | return { 13 | content: "paragraph+", 14 | tableRole: "header_cell", 15 | isolating: true, 16 | parseDOM: [{ tag: "th" }], 17 | toDOM(node) { 18 | return [ 19 | "th", 20 | node.attrs.alignment 21 | ? { style: `text-align: ${node.attrs.alignment}` } 22 | : {}, 23 | 0, 24 | ]; 25 | }, 26 | attrs: { 27 | colspan: { default: 1 }, 28 | rowspan: { default: 1 }, 29 | alignment: { default: null }, 30 | }, 31 | }; 32 | } 33 | 34 | toMarkdown() { 35 | // see: renderTable 36 | } 37 | 38 | parseMarkdown() { 39 | return { 40 | block: "th", 41 | getAttrs: tok => ({ alignment: tok.info }), 42 | }; 43 | } 44 | 45 | get plugins() { 46 | return [ 47 | new Plugin({ 48 | props: { 49 | decorations: state => { 50 | const { doc, selection } = state; 51 | const decorations: Decoration[] = []; 52 | const cells = getCellsInRow(0)(selection); 53 | 54 | if (cells) { 55 | cells.forEach(({ pos }, index) => { 56 | decorations.push( 57 | Decoration.widget(pos + 1, () => { 58 | const colSelected = isColumnSelected(index)(selection); 59 | let className = "grip-column"; 60 | if (colSelected) { 61 | className += " selected"; 62 | } 63 | if (index === 0) { 64 | className += " first"; 65 | } else if (index === cells.length - 1) { 66 | className += " last"; 67 | } 68 | const grip = document.createElement("a"); 69 | grip.className = className; 70 | grip.addEventListener("mousedown", event => { 71 | event.preventDefault(); 72 | event.stopImmediatePropagation(); 73 | this.options.onSelectColumn(index, state); 74 | }); 75 | return grip; 76 | }) 77 | ); 78 | }); 79 | } 80 | 81 | return DecorationSet.create(doc, decorations); 82 | }, 83 | }, 84 | }), 85 | ]; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/nodes/TableRow.ts: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | 3 | export default class TableRow extends Node { 4 | get name() { 5 | return "tr"; 6 | } 7 | 8 | get schema() { 9 | return { 10 | content: "(th | td)*", 11 | tableRole: "row", 12 | parseDOM: [{ tag: "tr" }], 13 | toDOM() { 14 | return ["tr", 0]; 15 | }, 16 | }; 17 | } 18 | 19 | parseMarkdown() { 20 | return { block: "tr" }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/nodes/Text.ts: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | 3 | export default class Text extends Node { 4 | get name() { 5 | return "text"; 6 | } 7 | 8 | get schema() { 9 | return { 10 | group: "inline", 11 | }; 12 | } 13 | 14 | toMarkdown(state, node) { 15 | state.text(node.text); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/plugins/EmojiTrigger.tsx: -------------------------------------------------------------------------------- 1 | import { InputRule } from "prosemirror-inputrules"; 2 | import { Plugin } from "prosemirror-state"; 3 | import Extension from "../lib/Extension"; 4 | import isInCode from "../queries/isInCode"; 5 | import { run } from "./BlockMenuTrigger"; 6 | 7 | const OPEN_REGEX = /(?:^|\s):([0-9a-zA-Z_+-]+)?$/; 8 | const CLOSE_REGEX = /(?:^|\s):(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/; 9 | 10 | export default class EmojiTrigger extends Extension { 11 | get name() { 12 | return "emojimenu"; 13 | } 14 | 15 | get plugins() { 16 | return [ 17 | new Plugin({ 18 | props: { 19 | handleClick: () => { 20 | this.options.onClose(); 21 | return false; 22 | }, 23 | handleKeyDown: (view, event) => { 24 | // Prosemirror input rules are not triggered on backspace, however 25 | // we need them to be evaluted for the filter trigger to work 26 | // correctly. This additional handler adds inputrules-like handling. 27 | if (event.key === "Backspace") { 28 | // timeout ensures that the delete has been handled by prosemirror 29 | // and any characters removed, before we evaluate the rule. 30 | setTimeout(() => { 31 | const { pos } = view.state.selection.$from; 32 | return run(view, pos, pos, OPEN_REGEX, (state, match) => { 33 | if (match) { 34 | this.options.onOpen(match[1]); 35 | } else { 36 | this.options.onClose(); 37 | } 38 | return null; 39 | }); 40 | }); 41 | } 42 | 43 | // If the query is active and we're navigating the block menu then 44 | // just ignore the key events in the editor itself until we're done 45 | if ( 46 | event.key === "Enter" || 47 | event.key === "ArrowUp" || 48 | event.key === "ArrowDown" || 49 | event.key === "Tab" 50 | ) { 51 | const { pos } = view.state.selection.$from; 52 | 53 | return run(view, pos, pos, OPEN_REGEX, (state, match) => { 54 | // just tell Prosemirror we handled it and not to do anything 55 | return match ? true : null; 56 | }); 57 | } 58 | 59 | return false; 60 | }, 61 | }, 62 | }), 63 | ]; 64 | } 65 | 66 | inputRules() { 67 | return [ 68 | // main regex should match only: 69 | // :word 70 | new InputRule(OPEN_REGEX, (state, match) => { 71 | if ( 72 | match && 73 | state.selection.$from.parent.type.name === "paragraph" && 74 | !isInCode(state) 75 | ) { 76 | this.options.onOpen(match[1]); 77 | } 78 | return null; 79 | }), 80 | // invert regex should match some of these scenarios: 81 | // :word 82 | // : 83 | // :word 84 | // :) 85 | new InputRule(CLOSE_REGEX, (state, match) => { 86 | if (match) { 87 | this.options.onClose(); 88 | } 89 | return null; 90 | }), 91 | ]; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/plugins/Folding.tsx: -------------------------------------------------------------------------------- 1 | import { Plugin } from "prosemirror-state"; 2 | import { Decoration, DecorationSet } from "prosemirror-view"; 3 | import Extension from "../lib/Extension"; 4 | import { findBlockNodes } from "prosemirror-utils"; 5 | import findCollapsedNodes from "../queries/findCollapsedNodes"; 6 | import { headingToPersistenceKey } from "../lib/headingToSlug"; 7 | 8 | export default class Folding extends Extension { 9 | get name() { 10 | return "folding"; 11 | } 12 | 13 | get plugins() { 14 | let loaded = false; 15 | 16 | return [ 17 | new Plugin({ 18 | view: view => { 19 | loaded = false; 20 | view.dispatch(view.state.tr.setMeta("folding", { loaded: true })); 21 | return {}; 22 | }, 23 | appendTransaction: (transactions, oldState, newState) => { 24 | if (loaded) return; 25 | if ( 26 | !transactions.some(transaction => transaction.getMeta("folding")) 27 | ) { 28 | return; 29 | } 30 | 31 | let modified = false; 32 | const tr = newState.tr; 33 | const blocks = findBlockNodes(newState.doc); 34 | 35 | for (const block of blocks) { 36 | if (block.node.type.name === "heading") { 37 | const persistKey = headingToPersistenceKey( 38 | block.node, 39 | this.editor.props.id 40 | ); 41 | const persistedState = localStorage?.getItem(persistKey); 42 | 43 | if (persistedState === "collapsed") { 44 | tr.setNodeMarkup(block.pos, undefined, { 45 | ...block.node.attrs, 46 | collapsed: true, 47 | }); 48 | modified = true; 49 | } 50 | } 51 | } 52 | 53 | loaded = true; 54 | return modified ? tr : null; 55 | }, 56 | props: { 57 | decorations: state => { 58 | const { doc } = state; 59 | const decorations: Decoration[] = findCollapsedNodes(doc).map( 60 | block => 61 | Decoration.node(block.pos, block.pos + block.node.nodeSize, { 62 | class: "folded-content", 63 | }) 64 | ); 65 | 66 | return DecorationSet.create(doc, decorations); 67 | }, 68 | }, 69 | }), 70 | ]; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/plugins/History.ts: -------------------------------------------------------------------------------- 1 | import { undoInputRule } from "prosemirror-inputrules"; 2 | import { history, undo, redo } from "prosemirror-history"; 3 | import Extension from "../lib/Extension"; 4 | 5 | export default class History extends Extension { 6 | get name() { 7 | return "history"; 8 | } 9 | 10 | keys() { 11 | return { 12 | "Mod-z": undo, 13 | "Mod-y": redo, 14 | "Shift-Mod-z": redo, 15 | Backspace: undoInputRule, 16 | }; 17 | } 18 | 19 | get plugins() { 20 | return [history()]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/plugins/Keys.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Plugin, 3 | Selection, 4 | AllSelection, 5 | TextSelection, 6 | } from "prosemirror-state"; 7 | import { GapCursor } from "prosemirror-gapcursor"; 8 | import Extension from "../lib/Extension"; 9 | import isModKey from "../lib/isModKey"; 10 | export default class Keys extends Extension { 11 | get name() { 12 | return "keys"; 13 | } 14 | 15 | get plugins() { 16 | return [ 17 | new Plugin({ 18 | props: { 19 | handleDOMEvents: { 20 | blur: this.options.onBlur, 21 | focus: this.options.onFocus, 22 | }, 23 | // we can't use the keys bindings for this as we want to preventDefault 24 | // on the original keyboard event when handled 25 | handleKeyDown: (view, event) => { 26 | if (view.state.selection instanceof AllSelection) { 27 | if (event.key === "ArrowUp") { 28 | const selection = Selection.atStart(view.state.doc); 29 | view.dispatch(view.state.tr.setSelection(selection)); 30 | return true; 31 | } 32 | if (event.key === "ArrowDown") { 33 | const selection = Selection.atEnd(view.state.doc); 34 | view.dispatch(view.state.tr.setSelection(selection)); 35 | return true; 36 | } 37 | } 38 | 39 | // edge case where horizontal gap cursor does nothing if Enter key 40 | // is pressed. Insert a newline and then move the cursor into it. 41 | if (view.state.selection instanceof GapCursor) { 42 | if (event.key === "Enter") { 43 | view.dispatch( 44 | view.state.tr.insert( 45 | view.state.selection.from, 46 | view.state.schema.nodes.paragraph.create({}) 47 | ) 48 | ); 49 | view.dispatch( 50 | view.state.tr.setSelection( 51 | TextSelection.near( 52 | view.state.doc.resolve(view.state.selection.from), 53 | -1 54 | ) 55 | ) 56 | ); 57 | return true; 58 | } 59 | } 60 | 61 | // All the following keys require mod to be down 62 | if (!isModKey(event)) { 63 | return false; 64 | } 65 | 66 | if (event.key === "s") { 67 | event.preventDefault(); 68 | this.options.onSave(); 69 | return true; 70 | } 71 | 72 | if (event.key === "Enter") { 73 | event.preventDefault(); 74 | this.options.onSaveAndExit(); 75 | return true; 76 | } 77 | 78 | if (event.key === "Escape") { 79 | event.preventDefault(); 80 | this.options.onCancel(); 81 | return true; 82 | } 83 | 84 | return false; 85 | }, 86 | }, 87 | }), 88 | ]; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/plugins/MaxLength.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, Transaction } from "prosemirror-state"; 2 | import Extension from "../lib/Extension"; 3 | 4 | export default class MaxLength extends Extension { 5 | get name() { 6 | return "maxlength"; 7 | } 8 | 9 | get plugins() { 10 | return [ 11 | new Plugin({ 12 | filterTransaction: (tr: Transaction) => { 13 | if (this.options.maxLength) { 14 | const result = tr.doc && tr.doc.nodeSize > this.options.maxLength; 15 | return !result; 16 | } 17 | 18 | return true; 19 | }, 20 | }), 21 | ]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/plugins/PasteHandler.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "prosemirror-state"; 2 | import { isInTable } from "prosemirror-tables"; 3 | import { toggleMark } from "prosemirror-commands"; 4 | import Extension from "../lib/Extension"; 5 | import isUrl from "../lib/isUrl"; 6 | import isMarkdown from "../lib/isMarkdown"; 7 | import selectionIsInCode from "../queries/isInCode"; 8 | import { LANGUAGES } from "./Prism"; 9 | 10 | /** 11 | * Add support for additional syntax that users paste even though it isn't 12 | * supported by the markdown parser directly by massaging the text content. 13 | * 14 | * @param text The incoming pasted plain text 15 | */ 16 | function normalizePastedMarkdown(text: string): string { 17 | // find checkboxes not contained in a list and wrap them in list items 18 | const CHECKBOX_REGEX = /^\s?(\[(X|\s|_|-)\]\s(.*)?)/gim; 19 | 20 | while (text.match(CHECKBOX_REGEX)) { 21 | text = text.replace(CHECKBOX_REGEX, match => `- ${match.trim()}`); 22 | } 23 | 24 | return text; 25 | } 26 | 27 | export default class PasteHandler extends Extension { 28 | get name() { 29 | return "markdown-paste"; 30 | } 31 | 32 | get plugins() { 33 | return [ 34 | new Plugin({ 35 | props: { 36 | handlePaste: (view, event: ClipboardEvent) => { 37 | if (view.props.editable && !view.props.editable(view.state)) { 38 | return false; 39 | } 40 | if (!event.clipboardData) return false; 41 | 42 | const text = event.clipboardData.getData("text/plain"); 43 | const html = event.clipboardData.getData("text/html"); 44 | const vscode = event.clipboardData.getData("vscode-editor-data"); 45 | const { state, dispatch } = view; 46 | 47 | // first check if the clipboard contents can be parsed as a single 48 | // url, this is mainly for allowing pasted urls to become embeds 49 | if (isUrl(text)) { 50 | // just paste the link mark directly onto the selected text 51 | if (!state.selection.empty) { 52 | toggleMark(this.editor.schema.marks.link, { href: text })( 53 | state, 54 | dispatch 55 | ); 56 | return true; 57 | } 58 | 59 | // Is this link embeddable? Create an embed! 60 | const { embeds } = this.editor.props; 61 | 62 | if (embeds && !isInTable(state)) { 63 | for (const embed of embeds) { 64 | const matches = embed.matcher(text); 65 | if (matches) { 66 | this.editor.commands.embed({ 67 | href: text, 68 | }); 69 | return true; 70 | } 71 | } 72 | } 73 | 74 | // well, it's not an embed and there is no text selected – so just 75 | // go ahead and insert the link directly 76 | const transaction = view.state.tr 77 | .insertText(text, state.selection.from, state.selection.to) 78 | .addMark( 79 | state.selection.from, 80 | state.selection.to + text.length, 81 | state.schema.marks.link.create({ href: text }) 82 | ); 83 | view.dispatch(transaction); 84 | return true; 85 | } 86 | 87 | // If the users selection is currently in a code block then paste 88 | // as plain text, ignore all formatting and HTML content. 89 | if (selectionIsInCode(view.state)) { 90 | event.preventDefault(); 91 | 92 | view.dispatch(view.state.tr.insertText(text)); 93 | return true; 94 | } 95 | 96 | // Because VSCode is an especially popular editor that places metadata 97 | // on the clipboard, we can parse it to find out what kind of content 98 | // was pasted. 99 | const vscodeMeta = vscode ? JSON.parse(vscode) : undefined; 100 | const pasteCodeLanguage = vscodeMeta?.mode; 101 | 102 | if (pasteCodeLanguage && pasteCodeLanguage !== "markdown") { 103 | event.preventDefault(); 104 | view.dispatch( 105 | view.state.tr 106 | .replaceSelectionWith( 107 | view.state.schema.nodes.code_fence.create({ 108 | language: Object.keys(LANGUAGES).includes(vscodeMeta.mode) 109 | ? vscodeMeta.mode 110 | : null, 111 | }) 112 | ) 113 | .insertText(text) 114 | ); 115 | return true; 116 | } 117 | 118 | // If the HTML on the clipboard is from Prosemirror then the best 119 | // compatability is to just use the HTML parser, regardless of 120 | // whether it "looks" like Markdown, see: outline/outline#2416 121 | if (html?.includes("data-pm-slice")) { 122 | return false; 123 | } 124 | 125 | // If the text on the clipboard looks like Markdown OR there is no 126 | // html on the clipboard then try to parse content as Markdown 127 | if ( 128 | isMarkdown(text) || 129 | html.length === 0 || 130 | pasteCodeLanguage === "markdown" 131 | ) { 132 | event.preventDefault(); 133 | 134 | const paste = this.editor.pasteParser.parse( 135 | normalizePastedMarkdown(text) 136 | ); 137 | const slice = paste.slice(0); 138 | 139 | const transaction = view.state.tr.replaceSelection(slice); 140 | view.dispatch(transaction); 141 | return true; 142 | } 143 | 144 | // otherwise use the default HTML parser which will handle all paste 145 | // "from the web" events 146 | return false; 147 | }, 148 | }, 149 | }), 150 | ]; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/plugins/Placeholder.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "prosemirror-state"; 2 | import { Decoration, DecorationSet } from "prosemirror-view"; 3 | import Extension from "../lib/Extension"; 4 | 5 | export default class Placeholder extends Extension { 6 | get name() { 7 | return "empty-placeholder"; 8 | } 9 | 10 | get defaultOptions() { 11 | return { 12 | emptyNodeClass: "placeholder", 13 | placeholder: "", 14 | }; 15 | } 16 | 17 | get plugins() { 18 | return [ 19 | new Plugin({ 20 | props: { 21 | decorations: state => { 22 | const { doc } = state; 23 | const decorations: Decoration[] = []; 24 | const completelyEmpty = 25 | doc.textContent === "" && 26 | doc.childCount <= 1 && 27 | doc.content.size <= 2; 28 | 29 | doc.descendants((node, pos) => { 30 | if (!completelyEmpty) { 31 | return; 32 | } 33 | if (pos !== 0 || node.type.name !== "paragraph") { 34 | return; 35 | } 36 | 37 | const decoration = Decoration.node(pos, pos + node.nodeSize, { 38 | class: this.options.emptyNodeClass, 39 | "data-empty-text": this.options.placeholder, 40 | }); 41 | decorations.push(decoration); 42 | }); 43 | 44 | return DecorationSet.create(doc, decorations); 45 | }, 46 | }, 47 | }), 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/plugins/Prism.ts: -------------------------------------------------------------------------------- 1 | import refractor from "refractor/core"; 2 | import flattenDeep from "lodash/flattenDeep"; 3 | import { Plugin, PluginKey, Transaction } from "prosemirror-state"; 4 | import { Node } from "prosemirror-model"; 5 | import { Decoration, DecorationSet } from "prosemirror-view"; 6 | import { findBlockNodes } from "prosemirror-utils"; 7 | 8 | export const LANGUAGES = { 9 | none: "None", // additional entry to disable highlighting 10 | bash: "Bash", 11 | css: "CSS", 12 | clike: "C", 13 | csharp: "C#", 14 | go: "Go", 15 | markup: "HTML", 16 | objectivec: "Objective-C", 17 | java: "Java", 18 | javascript: "JavaScript", 19 | json: "JSON", 20 | perl: "Perl", 21 | php: "PHP", 22 | powershell: "Powershell", 23 | python: "Python", 24 | ruby: "Ruby", 25 | rust: "Rust", 26 | sql: "SQL", 27 | typescript: "TypeScript", 28 | yaml: "YAML", 29 | }; 30 | 31 | type ParsedNode = { 32 | text: string; 33 | classes: string[]; 34 | }; 35 | 36 | const cache: Record = {}; 37 | 38 | function getDecorations({ doc, name }: { doc: Node; name: string }) { 39 | const decorations: Decoration[] = []; 40 | const blocks: { node: Node; pos: number }[] = findBlockNodes(doc).filter( 41 | item => item.node.type.name === name 42 | ); 43 | 44 | function parseNodes( 45 | nodes: refractor.RefractorNode[], 46 | classNames: string[] = [] 47 | ): any { 48 | return nodes.map(node => { 49 | if (node.type === "element") { 50 | const classes = [...classNames, ...(node.properties.className || [])]; 51 | return parseNodes(node.children, classes); 52 | } 53 | 54 | return { 55 | text: node.value, 56 | classes: classNames, 57 | }; 58 | }); 59 | } 60 | 61 | blocks.forEach(block => { 62 | let startPos = block.pos + 1; 63 | const language = block.node.attrs.language; 64 | if (!language || language === "none" || !refractor.registered(language)) { 65 | return; 66 | } 67 | 68 | if (!cache[block.pos] || !cache[block.pos].node.eq(block.node)) { 69 | const nodes = refractor.highlight(block.node.textContent, language); 70 | const _decorations = flattenDeep(parseNodes(nodes)) 71 | .map((node: ParsedNode) => { 72 | const from = startPos; 73 | const to = from + node.text.length; 74 | 75 | startPos = to; 76 | 77 | return { 78 | ...node, 79 | from, 80 | to, 81 | }; 82 | }) 83 | .filter(node => node.classes && node.classes.length) 84 | .map(node => 85 | Decoration.inline(node.from, node.to, { 86 | class: node.classes.join(" "), 87 | }) 88 | ); 89 | 90 | cache[block.pos] = { 91 | node: block.node, 92 | decorations: _decorations, 93 | }; 94 | } 95 | cache[block.pos].decorations.forEach(decoration => { 96 | decorations.push(decoration); 97 | }); 98 | }); 99 | 100 | Object.keys(cache) 101 | .filter(pos => !blocks.find(block => block.pos === Number(pos))) 102 | .forEach(pos => { 103 | delete cache[Number(pos)]; 104 | }); 105 | 106 | return DecorationSet.create(doc, decorations); 107 | } 108 | 109 | export default function Prism({ name }) { 110 | let highlighted = false; 111 | 112 | return new Plugin({ 113 | key: new PluginKey("prism"), 114 | state: { 115 | init: (_: Plugin, { doc }) => { 116 | return DecorationSet.create(doc, []); 117 | }, 118 | apply: (transaction: Transaction, decorationSet, oldState, state) => { 119 | const nodeName = state.selection.$head.parent.type.name; 120 | const previousNodeName = oldState.selection.$head.parent.type.name; 121 | const codeBlockChanged = 122 | transaction.docChanged && [nodeName, previousNodeName].includes(name); 123 | const ySyncEdit = !!transaction.getMeta("y-sync$"); 124 | 125 | if (!highlighted || codeBlockChanged || ySyncEdit) { 126 | highlighted = true; 127 | return getDecorations({ doc: transaction.doc, name }); 128 | } 129 | 130 | return decorationSet.map(transaction.mapping, transaction.doc); 131 | }, 132 | }, 133 | view: view => { 134 | if (!highlighted) { 135 | // we don't highlight code blocks on the first render as part of mounting 136 | // as it's expensive (relative to the rest of the document). Instead let 137 | // it render un-highlighted and then trigger a defered render of Prism 138 | // by updating the plugins metadata 139 | setTimeout(() => { 140 | view.dispatch(view.state.tr.setMeta("prism", { loaded: true })); 141 | }, 10); 142 | } 143 | return {}; 144 | }, 145 | props: { 146 | decorations(state) { 147 | return this.getState(state); 148 | }, 149 | }, 150 | }); 151 | } 152 | -------------------------------------------------------------------------------- /src/plugins/SmartText.ts: -------------------------------------------------------------------------------- 1 | import { ellipsis, smartQuotes, InputRule } from "prosemirror-inputrules"; 2 | import Extension from "../lib/Extension"; 3 | 4 | const rightArrow = new InputRule(/->$/, "→"); 5 | 6 | export default class SmartText extends Extension { 7 | get name() { 8 | return "smart_text"; 9 | } 10 | 11 | inputRules() { 12 | return [rightArrow, ellipsis, ...smartQuotes]; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/plugins/TrailingNode.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, PluginKey } from "prosemirror-state"; 2 | import Extension from "../lib/Extension"; 3 | 4 | export default class TrailingNode extends Extension { 5 | get name() { 6 | return "trailing_node"; 7 | } 8 | 9 | get defaultOptions() { 10 | return { 11 | node: "paragraph", 12 | notAfter: ["paragraph", "heading"], 13 | }; 14 | } 15 | 16 | get plugins() { 17 | const plugin = new PluginKey(this.name); 18 | const disabledNodes = Object.entries(this.editor.schema.nodes) 19 | .map(([, value]) => value) 20 | .filter(node => this.options.notAfter.includes(node.name)); 21 | 22 | return [ 23 | new Plugin({ 24 | key: plugin, 25 | view: () => ({ 26 | update: view => { 27 | const { state } = view; 28 | const insertNodeAtEnd = plugin.getState(state); 29 | 30 | if (!insertNodeAtEnd) { 31 | return; 32 | } 33 | 34 | const { doc, schema, tr } = state; 35 | const type = schema.nodes[this.options.node]; 36 | const transaction = tr.insert(doc.content.size, type.create()); 37 | view.dispatch(transaction); 38 | }, 39 | }), 40 | state: { 41 | init: (_, state) => { 42 | const lastNode = state.tr.doc.lastChild; 43 | return lastNode ? !disabledNodes.includes(lastNode.type) : false; 44 | }, 45 | apply: (tr, value) => { 46 | if (!tr.docChanged) { 47 | return value; 48 | } 49 | 50 | const lastNode = tr.doc.lastChild; 51 | return lastNode ? !disabledNodes.includes(lastNode.type) : false; 52 | }, 53 | }, 54 | }), 55 | ]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/queries/findCollapsedNodes.ts: -------------------------------------------------------------------------------- 1 | import { findBlockNodes, NodeWithPos } from "prosemirror-utils"; 2 | import { Node } from "prosemirror-model"; 3 | 4 | export default function findCollapsedNodes(doc: Node): NodeWithPos[] { 5 | const blocks = findBlockNodes(doc); 6 | const nodes: NodeWithPos[] = []; 7 | 8 | let withinCollapsedHeading; 9 | 10 | for (const block of blocks) { 11 | if (block.node.type.name === "heading") { 12 | if ( 13 | !withinCollapsedHeading || 14 | block.node.attrs.level <= withinCollapsedHeading 15 | ) { 16 | if (block.node.attrs.collapsed) { 17 | if (!withinCollapsedHeading) { 18 | withinCollapsedHeading = block.node.attrs.level; 19 | } 20 | } else { 21 | withinCollapsedHeading = undefined; 22 | } 23 | continue; 24 | } 25 | } 26 | 27 | if (withinCollapsedHeading) { 28 | nodes.push(block); 29 | } 30 | } 31 | 32 | return nodes; 33 | } 34 | -------------------------------------------------------------------------------- /src/queries/getColumnIndex.ts: -------------------------------------------------------------------------------- 1 | export default function getColumnIndex(selection) { 2 | const isColSelection = selection.isColSelection && selection.isColSelection(); 3 | if (!isColSelection) return undefined; 4 | 5 | const path = selection.$from.path; 6 | return path[path.length - 5]; 7 | } 8 | -------------------------------------------------------------------------------- /src/queries/getMarkRange.ts: -------------------------------------------------------------------------------- 1 | import { ResolvedPos, MarkType } from "prosemirror-model"; 2 | 3 | export default function getMarkRange($pos?: ResolvedPos, type?: MarkType) { 4 | if (!$pos || !type) { 5 | return false; 6 | } 7 | 8 | const start = $pos.parent.childAfter($pos.parentOffset); 9 | if (!start.node) { 10 | return false; 11 | } 12 | 13 | const mark = start.node.marks.find(mark => mark.type === type); 14 | if (!mark) { 15 | return false; 16 | } 17 | 18 | let startIndex = $pos.index(); 19 | let startPos = $pos.start() + start.offset; 20 | let endIndex = startIndex + 1; 21 | let endPos = startPos + start.node.nodeSize; 22 | 23 | while ( 24 | startIndex > 0 && 25 | mark.isInSet($pos.parent.child(startIndex - 1).marks) 26 | ) { 27 | startIndex -= 1; 28 | startPos -= $pos.parent.child(startIndex).nodeSize; 29 | } 30 | 31 | while ( 32 | endIndex < $pos.parent.childCount && 33 | mark.isInSet($pos.parent.child(endIndex).marks) 34 | ) { 35 | endPos += $pos.parent.child(endIndex).nodeSize; 36 | endIndex += 1; 37 | } 38 | 39 | return { from: startPos, to: endPos, mark }; 40 | } 41 | -------------------------------------------------------------------------------- /src/queries/getParentListItem.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "prosemirror-model"; 2 | import { EditorState } from "prosemirror-state"; 3 | 4 | export default function getParentListItem( 5 | state: EditorState 6 | ): [Node, number] | void { 7 | const $head = state.selection.$head; 8 | for (let d = $head.depth; d > 0; d--) { 9 | const node = $head.node(d); 10 | if (["list_item", "checkbox_item"].includes(node.type.name)) { 11 | return [node, $head.before(d)]; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/queries/getRowIndex.ts: -------------------------------------------------------------------------------- 1 | export default function getRowIndex(selection) { 2 | const isRowSelection = selection.isRowSelection && selection.isRowSelection(); 3 | if (!isRowSelection) return undefined; 4 | 5 | const path = selection.$from.path; 6 | return path[path.length - 8]; 7 | } 8 | -------------------------------------------------------------------------------- /src/queries/isInCode.ts: -------------------------------------------------------------------------------- 1 | import isMarkActive from "./isMarkActive"; 2 | import { EditorState } from "prosemirror-state"; 3 | 4 | export default function isInCode(state: EditorState): boolean { 5 | if (state.schema.nodes.code_block) { 6 | const $head = state.selection.$head; 7 | for (let d = $head.depth; d > 0; d--) { 8 | if ($head.node(d).type === state.schema.nodes.code_block) { 9 | return true; 10 | } 11 | } 12 | } 13 | 14 | return isMarkActive(state.schema.marks.code_inline)(state); 15 | } 16 | -------------------------------------------------------------------------------- /src/queries/isInList.ts: -------------------------------------------------------------------------------- 1 | export default function isInList(state) { 2 | const $head = state.selection.$head; 3 | for (let d = $head.depth; d > 0; d--) { 4 | if ( 5 | ["ordered_list", "bullet_list", "checkbox_list"].includes( 6 | $head.node(d).type.name 7 | ) 8 | ) { 9 | return true; 10 | } 11 | } 12 | 13 | return false; 14 | } 15 | -------------------------------------------------------------------------------- /src/queries/isList.ts: -------------------------------------------------------------------------------- 1 | export default function isList(node, schema) { 2 | return ( 3 | node.type === schema.nodes.bullet_list || 4 | node.type === schema.nodes.ordered_list || 5 | node.type === schema.nodes.checkbox_list 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/queries/isMarkActive.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from "prosemirror-state"; 2 | 3 | const isMarkActive = type => (state: EditorState): boolean => { 4 | if (!type) { 5 | return false; 6 | } 7 | 8 | const { from, $from, to, empty } = state.selection; 9 | 10 | return empty 11 | ? type.isInSet(state.storedMarks || $from.marks()) 12 | : state.doc.rangeHasMark(from, to, type); 13 | }; 14 | 15 | export default isMarkActive; 16 | -------------------------------------------------------------------------------- /src/queries/isNodeActive.ts: -------------------------------------------------------------------------------- 1 | import { findParentNode, findSelectedNodeOfType } from "prosemirror-utils"; 2 | 3 | const isNodeActive = (type, attrs: Record = {}) => state => { 4 | if (!type) { 5 | return false; 6 | } 7 | 8 | const node = 9 | findSelectedNodeOfType(type)(state.selection) || 10 | findParentNode(node => node.type === type)(state.selection); 11 | 12 | if (!Object.keys(attrs).length || !node) { 13 | return !!node; 14 | } 15 | 16 | return node.node.hasMarkup(type, { ...node.node.attrs, ...attrs }); 17 | }; 18 | 19 | export default isNodeActive; 20 | -------------------------------------------------------------------------------- /src/rules/breaks.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from "markdown-it"; 2 | import Token from "markdown-it/lib/token"; 3 | 4 | function isHardbreak(token: Token) { 5 | return ( 6 | token.type === "hardbreak" || 7 | (token.type === "text" && token.content === "\\") 8 | ); 9 | } 10 | 11 | export default function markdownBreakToParagraphs(md: MarkdownIt) { 12 | // insert a new rule after the "inline" rules are parsed 13 | md.core.ruler.after("inline", "breaks", state => { 14 | const { Token } = state; 15 | const tokens = state.tokens; 16 | 17 | // work backwards through the tokens and find text that looks like a br 18 | for (let i = tokens.length - 1; i > 0; i--) { 19 | const tokenChildren = tokens[i].children || []; 20 | const matches = tokenChildren.filter(isHardbreak); 21 | 22 | if (matches.length) { 23 | let token; 24 | 25 | const nodes: Token[] = []; 26 | const children = tokenChildren.filter(child => !isHardbreak(child)); 27 | 28 | let count = matches.length; 29 | if (!!children.length) count++; 30 | 31 | for (let i = 0; i < count; i++) { 32 | const isLast = i === count - 1; 33 | 34 | token = new Token("paragraph_open", "p", 1); 35 | nodes.push(token); 36 | 37 | const text = new Token("text", "", 0); 38 | text.content = ""; 39 | 40 | token = new Token("inline", "", 0); 41 | token.level = 1; 42 | token.children = isLast ? [text, ...children] : [text]; 43 | token.content = ""; 44 | nodes.push(token); 45 | 46 | token = new Token("paragraph_close", "p", -1); 47 | nodes.push(token); 48 | } 49 | 50 | tokens.splice(i - 1, 3, ...nodes); 51 | } 52 | } 53 | 54 | return false; 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /src/rules/checkboxes.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from "markdown-it"; 2 | import Token from "markdown-it/lib/token"; 3 | 4 | const CHECKBOX_REGEX = /\[(X|\s|_|-)\]\s(.*)?/i; 5 | 6 | function matches(token: Token | void) { 7 | return token && token.content.match(CHECKBOX_REGEX); 8 | } 9 | 10 | function isInline(token: Token | void): boolean { 11 | return !!token && token.type === "inline"; 12 | } 13 | 14 | function isParagraph(token: Token | void): boolean { 15 | return !!token && token.type === "paragraph_open"; 16 | } 17 | 18 | function isListItem(token: Token | void): boolean { 19 | return ( 20 | !!token && 21 | (token.type === "list_item_open" || token.type === "checkbox_item_open") 22 | ); 23 | } 24 | 25 | function looksLikeChecklist(tokens: Token[], index: number) { 26 | return ( 27 | isInline(tokens[index]) && 28 | isListItem(tokens[index - 2]) && 29 | isParagraph(tokens[index - 1]) && 30 | matches(tokens[index]) 31 | ); 32 | } 33 | 34 | export default function markdownItCheckbox(md: MarkdownIt): void { 35 | function render(tokens, idx) { 36 | const token = tokens[idx]; 37 | const checked = !!token.attrGet("checked"); 38 | 39 | if (token.nesting === 1) { 40 | // opening tag 41 | return `
  • ${checked ? "[x]" : "[ ]"}`; 44 | } else { 45 | // closing tag 46 | return "
  • \n"; 47 | } 48 | } 49 | 50 | md.renderer.rules.checkbox_item_open = render; 51 | md.renderer.rules.checkbox_item_close = render; 52 | 53 | // insert a new rule after the "inline" rules are parsed 54 | md.core.ruler.after("inline", "checkboxes", state => { 55 | const tokens = state.tokens; 56 | 57 | // work backwards through the tokens and find text that looks like a checkbox 58 | for (let i = tokens.length - 1; i > 0; i--) { 59 | const matches = looksLikeChecklist(tokens, i); 60 | if (matches) { 61 | const value = matches[1]; 62 | const checked = value.toLowerCase() === "x"; 63 | 64 | // convert surrounding list tokens 65 | if (tokens[i - 3].type === "bullet_list_open") { 66 | tokens[i - 3].type = "checkbox_list_open"; 67 | } 68 | 69 | if (tokens[i + 3].type === "bullet_list_close") { 70 | tokens[i + 3].type = "checkbox_list_close"; 71 | } 72 | 73 | // remove [ ] [x] from list item label – must use the content from the 74 | // child for escaped characters to be unescaped correctly. 75 | const tokenChildren = tokens[i].children; 76 | if (tokenChildren) { 77 | const contentMatches = tokenChildren[0].content.match(CHECKBOX_REGEX); 78 | 79 | if (contentMatches) { 80 | const label = contentMatches[2]; 81 | 82 | tokens[i].content = label; 83 | tokenChildren[0].content = label; 84 | } 85 | } 86 | 87 | // open list item and ensure checked state is transferred 88 | tokens[i - 2].type = "checkbox_item_open"; 89 | 90 | if (checked === true) { 91 | tokens[i - 2].attrs = [["checked", "true"]]; 92 | } 93 | 94 | // close the list item 95 | let j = i; 96 | while (tokens[j].type !== "list_item_close") { 97 | j++; 98 | } 99 | tokens[j].type = "checkbox_item_close"; 100 | } 101 | } 102 | 103 | return false; 104 | }); 105 | } 106 | -------------------------------------------------------------------------------- /src/rules/embeds.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from "markdown-it"; 2 | import Token from "markdown-it/lib/token"; 3 | 4 | function isParagraph(token: Token) { 5 | return token.type === "paragraph_open"; 6 | } 7 | 8 | function isInline(token: Token) { 9 | return token.type === "inline" && token.level === 1; 10 | } 11 | 12 | function isLinkOpen(token: Token) { 13 | return token.type === "link_open"; 14 | } 15 | 16 | function isLinkClose(token: Token) { 17 | return token.type === "link_close"; 18 | } 19 | 20 | export default function(embeds) { 21 | function isEmbed(token: Token, link: Token) { 22 | const href = link.attrs ? link.attrs[0][1] : ""; 23 | const simpleLink = href === token.content; 24 | 25 | if (!simpleLink) return false; 26 | if (!embeds) return false; 27 | 28 | for (const embed of embeds) { 29 | const matches = embed.matcher(href); 30 | if (matches) { 31 | return { 32 | ...embed, 33 | matches, 34 | }; 35 | } 36 | } 37 | } 38 | 39 | return function markdownEmbeds(md: MarkdownIt) { 40 | md.core.ruler.after("inline", "embeds", state => { 41 | const tokens = state.tokens; 42 | let insideLink; 43 | 44 | for (let i = 0; i < tokens.length - 1; i++) { 45 | // once we find an inline token look through it's children for links 46 | if (isInline(tokens[i]) && isParagraph(tokens[i - 1])) { 47 | const tokenChildren = tokens[i].children || []; 48 | 49 | for (let j = 0; j < tokenChildren.length - 1; j++) { 50 | const current = tokenChildren[j]; 51 | if (!current) continue; 52 | 53 | if (isLinkOpen(current)) { 54 | insideLink = current; 55 | continue; 56 | } 57 | 58 | if (isLinkClose(current)) { 59 | insideLink = null; 60 | continue; 61 | } 62 | 63 | // of hey, we found a link – lets check to see if it should be 64 | // considered to be an embed 65 | if (insideLink) { 66 | const result = isEmbed(current, insideLink); 67 | if (result) { 68 | const { content } = current; 69 | 70 | // convert to embed token 71 | const token = new Token("embed", "iframe", 0); 72 | token.attrSet("href", content); 73 | 74 | // delete the inline link – this makes the assumption that the 75 | // embed is the only thing in the para. 76 | // TODO: double check this 77 | tokens.splice(i - 1, 3, token); 78 | break; 79 | } 80 | } 81 | } 82 | } 83 | } 84 | 85 | return false; 86 | }); 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /src/rules/emoji.ts: -------------------------------------------------------------------------------- 1 | import nameToEmoji from "gemoji/name-to-emoji.json"; 2 | import MarkdownIt from "markdown-it"; 3 | import emojiPlugin from "markdown-it-emoji"; 4 | 5 | export default function emoji(md: MarkdownIt): (md: MarkdownIt) => void { 6 | return emojiPlugin(md, { 7 | defs: nameToEmoji, 8 | shortcuts: {}, 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /src/rules/mark.ts: -------------------------------------------------------------------------------- 1 | // Adapted from: 2 | // https://github.com/markdown-it/markdown-it-mark/blob/master/index.js 3 | 4 | export default function(options: { delim: string; mark: string }) { 5 | const delimCharCode = options.delim.charCodeAt(0); 6 | 7 | return function emphasisPlugin(md) { 8 | function tokenize(state, silent) { 9 | let i, token; 10 | 11 | const start = state.pos, 12 | marker = state.src.charCodeAt(start); 13 | 14 | if (silent) { 15 | return false; 16 | } 17 | 18 | if (marker !== delimCharCode) { 19 | return false; 20 | } 21 | 22 | const scanned = state.scanDelims(state.pos, true); 23 | const ch = String.fromCharCode(marker); 24 | let len = scanned.length; 25 | 26 | if (len < 2) { 27 | return false; 28 | } 29 | 30 | if (len % 2) { 31 | token = state.push("text", "", 0); 32 | token.content = ch; 33 | len--; 34 | } 35 | 36 | for (i = 0; i < len; i += 2) { 37 | token = state.push("text", "", 0); 38 | token.content = ch + ch; 39 | 40 | if (!scanned.can_open && !scanned.can_close) { 41 | continue; 42 | } 43 | 44 | state.delimiters.push({ 45 | marker, 46 | length: 0, // disable "rule of 3" length checks meant for emphasis 47 | jump: i, 48 | token: state.tokens.length - 1, 49 | end: -1, 50 | open: scanned.can_open, 51 | close: scanned.can_close, 52 | }); 53 | } 54 | 55 | state.pos += scanned.length; 56 | return true; 57 | } 58 | 59 | // Walk through delimiter list and replace text tokens with tags 60 | // 61 | function postProcess(state, delimiters) { 62 | let i, j, startDelim, endDelim, token; 63 | const loneMarkers: number[] = [], 64 | max = delimiters.length; 65 | 66 | for (i = 0; i < max; i++) { 67 | startDelim = delimiters[i]; 68 | 69 | if (startDelim.marker !== delimCharCode) { 70 | continue; 71 | } 72 | 73 | if (startDelim.end === -1) { 74 | continue; 75 | } 76 | 77 | endDelim = delimiters[startDelim.end]; 78 | 79 | token = state.tokens[startDelim.token]; 80 | token.type = `${options.mark}_open`; 81 | token.tag = "span"; 82 | token.attrs = [["class", options.mark]]; 83 | token.nesting = 1; 84 | token.markup = options.delim; 85 | token.content = ""; 86 | 87 | token = state.tokens[endDelim.token]; 88 | token.type = `${options.mark}_close`; 89 | token.tag = "span"; 90 | token.nesting = -1; 91 | token.markup = options.delim; 92 | token.content = ""; 93 | 94 | if ( 95 | state.tokens[endDelim.token - 1].type === "text" && 96 | state.tokens[endDelim.token - 1].content === options.delim[0] 97 | ) { 98 | loneMarkers.push(endDelim.token - 1); 99 | } 100 | } 101 | 102 | // If a marker sequence has an odd number of characters, it's split 103 | // like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the 104 | // start of the sequence. 105 | // 106 | // So, we have to move all those markers after subsequent s_close tags. 107 | while (loneMarkers.length) { 108 | i = loneMarkers.pop(); 109 | j = i + 1; 110 | 111 | while ( 112 | j < state.tokens.length && 113 | state.tokens[j].type === `${options.mark}_close` 114 | ) { 115 | j++; 116 | } 117 | 118 | j--; 119 | 120 | if (i !== j) { 121 | token = state.tokens[j]; 122 | state.tokens[j] = state.tokens[i]; 123 | state.tokens[i] = token; 124 | } 125 | } 126 | } 127 | 128 | md.inline.ruler.before("emphasis", options.mark, tokenize); 129 | md.inline.ruler2.before("emphasis", options.mark, function(state) { 130 | let curr; 131 | const tokensMeta = state.tokens_meta, 132 | max = (state.tokens_meta || []).length; 133 | 134 | postProcess(state, state.delimiters); 135 | 136 | for (curr = 0; curr < max; curr++) { 137 | if (tokensMeta[curr] && tokensMeta[curr].delimiters) { 138 | postProcess(state, tokensMeta[curr].delimiters); 139 | } 140 | } 141 | }); 142 | }; 143 | } 144 | -------------------------------------------------------------------------------- /src/rules/notices.ts: -------------------------------------------------------------------------------- 1 | import customFence from "markdown-it-container"; 2 | 3 | export default function notice(md): void { 4 | return customFence(md, "notice", { 5 | marker: ":", 6 | validate: () => true, 7 | render: function(tokens, idx) { 8 | const { info } = tokens[idx]; 9 | 10 | if (tokens[idx].nesting === 1) { 11 | // opening tag 12 | return `
    \n`; 13 | } else { 14 | // closing tag 15 | return "
    \n"; 16 | } 17 | }, 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/rules/tables.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from "markdown-it"; 2 | import Token from "markdown-it/lib/token"; 3 | 4 | const BREAK_REGEX = /(?:^|[^\\])\\n/; 5 | 6 | export default function markdownTables(md: MarkdownIt): void { 7 | // insert a new rule after the "inline" rules are parsed 8 | md.core.ruler.after("inline", "tables-pm", state => { 9 | const tokens = state.tokens; 10 | let inside = false; 11 | 12 | for (let i = tokens.length - 1; i > 0; i--) { 13 | if (inside) { 14 | tokens[i].level--; 15 | } 16 | 17 | // convert unescaped \n in the text into real br tag 18 | if (tokens[i].type === "inline" && tokens[i].content.match(BREAK_REGEX)) { 19 | const existing = tokens[i].children || []; 20 | tokens[i].children = []; 21 | 22 | existing.forEach(child => { 23 | const breakParts = child.content.split(BREAK_REGEX); 24 | 25 | // a schema agnostic way to know if a node is inline code would be 26 | // great, for now we are stuck checking the node type. 27 | if (breakParts.length > 1 && child.type !== "code_inline") { 28 | breakParts.forEach((part, index) => { 29 | const token = new Token("text", "", 1); 30 | token.content = part.trim(); 31 | tokens[i].children?.push(token); 32 | 33 | if (index < breakParts.length - 1) { 34 | const brToken = new Token("br", "br", 1); 35 | tokens[i].children?.push(brToken); 36 | } 37 | }); 38 | } else { 39 | tokens[i].children?.push(child); 40 | } 41 | }); 42 | } 43 | 44 | // filter out incompatible tokens from markdown-it that we don't need 45 | // in prosemirror. thead/tbody do nothing. 46 | if ( 47 | ["thead_open", "thead_close", "tbody_open", "tbody_close"].includes( 48 | tokens[i].type 49 | ) 50 | ) { 51 | inside = !inside; 52 | tokens.splice(i, 1); 53 | } 54 | 55 | if (["th_open", "td_open"].includes(tokens[i].type)) { 56 | // markdown-it table parser does not return paragraphs inside the cells 57 | // but prosemirror requires them, so we add 'em in here. 58 | tokens.splice(i + 1, 0, new Token("paragraph_open", "p", 1)); 59 | 60 | // markdown-it table parser stores alignment as html styles, convert 61 | // to a simple string here 62 | const tokenAttrs = tokens[i].attrs; 63 | if (tokenAttrs) { 64 | const style = tokenAttrs[0][1]; 65 | tokens[i].info = style.split(":")[1]; 66 | } 67 | } 68 | 69 | if (["th_close", "td_close"].includes(tokens[i].type)) { 70 | tokens.splice(i, 0, new Token("paragraph_close", "p", -1)); 71 | } 72 | } 73 | 74 | return false; 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /src/rules/underlines.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from "markdown-it"; 2 | 3 | export default function markdownUnderlines(md: MarkdownIt) { 4 | md.inline.ruler2.after("emphasis", "underline", state => { 5 | const tokens = state.tokens; 6 | 7 | for (let i = tokens.length - 1; i > 0; i--) { 8 | const token = tokens[i]; 9 | 10 | if (token.markup === "__") { 11 | if (token.type === "strong_open") { 12 | tokens[i].tag = "underline"; 13 | tokens[i].type = "underline_open"; 14 | } 15 | if (token.type === "strong_close") { 16 | tokens[i].tag = "underline"; 17 | tokens[i].type = "underline_close"; 18 | } 19 | } 20 | } 21 | 22 | return false; 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /src/server.test.ts: -------------------------------------------------------------------------------- 1 | import { parser } from "./server"; 2 | 3 | test("renders an empty doc", () => { 4 | const ast = parser.parse(""); 5 | 6 | expect(ast.toJSON()).toEqual({ 7 | content: [{ type: "paragraph" }], 8 | type: "doc", 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "prosemirror-model"; 2 | import ExtensionManager from "./lib/ExtensionManager"; 3 | import render from "./lib/renderToHtml"; 4 | 5 | // nodes 6 | import Doc from "./nodes/Doc"; 7 | import Text from "./nodes/Text"; 8 | import Blockquote from "./nodes/Blockquote"; 9 | import Emoji from "./nodes/Emoji"; 10 | import BulletList from "./nodes/BulletList"; 11 | import CodeBlock from "./nodes/CodeBlock"; 12 | import CodeFence from "./nodes/CodeFence"; 13 | import CheckboxList from "./nodes/CheckboxList"; 14 | import CheckboxItem from "./nodes/CheckboxItem"; 15 | import Embed from "./nodes/Embed"; 16 | import HardBreak from "./nodes/HardBreak"; 17 | import Heading from "./nodes/Heading"; 18 | import HorizontalRule from "./nodes/HorizontalRule"; 19 | import Image from "./nodes/Image"; 20 | import ListItem from "./nodes/ListItem"; 21 | import Notice from "./nodes/Notice"; 22 | import OrderedList from "./nodes/OrderedList"; 23 | import Paragraph from "./nodes/Paragraph"; 24 | import Table from "./nodes/Table"; 25 | import TableCell from "./nodes/TableCell"; 26 | import TableHeadCell from "./nodes/TableHeadCell"; 27 | import TableRow from "./nodes/TableRow"; 28 | 29 | // marks 30 | import Bold from "./marks/Bold"; 31 | import Code from "./marks/Code"; 32 | import Highlight from "./marks/Highlight"; 33 | import Italic from "./marks/Italic"; 34 | import Link from "./marks/Link"; 35 | import Strikethrough from "./marks/Strikethrough"; 36 | import TemplatePlaceholder from "./marks/Placeholder"; 37 | import Underline from "./marks/Underline"; 38 | 39 | const extensions = new ExtensionManager([ 40 | new Doc(), 41 | new Text(), 42 | new HardBreak(), 43 | new Paragraph(), 44 | new Blockquote(), 45 | new Emoji(), 46 | new BulletList(), 47 | new CodeBlock(), 48 | new CodeFence(), 49 | new CheckboxList(), 50 | new CheckboxItem(), 51 | new Embed(), 52 | new ListItem(), 53 | new Notice(), 54 | new Heading(), 55 | new HorizontalRule(), 56 | new Image(), 57 | new Table(), 58 | new TableCell(), 59 | new TableHeadCell(), 60 | new TableRow(), 61 | new Bold(), 62 | new Code(), 63 | new Highlight(), 64 | new Italic(), 65 | new Link(), 66 | new Strikethrough(), 67 | new TemplatePlaceholder(), 68 | new Underline(), 69 | new OrderedList(), 70 | ]); 71 | 72 | export const schema = new Schema({ 73 | nodes: extensions.nodes, 74 | marks: extensions.marks, 75 | }); 76 | 77 | export const parser = extensions.parser({ 78 | schema, 79 | plugins: extensions.rulePlugins, 80 | }); 81 | 82 | export const serializer = extensions.serializer(); 83 | 84 | export const renderToHtml = (markdown: string): string => 85 | render(markdown, extensions.rulePlugins); 86 | -------------------------------------------------------------------------------- /src/stories/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import Editor from "./index"; 2 | import debounce from "lodash/debounce"; 3 | import { Props } from ".."; 4 | import React from "react"; 5 | import { Story, Meta } from "@storybook/react/types-6-0"; 6 | 7 | export default { 8 | title: "Editor", 9 | component: Editor, 10 | argTypes: { 11 | value: { control: "text" }, 12 | readOnly: { control: "boolean" }, 13 | onSave: { action: "save" }, 14 | onCancel: { action: "cancel" }, 15 | onClickHashtag: { action: "hashtag clicked" }, 16 | onClickLink: { action: "link clicked" }, 17 | onHoverLink: { action: "link hovered" }, 18 | onShowToast: { action: "toast" }, 19 | onFocus: { action: "focused" }, 20 | onBlur: { action: "blurred" }, 21 | disableExtensions: { control: "array" }, 22 | }, 23 | args: { 24 | disableExtensions: [], 25 | }, 26 | } as Meta; 27 | 28 | const Template: Story = args => ; 29 | 30 | export const Default = Template.bind({}); 31 | Default.args = { 32 | defaultValue: `# Welcome 33 | 34 | Just an easy to use **Markdown** editor with \`slash commands\``, 35 | }; 36 | 37 | export const Emoji = Template.bind({}); 38 | Emoji.args = { 39 | defaultValue: `# Emoji 40 | 41 | \ 42 | :1st_place_medal: 43 | `, 44 | }; 45 | 46 | export const TemplateDoc = Template.bind({}); 47 | TemplateDoc.args = { 48 | template: true, 49 | defaultValue: `# Template 50 | 51 | This document acts as a "template document", it's possible to insert placeholder marks that can be filled in later by others in a non-template document. 52 | 53 | \\ 54 | !!This is a template placeholder!!`, 55 | }; 56 | 57 | export const Headings = Template.bind({}); 58 | Headings.args = { 59 | defaultValue: `# Heading 1 60 | 61 | ## Heading 2 62 | 63 | ### Heading 3 64 | 65 | #### Heading 4`, 66 | }; 67 | 68 | export const Lists = Template.bind({}); 69 | Lists.args = { 70 | defaultValue: `# Lists 71 | 72 | - An 73 | - Unordered 74 | - List 75 | 76 | \\ 77 | 1. An 78 | 1. Ordered 79 | 1. List`, 80 | }; 81 | 82 | export const Blockquotes = Template.bind({}); 83 | Blockquotes.args = { 84 | defaultValue: `# Block quotes 85 | 86 | > Quotes are another way to callout text within a larger document 87 | > They are often used to incorrectly attribute words to historical figures`, 88 | }; 89 | 90 | export const Tables = Template.bind({}); 91 | Tables.args = { 92 | defaultValue: `# Tables 93 | 94 | Simple tables with alignment and row/col editing are supported, they can be inserted from the slash menu 95 | 96 | | Editor | Rank | React | Collaborative | 97 | |-------------|------|-------|--------------:| 98 | | Prosemirror | A | No | Yes | 99 | | Slate | B | Yes | No | 100 | | CKEdit | C | No | Yes | 101 | `, 102 | }; 103 | 104 | export const Marks = Template.bind({}); 105 | Marks.args = { 106 | defaultValue: `This document shows the variety of marks available, most can be accessed through the formatting menu by selecting text or by typing out the Markdown manually. 107 | 108 | \\ 109 | **bold** 110 | _italic_ 111 | ~~strikethrough~~ 112 | __underline__ 113 | ==highlighted== 114 | \`inline code\` 115 | !!placeholder!! 116 | [a link](http://www.getoutline.com) 117 | `, 118 | }; 119 | 120 | export const Code = Template.bind({}); 121 | Code.args = { 122 | defaultValue: `# Code 123 | 124 | \`\`\`html 125 | 126 |

    Simple code blocks are supported 127 | 128 | \`\`\` 129 | `, 130 | }; 131 | 132 | export const Notices = Template.bind({}); 133 | Notices.args = { 134 | defaultValue: `# Notices 135 | 136 | There are three types of editable notice blocks that can be used to callout information: 137 | 138 | \\ 139 | :::info 140 | Informational 141 | ::: 142 | 143 | :::tip 144 | Tip 145 | ::: 146 | 147 | :::warning 148 | Warning 149 | ::: 150 | `, 151 | }; 152 | 153 | export const ReadOnly = Template.bind({}); 154 | ReadOnly.args = { 155 | readOnly: true, 156 | defaultValue: `# Read Only 157 | 158 | The content of this editor cannot be edited`, 159 | }; 160 | 161 | export const MaxLength = Template.bind({}); 162 | MaxLength.args = { 163 | maxLength: 100, 164 | defaultValue: `This document has a max length of 100 characters. Once reached typing is prevented`, 165 | }; 166 | 167 | export const Checkboxes = Template.bind({}); 168 | Checkboxes.args = { 169 | defaultValue: ` 170 | - [x] done 171 | - [ ] todo`, 172 | }; 173 | 174 | export const ReadOnlyWriteCheckboxes = Template.bind({}); 175 | ReadOnlyWriteCheckboxes.args = { 176 | readOnly: true, 177 | readOnlyWriteCheckboxes: true, 178 | defaultValue: `A read-only editor with the exception that checkboxes remain toggleable, like GitHub 179 | 180 | \\ 181 | - [x] done 182 | - [ ] todo`, 183 | }; 184 | 185 | export const Persisted = Template.bind({}); 186 | Persisted.args = { 187 | defaultValue: 188 | localStorage.getItem("saved") || 189 | `# Persisted 190 | 191 | The contents of this editor are persisted to local storage on change (edit and reload)`, 192 | onChange: debounce(value => { 193 | const text = value(); 194 | localStorage.setItem("saved", text); 195 | }, 250), 196 | }; 197 | 198 | export const Placeholder = Template.bind({}); 199 | Placeholder.args = { 200 | defaultValue: "", 201 | placeholder: "This is a custom placeholder…", 202 | }; 203 | 204 | export const Images = Template.bind({}); 205 | Images.args = { 206 | defaultValue: `# Images 207 | ![A caption](https://upload.wikimedia.org/wikipedia/commons/0/06/Davide-ragusa-gcDwzUGuUoI-unsplash.jpg)`, 208 | }; 209 | 210 | export const Focused = Template.bind({}); 211 | Focused.args = { 212 | autoFocus: true, 213 | defaultValue: `# Focused 214 | 215 | This editor starts in focus`, 216 | }; 217 | 218 | export const Dark = Template.bind({}); 219 | Dark.args = { 220 | dark: true, 221 | defaultValue: `# Dark 222 | 223 | There's a customizable dark theme too`, 224 | }; 225 | 226 | export const RTL = Template.bind({}); 227 | RTL.args = { 228 | dir: "rtl", 229 | defaultValue: `# خوش آمدید 230 | 231 | متن نمونه برای نمایش پشتیبانی از زبان‌های RTL نظیر فارسی، عربی، عبری و ... 232 | 233 | \\ 234 | - [x] آیتم اول 235 | - [ ] آیتم دوم`, 236 | }; 237 | -------------------------------------------------------------------------------- /src/stories/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { dark, light } from "../styles/theme"; 3 | import Editor from ".."; 4 | 5 | const docSearchResults = [ 6 | { 7 | title: "Hiring", 8 | subtitle: "Created by Jane", 9 | url: "/doc/hiring", 10 | }, 11 | { 12 | title: "Product Roadmap", 13 | subtitle: "Created by Tom", 14 | url: "/doc/product-roadmap", 15 | }, 16 | { 17 | title: "Finances", 18 | subtitle: "Created by Coley", 19 | url: "/doc/finances", 20 | }, 21 | { 22 | title: "Security", 23 | subtitle: "Created by Coley", 24 | url: "/doc/security", 25 | }, 26 | { 27 | title: "Super secret stuff", 28 | subtitle: "Created by Coley", 29 | url: "/doc/secret-stuff", 30 | }, 31 | { 32 | title: "Supero notes", 33 | subtitle: "Created by Vanessa", 34 | url: "/doc/supero-notes", 35 | }, 36 | { 37 | title: "Meeting notes", 38 | subtitle: "Created by Rob", 39 | url: "/doc/meeting-notes", 40 | }, 41 | ]; 42 | 43 | class YoutubeEmbed extends React.Component<{ 44 | attrs: any; 45 | isSelected: boolean; 46 | }> { 47 | render() { 48 | const { attrs } = this.props; 49 | const videoId = attrs.matches[1]; 50 | 51 | return ( 52 |