feat: add rich text formatting toolbar to model descriptions

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-07 00:53:49 +02:00
1 parent 97f0eea4c2
commit d49d7c9919
6 files changed
+436 -18

No files matched your search

+2 -1
View File
@@ -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([]);
});
});
+18 -1
View File
@@ -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 ![images](https://…)."
"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",
+18 -1
View File
@@ -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 ![paveikslėliai](https://…)."
"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={{
+101
View File
@@ -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![image](https://)\n\nafter");
expect(applyEdit(value, edit).slice(edit.select.start, edit.select.end)).toBe("https://");
});
});
+138
View File
@@ -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);
}
/** `![alt](url)` 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);
}