Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ The renderer targets the subset of CommonMark + GitHub-flavored Markdown that LL
### Supported

- [x] Headings (`#` … `######`)
- [x] Paragraphs with soft and hard line breaks
- [x] Paragraphs with soft and hard line breaks (adjacent headings and paragraphs share one text view, so a text selection can span them)
- [x] Images (`![alt](url)`) — block-level, opt-in via the experimental `ImageConfig` (remote-allowlist, asset-catalog, and bundled-resource sources; tap to open the built-in fullscreen viewer)
- [x] **Bold**, *italic*, ***bold-italic***, ~~strikethrough~~
- [x] `Inline code`
Expand Down
1 change: 1 addition & 0 deletions Sources/MarkdownText/Block/Document+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ extension Markdown.Document {
return self
.blockConvertibleChildren
.map { $0.convert(attributeContainer: NSAttributeContainer(), config: config) }
.groupingAdjacentTextBlocks(config: config)
}
}
1 change: 1 addition & 0 deletions Sources/MarkdownText/Block/Paragraph+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ extension Paragraph: BlockConvertible {
}
container[.foregroundColor] = MDColor(config.paragraphStyle.textColor)
let paragraphContent: NSMutableAttributedString = self.buildParagraphContent(container: container, config: config)
paragraphContent.applyParagraphLayout()
return MarkdownRenderable.paragraph(id: self.id, content: paragraphContent)
}

Expand Down
4 changes: 3 additions & 1 deletion Sources/MarkdownText/Models/MarkdownRenderConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ public struct MarkdownRenderConfig: Hashable, Sendable {
/// Configuration that controls code-block syntax-highlighting styling.
public let codeBlockConfig: CodeBlockConfig
/// Vertical spacing between adjacent blocks (paragraphs, headings,
/// code blocks, lists, etc.). Defaults to 30.
/// code blocks, lists, etc.). Defaults to 30. Adjacent top-level
/// paragraphs share one text view, so they're never spaced closer than
/// their 5-point line spacing.
public let blockSpacing: CGFloat
/// Configuration for the built-in "Select more text" edit-menu action and the
/// modal it presents. Enabled by default.
Expand Down
127 changes: 127 additions & 0 deletions Sources/MarkdownText/Models/MarkdownRenderable+TextGroup.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.
//

import Foundation
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif

extension NSAttributedString.Key {
/// Library-internal: marks each block of a `.textGroup`, so its text view can
/// expose one accessibility element per block.
static let textBlock = NSAttributedString.Key("markdown.textBlock")
}

/// The value of `.textBlock`: the block's ID, and its level if it's a heading.
struct TextBlock: Hashable {
let id: String
let headingLevel: Int?
}

extension MarkdownRenderable {
/// Line spacing paragraphs render with; headings have none.
static let paragraphLineSpacing: CGFloat = 5

/// The text of headings and paragraphs: the blocks that can share a text view.
fileprivate var textBlockContent: NSMutableAttributedString? {
switch self {
case .paragraph(_, let content), .heading(_, _, let content): return content
default: return nil
}
}

fileprivate var headingLevel: Int? {
guard case .heading(_, let level, _) = self else { return nil }
return level
}

/// The line spacing a text block renders with.
fileprivate var lineSpacing: CGFloat {
isHeading ? 0 : Self.paragraphLineSpacing
}
}

extension NSMutableAttributedString {
/// Applies the line spacing and alignment paragraphs render with.
func applyParagraphLayout() {
updateParagraphStyle {
$0.lineSpacing = MarkdownRenderable.paragraphLineSpacing
$0.alignment = .left
}
}
}

extension Array where Element == MarkdownRenderable {

/// Merges each run of adjacent non-empty headings and paragraphs into a
/// single `.textGroup`, so the run renders in one text view and a text
/// selection can span it.
///
/// A group keeps its first block's ID, so as streaming appends blocks
/// SwiftUI keeps updating the same text view.
func groupingAdjacentTextBlocks(config: MarkdownRenderConfig) -> [MarkdownRenderable] {
var result: [MarkdownRenderable] = []
var run: [MarkdownRenderable] = []

func flushRun() {
if run.count > 1 {
result.append(.textGroup(id: run[0].id, blocks: run, content: run.joinedAsTextBlocks(config: config)))
} else {
result += run
}
run.removeAll()
}

for renderable in self {
// An empty block has no line to space from its neighbors, so it keeps its own view.
if let content = renderable.textBlockContent, content.length > 0 {
run.append(renderable)
} else {
flushRun()
result.append(renderable)
}
}
flushRun()
return result
}

/// Joins text blocks with paragraph breaks, spaced to match the
/// `blockSpacing` gap that `BlockView` puts between separate blocks, but
/// never closer than the line spacing TextKit puts at a break.
private func joinedAsTextBlocks(config: MarkdownRenderConfig) -> NSMutableAttributedString {
let separator = NSAttributedString(string: "\n", attributes: [.font: config.paragraphStyle.textFonts.normal])
let result = NSMutableAttributedString()
var previousLineSpacing: CGFloat = 0
for block in self {
guard let content = block.textBlockContent else { continue }
if result.length > 0 {
result.append(separator)
}
let start = result.length
result.append(content)
result.addAttribute(.textBlock, value: TextBlock(id: block.id, headingLevel: block.headingLevel), range: NSRange(location: start, length: content.length))
if start > 0 {
// Only the block's first line: later lines come from soft/hard breaks.
let firstLine = (content.string as NSString).paragraphRange(for: NSRange(location: 0, length: 0))
let spacing = Swift.max(0, config.blockSpacing - lineSpacingAtBreak(after: previousLineSpacing, before: block.lineSpacing))
result.updateParagraphStyle(in: NSRange(location: start + firstLine.location, length: firstLine.length)) { $0.paragraphSpacingBefore = spacing }
}
previousLineSpacing = block.lineSpacing
}
return result
}

/// The line spacing TextKit already puts at a break between two blocks: the
/// next block's in TextKit 2 (UITextView), the previous block's in TextKit 1 (NSTextView).
private func lineSpacingAtBreak(after previous: CGFloat, before next: CGFloat) -> CGFloat {
#if canImport(UIKit)
return next
#else
return previous
#endif
}
}
14 changes: 14 additions & 0 deletions Sources/MarkdownText/Models/MarkdownRenderable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ indirect enum MarkdownRenderable: Identifiable, Equatable, @unchecked Sendable {
/// To be rendered as a paragraph
case paragraph(id: String, content: NSMutableAttributedString)

/// Adjacent top-level headings and paragraphs rendered in a single text view
/// so a text selection can span them. `content` joins the `blocks` with
/// paragraph breaks spaced by `blockSpacing`, or by the line spacing where
/// that's larger. See `groupingAdjacentTextBlocks(config:)`.
case textGroup(id: String, blocks: [MarkdownRenderable], content: NSMutableAttributedString)

/// To be rendered as LaTeX (Math formatting)
case latex(id: String, content: String)

Expand Down Expand Up @@ -49,6 +55,7 @@ indirect enum MarkdownRenderable: Identifiable, Equatable, @unchecked Sendable {
var id: String {
switch self {
case .paragraph(let id, _): return id
case .textGroup(let id, _, _): return id
case .latex(let id, _): return id
case .heading(let id, _, _): return id
case .orderedList(let id, _): return id
Expand All @@ -74,6 +81,13 @@ indirect enum MarkdownRenderable: Identifiable, Equatable, @unchecked Sendable {
default: return false
}
}

var isHeading: Bool {
switch self {
case .heading: return true
default: return false
}
}
}

struct MarkdownListItem: Equatable {
Expand Down
5 changes: 4 additions & 1 deletion Sources/MarkdownText/Models/RenderableDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public struct RenderableDocument: Equatable, Sendable {
attributes[.kern] = kern
}
let content = NSMutableAttributedString(string: plainText, attributes: attributes)
content.applyParagraphLayout()
self.init(renderables: [.paragraph(id: UUID().uuidString, content: content)])
}

Expand Down Expand Up @@ -84,6 +85,8 @@ extension MarkdownRenderable {
switch self {
case .paragraph(_, let content), .heading(_, _, let content):
return content.string
case .textGroup(_, let blocks, _):
return blocks.compactMap { $0.plainText }.joined(separator: "\n\n")
case .latex(_, let content):
return content
case .orderedList(_, let items):
Expand Down Expand Up @@ -129,7 +132,7 @@ private extension BlockQuoteType {
extension MarkdownRenderable {
func extractAttributedStrings() -> [NSAttributedString] {
switch self {
case .paragraph(_, let str):
case .paragraph(_, let str), .textGroup(_, _, let str):
return [str]
case .orderedList(_, let items):
return items.flatMap { $0.attributedStrings() }
Expand Down
8 changes: 3 additions & 5 deletions Sources/MarkdownText/UI/BlockView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,12 @@ struct SingleBlockView: View {
var body: some View {
Group {
switch renderable {
case .heading(_, _, let contents):
case .heading(_, _, let contents), .paragraph(_, let contents), .textGroup(_, _, let contents):
// One branch for all text blocks, so a block that becomes a group while streaming keeps its text view.
ParagraphView(contents: contents)
.transition(.opacity)
.accessibilityAddTraits(.isHeader)
case .paragraph(_, let contents):
ParagraphView(contents: contents, lineSpacing: 5)
.fixedSize(horizontal: false, vertical: true)
.transition(.opacity)
.accessibilityAddTraits(renderable.isHeading ? .isHeader : [])
case .latex(_, let latexString):
ScrollView(.horizontal) {
HStack(spacing: 0) {
Expand Down
2 changes: 1 addition & 1 deletion Sources/MarkdownText/UI/Images/PinchZoomView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ struct PinchZoomView: View {
GeometryReader { geometry in
image
.resizable()
.aspectRatio(contentMode: .fit)
.scaledToFit()
.scaleEffect(scale * activeScale)
.offset(offset)
.background(
Expand Down
Loading
Loading