/* * 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. */ import { t } from "@rustpad/i18n"; function selection(editor) { return { start: editor.selectionStart, end: editor.selectionEnd }; } function toggleWrap(editor, before, after = before, placeholder = t("editor.format.text", {}, "text")) { 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 insertStandaloneLine(editor, content) { const { start, end } = selection(editor); const value = editor.value; const needsLeadingNewline = start > 0 && value[start - 1] !== "\n"; const needsTrailingNewline = end >= value.length || value[end] !== "\n"; const marker = `${needsLeadingNewline ? "\n" : ""}${content}${needsTrailingNewline ? "\n" : ""}`; editor.setRangeText(marker, start, end, "end"); } export function insertPageBreak(editor) { insertStandaloneLine(editor, "[PAGEBREAK]"); } export function insertHorizontalRule(editor) { insertStandaloneLine(editor, "---"); } 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://)", t("editor.format.description", {}, "description")); if (format === "inline-code") toggleWrap(editor, "`", "`", t("editor.format.code", {}, "code")); if (format === "highlight") toggleWrap(editor, "==", "==", t("editor.format.important", {}, "important")); if (format === "subscript") toggleWrap(editor, "~", "~", "2"); if (format === "superscript") toggleWrap(editor, "^", "^", "2"); if (format === "codeblock") toggleWrap(editor, "```text\n", "\n```", t("editor.format.code", {}, "code")); if (format === "codeblock-lines") toggleWrap(editor, "```text=\n", "\n```", t("editor.format.code", {}, "code")); if (format === "mermaid") toggleWrap(editor, "```mermaid\n", "\n```", t("editor.format.diagram", {}, "graph TD\n A[Start] --> B[End]")); if (format === "details") toggleWrap(editor, `
\n${t("editor.format.detailsSummary", {}, "Click me")}\n\n`, "\n
", t("editor.format.content", {}, "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:::", t("editor.format.alertContent", {}, "Alert content")); } if (format === "table") toggleWrap(editor, `| ${t("editor.format.column1", {}, "Column 1")} | ${t("editor.format.column2", {}, "Column 2")} |\n| --- | --- |\n| `, ` | ${t("editor.format.value", {}, "value")} |`, t("editor.format.value", {}, "value")); if (format === "footnote") toggleWrap(editor, "", `[^1]\n\n[^1]: ${t("editor.format.footnote", {}, "Footnote text")}`, t("editor.format.textWithFootnote", {}, "Text with footnote")); if (format === "definition") toggleWrap(editor, "", `\n: ${t("editor.format.definition", {}, "Definition")}`, t("editor.format.term", {}, "Term")); if (format === "page-break") insertPageBreak(editor); if (format === "horizontal-rule") insertHorizontalRule(editor); 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 === "Enter") format = "page-break"; 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 }); }); }