mirror of
https://github.com/hansjone/netx.git
synced 2026-10-16 16:50:46 +08:00
Tighten NetX help copy and structure HelpHint typography.
Remove permanent list-page hint banners, keep concepts in short HelpHint popovers with scannable sections and lists. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
046029aa16
commit
1463652dbb
13 changed files with 336 additions and 81 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -9,20 +9,160 @@ type Props = {
|
|||
};
|
||||
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
const POPOVER_MAX_WIDTH = 360;
|
||||
const POPOVER_MAX_WIDTH = 420;
|
||||
|
||||
type HelpBlock =
|
||||
| { kind: "title"; text: string }
|
||||
| { kind: "list"; items: string[] }
|
||||
| { kind: "para"; text: string };
|
||||
|
||||
const SECTION_RE = /^[【\[](.+?)[】\]]\s*$/;
|
||||
const BULLET_RE = /^[·•\-\*]\s+(.+)$/;
|
||||
/** Highlight field names / env keys / templates — skip short ALLCAPS acronyms (UME, DSH). */
|
||||
const CODEISH_RE =
|
||||
/(`[^`]+`|\{[A-Za-z0-9_./:@-]+\}|[A-Z][A-Z0-9_]{5,}(?::[a-z]+)?|[a-z]+(?:_[a-z0-9]+)+|[a-z]+[A-Z][A-Za-z0-9]*)/g;
|
||||
|
||||
function parseHelpBlocks(raw: string): HelpBlock[] {
|
||||
const lines = String(raw || "")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split("\n")
|
||||
.map((l) => l.trimEnd());
|
||||
const blocks: HelpBlock[] = [];
|
||||
let listBuf: string[] = [];
|
||||
let paraBuf: string[] = [];
|
||||
|
||||
const flushList = () => {
|
||||
if (!listBuf.length) return;
|
||||
blocks.push({ kind: "list", items: listBuf });
|
||||
listBuf = [];
|
||||
};
|
||||
const flushPara = () => {
|
||||
if (!paraBuf.length) return;
|
||||
blocks.push({ kind: "para", text: paraBuf.join(" ").trim() });
|
||||
paraBuf = [];
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
flushList();
|
||||
flushPara();
|
||||
continue;
|
||||
}
|
||||
const section = trimmed.match(SECTION_RE);
|
||||
if (section) {
|
||||
flushList();
|
||||
flushPara();
|
||||
blocks.push({ kind: "title", text: section[1].trim() });
|
||||
continue;
|
||||
}
|
||||
const bullet = trimmed.match(BULLET_RE);
|
||||
if (bullet) {
|
||||
flushPara();
|
||||
listBuf.push(bullet[1].trim());
|
||||
continue;
|
||||
}
|
||||
flushList();
|
||||
paraBuf.push(trimmed);
|
||||
}
|
||||
flushList();
|
||||
flushPara();
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function renderInline(text: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
const re = new RegExp(CODEISH_RE.source, "g");
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (m.index > last) nodes.push(text.slice(last, m.index));
|
||||
const raw = m[0];
|
||||
const body = raw.startsWith("`") && raw.endsWith("`") ? raw.slice(1, -1) : raw;
|
||||
nodes.push(
|
||||
<code key={`${m.index}-${body}`} className="help-hint__code">
|
||||
{body}
|
||||
</code>,
|
||||
);
|
||||
last = m.index + raw.length;
|
||||
}
|
||||
if (last < text.length) nodes.push(text.slice(last));
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** Long single paragraphs → clause list so plain help is still scannable. */
|
||||
function expandPlainPara(text: string): HelpBlock[] {
|
||||
const parts = text
|
||||
.split(/(?<=[。;;])\s*/)
|
||||
.map((s) => s.replace(/[。;;]\s*$/, "").trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length >= 2 && text.length >= 48) {
|
||||
return [{ kind: "list", items: parts }];
|
||||
}
|
||||
return [{ kind: "para", text }];
|
||||
}
|
||||
|
||||
function HelpBody({ text, nowrap }: { text: string; nowrap: boolean }) {
|
||||
const blocks = useMemo(() => {
|
||||
const parsed = parseHelpBlocks(text);
|
||||
if (nowrap) return parsed;
|
||||
if (parsed.length === 1 && parsed[0].kind === "para") {
|
||||
return expandPlainPara(parsed[0].text);
|
||||
}
|
||||
return parsed.flatMap((b) => (b.kind === "para" && b.text.length >= 96 ? expandPlainPara(b.text) : [b]));
|
||||
}, [text, nowrap]);
|
||||
|
||||
if (nowrap) {
|
||||
return <div className="help-hint__plain">{renderInline(text)}</div>;
|
||||
}
|
||||
return (
|
||||
<div className="help-hint__body">
|
||||
{blocks.map((b, i) => {
|
||||
if (b.kind === "title") {
|
||||
return (
|
||||
<div key={`t-${i}`} className="help-hint__section-title">
|
||||
{b.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (b.kind === "list") {
|
||||
return (
|
||||
<ul key={`l-${i}`} className="help-hint__list">
|
||||
{b.items.map((item, j) => (
|
||||
<li key={j}>{renderInline(item)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p key={`p-${i}`} className="help-hint__para">
|
||||
{renderInline(b.text)}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
|
||||
const rootRef = useRef<HTMLSpanElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const structured = useMemo(() => {
|
||||
const parsed = parseHelpBlocks(text);
|
||||
if (parsed.some((b) => b.kind === "title" || b.kind === "list")) return true;
|
||||
// Long plain paragraphs are auto-split into a list in HelpBody.
|
||||
return parsed.length === 1 && parsed[0].kind === "para" && parsed[0].text.length >= 48;
|
||||
}, [text]);
|
||||
|
||||
const updatePopoverPosition = () => {
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const popover = popoverRef.current;
|
||||
const maxWidth = Math.min(POPOVER_MAX_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2);
|
||||
const preferred = structured && !nowrap ? POPOVER_MAX_WIDTH : 360;
|
||||
const maxWidth = Math.min(preferred, window.innerWidth - VIEWPORT_MARGIN * 2);
|
||||
const popWidth = popover?.offsetWidth || maxWidth;
|
||||
const popHeight = popover?.offsetHeight || 0;
|
||||
|
||||
|
|
@ -40,6 +180,7 @@ export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: P
|
|||
left,
|
||||
right: "auto",
|
||||
maxWidth,
|
||||
maxHeight: Math.min(window.innerHeight - VIEWPORT_MARGIN * 2, 480),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -55,7 +196,7 @@ export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: P
|
|||
window.removeEventListener("resize", onReflow);
|
||||
window.removeEventListener("scroll", onReflow, true);
|
||||
};
|
||||
}, [open, align, text, nowrap]);
|
||||
}, [open, align, text, nowrap, structured]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -98,11 +239,11 @@ export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: P
|
|||
ref={popoverRef}
|
||||
className={`help-hint__popover help-hint__popover--portal${
|
||||
nowrap ? " help-hint__popover--nowrap" : ""
|
||||
}`}
|
||||
}${structured && !nowrap ? " help-hint__popover--structured" : ""}`}
|
||||
style={popoverStyle}
|
||||
role="tooltip"
|
||||
>
|
||||
{text}
|
||||
<HelpBody text={text} nowrap={nowrap} />
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue