diff options
| author | anika-ahluwalia <anika.ahluwalia@gmail.com> | 2020-04-29 17:21:06 -0500 |
|---|---|---|
| committer | anika-ahluwalia <anika.ahluwalia@gmail.com> | 2020-04-29 17:21:06 -0500 |
| commit | a3d0d5cb8d00eb6c360c95e0c5c03e37b218e49a (patch) | |
| tree | 734f941feef0c87e2c15cb0c323334de29cafcaf /src/client/views/nodes/formattedText/RichTextRules.ts | |
| parent | 7b8651a1a1f824e6c6a5135a4420766686f35175 (diff) | |
| parent | d66aaffc27405f4231a29cd6edda3477077ae946 (diff) | |
Merge branch 'master' of https://github.com/browngraphicslab/Dash-Web into script_documents
Diffstat (limited to 'src/client/views/nodes/formattedText/RichTextRules.ts')
| -rw-r--r-- | src/client/views/nodes/formattedText/RichTextRules.ts | 319 |
1 files changed, 319 insertions, 0 deletions
diff --git a/src/client/views/nodes/formattedText/RichTextRules.ts b/src/client/views/nodes/formattedText/RichTextRules.ts new file mode 100644 index 000000000..d619bc4a0 --- /dev/null +++ b/src/client/views/nodes/formattedText/RichTextRules.ts @@ -0,0 +1,319 @@ +import { ellipsis, emDash, InputRule, smartQuotes, textblockTypeInputRule } from "prosemirror-inputrules"; +import { NodeSelection, TextSelection } from "prosemirror-state"; +import { DataSym, Doc } from "../../../../new_fields/Doc"; +import { Id } from "../../../../new_fields/FieldSymbols"; +import { ComputedField } from "../../../../new_fields/ScriptField"; +import { Cast, NumCast } from "../../../../new_fields/Types"; +import { returnFalse, Utils } from "../../../../Utils"; +import { DocServer } from "../../../DocServer"; +import { Docs, DocUtils } from "../../../documents/Documents"; +import { FormattedTextBox } from "./FormattedTextBox"; +import { wrappingInputRule } from "./prosemirrorPatches"; +import RichTextMenu from "./RichTextMenu"; +import { schema } from "./schema_rts"; + +export class RichTextRules { + public Document: Doc; + public TextBox: FormattedTextBox; + public EnteringStyle: boolean = false; + constructor(doc: Doc, textBox: FormattedTextBox) { + this.Document = doc; + this.TextBox = textBox; + } + public inpRules = { + rules: [ + ...smartQuotes, + ellipsis, + emDash, + + // > blockquote + wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote), + + // 1. ordered list + wrappingInputRule( + /^1\.\s$/, + schema.nodes.ordered_list, + () => { + return ({ mapStyle: "decimal", bulletStyle: 1 }); + }, + (match: any, node: any) => { + return node.childCount + node.attrs.order === +match[1]; + }, + (type: any) => ({ type: type, attrs: { mapStyle: "decimal", bulletStyle: 1 } }) + ), + // a. alphabbetical list + wrappingInputRule( + /^a\.\s$/, + schema.nodes.ordered_list, + // match => { + () => { + return ({ mapStyle: "alpha", bulletStyle: 1 }); + // return ({ order: +match[1] }) + }, + (match: any, node: any) => { + return node.childCount + node.attrs.order === +match[1]; + }, + (type: any) => ({ type: type, attrs: { mapStyle: "alpha", bulletStyle: 1 } }) + ), + + // * bullet list + wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list), + + // ``` code block + textblockTypeInputRule(/^```$/, schema.nodes.code_block), + + // create an inline view of a tag stored under the '#' field + new InputRule( + new RegExp(/#([a-zA-Z_\-]+[a-zA-Z_\-0-9]*)\s$/), + (state, match, start, end) => { + const tag = match[1]; + if (!tag) return state.tr; + this.Document[DataSym]["#"] = tag; + const fieldView = state.schema.nodes.dashField.create({ fieldKey: "#" }); + return state.tr.deleteRange(start, end).insert(start, fieldView); + }), + + // # heading + textblockTypeInputRule( + new RegExp(/^(#{1,6})\s$/), + schema.nodes.heading, + match => { + return ({ level: match[1].length }); + } + ), + + // set the font size using #<font-size> + new InputRule( + new RegExp(/%([0-9]+)\s$/), + (state, match, start, end) => { + const size = Number(match[1]); + return state.tr.deleteRange(start, end).addStoredMark(schema.marks.pFontSize.create({ fontSize: size })); + }), + + // create a text display of a metadata field on this or another document, or create a hyperlink portal to another document [[ <fieldKey> : <Doc>]] // [[:Doc]] => hyperlink [[fieldKey]] => show field [[fieldKey:Doc]] => show field of doc + new InputRule( + new RegExp(/\[\[([a-zA-Z_@\? \-0-9]*)(=[a-zA-Z_@\? /\-0-9]*)?(:[a-zA-Z_@\? \-0-9]+)?\]\]$/), + (state, match, start, end) => { + const fieldKey = match[1]; + const docid = match[3]?.substring(1); + const value = match[2]?.substring(1); + if (!fieldKey) { + if (docid) { + DocServer.GetRefField(docid).then(docx => { + const target = ((docx instanceof Doc) && docx) || Docs.Create.FreeformDocument([], { title: docid, _width: 500, _height: 500, _LODdisable: true, }, docid); + DocUtils.Publish(target, docid, returnFalse, returnFalse); + DocUtils.MakeLink({ doc: this.Document }, { doc: target }, "portal to"); + }); + const link = state.schema.marks.link.create({ href: Utils.prepend("/doc/" + docid), location: "onRight", title: docid, targetId: docid }); + return state.tr.deleteRange(end - 1, end).deleteRange(start, start + 2).addMark(start, end - 3, link); + } + return state.tr; + } + if (value !== "" && value !== undefined) { + const num = value.match(/^[0-9.]$/); + this.Document[DataSym][fieldKey] = value === "true" ? true : value === "false" ? false : (num ? Number(value) : value); + } + const fieldView = state.schema.nodes.dashField.create({ fieldKey, docid }); + return state.tr.deleteRange(start, end).insert(start, fieldView); + }), + // create an inline view of a document {{ <layoutKey> : <Doc> }} // {{:Doc}} => show default view of document {{<layout>}} => show layout for this doc {{<layout> : Doc}} => show layout for another doc + new InputRule( + new RegExp(/\{\{([a-zA-Z_ \-0-9]*)(\([a-zA-Z0-9…._/\-]*\))?(:[a-zA-Z_ \-0-9]+)?\}\}$/), + (state, match, start, end) => { + const fieldKey = match[1] || ""; + const fieldParam = match[2]?.replace("…", "...") || ""; + const docid = match[3]?.substring(1); + if (!fieldKey && !docid) return state.tr; + docid && DocServer.GetRefField(docid).then(docx => { + if (!(docx instanceof Doc && docx)) { + const docx = Docs.Create.FreeformDocument([], { title: docid, _width: 500, _height: 500, _LODdisable: true }, docid); + DocUtils.Publish(docx, docid, returnFalse, returnFalse); + } + }); + const node = (state.doc.resolve(start) as any).nodeAfter; + const dashDoc = schema.nodes.dashDoc.create({ width: 75, height: 75, title: "dashDoc", docid, fieldKey: fieldKey + fieldParam, float: "unset", alias: Utils.GenerateGuid() }); + const sm = state.storedMarks || undefined; + return node ? state.tr.replaceRangeWith(start, end, dashDoc).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : state.tr; + }), + new InputRule( + new RegExp(/>>$/), + (state, match, start, end) => { + const textDoc = this.Document[DataSym]; + const numInlines = NumCast(textDoc.inlineTextCount); + textDoc.inlineTextCount = numInlines + 1; + const inlineFieldKey = "inline" + numInlines; // which field on the text document this annotation will write to + const inlineLayoutKey = "layout_" + inlineFieldKey; // the field holding the layout string that will render the inline annotation + const textDocInline = Docs.Create.TextDocument("", { layoutKey: inlineLayoutKey, _width: 75, _height: 35, annotationOn: textDoc, _autoHeight: true, _fontSize: 9, title: "inline comment" }); + textDocInline.title = inlineFieldKey; // give the annotation its own title + textDocInline.customTitle = true; // And make sure that it's 'custom' so that editing text doesn't change the title of the containing doc + textDocInline.isTemplateForField = inlineFieldKey; // this is needed in case the containing text doc is converted to a template at some point + textDocInline.proto = textDoc; // make the annotation inherit from the outer text doc so that it can resolve any nested field references, e.g., [[field]] + textDocInline._textContext = ComputedField.MakeFunction(`copyField(self.${inlineFieldKey})`); + textDoc[inlineLayoutKey] = FormattedTextBox.LayoutString(inlineFieldKey); // create a layout string for the layout key that will render the annotation text + textDoc[inlineFieldKey] = ""; // set a default value for the annotation + const node = (state.doc.resolve(start) as any).nodeAfter; + const newNode = schema.nodes.dashComment.create({ docid: textDocInline[Id] }); + const dashDoc = schema.nodes.dashDoc.create({ width: 75, height: 35, title: "dashDoc", docid: textDocInline[Id], float: "right" }); + const sm = state.storedMarks || undefined; + const replaced = node ? state.tr.insert(start, newNode).replaceRangeWith(start + 1, end + 1, dashDoc).insertText(" ", start + 2).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : + state.tr; + return replaced; + }), + // stop using active style + new InputRule( + new RegExp(/%%$/), + (state, match, start, end) => { + const tr = state.tr.deleteRange(start, end); + const marks = state.tr.selection.$anchor.nodeBefore?.marks; + return marks ? Array.from(marks).filter(m => m !== state.schema.marks.user_mark).reduce((tr, m) => tr.removeStoredMark(m), tr) : tr; + }), + + // set the Todo user-tag on the current selection (assumes % was used to initiate an EnteringStyle mode) + new InputRule( + new RegExp(/[ti!x]$/), + (state, match, start, end) => { + if (state.selection.to === state.selection.from || !this.EnteringStyle) return null; + const tag = match[0] === "t" ? "todo" : match[0] === "i" ? "ignore" : match[0] === "x" ? "disagree" : match[0] === "!" ? "important" : "??"; + const node = (state.doc.resolve(start) as any).nodeAfter; + if (node?.marks.findIndex((m: any) => m.type === schema.marks.user_tag) !== -1) return state.tr.removeMark(start, end, schema.marks.user_tag); + return node ? state.tr.addMark(start, end, schema.marks.user_tag.create({ userid: Doc.CurrentUserEmail, tag: tag, modified: Math.round(Date.now() / 1000 / 60) })) : state.tr; + }), + + // set the First-line indent node type for the selection's paragraph (assumes % was used to initiate an EnteringStyle mode) + new InputRule( + new RegExp(/(%d|d)$/), + (state, match, start, end) => { + if (!match[0].startsWith("%") && !this.EnteringStyle) return null; + const pos = (state.doc.resolve(start) as any); + for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { + const node = pos.node(depth); + if (node.type === schema.nodes.paragraph) { + const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, indent: node.attrs.indent === 25 ? undefined : 25 }); + const result = replaced.setSelection(new TextSelection(replaced.doc.resolve(start))); + return match[0].startsWith("%") ? result.deleteRange(start, end) : result; + } + } + return null; + }), + + // set the Hanging indent node type for the current selection's paragraph (assumes % was used to initiate an EnteringStyle mode) + new InputRule( + new RegExp(/(%h|h)$/), + (state, match, start, end) => { + if (!match[0].startsWith("%") && !this.EnteringStyle) return null; + const pos = (state.doc.resolve(start) as any); + for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { + const node = pos.node(depth); + if (node.type === schema.nodes.paragraph) { + const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, indent: node.attrs.indent === -25 ? undefined : -25 }); + const result = replaced.setSelection(new TextSelection(replaced.doc.resolve(start))); + return match[0].startsWith("%") ? result.deleteRange(start, end) : result; + } + } + return null; + }), + // set the Quoted indent node type for the current selection's paragraph (assumes % was used to initiate an EnteringStyle mode) + new InputRule( + new RegExp(/(%q|q)$/), + (state, match, start, end) => { + if (!match[0].startsWith("%") && !this.EnteringStyle) return null; + const pos = (state.doc.resolve(start) as any); + if (state.selection instanceof NodeSelection && state.selection.node.type === schema.nodes.ordered_list) { + const node = state.selection.node; + return state.tr.setNodeMarkup(pos.pos, node.type, { ...node.attrs, indent: node.attrs.indent === 30 ? undefined : 30 }); + } + for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { + const node = pos.node(depth); + if (node.type === schema.nodes.paragraph) { + const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, inset: node.attrs.inset === 30 ? undefined : 30 }); + const result = replaced.setSelection(new TextSelection(replaced.doc.resolve(start))); + return match[0].startsWith("%") ? result.deleteRange(start, end) : result; + } + } + return null; + }), + + + // center justify text + new InputRule( + new RegExp(/%\^$/), + (state, match, start, end) => { + const node = (state.doc.resolve(start) as any).nodeAfter; + const sm = state.storedMarks || undefined; + const replaced = node ? state.tr.replaceRangeWith(start, end, schema.nodes.paragraph.create({ align: "center" })).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : + state.tr; + return replaced.setSelection(new TextSelection(replaced.doc.resolve(end - 2))); + }), + // left justify text + new InputRule( + new RegExp(/%\[$/), + (state, match, start, end) => { + const node = (state.doc.resolve(start) as any).nodeAfter; + const sm = state.storedMarks || undefined; + const replaced = node ? state.tr.replaceRangeWith(start, end, schema.nodes.paragraph.create({ align: "left" })).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : + state.tr; + return replaced.setSelection(new TextSelection(replaced.doc.resolve(end - 2))); + }), + // right justify text + new InputRule( + new RegExp(/%\]$/), + (state, match, start, end) => { + const node = (state.doc.resolve(start) as any).nodeAfter; + const sm = state.storedMarks || undefined; + const replaced = node ? state.tr.replaceRangeWith(start, end, schema.nodes.paragraph.create({ align: "right" })).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : + state.tr; + return replaced.setSelection(new TextSelection(replaced.doc.resolve(end - 2))); + }), + new InputRule( + new RegExp(/%\(/), + (state, match, start, end) => { + const node = (state.doc.resolve(start) as any).nodeAfter; + const sm = state.storedMarks || []; + const mark = state.schema.marks.summarizeInclusive.create(); + sm.push(mark); + const selected = state.tr.setSelection(new TextSelection(state.doc.resolve(start), state.doc.resolve(end))).addMark(start, end, mark); + const content = selected.selection.content(); + const replaced = node ? selected.replaceRangeWith(start, end, + schema.nodes.summary.create({ visibility: true, text: content, textslice: content.toJSON() })) : + state.tr; + return replaced.setSelection(new TextSelection(replaced.doc.resolve(end + 1))).setStoredMarks([...node.marks, ...sm]); + }), + new InputRule( + new RegExp(/%\)/), + (state, match, start, end) => { + return state.tr.deleteRange(start, end).removeStoredMark(state.schema.marks.summarizeInclusive.create()); + }), + new InputRule( + new RegExp(/%f$/), + (state, match, start, end) => { + const newNode = schema.nodes.footnote.create({}); + const tr = state.tr; + tr.deleteRange(start, end).replaceSelectionWith(newNode); // replace insertion with a footnote. + return tr.setSelection(new NodeSelection( // select the footnote node to open its display + tr.doc.resolve( // get the location of the footnote node by subtracting the nodesize of the footnote from the current insertion point anchor (which will be immediately after the footnote node) + tr.selection.anchor - tr.selection.$anchor.nodeBefore!.nodeSize))); + }), + + // activate a style by name using prefix '%' + new InputRule( + new RegExp(/%[a-z]+$/), + (state, match, start, end) => { + const color = match[0].substring(1, match[0].length); + const marks = RichTextMenu.Instance._brushMap.get(color); + if (marks) { + const tr = state.tr.deleteRange(start, end); + return marks ? Array.from(marks).reduce((tr, m) => tr.addStoredMark(m), tr) : tr; + } + const isValidColor = (strColor: string) => { + const s = new Option().style; + s.color = strColor; + return s.color === strColor.toLowerCase(); // 'false' if color wasn't assigned + }; + if (isValidColor(color)) { + return state.tr.deleteRange(start, end).addStoredMark(schema.marks.pFontColor.create({ color: color })); + } + return null; + }), + ] + }; +} |
