mirror of
https://github.com/writeas/writefreely-swiftui-multiplatform.git
synced 2024-11-15 01:11:02 +00:00
Merge pull request #143 from writeas/integrate-maceditortextview
Make the post editor first responder on blank new posts
This commit is contained in:
commit
3c501cf67d
@ -22,6 +22,7 @@
|
||||
171BFDFB24D4AF8300888236 /* CollectionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171BFDF924D4AF8300888236 /* CollectionListView.swift */; };
|
||||
173E19D1254318F600440F0F /* RemoteChangePromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 173E19D0254318F600440F0F /* RemoteChangePromptView.swift */; };
|
||||
173E19E3254329CC00440F0F /* PostTextEditingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 173E19E2254329CC00440F0F /* PostTextEditingView.swift */; };
|
||||
17466626256C0D0600629997 /* MacEditorTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17466625256C0D0600629997 /* MacEditorTextView.swift */; };
|
||||
17480CA5251272EE00EB7765 /* Bundle+AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17480CA4251272EE00EB7765 /* Bundle+AppVersion.swift */; };
|
||||
17480CA6251272EE00EB7765 /* Bundle+AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17480CA4251272EE00EB7765 /* Bundle+AppVersion.swift */; };
|
||||
174D313224EC2831006CA9EE /* WriteFreelyModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174D313124EC2831006CA9EE /* WriteFreelyModel.swift */; };
|
||||
@ -123,6 +124,7 @@
|
||||
171BFDF924D4AF8300888236 /* CollectionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionListView.swift; sourceTree = "<group>"; };
|
||||
173E19D0254318F600440F0F /* RemoteChangePromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteChangePromptView.swift; sourceTree = "<group>"; };
|
||||
173E19E2254329CC00440F0F /* PostTextEditingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostTextEditingView.swift; sourceTree = "<group>"; };
|
||||
17466625256C0D0600629997 /* MacEditorTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacEditorTextView.swift; sourceTree = "<group>"; };
|
||||
17480CA4251272EE00EB7765 /* Bundle+AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+AppVersion.swift"; sourceTree = "<group>"; };
|
||||
174D313124EC2831006CA9EE /* WriteFreelyModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteFreelyModel.swift; sourceTree = "<group>"; };
|
||||
1753F6AB24E431CC00309365 /* MacPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacPreferencesView.swift; sourceTree = "<group>"; };
|
||||
@ -309,6 +311,7 @@
|
||||
children = (
|
||||
17A67CAE251A5DD7002F163D /* PostEditorView.swift */,
|
||||
17E5DF892543610700DCDC9B /* PostTextEditingView.swift */,
|
||||
17466625256C0D0600629997 /* MacEditorTextView.swift */,
|
||||
);
|
||||
path = PostEditor;
|
||||
sourceTree = "<group>";
|
||||
@ -744,6 +747,7 @@
|
||||
17480CA6251272EE00EB7765 /* Bundle+AppVersion.swift in Sources */,
|
||||
17C42E662509237800072984 /* PostListFilteredView.swift in Sources */,
|
||||
17120DAD24E1B99F002B9F6C /* AccountLoginView.swift in Sources */,
|
||||
17466626256C0D0600629997 /* MacEditorTextView.swift in Sources */,
|
||||
17E5DF8A2543610700DCDC9B /* PostTextEditingView.swift in Sources */,
|
||||
17C42E71250AAFD500072984 /* NSManagedObjectContext+ExecuteAndMergeChanges.swift in Sources */,
|
||||
1756AE7B24CB65DF00FD7257 /* PostListView.swift in Sources */,
|
||||
|
@ -7,12 +7,12 @@
|
||||
<key>WriteFreely-MultiPlatform (iOS).xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
<key>WriteFreely-MultiPlatform (macOS).xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
|
207
macOS/PostEditor/MacEditorTextView.swift
Normal file
207
macOS/PostEditor/MacEditorTextView.swift
Normal file
@ -0,0 +1,207 @@
|
||||
// Based on:
|
||||
//
|
||||
// MacEditorTextView
|
||||
// Copyright (c) Thiago Holanda 2020
|
||||
// https://twitter.com/tholanda
|
||||
//
|
||||
// MIT license
|
||||
//
|
||||
// See: https://gist.github.com/unnamedd/6e8c3fbc806b8deb60fa65d6b9affab0
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct MacEditorTextView: NSViewRepresentable {
|
||||
@Binding var text: String
|
||||
var isFirstResponder: Bool = false
|
||||
var isEditable: Bool = true
|
||||
var font: NSFont? = NSFont(name: PostAppearance.serif.rawValue, size: 17)
|
||||
|
||||
var onEditingChanged: () -> Void = {}
|
||||
var onCommit: () -> Void = {}
|
||||
var onTextChange: (String) -> Void = { _ in }
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> CustomTextView {
|
||||
let textView = CustomTextView(
|
||||
text: text,
|
||||
isEditable: isEditable,
|
||||
isFirstResponder: isFirstResponder,
|
||||
font: font
|
||||
)
|
||||
textView.delegate = context.coordinator
|
||||
|
||||
return textView
|
||||
}
|
||||
|
||||
func updateNSView(_ view: CustomTextView, context: Context) {
|
||||
view.text = text
|
||||
view.selectedRanges = context.coordinator.selectedRanges
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
extension MacEditorTextView {
|
||||
|
||||
class Coordinator: NSObject, NSTextViewDelegate {
|
||||
var parent: MacEditorTextView
|
||||
var selectedRanges: [NSValue] = []
|
||||
var didBecomeFirstResponder: Bool = false
|
||||
|
||||
init(_ parent: MacEditorTextView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func textDidBeginEditing(_ notification: Notification) {
|
||||
guard let textView = notification.object as? NSTextView else {
|
||||
return
|
||||
}
|
||||
|
||||
self.parent.text = textView.string
|
||||
self.parent.onEditingChanged()
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard let textView = notification.object as? NSTextView else {
|
||||
return
|
||||
}
|
||||
|
||||
self.parent.text = textView.string
|
||||
self.selectedRanges = textView.selectedRanges
|
||||
self.parent.onTextChange(textView.string)
|
||||
}
|
||||
|
||||
func textDidEndEditing(_ notification: Notification) {
|
||||
guard let textView = notification.object as? NSTextView else {
|
||||
return
|
||||
}
|
||||
|
||||
self.parent.text = textView.string
|
||||
self.parent.onCommit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CustomTextView
|
||||
|
||||
final class CustomTextView: NSView {
|
||||
private var isFirstResponder: Bool
|
||||
private var isEditable: Bool
|
||||
private var font: NSFont?
|
||||
|
||||
weak var delegate: NSTextViewDelegate?
|
||||
|
||||
var text: String {
|
||||
didSet {
|
||||
textView.string = text
|
||||
}
|
||||
}
|
||||
|
||||
var selectedRanges: [NSValue] = [] {
|
||||
didSet {
|
||||
guard selectedRanges.count > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
textView.selectedRanges = selectedRanges
|
||||
}
|
||||
}
|
||||
|
||||
private lazy var scrollView: NSScrollView = {
|
||||
let scrollView = NSScrollView()
|
||||
scrollView.drawsBackground = false
|
||||
scrollView.borderType = .noBorder
|
||||
scrollView.hasVerticalScroller = true
|
||||
scrollView.hasHorizontalRuler = false
|
||||
scrollView.autoresizingMask = [.width, .height]
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return scrollView
|
||||
}()
|
||||
|
||||
private lazy var textView: NSTextView = {
|
||||
let contentSize = scrollView.contentSize
|
||||
let textStorage = NSTextStorage()
|
||||
|
||||
let layoutManager = NSLayoutManager()
|
||||
textStorage.addLayoutManager(layoutManager)
|
||||
|
||||
let textContainer = NSTextContainer(containerSize: scrollView.frame.size)
|
||||
textContainer.widthTracksTextView = true
|
||||
textContainer.containerSize = NSSize(
|
||||
width: contentSize.width,
|
||||
height: CGFloat.greatestFiniteMagnitude
|
||||
)
|
||||
|
||||
layoutManager.addTextContainer(textContainer)
|
||||
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.lineSpacing = 8.5
|
||||
|
||||
let textView = NSTextView(frame: .zero, textContainer: textContainer)
|
||||
textView.autoresizingMask = .width
|
||||
textView.delegate = self.delegate
|
||||
textView.drawsBackground = false
|
||||
textView.font = self.font
|
||||
textView.defaultParagraphStyle = paragraphStyle
|
||||
textView.isEditable = self.isEditable
|
||||
textView.isHorizontallyResizable = false
|
||||
textView.isVerticallyResizable = true
|
||||
textView.maxSize = NSSize(
|
||||
width: CGFloat.greatestFiniteMagnitude,
|
||||
height: CGFloat.greatestFiniteMagnitude
|
||||
)
|
||||
textView.minSize = NSSize(width: 0, height: contentSize.height)
|
||||
textView.textColor = NSColor.labelColor
|
||||
|
||||
return textView
|
||||
}()
|
||||
|
||||
// MARK: - Init
|
||||
init(text: String, isEditable: Bool, isFirstResponder: Bool, font: NSFont?) {
|
||||
self.font = font
|
||||
self.isFirstResponder = isFirstResponder
|
||||
self.isEditable = isEditable
|
||||
self.text = text
|
||||
|
||||
super.init(frame: .zero)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Life cycle
|
||||
|
||||
override func viewWillDraw() {
|
||||
super.viewWillDraw()
|
||||
|
||||
setupScrollViewConstraints()
|
||||
setupTextView()
|
||||
|
||||
if isFirstResponder {
|
||||
self.window?.makeFirstResponder(self.textView)
|
||||
}
|
||||
}
|
||||
|
||||
func setupScrollViewConstraints() {
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
addSubview(scrollView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
scrollView.topAnchor.constraint(equalTo: topAnchor),
|
||||
scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
scrollView.leadingAnchor.constraint(equalTo: leadingAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
func setupTextView() {
|
||||
scrollView.documentView = textView
|
||||
}
|
||||
}
|
@ -6,16 +6,15 @@ struct PostEditorView: View {
|
||||
|
||||
@ObservedObject var post: WFAPost
|
||||
@State private var isHovering: Bool = false
|
||||
@State private var updatingTitleFromServer: Bool = false
|
||||
@State private var updatingBodyFromServer: Bool = false
|
||||
@State private var updatingFromServer: Bool = false
|
||||
|
||||
var body: some View {
|
||||
PostTextEditingView(
|
||||
post: post,
|
||||
updatingTitleFromServer: $updatingTitleFromServer,
|
||||
updatingBodyFromServer: $updatingBodyFromServer
|
||||
updatingFromServer: $updatingFromServer
|
||||
)
|
||||
.padding()
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .status) {
|
||||
PostEditorStatusToolbarView(post: post)
|
||||
@ -37,8 +36,8 @@ struct PostEditorView: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: post.hasNewerRemoteCopy, perform: { _ in
|
||||
if post.status == PostStatus.edited.rawValue && !post.hasNewerRemoteCopy {
|
||||
post.status = PostStatus.published.rawValue
|
||||
if !post.hasNewerRemoteCopy {
|
||||
self.updatingFromServer = true
|
||||
}
|
||||
})
|
||||
.onDisappear(perform: {
|
||||
|
@ -2,65 +2,98 @@ import SwiftUI
|
||||
|
||||
struct PostTextEditingView: View {
|
||||
@ObservedObject var post: WFAPost
|
||||
@Binding var updatingTitleFromServer: Bool
|
||||
@Binding var updatingBodyFromServer: Bool
|
||||
@State private var isHovering: Bool = false
|
||||
@Binding var updatingFromServer: Bool
|
||||
@State private var appearance: PostAppearance = .serif
|
||||
private let bodyLineSpacingMultiplier: CGFloat = 0.5
|
||||
@State private var combinedText = ""
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
TextField("Title (optional)", text: $post.title)
|
||||
.textFieldStyle(PlainTextFieldStyle())
|
||||
.padding(.horizontal, 4)
|
||||
.font(.custom(appearance.rawValue, size: 26, relativeTo: .largeTitle))
|
||||
.onChange(of: post.title) { _ in
|
||||
if post.status == PostStatus.published.rawValue && !updatingTitleFromServer {
|
||||
post.status = PostStatus.edited.rawValue
|
||||
}
|
||||
if updatingTitleFromServer {
|
||||
updatingTitleFromServer = false
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.padding(.bottom)
|
||||
ZStack(alignment: .topLeading) {
|
||||
if post.body.count == 0 {
|
||||
Text("Write…")
|
||||
.foregroundColor(Color(NSColor.placeholderTextColor))
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.font(.custom(appearance.rawValue, size: 17, relativeTo: .body))
|
||||
}
|
||||
TextEditor(text: $post.body)
|
||||
ZStack(alignment: .topLeading) {
|
||||
if combinedText.count == 0 {
|
||||
Text("Write…")
|
||||
.foregroundColor(Color(NSColor.placeholderTextColor))
|
||||
.padding(.horizontal, 5)
|
||||
.font(.custom(appearance.rawValue, size: 17, relativeTo: .body))
|
||||
.lineSpacing(17 * bodyLineSpacingMultiplier)
|
||||
.opacity(post.body.count == 0 && !isHovering ? 0.0 : 1.0)
|
||||
.onChange(of: post.body) { _ in
|
||||
if post.status == PostStatus.published.rawValue && !updatingBodyFromServer {
|
||||
post.status = PostStatus.edited.rawValue
|
||||
}
|
||||
if updatingBodyFromServer {
|
||||
updatingBodyFromServer = false
|
||||
}
|
||||
}
|
||||
.onHover(perform: { hovering in
|
||||
self.isHovering = hovering
|
||||
})
|
||||
}
|
||||
.padding(4)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
if post.appearance == "sans" {
|
||||
MacEditorTextView(
|
||||
text: $combinedText,
|
||||
isFirstResponder: combinedText.isEmpty,
|
||||
isEditable: true,
|
||||
font: NSFont(name: PostAppearance.sans.rawValue, size: 17),
|
||||
onEditingChanged: onEditingChanged,
|
||||
onCommit: onCommit,
|
||||
onTextChange: onTextChange
|
||||
)
|
||||
} else if post.appearance == "wrap" || post.appearance == "mono" || post.appearance == "code" {
|
||||
MacEditorTextView(
|
||||
text: $combinedText,
|
||||
isFirstResponder: combinedText.isEmpty,
|
||||
isEditable: true,
|
||||
font: NSFont(name: PostAppearance.mono.rawValue, size: 17),
|
||||
onEditingChanged: onEditingChanged,
|
||||
onCommit: onCommit,
|
||||
onTextChange: onTextChange
|
||||
)
|
||||
} else {
|
||||
MacEditorTextView(
|
||||
text: $combinedText,
|
||||
isFirstResponder: combinedText.isEmpty,
|
||||
isEditable: true,
|
||||
font: NSFont(name: PostAppearance.serif.rawValue, size: 17),
|
||||
onEditingChanged: onEditingChanged,
|
||||
onCommit: onCommit,
|
||||
onTextChange: onTextChange
|
||||
)
|
||||
}
|
||||
}
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.onAppear(perform: {
|
||||
switch post.appearance {
|
||||
case "sans":
|
||||
self.appearance = .sans
|
||||
case "wrap", "mono", "code":
|
||||
self.appearance = .mono
|
||||
default:
|
||||
self.appearance = .serif
|
||||
if post.title.isEmpty {
|
||||
self.combinedText = post.body
|
||||
} else {
|
||||
self.combinedText = "# \(post.title)\n\n\(post.body)"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func onEditingChanged() {
|
||||
// Add code here to take action when the user first starts typing.
|
||||
}
|
||||
|
||||
private func onTextChange(_ text: String) {
|
||||
extractTitle(text)
|
||||
|
||||
if post.status == PostStatus.published.rawValue && !updatingFromServer {
|
||||
post.status = PostStatus.edited.rawValue
|
||||
}
|
||||
|
||||
if updatingFromServer {
|
||||
self.updatingFromServer = false
|
||||
}
|
||||
}
|
||||
|
||||
private func onCommit() {
|
||||
// Add code here to take action when the user navigates away from the post.
|
||||
}
|
||||
|
||||
private func extractTitle(_ text: String) {
|
||||
var detectedTitle: String
|
||||
|
||||
if text.hasPrefix("# ") {
|
||||
let endOfTitleIndex = text.firstIndex(of: "\n") ?? text.endIndex
|
||||
detectedTitle = String(text[..<endOfTitleIndex])
|
||||
|
||||
self.post.title = String(detectedTitle.dropFirst("# ".count))
|
||||
let remainingText = String(text.dropFirst(detectedTitle.count).dropFirst(1))
|
||||
if remainingText.hasPrefix("\n") {
|
||||
self.post.body = String(remainingText.dropFirst(1))
|
||||
} else {
|
||||
self.post.body = remainingText
|
||||
}
|
||||
} else {
|
||||
self.post.title = ""
|
||||
self.post.body = text
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user