Add toolbar for JSON editor
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Libbox
|
||||||
import Library
|
import Library
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -7,8 +8,10 @@ public final class EditProfileContentViewModel: BaseViewModel {
|
|||||||
@Published public var profile: Profile?
|
@Published public var profile: Profile?
|
||||||
@Published public var profileContent = ""
|
@Published public var profileContent = ""
|
||||||
@Published public var isChanged = false
|
@Published public var isChanged = false
|
||||||
|
@Published public var configurationError: String?
|
||||||
|
|
||||||
private let profileID: Int64?
|
private let profileID: Int64?
|
||||||
|
private var validationTask: Task<Void, Never>?
|
||||||
|
|
||||||
public init(profileID: Int64?) {
|
public init(profileID: Int64?) {
|
||||||
self.profileID = profileID
|
self.profileID = profileID
|
||||||
@@ -18,6 +21,7 @@ public final class EditProfileContentViewModel: BaseViewModel {
|
|||||||
|
|
||||||
public func markAsChanged() {
|
public func markAsChanged() {
|
||||||
isChanged = true
|
isChanged = true
|
||||||
|
scheduleValidation()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reset() {
|
public func reset() {
|
||||||
@@ -25,9 +29,53 @@ public final class EditProfileContentViewModel: BaseViewModel {
|
|||||||
profile = nil
|
profile = nil
|
||||||
profileContent = ""
|
profileContent = ""
|
||||||
isChanged = false
|
isChanged = false
|
||||||
|
configurationError = nil
|
||||||
|
validationTask?.cancel()
|
||||||
|
validationTask = nil
|
||||||
alert = nil
|
alert = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func scheduleValidation() {
|
||||||
|
configurationError = nil
|
||||||
|
validationTask?.cancel()
|
||||||
|
validationTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await checkConfiguration()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkConfiguration() async {
|
||||||
|
let content = profileContent
|
||||||
|
if content.isEmpty { return }
|
||||||
|
var error: NSError?
|
||||||
|
LibboxCheckConfig(content, &error)
|
||||||
|
if let error {
|
||||||
|
configurationError = error.localizedDescription
|
||||||
|
} else {
|
||||||
|
configurationError = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func formatConfiguration() async {
|
||||||
|
let content = profileContent
|
||||||
|
if content.isEmpty { return }
|
||||||
|
var error: NSError?
|
||||||
|
let result = LibboxFormatConfig(content, &error)
|
||||||
|
if let error {
|
||||||
|
configurationError = error.localizedDescription
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let formatted = result?.value, formatted != content {
|
||||||
|
profileContent = formatted
|
||||||
|
isChanged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func dismissConfigurationError() {
|
||||||
|
configurationError = nil
|
||||||
|
}
|
||||||
|
|
||||||
public func loadContent() async {
|
public func loadContent() async {
|
||||||
do {
|
do {
|
||||||
try await loadContentBackground()
|
try await loadContentBackground()
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
|
||||||
|
public struct EditorToolbarView: View {
|
||||||
|
let canUndo: Bool
|
||||||
|
let canRedo: Bool
|
||||||
|
let onUndo: () -> Void
|
||||||
|
let onRedo: () -> Void
|
||||||
|
let onFormat: () -> Void
|
||||||
|
let onInsertSymbol: (String) -> Void
|
||||||
|
let configurationError: String?
|
||||||
|
let onDismissError: () -> Void
|
||||||
|
|
||||||
|
public init(
|
||||||
|
canUndo: Bool,
|
||||||
|
canRedo: Bool,
|
||||||
|
onUndo: @escaping () -> Void,
|
||||||
|
onRedo: @escaping () -> Void,
|
||||||
|
onFormat: @escaping () -> Void,
|
||||||
|
onInsertSymbol: @escaping (String) -> Void,
|
||||||
|
configurationError: String?,
|
||||||
|
onDismissError: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.canUndo = canUndo
|
||||||
|
self.canRedo = canRedo
|
||||||
|
self.onUndo = onUndo
|
||||||
|
self.onRedo = onRedo
|
||||||
|
self.onFormat = onFormat
|
||||||
|
self.onInsertSymbol = onInsertSymbol
|
||||||
|
self.configurationError = configurationError
|
||||||
|
self.onDismissError = onDismissError
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
if let error = configurationError {
|
||||||
|
errorBanner(error)
|
||||||
|
}
|
||||||
|
symbolBar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func errorBanner(_ error: String) -> some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
Text(error)
|
||||||
|
.font(.footnote)
|
||||||
|
.lineLimit(2)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
onDismissError()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
#if os(iOS)
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
#else
|
||||||
|
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
#endif
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var symbolBar: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
actionButtons
|
||||||
|
Divider().frame(height: 24).padding(.horizontal, 4)
|
||||||
|
symbolButtons
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
#else
|
||||||
|
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
#endif
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var actionButtons: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Button {
|
||||||
|
onUndo()
|
||||||
|
} label: {
|
||||||
|
Label("Undo", systemImage: "arrow.uturn.backward")
|
||||||
|
}
|
||||||
|
.disabled(!canUndo)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onRedo()
|
||||||
|
} label: {
|
||||||
|
Label("Redo", systemImage: "arrow.uturn.forward")
|
||||||
|
}
|
||||||
|
.disabled(!canRedo)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onFormat()
|
||||||
|
} label: {
|
||||||
|
Label("Format", systemImage: "text.alignleft")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var symbolButtons: some View {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
ForEach(primarySymbols, id: \.self) { symbol in
|
||||||
|
symbolButton(symbol)
|
||||||
|
}
|
||||||
|
ForEach(secondarySymbols, id: \.self) { symbol in
|
||||||
|
symbolButton(symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var primarySymbols: [String] {
|
||||||
|
["\"", ":", ",", "{", "}", "[", "]"]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var secondarySymbols: [String] {
|
||||||
|
["true", "false"]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func symbolButton(_ symbol: String) -> some View {
|
||||||
|
Button {
|
||||||
|
onInsertSymbol(symbol)
|
||||||
|
} label: {
|
||||||
|
Text(symbol)
|
||||||
|
.font(.system(.body, design: .monospaced).weight(.medium))
|
||||||
|
#if os(iOS)
|
||||||
|
.frame(minWidth: symbol.count > 1 ? nil : 32, minHeight: 32)
|
||||||
|
#else
|
||||||
|
.frame(minWidth: symbol.count > 1 ? nil : 24)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -1141,6 +1141,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Format" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"FPS" : {
|
"FPS" : {
|
||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
@@ -2079,6 +2082,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Redo" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Register task failed" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Releases" : {
|
"Releases" : {
|
||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
@@ -2671,6 +2680,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Undo" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Unexpected message type %lld" : {
|
"Unexpected message type %lld" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -4,6 +4,36 @@ import CodeEditSourceEditor
|
|||||||
import CodeEditTextView
|
import CodeEditTextView
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class CodeEditEditorController: ObservableObject {
|
||||||
|
weak var controller: TextViewController?
|
||||||
|
|
||||||
|
@Published public var canUndo = false
|
||||||
|
@Published public var canRedo = false
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func undo() {
|
||||||
|
controller?.textView.undoManager?.undo()
|
||||||
|
updateUndoState()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func redo() {
|
||||||
|
controller?.textView.undoManager?.redo()
|
||||||
|
updateUndoState()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func insertSymbol(_ symbol: String) {
|
||||||
|
guard let textView = controller?.textView else { return }
|
||||||
|
textView.insertText(symbol, replacementRange: textView.selectedRange())
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUndoState() {
|
||||||
|
canUndo = controller?.textView.undoManager?.canUndo ?? false
|
||||||
|
canRedo = controller?.textView.undoManager?.canRedo ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private extension NSColor {
|
private extension NSColor {
|
||||||
var forEditor: NSColor {
|
var forEditor: NSColor {
|
||||||
usingColorSpace(.sRGB) ?? self
|
usingColorSpace(.sRGB) ?? self
|
||||||
@@ -53,6 +83,13 @@ private func makeConfiguration(isEditable: Bool) -> SourceEditorConfiguration {
|
|||||||
struct CodeEditTextView: NSViewRepresentable {
|
struct CodeEditTextView: NSViewRepresentable {
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
let isEditable: Bool
|
let isEditable: Bool
|
||||||
|
let editorController: CodeEditEditorController?
|
||||||
|
|
||||||
|
init(text: Binding<String>, isEditable: Bool, editorController: CodeEditEditorController? = nil) {
|
||||||
|
_text = text
|
||||||
|
self.isEditable = isEditable
|
||||||
|
self.editorController = editorController
|
||||||
|
}
|
||||||
|
|
||||||
func makeNSView(context: Context) -> NSView {
|
func makeNSView(context: Context) -> NSView {
|
||||||
let controller = TextViewController(
|
let controller = TextViewController(
|
||||||
@@ -79,6 +116,11 @@ struct CodeEditTextView: NSViewRepresentable {
|
|||||||
|
|
||||||
context.coordinator.controller = controller
|
context.coordinator.controller = controller
|
||||||
context.coordinator.setupObservation()
|
context.coordinator.setupObservation()
|
||||||
|
editorController?.controller = controller
|
||||||
|
Task { @MainActor in
|
||||||
|
editorController?.updateUndoState()
|
||||||
|
}
|
||||||
|
|
||||||
return containerView
|
return containerView
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,19 +132,22 @@ struct CodeEditTextView: NSViewRepresentable {
|
|||||||
if controller.configuration.behavior.isEditable != isEditable {
|
if controller.configuration.behavior.isEditable != isEditable {
|
||||||
controller.configuration = makeConfiguration(isEditable: isEditable)
|
controller.configuration = makeConfiguration(isEditable: isEditable)
|
||||||
}
|
}
|
||||||
|
editorController?.controller = controller
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator(text: $text)
|
Coordinator(text: $text, editorController: editorController)
|
||||||
}
|
}
|
||||||
|
|
||||||
class Coordinator: NSObject {
|
class Coordinator: NSObject {
|
||||||
var controller: TextViewController?
|
var controller: TextViewController?
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
private var observation: NSObjectProtocol?
|
private var observation: NSObjectProtocol?
|
||||||
|
private weak var editorController: CodeEditEditorController?
|
||||||
|
|
||||||
init(text: Binding<String>) {
|
init(text: Binding<String>, editorController: CodeEditEditorController?) {
|
||||||
_text = text
|
_text = text
|
||||||
|
self.editorController = editorController
|
||||||
super.init()
|
super.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +160,9 @@ struct CodeEditTextView: NSViewRepresentable {
|
|||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
guard let self, let controller = self.controller else { return }
|
guard let self, let controller = self.controller else { return }
|
||||||
self.text = controller.text
|
self.text = controller.text
|
||||||
|
Task { @MainActor in
|
||||||
|
self.editorController?.updateUndoState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ public struct MacApplication: Scene {
|
|||||||
@StateObject private var environments = ExtensionEnvironments()
|
@StateObject private var environments = ExtensionEnvironments()
|
||||||
|
|
||||||
private let profileEditor: (Binding<String>, Bool) -> AnyView = { text, isEditable in
|
private let profileEditor: (Binding<String>, Bool) -> AnyView = { text, isEditable in
|
||||||
AnyView(CodeEditTextView(text: text, isEditable: isEditable))
|
AnyView(ProfileEditorWrapperView(text: text, isEditable: isEditable))
|
||||||
}
|
}
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public struct MainView: View {
|
|||||||
@State private var pendingSettingsPage: SettingsPage?
|
@State private var pendingSettingsPage: SettingsPage?
|
||||||
|
|
||||||
private let profileEditor: (Binding<String>, Bool) -> AnyView = { text, isEditable in
|
private let profileEditor: (Binding<String>, Bool) -> AnyView = { text, isEditable in
|
||||||
AnyView(CodeEditTextView(text: text, isEditable: isEditable))
|
AnyView(ProfileEditorWrapperView(text: text, isEditable: isEditable))
|
||||||
}
|
}
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import ApplicationLibrary
|
||||||
|
import Libbox
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ProfileEditorWrapperView: View {
|
||||||
|
@Binding var text: String
|
||||||
|
let isEditable: Bool
|
||||||
|
|
||||||
|
@StateObject private var controller = CodeEditEditorController()
|
||||||
|
@State private var configurationError: String?
|
||||||
|
@State private var validationTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
CodeEditTextView(text: $text, isEditable: isEditable, editorController: controller)
|
||||||
|
|
||||||
|
if isEditable {
|
||||||
|
EditorToolbarView(
|
||||||
|
canUndo: controller.canUndo,
|
||||||
|
canRedo: controller.canRedo,
|
||||||
|
onUndo: { controller.undo() },
|
||||||
|
onRedo: { controller.redo() },
|
||||||
|
onFormat: { formatConfiguration() },
|
||||||
|
onInsertSymbol: { controller.insertSymbol($0) },
|
||||||
|
configurationError: configurationError,
|
||||||
|
onDismissError: { configurationError = nil }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChangeCompat(of: text) {
|
||||||
|
if isEditable {
|
||||||
|
scheduleValidation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleValidation() {
|
||||||
|
configurationError = nil
|
||||||
|
validationTask?.cancel()
|
||||||
|
validationTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await checkConfiguration()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkConfiguration() async {
|
||||||
|
let content = text
|
||||||
|
if content.isEmpty { return }
|
||||||
|
var error: NSError?
|
||||||
|
LibboxCheckConfig(content, &error)
|
||||||
|
if let error {
|
||||||
|
configurationError = error.localizedDescription
|
||||||
|
} else {
|
||||||
|
configurationError = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatConfiguration() {
|
||||||
|
let content = text
|
||||||
|
if content.isEmpty { return }
|
||||||
|
var error: NSError?
|
||||||
|
let result = LibboxFormatConfig(content, &error)
|
||||||
|
if let error {
|
||||||
|
configurationError = error.localizedDescription
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let formatted = result?.value, formatted != content {
|
||||||
|
text = formatted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -17,7 +17,7 @@ struct MainView: View {
|
|||||||
@State private var buttonState = ButtonVisibilityState()
|
@State private var buttonState = ButtonVisibilityState()
|
||||||
|
|
||||||
private let profileEditor: (Binding<String>, Bool) -> AnyView = { text, isEditable in
|
private let profileEditor: (Binding<String>, Bool) -> AnyView = { text, isEditable in
|
||||||
AnyView(RunestoneTextView(text: text, isEditable: isEditable))
|
AnyView(ProfileEditorWrapperView(text: text, isEditable: isEditable))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var shouldShowBottomAccessory: Bool {
|
private var shouldShowBottomAccessory: Bool {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import ApplicationLibrary
|
||||||
|
import Libbox
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ProfileEditorWrapperView: View {
|
||||||
|
@Binding var text: String
|
||||||
|
let isEditable: Bool
|
||||||
|
|
||||||
|
@StateObject private var controller = RunestoneEditorController()
|
||||||
|
@State private var configurationError: String?
|
||||||
|
@State private var validationTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
RunestoneTextView(text: $text, isEditable: isEditable, controller: controller)
|
||||||
|
|
||||||
|
if isEditable {
|
||||||
|
EditorToolbarView(
|
||||||
|
canUndo: controller.canUndo,
|
||||||
|
canRedo: controller.canRedo,
|
||||||
|
onUndo: { controller.undo() },
|
||||||
|
onRedo: { controller.redo() },
|
||||||
|
onFormat: { formatConfiguration() },
|
||||||
|
onInsertSymbol: { controller.insertSymbol($0) },
|
||||||
|
configurationError: configurationError,
|
||||||
|
onDismissError: { configurationError = nil }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChangeCompat(of: text) {
|
||||||
|
if isEditable {
|
||||||
|
scheduleValidation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleValidation() {
|
||||||
|
configurationError = nil
|
||||||
|
validationTask?.cancel()
|
||||||
|
validationTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await checkConfiguration()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkConfiguration() async {
|
||||||
|
let content = text
|
||||||
|
if content.isEmpty { return }
|
||||||
|
var error: NSError?
|
||||||
|
LibboxCheckConfig(content, &error)
|
||||||
|
if let error {
|
||||||
|
configurationError = error.localizedDescription
|
||||||
|
} else {
|
||||||
|
configurationError = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatConfiguration() {
|
||||||
|
let content = text
|
||||||
|
if content.isEmpty { return }
|
||||||
|
var error: NSError?
|
||||||
|
let result = LibboxFormatConfig(content, &error)
|
||||||
|
if let error {
|
||||||
|
configurationError = error.localizedDescription
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let formatted = result?.value, formatted != content {
|
||||||
|
controller.setText(formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,54 @@ import Runestone
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import TreeSitterJSON5Runestone
|
import TreeSitterJSON5Runestone
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class RunestoneEditorController: ObservableObject {
|
||||||
|
weak var textView: TextView?
|
||||||
|
|
||||||
|
@Published public var canUndo = false
|
||||||
|
@Published public var canRedo = false
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func undo() {
|
||||||
|
textView?.undoManager?.undo()
|
||||||
|
updateUndoState()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func redo() {
|
||||||
|
textView?.undoManager?.redo()
|
||||||
|
updateUndoState()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func insertSymbol(_ symbol: String) {
|
||||||
|
guard let textView else { return }
|
||||||
|
textView.insertText(symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setText(_ newText: String) {
|
||||||
|
guard let textView else { return }
|
||||||
|
if let textRange = textView.textRange(from: textView.beginningOfDocument, to: textView.endOfDocument) {
|
||||||
|
textView.selectedTextRange = textRange
|
||||||
|
textView.insertText(newText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUndoState() {
|
||||||
|
canUndo = textView?.undoManager?.canUndo ?? false
|
||||||
|
canRedo = textView?.undoManager?.canRedo ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct RunestoneTextView: UIViewRepresentable {
|
struct RunestoneTextView: UIViewRepresentable {
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
let isEditable: Bool
|
let isEditable: Bool
|
||||||
|
let controller: RunestoneEditorController?
|
||||||
|
|
||||||
|
init(text: Binding<String>, isEditable: Bool, controller: RunestoneEditorController? = nil) {
|
||||||
|
_text = text
|
||||||
|
self.isEditable = isEditable
|
||||||
|
self.controller = controller
|
||||||
|
}
|
||||||
|
|
||||||
func makeUIView(context: Context) -> TextView {
|
func makeUIView(context: Context) -> TextView {
|
||||||
let textView = TextView()
|
let textView = TextView()
|
||||||
@@ -43,6 +88,12 @@ struct RunestoneTextView: UIViewRepresentable {
|
|||||||
textView.isEditable = isEditable
|
textView.isEditable = isEditable
|
||||||
textView.editorDelegate = context.coordinator
|
textView.editorDelegate = context.coordinator
|
||||||
|
|
||||||
|
context.coordinator.textView = textView
|
||||||
|
controller?.textView = textView
|
||||||
|
Task { @MainActor in
|
||||||
|
controller?.updateUndoState()
|
||||||
|
}
|
||||||
|
|
||||||
return textView
|
return textView
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +104,7 @@ struct RunestoneTextView: UIViewRepresentable {
|
|||||||
if textView.isEditable != isEditable {
|
if textView.isEditable != isEditable {
|
||||||
textView.isEditable = isEditable
|
textView.isEditable = isEditable
|
||||||
}
|
}
|
||||||
|
controller?.textView = textView
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
@@ -61,6 +113,7 @@ struct RunestoneTextView: UIViewRepresentable {
|
|||||||
|
|
||||||
final class Coordinator: TextViewDelegate {
|
final class Coordinator: TextViewDelegate {
|
||||||
var parent: RunestoneTextView
|
var parent: RunestoneTextView
|
||||||
|
weak var textView: TextView?
|
||||||
|
|
||||||
init(_ parent: RunestoneTextView) {
|
init(_ parent: RunestoneTextView) {
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
@@ -68,6 +121,9 @@ struct RunestoneTextView: UIViewRepresentable {
|
|||||||
|
|
||||||
func textViewDidChange(_ textView: TextView) {
|
func textViewDidChange(_ textView: TextView) {
|
||||||
parent.text = textView.text
|
parent.text = textView.text
|
||||||
|
Task { @MainActor in
|
||||||
|
parent.controller?.updateUndoState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user