Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions QuickMD/QuickMD/BlockHeightMeasurer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,12 @@ enum BlockLayout {
/// the bitmap is decoded, so this is what a measured row starts at.
static let placeholderHeight: CGFloat = 100
static let maxDisplayWidth: CGFloat = 600

/// Scale the fitted 100% width, then keep the result inside the column.
static func displayWidth(fontScale: CGFloat, contentWidth: CGFloat) -> CGFloat {
let available = max(1, contentWidth)
return min(available, min(maxDisplayWidth, available) * fontScale)
}
}

// MARK: Display math
Expand Down
24 changes: 22 additions & 2 deletions QuickMD/QuickMD/MarkdownView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ struct MarkdownView: View {
@State private var currentMatchIndex: Int = 0
@State private var matchBlockIds: [String] = []
@State private var scrollTrigger: Int = 0
@State private var graphicPreview: GraphicPreview?
@State private var keyMonitor: Any?
/// The NSWindow hosting this view (set by `WindowConfigurator`); the key
/// monitor uses it to ignore events addressed to other tabs' windows.
Expand Down Expand Up @@ -362,6 +363,13 @@ struct MarkdownView: View {
/// modifier chain no longer type-checks inside the compiler's budget.
private var configuredDocumentStack: some View {
documentStack
.disabled(graphicPreview != nil)
.accessibilityHidden(graphicPreview != nil)
.overlay {
if let graphicPreview {
GraphicPreviewOverlay(preview: graphicPreview) { self.graphicPreview = nil }
}
}
.background(theme.backgroundColor)
.background(WindowConfigurator { window in
// Make every QuickMD document window prefer to join existing windows
Expand Down Expand Up @@ -557,6 +565,13 @@ struct MarkdownView: View {
if let hostWindow, let eventWindow = event.window, eventWindow !== hostWindow {
return event
}
if graphicPreview != nil {
if event.keyCode == 53 {
graphicPreview = nil
return nil
}
return event
}
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)

if flags.contains(.command) && event.charactersIgnoringModifiers == "g" {
Expand Down Expand Up @@ -680,7 +695,9 @@ struct MarkdownView: View {
.padding(.vertical, Metrics.codeOuterVerticalPadding)

case .image(let url, let alt):
ImageBlockView(url: url, alt: alt, theme: theme, documentURL: documentURL)
ImageBlockView(url: url, alt: alt, theme: theme, documentURL: documentURL,
fontScale: scale, contentWidth: contentWidth,
onEnlarge: { graphicPreview = $0 })
.padding(.vertical, Metrics.imageOuterVerticalPadding)

case .blockquote(let content, let level):
Expand Down Expand Up @@ -721,7 +738,10 @@ struct MarkdownView: View {

case .mermaidDiagram(let source):
MermaidBlockView(blockId: block.id, source: source, theme: theme,
heightCache: heightCache)
heightCache: heightCache, fontScale: scale,
contentWidth: contentWidth,
onEnlarge: { graphicPreview = $0 })
.id("\(block.id)|\(scale)|\(contentWidth)|\(theme.isDark)")
.padding(.vertical, Metrics.mermaidOuterVerticalPadding)
}
}
Expand Down
32 changes: 30 additions & 2 deletions QuickMD/QuickMD/Resources/mermaid-template.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
<div id="output"></div>
<script src="mermaid.min.js"></script>
<script>
function renderDiagram(source, isDark) {
var resizeDiagram = null;
window.addEventListener('resize', function() {
if (resizeDiagram) resizeDiagram();
});

function renderDiagram(source, isDark, scale = 1, fitWindow = false) {
// renderDiagram may run repeatedly on this page (theme switches re-render
// in place). mermaid.render can leave its temp element behind after a
// failed render — remove it or the next render throws on a duplicate id.
Expand All @@ -28,6 +33,7 @@
});

const container = document.getElementById('output');
resizeDiagram = null;
container.innerHTML = '';

// Report height back to Swift. The zoom viewer hosts this same template
Expand All @@ -44,7 +50,29 @@

mermaid.render('diagram', source).then(function(result) {
container.innerHTML = result.svg;
reportHeight();
const svg = container.querySelector('svg');
const bounds = svg.viewBox.baseVal;
const naturalWidth = bounds.width;
const naturalHeight = bounds.height;
if (fitWindow) {
document.body.style.display = 'flex';
document.body.style.alignItems = 'center';
document.body.style.minHeight = '100vh';
container.style.width = '100%';
}
resizeDiagram = function() {
if (naturalWidth > 0 && naturalHeight > 0) {
const factor = fitWindow
? Math.min(window.innerWidth / naturalWidth, window.innerHeight / naturalHeight)
: Math.min(scale * Math.min(1, window.innerWidth / naturalWidth),
window.innerWidth / naturalWidth);
svg.style.maxWidth = 'none';
svg.style.width = (naturalWidth * factor) + 'px';
svg.style.height = (naturalHeight * factor) + 'px';
}
reportHeight();
};
resizeDiagram();
}).catch(function(err) {
container.innerHTML = '<pre style="color:#e55;font-size:12px;padding:8px;">' +
err.message.replace(/</g, '&lt;') + '</pre>';
Expand Down
102 changes: 80 additions & 22 deletions QuickMD/QuickMD/Views/ImageBlockView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ struct ImageBlockView: View {
let alt: String
let theme: MarkdownTheme
let documentURL: URL?
let fontScale: CGFloat
let contentWidth: CGFloat
var onEnlarge: (GraphicPreview) -> Void = { _ in }

/// Display width cap and the pre-load placeholder height — see
/// `BlockLayout.ImageBlock` (shared with `BlockHeightMeasurer`, which starts
Expand Down Expand Up @@ -43,11 +46,7 @@ struct ImageBlockView: View {
ProgressView()
.frame(height: Metrics.placeholderHeight)
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: Metrics.maxDisplayWidth)
.clipShape(RoundedRectangle(cornerRadius: 8))
graphicButton(image, fileURL: nil)
case .failure:
imageErrorView
@unknown default:
Expand All @@ -62,22 +61,32 @@ struct ImageBlockView: View {

if !alt.isEmpty {
Text(alt)
.font(.system(size: 12))
.font(.system(size: 12 * fontScale))
.foregroundColor(theme.secondaryTextColor)
.italic()
}
}

private func graphicButton(_ image: Image, fileURL: URL?) -> some View {
Button {
onEnlarge(.image(image, title: alt, fileURL: fileURL))
} label: {
image.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: Metrics.displayWidth(fontScale: fontScale, contentWidth: contentWidth))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.help("Click to enlarge image")
.accessibilityLabel(alt.isEmpty ? "Enlarge image" : "Enlarge image: \(alt)")
}

// MARK: - Local Image View

@ViewBuilder
private func localImageView(for fileURL: URL) -> some View {
if let image = localImage {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: Metrics.maxDisplayWidth)
.clipShape(RoundedRectangle(cornerRadius: 8))
graphicButton(Image(nsImage: image), fileURL: fileURL)
} else if isLoadingLocal {
ProgressView()
.frame(height: Metrics.placeholderHeight)
Expand Down Expand Up @@ -109,7 +118,7 @@ struct ImageBlockView: View {
.buttonStyle(.bordered)
.controlSize(.small)
}
.frame(maxWidth: Metrics.maxDisplayWidth)
.frame(maxWidth: Metrics.displayWidth(fontScale: fontScale, contentWidth: contentWidth))
.padding(.vertical, 12)
}

Expand Down Expand Up @@ -162,25 +171,22 @@ struct ImageBlockView: View {

/// Efficiently load and downsample image using ImageIO
/// This prevents loading huge images (e.g., 4K) at full resolution
private static func loadDownsampledImage(from url: URL, maxPixelSize: Int) -> NSImage? {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
// Fallback to NSImage if CGImageSource fails
private nonisolated static func loadDownsampledImage(from url: URL, maxPixelSize: Int) -> NSImage? {
guard let cgImage = loadThumbnail(from: url, maxPixelSize: maxPixelSize) else {
return NSImage(contentsOf: url)
}
return NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
}

fileprivate nonisolated static func loadThumbnail(from url: URL, maxPixelSize: Int) -> CGImage? {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
let options: [CFString: Any] = [
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true
]

guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
// Fallback to NSImage if thumbnail creation fails
return NSImage(contentsOf: url)
}

return NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
return CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
}

// MARK: - URL Resolution
Expand Down Expand Up @@ -219,3 +225,55 @@ struct ImageBlockView: View {
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}

// The document owns presentation so the preview covers the whole window,
// including sidebars, and survives virtualization of the originating row.
enum GraphicPreview {
case image(Image, title: String, fileURL: URL?)
case diagram(String, isDark: Bool)
}

struct GraphicPreviewOverlay: View {
let preview: GraphicPreview
let onClose: () -> Void
@State private var detailedImage: NSImage?

var body: some View {
Group {
switch preview {
case .image(let image, let title, let fileURL):
VStack(spacing: 0) {
HStack {
Text(title.isEmpty ? "Image" : title).lineLimit(1)
Spacer()
Button("Done", action: onClose)
.keyboardShortcut(.cancelAction)
}
.padding()
GeometryReader { geometry in
(detailedImage.map { Image(nsImage: $0) } ?? image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: geometry.size.width, height: geometry.size.height)
}
.padding(16)
}
.task(id: fileURL) {
detailedImage = nil
guard let fileURL else { return }
let loaded = await Task.detached(priority: .userInitiated) {
ImageBlockView.loadThumbnail(from: fileURL, maxPixelSize: 4096)
}.value
guard !Task.isCancelled else { return }
if let loaded {
detailedImage = NSImage(cgImage: loaded, size: NSSize(width: loaded.width, height: loaded.height))
}
}
case .diagram(let source, let isDark):
MermaidZoomView(source: source, isDark: isDark, onClose: onClose)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.background)
}
}
Loading
Loading