export interface Selection { value: string; start: number; end: number; } export interface Edit { from: number; to: number; insert: string; selectionStart: number; selectionEnd: number; } export type ListKind = "bullet" | "ordered" | "task"; const URL_PATTERN = /^https?:\/\/\S+$/; export const wrap = ({ value, start, end }: Selection, marker: string): Edit => { const selected = value.slice(start, end); const before = value.slice(start - marker.length, start); const after = value.slice(end, end + marker.length); if (before === marker && after === marker) { return { from: start - marker.length, to: end + marker.length, insert: selected, selectionStart: start - marker.length, selectionEnd: end - marker.length }; } if ( selected.length >= marker.length * 2 && selected.startsWith(marker) && selected.endsWith(marker) ) { const inner = selected.slice(marker.length, -marker.length); return { from: start, to: end, insert: inner, selectionStart: start, selectionEnd: start + inner.length }; } return { from: start, to: end, insert: `${marker}${selected}${marker}`, selectionStart: start + marker.length, selectionEnd: end + marker.length }; }; const blockPadding = (value: string, from: number, to: number) => { const before = value.slice(0, from); const after = value.slice(to); const leading = before === "" || before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n"; const trailing = after === "" || after.startsWith("\n\n") ? "" : after.startsWith("\n") ? "\n" : "\n\n"; return { leading, trailing }; }; const insertBlock = ( { value, start, end }: Selection, block: string, select: [number, number] ): Edit => { const { leading, trailing } = blockPadding(value, start, end); const offset = start + leading.length; return { from: start, to: end, insert: `${leading}${block}${trailing}`, selectionStart: offset + select[0], selectionEnd: offset + select[1] }; }; export const code = (selection: Selection): Edit => selection.value.slice(selection.start, selection.end).includes("\n") ? codeBlock(selection) : wrap(selection, "`"); export const codeBlock = (selection: Selection): Edit => { const selected = selection.value.slice(selection.start, selection.end); return insertBlock(selection, `\`\`\`\n${selected}\n\`\`\``, [4, 4 + selected.length]); }; export const link = ({ value, start, end }: Selection): Edit => { const selected = value.slice(start, end); if (URL_PATTERN.test(selected)) { return { from: start, to: end, insert: `[](${selected})`, selectionStart: start + 1, selectionEnd: start + 1 }; } const urlStart = start + selected.length + 3; return { from: start, to: end, insert: `[${selected}](url)`, selectionStart: urlStart, selectionEnd: urlStart + 3 }; }; export const image = ({ value, start, end }: Selection): Edit => { const selected = value.slice(start, end); const urlStart = start + selected.length + 4; return { from: start, to: end, insert: `![${selected}](url)`, selectionStart: urlStart, selectionEnd: urlStart + 3 }; }; export const separator = (selection: Selection): Edit => insertBlock({ ...selection, start: selection.end }, "---", [3, 3]); export const table = (selection: Selection): Edit => insertBlock( { ...selection, start: selection.end }, "| Column | Column |\n| ------ | ------ |\n| Cell | Cell |", [2, 8] ); const LIST_PREFIX: Record string> = { bullet: () => "- ", ordered: (index) => `${index + 1}. `, task: () => "- [ ] " }; const LIST_MATCH: Record = { bullet: /^- (?!\[[ xX]\] )/, ordered: /^\d+\. /, task: /^- \[[ xX]\] / }; export const list = ({ value, start, end }: Selection, kind: ListKind): Edit => { const from = value.lastIndexOf("\n", start - 1) + 1; const lineEnd = value.indexOf("\n", end); const to = lineEnd === -1 ? value.length : lineEnd; const lines = value.slice(from, to).split("\n"); const listed = lines.every((line) => LIST_MATCH[kind].test(line)); const next = lines.map((line, index) => listed ? line.replace(LIST_MATCH[kind], "") : `${LIST_PREFIX[kind](index)}${line}` ); const insert = next.join("\n"); if (lines.length === 1) { const shift = insert.length - lines[0].length; return { from, to, insert, selectionStart: Math.max(from, start + shift), selectionEnd: Math.max(from, end + shift) }; } return { from, to, insert, selectionStart: from, selectionEnd: from + insert.length }; }; export interface Trigger { char: "@" | "#"; query: string; from: number; to: number; } const TRIGGER_PATTERN = /(?:^|[\s(])([@#])([\w.-]*)$/; export const findTrigger = (value: string, caret: number): Trigger | null => { const lineStart = value.lastIndexOf("\n", caret - 1) + 1; const match = TRIGGER_PATTERN.exec(value.slice(lineStart, caret)); if (!match) return null; const query = match[2]; return { char: match[1] as Trigger["char"], query, from: caret - query.length - 1, to: caret }; }; export const applyEdit = (textarea: HTMLTextAreaElement, edit: Edit) => { textarea.focus(); textarea.setSelectionRange(edit.from, edit.to); const applied = edit.insert === "" ? document.execCommand("delete") : document.execCommand("insertText", false, edit.insert); if (!applied) { textarea.setRangeText(edit.insert, edit.from, edit.to, "end"); textarea.dispatchEvent(new Event("input", { bubbles: true })); } textarea.setSelectionRange(edit.selectionStart, edit.selectionEnd); };