eleven / messenger Public

Revert the rich-text composers (keep grammar, frost + polish) (#482)

* mac: back to the TextField composer — the NSTextView field wasn't worth it

Live use found the NSTextView-backed MacComposerField (PRs #471/#481) too
rough to keep: it scrolled instead of auto-growing when text wrapped, and a
long paste could wedge the input entirely. Restore the SwiftUI TextField
composer (which grows and pastes fine) and drop Mac ⌘B/⌘I with it — the
formatting shortcuts weren't worth the trade.

Kept from the same work: the FrostedBand footer/header glass, the footer
padding + palette plus icon, the botless-chat command typeahead, the
locked-pane sign-in, the ***bold italic*** grammar, and the iOS composer
(its UITextView predates all this and had no such trouble).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: revert the rich composer too — both inputs back to pre-#471

Paul's call before it reached a device: with the Mac field already pulled,
the iOS composer goes back to its plain-text UITextView as well (it shares
the serialize-on-change design whose failure shapes sank the Mac). The
now-orphaned RichTextEdit runs↔markers bridge and its tests go with it —
git history and project memory keep the lessons for attempt two.

The ***bold italic*** grammar STAYS on every renderer (web + native): it is
input-agnostic, already announced in the changelog, and hand-typed markers
render on all surfaces as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 04a7a846f04c12eef1f5788d412ae8d75bfad9cb parent e830df5
5 files changed, +98 −863
  • ios/LChat/Views/ComposerField.swift +9 −143
  • ios/LChatCore/Sources/LChatCore/RichTextEdit.swift +0 −106
  • ios/LChatCore/Tests/LChatCoreTests/RichTextEditTests.swift +0 −109
  • ios/LChatMac/MacComposerField.swift +0 −412
  • ios/LChatMac/NativeFeedView.swift +89 −93
diff --git a/ios/LChat/Views/ComposerField.swift b/ios/LChat/Views/ComposerField.swift
index 3027df1..400a079 100644
--- a/ios/LChat/Views/ComposerField.swift
+++ b/ios/LChat/Views/ComposerField.swift
@@ -1,6 +1,5 @@
import SwiftUI
import UIKit
-import LChatCore
// The composer's text input. We drop to a UITextView (via a representable)
// instead of SwiftUI's TextField because we need to intercept Return *before* it
@@ -12,21 +11,11 @@ import LChatCore
// `sendOnReturn` follows the CLAUDE.md platform split: on the Mac a bare Return
// sends and Shift+Return inserts a newline (matching the web composer on
// desktop); on touch, Return always inserts a newline and the send button sends.
-//
-// Rich text: the field edits *styled* text (⌘B/⌘I and the selection callout's
-// Format menu — UIKit wires both once allowsEditingTextAttributes is on), but
-// the `text` binding stays a plain String holding the wire's marker form
-// (**bold** / *italic* / ***both*** — RichTextEdit.swift), so send paths, slash
-// commands and drafts are untouched. Display ↔ markers converts at the edges:
-// serialize on every change, parse on external sets (an edit-load shows glyphs,
-// not asterisks). Underline is refused — the grammar has no marker for it, and
-// silently dropping it on send would be worse than not offering it.
struct ComposerField: UIViewRepresentable {
@Binding var text: String
@Binding var focused: Bool
// Caret position as a UTF-16 offset (UIKit's native unit) — drives the
- // typeahead, which needs to know which token the caret sits in. Offsets are
- // into the *serialized* text (the binding), not the styled display text.
+ // typeahead, which needs to know which token the caret sits in.
var caret: Binding<Int>? = nil
var placeholder: String = ""
var sendOnReturn: Bool
@@ -47,9 +36,6 @@ struct ComposerField: UIViewRepresentable {
// Live-track the system Text Size (Dynamic Type) — preferredFont only
// sizes at creation without this.
tv.adjustsFontForContentSizeCategory = true
- // ⌘B/⌘I toggle traits on the selection (or the typing attributes at a
- // bare caret) — UIKit provides the key commands and the Format menu.
- tv.allowsEditingTextAttributes = true
tv.backgroundColor = .clear
tv.textContainerInset = .zero
tv.textContainer.lineFragmentPadding = 0
@@ -59,14 +45,7 @@ struct ComposerField: UIViewRepresentable {
func updateUIView(_ tv: GrowingTextView, context: Context) {
context.coordinator.parent = self
- // Compare serialized forms: user edits round-trip exactly (didChange set
- // the binding from this very serialization), so only *external* sets —
- // an edit-load, a clear after send — reparse the view. Canonicalize the
- // target first so a draft arriving as `_i_` (which serializes back as
- // `*i*`) doesn't re-set the view on every update.
- if tv.serializedText() != serializeRuns(parseRuns(text)) {
- tv.setMarkerText(text)
- }
+ if tv.text != text { tv.text = text }
tv.placeholderLabel.text = placeholder
tv.placeholderLabel.isHidden = !text.isEmpty
// Drive first responder from the focus binding — async so we never mutate
@@ -97,15 +76,13 @@ struct ComposerField: UIViewRepresentable {
init(_ parent: ComposerField) { self.parent = parent }
func textViewDidChange(_ tv: UITextView) {
- guard let tv = tv as? GrowingTextView else { return }
- parent.text = tv.serializedText()
- parent.caret?.wrappedValue = tv.serializedCaret()
- tv.placeholderLabel.isHidden = !tv.text.isEmpty
+ parent.text = tv.text
+ parent.caret?.wrappedValue = tv.selectedRange.location
+ (tv as? GrowingTextView)?.placeholderLabel.isHidden = !tv.text.isEmpty
}
func textViewDidChangeSelection(_ tv: UITextView) {
- guard let tv = tv as? GrowingTextView else { return }
- parent.caret?.wrappedValue = tv.serializedCaret()
+ parent.caret?.wrappedValue = tv.selectedRange.location
}
func textView(_ tv: UITextView, shouldChangeTextIn range: NSRange,
@@ -127,9 +104,9 @@ struct ComposerField: UIViewRepresentable {
}
}
-// A UITextView that reports a placeholder, caps its growth at six lines, tracks
-// whether Shift is held so Shift+Return can mean "newline" while a bare Return
-// sends, and converts its styled contents to/from the wire's marker grammar.
+// A UITextView that reports a placeholder, caps its growth at six lines, and
+// tracks whether Shift is held so Shift+Return can mean "newline" while a bare
+// Return sends.
final class GrowingTextView: UITextView {
let placeholderLabel = UILabel()
private(set) var shiftHeld = false
@@ -139,18 +116,9 @@ final class GrowingTextView: UITextView {
// Hardware-keyboard ↑ with an empty draft (the desktop edit convention).
var onEditLast: (() -> Void)?
- // The unstyled body font — the base every run's traits derive from. Never
- // read `font` for this: after styled content lands, `font` reports the run
- // at index 0, and deriving from a bold base would bold everything.
- private var baseFont: UIFont { .preferredFont(forTextStyle: .body) }
- private var baseColor: UIColor { textColor ?? .label }
-
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(paste(_:)), onPasteImage != nil,
UIPasteboard.general.hasImages { return true }
- // No underline: the marker grammar can't carry it, and applying a style
- // that silently vanishes on send would be a lie. (Bold/italic stay.)
- if action == #selector(toggleUnderline(_:)) { return false }
return super.canPerformAction(action, withSender: sender)
}
@@ -162,111 +130,9 @@ final class GrowingTextView: UITextView {
}
if !pb.hasStrings { return } // image-only paste fully consumed
}
- // Flatten styled pastes: allowsEditingTextAttributes would otherwise keep
- // a web page's fonts/colors, which the marker grammar can't represent.
- // insertText adopts the caret's typing attributes — pasting into a bold
- // stretch stays bold, like Messages.
- if let s = pb.string {
- insertText(s)
- delegate?.textViewDidChange?(self) // programmatic edits don't always notify
- return
- }
super.paste(sender)
}
- // toggleBoldface/toggleItalics change attributes without changing text, so
- // textViewDidChange never fires — but the binding must update *now*: a send
- // right after ⌘B would otherwise serialize the pre-toggle draft.
- override func toggleBoldface(_ sender: Any?) {
- super.toggleBoldface(sender)
- delegate?.textViewDidChange?(self)
- }
- override func toggleItalics(_ sender: Any?) {
- super.toggleItalics(sender)
- delegate?.textViewDidChange?(self)
- }
-
- // ---- styled contents ↔ marker grammar (RichTextEdit.swift) ----
-
- // The style of the character at a UTF-16 index, as an empty-text run.
- private func styleAt(_ index: Int) -> RichRun {
- let attrs = attributedText.attributes(at: index, effectiveRange: nil)
- let font = attrs[.font] as? UIFont
- let traits = font?.fontDescriptor.symbolicTraits ?? []
- let strike = ((attrs[.strikethroughStyle] as? Int) ?? 0) != 0
- return RichRun("", bold: traits.contains(.traitBold), italic: traits.contains(.traitItalic),
- strike: strike, code: traits.contains(.traitMonoSpace))
- }
-
- private func runs(of a: NSAttributedString) -> [RichRun] {
- var runs: [RichRun] = []
- let str = a.string as NSString
- a.enumerateAttributes(in: NSRange(location: 0, length: a.length)) { attrs, range, _ in
- let font = attrs[.font] as? UIFont
- let traits = font?.fontDescriptor.symbolicTraits ?? []
- let strike = ((attrs[.strikethroughStyle] as? Int) ?? 0) != 0
- let run = RichRun(str.substring(with: range),
- bold: traits.contains(.traitBold), italic: traits.contains(.traitItalic),
- strike: strike, code: traits.contains(.traitMonoSpace))
- // Coalesce: UIKit splits runs on attributes (color, kerning…) that
- // don't matter to the grammar.
- if let last = runs.last, last.sameStyle(as: run) {
- runs[runs.count - 1].text += run.text
- } else {
- runs.append(run)
- }
- }
- return runs
- }
-
- /// The field's contents in wire form — what the `text` binding holds.
- func serializedText() -> String { serializeRuns(runs(of: attributedText)) }
-
- /// Replace the contents from wire form (an edit-load / external set): the
- /// draft shows glyphs, not asterisks.
- func setMarkerText(_ text: String) {
- let out = NSMutableAttributedString()
- for run in parseRuns(text) {
- var traits = baseFont.fontDescriptor.symbolicTraits
- if run.bold { traits.insert(.traitBold) }
- if run.italic { traits.insert(.traitItalic) }
- var font = baseFont
- if run.code {
- font = .monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular)
- if run.bold || run.italic, let d = font.fontDescriptor.withSymbolicTraits(traits) {
- font = UIFont(descriptor: d, size: 0)
- }
- } else if run.bold || run.italic, let d = baseFont.fontDescriptor.withSymbolicTraits(traits) {
- font = UIFont(descriptor: d, size: 0)
- }
- var attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: baseColor]
- if run.strike { attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue }
- out.append(NSAttributedString(string: run.text, attributes: attrs))
- }
- attributedText = out
- // A cleared field must not inherit the last run's style into the next
- // message — reset the caret style to plain.
- if text.isEmpty { typingAttributes = [.font: baseFont, .foregroundColor: baseColor] }
- }
-
- /// The caret as an offset into the *serialized* text. Serializing the prefix
- /// before the caret counts every marker the caret sits after; when the caret
- /// is strictly inside a styled run, that prefix closed the run's markers
- /// behind it — characters the true position is before, so back them off.
- func serializedCaret() -> Int {
- let loc = selectedRange.location
- guard loc > 0, loc <= attributedText.length else { return 0 }
- let prefix = attributedText.attributedSubstring(from: NSRange(location: 0, length: loc))
- var n = (serializeRuns(runs(of: prefix)) as NSString).length
- if loc < attributedText.length {
- let before = styleAt(loc - 1)
- if before.sameStyle(as: styleAt(loc)), let marker = before.marker {
- n -= (marker as NSString).length
- }
- }
- return n
- }
-
private var lineHeight: CGFloat { (font ?? .preferredFont(forTextStyle: .body)).lineHeight }
var capHeight: CGFloat { ceil(lineHeight * 6) }
diff --git a/ios/LChatCore/Sources/LChatCore/RichTextEdit.swift b/ios/LChatCore/Sources/LChatCore/RichTextEdit.swift
deleted file mode 100644
index 69f3e83..0000000
--- a/ios/LChatCore/Sources/LChatCore/RichTextEdit.swift
+++ /dev/null
@@ -1,106 +0,0 @@
-import Foundation
-
-// The bridge between a rich-text composer and the wire's marker grammar
-// (format.js / RichText.swift): a draft is modelled as a flat list of styled
-// runs, serialized to markers on send and parsed back for edit-loads. Pure
-// Foundation — the UIKit NSAttributedString adapter lives with ComposerField;
-// this half is shared so the Mac's native composer can reuse it.
-//
-// The grammar is single-level (no nesting) with one combined form:
-// `code` ***bold italic*** **bold** *italic* ~strike~
-// so a run can't carry every style combination. serializeRuns resolves the
-// inexpressible ones by priority: code wins outright, then bold/italic
-// (together as ***), and strike only stands alone — a strike combined with
-// bold/italic keeps the bold/italic and drops the strike.
-
-/// A stretch of draft text with one uniform style.
-public struct RichRun: Equatable {
- public var text: String
- public var bold: Bool
- public var italic: Bool
- public var strike: Bool
- public var code: Bool
-
- public init(_ text: String, bold: Bool = false, italic: Bool = false,
- strike: Bool = false, code: Bool = false) {
- self.text = text
- self.bold = bold
- self.italic = italic
- self.strike = strike
- self.code = code
- }
-
- public var isPlain: Bool { !bold && !italic && !strike && !code }
-
- public func sameStyle(as other: RichRun) -> Bool {
- bold == other.bold && italic == other.italic && strike == other.strike && code == other.code
- }
-
- /// The marker pair for this style, resolved by the priority above; nil for
- /// plain runs. Italic serializes as `*`, never `_` — the underscore form
- /// needs word boundaries (file_names), the asterisk works mid-word too.
- /// Public for the composer's caret math (mapping display offsets to
- /// serialized offsets needs marker lengths).
- public var marker: String? {
- if code { return "`" }
- if bold && italic { return "***" }
- if bold { return "**" }
- if italic { return "*" }
- if strike { return "~" }
- return nil
- }
-}
-
-/// Serialize composer runs to marker text. Markers can't span newlines (the
-/// grammar is per-line), so a styled run is split and each line wrapped on its
-/// own; whitespace-only stretches stay unwrapped (`** **` is noise, and the
-/// style is invisible on spaces anyway). A styled run whose text contains its
-/// own marker char will mis-parse on the receiver — the same edge web users
-/// typing markers by hand have always had; there is no escaping in the grammar.
-public func serializeRuns(_ runs: [RichRun]) -> String {
- var out = ""
- for run in runs {
- guard !run.text.isEmpty else { continue }
- guard let marker = run.marker else {
- out += run.text
- continue
- }
- // Split keeping empty segments so "a\n\nb" keeps both newlines.
- let lines = run.text.split(separator: "\n", omittingEmptySubsequences: false)
- out += lines.map { line in
- let s = String(line)
- if s.isEmpty || s.allSatisfy({ $0 == " " || $0 == "\t" }) { return s }
- return marker + s + marker
- }.joined(separator: "\n")
- }
- return out
-}
-
-/// Parse marker text back into runs (the edit-load direction), via the same
-/// richTokens the feed renders with — so the composer shows exactly what the
-/// bubble showed. Links flatten to plain text (the composer styles nothing
-/// about them; their text round-trips verbatim). Adjacent same-style runs
-/// coalesce so the output is canonical.
-public func parseRuns(_ text: String) -> [RichRun] {
- var runs: [RichRun] = []
- func push(_ run: RichRun) {
- guard !run.text.isEmpty else { return }
- if let last = runs.last, last.sameStyle(as: run) {
- runs[runs.count - 1].text += run.text
- } else {
- runs.append(run)
- }
- }
- for token in richTokens(text) {
- switch token {
- case .text(let s): push(RichRun(s))
- case .link(let s, _): push(RichRun(s))
- case .bold(let s): push(RichRun(s, bold: true))
- case .italic(let s): push(RichRun(s, italic: true))
- case .boldItalic(let s): push(RichRun(s, bold: true, italic: true))
- case .strike(let s): push(RichRun(s, strike: true))
- case .code(let s): push(RichRun(s, code: true))
- }
- }
- return runs
-}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/RichTextEditTests.swift b/ios/LChatCore/Tests/LChatCoreTests/RichTextEditTests.swift
deleted file mode 100644
index 6672075..0000000
--- a/ios/LChatCore/Tests/LChatCoreTests/RichTextEditTests.swift
+++ /dev/null
@@ -1,109 +0,0 @@
-import XCTest
-@testable import LChatCore
-
-// The composer's runs ↔ markers bridge (RichTextEdit.swift). The hard
-// invariant is the round trip: parse(serialize(runs)) == runs for anything the
-// editor can produce — a draft must read back exactly as it was styled.
-final class RichTextEditTests: XCTestCase {
- private func roundTrips(_ runs: [RichRun], _ note: String = "") {
- XCTAssertEqual(parseRuns(serializeRuns(runs)), runs, note)
- }
-
- func testPlainTextSerializesVerbatim() {
- XCTAssertEqual(serializeRuns([RichRun("hello world")]), "hello world")
- // Typed markers in a plain run pass through untouched (same as the web
- // composer: what you type is what is sent).
- XCTAssertEqual(serializeRuns([RichRun("a **manual** marker")]), "a **manual** marker")
- }
-
- func testMarkersPerStyle() {
- XCTAssertEqual(serializeRuns([RichRun("b", bold: true)]), "**b**")
- XCTAssertEqual(serializeRuns([RichRun("i", italic: true)]), "*i*")
- XCTAssertEqual(serializeRuns([RichRun("bi", bold: true, italic: true)]), "***bi***")
- XCTAssertEqual(serializeRuns([RichRun("s", strike: true)]), "~s~")
- XCTAssertEqual(serializeRuns([RichRun("c", code: true)]), "`c`")
- }
-
- func testItalicSerializesAsAsteriskForMidWordSafety() {
- // `_` needs word boundaries in the grammar; `*` renders anywhere.
- let s = serializeRuns([RichRun("un"), RichRun("believ", italic: true), RichRun("able")])
- XCTAssertEqual(s, "un*believ*able")
- XCTAssertEqual(parseRuns(s), [RichRun("un"), RichRun("believ", italic: true), RichRun("able")])
- }
-
- func testStyledRunSplitsAtNewlines() {
- // Markers can't span lines, so each line wraps on its own.
- XCTAssertEqual(serializeRuns([RichRun("one\ntwo", bold: true)]), "**one**\n**two**")
- XCTAssertEqual(serializeRuns([RichRun("a\n\nb", italic: true)]), "*a*\n\n*b*")
- }
-
- func testWhitespaceOnlyStretchesStayUnwrapped() {
- XCTAssertEqual(serializeRuns([RichRun(" ", bold: true)]), " ")
- XCTAssertEqual(serializeRuns([RichRun("a\n \nb", bold: true)]), "**a**\n \n**b**")
- }
-
- func testInexpressibleCombosResolveByPriority() {
- // code wins outright; strike drops when combined with bold/italic.
- XCTAssertEqual(serializeRuns([RichRun("x", bold: true, code: true)]), "`x`")
- XCTAssertEqual(serializeRuns([RichRun("x", bold: true, strike: true)]), "**x**")
- XCTAssertEqual(serializeRuns([RichRun("x", italic: true, strike: true)]), "*x*")
- }
-
- func testParseMirrorsTheFeed() {
- XCTAssertEqual(parseRuns("a **b** *i* ***bi*** ~s~ `c`"), [
- RichRun("a "),
- RichRun("b", bold: true),
- RichRun(" "),
- RichRun("i", italic: true),
- RichRun(" "),
- RichRun("bi", bold: true, italic: true),
- RichRun(" "),
- RichRun("s", strike: true),
- RichRun(" "),
- RichRun("c", code: true),
- ])
- }
-
- func testLinksFlattenToPlainTextAndCoalesce() {
- // The link and its neighbours read back as one plain run — the composer
- // doesn't style links, and their text must survive verbatim.
- XCTAssertEqual(parseRuns("see https://x.com now"), [RichRun("see https://x.com now")])
- }
-
- func testUnderscoreItalicCanonicalizesToAsterisk() {
- // Not a round trip — parse reads `_i_`, serialize re-emits `*i*`.
- XCTAssertEqual(serializeRuns(parseRuns("_i_")), "*i*")
- }
-
- func testRoundTripsEverythingTheEditorProduces() {
- roundTrips([RichRun("plain")])
- roundTrips([RichRun("a "), RichRun("b", bold: true), RichRun(" c")])
- roundTrips([RichRun("x", bold: true, italic: true)], "combined bold italic")
- roundTrips([
- RichRun("mixed "),
- RichRun("bold", bold: true),
- RichRun(" and "),
- RichRun("both", bold: true, italic: true),
- RichRun(" then\nplain line two"),
- ])
- roundTrips([RichRun("líne ✨ emoji", italic: true)], "unicode survives")
- }
-
- func testMultiLineStyledRunsCanonicalizeStably() {
- // A styled run spanning a newline splits per line (markers can't span
- // lines), so its parse is the canonical split form — the newline itself
- // is a plain run, which styles identically in the editor (styling on a
- // newline is invisible). What must hold is stability from then on:
- // serializing the canonical form reproduces the same markers.
- let s = serializeRuns([RichRun("two\nlines", bold: true), RichRun(" tail")])
- XCTAssertEqual(s, "**two**\n**lines** tail")
- let canonical = parseRuns(s)
- XCTAssertEqual(canonical, [
- RichRun("two", bold: true),
- RichRun("\n"),
- RichRun("lines", bold: true),
- RichRun(" tail"),
- ])
- XCTAssertEqual(serializeRuns(canonical), s, "canonical form is a fixed point")
- }
-}
diff --git a/ios/LChatMac/MacComposerField.swift b/ios/LChatMac/MacComposerField.swift
deleted file mode 100644
index dc52e24..0000000
--- a/ios/LChatMac/MacComposerField.swift
+++ /dev/null
@@ -1,412 +0,0 @@
-import SwiftUI
-import AppKit
-import LChatCore
-
-// The Mac native composer's text input — the NSTextView-backed field the old
-// SwiftUI TextField couldn't be (no selection access, no attributed text, no
-// caret for the typeahead). The AppKit twin of iOS's ComposerField, sharing its
-// contract exactly:
-//
-// - Rich text in the box: ⌘B/⌘I toggle bold/italic on the selection (or the
-// typing style at a bare caret), like Messages. ⌘U is swallowed — the marker
-// grammar has no underline, and a style that silently vanishes on send would
-// be a lie.
-// - The `text` binding stays a plain String holding the wire's marker form
-// (**bold** / *italic* / ***both*** — RichTextEdit.swift), so the send path,
-// slash commands and drafts are untouched. Serialize on every change; parse
-// on external sets (an edit-load shows glyphs, not asterisks).
-// - Return sends, Shift/Option+Return inserts a newline (the desktop
-// shouldSend split); the typeahead gets first claim on ↑↓/Tab/Return/Esc via
-// the handler closures, and ↑ on an empty draft edits your last message.
-// - `caret` reports a UTF-16 offset into the *serialized* text — real caret
-// tracking for the typeahead (the old field could only assume caret-at-end).
-struct MacComposerField: NSViewRepresentable {
- @Binding var text: String
- @Binding var focused: Bool
- var caret: Binding<Int>? = nil
- var placeholder: String = ""
- var fontSize: CGFloat
- var isDisabled: Bool = false
- var onSend: () -> Void
- // Typeahead/shortcut hooks — each returns whether it consumed the key.
- var onMoveUp: () -> Bool = { false }
- var onMoveDown: () -> Bool = { false }
- var onAccept: () -> Bool = { false } // Tab / Return over an open typeahead
- var onEscape: () -> Bool = { false }
- // Stage images / file URLs from a paste; returns whether anything staged.
- // nil in a default-command thread (text-only surface, like the web).
- var onStagePaste: ((NSPasteboard) -> Bool)? = nil
-
- func makeCoordinator() -> Coordinator { Coordinator(self) }
-
- func makeNSView(context: Context) -> NSScrollView {
- let tv = ComposerTextView()
- tv.baseFontSize = fontSize
- tv.delegate = context.coordinator
- tv.onFocusChange = { [weak coordinator = context.coordinator] has in
- guard let parent = coordinator?.parent else { return }
- if parent.focused != has { parent.focused = has }
- }
- tv.isRichText = true // attributed editing; pastes are flattened below
- tv.allowsUndo = true
- tv.drawsBackground = false
- tv.textContainerInset = .zero
- tv.textContainer?.lineFragmentPadding = 0
- tv.textContainer?.widthTracksTextView = true
- tv.isVerticallyResizable = true
- tv.isHorizontallyResizable = false
- tv.autoresizingMask = [.width]
- // The classic scroll-view pairing: without an unbounded maxSize the
- // text view can't grow past its initial frame, and content beyond it
- // would neither lay out nor scroll.
- tv.minSize = .zero
- tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude,
- height: CGFloat.greatestFiniteMagnitude)
- tv.usesFontPanel = false
- tv.importsGraphics = false
- tv.isAutomaticLinkDetectionEnabled = false // a link attribute we'd never serialize
- tv.applyBaseTypingAttributes()
-
- let scroll = NSScrollView()
- scroll.documentView = tv
- scroll.drawsBackground = false
- scroll.borderType = .noBorder
- scroll.hasVerticalScroller = true
- scroll.autohidesScrollers = true
- return scroll
- }
-
- func updateNSView(_ scroll: NSScrollView, context: Context) {
- context.coordinator.parent = self
- guard let tv = scroll.documentView as? ComposerTextView else { return }
- tv.onStagePaste = onStagePaste
- tv.isEditable = !isDisabled
- if tv.baseFontSize != fontSize {
- tv.baseFontSize = fontSize
- tv.setMarkerText(text) // re-derive every run from the new base size
- }
- // Compare serialized forms: user edits round-trip exactly, so only
- // *external* sets (edit-load, clear after send, typeahead splice)
- // reparse the view. Canonicalize the target so a draft arriving as
- // `_i_` (serializes back as `*i*`) doesn't re-set on every update.
- if tv.serializedText() != serializeRuns(parseRuns(text)) {
- tv.setMarkerText(text)
- }
- tv.placeholderLabel.stringValue = placeholder
- tv.placeholderLabel.isHidden = !text.isEmpty
- // Drive first responder from the focus binding — async so we never
- // mutate responder state mid-update. Compare against the live first
- // responder so a re-assert to `true` still re-grabs focus.
- let isFirst = tv.window?.firstResponder === tv
- if focused != isFirst {
- DispatchQueue.main.async {
- guard let window = tv.window else { return }
- if focused {
- window.makeFirstResponder(tv)
- } else if window.firstResponder === tv {
- window.makeFirstResponder(nil)
- }
- }
- }
- }
-
- // Grow with the text up to nine lines (the old field's lineLimit(1...9)),
- // then scroll within. ALWAYS returns a finite, capped height — SwiftUI
- // probes with zero/unspecified/infinite proposals, and returning nil for
- // any of them marks the view as greedy: the field then swallows the whole
- // pane (the giant-composer bug).
- func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSScrollView, context: Context) -> CGSize? {
- guard let tv = nsView.documentView as? ComposerTextView,
- let container = tv.textContainer, let layout = tv.layoutManager else { return nil }
- // Resolve a working width: the proposal when it's a real number, else
- // whatever the view last had (a probe must still yield a finite height).
- var width = proposal.width ?? tv.bounds.width
- if !width.isFinite || width <= 0 { width = max(tv.bounds.width, 100) }
- if tv.frame.width != width { tv.setFrameSize(NSSize(width: width, height: tv.frame.height)) }
- layout.ensureLayout(for: container)
- let line = layout.defaultLineHeight(for: tv.baseFont)
- let used = layout.usedRect(for: container).height
- let height = ceil(min(max(line, used), ceil(line * 9)))
- // Width: honor a finite proposal exactly; for zero/infinite probes
- // report the working width rather than echoing the probe back.
- if let w = proposal.width, w.isFinite, w > 0 {
- return CGSize(width: w, height: height)
- }
- return CGSize(width: width, height: height)
- }
-
- final class Coordinator: NSObject, NSTextViewDelegate {
- var parent: MacComposerField
- init(_ parent: MacComposerField) { self.parent = parent }
-
- func textDidChange(_ notification: Notification) {
- guard let tv = notification.object as? ComposerTextView else { return }
- parent.text = tv.serializedText()
- parent.caret?.wrappedValue = tv.serializedCaret()
- tv.placeholderLabel.isHidden = !tv.string.isEmpty
- }
-
- func textViewDidChangeSelection(_ notification: Notification) {
- guard let tv = notification.object as? ComposerTextView else { return }
- parent.caret?.wrappedValue = tv.serializedCaret()
- }
-
- func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
- switch commandSelector {
- case #selector(NSResponder.insertNewline(_:)):
- // Desktop shouldSend: Shift/Option+Return newlines (fall through
- // to the standard insertion), plain Return accepts the typeahead
- // or sends.
- let mods = NSApp.currentEvent?.modifierFlags ?? []
- if mods.contains(.shift) || mods.contains(.option) { return false }
- if parent.onAccept() { return true }
- parent.onSend()
- return true
- case #selector(NSResponder.moveUp(_:)):
- return parent.onMoveUp()
- case #selector(NSResponder.moveDown(_:)):
- return parent.onMoveDown()
- case #selector(NSResponder.insertTab(_:)):
- return parent.onAccept()
- case #selector(NSResponder.cancelOperation(_:)):
- return parent.onEscape()
- case #selector(NSResponder.complete(_:)):
- return true // ours is the only completion UI — never the system popup
- default:
- return false
- }
- }
- }
-}
-
-// The NSTextView itself: placeholder, ⌘B/⌘I trait toggles, flattened pastes,
-// and the styled-contents ↔ marker-grammar conversion (RichTextEdit.swift).
-final class ComposerTextView: NSTextView {
- let placeholderLabel = NSTextField(labelWithString: "")
- var onStagePaste: ((NSPasteboard) -> Bool)?
- var onFocusChange: ((Bool) -> Void)?
-
- var baseFontSize: CGFloat = NSFont.systemFontSize {
- didSet {
- placeholderLabel.font = .systemFont(ofSize: baseFontSize)
- if string.isEmpty { applyBaseTypingAttributes() }
- }
- }
- // The unstyled body font every run's traits derive from. Never `font` —
- // after styled content lands, that reports the run at the caret, and
- // deriving from a bold base would bold everything.
- var baseFont: NSFont { .systemFont(ofSize: baseFontSize) }
- private var baseColor: NSColor { .labelColor }
-
- override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) {
- super.init(frame: frameRect, textContainer: container)
- setUpPlaceholder()
- }
- // AppKit (unlike UIKit) does NOT build the text system for a nil container
- // — init(frame:textContainer: nil) yields a view with no textContainer, no
- // layoutManager, no textStorage: typing is dead and sizeThatFits' guards
- // bail (which read as "greedy", the composer-swallows-the-pane bug).
- // Assemble the classic TextKit stack by hand.
- convenience init() {
- let storage = NSTextStorage()
- let layout = NSLayoutManager()
- storage.addLayoutManager(layout)
- let container = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
- container.widthTracksTextView = true
- layout.addTextContainer(container)
- self.init(frame: .zero, textContainer: container)
- }
- required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
-
- private func setUpPlaceholder() {
- placeholderLabel.font = .systemFont(ofSize: baseFontSize)
- placeholderLabel.textColor = .placeholderTextColor
- placeholderLabel.lineBreakMode = .byTruncatingTail
- placeholderLabel.maximumNumberOfLines = 1
- addSubview(placeholderLabel)
- }
-
- override func layout() {
- super.layout()
- placeholderLabel.frame = NSRect(x: 0, y: 0, width: bounds.width,
- height: placeholderLabel.intrinsicContentSize.height)
- }
-
- func applyBaseTypingAttributes() {
- typingAttributes = [.font: baseFont, .foregroundColor: baseColor]
- }
-
- override func becomeFirstResponder() -> Bool {
- let ok = super.becomeFirstResponder()
- if ok { onFocusChange?(true) }
- return ok
- }
- override func resignFirstResponder() -> Bool {
- let ok = super.resignFirstResponder()
- if ok { onFocusChange?(false) }
- return ok
- }
-
- // ⌘B/⌘I toggle traits; ⌘U is swallowed (no underline marker — see the
- // representable's doc comment). Everything else keeps its meaning.
- override func performKeyEquivalent(with event: NSEvent) -> Bool {
- // Key equivalents walk the VIEW hierarchy, not the responder chain —
- // and RootView keeps every opened space's pane mounted (hidden at
- // opacity 0), each with its own composer. Without the focus guard the
- // FIRST composer in the tree swallows ⌘B/⌘I and formats a hidden
- // draft, so the shortcut only ever "worked" on the top space. Only the
- // focused field may claim the key; everyone else passes it along.
- guard window?.firstResponder === self else {
- return super.performKeyEquivalent(with: event)
- }
- if event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command,
- let key = event.charactersIgnoringModifiers?.lowercased() {
- switch key {
- case "b": toggleTrait(.bold); return true
- case "i": toggleTrait(.italic); return true
- case "u": return true
- default: break
- }
- }
- return super.performKeyEquivalent(with: event)
- }
-
- // Toggle a symbolic trait over the selection (all-styled → remove, else
- // add — the standard semantics), or flip the typing style at a bare caret.
- // Selection changes go through shouldChangeText/didChangeText so undo works
- // and the delegate (→ the binding) hears about the attribute-only edit.
- private func toggleTrait(_ trait: NSFontDescriptor.SymbolicTraits) {
- let range = selectedRange()
- if range.length == 0 {
- var attrs = typingAttributes
- let font = (attrs[.font] as? NSFont) ?? baseFont
- let has = font.fontDescriptor.symbolicTraits.contains(trait)
- attrs[.font] = converted(font, trait: trait, on: !has)
- typingAttributes = attrs
- return
- }
- guard let storage = textStorage, shouldChangeText(in: range, replacementString: nil) else { return }
- var allStyled = true
- storage.enumerateAttribute(.font, in: range) { value, _, stop in
- let font = (value as? NSFont) ?? baseFont
- if !font.fontDescriptor.symbolicTraits.contains(trait) {
- allStyled = false
- stop.pointee = true
- }
- }
- storage.beginEditing()
- storage.enumerateAttribute(.font, in: range) { value, subRange, _ in
- let font = (value as? NSFont) ?? baseFont
- storage.addAttribute(.font, value: converted(font, trait: trait, on: !allStyled), range: subRange)
- }
- storage.endEditing()
- didChangeText()
- }
-
- private func converted(_ font: NSFont, trait: NSFontDescriptor.SymbolicTraits, on: Bool) -> NSFont {
- var traits = font.fontDescriptor.symbolicTraits
- if on { traits.insert(trait) } else { traits.remove(trait) }
- let descriptor = font.fontDescriptor.withSymbolicTraits(traits)
- return NSFont(descriptor: descriptor, size: font.pointSize) ?? font
- }
-
- // Pastes: stage images/file URLs as uploads (the web composer's
- // paste-to-upload), and flatten any styled text to the caret's typing
- // style — isRichText would otherwise keep a web page's fonts/colors, which
- // the marker grammar can't represent. A mixed clipboard does both.
- override func paste(_ sender: Any?) {
- let pb = NSPasteboard.general
- let staged = onStagePaste?(pb) ?? false
- if let s = pb.string(forType: .string), !s.isEmpty {
- insertText(s, replacementRange: selectedRange())
- return
- }
- if !staged { super.pasteAsPlainText(sender) }
- }
-
- // ---- styled contents ↔ marker grammar (RichTextEdit.swift) ----
-
- // The style of the character at a UTF-16 index, as an empty-text run.
- private func styleAt(_ index: Int) -> RichRun {
- guard let storage = textStorage else { return RichRun("") }
- let attrs = storage.attributes(at: index, effectiveRange: nil)
- let traits = ((attrs[.font] as? NSFont) ?? baseFont).fontDescriptor.symbolicTraits
- let strike = ((attrs[.strikethroughStyle] as? Int) ?? 0) != 0
- return RichRun("", bold: traits.contains(.bold), italic: traits.contains(.italic),
- strike: strike, code: traits.contains(.monoSpace))
- }
-
- private func runs(of a: NSAttributedString) -> [RichRun] {
- var runs: [RichRun] = []
- let str = a.string as NSString
- a.enumerateAttributes(in: NSRange(location: 0, length: a.length)) { attrs, range, _ in
- let traits = ((attrs[.font] as? NSFont) ?? baseFont).fontDescriptor.symbolicTraits
- let strike = ((attrs[.strikethroughStyle] as? Int) ?? 0) != 0
- let run = RichRun(str.substring(with: range),
- bold: traits.contains(.bold), italic: traits.contains(.italic),
- strike: strike, code: traits.contains(.monoSpace))
- // Coalesce: AppKit splits runs on attributes (color, kerning…) that
- // don't matter to the grammar.
- if let last = runs.last, last.sameStyle(as: run) {
- runs[runs.count - 1].text += run.text
- } else {
- runs.append(run)
- }
- }
- return runs
- }
-
- /// The field's contents in wire form — what the `text` binding holds.
- func serializedText() -> String {
- serializeRuns(runs(of: textStorage ?? NSAttributedString()))
- }
-
- /// Replace the contents from wire form (an edit-load / external set): the
- /// draft shows glyphs, not asterisks.
- func setMarkerText(_ text: String) {
- let out = NSMutableAttributedString()
- for run in parseRuns(text) {
- var traits = baseFont.fontDescriptor.symbolicTraits
- if run.bold { traits.insert(.bold) }
- if run.italic { traits.insert(.italic) }
- var font = baseFont
- if run.code {
- font = .monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular)
- if run.bold || run.italic {
- font = NSFont(descriptor: font.fontDescriptor.withSymbolicTraits(traits),
- size: 0) ?? font
- }
- } else if run.bold || run.italic {
- font = NSFont(descriptor: baseFont.fontDescriptor.withSymbolicTraits(traits),
- size: 0) ?? font
- }
- var attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: baseColor]
- if run.strike { attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue }
- out.append(NSAttributedString(string: run.text, attributes: attrs))
- }
- textStorage?.setAttributedString(out)
- setSelectedRange(NSRange(location: out.length, length: 0))
- // A cleared field must not inherit the last run's style into the next
- // message — reset the caret style to plain.
- if text.isEmpty { applyBaseTypingAttributes() }
- }
-
- /// The caret as an offset into the *serialized* text. Serializing the prefix
- /// before the caret counts every marker the caret sits after; when the caret
- /// is strictly inside a styled run, that prefix closed the run's markers
- /// behind it — characters the true position is before, so back them off.
- func serializedCaret() -> Int {
- guard let storage = textStorage else { return 0 }
- let loc = selectedRange().location
- guard loc > 0, loc <= storage.length else { return 0 }
- let prefix = storage.attributedSubstring(from: NSRange(location: 0, length: loc))
- var n = (serializeRuns(runs(of: prefix)) as NSString).length
- if loc < storage.length {
- let before = styleAt(loc - 1)
- if before.sameStyle(as: styleAt(loc)), let marker = before.marker {
- n -= (marker as NSString).length
- }
- }
- return n
- }
-}
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 14cf47a..5860ba4 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -93,10 +93,7 @@ struct NativeFeedView: View {
// The native composer's draft — cleared only when a send actually reaches
// the server (model.send returns false on a down socket, keeping the text).
@State private var draft = ""
- @State private var composerFocused = false
- // The caret as a UTF-16 offset into `draft` (MacComposerField maps its
- // display caret to serialized offsets) — drives the typeahead.
- @State private var composerCaret = 0
+ @FocusState private var composerFocused: Bool
// Attachments staged on the draft: picked → uploading (spinner) → ready.
// Several can ride one message, like the web's pending list.
private struct Staged: Identifiable {
@@ -686,36 +683,24 @@ struct NativeFeedView: View {
// iMessage-style: a within-window frost (FrostedBand) — matches the
// chat stream's surface exactly at rest (blurring the same window
// background it sits on) while messages scrolling underneath ghost
- // through the glass. (The old `.bar` material looked like a second,
- // lighter surface; an opaque fill killed the ghosting.) Only the
- // input row wears it — the typeahead/banners above stay
- // transparent floaters.
+ // through the glass. Only the input row wears it — the typeahead/
+ // banners above stay transparent floaters.
.background(FrostedBand())
}
.onChange(of: draft) { _, text in
- refreshTypeahead()
+ // Caret-at-end is the common case while typing; caret tracking for
+ // mid-text edits needs the NSTextView-backed field (iOS's
+ // ComposerField pattern) — container work.
+ taToken = composerToken(text)
+ taItems = taToken.map {
+ typeaheadItems($0, roster: roster, myUserId: myUserId, hasBot: hasBot,
+ appCommands: appCommands)
+ } ?? []
+ taIndex = 0
// The typing beats: alive while there's a draft, an immediate
// clear when it empties (edits don't announce themselves).
model.typingBeat(active: !text.isEmpty && editingID == nil)
}
- // Caret moves (clicks / arrows) can enter or leave a token with the
- // text unchanged — re-check, like the web composer's keyup handler.
- .onChange(of: composerCaret) { _, _ in refreshTypeahead() }
- }
-
- // The token under the REAL caret (MacComposerField tracks it) — the old
- // TextField could only assume caret-at-end. The field reports UTF-16, the
- // tokenizer speaks Characters, so convert at the boundary (as on iOS).
- private func refreshTypeahead() {
- let utf16 = min(max(composerCaret, 0), draft.utf16.count)
- let idx = String.Index(utf16Offset: utf16, in: draft)
- let charCaret = draft.distance(from: draft.startIndex, to: idx)
- taToken = composerToken(draft, caret: charCaret)
- taItems = taToken.map {
- typeaheadItems($0, roster: roster, myUserId: myUserId, hasBot: hasBot,
- appCommands: appCommands)
- } ?? []
- taIndex = 0
}
// Accept a candidate: splice it over the token (trigger → caret), exactly
@@ -834,69 +819,77 @@ struct NativeFeedView: View {
.buttonStyle(.plain)
.padding(.bottom, 4)
}
- // The NSTextView-backed field (MacComposerField): rich text in the
- // box (⌘B/⌘I serialize to markers in `draft`), real caret tracking
- // for the typeahead, and paste-to-upload — the SwiftUI TextField it
- // replaced could do none of those. Typeahead keys route through the
- // handlers: ↑↓ move, Tab/Return accept, Esc dismisses — Return
- // falls through to send when no menu is up. Up-arrow ALSO edits the
- // last message when the box is empty.
- MacComposerField(
- text: $draft,
- focused: $composerFocused,
- caret: $composerCaret,
- placeholder: defaultCommand?.placeholder ?? "Message",
- fontSize: chatFontSize, // the feed's reading size, like the web composer
+ TextField(defaultCommand?.placeholder ?? "Message", text: $draft, axis: .vertical)
+ // Paste an image (or a copied file) straight onto the draft -
+ // it stages as an upload, like the web composer's paste. In a
+ // default-command thread the type list is empty, so ⌘V is NOT
+ // claimed here and the field's normal text paste still works.
+ .onPasteCommand(of: defaultCommand == nil ? [.png, .jpeg, .tiff, .image, .fileURL] : []) { providers in
+ handlePaste(providers)
+ }
+ .textFieldStyle(.plain)
+ .font(.system(size: chatFontSize)) // the feed's reading size, like the web composer
+ .lineLimit(1...9) // grows to ~9 lines, then scrolls within itself
+ .padding(.horizontal, 14)
+ .padding(.vertical, 9)
+ // The web composer: a bordered pill on the panel background,
+ // not a filled grey field.
+ .background(RoundedRectangle(cornerRadius: 19, style: .continuous)
+ .fill(.background))
+ .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous)
+ .strokeBorder(.quaternary, lineWidth: 1))
+ .onSubmit(sendDraft)
// Queued behind an upload = the message is committed; editing
// what's about to auto-send would race it.
- isDisabled: sendQueued,
- onSend: sendDraft,
- onMoveUp: {
+ .disabled(sendQueued)
+ // Typeahead keys: ↑↓ move, Tab/Return accept, Esc dismisses —
+ // Return falls through to send when no menu is up. Up-arrow
+ // ALSO edits the last message when the box is empty (one
+ // handler — two .onKeyPress(.upArrow) on a field don't both
+ // fire, which is why the edit shortcut never worked).
+ .onKeyPress(.upArrow) {
if !taItems.isEmpty {
taIndex = (taIndex + taItems.count - 1) % taItems.count
- return true
+ return .handled
}
if draft.isEmpty, editingID == nil,
let last = model.messages.last(where: { $0.mine && !$0.action }) {
startEdit(last)
- return true
+ return .handled
}
- return false
- },
- onMoveDown: {
- guard !taItems.isEmpty else { return false }
+ return .ignored
+ }
+ .onKeyPress(.downArrow) {
+ guard !taItems.isEmpty else { return .ignored }
taIndex = (taIndex + 1) % taItems.count
- return true
- },
- onAccept: {
- guard !taItems.isEmpty, let token = taToken else { return false }
+ return .handled
+ }
+ .onKeyPress(.tab) {
+ guard !taItems.isEmpty, let token = taToken else { return .ignored }
pick(taItems[taIndex], token: token)
- return true
- },
- onEscape: {
- guard !taItems.isEmpty else { return false }
+ return .handled
+ }
+ .onKeyPress(phases: .down) { press in
+ guard press.key == .return else { return .ignored }
+ // Desktop shouldSend: Shift/Option+Return newlines, plain
+ // Return accepts the typeahead or sends (via onSubmit).
+ if press.modifiers.contains(.shift) || press.modifiers.contains(.option) {
+ draft += "\n"
+ return .handled
+ }
+ if !taItems.isEmpty, let token = taToken {
+ pick(taItems[taIndex], token: token)
+ return .handled
+ }
+ return .ignored
+ }
+ .onKeyPress(.escape) {
+ guard !taItems.isEmpty else { return .ignored }
taToken = nil
taItems = []
- return true
- },
- // Paste an image (or a copied file) straight onto the draft —
- // it stages as an upload, like the web composer's paste. Absent
- // in a default-command thread (text-only surface), so a text
- // paste there stays a plain text paste.
- onStagePaste: defaultCommand == nil ? { stagePasteboard($0) } : nil
- )
- // Claim the row's full width (the + and send buttons keep their
- // fixed sizes) — the representable reports its measured width at
- // the max probe, so without this the HStack centres a short pill.
- .frame(maxWidth: .infinity)
- .padding(.horizontal, 14)
- .padding(.vertical, 9)
- // The web composer: a bordered pill on the panel background,
- // not a filled grey field.
- .background(RoundedRectangle(cornerRadius: 19, style: .continuous)
- .fill(.background))
- .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous)
- .strokeBorder(.quaternary, lineWidth: 1))
+ return .handled
+ }
+ .focused($composerFocused)
// Regaining the app drops the caret into the message box, the
// same courtesy the webview shell extends (focusComposer) —
// but ONLY from the pane actually on screen (every instance's
@@ -1230,24 +1223,27 @@ struct NativeFeedView: View {
}
}
- // Pasted content (from MacComposerField's paste override): file URLs route
- // through stage(url); raw image data stages as a PNG (like the web
- // composer's paste-to-upload). Returns whether anything staged, so the
- // field knows if the paste was (partly) consumed.
- private func stagePasteboard(_ pb: NSPasteboard) -> Bool {
- if let urls = pb.readObjects(forClasses: [NSURL.self]) as? [URL] {
- let files = urls.filter(\.isFileURL)
- if !files.isEmpty {
- files.forEach { stage($0) }
- return true
+ // Pasted content: file URLs route through stage(url); raw image data
+ // stages as a PNG (like the web composer's paste-to-upload).
+ private func handlePaste(_ providers: [NSItemProvider]) {
+ for provider in providers {
+ if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
+ _ = provider.loadObject(ofClass: URL.self) { url, _ in
+ if let url { DispatchQueue.main.async { stage(url) } }
+ }
+ continue
+ }
+ let types: [UTType] = [.png, .jpeg, .tiff, .image]
+ guard let type = types.first(where: {
+ provider.hasItemConformingToTypeIdentifier($0.identifier)
+ }) else { continue }
+ provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, _ in
+ guard let data else { return }
+ DispatchQueue.main.async {
+ stage(data: data, name: "pasted.png", type: .png)
+ }
}
}
- for type in [NSPasteboard.PasteboardType.png, .tiff] {
- guard let data = pb.data(forType: type) else { continue }
- stage(data: data, name: "pasted.png", type: type == .png ? .png : .tiff)
- return true
- }
- return false
}
private func stage(_ url: URL) {