mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
feat: add rich text formatting toolbar to model descriptions
This commit is contained in:
1 parent
97f0eea4c2
commit
d49d7c9919
6 files changed
+436
-18
No files matched your search
@@ -193,7 +193,8 @@ describe("admin audit logs", () => {
|
||||
it("filters logs by user", async () => {
|
||||
const res = await request(app).get(`/api/admin/logs?user_id=${memberUserId}`).set(auth(adminToken));
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as Array<{ user_id: string }>).every((l) => l.user_id === memberUserId)).toBe(true);
|
||||
// Lists the offending entries, not just `false`, if this ever fails.
|
||||
expect((res.body as Array<{ user_id: string }>).filter((l) => l.user_id !== memberUserId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1463,7 +1463,24 @@
|
||||
"descriptionWrite": "Write",
|
||||
"descriptionPreview": "Preview",
|
||||
"descriptionPlaceholder": "Describe the model, print settings, assembly steps...",
|
||||
"descriptionMarkdownHint": "Markdown supported: **bold**, *italic*, # headings, - lists, [links](https://…) and ."
|
||||
"descriptionMarkdownHint": "Formatted with Markdown. Images added by URL are saved with the model.",
|
||||
"toolbar": {
|
||||
"label": "Formatting",
|
||||
"bold": "Bold",
|
||||
"boldText": "bold text",
|
||||
"italic": "Italic",
|
||||
"italicText": "italic text",
|
||||
"heading": "Heading",
|
||||
"bulletList": "Bulleted list",
|
||||
"numberedList": "Numbered list",
|
||||
"quote": "Quote",
|
||||
"code": "Code",
|
||||
"codeText": "code",
|
||||
"link": "Link",
|
||||
"linkText": "link text",
|
||||
"image": "Image from URL",
|
||||
"imageText": "image"
|
||||
}
|
||||
},
|
||||
"author": {
|
||||
"pageTitle": "Author",
|
||||
|
||||
@@ -1466,7 +1466,24 @@
|
||||
"descriptionWrite": "Rašyti",
|
||||
"descriptionPreview": "Peržiūra",
|
||||
"descriptionPlaceholder": "Aprašykite modelį, spausdinimo nustatymus, surinkimo žingsnius...",
|
||||
"descriptionMarkdownHint": "Palaikomas Markdown: **paryškinta**, *kursyvas*, # antraštės, - sąrašai, [nuorodos](https://…) ir ."
|
||||
"descriptionMarkdownHint": "Formatuojama Markdown. Pagal URL pridėti paveikslėliai išsaugomi kartu su modeliu.",
|
||||
"toolbar": {
|
||||
"label": "Formatavimas",
|
||||
"bold": "Paryškinti",
|
||||
"boldText": "paryškintas tekstas",
|
||||
"italic": "Kursyvas",
|
||||
"italicText": "kursyvinis tekstas",
|
||||
"heading": "Antraštė",
|
||||
"bulletList": "Sąrašas",
|
||||
"numberedList": "Numeruotas sąrašas",
|
||||
"quote": "Citata",
|
||||
"code": "Kodas",
|
||||
"codeText": "kodas",
|
||||
"link": "Nuoroda",
|
||||
"linkText": "nuorodos tekstas",
|
||||
"image": "Paveikslėlis iš URL",
|
||||
"imageText": "paveikslėlis"
|
||||
}
|
||||
},
|
||||
"author": {
|
||||
"pageTitle": "Autorius",
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import { useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Box from "@mui/material/Box";
|
||||
import Tabs from "@mui/material/Tabs";
|
||||
import Tab from "@mui/material/Tab";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import FormatBoldIcon from "@mui/icons-material/FormatBold";
|
||||
import FormatItalicIcon from "@mui/icons-material/FormatItalic";
|
||||
import TitleIcon from "@mui/icons-material/Title";
|
||||
import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted";
|
||||
import FormatListNumberedIcon from "@mui/icons-material/FormatListNumbered";
|
||||
import FormatQuoteIcon from "@mui/icons-material/FormatQuote";
|
||||
import CodeIcon from "@mui/icons-material/Code";
|
||||
import InsertLinkIcon from "@mui/icons-material/InsertLink";
|
||||
import ImageOutlinedIcon from "@mui/icons-material/ImageOutlined";
|
||||
import MarkdownDescription from "../../components/MarkdownDescription";
|
||||
import { dividerBorderColor } from "../../theme";
|
||||
import {
|
||||
applyEdit,
|
||||
insertImage,
|
||||
insertLink,
|
||||
toggleInline,
|
||||
toggleLines,
|
||||
type MarkdownEdit,
|
||||
type Selection,
|
||||
} from "../../utils/markdownEdits";
|
||||
|
||||
type Builder = (current: string, sel: Selection) => MarkdownEdit;
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
@@ -14,10 +37,102 @@ type Props = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/** The description field: Markdown source, with a tab to preview it as the detail page shows it. */
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
const MOD = isMac ? "⌘" : "Ctrl+";
|
||||
|
||||
/** The description field: Markdown source with a formatting toolbar, and a tab to preview it as
|
||||
* the detail page shows it. */
|
||||
export default function DescriptionEditor({ value, onChange, disabled }: Props) {
|
||||
const { t } = useTranslation("models");
|
||||
const [tab, setTab] = useState<"write" | "preview">("write");
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const apply = (build: Builder) => {
|
||||
const textarea = inputRef.current;
|
||||
if (!textarea || disabled) return;
|
||||
const edit = build(textarea.value, { start: textarea.selectionStart, end: textarea.selectionEnd });
|
||||
const expected = applyEdit(textarea.value, edit);
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(edit.start, edit.end);
|
||||
// Through the browser's own editing, so ⌘Z undoes it like typing; it fires the change event.
|
||||
const applied = edit.insert
|
||||
? document.execCommand("insertText", false, edit.insert)
|
||||
: document.execCommand("delete", false);
|
||||
if (!applied || textarea.value !== expected) onChange(expected);
|
||||
const select = () => textarea.setSelectionRange(edit.select.start, edit.select.end);
|
||||
select();
|
||||
// The fallback path re-renders before the new text is in place.
|
||||
requestAnimationFrame(select);
|
||||
};
|
||||
|
||||
const placeholders = { text: t("edit.toolbar.linkText"), url: "https://" };
|
||||
const actions: { key: string; label: string; icon: ReactNode; edit: Builder; dividerBefore?: boolean }[] = [
|
||||
{
|
||||
key: "bold",
|
||||
label: `${t("edit.toolbar.bold")} (${MOD}B)`,
|
||||
icon: <FormatBoldIcon fontSize="small" />,
|
||||
edit: (v, s) => toggleInline(v, s, "bold", t("edit.toolbar.boldText")),
|
||||
},
|
||||
{
|
||||
key: "italic",
|
||||
label: `${t("edit.toolbar.italic")} (${MOD}I)`,
|
||||
icon: <FormatItalicIcon fontSize="small" />,
|
||||
edit: (v, s) => toggleInline(v, s, "italic", t("edit.toolbar.italicText")),
|
||||
},
|
||||
{
|
||||
key: "heading",
|
||||
label: t("edit.toolbar.heading"),
|
||||
icon: <TitleIcon fontSize="small" />,
|
||||
edit: (v, s) => toggleLines(v, s, "heading"),
|
||||
},
|
||||
{
|
||||
key: "bullet",
|
||||
label: t("edit.toolbar.bulletList"),
|
||||
icon: <FormatListBulletedIcon fontSize="small" />,
|
||||
edit: (v, s) => toggleLines(v, s, "bullet"),
|
||||
dividerBefore: true,
|
||||
},
|
||||
{
|
||||
key: "numbered",
|
||||
label: t("edit.toolbar.numberedList"),
|
||||
icon: <FormatListNumberedIcon fontSize="small" />,
|
||||
edit: (v, s) => toggleLines(v, s, "numbered"),
|
||||
},
|
||||
{
|
||||
key: "quote",
|
||||
label: t("edit.toolbar.quote"),
|
||||
icon: <FormatQuoteIcon fontSize="small" />,
|
||||
edit: (v, s) => toggleLines(v, s, "quote"),
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
label: t("edit.toolbar.code"),
|
||||
icon: <CodeIcon fontSize="small" />,
|
||||
edit: (v, s) => toggleInline(v, s, "code", t("edit.toolbar.codeText")),
|
||||
},
|
||||
{
|
||||
key: "link",
|
||||
label: `${t("edit.toolbar.link")} (${MOD}K)`,
|
||||
icon: <InsertLinkIcon fontSize="small" />,
|
||||
edit: (v, s) => insertLink(v, s, placeholders),
|
||||
dividerBefore: true,
|
||||
},
|
||||
{
|
||||
key: "image",
|
||||
label: t("edit.toolbar.image"),
|
||||
icon: <ImageOutlinedIcon fontSize="small" />,
|
||||
edit: (v, s) => insertImage(v, s, { ...placeholders, text: t("edit.toolbar.imageText") }),
|
||||
},
|
||||
];
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return;
|
||||
const key = { b: "bold", i: "italic", k: "link" }[e.key.toLowerCase()];
|
||||
const action = key && actions.find((a) => a.key === key);
|
||||
if (!action) return;
|
||||
e.preventDefault();
|
||||
apply(action.edit);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -31,19 +146,48 @@ export default function DescriptionEditor({ value, onChange, disabled }: Props)
|
||||
</Tabs>
|
||||
</Box>
|
||||
{tab === "write" ? (
|
||||
<TextField
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={6}
|
||||
maxRows={20}
|
||||
placeholder={t("edit.descriptionPlaceholder")}
|
||||
helperText={t("edit.descriptionMarkdownHint")}
|
||||
inputProps={{ "aria-label": t("detail.description") }}
|
||||
sx={{ mt: 1, "& textarea": { fontFamily: "monospace", fontSize: "0.85rem" } }}
|
||||
/>
|
||||
<>
|
||||
<Box
|
||||
role="toolbar"
|
||||
aria-label={t("edit.toolbar.label")}
|
||||
sx={{ mt: 1, display: "flex", alignItems: "center", flexWrap: "wrap", gap: 0.25 }}
|
||||
>
|
||||
{actions.map((action) => [
|
||||
action.dividerBefore && (
|
||||
<Divider key={`${action.key}-divider`} orientation="vertical" flexItem sx={{ mx: 0.5, my: 0.75 }} />
|
||||
),
|
||||
<Tooltip key={action.key} title={action.label}>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={action.label}
|
||||
disabled={disabled}
|
||||
// Keeps the textarea's selection: a focused button would take it.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => apply(action.edit)}
|
||||
>
|
||||
{action.icon}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>,
|
||||
])}
|
||||
</Box>
|
||||
<TextField
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
inputRef={inputRef}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={6}
|
||||
maxRows={20}
|
||||
placeholder={t("edit.descriptionPlaceholder")}
|
||||
helperText={t("edit.descriptionMarkdownHint")}
|
||||
inputProps={{ "aria-label": t("detail.description") }}
|
||||
sx={{ mt: 0.5, "& textarea": { fontFamily: "monospace", fontSize: "0.85rem" } }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyEdit,
|
||||
insertImage,
|
||||
insertLink,
|
||||
toggleInline,
|
||||
toggleLines,
|
||||
type MarkdownEdit,
|
||||
type Selection,
|
||||
} from "./markdownEdits";
|
||||
|
||||
/** Writes a selection into the text as [ and ] markers (| for a caret) so results read at a glance. */
|
||||
function show(value: string, edit: MarkdownEdit): string {
|
||||
const next = applyEdit(value, edit);
|
||||
const { start, end } = edit.select;
|
||||
if (start === end) return `${next.slice(0, start)}|${next.slice(start)}`;
|
||||
return `${next.slice(0, start)}[${next.slice(start, end)}]${next.slice(end)}`;
|
||||
}
|
||||
|
||||
/** The reverse of show: "a [b] c" is "a b c" with "b" selected. */
|
||||
function parse(marked: string): { value: string; sel: Selection } {
|
||||
const caret = marked.indexOf("|");
|
||||
if (caret !== -1) return { value: marked.replace("|", ""), sel: { start: caret, end: caret } };
|
||||
const start = marked.indexOf("[");
|
||||
const end = marked.indexOf("]") - 1;
|
||||
return { value: marked.replace("[", "").replace("]", ""), sel: { start, end } };
|
||||
}
|
||||
|
||||
describe("toggleInline", () => {
|
||||
it("wraps the selection, keeping it selected", () => {
|
||||
const { value, sel } = parse("make [this] bold");
|
||||
expect(show(value, toggleInline(value, sel, "bold", "bold text"))).toBe("make **[this]** bold");
|
||||
});
|
||||
|
||||
it("unwraps a selection that is already wrapped", () => {
|
||||
const { value, sel } = parse("make **[this]** bold");
|
||||
expect(show(value, toggleInline(value, sel, "bold", "bold text"))).toBe("make [this] bold");
|
||||
});
|
||||
|
||||
it("inserts a selected placeholder at a caret", () => {
|
||||
const { value, sel } = parse("say |");
|
||||
expect(show(value, toggleInline(value, sel, "italic", "italic text"))).toBe("say *[italic text]*");
|
||||
});
|
||||
|
||||
it("doesn't mistake bold markers for italic ones", () => {
|
||||
const { value, sel } = parse("**[word]**");
|
||||
expect(show(value, toggleInline(value, sel, "italic", "x"))).toBe("***[word]***");
|
||||
});
|
||||
|
||||
it("fences a multi-line code selection", () => {
|
||||
const { value, sel } = parse("[a\nb]");
|
||||
expect(applyEdit(value, toggleInline(value, sel, "code", "code"))).toBe("```\na\nb\n```");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleLines", () => {
|
||||
it("numbers every selected line, replacing bullets", () => {
|
||||
const { value, sel } = parse("intro\n[- one\n- two]\noutro");
|
||||
expect(applyEdit(value, toggleLines(value, sel, "numbered"))).toBe("intro\n1. one\n2. two\noutro");
|
||||
});
|
||||
|
||||
it("removes the prefix when every selected line has it, skipping blank lines", () => {
|
||||
const { value, sel } = parse("[> a\n\n> b]");
|
||||
expect(applyEdit(value, toggleLines(value, sel, "quote"))).toBe("a\n\nb");
|
||||
});
|
||||
|
||||
it("turns the caret's line into a heading and keeps the caret at its end", () => {
|
||||
const { value, sel } = parse("Ti|tle\nbody");
|
||||
expect(show(value, toggleLines(value, sel, "heading"))).toBe("## Title|\nbody");
|
||||
});
|
||||
|
||||
it("doesn't take in the line after a selection ending in a newline", () => {
|
||||
const { value, sel } = parse("[a\n]b");
|
||||
expect(applyEdit(value, toggleLines(value, sel, "bullet"))).toBe("- a\nb");
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertLink and insertImage", () => {
|
||||
const placeholders = { text: "link text", url: "https://" };
|
||||
|
||||
it("makes selected text a link and selects the URL to type", () => {
|
||||
const { value, sel } = parse("see [the guide]");
|
||||
const edit = insertLink(value, sel, placeholders);
|
||||
expect(applyEdit(value, edit)).toBe("see [the guide](https://)");
|
||||
expect(applyEdit(value, edit).slice(edit.select.start, edit.select.end)).toBe("https://");
|
||||
});
|
||||
|
||||
it("makes a selected URL the link target and selects the text to type", () => {
|
||||
const { value, sel } = parse("[https://example.com]");
|
||||
const edit = insertLink(value, sel, placeholders);
|
||||
expect(applyEdit(value, edit)).toBe("[link text](https://example.com)");
|
||||
expect(applyEdit(value, edit).slice(edit.select.start, edit.select.end)).toBe("link text");
|
||||
});
|
||||
|
||||
it("puts an image on its own line", () => {
|
||||
const { value, sel } = parse("before|after");
|
||||
const edit = insertImage(value, sel, { text: "image", url: "https://" });
|
||||
expect(applyEdit(value, edit)).toBe("before\n\n\n\nafter");
|
||||
expect(applyEdit(value, edit).slice(edit.select.start, edit.select.end)).toBe("https://");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
// Toolbar and shortcut edits for a Markdown textarea. Each returns the range of the current text
|
||||
// to replace, what to put there, and what to select afterwards, so the caller can apply it as a
|
||||
// single undoable edit.
|
||||
|
||||
export type Selection = { start: number; end: number };
|
||||
|
||||
export type MarkdownEdit = {
|
||||
/** Range of the current text to replace. */
|
||||
start: number;
|
||||
end: number;
|
||||
insert: string;
|
||||
/** Selection in the resulting text. */
|
||||
select: Selection;
|
||||
};
|
||||
|
||||
export type InlineStyle = "bold" | "italic" | "code";
|
||||
export type LineStyle = "heading" | "bullet" | "numbered" | "quote";
|
||||
|
||||
const INLINE_MARKERS: Record<InlineStyle, string> = { bold: "**", italic: "*", code: "`" };
|
||||
|
||||
/** Wraps the selection in the style's marker, or unwraps it when it already has it. */
|
||||
export function toggleInline(value: string, sel: Selection, style: InlineStyle, placeholder: string): MarkdownEdit {
|
||||
const selected = value.slice(sel.start, sel.end);
|
||||
if (style === "code" && selected.includes("\n")) return codeBlock(sel, selected);
|
||||
const marker = INLINE_MARKERS[style];
|
||||
const m = marker.length;
|
||||
|
||||
// Markers just outside the selection, but not part of a longer run (italic inside **bold**).
|
||||
const before = value.slice(sel.start - m, sel.start);
|
||||
const after = value.slice(sel.end, sel.end + m);
|
||||
const runBefore = value[sel.start - m - 1] === marker[0];
|
||||
const runAfter = value[sel.end + m] === marker[0];
|
||||
if (before === marker && after === marker && !runBefore && !runAfter) {
|
||||
const start = sel.start - m;
|
||||
return { start, end: sel.end + m, insert: selected, select: { start, end: start + selected.length } };
|
||||
}
|
||||
if (selected.length >= 2 * m && selected.startsWith(marker) && selected.endsWith(marker)) {
|
||||
const inner = selected.slice(m, -m);
|
||||
return { ...sel, insert: inner, select: { start: sel.start, end: sel.start + inner.length } };
|
||||
}
|
||||
const text = selected || placeholder;
|
||||
return {
|
||||
...sel,
|
||||
insert: `${marker}${text}${marker}`,
|
||||
select: { start: sel.start + m, end: sel.start + m + text.length },
|
||||
};
|
||||
}
|
||||
|
||||
function codeBlock(sel: Selection, selected: string): MarkdownEdit {
|
||||
const insert = `\`\`\`\n${selected.replace(/\n$/, "")}\n\`\`\``;
|
||||
return { ...sel, insert, select: { start: sel.start + 4, end: sel.start + insert.length - 4 } };
|
||||
}
|
||||
|
||||
const LINE_PREFIX: Record<LineStyle, RegExp> = {
|
||||
heading: /^#{1,6} /,
|
||||
bullet: /^[-*+] /,
|
||||
numbered: /^\d+\. /,
|
||||
quote: /^> ?/,
|
||||
};
|
||||
// Switching between list kinds replaces the other kind's marker instead of stacking both.
|
||||
const ANY_LIST_PREFIX = /^([-*+]|\d+\.) /;
|
||||
|
||||
/** Adds the style's prefix to every selected line, or removes it when all of them have it. */
|
||||
export function toggleLines(value: string, sel: Selection, style: LineStyle): MarkdownEdit {
|
||||
const start = value.lastIndexOf("\n", sel.start - 1) + 1;
|
||||
// A selection ending right after a newline doesn't take in the next line.
|
||||
const endFrom = sel.end > sel.start && value[sel.end - 1] === "\n" ? sel.end - 1 : sel.end;
|
||||
const newline = value.indexOf("\n", endFrom);
|
||||
const end = newline === -1 ? value.length : newline;
|
||||
const lines = value.slice(start, end).split("\n");
|
||||
|
||||
const prefix = LINE_PREFIX[style];
|
||||
const content = lines.filter((line) => line.trim());
|
||||
const remove = content.length > 0 && content.every((line) => prefix.test(line));
|
||||
let number = 0;
|
||||
const next = lines.map((line) => {
|
||||
if (remove) return line.replace(prefix, "");
|
||||
if (!line.trim() && lines.length > 1) return line;
|
||||
const bare = style === "bullet" || style === "numbered" ? line.replace(ANY_LIST_PREFIX, "") : line;
|
||||
const stripped = bare.replace(prefix, "");
|
||||
if (style === "heading") return `## ${stripped}`;
|
||||
if (style === "bullet") return `- ${stripped}`;
|
||||
if (style === "numbered") return `${++number}. ${stripped}`;
|
||||
return `> ${stripped}`;
|
||||
});
|
||||
const insert = next.join("\n");
|
||||
const caretOnly = sel.start === sel.end && lines.length === 1;
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
insert,
|
||||
select: caretOnly
|
||||
? { start: start + insert.length, end: start + insert.length }
|
||||
: { start, end: start + insert.length },
|
||||
};
|
||||
}
|
||||
|
||||
/** `[text](url)`, selecting whichever part still needs typing. */
|
||||
export function insertLink(value: string, sel: Selection, placeholders: { text: string; url: string }): MarkdownEdit {
|
||||
return linkLike(value, sel, "", placeholders);
|
||||
}
|
||||
|
||||
/** `` on its own line, with the URL selected for pasting. */
|
||||
export function insertImage(value: string, sel: Selection, placeholders: { text: string; url: string }): MarkdownEdit {
|
||||
const edit = linkLike(value, sel, "!", placeholders);
|
||||
const lead = sel.start > 0 && value[sel.start - 1] !== "\n" ? "\n\n" : "";
|
||||
const trail = sel.end < value.length && value[sel.end] !== "\n" ? "\n\n" : "";
|
||||
return {
|
||||
...edit,
|
||||
insert: `${lead}${edit.insert}${trail}`,
|
||||
select: { start: edit.select.start + lead.length, end: edit.select.end + lead.length },
|
||||
};
|
||||
}
|
||||
|
||||
function linkLike(
|
||||
value: string,
|
||||
sel: Selection,
|
||||
bang: string,
|
||||
placeholders: { text: string; url: string },
|
||||
): MarkdownEdit {
|
||||
const selected = value.slice(sel.start, sel.end).trim();
|
||||
const isUrl = /^https?:\/\/\S+$/i.test(selected);
|
||||
const text = isUrl ? placeholders.text : selected || placeholders.text;
|
||||
const url = isUrl ? selected : placeholders.url;
|
||||
const insert = `${bang}[${text}](${url})`;
|
||||
const textStart = sel.start + bang.length + 1;
|
||||
const urlStart = textStart + text.length + 2;
|
||||
// A selected URL still needs its text; anything else needs its URL.
|
||||
const select = isUrl
|
||||
? { start: textStart, end: textStart + text.length }
|
||||
: { start: urlStart, end: urlStart + url.length };
|
||||
return { ...sel, insert, select };
|
||||
}
|
||||
|
||||
/** The text after applying an edit. */
|
||||
export function applyEdit(value: string, edit: MarkdownEdit): string {
|
||||
return value.slice(0, edit.start) + edit.insert + value.slice(edit.end);
|
||||
}
|
||||
Reference in new issue
Block a user