UNPKG

@eccenca/gui-elements

Version:

GUI elements based on other libraries, usable in React application, written in Typescript.

341 lines (283 loc) 11.7 kB
import { type ChangeSpec, EditorSelection } from "@codemirror/state"; import { EditorView } from "codemirror"; import { ValidIconName } from "../../../../components/Icon/canonicalIconNames"; enum Commands { header1 = "Heading 1", header2 = "Heading 2", header3 = "Heading 3", header4 = "Heading 4", header5 = "Heading 5", header6 = "Heading 6", codeBlock = "Code block", quote = "Block quote", bold = "Bold", italic = "Italic", strike = "StrikeThrough", inlineCode = "Inline code", unorderedList = "Unordered list", orderedList = "Ordered list", todoList = "Todo list", link = "Link", image = "Image", } type formatConfig = { start: number; startDelimiter: string; stop?: number; endDelimiter?: string }; type headerLevels = 1 | 2 | 3 | 4 | 5 | 6; type ListType = "ul" | "ol" | "todo"; //contains all utilities for markdown toolbar export default class MarkdownCommand { private view: EditorView | null = null; //list of supported commands as well as the valid icon names. public static commands = { paragraphs: [ Commands.header1, Commands.header2, Commands.header3, Commands.header4, Commands.header5, Commands.header6, Commands.quote, Commands.codeBlock, ], basic: [ { title: Commands.bold, icon: "operation-format-text-bold" }, { title: Commands.italic, icon: "operation-format-text-italic" }, { title: Commands.strike, icon: "operation-format-text-strikethrough" }, { title: Commands.inlineCode, icon: "operation-format-text-code" }, ] as { title: Commands; icon: ValidIconName }[], lists: [ { title: Commands.unorderedList, icon: "operation-format-list-bullet", moniker: "ul" }, { title: Commands.orderedList, icon: "operation-format-list-numbered", moniker: "ol" }, { title: Commands.todoList, icon: "operation-format-list-checked", moniker: "todo" }, ] as { title: Commands; icon: ValidIconName; moniker: string }[], attachments: [ { title: Commands.link, icon: "operation-link" }, { title: Commands.image, icon: "item-image" }, ] as { title: Commands; icon: ValidIconName }[], } as const; constructor(view: EditorView) { this.view = view; } /** * Supported list types are ol, ul, todo. * utility helps to determine which at the start of the line */ private getListTypeOfLine = (text: string): [ListType, number?] | undefined => { if (!text) return; text = text?.trimStart(); if (text.startsWith("- ")) { if (text.startsWith("- [ ] ") || text.startsWith("- [x] ")) return ["todo"]; return ["ul"]; } const v = text.match(/^(\d+)\. /); return v ? ["ol", Number.parseInt(v[1], 10)] : undefined; }; //inserts the list delimiters of "-", "- [ ]" and "{number}." private createListDelimiter(text: string, type: string, orderedList: { currentIndex: number }) { return text.replace(/^(( *)(-( \[[x ]])?|\d+\.) )?/, (...args) => { const { space = "" } = args[args.length - 1]; let newFlag = "- "; if (type === "ol") { newFlag = `${orderedList.currentIndex}. `; orderedList.currentIndex++; } else if (type === "todo") { newFlag = "- [ ] "; } return space + newFlag; }); } //factory for different list types. private createList = (type: ListType) => { if (!this.view) return; const view = this.view; const doc = view.state.doc; const orderedList = { currentIndex: 1 }; view.dispatch( view.state.changeByRange((range) => { const text = doc.slice(range.from, range.to); const changes: ChangeSpec[] = []; let selectionStart: number = range.from; let selectionLength: number = range.to - range.from; Array.from({ length: text.lines }).forEach((_, index) => { const line = doc.line(doc.lineAt(range.from).number + index); const currentListType = this.getListTypeOfLine(line.text); if (currentListType && currentListType[0] === type) { if (currentListType[0] === "ol" && currentListType[1]) { orderedList.currentIndex = currentListType[1]; } return; } const content = this.createListDelimiter(line.text, type, orderedList); const diffLength = content.length - line.length; changes.push({ from: line.from, to: line.to, insert: content, }); if (index === 0) { selectionStart = selectionStart + diffLength; } else { selectionLength = selectionLength + diffLength; } }); return { changes, range: EditorSelection.range(selectionStart, selectionStart + selectionLength), }; }) ); view.focus(); }; private enforceCursorFocus = (cursorPosition: number) => { if (!this.view) return; const view = this.view; setTimeout(() => { view.dispatch({ selection: EditorSelection.cursor(cursorPosition), }); view.focus(); }, 50); }; //supported headers from h1-h6, h6 being the smallest private createHeading = (level: headerLevels) => { if (!this.view) return; const view = this.view; const state = view.state; const flags = "#".repeat(level) + " "; let lastCursorPosition = 0; view.dispatch( state.changeByRange((range) => { const line = state.doc.lineAt(range.from); const content = line.text.replace(/^((#+) )?/, flags); const diffLength = content.length - line.length; lastCursorPosition = line.to + diffLength; return { changes: { from: line.from, to: line.to, insert: content, }, range: EditorSelection.range(range.anchor + diffLength, range.head + diffLength), }; }) ); this.enforceCursorFocus(lastCursorPosition); }; private applyFormatting = ({ start, startDelimiter, endDelimiter = startDelimiter, stop = start, }: formatConfig) => { if (!this.view) return; const view = this.view; const { from, to } = view.state.selection.main; const text = view.state.sliceDoc(from, to); view.dispatch( view.state.changeByRange((range) => { return { changes: [{ from: range.from, to: range.to, insert: `${startDelimiter}${text}${endDelimiter}` }], range: EditorSelection.range(range.from + start, range.to + stop), }; }) ); view.focus(); }; private applyAttachment = (type: Commands.link | Commands.image) => { if (!this.view) return; const view = this.view; const { state } = view; const isImageAttachmentType = type === Commands.image; const { doc } = state; view.dispatch( state.changeByRange((range) => { const { from, to } = range; const text = doc.sliceString(from, to); const link = `${isImageAttachmentType ? `!` : ""}[${text}]()`; const cursor = from + (text.length ? 3 + text.length : 1 + Number(isImageAttachmentType)); return { changes: [ { from, to, insert: link, }, ], range: EditorSelection.range(cursor, cursor), }; }) ); view.focus(); }; private applyQuoteFormatting = () => { if (!this.view) return; const view = this.view; const { state } = view; const { doc } = state; let lastCursorPosition = 0; view.dispatch( view.state.changeByRange((range) => { const startLine = doc.lineAt(range.from); const text = doc.slice(range.from, range.to); const lineCount = text.lines; const changes: ChangeSpec[] = []; let selectionStart: number = range.from; let selectionLength: number = range.to - range.from; new Array(lineCount).fill(0).forEach((_, index) => { const line = doc.line(startLine.number + index); if (line.text.startsWith("> ")) { return; } changes.push({ from: line.from, insert: "> ", }); if (index === 0) { selectionStart = selectionStart + 2; } else { selectionLength += 2; } }); lastCursorPosition = selectionStart + selectionLength; return { changes, range: EditorSelection.range(selectionStart, selectionStart + selectionLength), }; }) ); this.enforceCursorFocus(lastCursorPosition); }; executeCommand = (command: Commands): true | void => { switch (command) { case Commands.bold: return this.applyFormatting({ start: 2, startDelimiter: "**" }); case Commands.italic: return this.applyFormatting({ start: 1, startDelimiter: "*" }); case Commands.codeBlock: return this.applyFormatting({ start: 3, startDelimiter: "```\n", endDelimiter: "\n```" }); case Commands.strike: return this.applyFormatting({ start: 2, startDelimiter: "~~" }); case Commands.inlineCode: return this.applyFormatting({ start: 1, startDelimiter: "`" }); case Commands.header1: case Commands.header2: case Commands.header3: case Commands.header4: case Commands.header5: case Commands.header6: return this.createHeading(Number(command.slice(-1)) as headerLevels); case Commands.unorderedList: case Commands.orderedList: case Commands.todoList: return this.createList( MarkdownCommand.commands.lists.find((l) => l.title === command)?.moniker as ListType ); case Commands.image: case Commands.link: return this.applyAttachment(command); case Commands.quote: return this.applyQuoteFormatting(); default: return; //do nothing; } }; }