Themes (#168)
* Add dynamic color extension * Add a basic theme * Add DocC theme * Fix block spacing logic * Update theme initializer * Add DocC theme tests * Add GitHub theme
41
Sources/MarkdownUI/Common/Color+RGBA.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
public init(rgba: UInt32) {
|
||||
self.init(
|
||||
red: CGFloat((rgba & 0xff00_0000) >> 24) / 255.0,
|
||||
green: CGFloat((rgba & 0x00ff_0000) >> 16) / 255.0,
|
||||
blue: CGFloat((rgba & 0x0000_ff00) >> 8) / 255.0,
|
||||
opacity: CGFloat(rgba & 0x0000_00ff) / 255.0
|
||||
)
|
||||
}
|
||||
|
||||
public init(light: @escaping @autoclosure () -> Color, dark: @escaping @autoclosure () -> Color) {
|
||||
#if os(macOS)
|
||||
self.init(
|
||||
nsColor: .init(name: nil) { appearance in
|
||||
if appearance.bestMatch(from: [.aqua, .darkAqua]) == .aqua {
|
||||
return NSColor(light())
|
||||
} else {
|
||||
return NSColor(dark())
|
||||
}
|
||||
}
|
||||
)
|
||||
#elseif os(iOS) || os(tvOS)
|
||||
self.init(
|
||||
uiColor: .init { traitCollection in
|
||||
switch traitCollection.userInterfaceStyle {
|
||||
case .unspecified, .light:
|
||||
return UIColor(light())
|
||||
case .dark:
|
||||
return UIColor(dark())
|
||||
@unknown default:
|
||||
return UIColor(light())
|
||||
}
|
||||
}
|
||||
)
|
||||
#elseif os(watchOS)
|
||||
self.init(uiColor: dark())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,16 @@ extension Size {
|
||||
}
|
||||
|
||||
extension View {
|
||||
public func frame(
|
||||
width: Size? = nil,
|
||||
height: Size? = nil,
|
||||
alignment: Alignment = .center
|
||||
) -> some View {
|
||||
self.modifier(FrameModifier(width: width, height: height, alignment: alignment))
|
||||
}
|
||||
|
||||
public func frame(minWidth: Size, alignment: Alignment = .center) -> some View {
|
||||
self.modifier(FrameModifier(minWidth: minWidth, alignment: alignment))
|
||||
self.modifier(FrameModifier2(minWidth: minWidth, alignment: alignment))
|
||||
}
|
||||
|
||||
public func padding(_ edges: Edge.Set = .all, _ length: Size) -> some View {
|
||||
@@ -55,6 +63,22 @@ extension View {
|
||||
private struct FrameModifier: ViewModifier {
|
||||
@Environment(\.fontStyle) private var fontStyle
|
||||
|
||||
let width: Size?
|
||||
let height: Size?
|
||||
let alignment: Alignment
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.frame(
|
||||
width: self.width?.points(relativeTo: self.fontStyle),
|
||||
height: self.height?.points(relativeTo: self.fontStyle),
|
||||
alignment: self.alignment
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct FrameModifier2: ViewModifier {
|
||||
@Environment(\.fontStyle) private var fontStyle
|
||||
|
||||
let minWidth: Size
|
||||
let alignment: Alignment
|
||||
|
||||
|
||||
@@ -19,90 +19,7 @@ public struct BlockStyle {
|
||||
}
|
||||
|
||||
extension BlockStyle {
|
||||
public static var defaultImage: BlockStyle {
|
||||
self.default
|
||||
}
|
||||
|
||||
public static func defaultImage(alignment: HorizontalAlignment) -> BlockStyle {
|
||||
BlockStyle { label in
|
||||
ZStack {
|
||||
label
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .init(horizontal: alignment, vertical: .center))
|
||||
}
|
||||
}
|
||||
|
||||
public static var `default`: BlockStyle {
|
||||
BlockStyle { label in
|
||||
label
|
||||
.lineSpacing(.em(0.15))
|
||||
}
|
||||
}
|
||||
|
||||
public static var defaultBlockquote: BlockStyle {
|
||||
BlockStyle { label in
|
||||
label
|
||||
.markdownFontStyle {
|
||||
$0.italic()
|
||||
}
|
||||
.padding(.leading, .em(2))
|
||||
.padding(.trailing, .em(1))
|
||||
}
|
||||
}
|
||||
|
||||
public static var defaultCodeBlock: BlockStyle {
|
||||
BlockStyle { label in
|
||||
label.markdownFontStyle {
|
||||
$0.monospaced()
|
||||
}
|
||||
.padding(.leading, .em(1))
|
||||
.lineSpacing(.em(0.15))
|
||||
}
|
||||
}
|
||||
|
||||
public static var defaultHeading1: BlockStyle {
|
||||
defaultHeading(level: 1)
|
||||
}
|
||||
|
||||
public static var defaultHeading2: BlockStyle {
|
||||
defaultHeading(level: 2)
|
||||
}
|
||||
|
||||
public static var defaultHeading3: BlockStyle {
|
||||
defaultHeading(level: 3)
|
||||
}
|
||||
|
||||
public static var defaultHeading4: BlockStyle {
|
||||
defaultHeading(level: 4)
|
||||
}
|
||||
|
||||
public static var defaultHeading5: BlockStyle {
|
||||
defaultHeading(level: 5)
|
||||
}
|
||||
|
||||
public static var defaultHeading6: BlockStyle {
|
||||
defaultHeading(level: 6)
|
||||
}
|
||||
|
||||
private static func defaultHeading(level: Int) -> BlockStyle {
|
||||
BlockStyle { label in
|
||||
label.markdownFontStyle {
|
||||
$0.bold()
|
||||
.size(headingSizes[level - 1])
|
||||
}
|
||||
.markdownBlockSpacing(top: .rem(1.5))
|
||||
}
|
||||
}
|
||||
|
||||
public static var defaultThematicBreak: BlockStyle {
|
||||
BlockStyle { _ in
|
||||
Divider()
|
||||
.markdownBlockSpacing(top: .rem(2), bottom: .rem(2))
|
||||
}
|
||||
public static var unit: BlockStyle {
|
||||
BlockStyle { $0 }
|
||||
}
|
||||
}
|
||||
|
||||
private let headingSizes: [Size] = [
|
||||
.em(2), .em(1.5), .em(1.17),
|
||||
.em(1), .em(0.83), .em(0.67),
|
||||
]
|
||||
|
||||
@@ -56,7 +56,7 @@ extension FontStyle: Hashable {
|
||||
}
|
||||
|
||||
extension FontStyle {
|
||||
public static var `default`: FontStyle {
|
||||
public static var body: FontStyle {
|
||||
#if os(macOS)
|
||||
return .system(size: 13)
|
||||
#elseif os(iOS)
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct InlineStyle {
|
||||
var update: (inout AttributeContainer) -> Void
|
||||
var transform: (inout AttributeContainer) -> Void
|
||||
|
||||
public init(update: @escaping (inout AttributeContainer) -> Void) {
|
||||
self.update = update
|
||||
public init(transform: @escaping (_ attributes: inout AttributeContainer) -> Void) {
|
||||
self.transform = transform
|
||||
}
|
||||
|
||||
func updating(_ attributes: AttributeContainer) -> AttributeContainer {
|
||||
func transforming(_ attributes: AttributeContainer) -> AttributeContainer {
|
||||
var newAttributes = attributes
|
||||
update(&newAttributes)
|
||||
transform(&newAttributes)
|
||||
return newAttributes
|
||||
}
|
||||
}
|
||||
|
||||
extension InlineStyle {
|
||||
public static var `default`: Self {
|
||||
public static var unit: Self {
|
||||
.init { _ in }
|
||||
}
|
||||
|
||||
public static var monospaced: Self {
|
||||
.monospaced()
|
||||
}
|
||||
|
||||
public static func monospaced(backgroundColor: Color? = nil) -> Self {
|
||||
public static func monospaced(size: Size = .em(1), backgroundColor: Color? = nil) -> Self {
|
||||
.init { attributes in
|
||||
attributes.fontStyle = attributes.fontStyle?.monospaced()
|
||||
attributes.fontStyle = attributes.fontStyle?.monospaced().size(size)
|
||||
attributes.backgroundColor = backgroundColor
|
||||
}
|
||||
}
|
||||
@@ -36,13 +32,6 @@ extension InlineStyle {
|
||||
}
|
||||
}
|
||||
|
||||
public static var italicUnderline: Self {
|
||||
.init { attributes in
|
||||
attributes.fontStyle = attributes.fontStyle?.italic()
|
||||
attributes.underlineStyle = .single
|
||||
}
|
||||
}
|
||||
|
||||
public static var bold: Self {
|
||||
.init { attributes in
|
||||
attributes.fontStyle = attributes.fontStyle?.bold()
|
||||
@@ -61,16 +50,9 @@ extension InlineStyle {
|
||||
}
|
||||
}
|
||||
|
||||
public static var redacted: Self {
|
||||
public static func foregroundColor(_ color: Color) -> Self {
|
||||
.init { attributes in
|
||||
attributes.foregroundColor = .primary
|
||||
attributes.backgroundColor = .primary
|
||||
}
|
||||
}
|
||||
|
||||
public static var underlineDot: Self {
|
||||
.init { attributes in
|
||||
attributes.underlineStyle = .init(pattern: .dot)
|
||||
attributes.foregroundColor = color
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,23 +81,23 @@ extension ListMarkerStyle where Configuration == ListItemConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
private struct Bullet: View {
|
||||
public struct Bullet: View {
|
||||
@Environment(\.fontStyle.size) private var fontSize
|
||||
private let image: SwiftUI.Image
|
||||
|
||||
var body: some View {
|
||||
public var body: some View {
|
||||
image.font(.system(size: round(fontSize / 3)))
|
||||
}
|
||||
|
||||
static var disc: Bullet {
|
||||
public static var disc: Bullet {
|
||||
.init(image: .init(systemName: "circle.fill"))
|
||||
}
|
||||
|
||||
static var circle: Bullet {
|
||||
public static var circle: Bullet {
|
||||
.init(image: .init(systemName: "circle"))
|
||||
}
|
||||
|
||||
static var square: Bullet {
|
||||
public static var square: Bullet {
|
||||
.init(image: .init(systemName: "square.fill"))
|
||||
}
|
||||
}
|
||||
@@ -105,16 +105,3 @@ private struct Bullet: View {
|
||||
public struct TaskListItemConfiguration {
|
||||
public var isCompleted: Bool
|
||||
}
|
||||
|
||||
extension ListMarkerStyle where Configuration == TaskListItemConfiguration {
|
||||
// MARK: - Tasks
|
||||
|
||||
public static var checkmarkSquareFill: ListMarkerStyle {
|
||||
ListMarkerStyle { configuration in
|
||||
SwiftUI.Image(systemName: configuration.isCompleted ? "checkmark.square.fill" : "square")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.imageScale(.small)
|
||||
.frame(minWidth: .em(1.5), alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,16 @@ import SwiftUI
|
||||
|
||||
public struct TableBorderStyle {
|
||||
let border: TableBorder
|
||||
let style: AnyShapeStyle
|
||||
let color: Color
|
||||
let strokeStyle: StrokeStyle
|
||||
|
||||
public init<S: ShapeStyle>(_ border: TableBorder = .all, style: S, strokeStyle: StrokeStyle) {
|
||||
public init(_ border: TableBorder = .all, color: Color, strokeStyle: StrokeStyle) {
|
||||
self.border = border
|
||||
self.style = .init(style)
|
||||
self.color = color
|
||||
self.strokeStyle = strokeStyle
|
||||
}
|
||||
|
||||
public init<S: ShapeStyle>(_ border: TableBorder = .all, style: S, width: CGFloat = 1) {
|
||||
self.init(border, style: style, strokeStyle: .init(lineWidth: width))
|
||||
}
|
||||
}
|
||||
|
||||
extension TableBorderStyle {
|
||||
public static var `default`: TableBorderStyle {
|
||||
.init(style: Color.secondary)
|
||||
public init(_ border: TableBorder = .all, color: Color, width: CGFloat = 1) {
|
||||
self.init(border, color: color, strokeStyle: .init(lineWidth: width))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ public struct TableCellBackgroundStyle {
|
||||
}
|
||||
|
||||
extension TableCellBackgroundStyle {
|
||||
public static var `default`: TableCellBackgroundStyle {
|
||||
public static var clear: TableCellBackgroundStyle {
|
||||
TableCellBackgroundStyle { _ in
|
||||
Color.clear
|
||||
}
|
||||
|
||||
@@ -21,16 +21,3 @@ public struct TableCellStyle {
|
||||
self.makeBody = { AnyView(makeBody($0)) }
|
||||
}
|
||||
}
|
||||
|
||||
extension TableCellStyle {
|
||||
public static var `default`: TableCellStyle {
|
||||
TableCellStyle { configuration in
|
||||
configuration.label
|
||||
.markdownFontStyle { fontStyle in
|
||||
configuration.row == 0 ? fontStyle.bold() : fontStyle
|
||||
}
|
||||
.padding(.horizontal, .em(0.72))
|
||||
.padding(.vertical, .em(0.35))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
74
Sources/MarkdownUI/Theme/Theme+Basic.swift
Normal file
@@ -0,0 +1,74 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Theme {
|
||||
public static let basic = Theme(
|
||||
code: .monospaced(size: .em(0.94)),
|
||||
heading1: BlockStyle { label in
|
||||
label
|
||||
.markdownBlockSpacing(top: .rem(1.5), bottom: .rem(1))
|
||||
.markdownFontStyle { $0.bold().size(.em(2)) }
|
||||
},
|
||||
heading2: BlockStyle { label in
|
||||
label
|
||||
.markdownBlockSpacing(top: .rem(1.5), bottom: .rem(1))
|
||||
.markdownFontStyle { $0.bold().size(.em(1.5)) }
|
||||
},
|
||||
heading3: BlockStyle { label in
|
||||
label
|
||||
.markdownBlockSpacing(top: .rem(1.5), bottom: .rem(1))
|
||||
.markdownFontStyle { $0.bold().size(.em(1.17)) }
|
||||
},
|
||||
heading4: BlockStyle { label in
|
||||
label
|
||||
.markdownBlockSpacing(top: .rem(1.5), bottom: .rem(1))
|
||||
.markdownFontStyle { $0.bold().size(.em(1)) }
|
||||
},
|
||||
heading5: BlockStyle { label in
|
||||
label
|
||||
.markdownBlockSpacing(top: .rem(1.5), bottom: .rem(1))
|
||||
.markdownFontStyle { $0.bold().size(.em(0.83)) }
|
||||
},
|
||||
heading6: BlockStyle { label in
|
||||
label
|
||||
.markdownBlockSpacing(top: .rem(1.5), bottom: .rem(1))
|
||||
.markdownFontStyle { $0.bold().size(.em(0.67)) }
|
||||
},
|
||||
paragraph: BlockStyle { label in
|
||||
label
|
||||
.lineSpacing(.em(0.15))
|
||||
.markdownBlockSpacing(top: .zero, bottom: .em(1))
|
||||
},
|
||||
blockquote: BlockStyle { label in
|
||||
label
|
||||
.markdownFontStyle { $0.italic() }
|
||||
.padding(.leading, .em(2))
|
||||
.padding(.trailing, .em(1))
|
||||
},
|
||||
codeBlock: BlockStyle { label in
|
||||
label.markdownFontStyle { $0.monospaced().size(.em(0.94)) }
|
||||
.padding(.leading, .em(1))
|
||||
.lineSpacing(.em(0.15))
|
||||
.markdownBlockSpacing(top: .zero, bottom: .em(1))
|
||||
},
|
||||
taskListMarker: ListMarkerStyle { configuration in
|
||||
SwiftUI.Image(systemName: configuration.isCompleted ? "checkmark.square.fill" : "square")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.imageScale(.small)
|
||||
.frame(minWidth: .em(1.5), alignment: .trailing)
|
||||
},
|
||||
table: BlockStyle { label in
|
||||
label.markdownBlockSpacing(top: .zero, bottom: .em(1))
|
||||
},
|
||||
tableBorder: TableBorderStyle(color: .secondary),
|
||||
tableCell: TableCellStyle { configuration in
|
||||
configuration.label
|
||||
.markdownFontStyle { configuration.row == 0 ? $0.bold() : $0 }
|
||||
.lineSpacing(.em(0.15))
|
||||
.padding(.horizontal, .em(0.72))
|
||||
.padding(.vertical, .em(0.35))
|
||||
},
|
||||
thematicBreak: BlockStyle { _ in
|
||||
Divider().markdownBlockSpacing(top: .em(2), bottom: .em(2))
|
||||
}
|
||||
)
|
||||
}
|
||||
132
Sources/MarkdownUI/Theme/Theme+DocC.swift
Normal file
@@ -0,0 +1,132 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Theme {
|
||||
public static let docC = Theme(
|
||||
textColor: .text,
|
||||
link: .foregroundColor(.link),
|
||||
heading1: BlockStyle { label in
|
||||
label
|
||||
.markdownBlockSpacing(top: .em(0.8), bottom: .zero)
|
||||
.markdownFontStyle { $0.bold().size(.em(2)) }
|
||||
},
|
||||
heading2: BlockStyle { label in
|
||||
label
|
||||
.lineSpacing(.em(0.0625))
|
||||
.markdownBlockSpacing(top: .em(1.6), bottom: .zero)
|
||||
.markdownFontStyle { $0.bold().size(.em(1.88235)) }
|
||||
},
|
||||
heading3: BlockStyle { label in
|
||||
label
|
||||
.lineSpacing(.em(0.07143))
|
||||
.markdownBlockSpacing(top: .em(1.6), bottom: .zero)
|
||||
.markdownFontStyle { $0.bold().size(.em(1.64706)) }
|
||||
},
|
||||
heading4: BlockStyle { label in
|
||||
label
|
||||
.lineSpacing(.em(0.083335))
|
||||
.markdownBlockSpacing(top: .em(1.6), bottom: .zero)
|
||||
.markdownFontStyle { $0.bold().size(.em(1.41176)) }
|
||||
},
|
||||
heading5: BlockStyle { label in
|
||||
label
|
||||
.lineSpacing(.em(0.09091))
|
||||
.markdownBlockSpacing(top: .em(1.6), bottom: .zero)
|
||||
.markdownFontStyle { $0.bold().size(.em(1.29412)) }
|
||||
},
|
||||
heading6: BlockStyle { label in
|
||||
label
|
||||
.lineSpacing(.em(0.235295))
|
||||
.markdownBlockSpacing(top: .em(1.6), bottom: .zero)
|
||||
.markdownFontStyle { $0.bold() }
|
||||
},
|
||||
paragraph: BlockStyle { label in
|
||||
label
|
||||
.lineSpacing(.em(0.235295))
|
||||
.markdownBlockSpacing(top: .em(0.8), bottom: .zero)
|
||||
},
|
||||
blockquote: BlockStyle { label in
|
||||
label
|
||||
.padding(.all, .rem(0.94118))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background {
|
||||
ZStack {
|
||||
RoundedRectangle.container
|
||||
.fill(Color.asideNoteBackground)
|
||||
RoundedRectangle.container
|
||||
.strokeBorder(Color.asideNoteBorder)
|
||||
}
|
||||
}
|
||||
.markdownBlockSpacing(top: .em(1.6), bottom: .zero)
|
||||
},
|
||||
codeBlock: BlockStyle { label in
|
||||
ScrollView(.horizontal) {
|
||||
label
|
||||
.lineSpacing(.em(0.333335))
|
||||
.markdownFontStyle { $0.monospaced().size(.rem(0.88235)) }
|
||||
.padding(.vertical, 8)
|
||||
.padding(.leading, 14)
|
||||
}
|
||||
.background(Color.codeBackground)
|
||||
.clipShape(.container)
|
||||
.markdownBlockSpacing(top: .em(0.8), bottom: .zero)
|
||||
},
|
||||
image: BlockStyle { label in
|
||||
label
|
||||
.frame(maxWidth: .infinity)
|
||||
.markdownBlockSpacing(top: .em(1.6), bottom: .em(1.6))
|
||||
},
|
||||
listItem: BlockStyle { label in
|
||||
label.markdownBlockSpacing(top: .em(0.8))
|
||||
},
|
||||
taskListMarker: ListMarkerStyle { _ in
|
||||
// DocC renders task lists as bullet lists
|
||||
Bullet.disc
|
||||
.frame(minWidth: .em(1.5), alignment: .trailing)
|
||||
},
|
||||
table: BlockStyle { label in
|
||||
label.markdownBlockSpacing(top: .em(1.6), bottom: .zero)
|
||||
},
|
||||
tableBorder: TableBorderStyle(.horizontalLines, color: .grid),
|
||||
tableCell: TableCellStyle { configuration in
|
||||
configuration.label
|
||||
.markdownFontStyle { configuration.row == 0 ? $0.bold() : $0 }
|
||||
.lineSpacing(.em(0.235295))
|
||||
.padding(.all, .rem(0.58824))
|
||||
},
|
||||
thematicBreak: BlockStyle { _ in
|
||||
Divider()
|
||||
.overlay(Color.grid)
|
||||
.markdownBlockSpacing(top: .em(2.35), bottom: .em(2.35))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
extension Shape where Self == RoundedRectangle {
|
||||
fileprivate static var container: Self {
|
||||
.init(cornerRadius: 15, style: .continuous)
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
fileprivate static let text = Color(
|
||||
light: Color(rgba: 0x1d1d_1fff), dark: Color(rgba: 0xf5f5_f7ff)
|
||||
)
|
||||
fileprivate static let secondaryLabel = Color(
|
||||
light: Color(rgba: 0x6e6e_73ff), dark: Color(rgba: 0x8686_8bff)
|
||||
)
|
||||
fileprivate static let link = Color(
|
||||
light: Color(rgba: 0x0066_ccff), dark: Color(rgba: 0x2997_ffff)
|
||||
)
|
||||
fileprivate static let asideNoteBackground = Color(
|
||||
light: Color(rgba: 0xf5f5_f7ff), dark: Color(rgba: 0x3232_32ff)
|
||||
)
|
||||
fileprivate static let asideNoteBorder = Color(
|
||||
light: Color(rgba: 0x6969_69ff), dark: Color(rgba: 0x9a9a_9eff)
|
||||
)
|
||||
fileprivate static let codeBackground = Color(
|
||||
light: Color(rgba: 0xf5f5_f7ff), dark: Color(rgba: 0x3333_36ff)
|
||||
)
|
||||
fileprivate static let grid = Color(
|
||||
light: Color(rgba: 0xd2d2_d7ff), dark: Color(rgba: 0x4242_45ff)
|
||||
)
|
||||
}
|
||||
141
Sources/MarkdownUI/Theme/Theme+GitHub.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Theme {
|
||||
public static let gitHub = Theme(
|
||||
textColor: .text,
|
||||
backgroundColor: .background,
|
||||
font: .system(size: 16),
|
||||
code: .monospaced(size: .em(0.85), backgroundColor: .secondaryBackground),
|
||||
strong: .weight(.semibold),
|
||||
link: .foregroundColor(.link),
|
||||
heading1: BlockStyle { label in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
label
|
||||
.padding(.bottom, .em(0.3))
|
||||
.lineSpacing(.em(0.125))
|
||||
.markdownBlockSpacing(top: .points(24), bottom: .points(16))
|
||||
.markdownFontStyle { $0.weight(.semibold).size(.em(2)) }
|
||||
Divider().overlay(Color.divider)
|
||||
}
|
||||
},
|
||||
heading2: BlockStyle { label in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
label
|
||||
.padding(.bottom, .em(0.3))
|
||||
.lineSpacing(.em(0.125))
|
||||
.markdownBlockSpacing(top: .points(24), bottom: .points(16))
|
||||
.markdownFontStyle { $0.weight(.semibold).size(.em(1.5)) }
|
||||
Divider().overlay(Color.divider)
|
||||
}
|
||||
},
|
||||
heading3: BlockStyle { label in
|
||||
label
|
||||
.lineSpacing(.em(0.125))
|
||||
.markdownBlockSpacing(top: .points(24), bottom: .points(16))
|
||||
.markdownFontStyle { $0.weight(.semibold).size(.em(1.25)) }
|
||||
},
|
||||
heading4: BlockStyle { label in
|
||||
label
|
||||
.lineSpacing(.em(0.125))
|
||||
.markdownBlockSpacing(top: .points(24), bottom: .points(16))
|
||||
.markdownFontStyle { $0.weight(.semibold) }
|
||||
},
|
||||
heading5: BlockStyle { label in
|
||||
label
|
||||
.lineSpacing(.em(0.125))
|
||||
.markdownBlockSpacing(top: .points(24), bottom: .points(16))
|
||||
.markdownFontStyle { $0.weight(.semibold).size(.em(0.875)) }
|
||||
},
|
||||
heading6: BlockStyle { label in
|
||||
label
|
||||
.lineSpacing(.em(0.125))
|
||||
.foregroundColor(.tertiaryText)
|
||||
.markdownBlockSpacing(top: .points(24), bottom: .points(16))
|
||||
.markdownFontStyle { $0.weight(.semibold).size(.em(0.85)) }
|
||||
},
|
||||
paragraph: BlockStyle { label in
|
||||
label
|
||||
.lineSpacing(.em(0.25))
|
||||
.markdownBlockSpacing(top: .zero, bottom: .points(16))
|
||||
},
|
||||
blockquote: BlockStyle { label in
|
||||
HStack(spacing: 0) {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.border)
|
||||
.frame(width: .em(0.2))
|
||||
label
|
||||
.foregroundColor(.secondaryText)
|
||||
.padding(.horizontal, .em(1))
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
},
|
||||
codeBlock: BlockStyle { label in
|
||||
ScrollView(.horizontal) {
|
||||
label
|
||||
.lineSpacing(.em(0.225))
|
||||
.markdownFontStyle { $0.monospaced().size(.em(0.85)) }
|
||||
.padding(16)
|
||||
}
|
||||
.background(Color.secondaryBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.markdownBlockSpacing(top: .zero, bottom: .points(16))
|
||||
},
|
||||
listItem: BlockStyle { label in
|
||||
label.markdownBlockSpacing(top: .em(0.25))
|
||||
},
|
||||
taskListMarker: ListMarkerStyle { configuration in
|
||||
SwiftUI.Image(systemName: configuration.isCompleted ? "checkmark.square.fill" : "square")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(Color.checkbox, Color.checkboxBackground)
|
||||
.imageScale(.small)
|
||||
.frame(minWidth: .em(1.5), alignment: .trailing)
|
||||
},
|
||||
table: BlockStyle { label in
|
||||
label.markdownBlockSpacing(top: .zero, bottom: .points(16))
|
||||
},
|
||||
tableBorder: TableBorderStyle(color: .border),
|
||||
tableCell: TableCellStyle { configuration in
|
||||
configuration.label
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 13)
|
||||
.lineSpacing(.em(0.25))
|
||||
.markdownFontStyle { configuration.row == 0 ? $0.weight(.semibold) : $0 }
|
||||
},
|
||||
tableCellBackground: .alternatingRows(Color.background, Color.secondaryBackground),
|
||||
thematicBreak: BlockStyle { _ in
|
||||
Divider()
|
||||
.frame(height: .em(0.25))
|
||||
.overlay(Color.border)
|
||||
.markdownBlockSpacing(top: .points(24), bottom: .points(24))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
extension Color {
|
||||
fileprivate static let text = Color(
|
||||
light: Color(rgba: 0x0606_06ff), dark: Color(rgba: 0xfbfb_fcff)
|
||||
)
|
||||
fileprivate static let secondaryText = Color(
|
||||
light: Color(rgba: 0x6b6e_7bff), dark: Color(rgba: 0x9294_a0ff)
|
||||
)
|
||||
fileprivate static let tertiaryText = Color(
|
||||
light: Color(rgba: 0x6b6e_7bff), dark: Color(rgba: 0x6d70_7dff)
|
||||
)
|
||||
fileprivate static let background = Color(
|
||||
light: .white, dark: Color(rgba: 0x1819_1dff)
|
||||
)
|
||||
fileprivate static let secondaryBackground = Color(
|
||||
light: Color(rgba: 0xf7f7_f9ff), dark: Color(rgba: 0x2526_2aff)
|
||||
)
|
||||
fileprivate static let link = Color(
|
||||
light: Color(rgba: 0x2c65_cfff), dark: Color(rgba: 0x4c8e_f8ff)
|
||||
)
|
||||
fileprivate static let border = Color(
|
||||
light: Color(rgba: 0xe4e4_e8ff), dark: Color(rgba: 0x4244_4eff)
|
||||
)
|
||||
fileprivate static let divider = Color(
|
||||
light: Color(rgba: 0xd0d0_d3ff), dark: Color(rgba: 0x3334_38ff)
|
||||
)
|
||||
fileprivate static let checkbox = Color(rgba: 0xb9b9_bbff)
|
||||
fileprivate static let checkboxBackground = Color(rgba: 0xeeee_efff)
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct Theme {
|
||||
// MARK: - Colors
|
||||
|
||||
public var textColor: Color
|
||||
public var backgroundColor: Color
|
||||
|
||||
// MARK: - Inlines
|
||||
|
||||
public var font: FontStyle
|
||||
@@ -12,20 +17,16 @@ public struct Theme {
|
||||
|
||||
// MARK: - Blocks
|
||||
|
||||
public var image: BlockStyle
|
||||
public private(set) var headings: [BlockStyle]
|
||||
public var paragraph: BlockStyle
|
||||
public var blockquote: BlockStyle
|
||||
public var codeBlock: BlockStyle
|
||||
public var image: BlockStyle
|
||||
public var list: BlockStyle
|
||||
public var listItem: BlockStyle
|
||||
public var taskListMarker: ListMarkerStyle<TaskListItemConfiguration>
|
||||
public var bulletedListMarker: ListMarkerStyle<ListItemConfiguration>
|
||||
public var numberedListMarker: ListMarkerStyle<ListItemConfiguration>
|
||||
public var codeBlock: BlockStyle
|
||||
public var paragraph: BlockStyle
|
||||
public var headings: [BlockStyle] {
|
||||
willSet {
|
||||
precondition(newValue.count == 6, "A theme must have six heading styles.")
|
||||
}
|
||||
}
|
||||
public var table: BlockStyle
|
||||
public var tableBorder: TableBorderStyle
|
||||
public var tableCell: TableCellStyle
|
||||
@@ -33,45 +34,55 @@ public struct Theme {
|
||||
public var thematicBreak: BlockStyle
|
||||
|
||||
public init(
|
||||
font: FontStyle = .default,
|
||||
code: InlineStyle,
|
||||
emphasis: InlineStyle,
|
||||
strong: InlineStyle,
|
||||
strikethrough: InlineStyle,
|
||||
link: InlineStyle,
|
||||
image: BlockStyle,
|
||||
textColor: Color = .primary,
|
||||
backgroundColor: Color = .clear,
|
||||
font: FontStyle = .body,
|
||||
code: InlineStyle = .monospaced(),
|
||||
emphasis: InlineStyle = .italic,
|
||||
strong: InlineStyle = .bold,
|
||||
strikethrough: InlineStyle = .strikethrough,
|
||||
link: InlineStyle = .unit,
|
||||
heading1: BlockStyle,
|
||||
heading2: BlockStyle,
|
||||
heading3: BlockStyle,
|
||||
heading4: BlockStyle,
|
||||
heading5: BlockStyle,
|
||||
heading6: BlockStyle,
|
||||
paragraph: BlockStyle,
|
||||
blockquote: BlockStyle,
|
||||
list: BlockStyle = .default,
|
||||
listItem: BlockStyle = .default,
|
||||
taskListMarker: ListMarkerStyle<TaskListItemConfiguration>,
|
||||
bulletedListMarker: ListMarkerStyle<ListItemConfiguration>,
|
||||
numberedListMarker: ListMarkerStyle<ListItemConfiguration>,
|
||||
codeBlock: BlockStyle,
|
||||
paragraph: BlockStyle = .default,
|
||||
headings: [BlockStyle],
|
||||
table: BlockStyle = .default,
|
||||
tableBorder: TableBorderStyle = .default,
|
||||
tableCell: TableCellStyle = .default,
|
||||
tableCellBackground: TableCellBackgroundStyle = .default,
|
||||
image: BlockStyle = .unit,
|
||||
list: BlockStyle = .unit,
|
||||
listItem: BlockStyle = .unit,
|
||||
taskListMarker: ListMarkerStyle<TaskListItemConfiguration>,
|
||||
bulletedListMarker: ListMarkerStyle<ListItemConfiguration> = .discCircleSquare,
|
||||
numberedListMarker: ListMarkerStyle<ListItemConfiguration> = .decimal,
|
||||
table: BlockStyle,
|
||||
tableBorder: TableBorderStyle,
|
||||
tableCell: TableCellStyle,
|
||||
tableCellBackground: TableCellBackgroundStyle = .clear,
|
||||
thematicBreak: BlockStyle
|
||||
) {
|
||||
self.textColor = textColor
|
||||
self.backgroundColor = backgroundColor
|
||||
|
||||
self.font = font
|
||||
self.code = code
|
||||
self.emphasis = emphasis
|
||||
self.strong = strong
|
||||
self.strikethrough = strikethrough
|
||||
self.link = link
|
||||
self.image = image
|
||||
|
||||
self.headings = [heading1, heading2, heading3, heading4, heading5, heading6]
|
||||
self.paragraph = paragraph
|
||||
self.blockquote = blockquote
|
||||
self.codeBlock = codeBlock
|
||||
self.image = image
|
||||
self.list = list
|
||||
self.listItem = listItem
|
||||
self.taskListMarker = taskListMarker
|
||||
self.bulletedListMarker = bulletedListMarker
|
||||
self.numberedListMarker = numberedListMarker
|
||||
self.codeBlock = codeBlock
|
||||
self.paragraph = paragraph
|
||||
precondition(headings.count == 6, "A theme must have six heading styles.")
|
||||
self.headings = headings
|
||||
self.table = table
|
||||
self.tableBorder = tableBorder
|
||||
self.tableCell = tableCell
|
||||
@@ -79,30 +90,3 @@ public struct Theme {
|
||||
self.thematicBreak = thematicBreak
|
||||
}
|
||||
}
|
||||
|
||||
extension Theme {
|
||||
public static var `default`: Self {
|
||||
.init(
|
||||
code: .monospaced,
|
||||
emphasis: .italic,
|
||||
strong: .bold,
|
||||
strikethrough: .strikethrough,
|
||||
link: .default,
|
||||
image: .defaultImage,
|
||||
blockquote: .defaultBlockquote,
|
||||
taskListMarker: .checkmarkSquareFill,
|
||||
bulletedListMarker: .discCircleSquare,
|
||||
numberedListMarker: .decimal,
|
||||
codeBlock: .defaultCodeBlock,
|
||||
headings: [
|
||||
.defaultHeading1,
|
||||
.defaultHeading2,
|
||||
.defaultHeading3,
|
||||
.defaultHeading4,
|
||||
.defaultHeading5,
|
||||
.defaultHeading6,
|
||||
],
|
||||
thematicBreak: .defaultThematicBreak
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ where
|
||||
{
|
||||
@Environment(\.multilineTextAlignment) private var textAlignment
|
||||
@Environment(\.tightSpacingEnabled) private var tightSpacingEnabled
|
||||
@Environment(\.fontStyle) private var fontStyle
|
||||
|
||||
@State private var blockSpacings: [Int: BlockSpacing] = [:]
|
||||
|
||||
@@ -27,7 +26,7 @@ where
|
||||
VStack(alignment: self.textAlignment.alignment.horizontal, spacing: 0) {
|
||||
ForEach(self.data, id: \.self) { element in
|
||||
self.content(element.index, element.value)
|
||||
.onBlockSpacingChange { value in
|
||||
.onPreferenceChange(BlockSpacingPreference.self) { value in
|
||||
self.blockSpacings[element.hashValue] = value
|
||||
}
|
||||
.padding(.top, self.topPaddingLength(for: element))
|
||||
@@ -35,25 +34,19 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
private func topPaddingLength(for element: Indexed<Data.Element>) -> CGFloat {
|
||||
private func topPaddingLength(for element: Indexed<Data.Element>) -> CGFloat? {
|
||||
guard element.index > 0 else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let topSpacing = self.topSpacing(for: element)
|
||||
let topSpacing = self.blockSpacings[element.hashValue]?.top
|
||||
let predecessor = self.data[element.index - 1]
|
||||
let predecessorBottomSpacing =
|
||||
self.tightSpacingEnabled ? 0 : self.bottomSpacing(for: predecessor)
|
||||
self.tightSpacingEnabled ? 0 : self.blockSpacings[predecessor.hashValue]?.bottom
|
||||
|
||||
return max(topSpacing, predecessorBottomSpacing)
|
||||
}
|
||||
|
||||
private func topSpacing(for element: Indexed<Data.Element>) -> CGFloat {
|
||||
(self.blockSpacings[element.hashValue] ?? .default).top.points(relativeTo: self.fontStyle)
|
||||
}
|
||||
|
||||
private func bottomSpacing(for element: Indexed<Data.Element>) -> CGFloat {
|
||||
(self.blockSpacings[element.hashValue] ?? .default).bottom.points(relativeTo: self.fontStyle)
|
||||
return [topSpacing, predecessorBottomSpacing]
|
||||
.compactMap { $0 }
|
||||
.max()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
44
Sources/MarkdownUI/Views/Blocks/BlockSpacing.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BlockSpacing: Equatable {
|
||||
var top: CGFloat?
|
||||
var bottom: CGFloat?
|
||||
|
||||
static let unspecified = BlockSpacing()
|
||||
}
|
||||
|
||||
extension View {
|
||||
public func markdownBlockSpacing(top: Size? = nil, bottom: Size? = nil) -> some View {
|
||||
self.modifier(BlockSpacingModifier(top: top, bottom: bottom))
|
||||
}
|
||||
}
|
||||
|
||||
struct BlockSpacingPreference: PreferenceKey {
|
||||
static let defaultValue: BlockSpacing = .unspecified
|
||||
|
||||
static func reduce(value: inout BlockSpacing, nextValue: () -> BlockSpacing) {
|
||||
let newValue = nextValue()
|
||||
|
||||
value.top = [value.top, newValue.top].compactMap { $0 }.max()
|
||||
value.bottom = [value.bottom, newValue.bottom].compactMap { $0 }.max()
|
||||
}
|
||||
}
|
||||
|
||||
private struct BlockSpacingModifier: ViewModifier {
|
||||
@Environment(\.fontStyle) private var fontStyle
|
||||
|
||||
let top: Size?
|
||||
let bottom: Size?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.transformPreference(BlockSpacingPreference.self) { value in
|
||||
let newValue = BlockSpacing(
|
||||
top: self.top?.points(relativeTo: self.fontStyle),
|
||||
bottom: self.bottom?.points(relativeTo: self.fontStyle)
|
||||
)
|
||||
|
||||
value.top = [value.top, newValue.top].compactMap { $0 }.max()
|
||||
value.bottom = [value.bottom, newValue.bottom].compactMap { $0 }.max()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
public func markdownBlockSpacing(top: Size? = nil, bottom: Size? = nil) -> some View {
|
||||
self.transformPreference(BlockSpacingPreference.self) { blockSpacing in
|
||||
blockSpacing.top = top ?? blockSpacing.top
|
||||
blockSpacing.bottom = bottom ?? blockSpacing.bottom
|
||||
}
|
||||
}
|
||||
|
||||
func onBlockSpacingChange(perform action: @escaping (BlockSpacing) -> Void) -> some View {
|
||||
self.onPreferenceChange(BlockSpacingPreference.self, perform: action)
|
||||
}
|
||||
}
|
||||
|
||||
struct BlockSpacing: Equatable {
|
||||
var top: Size
|
||||
var bottom: Size
|
||||
}
|
||||
|
||||
extension BlockSpacing {
|
||||
static let `default` = BlockSpacing(top: .zero, bottom: .rem(1))
|
||||
}
|
||||
|
||||
private struct BlockSpacingPreference: PreferenceKey {
|
||||
static let defaultValue: BlockSpacing = .default
|
||||
|
||||
static func reduce(value: inout BlockSpacing, nextValue: () -> BlockSpacing) {
|
||||
value.bottom = nextValue().bottom
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,21 @@ extension TableBorder {
|
||||
}
|
||||
}
|
||||
|
||||
public static var topBottomOutline: TableBorder {
|
||||
TableBorder { tableBounds, borderWidth in
|
||||
[
|
||||
CGRect(
|
||||
origin: .init(x: tableBounds.bounds.minX, y: tableBounds.bounds.minY),
|
||||
size: .init(width: tableBounds.bounds.width, height: borderWidth)
|
||||
),
|
||||
CGRect(
|
||||
origin: .init(x: tableBounds.bounds.minX, y: tableBounds.bounds.maxY - borderWidth),
|
||||
size: .init(width: tableBounds.bounds.width, height: borderWidth)
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
public static var horizontalGridlines: TableBorder {
|
||||
TableBorder { tableBounds, borderWidth in
|
||||
(0..<tableBounds.rowCount - 1)
|
||||
@@ -50,6 +65,13 @@ extension TableBorder {
|
||||
}
|
||||
}
|
||||
|
||||
public static var horizontalLines: TableBorder {
|
||||
TableBorder { tableBounds, borderWidth in
|
||||
Self.topBottomOutline.rectangles(tableBounds, borderWidth)
|
||||
+ Self.horizontalGridlines.rectangles(tableBounds, borderWidth)
|
||||
}
|
||||
}
|
||||
|
||||
public static var all: TableBorder {
|
||||
TableBorder { tableBounds, borderWidth in
|
||||
Self.gridlines.rectangles(tableBounds, borderWidth)
|
||||
|
||||
@@ -15,7 +15,7 @@ struct TableBorderView: View {
|
||||
ForEach(0..<rectangles.count, id: \.self) {
|
||||
let rectangle = rectangles[$0]
|
||||
Rectangle()
|
||||
.strokeBorder(self.tableBorder.style, style: self.tableBorder.strokeStyle)
|
||||
.strokeBorder(self.tableBorder.color, style: self.tableBorder.strokeStyle)
|
||||
.offset(x: rectangle.minX, y: rectangle.minY)
|
||||
.frame(width: rectangle.width, height: rectangle.height)
|
||||
}
|
||||
|
||||
@@ -57,5 +57,5 @@ extension EnvironmentValues {
|
||||
}
|
||||
|
||||
private struct FontStyleKey: EnvironmentKey {
|
||||
static let defaultValue = FontStyle.default
|
||||
static let defaultValue: FontStyle = .body
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@ extension EnvironmentValues {
|
||||
}
|
||||
|
||||
private struct ThemeKey: EnvironmentKey {
|
||||
static var defaultValue: Theme = .default
|
||||
static var defaultValue: Theme = .basic
|
||||
}
|
||||
|
||||
@@ -26,29 +26,29 @@ extension AttributedString {
|
||||
case .lineBreak:
|
||||
self.init("\n", attributes: attributes)
|
||||
case .code(let content):
|
||||
self.init(content, attributes: environment.code.updating(attributes))
|
||||
self.init(content, attributes: environment.code.transforming(attributes))
|
||||
case .html(let content):
|
||||
self.init(content, attributes: attributes)
|
||||
case .emphasis(let children):
|
||||
self.init(
|
||||
inlines: children,
|
||||
environment: environment,
|
||||
attributes: environment.emphasis.updating(attributes)
|
||||
attributes: environment.emphasis.transforming(attributes)
|
||||
)
|
||||
case .strong(let children):
|
||||
self.init(
|
||||
inlines: children,
|
||||
environment: environment,
|
||||
attributes: environment.strong.updating(attributes)
|
||||
attributes: environment.strong.transforming(attributes)
|
||||
)
|
||||
case .strikethrough(let children):
|
||||
self.init(
|
||||
inlines: children,
|
||||
environment: environment,
|
||||
attributes: environment.strikethrough.updating(attributes)
|
||||
attributes: environment.strikethrough.transforming(attributes)
|
||||
)
|
||||
case .link(let destination, let children):
|
||||
var newAttributes = environment.link.updating(attributes)
|
||||
var newAttributes = environment.link.transforming(attributes)
|
||||
newAttributes.link = URL.init(string: destination)?.relativeTo(environment.baseURL)
|
||||
self.init(inlines: children, environment: environment, attributes: newAttributes)
|
||||
case .image:
|
||||
|
||||
@@ -15,7 +15,10 @@ public struct Markdown: View {
|
||||
}
|
||||
}
|
||||
|
||||
@Environment(\.theme.textColor) private var textColor
|
||||
@Environment(\.theme.backgroundColor) private var backgroundColor
|
||||
@Environment(\.theme.font) private var font
|
||||
|
||||
@State private var blocks: [Block] = []
|
||||
|
||||
private let storage: Storage
|
||||
@@ -38,6 +41,8 @@ public struct Markdown: View {
|
||||
self.blocks = storage.markdownContent.blocks
|
||||
}
|
||||
.environment(\.markdownBaseURL, self.baseURL)
|
||||
.foregroundColor(self.textColor)
|
||||
.background(self.backgroundColor)
|
||||
.fontStyle(self.font)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,24 +92,6 @@
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testImageStyle() {
|
||||
let view = Markdown {
|
||||
#"""
|
||||
A link that contains an image instead of text:
|
||||
|
||||
[](https://example.com)
|
||||
|
||||
― Photo by André Spieker
|
||||
"""#
|
||||
}
|
||||
.border(Color.accentColor)
|
||||
.padding()
|
||||
.markdownImageLoader(.asset(in: .module), forURLScheme: "asset")
|
||||
.markdownTheme(\.image, .defaultImage(alignment: .center))
|
||||
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testMultipleImages() {
|
||||
let view = Markdown {
|
||||
#"""
|
||||
@@ -144,24 +126,5 @@
|
||||
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testImageStyleWithMultipleImages() {
|
||||
let view = Markdown {
|
||||
#"""
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
― Photo by André Spieker
|
||||
"""#
|
||||
}
|
||||
.border(Color.accentColor)
|
||||
.padding()
|
||||
.markdownImageLoader(.asset(in: .module), forURLScheme: "asset")
|
||||
.markdownTheme(\.image, .defaultImage(alignment: .center))
|
||||
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
\.tableBorder,
|
||||
.init(
|
||||
.outline,
|
||||
style: Color.mint,
|
||||
color: Color.mint,
|
||||
strokeStyle: .init(lineWidth: 2, lineJoin: .round, dash: [4])
|
||||
)
|
||||
)
|
||||
|
||||
@@ -274,10 +274,27 @@
|
||||
.border(Color.accentColor)
|
||||
.padding()
|
||||
.markdownTheme(\.code, .monospaced(backgroundColor: .yellow))
|
||||
.markdownTheme(\.emphasis, .italicUnderline)
|
||||
.markdownTheme(
|
||||
\.emphasis,
|
||||
InlineStyle { attributes in
|
||||
attributes.fontStyle = attributes.fontStyle?.italic()
|
||||
attributes.underlineStyle = .single
|
||||
}
|
||||
)
|
||||
.markdownTheme(\.strong, .weight(.heavy))
|
||||
.markdownTheme(\.strikethrough, .redacted)
|
||||
.markdownTheme(\.link, .underlineDot)
|
||||
.markdownTheme(
|
||||
\.strikethrough,
|
||||
InlineStyle { attributes in
|
||||
attributes.foregroundColor = .primary
|
||||
attributes.backgroundColor = .primary
|
||||
}
|
||||
)
|
||||
.markdownTheme(
|
||||
\.link,
|
||||
InlineStyle { attributes in
|
||||
attributes.underlineStyle = .init(pattern: .dot)
|
||||
}
|
||||
)
|
||||
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
174
Tests/MarkdownUITests/ThemeDocCTests.swift
Normal file
@@ -0,0 +1,174 @@
|
||||
#if os(iOS)
|
||||
import SnapshotTesting
|
||||
import SwiftUI
|
||||
import XCTest
|
||||
|
||||
import MarkdownUI
|
||||
|
||||
final class ThemeDocCTests: XCTestCase {
|
||||
private let layout = SwiftUISnapshotLayout.device(config: .iPhone8)
|
||||
|
||||
func testInlines() {
|
||||
let view = ThemePreview(theme: .docC) {
|
||||
#"""
|
||||
**This is bold text**
|
||||
|
||||
*This text is italicized*
|
||||
|
||||
~~This was mistaken text~~
|
||||
|
||||
**This text is _extremely_ important**
|
||||
|
||||
MarkdownUI is fully compliant with the [CommonMark Spec](https://spec.commonmark.org/current/).
|
||||
|
||||
Use `git status` to list all new or modified files that haven't yet been committed.
|
||||
"""#
|
||||
}
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testHeadings() {
|
||||
let view = ThemePreview(theme: .docC, colorScheme: .light) {
|
||||
#"""
|
||||
Paragraph.
|
||||
# Heading 1
|
||||
Paragraph.
|
||||
## Heading 2
|
||||
Paragraph.
|
||||
### Heading 3
|
||||
Paragraph.
|
||||
#### Heading 4
|
||||
Paragraph.
|
||||
##### Heading 5
|
||||
Paragraph.
|
||||
###### Heading 6
|
||||
Paragraph.
|
||||
"""#
|
||||
}
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testParagraph() {
|
||||
let view = ThemePreview(theme: .docC, colorScheme: .light) {
|
||||
#"""
|
||||
The sky above the port was the color of television, tuned to a dead channel.
|
||||
|
||||
It was a bright cold day in April, and the clocks were striking thirteen.
|
||||
|
||||
The sky above the port was the color of television, tuned to a dead channel.
|
||||
|
||||
It was a bright cold day in April, and the clocks were striking thirteen.
|
||||
"""#
|
||||
}
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testBlockquote() {
|
||||
let view = ThemePreview(theme: .docC) {
|
||||
#"""
|
||||
The sky above the port was the color of television, tuned to a dead channel.
|
||||
|
||||
> Outside of a dog, a book is man's best friend. Inside of a dog it's too dark to read.
|
||||
|
||||
It was a bright cold day in April, and the clocks were striking thirteen.
|
||||
"""#
|
||||
}
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testCodeBlock() {
|
||||
let view = ThemePreview(theme: .docC) {
|
||||
#"""
|
||||
The sky above the port was the color of television, tuned to a dead channel.
|
||||
|
||||
```swift
|
||||
struct Sightseeing: Activity {
|
||||
func perform(with sloth: inout Sloth) -> Speed {
|
||||
sloth.energyLevel -= 10
|
||||
return .slow
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
It was a bright cold day in April, and the clocks were striking thirteen.
|
||||
"""#
|
||||
}
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testImage() {
|
||||
let view = ThemePreview(theme: .docC, colorScheme: .light) {
|
||||
#"""
|
||||
The sky above the port was the color of television, tuned to a dead channel.
|
||||
|
||||

|
||||
|
||||
It was a bright cold day in April, and the clocks were striking thirteen.
|
||||
|
||||

|
||||

|
||||
|
||||
― Photo by André Spieker
|
||||
"""#
|
||||
}
|
||||
.markdownImageLoader(.asset(in: .module), forURLScheme: "asset")
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testList() {
|
||||
let view = ThemePreview(theme: .docC, colorScheme: .light) {
|
||||
#"""
|
||||
The sky above the port was the color of television, tuned to a dead channel.
|
||||
|
||||
* Systems
|
||||
* FFF units
|
||||
* Great Underground Empire (Zork)
|
||||
* Potrzebie
|
||||
* Equals the thickness of Mad issue 26
|
||||
* Developed by 19-year-old Donald E. Knuth
|
||||
|
||||
It was a bright cold day in April, and the clocks were striking thirteen.
|
||||
|
||||
10. Helmets
|
||||
1. Hoods
|
||||
1. Headbands, headscarves, wimples
|
||||
|
||||
The sky above the port was the color of television, tuned to a dead channel.
|
||||
|
||||
- [x] A finished task
|
||||
- [ ] An unfinished task
|
||||
"""#
|
||||
}
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testTable() {
|
||||
let view = ThemePreview(theme: .docC) {
|
||||
#"""
|
||||
Add tables of data:
|
||||
|
||||
| Sloth speed | Description |
|
||||
| ------------ | ------------------------------------- |
|
||||
| `slow` | Moves slightly faster than a snail. |
|
||||
| `medium` | Moves at an average speed. |
|
||||
| `fast` | Moves faster than a hare. |
|
||||
"""#
|
||||
}
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testThematicBreak() {
|
||||
let view = ThemePreview(theme: .docC) {
|
||||
#"""
|
||||
The sky above the port was the color of television, tuned to a dead channel.
|
||||
|
||||
---
|
||||
|
||||
It was a bright cold day in April, and the clocks were striking thirteen.
|
||||
"""#
|
||||
}
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
177
Tests/MarkdownUITests/ThemeGitHubTests.swift
Normal file
@@ -0,0 +1,177 @@
|
||||
#if os(iOS)
|
||||
import SnapshotTesting
|
||||
import SwiftUI
|
||||
import XCTest
|
||||
|
||||
import MarkdownUI
|
||||
|
||||
final class ThemeGitHubTests: XCTestCase {
|
||||
private let layout = SwiftUISnapshotLayout.device(config: .iPhone8)
|
||||
|
||||
func testInlines() {
|
||||
let view = ThemePreview(theme: .gitHub) {
|
||||
#"""
|
||||
**This is bold text**
|
||||
|
||||
*This text is italicized*
|
||||
|
||||
~~This was mistaken text~~
|
||||
|
||||
**This text is _extremely_ important**
|
||||
|
||||
MarkdownUI is fully compliant with the [CommonMark Spec](https://spec.commonmark.org/current/).
|
||||
|
||||
Use `git status` to list all new or modified files that haven't yet been committed.
|
||||
"""#
|
||||
}
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testHeadings() {
|
||||
let view = ThemePreview(theme: .gitHub, colorScheme: .light) {
|
||||
#"""
|
||||
Paragraph.
|
||||
# Heading 1
|
||||
Paragraph.
|
||||
## Heading 2
|
||||
Paragraph.
|
||||
### Heading 3
|
||||
Paragraph.
|
||||
#### Heading 4
|
||||
Paragraph.
|
||||
##### Heading 5
|
||||
Paragraph.
|
||||
###### Heading 6
|
||||
Paragraph.
|
||||
"""#
|
||||
}
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testHeadingsColors() {
|
||||
let view = ThemePreview(theme: .gitHub) {
|
||||
#"""
|
||||
# Heading 1
|
||||
Paragraph.
|
||||
## Heading 2
|
||||
Paragraph.
|
||||
###### Heading 6
|
||||
Paragraph.
|
||||
"""#
|
||||
}
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testParagraph() {
|
||||
let view = ThemePreview(theme: .gitHub, colorScheme: .light) {
|
||||
#"""
|
||||
The sky above the port was the color of television, tuned to a dead channel.
|
||||
|
||||
It was a bright cold day in April, and the clocks were striking thirteen.
|
||||
|
||||
The sky above the port was the color of television, tuned to a dead channel.
|
||||
|
||||
It was a bright cold day in April, and the clocks were striking thirteen.
|
||||
"""#
|
||||
}
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testBlockquote() {
|
||||
let view = ThemePreview(theme: .gitHub) {
|
||||
#"""
|
||||
The sky above the port was the color of television, tuned to a dead channel.
|
||||
|
||||
> Outside of a dog, a book is man's best friend. Inside of a dog it's too dark to read.
|
||||
|
||||
It was a bright cold day in April, and the clocks were striking thirteen.
|
||||
"""#
|
||||
}
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testCodeBlock() {
|
||||
let view = ThemePreview(theme: .gitHub) {
|
||||
#"""
|
||||
The sky above the port was the color of television, tuned to a dead channel.
|
||||
|
||||
```swift
|
||||
struct Sightseeing: Activity {
|
||||
func perform(with sloth: inout Sloth) -> Speed {
|
||||
sloth.energyLevel -= 10
|
||||
return .slow
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
It was a bright cold day in April, and the clocks were striking thirteen.
|
||||
"""#
|
||||
}
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testTaskList() {
|
||||
let view = ThemePreview(theme: .gitHub) {
|
||||
#"""
|
||||
The sky above the port was the color of television, tuned to a dead channel.
|
||||
|
||||
- [x] A finished task
|
||||
- [ ] An unfinished task
|
||||
"""#
|
||||
}
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testList() {
|
||||
let view = ThemePreview(theme: .gitHub, colorScheme: .light) {
|
||||
#"""
|
||||
The sky above the port was the color of television, tuned to a dead channel.
|
||||
|
||||
* Systems
|
||||
* FFF units
|
||||
* Great Underground Empire (Zork)
|
||||
* Potrzebie
|
||||
* Equals the thickness of Mad issue 26
|
||||
* Developed by 19-year-old Donald E. Knuth
|
||||
|
||||
It was a bright cold day in April, and the clocks were striking thirteen.
|
||||
|
||||
10. Helmets
|
||||
1. Hoods
|
||||
1. Headbands, headscarves, wimples
|
||||
|
||||
The sky above the port was the color of television, tuned to a dead channel.
|
||||
"""#
|
||||
}
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testTable() {
|
||||
let view = ThemePreview(theme: .gitHub) {
|
||||
#"""
|
||||
Add tables of data:
|
||||
|
||||
| Sloth speed | Description |
|
||||
| ------------ | ------------------------------------- |
|
||||
| `slow` | Moves slightly faster than a snail. |
|
||||
| `medium` | Moves at an average speed. |
|
||||
| `fast` | Moves faster than a hare. |
|
||||
"""#
|
||||
}
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
|
||||
func testThematicBreak() {
|
||||
let view = ThemePreview(theme: .gitHub) {
|
||||
#"""
|
||||
The sky above the port was the color of television, tuned to a dead channel.
|
||||
|
||||
---
|
||||
|
||||
It was a bright cold day in April, and the clocks were striking thirteen.
|
||||
"""#
|
||||
}
|
||||
assertSnapshot(matching: view, as: .image(layout: layout))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
39
Tests/MarkdownUITests/ThemePreview.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
import MarkdownUI
|
||||
import SwiftUI
|
||||
|
||||
struct ThemePreview: View {
|
||||
private let theme: Theme
|
||||
private let colorSchemes: [ColorScheme]
|
||||
private let content: () -> MarkdownContent
|
||||
|
||||
init(
|
||||
theme: Theme,
|
||||
colorSchemes: [ColorScheme] = ColorScheme.allCases,
|
||||
@MarkdownContentBuilder content: @escaping () -> MarkdownContent
|
||||
) {
|
||||
self.theme = theme
|
||||
self.colorSchemes = colorSchemes
|
||||
self.content = content
|
||||
}
|
||||
|
||||
init(
|
||||
theme: Theme,
|
||||
colorScheme: ColorScheme,
|
||||
@MarkdownContentBuilder content: @escaping () -> MarkdownContent
|
||||
) {
|
||||
self.init(theme: theme, colorSchemes: [colorScheme], content: content)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ForEach(self.colorSchemes, id: \.self) { colorScheme in
|
||||
Markdown(content: self.content)
|
||||
.padding()
|
||||
.background()
|
||||
.colorScheme(colorScheme)
|
||||
}
|
||||
}
|
||||
.markdownTheme(self.theme)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 77 KiB |