607 lines
24 KiB
Swift
607 lines
24 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
import WebKit
|
|
import PhotosUI
|
|
|
|
// MARK: - UITextView wrapper that exposes selection-aware formatting
|
|
|
|
struct MarkdownTextEditor: UIViewRepresentable {
|
|
@Binding var text: String
|
|
/// Called with the UITextView so the parent can apply formatting
|
|
var onTextViewReady: (UITextView) -> Void
|
|
|
|
func makeUIView(context: Context) -> UITextView {
|
|
let tv = UITextView()
|
|
tv.font = UIFont.monospacedSystemFont(ofSize: 16, weight: .regular)
|
|
tv.autocorrectionType = .no
|
|
tv.autocapitalizationType = .none
|
|
tv.delegate = context.coordinator
|
|
tv.backgroundColor = .clear
|
|
tv.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
|
|
// Set initial text (e.g. when editing an existing page)
|
|
tv.text = text
|
|
onTextViewReady(tv)
|
|
return tv
|
|
}
|
|
|
|
func updateUIView(_ tv: UITextView, context: Context) {
|
|
// Only push changes that originated outside the UITextView (e.g. formatting toolbar).
|
|
// Skip updates triggered by the user typing to avoid cursor position resets.
|
|
guard !context.coordinator.isEditing, tv.text != text else { return }
|
|
let sel = tv.selectedRange
|
|
tv.text = text
|
|
// Clamp selection to new text length
|
|
let len = tv.text.utf16.count
|
|
tv.selectedRange = NSRange(location: min(sel.location, len), length: 0)
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
|
|
|
final class Coordinator: NSObject, UITextViewDelegate {
|
|
var parent: MarkdownTextEditor
|
|
/// True while the user is actively editing, so updateUIView won't fight the keyboard.
|
|
var isEditing = false
|
|
|
|
init(_ parent: MarkdownTextEditor) { self.parent = parent }
|
|
|
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
|
isEditing = true
|
|
}
|
|
|
|
func textViewDidEndEditing(_ textView: UITextView) {
|
|
isEditing = false
|
|
}
|
|
|
|
func textViewDidChange(_ textView: UITextView) {
|
|
parent.text = textView.text
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Page Editor
|
|
|
|
struct PageEditorView: View {
|
|
@State private var viewModel: PageEditorViewModel
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var showDiscardAlert = false
|
|
/// Reference to the underlying UITextView for formatting operations
|
|
@State private var textView: UITextView? = nil
|
|
@State private var imagePickerItem: PhotosPickerItem? = nil
|
|
|
|
init(mode: PageEditorViewModel.Mode) {
|
|
_viewModel = State(initialValue: PageEditorViewModel(mode: mode))
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
// Title field — prominent, borderless, single bottom rule
|
|
VStack(spacing: 0) {
|
|
TextField(L("editor.title.placeholder"), text: $viewModel.title)
|
|
.font(.system(size: 22, weight: .bold))
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 14)
|
|
.padding(.bottom, 10)
|
|
|
|
Rectangle()
|
|
.fill(Color(.separator))
|
|
.frame(height: 0.5)
|
|
}
|
|
|
|
// Content area
|
|
if viewModel.activeTab == .write {
|
|
VStack(spacing: 0) {
|
|
MarkdownTextEditor(text: $viewModel.markdownContent) { tv in
|
|
textView = tv
|
|
}
|
|
|
|
// Image upload progress
|
|
if case .uploading = viewModel.imageUploadState {
|
|
HStack(spacing: 8) {
|
|
ProgressView().controlSize(.small)
|
|
Text(L("editor.image.uploading"))
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 6)
|
|
.background(Color(.secondarySystemBackground))
|
|
}
|
|
|
|
if case .failed(let msg) = viewModel.imageUploadState {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.circle.fill")
|
|
.foregroundStyle(.red)
|
|
Text(msg)
|
|
.font(.footnote)
|
|
.foregroundStyle(.red)
|
|
Spacer()
|
|
Button { viewModel.imageUploadState = .idle } label: {
|
|
Image(systemName: "xmark").font(.footnote)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Color(.secondarySystemBackground))
|
|
}
|
|
|
|
Divider()
|
|
FormattingToolbar(
|
|
imagePickerItem: $imagePickerItem,
|
|
isUploadingImage: {
|
|
if case .uploading = viewModel.imageUploadState { return true }
|
|
return false
|
|
}()
|
|
) { action in
|
|
applyFormat(action)
|
|
}
|
|
}
|
|
} else {
|
|
MarkdownPreviewView(markdown: viewModel.markdownContent)
|
|
}
|
|
|
|
// Save error
|
|
if let error = viewModel.saveError {
|
|
ErrorBanner(error: error) {
|
|
Task { await viewModel.save() }
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
.navigationTitle(navigationTitle)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button(L("editor.cancel")) {
|
|
if viewModel.hasUnsavedChanges {
|
|
showDiscardAlert = true
|
|
} else {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
// Write / Preview toggle lives in the nav bar
|
|
ToolbarItem(placement: .principal) {
|
|
EditorTabToggle(activeTab: $viewModel.activeTab)
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(L("editor.save")) {
|
|
Task {
|
|
await viewModel.save()
|
|
if viewModel.saveError == nil {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
.disabled(viewModel.title.isEmpty || viewModel.markdownContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSaving)
|
|
.overlay {
|
|
if viewModel.isSaving {
|
|
ProgressView().scaleEffect(0.7)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.alert(L("editor.discard.title"), isPresented: $showDiscardAlert) {
|
|
Button(L("editor.discard.confirm"), role: .destructive) { dismiss() }
|
|
Button(L("editor.discard.keepediting"), role: .cancel) {}
|
|
} message: {
|
|
Text(L("editor.discard.message"))
|
|
}
|
|
// Handle image picked from photo library
|
|
.onChange(of: imagePickerItem) { _, newItem in
|
|
guard let newItem else { return }
|
|
Task {
|
|
guard let data = try? await newItem.loadTransferable(type: Data.self) else { return }
|
|
let mimeType: String
|
|
let filename: String
|
|
if let uti = newItem.supportedContentTypes.first {
|
|
if uti.conforms(to: .png) {
|
|
mimeType = "image/png"; filename = "image.png"
|
|
} else if uti.conforms(to: .webP) {
|
|
mimeType = "image/webp"; filename = "image.webp"
|
|
} else {
|
|
mimeType = "image/jpeg"; filename = "image.jpg"
|
|
}
|
|
} else {
|
|
mimeType = "image/jpeg"; filename = "image.jpg"
|
|
}
|
|
await viewModel.uploadImage(data: data, filename: filename, mimeType: mimeType)
|
|
imagePickerItem = nil
|
|
}
|
|
}
|
|
// When upload completes, insert markdown at cursor
|
|
.onChange(of: viewModel.pendingImageMarkdown) { _, markdown in
|
|
guard let markdown else { return }
|
|
viewModel.pendingImageMarkdown = nil
|
|
guard let tv = textView else {
|
|
viewModel.markdownContent += "\n\(markdown)"
|
|
return
|
|
}
|
|
let range = tv.selectedRange
|
|
let insertion = "\n\(markdown)\n"
|
|
replace(in: tv, range: range, with: insertion, cursorOffset: insertion.count)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var navigationTitle: String {
|
|
switch viewModel.mode {
|
|
case .create: return L("editor.new.title")
|
|
case .edit: return L("editor.edit.title")
|
|
}
|
|
}
|
|
|
|
// MARK: - Apply formatting to selected text (or insert at cursor)
|
|
|
|
private func applyFormat(_ action: FormatAction) {
|
|
guard let tv = textView else { return }
|
|
let text = tv.text ?? ""
|
|
let nsText = text as NSString
|
|
let range = tv.selectedRange
|
|
|
|
switch action {
|
|
|
|
// Inline wrap: surround selection or insert markers
|
|
case .bold, .italic, .strikethrough, .inlineCode:
|
|
let marker = action.inlineMarker
|
|
if range.length > 0 {
|
|
let selected = nsText.substring(with: range)
|
|
let replacement = "\(marker)\(selected)\(marker)"
|
|
replace(in: tv, range: range, with: replacement, cursorOffset: replacement.count)
|
|
} else {
|
|
let placeholder = action.placeholder
|
|
let insertion = "\(marker)\(placeholder)\(marker)"
|
|
replace(in: tv, range: range, with: insertion,
|
|
selectRange: NSRange(location: range.location + marker.count,
|
|
length: placeholder.count))
|
|
}
|
|
|
|
// Line prefix: prepend to each selected line
|
|
case .h1, .h2, .h3, .bulletList, .numberedList, .blockquote:
|
|
let prefix = action.linePrefix
|
|
applyLinePrefix(tv: tv, text: text, nsText: nsText, range: range, prefix: prefix)
|
|
|
|
// Block insert at cursor
|
|
case .codeBlock:
|
|
let block = "```\n\(range.length > 0 ? nsText.substring(with: range) : "code")\n```"
|
|
replace(in: tv, range: range, with: block, cursorOffset: block.count)
|
|
|
|
case .link:
|
|
if range.length > 0 {
|
|
let selected = nsText.substring(with: range)
|
|
let insertion = "[\(selected)](url)"
|
|
replace(in: tv, range: range, with: insertion, cursorOffset: insertion.count)
|
|
} else {
|
|
let insertion = "[text](url)"
|
|
replace(in: tv, range: range, with: insertion,
|
|
selectRange: NSRange(location: range.location + 1, length: 4))
|
|
}
|
|
|
|
case .horizontalRule:
|
|
// Insert on its own line
|
|
let before = range.location > 0 ? "\n" : ""
|
|
let insertion = "\(before)---\n"
|
|
replace(in: tv, range: range, with: insertion, cursorOffset: insertion.count)
|
|
}
|
|
}
|
|
|
|
/// Replace a range in the UITextView and sync back to the binding.
|
|
private func replace(in tv: UITextView, range: NSRange, with string: String,
|
|
cursorOffset: Int? = nil, selectRange: NSRange? = nil) {
|
|
guard let swiftRange = Range(range, in: tv.text) else { return }
|
|
var newText = tv.text!
|
|
newText.replaceSubrange(swiftRange, with: string)
|
|
tv.text = newText
|
|
viewModel.markdownContent = newText
|
|
// Position cursor
|
|
if let sel = selectRange {
|
|
tv.selectedRange = sel
|
|
} else if let offset = cursorOffset {
|
|
let newPos = range.location + offset
|
|
tv.selectedRange = NSRange(location: min(newPos, newText.utf16.count), length: 0)
|
|
}
|
|
}
|
|
|
|
/// Toggle a line prefix (e.g. `## `) on all lines touched by the selection.
|
|
private func applyLinePrefix(tv: UITextView, text: String, nsText: NSString,
|
|
range: NSRange, prefix: String) {
|
|
// Expand range to full lines
|
|
let lineRange = nsText.lineRange(for: range)
|
|
let linesString = nsText.substring(with: lineRange)
|
|
let lines = linesString.components(separatedBy: "\n")
|
|
|
|
// Determine if ALL non-empty lines already have this prefix → toggle off
|
|
let nonEmpty = lines.filter { !$0.isEmpty }
|
|
let allPrefixed = !nonEmpty.isEmpty && nonEmpty.allSatisfy { $0.hasPrefix(prefix) }
|
|
|
|
let transformed = lines.map { line -> String in
|
|
if line.isEmpty { return line }
|
|
if allPrefixed {
|
|
return String(line.dropFirst(prefix.count))
|
|
} else {
|
|
return line.hasPrefix(prefix) ? line : prefix + line
|
|
}
|
|
}.joined(separator: "\n")
|
|
|
|
replace(in: tv, range: lineRange, with: transformed, cursorOffset: transformed.count)
|
|
}
|
|
}
|
|
|
|
// MARK: - Write / Preview Tab Toggle
|
|
|
|
struct EditorTabToggle: View {
|
|
@Binding var activeTab: PageEditorViewModel.EditorTab
|
|
|
|
var body: some View {
|
|
HStack(spacing: 0) {
|
|
tabButton(L("editor.tab.write"), tab: .write)
|
|
tabButton(L("editor.tab.preview"), tab: .preview)
|
|
}
|
|
.background(Color(.secondarySystemBackground), in: Capsule())
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func tabButton(_ label: String, tab: PageEditorViewModel.EditorTab) -> some View {
|
|
let isSelected = activeTab == tab
|
|
Text(label)
|
|
.font(.system(size: 13, weight: isSelected ? .semibold : .regular))
|
|
.foregroundStyle(isSelected ? .primary : .secondary)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 6)
|
|
.background(
|
|
isSelected
|
|
? Color(.systemBackground)
|
|
: Color.clear,
|
|
in: Capsule()
|
|
)
|
|
.padding(3)
|
|
.animation(.easeInOut(duration: 0.18), value: activeTab)
|
|
.onTapGesture { activeTab = tab }
|
|
}
|
|
}
|
|
|
|
// MARK: - Format Actions
|
|
|
|
enum FormatAction {
|
|
case h1, h2, h3
|
|
case bold, italic, strikethrough, inlineCode
|
|
case bulletList, numberedList, blockquote
|
|
case link, codeBlock, horizontalRule
|
|
|
|
var inlineMarker: String {
|
|
switch self {
|
|
case .bold: return "**"
|
|
case .italic: return "*"
|
|
case .strikethrough: return "~~"
|
|
case .inlineCode: return "`"
|
|
default: return ""
|
|
}
|
|
}
|
|
|
|
var linePrefix: String {
|
|
switch self {
|
|
case .h1: return "# "
|
|
case .h2: return "## "
|
|
case .h3: return "### "
|
|
case .bulletList: return "- "
|
|
case .numberedList: return "1. "
|
|
case .blockquote: return "> "
|
|
default: return ""
|
|
}
|
|
}
|
|
|
|
var placeholder: String {
|
|
switch self {
|
|
case .bold: return "bold"
|
|
case .italic: return "italic"
|
|
case .strikethrough: return "text"
|
|
case .inlineCode: return "code"
|
|
default: return ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Formatting Toolbar
|
|
|
|
struct FormattingToolbar: View {
|
|
@Binding var imagePickerItem: PhotosPickerItem?
|
|
let isUploadingImage: Bool
|
|
let onAction: (FormatAction) -> Void
|
|
|
|
var body: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 6) {
|
|
FormatButton("H1", action: .h1, onAction: onAction)
|
|
FormatButton("H2", action: .h2, onAction: onAction)
|
|
FormatButton("H3", action: .h3, onAction: onAction)
|
|
|
|
toolbarDivider
|
|
|
|
FormatButton(systemImage: "bold", action: .bold, onAction: onAction)
|
|
FormatButton(systemImage: "italic", action: .italic, onAction: onAction)
|
|
FormatButton(systemImage: "strikethrough", action: .strikethrough, onAction: onAction)
|
|
FormatButton(systemImage: "chevron.left.forwardslash.chevron.right", action: .inlineCode, onAction: onAction)
|
|
|
|
toolbarDivider
|
|
|
|
FormatButton(systemImage: "list.bullet", action: .bulletList, onAction: onAction)
|
|
FormatButton(systemImage: "list.number", action: .numberedList, onAction: onAction)
|
|
FormatButton(systemImage: "text.quote", action: .blockquote, onAction: onAction)
|
|
|
|
toolbarDivider
|
|
|
|
FormatButton(systemImage: "link", action: .link, onAction: onAction)
|
|
FormatButton(systemImage: "curlybraces", action: .codeBlock, onAction: onAction)
|
|
FormatButton(systemImage: "minus", action: .horizontalRule, onAction: onAction)
|
|
|
|
toolbarDivider
|
|
|
|
// Image picker button
|
|
PhotosPicker(
|
|
selection: $imagePickerItem,
|
|
matching: .images,
|
|
photoLibrary: .shared()
|
|
) {
|
|
Group {
|
|
if isUploadingImage {
|
|
ProgressView().controlSize(.small)
|
|
} else {
|
|
Image(systemName: "photo")
|
|
.font(.system(size: 15, weight: .medium))
|
|
}
|
|
}
|
|
.frame(width: 40, height: 36)
|
|
.foregroundStyle(.secondary)
|
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 9))
|
|
}
|
|
.disabled(isUploadingImage)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
}
|
|
.background(Color(.systemBackground))
|
|
.overlay(alignment: .top) {
|
|
Rectangle()
|
|
.fill(Color(.separator))
|
|
.frame(height: 0.5)
|
|
}
|
|
}
|
|
|
|
private var toolbarDivider: some View {
|
|
Rectangle()
|
|
.fill(Color(.separator))
|
|
.frame(width: 0.5, height: 22)
|
|
.padding(.horizontal, 2)
|
|
}
|
|
}
|
|
|
|
struct FormatButton: View {
|
|
let label: String?
|
|
let systemImage: String?
|
|
let action: FormatAction
|
|
let onAction: (FormatAction) -> Void
|
|
|
|
init(_ label: String, action: FormatAction, onAction: @escaping (FormatAction) -> Void) {
|
|
self.label = label
|
|
self.systemImage = nil
|
|
self.action = action
|
|
self.onAction = onAction
|
|
}
|
|
|
|
init(systemImage: String, action: FormatAction, onAction: @escaping (FormatAction) -> Void) {
|
|
self.label = nil
|
|
self.systemImage = systemImage
|
|
self.action = action
|
|
self.onAction = onAction
|
|
}
|
|
|
|
var body: some View {
|
|
// Use onTapGesture instead of Button so the toolbar tap doesn't
|
|
// resign the UITextView's first responder (which would clear the selection).
|
|
Group {
|
|
if let label {
|
|
Text(label)
|
|
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
|
} else if let systemImage {
|
|
Image(systemName: systemImage)
|
|
.font(.system(size: 15, weight: .medium))
|
|
}
|
|
}
|
|
.frame(width: 40, height: 36)
|
|
.foregroundStyle(.secondary)
|
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 9))
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
onAction(action)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Markdown Preview
|
|
|
|
struct MarkdownPreviewView: View {
|
|
let markdown: String
|
|
@State private var webPage = WebPage()
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
WebView(webPage)
|
|
.onAppear { loadPreview() }
|
|
.onChange(of: markdown) { loadPreview() }
|
|
.onChange(of: colorScheme) { loadPreview() }
|
|
}
|
|
|
|
private func loadPreview() {
|
|
let html = markdownToHTML(markdown)
|
|
let isDark = colorScheme == .dark
|
|
let bg = isDark ? "#1c1c1e" : "#ffffff"
|
|
let fg = isDark ? "#f2f2f7" : "#000000"
|
|
let codeBg = isDark ? "#2c2c2e" : "#f2f2f7"
|
|
|
|
let fullHTML = """
|
|
<!DOCTYPE html><html>
|
|
<head>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>
|
|
body { font-family: -apple-system; font-size: 16px; line-height: 1.6; padding: 16px;
|
|
background: \(bg); color: \(fg); }
|
|
pre { background: \(codeBg); padding: 12px; border-radius: 8px; overflow-x: auto; }
|
|
code { font-family: "SF Mono", monospace; font-size: 14px; background: \(codeBg);
|
|
padding: 2px 4px; border-radius: 4px; }
|
|
pre code { background: transparent; padding: 0; }
|
|
blockquote { border-left: 3px solid #0a84ff; padding-left: 12px; color: #8e8e93; margin-left: 0; }
|
|
</style>
|
|
</head>
|
|
<body>\(html)</body></html>
|
|
"""
|
|
webPage.load(html: fullHTML, baseURL: URL(string: "https://bookstack.example.com")!)
|
|
}
|
|
|
|
/// Minimal Markdown → HTML converter for preview purposes.
|
|
/// For editing purposes the full rendering happens server-side.
|
|
private func markdownToHTML(_ md: String) -> String {
|
|
var html = md
|
|
// Code blocks (must come before inline code)
|
|
let codeBlockPattern = #"```[\w]*\n([\s\S]*?)```"#
|
|
html = html.replacingOccurrences(of: codeBlockPattern, with: "<pre><code>$1</code></pre>",
|
|
options: .regularExpression)
|
|
// Inline code
|
|
html = html.replacingOccurrences(of: "`([^`]+)`", with: "<code>$1</code>",
|
|
options: .regularExpression)
|
|
// Bold + italic
|
|
html = html.replacingOccurrences(of: "\\*\\*\\*(.+?)\\*\\*\\*", with: "<strong><em>$1</em></strong>",
|
|
options: .regularExpression)
|
|
html = html.replacingOccurrences(of: "\\*\\*(.+?)\\*\\*", with: "<strong>$1</strong>",
|
|
options: .regularExpression)
|
|
html = html.replacingOccurrences(of: "\\*(.+?)\\*", with: "<em>$1</em>",
|
|
options: .regularExpression)
|
|
// Headings (use (?m) inline flag for multiline anchors)
|
|
for h in stride(from: 6, through: 1, by: -1) {
|
|
html = html.replacingOccurrences(of: "(?m)^#{" + "\(h)" + "} (.+)$",
|
|
with: "<h\(h)>$1</h\(h)>",
|
|
options: .regularExpression)
|
|
}
|
|
// Horizontal rule
|
|
html = html.replacingOccurrences(of: "(?m)^---$", with: "<hr>",
|
|
options: .regularExpression)
|
|
// Unordered list items
|
|
html = html.replacingOccurrences(of: "(?m)^[\\-\\*] (.+)$", with: "<li>$1</li>",
|
|
options: .regularExpression)
|
|
// Blockquote
|
|
html = html.replacingOccurrences(of: "(?m)^> (.+)$", with: "<blockquote>$1</blockquote>",
|
|
options: .regularExpression)
|
|
// Paragraphs: double newlines become <br><br>
|
|
html = html.replacingOccurrences(of: "\n\n", with: "<br><br>")
|
|
return html
|
|
}
|
|
}
|
|
|
|
#Preview("New Page") {
|
|
PageEditorView(mode: .create(bookId: 1))
|
|
}
|
|
|
|
#Preview("Edit Page") {
|
|
PageEditorView(mode: .edit(page: .mock))
|
|
}
|