From ba518faa051ec23fd22bb6021fe20117da75a336 Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:49:16 +0100 Subject: [PATCH] Render notes as Markdown with resource mentions (#1136) Notes in the web UI now render through ReactMarkdown. A mention like @instance/778528005 or @user/cooldude2606 turns into a link to that resource's page, labelled with its name if the client has it and the id otherwise. The stored note keeps the mention as written. Line breaks are kept as hard breaks so existing plain text notes look the same. Co-authored-by: Claude Opus 5.5 --- docs/managing-a-cluster.md | 5 ++ .../web_ui/src/components/ResourceMention.tsx | 71 ++++++++++++++++++ .../web_ui/src/components/ResourceNotes.tsx | 72 ++++++++++++++++++- 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 packages/web_ui/src/components/ResourceMention.tsx diff --git a/docs/managing-a-cluster.md b/docs/managing-a-cluster.md index 7311ca25..b3a6f582 100644 --- a/docs/managing-a-cluster.md +++ b/docs/managing-a-cluster.md @@ -236,6 +236,11 @@ To be written. Free text notes can be attached to hosts, instances, users, roles, mod packs, mods and saves. They are stored on the controller and shown to everyone with access to the resource in the web interface, and can for example be used to record who runs a host or what an instance is meant for. +The web interface renders notes as Markdown. +A mention of the form `@/` becomes a link to that resource showing its name, for example `@instance/778528005` or `@user/cooldude2606`. +This works for hosts, instances, users, roles and mod packs, and the id is shown if the name is not available. +The note itself stores the mention as written. + ### List notes ctl> note list [--type ] diff --git a/packages/web_ui/src/components/ResourceMention.tsx b/packages/web_ui/src/components/ResourceMention.tsx new file mode 100644 index 00000000..1b45d335 --- /dev/null +++ b/packages/web_ui/src/components/ResourceMention.tsx @@ -0,0 +1,71 @@ +import React from "react"; + +import Link from "./Link"; +import { useHost } from "../model/host"; +import { useInstance } from "../model/instance"; +import { useModPack } from "../model/mod_pack"; +import { useRole } from "../model/roles"; +import { useUser } from "../model/user"; + +/** Matches @type/id references to resources that have a view page. */ +export const mentionPattern = /(?; + case "instance": return ; + case "role": return ; + case "mod_pack": return ; + case "user": return ; + default: return <>@{props.resourceType}/{props.resourceId}; + } +} + +function MentionLink(props: { resourceType: string, id: string, name?: string }) { + return {props.name ?? props.id}; +} + +function HostMention(props: { id: string }) { + const [host] = useHost(Number(props.id)); + return ; +} + +function InstanceMention(props: { id: string }) { + const [instance] = useInstance(Number(props.id)); + return ; +} + +function RoleMention(props: { id: string }) { + const [role] = useRole(Number(props.id)); + return ; +} + +function ModPackMention(props: { id: string }) { + const [modPack] = useModPack(Number(props.id)); + return ; +} + +function UserMention(props: { id: string }) { + const [user] = useUser(props.id); + return ; +} diff --git a/packages/web_ui/src/components/ResourceNotes.tsx b/packages/web_ui/src/components/ResourceNotes.tsx index c28231d7..c316bbb6 100644 --- a/packages/web_ui/src/components/ResourceNotes.tsx +++ b/packages/web_ui/src/components/ResourceNotes.tsx @@ -1,5 +1,6 @@ import React, { useContext, useEffect, useState } from "react"; import { Button, Input, Space, Typography } from "antd"; +import ReactMarkdown, { Components } from "react-markdown"; import * as lib from "@clusterio/lib"; import { notifyErrorHandler } from "../util/notify"; @@ -7,10 +8,70 @@ import { useAccount } from "../model/account"; import { useNote } from "../model/notes"; import { formatTimestamp } from "../util/time_format"; import ControlContext from "./ControlContext"; +import ResourceMention, { mentionPath, mentionPattern } from "./ResourceMention"; import SectionHeader from "./SectionHeader"; const { Text } = Typography; +/** + * Remark plugin turning @type/id mentions into links and line breaks into + * hard breaks. Text inside code and existing links is left alone. + */ +function remarkNoteText() { + function split(value: string) { + const nodes: any[] = []; + let last = 0; + function pushText(text: string) { + text.split("\n").forEach((line, index) => { + if (index > 0) { + nodes.push({ type: "break" }); + } + if (line) { + nodes.push({ type: "text", value: line }); + } + }); + } + for (const match of value.matchAll(mentionPattern)) { + pushText(value.slice(last, match.index)); + const resourceType = match[1] ?? match[3]; + const resourceId = match[2] ?? match[4]; + nodes.push({ + type: "link", + url: mentionPath(resourceType, resourceId), + children: [{ type: "text", value: match[0] }], + data: { hProperties: { dataResourceType: resourceType, dataResourceId: resourceId } }, + }); + last = match.index + match[0].length; + } + pushText(value.slice(last)); + return nodes; + } + function visit(node: any) { + if (!node.children || node.type === "link" || node.type === "linkReference") { + return; + } + node.children = node.children.flatMap((child: any) => { + if (child.type === "text") { + return split(child.value); + } + visit(child); + return [child]; + }); + } + return visit; +} + +const noteComponents: Components = { + a({ node, ...props }) { + const resourceType = node?.properties.dataResourceType; + const resourceId = node?.properties.dataResourceId; + if (typeof resourceType === "string" && typeof resourceId === "string") { + return ; + } + return ; + }, +}; + type ResourceNotesProps = { /** Type of resource, such as "host" or "instance". */ resourceType: string; @@ -85,12 +146,19 @@ function ResourceNotesEditor(props: ResourceNotesProps) { save(); } }} - placeholder="Write a note visible to everyone with access to this page" + placeholder={ + "Write a note visible to everyone with access to this page. " + + "Supports Markdown and links like @instance/ or @user/" + } autoFocus /> : <> {note - ?
{note.content}
+ ?
+ + {note.content} + +
: {synced ? "No note" : "Loading"} } {note && (note.updatedBy || note.updatedAtMs) &&