├── .eslintignore ├── .gitignore ├── .npmignore ├── src ├── types │ ├── markdown-it-mark.d.ts │ ├── prosemirror-model.d.ts │ └── index.ts ├── lib │ ├── isUrl.ts │ ├── markdown │ │ ├── notices.ts │ │ ├── underlines.ts │ │ ├── rules.ts │ │ ├── breaks.ts │ │ ├── checkboxes.ts │ │ ├── tables.ts │ │ ├── embeds.ts │ │ └── mark.ts │ ├── getMarkAttrs.ts │ ├── getDataTransferFiles.ts │ ├── headingToSlug.ts │ ├── Extension.ts │ ├── getHeadings.ts │ ├── uploadPlaceholder.ts │ ├── markInputRule.ts │ ├── ComponentView.tsx │ └── ExtensionManager.ts ├── queries │ ├── isList.ts │ ├── getRowIndex.ts │ ├── getColumnIndex.ts │ ├── isInList.ts │ ├── isMarkActive.ts │ ├── isInCode.ts │ ├── isNodeActive.ts │ └── getMarkRange.ts ├── nodes │ ├── Doc.ts │ ├── ReactNode.ts │ ├── CodeBlock.ts │ ├── Text.ts │ ├── TableRow.ts │ ├── Node.ts │ ├── ListItem.ts │ ├── Blockquote.ts │ ├── BulletList.ts │ ├── HardBreak.ts │ ├── CheckboxList.ts │ ├── Paragraph.ts │ ├── HorizontalRule.ts │ ├── Embed.tsx │ ├── OrderedList.ts │ ├── CheckboxItem.ts │ ├── TableHeadCell.ts │ ├── Notice.tsx │ ├── TableCell.ts │ ├── CodeFence.ts │ ├── Table.ts │ ├── Heading.ts │ └── Image.tsx ├── components │ ├── Tooltip.tsx │ ├── ToolbarSeparator.tsx │ ├── VisuallyHidden.tsx │ ├── Input.tsx │ ├── ToolbarButton.tsx │ ├── Flex.tsx │ ├── Menu.tsx │ ├── LinkSearchResult.tsx │ ├── BlockMenuItem.tsx │ ├── LinkToolbar.tsx │ ├── SelectionToolbar.tsx │ ├── FloatingToolbar.tsx │ └── LinkEditor.tsx ├── plugins │ ├── SmartText.ts │ ├── History.ts │ ├── Placeholder.ts │ ├── TrailingNode.ts │ ├── Keys.ts │ ├── MarkdownPaste.ts │ ├── Prism.ts │ └── BlockMenuTrigger.ts ├── menus │ ├── table.tsx │ ├── tableRow.tsx │ ├── tableCol.tsx │ ├── formatting.ts │ └── block.ts ├── commands │ ├── toggleWrap.ts │ ├── toggleBlockType.ts │ ├── README.md │ ├── backspaceToParagraph.ts │ ├── toggleList.ts │ ├── createAndInsertLink.ts │ └── insertFiles.ts ├── marks │ ├── Mark.ts │ ├── Highlight.ts │ ├── Underline.ts │ ├── Bold.ts │ ├── Italic.ts │ ├── Strikethrough.ts │ ├── Code.ts │ ├── Link.ts │ └── Placeholder.ts ├── dictionary.ts ├── server.ts └── theme.ts ├── .github └── ISSUE_TEMPLATE │ ├── config.yml │ └── bug_report.md ├── example ├── dist │ └── index.html ├── webpack.config.js └── src │ └── index.js ├── tsconfig.json ├── .circleci └── config.yml ├── .eslintrc ├── LICENSE └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/types/markdown-it-mark.d.ts: -------------------------------------------------------------------------------- 1 | declare module "markdown-it-mark" { 2 | function plugin(md: any): void; 3 | 4 | export = plugin; 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/isUrl.ts: -------------------------------------------------------------------------------- 1 | export default function isUrl(text: string) { 2 | try { 3 | new URL(text); 4 | return true; 5 | } catch (err) { 6 | return false; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/markdown/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 | }); 8 | } 9 | -------------------------------------------------------------------------------- /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.todo_list 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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: 10px; 10 | `; 11 | 12 | export default Separator; 13 | -------------------------------------------------------------------------------- /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/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/isMarkActive.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from "prosemirror-state"; 2 | 3 | const isMarkActive = type => (state: EditorState): boolean => { 4 | const { from, $from, to, empty } = state.selection; 5 | 6 | return empty 7 | ? type.isInSet(state.storedMarks || $from.marks()) 8 | : state.doc.rangeHasMark(from, to, type); 9 | }; 10 | 11 | export default isMarkActive; 12 | -------------------------------------------------------------------------------- /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 | 15 | export default Input; 16 | -------------------------------------------------------------------------------- /src/queries/isInCode.ts: -------------------------------------------------------------------------------- 1 | import isMarkActive from "./isMarkActive"; 2 | 3 | export default function isInCode(state) { 4 | const $head = state.selection.$head; 5 | for (let d = $head.depth; d > 0; d--) { 6 | if ($head.node(d).type === state.schema.nodes.code_block) { 7 | return true; 8 | } 9 | } 10 | 11 | return isMarkActive(state.schema.marks.code_inline)(state); 12 | } 13 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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/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/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/types/prosemirror-model.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import model from "prosemirror-model"; 3 | 4 | declare module "prosemirror-model" { 5 | interface Slice { 6 | // this method is missing in the DefinitelyTyped type definition, so we 7 | // must patch it here. 8 | // https://github.com/ProseMirror/prosemirror-model/blob/bd13a2329fda39f1c4d09abd8f0db2032bdc8014/src/replace.js#L51 9 | removeBetween(from: number, to: number): Slice; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /example/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example 6 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /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/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/queries/isNodeActive.ts: -------------------------------------------------------------------------------- 1 | import { findParentNode, findSelectedNodeOfType } from "prosemirror-utils"; 2 | 3 | const isNodeActive = (type, attrs: Record = {}) => state => { 4 | const node = 5 | findSelectedNodeOfType(type)(state.selection) || 6 | findParentNode(node => node.type === type)(state.selection); 7 | 8 | if (!Object.keys(attrs).length || !node) { 9 | return !!node; 10 | } 11 | 12 | return node.node.hasMarkup(type, { ...node.node.attrs, ...attrs }); 13 | }; 14 | 15 | export default isNodeActive; 16 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "strict": false, 5 | "strictNullChecks": true, 6 | "noImplicitAny": false, 7 | "esModuleInterop": true, 8 | "allowJs": true, 9 | "module": "commonjs", 10 | "target": "es2017", 11 | "jsx": "react", 12 | "types": [ 13 | "react" 14 | ], 15 | "rootDir": "src", 16 | "outDir": "dist", 17 | "stripInternal": true, 18 | "removeComments": true, 19 | "declarationMap": true, 20 | "declaration": true, 21 | }, 22 | "include": [ 23 | "src/**/*" 24 | ], 25 | "exclude": [ 26 | "dist", 27 | "node_modules" 28 | ] 29 | } -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { EditorState } from "prosemirror-state"; 3 | 4 | export enum ToastType { 5 | Error = "error", 6 | Info = "info", 7 | } 8 | 9 | export type MenuItem = { 10 | icon?: typeof React.Component | React.FC; 11 | name?: string; 12 | title?: string; 13 | shortcut?: string; 14 | keywords?: string; 15 | tooltip?: string; 16 | attrs?: Record; 17 | visible?: boolean; 18 | active?: (state: EditorState) => boolean; 19 | }; 20 | 21 | export type EmbedDescriptor = MenuItem & { 22 | matcher: (url: string) => boolean | []; 23 | component: typeof React.Component | React.FC; 24 | }; 25 | -------------------------------------------------------------------------------- /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: 10px; 12 | border: none; 13 | background: none; 14 | transition: opacity 100ms ease-in-out; 15 | padding: 0; 16 | opacity: 0.7; 17 | outline: none; 18 | 19 | &:first-child { 20 | margin-left: 0; 21 | } 22 | 23 | &:hover { 24 | opacity: 1; 25 | } 26 | 27 | &:disabled { 28 | opacity: 0.3; 29 | cursor: default; 30 | } 31 | 32 | ${props => props.active && "opacity: 1;"}; 33 | `; 34 | -------------------------------------------------------------------------------- /src/lib/markdown/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/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/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/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/lib/markdown/rules.ts: -------------------------------------------------------------------------------- 1 | import markdownit from "markdown-it"; 2 | import markPlugin from "./mark"; 3 | import checkboxPlugin from "./checkboxes"; 4 | import embedsPlugin from "./embeds"; 5 | import breakPlugin from "./breaks"; 6 | import tablesPlugin from "./tables"; 7 | import noticesPlugin from "./notices"; 8 | import underlinesPlugin from "./underlines"; 9 | 10 | export default function rules({ embeds }) { 11 | return markdownit("default", { 12 | breaks: false, 13 | html: false, 14 | }) 15 | .use(embedsPlugin(embeds)) 16 | .use(breakPlugin) 17 | .use(checkboxPlugin) 18 | .use(markPlugin({ delim: "==", mark: "mark" })) 19 | .use(markPlugin({ delim: "!!", mark: "placeholder" })) 20 | .use(underlinesPlugin) 21 | .use(tablesPlugin) 22 | .use(noticesPlugin); 23 | } 24 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const path = require("path"); 3 | 4 | module.exports = { 5 | mode: "development", 6 | 7 | resolve: { 8 | mainFields: ["browser", "main"], 9 | extensions: [".ts", ".tsx", ".js"], 10 | }, 11 | 12 | entry: path.resolve(__dirname, "src", "index.js"), 13 | 14 | output: { 15 | filename: "main.js", 16 | path: path.resolve(__dirname, "dist"), 17 | }, 18 | 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.(tsx?|js)$/, 23 | exclude: /node_modules/, 24 | use: [ 25 | { 26 | loader: "ts-loader", 27 | options: { 28 | transpileOnly: true, 29 | }, 30 | }, 31 | ], 32 | }, 33 | ], 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /.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:10.16.3 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 | 32 | -------------------------------------------------------------------------------- /src/marks/Highlight.ts: -------------------------------------------------------------------------------- 1 | import { toggleMark } from "prosemirror-commands"; 2 | import markInputRule from "../lib/markInputRule"; 3 | import Mark from "./Mark"; 4 | 5 | export default class Highlight extends Mark { 6 | get name() { 7 | return "mark"; 8 | } 9 | 10 | get schema() { 11 | return { 12 | parseDOM: [{ tag: "mark" }], 13 | toDOM: () => ["mark"], 14 | }; 15 | } 16 | 17 | inputRules({ type }) { 18 | return [markInputRule(/(?:==)([^=]+)(?:==)$/, type)]; 19 | } 20 | 21 | keys({ type }) { 22 | return { 23 | "Mod-Ctrl-h": toggleMark(type), 24 | }; 25 | } 26 | 27 | get toMarkdown() { 28 | return { 29 | open: "==", 30 | close: "==", 31 | mixable: true, 32 | expelEnclosingWhitespace: true, 33 | }; 34 | } 35 | 36 | parseMarkdown() { 37 | return { mark: "mark" }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.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": 2, 18 | "no-mixed-operators": "off", 19 | "jsx-a11y/href-no-hash": "off", 20 | "react/prop-types": "off", 21 | "@typescript-eslint/explicit-function-return-type": "off", 22 | "prettier/prettier": [ 23 | "error", 24 | { 25 | "printWidth": 80, 26 | "trailingComma": "es5" 27 | } 28 | ] 29 | }, 30 | "settings": { 31 | "react": { 32 | "version": "detect" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/nodes/ListItem.ts: -------------------------------------------------------------------------------- 1 | import { 2 | splitListItem, 3 | sinkListItem, 4 | liftListItem, 5 | } from "prosemirror-schema-list"; 6 | import Node from "./Node"; 7 | 8 | export default class ListItem extends Node { 9 | get name() { 10 | return "list_item"; 11 | } 12 | 13 | get schema() { 14 | return { 15 | content: "paragraph block*", 16 | defining: true, 17 | draggable: true, 18 | parseDOM: [{ tag: "li" }], 19 | toDOM: () => ["li", 0], 20 | }; 21 | } 22 | 23 | keys({ type }) { 24 | return { 25 | Enter: splitListItem(type), 26 | Tab: sinkListItem(type), 27 | "Shift-Tab": liftListItem(type), 28 | "Mod-]": sinkListItem(type), 29 | "Mod-[": liftListItem(type), 30 | }; 31 | } 32 | 33 | toMarkdown(state, node) { 34 | state.renderContent(node); 35 | } 36 | 37 | parseMarkdown() { 38 | return { block: "list_item" }; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /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?: Record; 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/marks/Underline.ts: -------------------------------------------------------------------------------- 1 | import { toggleMark } from "prosemirror-commands"; 2 | import markInputRule from "../lib/markInputRule"; 3 | import Mark from "./Mark"; 4 | 5 | export default class Underline extends Mark { 6 | get name() { 7 | return "underline"; 8 | } 9 | 10 | get schema() { 11 | return { 12 | parseDOM: [ 13 | { tag: "u" }, 14 | { 15 | style: "text-decoration", 16 | getAttrs: value => value === "underline", 17 | }, 18 | ], 19 | toDOM: () => ["u", 0], 20 | }; 21 | } 22 | 23 | inputRules({ type }) { 24 | return [markInputRule(/(?:__)([^_]+)(?:__)$/, type)]; 25 | } 26 | 27 | keys({ type }) { 28 | return { 29 | "Mod-u": toggleMark(type), 30 | }; 31 | } 32 | 33 | get toMarkdown() { 34 | return { 35 | open: "__", 36 | close: "__", 37 | mixable: true, 38 | expelEnclosingWhitespace: true, 39 | }; 40 | } 41 | 42 | parseMarkdown() { 43 | return { mark: "underline" }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /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/nodes/Blockquote.ts: -------------------------------------------------------------------------------- 1 | import { wrappingInputRule } from "prosemirror-inputrules"; 2 | import Node from "./Node"; 3 | import toggleWrap from "../commands/toggleWrap"; 4 | 5 | export default class Blockquote extends Node { 6 | get name() { 7 | return "blockquote"; 8 | } 9 | 10 | get schema() { 11 | return { 12 | content: "block+", 13 | group: "block", 14 | defining: true, 15 | parseDOM: [{ tag: "blockquote" }], 16 | toDOM: () => ["blockquote", 0], 17 | }; 18 | } 19 | 20 | inputRules({ type }) { 21 | return [wrappingInputRule(/^\s*>\s$/, type)]; 22 | } 23 | 24 | commands({ type }) { 25 | return () => toggleWrap(type); 26 | } 27 | 28 | keys({ type }) { 29 | return { 30 | "Ctrl->": toggleWrap(type), 31 | "Mod-]": toggleWrap(type), 32 | }; 33 | } 34 | 35 | toMarkdown(state, node) { 36 | state.wrapBlock("> ", null, node, () => state.renderContent(node)); 37 | } 38 | 39 | parseMarkdown() { 40 | return { block: "blockquote" }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /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/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/HardBreak.ts: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | import { isInTable } from "prosemirror-tables"; 3 | 4 | export default class HardBreak extends Node { 5 | get name() { 6 | return "br"; 7 | } 8 | 9 | get schema() { 10 | return { 11 | inline: true, 12 | group: "inline", 13 | selectable: false, 14 | parseDOM: [{ tag: "br" }], 15 | toDOM() { 16 | return ["br"]; 17 | }, 18 | }; 19 | } 20 | 21 | commands({ type }) { 22 | return () => (state, dispatch) => { 23 | dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); 24 | return true; 25 | }; 26 | } 27 | 28 | keys({ type }) { 29 | return { 30 | "Shift-Enter": (state, dispatch) => { 31 | if (!isInTable(state)) return false; 32 | dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); 33 | return true; 34 | }, 35 | }; 36 | } 37 | 38 | toMarkdown(state) { 39 | state.write(" \\n "); 40 | } 41 | 42 | parseMarkdown() { 43 | return { node: "br" }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/Extension.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import { InputRule } from "prosemirror-inputrules"; 3 | import { Plugin } from "prosemirror-state"; 4 | import Editor from "../"; 5 | 6 | type Command = (attrs) => (state, dispatch) => any; 7 | 8 | export default class Extension { 9 | options: Record; 10 | editor: Editor; 11 | 12 | constructor(options: Record = {}) { 13 | this.options = { 14 | ...this.defaultOptions, 15 | ...options, 16 | }; 17 | } 18 | 19 | bindEditor(editor: Editor) { 20 | this.editor = editor; 21 | } 22 | 23 | get type() { 24 | return "extension"; 25 | } 26 | 27 | get name() { 28 | return ""; 29 | } 30 | 31 | get plugins(): Plugin[] { 32 | return []; 33 | } 34 | 35 | keys(options) { 36 | return {}; 37 | } 38 | 39 | inputRules(options): InputRule[] { 40 | return []; 41 | } 42 | 43 | commands(options): Record | Command { 44 | return attrs => () => false; 45 | } 46 | 47 | get defaultOptions() { 48 | return {}; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /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(/(?:^|[^_])(_([^_]+)_)$/, 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/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/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/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/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/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() { 15 | return ["p", 0]; 16 | }, 17 | }; 18 | } 19 | 20 | keys({ type }) { 21 | return { 22 | "Shift-Ctrl-0": setBlockType(type), 23 | }; 24 | } 25 | 26 | commands({ type }) { 27 | return () => setBlockType(type); 28 | } 29 | 30 | toMarkdown(state, node) { 31 | // render empty paragraphs as hard breaks to ensure that newlines are 32 | // persisted between reloads (this breaks from markdown tradition) 33 | if ( 34 | node.textContent.trim() === "" && 35 | node.childCount === 0 && 36 | !state.inTable 37 | ) { 38 | state.write("\\\n"); 39 | } else { 40 | state.renderInline(node); 41 | state.closeBlock(node); 42 | } 43 | } 44 | 45 | parseMarkdown() { 46 | return { block: "paragraph" }; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /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 | group: "block", 12 | parseDOM: [{ tag: "hr" }], 13 | toDOM() { 14 | return ["hr"]; 15 | }, 16 | }; 17 | } 18 | 19 | commands({ type }) { 20 | return () => (state, dispatch) => { 21 | dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); 22 | return true; 23 | }; 24 | } 25 | 26 | keys({ type }) { 27 | return { 28 | "Mod-_": (state, dispatch) => { 29 | dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); 30 | return true; 31 | }, 32 | }; 33 | } 34 | 35 | inputRules({ type }) { 36 | return [ 37 | new InputRule(/^(?:---|___\s|\*\*\*\s)$/, (state, match, start, end) => { 38 | const { tr } = state; 39 | 40 | if (match[0]) { 41 | tr.replaceWith(start - 1, end, type.create({})); 42 | } 43 | 44 | return tr; 45 | }), 46 | ]; 47 | } 48 | 49 | toMarkdown(state, node) { 50 | state.write(node.attrs.markup || "\n---"); 51 | state.closeBlock(node); 52 | } 53 | 54 | parseMarkdown() { 55 | return { node: "hr" }; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/nodes/Embed.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Node from "./Node"; 3 | 4 | export default class Embed extends Node { 5 | get name() { 6 | return "embed"; 7 | } 8 | 9 | get schema() { 10 | return { 11 | content: "inline*", 12 | group: "block", 13 | atom: true, 14 | attrs: { 15 | href: {}, 16 | component: {}, 17 | matches: {}, 18 | }, 19 | parseDOM: [{ tag: "iframe" }], 20 | toDOM: node => [ 21 | "iframe", 22 | { src: node.attrs.href, contentEditable: false }, 23 | 0, 24 | ], 25 | }; 26 | } 27 | 28 | component({ isEditable, isSelected, theme, node }) { 29 | const Component = node.attrs.component; 30 | 31 | return ( 32 | 38 | ); 39 | } 40 | 41 | commands({ type }) { 42 | return attrs => (state, dispatch) => { 43 | dispatch( 44 | state.tr.replaceSelectionWith(type.create(attrs)).scrollIntoView() 45 | ); 46 | return true; 47 | }; 48 | } 49 | 50 | toMarkdown(state, node) { 51 | state.ensureNewLine(); 52 | state.write( 53 | "[" + state.esc(node.attrs.href) + "](" + state.esc(node.attrs.href) + ")" 54 | ); 55 | state.write("\n\n"); 56 | } 57 | 58 | parseMarkdown() { 59 | return { 60 | node: "embed", 61 | getAttrs: token => ({ 62 | href: token.attrGet("href"), 63 | matches: token.attrGet("matches"), 64 | component: token.attrGet("component"), 65 | }), 66 | }; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/lib/uploadPlaceholder.ts: -------------------------------------------------------------------------------- 1 | import { 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 && action.add) { 18 | const element = document.createElement("div"); 19 | element.className = "image placeholder"; 20 | 21 | const img = document.createElement("img"); 22 | img.src = URL.createObjectURL(action.add.file); 23 | 24 | element.appendChild(img); 25 | 26 | const deco = Decoration.widget(action.add.pos, element, { 27 | id: action.add.id, 28 | }); 29 | set = set.add(tr.doc, [deco]); 30 | } else if (action && action.remove) { 31 | set = set.remove( 32 | set.find(null, null, spec => spec.id === action.remove.id) 33 | ); 34 | } 35 | return set; 36 | }, 37 | }, 38 | props: { 39 | decorations(state) { 40 | return this.getState(state); 41 | }, 42 | }, 43 | }); 44 | 45 | export default uploadPlaceholder; 46 | 47 | export function findPlaceholder(state, id) { 48 | const decos = uploadPlaceholder.getState(state); 49 | const found = decos.find(null, null, spec => spec.id === id); 50 | return found.length ? found[0].from : null; 51 | } 52 | -------------------------------------------------------------------------------- /src/marks/Code.ts: -------------------------------------------------------------------------------- 1 | import { toggleMark } from "prosemirror-commands"; 2 | import markInputRule from "../lib/markInputRule"; 3 | import Mark from "./Mark"; 4 | 5 | function backticksFor(node, side) { 6 | const ticks = /`+/g; 7 | let match: RegExpMatchArray | null; 8 | let len = 0; 9 | 10 | if (node.isText) { 11 | while ((match = ticks.exec(node.text))) { 12 | len = Math.max(len, match[0].length); 13 | } 14 | } 15 | 16 | let result = len > 0 && side > 0 ? " `" : "`"; 17 | for (let i = 0; i < len; i++) { 18 | result += "`"; 19 | } 20 | if (len > 0 && side < 0) { 21 | result += " "; 22 | } 23 | return result; 24 | } 25 | 26 | export default class Code extends Mark { 27 | get name() { 28 | return "code_inline"; 29 | } 30 | 31 | get schema() { 32 | return { 33 | excludes: "_", 34 | parseDOM: [{ tag: "code" }], 35 | toDOM: () => ["code", { spellCheck: false }], 36 | }; 37 | } 38 | 39 | inputRules({ type }) { 40 | return [markInputRule(/(?:^|[^`])(`([^`]+)`)$/, type)]; 41 | } 42 | 43 | keys({ type }) { 44 | // Note: This key binding only works on non-Mac platforms 45 | // https://github.com/ProseMirror/prosemirror/issues/515 46 | return { 47 | "Mod`": toggleMark(type), 48 | }; 49 | } 50 | 51 | get toMarkdown() { 52 | return { 53 | open(_state, _mark, parent, index) { 54 | return backticksFor(parent.child(index), -1); 55 | }, 56 | close(_state, _mark, parent, index) { 57 | return backticksFor(parent.child(index - 1), 1); 58 | }, 59 | escape: false, 60 | }; 61 | } 62 | 63 | parseMarkdown() { 64 | return { mark: "code_inline" }; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/components/Menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { EditorView } from "prosemirror-view"; 3 | import { withTheme } from "styled-components"; 4 | import ToolbarButton from "./ToolbarButton"; 5 | import ToolbarSeparator from "./ToolbarSeparator"; 6 | import theme from "../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 | class Menu extends React.Component { 18 | render() { 19 | const { view, items } = this.props; 20 | const { state } = view; 21 | const Tooltip = this.props.tooltip; 22 | 23 | return ( 24 |
25 | {items.map((item, index) => { 26 | if (item.name === "separator" && item.visible !== false) { 27 | return ; 28 | } 29 | if (item.visible === false || !item.icon) { 30 | return null; 31 | } 32 | const Icon = item.icon; 33 | const isActive = item.active ? item.active(state) : false; 34 | 35 | return ( 36 | 39 | item.name && this.props.commands[item.name](item.attrs) 40 | } 41 | active={isActive} 42 | > 43 | 44 | 45 | 46 | 47 | ); 48 | })} 49 |
50 | ); 51 | } 52 | } 53 | 54 | export default withTheme(Menu); 55 | -------------------------------------------------------------------------------- /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/plugins/Keys.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, Selection, AllSelection } from "prosemirror-state"; 2 | import Extension from "../lib/Extension"; 3 | 4 | export default class Keys extends Extension { 5 | get name() { 6 | return "keys"; 7 | } 8 | 9 | get plugins() { 10 | return [ 11 | new Plugin({ 12 | props: { 13 | // we can't use the keys bindings for this as we want to preventDefault 14 | // on the original keyboard event when handled 15 | handleKeyDown: (view, event) => { 16 | if (view.state.selection instanceof AllSelection) { 17 | if (event.key === "ArrowUp") { 18 | const selection = Selection.atStart(view.state.doc); 19 | view.dispatch(view.state.tr.setSelection(selection)); 20 | return true; 21 | } 22 | if (event.key === "ArrowDown") { 23 | const selection = Selection.atEnd(view.state.doc); 24 | view.dispatch(view.state.tr.setSelection(selection)); 25 | return true; 26 | } 27 | } 28 | 29 | if (!event.metaKey) return false; 30 | if (event.key === "s") { 31 | event.preventDefault(); 32 | this.options.onSave(); 33 | return true; 34 | } 35 | 36 | if (event.key === "Enter") { 37 | event.preventDefault(); 38 | this.options.onSaveAndExit(); 39 | return true; 40 | } 41 | 42 | if (event.key === "Escape") { 43 | event.preventDefault(); 44 | this.options.onCancel(); 45 | return true; 46 | } 47 | 48 | return false; 49 | }, 50 | }, 51 | }), 52 | ]; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/lib/markdown/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/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 | em: "Italic", 21 | embedInvalidLink: "Sorry, that link won’t work for this embed type", 22 | findOrCreateDoc: "Find or create a doc…", 23 | h1: "Big heading", 24 | h2: "Medium heading", 25 | h3: "Small heading", 26 | heading: "Heading", 27 | hr: "Divider", 28 | image: "Image", 29 | imageUploadError: "Sorry, an error occurred uploading the image", 30 | info: "Info", 31 | infoNotice: "Info notice", 32 | link: "Link", 33 | linkCopied: "Link copied to clipboard", 34 | mark: "Highlight", 35 | newLineEmpty: "Type '/' to insert…", 36 | newLineWithSlash: "Keep typing to filter…", 37 | noResults: "No results", 38 | openLink: "Open link", 39 | orderedList: "Ordered list", 40 | pasteLink: "Paste a link…", 41 | pasteLinkWithTitle: (title: string) => `Paste a ${title} link…`, 42 | placeholder: "Placeholder", 43 | quote: "Quote", 44 | removeLink: "Remove link", 45 | searchOrPasteLink: "Search or paste a link…", 46 | strikethrough: "Strikethrough", 47 | strong: "Bold", 48 | subheading: "Subheading", 49 | table: "Table", 50 | tip: "Tip", 51 | tipNotice: "Tip notice", 52 | warning: "Warning", 53 | warningNotice: "Warning notice", 54 | }; 55 | 56 | export default base; 57 | -------------------------------------------------------------------------------- /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: HTMLElement) => ({ 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 | const start = node.attrs.order || 1; 59 | const maxW = `${start + node.childCount - 1}`.length; 60 | const space = state.repeat(" ", maxW + 2); 61 | 62 | state.renderList(node, space, i => { 63 | const nStr = `${start + i}`; 64 | return state.repeat(" ", maxW - nStr.length) + nStr + ". "; 65 | }); 66 | } 67 | 68 | parseMarkdown() { 69 | return { block: "ordered_list" }; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /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) => {} 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/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 | dictionary: typeof baseDictionary 18 | ): MenuItem[] { 19 | const { schema } = state; 20 | 21 | return [ 22 | { 23 | name: "setColumnAttr", 24 | tooltip: dictionary.alignLeft, 25 | icon: AlignLeftIcon, 26 | attrs: { index, alignment: "left" }, 27 | active: isNodeActive(schema.nodes.th, { 28 | colspan: 1, 29 | rowspan: 1, 30 | alignment: "left", 31 | }), 32 | }, 33 | { 34 | name: "setColumnAttr", 35 | tooltip: dictionary.alignCenter, 36 | icon: AlignCenterIcon, 37 | attrs: { index, alignment: "center" }, 38 | active: isNodeActive(schema.nodes.th, { 39 | colspan: 1, 40 | rowspan: 1, 41 | alignment: "center", 42 | }), 43 | }, 44 | { 45 | name: "setColumnAttr", 46 | tooltip: dictionary.alignRight, 47 | icon: AlignRightIcon, 48 | attrs: { index, alignment: "right" }, 49 | active: isNodeActive(schema.nodes.th, { 50 | colspan: 1, 51 | rowspan: 1, 52 | alignment: "right", 53 | }), 54 | }, 55 | { 56 | name: "separator", 57 | }, 58 | { 59 | name: "addColumnBefore", 60 | tooltip: dictionary.addColumnBefore, 61 | icon: InsertLeftIcon, 62 | active: () => false, 63 | }, 64 | { 65 | name: "addColumnAfter", 66 | tooltip: dictionary.addColumnAfter, 67 | icon: InsertRightIcon, 68 | active: () => false, 69 | }, 70 | { 71 | name: "separator", 72 | }, 73 | { 74 | name: "deleteColumn", 75 | tooltip: dictionary.deleteColumn, 76 | icon: TrashIcon, 77 | active: () => false, 78 | }, 79 | ]; 80 | } 81 | -------------------------------------------------------------------------------- /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/server.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "prosemirror-model"; 2 | import ExtensionManager from "./lib/ExtensionManager"; 3 | 4 | // nodes 5 | import Doc from "./nodes/Doc"; 6 | import Text from "./nodes/Text"; 7 | import Blockquote from "./nodes/Blockquote"; 8 | import BulletList from "./nodes/BulletList"; 9 | import CodeBlock from "./nodes/CodeBlock"; 10 | import CodeFence from "./nodes/CodeFence"; 11 | import CheckboxList from "./nodes/CheckboxList"; 12 | import CheckboxItem from "./nodes/CheckboxItem"; 13 | import Embed from "./nodes/Embed"; 14 | import HardBreak from "./nodes/HardBreak"; 15 | import Heading from "./nodes/Heading"; 16 | import HorizontalRule from "./nodes/HorizontalRule"; 17 | import Image from "./nodes/Image"; 18 | import ListItem from "./nodes/ListItem"; 19 | import Notice from "./nodes/Notice"; 20 | import OrderedList from "./nodes/OrderedList"; 21 | import Paragraph from "./nodes/Paragraph"; 22 | import Table from "./nodes/Table"; 23 | import TableCell from "./nodes/TableCell"; 24 | import TableHeadCell from "./nodes/TableHeadCell"; 25 | import TableRow from "./nodes/TableRow"; 26 | 27 | // marks 28 | import Bold from "./marks/Bold"; 29 | import Code from "./marks/Code"; 30 | import Highlight from "./marks/Highlight"; 31 | import Italic from "./marks/Italic"; 32 | import Link from "./marks/Link"; 33 | import Strikethrough from "./marks/Strikethrough"; 34 | import TemplatePlaceholder from "./marks/Placeholder"; 35 | import Underline from "./marks/Underline"; 36 | 37 | const extensions = new ExtensionManager([ 38 | new Doc(), 39 | new Text(), 40 | new HardBreak(), 41 | new Paragraph(), 42 | new Blockquote(), 43 | new BulletList(), 44 | new CodeBlock(), 45 | new CodeFence(), 46 | new CheckboxList(), 47 | new CheckboxItem(), 48 | new Embed(), 49 | new ListItem(), 50 | new Notice(), 51 | new Heading(), 52 | new HorizontalRule(), 53 | new Image(), 54 | new Table(), 55 | new TableCell(), 56 | new TableHeadCell(), 57 | new TableRow(), 58 | new Bold(), 59 | new Code(), 60 | new Highlight(), 61 | new Italic(), 62 | new Link(), 63 | new Strikethrough(), 64 | new TemplatePlaceholder(), 65 | new Underline(), 66 | new OrderedList(), 67 | ]); 68 | 69 | export const schema = new Schema({ 70 | nodes: extensions.nodes, 71 | marks: extensions.marks, 72 | }); 73 | 74 | export const parser = extensions.parser({ 75 | schema, 76 | }); 77 | 78 | export const serializer = extensions.serializer(); 79 | -------------------------------------------------------------------------------- /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/nodes/CheckboxItem.ts: -------------------------------------------------------------------------------- 1 | import { 2 | splitListItem, 3 | sinkListItem, 4 | liftListItem, 5 | } from "prosemirror-schema-list"; 6 | import Node from "./Node"; 7 | 8 | export default class CheckboxItem extends Node { 9 | get name() { 10 | return "checkbox_item"; 11 | } 12 | 13 | get schema() { 14 | return { 15 | attrs: { 16 | checked: { 17 | default: false, 18 | }, 19 | }, 20 | content: "paragraph block*", 21 | defining: true, 22 | draggable: false, 23 | parseDOM: [ 24 | { 25 | tag: `li[data-type="${this.name}"]`, 26 | getAttrs: dom => ({ 27 | checked: dom.getElementsByTagName("input")[0].checked 28 | ? true 29 | : false, 30 | }), 31 | }, 32 | ], 33 | toDOM: node => { 34 | const input = document.createElement("input"); 35 | input.type = "checkbox"; 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 | handleChange = event => { 62 | const { view } = this.editor; 63 | const { tr } = view.state; 64 | const { top, left } = event.target.getBoundingClientRect(); 65 | const result = view.posAtCoords({ top, left }); 66 | 67 | if (result) { 68 | const transaction = tr.setNodeMarkup(result.inside, undefined, { 69 | checked: event.target.checked, 70 | }); 71 | view.dispatch(transaction); 72 | } 73 | }; 74 | 75 | keys({ type }) { 76 | return { 77 | Enter: splitListItem(type), 78 | Tab: sinkListItem(type), 79 | "Shift-Tab": liftListItem(type), 80 | "Mod-]": sinkListItem(type), 81 | "Mod-[": liftListItem(type), 82 | }; 83 | } 84 | 85 | toMarkdown(state, node) { 86 | state.write(node.attrs.checked ? "[x] " : "[ ] "); 87 | state.renderContent(node); 88 | } 89 | 90 | parseMarkdown() { 91 | return { 92 | block: "checkbox_item", 93 | getAttrs: tok => ({ 94 | checked: tok.attrGet("checked") ? true : undefined, 95 | }), 96 | }; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /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 "../theme"; 5 | 6 | type Props = { 7 | selected: boolean; 8 | disabled?: boolean; 9 | onClick: () => void; 10 | theme: typeof theme; 11 | icon: typeof React.Component | React.FC; 12 | title: string; 13 | shortcut?: string; 14 | }; 15 | 16 | function BlockMenuItem({ 17 | selected, 18 | disabled, 19 | onClick, 20 | title, 21 | shortcut, 22 | icon, 23 | }: Props) { 24 | const Icon = icon; 25 | 26 | const ref = React.useCallback( 27 | node => { 28 | if (selected && node) { 29 | scrollIntoView(node, { 30 | scrollMode: "if-needed", 31 | block: "center", 32 | boundary: parent => { 33 | // All the parent elements of your target are checked until they 34 | // reach the #block-menu-container. Prevents body and other parent 35 | // elements from being scrolled 36 | return parent.id !== "block-menu-container"; 37 | }, 38 | }); 39 | } 40 | }, 41 | [selected] 42 | ); 43 | 44 | return ( 45 | 50 | 51 |   {title} 52 | {shortcut} 53 | 54 | ); 55 | } 56 | 57 | const MenuItem = styled.button<{ 58 | selected: boolean; 59 | }>` 60 | display: flex; 61 | align-items: center; 62 | justify-content: flex-start; 63 | font-weight: 500; 64 | font-size: 14px; 65 | line-height: 1; 66 | width: 100%; 67 | height: 36px; 68 | cursor: pointer; 69 | border: none; 70 | opacity: ${props => (props.disabled ? ".5" : "1")}; 71 | color: ${props => 72 | props.selected ? props.theme.black : props.theme.blockToolbarText}; 73 | background: ${props => 74 | props.selected ? props.theme.blockToolbarTrigger : "none"}; 75 | padding: 0 16px; 76 | outline: none; 77 | 78 | &:hover, 79 | &:active { 80 | color: ${props => props.theme.black}; 81 | background: ${props => 82 | props.selected 83 | ? props.theme.blockToolbarTrigger 84 | : props.theme.blockToolbarHoverBackground}; 85 | } 86 | `; 87 | 88 | const Shortcut = styled.span` 89 | color: ${props => props.theme.textSecondary}; 90 | flex-grow: 1; 91 | text-align: right; 92 | `; 93 | 94 | export default withTheme(BlockMenuItem); 95 | -------------------------------------------------------------------------------- /src/lib/markdown/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) { 7 | return token.content.match(CHECKBOX_REGEX); 8 | } 9 | 10 | function isInline(token: Token): boolean { 11 | return token.type === "inline"; 12 | } 13 | function isParagraph(token: Token): boolean { 14 | return token.type === "paragraph_open"; 15 | } 16 | 17 | function looksLikeChecklist(tokens: Token[], index: number) { 18 | return ( 19 | isInline(tokens[index]) && 20 | isParagraph(tokens[index - 1]) && 21 | matches(tokens[index]) 22 | ); 23 | } 24 | 25 | export default function markdownItCheckbox(md: MarkdownIt): void { 26 | // insert a new rule after the "inline" rules are parsed 27 | md.core.ruler.after("inline", "checkboxes", state => { 28 | const tokens = state.tokens; 29 | 30 | // work backwards through the tokens and find text that looks like a checkbox 31 | for (let i = tokens.length - 1; i > 0; i--) { 32 | const matches = looksLikeChecklist(tokens, i); 33 | if (matches) { 34 | const value = matches[1]; 35 | const checked = value.toLowerCase() === "x"; 36 | 37 | // convert surrounding list tokens 38 | if (tokens[i - 3].type === "bullet_list_open") { 39 | tokens[i - 3].type = "checkbox_list_open"; 40 | } 41 | 42 | if (tokens[i + 3].type === "bullet_list_close") { 43 | tokens[i + 3].type = "checkbox_list_close"; 44 | } 45 | 46 | // remove [ ] [x] from list item label – must use the content from the 47 | // child for escaped characters to be unescaped correctly. 48 | const tokenChildren = tokens[i].children; 49 | if (tokenChildren) { 50 | const contentMatches = tokenChildren[0].content.match(CHECKBOX_REGEX); 51 | 52 | if (contentMatches) { 53 | const label = contentMatches[2]; 54 | 55 | tokens[i].content = label; 56 | tokenChildren[0].content = label; 57 | } 58 | } 59 | 60 | // open list item and ensure checked state is transferred 61 | tokens[i - 2].type = "checkbox_item_open"; 62 | if (checked === true) { 63 | tokens[i - 2].attrs = [["checked", "true"]]; 64 | } 65 | 66 | // close the list item 67 | let j = i; 68 | while (tokens[j].type !== "list_item_close") { 69 | j++; 70 | } 71 | tokens[j].type = "checkbox_item_close"; 72 | } 73 | } 74 | 75 | return false; 76 | }); 77 | } 78 | -------------------------------------------------------------------------------- /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(state, node) { 35 | state.renderContent(node); 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 | this.options.onSelectColumn(index, state); 73 | }); 74 | return grip; 75 | }) 76 | ); 77 | }); 78 | } 79 | 80 | return DecorationSet.create(doc, decorations); 81 | }, 82 | }, 83 | }), 84 | ]; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /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 "../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/markdown/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) { 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/lib/markdown/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 | token.attrSet("component", result.component); 74 | token.attrSet("matches", result.matches); 75 | 76 | // delete the inline link – this makes the assumption that the 77 | // embed is the only thing in the para. 78 | // TODO: double check this 79 | tokens.splice(i - 1, 3, token); 80 | break; 81 | } 82 | } 83 | } 84 | } 85 | } 86 | 87 | return false; 88 | }); 89 | }; 90 | } 91 | -------------------------------------------------------------------------------- /src/commands/insertFiles.ts: -------------------------------------------------------------------------------- 1 | import uploadPlaceholderPlugin, { 2 | findPlaceholder, 3 | } from "../lib/uploadPlaceholder"; 4 | import { ToastType } from "../types"; 5 | 6 | const insertFiles = function(view, event, pos, files, options) { 7 | // filter to only include image files 8 | const images = files.filter(file => /image/i.test(file.type)); 9 | if (images.length === 0) return; 10 | 11 | const { 12 | dictionary, 13 | uploadImage, 14 | onImageUploadStart, 15 | onImageUploadStop, 16 | onShowToast, 17 | } = options; 18 | 19 | if (!uploadImage) { 20 | console.warn( 21 | "uploadImage callback must be defined to handle image uploads." 22 | ); 23 | return; 24 | } 25 | 26 | // okay, we have some dropped images and a handler – lets stop this 27 | // event going any further up the stack 28 | event.preventDefault(); 29 | 30 | // let the user know we're starting to process the images 31 | if (onImageUploadStart) onImageUploadStart(); 32 | 33 | const { schema } = view.state; 34 | 35 | // we'll use this to track of how many images have succeeded or failed 36 | let complete = 0; 37 | 38 | // the user might have dropped multiple images at once, we need to loop 39 | for (const file of images) { 40 | // Use an object to act as the ID for this upload, clever. 41 | const id = {}; 42 | 43 | const { tr } = view.state; 44 | 45 | // insert a placeholder at this position 46 | tr.setMeta(uploadPlaceholderPlugin, { 47 | add: { id, file, pos }, 48 | }); 49 | view.dispatch(tr); 50 | 51 | // start uploading the image file to the server. Using "then" syntax 52 | // to allow all placeholders to be entered at once with the uploads 53 | // happening in the background in parallel. 54 | uploadImage(file) 55 | .then(src => { 56 | const pos = findPlaceholder(view.state, id); 57 | 58 | // if the content around the placeholder has been deleted 59 | // then forget about inserting this image 60 | if (pos === null) return; 61 | 62 | // otherwise, insert it at the placeholder's position, and remove 63 | // the placeholder itself 64 | const transaction = view.state.tr 65 | .replaceWith(pos, pos, schema.nodes.image.create({ src })) 66 | .setMeta(uploadPlaceholderPlugin, { remove: { id } }); 67 | 68 | view.dispatch(transaction); 69 | }) 70 | .catch(error => { 71 | console.error(error); 72 | 73 | // cleanup the placeholder if there is a failure 74 | const transaction = view.state.tr.setMeta(uploadPlaceholderPlugin, { 75 | remove: { id }, 76 | }); 77 | view.dispatch(transaction); 78 | 79 | // let the user know 80 | if (onShowToast) { 81 | onShowToast(dictionary.imageUploadError, ToastType.Error); 82 | } 83 | }) 84 | // eslint-disable-next-line no-loop-func 85 | .finally(() => { 86 | complete++; 87 | 88 | // once everything is done, let the user know 89 | if (complete === images.length) { 90 | if (onImageUploadStop) onImageUploadStop(); 91 | } 92 | }); 93 | } 94 | }; 95 | 96 | export default insertFiles; 97 | -------------------------------------------------------------------------------- /src/menus/formatting.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BoldIcon, 3 | CodeIcon, 4 | Heading1Icon, 5 | Heading2Icon, 6 | ItalicIcon, 7 | BlockQuoteIcon, 8 | LinkIcon, 9 | StrikethroughIcon, 10 | InputIcon, 11 | HighlightIcon, 12 | } from "outline-icons"; 13 | import { isInTable } from "prosemirror-tables"; 14 | import { EditorState } from "prosemirror-state"; 15 | import isInList from "../queries/isInList"; 16 | import isMarkActive from "../queries/isMarkActive"; 17 | import isNodeActive from "../queries/isNodeActive"; 18 | import { MenuItem } from "../types"; 19 | import baseDictionary from "../dictionary"; 20 | 21 | export default function formattingMenuItems( 22 | state: EditorState, 23 | isTemplate: boolean, 24 | dictionary: typeof baseDictionary 25 | ): MenuItem[] { 26 | const { schema } = state; 27 | const isTable = isInTable(state); 28 | const isList = isInList(state); 29 | const allowBlocks = !isTable && !isList; 30 | 31 | return [ 32 | { 33 | name: "placeholder", 34 | tooltip: dictionary.placeholder, 35 | icon: InputIcon, 36 | active: isMarkActive(schema.marks.placeholder), 37 | visible: isTemplate, 38 | }, 39 | { 40 | name: "separator", 41 | visible: isTemplate, 42 | }, 43 | { 44 | name: "strong", 45 | tooltip: dictionary.strong, 46 | icon: BoldIcon, 47 | active: isMarkActive(schema.marks.strong), 48 | }, 49 | { 50 | name: "em", 51 | tooltip: dictionary.em, 52 | icon: ItalicIcon, 53 | active: isMarkActive(schema.marks.em), 54 | }, 55 | { 56 | name: "strikethrough", 57 | tooltip: dictionary.strikethrough, 58 | icon: StrikethroughIcon, 59 | active: isMarkActive(schema.marks.strikethrough), 60 | }, 61 | { 62 | name: "mark", 63 | tooltip: dictionary.mark, 64 | icon: HighlightIcon, 65 | active: isMarkActive(schema.marks.mark), 66 | visible: !isTemplate, 67 | }, 68 | { 69 | name: "code_inline", 70 | tooltip: dictionary.codeInline, 71 | icon: CodeIcon, 72 | active: isMarkActive(schema.marks.code_inline), 73 | }, 74 | { 75 | name: "separator", 76 | visible: allowBlocks, 77 | }, 78 | { 79 | name: "heading", 80 | tooltip: dictionary.heading, 81 | icon: Heading1Icon, 82 | active: isNodeActive(schema.nodes.heading, { level: 1 }), 83 | attrs: { level: 1 }, 84 | visible: allowBlocks, 85 | }, 86 | { 87 | name: "heading", 88 | tooltip: dictionary.subheading, 89 | icon: Heading2Icon, 90 | active: isNodeActive(schema.nodes.heading, { level: 2 }), 91 | attrs: { level: 2 }, 92 | visible: allowBlocks, 93 | }, 94 | { 95 | name: "blockquote", 96 | tooltip: dictionary.quote, 97 | icon: BlockQuoteIcon, 98 | active: isNodeActive(schema.nodes.blockquote), 99 | attrs: { level: 2 }, 100 | visible: allowBlocks, 101 | }, 102 | { 103 | name: "separator", 104 | }, 105 | { 106 | name: "link", 107 | tooltip: dictionary.createLink, 108 | icon: LinkIcon, 109 | active: isMarkActive(schema.marks.link), 110 | attrs: { href: "" }, 111 | }, 112 | ]; 113 | } 114 | -------------------------------------------------------------------------------- /src/plugins/MarkdownPaste.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "prosemirror-state"; 2 | import { toggleMark } from "prosemirror-commands"; 3 | import Extension from "../lib/Extension"; 4 | import isUrl from "../lib/isUrl"; 5 | import isInCode from "../queries/isInCode"; 6 | 7 | export default class MarkdownPaste extends Extension { 8 | get name() { 9 | return "markdown-paste"; 10 | } 11 | 12 | get plugins() { 13 | return [ 14 | new Plugin({ 15 | props: { 16 | handlePaste: (view, event: ClipboardEvent) => { 17 | if (view.props.editable && !view.props.editable(view.state)) { 18 | return false; 19 | } 20 | if (!event.clipboardData) return false; 21 | 22 | const text = event.clipboardData.getData("text/plain"); 23 | const html = event.clipboardData.getData("text/html"); 24 | const { state, dispatch } = view; 25 | 26 | // first check if the clipboard contents can be parsed as a url 27 | if (isUrl(text)) { 28 | // just paste the link mark directly onto the selected text 29 | if (!state.selection.empty) { 30 | toggleMark(this.editor.schema.marks.link, { href: text })( 31 | state, 32 | dispatch 33 | ); 34 | return true; 35 | } 36 | 37 | // Is this link embeddable? Create an embed! 38 | const { embeds } = this.editor.props; 39 | 40 | if (embeds) { 41 | for (const embed of embeds) { 42 | const matches = embed.matcher(text); 43 | if (matches) { 44 | this.editor.commands.embed({ 45 | href: text, 46 | component: embed.component, 47 | matches, 48 | }); 49 | return true; 50 | } 51 | } 52 | } 53 | 54 | // well, it's not an embed and there is no text selected – so just 55 | // go ahead and insert the link directly 56 | const transaction = view.state.tr 57 | .insertText(text, state.selection.from, state.selection.to) 58 | .addMark( 59 | state.selection.from, 60 | state.selection.to + text.length, 61 | state.schema.marks.link.create({ href: text }) 62 | ); 63 | view.dispatch(transaction); 64 | return true; 65 | } 66 | 67 | // otherwise, if we have html on the clipboard then fallback to the 68 | // default HTML parser behavior that comes with Prosemirror. 69 | if (text.length === 0 || html) return false; 70 | 71 | event.preventDefault(); 72 | 73 | if (isInCode(view.state)) { 74 | view.dispatch(view.state.tr.insertText(text)); 75 | return true; 76 | } 77 | 78 | const paste = this.editor.parser.parse(text); 79 | const slice = paste.slice(0); 80 | 81 | const transaction = view.state.tr.replaceSelection(slice); 82 | view.dispatch(transaction); 83 | return true; 84 | }, 85 | }, 86 | }), 87 | ]; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /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 | 8 | export default class Notice extends Node { 9 | get styleOptions() { 10 | return Object.entries({ 11 | info: this.options.dictionary.info, 12 | warning: this.options.dictionary.warning, 13 | tip: this.options.dictionary.tip, 14 | }); 15 | } 16 | 17 | get name() { 18 | return "container_notice"; 19 | } 20 | 21 | get schema() { 22 | return { 23 | attrs: { 24 | style: { 25 | default: "info", 26 | }, 27 | }, 28 | content: "block+", 29 | group: "block", 30 | defining: true, 31 | draggable: true, 32 | parseDOM: [{ tag: "div.notice-block", preserveWhitespace: "full" }], 33 | toDOM: node => { 34 | const select = document.createElement("select"); 35 | select.addEventListener("change", this.handleStyleChange); 36 | 37 | this.styleOptions.forEach(([key, label]) => { 38 | const option = document.createElement("option"); 39 | option.value = key; 40 | option.innerText = label; 41 | option.selected = node.attrs.style === key; 42 | select.appendChild(option); 43 | }); 44 | 45 | let component; 46 | 47 | if (node.attrs.style === "tip") { 48 | component = ; 49 | } else if (node.attrs.style === "warning") { 50 | component = ; 51 | } else { 52 | component = ; 53 | } 54 | 55 | const icon = document.createElement("div"); 56 | icon.className = "icon"; 57 | ReactDOM.render(component, icon); 58 | 59 | return [ 60 | "div", 61 | { class: `notice-block ${node.attrs.style}` }, 62 | icon, 63 | ["div", { contentEditable: false }, select], 64 | ["div", 0], 65 | ]; 66 | }, 67 | }; 68 | } 69 | 70 | commands({ type }) { 71 | return attrs => toggleWrap(type, attrs); 72 | } 73 | 74 | handleStyleChange = event => { 75 | const { view } = this.editor; 76 | const { tr } = view.state; 77 | const element = event.target; 78 | const { top, left } = element.getBoundingClientRect(); 79 | const result = view.posAtCoords({ top, left }); 80 | 81 | if (result) { 82 | const transaction = tr.setNodeMarkup(result.inside, undefined, { 83 | style: element.value, 84 | }); 85 | view.dispatch(transaction); 86 | } 87 | }; 88 | 89 | inputRules({ type }) { 90 | return [wrappingInputRule(/^:::$/, type)]; 91 | } 92 | 93 | toMarkdown(state, node) { 94 | state.write("\n:::" + (node.attrs.style || "info") + "\n"); 95 | state.renderContent(node); 96 | state.ensureNewLine(); 97 | state.write(":::"); 98 | state.closeBlock(node); 99 | } 100 | 101 | parseMarkdown() { 102 | return { 103 | block: "container_notice", 104 | getAttrs: tok => ({ style: tok.info }), 105 | }; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | const colors = { 2 | almostBlack: "#181A1B", 3 | lightBlack: "#2F3336", 4 | almostWhite: "#E6E6E6", 5 | white: "#FFF", 6 | white10: "rgba(255, 255, 255, 0.1)", 7 | black: "#000", 8 | black10: "rgba(0, 0, 0, 0.1)", 9 | primary: "#1AB6FF", 10 | greyLight: "#F4F7FA", 11 | grey: "#E8EBED", 12 | greyMid: "#C5CCD3", 13 | greyDark: "#DAE1E9", 14 | }; 15 | 16 | export const base = { 17 | ...colors, 18 | fontFamily: 19 | "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen, Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif", 20 | fontFamilyMono: 21 | "'SFMono-Regular',Consolas,'Liberation Mono', Menlo, Courier,monospace", 22 | fontWeight: 400, 23 | zIndex: 100, 24 | link: colors.primary, 25 | placeholder: "#B1BECC", 26 | textSecondary: "#4E5C6E", 27 | textLight: colors.white, 28 | textHighlight: "#b3e7ff", 29 | selected: colors.primary, 30 | codeComment: "#6a737d", 31 | codePunctuation: "#5e6687", 32 | codeNumber: "#d73a49", 33 | codeProperty: "#c08b30", 34 | codeTag: "#3d8fd1", 35 | codeString: "#032f62", 36 | codeSelector: "#6679cc", 37 | codeAttr: "#c76b29", 38 | codeEntity: "#22a2c9", 39 | codeKeyword: "#d73a49", 40 | codeFunction: "#6f42c1", 41 | codeStatement: "#22a2c9", 42 | codePlaceholder: "#3d8fd1", 43 | codeInserted: "#202746", 44 | codeImportant: "#c94922", 45 | 46 | blockToolbarBackground: colors.white, 47 | blockToolbarTrigger: colors.greyMid, 48 | blockToolbarTriggerIcon: colors.white, 49 | blockToolbarItem: colors.almostBlack, 50 | blockToolbarText: colors.almostBlack, 51 | blockToolbarHoverBackground: colors.greyLight, 52 | blockToolbarDivider: colors.greyMid, 53 | 54 | noticeInfoBackground: "#F5BE31", 55 | noticeInfoText: colors.almostBlack, 56 | noticeTipBackground: "#9E5CF7", 57 | noticeTipText: colors.white, 58 | noticeWarningBackground: "#FF5C80", 59 | noticeWarningText: colors.white, 60 | }; 61 | 62 | export const light = { 63 | ...base, 64 | background: colors.white, 65 | text: colors.almostBlack, 66 | code: colors.lightBlack, 67 | cursor: colors.black, 68 | divider: colors.greyMid, 69 | 70 | toolbarBackground: colors.lightBlack, 71 | toolbarHoverBackground: colors.black, 72 | toolbarInput: colors.white10, 73 | toolbarItem: colors.white, 74 | 75 | tableDivider: colors.greyMid, 76 | tableSelected: colors.primary, 77 | tableSelectedBackground: "#E5F7FF", 78 | 79 | quote: colors.greyDark, 80 | codeBackground: colors.greyLight, 81 | codeBorder: colors.grey, 82 | horizontalRule: colors.greyMid, 83 | imageErrorBackground: colors.greyLight, 84 | }; 85 | 86 | export const dark = { 87 | ...base, 88 | background: colors.almostBlack, 89 | text: colors.almostWhite, 90 | code: colors.almostWhite, 91 | cursor: colors.white, 92 | divider: "#4E5C6E", 93 | placeholder: "#52657A", 94 | 95 | toolbarBackground: colors.white, 96 | toolbarHoverBackground: colors.greyMid, 97 | toolbarInput: colors.black10, 98 | toolbarItem: colors.lightBlack, 99 | 100 | tableDivider: colors.lightBlack, 101 | tableSelected: colors.primary, 102 | tableSelectedBackground: "#002333", 103 | 104 | quote: colors.greyDark, 105 | codeBackground: colors.black, 106 | codeBorder: colors.lightBlack, 107 | codeString: "#3d8fd1", 108 | horizontalRule: colors.lightBlack, 109 | imageErrorBackground: "rgba(0, 0, 0, 0.5)", 110 | }; 111 | 112 | export default light; 113 | -------------------------------------------------------------------------------- /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(state, node) { 39 | state.renderContent(node); 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 | this.options.onSelectTable(state); 73 | }); 74 | return grip; 75 | }) 76 | ); 77 | } 78 | decorations.push( 79 | Decoration.widget(pos + 1, () => { 80 | const rowSelected = isRowSelected(index)(selection); 81 | 82 | let className = "grip-row"; 83 | if (rowSelected) { 84 | className += " selected"; 85 | } 86 | if (index === 0) { 87 | className += " first"; 88 | } else if (index === cells.length - 1) { 89 | className += " last"; 90 | } 91 | const grip = document.createElement("a"); 92 | grip.className = className; 93 | grip.addEventListener("mousedown", event => { 94 | event.preventDefault(); 95 | this.options.onSelectRow(index, state); 96 | }); 97 | return grip; 98 | }) 99 | ); 100 | }); 101 | } 102 | 103 | return DecorationSet.create(doc, decorations); 104 | }, 105 | }, 106 | }), 107 | ]; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/plugins/Prism.ts: -------------------------------------------------------------------------------- 1 | import refractor from "refractor/core"; 2 | import flattenDeep from "lodash/flattenDeep"; 3 | import { Plugin, PluginKey } from "prosemirror-state"; 4 | import { Decoration, DecorationSet } from "prosemirror-view"; 5 | import { findBlockNodes } from "prosemirror-utils"; 6 | 7 | export const LANGUAGES = { 8 | none: "None", // additional entry to disable highlighting 9 | bash: "Bash", 10 | css: "CSS", 11 | clike: "C", 12 | csharp: "C#", 13 | markup: "HTML", 14 | java: "Java", 15 | javascript: "JavaScript", 16 | json: "JSON", 17 | php: "PHP", 18 | powershell: "Powershell", 19 | python: "Python", 20 | ruby: "Ruby", 21 | typescript: "TypeScript", 22 | }; 23 | 24 | type ParsedNode = { 25 | text: string; 26 | classes: string[]; 27 | }; 28 | 29 | function getDecorations({ doc, name }) { 30 | const decorations: Decoration[] = []; 31 | const blocks = findBlockNodes(doc).filter( 32 | item => item.node.type.name === name 33 | ); 34 | 35 | function parseNodes( 36 | nodes: refractor.RefractorNode[], 37 | classNames: string[] = [] 38 | ): any { 39 | return nodes.map(node => { 40 | if (node.type === "element") { 41 | const classes = [...classNames, ...(node.properties.className || [])]; 42 | return parseNodes(node.children, classes); 43 | } 44 | 45 | return { 46 | text: node.value, 47 | classes: classNames, 48 | }; 49 | }); 50 | } 51 | 52 | blocks.forEach(block => { 53 | let startPos = block.pos + 1; 54 | const language = block.node.attrs.language; 55 | if ( 56 | !language || 57 | language === "none" || 58 | !Object.keys(LANGUAGES).includes(language) 59 | ) { 60 | return; 61 | } 62 | 63 | const nodes = refractor.highlight(block.node.textContent, language); 64 | 65 | flattenDeep(parseNodes(nodes)) 66 | .map((node: ParsedNode) => { 67 | const from = startPos; 68 | const to = from + node.text.length; 69 | 70 | startPos = to; 71 | 72 | return { 73 | ...node, 74 | from, 75 | to, 76 | }; 77 | }) 78 | .forEach(node => { 79 | const decoration = Decoration.inline(node.from, node.to, { 80 | class: (node.classes || []).join(" "), 81 | }); 82 | decorations.push(decoration); 83 | }); 84 | }); 85 | 86 | return DecorationSet.create(doc, decorations); 87 | } 88 | 89 | export default function Prism({ name, deferred = true }) { 90 | return new Plugin({ 91 | key: new PluginKey("prism"), 92 | state: { 93 | init: (_, { doc }) => { 94 | if (deferred) return; 95 | 96 | return getDecorations({ doc, name }); 97 | }, 98 | apply: (transaction, decorationSet, oldState, state) => { 99 | // TODO: find way to cache decorations 100 | // see: https://discuss.prosemirror.net/t/how-to-update-multiple-inline-decorations-on-node-change/1493 101 | 102 | const deferredInit = !decorationSet; 103 | const nodeName = state.selection.$head.parent.type.name; 104 | const previousNodeName = oldState.selection.$head.parent.type.name; 105 | const codeBlockChanged = 106 | transaction.docChanged && [nodeName, previousNodeName].includes(name); 107 | 108 | if (deferredInit || codeBlockChanged) { 109 | return getDecorations({ doc: transaction.doc, name }); 110 | } 111 | 112 | return decorationSet.map(transaction.mapping, transaction.doc); 113 | }, 114 | }, 115 | props: { 116 | decorations(state) { 117 | return this.getState(state); 118 | }, 119 | }, 120 | }); 121 | } 122 | -------------------------------------------------------------------------------- /src/menus/block.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BlockQuoteIcon, 3 | BulletedListIcon, 4 | CodeIcon, 5 | Heading1Icon, 6 | Heading2Icon, 7 | Heading3Icon, 8 | HorizontalRuleIcon, 9 | OrderedListIcon, 10 | TableIcon, 11 | TodoListIcon, 12 | ImageIcon, 13 | StarredIcon, 14 | WarningIcon, 15 | InfoIcon, 16 | LinkIcon, 17 | } from "outline-icons"; 18 | import { MenuItem } from "../types"; 19 | import baseDictionary from "../dictionary"; 20 | 21 | const SSR = typeof window === "undefined"; 22 | const isMac = !SSR && window.navigator.platform === "MacIntel"; 23 | const mod = isMac ? "⌘" : "ctrl"; 24 | 25 | export default function blockMenuItems( 26 | dictionary: typeof baseDictionary 27 | ): MenuItem[] { 28 | return [ 29 | { 30 | name: "heading", 31 | title: dictionary.h1, 32 | keywords: "h1 heading1 title", 33 | icon: Heading1Icon, 34 | shortcut: "^ ⇧ 1", 35 | attrs: { level: 1 }, 36 | }, 37 | { 38 | name: "heading", 39 | title: dictionary.h2, 40 | keywords: "h2 heading2", 41 | icon: Heading2Icon, 42 | shortcut: "^ ⇧ 2", 43 | attrs: { level: 2 }, 44 | }, 45 | { 46 | name: "heading", 47 | title: dictionary.h3, 48 | keywords: "h3 heading3", 49 | icon: Heading3Icon, 50 | shortcut: "^ ⇧ 3", 51 | attrs: { level: 3 }, 52 | }, 53 | { 54 | name: "separator", 55 | }, 56 | { 57 | name: "checkbox_list", 58 | title: dictionary.checkboxList, 59 | icon: TodoListIcon, 60 | keywords: "checklist checkbox task", 61 | shortcut: "^ ⇧ 7", 62 | }, 63 | { 64 | name: "bullet_list", 65 | title: dictionary.bulletList, 66 | icon: BulletedListIcon, 67 | shortcut: "^ ⇧ 8", 68 | }, 69 | { 70 | name: "ordered_list", 71 | title: dictionary.orderedList, 72 | icon: OrderedListIcon, 73 | shortcut: "^ ⇧ 9", 74 | }, 75 | { 76 | name: "separator", 77 | }, 78 | { 79 | name: "table", 80 | title: dictionary.table, 81 | icon: TableIcon, 82 | attrs: { rowsCount: 3, colsCount: 3 }, 83 | }, 84 | { 85 | name: "blockquote", 86 | title: dictionary.quote, 87 | icon: BlockQuoteIcon, 88 | shortcut: `${mod} ]`, 89 | }, 90 | { 91 | name: "code_block", 92 | title: dictionary.codeBlock, 93 | icon: CodeIcon, 94 | shortcut: "^ ⇧ \\", 95 | keywords: "script", 96 | }, 97 | { 98 | name: "hr", 99 | title: dictionary.hr, 100 | icon: HorizontalRuleIcon, 101 | shortcut: `${mod} _`, 102 | keywords: "horizontal rule break line", 103 | }, 104 | { 105 | name: "image", 106 | title: dictionary.image, 107 | icon: ImageIcon, 108 | keywords: "picture photo", 109 | }, 110 | { 111 | name: "link", 112 | title: dictionary.link, 113 | icon: LinkIcon, 114 | shortcut: `${mod} k`, 115 | keywords: "link url uri href", 116 | }, 117 | { 118 | name: "separator", 119 | }, 120 | { 121 | name: "container_notice", 122 | title: dictionary.infoNotice, 123 | icon: InfoIcon, 124 | keywords: "container_notice card information", 125 | attrs: { style: "info" }, 126 | }, 127 | { 128 | name: "container_notice", 129 | title: dictionary.warningNotice, 130 | icon: WarningIcon, 131 | keywords: "container_notice card error", 132 | attrs: { style: "warning" }, 133 | }, 134 | { 135 | name: "container_notice", 136 | title: dictionary.tipNotice, 137 | icon: StarredIcon, 138 | keywords: "container_notice card suggestion", 139 | attrs: { style: "tip" }, 140 | }, 141 | ]; 142 | } 143 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rich-markdown-editor", 3 | "description": "A rich text editor with Markdown shortcuts", 4 | "version": "11.0.2", 5 | "main": "dist/index.js", 6 | "typings": "dist/index.d.ts", 7 | "license": "BSD-3-Clause", 8 | "scripts": { 9 | "test": "echo \"Error: no test specified\" && exit 1", 10 | "lint": "tsc --noEmit && eslint '*/**/*.{js,ts,tsx}' --quiet --fix", 11 | "start": "webpack-serve --config example/webpack.config.js", 12 | "build": "tsc", 13 | "prepublish": "yarn build" 14 | }, 15 | "serve": { 16 | "open": true, 17 | "static": "example/dist" 18 | }, 19 | "dependencies": { 20 | "copy-to-clipboard": "^3.0.8", 21 | "lodash": "^4.17.11", 22 | "markdown-it-container": "^3.0.0", 23 | "outline-icons": "^1.21.0-3", 24 | "prismjs": "^1.19.0", 25 | "prosemirror-commands": "^1.1.4", 26 | "prosemirror-dropcursor": "^1.3.2", 27 | "prosemirror-gapcursor": "^1.1.5", 28 | "prosemirror-history": "^1.1.3", 29 | "prosemirror-inputrules": "^1.1.3", 30 | "prosemirror-keymap": "^1.1.4", 31 | "prosemirror-markdown": "^1.4.4", 32 | "prosemirror-model": "^1.11.2", 33 | "prosemirror-schema-list": "^1.1.2", 34 | "prosemirror-state": "^1.3.3", 35 | "prosemirror-tables": "^1.1.1", 36 | "prosemirror-transform": "1.2.5", 37 | "prosemirror-utils": "^0.9.6", 38 | "prosemirror-view": "^1.15.7", 39 | "react-medium-image-zoom": "^3.0.16", 40 | "react-portal": "^4.2.1", 41 | "refractor": "^3.1.0", 42 | "resize-observer-polyfill": "^1.5.1", 43 | "slugify": "^1.4.0", 44 | "smooth-scroll-into-view-if-needed": "^1.1.27", 45 | "typescript": "3.7.5" 46 | }, 47 | "peerDependencies": { 48 | "react": "^16.8.6", 49 | "react-dom": "^16.8.6", 50 | "styled-components": "^5.1.0" 51 | }, 52 | "devDependencies": { 53 | "@types/lodash": "^4.14.149", 54 | "@types/markdown-it": "^10.0.1", 55 | "@types/prosemirror-commands": "^1.0.1", 56 | "@types/prosemirror-dropcursor": "^1.0.0", 57 | "@types/prosemirror-gapcursor": "^1.0.1", 58 | "@types/prosemirror-history": "^1.0.1", 59 | "@types/prosemirror-inputrules": "^1.0.2", 60 | "@types/prosemirror-keymap": "^1.0.1", 61 | "@types/prosemirror-markdown": "^1.0.3", 62 | "@types/prosemirror-model": "^1.7.2", 63 | "@types/prosemirror-schema-list": "^1.0.1", 64 | "@types/prosemirror-state": "^1.2.4", 65 | "@types/prosemirror-view": "^1.11.4", 66 | "@types/react": "^16.9.19", 67 | "@types/react-dom": "^16.9.5", 68 | "@types/refractor": "^2.8.0", 69 | "@types/styled-components": "^4.4.2", 70 | "@typescript-eslint/eslint-plugin": "^2.17.0", 71 | "@typescript-eslint/parser": "^2.17.0", 72 | "eslint": "^6.8.0", 73 | "eslint-config-prettier": "^6.9.0", 74 | "eslint-config-react-app": "^2.1.0", 75 | "eslint-plugin-import": "^2.9.0", 76 | "eslint-plugin-jsx-a11y": "^6.0.3", 77 | "eslint-plugin-prettier": "^3.1.2", 78 | "eslint-plugin-react": "^7.7.0", 79 | "prettier": "^1.19.1", 80 | "react": "^16.8.6", 81 | "react-dom": "^16.8.6", 82 | "source-map-loader": "^0.2.4", 83 | "styled-components": "^5.1.0", 84 | "ts-loader": "^6.2.1", 85 | "webpack": "^4.29.6", 86 | "webpack-cli": "^3.2.3", 87 | "webpack-serve": "^3.2.0" 88 | }, 89 | "resolutions": { 90 | "yargs-parser": "^15.0.1" 91 | }, 92 | "repository": { 93 | "type": "git", 94 | "url": "git+https://github.com/outline/rich-markdown-editor.git" 95 | }, 96 | "keywords": [ 97 | "editor", 98 | "markdown", 99 | "text", 100 | "wysiwyg" 101 | ], 102 | "author": "Tom Moor ", 103 | "bugs": { 104 | "url": "https://github.com/outline/rich-markdown-editor/issues" 105 | }, 106 | "homepage": "https://github.com/outline/rich-markdown-editor#readme" 107 | } 108 | -------------------------------------------------------------------------------- /src/components/LinkToolbar.tsx: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import * as React from "react"; 3 | import { EditorView } from "prosemirror-view"; 4 | import LinkEditor, { SearchResult } from "./LinkEditor"; 5 | import FloatingToolbar from "./FloatingToolbar"; 6 | import createAndInsertLink from "../commands/createAndInsertLink"; 7 | import baseDictionary from "../dictionary"; 8 | 9 | type Props = { 10 | isActive: boolean; 11 | view: EditorView; 12 | tooltip: typeof React.Component | React.FC; 13 | dictionary: typeof baseDictionary; 14 | onCreateLink?: (title: string) => Promise; 15 | onSearchLink?: (term: string) => Promise; 16 | onClickLink: (href: string, event: MouseEvent) => void; 17 | onShowToast?: (msg: string, code: string) => void; 18 | onClose: () => void; 19 | }; 20 | 21 | function isActive(props) { 22 | const { view } = props; 23 | const { selection } = view.state; 24 | 25 | const paragraph = view.domAtPos(selection.$from.pos); 26 | return props.isActive && !!paragraph.node; 27 | } 28 | 29 | export default class LinkToolbar extends React.Component { 30 | menuRef = React.createRef(); 31 | 32 | state = { 33 | left: -1000, 34 | top: undefined, 35 | }; 36 | 37 | componentDidMount() { 38 | window.addEventListener("mousedown", this.handleClickOutside); 39 | } 40 | 41 | componentWillUnmount() { 42 | window.removeEventListener("mousedown", this.handleClickOutside); 43 | } 44 | 45 | handleClickOutside = ev => { 46 | if ( 47 | ev.target && 48 | this.menuRef.current && 49 | this.menuRef.current.contains(ev.target) 50 | ) { 51 | return; 52 | } 53 | 54 | this.props.onClose(); 55 | }; 56 | 57 | handleOnCreateLink = async (title: string) => { 58 | const { dictionary, onCreateLink, view, onClose, onShowToast } = this.props; 59 | 60 | onClose(); 61 | this.props.view.focus(); 62 | 63 | if (!onCreateLink) { 64 | return; 65 | } 66 | 67 | const { dispatch, state } = view; 68 | const { from, to } = state.selection; 69 | assert(from === to); 70 | 71 | const href = `creating#${title}…`; 72 | 73 | // Insert a placeholder link 74 | dispatch( 75 | view.state.tr 76 | .insertText(title, from, to) 77 | .addMark( 78 | from, 79 | to + title.length, 80 | state.schema.marks.link.create({ href }) 81 | ) 82 | ); 83 | 84 | createAndInsertLink(view, title, href, { 85 | onCreateLink, 86 | onShowToast, 87 | dictionary, 88 | }); 89 | }; 90 | 91 | handleOnSelectLink = ({ 92 | href, 93 | title, 94 | }: { 95 | href: string; 96 | title: string; 97 | from: number; 98 | to: number; 99 | }) => { 100 | const { view, onClose } = this.props; 101 | 102 | onClose(); 103 | this.props.view.focus(); 104 | 105 | const { dispatch, state } = view; 106 | const { from, to } = state.selection; 107 | assert(from === to); 108 | 109 | dispatch( 110 | view.state.tr 111 | .insertText(title, from, to) 112 | .addMark( 113 | from, 114 | to + title.length, 115 | state.schema.marks.link.create({ href }) 116 | ) 117 | ); 118 | }; 119 | 120 | render() { 121 | const { onCreateLink, onClose, ...rest } = this.props; 122 | const selection = this.props.view.state.selection; 123 | 124 | return ( 125 | 130 | {isActive(this.props) && ( 131 | 139 | )} 140 | 141 | ); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/lib/markdown/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.nesting = 1; 83 | token.markup = options.delim; 84 | token.content = ""; 85 | 86 | token = state.tokens[endDelim.token]; 87 | token.type = `${options.mark}_close`; 88 | token.tag = "span"; 89 | token.nesting = -1; 90 | token.markup = options.delim; 91 | token.content = ""; 92 | 93 | if ( 94 | state.tokens[endDelim.token - 1].type === "text" && 95 | state.tokens[endDelim.token - 1].content === options.delim[0] 96 | ) { 97 | loneMarkers.push(endDelim.token - 1); 98 | } 99 | } 100 | 101 | // If a marker sequence has an odd number of characters, it's split 102 | // like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the 103 | // start of the sequence. 104 | // 105 | // So, we have to move all those markers after subsequent s_close tags. 106 | while (loneMarkers.length) { 107 | i = loneMarkers.pop(); 108 | j = i + 1; 109 | 110 | while ( 111 | j < state.tokens.length && 112 | state.tokens[j].type === `${options.mark}_close` 113 | ) { 114 | j++; 115 | } 116 | 117 | j--; 118 | 119 | if (i !== j) { 120 | token = state.tokens[j]; 121 | state.tokens[j] = state.tokens[i]; 122 | state.tokens[i] = token; 123 | } 124 | } 125 | } 126 | 127 | md.inline.ruler.before("emphasis", options.mark, tokenize); 128 | md.inline.ruler2.before("emphasis", options.mark, function(state) { 129 | let curr; 130 | const tokensMeta = state.tokens_meta, 131 | max = (state.tokens_meta || []).length; 132 | 133 | postProcess(state, state.delimiters); 134 | 135 | for (curr = 0; curr < max; curr++) { 136 | if (tokensMeta[curr] && tokensMeta[curr].delimiters) { 137 | postProcess(state, tokensMeta[curr].delimiters); 138 | } 139 | } 140 | }); 141 | }; 142 | } 143 | -------------------------------------------------------------------------------- /src/nodes/CodeFence.ts: -------------------------------------------------------------------------------- 1 | import refractor from "refractor/core"; 2 | import bash from "refractor/lang/bash"; 3 | import css from "refractor/lang/css"; 4 | import clike from "refractor/lang/clike"; 5 | import csharp from "refractor/lang/csharp"; 6 | import java from "refractor/lang/java"; 7 | import javascript from "refractor/lang/javascript"; 8 | import json from "refractor/lang/json"; 9 | import markup from "refractor/lang/markup"; 10 | import php from "refractor/lang/php"; 11 | import python from "refractor/lang/python"; 12 | import powershell from "refractor/lang/powershell"; 13 | import ruby from "refractor/lang/ruby"; 14 | import typescript from "refractor/lang/typescript"; 15 | 16 | import { setBlockType } from "prosemirror-commands"; 17 | import { textblockTypeInputRule } from "prosemirror-inputrules"; 18 | import copy from "copy-to-clipboard"; 19 | import Prism, { LANGUAGES } from "../plugins/Prism"; 20 | import Node from "./Node"; 21 | import { ToastType } from "../types"; 22 | 23 | [ 24 | bash, 25 | css, 26 | clike, 27 | csharp, 28 | java, 29 | javascript, 30 | json, 31 | markup, 32 | php, 33 | python, 34 | powershell, 35 | ruby, 36 | typescript, 37 | ].forEach(refractor.register); 38 | 39 | export default class CodeFence extends Node { 40 | get languageOptions() { 41 | return Object.entries(LANGUAGES); 42 | } 43 | 44 | get name() { 45 | return "code_fence"; 46 | } 47 | 48 | get schema() { 49 | return { 50 | attrs: { 51 | language: { 52 | default: "javascript", 53 | }, 54 | }, 55 | content: "text*", 56 | marks: "", 57 | group: "block", 58 | code: true, 59 | defining: true, 60 | draggable: false, 61 | parseDOM: [ 62 | { tag: "pre", preserveWhitespace: "full" }, 63 | { 64 | tag: ".code-block", 65 | preserveWhitespace: "full", 66 | contentElement: "code", 67 | getAttrs: node => { 68 | return { 69 | language: node.dataset.language, 70 | }; 71 | }, 72 | }, 73 | ], 74 | toDOM: node => { 75 | const button = document.createElement("button"); 76 | button.innerText = "Copy"; 77 | button.type = "button"; 78 | button.addEventListener("click", this.handleCopyToClipboard(node)); 79 | 80 | const select = document.createElement("select"); 81 | select.addEventListener("change", this.handleLanguageChange); 82 | 83 | this.languageOptions.forEach(([key, label]) => { 84 | const option = document.createElement("option"); 85 | const value = key === "none" ? "" : key; 86 | option.value = value; 87 | option.innerText = label; 88 | option.selected = node.attrs.language === value; 89 | select.appendChild(option); 90 | }); 91 | 92 | return [ 93 | "div", 94 | { class: "code-block", "data-language": node.attrs.language }, 95 | ["div", { contentEditable: false }, select, button], 96 | ["pre", ["code", { spellCheck: false }, 0]], 97 | ]; 98 | }, 99 | }; 100 | } 101 | 102 | commands({ type }) { 103 | return () => setBlockType(type); 104 | } 105 | 106 | keys({ type }) { 107 | return { 108 | "Shift-Ctrl-\\": setBlockType(type), 109 | }; 110 | } 111 | 112 | handleCopyToClipboard(node) { 113 | return () => { 114 | copy(node.textContent); 115 | if (this.options.onShowToast) { 116 | this.options.onShowToast( 117 | this.options.dictionary.codeCopied, 118 | ToastType.Info 119 | ); 120 | } 121 | }; 122 | } 123 | 124 | handleLanguageChange = event => { 125 | const { view } = this.editor; 126 | const { tr } = view.state; 127 | const element = event.target; 128 | const { top, left } = element.getBoundingClientRect(); 129 | const result = view.posAtCoords({ top, left }); 130 | 131 | if (result) { 132 | const transaction = tr.setNodeMarkup(result.inside, undefined, { 133 | language: element.value, 134 | }); 135 | view.dispatch(transaction); 136 | } 137 | }; 138 | 139 | get plugins() { 140 | return [ 141 | Prism({ 142 | name: this.name, 143 | deferred: !this.options.initialReadOnly, 144 | }), 145 | ]; 146 | } 147 | 148 | inputRules({ type }) { 149 | return [textblockTypeInputRule(/^```$/, type)]; 150 | } 151 | 152 | toMarkdown(state, node) { 153 | state.write("```" + (node.attrs.language || "") + "\n"); 154 | state.text(node.textContent, false); 155 | state.ensureNewLine(); 156 | state.write("```"); 157 | state.closeBlock(node); 158 | } 159 | 160 | get markdownToken() { 161 | return "fence"; 162 | } 163 | 164 | parseMarkdown() { 165 | return { 166 | block: "code_block", 167 | getAttrs: tok => ({ language: tok.info }), 168 | }; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /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 | 26 | export default class Table extends Node { 27 | get name() { 28 | return "table"; 29 | } 30 | 31 | get schema() { 32 | return { 33 | content: "tr+", 34 | tableRole: "table", 35 | isolating: true, 36 | group: "block", 37 | parseDOM: [{ tag: "table" }], 38 | toDOM() { 39 | return [ 40 | "div", 41 | { class: "scrollable-wrapper" }, 42 | [ 43 | "div", 44 | { class: "scrollable" }, 45 | ["table", { class: "rme-table" }, ["tbody", 0]], 46 | ], 47 | ]; 48 | }, 49 | }; 50 | } 51 | 52 | commands({ schema }) { 53 | return { 54 | createTable: ({ rowsCount, colsCount }) => (state, dispatch) => { 55 | const offset = state.tr.selection.anchor + 1; 56 | const nodes = createTable(schema, rowsCount, colsCount); 57 | const tr = state.tr.replaceSelectionWith(nodes).scrollIntoView(); 58 | const resolvedPos = tr.doc.resolve(offset); 59 | 60 | tr.setSelection(TextSelection.near(resolvedPos)); 61 | dispatch(tr); 62 | }, 63 | setColumnAttr: ({ index, alignment }) => (state, dispatch) => { 64 | const cells = getCellsInColumn(index)(state.selection) || []; 65 | let transaction = state.tr; 66 | cells.forEach(({ pos }) => { 67 | transaction = transaction.setNodeMarkup(pos, null, { 68 | alignment, 69 | }); 70 | }); 71 | dispatch(transaction); 72 | }, 73 | addColumnBefore: () => addColumnBefore, 74 | addColumnAfter: () => addColumnAfter, 75 | deleteColumn: () => deleteColumn, 76 | addRowAfter: ({ index }) => (state, dispatch) => { 77 | if (index === 0) { 78 | // A little hack to avoid cloning the heading row by cloning the row 79 | // beneath and then moving it to the right index. 80 | const tr = addRowAt(index + 2, true)(state.tr); 81 | dispatch(moveRow(index + 2, index + 1)(tr)); 82 | } else { 83 | dispatch(addRowAt(index + 1, true)(state.tr)); 84 | } 85 | }, 86 | deleteRow: () => deleteRow, 87 | deleteTable: () => deleteTable, 88 | toggleHeaderColumn: () => toggleHeaderColumn, 89 | toggleHeaderRow: () => toggleHeaderRow, 90 | toggleHeaderCell: () => toggleHeaderCell, 91 | setCellAttr: () => setCellAttr, 92 | fixTables: () => fixTables, 93 | }; 94 | } 95 | 96 | keys() { 97 | return { 98 | Tab: goToNextCell(1), 99 | "Shift-Tab": goToNextCell(-1), 100 | Enter: (state, dispatch) => { 101 | if (!isInTable(state)) return false; 102 | 103 | // TODO: Adding row at the end for now, can we find the current cell 104 | // row index and add the row below that? 105 | const cells = getCellsInColumn(0)(state.selection) || []; 106 | 107 | dispatch(addRowAt(cells.length, true)(state.tr)); 108 | return true; 109 | }, 110 | }; 111 | } 112 | 113 | toMarkdown(state, node) { 114 | state.renderTable(node); 115 | state.closeBlock(node); 116 | } 117 | 118 | parseMarkdown() { 119 | return { block: "table" }; 120 | } 121 | 122 | get plugins() { 123 | return [ 124 | tableEditing(), 125 | new Plugin({ 126 | props: { 127 | decorations: state => { 128 | const { doc } = state; 129 | const decorations: Decoration[] = []; 130 | let index = 0; 131 | 132 | doc.descendants((node, pos) => { 133 | if (node.type.name !== this.name) return; 134 | 135 | const elements = document.getElementsByClassName("rme-table"); 136 | const table = elements[index]; 137 | if (!table) return; 138 | 139 | const element = table.parentElement; 140 | const shadowRight = !!( 141 | element && element.scrollWidth > element.clientWidth 142 | ); 143 | 144 | if (shadowRight) { 145 | decorations.push( 146 | Decoration.widget(pos + 1, () => { 147 | const shadow = document.createElement("div"); 148 | shadow.className = "scrollable-shadow right"; 149 | return shadow; 150 | }) 151 | ); 152 | } 153 | index++; 154 | }); 155 | 156 | return DecorationSet.create(doc, decorations); 157 | }, 158 | }, 159 | }), 160 | ]; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/components/SelectionToolbar.tsx: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import * as React from "react"; 3 | import { Portal } from "react-portal"; 4 | import { some } from "lodash"; 5 | import { EditorView } from "prosemirror-view"; 6 | import getTableColMenuItems from "../menus/tableCol"; 7 | import getTableRowMenuItems from "../menus/tableRow"; 8 | import getTableMenuItems from "../menus/table"; 9 | import getFormattingMenuItems from "../menus/formatting"; 10 | import FloatingToolbar from "./FloatingToolbar"; 11 | import LinkEditor, { SearchResult } from "./LinkEditor"; 12 | import Menu from "./Menu"; 13 | import isMarkActive from "../queries/isMarkActive"; 14 | import getMarkRange from "../queries/getMarkRange"; 15 | import isNodeActive from "../queries/isNodeActive"; 16 | import getColumnIndex from "../queries/getColumnIndex"; 17 | import getRowIndex from "../queries/getRowIndex"; 18 | import createAndInsertLink from "../commands/createAndInsertLink"; 19 | import { MenuItem } from "../types"; 20 | import baseDictionary from "../dictionary"; 21 | 22 | type Props = { 23 | dictionary: typeof baseDictionary; 24 | tooltip: typeof React.Component | React.FC; 25 | isTemplate: boolean; 26 | commands: Record; 27 | onSearchLink?: (term: string) => Promise; 28 | onClickLink: (href: string, event: MouseEvent) => void; 29 | onCreateLink?: (title: string) => Promise; 30 | onShowToast?: (msg: string, code: string) => void; 31 | view: EditorView; 32 | }; 33 | 34 | function isActive(props) { 35 | const { view } = props; 36 | const { selection } = view.state; 37 | 38 | if (!selection) return false; 39 | if (selection.empty) return false; 40 | if (selection.node) return false; 41 | 42 | const slice = selection.content(); 43 | const fragment = slice.content; 44 | const nodes = fragment.content; 45 | 46 | return some(nodes, n => n.content.size); 47 | } 48 | 49 | export default class SelectionToolbar extends React.Component { 50 | handleOnCreateLink = async (title: string) => { 51 | const { dictionary, onCreateLink, view, onShowToast } = this.props; 52 | 53 | if (!onCreateLink) { 54 | return; 55 | } 56 | 57 | const { dispatch, state } = view; 58 | const { from, to } = state.selection; 59 | assert(from !== to); 60 | 61 | const href = `creating#${title}…`; 62 | const markType = state.schema.marks.link; 63 | 64 | // Insert a placeholder link 65 | dispatch( 66 | view.state.tr 67 | .removeMark(from, to, markType) 68 | .addMark(from, to, markType.create({ href })) 69 | ); 70 | 71 | createAndInsertLink(view, title, href, { 72 | onCreateLink, 73 | onShowToast, 74 | dictionary, 75 | }); 76 | }; 77 | 78 | handleOnSelectLink = ({ 79 | href, 80 | from, 81 | to, 82 | }: { 83 | href: string; 84 | from: number; 85 | to: number; 86 | }): void => { 87 | const { view } = this.props; 88 | const { state, dispatch } = view; 89 | 90 | const markType = state.schema.marks.link; 91 | 92 | dispatch( 93 | state.tr 94 | .removeMark(from, to, markType) 95 | .addMark(from, to, markType.create({ href })) 96 | ); 97 | }; 98 | 99 | render() { 100 | const { dictionary, onCreateLink, isTemplate, ...rest } = this.props; 101 | const { view } = rest; 102 | const { state } = view; 103 | const { selection }: { selection: any } = state; 104 | const isCodeSelection = isNodeActive(state.schema.nodes.code_block)(state); 105 | 106 | // toolbar is disabled in code blocks, no bold / italic etc 107 | if (isCodeSelection) { 108 | return null; 109 | } 110 | 111 | const colIndex = getColumnIndex(state.selection); 112 | const rowIndex = getRowIndex(state.selection); 113 | const isTableSelection = colIndex !== undefined && rowIndex !== undefined; 114 | const link = isMarkActive(state.schema.marks.link)(state); 115 | const range = getMarkRange(selection.$from, state.schema.marks.link); 116 | 117 | let items: MenuItem[] = []; 118 | if (isTableSelection) { 119 | items = getTableMenuItems(dictionary); 120 | } else if (colIndex !== undefined) { 121 | items = getTableColMenuItems(state, colIndex, dictionary); 122 | } else if (rowIndex !== undefined) { 123 | items = getTableRowMenuItems(state, rowIndex, dictionary); 124 | } else { 125 | items = getFormattingMenuItems(state, isTemplate, dictionary); 126 | } 127 | 128 | if (!items.length) { 129 | return null; 130 | } 131 | 132 | return ( 133 | 134 | 135 | {link && range ? ( 136 | 145 | ) : ( 146 | 147 | )} 148 | 149 | 150 | ); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /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 | // allow opening links in editing mode with the meta/cmd key 116 | if ( 117 | view.props.editable && 118 | view.props.editable(view.state) && 119 | !event.metaKey 120 | ) { 121 | return false; 122 | } 123 | 124 | if (event.target instanceof HTMLAnchorElement) { 125 | const href = 126 | event.target.href || 127 | (event.target.parentNode instanceof HTMLAnchorElement 128 | ? event.target.parentNode.href 129 | : ""); 130 | 131 | const isHashtag = href.startsWith("#"); 132 | if (isHashtag && this.options.onClickHashtag) { 133 | event.stopPropagation(); 134 | event.preventDefault(); 135 | this.options.onClickHashtag(href, event); 136 | return true; 137 | } 138 | 139 | if (this.options.onClickLink) { 140 | event.stopPropagation(); 141 | event.preventDefault(); 142 | this.options.onClickLink(href, event); 143 | return true; 144 | } 145 | } 146 | 147 | return false; 148 | }, 149 | }, 150 | }, 151 | }), 152 | ]; 153 | } 154 | 155 | get toMarkdown() { 156 | return { 157 | open(_state, mark, parent, index) { 158 | return isPlainURL(mark, parent, index, 1) ? "<" : "["; 159 | }, 160 | close(state, mark, parent, index) { 161 | return isPlainURL(mark, parent, index, -1) 162 | ? ">" 163 | : "](" + 164 | state.esc(mark.attrs.href) + 165 | (mark.attrs.title ? " " + state.quote(mark.attrs.title) : "") + 166 | ")"; 167 | }, 168 | }; 169 | } 170 | 171 | parseMarkdown() { 172 | return { 173 | mark: "link", 174 | getAttrs: tok => ({ 175 | href: tok.attrGet("href"), 176 | title: tok.attrGet("title") || null, 177 | }), 178 | }; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/marks/Placeholder.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, TextSelection } from "prosemirror-state"; 2 | import getMarkRange from "../queries/getMarkRange"; 3 | import Mark from "./Mark"; 4 | 5 | export default class Placeholder extends Mark { 6 | get name() { 7 | return "placeholder"; 8 | } 9 | 10 | get schema() { 11 | return { 12 | parseDOM: [{ tag: "span.template-placeholder" }], 13 | toDOM: () => ["span", { class: "template-placeholder" }], 14 | }; 15 | } 16 | 17 | get toMarkdown() { 18 | return { 19 | open: "!!", 20 | close: "!!", 21 | mixable: true, 22 | expelEnclosingWhitespace: true, 23 | }; 24 | } 25 | 26 | parseMarkdown() { 27 | return { mark: "placeholder" }; 28 | } 29 | 30 | get plugins() { 31 | return [ 32 | new Plugin({ 33 | props: { 34 | handleTextInput: (view, from, to, text) => { 35 | if (this.editor.props.template) { 36 | return false; 37 | } 38 | 39 | const { state, dispatch } = view; 40 | const $from = state.doc.resolve(from); 41 | 42 | const range = getMarkRange($from, state.schema.marks.placeholder); 43 | if (!range) return false; 44 | 45 | const selectionStart = Math.min(from, range.from); 46 | const selectionEnd = Math.max(to, range.to); 47 | 48 | dispatch( 49 | state.tr 50 | .removeMark( 51 | range.from, 52 | range.to, 53 | state.schema.marks.placeholder 54 | ) 55 | .insertText(text, selectionStart, selectionEnd) 56 | ); 57 | 58 | const $to = view.state.doc.resolve(selectionStart + text.length); 59 | dispatch(view.state.tr.setSelection(TextSelection.near($to))); 60 | 61 | return true; 62 | }, 63 | handleKeyDown: (view, event: KeyboardEvent) => { 64 | if (!view.props.editable || !view.props.editable(view.state)) { 65 | return false; 66 | } 67 | if (this.editor.props.template) { 68 | return false; 69 | } 70 | if ( 71 | event.key !== "ArrowLeft" && 72 | event.key !== "ArrowRight" && 73 | event.key !== "Backspace" 74 | ) { 75 | return false; 76 | } 77 | 78 | const { state, dispatch } = view; 79 | 80 | if (event.key === "Backspace") { 81 | const range = getMarkRange( 82 | state.doc.resolve(Math.max(0, state.selection.from - 1)), 83 | state.schema.marks.placeholder 84 | ); 85 | if (!range) return false; 86 | 87 | dispatch( 88 | state.tr 89 | .removeMark( 90 | range.from, 91 | range.to, 92 | state.schema.marks.placeholder 93 | ) 94 | .insertText("", range.from, range.to) 95 | ); 96 | return true; 97 | } 98 | 99 | if (event.key === "ArrowLeft") { 100 | const range = getMarkRange( 101 | state.doc.resolve(Math.max(0, state.selection.from - 1)), 102 | state.schema.marks.placeholder 103 | ); 104 | if (!range) return false; 105 | 106 | const startOfMark = state.doc.resolve(range.from); 107 | dispatch(state.tr.setSelection(TextSelection.near(startOfMark))); 108 | return true; 109 | } 110 | 111 | if (event.key === "ArrowRight") { 112 | const range = getMarkRange( 113 | state.selection.$from, 114 | state.schema.marks.placeholder 115 | ); 116 | if (!range) return false; 117 | 118 | const endOfMark = state.doc.resolve(range.to); 119 | dispatch(state.tr.setSelection(TextSelection.near(endOfMark))); 120 | return true; 121 | } 122 | 123 | return false; 124 | }, 125 | handleClick: (view, pos, event: MouseEvent) => { 126 | if (!view.props.editable || !view.props.editable(view.state)) { 127 | return false; 128 | } 129 | if (this.editor.props.template) { 130 | return false; 131 | } 132 | 133 | if ( 134 | event.target instanceof HTMLSpanElement && 135 | event.target.className.includes("template-placeholder") 136 | ) { 137 | const { state, dispatch } = view; 138 | const range = getMarkRange( 139 | state.selection.$from, 140 | state.schema.marks.placeholder 141 | ); 142 | if (!range) return false; 143 | 144 | event.stopPropagation(); 145 | event.preventDefault(); 146 | const startOfMark = state.doc.resolve(range.from); 147 | dispatch(state.tr.setSelection(TextSelection.near(startOfMark))); 148 | 149 | return true; 150 | } 151 | return false; 152 | }, 153 | }, 154 | }), 155 | ]; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/nodes/Heading.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "prosemirror-state"; 2 | import copy from "copy-to-clipboard"; 3 | import { Decoration, DecorationSet } from "prosemirror-view"; 4 | import { Node as ProsemirrorNode, NodeType } from "prosemirror-model"; 5 | import { textblockTypeInputRule } from "prosemirror-inputrules"; 6 | import { setBlockType } from "prosemirror-commands"; 7 | import { MarkdownSerializerState } from "prosemirror-markdown"; 8 | import backspaceToParagraph from "../commands/backspaceToParagraph"; 9 | import toggleBlockType from "../commands/toggleBlockType"; 10 | import headingToSlug from "../lib/headingToSlug"; 11 | import Node from "./Node"; 12 | import { ToastType } from "../types"; 13 | 14 | export default class Heading extends Node { 15 | className = "heading-name"; 16 | 17 | get name() { 18 | return "heading"; 19 | } 20 | 21 | get defaultOptions() { 22 | return { 23 | levels: [1, 2, 3, 4], 24 | }; 25 | } 26 | 27 | get schema() { 28 | return { 29 | attrs: { 30 | level: { 31 | default: 1, 32 | }, 33 | }, 34 | content: "inline*", 35 | group: "block", 36 | defining: true, 37 | draggable: false, 38 | parseDOM: this.options.levels.map(level => ({ 39 | tag: `h${level}`, 40 | attrs: { level }, 41 | })), 42 | toDOM: node => { 43 | const button = document.createElement("button"); 44 | button.innerText = "#"; 45 | button.type = "button"; 46 | button.className = "heading-anchor"; 47 | button.addEventListener("click", this.handleCopyLink()); 48 | 49 | return [ 50 | `h${node.attrs.level + (this.options.offset || 0)}`, 51 | button, 52 | ["span", 0], 53 | ]; 54 | }, 55 | }; 56 | } 57 | 58 | toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { 59 | state.write(state.repeat("#", node.attrs.level) + " "); 60 | state.renderInline(node); 61 | state.closeBlock(node); 62 | } 63 | 64 | parseMarkdown() { 65 | return { 66 | block: "heading", 67 | getAttrs: (token: Record) => ({ 68 | level: +token.tag.slice(1), 69 | }), 70 | }; 71 | } 72 | 73 | commands({ type, schema }) { 74 | return (attrs: Record) => { 75 | return toggleBlockType(type, schema.nodes.paragraph, attrs); 76 | }; 77 | } 78 | 79 | handleCopyLink = () => { 80 | return event => { 81 | // this is unfortunate but appears to be the best way to grab the anchor 82 | // as it's added directly to the dom by a decoration. 83 | const anchor = event.currentTarget.nextSibling.getElementsByClassName( 84 | this.className 85 | )[0]; 86 | const hash = `#${anchor.id}`; 87 | 88 | // the existing url might contain a hash already, lets make sure to remove 89 | // that rather than appending another one. 90 | const urlWithoutHash = window.location.href.split("#")[0]; 91 | copy(urlWithoutHash + hash); 92 | 93 | if (this.options.onShowToast) { 94 | this.options.onShowToast( 95 | this.options.dictionary.linkCopied, 96 | ToastType.Info 97 | ); 98 | } 99 | }; 100 | }; 101 | 102 | keys({ type }: { type: NodeType }) { 103 | const options = this.options.levels.reduce( 104 | (items, level) => ({ 105 | ...items, 106 | ...{ 107 | [`Shift-Ctrl-${level}`]: setBlockType(type, { level }), 108 | }, 109 | }), 110 | {} 111 | ); 112 | 113 | return { 114 | ...options, 115 | Backspace: backspaceToParagraph(type), 116 | }; 117 | } 118 | 119 | get plugins() { 120 | return [ 121 | new Plugin({ 122 | props: { 123 | decorations: state => { 124 | const { doc } = state; 125 | const decorations: Decoration[] = []; 126 | const previouslySeen = {}; 127 | 128 | doc.descendants((node, pos) => { 129 | if (node.type.name !== this.name) return; 130 | 131 | // calculate the optimal id 132 | const slug = headingToSlug(node); 133 | let id = slug; 134 | 135 | // check if we've already used it, and if so how many times? 136 | // Make the new id based on that number ensuring that we have 137 | // unique ID's even when headings are identical 138 | if (previouslySeen[slug] > 0) { 139 | id = headingToSlug(node, previouslySeen[slug]); 140 | } 141 | 142 | // record that we've seen this slug for the next loop 143 | previouslySeen[slug] = 144 | previouslySeen[slug] !== undefined 145 | ? previouslySeen[slug] + 1 146 | : 1; 147 | 148 | decorations.push( 149 | Decoration.inline(pos, pos + node.nodeSize, { 150 | id, 151 | class: this.className, 152 | nodeName: "a", 153 | }) 154 | ); 155 | }); 156 | 157 | return DecorationSet.create(doc, decorations); 158 | }, 159 | }, 160 | }), 161 | ]; 162 | } 163 | 164 | inputRules({ type }: { type: NodeType }) { 165 | return this.options.levels.map(level => 166 | textblockTypeInputRule(new RegExp(`^(#{1,${level}})\\s$`), type, () => ({ 167 | level, 168 | })) 169 | ); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/lib/ExtensionManager.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "prosemirror-model"; 2 | import { keymap } from "prosemirror-keymap"; 3 | import { MarkdownParser } from "prosemirror-markdown"; 4 | import { MarkdownSerializer } from "./markdown/serializer"; 5 | import Editor from "../"; 6 | import Extension from "./Extension"; 7 | import makeRules from "./markdown/rules"; 8 | import Node from "../nodes/Node"; 9 | import Mark from "../marks/Mark"; 10 | 11 | export default class ExtensionManager { 12 | extensions: Extension[]; 13 | embeds; 14 | 15 | constructor(extensions: Extension[] = [], editor?: Editor) { 16 | if (editor) { 17 | extensions.forEach(extension => { 18 | extension.bindEditor(editor); 19 | }); 20 | } 21 | 22 | this.extensions = extensions; 23 | this.embeds = editor ? editor.props.embeds : undefined; 24 | } 25 | 26 | get nodes() { 27 | return this.extensions 28 | .filter(extension => extension.type === "node") 29 | .reduce( 30 | (nodes, node: Node) => ({ 31 | ...nodes, 32 | [node.name]: node.schema, 33 | }), 34 | {} 35 | ); 36 | } 37 | 38 | serializer() { 39 | const nodes = this.extensions 40 | .filter(extension => extension.type === "node") 41 | .reduce( 42 | (nodes, extension: Node) => ({ 43 | ...nodes, 44 | [extension.name]: extension.toMarkdown, 45 | }), 46 | {} 47 | ); 48 | 49 | const marks = this.extensions 50 | .filter(extension => extension.type === "mark") 51 | .reduce( 52 | (marks, extension: Mark) => ({ 53 | ...marks, 54 | [extension.name]: extension.toMarkdown, 55 | }), 56 | {} 57 | ); 58 | 59 | return new MarkdownSerializer(nodes, marks); 60 | } 61 | 62 | parser({ schema }) { 63 | const tokens = this.extensions 64 | .filter( 65 | extension => extension.type === "mark" || extension.type === "node" 66 | ) 67 | .reduce((nodes, extension: Node | Mark) => { 68 | const md = extension.parseMarkdown(); 69 | if (!md) return nodes; 70 | 71 | return { 72 | ...nodes, 73 | [extension.markdownToken || extension.name]: md, 74 | }; 75 | }, {}); 76 | 77 | return new MarkdownParser( 78 | schema, 79 | makeRules({ embeds: this.embeds }), 80 | tokens 81 | ); 82 | } 83 | 84 | get marks() { 85 | return this.extensions 86 | .filter(extension => extension.type === "mark") 87 | .reduce( 88 | (marks, { name, schema }: Mark) => ({ 89 | ...marks, 90 | [name]: schema, 91 | }), 92 | {} 93 | ); 94 | } 95 | 96 | get plugins() { 97 | return this.extensions 98 | .filter(extension => extension.plugins) 99 | .reduce((allPlugins, { plugins }) => [...allPlugins, ...plugins], []); 100 | } 101 | 102 | keymaps({ schema }: { schema: Schema }) { 103 | const extensionKeymaps = this.extensions 104 | .filter(extension => ["extension"].includes(extension.type)) 105 | .filter(extension => extension.keys) 106 | .map(extension => extension.keys({ schema })); 107 | 108 | const nodeMarkKeymaps = this.extensions 109 | .filter(extension => ["node", "mark"].includes(extension.type)) 110 | .filter(extension => extension.keys) 111 | .map(extension => 112 | extension.keys({ 113 | type: schema[`${extension.type}s`][extension.name], 114 | schema, 115 | }) 116 | ); 117 | 118 | return [ 119 | ...extensionKeymaps, 120 | ...nodeMarkKeymaps, 121 | ].map((keys: Record) => keymap(keys)); 122 | } 123 | 124 | inputRules({ schema }: { schema: Schema }) { 125 | const extensionInputRules = this.extensions 126 | .filter(extension => ["extension"].includes(extension.type)) 127 | .filter(extension => extension.inputRules) 128 | .map(extension => extension.inputRules({ schema })); 129 | 130 | const nodeMarkInputRules = this.extensions 131 | .filter(extension => ["node", "mark"].includes(extension.type)) 132 | .filter(extension => extension.inputRules) 133 | .map(extension => 134 | extension.inputRules({ 135 | type: schema[`${extension.type}s`][extension.name], 136 | schema, 137 | }) 138 | ); 139 | 140 | return [...extensionInputRules, ...nodeMarkInputRules].reduce( 141 | (allInputRules, inputRules) => [...allInputRules, ...inputRules], 142 | [] 143 | ); 144 | } 145 | 146 | commands({ schema, view }) { 147 | return this.extensions 148 | .filter(extension => extension.commands) 149 | .reduce((allCommands, extension) => { 150 | const { name, type } = extension; 151 | const commands = {}; 152 | const value = extension.commands({ 153 | schema, 154 | ...(["node", "mark"].includes(type) 155 | ? { 156 | type: schema[`${type}s`][name], 157 | } 158 | : {}), 159 | }); 160 | 161 | const apply = (callback, attrs) => { 162 | if (!view.editable) { 163 | return false; 164 | } 165 | view.focus(); 166 | return callback(attrs)(view.state, view.dispatch, view); 167 | }; 168 | 169 | const handle = (_name, _value) => { 170 | if (Array.isArray(_value)) { 171 | commands[_name] = attrs => 172 | _value.forEach(callback => apply(callback, attrs)); 173 | } else if (typeof _value === "function") { 174 | commands[_name] = attrs => apply(_value, attrs); 175 | } 176 | }; 177 | 178 | if (typeof value === "object") { 179 | Object.entries(value).forEach(([commandName, commandValue]) => { 180 | handle(commandName, commandValue); 181 | }); 182 | } else { 183 | handle(name, value); 184 | } 185 | 186 | return { 187 | ...allCommands, 188 | ...commands, 189 | }; 190 | }, {}); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/plugins/BlockMenuTrigger.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "prosemirror-state"; 2 | import { InputRule } from "prosemirror-inputrules"; 3 | import { Decoration, DecorationSet } from "prosemirror-view"; 4 | import { findParentNode } from "prosemirror-utils"; 5 | import Extension from "../lib/Extension"; 6 | 7 | const MAX_MATCH = 500; 8 | const OPEN_REGEX = /^\/(\w+)?$/; 9 | const CLOSE_REGEX = /(^(?!\/(\w+)?)(.*)$|^\/((\w+)\s.*|\s)$)/; 10 | 11 | // based on the input rules code in Prosemirror, here: 12 | // https://github.com/ProseMirror/prosemirror-inputrules/blob/master/src/inputrules.js 13 | function run(view, from, to, regex, handler) { 14 | if (view.composing) { 15 | return false; 16 | } 17 | const state = view.state; 18 | const $from = state.doc.resolve(from); 19 | if ($from.parent.type.spec.code) { 20 | return false; 21 | } 22 | 23 | const textBefore = $from.parent.textBetween( 24 | Math.max(0, $from.parentOffset - MAX_MATCH), 25 | $from.parentOffset, 26 | null, 27 | "\ufffc" 28 | ); 29 | 30 | const match = regex.exec(textBefore); 31 | const tr = handler(state, match, match ? from - match[0].length : from, to); 32 | if (!tr) return false; 33 | return true; 34 | } 35 | 36 | export default class BlockMenuTrigger extends Extension { 37 | get name() { 38 | return "blockmenu"; 39 | } 40 | 41 | get plugins() { 42 | return [ 43 | new Plugin({ 44 | props: { 45 | handleClick: () => { 46 | this.options.onClose(); 47 | return false; 48 | }, 49 | handleKeyDown: (view, event) => { 50 | // Prosemirror input rules are not triggered on backspace, however 51 | // we need them to be evaluted for the filter trigger to work 52 | // correctly. This additional handler adds inputrules-like handling. 53 | if (event.key === "Backspace") { 54 | // timeout ensures that the delete has been handled by prosemirror 55 | // and any characters removed, before we evaluate the rule. 56 | setTimeout(() => { 57 | const { pos } = view.state.selection.$from; 58 | return run(view, pos, pos, OPEN_REGEX, (state, match) => { 59 | if (match) { 60 | this.options.onOpen(match[1]); 61 | } else { 62 | this.options.onClose(); 63 | } 64 | return null; 65 | }); 66 | }); 67 | } 68 | 69 | // If the query is active and we're navigating the block menu then 70 | // just ignore the key events in the editor itself until we're done 71 | if ( 72 | event.key === "Enter" || 73 | event.key === "ArrowUp" || 74 | event.key === "ArrowDown" || 75 | event.key === "Tab" 76 | ) { 77 | const { pos } = view.state.selection.$from; 78 | 79 | return run(view, pos, pos, OPEN_REGEX, (state, match) => { 80 | // just tell Prosemirror we handled it and not to do anything 81 | return match ? true : null; 82 | }); 83 | } 84 | 85 | return false; 86 | }, 87 | decorations: state => { 88 | const parent = findParentNode( 89 | node => node.type.name === "paragraph" 90 | )(state.selection); 91 | 92 | if (!parent) { 93 | return; 94 | } 95 | 96 | const decorations: Decoration[] = []; 97 | const isEmpty = parent && parent.node.content.size === 0; 98 | const isSlash = parent && parent.node.textContent === "/"; 99 | const isTopLevel = state.selection.$from.depth === 1; 100 | 101 | if (isTopLevel) { 102 | if (isEmpty) { 103 | decorations.push( 104 | Decoration.widget(parent.pos, () => { 105 | const icon = document.createElement("button"); 106 | icon.type = "button"; 107 | icon.className = "block-menu-trigger"; 108 | icon.innerText = "+"; 109 | icon.addEventListener("click", () => { 110 | this.options.onOpen(""); 111 | }); 112 | return icon; 113 | }) 114 | ); 115 | 116 | decorations.push( 117 | Decoration.node( 118 | parent.pos, 119 | parent.pos + parent.node.nodeSize, 120 | { 121 | class: "placeholder", 122 | "data-empty-text": this.options.dictionary.newLineEmpty, 123 | } 124 | ) 125 | ); 126 | } 127 | 128 | if (isSlash) { 129 | decorations.push( 130 | Decoration.node( 131 | parent.pos, 132 | parent.pos + parent.node.nodeSize, 133 | { 134 | class: "placeholder", 135 | "data-empty-text": ` ${this.options.dictionary.newLineWithSlash}`, 136 | } 137 | ) 138 | ); 139 | } 140 | 141 | return DecorationSet.create(state.doc, decorations); 142 | } 143 | 144 | return; 145 | }, 146 | }, 147 | }), 148 | ]; 149 | } 150 | 151 | inputRules() { 152 | return [ 153 | // main regex should match only: 154 | // /word 155 | new InputRule(OPEN_REGEX, (state, match) => { 156 | if (match && state.selection.$from.parent.type.name === "paragraph") { 157 | this.options.onOpen(match[1]); 158 | } 159 | return null; 160 | }), 161 | // invert regex should match some of these scenarios: 162 | // /word 163 | // / 164 | // /word 165 | new InputRule(CLOSE_REGEX, (state, match) => { 166 | if (match) { 167 | this.options.onClose(); 168 | } 169 | return null; 170 | }), 171 | ]; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/components/FloatingToolbar.tsx: -------------------------------------------------------------------------------- 1 | import ResizeObserver from "resize-observer-polyfill"; 2 | import * as React from "react"; 3 | import { Portal } from "react-portal"; 4 | import { EditorView } from "prosemirror-view"; 5 | import styled from "styled-components"; 6 | 7 | const SSR = typeof window === "undefined"; 8 | 9 | type Props = { 10 | active?: boolean; 11 | view: EditorView; 12 | children: React.ReactNode; 13 | forwardedRef?: React.RefObject | null; 14 | }; 15 | 16 | const defaultPosition = { 17 | left: -1000, 18 | top: 0, 19 | offset: 0, 20 | visible: false, 21 | }; 22 | 23 | const useComponentSize = ref => { 24 | const [size, setSize] = React.useState({ 25 | width: 0, 26 | height: 0, 27 | }); 28 | 29 | React.useEffect(() => { 30 | const sizeObserver = new ResizeObserver(entries => { 31 | entries.forEach(({ target }) => { 32 | if ( 33 | size.width !== target.clientWidth || 34 | size.height !== target.clientHeight 35 | ) { 36 | setSize({ width: target.clientWidth, height: target.clientHeight }); 37 | } 38 | }); 39 | }); 40 | sizeObserver.observe(ref.current); 41 | 42 | return () => sizeObserver.disconnect(); 43 | }, [ref]); 44 | 45 | return size; 46 | }; 47 | 48 | function usePosition({ menuRef, isSelectingText, props }) { 49 | const { view, active } = props; 50 | const { selection } = view.state; 51 | const { width: menuWidth, height: menuHeight } = useComponentSize(menuRef); 52 | 53 | if (!active || !menuWidth || !menuHeight || SSR || isSelectingText) { 54 | return defaultPosition; 55 | } 56 | 57 | // based on the start and end of the selection calculate the position at 58 | // the center top 59 | const fromPos = view.coordsAtPos(selection.$from.pos); 60 | const toPos = view.coordsAtPos(selection.$to.pos); 61 | 62 | // ensure that start < end for the menu to be positioned correctly 63 | const selectionBounds = { 64 | top: Math.min(fromPos.top, toPos.top), 65 | bottom: Math.max(fromPos.bottom, toPos.bottom), 66 | left: Math.min(fromPos.left, toPos.left), 67 | right: Math.max(fromPos.right, toPos.right), 68 | }; 69 | 70 | // tables are an oddity, and need their own logic 71 | const isColSelection = selection.isColSelection && selection.isColSelection(); 72 | const isRowSelection = selection.isRowSelection && selection.isRowSelection(); 73 | 74 | if (isRowSelection) { 75 | selectionBounds.right = selectionBounds.left + 12; 76 | } else if (isColSelection) { 77 | const { node: element } = view.domAtPos(selection.$from.pos); 78 | const { width } = element.getBoundingClientRect(); 79 | selectionBounds.right = selectionBounds.left + width; 80 | } 81 | 82 | // calcluate the horizontal center of the selection 83 | const halfSelection = 84 | Math.abs(selectionBounds.right - selectionBounds.left) / 2; 85 | const centerOfSelection = selectionBounds.left + halfSelection; 86 | 87 | // position the menu so that it is centered over the selection except in 88 | // the cases where it would extend off the edge of the screen. In these 89 | // instances leave a margin 90 | const margin = 12; 91 | const left = Math.min( 92 | window.innerWidth - menuWidth - margin, 93 | Math.max(margin, centerOfSelection - menuWidth / 2) 94 | ); 95 | const top = Math.min( 96 | window.innerHeight - menuHeight - margin, 97 | Math.max(margin, selectionBounds.top - menuHeight) 98 | ); 99 | 100 | // if the menu has been offset to not extend offscreen then we should adjust 101 | // the position of the triangle underneath to correctly point to the center 102 | // of the selection still 103 | const offset = left - (centerOfSelection - menuWidth / 2); 104 | 105 | return { 106 | left: Math.round(left + window.scrollX), 107 | top: Math.round(top + window.scrollY), 108 | offset: Math.round(offset), 109 | visible: true, 110 | }; 111 | } 112 | 113 | function FloatingToolbar(props) { 114 | const menuRef = props.forwardedRef || React.createRef(); 115 | const [isSelectingText, setSelectingText] = React.useState(false); 116 | const position = usePosition({ 117 | menuRef, 118 | isSelectingText, 119 | props, 120 | }); 121 | 122 | React.useEffect(() => { 123 | const handleMouseDown = () => { 124 | if (!props.active) { 125 | setSelectingText(true); 126 | } 127 | }; 128 | 129 | const handleMouseUp = () => { 130 | setSelectingText(false); 131 | }; 132 | 133 | window.addEventListener("mousedown", handleMouseDown); 134 | window.addEventListener("mouseup", handleMouseUp); 135 | 136 | return () => { 137 | window.removeEventListener("mousedown", handleMouseDown); 138 | window.removeEventListener("mouseup", handleMouseUp); 139 | }; 140 | }, [props.active]); 141 | 142 | // only render children when state is updated to visible 143 | // to prevent gaining input focus before calculatePosition runs 144 | return ( 145 | 146 | 155 | {position.visible && props.children} 156 | 157 | 158 | ); 159 | } 160 | 161 | const Wrapper = styled.div<{ 162 | active?: boolean; 163 | offset: number; 164 | }>` 165 | will-change: opacity, transform; 166 | padding: 8px 16px; 167 | position: absolute; 168 | z-index: ${props => props.theme.zIndex + 100}; 169 | opacity: 0; 170 | background-color: ${props => props.theme.toolbarBackground}; 171 | border-radius: 4px; 172 | transform: scale(0.95); 173 | transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275), 174 | transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275); 175 | transition-delay: 150ms; 176 | line-height: 0; 177 | height: 40px; 178 | box-sizing: border-box; 179 | pointer-events: none; 180 | white-space: nowrap; 181 | 182 | &::before { 183 | content: ""; 184 | display: block; 185 | width: 24px; 186 | height: 24px; 187 | transform: translateX(-50%) rotate(45deg); 188 | background: ${props => props.theme.toolbarBackground}; 189 | border-radius: 3px; 190 | z-index: -1; 191 | position: absolute; 192 | bottom: -2px; 193 | left: calc(50% - ${props => props.offset || 0}px); 194 | } 195 | 196 | * { 197 | box-sizing: border-box; 198 | } 199 | 200 | ${({ active }) => 201 | active && 202 | ` 203 | transform: translateY(-6px) scale(1); 204 | pointer-events: all; 205 | opacity: 1; 206 | `}; 207 | 208 | @media print { 209 | display: none; 210 | } 211 | `; 212 | 213 | export default React.forwardRef(function FloatingToolbarWithForwardedRef( 214 | props: Props, 215 | ref: React.RefObject 216 | ) { 217 | return ; 218 | }); 219 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import debounce from "lodash/debounce"; 3 | import ReactDOM from "react-dom"; 4 | import Editor from "../../src"; 5 | 6 | const element = document.getElementById("main"); 7 | const savedText = localStorage.getItem("saved"); 8 | const exampleText = ` 9 | # Welcome 10 | 11 | This is example content. It is persisted between reloads in localStorage. 12 | `; 13 | const defaultValue = savedText || exampleText; 14 | 15 | const docSearchResults = [ 16 | { 17 | title: "Hiring", 18 | subtitle: "Created by Jane", 19 | url: "/doc/hiring", 20 | }, 21 | { 22 | title: "Product Roadmap", 23 | subtitle: "Created by Tom", 24 | url: "/doc/product-roadmap", 25 | }, 26 | { 27 | title: "Finances", 28 | subtitle: "Created by Coley", 29 | url: "/doc/finances", 30 | }, 31 | { 32 | title: "Security", 33 | subtitle: "Created by Coley", 34 | url: "/doc/security", 35 | }, 36 | { 37 | title: "Super secret stuff", 38 | subtitle: "Created by Coley", 39 | url: "/doc/secret-stuff", 40 | }, 41 | { 42 | title: "Supero notes", 43 | subtitle: "Created by Vanessa", 44 | url: "/doc/supero-notes", 45 | }, 46 | { 47 | title: "Meeting notes", 48 | subtitle: "Created by Rob", 49 | url: "/doc/meeting-notes", 50 | }, 51 | ]; 52 | 53 | class YoutubeEmbed extends React.Component { 54 | render() { 55 | const { attrs } = this.props; 56 | const videoId = attrs.matches[1]; 57 | 58 | return ( 59 |