();
let graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
/**
* Pretext lays out each item's whitespace-collapsed text, which `prepareWithSegments` reproduces
* segment for segment; map that text back onto the source once per item.
*/
function source(item: RichInlineItem): Source {
let hit = sourceCache.get(item);
if (hit) return hit;
let { segments } = prepareWithSegments(item.text, item.font);
let starts: number[] = [];
let length = 0;
for (let segment of segments) {
starts.push(length);
length += segment.length;
}
let text = item.text;
let offsets: number[] = [];
let at = 0;
while (at < text.length && COLLAPSIBLE.test(text[at]!)) at++;
for (let n = 0; n <= length; n++) {
offsets.push(at);
if (!COLLAPSIBLE.test(text[at] || "")) at++;
else while (at < text.length && COLLAPSIBLE.test(text[at]!)) at++;
}
let value = { segments, starts, offsets };
sourceCache.set(item, value);
return value;
}
function sourceOffset(item: RichInlineItem, cursor: LayoutCursor): number {
let { segments, starts, offsets } = source(item);
let target = starts[cursor.segmentIndex]!;
if (cursor.graphemeIndex > 0) {
let n = 0;
for (let { segment } of graphemes.segment(segments[cursor.segmentIndex]!)) {
if (n++ === cursor.graphemeIndex) break;
target += segment.length;
}
}
return offsets[target]!;
}
/**
* Where Pretext wraps `line` at `width`. The browser's own line breaker and text shaping disagree
* with Pretext at some boundaries (`body/meta`, link text, subpixel drift), and the block height is
* Pretext's, so rendering forces Pretext's breaks instead of letting the browser choose its own.
*/
function wrapsAt(line: Line, width: number): Wraps {
if (!line.prepared) return NO_WRAPS;
let hit = wrapCache.get(line);
if (hit?.width === width) return hit.wraps;
let wraps: Wraps = { breaks: new Map(), wide: new Set() };
let first = true;
walkRichInlineLineRanges(line.prepared, width, range => {
if (range.width > width) {
for (let fragment of range.fragments) {
if (fragment.occupiedWidth > width) wraps.wide.add(fragment.itemIndex);
}
}
if (first) {
first = false;
return;
}
let { itemIndex, start } = range.fragments[0]!;
let at = sourceOffset(line.items[itemIndex]!, start);
let list = wraps.breaks.get(itemIndex);
if (list) list.push(at);
else wraps.breaks.set(itemIndex, [at]);
});
wrapCache.set(line, { width, wraps });
return wraps;
}
/** Split `text` at wrap offsets, joining the pieces with forced line breaks. */
function wrapText(text: string, offsets: number[] | undefined, key: string): ReactNode {
if (!offsets) return text;
let out: ReactNode[] = [];
let last = 0;
for (let at of offsets) {
if (at > last) out.push(text.slice(last, at));
out.push();
last = at;
}
if (last < text.length) out.push(text.slice(last));
return out;
}
/** A line break that is drawn but never copied, so selected text keeps its original spacing. */
function Wrap() {
return ;
}
/** 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, wrapped where Pretext wraps it at `width`. */
function LineView({ line, fonts, width }: { line: Line; fonts: FontConfig; width?: number }) {
let es = emojiStyle(fonts);
let cs = codeStyle(fonts);
let wraps = width === undefined ? NO_WRAPS : wrapsAt(line, width);
return (
<>
{line.nodes.map((node, i) => {
let key = inline(node, i);
let at = wraps.breaks.get(i);
let el: ReactNode;
switch (node.kind) {
case "mention":
el = (
@
{node.avatar && (
)}
{node.name}
);
break;
case "emoji":
el = node.src
?
: :{node.name}:;
break;
case "document":
el = ;
break;
case "plan":
el = ;
break;
case "code":
el = {node.text};
break;
case "link":
return (
{wrapText(node.text, at, key)}
);
case "text":
return {adorn(wrapText(node.text, at, key), node)};
}
return (
{at && }
{wraps.wide.has(i) ? {el} : el}
);
})}
>
);
}
/** Render multiple lines within a block, joined by `
`. */
function LinesView({ lines, fonts, width }: { lines: Line[]; fonts: FontConfig; width?: number }) {
return (
<>
{lines.map((line, i) => (
{i > 0 &&
}
))}
>
);
}
function boxStyle(fonts: FontConfig, height: number): CSSProperties {
return {
blockSize: height,
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
gap: BLOCK_GAP,
font: fonts.font,
lineHeight: `${fonts.lineHeight}px`,
// Lines break only at Pretext's wraps, so the browser can't add a line the height lacks.
// Subpixel shaping drift may paint into the bubble padding instead of being cut off.
whiteSpace: "nowrap",
overflowX: "visible",
overflowY: "clip",
};
}
function animate(node: ReactNode, key: string, active: boolean) {
return (
{node}
);
}
function adorn(node: ReactNode, source: Line["nodes"][number]) {
let el = node;
if (source.kind !== "text") return el;
if (source.strike) el = {el};
if (source.underline) el = {el};
if (source.italic) el = {el};
if (source.bold) el = {el};
return el;
}
/** Stream tokens of `text`, with `null` where a wrap offset forces a line break. */
function streamParts(text: string, offsets: number[] = []): (string | null)[] {
let out: (string | null)[] = [];
let at = 0;
let k = 0;
for (let part of pieces(text)) {
let end = at + part.length;
while (k < offsets.length && offsets[k]! < end) {
let cut = offsets[k++]! - at;
if (cut > 0) {
out.push(part.slice(0, cut));
part = part.slice(cut);
at += cut;
}
out.push(null);
}
out.push(part);
at = end;
}
return out;
}
function LineStream({ line, fonts, path, track, width }: {
line: Line;
fonts: FontConfig;
path: string;
track: Track;
width: number;
}) {
let es = emojiStyle(fonts);
let cs = codeStyle(fonts);
let wraps = wrapsAt(line, width);
return (
<>
{line.nodes.map((node, i) => {
let key = pathid(path, i);
let at = wraps.breaks.get(i);
let parts = (adorned: boolean) =>
streamParts(node.kind === "link" || node.kind === "text" ? node.text : "", at).map(
(part, j) =>
part === null
?
: animate(
adorned ? adorn(part, node) : part,
pathid(key, j),
fresh(track, part),
),
);
let el: ReactNode;
switch (node.kind) {
case "mention":
el = animate(
@
{node.avatar && (
)}
{node.name}
,
key,
fresh(track, "@" + node.name),
);
break;
case "emoji":
el = animate(
node.src
?
: :{node.name}:,
key,
fresh(track, ":" + node.name + ":"),
);
break;
case "document":
el = animate(
,
key,
fresh(track, "&" + node.reference),
);
break;
case "plan":
el = animate(
,
key,
fresh(track, node.reference),
);
break;
case "code":
el = (
{pieces(node.text).map((part, j) =>
animate(part, pathid(key, j), fresh(track, part))
)}
);
break;
case "link":
return (
{parts(false)}
);
case "text":
return {parts(true)};
}
return (
{at && }
{wraps.wide.has(i) ? {el} : el}
);
})}
>
);
}
function LinesStream({ lines, fonts, path, track, width }: {
lines: Line[];
fonts: FontConfig;
path: string;
track: Track;
width: number;
}) {
return (
<>
{lines.map((line, i) => (
{i > 0 &&
}
))}
>
);
}
function NodeStream({ node, fonts, path, track, width }: {
node: BlockNode;
fonts: FontConfig;
path: string;
track: Track;
width: number;
}) {
switch (node.kind) {
case "paragraph":
return (
);
case "heading":
return (
);
case "list": {
let inner = Math.max(0, width - LIST_INDENT);
let items = node.items.map((item, i) => (
{item.map((child, j) => (
))}
));
return node.ordered ? {items}
: ;
}
case "blockquote":
let inner = Math.max(0, width - QUOTE_INDENT - QUOTE_BORDER);
return (
{node.children.map((child, i) => (
))}
);
case "table":
return ;
case "image":
return ;
case "hr":
return
;
}
}
function Stream({ blocks, fonts, compact, raw, active, height, width }: {
blocks: BlockNode[];
fonts: FontConfig;
compact: boolean;
raw: string;
active: boolean;
height: number;
width: number;
}) {
let edge = useRef(raw.length);
let track = { raw, edge: edge.current, cursor: 0, active };
let cls = compact ? "content measured thinking streaming" : "content measured streaming";
if (active) cls += " streaming-live";
useEffect(() => {
edge.current = raw.length;
}, [raw]);
return (
{blocks.map((node, i) => (
))}
);
}
function TextView({ blocks, fonts, compact, raw, streaming, height, width }: {
blocks: BlockNode[];
fonts: FontConfig;
compact: boolean;
raw: string;
streaming: boolean;
height: number;
width: number;
}) {
let [live, set] = useState(false);
useEffect(() => {
if (streaming) {
set(true);
return;
}
if (!live) return;
let id = setTimeout(() => set(false));
return () => clearTimeout(id);
}, [streaming, live]);
let active = streaming || live;
if (active) {
return (
);
}
return (
{blocks.map((node, i) => (
))}
);
}
/** Render a block node as semantic HTML. */
function NodeView({ node, fonts, width }: { node: BlockNode; fonts: FontConfig; width: number }) {
switch (node.kind) {
case "paragraph":
return (
);
case "heading":
return (
);
case "list": {
let inner = Math.max(0, width - LIST_INDENT);
let items = node.items.map((item, i) => (
{item.map((child, j) => (
))}
));
return node.ordered ? {items}
: ;
}
case "blockquote":
let inner = Math.max(0, width - QUOTE_INDENT - QUOTE_BORDER);
return (
{node.children.map((child, i) => (
))}
);
case "table":
return ;
case "image":
return ;
case "hr":
return
;
}
}
function ImageNodeView(
{ node, width }: { node: Extract; width: number },
) {
return imageBlock(node.src, 0, 0, node.alt).render(width);
}
function TableView({ rows, fonts }: { rows: TableRow[]; fonts: FontConfig }) {
let height = measureTable(rows, fonts.lineHeight).height;
return (
{rows.map((row, i) => (
{row.cells.map((cell, j) => {
let style = {
paddingBlock: CELL_PAD_Y,
paddingInline: CELL_PAD_X,
textAlign: cell.align,
};
return cell.header
? (
|
|
)
: (
|
);
})}
))}
);
}
/** Rich text block with full markdown support (paragraphs, lists, quotes, inline formatting). */
function text(
raw: string,
mentions?: Mention[],
compact = false,
streaming = false,
parsed?: BlockNode[],
plan = false,
): Block {
let fonts = blockFonts(compact);
let blocks = parsed || fallback(raw, mentions, fonts, plan);
let sizes = new Map();
let size = (width: number) => {
let hit = sizes.get(width);
if (hit) return hit;
let value = measureBlocks(blocks, width, fonts.lineHeight);
sizes.set(width, value);
return value;
};
let block: Block & { streaming: boolean } = {
streaming,
measure(width) {
return size(width);
},
render(width) {
let height = size(width).height;
return (
);
},
};
return block;
}
export { text };