diff --git a/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift b/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift index 59e9b750..ce393e93 100644 --- a/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift +++ b/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift @@ -184,6 +184,44 @@ public struct NoOpLatexRenderer: LatexRenderer { public func render(latex: String, fontSize: CGFloat, theme: MarkdownEditorTheme) -> LatexRenderResult? { nil } } +// MARK: - Diagrams + +/// Renders a fenced diagram code block (e.g. ```` ```mermaid ````) to an image +/// for inline display, mirroring ``LatexRenderer`` for block math. +/// +/// When a renderer is supplied, the engine collapses the fenced source and +/// draws the returned image in its place — exactly as it does for block LaTeX. +/// A caret entering the block reveals the raw source again for editing, so +/// hosts should keep `render` cheap (cache by source) since styling reruns +/// on edits elsewhere in the document. +public protocol DiagramRenderer: Sendable { + /// Render `source` (the code inside the fence, fences excluded) written in + /// `language` (the fence info string, e.g. `"mermaid"`), optionally tinted + /// by `theme`. `isDarkMode` reflects the editor's current effective + /// appearance so the renderer can match light/dark. + /// - Returns: A rendered result, or `nil` if this renderer doesn't handle + /// `language` or can't produce an image — the engine then leaves the + /// block as an ordinary syntax-highlighted code block. + func render(source: String, language: String, theme: MarkdownEditorTheme, isDarkMode: Bool) -> DiagramRenderResult? +} + +/// Output of a diagram render call. +public struct DiagramRenderResult: Sendable { + public let image: NSImage + public let size: CGSize + + public init(image: NSImage, size: CGSize) { + self.image = image + self.size = size + } +} + +/// Default renderer that draws no diagrams. Fenced blocks stay as code. +public struct NoOpDiagramRenderer: DiagramRenderer { + public init() {} + public func render(source: String, language: String, theme: MarkdownEditorTheme, isDarkMode: Bool) -> DiagramRenderResult? { nil } +} + // MARK: - Event Bus /// Optional notification-name bridge that lets the editor communicate with @@ -304,6 +342,7 @@ public struct MarkdownEditorServices: Sendable { public var images: any EmbeddedImageProvider public var syntaxHighlighter: any SyntaxHighlighter public var latex: any LatexRenderer + public var diagrams: any DiagramRenderer public var bus: MarkdownEditorBus public init( @@ -311,12 +350,14 @@ public struct MarkdownEditorServices: Sendable { images: any EmbeddedImageProvider = NoOpEmbeddedImageProvider(), syntaxHighlighter: any SyntaxHighlighter = PlainTextSyntaxHighlighter(), latex: any LatexRenderer = NoOpLatexRenderer(), + diagrams: any DiagramRenderer = NoOpDiagramRenderer(), bus: MarkdownEditorBus = .default ) { self.wikiLinks = wikiLinks self.images = images self.syntaxHighlighter = syntaxHighlighter self.latex = latex + self.diagrams = diagrams self.bus = bus } diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Diagrams.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Diagrams.swift new file mode 100644 index 00000000..78f3a56e --- /dev/null +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Diagrams.swift @@ -0,0 +1,111 @@ +// +// MarkdownStyler+Diagrams.swift +// MarkdownEngine +// +// Renders fenced diagram code blocks (```mermaid …```) inline as images, +// mirroring the block-LaTeX pass in MarkdownStyler+Latex.swift. When a +// `DiagramRenderer` is configured and the caret is outside the block, the +// fenced source collapses and the rendered image is drawn in its place; +// moving the caret into the block reveals the raw source for editing. +// + +import AppKit +import Foundation + +extension MarkdownStyler { + + static func styleDiagramBlocks(_ ctx: StylingContext) -> [StyledRange] { + var attrs: [StyledRange] = [] + // Match the diagram to the text view's real light/dark appearance, exactly + // as rendered tables resolve their colors. + let appearance = ctx.layoutBridge?.firstTextContainer?.textView?.effectiveAppearance + ?? NSApp.effectiveAppearance + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + let containerWidth = effectiveContainerWidth(for: ctx) + // Wide diagrams reuse the same scrollable-block overlay tables use; the + // sourceID must be stable per occurrence so scroll offsets survive restyles. + var occurrenceBySource: [String: Int] = [:] + + for (idx, token) in ctx.tokens.enumerated() where token.kind == .codeBlock { + guard let language = fenceInfoString(of: token, in: ctx.nsText) else { continue } + + // Editing the block? Leave it as an ordinary code block so the raw + // source shows with normal monospace + syntax styling. + if ctx.activeTokenIndices.contains(idx) { continue } + + // Only render a diagram when the block stands alone in its paragraph + // (same requirement block LaTeX has for `appendRenderedStandaloneBlock`). + guard token.standaloneParagraphRange(in: ctx.nsText) != nil else { continue } + + let rawSource = ctx.nsText.substring(with: token.contentRange) + let source = rawSource.trimmingCharacters(in: .whitespacesAndNewlines) + guard !source.isEmpty, + let result = ctx.services.diagrams.render( + source: source, + language: language, + theme: ctx.configuration.theme, + isDarkMode: isDark + ) + else { continue } + + // Suppress the code-block background so no filled box shows behind + // the diagram — the AST styler tagged this range as code earlier. + attrs.append((token.range, [.backgroundColor: NSColor.clear])) + + let markerTexts = [ + ctx.nsText.substring(with: token.markerRanges[0]), + ctx.nsText.substring(with: token.markerRanges[1]) + ] + // Clamp diagrams wider than the reading column into a horizontal + // scroller instead of letting them overflow the text view. + let occurrenceKey = "\(language)\n\(source)" + let occurrence = occurrenceBySource[occurrenceKey, default: 0] + occurrenceBySource[occurrenceKey] = occurrence + 1 + + let mode: RenderedStandaloneBlockMode + if result.size.width > containerWidth + 0.5 { + mode = .collapsedSourceScrollable( + markerTexts: markerTexts, + displayWidth: containerWidth, + sourceID: diagramSourceID(for: occurrenceKey, occurrence: occurrence) + ) + } else { + mode = .collapsedSource(markerTexts: markerTexts) + } + + _ = appendRenderedStandaloneBlock( + for: token, + rawContent: rawSource, + image: result.image, + imageBounds: CGRect(x: 0, y: 0, width: result.size.width, height: result.size.height), + paragraphSpacingBefore: ctx.configuration.blockLatex.paragraphSpacingBefore, + paragraphSpacing: ctx.configuration.blockLatex.paragraphSpacing, + alignment: .center, + mode: mode, + ctx: ctx, + attrs: &attrs + ) + } + return attrs + } + + /// Stable per-occurrence ID for a diagram's scrollable overlay, so its + /// horizontal scroll offset persists across restyles. + private static func diagramSourceID(for source: String, occurrence: Int) -> Int { + var hasher = Hasher() + hasher.combine("diagram-overlay-v1") + hasher.combine(source) + hasher.combine(occurrence) + return hasher.finalize() + } + + /// The fence info string (language) of a code-block token, e.g. `"mermaid"` + /// for ```` ```mermaid ````. `nil` for a bare ```` ``` ```` fence. + private static func fenceInfoString(of token: MarkdownToken, in ns: NSString) -> String? { + guard let openMarker = token.markerRanges.first else { return nil } + let info = ns.substring(with: openMarker) + .drop(while: { $0 == "`" }) + .trimmingCharacters(in: .whitespacesAndNewlines) + return info.isEmpty ? nil : info + } +} diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift index 9ac29299..b77ab9a2 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler.swift @@ -88,6 +88,7 @@ enum MarkdownStyler { // NSImage rendering reuses the existing, proven machinery. result += styleBlockLatex(ctx) result += styleInlineLatex(ctx) + result += styleDiagramBlocks(ctx) result += styleImageEmbeds(ctx) result += styleImageLinks(ctx) result += styleTables(ctx)