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 pasteLink = ({ value, start, end }: Selection, pasted: string): Edit | null => { const selected = value.slice(start, end); const url = pasted.trim(); if (selected.trim() === "" || selected.includes("\n")) return null; if (value.slice(start - 2, start) === "](" && value[end] === ")") return null; if (!URL_PATTERN.test(url) || URL_PATTERN.test(selected)) return null; const insert = `[${selected}](${url})`; return { from: start, to: end, insert, selectionStart: start + insert.length, selectionEnd: start + insert.length }; }; 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]); const escapeAttribute = (value: string) => value.replace(/&/g, "&").replace(/"/g, """).replace(/ { const selected = selection.value.slice(selection.start, selection.end).replace(/\s+/g, " "); const alt = escapeAttribute(selected.trim() || "Image"); const prefix = '';
	return insertBlock(
		selection,
		`${prefix}${alt}`, [prefix.length, prefix.length + alt.length] ); }; 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 const insertInline = ({ value, start, end }: Selection, text: string): Edit => { const before = value.slice(0, start); const after = value.slice(end); const lineBefore = before.slice(before.lastIndexOf("\n") + 1); const lineAfter = after.split("\n", 1)[0]; const [leading, trailing] = start !== end || lineBefore === "" ? ["", ""] : lineAfter.trim() === "" ? ["\n", ""] : [/\s$/.test(before) ? "" : " ", /^\s/.test(after) ? "" : " "]; const insert = `${leading}${text}${trailing}`; const caret = start + insert.length; return { from: start, to: end, insert, selectionStart: caret, selectionEnd: caret }; }; export const inlineCode = (text: string): string => { const longest = Math.max(0, ...Array.from(text.matchAll(/`+/g), ([run]) => run.length)); const fence = "`".repeat(longest + 1); const pad = text.startsWith("`") || text.endsWith("`") ? " " : ""; return `${fence}${pad}${text}${pad}${fence}`; }; export const markdownLink = (label: string, url: string): string => `[${label}](<${url.replace(/[<>\r\n]/g, encodeURIComponent)}>)`; 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); };