import { marked, type Token, type Tokens } from 'marked' import { layoutWithLines, measureLineStats, measureNaturalWidth, prepareWithSegments, type LayoutLine, type PreparedTextWithSegments, } from '../../src/layout.ts' import { materializeRichInlineLineRange, measureRichInlineStats, prepareRichInline, walkRichInlineLineRanges, type PreparedRichInline, } from '../../src/rich-inline.ts' import { BASE_MESSAGE_SPECS, type MarkdownChatSeed } from './markdown-chat.data.ts' export const MIN_CHAT_WIDTH = 360 export const DEFAULT_CHAT_WIDTH = 640 export const MAX_CHAT_WIDTH = 860 export const TOTAL_MESSAGE_COUNT = 10_000 export const CHAT_VIEWPORT_HEIGHT = 560 export const OCCLUSION_BANNER_HEIGHT = 61 export const PAGE_MARGIN = 28 export const MESSAGE_SIDE_PADDING = 22 const COMPACT_OCCLUSION_BANNER_HEIGHT = 43 const COMPACT_OCCLUSION_VIEWPORT_HEIGHT = 460 const CHAT_TOP_PADDING_OFFSET = 14 const CHAT_BOTTOM_PADDING_OFFSET = 10 const MESSAGE_GAP = 12 const BUBBLE_MAX_RATIO = 0.78 export const BUBBLE_PADDING_X = 16 const BUBBLE_PADDING_Y = 10 const BODY_LINE_HEIGHT = 22 const HEADING_ONE_LINE_HEIGHT = 28 const HEADING_TWO_LINE_HEIGHT = 25 const HARD_BREAK_GAP = 4 const BLOCK_GAP = 12 const RICH_BLOCK_GAP = 2 const LIST_ITEM_GAP = 4 const LIST_NESTING_INDENT = 18 const BLOCKQUOTE_INDENT = 18 const LIST_MARKER_GAP = 10 export const CODE_LINE_HEIGHT = 18 export const CODE_BLOCK_PADDING_X = 12 export const CODE_BLOCK_PADDING_Y = 8 const RULE_HEIGHT = 18 const RAIL_OFFSET = 5 const SANS_FAMILY = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' const SERIF_FAMILY = '"Iowan Old Style", Georgia, "Times New Roman", serif' const MONO_FAMILY = '"SF Mono", ui-monospace, Menlo, Monaco, monospace' const INLINE_CODE_FONT = `600 12px ${MONO_FAMILY}` const INLINE_CODE_EXTRA_WIDTH = 12 const IMAGE_FONT = `700 11px ${SANS_FAMILY}` const IMAGE_EXTRA_WIDTH = 14 const MARKER_FONT = `600 11px ${MONO_FAMILY}` type InlineVariant = 'body' | 'heading-1' | 'heading-2' type MarkState = { bold: boolean italic: boolean strike: boolean href: string | null } type ParseContext = { listDepth: number quoteDepth: number } type InlinePiece = { breakMode: 'normal' | 'never' className: string extraWidth: number font: string href: string | null text: string } type PreparedBlockBase = { contentLeft: number marginTop: number markerClassName: string | null markerLeft: number | null markerText: string | null quoteRailLefts: number[] } type PreparedInlineBlock = PreparedBlockBase & { kind: 'inline' classNames: string[] flow: PreparedRichInline hrefs: Array lineHeight: number } type PreparedCodeBlock = PreparedBlockBase & { kind: 'code' lineHeight: number prepared: PreparedTextWithSegments } type PreparedRuleBlock = PreparedBlockBase & { kind: 'rule' height: number } type PreparedBlock = PreparedInlineBlock | PreparedCodeBlock | PreparedRuleBlock export type PreparedChatTemplate = { blocks: PreparedBlock[] role: 'assistant' | 'user' } export type InlineFragmentLayout = { className: string href: string | null leadingGap: number text: string } type BlockFrameBase = { contentLeft: number height: number markerClassName: string | null markerLeft: number | null markerText: string | null quoteRailLefts: number[] top: number } type InlineBlockFrame = BlockFrameBase & { kind: 'inline' lineHeight: number usedWidth: number } type CodeBlockFrame = BlockFrameBase & { kind: 'code' lineHeight: number width: number } type RuleBlockFrame = BlockFrameBase & { kind: 'rule' width: number } type BlockFrame = InlineBlockFrame | CodeBlockFrame | RuleBlockFrame type InlineBlockLayout = { contentLeft: number height: number kind: 'inline' lineHeight: number lines: Array<{ fragments: InlineFragmentLayout[] width: number }> markerClassName: string | null markerLeft: number | null markerText: string | null quoteRailLefts: number[] top: number usedWidth: number } type CodeBlockLayout = { contentLeft: number height: number kind: 'code' lines: LayoutLine[] markerClassName: string | null markerLeft: number | null markerText: string | null quoteRailLefts: number[] top: number usedWidth: number width: number } type RuleBlockLayout = { contentLeft: number height: number kind: 'rule' markerClassName: string | null markerLeft: number | null markerText: string | null quoteRailLefts: number[] top: number width: number } export type BlockLayout = InlineBlockLayout | CodeBlockLayout | RuleBlockLayout export type TemplateFrame = { blocks: BlockFrame[] bubbleHeight: number contentInsetX: number frameWidth: number layoutContentWidth: number role: 'assistant' | 'user' totalHeight: number } export type ChatMessageInstance = { bottom: number prepared: PreparedChatTemplate frame: TemplateFrame top: number } export type ConversationFrame = { chatWidth: number messages: ChatMessageInstance[] occlusionBannerHeight: number totalHeight: number } const EMPTY_MARK_STATE: MarkState = { bold: false, italic: false, strike: false, href: null, } function parseMarkdownHref(href: string | null | undefined): string | null { if (href === undefined || href === null) return null try { const url = new URL(href) return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null } catch { return null } } const markerWidthCache = new Map() export function createPreparedChatTemplates( specs: readonly MarkdownChatSeed[] = BASE_MESSAGE_SPECS, ): PreparedChatTemplate[] { return specs.map(spec => ({ blocks: parseMarkdownBlocks(spec.markdown), role: spec.role, })) } export function getMaxChatWidth(viewportWidth: number): number { return Math.max(240, Math.min(MAX_CHAT_WIDTH, viewportWidth - PAGE_MARGIN * 2)) } export function buildConversationFrame( templates: readonly PreparedChatTemplate[], chatWidth: number, occlusionBannerHeight: number = OCCLUSION_BANNER_HEIGHT, ): ConversationFrame { const messageCount = TOTAL_MESSAGE_COUNT const laneWidth = Math.max(120, chatWidth - MESSAGE_SIDE_PADDING * 2) const userFrameWidth = Math.min(laneWidth, Math.max(240, Math.floor(chatWidth * BUBBLE_MAX_RATIO))) const assistantFrameWidth = laneWidth const messages: ChatMessageInstance[] = new Array(messageCount) const chatTopPadding = occlusionBannerHeight + CHAT_TOP_PADDING_OFFSET const chatBottomPadding = occlusionBannerHeight + CHAT_BOTTOM_PADDING_OFFSET let y = chatTopPadding for (let ordinal = 0; ordinal < messageCount; ordinal++) { const templateIndex = ordinal % templates.length const template = templates[templateIndex]! const contentInsetX = template.role === 'assistant' ? 0 : BUBBLE_PADDING_X const frameWidth = template.role === 'assistant' ? assistantFrameWidth : userFrameWidth const contentWidth = Math.max(120, frameWidth - contentInsetX * 2) const messageFrame = layoutTemplateFrame(template, frameWidth, contentWidth, contentInsetX) const top = y const bottom = top + messageFrame.totalHeight messages[ordinal] = { bottom, frame: messageFrame, prepared: template, top, } y = bottom y += MESSAGE_GAP } const totalHeight = messages.length === 0 ? chatTopPadding + chatBottomPadding : y - MESSAGE_GAP + chatBottomPadding return { chatWidth, messages, occlusionBannerHeight, totalHeight, } } export function getOcclusionBannerHeight(viewportHeight: number): number { return viewportHeight <= COMPACT_OCCLUSION_VIEWPORT_HEIGHT ? COMPACT_OCCLUSION_BANNER_HEIGHT : OCCLUSION_BANNER_HEIGHT } export function findVisibleRange( frame: ConversationFrame, scrollTop: number, viewportHeight: number, topOcclusionHeight: number, bottomOcclusionHeight: number, ): { end: number start: number } { if (frame.messages.length === 0) return { start: 0, end: 0 } const minY = Math.max(0, scrollTop + topOcclusionHeight) const maxY = Math.max(minY, scrollTop + viewportHeight - bottomOcclusionHeight) let low = 0 let high = frame.messages.length while (low < high) { const mid = (low + high) >> 1 if (frame.messages[mid]!.bottom > minY) { high = mid } else { low = mid + 1 } } const start = low low = start high = frame.messages.length while (low < high) { const mid = (low + high) >> 1 if (frame.messages[mid]!.top >= maxY) { high = mid } else { low = mid + 1 } } return { start, end: low } } export function formatPixelCount(value: number): string { return `${Math.round(value).toLocaleString()}px` } function parseMarkdownBlocks(markdown: string): PreparedBlock[] { const tokens = marked.lexer(markdown, { gfm: true }) return parseBlockTokens(tokens, { listDepth: 0, quoteDepth: 0 }) } function parseBlockTokens(tokens: readonly Token[], ctx: ParseContext): PreparedBlock[] { const blocks: PreparedBlock[] = [] for (let index = 0; index < tokens.length; index++) { const token = tokens[index]! switch (token.type) { case 'space': case 'def': { continue } case 'paragraph': { appendBlockGroup(blocks, buildInlineBlocks(token.tokens ?? [], 'body', ctx), BLOCK_GAP) continue } case 'heading': { appendBlockGroup( blocks, buildInlineBlocks(token.tokens ?? [], headingVariant(token.depth), ctx), BLOCK_GAP + 4, ) continue } case 'code': { appendBlockGroup(blocks, [buildCodeBlock(token.text, ctx)], RICH_BLOCK_GAP) continue } case 'list': { appendBlockGroup(blocks, buildListBlocks(token as Tokens.List, ctx), BLOCK_GAP) continue } case 'blockquote': { appendBlockGroup( blocks, parseBlockTokens(token.tokens ?? [], { listDepth: ctx.listDepth, quoteDepth: ctx.quoteDepth + 1, }), RICH_BLOCK_GAP, ) continue } case 'hr': { appendBlockGroup(blocks, [buildRuleBlock(ctx)], BLOCK_GAP + 2) continue } case 'table': { appendBlockGroup(blocks, [buildCodeBlock(formatTable(token as Tokens.Table), ctx)], RICH_BLOCK_GAP) continue } case 'html': { const htmlText = token.text.trim().length > 0 ? token.text : token.raw const isPre = 'pre' in token && token.pre === true if (token.block || isPre) { appendBlockGroup(blocks, [buildCodeBlock(htmlText, ctx)], RICH_BLOCK_GAP) } else { appendBlockGroup(blocks, buildPlainTextBlocks(htmlText, 'body', ctx), BLOCK_GAP) } continue } case 'text': { if (Array.isArray(token.tokens) && token.tokens.length > 0) { appendBlockGroup(blocks, buildInlineBlocks(token.tokens, 'body', ctx), BLOCK_GAP) } else { appendBlockGroup(blocks, buildPlainTextBlocks(token.text, 'body', ctx), BLOCK_GAP) } continue } default: { const fallbackText = fallbackTextForToken(token) if (fallbackText.length > 0) { appendBlockGroup(blocks, buildPlainTextBlocks(fallbackText, 'body', ctx), BLOCK_GAP) } } } } return blocks } function buildListBlocks(token: Tokens.List, ctx: ParseContext): PreparedBlock[] { const blocks: PreparedBlock[] = [] const itemCtx: ParseContext = { listDepth: ctx.listDepth + 1, quoteDepth: ctx.quoteDepth, } for (let index = 0; index < token.items.length; index++) { const item = token.items[index]! let itemBlocks = parseBlockTokens(item.tokens, itemCtx) if (itemBlocks.length === 0) { itemBlocks = buildPlainTextBlocks(item.text, 'body', itemCtx) } decorateListItemBlocks(itemBlocks, resolveListMarkerText(token, item, index), resolveListMarkerClassName(token, item)) appendBlockGroup(blocks, itemBlocks, LIST_ITEM_GAP) } return blocks } function decorateListItemBlocks( blocks: PreparedBlock[], markerText: string, markerClassName: string, ): void { if (blocks.length === 0) return const markerArea = measureMarkerWidth(markerText) + LIST_MARKER_GAP for (let index = 0; index < blocks.length; index++) { blocks[index] = shiftBlock(blocks[index]!, markerArea) } const firstBlock = blocks[0]! blocks[0] = { ...firstBlock, markerClassName, markerLeft: firstBlock.contentLeft - markerArea, markerText, } satisfies PreparedBlock } function buildPlainTextBlocks( text: string, variant: InlineVariant, ctx: ParseContext, ): PreparedBlock[] { const piece = createTextPiece(text, EMPTY_MARK_STATE, variant) if (piece === null) return [] return buildPreparedInlineBlocks([[piece]], variant, ctx) } function buildInlineBlocks( tokens: readonly Token[], variant: InlineVariant, ctx: ParseContext, ): PreparedBlock[] { const lines = collectInlinePieceLines(tokens, variant) return buildPreparedInlineBlocks(lines, variant, ctx) } function buildPreparedInlineBlocks( lines: InlinePiece[][], variant: InlineVariant, ctx: ParseContext, ): PreparedBlock[] { const blocks: PreparedBlock[] = [] for (let index = 0; index < lines.length; index++) { const block = buildPreparedInlineBlock(lines[index]!, variant, ctx) if (block === null) continue blocks.push({ ...block, marginTop: blocks.length === 0 ? 0 : HARD_BREAK_GAP, } satisfies PreparedBlock) } return blocks } function buildPreparedInlineBlock( pieces: InlinePiece[], variant: InlineVariant, ctx: ParseContext, ): PreparedInlineBlock | null { if (pieces.length === 0) return null return { ...createBlockBase(ctx), classNames: pieces.map(piece => piece.className), flow: prepareRichInline(pieces.map(piece => ({ text: piece.text, font: piece.font, break: piece.breakMode, extraWidth: piece.extraWidth, }))), hrefs: pieces.map(piece => piece.href), kind: 'inline', lineHeight: lineHeightForVariant(variant), } } function buildCodeBlock(text: string, ctx: ParseContext): PreparedCodeBlock { return { ...createBlockBase(ctx), kind: 'code', lineHeight: CODE_LINE_HEIGHT, prepared: prepareWithSegments(stripSingleTrailingNewline(text), `500 12px ${MONO_FAMILY}`, { whiteSpace: 'pre-wrap', }), } } function buildRuleBlock(ctx: ParseContext): PreparedRuleBlock { return { ...createBlockBase(ctx), height: RULE_HEIGHT, kind: 'rule', } } function createBlockBase(ctx: ParseContext): PreparedBlockBase { const listIndent = Math.max(0, ctx.listDepth - 1) * LIST_NESTING_INDENT const contentLeft = listIndent + ctx.quoteDepth * BLOCKQUOTE_INDENT const quoteRailLefts: number[] = [] for (let depth = 0; depth < ctx.quoteDepth; depth++) { quoteRailLefts.push(listIndent + depth * BLOCKQUOTE_INDENT + RAIL_OFFSET) } return { contentLeft, marginTop: 0, markerClassName: null, markerLeft: null, markerText: null, quoteRailLefts, } } function collectInlinePieceLines( tokens: readonly Token[], variant: InlineVariant, ): InlinePiece[][] { const lines: InlinePiece[][] = [[]] function currentLine(): InlinePiece[] { return lines[lines.length - 1]! } function pushLineBreak(): void { lines.push([]) } function pushPiece(piece: InlinePiece | null): void { if (piece === null) return const line = currentLine() const previous = line[line.length - 1] if (previous !== undefined && canMergeInlinePieces(previous, piece)) { previous.text += piece.text return } line.push(piece) } function walk(tokenList: readonly Token[], marks: MarkState): void { for (let index = 0; index < tokenList.length; index++) { const token = tokenList[index]! switch (token.type) { case 'text': { if (Array.isArray(token.tokens) && token.tokens.length > 0) { walk(token.tokens, marks) } else { pushPiece(createTextPiece(token.text, marks, variant)) } continue } case 'escape': { pushPiece(createTextPiece(token.text, marks, variant)) continue } case 'strong': { walk(token.tokens ?? [], { ...marks, bold: true }) continue } case 'em': { walk(token.tokens ?? [], { ...marks, italic: true }) continue } case 'del': { walk(token.tokens ?? [], { ...marks, strike: true }) continue } case 'codespan': { pushPiece(createCodePiece(token.text)) continue } case 'link': { walk(token.tokens ?? [], { ...marks, href: parseMarkdownHref(token.href) }) continue } case 'image': { pushPiece(createImagePiece(token.text.length > 0 ? token.text : token.href)) continue } case 'br': { pushLineBreak() continue } case 'checkbox': { pushPiece(createTextPiece(token.checked ? '[x] ' : '[ ] ', marks, variant)) continue } case 'html': { pushPiece(createTextPiece(token.text, marks, variant)) continue } default: { const fallback = fallbackTextForToken(token) if (fallback.length > 0) { pushPiece(createTextPiece(fallback, marks, variant)) } } } } } walk(tokens, EMPTY_MARK_STATE) while (lines.length > 0 && lines[lines.length - 1]!.length === 0) { lines.pop() } return lines } function createTextPiece( text: string, marks: MarkState, variant: InlineVariant, ): InlinePiece | null { if (text.length === 0) return null return { breakMode: 'normal', className: resolveTextClassName(variant, marks), extraWidth: 0, font: resolveTextFont(variant, marks), href: marks.href, text, } } function createCodePiece(text: string): InlinePiece | null { if (text.length === 0) return null return { breakMode: 'normal', className: 'frag frag--code', extraWidth: INLINE_CODE_EXTRA_WIDTH, font: INLINE_CODE_FONT, href: null, text, } } function createImagePiece(text: string): InlinePiece { return { breakMode: 'never', className: 'frag frag--chip', extraWidth: IMAGE_EXTRA_WIDTH, font: IMAGE_FONT, href: null, text: text.length > 0 ? text : 'image', } } function canMergeInlinePieces(a: InlinePiece, b: InlinePiece): boolean { return ( a.breakMode === b.breakMode && a.className === b.className && a.extraWidth === b.extraWidth && a.font === b.font && a.href === b.href ) } function resolveTextFont(variant: InlineVariant, marks: MarkState): string { const italicPrefix = marks.italic ? 'italic ' : '' switch (variant) { case 'heading-1': { const weight = marks.bold ? 800 : 700 return `${italicPrefix}${weight} 20px ${SERIF_FAMILY}` } case 'heading-2': { const weight = marks.bold ? 800 : 700 return `${italicPrefix}${weight} 17px ${SERIF_FAMILY}` } case 'body': { const weight = marks.bold ? 700 : marks.href === null ? 400 : 500 return `${italicPrefix}${weight} 14px ${SANS_FAMILY}` } } } function resolveTextClassName(variant: InlineVariant, marks: MarkState): string { let className = 'frag' switch (variant) { case 'heading-1': className += ' frag--heading-1' break case 'heading-2': className += ' frag--heading-2' break case 'body': className += ' frag--body' break } if (marks.href !== null) className += ' is-link' if (marks.bold) className += ' is-strong' if (marks.italic) className += ' is-em' if (marks.strike) className += ' is-del' return className } function headingVariant(depth: number): InlineVariant { if (depth <= 1) return 'heading-1' if (depth === 2) return 'heading-2' return 'body' } function lineHeightForVariant(variant: InlineVariant): number { switch (variant) { case 'heading-1': return HEADING_ONE_LINE_HEIGHT case 'heading-2': return HEADING_TWO_LINE_HEIGHT case 'body': return BODY_LINE_HEIGHT } } function appendBlockGroup( target: PreparedBlock[], group: PreparedBlock[], firstMargin: number, ): void { if (group.length === 0) return for (let index = 0; index < group.length; index++) { const block = group[index]! target.push({ ...block, marginTop: index === 0 ? (target.length === 0 ? 0 : firstMargin) : block.marginTop, } satisfies PreparedBlock) } } function shiftBlock(block: PreparedBlock, delta: number): PreparedBlock { return { ...block, contentLeft: block.contentLeft + delta, } satisfies PreparedBlock } function resolveListMarkerText( list: Tokens.List, item: Tokens.ListItem, index: number, ): string { if (item.task) return item.checked ? '☑' : '☐' if (list.ordered) { const start = typeof list.start === 'number' ? list.start : 1 return `${start + index}.` } return '•' } function resolveListMarkerClassName( list: Tokens.List, item: Tokens.ListItem, ): string { if (item.task) return 'block-marker block-marker--task' return list.ordered ? 'block-marker block-marker--ordered' : 'block-marker block-marker--bullet' } function measureMarkerWidth(text: string): number { const cached = markerWidthCache.get(text) if (cached !== undefined) return cached const width = measureNaturalWidth(prepareWithSegments(text, MARKER_FONT)) markerWidthCache.set(text, width) return width } function fallbackTextForToken(token: Token): string { if ('text' in token && typeof token.text === 'string') return token.text return token.raw ?? '' } function formatTable(token: Tokens.Table): string { const header = token.header.map(cell => inlineTokensToPlainText(cell.tokens)).join(' | ') const divider = token.header.map(() => '---').join(' | ') const rows = token.rows.map(row => row.map(cell => inlineTokensToPlainText(cell.tokens)).join(' | ')) return [header, divider, ...rows].join('\n') } function inlineTokensToPlainText(tokens: readonly Token[]): string { let text = '' for (let index = 0; index < tokens.length; index++) { const token = tokens[index]! switch (token.type) { case 'strong': case 'em': case 'del': case 'link': text += inlineTokensToPlainText(token.tokens ?? []) break case 'codespan': case 'escape': case 'text': case 'html': text += token.text break case 'br': text += '\n' break case 'image': text += token.text break default: text += fallbackTextForToken(token) } } return text } function stripSingleTrailingNewline(text: string): string { return text.endsWith('\n') ? text.slice(0, -1) : text } function layoutTemplateFrame( template: PreparedChatTemplate, maxFrameWidth: number, maxContentWidth: number, contentInsetX: number, ): TemplateFrame { let y = BUBBLE_PADDING_Y const blocks: BlockFrame[] = [] let usedContentWidth = 0 for (let index = 0; index < template.blocks.length; index++) { const block = template.blocks[index]! y += block.marginTop const blockFrame = layoutBlockFrame(block, maxContentWidth, y) blocks.push(blockFrame) y += blockFrame.height usedContentWidth = Math.max(usedContentWidth, getUsedBlockWidth(blockFrame)) } const bubbleHeight = y + BUBBLE_PADDING_Y const frameWidth = template.role === 'assistant' ? maxFrameWidth : Math.min(maxFrameWidth, contentInsetX * 2 + Math.max(1, usedContentWidth)) return { blocks, bubbleHeight, contentInsetX, frameWidth, layoutContentWidth: maxContentWidth, role: template.role, totalHeight: bubbleHeight, } } function layoutBlockFrame( block: PreparedBlock, contentWidth: number, top: number, ): BlockFrame { switch (block.kind) { case 'inline': { const lineWidth = Math.max(1, contentWidth - block.contentLeft) const { lineCount, maxLineWidth } = measureRichInlineStats(block.flow, lineWidth) return { contentLeft: block.contentLeft, height: lineCount * block.lineHeight, kind: 'inline', lineHeight: block.lineHeight, markerClassName: block.markerClassName, markerLeft: block.markerLeft, markerText: block.markerText, quoteRailLefts: block.quoteRailLefts, top, usedWidth: maxLineWidth, } } case 'code': { const boxWidth = Math.max(1, contentWidth - block.contentLeft) const innerWidth = Math.max(1, boxWidth - CODE_BLOCK_PADDING_X * 2) const { lineCount, maxLineWidth } = measureLineStats(block.prepared, innerWidth) return { contentLeft: block.contentLeft, height: lineCount * block.lineHeight + CODE_BLOCK_PADDING_Y * 2, kind: 'code', lineHeight: block.lineHeight, markerClassName: block.markerClassName, markerLeft: block.markerLeft, markerText: block.markerText, quoteRailLefts: block.quoteRailLefts, top, width: maxLineWidth + CODE_BLOCK_PADDING_X * 2, } } case 'rule': { return { contentLeft: block.contentLeft, height: block.height, kind: 'rule', markerClassName: block.markerClassName, markerLeft: block.markerLeft, markerText: block.markerText, quoteRailLefts: block.quoteRailLefts, top, width: Math.max(1, contentWidth - block.contentLeft), } } } } function getUsedBlockWidth(block: BlockFrame): number { switch (block.kind) { case 'inline': return block.contentLeft + block.usedWidth case 'code': return block.contentLeft + block.width case 'rule': return block.contentLeft + block.width } } export function materializeTemplateBlocks(message: ChatMessageInstance): BlockLayout[] { return message.prepared.blocks.map((block, index) => materializeBlockLayout(block, message.frame.blocks[index]!, message.frame.layoutContentWidth), ) } function materializeBlockLayout( block: PreparedBlock, frame: BlockFrame, contentWidth: number, ): BlockLayout { switch (frame.kind) { case 'inline': { if (block.kind !== 'inline') throw new Error('Inline block/frame mismatch') const lineWidth = Math.max(1, contentWidth - frame.contentLeft) const lines: Array<{ fragments: InlineFragmentLayout[]; width: number }> = [] walkRichInlineLineRanges(block.flow, lineWidth, range => { const line = materializeRichInlineLineRange(block.flow, range) lines.push({ fragments: line.fragments.map(fragment => ({ className: block.classNames[fragment.itemIndex]!, href: block.hrefs[fragment.itemIndex] ?? null, leadingGap: fragment.gapBefore, text: fragment.text, })), width: line.width, }) }) return { contentLeft: frame.contentLeft, height: frame.height, kind: 'inline', lineHeight: frame.lineHeight, lines, markerClassName: frame.markerClassName, markerLeft: frame.markerLeft, markerText: frame.markerText, quoteRailLefts: frame.quoteRailLefts, top: frame.top, usedWidth: frame.usedWidth, } } case 'code': { if (block.kind !== 'code') throw new Error('Code block/frame mismatch') const boxWidth = Math.max(1, contentWidth - frame.contentLeft) const innerWidth = Math.max(1, boxWidth - CODE_BLOCK_PADDING_X * 2) const layout = layoutWithLines(block.prepared, innerWidth, frame.lineHeight) return { contentLeft: frame.contentLeft, height: frame.height, kind: 'code', lines: layout.lines, markerClassName: frame.markerClassName, markerLeft: frame.markerLeft, markerText: frame.markerText, quoteRailLefts: frame.quoteRailLefts, top: frame.top, usedWidth: frame.width, width: frame.width, } } case 'rule': { if (block.kind !== 'rule') throw new Error('Rule block/frame mismatch') return { contentLeft: frame.contentLeft, height: frame.height, kind: 'rule', markerClassName: frame.markerClassName, markerLeft: frame.markerLeft, markerText: frame.markerText, quoteRailLefts: frame.quoteRailLefts, top: frame.top, width: frame.width, } } } }