From e4e8adf0311817f47a7e044047fdcf30ee6808cc Mon Sep 17 00:00:00 2001 From: aravind-segu Date: Thu, 4 Jun 2020 00:16:09 -0400 Subject: [PATCH 1/2] Got Tag UI Working --- src/components/FloatingToolbar.tsx | 30 ++++++---- src/components/TagEditor.tsx | 96 ++++++++++++++++++++++++++++++ src/index.tsx | 11 ++++ src/marks/Hide.ts | 28 +++++++++ src/marks/Link.ts | 4 ++ src/marks/Tag.ts | 76 +++++++++++++++++++++++ src/plugins/TagFiltering.ts | 81 +++++++++++++++---------- src/server.ts | 4 ++ 8 files changed, 288 insertions(+), 42 deletions(-) create mode 100644 src/components/TagEditor.tsx create mode 100644 src/marks/Hide.ts create mode 100644 src/marks/Tag.ts diff --git a/src/components/FloatingToolbar.tsx b/src/components/FloatingToolbar.tsx index 1a03d3f92..ec7e33876 100644 --- a/src/components/FloatingToolbar.tsx +++ b/src/components/FloatingToolbar.tsx @@ -8,6 +8,7 @@ import getTableRowMenuItems from "../menus/tableRow"; import getTableMenuItems from "../menus/table"; import getFormattingMenuItems from "../menus/formatting"; import LinkEditor, { SearchResult } from "./LinkEditor"; +import TagEditor from "./TagEditor"; import Menu from "./Menu"; import isMarkActive from "../queries/isMarkActive"; import getMarkRange from "../queries/getMarkRange"; @@ -129,8 +130,9 @@ export default class FloatingToolbar extends React.Component { const rowIndex = getRowIndex(state.selection); const isTableSelection = colIndex !== undefined && rowIndex !== undefined; const link = isMarkActive(state.schema.marks.link)(state); - const range = getMarkRange(selection.$from, state.schema.marks.link); - + const tag = isMarkActive(state.schema.marks.tag)(state); + const linkRange = getMarkRange(selection.$from, state.schema.marks.link); + const tagRange = getMarkRange(selection.$from, state.schema.marks.tag); let items = []; if (isTableSelection) { items = getTableMenuItems(); @@ -155,16 +157,20 @@ export default class FloatingToolbar extends React.Component { left={this.state.left} offset={this.state.offset} > - {link && range ? ( - - ) : ( - - )} + {(link && linkRange) ? () : + ((tag && tagRange) ? () + : () + ) + } ); diff --git a/src/components/TagEditor.tsx b/src/components/TagEditor.tsx new file mode 100644 index 000000000..93b6ccbdc --- /dev/null +++ b/src/components/TagEditor.tsx @@ -0,0 +1,96 @@ +import * as React from "react"; +import { EditorView } from "prosemirror-view"; +import { Mark } from "prosemirror-model"; +import { TrashIcon } from "outline-icons"; +import styled, { withTheme } from "styled-components"; +import theme from "../theme"; +import Flex from "./Flex"; +import Input from "./Input"; +import ToolbarButton from "./ToolbarButton"; + + +type Props = { + mark: Mark; + from: number; + to: number; + tooltip: typeof React.Component; + view: EditorView; + theme: typeof theme; + }; + + class TagEditor extends React.Component{ + discardInputValue = false; + initialValue: string = this.props.mark.attrs.name; + state = { + value: this.props.mark.attrs.name, + }; + + handleChange = async (event): Promise => { + const value = event.target.value.trim(); + this.setState({value}); + }; + + componentWillUnmount = () => { + // If we discarded the changes then nothing to do or already saved + if (this.discardInputValue || this.state.value === this.initialValue) { + return; + } + // If the name is empty remove the mark + let name = (this.state.value || "").trim(); + if (!name) { + return this.handleRemoveTag(); + } + //Save the changes + this.save(name); + }; + + save = (name: string): void => { + this.discardInputValue = true; + const { from, to } = this.props; + const { state, dispatch } = this.props.view; + const markType = state.schema.marks.tag; + + dispatch( + state.tr + .removeMark(from, to, markType) + .addMark(from, to, markType.create({ name })) + ); + }; + + handleRemoveTag = (): void => { + this.discardInputValue = true; + const { from, to, mark } = this.props; + const { state, dispatch } = this.props.view; + + dispatch(state.tr.removeMark(from, to, mark)); + }; + + render() { + const { mark } = this.props; + const Tooltip = this.props.tooltip; + + return ( + + + + + + + + + ); + } + } + + const Wrapper = styled(Flex)` + margin-left: -8px; + margin-right: -8px; + min-width: 336px; +`; + +export default withTheme(TagEditor); diff --git a/src/index.tsx b/src/index.tsx index cfe73e5be..2c8836464 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -53,6 +53,8 @@ import Highlight from "./marks/Highlight"; import Italic from "./marks/Italic"; import Link from "./marks/Link"; import Strikethrough from "./marks/Strikethrough"; +import Tag from "./marks/Tag"; +import Hide from "./marks/Hide"; // plugins import BlockMenuTrigger from "./plugins/BlockMenuTrigger"; @@ -251,6 +253,8 @@ class RichMarkdownEditor extends React.PureComponent { new Bold(), new Code(), new Highlight(), + new Tag(), + new Hide(), new Italic(), new Link({ onClickLink: this.props.onClickLink, @@ -742,6 +746,13 @@ const StyledEditor = styled("div")<{ readOnly: boolean }>` font-style: italic; } + tag { + background: yellow; + } + + hide { + display: none; + } b, strong { font-weight: 600; diff --git a/src/marks/Hide.ts b/src/marks/Hide.ts new file mode 100644 index 000000000..6636f480b --- /dev/null +++ b/src/marks/Hide.ts @@ -0,0 +1,28 @@ +import markInputRule from "../lib/markInputRule"; +import Mark from "./Mark"; + +export default class Hide extends Mark { + get name() { + return "hide"; + } + + get schema() { + return { + parseDOM: [{ tag: "hide" }], + toDOM: () => ["hide"], + }; + } + + get toMarkdown() { + return { + open: "++", + close: "++", + mixable: true, + expelEnclosingWhitespace: true, + }; + } + + parseMarkdown() { + return { mark: "hide" }; + } +} diff --git a/src/marks/Link.ts b/src/marks/Link.ts index f15e307c6..319af1fd3 100644 --- a/src/marks/Link.ts +++ b/src/marks/Link.ts @@ -62,6 +62,10 @@ export default class Link extends Mark { inputRules({ type }) { return [ new InputRule(LINK_INPUT_REGEX, (state, match, start, end) => { + console.log(state); + console.log(match); + console.log(start); + console.log(end); const [okay, alt, href] = match; const { tr } = state; diff --git a/src/marks/Tag.ts b/src/marks/Tag.ts new file mode 100644 index 000000000..a7dffea51 --- /dev/null +++ b/src/marks/Tag.ts @@ -0,0 +1,76 @@ +import Mark from "./Mark"; +import { InputRule } from "prosemirror-inputrules"; +import { toggleMark } from "prosemirror-commands"; + +const TAG_REGEX = /:(.+):\(([^\)]+)\)/; + +export default class Tag extends Mark { + get name() { + return "tag"; + } + + get schema() { + return { + attrs: { + name: { + default: null, + }, + }, + parseDOM: [ + { + tag: "tag[name]", + getAttrs: (dom: HTMLElement) => ({ + name: dom.getAttribute("name"), + }), + }, + ], + toDOM: node => [ + "tag", + { + ...node.attrs, + }, + 0, + ], + }; + } + + + inputRules({ type }) { + return [ + new InputRule(TAG_REGEX, (state, match, start, end) => { + const [okay, name, content] = match; + const { tr } = state; + + if (okay) { + tr.replaceWith(start, end, this.editor.schema.text(content)).addMark( + start, + start+content.length, + type.create({ name }) + ); + } + + return tr; + }), + ]; + } + + get toMarkdown() { + return { + open(state, mark, parent, index) { + return ":" + state.esc(mark.attrs.name) + ":(" + }, + close(state, mark, parent, index) { + return ")" + }, + }; + } + + parseMarkdown() { + return { + mark: "tag", + getAttrs: tok => ({ + name: tok.attrGet("name"), + }), + }; + } +} \ No newline at end of file diff --git a/src/plugins/TagFiltering.ts b/src/plugins/TagFiltering.ts index c338d1868..de0a80956 100644 --- a/src/plugins/TagFiltering.ts +++ b/src/plugins/TagFiltering.ts @@ -1,24 +1,26 @@ import { Plugin, PluginKey } from "prosemirror-state"; import Extension from "../lib/Extension"; +import Hide from "../marks/Hide"; export default class TagFiltering extends Extension { static pluginName = "tag-filtering"; static pluginKey = new PluginKey(TagFiltering.pluginName); - static matchTextblockNode = (node, tagFilters) => { + static matchTextblockNode = (tag, tagFilters) => { // TODO (carl) tag marks not just string if (!tagFilters) { return true; } if (!Array.isArray(tagFilters)) { - return node.textContent.includes(tagFilters); + let filter = tagFilters.substring(2, tagFilters.length - 1); + return tag.includes(filter); } // invariant: tagFilters isArray // only not expressions have array length 2 if (tagFilters.length === 2) { - return !TagFiltering.matchTextblockNode(node, tagFilters[1]); + return !TagFiltering.matchTextblockNode(tag, tagFilters[1]); } - const match1 = TagFiltering.matchTextblockNode(node, tagFilters[0]); + const match1 = TagFiltering.matchTextblockNode(tag, tagFilters[0]); if (tagFilters.length === 1) { return match1; } @@ -31,7 +33,7 @@ export default class TagFiltering extends Extension { // invariant: // if andOp then !match1, so result should be "match2", // if !andOp then match1, so result should be "match2" - return TagFiltering.matchTextblockNode(node, tagFilters[2]); + return TagFiltering.matchTextblockNode(tag, tagFilters[2]); }; get name() { @@ -60,63 +62,82 @@ export default class TagFiltering extends Extension { break; } } - if (trTagFilters === undefined) { + if (trTagFilters === undefined || trTagFilters === null) { return; } let tr = null; - const evaluate = (node, pos, offset) => { + const evaluate = (node, pos) => { let match = false; - if (node.isTextblock) { - match = TagFiltering.matchTextblockNode(node, trTagFilters); + let allMarks = node.marks; + let hasTag = false; + let tag = ""; + for(let i = 0; i < allMarks.length; i++){ + let mark = allMarks[i]; + if(mark['type']['name'] == "tag"){ + hasTag = true; + tag = mark['attrs']['name']; + break; + } + } + if (hasTag) { + match = TagFiltering.matchTextblockNode(tag, trTagFilters); } else { - let cumulativeChildSize = 1; + let cumulativeChildSize = 0; for (let i = 0; i < node.childCount; ++i) { const child = node.child(i); const evaluateChild = evaluate( child, - pos + cumulativeChildSize, - offset - ); + pos + cumulativeChildSize + ); match = match || evaluateChild[0]; - offset = evaluateChild[1]; - cumulativeChildSize += child.nodeSize; + cumulativeChildSize += evaluateChild[1]; } } let difPos = 0; if (node.type !== node.type.schema.doc) { // don't hide/unhide for the doc node + let nodeMarks = node.marks; + let hasHide = false; + for(let i = 0; i < nodeMarks.length; i++){ + let mark = nodeMarks[i]; + if(mark['type']['name'] == "hide"){ + nodeMarks = true; + break; + } + } if (!match) { // TODO (carl) actually hide if ( - node.isTextblock && - !node.textContent.startsWith("${hide} ") + node.text != undefined && + !hasHide ) { const endPos = pos + (node.textContent.startsWith("${unhide} ") ? 10 : 0); difPos = pos + 8 - endPos; - tr = (tr || newState.tr).insertText( - "${hide} ", - pos + offset, - endPos + offset + tr = (tr || newState.tr).addMark( + pos, pos+node.text.length, newState.schema.marks.hide.create() ); } } else { // TODO (carl) actually detect hidden and unhide if ( - node.isTextblock && - node.textContent.startsWith("${hide} ") + node.text != undefined ) { difPos = -8; - tr = (tr || newState.tr).insertText( - "", - pos + offset, - pos + offset + 8 - ); + tr = (tr || newState.tr).removeMark( pos, pos+node.text.length, newState.schema.marks.hide); } } } - return [match, offset + difPos]; + return [match, node.nodeSize]; }; - evaluate(newState.doc, 0, 0); + + const traverse = (node) => { + for (let i = 0; i < node.childCount; ++i) { + const child = node.child(i); + traverse(child); + } + } + evaluate(newState.doc, 0); + traverse(newState.doc); return tr; }, }), diff --git a/src/server.ts b/src/server.ts index 17d6190ba..73300ec30 100644 --- a/src/server.ts +++ b/src/server.ts @@ -29,6 +29,8 @@ import Highlight from "./marks/Highlight"; import Italic from "./marks/Italic"; import Link from "./marks/Link"; import Strikethrough from "./marks/Strikethrough"; +import Tag from "./marks/Tag"; +import Hide from "./marks/Hide"; const extensions = new ExtensionManager([ new Doc(), @@ -52,6 +54,8 @@ const extensions = new ExtensionManager([ new Bold(), new Code(), new Highlight(), + new Tag(), + new Hide(), new Italic(), new Link(), new Strikethrough(), From 3f837c440519ff8e5940c5744b71aea4faa1e998 Mon Sep 17 00:00:00 2001 From: aravind-segu Date: Thu, 4 Jun 2020 00:18:19 -0400 Subject: [PATCH 2/2] Updated Link --- src/marks/Link.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/marks/Link.ts b/src/marks/Link.ts index 319af1fd3..f15e307c6 100644 --- a/src/marks/Link.ts +++ b/src/marks/Link.ts @@ -62,10 +62,6 @@ export default class Link extends Mark { inputRules({ type }) { return [ new InputRule(LINK_INPUT_REGEX, (state, match, start, end) => { - console.log(state); - console.log(match); - console.log(start); - console.log(end); const [okay, alt, href] = match; const { tr } = state;