160 lines
5.3 KiB
Swift
160 lines
5.3 KiB
Swift
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
|
|
}
|