Sapling/Sources/SaplingEditor/HybridMarkdownEditor.swift

161 lines
5.3 KiB
Swift
Raw Normal View History

2026-05-29 15:19:33 +02:00
import SwiftUI
import SaplingCore
import SaplingRenderer
@MainActor
public final class HybridMarkdownEditorViewModel: ObservableObject {
@Published public var document: MarkdownDocument
@Published public var activeLineIndex: Int
public init(document: MarkdownDocument, activeLineIndex: Int = 0) {
self.document = document
self.activeLineIndex = activeLineIndex
}
public var lines: [String] {
document.content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
}
public func bindingForActiveLine() -> Binding<String> {
Binding(
get: { [weak self] in
guard let self else { return "" }
let lines = self.lines
guard lines.indices.contains(self.activeLineIndex) else { return "" }
return lines[self.activeLineIndex]
},
set: { [weak self] newValue in
self?.replaceActiveLine(with: newValue)
}
)
}
public func activateLine(at index: Int) {
activeLineIndex = index
}
private func replaceActiveLine(with newValue: String) {
var updatedLines = lines
if updatedLines.indices.contains(activeLineIndex) {
updatedLines[activeLineIndex] = newValue
} else {
updatedLines.append(newValue)
activeLineIndex = updatedLines.endIndex - 1
}
document.content = updatedLines.joined(separator: "\n")
}
}
public struct HybridMarkdownEditor: View {
@ObservedObject private var viewModel: HybridMarkdownEditorViewModel
private let renderer: any MarkdownRendering
public init(
viewModel: HybridMarkdownEditorViewModel,
renderer: any MarkdownRendering = MarkdownRenderer()
) {
self.viewModel = viewModel
self.renderer = renderer
}
public var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 4) {
ForEach(Array(viewModel.lines.enumerated()), id: \.offset) { index, line in
if index == viewModel.activeLineIndex {
TextEditor(text: viewModel.bindingForActiveLine())
.font(.system(.body, design: .monospaced))
.scrollContentBackground(.hidden)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.frame(minHeight: 34)
.background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 6))
} else {
RenderedMarkdownLine(line: line, renderer: renderer)
.contentShape(Rectangle())
.onTapGesture {
viewModel.activateLine(at: index)
}
}
}
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(platformTextBackground)
}
}
private struct RenderedMarkdownLine: View {
let line: String
let renderer: any MarkdownRendering
var body: some View {
let block = renderer.blocks(for: line).first ?? .blank(id: UUID())
blockView(block)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
.padding(.vertical, 3)
}
@ViewBuilder
private func blockView(_ block: RenderedMarkdownBlock) -> some View {
switch block {
case .heading(_, let level, let text):
Text(text)
.font(headingFont(level: level))
.fontWeight(.semibold)
case .paragraph(_, let text):
Text(text)
.font(.body)
case .codeBlock(_, let language, let code):
VStack(alignment: .leading, spacing: 6) {
if let language {
Text(language.uppercased())
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(code)
.font(.system(.body, design: .monospaced))
}
.padding(10)
.background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 6))
case .task(_, let checked, let text):
HStack(spacing: 8) {
Image(systemName: checked ? "checkmark.square.fill" : "square")
.foregroundStyle(checked ? .green : .secondary)
Text(text)
}
case .image(_, let altText, let source):
HStack(spacing: 8) {
Image(systemName: "photo")
VStack(alignment: .leading, spacing: 2) {
Text(altText.isEmpty ? "Image" : altText)
Text(source)
.font(.caption)
.foregroundStyle(.secondary)
}
}
case .blank:
Spacer(minLength: 20)
}
}
private func headingFont(level: Int) -> Font {
switch level {
case 1: .largeTitle
case 2: .title
case 3: .title2
default: .headline
}
}
}
private var platformTextBackground: Color {
#if os(macOS)
Color(nsColor: .textBackgroundColor)
#else
Color(uiColor: .systemBackground)
#endif
}