import { type CSSProperties, Fragment, type ReactNode, useEffect, useRef, useState } from "react";
import { measureRichInlineStats } from "@chenglou/pretext/rich-inline";
import type { Block } from "../../lib/block";
import { INLINE_CODE_PADDING, LIST_INDENT, QUOTE_BORDER, QUOTE_INDENT } from "./constants";
import { image as imageBlock } from "./image";
import {
type BlockNode,
fallback,
type FontConfig,
fonts as blockFonts,
type Line,
type Mention,
type TableCell,
type TableRow,
} from "./parse";
import "./blocks.css";
const CELL_PAD_X = 8;
const CELL_PAD_Y = 5;
const TABLE_BORDER = 1;
const TABLE_WIDTH = 100000;
const BLOCK_GAP = 6;
function codeStyle(fonts: FontConfig): CSSProperties {
return {
paddingInline: INLINE_CODE_PADDING,
borderRadius: "var(--radius-sm)",
background: "var(--color-muted)",
font: fonts.codeFont,
lineHeight: `${fonts.lineHeight}px`,
whiteSpace: "nowrap",
};
}
function pieces(text: string): string[] {
return text.match(/\S+\s*|\s+/g) || [];
}
type Track = {
raw: string;
edge: number;
cursor: number;
active: boolean;
};
function emojiStyle(fonts: FontConfig): CSSProperties {
return {
display: "inline-block",
blockSize: `${fonts.emojiSize}px`,
inlineSize: `${fonts.emojiSize}px`,
verticalAlign: "text-bottom",
objectFit: "contain",
};
}
function avatarStyle(src: string): CSSProperties {
return { backgroundImage: `url(${JSON.stringify(src)})` };
}
type ReferenceProps =
| { kind: "document"; uid: string; name: string }
| { kind: "plan"; prefix: boolean };
function LocalReference(props: ReferenceProps) {
return (
);
}
function inline(node: Line["nodes"][number], index: number): string {
switch (node.kind) {
case "mention":
return `${index}:mention:${node.name}:${node.avatar || ""}`;
case "document":
return `${index}:document:${node.uid}:${node.name}:${node.reference}`;
case "plan":
return `${index}:plan:${node.reference}`;
case "emoji":
return `${index}:emoji:${node.name}:${node.src || ""}`;
case "code":
case "text":
return `${index}:${node.kind}:${node.text}`;
case "link":
return `${index}:link:${node.href}:${node.text}`;
}
}
function lineid(line: Line, index: number): string {
let key = `${index}:line`;
for (let i = 0; i < line.nodes.length; i++) key += ":" + inline(line.nodes[i]!, i);
return key;
}
function nodeid(node: BlockNode, index: number): string {
switch (node.kind) {
case "paragraph":
case "heading":
return `${index}:${node.kind}:${node.lines.map(lineid).join("|")}`;
case "list":
return `${index}:list:${node.items.length}:${node.ordered ? "1" : "0"}`;
case "blockquote":
return `${index}:blockquote:${node.children.length}`;
case "table":
return `${index}:table:${node.rows.map(rowid).join("|")}`;
case "image":
return `${index}:image:${node.src}:${node.alt}`;
case "hr":
return `${index}:hr`;
}
}
function itemid(item: BlockNode[], index: number): string {
let key = `${index}:item`;
for (let i = 0; i < item.length; i++) key += ":" + nodeid(item[i]!, i);
return key;
}
function pathid(path: string, index: number): string {
return `${path}.${index}`;
}
function cellid(cell: TableCell, index: number): string {
return `${index}:cell:${cell.align || ""}:${cell.header ? "1" : "0"}:${
cell.lines.map(lineid).join("|")
}`;
}
function rowid(row: TableRow, index: number): string {
return `${index}:row:${row.cells.map(cellid).join("|")}`;
}
function fresh(track: Track, text: string): boolean {
if (!track.active) return false;
if (!text.trim()) return false;
let index = track.raw.indexOf(text, track.cursor);
if (index < 0) index = track.cursor;
track.cursor = index + text.length;
return index >= track.edge;
}
/** Measure height and widest line across `lines` in a single Pretext walk. */
function measureLines(
lines: Line[],
width: number,
lineHeight: number,
): { height: number; fit: number } {
let h = 0, f = 0;
for (let line of lines) {
if (!line.prepared) {
h += lineHeight;
continue;
}
let stats = measureRichInlineStats(line.prepared, width);
h += Math.max(1, stats.lineCount) * lineHeight;
if (stats.maxLineWidth > f) f = stats.maxLineWidth;
}
return { height: h, fit: f };
}
/** Measure a block node tree; returns rendered height and narrowest width that preserves the wrap. */
function measureNode(
node: BlockNode,
width: number,
lineHeight: number,
): { height: number; fit: number } {
switch (node.kind) {
case "paragraph":
case "heading":
return measureLines(node.lines, width, lineHeight);
case "list": {
let inner = width - LIST_INDENT;
let h = 0, f = 0;
for (let item of node.items) {
for (let child of item) {
let m = measureNode(child, inner, lineHeight);
h += m.height;
if (m.fit > f) f = m.fit;
}
}
return { height: h, fit: f + LIST_INDENT };
}
case "blockquote": {
let inner = width - QUOTE_INDENT - QUOTE_BORDER;
let h = 0, f = 0;
for (let child of node.children) {
let m = measureNode(child, inner, lineHeight);
h += m.height;
if (m.fit > f) f = m.fit;
}
return { height: h, fit: f + QUOTE_INDENT + QUOTE_BORDER };
}
case "table":
return measureTable(node.rows, lineHeight);
case "image":
return imageBlock(node.src, 0, 0, node.alt).measure(width);
case "hr":
return { height: lineHeight, fit: 0 };
}
}
function measureTable(rows: TableRow[], lineHeight: number): { height: number; fit: number } {
let cols = rows.reduce((max, row) => Math.max(max, row.cells.length), 0);
let widths = Array.from({ length: cols }, () => 0);
let height = TABLE_BORDER * 2;
for (let row of rows) {
let h = lineHeight + CELL_PAD_Y * 2;
for (let i = 0; i < row.cells.length; i++) {
let cell = row.cells[i]!;
let m = measureLines(cell.lines, TABLE_WIDTH, lineHeight);
widths[i] = Math.max(widths[i]!, m.fit);
h = Math.max(h, m.height + CELL_PAD_Y * 2);
}
height += h;
}
let fit = widths.reduce(
(sum, width) => sum + Math.ceil(width) + CELL_PAD_X * 2,
TABLE_BORDER * 2,
);
if (cols > 1) fit += cols - 1;
return { height, fit };
}
function measureBlocks(
blocks: BlockNode[],
width: number,
lineHeight: number,
): { height: number; fit: number } {
let h = 0, f = 0;
for (let node of blocks) {
let m = measureNode(node, width, lineHeight);
h += m.height;
if (m.fit > f) f = m.fit;
}
if (blocks.length > 1) h += (blocks.length - 1) * BLOCK_GAP;
return { height: h, fit: Math.ceil(f) };
}
/** Render inline nodes for a single line. */
function LineView({ line, fonts }: { line: Line; fonts: FontConfig }) {
let es = emojiStyle(fonts);
let cs = codeStyle(fonts);
return (
<>
{line.nodes.map((node, i) => {
let key = inline(node, i);
switch (node.kind) {
case "mention":
return (
@
{node.avatar && (
)}
{node.name}
);
case "emoji":
return node.src
?
: :{node.name}:;
case "document":
return
{node.text};
case "link":
return (
{node.text}
);
case "text": {
let el: ReactNode = node.text;
if (node.strike) {
el = {el};
}
if (node.underline) {
el = {el};
}
if (node.italic) {
el = {el};
}
if (node.bold) {
el = {el};
}
return
`. */
function LinesView({ lines, fonts }: { lines: Line[]; fonts: FontConfig }) {
return (
<>
{lines.map((line, i) => (
}
{el};
if (source.underline) el = {el};
if (source.italic) el = {el};
if (source.bold) el = {el};
return el;
}
function LineStream({ line, fonts, path, track }: {
line: Line;
fonts: FontConfig;
path: string;
track: Track;
}) {
let es = emojiStyle(fonts);
let cs = codeStyle(fonts);
return (
<>
{line.nodes.map((node, i) => {
let key = pathid(path, i);
switch (node.kind) {
case "mention":
return animate(
@
{node.avatar && (
)}
{node.name}
,
key,
fresh(track, "@" + node.name),
);
case "emoji":
return animate(
node.src
?
: :{node.name}:,
key,
fresh(track, ":" + node.name + ":"),
);
case "document":
return animate(
{pieces(node.text).map((part, j) =>
animate(part, pathid(key, j), fresh(track, part))
)}
);
case "link":
return (
{pieces(node.text).map((part, j) =>
animate(part, pathid(key, j), fresh(track, part))
)}
);
case "text":
return (
}
{node.children.map((child, i) => (); case "table": return))}
{node.children.map((child, i) => (); case "table": return))}
|
|
)
: (
|
);
})}
|---|