355 lines
13 KiB
Swift
355 lines
13 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
import MarkdownUI
|
|
|
|
/// SwiftUI view that renders a MarkdownPanel's content using MarkdownUI.
|
|
struct MarkdownPanelView: View {
|
|
@ObservedObject var panel: MarkdownPanel
|
|
let isFocused: Bool
|
|
let isVisibleInUI: Bool
|
|
let portalPriority: Int
|
|
let onRequestPanelFocus: () -> Void
|
|
|
|
@State private var focusFlashOpacity: Double = 0.0
|
|
@State private var focusFlashAnimationGeneration: Int = 0
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
Group {
|
|
if panel.isFileUnavailable {
|
|
fileUnavailableView
|
|
} else {
|
|
markdownContentView
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(backgroundColor)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius)
|
|
.stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3)
|
|
.shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10)
|
|
.padding(FocusFlashPattern.ringInset)
|
|
.allowsHitTesting(false)
|
|
}
|
|
.overlay {
|
|
if isVisibleInUI {
|
|
// Observe left-clicks without intercepting them so markdown text
|
|
// selection and link activation continue to use the native path.
|
|
MarkdownPointerObserver(onPointerDown: onRequestPanelFocus)
|
|
}
|
|
}
|
|
.onChange(of: panel.focusFlashToken) { _ in
|
|
triggerFocusFlashAnimation()
|
|
}
|
|
}
|
|
|
|
// MARK: - Content
|
|
|
|
private var markdownContentView: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// File path breadcrumb
|
|
filePathHeader
|
|
.padding(.horizontal, 24)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 8)
|
|
|
|
Divider()
|
|
.padding(.horizontal, 16)
|
|
|
|
// Rendered markdown
|
|
Markdown(panel.content)
|
|
.markdownTheme(cmuxMarkdownTheme)
|
|
.textSelection(.enabled)
|
|
.padding(.horizontal, 24)
|
|
.padding(.vertical, 16)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var filePathHeader: some View {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "doc.richtext")
|
|
.foregroundColor(.secondary)
|
|
.font(.system(size: 12))
|
|
Text(panel.filePath)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
private var fileUnavailableView: some View {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "doc.questionmark")
|
|
.font(.system(size: 40))
|
|
.foregroundColor(.secondary)
|
|
Text(String(localized: "markdown.fileUnavailable.title", defaultValue: "File unavailable"))
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
Text(panel.filePath)
|
|
.font(.system(size: 12, design: .monospaced))
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.textSelection(.enabled)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.padding(.horizontal, 24)
|
|
Text(String(localized: "markdown.fileUnavailable.message", defaultValue: "The file may have been moved or deleted."))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
// MARK: - Theme
|
|
|
|
private var backgroundColor: Color {
|
|
colorScheme == .dark
|
|
? Color(nsColor: NSColor(white: 0.12, alpha: 1.0))
|
|
: Color(nsColor: NSColor(white: 0.98, alpha: 1.0))
|
|
}
|
|
|
|
private var cmuxMarkdownTheme: Theme {
|
|
let isDark = colorScheme == .dark
|
|
|
|
return Theme()
|
|
// Text
|
|
.text {
|
|
ForegroundColor(isDark ? .white.opacity(0.9) : .primary)
|
|
FontSize(14)
|
|
}
|
|
// Headings
|
|
.heading1 { configuration in
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
configuration.label
|
|
.markdownTextStyle {
|
|
FontWeight(.bold)
|
|
FontSize(28)
|
|
ForegroundColor(isDark ? .white : .primary)
|
|
}
|
|
Divider()
|
|
}
|
|
.markdownMargin(top: 24, bottom: 16)
|
|
}
|
|
.heading2 { configuration in
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
configuration.label
|
|
.markdownTextStyle {
|
|
FontWeight(.bold)
|
|
FontSize(22)
|
|
ForegroundColor(isDark ? .white : .primary)
|
|
}
|
|
Divider()
|
|
}
|
|
.markdownMargin(top: 20, bottom: 12)
|
|
}
|
|
.heading3 { configuration in
|
|
configuration.label
|
|
.markdownTextStyle {
|
|
FontWeight(.semibold)
|
|
FontSize(18)
|
|
ForegroundColor(isDark ? .white : .primary)
|
|
}
|
|
.markdownMargin(top: 16, bottom: 8)
|
|
}
|
|
.heading4 { configuration in
|
|
configuration.label
|
|
.markdownTextStyle {
|
|
FontWeight(.semibold)
|
|
FontSize(16)
|
|
ForegroundColor(isDark ? .white : .primary)
|
|
}
|
|
.markdownMargin(top: 12, bottom: 6)
|
|
}
|
|
.heading5 { configuration in
|
|
configuration.label
|
|
.markdownTextStyle {
|
|
FontWeight(.medium)
|
|
FontSize(14)
|
|
ForegroundColor(isDark ? .white : .primary)
|
|
}
|
|
.markdownMargin(top: 10, bottom: 4)
|
|
}
|
|
.heading6 { configuration in
|
|
configuration.label
|
|
.markdownTextStyle {
|
|
FontWeight(.medium)
|
|
FontSize(13)
|
|
ForegroundColor(isDark ? .white.opacity(0.7) : .secondary)
|
|
}
|
|
.markdownMargin(top: 8, bottom: 4)
|
|
}
|
|
// Code blocks
|
|
.codeBlock { configuration in
|
|
ScrollView(.horizontal, showsIndicators: true) {
|
|
configuration.label
|
|
.markdownTextStyle {
|
|
FontFamilyVariant(.monospaced)
|
|
FontSize(13)
|
|
ForegroundColor(isDark ? Color(red: 0.9, green: 0.9, blue: 0.9) : Color(red: 0.2, green: 0.2, blue: 0.2))
|
|
}
|
|
.padding(12)
|
|
}
|
|
.background(isDark
|
|
? Color(nsColor: NSColor(white: 0.08, alpha: 1.0))
|
|
: Color(nsColor: NSColor(white: 0.93, alpha: 1.0)))
|
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
.markdownMargin(top: 8, bottom: 8)
|
|
}
|
|
// Inline code
|
|
.code {
|
|
FontFamilyVariant(.monospaced)
|
|
FontSize(13)
|
|
ForegroundColor(isDark ? Color(red: 0.85, green: 0.6, blue: 0.95) : Color(red: 0.6, green: 0.2, blue: 0.7))
|
|
BackgroundColor(isDark
|
|
? Color(nsColor: NSColor(white: 0.18, alpha: 1.0))
|
|
: Color(nsColor: NSColor(white: 0.92, alpha: 1.0)))
|
|
}
|
|
// Block quotes
|
|
.blockquote { configuration in
|
|
HStack(spacing: 0) {
|
|
RoundedRectangle(cornerRadius: 1.5)
|
|
.fill(isDark ? Color.white.opacity(0.2) : Color.gray.opacity(0.4))
|
|
.frame(width: 3)
|
|
configuration.label
|
|
.markdownTextStyle {
|
|
ForegroundColor(isDark ? .white.opacity(0.6) : .secondary)
|
|
FontSize(14)
|
|
}
|
|
.padding(.leading, 12)
|
|
}
|
|
.markdownMargin(top: 8, bottom: 8)
|
|
}
|
|
// Links
|
|
.link {
|
|
ForegroundColor(Color.accentColor)
|
|
}
|
|
// Strong
|
|
.strong {
|
|
FontWeight(.semibold)
|
|
}
|
|
// Tables
|
|
.table { configuration in
|
|
configuration.label
|
|
.markdownTableBorderStyle(.init(color: isDark ? .white.opacity(0.15) : .gray.opacity(0.3)))
|
|
.markdownTableBackgroundStyle(
|
|
.alternatingRows(
|
|
isDark
|
|
? Color(nsColor: NSColor(white: 0.14, alpha: 1.0))
|
|
: Color(nsColor: NSColor(white: 0.96, alpha: 1.0)),
|
|
isDark
|
|
? Color(nsColor: NSColor(white: 0.10, alpha: 1.0))
|
|
: Color(nsColor: NSColor(white: 1.0, alpha: 1.0))
|
|
)
|
|
)
|
|
.markdownMargin(top: 8, bottom: 8)
|
|
}
|
|
// Thematic break (horizontal rule)
|
|
.thematicBreak {
|
|
Divider()
|
|
.markdownMargin(top: 16, bottom: 16)
|
|
}
|
|
// List items
|
|
.listItem { configuration in
|
|
configuration.label
|
|
.markdownMargin(top: 4, bottom: 4)
|
|
}
|
|
// Paragraphs
|
|
.paragraph { configuration in
|
|
configuration.label
|
|
.markdownMargin(top: 4, bottom: 8)
|
|
}
|
|
}
|
|
|
|
// MARK: - Focus Flash
|
|
|
|
private func triggerFocusFlashAnimation() {
|
|
focusFlashAnimationGeneration &+= 1
|
|
let generation = focusFlashAnimationGeneration
|
|
focusFlashOpacity = FocusFlashPattern.values.first ?? 0
|
|
|
|
for segment in FocusFlashPattern.segments {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) {
|
|
guard focusFlashAnimationGeneration == generation else { return }
|
|
withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) {
|
|
focusFlashOpacity = segment.targetOpacity
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func focusFlashAnimation(for curve: FocusFlashCurve, duration: TimeInterval) -> Animation {
|
|
switch curve {
|
|
case .easeIn:
|
|
return .easeIn(duration: duration)
|
|
case .easeOut:
|
|
return .easeOut(duration: duration)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MarkdownPointerObserver: NSViewRepresentable {
|
|
let onPointerDown: () -> Void
|
|
|
|
func makeNSView(context: Context) -> MarkdownPanelPointerObserverView {
|
|
let view = MarkdownPanelPointerObserverView()
|
|
view.onPointerDown = onPointerDown
|
|
return view
|
|
}
|
|
|
|
func updateNSView(_ nsView: MarkdownPanelPointerObserverView, context: Context) {
|
|
nsView.onPointerDown = onPointerDown
|
|
}
|
|
}
|
|
|
|
final class MarkdownPanelPointerObserverView: NSView {
|
|
var onPointerDown: (() -> Void)?
|
|
private var eventMonitor: Any?
|
|
|
|
override var mouseDownCanMoveWindow: Bool { false }
|
|
|
|
override init(frame frameRect: NSRect) {
|
|
super.init(frame: frameRect)
|
|
installEventMonitorIfNeeded()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
deinit {
|
|
if let eventMonitor {
|
|
NSEvent.removeMonitor(eventMonitor)
|
|
}
|
|
}
|
|
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
nil
|
|
}
|
|
|
|
func shouldHandle(_ event: NSEvent) -> Bool {
|
|
guard event.type == .leftMouseDown,
|
|
let window,
|
|
event.window === window,
|
|
!isHiddenOrHasHiddenAncestor else { return false }
|
|
let point = convert(event.locationInWindow, from: nil)
|
|
return bounds.contains(point)
|
|
}
|
|
|
|
func handleEventIfNeeded(_ event: NSEvent) -> NSEvent {
|
|
guard shouldHandle(event) else { return event }
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.onPointerDown?()
|
|
}
|
|
return event
|
|
}
|
|
|
|
private func installEventMonitorIfNeeded() {
|
|
guard eventMonitor == nil else { return }
|
|
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in
|
|
self?.handleEventIfNeeded(event) ?? event
|
|
}
|
|
}
|
|
}
|