From ac95514056bc817240c38b1b47b9f471ce66b581 Mon Sep 17 00:00:00 2001 From: Christine Tham Date: Sat, 11 Jul 2026 16:55:08 +1000 Subject: [PATCH 1/3] Add DiagramRenderer service for inline fenced-diagram rendering Introduces a `DiagramRenderer` protocol (mirroring `LatexRenderer`) plus a `diagrams` slot in `MarkdownEditorServices`, and a `styleDiagramBlocks` pass that intercepts standalone fenced code blocks whose info string a renderer claims (e.g. ```mermaid) and draws the returned image in place of the source via the existing `appendRenderedStandaloneBlock` machinery. Moving the caret into the block reveals the raw source for editing; the code background is cleared so no filled box shows behind the diagram. Defaults to a NoOpDiagramRenderer, so existing embedders are unaffected. Co-Authored-By: Claude Opus 4.8 --- .../Services/MarkdownEditorServices.swift | 40 +++++++++++ .../Styling/MarkdownStyler+Diagrams.swift | 72 +++++++++++++++++++ .../Styling/MarkdownStyler.swift | 1 + 3 files changed, 113 insertions(+) create mode 100644 Sources/MarkdownEngine/Styling/MarkdownStyler+Diagrams.swift diff --git a/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift b/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift index 59e9b750..494cecac 100644 --- a/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift +++ b/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift @@ -184,6 +184,43 @@ 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`. + /// - 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) -> 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) -> DiagramRenderResult? { nil } +} + // MARK: - Event Bus /// Optional notification-name bridge that lets the editor communicate with @@ -304,6 +341,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 +349,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..1f999919 --- /dev/null +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Diagrams.swift @@ -0,0 +1,72 @@ +// +// 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] = [] + 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 + ) + 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])) + + _ = 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: .collapsedSource(markerTexts: [ + ctx.nsText.substring(with: token.markerRanges[0]), + ctx.nsText.substring(with: token.markerRanges[1]) + ]), + ctx: ctx, + attrs: &attrs + ) + } + return attrs + } + + /// 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) From e69634257d248975d9793befbf74bbe3bdca5b4c Mon Sep 17 00:00:00 2001 From: Christine Tham Date: Sat, 11 Jul 2026 17:13:18 +1000 Subject: [PATCH 2/3] Diagrams: pass dark-mode + clamp wide diagrams to reading width `DiagramRenderer.render` now receives `isDarkMode` (resolved from the text view's effective appearance, like rendered tables) so renderers can match light/dark. `styleDiagramBlocks` also measures the reading column and routes diagrams wider than it through the existing `.collapsedSourceScrollable` overlay (with a stable per-occurrence sourceID) instead of letting them overflow the text view. Co-Authored-By: Claude Opus 4.8 --- .../Services/MarkdownEditorServices.swift | 7 +-- .../Styling/MarkdownStyler+Diagrams.swift | 47 +++++++++++++++++-- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift b/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift index 494cecac..ce393e93 100644 --- a/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift +++ b/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift @@ -197,11 +197,12 @@ public struct NoOpLatexRenderer: LatexRenderer { 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`. + /// 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) -> DiagramRenderResult? + func render(source: String, language: String, theme: MarkdownEditorTheme, isDarkMode: Bool) -> DiagramRenderResult? } /// Output of a diagram render call. @@ -218,7 +219,7 @@ public struct DiagramRenderResult: Sendable { /// 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) -> DiagramRenderResult? { nil } + public func render(source: String, language: String, theme: MarkdownEditorTheme, isDarkMode: Bool) -> DiagramRenderResult? { nil } } // MARK: - Event Bus diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Diagrams.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Diagrams.swift index 1f999919..7ac5dbfb 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Diagrams.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Diagrams.swift @@ -16,6 +16,16 @@ 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 } @@ -33,7 +43,8 @@ extension MarkdownStyler { let result = ctx.services.diagrams.render( source: source, language: language, - theme: ctx.configuration.theme + theme: ctx.configuration.theme, + isDarkMode: isDark ) else { continue } @@ -41,6 +52,25 @@ extension MarkdownStyler { // 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 mode: RenderedStandaloneBlockMode + if result.size.width > containerWidth + 0.5 { + let occurrence = occurrenceBySource[source, default: 0] + occurrenceBySource[source] = occurrence + 1 + mode = .collapsedSourceScrollable( + markerTexts: markerTexts, + displayWidth: containerWidth, + sourceID: diagramSourceID(for: source, occurrence: occurrence) + ) + } else { + mode = .collapsedSource(markerTexts: markerTexts) + } + _ = appendRenderedStandaloneBlock( for: token, rawContent: rawSource, @@ -49,10 +79,7 @@ extension MarkdownStyler { paragraphSpacingBefore: ctx.configuration.blockLatex.paragraphSpacingBefore, paragraphSpacing: ctx.configuration.blockLatex.paragraphSpacing, alignment: .center, - mode: .collapsedSource(markerTexts: [ - ctx.nsText.substring(with: token.markerRanges[0]), - ctx.nsText.substring(with: token.markerRanges[1]) - ]), + mode: mode, ctx: ctx, attrs: &attrs ) @@ -60,6 +87,16 @@ extension MarkdownStyler { 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? { From 728b9f5845d6921c96fc30128a8696402c005e8c Mon Sep 17 00:00:00 2001 From: Christine Tham Date: Sun, 12 Jul 2026 09:42:46 +1000 Subject: [PATCH 3/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../MarkdownEngine/Styling/MarkdownStyler+Diagrams.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Diagrams.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Diagrams.swift index 7ac5dbfb..78f3a56e 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Diagrams.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Diagrams.swift @@ -58,14 +58,16 @@ extension MarkdownStyler { ] // 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 { - let occurrence = occurrenceBySource[source, default: 0] - occurrenceBySource[source] = occurrence + 1 mode = .collapsedSourceScrollable( markerTexts: markerTexts, displayWidth: containerWidth, - sourceID: diagramSourceID(for: source, occurrence: occurrence) + sourceID: diagramSourceID(for: occurrenceKey, occurrence: occurrence) ) } else { mode = .collapsedSource(markerTexts: markerTexts)