import JavaScriptCore import SwiftUI import UIKit import WebKit /// A Mermaid block drawn as the pages draw it, by Pele (https://pele.run). Pele /// lays it out in a JavaScript context of its own, with no page, network or /// anything else to reach, measuring its words with the system font. The SVG it /// makes is then shown in a web view that runs no script. Until it's drawn, and /// if it can't be, the block shows its source, as `code` draws it. struct DiagramView: View { let source: String @ViewBuilder let code: Source @State private var width: CGFloat = 0 @State private var drawn: Diagrams.Drawn? private struct Ask: Equatable { let source: String let width: Int } var body: some View { Group { if let drawn { ScrollView(.horizontal, showsIndicators: false) { DiagramPage(svg: drawn.svg) .frame(width: drawn.width, height: drawn.height) } .scrollDisabled(drawn.width <= width) .accessibilityElement() .accessibilityLabel("Diagram") } else { code } } .frame(maxWidth: .infinity, alignment: .leading) .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width = $0 } .task(id: Ask(source: source, width: Int(width))) { guard width > 0 else { return } drawn = await Diagrams.shared.draw(source, width: width) } } } /// Pele, loaded once, and what it has drawn. actor Diagrams { static let shared = Diagrams() struct Drawn: Sendable, Hashable { let svg: String let width: CGFloat let height: CGFloat } static let font = "-apple-system, system-ui, sans-serif" static let mono = "ui-monospace, Menlo, monospace" private var context: JSContext? private var broken = false private var drawn: [String: Drawn?] = [:] func draw(_ source: String, width: CGFloat) -> Drawn? { let key = "\(Int(width))\n\(source)" if let known = drawn[key] { return known } let made = render(source, width: width) if drawn.count > 64 { drawn.removeAll() } drawn[key] = made return made } private func render(_ source: String, width: CGFloat) -> Drawn? { guard let context = loaded(), let draw = context.objectForKeyedSubscript("knotlessDraw"), let answer = draw.call(withArguments: [source, Double(width)]), answer.isString, let data = answer.toString().data(using: .utf8), let out = try? JSONDecoder().decode(Out.self, from: data), out.width > 0, out.height > 0 else { return nil } return Drawn(svg: out.svg, width: CGFloat(out.width), height: CGFloat(out.height)) } private struct Out: Decodable { let svg: String let width: Double let height: Double } private func loaded() -> JSContext? { if let context { return context } guard !broken else { return nil } broken = true guard let url = Bundle.main.url(forResource: "pele", withExtension: "js"), let script = try? String(contentsOf: url, encoding: .utf8), let context = JSContext() else { return nil } let measure: @convention(block) (String, Double, Int32) -> Double = { text, size, style in Diagrams.width(of: text, size: size, style: Int(style)) } context.setObject(measure, forKeyedSubscript: "knotlessMeasure" as NSString) context.evaluateScript(script, withSourceURL: url) context.evaluateScript(""" function knotlessDraw(source, width) { try { var r = pele.render(source, { measurer: { width: knotlessMeasure }, fontFamily: \(Self.quoted(Self.font)), fontFamilyMono: \(Self.quoted(Self.mono)), maxWidth: width, linkRel: 'noopener nofollow ugc' }); return JSON.stringify({ svg: r.svg, width: r.width, height: r.height }); } catch (e) { return null; } } """) guard context.exception == nil, context.objectForKeyedSubscript("pele")?.isObject == true else { return nil } broken = false self.context = context return context } private static func quoted(_ s: String) -> String { "'" + s.replacing("\\", with: "\\\\").replacing("'", with: "\\'") + "'" } /// How wide Pele's words are in the font the web view draws them in: its /// style is bits, 1 bold, 2 italic, 4 monospaced. static func width(of text: String, size: Double, style: Int) -> Double { let weight: UIFont.Weight = style & 1 != 0 ? .bold : .regular var font = style & 4 != 0 ? UIFont.monospacedSystemFont(ofSize: size, weight: weight) : UIFont.systemFont(ofSize: size, weight: weight) if style & 2 != 0, let italic = font.fontDescriptor.withSymbolicTraits(font.fontDescriptor.symbolicTraits.union(.traitItalic)) { font = UIFont(descriptor: italic, size: size) } let line = CTLineCreateWithAttributedString(NSAttributedString(string: text, attributes: [.font: font])) return CTLineGetTypographicBounds(line, nil, nil, nil) } } /// An SVG Pele made, in a web view with no script, no store and no way off the page. private struct DiagramPage: UIViewRepresentable { let svg: String /// Read, so a change of theme draws it again, in the theme's colours. @Environment(\.themeGeneration) private var generation func makeCoordinator() -> Coordinator { Coordinator() } func makeUIView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() configuration.defaultWebpagePreferences.allowsContentJavaScript = false configuration.websiteDataStore = .nonPersistent() configuration.dataDetectorTypes = [] let view = WKWebView(frame: .zero, configuration: configuration) view.isOpaque = false view.backgroundColor = .clear view.scrollView.backgroundColor = .clear view.scrollView.isScrollEnabled = false view.scrollView.contentInsetAdjustmentBehavior = .never view.navigationDelegate = context.coordinator return view } func updateUIView(_ view: WKWebView, context: Context) { let shown = "\(generation) \(svg)" guard context.coordinator.shown != shown else { return } context.coordinator.shown = shown view.loadHTMLString(Self.page(svg), baseURL: nil) } final class Coordinator: NSObject, WKNavigationDelegate { var shown: String? func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { navigationAction.request.url?.absoluteString == "about:blank" ? .allow : .cancel } } static func page(_ svg: String) -> String { """ \(svg) """ } /// The app's colours as Pele's, as the pages give it theirs. private static func palette(_ style: UIUserInterfaceStyle) -> String { let traits = UITraitCollection(userInterfaceStyle: style) func css(_ color: UIColor) -> String { var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 color.resolvedColor(with: traits).getRed(&r, green: &g, blue: &b, alpha: &a) return "rgba(\(Int((r * 255).rounded())),\(Int((g * 255).rounded())),\(Int((b * 255).rounded())),\(a))" } return [ ("bg", UIColor(Color.card)), ("fg", UIColor(Color.fg)), ("muted", UIColor(Color.mute)), ("line", UIColor(Color(themed: \.mute, otherwise: .systemGray))), // Fills a step off the card behind, in dark as in light. ("surface", UIColor(Color.inset)), ("surface-alt", UIColor(Color(themed: { Palette.mix($0.fg, 0.14, $0.bg) }, light: 0xe5e5ea, dark: 0x3a3a3c))), ("border", UIColor(Color(themed: \.line, otherwise: .systemGray2))), ("accent", UIColor(Color.accent)), ("critical", UIColor(Color.danger)), ("error", UIColor(Color.danger)), ].map { "--pele-\($0.0):\(css($0.1))" }.joined(separator: ";") + ";--pele-font:\(Diagrams.font);--pele-font-mono:\(Diagrams.mono)" } }