├── .eslintignore ├── src ├── types │ ├── gemoji.d.ts │ ├── markdown-it-mark.d.ts │ ├── prosemirror-model.d.ts │ └── index.ts ├── queries │ ├── isList.ts │ ├── getRowIndex.ts │ ├── getColumnIndex.ts │ ├── isInList.ts │ ├── isMarkActive.ts │ ├── getParentListItem.ts │ ├── isInCode.ts │ ├── isNodeActive.ts │ ├── findCollapsedNodes.ts │ └── getMarkRange.ts ├── nodes │ ├── Doc.ts │ ├── ReactNode.ts │ ├── CodeBlock.ts │ ├── Text.ts │ ├── TableRow.ts │ ├── Node.ts │ ├── BulletList.ts │ ├── CheckboxList.ts │ ├── Paragraph.ts │ ├── HardBreak.ts │ ├── Blockquote.ts │ ├── HorizontalRule.ts │ ├── OrderedList.ts │ ├── TableHeadCell.ts │ ├── CheckboxItem.ts │ ├── Emoji.tsx │ ├── Embed.tsx │ ├── TableCell.ts │ ├── Notice.tsx │ └── Table.ts ├── server.test.ts ├── lib │ ├── isModKey.ts │ ├── isUrl.ts │ ├── markdown │ │ └── rules.ts │ ├── isMarkdown.ts │ ├── getMarkAttrs.ts │ ├── getDataTransferFiles.ts │ ├── filterExcessSeparators.ts │ ├── renderToHtml.ts │ ├── headingToSlug.ts │ ├── getHeadings.ts │ ├── Extension.ts │ ├── isMarkdown.test.ts │ ├── markInputRule.ts │ ├── uploadPlaceholder.ts │ ├── ComponentView.tsx │ ├── __snapshots__ │ │ └── renderToHtml.test.ts.snap │ └── renderToHtml.test.ts ├── components │ ├── Tooltip.tsx │ ├── ToolbarSeparator.tsx │ ├── VisuallyHidden.tsx │ ├── Input.tsx │ ├── EmojiMenuItem.tsx │ ├── ToolbarButton.tsx │ ├── Flex.tsx │ ├── BlockMenu.tsx │ ├── ToolbarMenu.tsx │ ├── EmojiMenu.tsx │ ├── LinkSearchResult.tsx │ ├── BlockMenuItem.tsx │ └── LinkToolbar.tsx ├── rules │ ├── emoji.ts │ ├── notices.ts │ ├── underlines.ts │ ├── breaks.ts │ ├── embeds.ts │ ├── tables.ts │ ├── checkboxes.ts │ └── mark.ts ├── icons │ ├── BoldIcon.js │ ├── HighlightIcon.js │ ├── GptIcon.js │ ├── BlockQuoteIcon.js │ ├── Heading2Icon.js │ ├── StrikeThroughIcon.js │ ├── Heading3Icon.js │ ├── BulletedListIcon.js │ ├── OrderedListIcon.js │ ├── CodeIcon.js │ ├── LinkIcon.js │ ├── CheckListIcon.js │ └── CodeBlockIcon.js ├── plugins │ ├── SmartText.ts │ ├── History.ts │ ├── MaxLength.ts │ ├── Gpt.js │ ├── Placeholder.ts │ ├── TrailingNode.ts │ ├── Folding.tsx │ ├── Keys.ts │ ├── EmojiTrigger.tsx │ └── Prism.ts ├── menus │ ├── table.tsx │ ├── divider.tsx │ ├── tableRow.tsx │ ├── image.tsx │ ├── tableCol.tsx │ ├── block.ts │ └── formatting.ts ├── commands │ ├── toggleWrap.ts │ ├── toggleBlockType.ts │ ├── README.md │ ├── backspaceToParagraph.ts │ ├── wrapInCodeBlock.ts │ ├── toggleList.ts │ ├── splitHeading.ts │ ├── createAndInsertLink.ts │ ├── moveRight.ts │ ├── moveLeft.ts │ └── insertFiles.ts ├── marks │ ├── Mark.ts │ ├── Highlight.ts │ ├── Bold.ts │ ├── Italic.ts │ ├── Strikethrough.ts │ ├── Underline.ts │ ├── Code.ts │ └── Link.ts ├── hooks │ ├── useMediaQuery.ts │ ├── useViewportHeight.ts │ └── useComponentSize.ts ├── dictionary.ts ├── server.ts ├── styles │ └── theme.ts └── stories │ └── index.tsx ├── .gitignore ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── config.yml │ └── bug_report.md ├── .npmignore ├── .storybook ├── preview.js └── main.js ├── babel.config.js ├── tsconfig.json ├── .circleci └── config.yml ├── .eslintrc ├── LICENSE └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /src/types/gemoji.d.ts: -------------------------------------------------------------------------------- 1 | declare module "gemoji"; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules/* 3 | .log 4 | .DS_Store 5 | .idea 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [outline] 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .test.js 3 | example 4 | .circleci 5 | .github 6 | .eslintignore 7 | .eslintrc 8 | .map 9 | dist/stories -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | 2 | export const parameters = { 3 | layout: "padded", 4 | actions: { argTypesRegex: "^on[A-Z].*" }, 5 | } -------------------------------------------------------------------------------- /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/queries/isList.ts: -------------------------------------------------------------------------------- 1 | export default function isList(node, schema) { 2 | return ( 3 | node.type === schema.nodes.bullet_list || 4 | node.type === schema.nodes.ordered_list || 5 | node.type === schema.nodes.checkbox_list 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // This config is only used for TS support in Jest 2 | module.exports = { 3 | presets: [ 4 | "@babel/preset-react", 5 | ["@babel/preset-env", { targets: { node: "current" } }], 6 | "@babel/preset-typescript", 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "stories": [ 3 | "../src/**/*.stories.mdx", 4 | "../src/**/*.stories.@(js|jsx|ts|tsx)" 5 | ], 6 | "addons": [ 7 | "@storybook/addon-links", 8 | "@storybook/addon-essentials" 9 | ] 10 | } -------------------------------------------------------------------------------- /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/server.test.ts: -------------------------------------------------------------------------------- 1 | import { parser } from "./server"; 2 | 3 | test("renders an empty doc", () => { 4 | const ast = parser.parse(""); 5 | 6 | expect(ast.toJSON()).toEqual({ 7 | content: [{ type: "paragraph" }], 8 | type: "doc", 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/lib/isModKey.ts: -------------------------------------------------------------------------------- 1 | const SSR = typeof window === "undefined"; 2 | const isMac = !SSR && window.navigator.platform === "MacIntel"; 3 | 4 | export default function isModKey(event: KeyboardEvent | MouseEvent): boolean { 5 | return isMac ? event.metaKey : event.ctrlKey; 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/isUrl.ts: -------------------------------------------------------------------------------- 1 | export default function isUrl(text: string) { 2 | if (text.match(/\n/)) { 3 | return false; 4 | } 5 | 6 | try { 7 | const url = new URL(text); 8 | return url.hostname !== ""; 9 | } catch (err) { 10 | return false; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/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: 8px; 10 | `; 11 | 12 | export default Separator; 13 | -------------------------------------------------------------------------------- /src/rules/emoji.ts: -------------------------------------------------------------------------------- 1 | import nameToEmoji from "gemoji/name-to-emoji.json"; 2 | import MarkdownIt from "markdown-it"; 3 | import emojiPlugin from "markdown-it-emoji"; 4 | 5 | export default function emoji(md: MarkdownIt): (md: MarkdownIt) => void { 6 | return emojiPlugin(md, { 7 | defs: nameToEmoji, 8 | shortcuts: {}, 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /src/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/icons/BoldIcon.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function BoldIcon() { 4 | return ( 5 | 6 | 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /src/icons/HighlightIcon.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Icon() { 4 | return ( 5 | 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /.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/types/prosemirror-model.d.ts: -------------------------------------------------------------------------------- 1 | import "prosemirror-model"; 2 | 3 | declare module "prosemirror-model" { 4 | interface Slice { 5 | // this method is missing in the DefinitelyTyped type definition, so we 6 | // must patch it here. 7 | // https://github.com/ProseMirror/prosemirror-model/blob/bd13a2329fda39f1c4d09abd8f0db2032bdc8014/src/replace.js#L51 8 | removeBetween(from: number, to: number): Slice; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/queries/isMarkActive.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from "prosemirror-state"; 2 | 3 | const isMarkActive = type => (state: EditorState): boolean => { 4 | if (!type) { 5 | return false; 6 | } 7 | 8 | const { from, $from, to, empty } = state.selection; 9 | 10 | return empty 11 | ? type.isInSet(state.storedMarks || $from.marks()) 12 | : state.doc.rangeHasMark(from, to, type); 13 | }; 14 | 15 | export default isMarkActive; 16 | -------------------------------------------------------------------------------- /src/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/lib/markdown/rules.ts: -------------------------------------------------------------------------------- 1 | import markdownit, { PluginSimple } from "markdown-it"; 2 | 3 | export default function rules({ 4 | rules = {}, 5 | plugins = [], 6 | }: { 7 | rules?: Record; 8 | plugins?: PluginSimple[]; 9 | }) { 10 | const markdownIt = markdownit("default", { 11 | breaks: false, 12 | html: false, 13 | linkify: false, 14 | ...rules, 15 | }); 16 | plugins.forEach(plugin => markdownIt.use(plugin)); 17 | return markdownIt; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const Input = styled.input` 4 | font-size: 15px; 5 | background: ${props => props.theme.toolbarInput}; 6 | color: ${props => props.theme.toolbarItem}; 7 | border-radius: 2px; 8 | padding: 3px 8px; 9 | border: 0; 10 | margin: 0; 11 | outline: none; 12 | flex-grow: 1; 13 | 14 | @media (hover: none) and (pointer: coarse) { 15 | font-size: 16px; 16 | } 17 | `; 18 | 19 | export default Input; 20 | -------------------------------------------------------------------------------- /src/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/icons/GptIcon.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import React from "react"; 3 | 4 | export default function GptIcon() { 5 | return ( 6 | 7 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/queries/getParentListItem.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "prosemirror-model"; 2 | import { EditorState } from "prosemirror-state"; 3 | 4 | export default function getParentListItem( 5 | state: EditorState 6 | ): [Node, number] | void { 7 | const $head = state.selection.$head; 8 | for (let d = $head.depth; d > 0; d--) { 9 | const node = $head.node(d); 10 | if (["list_item", "checkbox_item"].includes(node.type.name)) { 11 | return [node, $head.before(d)]; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/queries/isInCode.ts: -------------------------------------------------------------------------------- 1 | import isMarkActive from "./isMarkActive"; 2 | import { EditorState } from "prosemirror-state"; 3 | 4 | export default function isInCode(state: EditorState): boolean { 5 | if (state.schema.nodes.code_block) { 6 | const $head = state.selection.$head; 7 | for (let d = $head.depth; d > 0; d--) { 8 | if ($head.node(d).type === state.schema.nodes.code_block) { 9 | return true; 10 | } 11 | } 12 | } 13 | 14 | return isMarkActive(state.schema.marks.code_inline)(state); 15 | } 16 | -------------------------------------------------------------------------------- /src/icons/BlockQuoteIcon.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Icon() { 4 | return ( 5 | 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /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/icons/Heading2Icon.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Icon() { 4 | return ( 5 | 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /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/icons/StrikeThroughIcon.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Icon() { 4 | return ( 5 | 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/rules/notices.ts: -------------------------------------------------------------------------------- 1 | import customFence from "markdown-it-container"; 2 | 3 | export default function notice(md): void { 4 | return customFence(md, "notice", { 5 | marker: ":", 6 | validate: () => true, 7 | render: function(tokens, idx) { 8 | const { info } = tokens[idx]; 9 | 10 | if (tokens[idx].nesting === 1) { 11 | // opening tag 12 | return `
\n`; 13 | } else { 14 | // closing tag 15 | return "
\n"; 16 | } 17 | }, 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/isMarkdown.ts: -------------------------------------------------------------------------------- 1 | export default function isMarkdown(text: string): boolean { 2 | // code-ish 3 | const fences = text.match(/^```/gm); 4 | if (fences && fences.length > 1) return true; 5 | 6 | // link-ish 7 | if (text.match(/\[[^]+\]\(https?:\/\/\S+\)/gm)) return true; 8 | if (text.match(/\[[^]+\]\(\/\S+\)/gm)) return true; 9 | 10 | // heading-ish 11 | if (text.match(/^#{1,6}\s+\S+/gm)) return true; 12 | 13 | // list-ish 14 | const listItems = text.match(/^[\d-*].?\s\S+/gm); 15 | if (listItems && listItems.length > 1) return true; 16 | 17 | return false; 18 | } 19 | -------------------------------------------------------------------------------- /src/queries/isNodeActive.ts: -------------------------------------------------------------------------------- 1 | import { findParentNode, findSelectedNodeOfType } from "prosemirror-utils"; 2 | 3 | const isNodeActive = (type, attrs: Record = {}) => state => { 4 | if (!type) { 5 | return false; 6 | } 7 | 8 | const node = 9 | findSelectedNodeOfType(type)(state.selection) || 10 | findParentNode(node => node.type === type)(state.selection); 11 | 12 | if (!Object.keys(attrs).length || !node) { 13 | return !!node; 14 | } 15 | 16 | return node.node.hasMarkup(type, { ...node.node.attrs, ...attrs }); 17 | }; 18 | 19 | export default isNodeActive; 20 | -------------------------------------------------------------------------------- /src/icons/Heading3Icon.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Icon() { 4 | return ( 5 | 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/nodes/Node.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownSerializerState } from "prosemirror-markdown"; 2 | import { Node as ProsemirrorNode } from "prosemirror-model"; 3 | import Extension from "../lib/Extension"; 4 | 5 | export default abstract class Node extends Extension { 6 | get type() { 7 | return "node"; 8 | } 9 | 10 | abstract get schema(); 11 | 12 | get markdownToken(): string { 13 | return ""; 14 | } 15 | 16 | toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { 17 | console.error("toMarkdown not implemented", state, node); 18 | } 19 | 20 | parseMarkdown() { 21 | return; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/plugins/MaxLength.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, Transaction } from "prosemirror-state"; 2 | import Extension from "../lib/Extension"; 3 | 4 | export default class MaxLength extends Extension { 5 | get name() { 6 | return "maxlength"; 7 | } 8 | 9 | get plugins() { 10 | return [ 11 | new Plugin({ 12 | filterTransaction: (tr: Transaction) => { 13 | if (this.options.maxLength) { 14 | const result = tr.doc && tr.doc.nodeSize > this.options.maxLength; 15 | return !result; 16 | } 17 | 18 | return true; 19 | }, 20 | }), 21 | ]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/hooks/useMediaQuery.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | export default function useMediaQuery(query: string): boolean { 4 | const [matches, setMatches] = useState(false); 5 | 6 | useEffect(() => { 7 | if (window.matchMedia) { 8 | const media = window.matchMedia(query); 9 | if (media.matches !== matches) { 10 | setMatches(media.matches); 11 | } 12 | const listener = () => { 13 | setMatches(media.matches); 14 | }; 15 | media.addListener(listener); 16 | return () => media.removeListener(listener); 17 | } 18 | }, [matches, query]); 19 | 20 | return matches; 21 | } 22 | -------------------------------------------------------------------------------- /src/icons/BulletedListIcon.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Icon() { 4 | return ( 5 | 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /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 | defaultHidden?: boolean; 17 | attrs?: Record; 18 | visible?: boolean; 19 | active?: (state: EditorState) => boolean; 20 | }; 21 | 22 | export type EmbedDescriptor = MenuItem & { 23 | matcher: (url: string) => boolean | [] | RegExpMatchArray; 24 | component: typeof React.Component | React.FC; 25 | }; 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "strict": false, 5 | "strictNullChecks": true, 6 | "noImplicitAny": false, 7 | "esModuleInterop": true, 8 | "allowJs": true, 9 | "resolveJsonModule": true, 10 | "module": "commonjs", 11 | "target": "es2017", 12 | "lib": ["dom", "es2018"], 13 | "jsx": "react", 14 | "types": [ 15 | "react", 16 | "jest" 17 | ], 18 | "rootDir": "src", 19 | "outDir": "dist", 20 | "stripInternal": true, 21 | "removeComments": true, 22 | "declarationMap": true, 23 | "declaration": true, 24 | }, 25 | "include": [ 26 | "src/**/*" 27 | ], 28 | "exclude": [ 29 | "dist", 30 | "node_modules" 31 | ] 32 | } -------------------------------------------------------------------------------- /src/rules/underlines.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from "markdown-it"; 2 | 3 | export default function markdownUnderlines(md: MarkdownIt) { 4 | md.inline.ruler2.after("emphasis", "underline", state => { 5 | const tokens = state.tokens; 6 | 7 | for (let i = tokens.length - 1; i > 0; i--) { 8 | const token = tokens[i]; 9 | 10 | if (token.markup === "__") { 11 | if (token.type === "strong_open") { 12 | tokens[i].tag = "underline"; 13 | tokens[i].type = "underline_open"; 14 | } 15 | if (token.type === "strong_close") { 16 | tokens[i].tag = "underline"; 17 | tokens[i].type = "underline_close"; 18 | } 19 | } 20 | } 21 | 22 | return false; 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /src/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/icons/OrderedListIcon.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Icon() { 4 | return ( 5 | 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/plugins/Gpt.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { Plugin } from "prosemirror-state"; 3 | import Extension from "../lib/Extension"; 4 | 5 | export default class Gpt extends Extension { 6 | get name() { 7 | return "gpt"; 8 | } 9 | 10 | commands() { 11 | return () => { 12 | const _this = this; 13 | return (state, dispatch) => { 14 | const text = state.doc.cut( 15 | state.selection.from, 16 | state.selection.to 17 | ); 18 | const markdown = _this.editor.serializer.serialize(text); 19 | _this.editor.props.onSelect({ 20 | view: _this.editor.view, 21 | text: text.textContent, 22 | markdown 23 | }); 24 | 25 | this.options.onGpt(markdown); 26 | } 27 | } 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/icons/CodeIcon.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function CodeIcon() { 4 | return ( 5 | 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/commands/backspaceToParagraph.ts: -------------------------------------------------------------------------------- 1 | export default function backspaceToParagraph(type) { 2 | return (state, dispatch) => { 3 | if (!state) return null; 4 | 5 | const { $from, from, to, empty } = state.selection; 6 | 7 | // if the selection has anything in it then use standard delete behavior 8 | if (!empty) return null; 9 | 10 | // check we're in a matching node 11 | if ($from.parent.type !== type) return null; 12 | 13 | // check if we're at the beginning of the heading 14 | const $pos = state.doc.resolve(from - 1); 15 | if ($pos.parent === $from.parent) return null; 16 | 17 | // okay, replace it with a paragraph 18 | dispatch( 19 | state.tr 20 | .setBlockType(from, to, type.schema.nodes.paragraph) 21 | .scrollIntoView() 22 | ); 23 | return true; 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/commands/wrapInCodeBlock.ts: -------------------------------------------------------------------------------- 1 | import { NodeType } from "prosemirror-model"; 2 | import { EditorState, Transaction } from "prosemirror-state"; 3 | 4 | export default function wrapInCodeBlock(type: NodeType, attrs = {}) { 5 | return (state: EditorState, dispatch?: (tr: Transaction) => void) => { 6 | const { from, to } = state.selection; 7 | if (!dispatch) return true; 8 | 9 | const selectedText = state.doc.textBetween(from, to, "\n"); 10 | if (!selectedText || selectedText.trim() === "") return false; 11 | 12 | const tr = state.tr; 13 | const textNode = state.schema.text(selectedText); // keeps \n inside 14 | const codeNode = type.create(attrs, textNode); 15 | 16 | tr.replaceRangeWith(from, to, codeNode); // replaces in one step 17 | dispatch(tr.scrollIntoView()); 18 | return true; 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | - image: circleci/node:14 10 | 11 | working_directory: ~/repo 12 | 13 | steps: 14 | - checkout 15 | 16 | # Download and cache dependencies 17 | - restore_cache: 18 | keys: 19 | - v2-dependencies-{{ checksum "package.json" }} 20 | # fallback to using the latest cache if no exact match is found 21 | - v2-dependencies- 22 | 23 | - run: yarn install --pure-lockfile 24 | 25 | - save_cache: 26 | paths: 27 | - node_modules 28 | key: v2-dependencies-{{ checksum "package.json" }} 29 | 30 | - run: yarn lint 31 | - run: yarn test 32 | 33 | -------------------------------------------------------------------------------- /src/lib/filterExcessSeparators.ts: -------------------------------------------------------------------------------- 1 | import { EmbedDescriptor, MenuItem } from "../types"; 2 | 3 | export default function filterExcessSeparators( 4 | items: (MenuItem | EmbedDescriptor)[] 5 | ): (MenuItem | EmbedDescriptor)[] { 6 | return items.reduce((acc, item, index) => { 7 | // trim separators from start / end 8 | if (item.name === "separator" && index === 0) return acc; 9 | if (item.name === "separator" && index === items.length - 1) return acc; 10 | 11 | // trim double separators looking ahead / behind 12 | const prev = items[index - 1]; 13 | if (prev && prev.name === "separator" && item.name === "separator") 14 | return acc; 15 | 16 | const next = items[index + 1]; 17 | if (next && next.name === "separator" && item.name === "separator") 18 | return acc; 19 | 20 | // otherwise, continue 21 | return [...acc, item]; 22 | }, []); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/EmojiMenuItem.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import BlockMenuItem, { Props as BlockMenuItemProps } from "./BlockMenuItem"; 3 | import styled from "styled-components"; 4 | 5 | const Emoji = styled.span` 6 | font-size: 16px; 7 | `; 8 | 9 | const EmojiTitle = ({ 10 | emoji, 11 | title, 12 | }: { 13 | emoji: React.ReactNode; 14 | title: React.ReactNode; 15 | }) => { 16 | return ( 17 |

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

22 | ); 23 | }; 24 | 25 | type EmojiMenuItemProps = Omit & { 26 | emoji: string; 27 | }; 28 | 29 | export default function EmojiMenuItem(props: EmojiMenuItemProps) { 30 | return ( 31 | } 34 | /> 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/icons/LinkIcon.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Icon() { 4 | return ( 5 | 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/menus/divider.tsx: -------------------------------------------------------------------------------- 1 | import { EditorState } from "prosemirror-state"; 2 | import { PageBreakIcon, HorizontalRuleIcon } from "outline-icons"; 3 | import isNodeActive from "../queries/isNodeActive"; 4 | import { MenuItem } from "../types"; 5 | import baseDictionary from "../dictionary"; 6 | 7 | export default function dividerMenuItems( 8 | state: EditorState, 9 | dictionary: typeof baseDictionary 10 | ): MenuItem[] { 11 | const { schema } = state; 12 | 13 | return [ 14 | { 15 | name: "hr", 16 | tooltip: dictionary.pageBreak, 17 | attrs: { markup: "***" }, 18 | active: isNodeActive(schema.nodes.hr, { markup: "***" }), 19 | icon: PageBreakIcon, 20 | }, 21 | { 22 | name: "hr", 23 | tooltip: dictionary.hr, 24 | attrs: { markup: "---" }, 25 | active: isNodeActive(schema.nodes.hr, { markup: "---" }), 26 | icon: HorizontalRuleIcon, 27 | }, 28 | ]; 29 | } 30 | -------------------------------------------------------------------------------- /src/components/ToolbarButton.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | type Props = { active?: boolean; disabled?: boolean }; 4 | 5 | export default styled.button` 6 | display: inline-block; 7 | flex: 0; 8 | width: 24px; 9 | height: 24px; 10 | cursor: pointer; 11 | margin-left: 8px; 12 | border: none; 13 | background: none; 14 | transition: opacity 100ms ease-in-out; 15 | padding: 0; 16 | opacity: 0.7; 17 | outline: none; 18 | pointer-events: all; 19 | position: relative; 20 | 21 | &:first-child { 22 | margin-left: 0; 23 | } 24 | 25 | &:hover { 26 | opacity: 1; 27 | } 28 | 29 | &:disabled { 30 | opacity: 0.3; 31 | cursor: default; 32 | } 33 | 34 | &:before { 35 | position: absolute; 36 | content: ""; 37 | top: -4px; 38 | right: -4px; 39 | left: -4px; 40 | bottom: -4px; 41 | } 42 | 43 | ${props => props.active && "opacity: 1;"}; 44 | `; 45 | -------------------------------------------------------------------------------- /src/components/Flex.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | type JustifyValues = 5 | | "center" 6 | | "space-around" 7 | | "space-between" 8 | | "flex-start" 9 | | "flex-end"; 10 | 11 | type AlignValues = 12 | | "stretch" 13 | | "center" 14 | | "baseline" 15 | | "flex-start" 16 | | "flex-end"; 17 | 18 | type Props = { 19 | style?: React.CSSProperties; 20 | column?: boolean; 21 | align?: AlignValues; 22 | justify?: JustifyValues; 23 | auto?: boolean; 24 | className?: string; 25 | children?: React.ReactNode; 26 | }; 27 | 28 | const Flex = styled.div` 29 | display: flex; 30 | flex: ${({ auto }: Props) => (auto ? "1 1 auto" : "initial")}; 31 | flex-direction: ${({ column }: Props) => (column ? "column" : "row")}; 32 | align-items: ${({ align }: Props) => align}; 33 | justify-content: ${({ justify }: Props) => justify}; 34 | `; 35 | 36 | export default Flex; 37 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "plugin:react/recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "prettier/@typescript-eslint", 7 | "plugin:prettier/recommended", 8 | "plugin:import/errors", 9 | "plugin:import/warnings", 10 | "plugin:import/typescript" 11 | ], 12 | "plugins": [ 13 | "jsx-a11y" 14 | ], 15 | "rules": { 16 | "eqeqeq": 2, 17 | "no-unused-vars": "off", 18 | "no-mixed-operators": "off", 19 | "jsx-a11y/href-no-hash": "off", 20 | "react/prop-types": "off", 21 | "@typescript-eslint/no-unused-vars": [ 22 | "error" 23 | ], 24 | "@typescript-eslint/explicit-function-return-type": "off", 25 | "prettier/prettier": [ 26 | "error", 27 | { 28 | "printWidth": 80, 29 | "trailingComma": "es5" 30 | } 31 | ] 32 | }, 33 | "settings": { 34 | "react": { 35 | "version": "detect" 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /src/queries/findCollapsedNodes.ts: -------------------------------------------------------------------------------- 1 | import { findBlockNodes, NodeWithPos } from "prosemirror-utils"; 2 | import { Node } from "prosemirror-model"; 3 | 4 | export default function findCollapsedNodes(doc: Node): NodeWithPos[] { 5 | const blocks = findBlockNodes(doc); 6 | const nodes: NodeWithPos[] = []; 7 | 8 | let withinCollapsedHeading; 9 | 10 | for (const block of blocks) { 11 | if (block.node.type.name === "heading") { 12 | if ( 13 | !withinCollapsedHeading || 14 | block.node.attrs.level <= withinCollapsedHeading 15 | ) { 16 | if (block.node.attrs.collapsed) { 17 | if (!withinCollapsedHeading) { 18 | withinCollapsedHeading = block.node.attrs.level; 19 | } 20 | } else { 21 | withinCollapsedHeading = undefined; 22 | } 23 | continue; 24 | } 25 | } 26 | 27 | if (withinCollapsedHeading) { 28 | nodes.push(block); 29 | } 30 | } 31 | 32 | return nodes; 33 | } 34 | -------------------------------------------------------------------------------- /src/icons/CheckListIcon.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Icon() { 4 | return ( 5 | 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /.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/lib/renderToHtml.ts: -------------------------------------------------------------------------------- 1 | import createMarkdown from "./markdown/rules"; 2 | import { PluginSimple } from "markdown-it"; 3 | import markRule from "../rules/mark"; 4 | import checkboxRule from "../rules/checkboxes"; 5 | import embedsRule from "../rules/embeds"; 6 | import breakRule from "../rules/breaks"; 7 | import tablesRule from "../rules/tables"; 8 | import noticesRule from "../rules/notices"; 9 | import underlinesRule from "../rules/underlines"; 10 | import emojiRule from "../rules/emoji"; 11 | 12 | const defaultRules = [ 13 | embedsRule, 14 | breakRule, 15 | checkboxRule, 16 | markRule({ delim: "==", mark: "highlight" }), 17 | markRule({ delim: "!!", mark: "placeholder" }), 18 | underlinesRule, 19 | tablesRule, 20 | noticesRule, 21 | emojiRule, 22 | ]; 23 | 24 | export default function renderToHtml( 25 | markdown: string, 26 | rulePlugins: PluginSimple[] = defaultRules 27 | ): string { 28 | return createMarkdown({ plugins: rulePlugins }) 29 | .render(markdown) 30 | .trim(); 31 | } 32 | -------------------------------------------------------------------------------- /src/marks/Highlight.ts: -------------------------------------------------------------------------------- 1 | import { toggleMark } from "prosemirror-commands"; 2 | import markInputRule from "../lib/markInputRule"; 3 | import Mark from "./Mark"; 4 | import markRule from "../rules/mark"; 5 | 6 | export default class Highlight extends Mark { 7 | get name() { 8 | return "highlight"; 9 | } 10 | 11 | get schema() { 12 | return { 13 | parseDOM: [{ tag: "mark" }], 14 | toDOM: () => ["mark"], 15 | }; 16 | } 17 | 18 | inputRules({ type }) { 19 | return [markInputRule(/(?:==)([^=]+)(?:==)$/, type)]; 20 | } 21 | 22 | keys({ type }) { 23 | return { 24 | "Mod-Ctrl-h": toggleMark(type), 25 | }; 26 | } 27 | 28 | get rulePlugins() { 29 | return [markRule({ delim: "==", mark: "highlight" })]; 30 | } 31 | 32 | get toMarkdown() { 33 | return { 34 | open: "==", 35 | close: "==", 36 | mixable: true, 37 | expelEnclosingWhitespace: true, 38 | }; 39 | } 40 | 41 | parseMarkdown() { 42 | return { mark: "highlight" }; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/marks/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/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/lib/headingToSlug.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "prosemirror-model"; 2 | import escape from "lodash/escape"; 3 | import slugify from "slugify"; 4 | 5 | // Slugify, escape, and remove periods from headings so that they are 6 | // compatible with both url hashes AND dom ID's (querySelector does not like 7 | // ID's that begin with a number or a period, for example). 8 | function safeSlugify(text: string) { 9 | return `h-${escape( 10 | slugify(text, { 11 | remove: /[!"#$%&'\.()*+,\/:;<=>?@\[\]\\^_`{|}~]/g, 12 | lower: true, 13 | }) 14 | )}`; 15 | } 16 | 17 | // calculates a unique slug for this heading based on it's text and position 18 | // in the document that is as stable as possible 19 | export default function headingToSlug(node: Node, index = 0) { 20 | const slugified = safeSlugify(node.textContent); 21 | if (index === 0) return slugified; 22 | return `${slugified}-${index}`; 23 | } 24 | 25 | export function headingToPersistenceKey(node: Node, id?: string) { 26 | const slug = headingToSlug(node); 27 | return `rme-${id || window?.location.pathname}–${slug}`; 28 | } 29 | -------------------------------------------------------------------------------- /src/marks/Italic.ts: -------------------------------------------------------------------------------- 1 | import { toggleMark } from "prosemirror-commands"; 2 | import markInputRule from "../lib/markInputRule"; 3 | import Mark from "./Mark"; 4 | 5 | export default class Italic extends Mark { 6 | get name() { 7 | return "em"; 8 | } 9 | 10 | get schema() { 11 | return { 12 | parseDOM: [ 13 | { tag: "i" }, 14 | { tag: "em" }, 15 | { style: "font-style", getAttrs: value => value === "italic" }, 16 | ], 17 | toDOM: () => ["em"], 18 | }; 19 | } 20 | 21 | inputRules({ type }) { 22 | return [ 23 | markInputRule(/(?:^|[\s])(_([^_]+)_)$/, type), 24 | markInputRule(/(?:^|[^*])(\*([^*]+)\*)$/, type), 25 | ]; 26 | } 27 | 28 | keys({ type }) { 29 | return { 30 | "Mod-i": toggleMark(type), 31 | "Mod-I": toggleMark(type), 32 | }; 33 | } 34 | 35 | get toMarkdown() { 36 | return { 37 | open: "*", 38 | close: "*", 39 | mixable: true, 40 | expelEnclosingWhitespace: true, 41 | }; 42 | } 43 | 44 | parseMarkdown() { 45 | return { mark: "em" }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/marks/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/marks/Underline.ts: -------------------------------------------------------------------------------- 1 | import { toggleMark } from "prosemirror-commands"; 2 | import markInputRule from "../lib/markInputRule"; 3 | import Mark from "./Mark"; 4 | import underlinesRule from "../rules/underlines"; 5 | 6 | export default class Underline extends Mark { 7 | get name() { 8 | return "underline"; 9 | } 10 | 11 | get schema() { 12 | return { 13 | parseDOM: [ 14 | { tag: "u" }, 15 | { 16 | style: "text-decoration", 17 | getAttrs: value => value === "underline", 18 | }, 19 | ], 20 | toDOM: () => ["u", 0], 21 | }; 22 | } 23 | 24 | get rulePlugins() { 25 | return [underlinesRule]; 26 | } 27 | 28 | inputRules({ type }) { 29 | return [markInputRule(/(?:__)([^_]+)(?:__)$/, type)]; 30 | } 31 | 32 | keys({ type }) { 33 | return { 34 | "Mod-u": toggleMark(type), 35 | }; 36 | } 37 | 38 | get toMarkdown() { 39 | return { 40 | open: "__", 41 | close: "__", 42 | mixable: true, 43 | expelEnclosingWhitespace: true, 44 | }; 45 | } 46 | 47 | parseMarkdown() { 48 | return { mark: "underline" }; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/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", preserveWhitespace: true }], 14 | toDOM: () => ["p", 0], 15 | }; 16 | } 17 | 18 | keys({ type }) { 19 | return { 20 | "Shift-Ctrl-0": setBlockType(type), 21 | }; 22 | } 23 | 24 | commands({ type }) { 25 | return () => setBlockType(type); 26 | } 27 | 28 | toMarkdown(state, node) { 29 | // render empty paragraphs as hard breaks to ensure that newlines are 30 | // persisted between reloads (this breaks from markdown tradition) 31 | if ( 32 | node.textContent.trim() === "" && 33 | node.childCount === 0 && 34 | !state.inTable 35 | ) { 36 | state.write("\\\n"); 37 | } else { 38 | state.renderInline(node); 39 | state.closeBlock(node); 40 | } 41 | } 42 | 43 | parseMarkdown() { 44 | return { block: "paragraph" }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/icons/CodeBlockIcon.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import React from "react"; 3 | 4 | export default function GptIcon() { 5 | // return ( 6 | // 7 | // ) 8 | return ( 9 | 10 | 11 | // 12 | // 13 | // 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/nodes/HardBreak.ts: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | import { isInTable } from "prosemirror-tables"; 3 | import breakRule from "../rules/breaks"; 4 | 5 | export default class HardBreak extends Node { 6 | get name() { 7 | return "br"; 8 | } 9 | 10 | get schema() { 11 | return { 12 | inline: true, 13 | group: "inline", 14 | selectable: false, 15 | parseDOM: [{ tag: "br" }], 16 | toDOM() { 17 | return ["br"]; 18 | }, 19 | }; 20 | } 21 | 22 | get rulePlugins() { 23 | return [breakRule]; 24 | } 25 | 26 | commands({ type }) { 27 | return () => (state, dispatch) => { 28 | dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); 29 | return true; 30 | }; 31 | } 32 | 33 | keys({ type }) { 34 | return { 35 | "Shift-Enter": (state, dispatch) => { 36 | if (!isInTable(state)) return false; 37 | dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); 38 | return true; 39 | }, 40 | }; 41 | } 42 | 43 | toMarkdown(state) { 44 | state.write(" \\n "); 45 | } 46 | 47 | parseMarkdown() { 48 | return { node: "br" }; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/lib/Extension.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { InputRule } from "prosemirror-inputrules"; 3 | import { Plugin } from "prosemirror-state"; 4 | import Editor from "../"; 5 | import { PluginSimple } from "markdown-it"; 6 | 7 | type Command = (attrs) => (state, dispatch) => any; 8 | 9 | export default class Extension { 10 | options: Record; 11 | editor: Editor; 12 | 13 | constructor(options: Record = {}) { 14 | this.options = { 15 | ...this.defaultOptions, 16 | ...options, 17 | }; 18 | } 19 | 20 | bindEditor(editor: Editor) { 21 | this.editor = editor; 22 | } 23 | 24 | get type() { 25 | return "extension"; 26 | } 27 | 28 | get name() { 29 | return ""; 30 | } 31 | 32 | get plugins(): Plugin[] { 33 | return []; 34 | } 35 | 36 | get rulePlugins(): PluginSimple[] { 37 | return []; 38 | } 39 | 40 | keys(options) { 41 | return {}; 42 | } 43 | 44 | inputRules(options): InputRule[] { 45 | return []; 46 | } 47 | 48 | commands(options): Record | Command { 49 | return attrs => () => false; 50 | } 51 | 52 | get defaultOptions() { 53 | return {}; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/nodes/Blockquote.ts: -------------------------------------------------------------------------------- 1 | import { wrappingInputRule } from "prosemirror-inputrules"; 2 | import Node from "./Node"; 3 | import toggleWrap from "../commands/toggleWrap"; 4 | import isNodeActive from "../queries/isNodeActive"; 5 | 6 | export default class Blockquote extends Node { 7 | get name() { 8 | return "blockquote"; 9 | } 10 | 11 | get schema() { 12 | return { 13 | content: "block+", 14 | group: "block", 15 | defining: true, 16 | parseDOM: [{ tag: "blockquote" }], 17 | toDOM: () => ["blockquote", 0], 18 | }; 19 | } 20 | 21 | inputRules({ type }) { 22 | return [wrappingInputRule(/^\s*>\s$/, type)]; 23 | } 24 | 25 | commands({ type }) { 26 | return () => toggleWrap(type); 27 | } 28 | 29 | keys({ type }) { 30 | return { 31 | "Ctrl->": toggleWrap(type), 32 | "Mod-]": toggleWrap(type), 33 | "Shift-Enter": (state, dispatch) => { 34 | if (!isNodeActive(type)(state)) { 35 | return false; 36 | } 37 | 38 | const { tr, selection } = state; 39 | dispatch(tr.split(selection.to)); 40 | return true; 41 | }, 42 | }; 43 | } 44 | 45 | toMarkdown(state, node) { 46 | state.wrapBlock("> ", null, node, () => state.renderContent(node)); 47 | } 48 | 49 | parseMarkdown() { 50 | return { block: "blockquote" }; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/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/components/BlockMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { findParentNode } from "prosemirror-utils"; 3 | import CommandMenu, { Props } from "./CommandMenu"; 4 | import BlockMenuItem from "./BlockMenuItem"; 5 | import getMenuItems from "../menus/block"; 6 | 7 | type BlockMenuProps = Omit< 8 | Props, 9 | "renderMenuItem" | "items" | "onClearSearch" 10 | > & 11 | Required>; 12 | 13 | class BlockMenu extends React.Component { 14 | get items() { 15 | return getMenuItems(this.props.dictionary); 16 | } 17 | 18 | clearSearch = () => { 19 | const { state, dispatch } = this.props.view; 20 | const parent = findParentNode(node => !!node)(state.selection); 21 | 22 | if (parent) { 23 | dispatch(state.tr.insertText("", parent.pos, state.selection.to)); 24 | } 25 | }; 26 | 27 | render() { 28 | return ( 29 | { 34 | return ( 35 | 42 | ); 43 | }} 44 | items={this.items} 45 | /> 46 | ); 47 | } 48 | } 49 | 50 | export default BlockMenu; 51 | -------------------------------------------------------------------------------- /src/hooks/useViewportHeight.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useState, useRef } from "react"; 2 | 3 | export default function useViewportHeight(): number | void { 4 | // Get initial height from visualViewport or fallback to innerHeight 5 | const [height, setHeight] = useState( 6 | () => window.visualViewport?.height || window.innerHeight 7 | ); 8 | 9 | const timeoutRef = useRef(null); 10 | 11 | useLayoutEffect(() => { 12 | // Debounced resize handler 13 | const handleResize = () => { 14 | // Clear any existing timeout 15 | if (timeoutRef.current) { 16 | window.clearTimeout(timeoutRef.current); 17 | } 18 | // Set a new timeout 19 | timeoutRef.current = window.setTimeout(() => { 20 | const newHeight = window.visualViewport?.height || window.innerHeight; 21 | setHeight(newHeight); 22 | }, 16); // Roughly one frame at 60fps 23 | }; 24 | 25 | // Add event listener 26 | window.visualViewport?.addEventListener("resize", handleResize); 27 | // Also listen to window resize as a fallback 28 | window.addEventListener("resize", handleResize); 29 | 30 | // Cleanup function 31 | return () => { 32 | if (timeoutRef.current) { 33 | window.clearTimeout(timeoutRef.current); 34 | } 35 | window.visualViewport?.removeEventListener("resize", handleResize); 36 | window.removeEventListener("resize", handleResize); 37 | }; 38 | }, []); 39 | 40 | return height; 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/hooks/useComponentSize.ts: -------------------------------------------------------------------------------- 1 | import ResizeObserver from "resize-observer-polyfill"; 2 | import { useState, useEffect, useRef } from "react"; 3 | 4 | export default function useComponentSize( 5 | ref 6 | ): { width: number; height: number } { 7 | const [size, setSize] = useState({ 8 | width: 0, 9 | height: 0, 10 | }); 11 | 12 | // Properly type the timeout reference 13 | const timeoutRef = useRef(null); 14 | 15 | useEffect(() => { 16 | if (!ref.current) return; 17 | 18 | // Initial size measurement 19 | setSize({ 20 | width: ref.current.clientWidth, 21 | height: ref.current.clientHeight 22 | }); 23 | 24 | const handleResize = (entries: ResizeObserverEntry[]) => { 25 | if (timeoutRef.current !== null) { 26 | window.clearTimeout(timeoutRef.current); 27 | } 28 | 29 | timeoutRef.current = window.setTimeout(() => { 30 | entries.forEach(({ target }) => { 31 | if ( 32 | size.width !== (target as HTMLElement).clientWidth || 33 | size.height !== (target as HTMLElement).clientHeight 34 | ) { 35 | setSize({ 36 | width: (target as HTMLElement).clientWidth, 37 | height: (target as HTMLElement).clientHeight 38 | }); 39 | } 40 | }); 41 | }, 20); // 20ms debounce 42 | }; 43 | 44 | const sizeObserver = new ResizeObserver(handleResize); 45 | sizeObserver.observe(ref.current); 46 | 47 | return () => { 48 | if (timeoutRef.current !== null) { 49 | window.clearTimeout(timeoutRef.current); 50 | } 51 | sizeObserver.disconnect(); 52 | }; 53 | }, [ref]); 54 | 55 | return size; 56 | } 57 | -------------------------------------------------------------------------------- /src/nodes/HorizontalRule.ts: -------------------------------------------------------------------------------- 1 | import { InputRule } from "prosemirror-inputrules"; 2 | import Node from "./Node"; 3 | 4 | export default class HorizontalRule extends Node { 5 | get name() { 6 | return "hr"; 7 | } 8 | 9 | get schema() { 10 | return { 11 | attrs: { 12 | markup: { 13 | default: "---", 14 | }, 15 | }, 16 | group: "block", 17 | parseDOM: [{ tag: "hr" }], 18 | toDOM: node => { 19 | return [ 20 | "hr", 21 | { class: node.attrs.markup === "***" ? "page-break" : "" }, 22 | ]; 23 | }, 24 | }; 25 | } 26 | 27 | commands({ type }) { 28 | return attrs => (state, dispatch) => { 29 | dispatch( 30 | state.tr.replaceSelectionWith(type.create(attrs)).scrollIntoView() 31 | ); 32 | return true; 33 | }; 34 | } 35 | 36 | keys({ type }) { 37 | return { 38 | "Mod-_": (state, dispatch) => { 39 | dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); 40 | return true; 41 | }, 42 | }; 43 | } 44 | 45 | inputRules({ type }) { 46 | return [ 47 | new InputRule(/^(?:---|___\s|\*\*\*\s)$/, (state, match, start, end) => { 48 | const { tr } = state; 49 | 50 | if (match[0]) { 51 | const markup = match[0].trim(); 52 | tr.replaceWith(start - 1, end, type.create({ markup })); 53 | } 54 | 55 | return tr; 56 | }), 57 | ]; 58 | } 59 | 60 | toMarkdown(state, node) { 61 | state.write(`\n${node.attrs.markup}`); 62 | state.closeBlock(node); 63 | } 64 | 65 | parseMarkdown() { 66 | return { 67 | node: "hr", 68 | getAttrs: tok => ({ 69 | markup: tok.markup, 70 | }), 71 | }; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/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/rules/breaks.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from "markdown-it"; 2 | import Token from "markdown-it/lib/token"; 3 | 4 | function isHardbreak(token: Token) { 5 | return ( 6 | token.type === "hardbreak" || 7 | (token.type === "text" && token.content === "\\") 8 | ); 9 | } 10 | 11 | export default function markdownBreakToParagraphs(md: MarkdownIt) { 12 | // insert a new rule after the "inline" rules are parsed 13 | md.core.ruler.after("inline", "breaks", state => { 14 | const { Token } = state; 15 | const tokens = state.tokens; 16 | 17 | // work backwards through the tokens and find text that looks like a br 18 | for (let i = tokens.length - 1; i > 0; i--) { 19 | const tokenChildren = tokens[i].children || []; 20 | const matches = tokenChildren.filter(isHardbreak); 21 | 22 | if (matches.length) { 23 | let token; 24 | 25 | const nodes: Token[] = []; 26 | const children = tokenChildren.filter(child => !isHardbreak(child)); 27 | 28 | let count = matches.length; 29 | if (!!children.length) count++; 30 | 31 | for (let i = 0; i < count; i++) { 32 | const isLast = i === count - 1; 33 | 34 | token = new Token("paragraph_open", "p", 1); 35 | nodes.push(token); 36 | 37 | const text = new Token("text", "", 0); 38 | text.content = ""; 39 | 40 | token = new Token("inline", "", 0); 41 | token.level = 1; 42 | token.children = isLast ? [text, ...children] : [text]; 43 | token.content = ""; 44 | nodes.push(token); 45 | 46 | token = new Token("paragraph_close", "p", -1); 47 | nodes.push(token); 48 | } 49 | 50 | tokens.splice(i - 1, 3, ...nodes); 51 | } 52 | } 53 | 54 | return false; 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /src/components/ToolbarMenu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { EditorView } from "prosemirror-view"; 3 | import styled, { withTheme } from "styled-components"; 4 | import ToolbarButton from "./ToolbarButton"; 5 | import ToolbarSeparator from "./ToolbarSeparator"; 6 | import theme from "../styles/theme"; 7 | import { MenuItem } from "../types"; 8 | 9 | type Props = { 10 | tooltip: typeof React.Component | React.FC; 11 | commands: Record; 12 | view: EditorView; 13 | theme: typeof theme; 14 | items: MenuItem[]; 15 | }; 16 | 17 | const FlexibleWrapper = styled.div` 18 | display: flex; 19 | `; 20 | 21 | class ToolbarMenu extends React.Component { 22 | render() { 23 | const { view, items } = this.props; 24 | const { state } = view; 25 | const Tooltip = this.props.tooltip; 26 | 27 | return ( 28 | 29 | {items.map((item, index) => { 30 | if (item.name === "separator" && item.visible !== false) { 31 | return ; 32 | } 33 | if (item.visible === false || !item.icon) { 34 | return null; 35 | } 36 | const Icon = item.icon; 37 | const isActive = item.active ? item.active(state) : false; 38 | 39 | return ( 40 | 43 | item.name && this.props.commands[item.name](item.attrs) 44 | } 45 | active={isActive} 46 | > 47 | 48 | 49 | 50 | 51 | ); 52 | })} 53 | 54 | ); 55 | } 56 | } 57 | 58 | export default withTheme(ToolbarMenu); 59 | -------------------------------------------------------------------------------- /src/marks/Code.ts: -------------------------------------------------------------------------------- 1 | import { toggleMark } from "prosemirror-commands"; 2 | import markInputRule from "../lib/markInputRule"; 3 | import moveLeft from "../commands/moveLeft"; 4 | import moveRight from "../commands/moveRight"; 5 | import Mark from "./Mark"; 6 | 7 | function backticksFor(node, side) { 8 | const ticks = /`+/g; 9 | let match: RegExpMatchArray | null; 10 | let len = 0; 11 | 12 | if (node.isText) { 13 | while ((match = ticks.exec(node.text))) { 14 | len = Math.max(len, match[0].length); 15 | } 16 | } 17 | 18 | let result = len > 0 && side > 0 ? " `" : "`"; 19 | for (let i = 0; i < len; i++) { 20 | result += "`"; 21 | } 22 | if (len > 0 && side < 0) { 23 | result += " "; 24 | } 25 | return result; 26 | } 27 | 28 | export default class Code extends Mark { 29 | get name() { 30 | return "code_inline"; 31 | } 32 | 33 | get schema() { 34 | return { 35 | excludes: "_", 36 | parseDOM: [{ tag: "code", preserveWhitespace: true }], 37 | toDOM: () => ["code", { spellCheck: false }], 38 | }; 39 | } 40 | 41 | inputRules({ type }) { 42 | return [markInputRule(/(?:^|[^`])(`([^`]+)`)$/, type)]; 43 | } 44 | 45 | keys({ type }) { 46 | // Note: This key binding only works on non-Mac platforms 47 | // https://github.com/ProseMirror/prosemirror/issues/515 48 | return { 49 | "Mod`": toggleMark(type), 50 | ArrowLeft: moveLeft(), 51 | ArrowRight: moveRight(), 52 | }; 53 | } 54 | 55 | get toMarkdown() { 56 | return { 57 | open(_state, _mark, parent, index) { 58 | return backticksFor(parent.child(index), -1); 59 | }, 60 | close(_state, _mark, parent, index) { 61 | return backticksFor(parent.child(index - 1), 1); 62 | }, 63 | escape: false, 64 | }; 65 | } 66 | 67 | parseMarkdown() { 68 | return { mark: "code_inline" }; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/lib/isMarkdown.test.ts: -------------------------------------------------------------------------------- 1 | import isMarkdown from "./isMarkdown"; 2 | 3 | test("returns false for an empty string", () => { 4 | expect(isMarkdown("")).toBe(false); 5 | }); 6 | 7 | test("returns false for plain text", () => { 8 | expect(isMarkdown("plain text")).toBe(false); 9 | }); 10 | 11 | test("returns true for bullet list", () => { 12 | expect( 13 | isMarkdown(`- item one 14 | - item two 15 | - nested item`) 16 | ).toBe(true); 17 | }); 18 | 19 | test("returns true for numbered list", () => { 20 | expect( 21 | isMarkdown(`1. item one 22 | 1. item two`) 23 | ).toBe(true); 24 | expect( 25 | isMarkdown(`1. item one 26 | 2. item two`) 27 | ).toBe(true); 28 | }); 29 | 30 | test("returns true for code fence", () => { 31 | expect( 32 | isMarkdown(`\`\`\`javascript 33 | this is code 34 | \`\`\``) 35 | ).toBe(true); 36 | }); 37 | 38 | test("returns false for non-closed fence", () => { 39 | expect( 40 | isMarkdown(`\`\`\` 41 | this is not code 42 | `) 43 | ).toBe(false); 44 | }); 45 | 46 | test("returns true for heading", () => { 47 | expect(isMarkdown(`# Heading 1`)).toBe(true); 48 | expect(isMarkdown(`## Heading 2`)).toBe(true); 49 | expect(isMarkdown(`### Heading 3`)).toBe(true); 50 | }); 51 | 52 | test("returns false for hashtag", () => { 53 | expect(isMarkdown(`Test #hashtag`)).toBe(false); 54 | expect(isMarkdown(` #hashtag`)).toBe(false); 55 | }); 56 | 57 | test("returns true for absolute link", () => { 58 | expect(isMarkdown(`[title](http://www.google.com)`)).toBe(true); 59 | }); 60 | 61 | test("returns true for relative link", () => { 62 | expect(isMarkdown(`[title](/doc/mydoc-234tnes)`)).toBe(true); 63 | }); 64 | 65 | test("returns true for relative image", () => { 66 | expect(isMarkdown(`![alt](/coolimage.png)`)).toBe(true); 67 | }); 68 | 69 | test("returns true for absolute image", () => { 70 | expect(isMarkdown(`![alt](https://www.google.com/coolimage.png)`)).toBe(true); 71 | }); 72 | -------------------------------------------------------------------------------- /src/commands/splitHeading.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, TextSelection } from "prosemirror-state"; 2 | import { findBlockNodes } from "prosemirror-utils"; 3 | import { NodeType } from "prosemirror-model"; 4 | import findCollapsedNodes from "../queries/findCollapsedNodes"; 5 | 6 | export default function splitHeading(type: NodeType) { 7 | return (state: EditorState, dispatch): boolean => { 8 | const { $from, from, $to, to } = state.selection; 9 | 10 | // check we're in a matching heading node 11 | if ($from.parent.type !== type) return false; 12 | 13 | // check that the caret is at the end of the content, if it isn't then 14 | // standard node splitting behaviour applies 15 | const endPos = $to.after() - 1; 16 | if (endPos !== to) return false; 17 | 18 | // If the node isn't collapsed standard behavior applies 19 | if (!$from.parent.attrs.collapsed) return false; 20 | 21 | // Find the next visible block after this one. It takes into account nested 22 | // collapsed headings and reaching the end of the document 23 | const allBlocks = findBlockNodes(state.doc); 24 | const collapsedBlocks = findCollapsedNodes(state.doc); 25 | const visibleBlocks = allBlocks.filter( 26 | a => !collapsedBlocks.find(b => b.pos === a.pos) 27 | ); 28 | const nextVisibleBlock = visibleBlocks.find(a => a.pos > from); 29 | const pos = nextVisibleBlock 30 | ? nextVisibleBlock.pos 31 | : state.doc.content.size; 32 | 33 | // Insert our new heading directly before the next visible block 34 | const transaction = state.tr.insert( 35 | pos, 36 | type.create({ ...$from.parent.attrs, collapsed: false }) 37 | ); 38 | 39 | // Move the selection into the new heading node and make sure it's on screen 40 | dispatch( 41 | transaction 42 | .setSelection( 43 | TextSelection.near( 44 | transaction.doc.resolve( 45 | Math.min(pos + 1, transaction.doc.content.size) 46 | ) 47 | ) 48 | ) 49 | .scrollIntoView() 50 | ); 51 | 52 | return true; 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /src/menus/image.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | TrashIcon, 3 | DownloadIcon, 4 | ReplaceIcon, 5 | AlignImageLeftIcon, 6 | AlignImageRightIcon, 7 | AlignImageCenterIcon, 8 | } from "outline-icons"; 9 | import isNodeActive from "../queries/isNodeActive"; 10 | import { MenuItem } from "../types"; 11 | import baseDictionary from "../dictionary"; 12 | import { EditorState } from "prosemirror-state"; 13 | 14 | export default function imageMenuItems( 15 | state: EditorState, 16 | dictionary: typeof baseDictionary 17 | ): MenuItem[] { 18 | const { schema } = state; 19 | const isLeftAligned = isNodeActive(schema.nodes.image, { 20 | layoutClass: "left-50", 21 | }); 22 | const isRightAligned = isNodeActive(schema.nodes.image, { 23 | layoutClass: "right-50", 24 | }); 25 | 26 | return [ 27 | { 28 | name: "alignLeft", 29 | tooltip: dictionary.alignLeft, 30 | icon: AlignImageLeftIcon, 31 | visible: true, 32 | active: isLeftAligned, 33 | }, 34 | { 35 | name: "alignCenter", 36 | tooltip: dictionary.alignCenter, 37 | icon: AlignImageCenterIcon, 38 | visible: true, 39 | active: state => 40 | isNodeActive(schema.nodes.image)(state) && 41 | !isLeftAligned(state) && 42 | !isRightAligned(state), 43 | }, 44 | { 45 | name: "alignRight", 46 | tooltip: dictionary.alignRight, 47 | icon: AlignImageRightIcon, 48 | visible: true, 49 | active: isRightAligned, 50 | }, 51 | { 52 | name: "separator", 53 | visible: true, 54 | }, 55 | { 56 | name: "downloadImage", 57 | tooltip: dictionary.downloadImage, 58 | icon: DownloadIcon, 59 | visible: !!fetch, 60 | active: () => false, 61 | }, 62 | { 63 | name: "replaceImage", 64 | tooltip: dictionary.replaceImage, 65 | icon: ReplaceIcon, 66 | visible: true, 67 | active: () => false, 68 | }, 69 | { 70 | name: "deleteImage", 71 | tooltip: dictionary.deleteImage, 72 | icon: TrashIcon, 73 | visible: true, 74 | active: () => false, 75 | }, 76 | ]; 77 | } 78 | -------------------------------------------------------------------------------- /src/nodes/OrderedList.ts: -------------------------------------------------------------------------------- 1 | import { wrappingInputRule } from "prosemirror-inputrules"; 2 | import toggleList from "../commands/toggleList"; 3 | import Node from "./Node"; 4 | 5 | export default class OrderedList extends Node { 6 | get name() { 7 | return "ordered_list"; 8 | } 9 | 10 | get schema() { 11 | return { 12 | attrs: { 13 | order: { 14 | default: 1, 15 | }, 16 | }, 17 | content: "list_item+", 18 | group: "block", 19 | parseDOM: [ 20 | { 21 | tag: "ol", 22 | getAttrs: (dom: HTMLOListElement) => ({ 23 | order: dom.hasAttribute("start") 24 | ? parseInt(dom.getAttribute("start") || "1", 10) 25 | : 1, 26 | }), 27 | }, 28 | ], 29 | toDOM: node => 30 | node.attrs.order === 1 31 | ? ["ol", 0] 32 | : ["ol", { start: node.attrs.order }, 0], 33 | }; 34 | } 35 | 36 | commands({ type, schema }) { 37 | return () => toggleList(type, schema.nodes.list_item); 38 | } 39 | 40 | keys({ type, schema }) { 41 | return { 42 | "Shift-Ctrl-9": toggleList(type, schema.nodes.list_item), 43 | }; 44 | } 45 | 46 | inputRules({ type }) { 47 | return [ 48 | wrappingInputRule( 49 | /^(\d+)\.\s$/, 50 | type, 51 | match => ({ order: +match[1] }), 52 | (match, node) => node.childCount + node.attrs.order === +match[1] 53 | ), 54 | ]; 55 | } 56 | 57 | toMarkdown(state, node) { 58 | state.write("\n"); 59 | 60 | const start = node.attrs.order !== undefined ? node.attrs.order : 1; 61 | const maxW = `${start + node.childCount - 1}`.length; 62 | const space = state.repeat(" ", maxW + 2); 63 | 64 | state.renderList(node, space, i => { 65 | const nStr = `${start + i}`; 66 | return state.repeat(" ", maxW - nStr.length) + nStr + ". "; 67 | }); 68 | } 69 | 70 | parseMarkdown() { 71 | return { 72 | block: "ordered_list", 73 | getAttrs: tok => ({ 74 | order: parseInt(tok.attrGet("start") || "1", 10), 75 | }), 76 | }; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/lib/markInputRule.ts: -------------------------------------------------------------------------------- 1 | import { MarkType, Mark } from "prosemirror-model"; 2 | import { InputRule } from "prosemirror-inputrules"; 3 | import { EditorState } from "prosemirror-state"; 4 | 5 | function getMarksBetween(start: number, end: number, state: EditorState) { 6 | let marks: { start: number; end: number; mark: Mark }[] = []; 7 | 8 | state.doc.nodesBetween(start, end, (node, pos) => { 9 | marks = [ 10 | ...marks, 11 | ...node.marks.map(mark => ({ 12 | start: pos, 13 | end: pos + node.nodeSize, 14 | mark, 15 | })), 16 | ]; 17 | }); 18 | 19 | return marks; 20 | } 21 | 22 | export default function( 23 | regexp: RegExp, 24 | markType: MarkType, 25 | getAttrs?: (match) => Record 26 | ): InputRule { 27 | return new InputRule( 28 | regexp, 29 | (state: EditorState, match: string[], start: number, end: number) => { 30 | const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs; 31 | const { tr } = state; 32 | const m = match.length - 1; 33 | let markEnd = end; 34 | let markStart = start; 35 | 36 | if (match[m]) { 37 | const matchStart = start + match[0].indexOf(match[m - 1]); 38 | const matchEnd = matchStart + match[m - 1].length - 1; 39 | const textStart = matchStart + match[m - 1].lastIndexOf(match[m]); 40 | const textEnd = textStart + match[m].length; 41 | 42 | const excludedMarks = getMarksBetween(start, end, state) 43 | .filter(item => item.mark.type.excludes(markType)) 44 | .filter(item => item.end > matchStart); 45 | 46 | if (excludedMarks.length) { 47 | return null; 48 | } 49 | 50 | if (textEnd < matchEnd) { 51 | tr.delete(textEnd, matchEnd); 52 | } 53 | if (textStart > matchStart) { 54 | tr.delete(matchStart, textStart); 55 | } 56 | markStart = matchStart; 57 | markEnd = markStart + match[m].length; 58 | } 59 | 60 | tr.addMark(markStart, markEnd, markType.create(attrs)); 61 | tr.removeStoredMark(markType); 62 | return tr; 63 | } 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/dictionary.ts: -------------------------------------------------------------------------------- 1 | export const base = { 2 | addColumnAfter: "Insert column after", 3 | addColumnBefore: "Insert column before", 4 | addRowAfter: "Insert row after", 5 | addRowBefore: "Insert row before", 6 | alignCenter: "Align center", 7 | alignLeft: "Align left", 8 | alignRight: "Align right", 9 | bulletList: "Bulleted list", 10 | checkboxList: "Todo list", 11 | codeBlock: "Code block", 12 | codeCopied: "Copied to clipboard", 13 | codeInline: "Code", 14 | createLink: "Create link", 15 | createLinkError: "Sorry, an error occurred creating the link", 16 | createNewDoc: "Create a new doc", 17 | deleteColumn: "Delete column", 18 | deleteRow: "Delete row", 19 | deleteTable: "Delete table", 20 | deleteImage: "Delete image", 21 | downloadImage: "Download image", 22 | replaceImage: "Replace image", 23 | alignImageLeft: "Float left half width", 24 | alignImageRight: "Float right half width", 25 | alignImageDefault: "Center large", 26 | em: "Italic", 27 | embedInvalidLink: "Sorry, that link won’t work for this embed type", 28 | findOrCreateDoc: "Find or create a doc…", 29 | h2: "Medium heading", 30 | h3: "Small heading", 31 | heading: "Heading", 32 | hr: "Divider", 33 | image: "Image", 34 | imageUploadError: "Sorry, an error occurred uploading the image", 35 | imageCaptionPlaceholder: "Write a caption", 36 | info: "Info", 37 | infoNotice: "Info notice", 38 | link: "Link", 39 | linkCopied: "Link copied to clipboard", 40 | mark: "Highlight", 41 | newLineEmpty: "Type '/' to insert…", 42 | newLineWithSlash: "Keep typing to filter…", 43 | noResults: "No results", 44 | openLink: "Open link", 45 | orderedList: "Ordered list", 46 | pageBreak: "Page break", 47 | pasteLink: "Paste a link…", 48 | pasteLinkWithTitle: (title: string): string => `Paste a ${title} link…`, 49 | placeholder: "Placeholder", 50 | quote: "Quote", 51 | removeLink: "Remove link", 52 | searchOrPasteLink: "Search or paste a link…", 53 | strikethrough: "Strikethrough", 54 | strong: "Bold", 55 | subheading: "Subheading", 56 | table: "Table", 57 | tip: "Tip", 58 | tipNotice: "Tip notice", 59 | warning: "Warning", 60 | warningNotice: "Warning notice", 61 | }; 62 | 63 | export default base; 64 | -------------------------------------------------------------------------------- /src/menus/tableCol.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | TrashIcon, 3 | AlignLeftIcon, 4 | AlignRightIcon, 5 | AlignCenterIcon, 6 | InsertLeftIcon, 7 | InsertRightIcon, 8 | } from "outline-icons"; 9 | import { EditorState } from "prosemirror-state"; 10 | import isNodeActive from "../queries/isNodeActive"; 11 | import { MenuItem } from "../types"; 12 | import baseDictionary from "../dictionary"; 13 | 14 | export default function tableColMenuItems( 15 | state: EditorState, 16 | index: number, 17 | rtl: boolean, 18 | dictionary: typeof baseDictionary 19 | ): MenuItem[] { 20 | const { schema } = state; 21 | 22 | return [ 23 | { 24 | name: "setColumnAttr", 25 | tooltip: dictionary.alignLeft, 26 | icon: AlignLeftIcon, 27 | attrs: { index, alignment: "left" }, 28 | active: isNodeActive(schema.nodes.th, { 29 | colspan: 1, 30 | rowspan: 1, 31 | alignment: "left", 32 | }), 33 | }, 34 | { 35 | name: "setColumnAttr", 36 | tooltip: dictionary.alignCenter, 37 | icon: AlignCenterIcon, 38 | attrs: { index, alignment: "center" }, 39 | active: isNodeActive(schema.nodes.th, { 40 | colspan: 1, 41 | rowspan: 1, 42 | alignment: "center", 43 | }), 44 | }, 45 | { 46 | name: "setColumnAttr", 47 | tooltip: dictionary.alignRight, 48 | icon: AlignRightIcon, 49 | attrs: { index, alignment: "right" }, 50 | active: isNodeActive(schema.nodes.th, { 51 | colspan: 1, 52 | rowspan: 1, 53 | alignment: "right", 54 | }), 55 | }, 56 | { 57 | name: "separator", 58 | }, 59 | { 60 | name: rtl ? "addColumnAfter" : "addColumnBefore", 61 | tooltip: rtl ? dictionary.addColumnAfter : dictionary.addColumnBefore, 62 | icon: InsertLeftIcon, 63 | active: () => false, 64 | }, 65 | { 66 | name: rtl ? "addColumnBefore" : "addColumnAfter", 67 | tooltip: rtl ? dictionary.addColumnBefore : dictionary.addColumnAfter, 68 | icon: InsertRightIcon, 69 | active: () => false, 70 | }, 71 | { 72 | name: "separator", 73 | }, 74 | { 75 | name: "deleteColumn", 76 | tooltip: dictionary.deleteColumn, 77 | icon: TrashIcon, 78 | active: () => false, 79 | }, 80 | ]; 81 | } 82 | -------------------------------------------------------------------------------- /src/components/EmojiMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gemojies from "gemoji"; 3 | import FuzzySearch from "fuzzy-search"; 4 | import CommandMenu, { Props } from "./CommandMenu"; 5 | import EmojiMenuItem from "./EmojiMenuItem"; 6 | 7 | type Emoji = { 8 | name: string; 9 | title: string; 10 | emoji: string; 11 | description: string; 12 | attrs: { markup: string; "data-name": string }; 13 | }; 14 | 15 | const searcher = new FuzzySearch<{ 16 | names: string[]; 17 | description: string; 18 | emoji: string; 19 | }>(gemojies, ["names"], { 20 | caseSensitive: true, 21 | sort: true, 22 | }); 23 | 24 | class EmojiMenu extends React.Component< 25 | Omit< 26 | Props, 27 | | "renderMenuItem" 28 | | "items" 29 | | "onLinkToolbarOpen" 30 | | "embeds" 31 | | "onClearSearch" 32 | > 33 | > { 34 | get items(): Emoji[] { 35 | const { search = "" } = this.props; 36 | 37 | const n = search.toLowerCase(); 38 | const result = searcher.search(n).map(item => { 39 | const description = item.description; 40 | const name = item.names[0]; 41 | return { 42 | ...item, 43 | name: "emoji", 44 | title: name, 45 | description, 46 | attrs: { markup: name, "data-name": name }, 47 | }; 48 | }); 49 | 50 | return result.slice(0, 10); 51 | } 52 | 53 | clearSearch = () => { 54 | const { state, dispatch } = this.props.view; 55 | 56 | // clear search input 57 | dispatch( 58 | state.tr.insertText( 59 | "", 60 | state.selection.$from.pos - (this.props.search ?? "").length - 1, 61 | state.selection.to 62 | ) 63 | ); 64 | }; 65 | 66 | render() { 67 | return ( 68 | { 74 | return ( 75 | 82 | ); 83 | }} 84 | items={this.items} 85 | /> 86 | ); 87 | } 88 | } 89 | 90 | export default EmojiMenu; 91 | -------------------------------------------------------------------------------- /src/lib/uploadPlaceholder.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, Plugin } from "prosemirror-state"; 2 | import { Decoration, DecorationSet } from "prosemirror-view"; 3 | 4 | // based on the example at: https://prosemirror.net/examples/upload/ 5 | const uploadPlaceholder = new Plugin({ 6 | state: { 7 | init() { 8 | return DecorationSet.empty; 9 | }, 10 | apply(tr, set) { 11 | // Adjust decoration positions to changes made by the transaction 12 | set = set.map(tr.mapping, tr.doc); 13 | 14 | // See if the transaction adds or removes any placeholders 15 | const action = tr.getMeta(this); 16 | 17 | if (action?.add) { 18 | if (action.add.replaceExisting) { 19 | const $pos = tr.doc.resolve(action.add.pos); 20 | 21 | if ($pos.nodeAfter?.type.name === "image") { 22 | const deco = Decoration.node( 23 | $pos.pos, 24 | $pos.pos + $pos.nodeAfter.nodeSize, 25 | { 26 | class: "image-replacement-uploading", 27 | }, 28 | { 29 | id: action.add.id, 30 | } 31 | ); 32 | set = set.add(tr.doc, [deco]); 33 | } 34 | } else { 35 | const element = document.createElement("div"); 36 | element.className = "image placeholder"; 37 | 38 | const img = document.createElement("img"); 39 | img.src = URL.createObjectURL(action.add.file); 40 | 41 | element.appendChild(img); 42 | 43 | const deco = Decoration.widget(action.add.pos, element, { 44 | id: action.add.id, 45 | }); 46 | set = set.add(tr.doc, [deco]); 47 | } 48 | } 49 | 50 | if (action?.remove) { 51 | set = set.remove( 52 | set.find(null, null, spec => spec.id === action.remove.id) 53 | ); 54 | } 55 | return set; 56 | }, 57 | }, 58 | props: { 59 | decorations(state) { 60 | return this.getState(state); 61 | }, 62 | }, 63 | }); 64 | 65 | export default uploadPlaceholder; 66 | 67 | export function findPlaceholder( 68 | state: EditorState, 69 | id: string 70 | ): [number, number] | null { 71 | const decos = uploadPlaceholder.getState(state); 72 | const found = decos.find(null, null, spec => spec.id === id); 73 | return found.length ? [found[0].from, found[0].to] : null; 74 | } 75 | -------------------------------------------------------------------------------- /src/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/plugins/Folding.tsx: -------------------------------------------------------------------------------- 1 | import { Plugin } from "prosemirror-state"; 2 | import { Decoration, DecorationSet } from "prosemirror-view"; 3 | import Extension from "../lib/Extension"; 4 | import { findBlockNodes } from "prosemirror-utils"; 5 | import findCollapsedNodes from "../queries/findCollapsedNodes"; 6 | import { headingToPersistenceKey } from "../lib/headingToSlug"; 7 | 8 | export default class Folding extends Extension { 9 | get name() { 10 | return "folding"; 11 | } 12 | 13 | get plugins() { 14 | let loaded = false; 15 | 16 | return [ 17 | new Plugin({ 18 | view: view => { 19 | loaded = false; 20 | view.dispatch(view.state.tr.setMeta("folding", { loaded: true })); 21 | return {}; 22 | }, 23 | appendTransaction: (transactions, oldState, newState) => { 24 | if (loaded) return; 25 | if ( 26 | !transactions.some(transaction => transaction.getMeta("folding")) 27 | ) { 28 | return; 29 | } 30 | 31 | let modified = false; 32 | const tr = newState.tr; 33 | const blocks = findBlockNodes(newState.doc); 34 | 35 | for (const block of blocks) { 36 | if (block.node.type.name === "heading") { 37 | const persistKey = headingToPersistenceKey( 38 | block.node, 39 | this.editor.props.id 40 | ); 41 | const persistedState = localStorage?.getItem(persistKey); 42 | 43 | if (persistedState === "collapsed") { 44 | tr.setNodeMarkup(block.pos, undefined, { 45 | ...block.node.attrs, 46 | collapsed: true, 47 | }); 48 | modified = true; 49 | } 50 | } 51 | } 52 | 53 | loaded = true; 54 | return modified ? tr : null; 55 | }, 56 | props: { 57 | decorations: state => { 58 | const { doc } = state; 59 | const decorations: Decoration[] = findCollapsedNodes(doc).map( 60 | block => 61 | Decoration.node(block.pos, block.pos + block.node.nodeSize, { 62 | class: "folded-content", 63 | }) 64 | ); 65 | 66 | return DecorationSet.create(doc, decorations); 67 | }, 68 | }, 69 | }), 70 | ]; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/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/commands/moveRight.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Atlassian Pty Ltd 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // This file is based on the implementation found here: 18 | // https://bitbucket.org/atlassian/design-system-mirror/src/master/editor/editor-core/src/plugins/text-formatting/commands/text-formatting.ts 19 | 20 | import { EditorState, Transaction, TextSelection } from "prosemirror-state"; 21 | import isMarkActive from "../queries/isMarkActive"; 22 | 23 | export default function moveRight() { 24 | return (state: EditorState, dispatch: (tr: Transaction) => void): boolean => { 25 | const { code_inline } = state.schema.marks; 26 | const { empty, $cursor } = state.selection as TextSelection; 27 | if (!empty || !$cursor) { 28 | return false; 29 | } 30 | 31 | const { storedMarks } = state.tr; 32 | if (code_inline) { 33 | const insideCode = isMarkActive(code_inline)(state); 34 | const currentPosHasCode = state.doc.rangeHasMark( 35 | $cursor.pos, 36 | $cursor.pos, 37 | code_inline 38 | ); 39 | const nextPosHasCode = state.doc.rangeHasMark( 40 | $cursor.pos, 41 | $cursor.pos + 1, 42 | code_inline 43 | ); 44 | 45 | const exitingCode = 46 | !currentPosHasCode && 47 | !nextPosHasCode && 48 | (!storedMarks || !!storedMarks.length); 49 | const enteringCode = 50 | !currentPosHasCode && 51 | nextPosHasCode && 52 | (!storedMarks || !storedMarks.length); 53 | 54 | // entering code mark (from the left edge): don't move the cursor, just add the mark 55 | if (!insideCode && enteringCode) { 56 | dispatch(state.tr.addStoredMark(code_inline.create())); 57 | 58 | return true; 59 | } 60 | 61 | // exiting code mark: don't move the cursor, just remove the mark 62 | if (insideCode && exitingCode) { 63 | dispatch(state.tr.removeStoredMark(code_inline)); 64 | 65 | return true; 66 | } 67 | } 68 | 69 | return false; 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "prosemirror-model"; 2 | import ExtensionManager from "./lib/ExtensionManager"; 3 | import render from "./lib/renderToHtml"; 4 | 5 | // nodes 6 | import Doc from "./nodes/Doc"; 7 | import Text from "./nodes/Text"; 8 | import Blockquote from "./nodes/Blockquote"; 9 | import Emoji from "./nodes/Emoji"; 10 | import BulletList from "./nodes/BulletList"; 11 | import CodeBlock from "./nodes/CodeBlock"; 12 | import CodeFence from "./nodes/CodeFence"; 13 | import CheckboxList from "./nodes/CheckboxList"; 14 | import CheckboxItem from "./nodes/CheckboxItem"; 15 | import Embed from "./nodes/Embed"; 16 | import HardBreak from "./nodes/HardBreak"; 17 | import Heading from "./nodes/Heading"; 18 | import HorizontalRule from "./nodes/HorizontalRule"; 19 | import Image from "./nodes/Image"; 20 | import ListItem from "./nodes/ListItem"; 21 | import Notice from "./nodes/Notice"; 22 | import OrderedList from "./nodes/OrderedList"; 23 | import Paragraph from "./nodes/Paragraph"; 24 | import Table from "./nodes/Table"; 25 | import TableCell from "./nodes/TableCell"; 26 | import TableHeadCell from "./nodes/TableHeadCell"; 27 | import TableRow from "./nodes/TableRow"; 28 | 29 | // marks 30 | import Bold from "./marks/Bold"; 31 | import Code from "./marks/Code"; 32 | import Highlight from "./marks/Highlight"; 33 | import Italic from "./marks/Italic"; 34 | import Link from "./marks/Link"; 35 | import Strikethrough from "./marks/Strikethrough"; 36 | import TemplatePlaceholder from "./marks/Placeholder"; 37 | import Underline from "./marks/Underline"; 38 | 39 | const extensions = new ExtensionManager([ 40 | new Doc(), 41 | new Text(), 42 | new HardBreak(), 43 | new Paragraph(), 44 | new Blockquote(), 45 | new Emoji(), 46 | new BulletList(), 47 | new CodeBlock(), 48 | new CodeFence(), 49 | new CheckboxList(), 50 | new CheckboxItem(), 51 | new Embed(), 52 | new ListItem(), 53 | new Notice(), 54 | new Heading(), 55 | new HorizontalRule(), 56 | new Image(), 57 | new Table(), 58 | new TableCell(), 59 | new TableHeadCell(), 60 | new TableRow(), 61 | new Bold(), 62 | new Code(), 63 | new Highlight(), 64 | new Italic(), 65 | new Link(), 66 | new Strikethrough(), 67 | new TemplatePlaceholder(), 68 | new Underline(), 69 | new OrderedList(), 70 | ]); 71 | 72 | export const schema = new Schema({ 73 | nodes: extensions.nodes, 74 | marks: extensions.marks, 75 | }); 76 | 77 | export const parser = extensions.parser({ 78 | schema, 79 | plugins: extensions.rulePlugins, 80 | }); 81 | 82 | export const serializer = extensions.serializer(); 83 | 84 | export const renderToHtml = (markdown: string): string => 85 | render(markdown, extensions.rulePlugins); 86 | -------------------------------------------------------------------------------- /src/nodes/TableHeadCell.ts: -------------------------------------------------------------------------------- 1 | import { DecorationSet, Decoration } from "prosemirror-view"; 2 | import { Plugin } from "prosemirror-state"; 3 | import { isColumnSelected, getCellsInRow } from "prosemirror-utils"; 4 | import Node from "./Node"; 5 | 6 | export default class TableHeadCell extends Node { 7 | get name() { 8 | return "th"; 9 | } 10 | 11 | get schema() { 12 | return { 13 | content: "paragraph+", 14 | tableRole: "header_cell", 15 | isolating: true, 16 | parseDOM: [{ tag: "th" }], 17 | toDOM(node) { 18 | return [ 19 | "th", 20 | node.attrs.alignment 21 | ? { style: `text-align: ${node.attrs.alignment}` } 22 | : {}, 23 | 0, 24 | ]; 25 | }, 26 | attrs: { 27 | colspan: { default: 1 }, 28 | rowspan: { default: 1 }, 29 | alignment: { default: null }, 30 | }, 31 | }; 32 | } 33 | 34 | toMarkdown() { 35 | // see: renderTable 36 | } 37 | 38 | parseMarkdown() { 39 | return { 40 | block: "th", 41 | getAttrs: tok => ({ alignment: tok.info }), 42 | }; 43 | } 44 | 45 | get plugins() { 46 | return [ 47 | new Plugin({ 48 | props: { 49 | decorations: state => { 50 | const { doc, selection } = state; 51 | const decorations: Decoration[] = []; 52 | const cells = getCellsInRow(0)(selection); 53 | 54 | if (cells) { 55 | cells.forEach(({ pos }, index) => { 56 | decorations.push( 57 | Decoration.widget(pos + 1, () => { 58 | const colSelected = isColumnSelected(index)(selection); 59 | let className = "grip-column"; 60 | if (colSelected) { 61 | className += " selected"; 62 | } 63 | if (index === 0) { 64 | className += " first"; 65 | } else if (index === cells.length - 1) { 66 | className += " last"; 67 | } 68 | const grip = document.createElement("a"); 69 | grip.className = className; 70 | grip.addEventListener("mousedown", event => { 71 | event.preventDefault(); 72 | event.stopImmediatePropagation(); 73 | this.options.onSelectColumn(index, state); 74 | }); 75 | return grip; 76 | }) 77 | ); 78 | }); 79 | } 80 | 81 | return DecorationSet.create(doc, decorations); 82 | }, 83 | }, 84 | }), 85 | ]; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/rules/embeds.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from "markdown-it"; 2 | import Token from "markdown-it/lib/token"; 3 | 4 | function isParagraph(token: Token) { 5 | return token.type === "paragraph_open"; 6 | } 7 | 8 | function isInline(token: Token) { 9 | return token.type === "inline" && token.level === 1; 10 | } 11 | 12 | function isLinkOpen(token: Token) { 13 | return token.type === "link_open"; 14 | } 15 | 16 | function isLinkClose(token: Token) { 17 | return token.type === "link_close"; 18 | } 19 | 20 | export default function(embeds) { 21 | function isEmbed(token: Token, link: Token) { 22 | const href = link.attrs ? link.attrs[0][1] : ""; 23 | const simpleLink = href === token.content; 24 | 25 | if (!simpleLink) return false; 26 | if (!embeds) return false; 27 | 28 | for (const embed of embeds) { 29 | const matches = embed.matcher(href); 30 | if (matches) { 31 | return { 32 | ...embed, 33 | matches, 34 | }; 35 | } 36 | } 37 | } 38 | 39 | return function markdownEmbeds(md: MarkdownIt) { 40 | md.core.ruler.after("inline", "embeds", state => { 41 | const tokens = state.tokens; 42 | let insideLink; 43 | 44 | for (let i = 0; i < tokens.length - 1; i++) { 45 | // once we find an inline token look through it's children for links 46 | if (isInline(tokens[i]) && isParagraph(tokens[i - 1])) { 47 | const tokenChildren = tokens[i].children || []; 48 | 49 | for (let j = 0; j < tokenChildren.length - 1; j++) { 50 | const current = tokenChildren[j]; 51 | if (!current) continue; 52 | 53 | if (isLinkOpen(current)) { 54 | insideLink = current; 55 | continue; 56 | } 57 | 58 | if (isLinkClose(current)) { 59 | insideLink = null; 60 | continue; 61 | } 62 | 63 | // of hey, we found a link – lets check to see if it should be 64 | // considered to be an embed 65 | if (insideLink) { 66 | const result = isEmbed(current, insideLink); 67 | if (result) { 68 | const { content } = current; 69 | 70 | // convert to embed token 71 | const token = new Token("embed", "iframe", 0); 72 | token.attrSet("href", content); 73 | 74 | // delete the inline link – this makes the assumption that the 75 | // embed is the only thing in the para. 76 | // TODO: double check this 77 | tokens.splice(i - 1, 3, token); 78 | break; 79 | } 80 | } 81 | } 82 | } 83 | } 84 | 85 | return false; 86 | }); 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /src/nodes/CheckboxItem.ts: -------------------------------------------------------------------------------- 1 | import { 2 | splitListItem, 3 | sinkListItem, 4 | liftListItem, 5 | } from "prosemirror-schema-list"; 6 | import Node from "./Node"; 7 | import checkboxRule from "../rules/checkboxes"; 8 | 9 | export default class CheckboxItem extends Node { 10 | get name() { 11 | return "checkbox_item"; 12 | } 13 | 14 | get schema() { 15 | return { 16 | attrs: { 17 | checked: { 18 | default: false, 19 | }, 20 | }, 21 | content: "paragraph block*", 22 | defining: true, 23 | draggable: true, 24 | parseDOM: [ 25 | { 26 | tag: `li[data-type="${this.name}"]`, 27 | getAttrs: (dom: HTMLLIElement) => ({ 28 | checked: dom.className.includes("checked"), 29 | }), 30 | }, 31 | ], 32 | toDOM: node => { 33 | const input = document.createElement("input"); 34 | input.type = "checkbox"; 35 | input.tabIndex = -1; 36 | input.addEventListener("change", this.handleChange); 37 | 38 | if (node.attrs.checked) { 39 | input.checked = true; 40 | } 41 | 42 | return [ 43 | "li", 44 | { 45 | "data-type": this.name, 46 | class: node.attrs.checked ? "checked" : undefined, 47 | }, 48 | [ 49 | "span", 50 | { 51 | contentEditable: false, 52 | }, 53 | input, 54 | ], 55 | ["div", 0], 56 | ]; 57 | }, 58 | }; 59 | } 60 | 61 | get rulePlugins() { 62 | return [checkboxRule]; 63 | } 64 | 65 | handleChange = event => { 66 | const { view } = this.editor; 67 | const { tr } = view.state; 68 | const { top, left } = event.target.getBoundingClientRect(); 69 | const result = view.posAtCoords({ top, left }); 70 | 71 | if (result) { 72 | const transaction = tr.setNodeMarkup(result.inside, undefined, { 73 | checked: event.target.checked, 74 | }); 75 | view.dispatch(transaction); 76 | } 77 | }; 78 | 79 | keys({ type }) { 80 | return { 81 | Enter: splitListItem(type), 82 | Tab: sinkListItem(type), 83 | "Shift-Tab": liftListItem(type), 84 | "Mod-]": sinkListItem(type), 85 | "Mod-[": liftListItem(type), 86 | }; 87 | } 88 | 89 | toMarkdown(state, node) { 90 | state.write(node.attrs.checked ? "[x] " : "[ ] "); 91 | state.renderContent(node); 92 | } 93 | 94 | parseMarkdown() { 95 | return { 96 | block: "checkbox_item", 97 | getAttrs: tok => ({ 98 | checked: tok.attrGet("checked") ? true : undefined, 99 | }), 100 | }; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/rules/tables.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from "markdown-it"; 2 | import Token from "markdown-it/lib/token"; 3 | 4 | const BREAK_REGEX = /(?:^|[^\\])\\n/; 5 | 6 | export default function markdownTables(md: MarkdownIt): void { 7 | // insert a new rule after the "inline" rules are parsed 8 | md.core.ruler.after("inline", "tables-pm", state => { 9 | const tokens = state.tokens; 10 | let inside = false; 11 | 12 | for (let i = tokens.length - 1; i > 0; i--) { 13 | if (inside) { 14 | tokens[i].level--; 15 | } 16 | 17 | // convert unescaped \n in the text into real br tag 18 | if (tokens[i].type === "inline" && tokens[i].content.match(BREAK_REGEX)) { 19 | const existing = tokens[i].children || []; 20 | tokens[i].children = []; 21 | 22 | existing.forEach(child => { 23 | const breakParts = child.content.split(BREAK_REGEX); 24 | 25 | // a schema agnostic way to know if a node is inline code would be 26 | // great, for now we are stuck checking the node type. 27 | if (breakParts.length > 1 && child.type !== "code_inline") { 28 | breakParts.forEach((part, index) => { 29 | const token = new Token("text", "", 1); 30 | token.content = part.trim(); 31 | tokens[i].children?.push(token); 32 | 33 | if (index < breakParts.length - 1) { 34 | const brToken = new Token("br", "br", 1); 35 | tokens[i].children?.push(brToken); 36 | } 37 | }); 38 | } else { 39 | tokens[i].children?.push(child); 40 | } 41 | }); 42 | } 43 | 44 | // filter out incompatible tokens from markdown-it that we don't need 45 | // in prosemirror. thead/tbody do nothing. 46 | if ( 47 | ["thead_open", "thead_close", "tbody_open", "tbody_close"].includes( 48 | tokens[i].type 49 | ) 50 | ) { 51 | inside = !inside; 52 | tokens.splice(i, 1); 53 | } 54 | 55 | if (["th_open", "td_open"].includes(tokens[i].type)) { 56 | // markdown-it table parser does not return paragraphs inside the cells 57 | // but prosemirror requires them, so we add 'em in here. 58 | tokens.splice(i + 1, 0, new Token("paragraph_open", "p", 1)); 59 | 60 | // markdown-it table parser stores alignment as html styles, convert 61 | // to a simple string here 62 | const tokenAttrs = tokens[i].attrs; 63 | if (tokenAttrs) { 64 | const style = tokenAttrs[0][1]; 65 | tokens[i].info = style.split(":")[1]; 66 | } 67 | } 68 | 69 | if (["th_close", "td_close"].includes(tokens[i].type)) { 70 | tokens.splice(i, 0, new Token("paragraph_close", "p", -1)); 71 | } 72 | } 73 | 74 | return false; 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /src/lib/ComponentView.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { ThemeProvider } from "styled-components"; 4 | import { EditorView, Decoration } from "prosemirror-view"; 5 | import Extension from "../lib/Extension"; 6 | import Node from "../nodes/Node"; 7 | import { light as lightTheme, dark as darkTheme } from "../styles/theme"; 8 | import Editor from "../"; 9 | 10 | type Component = (options: { 11 | node: Node; 12 | theme: typeof lightTheme; 13 | isSelected: boolean; 14 | isEditable: boolean; 15 | getPos: () => number; 16 | }) => React.ReactElement; 17 | 18 | export default class ComponentView { 19 | component: Component; 20 | editor: Editor; 21 | extension: Extension; 22 | node: Node; 23 | view: EditorView; 24 | getPos: () => number; 25 | decorations: Decoration<{ [key: string]: any }>[]; 26 | isSelected = false; 27 | dom: HTMLElement | null; 28 | 29 | // See https://prosemirror.net/docs/ref/#view.NodeView 30 | constructor( 31 | component, 32 | { editor, extension, node, view, getPos, decorations } 33 | ) { 34 | this.component = component; 35 | this.editor = editor; 36 | this.extension = extension; 37 | this.getPos = getPos; 38 | this.decorations = decorations; 39 | this.node = node; 40 | this.view = view; 41 | this.dom = node.type.spec.inline 42 | ? document.createElement("span") 43 | : document.createElement("div"); 44 | 45 | this.renderElement(); 46 | } 47 | 48 | renderElement() { 49 | const { dark } = this.editor.props; 50 | const theme = this.editor.props.theme || (dark ? darkTheme : lightTheme); 51 | 52 | const children = this.component({ 53 | theme, 54 | node: this.node, 55 | isSelected: this.isSelected, 56 | isEditable: this.view.editable, 57 | getPos: this.getPos, 58 | }); 59 | 60 | ReactDOM.render( 61 | {children}, 62 | this.dom 63 | ); 64 | } 65 | 66 | update(node) { 67 | if (node.type !== this.node.type) { 68 | return false; 69 | } 70 | 71 | this.node = node; 72 | this.renderElement(); 73 | return true; 74 | } 75 | 76 | selectNode() { 77 | if (this.view.editable) { 78 | this.isSelected = true; 79 | this.renderElement(); 80 | } 81 | } 82 | 83 | deselectNode() { 84 | if (this.view.editable) { 85 | this.isSelected = false; 86 | this.renderElement(); 87 | } 88 | } 89 | 90 | stopEvent() { 91 | return true; 92 | } 93 | 94 | destroy() { 95 | if (this.dom) { 96 | ReactDOM.unmountComponentAtNode(this.dom); 97 | } 98 | this.dom = null; 99 | } 100 | 101 | ignoreMutation() { 102 | return true; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/nodes/Emoji.tsx: -------------------------------------------------------------------------------- 1 | import { InputRule } from "prosemirror-inputrules"; 2 | import nameToEmoji from "gemoji/name-to-emoji.json"; 3 | import Node from "./Node"; 4 | import emojiRule from "../rules/emoji"; 5 | 6 | export default class Emoji extends Node { 7 | get name() { 8 | return "emoji"; 9 | } 10 | 11 | get schema() { 12 | return { 13 | attrs: { 14 | style: { 15 | default: "", 16 | }, 17 | "data-name": { 18 | default: undefined, 19 | }, 20 | }, 21 | inline: true, 22 | content: "text*", 23 | marks: "", 24 | group: "inline", 25 | selectable: false, 26 | parseDOM: [ 27 | { 28 | tag: "span.emoji", 29 | preserveWhitespace: "full", 30 | getAttrs: (dom: HTMLDivElement) => ({ 31 | "data-name": dom.dataset.name, 32 | }), 33 | }, 34 | ], 35 | toDOM: node => { 36 | if (nameToEmoji[node.attrs["data-name"]]) { 37 | const text = document.createTextNode( 38 | nameToEmoji[node.attrs["data-name"]] 39 | ); 40 | return [ 41 | "span", 42 | { 43 | class: `emoji ${node.attrs["data-name"]}`, 44 | "data-name": node.attrs["data-name"], 45 | }, 46 | text, 47 | ]; 48 | } 49 | const text = document.createTextNode(`:${node.attrs["data-name"]}:`); 50 | return ["span", { class: "emoji" }, text]; 51 | }, 52 | }; 53 | } 54 | 55 | get rulePlugins() { 56 | return [emojiRule]; 57 | } 58 | 59 | commands({ type }) { 60 | return attrs => (state, dispatch) => { 61 | const { selection } = state; 62 | const position = selection.$cursor 63 | ? selection.$cursor.pos 64 | : selection.$to.pos; 65 | const node = type.create(attrs); 66 | const transaction = state.tr.insert(position, node); 67 | dispatch(transaction); 68 | return true; 69 | }; 70 | } 71 | 72 | inputRules({ type }) { 73 | return [ 74 | new InputRule(/^\:([a-zA-Z0-9_+-]+)\:$/, (state, match, start, end) => { 75 | const [okay, markup] = match; 76 | const { tr } = state; 77 | if (okay) { 78 | tr.replaceWith( 79 | start - 1, 80 | end, 81 | type.create({ 82 | "data-name": markup, 83 | markup, 84 | }) 85 | ); 86 | } 87 | 88 | return tr; 89 | }), 90 | ]; 91 | } 92 | 93 | toMarkdown(state, node) { 94 | const name = node.attrs["data-name"]; 95 | if (name) { 96 | state.write(`:${name}:`); 97 | } 98 | } 99 | 100 | parseMarkdown() { 101 | return { 102 | node: "emoji", 103 | getAttrs: tok => { 104 | return { "data-name": tok.markup.trim() }; 105 | }, 106 | }; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/plugins/Keys.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Plugin, 3 | Selection, 4 | AllSelection, 5 | TextSelection, 6 | } from "prosemirror-state"; 7 | import { GapCursor } from "prosemirror-gapcursor"; 8 | import Extension from "../lib/Extension"; 9 | import isModKey from "../lib/isModKey"; 10 | export default class Keys extends Extension { 11 | get name() { 12 | return "keys"; 13 | } 14 | 15 | get plugins() { 16 | return [ 17 | new Plugin({ 18 | props: { 19 | handleDOMEvents: { 20 | blur: this.options.onBlur, 21 | focus: this.options.onFocus, 22 | }, 23 | // we can't use the keys bindings for this as we want to preventDefault 24 | // on the original keyboard event when handled 25 | handleKeyDown: (view, event) => { 26 | if (view.state.selection instanceof AllSelection) { 27 | if (event.key === "ArrowUp") { 28 | const selection = Selection.atStart(view.state.doc); 29 | view.dispatch(view.state.tr.setSelection(selection)); 30 | return true; 31 | } 32 | if (event.key === "ArrowDown") { 33 | const selection = Selection.atEnd(view.state.doc); 34 | view.dispatch(view.state.tr.setSelection(selection)); 35 | return true; 36 | } 37 | } 38 | 39 | // edge case where horizontal gap cursor does nothing if Enter key 40 | // is pressed. Insert a newline and then move the cursor into it. 41 | if (view.state.selection instanceof GapCursor) { 42 | if (event.key === "Enter") { 43 | view.dispatch( 44 | view.state.tr.insert( 45 | view.state.selection.from, 46 | view.state.schema.nodes.paragraph.create({}) 47 | ) 48 | ); 49 | view.dispatch( 50 | view.state.tr.setSelection( 51 | TextSelection.near( 52 | view.state.doc.resolve(view.state.selection.from), 53 | -1 54 | ) 55 | ) 56 | ); 57 | return true; 58 | } 59 | } 60 | 61 | // All the following keys require mod to be down 62 | if (!isModKey(event)) { 63 | return false; 64 | } 65 | 66 | if (event.key === "s") { 67 | event.preventDefault(); 68 | this.options.onSave(); 69 | return true; 70 | } 71 | 72 | if (event.key === "Enter") { 73 | event.preventDefault(); 74 | this.options.onSaveAndExit(); 75 | return true; 76 | } 77 | 78 | if (event.key === "Escape") { 79 | event.preventDefault(); 80 | this.options.onCancel(); 81 | return true; 82 | } 83 | 84 | return false; 85 | }, 86 | }, 87 | }), 88 | ]; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/nodes/Embed.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Node from "./Node"; 3 | import embedsRule from "../rules/embeds"; 4 | 5 | const cache = {}; 6 | 7 | export default class Embed extends Node { 8 | get name() { 9 | return "embed"; 10 | } 11 | 12 | get schema() { 13 | return { 14 | content: "inline*", 15 | group: "block", 16 | atom: true, 17 | attrs: { 18 | href: {}, 19 | }, 20 | parseDOM: [ 21 | { 22 | tag: "iframe[class=embed]", 23 | getAttrs: (dom: HTMLIFrameElement) => { 24 | const { embeds } = this.editor.props; 25 | const href = dom.getAttribute("src") || ""; 26 | 27 | if (embeds) { 28 | for (const embed of embeds) { 29 | const matches = embed.matcher(href); 30 | if (matches) { 31 | return { 32 | href, 33 | }; 34 | } 35 | } 36 | } 37 | 38 | return {}; 39 | }, 40 | }, 41 | ], 42 | toDOM: node => [ 43 | "iframe", 44 | { class: "embed", src: node.attrs.href, contentEditable: false }, 45 | 0, 46 | ], 47 | }; 48 | } 49 | 50 | get rulePlugins() { 51 | return [embedsRule(this.options.embeds)]; 52 | } 53 | 54 | component({ isEditable, isSelected, theme, node }) { 55 | const { embeds } = this.editor.props; 56 | 57 | // matches are cached in module state to avoid re running loops and regex 58 | // here. Unfortuantely this function is not compatible with React.memo or 59 | // we would use that instead. 60 | const hit = cache[node.attrs.href]; 61 | let Component = hit ? hit.Component : undefined; 62 | let matches = hit ? hit.matches : undefined; 63 | 64 | if (!Component) { 65 | for (const embed of embeds) { 66 | const m = embed.matcher(node.attrs.href); 67 | if (m) { 68 | Component = embed.component; 69 | matches = m; 70 | cache[node.attrs.href] = { Component, matches }; 71 | } 72 | } 73 | } 74 | 75 | if (!Component) { 76 | return null; 77 | } 78 | 79 | return ( 80 | 86 | ); 87 | } 88 | 89 | commands({ type }) { 90 | return attrs => (state, dispatch) => { 91 | dispatch( 92 | state.tr.replaceSelectionWith(type.create(attrs)).scrollIntoView() 93 | ); 94 | return true; 95 | }; 96 | } 97 | 98 | toMarkdown(state, node) { 99 | state.ensureNewLine(); 100 | state.write( 101 | "[" + state.esc(node.attrs.href) + "](" + state.esc(node.attrs.href) + ")" 102 | ); 103 | state.write("\n\n"); 104 | } 105 | 106 | parseMarkdown() { 107 | return { 108 | node: "embed", 109 | getAttrs: token => ({ 110 | href: token.attrGet("href"), 111 | }), 112 | }; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/components/BlockMenuItem.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import scrollIntoView from "smooth-scroll-into-view-if-needed"; 3 | import styled, { withTheme } from "styled-components"; 4 | import theme from "../styles/theme"; 5 | 6 | export type Props = { 7 | selected: boolean; 8 | disabled?: boolean; 9 | onClick: () => void; 10 | theme: typeof theme; 11 | icon?: typeof React.Component | React.FC; 12 | title: React.ReactNode; 13 | shortcut?: string; 14 | containerId?: string; 15 | }; 16 | 17 | function BlockMenuItem({ 18 | selected, 19 | disabled, 20 | onClick, 21 | title, 22 | shortcut, 23 | icon, 24 | containerId = "block-menu-container", 25 | }: Props) { 26 | const Icon = icon; 27 | 28 | const ref = React.useCallback( 29 | node => { 30 | if (selected && node) { 31 | scrollIntoView(node, { 32 | scrollMode: "if-needed", 33 | block: "center", 34 | boundary: parent => { 35 | // All the parent elements of your target are checked until they 36 | // reach the #block-menu-container. Prevents body and other parent 37 | // elements from being scrolled 38 | return parent.id !== containerId; 39 | }, 40 | }); 41 | } 42 | }, 43 | [selected, containerId] 44 | ); 45 | 46 | return ( 47 | 52 | {Icon && ( 53 | <> 54 | 59 |    60 | 61 | )} 62 | {title} 63 | {shortcut && {shortcut}} 64 | 65 | ); 66 | } 67 | 68 | const MenuItem = styled.button<{ 69 | selected: boolean; 70 | }>` 71 | display: flex; 72 | align-items: center; 73 | justify-content: flex-start; 74 | font-weight: 500; 75 | font-size: 14px; 76 | line-height: 1; 77 | width: 100%; 78 | height: 36px; 79 | cursor: pointer; 80 | border: none; 81 | opacity: ${props => (props.disabled ? ".5" : "1")}; 82 | color: ${props => 83 | props.selected 84 | ? props.theme.blockToolbarTextSelected 85 | : props.theme.blockToolbarText}; 86 | background: ${props => 87 | props.selected 88 | ? props.theme.blockToolbarSelectedBackground || 89 | props.theme.blockToolbarTrigger 90 | : "none"}; 91 | padding: 0 16px; 92 | outline: none; 93 | 94 | &:hover, 95 | &:active { 96 | color: ${props => props.theme.blockToolbarTextSelected}; 97 | background: ${props => 98 | props.selected 99 | ? props.theme.blockToolbarSelectedBackground || 100 | props.theme.blockToolbarTrigger 101 | : props.theme.blockToolbarHoverBackground}; 102 | } 103 | `; 104 | 105 | const Shortcut = styled.span` 106 | color: ${props => props.theme.textSecondary}; 107 | flex-grow: 1; 108 | text-align: right; 109 | `; 110 | 111 | export default withTheme(BlockMenuItem); 112 | -------------------------------------------------------------------------------- /src/plugins/EmojiTrigger.tsx: -------------------------------------------------------------------------------- 1 | import { InputRule } from "prosemirror-inputrules"; 2 | import { Plugin } from "prosemirror-state"; 3 | import Extension from "../lib/Extension"; 4 | import isInCode from "../queries/isInCode"; 5 | import { run } from "./BlockMenuTrigger"; 6 | 7 | const OPEN_REGEX = /(?:^|\s):([0-9a-zA-Z_+-]+)?$/; 8 | const CLOSE_REGEX = /(?:^|\s):(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/; 9 | 10 | export default class EmojiTrigger extends Extension { 11 | get name() { 12 | return "emojimenu"; 13 | } 14 | 15 | get plugins() { 16 | return [ 17 | new Plugin({ 18 | props: { 19 | handleClick: () => { 20 | this.options.onClose(); 21 | return false; 22 | }, 23 | handleKeyDown: (view, event) => { 24 | // Prosemirror input rules are not triggered on backspace, however 25 | // we need them to be evaluted for the filter trigger to work 26 | // correctly. This additional handler adds inputrules-like handling. 27 | if (event.key === "Backspace") { 28 | // timeout ensures that the delete has been handled by prosemirror 29 | // and any characters removed, before we evaluate the rule. 30 | setTimeout(() => { 31 | const { pos } = view.state.selection.$from; 32 | return run(view, pos, pos, OPEN_REGEX, (state, match) => { 33 | if (match) { 34 | this.options.onOpen(match[1]); 35 | } else { 36 | this.options.onClose(); 37 | } 38 | return null; 39 | }); 40 | }); 41 | } 42 | 43 | // If the query is active and we're navigating the block menu then 44 | // just ignore the key events in the editor itself until we're done 45 | if ( 46 | event.key === "Enter" || 47 | event.key === "ArrowUp" || 48 | event.key === "ArrowDown" || 49 | event.key === "Tab" 50 | ) { 51 | const { pos } = view.state.selection.$from; 52 | 53 | return run(view, pos, pos, OPEN_REGEX, (state, match) => { 54 | // just tell Prosemirror we handled it and not to do anything 55 | return match ? true : null; 56 | }); 57 | } 58 | 59 | return false; 60 | }, 61 | }, 62 | }), 63 | ]; 64 | } 65 | 66 | inputRules() { 67 | return [ 68 | // main regex should match only: 69 | // :word 70 | new InputRule(OPEN_REGEX, (state, match) => { 71 | if ( 72 | match && 73 | state.selection.$from.parent.type.name === "paragraph" && 74 | !isInCode(state) 75 | ) { 76 | this.options.onOpen(match[1]); 77 | } 78 | return null; 79 | }), 80 | // invert regex should match some of these scenarios: 81 | // :word 82 | // : 83 | // :word 84 | // :) 85 | new InputRule(CLOSE_REGEX, (state, match) => { 86 | if (match) { 87 | this.options.onClose(); 88 | } 89 | return null; 90 | }), 91 | ]; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/rules/checkboxes.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from "markdown-it"; 2 | import Token from "markdown-it/lib/token"; 3 | 4 | const CHECKBOX_REGEX = /\[(X|\s|_|-)\]\s(.*)?/i; 5 | 6 | function matches(token: Token | void) { 7 | return token && token.content.match(CHECKBOX_REGEX); 8 | } 9 | 10 | function isInline(token: Token | void): boolean { 11 | return !!token && token.type === "inline"; 12 | } 13 | 14 | function isParagraph(token: Token | void): boolean { 15 | return !!token && token.type === "paragraph_open"; 16 | } 17 | 18 | function isListItem(token: Token | void): boolean { 19 | return ( 20 | !!token && 21 | (token.type === "list_item_open" || token.type === "checkbox_item_open") 22 | ); 23 | } 24 | 25 | function looksLikeChecklist(tokens: Token[], index: number) { 26 | return ( 27 | isInline(tokens[index]) && 28 | isListItem(tokens[index - 2]) && 29 | isParagraph(tokens[index - 1]) && 30 | matches(tokens[index]) 31 | ); 32 | } 33 | 34 | export default function markdownItCheckbox(md: MarkdownIt): void { 35 | function render(tokens, idx) { 36 | const token = tokens[idx]; 37 | const checked = !!token.attrGet("checked"); 38 | 39 | if (token.nesting === 1) { 40 | // opening tag 41 | return `
  • ${checked ? "[x]" : "[ ]"}`; 44 | } else { 45 | // closing tag 46 | return "
  • \n"; 47 | } 48 | } 49 | 50 | md.renderer.rules.checkbox_item_open = render; 51 | md.renderer.rules.checkbox_item_close = render; 52 | 53 | // insert a new rule after the "inline" rules are parsed 54 | md.core.ruler.after("inline", "checkboxes", state => { 55 | const tokens = state.tokens; 56 | 57 | // work backwards through the tokens and find text that looks like a checkbox 58 | for (let i = tokens.length - 1; i > 0; i--) { 59 | const matches = looksLikeChecklist(tokens, i); 60 | if (matches) { 61 | const value = matches[1]; 62 | const checked = value.toLowerCase() === "x"; 63 | 64 | // convert surrounding list tokens 65 | if (tokens[i - 3].type === "bullet_list_open") { 66 | tokens[i - 3].type = "checkbox_list_open"; 67 | } 68 | 69 | if (tokens[i + 3].type === "bullet_list_close") { 70 | tokens[i + 3].type = "checkbox_list_close"; 71 | } 72 | 73 | // remove [ ] [x] from list item label – must use the content from the 74 | // child for escaped characters to be unescaped correctly. 75 | const tokenChildren = tokens[i].children; 76 | if (tokenChildren) { 77 | const contentMatches = tokenChildren[0].content.match(CHECKBOX_REGEX); 78 | 79 | if (contentMatches) { 80 | const label = contentMatches[2]; 81 | 82 | tokens[i].content = label; 83 | tokenChildren[0].content = label; 84 | } 85 | } 86 | 87 | // open list item and ensure checked state is transferred 88 | tokens[i - 2].type = "checkbox_item_open"; 89 | 90 | if (checked === true) { 91 | tokens[i - 2].attrs = [["checked", "true"]]; 92 | } 93 | 94 | // close the list item 95 | let j = i; 96 | while (tokens[j].type !== "list_item_close") { 97 | j++; 98 | } 99 | tokens[j].type = "checkbox_item_close"; 100 | } 101 | } 102 | 103 | return false; 104 | }); 105 | } 106 | -------------------------------------------------------------------------------- /src/lib/__snapshots__/renderToHtml.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders blockquote 1`] = ` 4 | "
    5 |

    blockquote

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

    this is bold text

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

    this is inline code text

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

    Heading 2

    43 |

    Heading 3

    44 |

    Heading 4

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

    this is highlighted text

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

    \\"caption\\"

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

    \\"caption\\"

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

    content of notice

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

    this is italic text

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

    this is also italic text

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

    this is linked text

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

    plain text

    "`; 82 | 83 | exports[`renders table 1`] = ` 84 | " 85 | 86 | 88 | 90 | 92 | 93 | 94 | 96 | 98 | 100 | 101 | 102 | 104 | 106 | 108 | 109 |
    87 |

    heading

    89 |

    centered

    91 |

    right aligned

    95 |

    97 |

    center

    99 |

    103 |

    105 |

    107 |

    bottom r

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

    this is a placeholder

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

    content of notice

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

    this is underlined text

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

    this is strikethrough text

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

    content of notice

    127 |
    " 128 | `; 129 | -------------------------------------------------------------------------------- /src/nodes/TableCell.ts: -------------------------------------------------------------------------------- 1 | import { DecorationSet, Decoration } from "prosemirror-view"; 2 | import { Plugin } from "prosemirror-state"; 3 | import { 4 | isTableSelected, 5 | isRowSelected, 6 | getCellsInColumn, 7 | } from "prosemirror-utils"; 8 | import Node from "./Node"; 9 | 10 | export default class TableCell extends Node { 11 | get name() { 12 | return "td"; 13 | } 14 | 15 | get schema() { 16 | return { 17 | content: "paragraph+", 18 | tableRole: "cell", 19 | isolating: true, 20 | parseDOM: [{ tag: "td" }], 21 | toDOM(node) { 22 | return [ 23 | "td", 24 | node.attrs.alignment 25 | ? { style: `text-align: ${node.attrs.alignment}` } 26 | : {}, 27 | 0, 28 | ]; 29 | }, 30 | attrs: { 31 | colspan: { default: 1 }, 32 | rowspan: { default: 1 }, 33 | alignment: { default: null }, 34 | }, 35 | }; 36 | } 37 | 38 | toMarkdown() { 39 | // see: renderTable 40 | } 41 | 42 | parseMarkdown() { 43 | return { 44 | block: "td", 45 | getAttrs: tok => ({ alignment: tok.info }), 46 | }; 47 | } 48 | 49 | get plugins() { 50 | return [ 51 | new Plugin({ 52 | props: { 53 | decorations: state => { 54 | const { doc, selection } = state; 55 | const decorations: Decoration[] = []; 56 | const cells = getCellsInColumn(0)(selection); 57 | 58 | if (cells) { 59 | cells.forEach(({ pos }, index) => { 60 | if (index === 0) { 61 | decorations.push( 62 | Decoration.widget(pos + 1, () => { 63 | let className = "grip-table"; 64 | const selected = isTableSelected(selection); 65 | if (selected) { 66 | className += " selected"; 67 | } 68 | const grip = document.createElement("a"); 69 | grip.className = className; 70 | grip.addEventListener("mousedown", event => { 71 | event.preventDefault(); 72 | event.stopImmediatePropagation(); 73 | this.options.onSelectTable(state); 74 | }); 75 | return grip; 76 | }) 77 | ); 78 | } 79 | decorations.push( 80 | Decoration.widget(pos + 1, () => { 81 | const rowSelected = isRowSelected(index)(selection); 82 | 83 | let className = "grip-row"; 84 | if (rowSelected) { 85 | className += " selected"; 86 | } 87 | if (index === 0) { 88 | className += " first"; 89 | } 90 | if (index === cells.length - 1) { 91 | className += " last"; 92 | } 93 | const grip = document.createElement("a"); 94 | grip.className = className; 95 | grip.addEventListener("mousedown", event => { 96 | event.preventDefault(); 97 | event.stopImmediatePropagation(); 98 | this.options.onSelectRow(index, state); 99 | }); 100 | return grip; 101 | }) 102 | ); 103 | }); 104 | } 105 | 106 | return DecorationSet.create(doc, decorations); 107 | }, 108 | }, 109 | }), 110 | ]; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/nodes/Notice.tsx: -------------------------------------------------------------------------------- 1 | import { wrappingInputRule } from "prosemirror-inputrules"; 2 | import toggleWrap from "../commands/toggleWrap"; 3 | import { WarningIcon, InfoIcon, StarredIcon } from "outline-icons"; 4 | import * as React from "react"; 5 | import ReactDOM from "react-dom"; 6 | import Node from "./Node"; 7 | import noticesRule from "../rules/notices"; 8 | 9 | export default class Notice extends Node { 10 | get styleOptions() { 11 | return Object.entries({ 12 | info: this.options.dictionary.info, 13 | warning: this.options.dictionary.warning, 14 | tip: this.options.dictionary.tip, 15 | }); 16 | } 17 | 18 | get name() { 19 | return "container_notice"; 20 | } 21 | 22 | get rulePlugins() { 23 | return [noticesRule]; 24 | } 25 | 26 | get schema() { 27 | return { 28 | attrs: { 29 | style: { 30 | default: "info", 31 | }, 32 | }, 33 | content: "block+", 34 | group: "block", 35 | defining: true, 36 | draggable: true, 37 | parseDOM: [ 38 | { 39 | tag: "div.notice-block", 40 | preserveWhitespace: "full", 41 | contentElement: "div:last-child", 42 | getAttrs: (dom: HTMLDivElement) => ({ 43 | style: dom.className.includes("tip") 44 | ? "tip" 45 | : dom.className.includes("warning") 46 | ? "warning" 47 | : undefined, 48 | }), 49 | }, 50 | ], 51 | toDOM: node => { 52 | const select = document.createElement("select"); 53 | select.addEventListener("change", this.handleStyleChange); 54 | 55 | this.styleOptions.forEach(([key, label]) => { 56 | const option = document.createElement("option"); 57 | option.value = key; 58 | option.innerText = label; 59 | option.selected = node.attrs.style === key; 60 | select.appendChild(option); 61 | }); 62 | 63 | let component; 64 | 65 | if (node.attrs.style === "tip") { 66 | component = ; 67 | } else if (node.attrs.style === "warning") { 68 | component = ; 69 | } else { 70 | component = ; 71 | } 72 | 73 | const icon = document.createElement("div"); 74 | icon.className = "icon"; 75 | ReactDOM.render(component, icon); 76 | 77 | return [ 78 | "div", 79 | { class: `notice-block ${node.attrs.style}` }, 80 | icon, 81 | ["div", { contentEditable: false }, select], 82 | ["div", { class: "content" }, 0], 83 | ]; 84 | }, 85 | }; 86 | } 87 | 88 | commands({ type }) { 89 | return attrs => toggleWrap(type, attrs); 90 | } 91 | 92 | handleStyleChange = event => { 93 | const { view } = this.editor; 94 | const { tr } = view.state; 95 | const element = event.target; 96 | const { top, left } = element.getBoundingClientRect(); 97 | const result = view.posAtCoords({ top, left }); 98 | 99 | if (result) { 100 | const transaction = tr.setNodeMarkup(result.inside, undefined, { 101 | style: element.value, 102 | }); 103 | view.dispatch(transaction); 104 | } 105 | }; 106 | 107 | inputRules({ type }) { 108 | return [wrappingInputRule(/^:::$/, type)]; 109 | } 110 | 111 | toMarkdown(state, node) { 112 | state.write("\n:::" + (node.attrs.style || "info") + "\n"); 113 | state.renderContent(node); 114 | state.ensureNewLine(); 115 | state.write(":::"); 116 | state.closeBlock(node); 117 | } 118 | 119 | parseMarkdown() { 120 | return { 121 | block: "container_notice", 122 | getAttrs: tok => ({ style: tok.info }), 123 | }; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/styles/theme.ts: -------------------------------------------------------------------------------- 1 | const colors = { 2 | almostBlack: "#212428", 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: "#9cffa3", 29 | textHighlightForeground: colors.black, 30 | selected: colors.primary, 31 | codeComment: "#6a737d", 32 | codePunctuation: "#5e6687", 33 | codeNumber: "#d73a49", 34 | codeProperty: "#c08b30", 35 | codeTag: "#3d8fd1", 36 | codeString: "#032f62", 37 | codeSelector: "#6679cc", 38 | codeAttr: "#c76b29", 39 | codeEntity: "#22a2c9", 40 | codeKeyword: "#d73a49", 41 | codeFunction: "#6f42c1", 42 | codeStatement: "#22a2c9", 43 | codePlaceholder: "#3d8fd1", 44 | codeInserted: "#202746", 45 | codeImportant: "#c94922", 46 | 47 | blockToolbarBackground: colors.white, 48 | blockToolbarTrigger: colors.greyMid, 49 | blockToolbarTriggerIcon: colors.white, 50 | blockToolbarItem: colors.almostBlack, 51 | blockToolbarIcon: undefined, 52 | blockToolbarIconSelected: colors.black, 53 | blockToolbarText: colors.almostBlack, 54 | blockToolbarTextSelected: colors.black, 55 | blockToolbarSelectedBackground: colors.greyMid, 56 | blockToolbarHoverBackground: colors.greyLight, 57 | blockToolbarDivider: colors.greyMid, 58 | 59 | noticeInfoBackground: "#F5BE31", 60 | noticeInfoText: colors.almostBlack, 61 | noticeTipBackground: "#9E5CF7", 62 | noticeTipText: colors.white, 63 | noticeWarningBackground: "#FF5C80", 64 | noticeWarningText: colors.white, 65 | }; 66 | 67 | export const light = { 68 | ...base, 69 | background: colors.white, 70 | text: colors.almostBlack, 71 | code: colors.lightBlack, 72 | cursor: colors.black, 73 | divider: colors.greyMid, 74 | 75 | toolbarBackground: colors.lightBlack, 76 | toolbarHoverBackground: colors.black, 77 | toolbarInput: colors.white10, 78 | toolbarItem: colors.white, 79 | 80 | tableDivider: colors.greyMid, 81 | tableSelected: colors.primary, 82 | tableSelectedBackground: "#E5F7FF", 83 | 84 | quote: colors.greyDark, 85 | codeBackground: colors.greyLight, 86 | codeBorder: colors.grey, 87 | horizontalRule: colors.greyMid, 88 | imageErrorBackground: colors.greyLight, 89 | 90 | scrollbarBackground: colors.greyLight, 91 | scrollbarThumb: colors.greyMid, 92 | }; 93 | 94 | export const dark = { 95 | ...base, 96 | background: colors.almostBlack, 97 | text: colors.almostWhite, 98 | code: colors.almostWhite, 99 | cursor: colors.white, 100 | divider: "#4E5C6E", 101 | placeholder: "#52657A", 102 | 103 | toolbarBackground: colors.white, 104 | toolbarHoverBackground: colors.greyMid, 105 | toolbarInput: colors.black10, 106 | toolbarItem: colors.lightBlack, 107 | 108 | tableDivider: colors.lightBlack, 109 | tableSelected: colors.primary, 110 | tableSelectedBackground: "#002333", 111 | 112 | quote: colors.greyDark, 113 | codeBackground: colors.black, 114 | codeBorder: colors.lightBlack, 115 | codeString: "#3d8fd1", 116 | horizontalRule: colors.lightBlack, 117 | imageErrorBackground: "rgba(0, 0, 0, 0.5)", 118 | 119 | scrollbarBackground: colors.black, 120 | scrollbarThumb: colors.lightBlack, 121 | }; 122 | 123 | export default light; 124 | -------------------------------------------------------------------------------- /src/stories/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { dark, light } from "../styles/theme"; 3 | import Editor from ".."; 4 | 5 | const docSearchResults = [ 6 | { 7 | title: "Hiring", 8 | subtitle: "Created by Jane", 9 | url: "/doc/hiring", 10 | }, 11 | { 12 | title: "Product Roadmap", 13 | subtitle: "Created by Tom", 14 | url: "/doc/product-roadmap", 15 | }, 16 | { 17 | title: "Finances", 18 | subtitle: "Created by Coley", 19 | url: "/doc/finances", 20 | }, 21 | { 22 | title: "Security", 23 | subtitle: "Created by Coley", 24 | url: "/doc/security", 25 | }, 26 | { 27 | title: "Super secret stuff", 28 | subtitle: "Created by Coley", 29 | url: "/doc/secret-stuff", 30 | }, 31 | { 32 | title: "Supero notes", 33 | subtitle: "Created by Vanessa", 34 | url: "/doc/supero-notes", 35 | }, 36 | { 37 | title: "Meeting notes", 38 | subtitle: "Created by Rob", 39 | url: "/doc/meeting-notes", 40 | }, 41 | ]; 42 | 43 | class YoutubeEmbed extends React.Component<{ 44 | attrs: any; 45 | isSelected: boolean; 46 | }> { 47 | render() { 48 | const { attrs } = this.props; 49 | const videoId = attrs.matches[1]; 50 | 51 | return ( 52 |