import { ellipsis, emDash, InputRule, smartQuotes, textblockTypeInputRule } from 'prosemirror-inputrules'; import { NodeSelection, TextSelection } from 'prosemirror-state'; import { Doc, DocListCast, FieldResult, StrListCast } from '../../../../fields/Doc'; import { DocData } from '../../../../fields/DocSymbols'; import { Id } from '../../../../fields/FieldSymbols'; import { List } from '../../../../fields/List'; import { NumCast, StrCast } from '../../../../fields/Types'; import { Utils } from '../../../../Utils'; import { DocServer } from '../../../DocServer'; import { Docs, DocUtils } from '../../../documents/Documents'; import { CollectionViewType } from '../../../documents/DocumentTypes'; import { CollectionView } from '../../collections/CollectionView'; import { ContextMenu } from '../../ContextMenu'; import { KeyValueBox } from '../KeyValueBox'; 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(/%>$/, schema.nodes.blockquote), // 1. create numerical ordered list wrappingInputRule( /^1\.\s$/, schema.nodes.ordered_list, () => ({ mapStyle: 'decimal', bulletStyle: 1 }), (match: any, node: any) => node.childCount + node.attrs.order === +match[1], ((type: any) => ({ type: type, attrs: { mapStyle: 'decimal', bulletStyle: 1 } })) as any ), // A. create alphabetical ordered list wrappingInputRule( /^A\.\s$/, schema.nodes.ordered_list, // match => { () => { return { mapStyle: 'multi', bulletStyle: 1 }; // return ({ order: +match[1] }) }, (match: any, node: any) => { return node.childCount + node.attrs.order === +match[1]; }, ((type: any) => ({ type: type, attrs: { mapStyle: 'multi', bulletStyle: 1 } })) as any ), // * + - create bullet list wrappingInputRule( /^\s*([-+*])\s$/, schema.nodes.ordered_list, // match => { () => ({ mapStyle: 'bullet' }), // ({ order: +match[1] }) (match: any, node: any) => node.childCount + node.attrs.order === +match[1], ((type: any) => ({ type: type, attrs: { mapStyle: 'bullet' } })) as any ), // ``` create code block new InputRule(/^```$/, (state, match, start, end) => { let $start = state.doc.resolve(start); if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), schema.nodes.code_block)) return null; // this enables text with code blocks to be used as a 'paint' function via a styleprovider button that is added to Docs that have an onPaint script this.TextBox.layoutDoc.type_collection = CollectionViewType.Freeform; // make it a freeform when rendered as a collection since those are the only views that know about the paint function const paintedField = 'layout_' + this.TextBox.fieldKey + 'Painted'; // make a layout field key for storing the CollectionView jsx string pointing to the textbox's text this.TextBox.dataDoc[paintedField] = CollectionView.LayoutString(this.TextBox.fieldKey); const layoutFieldKey = StrCast(this.TextBox.layoutDoc.layout_fieldKey); // save the current layout fieldkey this.TextBox.layoutDoc.layout_fieldKey = paintedField; // setup the paint layout field key this.TextBox.DocumentView?.().setToggleDetail('onPaint'); // create the script to toggle between the painted and regular view this.TextBox.layoutDoc.layout_fieldKey = layoutFieldKey; // restore the layout field key to text return state.tr.delete(start, end).setBlockType(start, start, schema.nodes.code_block); }), // % set the 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 annotation to a field on the text document new InputRule(new RegExp(/>::$/), (state, match, start, end) => { const creator = (doc: Doc) => { const textDoc = this.Document[DocData]; const numInlines = NumCast(textDoc.inlineTextCount); textDoc.inlineTextCount = numInlines + 1; const node = (state.doc.resolve(start) as any).nodeAfter; const newNode = schema.nodes.dashComment.create({ docId: doc[Id], reflow: false }); const dashDoc = schema.nodes.dashDoc.create({ width: 75, height: 35, title: 'dashDoc', docId: doc[Id], float: 'right' }); const sm = state.storedMarks || undefined; this.TextBox.EditorView?.dispatch( node ? this.TextBox.EditorView.state.tr .insert(start, newNode) .replaceRangeWith(start + 1, end + 2, dashDoc) .insertText(' ', start + 2) .setStoredMarks([...node.marks, ...(sm ? sm : [])]) : this.TextBox.EditorView.state.tr ); }; DocUtils.addDocumentCreatorMenuItems(creator, creator, 200, 200); const cm = ContextMenu.Instance; cm.displayMenu(200, 200, undefined, true); return null; }), //Create annotation to a field on the text document new InputRule(new RegExp(/>>$/), (state, match, start, end) => { const textDoc = this.Document[DocData]; 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('', { _layout_fieldKey: inlineLayoutKey, _width: 75, _height: 35, annotationOn: textDoc, _layout_fitWidth: true, _layout_autoHeight: true, _text_fontSize: '9px', title: 'inline comment', }); textDocInline.title = inlineFieldKey; // give the annotation its own title textDocInline.title_custom = 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.isDataDoc = true; textDocInline.proto = textDoc; // make the annotation inherit from the outer text doc so that it can resolve any nested field references, e.g., [[field]] 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], reflow: true }); 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; }), // 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 resolved = state.doc.resolve(start) as any; if (resolved?.parent.type.name === 'paragraph') { return state.tr.deleteRange(start, end).setNodeMarkup(resolved.path[resolved.path.length - 4], schema.nodes.paragraph, { ...resolved.parent.attrs, align: 'center' }, resolved.parent.marks); } else { const node = resolved.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 resolved = state.doc.resolve(start) as any; if (resolved?.parent.type.name === 'paragraph') { return state.tr.deleteRange(start, end).setNodeMarkup(resolved.path[resolved.path.length - 4], schema.nodes.paragraph, { ...resolved.parent.attrs, align: 'left' }, resolved.parent.marks); } else { const node = resolved.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 resolved = state.doc.resolve(start) as any; if (resolved?.parent.type.name === 'paragraph') { return state.tr.deleteRange(start, end).setNodeMarkup(resolved.path[resolved.path.length - 4], schema.nodes.paragraph, { ...resolved.parent.attrs, align: 'right' }, resolved.parent.marks); } else { const node = resolved.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))); } }), // activate a style by name using prefix '%' new InputRule(new RegExp(/%[a-zA-Z_]+$/), (state, match, start, end) => { const color = match[0].substring(1, match[0].length); const marks = RichTextMenu.Instance?._brushMap.get(color); if ( DocListCast((Doc.UserDoc().template_notes as Doc).data) .concat(DocListCast((Doc.UserDoc().template_user as Doc).data)) .map(d => StrCast(d.title)) .includes(color) ) { setTimeout(() => this.TextBox.DocumentView?.().switchViews(true, color, undefined, true)); return state.tr.deleteRange(start, end); } 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; }), // toggle alternate text UI %/ new InputRule(new RegExp(/%\//), (state, match, start, end) => { setTimeout(() => this.TextBox.cycleAlternateText(true)); return state.tr.deleteRange(start, end); }), // 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.type !== state.schema.marks.user_mark) .reduce((tr, m) => tr.removeStoredMark(m), tr) : tr; }), // create a hyperlink to a titled document // @() new InputRule(new RegExp(/@\(([a-zA-Z_@\.\? \-0-9]+)\)/), (state, match, start, end) => { const docTitle = match[1]; const prefixLength = '@('.length; if (docTitle) { const linkToDoc = (target: Doc) => { const editor = this.TextBox.EditorView; const selection = editor?.state?.selection.$from.pos; if (editor) { const estate = editor.state; editor.dispatch(estate.tr.setSelection(new TextSelection(estate.doc.resolve(start), estate.doc.resolve(end - prefixLength)))); } DocUtils.MakeLink(this.TextBox.getAnchor(true), target, { link_relationship: 'portal to:portal from' }); const teditor = this.TextBox.EditorView; if (teditor && selection) { const tstate = teditor.state; teditor.dispatch(tstate.tr.setSelection(new TextSelection(tstate.doc.resolve(selection)))); } }; const getTitledDoc = (docTitle: string) => { if (!DocServer.FindDocByTitle(docTitle)) { Docs.Create.TextDocument('', { title: docTitle, _width: 400, _layout_fitWidth: true, _layout_autoHeight: true }); } const titledDoc = DocServer.FindDocByTitle(docTitle); return titledDoc ? Doc.BestEmbedding(titledDoc) : titledDoc; }; const target = getTitledDoc(docTitle); if (target) { setTimeout(() => linkToDoc(target)); return state.tr.insertText(' ').deleteRange(start, start + prefixLength); } } return state.tr; }), // create a text display of a metadata field on this or another document, or create a hyperlink portal to another document // [@{this,doctitle,}.fieldKey{:,=,:=,=:=}value] // [@{this,doctitle,}.fieldKey] new InputRule( new RegExp(/\[(@|@this\.|@[a-zA-Z_\? \-0-9]+\.)([a-zA-Z_\?\-0-9]+)((:|=|:=|=:=)([a-zA-Z,_\(\)\.@\?\+\-\*\/\ 0-9\(\)]*))?\]/), (state, match, start, end) => { const docTitle = match[1].substring(1).replace(/\.$/, ''); const fieldKey = match[2]; const assign = match[4] === ':' ? (match[4] = '') : match[4]; const value = match[5]; const dataDoc = value === undefined ? !fieldKey.startsWith('_') : !assign?.startsWith('='); const getTitledDoc = (docTitle: string) => DocServer.FindDocByTitle(docTitle); // if the value has commas assume its an array (unless it's part of a chat gpt call indicated by '((' ) if (value?.includes(',') && !value.startsWith('((')) { const values = value.split(','); const strs = values.some(v => !v.match(/^[-]?[0-9.]$/)); this.Document[DocData][fieldKey] = strs ? new List(values) : new List(values.map(v => Number(v))); } else if (value) { KeyValueBox.SetField(this.Document, fieldKey, assign + value, Doc.IsDataProto(this.Document) ? true : undefined, assign.includes(":=") ? undefined: (gptval: FieldResult) => (dataDoc ? this.Document[DocData]:this.Document)[fieldKey] = gptval as string ); // prettier-ignore if (fieldKey === this.TextBox.fieldKey) return this.TextBox.EditorView!.state.tr; } const target = docTitle ? getTitledDoc(docTitle) : undefined; const fieldView = state.schema.nodes.dashField.create({ fieldKey, docId: target?.[Id], hideKey: false, hideValue: false }); return state.tr.setSelection(new TextSelection(state.doc.resolve(start), state.doc.resolve(end))).replaceSelectionWith(fieldView, true); }, { inCode: true } ), // pass the contents between '((' and '))' to chatGPT and append the result new InputRule(new RegExp(/(^|[^=])(\(\(.*\)\))$/), (state, match, start, end) => { var count = 0; // ignore first return value which will be the notation that chat is pending a result KeyValueBox.SetField(this.Document, '', match[2], false, (gptval: FieldResult) => { if (count) { const tr = this.TextBox.EditorView?.state.tr.insertText(' ' + (gptval as string)); tr && this.TextBox.EditorView?.dispatch(tr.setSelection(new TextSelection(tr.doc.resolve(end + 2), tr.doc.resolve(end + 2 + (gptval as string).length)))); RichTextMenu.Instance?.elideSelection(this.TextBox.EditorView?.state, true); } count++; }); return null; }), // create a text display of a metadata field on this or another document, or create a hyperlink portal to another document // @(wiki:title) new InputRule(new RegExp(/@\(wiki:([a-zA-Z_@:\.\?\-0-9 ]+)\)$/), (state, match, start, end) => { const title = match[1].trim().replace(/ /g, '_'); this.TextBox.EditorView?.dispatch(state.tr.setSelection(new TextSelection(state.doc.resolve(start), state.doc.resolve(end)))); this.TextBox.makeLinkAnchor(undefined, 'add:right', `https://en.wikipedia.org/wiki/${title.trim()}`, 'wikipedia reference'); const fstate = this.TextBox.EditorView?.state; if (fstate) { const tr = fstate?.tr.deleteRange(start, start + '@(wiki:'.length); return tr.setSelection(new TextSelection(tr.doc.resolve(end - '@(wiki:'.length))).insertText(' '); } return state.tr; }), // create an inline equation node // %eq new InputRule(new RegExp(/%eq/), (state, match, start, end) => { const fieldKey = 'math' + Utils.GenerateGuid(); this.TextBox.dataDoc[fieldKey] = 'y='; const tr = state.tr.setSelection(new TextSelection(state.tr.doc.resolve(end - 3), state.tr.doc.resolve(end))).replaceSelectionWith(schema.nodes.equation.create({ fieldKey })); return tr.setSelection(new NodeSelection(tr.doc.resolve(tr.selection.$from.pos - 1))); }), // 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[DocData]['#' + tag] = '#' + tag; const tags = StrListCast(this.Document[DocData].tags); if (!tags.includes(tag)) { tags.push(tag); this.Document[DocData].tags = new List(tags); } const fieldView = state.schema.nodes.dashField.create({ fieldKey: '#' + tag }); return state.tr .setSelection(new TextSelection(state.doc.resolve(start), state.doc.resolve(end))) .replaceSelectionWith(fieldView, true) .insertText(' '); }), // # heading textblockTypeInputRule(new RegExp(/^(#{1,6})\s$/), schema.nodes.heading, match => { return { level: match[1].length }; }), // 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); if (node?.marks.findIndex((m: any) => m.type === schema.marks.user_mark) !== -1) { } return node ? state.tr .removeMark(start, end, schema.marks.user_mark) .addMark(start, end, schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.floor(Date.now() / 1000) })) .addMark(start, end, schema.marks.user_tag.create({ userid: Doc.CurrentUserEmail, tag: tag, modified: Math.round(Date.now() / 1000 / 60) })) : state.tr; }), new InputRule(new RegExp(/%\(/), (state, match, start, end) => { const node = (state.doc.resolve(start) as any).nodeAfter; const sm = state.storedMarks?.slice() || []; 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))).setStoredMarks([...node.marks, ...sm]); }), new InputRule(new RegExp(/%\)/), (state, match, start, end) => { return state.tr.deleteRange(start, end).removeStoredMark(state.schema.marks.summarizeInclusive.create()); }), ], }; }