mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
709 lines
26 KiB
Swift
709 lines
26 KiB
Swift
import Foundation
|
||
import Markdown
|
||
import SwiftUI
|
||
|
||
// MARK: - hapiOpenURL environment
|
||
|
||
/// Link-open handler for everything the markdown renderer emits: regular
|
||
/// URLs (after `HrefPolicy` filtering) and `hapi-file://?path=&line=` file
|
||
/// references (decode with `FilePathLink(url:)`). The app decides what to do
|
||
/// (SFSafariViewController, session file viewer, confirm sheet for
|
||
/// `.confirmFirst` schemes); HapiUI never opens anything by itself.
|
||
public struct HapiOpenURLAction: Sendable {
|
||
public typealias Handler = @Sendable (URL) -> Void
|
||
|
||
private let handler: Handler
|
||
|
||
public init(handler: @escaping Handler) {
|
||
self.handler = handler
|
||
}
|
||
|
||
public func callAsFunction(_ url: URL) {
|
||
handler(url)
|
||
}
|
||
}
|
||
|
||
private struct HapiOpenURLKey: EnvironmentKey {
|
||
static let defaultValue = HapiOpenURLAction { _ in }
|
||
}
|
||
|
||
public extension EnvironmentValues {
|
||
/// Handler invoked for links tapped inside HapiUI markdown content.
|
||
/// Defaults to a no-op; the app must install its own handler.
|
||
var hapiOpenURL: HapiOpenURLAction {
|
||
get { self[HapiOpenURLKey.self] }
|
||
set { self[HapiOpenURLKey.self] = newValue }
|
||
}
|
||
}
|
||
|
||
// MARK: - Block model
|
||
|
||
public struct MarkdownTableModel: Equatable, Sendable {
|
||
public enum Alignment: Equatable, Sendable {
|
||
case left
|
||
case center
|
||
case right
|
||
}
|
||
|
||
public var columnAlignments: [Alignment?]
|
||
public var header: [AttributedString]
|
||
public var rows: [[AttributedString]]
|
||
|
||
public init(columnAlignments: [Alignment?], header: [AttributedString], rows: [[AttributedString]]) {
|
||
self.columnAlignments = columnAlignments
|
||
self.header = header
|
||
self.rows = rows
|
||
}
|
||
}
|
||
|
||
public struct MarkdownListModel: Equatable, Sendable {
|
||
public struct Item: Equatable, Sendable {
|
||
/// nil for plain list items; true/false for GFM task list items.
|
||
public var checkbox: Bool?
|
||
public var blocks: [MarkdownBlockNode]
|
||
|
||
public init(checkbox: Bool? = nil, blocks: [MarkdownBlockNode]) {
|
||
self.checkbox = checkbox
|
||
self.blocks = blocks
|
||
}
|
||
}
|
||
|
||
public var isOrdered: Bool
|
||
public var startIndex: Int
|
||
public var items: [Item]
|
||
|
||
public init(isOrdered: Bool, startIndex: Int = 1, items: [Item]) {
|
||
self.isOrdered = isOrdered
|
||
self.startIndex = startIndex
|
||
self.items = items
|
||
}
|
||
}
|
||
|
||
/// Renderer-ready markdown block tree. Built from source text by
|
||
/// `MarkdownBlockTree.build(from:)`; pure data, so the conversion is unit
|
||
/// testable without rendering any view.
|
||
public indirect enum MarkdownBlockNode: Equatable, Sendable {
|
||
case paragraph(AttributedString)
|
||
case heading(level: Int, AttributedString)
|
||
case codeBlock(language: String?, code: String)
|
||
case blockquote([MarkdownBlockNode])
|
||
case list(MarkdownListModel)
|
||
case table(MarkdownTableModel)
|
||
case thematicBreak
|
||
/// Markdown image reference. Chat images arrive via tool results, not
|
||
/// markdown, so this renders as a placeholder link row.
|
||
case image(alt: String, destination: String?)
|
||
}
|
||
|
||
// MARK: - Block tree builder
|
||
|
||
public enum MarkdownBlockTree {
|
||
/// Pre-processes the raw source (table repair + indented-code disable,
|
||
/// mirroring the web's remark plugin order), parses it with
|
||
/// swift-markdown (GFM tables/strikethrough/task lists; smart-quote
|
||
/// substitution disabled to match remark output), and folds the tree
|
||
/// into renderer-ready blocks.
|
||
public static func build(from markdown: String) -> [MarkdownBlockNode] {
|
||
let prepared = MarkdownTransforms.disableIndentedCode(
|
||
MarkdownTransforms.repairTables(markdown)
|
||
)
|
||
let document = Document(parsing: prepared, options: [.disableSmartOpts])
|
||
var builder = BlockBuilder()
|
||
return builder.visit(document)
|
||
}
|
||
}
|
||
|
||
struct BlockBuilder: MarkupVisitor {
|
||
typealias Result = [MarkdownBlockNode]
|
||
|
||
mutating func defaultVisit(_ markup: Markup) -> [MarkdownBlockNode] {
|
||
// Unknown containers (custom blocks, directives): flatten children.
|
||
markup.children.flatMap { visit($0) }
|
||
}
|
||
|
||
mutating func visitDocument(_ document: Document) -> [MarkdownBlockNode] {
|
||
document.children.flatMap { visit($0) }
|
||
}
|
||
|
||
mutating func visitParagraph(_ paragraph: Paragraph) -> [MarkdownBlockNode] {
|
||
let children = Array(paragraph.children)
|
||
if children.count == 1, let image = children[0] as? Markdown.Image {
|
||
var inline = InlineBuilder(insideLink: true)
|
||
let alt = String(inline.content(of: image).characters)
|
||
return [.image(alt: alt, destination: image.source)]
|
||
}
|
||
var inline = InlineBuilder()
|
||
return [.paragraph(inline.content(of: paragraph))]
|
||
}
|
||
|
||
mutating func visitHeading(_ heading: Heading) -> [MarkdownBlockNode] {
|
||
var inline = InlineBuilder()
|
||
let level = min(max(heading.level, 1), 6)
|
||
return [.heading(level: level, inline.content(of: heading))]
|
||
}
|
||
|
||
mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> [MarkdownBlockNode] {
|
||
var code = codeBlock.code
|
||
if code.hasSuffix("\n") {
|
||
code = String(code.dropLast())
|
||
}
|
||
// The fence info string may carry extra tokens ("swift lineNumbers");
|
||
// only the first token is the language.
|
||
var language: String? = nil
|
||
if let info = codeBlock.language {
|
||
let token = info.split(separator: " ").first.map(String.init) ?? info
|
||
language = token.isEmpty ? nil : token
|
||
}
|
||
return [.codeBlock(language: language, code: code)]
|
||
}
|
||
|
||
mutating func visitHTMLBlock(_ html: HTMLBlock) -> [MarkdownBlockNode] {
|
||
// The web drops raw HTML (react-markdown default). Showing the
|
||
// literal source is the conservative visible choice on iOS.
|
||
var raw = html.rawHTML
|
||
if raw.hasSuffix("\n") {
|
||
raw = String(raw.dropLast())
|
||
}
|
||
return [.paragraph(AttributedString(raw))]
|
||
}
|
||
|
||
mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> [MarkdownBlockNode] {
|
||
[.blockquote(blockQuote.children.flatMap { visit($0) })]
|
||
}
|
||
|
||
mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> [MarkdownBlockNode] {
|
||
[.list(makeList(from: unorderedList, ordered: false, start: 1))]
|
||
}
|
||
|
||
mutating func visitOrderedList(_ orderedList: OrderedList) -> [MarkdownBlockNode] {
|
||
[.list(makeList(from: orderedList, ordered: true, start: Int(clamping: orderedList.startIndex)))]
|
||
}
|
||
|
||
mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) -> [MarkdownBlockNode] {
|
||
[.thematicBreak]
|
||
}
|
||
|
||
mutating func visitTable(_ table: Markdown.Table) -> [MarkdownBlockNode] {
|
||
let columnCount = max(table.maxColumnCount, 1)
|
||
var inline = InlineBuilder()
|
||
|
||
var header: [AttributedString] = []
|
||
for cell in table.head.cells {
|
||
header.append(inline.content(of: cell))
|
||
}
|
||
while header.count < columnCount {
|
||
header.append(AttributedString())
|
||
}
|
||
|
||
var rows: [[AttributedString]] = []
|
||
for row in table.body.rows {
|
||
var cells: [AttributedString] = []
|
||
for cell in row.cells {
|
||
cells.append(inline.content(of: cell))
|
||
}
|
||
while cells.count < columnCount {
|
||
cells.append(AttributedString())
|
||
}
|
||
rows.append(cells)
|
||
}
|
||
|
||
var alignments: [MarkdownTableModel.Alignment?] = table.columnAlignments.map { alignment -> MarkdownTableModel.Alignment? in
|
||
switch alignment {
|
||
case .left: return .left
|
||
case .center: return .center
|
||
case .right: return .right
|
||
case nil: return nil
|
||
}
|
||
}
|
||
while alignments.count < columnCount {
|
||
alignments.append(nil)
|
||
}
|
||
|
||
return [.table(MarkdownTableModel(
|
||
columnAlignments: alignments,
|
||
header: header,
|
||
rows: rows
|
||
))]
|
||
}
|
||
|
||
private mutating func makeList(
|
||
from container: some ListItemContainer,
|
||
ordered: Bool,
|
||
start: Int
|
||
) -> MarkdownListModel {
|
||
var items: [MarkdownListModel.Item] = []
|
||
for item in container.listItems {
|
||
let checkbox: Bool? = item.checkbox.map { $0 == .checked }
|
||
let blocks = item.children.flatMap { visit($0) }
|
||
items.append(MarkdownListModel.Item(checkbox: checkbox, blocks: blocks))
|
||
}
|
||
return MarkdownListModel(isOrdered: ordered, startIndex: start, items: items)
|
||
}
|
||
}
|
||
|
||
// MARK: - Inline builder
|
||
|
||
/// Folds inline markup into an `AttributedString`:
|
||
/// - bold / italic / strikethrough / inline code via
|
||
/// `InlinePresentationIntent` (theme styling is applied at render time),
|
||
/// - links via the `.link` attribute after `HrefPolicy` filtering (blocked
|
||
/// schemes render as plain text; scheme-less hrefs only survive as
|
||
/// workspace file links — anything else is inert, matching the web's
|
||
/// fail-closed rule),
|
||
/// - bare `scheme://` URLs and workspace file paths in plain text are
|
||
/// auto-linked (`MarkdownTransforms.detectAutolinkRanges` /
|
||
/// `detectFilePathRanges`); file references use `hapi-file://` URLs.
|
||
struct InlineBuilder: MarkupVisitor {
|
||
typealias Result = AttributedString
|
||
|
||
/// Inside a link (or alt text) no nested autolinking happens — mirrors
|
||
/// the web plugin skipping `link` parents.
|
||
var insideLink: Bool
|
||
|
||
init(insideLink: Bool = false) {
|
||
self.insideLink = insideLink
|
||
}
|
||
|
||
/// Concatenated visit of all children.
|
||
mutating func content(of markup: Markup) -> AttributedString {
|
||
var output = AttributedString()
|
||
for child in markup.children {
|
||
output += visit(child)
|
||
}
|
||
return output
|
||
}
|
||
|
||
mutating func defaultVisit(_ markup: Markup) -> AttributedString {
|
||
content(of: markup)
|
||
}
|
||
|
||
mutating func visitText(_ text: Markdown.Text) -> AttributedString {
|
||
insideLink ? AttributedString(text.string) : Self.linkified(text.string)
|
||
}
|
||
|
||
mutating func visitSoftBreak(_ softBreak: SoftBreak) -> AttributedString {
|
||
AttributedString(" ")
|
||
}
|
||
|
||
mutating func visitLineBreak(_ lineBreak: LineBreak) -> AttributedString {
|
||
AttributedString("\n")
|
||
}
|
||
|
||
mutating func visitInlineHTML(_ inlineHTML: InlineHTML) -> AttributedString {
|
||
AttributedString(inlineHTML.rawHTML)
|
||
}
|
||
|
||
mutating func visitSymbolLink(_ symbolLink: SymbolLink) -> AttributedString {
|
||
var piece = AttributedString(symbolLink.destination ?? "")
|
||
piece.inlinePresentationIntent = .code
|
||
return piece
|
||
}
|
||
|
||
mutating func visitEmphasis(_ emphasis: Emphasis) -> AttributedString {
|
||
Self.addingIntent(.emphasized, to: content(of: emphasis))
|
||
}
|
||
|
||
mutating func visitStrong(_ strong: Strong) -> AttributedString {
|
||
Self.addingIntent(.stronglyEmphasized, to: content(of: strong))
|
||
}
|
||
|
||
mutating func visitStrikethrough(_ strikethrough: Strikethrough) -> AttributedString {
|
||
Self.addingIntent(.strikethrough, to: content(of: strikethrough))
|
||
}
|
||
|
||
mutating func visitInlineCode(_ inlineCode: InlineCode) -> AttributedString {
|
||
var piece = AttributedString(inlineCode.code)
|
||
piece.inlinePresentationIntent = .code
|
||
if !insideLink,
|
||
let fileLink = MarkdownTransforms.filePathLink(forInlineCode: inlineCode.code),
|
||
let url = fileLink.url {
|
||
piece.link = url
|
||
}
|
||
return piece
|
||
}
|
||
|
||
mutating func visitLink(_ link: Markdown.Link) -> AttributedString {
|
||
let wasInsideLink = insideLink
|
||
insideLink = true
|
||
var label = content(of: link)
|
||
insideLink = wasInsideLink
|
||
|
||
var target = link.destination ?? ""
|
||
var trailing: AttributedString? = nil
|
||
|
||
// Angle autolinks (`<https://x,>`) have text == destination; strip
|
||
// trailing CJK punctuation out of the link (remark-strip-cjk-autolink
|
||
// parity — explicit `[text](url)` links are never modified).
|
||
let plain = String(label.characters)
|
||
if !target.isEmpty, plain == target,
|
||
let split = MarkdownTransforms.splitTrailingCJKPunctuation(fromAutolink: target) {
|
||
target = split.url
|
||
label = AttributedString(split.url)
|
||
trailing = AttributedString(split.trailing)
|
||
}
|
||
|
||
var result = label
|
||
if target.isEmpty {
|
||
// No destination — plain text.
|
||
} else if !HrefPolicy.hasScheme(target) {
|
||
// Scheme-less: only repo-relative workspace files become links;
|
||
// everything else path-like renders inert (web #1452 rule; SPA
|
||
// routes have no iOS equivalent).
|
||
if let fileLink = MarkdownTransforms.fileLinkTarget(forExplicitHref: target),
|
||
let url = fileLink.url {
|
||
result.link = url
|
||
}
|
||
} else {
|
||
switch HrefPolicy.classify(target) {
|
||
case .blocked:
|
||
break
|
||
case .allowed, .confirmFirst:
|
||
if let url = Self.makeURL(from: target) {
|
||
result.link = url
|
||
}
|
||
}
|
||
}
|
||
|
||
if let trailing {
|
||
result += trailing
|
||
}
|
||
return result
|
||
}
|
||
|
||
mutating func visitImage(_ image: Markdown.Image) -> AttributedString {
|
||
let wasInsideLink = insideLink
|
||
insideLink = true
|
||
let altText = content(of: image)
|
||
insideLink = wasInsideLink
|
||
|
||
let alt = String(altText.characters)
|
||
let label = alt.isEmpty ? (image.source ?? "image") : alt
|
||
var piece = AttributedString(label)
|
||
if let source = image.source,
|
||
HrefPolicy.hasScheme(source),
|
||
HrefPolicy.classify(source) != .blocked,
|
||
let url = Self.makeURL(from: source) {
|
||
piece.link = url
|
||
}
|
||
return piece
|
||
}
|
||
|
||
// MARK: helpers
|
||
|
||
static func addingIntent(
|
||
_ intent: InlinePresentationIntent,
|
||
to attributed: AttributedString
|
||
) -> AttributedString {
|
||
// Rebuild run-by-run instead of mutating in place: attribute writes
|
||
// count as mutations and would invalidate the run iterator.
|
||
var output = AttributedString()
|
||
for run in attributed.runs {
|
||
var piece = AttributedString(attributed[run.range])
|
||
let existing = run.inlinePresentationIntent ?? []
|
||
piece.inlinePresentationIntent = existing.union(intent)
|
||
output.append(piece)
|
||
}
|
||
return output
|
||
}
|
||
|
||
static func linkified(_ string: String) -> AttributedString {
|
||
var output = AttributedString()
|
||
var cursor = string.startIndex
|
||
for hit in MarkdownTransforms.detectAutolinkRanges(in: string) {
|
||
if hit.range.lowerBound > cursor {
|
||
output += filePathLinked(String(string[cursor..<hit.range.lowerBound]))
|
||
}
|
||
var piece = AttributedString(hit.url)
|
||
if HrefPolicy.classify(hit.url) != .blocked, let url = makeURL(from: hit.url) {
|
||
piece.link = url
|
||
}
|
||
output += piece
|
||
cursor = hit.range.upperBound
|
||
}
|
||
if cursor < string.endIndex {
|
||
output += filePathLinked(String(string[cursor...]))
|
||
}
|
||
return output
|
||
}
|
||
|
||
static func filePathLinked(_ string: String) -> AttributedString {
|
||
let hits = MarkdownTransforms.detectFilePathRanges(in: string)
|
||
guard !hits.isEmpty else { return AttributedString(string) }
|
||
var output = AttributedString()
|
||
var cursor = string.startIndex
|
||
for (range, link) in hits {
|
||
if range.lowerBound > cursor {
|
||
output += AttributedString(String(string[cursor..<range.lowerBound]))
|
||
}
|
||
var piece = AttributedString(String(string[range]))
|
||
if let url = link.url {
|
||
piece.link = url
|
||
}
|
||
output += piece
|
||
cursor = range.upperBound
|
||
}
|
||
if cursor < string.endIndex {
|
||
output += AttributedString(String(string[cursor...]))
|
||
}
|
||
return output
|
||
}
|
||
|
||
static func makeURL(from string: String) -> URL? {
|
||
if let url = URL(string: string) { return url }
|
||
guard let encoded = string.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
|
||
return nil
|
||
}
|
||
return URL(string: encoded)
|
||
}
|
||
}
|
||
|
||
// MARK: - Theme application
|
||
|
||
/// Applies palette-dependent styling to a built inline string: inline-code
|
||
/// runs get the monospaced font + chip colors, link runs get the link color
|
||
/// and underline. Bold/italic/strikethrough render natively from
|
||
/// `InlinePresentationIntent`.
|
||
func hapiStyledText(_ source: AttributedString, theme: HapiTheme, typography: HapiTypography) -> AttributedString {
|
||
var output = AttributedString()
|
||
for run in source.runs {
|
||
var piece = AttributedString(source[run.range])
|
||
if let intent = run.inlinePresentationIntent, intent.contains(.code) {
|
||
piece[AttributeScopes.SwiftUIAttributes.FontAttribute.self] = typography.inlineCodeFont
|
||
piece[AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute.self] = theme.inlineCodeForeground
|
||
piece[AttributeScopes.SwiftUIAttributes.BackgroundColorAttribute.self] = theme.inlineCodeBackground
|
||
}
|
||
if run.link != nil {
|
||
piece[AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute.self] = theme.link
|
||
piece[AttributeScopes.SwiftUIAttributes.UnderlineStyleAttribute.self] = .single
|
||
}
|
||
output.append(piece)
|
||
}
|
||
return output
|
||
}
|
||
|
||
// MARK: - Views
|
||
|
||
/// SwiftUI markdown renderer. Parses in `init` (cheap enough for chat-sized
|
||
/// content; streaming updates re-parse on each source change) and renders the
|
||
/// block tree. Link taps — including `hapi-file://` workspace references —
|
||
/// are delivered to `\.hapiOpenURL`.
|
||
public struct MarkdownView: View {
|
||
private let blocks: [MarkdownBlockNode]
|
||
@Environment(\.hapiOpenURL) private var hapiOpenURL
|
||
|
||
public init(markdown: String) {
|
||
self.blocks = MarkdownBlockTree.build(from: markdown)
|
||
}
|
||
|
||
public init(blocks: [MarkdownBlockNode]) {
|
||
self.blocks = blocks
|
||
}
|
||
|
||
public var body: some View {
|
||
let open = hapiOpenURL
|
||
MarkdownBlockListView(blocks: blocks)
|
||
.environment(\.openURL, OpenURLAction { url in
|
||
// Route AttributedString link taps into the app handler.
|
||
open(url)
|
||
return .handled
|
||
})
|
||
}
|
||
}
|
||
|
||
struct MarkdownBlockListView: View {
|
||
let blocks: [MarkdownBlockNode]
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
ForEach(Array(blocks.enumerated()), id: \.offset) { index, block in
|
||
MarkdownBlockView(block: block)
|
||
.padding(.top, Self.spacing(before: block, after: index > 0 ? blocks[index - 1] : nil))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pure layout calculation; does not need View's inferred MainActor isolation.
|
||
nonisolated static func spacing(before block: MarkdownBlockNode, after previous: MarkdownBlockNode?) -> CGFloat {
|
||
guard let previous else { return 0 }
|
||
if case .heading = block { return 24 }
|
||
if case .heading = previous { return 8 }
|
||
return 12
|
||
}
|
||
}
|
||
|
||
struct MarkdownBlockView: View {
|
||
let block: MarkdownBlockNode
|
||
@Environment(\.hapiTheme) private var theme
|
||
@Environment(\.hapiTypography) private var typography
|
||
|
||
var body: some View {
|
||
switch block {
|
||
case .paragraph(let text):
|
||
SwiftUI.Text(hapiStyledText(text, theme: theme, typography: typography))
|
||
.font(typography.bodyFont)
|
||
.lineSpacing(typography.bodyLineSpacing)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
.foregroundStyle(theme.textPrimary)
|
||
.textSelection(.enabled)
|
||
case .heading(let level, let text):
|
||
SwiftUI.Text(hapiStyledText(text, theme: theme, typography: typography))
|
||
.font(typography.headingFont(level))
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
.foregroundStyle(theme.textPrimary)
|
||
.textSelection(.enabled)
|
||
case .codeBlock(let language, let code):
|
||
CodeBlockView(language: language, code: code)
|
||
case .blockquote(let children):
|
||
HStack(alignment: .top, spacing: 10) {
|
||
RoundedRectangle(cornerRadius: 1.5)
|
||
.fill(theme.quoteBar)
|
||
.frame(width: 3)
|
||
MarkdownBlockListView(blocks: children)
|
||
}
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
.padding(10)
|
||
.background(theme.quoteBackground)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||
case .list(let model):
|
||
MarkdownListView(model: model)
|
||
case .table(let model):
|
||
MarkdownTableView(model: model)
|
||
case .thematicBreak:
|
||
Rectangle()
|
||
.fill(theme.divider)
|
||
.frame(height: 1)
|
||
.padding(.vertical, 4)
|
||
case .image(let alt, let destination):
|
||
MarkdownImageRow(alt: alt, destination: destination)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct MarkdownListView: View {
|
||
let model: MarkdownListModel
|
||
@Environment(\.hapiTheme) private var theme
|
||
@Environment(\.hapiTypography) private var typography
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
ForEach(Array(model.items.enumerated()), id: \.offset) { index, item in
|
||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||
marker(for: item, index: index)
|
||
MarkdownBlockListView(blocks: item.blocks)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func marker(for item: MarkdownListModel.Item, index: Int) -> some View {
|
||
if let checked = item.checkbox {
|
||
SwiftUI.Image(systemName: checked ? "checkmark.square.fill" : "square")
|
||
.font(.system(size: typography.bodySize - 2))
|
||
.foregroundStyle(checked ? theme.accent : theme.textHint)
|
||
.accessibilityLabel(SwiftUI.Text(checked ? "Completed" : "Not completed"))
|
||
} else if model.isOrdered {
|
||
SwiftUI.Text("\(model.startIndex + index).")
|
||
.font(typography.bodyFont.monospacedDigit())
|
||
.foregroundStyle(theme.textHint)
|
||
} else {
|
||
SwiftUI.Text("•")
|
||
.font(typography.bodyFont)
|
||
.foregroundStyle(theme.textHint)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct MarkdownTableView: View {
|
||
let model: MarkdownTableModel
|
||
@Environment(\.hapiTheme) private var theme
|
||
@Environment(\.hapiTypography) private var typography
|
||
|
||
var body: some View {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
Grid(alignment: .topLeading, horizontalSpacing: 0, verticalSpacing: 0) {
|
||
GridRow {
|
||
ForEach(Array(model.header.enumerated()), id: \.offset) { index, cell in
|
||
SwiftUI.Text(hapiStyledText(cell, theme: theme, typography: typography))
|
||
.fontWeight(.semibold)
|
||
.font(typography.bodyFont)
|
||
.foregroundStyle(theme.textPrimary)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 6)
|
||
.gridColumnAlignment(columnAlignment(index))
|
||
.background(theme.tableHeaderBackground)
|
||
}
|
||
}
|
||
ForEach(Array(model.rows.enumerated()), id: \.offset) { _, row in
|
||
Divider()
|
||
GridRow {
|
||
ForEach(Array(row.enumerated()), id: \.offset) { index, cell in
|
||
SwiftUI.Text(hapiStyledText(cell, theme: theme, typography: typography))
|
||
.font(typography.bodyFont)
|
||
.foregroundStyle(theme.textPrimary)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 6)
|
||
.gridColumnAlignment(columnAlignment(index))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.background(theme.surface)
|
||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||
}
|
||
|
||
private func columnAlignment(_ index: Int) -> HorizontalAlignment {
|
||
guard index < model.columnAlignments.count, let alignment = model.columnAlignments[index] else {
|
||
return .leading
|
||
}
|
||
switch alignment {
|
||
case .left: return .leading
|
||
case .center: return .center
|
||
case .right: return .trailing
|
||
}
|
||
}
|
||
}
|
||
|
||
struct MarkdownImageRow: View {
|
||
let alt: String
|
||
let destination: String?
|
||
@Environment(\.hapiTheme) private var theme
|
||
@Environment(\.hapiTypography) private var typography
|
||
@Environment(\.hapiOpenURL) private var hapiOpenURL
|
||
|
||
var body: some View {
|
||
let label = alt.isEmpty ? (destination ?? "image") : alt
|
||
let url: URL? = destination.flatMap { dest in
|
||
guard HrefPolicy.hasScheme(dest), HrefPolicy.classify(dest) != .blocked else { return nil }
|
||
return InlineBuilder.makeURL(from: dest)
|
||
}
|
||
|
||
HStack(spacing: 6) {
|
||
SwiftUI.Image(systemName: "photo")
|
||
.font(.system(size: typography.captionSize))
|
||
.foregroundStyle(theme.textHint)
|
||
if let url {
|
||
Button {
|
||
hapiOpenURL(url)
|
||
} label: {
|
||
SwiftUI.Text(label)
|
||
.font(typography.captionFont)
|
||
.foregroundStyle(theme.link)
|
||
.underline()
|
||
.lineLimit(1)
|
||
}
|
||
.buttonStyle(.plain)
|
||
} else {
|
||
SwiftUI.Text(label)
|
||
.font(typography.captionFont)
|
||
.foregroundStyle(theme.textSecondary)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
.padding(8)
|
||
.background(theme.surface)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||
}
|
||
}
|