diff --git a/apps/web/src/components/rich-text.tsx b/apps/web/src/components/rich-text.tsx index 1246721..97e3c0d 100644 --- a/apps/web/src/components/rich-text.tsx +++ b/apps/web/src/components/rich-text.tsx @@ -1,59 +1,7 @@ -import { useState } from "react"; +import { type ReactNode, useState } from "react"; +import { parseRichTextMarkup, parseTextSegments, type RichTextNode } from "../lib/rich-text"; import { ExternalLinkModal } from "./external-link-modal"; -type Segment = - | { id: string; type: "text"; value: string } - | { id: string; type: "url"; value: string } - | { id: string; type: "timecode"; value: string; seconds: number }; - -function parseTimestampToSeconds(value: string): number | null { - const parts = value.split(":").map((part) => Number(part)); - if (parts.some((part) => !Number.isFinite(part))) return null; - if (parts.length === 2) { - const [minutes, seconds] = parts; - return minutes * 60 + seconds; - } - if (parts.length === 3) { - const [hours, minutes, seconds] = parts; - return hours * 3600 + minutes * 60 + seconds; - } - return null; -} - -function parseSegments(text: string): Segment[] { - const regex = /https?:\/\/[^\s\])"',;:!>]+|\b(?:\d{1,2}:)?[0-5]?\d:[0-5]\d\b/g; - const segments: Segment[] = []; - let lastIndex = 0; - let counter = 0; - let match = regex.exec(text); - while (match !== null) { - if (match.index > lastIndex) { - segments.push({ - id: `t${counter++}`, - type: "text", - value: text.slice(lastIndex, match.index), - }); - } - const value = match[0]; - if (value.startsWith("http://") || value.startsWith("https://")) { - segments.push({ id: `u${counter++}`, type: "url", value }); - } else { - const seconds = parseTimestampToSeconds(value); - if (seconds === null) { - segments.push({ id: `t${counter++}`, type: "text", value }); - } else { - segments.push({ id: `c${counter++}`, type: "timecode", value, seconds }); - } - } - lastIndex = match.index + match[0].length; - match = regex.exec(text); - } - if (lastIndex < text.length) { - segments.push({ id: `t${counter}`, type: "text", value: text.slice(lastIndex) }); - } - return segments; -} - type RichTextProps = { text: string; onSeekTimestamp?: (seconds: number) => void; @@ -61,38 +9,81 @@ type RichTextProps = { export function RichText({ text, onSeekTimestamp }: RichTextProps) { const [pendingUrl, setPendingUrl] = useState(null); - const segments = parseSegments(text); + const nodes = parseRichTextMarkup(text); - return ( - - {segments.map((seg) => - seg.type === "text" ? ( - {seg.value} - ) : seg.type === "url" ? ( + function renderNodes(children: RichTextNode[], keyPrefix: string): ReactNode[] { + return children.flatMap((node, index) => renderNode(node, `${keyPrefix}-${index}`)); + } + + function renderNode(node: RichTextNode, key: string): ReactNode[] { + if (node.type === "text") { + return parseTextSegments(node.value, `${key}-`).map((segment) => + segment.type === "text" ? ( + {segment.value} + ) : segment.type === "url" ? ( { event.preventDefault(); - setPendingUrl(seg.value); + setPendingUrl(segment.value); }} className="text-accent hover:text-accent-strong underline underline-offset-2 transition-colors break-all text-left align-baseline" > - {seg.value} + {segment.value} ) : onSeekTimestamp ? ( ) : ( - {seg.value} + {segment.value} ), - )} + ); + } + if (node.type === "break") return [
]; + if (node.type === "link") { + return [ + { + event.preventDefault(); + setPendingUrl(node.href); + }} + className="text-accent hover:text-accent-strong underline underline-offset-2 transition-colors break-all text-left align-baseline" + > + {renderNodes(node.children, key)} + , + ]; + } + const children = renderNodes(node.children, key); + switch (node.tag) { + case "strong": + return [{children}]; + case "em": + return [{children}]; + case "u": + return [{children}]; + case "s": + return [{children}]; + case "code": + return [{children}]; + case "kbd": + return [{children}]; + case "mark": + return [{children}]; + } + } + + return ( + + {renderNodes(nodes, "rich-text")} {pendingUrl && ( 0 && nodes.at(-1)?.type !== "break") nodes.push({ type: "break" }); +} + +function formatTag(tag: string): RichTextFormat | null { + if (tag === "b") return "strong"; + if (tag === "i") return "em"; + if (tag === "strike" || tag === "del") return "s"; + return FORMAT_TAGS.has(tag) ? (tag as RichTextFormat) : null; +} + +function parseChildNodes(nodes: NodeListOf): RichTextNode[] { + const result: RichTextNode[] = []; + + for (const node of nodes) { + if (node.nodeType === 3) { + if (node.nodeValue) result.push({ type: "text", value: node.nodeValue }); + continue; + } + if (node.nodeType !== 1) continue; + + const element = node as Element; + const tag = element.tagName.toLowerCase(); + if (OMIT_TAGS.has(tag)) continue; + if (tag === "br") { + result.push({ type: "break" }); + continue; + } + + const children = parseChildNodes(element.childNodes); + if (tag === "a") { + const href = element.getAttribute("href"); + if (href && isSafeHttpUrl(href)) result.push({ type: "link", href, children }); + else result.push(...children); + continue; + } + const formattedTag = formatTag(tag); + if (formattedTag) { + result.push({ type: "format", tag: formattedTag, children }); + continue; + } + if (BLOCK_TAGS.has(tag)) { + appendBreak(result); + result.push(...children); + appendBreak(result); + continue; + } + result.push(...children); + } + + return result; +} + +function parseDocument(source: string): HTMLElement | null { + if (typeof DOMParser === "undefined") return null; + return new DOMParser().parseFromString(source, "text/html").body; +} + +export function parseRichTextMarkup(text: string): RichTextNode[] { + const body = parseDocument(text); + if (!body) return [{ type: "text", value: text }]; + + let nodes = parseChildNodes(body.childNodes); + if (body.children.length === 0 && /<\s*(?:a|br|b|strong|em|i|u|s|p|div)\b/i.test(text)) { + const decoded = body.textContent ?? text; + const decodedBody = decoded === text ? null : parseDocument(decoded); + if (decodedBody) nodes = parseChildNodes(decodedBody.childNodes); + } + return nodes; +} + +function parseTimestampToSeconds(value: string): number | null { + const parts = value.split(":").map((part) => Number(part)); + if (parts.some((part) => !Number.isFinite(part))) return null; + if (parts.length === 2) { + const [minutes, seconds] = parts; + return minutes * 60 + seconds; + } + if (parts.length === 3) { + const [hours, minutes, seconds] = parts; + return hours * 3600 + minutes * 60 + seconds; + } + return null; +} + +export function parseTextSegments(text: string, idPrefix = ""): RichTextSegment[] { + const regex = /https?:\/\/[^\s\])"',;:!>]+|\b(?:\d{1,2}:)?[0-5]?\d:[0-5]\d\b/g; + const segments: RichTextSegment[] = []; + let lastIndex = 0; + let counter = 0; + let match = regex.exec(text); + while (match !== null) { + if (match.index > lastIndex) { + segments.push({ + id: `${idPrefix}t${counter++}`, + type: "text", + value: text.slice(lastIndex, match.index), + }); + } + const value = match[0]; + if (value.startsWith("http://") || value.startsWith("https://")) { + segments.push({ id: `${idPrefix}u${counter++}`, type: "url", value }); + } else { + const seconds = parseTimestampToSeconds(value); + if (seconds === null) segments.push({ id: `${idPrefix}t${counter++}`, type: "text", value }); + else segments.push({ id: `${idPrefix}c${counter++}`, type: "timecode", value, seconds }); + } + lastIndex = match.index + match[0].length; + match = regex.exec(text); + } + if (lastIndex < text.length) { + segments.push({ id: `${idPrefix}t${counter}`, type: "text", value: text.slice(lastIndex) }); + } + return segments; +} diff --git a/apps/web/tests/rich-text.test.ts b/apps/web/tests/rich-text.test.ts new file mode 100644 index 0000000..0601e95 --- /dev/null +++ b/apps/web/tests/rich-text.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { parseTextSegments } from "../src/lib/rich-text"; + +describe("rich text segments", () => { + test("keeps links, timecodes, and surrounding text interactive", () => { + expect(parseTextSegments("Watch 1:23 https://example.com/video")).toEqual([ + { id: "t0", type: "text", value: "Watch " }, + { id: "c1", type: "timecode", value: "1:23", seconds: 83 }, + { id: "t2", type: "text", value: " " }, + { id: "u3", type: "url", value: "https://example.com/video" }, + ]); + }); + + test("does not create empty segments", () => { + expect(parseTextSegments("")).toEqual([]); + expect(parseTextSegments("1:23")).toEqual([ + { id: "c0", type: "timecode", value: "1:23", seconds: 83 }, + ]); + }); +});