185 lines
8.7 KiB
JavaScript
185 lines
8.7 KiB
JavaScript
/*
|
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
|
* Source-Available Code / Dual-Licensed.
|
|
*
|
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
|
* Commercial or production use requires a valid paid license.
|
|
* See LICENSE file in repository root for details.
|
|
*/
|
|
|
|
function selection(editor) {
|
|
return { start: editor.selectionStart, end: editor.selectionEnd };
|
|
}
|
|
|
|
function toggleWrap(editor, before, after = before, placeholder = "tekst") {
|
|
let { start, end } = selection(editor);
|
|
const value = editor.value;
|
|
const selected = value.slice(start, end);
|
|
|
|
if (start >= before.length && value.slice(start - before.length, start) === before && value.slice(end, end + after.length) === after) {
|
|
editor.setRangeText(selected, start - before.length, end + after.length, "select");
|
|
return;
|
|
}
|
|
if (selected.includes("\n") && !before.includes("\n") && !after.includes("\n")) {
|
|
const lines = selected.split("\n");
|
|
const nonEmpty = lines.filter(line => line.length > 0);
|
|
const allWrapped = nonEmpty.length > 0 && nonEmpty.every(line => line.startsWith(before) && line.endsWith(after) && line.length >= before.length + after.length);
|
|
const replacement = lines.map(line => {
|
|
if (!line) return line;
|
|
return allWrapped ? line.slice(before.length, line.length - after.length) : `${before}${line}${after}`;
|
|
}).join("\n");
|
|
editor.setRangeText(replacement, start, end, "select");
|
|
return;
|
|
}
|
|
|
|
if (selected.startsWith(before) && selected.endsWith(after) && selected.length >= before.length + after.length) {
|
|
editor.setRangeText(selected.slice(before.length, -after.length), start, end, "select");
|
|
return;
|
|
}
|
|
|
|
const text = selected || placeholder;
|
|
editor.setRangeText(before + text + after, start, end, "select");
|
|
if (!selected) editor.setSelectionRange(start + before.length, start + before.length + text.length);
|
|
}
|
|
|
|
function togglePrefix(editor, prefixFactory) {
|
|
const { start, end } = selection(editor);
|
|
const lineStart = editor.value.lastIndexOf("\n", start - 1) + 1;
|
|
const selected = editor.value.slice(lineStart, end);
|
|
const lines = selected.split("\n");
|
|
const prefixes = lines.map((_, index) => typeof prefixFactory === "function" ? prefixFactory(index) : prefixFactory);
|
|
const allPrefixed = lines.every((line, index) => line.startsWith(prefixes[index]));
|
|
const replacement = lines.map((line, index) => allPrefixed ? line.slice(prefixes[index].length) : prefixes[index] + line).join("\n");
|
|
editor.setRangeText(replacement, lineStart, end, "select");
|
|
}
|
|
|
|
export function applyFormat(editor, format) {
|
|
if (format === "bold") toggleWrap(editor, "**");
|
|
if (format === "italic") toggleWrap(editor, "*");
|
|
if (format === "strike") toggleWrap(editor, "~~");
|
|
if (format === "heading" || format === "heading2") togglePrefix(editor, "## ");
|
|
if (format === "heading1") togglePrefix(editor, "# ");
|
|
if (format === "heading3") togglePrefix(editor, "### ");
|
|
if (format === "heading4") togglePrefix(editor, "#### ");
|
|
if (format === "bullet") togglePrefix(editor, "- ");
|
|
if (format === "number") togglePrefix(editor, index => `${index + 1}. `);
|
|
if (format === "task") togglePrefix(editor, "- [ ] ");
|
|
if (format === "quote") togglePrefix(editor, "> ");
|
|
if (format === "link") toggleWrap(editor, "[", "](https://)", "description");
|
|
if (format === "inline-code") toggleWrap(editor, "`", "`", "code");
|
|
if (format === "highlight") toggleWrap(editor, "==", "==", "important");
|
|
if (format === "subscript") toggleWrap(editor, "~", "~", "2");
|
|
if (format === "superscript") toggleWrap(editor, "^", "^", "2");
|
|
if (format === "codeblock") toggleWrap(editor, "```text\n", "\n```", "code");
|
|
if (format === "codeblock-lines") toggleWrap(editor, "```text=\n", "\n```", "code");
|
|
if (format === "mermaid") toggleWrap(editor, "```mermaid\n", "\n```", "graph TD\n A[Start] --> B[End]");
|
|
if (format === "details") toggleWrap(editor, "<details>\n<summary>Click me</summary>\n\n", "\n</details>", "Content");
|
|
if (format === "toc") {
|
|
const { start, end } = selection(editor);
|
|
const selected = editor.value.slice(start, end);
|
|
editor.setRangeText(selected || "[TOC]", start, end, selected ? "select" : "end");
|
|
}
|
|
if (format.startsWith("alert-")) {
|
|
const type = format.slice("alert-".length);
|
|
toggleWrap(editor, `:::${type}\n`, "\n:::", "Alert content");
|
|
}
|
|
if (format === "table") toggleWrap(editor, "| Column 1 | Column 2 |\n| --- | --- |\n| ", " | value |", "value");
|
|
if (format === "footnote") toggleWrap(editor, "", "[^1]\n\n[^1]: Footnote text", "Text with footnote");
|
|
if (format === "definition") toggleWrap(editor, "", "\n: Definition", "Term");
|
|
if (format === "horizontal-rule") toggleWrap(editor, "\n---\n", "", "");
|
|
editor.focus();
|
|
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
|
}
|
|
|
|
export function bindFormatShortcuts(editor) {
|
|
editor.addEventListener("keydown", event => {
|
|
const primary = event.ctrlKey || event.metaKey;
|
|
let format = null;
|
|
if (primary && !event.shiftKey && event.key.toLowerCase() === "z") return; // native textarea undo
|
|
if (primary && !event.shiftKey && event.key.toLowerCase() === "b") format = "bold";
|
|
else if (primary && !event.shiftKey && event.key.toLowerCase() === "i") format = "italic";
|
|
else if (primary && event.shiftKey && event.key.toLowerCase() === "x") format = "strike";
|
|
else if (primary && !event.shiftKey && event.key.toLowerCase() === "k") format = "link";
|
|
else if (primary && event.shiftKey && event.key === "7") format = "number";
|
|
else if (primary && event.shiftKey && event.key === "8") format = "bullet";
|
|
else if (primary && event.shiftKey && event.key === "9") format = "task";
|
|
else if (event.altKey && /^[1-4]$/.test(event.key)) format = `heading${event.key}`;
|
|
if (!format) return;
|
|
event.preventDefault();
|
|
applyFormat(editor, format);
|
|
});
|
|
}
|
|
|
|
function selectedLineRange(value, start, end) {
|
|
const blockStart = value.lastIndexOf("\n", Math.max(0, start - 1)) + 1;
|
|
let blockEnd = value.indexOf("\n", end);
|
|
if (blockEnd < 0) blockEnd = value.length;
|
|
if (end > start && value[end - 1] === "\n") blockEnd = end - 1;
|
|
return { blockStart, blockEnd };
|
|
}
|
|
|
|
function mapOffsetThroughEdits(offset, edits) {
|
|
let delta = 0;
|
|
for (const edit of edits) {
|
|
if (offset < edit.start) break;
|
|
const editEnd = edit.start + edit.remove;
|
|
if (offset <= editEnd) return edit.start + delta + edit.insert.length;
|
|
delta += edit.insert.length - edit.remove;
|
|
}
|
|
return offset + delta;
|
|
}
|
|
|
|
export function applyIndentation(editor, { outdent = false, size = 2 } = {}) {
|
|
if (!editor || editor.readOnly) return false;
|
|
const indent = " ".repeat(Math.max(1, Number(size) || 2));
|
|
const start = editor.selectionStart;
|
|
const end = editor.selectionEnd;
|
|
const direction = editor.selectionDirection || "none";
|
|
|
|
if (!outdent && start === end) {
|
|
editor.setRangeText(indent, start, end, "end");
|
|
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
|
return true;
|
|
}
|
|
|
|
const value = editor.value;
|
|
const { blockStart, blockEnd } = selectedLineRange(value, start, end);
|
|
const block = value.slice(blockStart, blockEnd);
|
|
const lineStarts = [0];
|
|
for (let index = 0; index < block.length; index++) {
|
|
if (block[index] === "\n") lineStarts.push(index + 1);
|
|
}
|
|
|
|
const edits = lineStarts.map(relativeStart => {
|
|
const absoluteStart = blockStart + relativeStart;
|
|
if (!outdent) return { start: absoluteStart, remove: 0, insert: indent };
|
|
const line = value.slice(absoluteStart, value.indexOf("\n", absoluteStart) < 0 ? value.length : value.indexOf("\n", absoluteStart));
|
|
const removable = line.startsWith("\t") ? 1 : Math.min(indent.length, (line.match(/^ +/) || [""])[0].length);
|
|
return { start: absoluteStart, remove: removable, insert: "" };
|
|
}).filter(edit => edit.remove || edit.insert);
|
|
|
|
if (!edits.length) return false;
|
|
let replacement = block;
|
|
for (let index = edits.length - 1; index >= 0; index--) {
|
|
const edit = edits[index];
|
|
const relativeStart = edit.start - blockStart;
|
|
replacement = `${replacement.slice(0, relativeStart)}${edit.insert}${replacement.slice(relativeStart + edit.remove)}`;
|
|
}
|
|
|
|
const nextStart = mapOffsetThroughEdits(start, edits);
|
|
const nextEnd = mapOffsetThroughEdits(end, edits);
|
|
editor.setRangeText(replacement, blockStart, blockEnd, "start");
|
|
editor.setSelectionRange(nextStart, nextEnd, direction);
|
|
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
|
return true;
|
|
}
|
|
|
|
export function bindIndentationShortcuts(editor, { size = 2 } = {}) {
|
|
editor.addEventListener("keydown", event => {
|
|
if (event.key !== "Tab" || event.ctrlKey || event.metaKey || event.altKey) return;
|
|
if (editor.readOnly) return;
|
|
event.preventDefault();
|
|
applyIndentation(editor, { outdent: event.shiftKey, size });
|
|
});
|
|
}
|