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 { 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 }