Explorar el Código

Align text editor placeholders and caret with Grammora-style ThinCaretTextEditor.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 hace 1 mes
padre
commit
2738789046

+ 3 - 0
Reddit App/Utilities/AppTheme.swift

@@ -31,6 +31,9 @@ enum AppTheme {
     static let cornerRadius: CGFloat = 12
     static let cornerRadiusSmall: CGFloat = 10
     static let iconContainerSize: CGFloat = 32
+    static let textEditorHorizontalInset: CGFloat = 10
+    static let textEditorVerticalInset: CGFloat = 8
+    static let formFieldFontSize: CGFloat = 12
 }
 
 extension Color {

+ 4 - 16
Reddit App/Views/Components/PostGeneratorComponents.swift

@@ -105,22 +105,10 @@ struct PostFormTextEditor: View {
                 .font(.system(size: 10, weight: .medium))
                 .foregroundStyle(AppTheme.textTertiary)
 
-            ZStack(alignment: .topLeading) {
-                if text.isEmpty {
-                    Text(placeholder)
-                        .font(.system(size: 12))
-                        .foregroundStyle(AppTheme.textTertiary.opacity(0.7))
-                        .padding(.horizontal, 8)
-                        .padding(.vertical, 10)
-                }
-
-                TextEditor(text: $text)
-                    .font(.system(size: 12))
-                    .foregroundStyle(AppTheme.textPrimary)
-                    .scrollContentBackground(.hidden)
-                    .padding(.horizontal, 4)
-                    .padding(.vertical, 4)
-            }
+            ThinCaretTextEditor(
+                text: $text,
+                placeholder: placeholder
+            )
             .frame(minHeight: minHeight)
             .background(AppTheme.panelBackground)
             .clipShape(RoundedRectangle(cornerRadius: 8))

+ 242 - 0
Reddit App/Views/Components/ThinCaretTextEditor.swift

@@ -0,0 +1,242 @@
+import AppKit
+import SwiftUI
+
+private final class TextEditorScrollView: NSScrollView {
+    override func layout() {
+        super.layout()
+        syncTextContainerWidth()
+    }
+
+    override func resizeSubviews(withOldSize oldSize: NSSize) {
+        super.resizeSubviews(withOldSize: oldSize)
+        syncTextContainerWidth()
+    }
+
+    fileprivate func syncTextContainerWidthIfNeeded() {
+        syncTextContainerWidth()
+    }
+
+    private func syncTextContainerWidth() {
+        guard let textView = documentView as? NSTextView,
+              let container = textView.textContainer,
+              let layoutManager = textView.layoutManager else { return }
+
+        let width = contentView.bounds.width
+        guard width > 1 else { return }
+
+        textView.minSize = NSSize(width: width, height: 0)
+        textView.maxSize = NSSize(width: width, height: .greatestFiniteMagnitude)
+        container.lineBreakMode = .byWordWrapping
+
+        if abs(textView.frame.width - width) > 0.5 {
+            textView.setFrameSize(NSSize(width: width, height: textView.frame.height))
+        }
+
+        layoutManager.ensureLayout(for: container)
+
+        let usedHeight = layoutManager.usedRect(for: container).height
+        let insetHeight = textView.textContainerInset.height * 2
+        let newHeight = max(usedHeight + insetHeight, contentView.bounds.height)
+
+        if abs(textView.frame.height - newHeight) > 0.5 {
+            textView.setFrameSize(NSSize(width: width, height: newHeight))
+        }
+    }
+}
+
+private final class ThinCaretTextView: NSTextView {
+    private let caretWidth: CGFloat = 1
+
+    var placeholder: String = "" {
+        didSet {
+            guard placeholder != oldValue else { return }
+            needsDisplay = true
+        }
+    }
+
+    var placeholderColor: NSColor = .placeholderTextColor
+
+    override func viewDidMoveToWindow() {
+        super.viewDidMoveToWindow()
+        _ = layoutManager
+        hideSystemInsertionIndicator()
+    }
+
+    override func becomeFirstResponder() -> Bool {
+        let becameFirstResponder = super.becomeFirstResponder()
+        hideSystemInsertionIndicator()
+        return becameFirstResponder
+    }
+
+    override func layout() {
+        super.layout()
+        hideSystemInsertionIndicator()
+    }
+
+    override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) {
+        var caretRect = rect
+        caretRect.size.width = caretWidth
+        super.drawInsertionPoint(in: caretRect, color: color, turnedOn: flag)
+    }
+
+    override func setNeedsDisplay(_ rect: NSRect, avoidAdditionalLayout flag: Bool) {
+        var invalidRect = rect
+        invalidRect.size.width += caretWidth - 1
+        super.setNeedsDisplay(invalidRect, avoidAdditionalLayout: flag)
+    }
+
+    override func draw(_ dirtyRect: NSRect) {
+        drawPlaceholderIfNeeded()
+        super.draw(dirtyRect)
+    }
+
+    override func didChangeText() {
+        super.didChangeText()
+        needsDisplay = true
+    }
+
+    private func hideSystemInsertionIndicator() {
+        hideInsertionIndicators(in: self)
+    }
+
+    private func hideInsertionIndicators(in view: NSView) {
+        for subview in view.subviews {
+            if #available(macOS 14.0, *),
+               let indicator = subview as? NSTextInsertionIndicator {
+                indicator.displayMode = .hidden
+            }
+            hideInsertionIndicators(in: subview)
+        }
+    }
+
+    private func drawPlaceholderIfNeeded() {
+        guard string.isEmpty, !placeholder.isEmpty else { return }
+
+        let font = font ?? NSFont.systemFont(ofSize: AppTheme.formFieldFontSize)
+        let paragraphStyle = NSMutableParagraphStyle()
+        paragraphStyle.lineBreakMode = .byWordWrapping
+
+        let attributes: [NSAttributedString.Key: Any] = [
+            .font: font,
+            .foregroundColor: placeholderColor,
+            .paragraphStyle: paragraphStyle
+        ]
+
+        let horizontalInset = textContainerInset.width + (textContainer?.lineFragmentPadding ?? 0)
+        let verticalInset = textContainerInset.height
+        let drawRect = NSRect(
+            x: horizontalInset,
+            y: verticalInset,
+            width: bounds.width - horizontalInset - textContainerInset.width,
+            height: bounds.height - verticalInset - textContainerInset.height
+        )
+
+        placeholder.draw(
+            with: drawRect,
+            options: [.usesLineFragmentOrigin, .usesFontLeading],
+            attributes: attributes
+        )
+    }
+}
+
+struct ThinCaretTextEditor: NSViewRepresentable {
+    @Binding var text: String
+    var placeholder: String = ""
+    var fontSize: CGFloat = AppTheme.formFieldFontSize
+    var isEditable: Bool = true
+
+    func makeCoordinator() -> Coordinator {
+        Coordinator(parent: self)
+    }
+
+    func makeNSView(context: Context) -> NSScrollView {
+        let scrollView = TextEditorScrollView()
+        scrollView.hasVerticalScroller = true
+        scrollView.hasHorizontalScroller = false
+        scrollView.autohidesScrollers = true
+        scrollView.borderType = .noBorder
+        scrollView.drawsBackground = false
+        scrollView.focusRingType = .none
+
+        let textView = ThinCaretTextView()
+        textView.isEditable = isEditable
+        textView.isSelectable = true
+        textView.isRichText = false
+        textView.allowsUndo = true
+        textView.drawsBackground = false
+        textView.backgroundColor = .clear
+        textView.focusRingType = .none
+        textView.isVerticallyResizable = true
+        textView.isHorizontallyResizable = false
+        textView.autoresizingMask = [.width]
+        textView.textContainer?.lineFragmentPadding = 0
+        textView.textContainer?.lineBreakMode = .byWordWrapping
+        textView.textContainer?.widthTracksTextView = true
+        textView.textContainer?.containerSize = NSSize(
+            width: 0,
+            height: CGFloat.greatestFiniteMagnitude
+        )
+        textView.textContainerInset = NSSize(
+            width: AppTheme.textEditorHorizontalInset,
+            height: AppTheme.textEditorVerticalInset
+        )
+        textView.isAutomaticQuoteSubstitutionEnabled = false
+        textView.isAutomaticDashSubstitutionEnabled = false
+        textView.isAutomaticTextReplacementEnabled = false
+        textView.font = NSFont.systemFont(ofSize: fontSize)
+        textView.textColor = NSColor(AppTheme.textPrimary)
+        textView.insertionPointColor = NSColor(AppTheme.textPrimary)
+        textView.placeholder = placeholder
+        textView.placeholderColor = NSColor(AppTheme.textTertiary.opacity(0.7))
+        textView.delegate = context.coordinator
+        textView.string = text
+
+        scrollView.documentView = textView
+        context.coordinator.textView = textView
+        return scrollView
+    }
+
+    func updateNSView(_ scrollView: NSScrollView, context: Context) {
+        guard let textView = scrollView.documentView as? ThinCaretTextView else { return }
+
+        textView.isEditable = isEditable
+        textView.placeholder = placeholder
+        textView.placeholderColor = NSColor(AppTheme.textTertiary.opacity(0.7))
+        textView.textContainerInset = NSSize(
+            width: AppTheme.textEditorHorizontalInset,
+            height: AppTheme.textEditorVerticalInset
+        )
+
+        if textView.string != text {
+            textView.string = text
+            textView.needsDisplay = true
+        }
+
+        textView.textColor = NSColor(AppTheme.textPrimary)
+        textView.insertionPointColor = NSColor(AppTheme.textPrimary)
+        textView.font = NSFont.systemFont(ofSize: fontSize)
+
+        if let scrollView = scrollView as? TextEditorScrollView {
+            scrollView.syncTextContainerWidthIfNeeded()
+        }
+    }
+
+    final class Coordinator: NSObject, NSTextViewDelegate {
+        var parent: ThinCaretTextEditor
+        weak var textView: NSTextView?
+
+        init(parent: ThinCaretTextEditor) {
+            self.parent = parent
+        }
+
+        func textDidChange(_ notification: Notification) {
+            guard let textView = notification.object as? NSTextView else { return }
+            parent.text = textView.string
+            textView.needsDisplay = true
+
+            if let scrollView = textView.enclosingScrollView as? TextEditorScrollView {
+                scrollView.syncTextContainerWidthIfNeeded()
+            }
+        }
+    }
+}