diff --git a/package.json b/package.json index aaa08c515..72acd8bbc 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ ] }, "dependencies": { + "@quartzy/markdown-it-mentions": "^0.2.0", "copy-to-clipboard": "^3.0.8", "fuzzy-search": "^3.2.1", "gemoji": "6.x", @@ -52,7 +53,8 @@ "refractor": "^3.3.1", "resize-observer-polyfill": "^1.5.1", "slugify": "^1.4.0", - "smooth-scroll-into-view-if-needed": "^1.1.29" + "smooth-scroll-into-view-if-needed": "^1.1.29", + "url-parse": "^1.5.10" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0", @@ -112,7 +114,8 @@ "resolutions": { "markdown-it": "^12.2.0", "prosemirror-transform": "1.2.5", - "yargs-parser": "^15.0.1" + "yargs-parser": "^15.0.1", + "prosemirror-model": "1.9.1" }, "repository": { "type": "git", @@ -128,5 +131,8 @@ "bugs": { "url": "https://github.com/outline/rich-markdown-editor/issues" }, - "homepage": "https://github.com/outline/rich-markdown-editor#readme" + "homepage": "https://github.com/outline/rich-markdown-editor#readme", + "volta": { + "node": "16.18.0" + } } diff --git a/src/components/CommandMenu.tsx b/src/components/CommandMenu.tsx index 390512d90..e95478b19 100644 --- a/src/components/CommandMenu.tsx +++ b/src/components/CommandMenu.tsx @@ -11,6 +11,7 @@ import getDataTransferFiles from "../lib/getDataTransferFiles"; import filterExcessSeparators from "../lib/filterExcessSeparators"; import insertFiles from "../commands/insertFiles"; import baseDictionary from "../dictionary"; +import createAndInsertLink from "../commands/createAndInsertLink"; const SSR = typeof window === "undefined"; @@ -29,6 +30,7 @@ export type Props = { view: EditorView; search: string; uploadImage?: (file: File) => Promise; + onCreateLink?:(title: string) => Promise; onImageUploadStart?: () => void; onImageUploadStop?: () => void; onShowToast?: (message: string, id: string) => void; @@ -173,7 +175,46 @@ class CommandMenu extends React.Component, State> { } }; + handleOnCreateLink = async (title: string) => { + const { dictionary, onCreateLink, view, onClose, onShowToast } = this.props; + + onClose(); + this.props.view.focus(); + + if (!onCreateLink) { + return; + } + + const { dispatch, state } = view; + const { from, to } = state.selection; + if (from !== to) { + // selection must be collapsed + return; + } + + const href = `creating#${title}…`; + + // Insert a placeholder link + dispatch( + view.state.tr + .insertText(title, from, to) + .addMark( + from, + to + title.length, + state.schema.marks.link.create({ href }) + ) + ); + + createAndInsertLink(view, title, href, { + onCreateLink, + onShowToast, + dictionary, + }); + }; + insertItem = item => { + console.log(item.name); + switch (item.name) { case "image": return this.triggerImagePick(); @@ -185,6 +226,8 @@ class CommandMenu extends React.Component, State> { this.props.onLinkToolbarOpen?.(); return; } + // case 'mention' : + // return this.handleOnCreateLink(item.title) default: this.insertBlock(item); } @@ -302,7 +345,19 @@ class CommandMenu extends React.Component, State> { this.clearSearch(); const command = this.props.commands[item.name]; - + console.log(item,'insertBlock'); + + // if(item.name == 'mention') { + // const { state, dispatch } = this.props.view; + // dispatch( + // state.tr.insertText( + // item.title, + // state.selection.$from.pos - (this.props.search ?? "").length - 1, + // state.selection.to + // ) + // ); + // } + if (command) { command(item.attrs); } else { diff --git a/src/components/MentionMenu.tsx b/src/components/MentionMenu.tsx new file mode 100644 index 000000000..949fab6ef --- /dev/null +++ b/src/components/MentionMenu.tsx @@ -0,0 +1,120 @@ +import React from "react"; +import gemojies from "gemoji"; +import FuzzySearch from "fuzzy-search"; +import CommandMenu, { Props } from "./CommandMenu"; +import EmojiMenuItem from "./EmojiMenuItem"; +import MentionMenuItem from "./MentionMenuItem"; + +type Emoji = { + name: string; + title: string; + // emoji: string; + email: string; + attrs: { markup: string; "data-name": string }; +}; + +const people = [{ + name: { + firstName: 'Jesse', + lastName: 'Bowen', + }, + state: 'Seattle', +}, +{ + name: { + firstName: 'juniad', + lastName: 'Bowen', + }, + state: 'junaid', + +}, +{ + name: { + firstName: 'juniad', + lastName: 'adam', + }, + state: 'adam', +}, +{ + name: { + firstName: 'no-name', + lastName: 'yes-name', + }, + state: 'name', +} +]; + +const searcher = new FuzzySearch(people, ['name.firstName', 'state'], { + caseSensitive: true, + sort: true, +}); +type onCreateLink = (title: string) => Promise +class MentionMenu extends React.Component< + Omit< + Props, + | "renderMenuItem" + | "items" + | "onLinkToolbarOpen" + | "embeds" + | "onClearSearch" + > +> { + get items(): Emoji[] { + const { search = "" } = this.props; + + const n = search.toLowerCase(); + const result = searcher.search(n).map(item => { + const email = item.name.firstName; + const name = item.state; + return { + ...item, + name: "mention", + title: name, + email, + attrs: { markup: name, "data-name": name }, + }; + }); + + return result.slice(0, 10); + } + + clearSearch = () => { + const { state, dispatch } = this.props.view; + + // clear search input + dispatch( + state.tr.insertText( + "", + state.selection.$from.pos - (this.props.search ?? "").length - 1, + state.selection.to + ) + ); + }; + + + render() { + return ( + { + return ( + + ); + }} + items={this.items} + /> + ); + } +} + +export default MentionMenu; diff --git a/src/components/MentionMenuItem.tsx b/src/components/MentionMenuItem.tsx new file mode 100644 index 000000000..ebfffbe2b --- /dev/null +++ b/src/components/MentionMenuItem.tsx @@ -0,0 +1,39 @@ +import * as React from "react"; +import BlockMenuItem, { Props as BlockMenuItemProps } from "./BlockMenuItem"; +import styled from "styled-components"; + +const Emoji = styled.span` + font-size: 16px; +`; + + +const MentionName = ({ + title, +}: { + title: React.ReactNode; +}) => { + return ( +

+ {/* {emoji} */} +    + {title} +

+ ); +}; + +type EmojiMenuItemProps = Omit & { + // emoji: string; +}; + +const Mentions = styled(MentionName)({ + cursor: 'pointer' +}) + +export default function MentionMenuItem(props: EmojiMenuItemProps) { + return ( + } + /> + ); +} diff --git a/src/index.tsx b/src/index.tsx index 428a1c273..f92de42b1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -40,6 +40,7 @@ import CodeBlock from "./nodes/CodeBlock"; import CodeFence from "./nodes/CodeFence"; import CheckboxList from "./nodes/CheckboxList"; import Emoji from "./nodes/Emoji"; +import Mention from "./nodes/Mention"; import CheckboxItem from "./nodes/CheckboxItem"; import Embed from "./nodes/Embed"; import HardBreak from "./nodes/HardBreak"; @@ -68,6 +69,7 @@ import Underline from "./marks/Underline"; // plugins import BlockMenuTrigger from "./plugins/BlockMenuTrigger"; import EmojiTrigger from "./plugins/EmojiTrigger"; +import MentionsTrigger from "./plugins/Mentions"; import Folding from "./plugins/Folding"; import History from "./plugins/History"; import Keys from "./plugins/Keys"; @@ -77,6 +79,7 @@ import SmartText from "./plugins/SmartText"; import TrailingNode from "./plugins/TrailingNode"; import PasteHandler from "./plugins/PasteHandler"; import { PluginSimple } from "markdown-it"; +import MentionMenu from "./components/MentionMenu"; export { schema, parser, serializer, renderToHtml } from "./server"; @@ -164,6 +167,7 @@ type State = { linkMenuOpen: boolean; blockMenuSearch: string; emojiMenuOpen: boolean; + mentionsOpen: boolean }; type Step = { @@ -197,6 +201,7 @@ class RichMarkdownEditor extends React.PureComponent { linkMenuOpen: false, blockMenuSearch: "", emojiMenuOpen: false, + mentionsOpen: false }; isBlurred: boolean; @@ -219,6 +224,8 @@ class RichMarkdownEditor extends React.PureComponent { rulePlugins: PluginSimple[]; componentDidMount() { + console.log('hellooo'); + this.init(); if (this.props.scrollTo) { @@ -327,6 +334,7 @@ class RichMarkdownEditor extends React.PureComponent { onShowToast: this.props.onShowToast, }), new Emoji(), + new Mention(), new Text(), new CheckboxList(), new CheckboxItem(), @@ -397,6 +405,14 @@ class RichMarkdownEditor extends React.PureComponent { this.setState({ emojiMenuOpen: false }); }, }), + new MentionsTrigger({ + onOpen: (search: string) => { + this.setState({ mentionsOpen: true, blockMenuSearch: search }); + }, + onClose: () => { + this.setState({ mentionsOpen: false }); + }, + }), new Placeholder({ placeholder: this.props.placeholder, }), @@ -814,6 +830,16 @@ class RichMarkdownEditor extends React.PureComponent { search={this.state.blockMenuSearch} onClose={() => this.setState({ emojiMenuOpen: false })} /> + this.setState({ mentionsOpen: false })} + /> ({ + "data-name": dom.dataset.name, + }), + contentElement: ".mention" + }, + ], + toDOM: node => { + let mention = document.createElement("span") + mention.innerText = `@${node.attrs["data-name"]}` + mention.addEventListener('click', () => { + console.log('clik',node.attrs["data-name"]); + }) + return [ + "span", + { + class: "mention", + "data-name": node.attrs["data-name"], + }, + mention, + ]; + }, + }; + } + + get rulePlugins() { + return [mentionsRule]; + } + + commands({ type }) { + return attrs => (state, dispatch) => { + const { selection } = state; + const position = selection.$cursor + ? selection.$cursor.pos + : selection.$to.pos; + const node = type.create(attrs); + const transaction = state.tr.insert(position, node); + dispatch(transaction); + return true; + }; + } + + inputRules({ type }) { + return [ + new InputRule(/^\@([a-zA-Z0-9_+-]+)\@$/, (state, match, start, end) => { + const [okay, markup] = match; + const { tr } = state; + if (okay) { + tr.replaceWith( + start - 1, + end, + type.create({ + "data-name": markup, + markup, + }) + ); + } + return tr; + }), + ]; + } + + toMarkdown(state, node) { + const name = node.attrs["data-name"]; + if (name) { + const label = state.esc(name || ''); + const uri = state.esc(`mention://${name}/${name}`); + state.write(`@[${label}](${uri})`); + } + } + + parseMarkdown() { + return { + node: "mention", + getAttrs: tok => { + return { "data-name": tok.mention.type.trim() }; + }, + }; + } +} \ No newline at end of file diff --git a/src/plugins/Mentions.tsx b/src/plugins/Mentions.tsx new file mode 100644 index 000000000..c1c8fdea8 --- /dev/null +++ b/src/plugins/Mentions.tsx @@ -0,0 +1,93 @@ +import { InputRule } from "prosemirror-inputrules"; +import { Plugin } from "prosemirror-state"; +import Extension from "../lib/Extension"; +import isInCode from "../queries/isInCode"; +import { run } from "./BlockMenuTrigger"; + +const OPEN_REGEX = /(?:^|\s)@([0-9a-zA-Z_+-]+)?$/; +const CLOSE_REGEX = /(?:^|\s)@(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/; + +export default class MentionsTrigger extends Extension { + get name() { + return "mentionsMenu"; + } + + get plugins() { + return [ + new Plugin({ + props: { + handleClick: () => { + this.options.onClose(); + return false; + }, + handleKeyDown: (view, event) => { + // Prosemirror input rules are not triggered on backspace, however + // we need them to be evaluted for the filter trigger to work + // correctly. This additional handler adds inputrules-like handling. + if (event.key === "Backspace") { + // timeout ensures that the delete has been handled by prosemirror + // and any characters removed, before we evaluate the rule. + setTimeout(() => { + const { pos } = view.state.selection.$from; + return run(view, pos, pos, OPEN_REGEX, (state, match) => { + if (match) { + this.options.onOpen(match[1]); + } else { + this.options.onClose(); + } + return null; + }); + }); + } + + // If the query is active and we're navigating the block menu then + // just ignore the key events in the editor itself until we're done + if ( + event.key === "Enter" || + event.key === "ArrowUp" || + event.key === "ArrowDown" || + event.key === "Tab" + ) { + const { pos } = view.state.selection.$from; + + return run(view, pos, pos, OPEN_REGEX, (state, match) => { + // just tell Prosemirror we handled it and not to do anything + return match ? true : null; + }); + } + + return false; + }, + }, + }), + ]; + } + + inputRules() { + return [ + // main regex should match only: + // :word + new InputRule(OPEN_REGEX, (state, match) => { + if ( + match && + state.selection.$from.parent.type.name === "paragraph" && + !isInCode(state) + ) { + this.options.onOpen(match[1]); + } + return null; + }), + // invert regex should match some of these scenarios: + // :word + // : + // :word + // :) + new InputRule(CLOSE_REGEX, (state, match) => { + if (match) { + this.options.onClose(); + } + return null; + }), + ]; + } +} diff --git a/src/rules/mentions.ts b/src/rules/mentions.ts new file mode 100644 index 000000000..78f86b98c --- /dev/null +++ b/src/rules/mentions.ts @@ -0,0 +1,68 @@ +import parseUrl from 'url-parse'; + +export default function mention(md): void { + + function renderMention(tokens, idx) { + return `${tokens[idx].mention.label}`; + } + + function parseUri(uri) { + const pieces = parseUrl(uri); + + return { + type: pieces.host, + id: pieces.pathname.slice(1), + }; + } + + function parseMentions(state) { + const matcher = /@$/; + + state.tokens.forEach(blockToken => { + if (blockToken.type !== 'inline') return; + + const { children } = blockToken; + + children.forEach((token, idx) => { + // Back out if we're near the end of the token array + if (idx + 3 > children.length) return; + + // Grab the next four tokens that could potentially construct a mention + let [matchToken, openToken, textToken, closeToken = {}] = children.slice(idx, idx + 4); + + // Compensate for when the link has no label + if (textToken.type === 'link_close') { + closeToken = textToken; + textToken = null; + } + + // Back out if we're not dealing with a mention + if (matchToken.type !== 'text') return; + if (!matcher.test(matchToken.content)) return; + if (openToken.type !== 'link_open') return; + if (closeToken.type !== 'link_close') return; + + // Lookup the mention type and ID from the link's href + const href = openToken.attrs.reduce((href, attr) => attr[0] === 'href' ? attr[1] : href, ''); + + // Remove the @ character from the previous text node + matchToken.content = matchToken.content.slice(0, -1); + + // Replace the "link_open" with a single "mention" token + openToken.type = 'mention'; + openToken.mention = parseUri(href); + openToken.mention.label = textToken && textToken.content || ''; + + // Remove the "text" and "link_close" tokens + children.splice(idx + 2, textToken ? 2 : 1); + }); + + blockToken.children = children; + }); + } + + + md.core.ruler.after('inline', 'mention', parseMentions); + md.renderer.rules.mention = renderMention; + +} diff --git a/src/server.ts b/src/server.ts index fe7a16155..342c19458 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ import Doc from "./nodes/Doc"; import Text from "./nodes/Text"; import Blockquote from "./nodes/Blockquote"; import Emoji from "./nodes/Emoji"; +import Mention from "./nodes/Mention"; import BulletList from "./nodes/BulletList"; import CodeBlock from "./nodes/CodeBlock"; import CodeFence from "./nodes/CodeFence"; @@ -43,6 +44,7 @@ const extensions = new ExtensionManager([ new Paragraph(), new Blockquote(), new Emoji(), + new Mention(), new BulletList(), new CodeBlock(), new CodeFence(), diff --git a/src/styles/editor.ts b/src/styles/editor.ts index 668fd9afb..6af1d335c 100644 --- a/src/styles/editor.ts +++ b/src/styles/editor.ts @@ -237,7 +237,15 @@ export const StyledEditor = styled("div")<{ opacity: 1; } } - + + .mention { + background: #e8f5fa; + color: #1264a3; + border-radius: 4px; + cursor: pointer; + padding: 0px 2px; + } + .heading-actions { opacity: 0; background: ${props => props.theme.background};