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: ``,
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 = '
`,
[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);
};