fix(ui): verify live editor presentation path
This commit is contained in:
parent
f9530a9164
commit
75da8d10e1
2 changed files with 314 additions and 3 deletions
|
|
@ -203,11 +203,14 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
textView.isVerticallyResizable = true
|
textView.isVerticallyResizable = true
|
||||||
textView.isHorizontallyResizable = false
|
textView.isHorizontallyResizable = false
|
||||||
|
|
||||||
|
textView.string = text
|
||||||
textView.delegate = context.coordinator
|
textView.delegate = context.coordinator
|
||||||
textView.onFocusStateChange = { [weak coordinator = context.coordinator] textView in
|
textView.onFocusStateChange = { [weak coordinator = context.coordinator] textView in
|
||||||
coordinator?.applyHybridAttributes(to: textView)
|
coordinator?.applyHybridAttributes(to: textView)
|
||||||
}
|
}
|
||||||
textView.string = text
|
textView.onUserEditingInteraction = { [weak coordinator = context.coordinator] textView in
|
||||||
|
coordinator?.activateEditingPresentation(in: textView)
|
||||||
|
}
|
||||||
textView.isRichText = false
|
textView.isRichText = false
|
||||||
textView.isEditable = true
|
textView.isEditable = true
|
||||||
textView.isSelectable = true
|
textView.isSelectable = true
|
||||||
|
|
@ -261,6 +264,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
private var lastStyledText: String?
|
private var lastStyledText: String?
|
||||||
private var lastStyledActiveLineIndex: Int?
|
private var lastStyledActiveLineIndex: Int?
|
||||||
private var pendingEdit: DocumentLineIndexEdit?
|
private var pendingEdit: DocumentLineIndexEdit?
|
||||||
|
private var hasUserActivatedEditing = false
|
||||||
private var checklistButtons: [Int: ChecklistOverlayButton] = [:]
|
private var checklistButtons: [Int: ChecklistOverlayButton] = [:]
|
||||||
|
|
||||||
init(_ parent: NativeMarkdownTextView) {
|
init(_ parent: NativeMarkdownTextView) {
|
||||||
|
|
@ -284,6 +288,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
guard !isPerformingProgrammaticUpdate else { return }
|
guard !isPerformingProgrammaticUpdate else { return }
|
||||||
guard let textView = notification.object as? NSTextView else { return }
|
guard let textView = notification.object as? NSTextView else { return }
|
||||||
|
|
||||||
|
hasUserActivatedEditing = true
|
||||||
let selection = EditorSelection(range: textView.selectedRange())
|
let selection = EditorSelection(range: textView.selectedRange())
|
||||||
let edit = pendingEdit
|
let edit = pendingEdit
|
||||||
if let edit {
|
if let edit {
|
||||||
|
|
@ -358,10 +363,18 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentationActiveLineIndex(in textView: NSTextView) -> Int {
|
private func presentationActiveLineIndex(in textView: NSTextView) -> Int {
|
||||||
guard textView.window?.firstResponder === textView else { return -1 }
|
guard hasUserActivatedEditing,
|
||||||
|
textView.window?.firstResponder === textView
|
||||||
|
else { return -1 }
|
||||||
return currentLineIndex.lineIndex(containing: textView.selectedRange().location)
|
return currentLineIndex.lineIndex(containing: textView.selectedRange().location)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func activateEditingPresentation(in textView: NSTextView) {
|
||||||
|
guard !hasUserActivatedEditing else { return }
|
||||||
|
hasUserActivatedEditing = true
|
||||||
|
applyHybridAttributes(to: textView)
|
||||||
|
}
|
||||||
|
|
||||||
func setSelection(_ range: NSRange, in textView: NSTextView) {
|
func setSelection(_ range: NSRange, in textView: NSTextView) {
|
||||||
guard textView.selectedRange() != range else { return }
|
guard textView.selectedRange() != range else { return }
|
||||||
performProgrammaticUpdate {
|
performProgrammaticUpdate {
|
||||||
|
|
@ -381,6 +394,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
func invalidateStylingCache() {
|
func invalidateStylingCache() {
|
||||||
lastStyledText = nil
|
lastStyledText = nil
|
||||||
lastStyledActiveLineIndex = nil
|
lastStyledActiveLineIndex = nil
|
||||||
|
hasUserActivatedEditing = false
|
||||||
removeChecklistControls()
|
removeChecklistControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -531,6 +545,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
|
|
||||||
private final class EditorTextView: NSTextView {
|
private final class EditorTextView: NSTextView {
|
||||||
var onFocusStateChange: ((NSTextView) -> Void)?
|
var onFocusStateChange: ((NSTextView) -> Void)?
|
||||||
|
var onUserEditingInteraction: ((NSTextView) -> Void)?
|
||||||
|
|
||||||
override var acceptsFirstResponder: Bool {
|
override var acceptsFirstResponder: Bool {
|
||||||
true
|
true
|
||||||
|
|
@ -551,6 +566,21 @@ private final class EditorTextView: NSTextView {
|
||||||
}
|
}
|
||||||
return resignedFirstResponder
|
return resignedFirstResponder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func mouseDown(with event: NSEvent) {
|
||||||
|
onUserEditingInteraction?(self)
|
||||||
|
super.mouseDown(with: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func keyDown(with event: NSEvent) {
|
||||||
|
onUserEditingInteraction?(self)
|
||||||
|
super.keyDown(with: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func paste(_ sender: Any?) {
|
||||||
|
onUserEditingInteraction?(self)
|
||||||
|
super.paste(sender)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class ChecklistOverlayButton: NSButton {
|
private final class ChecklistOverlayButton: NSButton {
|
||||||
|
|
@ -602,6 +632,221 @@ private final class ComfortableEditorScrollView: NSScrollView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
@MainActor
|
||||||
|
public final class HybridMarkdownLiveEditorHarness {
|
||||||
|
public private(set) var text: String
|
||||||
|
public private(set) var selection: EditorSelection
|
||||||
|
public private(set) var renderPasses: [EditorRenderPassMetric] = []
|
||||||
|
|
||||||
|
private let box: StateBox
|
||||||
|
private let coordinator: NativeMarkdownTextView.Coordinator
|
||||||
|
private let window: NSWindow
|
||||||
|
private let scrollView: ComfortableEditorScrollView
|
||||||
|
private let textView: EditorTextView
|
||||||
|
|
||||||
|
public init(source: String, selectedRange: NSRange = NSRange(location: 0, length: 0)) {
|
||||||
|
let stateBox = StateBox(text: source, selection: EditorSelection(range: selectedRange))
|
||||||
|
self.text = source
|
||||||
|
self.selection = EditorSelection(range: selectedRange)
|
||||||
|
self.box = stateBox
|
||||||
|
|
||||||
|
let lineIndex = DocumentLineIndex(source: source)
|
||||||
|
let parent = NativeMarkdownTextView(
|
||||||
|
text: Binding(
|
||||||
|
get: { stateBox.text },
|
||||||
|
set: { stateBox.text = $0 }
|
||||||
|
),
|
||||||
|
selection: Binding(
|
||||||
|
get: { stateBox.selection },
|
||||||
|
set: { stateBox.selection = $0 }
|
||||||
|
),
|
||||||
|
activeLineIndex: lineIndex.lineIndex(containing: selectedRange.location),
|
||||||
|
lineIndex: lineIndex,
|
||||||
|
onTextEdit: { updatedText, edit, updatedSelection in
|
||||||
|
stateBox.text = updatedText
|
||||||
|
if let edit {
|
||||||
|
stateBox.lineIndex.replace(edit, updatedSource: updatedText)
|
||||||
|
} else {
|
||||||
|
stateBox.lineIndex = DocumentLineIndex(source: updatedText)
|
||||||
|
}
|
||||||
|
if let updatedSelection {
|
||||||
|
stateBox.selection = updatedSelection
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRenderPass: { metric in
|
||||||
|
stateBox.renderPasses.append(metric)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.coordinator = parent.makeCoordinator()
|
||||||
|
self.coordinator.currentLineIndex = lineIndex
|
||||||
|
self.scrollView = ComfortableEditorScrollView()
|
||||||
|
self.scrollView.hasVerticalScroller = true
|
||||||
|
self.scrollView.hasHorizontalScroller = false
|
||||||
|
self.scrollView.autohidesScrollers = true
|
||||||
|
self.scrollView.borderType = .noBorder
|
||||||
|
self.scrollView.drawsBackground = true
|
||||||
|
self.scrollView.backgroundColor = .textBackgroundColor
|
||||||
|
|
||||||
|
let textStorage = NSTextStorage()
|
||||||
|
let layoutManager = NSLayoutManager()
|
||||||
|
let textContainer = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
|
||||||
|
textContainer.widthTracksTextView = true
|
||||||
|
textContainer.heightTracksTextView = false
|
||||||
|
layoutManager.addTextContainer(textContainer)
|
||||||
|
textStorage.addLayoutManager(layoutManager)
|
||||||
|
|
||||||
|
self.textView = EditorTextView(frame: NSRect(x: 0, y: 0, width: 640, height: 480), textContainer: textContainer)
|
||||||
|
self.textView.autoresizingMask = [.width]
|
||||||
|
self.textView.minSize = NSSize(width: 0, height: 480)
|
||||||
|
self.textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
||||||
|
self.textView.isVerticallyResizable = true
|
||||||
|
self.textView.isHorizontallyResizable = false
|
||||||
|
self.textView.onFocusStateChange = { [weak coordinator] textView in
|
||||||
|
coordinator?.applyHybridAttributes(to: textView)
|
||||||
|
}
|
||||||
|
self.textView.onUserEditingInteraction = { [weak coordinator] textView in
|
||||||
|
coordinator?.activateEditingPresentation(in: textView)
|
||||||
|
}
|
||||||
|
self.textView.string = source
|
||||||
|
self.textView.delegate = coordinator
|
||||||
|
self.textView.setSelectedRange(selectedRange)
|
||||||
|
self.textView.isRichText = false
|
||||||
|
self.textView.isEditable = true
|
||||||
|
self.textView.isSelectable = true
|
||||||
|
self.textView.isAutomaticQuoteSubstitutionEnabled = false
|
||||||
|
self.textView.isAutomaticDashSubstitutionEnabled = false
|
||||||
|
self.textView.isAutomaticTextReplacementEnabled = false
|
||||||
|
self.textView.allowsUndo = true
|
||||||
|
self.textView.usesFindPanel = true
|
||||||
|
self.textView.isContinuousSpellCheckingEnabled = true
|
||||||
|
self.textView.backgroundColor = .textBackgroundColor
|
||||||
|
self.textView.insertionPointColor = .controlAccentColor
|
||||||
|
self.textView.font = .systemFont(ofSize: 16, weight: .regular)
|
||||||
|
self.textView.textContainer?.widthTracksTextView = true
|
||||||
|
self.textView.textContainer?.containerSize = NSSize(width: 640, height: CGFloat.greatestFiniteMagnitude)
|
||||||
|
|
||||||
|
self.scrollView.frame = NSRect(x: 0, y: 0, width: 640, height: 480)
|
||||||
|
self.scrollView.documentView = textView
|
||||||
|
self.scrollView.editorTextView = textView
|
||||||
|
self.scrollView.updateEditorInsets()
|
||||||
|
|
||||||
|
self.window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 640, height: 480),
|
||||||
|
styleMask: [.titled],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
self.window.contentView = scrollView
|
||||||
|
self.coordinator.applyHybridAttributes(to: textView)
|
||||||
|
syncState()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func simulateLaunchFirstResponder() {
|
||||||
|
window.makeFirstResponder(textView)
|
||||||
|
syncState()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func simulateFocusAway() {
|
||||||
|
window.makeFirstResponder(nil)
|
||||||
|
syncState()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setSelection(_ range: NSRange) {
|
||||||
|
coordinator.activateEditingPresentation(in: textView)
|
||||||
|
coordinator.setSelection(range, in: textView)
|
||||||
|
coordinator.textViewDidChangeSelection(Notification(name: NSTextView.didChangeSelectionNotification, object: textView))
|
||||||
|
syncState()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clickRenderedCheckbox(lineIndex: Int) {
|
||||||
|
guard let button = checklistButton(lineIndex: lineIndex) else { return }
|
||||||
|
button.performClick(nil)
|
||||||
|
syncState()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func headingMarkerIsHidden() -> Bool {
|
||||||
|
isHidden(at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func point(for text: String) -> CGPoint? {
|
||||||
|
let textRange = (textView.string as NSString).range(of: text)
|
||||||
|
guard textRange.location != NSNotFound,
|
||||||
|
let layoutManager = textView.layoutManager,
|
||||||
|
let textContainer = textView.textContainer
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
layoutManager.ensureLayout(for: textContainer)
|
||||||
|
let glyphRange = layoutManager.glyphRange(
|
||||||
|
forCharacterRange: NSRange(location: textRange.location, length: 1),
|
||||||
|
actualCharacterRange: nil
|
||||||
|
)
|
||||||
|
guard glyphRange.length > 0 else { return nil }
|
||||||
|
|
||||||
|
let fragment = layoutManager.lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil)
|
||||||
|
let glyphLocation = layoutManager.location(forGlyphAt: glyphRange.location)
|
||||||
|
let origin = textView.textContainerOrigin
|
||||||
|
return CGPoint(x: origin.x + fragment.minX + glyphLocation.x, y: origin.y + fragment.minY + glyphLocation.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func presentationSignature() -> String {
|
||||||
|
guard let storage = textView.textStorage else { return "" }
|
||||||
|
return MarkdownPresentationSnapshot.make(
|
||||||
|
from: storage,
|
||||||
|
lineIndex: coordinator.currentLineIndex,
|
||||||
|
containerWidth: textView.textContainer?.containerSize.width ?? 640
|
||||||
|
).signature
|
||||||
|
}
|
||||||
|
|
||||||
|
public func checklistButtonFrame(lineIndex: Int) -> CGRect? {
|
||||||
|
checklistButton(lineIndex: lineIndex)?.frame
|
||||||
|
}
|
||||||
|
|
||||||
|
public func selectedRange() -> NSRange {
|
||||||
|
textView.selectedRange()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func source() -> String {
|
||||||
|
textView.string
|
||||||
|
}
|
||||||
|
|
||||||
|
public func effectiveActiveLineIndex() -> Int {
|
||||||
|
coordinator.currentLineIndex.lineIndex(containing: textView.selectedRange().location)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checklistButton(lineIndex: Int) -> ChecklistOverlayButton? {
|
||||||
|
textView.subviews.compactMap { $0 as? ChecklistOverlayButton }.first { $0.task?.lineIndex == lineIndex }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isHidden(at location: Int) -> Bool {
|
||||||
|
guard let textStorage = textView.textStorage else { return false }
|
||||||
|
let color = textStorage.attribute(.foregroundColor, at: location, effectiveRange: nil) as? NSColor
|
||||||
|
let font = textStorage.attribute(.font, at: location, effectiveRange: nil) as? NSFont
|
||||||
|
return color?.alphaComponent == 0 && (font?.pointSize ?? 0) < 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncState() {
|
||||||
|
text = box.text
|
||||||
|
selection = box.selection
|
||||||
|
renderPasses = box.renderPasses
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class StateBox {
|
||||||
|
var text: String
|
||||||
|
var selection: EditorSelection
|
||||||
|
var lineIndex: DocumentLineIndex
|
||||||
|
var renderPasses: [EditorRenderPassMetric] = []
|
||||||
|
|
||||||
|
init(text: String, selection: EditorSelection) {
|
||||||
|
self.text = text
|
||||||
|
self.selection = selection
|
||||||
|
self.lineIndex = DocumentLineIndex(source: text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
private struct NativeMarkdownTextView: UIViewRepresentable {
|
private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
|
|
@ -617,8 +862,8 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UITextView {
|
func makeUIView(context: Context) -> UITextView {
|
||||||
let textView = UITextView()
|
let textView = UITextView()
|
||||||
textView.delegate = context.coordinator
|
|
||||||
textView.text = text
|
textView.text = text
|
||||||
|
textView.delegate = context.coordinator
|
||||||
textView.allowsEditingTextAttributes = false
|
textView.allowsEditingTextAttributes = false
|
||||||
textView.autocorrectionType = .yes
|
textView.autocorrectionType = .yes
|
||||||
textView.smartDashesType = .no
|
textView.smartDashesType = .no
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import SaplingEditor
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class HybridMarkdownLiveEditorHarnessTests: XCTestCase {
|
||||||
|
func testLiveInitialFirstResponderDoesNotShowHeadingSourceBeforeUserInteraction() {
|
||||||
|
let harness = HybridMarkdownLiveEditorHarness(source: "# Heading\nParagraph")
|
||||||
|
|
||||||
|
harness.simulateLaunchFirstResponder()
|
||||||
|
|
||||||
|
XCTAssertTrue(harness.headingMarkerIsHidden())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLiveParagraphGeometryReturnsAfterClickAndFocusAway() throws {
|
||||||
|
let source = """
|
||||||
|
# Heading
|
||||||
|
Paragraph with **bold**, *italic*, `code`, and [Link](https://example.com) markers.
|
||||||
|
Outro
|
||||||
|
"""
|
||||||
|
let harness = HybridMarkdownLiveEditorHarness(source: source)
|
||||||
|
harness.simulateLaunchFirstResponder()
|
||||||
|
let initialPoint = try XCTUnwrap(harness.point(for: "Paragraph"))
|
||||||
|
|
||||||
|
let paragraphLocation = (source as NSString).range(of: "bold").location
|
||||||
|
harness.setSelection(NSRange(location: paragraphLocation, length: 0))
|
||||||
|
harness.simulateFocusAway()
|
||||||
|
let finalPoint = try XCTUnwrap(harness.point(for: "Paragraph"))
|
||||||
|
|
||||||
|
XCTAssertEqual(initialPoint.x, finalPoint.x, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(initialPoint.y, finalPoint.y, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLiveCheckboxClickPreservesSelectionActiveLineAndGeometry() throws {
|
||||||
|
let source = "Editing here\n* [ ] Move with arrow keys."
|
||||||
|
let editingRange = (source as NSString).range(of: "Editing")
|
||||||
|
let harness = HybridMarkdownLiveEditorHarness(
|
||||||
|
source: source,
|
||||||
|
selectedRange: NSRange(location: editingRange.location, length: 0)
|
||||||
|
)
|
||||||
|
harness.simulateLaunchFirstResponder()
|
||||||
|
harness.setSelection(NSRange(location: editingRange.location, length: 0))
|
||||||
|
|
||||||
|
let selectionBefore = harness.selectedRange()
|
||||||
|
let activeLineBefore = harness.effectiveActiveLineIndex()
|
||||||
|
let labelPointBefore = try XCTUnwrap(harness.point(for: "Move"))
|
||||||
|
let checkboxFrameBefore = try XCTUnwrap(harness.checklistButtonFrame(lineIndex: 1))
|
||||||
|
|
||||||
|
harness.clickRenderedCheckbox(lineIndex: 1)
|
||||||
|
|
||||||
|
let labelPointAfter = try XCTUnwrap(harness.point(for: "Move"))
|
||||||
|
let checkboxFrameAfter = try XCTUnwrap(harness.checklistButtonFrame(lineIndex: 1))
|
||||||
|
XCTAssertEqual(harness.selectedRange(), selectionBefore)
|
||||||
|
XCTAssertEqual(harness.effectiveActiveLineIndex(), activeLineBefore)
|
||||||
|
XCTAssertTrue(harness.source().contains("* [x] Move with arrow keys."))
|
||||||
|
XCTAssertEqual(labelPointBefore.x, labelPointAfter.x, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(labelPointBefore.y, labelPointAfter.y, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(checkboxFrameBefore.origin.x, checkboxFrameAfter.origin.x, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(checkboxFrameBefore.origin.y, checkboxFrameAfter.origin.y, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(checkboxFrameBefore.size.width, checkboxFrameAfter.size.width, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(checkboxFrameBefore.size.height, checkboxFrameAfter.size.height, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
Loading…
Add table
Reference in a new issue