import Highlighter import SwiftUI import UIKit @MainActor final class CommentEditorViewController: UIViewController, UITextViewDelegate { private let context: AppContext private let owner: String private let repository: String private let number: Int64 private let comment: CommentRow? private let saved: () -> Void private let scrollView = UIScrollView() private let modeControl = UISegmentedControl(items: ["Write", "Preview"]) private let bodyView = UITextView() private let previewView = UIView() private let spinner = UIActivityIndicatorView(style: .medium) private var task: Task? private var previewController: UIViewController? private var editorFont: UIFont { UIFontMetrics(forTextStyle: .body).scaledFont( for: .monospacedSystemFont(ofSize: 15, weight: .regular) ) } init( context: AppContext, owner: String, repository: String, number: Int64, comment: CommentRow? = nil, saved: @escaping () -> Void ) { self.context = context self.owner = owner self.repository = repository self.number = number self.comment = comment self.saved = saved super.init(nibName: nil, bundle: nil) title = comment == nil ? "New Comment" : "Edit Comment" } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } deinit { task?.cancel() } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemGroupedBackground navigationItem.leftBarButtonItem = UIBarButtonItem( barButtonSystemItem: .cancel, target: self, action: #selector(cancel) ) restoreSaveButton() scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.keyboardDismissMode = .interactive view.addSubview(scrollView) let stack = UIStackView(arrangedSubviews: [modeControl, bodyView, previewView]) stack.axis = .vertical stack.spacing = 12 stack.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(stack) modeControl.selectedSegmentIndex = 0 modeControl.accessibilityLabel = "Comment editor mode" modeControl.addTarget(self, action: #selector(modeChanged), for: .valueChanged) bodyView.delegate = self bodyView.text = comment?.body ?? "" bodyView.font = editorFont bodyView.adjustsFontForContentSizeCategory = true bodyView.backgroundColor = .secondarySystemGroupedBackground bodyView.layer.cornerRadius = 10 bodyView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8) bodyView.autocapitalizationType = .sentences bodyView.accessibilityLabel = "Comment in Markdown" previewView.backgroundColor = .secondarySystemGroupedBackground previewView.layer.cornerRadius = 10 previewView.clipsToBounds = true previewView.accessibilityLabel = "Comment preview" previewView.isHidden = true highlightBody() updateSaveButton() NSLayoutConstraint.activate([ scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), scrollView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor), stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16), stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16), stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16), stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16), stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32), bodyView.heightAnchor.constraint(greaterThanOrEqualToConstant: 280), previewView.heightAnchor.constraint(greaterThanOrEqualToConstant: 280), ]) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) if comment == nil { bodyView.becomeFirstResponder() } } func textViewDidChange(_ textView: UITextView) { highlightBody() updateSaveButton() } private func highlightBody() { let source = bodyView.text ?? "" let selection = bodyView.selectedRange let highlighter = Highlighter() highlighter?.setTheme(traitCollection.userInterfaceStyle == .dark ? "atom-one-dark" : "atom-one-light") highlighter?.theme.setCodeFont(editorFont) bodyView.attributedText = highlighter?.highlight(source, as: "markdown") ?? NSAttributedString(string: source, attributes: [ .font: editorFont, .foregroundColor: UIColor.label, ]) bodyView.selectedRange = selection bodyView.typingAttributes = [ .font: editorFont, .foregroundColor: UIColor.label, ] } @objc private func modeChanged() { let preview = modeControl.selectedSegmentIndex == 1 if preview { previewController?.willMove(toParent: nil) previewController?.view.removeFromSuperview() previewController?.removeFromParent() let controller = UIHostingController( rootView: RepositoryMarkdownPreview(source: bodyView.text ?? "") ) addChild(controller) controller.view.translatesAutoresizingMaskIntoConstraints = false previewView.addSubview(controller.view) NSLayoutConstraint.activate([ controller.view.leadingAnchor.constraint(equalTo: previewView.leadingAnchor), controller.view.trailingAnchor.constraint(equalTo: previewView.trailingAnchor), controller.view.topAnchor.constraint(equalTo: previewView.topAnchor), controller.view.bottomAnchor.constraint(equalTo: previewView.bottomAnchor), ]) controller.didMove(toParent: self) previewController = controller } bodyView.isHidden = preview previewView.isHidden = !preview view.endEditing(true) } @objc private func save() { view.endEditing(true) navigationItem.leftBarButtonItem?.isEnabled = false navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner) spinner.startAnimating() task?.cancel() task = Task { do { try await context.core.saveIssueComment( owner: owner, repository: repository, number: number, commentId: comment?.id, body: bodyView.text ?? "" ) guard !Task.isCancelled else { return } saved() } catch { if !Task.isCancelled { show(error: error) restoreSaveButton() } } } } private func restoreSaveButton() { spinner.stopAnimating() navigationItem.leftBarButtonItem?.isEnabled = true navigationItem.rightBarButtonItem = UIBarButtonItem( title: "Save", style: .done, target: self, action: #selector(save) ) updateSaveButton() } private func updateSaveButton() { navigationItem.rightBarButtonItem?.isEnabled = !(bodyView.text ?? "") .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @objc private func cancel() { dismiss(animated: true) } }