@opengeni/react-native 4.2.1-canary.39642758903001 → 4.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +15 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/dev/opengeni/textselection/OpenGeniTextSelectionModule.kt +274 -0
- package/dist/{chunk-D3MFBDVR.js → chunk-2RNMGRTB.js} +3 -3
- package/dist/{chunk-D3MFBDVR.js.map → chunk-2RNMGRTB.js.map} +1 -1
- package/dist/{chunk-2W74OY6I.js → chunk-FHPMRXIG.js} +16 -2
- package/dist/chunk-FHPMRXIG.js.map +1 -0
- package/dist/{chunk-YTT4WSJZ.js → chunk-QFV5DISV.js} +3 -3
- package/dist/{chunk-YTT4WSJZ.js.map → chunk-QFV5DISV.js.map} +1 -1
- package/dist/{chunk-U5L2CUMJ.js → chunk-ZZNZBZWV.js} +3 -3
- package/dist/{chunk-U5L2CUMJ.js.map → chunk-ZZNZBZWV.js.map} +1 -1
- package/dist/index.js +2 -2
- package/dist/timeline/annotations.d.ts +81 -0
- package/dist/timeline/icon-nodes.generated.d.ts +5 -0
- package/dist/timeline/index.d.ts +2 -0
- package/dist/timeline/index.js +1186 -197
- package/dist/timeline/index.js.map +1 -1
- package/dist/timeline/markdown.js +5 -2
- package/dist/timeline/markdown.js.map +1 -1
- package/dist/timeline/message-timeline.d.ts +20 -2
- package/dist/timeline/previews.js +2 -2
- package/dist/timeline/queue-dock.d.ts +4 -1
- package/dist/timeline/text-selection.d.ts +44 -0
- package/expo-module.config.json +5 -2
- package/ios/OpenGeniTextSelectionModule.swift +560 -0
- package/package.json +6 -4
- package/src/timeline/activity.tsx +24 -15
- package/src/timeline/annotations.tsx +877 -0
- package/src/timeline/icon-nodes.generated.ts +14 -0
- package/src/timeline/index.ts +2 -0
- package/src/timeline/markdown.tsx +7 -0
- package/src/timeline/message-timeline.tsx +340 -174
- package/src/timeline/queue-dock.tsx +36 -0
- package/src/timeline/session-screen.tsx +122 -4
- package/src/timeline/sheet.tsx +3 -2
- package/src/timeline/text-selection.ts +115 -0
- package/dist/chunk-2W74OY6I.js.map +0 -1
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import ObjectiveC
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
/// Native text selection for timeline messages. React Native's selectable
|
|
6
|
+
/// Text on iOS only offers Copy for the whole paragraph. Inside a view whose
|
|
7
|
+
/// accessibility identifier (its React Native test id) starts with
|
|
8
|
+
/// `og-annotation-source:`, a long press instead selects the word under the
|
|
9
|
+
/// finger with the system selection handles and edit menu, so any part of the
|
|
10
|
+
/// paragraph can be selected, copied, looked up or shared. When JavaScript
|
|
11
|
+
/// listens and the identifier names a source, the menu also offers Add note,
|
|
12
|
+
/// which reports the selected text and its surroundings.
|
|
13
|
+
public class OpenGeniTextSelectionModule: Module {
|
|
14
|
+
public func definition() -> ModuleDefinition {
|
|
15
|
+
Name("OpenGeniTextSelection")
|
|
16
|
+
|
|
17
|
+
Events("onAnnotate")
|
|
18
|
+
|
|
19
|
+
OnCreate {
|
|
20
|
+
DispatchQueue.main.async { OpenGeniTextSelection.shared.install() }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
OnStartObserving {
|
|
24
|
+
OpenGeniTextSelection.shared.emit = { [weak self] event in
|
|
25
|
+
self?.sendEvent("onAnnotate", event)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
OnStopObserving {
|
|
30
|
+
OpenGeniTextSelection.shared.emit = nil
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// The Add note title, localized by JavaScript; nil hides Add note.
|
|
34
|
+
Function("setAnnotateTitle") { (title: String?) in
|
|
35
|
+
DispatchQueue.main.async { OpenGeniTextSelection.shared.annotateTitle = title }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Where each draft quote is drawn inside a source, in the source view's
|
|
39
|
+
/// coordinates: `{ x, top, rects }`, the end of its last glyph, the top of
|
|
40
|
+
/// that line's text and one text box per line (flattened x, y, width,
|
|
41
|
+
/// height), or an empty object when the quote is not drawn there. Each
|
|
42
|
+
/// query is `{ quote, before, after }`; the surrounding text picks between
|
|
43
|
+
/// repeated occurrences.
|
|
44
|
+
AsyncFunction("quoteAnchors") { (sourceKey: String, queries: [[String: String]]) -> [[String: Any]] in
|
|
45
|
+
OpenGeniTextSelection.shared.quoteAnchors(sourceKey: sourceKey, queries: queries)
|
|
46
|
+
}.runOnQueue(.main)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
final class OpenGeniTextSelection: NSObject {
|
|
51
|
+
static let shared = OpenGeniTextSelection()
|
|
52
|
+
static let sourcePrefix = "og-annotation-source:"
|
|
53
|
+
|
|
54
|
+
var emit: (([String: Any]) -> Void)?
|
|
55
|
+
var annotateTitle: String?
|
|
56
|
+
private var installed = false
|
|
57
|
+
private weak var active: OpenGeniSelectionTextView?
|
|
58
|
+
|
|
59
|
+
/// Swizzles the Fabric paragraph's long-press handler once.
|
|
60
|
+
func install() {
|
|
61
|
+
guard !installed, let cls = NSClassFromString("RCTParagraphComponentView") else { return }
|
|
62
|
+
installed = true
|
|
63
|
+
let selector = NSSelectorFromString("handleLongPress:")
|
|
64
|
+
guard let method = class_getInstanceMethod(cls, selector) else { return }
|
|
65
|
+
typealias Handler = @convention(c) (AnyObject, Selector, AnyObject) -> Void
|
|
66
|
+
let original = unsafeBitCast(method_getImplementation(method), to: Handler.self)
|
|
67
|
+
let block: @convention(block) (AnyObject, AnyObject) -> Void = { target, gesture in
|
|
68
|
+
if let view = target as? UIView, let press = gesture as? UILongPressGestureRecognizer,
|
|
69
|
+
OpenGeniTextSelection.shared.handle(press, on: view)
|
|
70
|
+
{
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
original(target, selector, gesture)
|
|
74
|
+
}
|
|
75
|
+
method_setImplementation(method, imp_implementationWithBlock(block))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// The source marker this paragraph sits in: nil when the paragraph is not
|
|
79
|
+
/// inside a timeline message, "" for a message that cannot be annotated.
|
|
80
|
+
private func sourceKey(for view: UIView) -> String? {
|
|
81
|
+
var current: UIView? = view
|
|
82
|
+
while let candidate = current {
|
|
83
|
+
if let id = candidate.accessibilityIdentifier, id.hasPrefix(Self.sourcePrefix) {
|
|
84
|
+
return String(id.dropFirst(Self.sourcePrefix.count))
|
|
85
|
+
}
|
|
86
|
+
current = candidate.superview
|
|
87
|
+
}
|
|
88
|
+
return nil
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Starts a selection for a long press; false leaves it to React Native.
|
|
92
|
+
private func handle(_ press: UILongPressGestureRecognizer, on paragraph: UIView) -> Bool {
|
|
93
|
+
guard let key = sourceKey(for: paragraph) else { return false }
|
|
94
|
+
// The press keeps reporting changes while the finger stays down; its
|
|
95
|
+
// start opens a selection and lifting the finger shows the edit menu, as
|
|
96
|
+
// in any iOS text view.
|
|
97
|
+
if press.state == .ended {
|
|
98
|
+
if let current = active, current.paragraph === paragraph { current.showMenu() }
|
|
99
|
+
return true
|
|
100
|
+
}
|
|
101
|
+
guard press.state == .began else { return true }
|
|
102
|
+
if let current = active, current.paragraph === paragraph { return true }
|
|
103
|
+
active?.dismiss()
|
|
104
|
+
guard
|
|
105
|
+
let text = Self.attributedText(of: paragraph), text.length > 0,
|
|
106
|
+
let frame = Self.textFrame(of: paragraph), frame.width > 0, frame.height > 0
|
|
107
|
+
else { return false }
|
|
108
|
+
let overlay = OpenGeniSelectionTextView(
|
|
109
|
+
paragraph: paragraph, text: text, frame: frame, sourceKey: key)
|
|
110
|
+
paragraph.addSubview(overlay)
|
|
111
|
+
active = overlay
|
|
112
|
+
overlay.begin(at: press.location(in: overlay))
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static func attributedText(of paragraph: UIView) -> NSAttributedString? {
|
|
117
|
+
guard paragraph.responds(to: NSSelectorFromString("attributedText")) else { return nil }
|
|
118
|
+
return paragraph.value(forKey: "attributedText") as? NSAttributedString
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Where React Native draws the paragraph's text, in its own coordinates:
|
|
122
|
+
/// the drawing frame of its content view where React Native exposes one,
|
|
123
|
+
/// else the content view's bounds (text without padding or borders).
|
|
124
|
+
private static func textFrame(of paragraph: UIView) -> CGRect? {
|
|
125
|
+
guard paragraph.responds(to: NSSelectorFromString("contentView")),
|
|
126
|
+
let content = paragraph.value(forKey: "contentView") as? UIView
|
|
127
|
+
else { return nil }
|
|
128
|
+
if content.responds(to: NSSelectorFromString("drawingFrame")),
|
|
129
|
+
let drawing = (content.value(forKey: "drawingFrame") as? NSValue)?.cgRectValue
|
|
130
|
+
{
|
|
131
|
+
return drawing.offsetBy(dx: content.frame.minX, dy: content.frame.minY)
|
|
132
|
+
}
|
|
133
|
+
return content.frame
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
func annotate(key: String, quote: String, before: String, after: String) {
|
|
137
|
+
emit?(["sourceKey": key, "quote": quote, "before": before, "after": after])
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
var canAnnotate: Bool { emit != nil && annotateTitle != nil }
|
|
141
|
+
|
|
142
|
+
// MARK: Quote anchors
|
|
143
|
+
|
|
144
|
+
func quoteAnchors(sourceKey: String, queries: [[String: String]]) -> [[String: Any]] {
|
|
145
|
+
let none = queries.map { _ in [String: Any]() }
|
|
146
|
+
guard let source = Self.sourceView(identifier: Self.sourcePrefix + sourceKey) else { return none }
|
|
147
|
+
let paragraphs = Self.paragraphs(in: source).compactMap { paragraph -> MeasuredParagraph? in
|
|
148
|
+
guard let text = Self.attributedText(of: paragraph), text.length > 0,
|
|
149
|
+
let frame = Self.textFrame(of: paragraph), frame.width > 0, frame.height > 0
|
|
150
|
+
else { return nil }
|
|
151
|
+
return MeasuredParagraph(view: paragraph, text: text, frame: frame)
|
|
152
|
+
}
|
|
153
|
+
return queries.map { query in
|
|
154
|
+
guard let quote = query["quote"].map(Self.trimmedQuote), !quote.isEmpty else { return [:] }
|
|
155
|
+
var best: (paragraph: MeasuredParagraph, range: NSRange, score: Int)?
|
|
156
|
+
for paragraph in paragraphs {
|
|
157
|
+
let string = paragraph.text.string as NSString
|
|
158
|
+
var search = NSRange(location: 0, length: string.length)
|
|
159
|
+
while search.length > 0 {
|
|
160
|
+
let found = string.range(of: quote, options: [], range: search)
|
|
161
|
+
if found.location == NSNotFound { break }
|
|
162
|
+
let score =
|
|
163
|
+
Self.commonSuffix(string.substring(to: found.location), query["before"] ?? "")
|
|
164
|
+
+ Self.commonPrefix(string.substring(from: NSMaxRange(found)), query["after"] ?? "")
|
|
165
|
+
if best == nil || score > best!.score { best = (paragraph, found, score) }
|
|
166
|
+
let next = found.location + 1
|
|
167
|
+
search = NSRange(location: next, length: string.length - next)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
guard let best else { return [:] }
|
|
171
|
+
return best.paragraph.anchor(for: best.range, in: source) ?? [:]
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/// A quote's visible text: surrounding whitespace has no glyph to anchor to.
|
|
176
|
+
private static func trimmedQuote(_ quote: String) -> String {
|
|
177
|
+
quote.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private static func commonSuffix(_ a: String, _ b: String) -> Int {
|
|
181
|
+
zip(a.reversed(), b.reversed()).prefix { $0 == $1 }.count
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private static func commonPrefix(_ a: String, _ b: String) -> Int {
|
|
185
|
+
zip(a, b).prefix { $0 == $1 }.count
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private static func sourceView(identifier: String) -> UIView? {
|
|
189
|
+
let windows = UIApplication.shared.connectedScenes
|
|
190
|
+
.compactMap { $0 as? UIWindowScene }
|
|
191
|
+
.flatMap(\.windows)
|
|
192
|
+
for window in windows {
|
|
193
|
+
if let view = find(in: window, where: { $0.accessibilityIdentifier == identifier }) {
|
|
194
|
+
return view
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return nil
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private static func find(in view: UIView, where match: (UIView) -> Bool) -> UIView? {
|
|
201
|
+
if match(view) { return view }
|
|
202
|
+
for child in view.subviews {
|
|
203
|
+
if let found = find(in: child, where: match) { return found }
|
|
204
|
+
}
|
|
205
|
+
return nil
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/// The paragraphs drawn inside a source, in document order.
|
|
209
|
+
private static func paragraphs(in view: UIView) -> [UIView] {
|
|
210
|
+
guard let cls = NSClassFromString("RCTParagraphComponentView") else { return [] }
|
|
211
|
+
var found: [UIView] = []
|
|
212
|
+
func walk(_ current: UIView) {
|
|
213
|
+
if current.isHidden { return }
|
|
214
|
+
if current.isKind(of: cls) {
|
|
215
|
+
found.append(current)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
current.subviews.forEach(walk)
|
|
219
|
+
}
|
|
220
|
+
walk(view)
|
|
221
|
+
return found
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/// A paragraph laid out the way React Native drew it, for measuring.
|
|
226
|
+
private struct MeasuredParagraph {
|
|
227
|
+
let view: UIView
|
|
228
|
+
let text: NSAttributedString
|
|
229
|
+
let frame: CGRect
|
|
230
|
+
|
|
231
|
+
func anchor(for range: NSRange, in source: UIView) -> [String: Any]? {
|
|
232
|
+
let end = NSMaxRange(range)
|
|
233
|
+
guard range.length > 0 else { return nil }
|
|
234
|
+
let layout = OpenGeniTextLayout(text: text, size: frame.size)
|
|
235
|
+
let manager = layout.manager
|
|
236
|
+
let storage = layout.storage
|
|
237
|
+
let glyphs = manager.glyphRange(
|
|
238
|
+
forCharacterRange: NSRange(location: end - 1, length: 1), actualCharacterRange: nil)
|
|
239
|
+
guard glyphs.length > 0 else { return nil }
|
|
240
|
+
let box = manager.boundingRect(forGlyphRange: glyphs, in: layout.container)
|
|
241
|
+
let (top, _) = textBox(glyph: glyphs.location, manager: manager, storage: storage)
|
|
242
|
+
let point = convert(CGPoint(x: box.maxX, y: top), to: source)
|
|
243
|
+
// One box per line, from the top of its text to the bottom of its descenders.
|
|
244
|
+
var rects: [Double] = []
|
|
245
|
+
let all = manager.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
|
|
246
|
+
manager.enumerateLineFragments(forGlyphRange: all) { _, _, _, lineGlyphs, _ in
|
|
247
|
+
let part = NSIntersectionRange(all, lineGlyphs)
|
|
248
|
+
guard part.length > 0 else { return }
|
|
249
|
+
let bounds = manager.boundingRect(forGlyphRange: part, in: layout.container)
|
|
250
|
+
let (lineTop, lineBottom) = self.textBox(
|
|
251
|
+
glyph: part.location, manager: manager, storage: storage)
|
|
252
|
+
let origin = self.convert(CGPoint(x: bounds.minX, y: lineTop), to: source)
|
|
253
|
+
rects += [
|
|
254
|
+
Double(origin.x), Double(origin.y), Double(bounds.width), Double(lineBottom - lineTop),
|
|
255
|
+
]
|
|
256
|
+
}
|
|
257
|
+
return ["x": Double(point.x), "top": Double(point.y), "rects": rects]
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// The top of the text and the bottom of its descenders on a glyph's line.
|
|
261
|
+
/// The glyph location already includes the baseline offset.
|
|
262
|
+
private func textBox(glyph: Int, manager: NSLayoutManager, storage: NSTextStorage)
|
|
263
|
+
-> (CGFloat, CGFloat)
|
|
264
|
+
{
|
|
265
|
+
let line = manager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil)
|
|
266
|
+
let baseline = line.minY + manager.location(forGlyphAt: glyph).y
|
|
267
|
+
let character = manager.characterIndexForGlyph(at: glyph)
|
|
268
|
+
let font = storage.attribute(.font, at: character, effectiveRange: nil) as? UIFont
|
|
269
|
+
let ascender = font?.ascender ?? line.height * 0.75
|
|
270
|
+
let descender = font?.descender ?? -line.height * 0.25
|
|
271
|
+
return (baseline - ascender, baseline - descender)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private func convert(_ point: CGPoint, to source: UIView) -> CGPoint {
|
|
275
|
+
view.convert(CGPoint(x: frame.minX + point.x, y: frame.minY + point.y), to: source)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/// TextKit 1 laid out like React Native lays out a paragraph: the same
|
|
280
|
+
/// attributed string, no line fragment padding, no font leading, clipping line
|
|
281
|
+
/// breaks and React Native's draw-time baseline offset.
|
|
282
|
+
final class OpenGeniTextLayout {
|
|
283
|
+
let storage: NSTextStorage
|
|
284
|
+
let manager: NSLayoutManager
|
|
285
|
+
let container: NSTextContainer
|
|
286
|
+
|
|
287
|
+
init(text: NSAttributedString, size: CGSize) {
|
|
288
|
+
storage = NSTextStorage(attributedString: Self.invisibleCopy(of: text))
|
|
289
|
+
manager = NSLayoutManager()
|
|
290
|
+
manager.usesFontLeading = false
|
|
291
|
+
storage.addLayoutManager(manager)
|
|
292
|
+
container = NSTextContainer(size: size)
|
|
293
|
+
container.lineFragmentPadding = 0
|
|
294
|
+
container.lineBreakMode = .byClipping
|
|
295
|
+
container.widthTracksTextView = false
|
|
296
|
+
container.heightTracksTextView = false
|
|
297
|
+
manager.addTextContainer(container)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/// React Native draws the text; this copy only carries layout, so every
|
|
301
|
+
/// drawn attribute is cleared. The baseline offset React Native applies at
|
|
302
|
+
/// draw time is applied here too (RCTApplyBaselineOffset).
|
|
303
|
+
static func invisibleCopy(of text: NSAttributedString) -> NSAttributedString {
|
|
304
|
+
let copy = NSMutableAttributedString(attributedString: text)
|
|
305
|
+
let all = NSRange(location: 0, length: copy.length)
|
|
306
|
+
for key: NSAttributedString.Key in [
|
|
307
|
+
.backgroundColor, .underlineStyle, .strikethroughStyle, .link, .shadow, .strokeColor,
|
|
308
|
+
] {
|
|
309
|
+
copy.removeAttribute(key, range: all)
|
|
310
|
+
}
|
|
311
|
+
copy.addAttribute(.foregroundColor, value: UIColor.clear, range: all)
|
|
312
|
+
var maximumLineHeight: CGFloat = 0
|
|
313
|
+
copy.enumerateAttribute(.paragraphStyle, in: all) { value, _, _ in
|
|
314
|
+
if let style = value as? NSParagraphStyle {
|
|
315
|
+
maximumLineHeight = max(maximumLineHeight, style.maximumLineHeight)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
guard maximumLineHeight > 0 else { return copy }
|
|
319
|
+
var maximumFontLineHeight: CGFloat = 0
|
|
320
|
+
copy.enumerateAttribute(.font, in: all) { value, _, _ in
|
|
321
|
+
if let font = value as? UIFont {
|
|
322
|
+
maximumFontLineHeight = max(maximumFontLineHeight, font.lineHeight)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
guard maximumLineHeight >= maximumFontLineHeight else { return copy }
|
|
326
|
+
copy.addAttribute(
|
|
327
|
+
.baselineOffset, value: (maximumLineHeight - maximumFontLineHeight) / 2, range: all)
|
|
328
|
+
return copy
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/// A transparent TextKit 1 text view laid out exactly like React Native lays
|
|
333
|
+
/// out the paragraph underneath (same attributed string, line fragment padding,
|
|
334
|
+
/// font leading and baseline offset), so the selection highlight and handles
|
|
335
|
+
/// sit on the glyphs React Native drew. It exists only while a selection does.
|
|
336
|
+
final class OpenGeniSelectionTextView: UITextView, UITextViewDelegate, UIGestureRecognizerDelegate {
|
|
337
|
+
weak var paragraph: UIView?
|
|
338
|
+
private let layout: OpenGeniTextLayout
|
|
339
|
+
private var storage: NSTextStorage { layout.storage }
|
|
340
|
+
private let sourceKey: String
|
|
341
|
+
private let snapshot: String
|
|
342
|
+
private var outsideTap: UITapGestureRecognizer?
|
|
343
|
+
private var watcher: Timer?
|
|
344
|
+
private var dismissed = false
|
|
345
|
+
/// Presents the menu after the long press. The text view's own edit menu
|
|
346
|
+
/// is private and only appears for its own gestures (dragging a handle).
|
|
347
|
+
private var editMenu: AnyObject?
|
|
348
|
+
|
|
349
|
+
init(paragraph: UIView, text: NSAttributedString, frame: CGRect, sourceKey: String) {
|
|
350
|
+
self.paragraph = paragraph
|
|
351
|
+
self.sourceKey = sourceKey
|
|
352
|
+
snapshot = text.string
|
|
353
|
+
layout = OpenGeniTextLayout(text: text, size: frame.size)
|
|
354
|
+
super.init(frame: frame, textContainer: layout.container)
|
|
355
|
+
backgroundColor = .clear
|
|
356
|
+
isEditable = false
|
|
357
|
+
isSelectable = true
|
|
358
|
+
isScrollEnabled = false
|
|
359
|
+
textContainerInset = .zero
|
|
360
|
+
dataDetectorTypes = []
|
|
361
|
+
clipsToBounds = false
|
|
362
|
+
delegate = self
|
|
363
|
+
accessibilityElementsHidden = true
|
|
364
|
+
if #available(iOS 16.0, *) {
|
|
365
|
+
let interaction = UIEditMenuInteraction(delegate: self)
|
|
366
|
+
addInteraction(interaction)
|
|
367
|
+
editMenu = interaction
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
@available(*, unavailable)
|
|
372
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
|
373
|
+
|
|
374
|
+
/// Selects the word at `point`; the edit menu follows when the finger lifts.
|
|
375
|
+
func begin(at point: CGPoint) {
|
|
376
|
+
_ = becomeFirstResponder()
|
|
377
|
+
selectedRange = wordRange(at: point)
|
|
378
|
+
installOutsideTap()
|
|
379
|
+
// A streaming message or a recycled view changes the text underneath;
|
|
380
|
+
// the selection then no longer matches what is drawn.
|
|
381
|
+
watcher = Timer.scheduledTimer(withTimeInterval: 0.35, repeats: true) { [weak self] _ in
|
|
382
|
+
guard let self else { return }
|
|
383
|
+
let current = self.paragraph.flatMap(OpenGeniTextSelection.attributedText(of:))?.string
|
|
384
|
+
if self.window == nil || current != self.snapshot { self.dismiss() }
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private func wordRange(at point: CGPoint) -> NSRange {
|
|
389
|
+
let string = storage.string as NSString
|
|
390
|
+
guard string.length > 0 else { return NSRange(location: 0, length: 0) }
|
|
391
|
+
var fraction: CGFloat = 0
|
|
392
|
+
let index = min(
|
|
393
|
+
layoutManager.characterIndex(
|
|
394
|
+
for: point, in: textContainer, fractionOfDistanceBetweenInsertionPoints: &fraction),
|
|
395
|
+
string.length - 1)
|
|
396
|
+
var found: NSRange?
|
|
397
|
+
var nearest: NSRange?
|
|
398
|
+
string.enumerateSubstrings(
|
|
399
|
+
in: NSRange(location: 0, length: string.length), options: .byWords
|
|
400
|
+
) { _, range, _, stop in
|
|
401
|
+
if NSLocationInRange(index, range) {
|
|
402
|
+
found = range
|
|
403
|
+
stop.pointee = true
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
if range.location > index {
|
|
407
|
+
if nearest == nil { nearest = range }
|
|
408
|
+
stop.pointee = true
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
nearest = range
|
|
412
|
+
}
|
|
413
|
+
if let range = found ?? nearest { return range }
|
|
414
|
+
return NSRange(location: 0, length: string.length)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
func showMenu() {
|
|
418
|
+
guard !dismissed, selectedRange.length > 0, let range = selectedTextRange else { return }
|
|
419
|
+
let rect = firstRect(for: range)
|
|
420
|
+
if #available(iOS 16.0, *), let interaction = editMenu as? UIEditMenuInteraction {
|
|
421
|
+
let configuration = UIEditMenuConfiguration(
|
|
422
|
+
identifier: nil, sourcePoint: CGPoint(x: rect.midX, y: rect.minY))
|
|
423
|
+
// Above the selection, like the system text views, unless it lacks room.
|
|
424
|
+
configuration.preferredArrowDirection = .down
|
|
425
|
+
interaction.presentEditMenu(with: configuration)
|
|
426
|
+
} else {
|
|
427
|
+
UIMenuController.shared.showMenu(from: self, rect: rect)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private func installOutsideTap() {
|
|
432
|
+
guard let window else { return }
|
|
433
|
+
let tap = UITapGestureRecognizer(target: self, action: #selector(outsideTapped))
|
|
434
|
+
tap.cancelsTouchesInView = false
|
|
435
|
+
tap.delegate = self
|
|
436
|
+
window.addGestureRecognizer(tap)
|
|
437
|
+
outsideTap = tap
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
@objc private func outsideTapped() { dismiss() }
|
|
441
|
+
|
|
442
|
+
func gestureRecognizer(
|
|
443
|
+
_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch
|
|
444
|
+
) -> Bool {
|
|
445
|
+
guard gestureRecognizer === outsideTap else { return true }
|
|
446
|
+
guard let view = touch.view else { return true }
|
|
447
|
+
// Taps on the selection or its edit menu keep the selection; the menu's
|
|
448
|
+
// actions (Copy, Add note) run after this recognizer.
|
|
449
|
+
return !view.isDescendant(of: self) && !Self.isEditMenu(view)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private static func isEditMenu(_ view: UIView) -> Bool {
|
|
453
|
+
var current: UIView? = view
|
|
454
|
+
while let candidate = current {
|
|
455
|
+
let name = NSStringFromClass(type(of: candidate))
|
|
456
|
+
if name.contains("EditMenu") || name.contains("Callout") || name.contains("ContextMenu") {
|
|
457
|
+
return true
|
|
458
|
+
}
|
|
459
|
+
current = candidate.superview
|
|
460
|
+
}
|
|
461
|
+
return false
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
func gestureRecognizer(
|
|
465
|
+
_ gestureRecognizer: UIGestureRecognizer,
|
|
466
|
+
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
|
|
467
|
+
) -> Bool {
|
|
468
|
+
gestureRecognizer === outsideTap
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
func dismiss() {
|
|
472
|
+
guard !dismissed else { return }
|
|
473
|
+
dismissed = true
|
|
474
|
+
watcher?.invalidate()
|
|
475
|
+
watcher = nil
|
|
476
|
+
if let outsideTap { outsideTap.view?.removeGestureRecognizer(outsideTap) }
|
|
477
|
+
outsideTap = nil
|
|
478
|
+
selectedRange = NSRange(location: 0, length: 0)
|
|
479
|
+
if isFirstResponder { _ = super.resignFirstResponder() }
|
|
480
|
+
removeFromSuperview()
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
override func resignFirstResponder() -> Bool {
|
|
484
|
+
let resigned = super.resignFirstResponder()
|
|
485
|
+
if resigned { DispatchQueue.main.async { [weak self] in self?.dismiss() } }
|
|
486
|
+
return resigned
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
func textViewDidChangeSelection(_ textView: UITextView) {
|
|
490
|
+
guard !dismissed, selectedRange.length == 0 else { return }
|
|
491
|
+
DispatchQueue.main.async { [weak self] in
|
|
492
|
+
guard let self, self.selectedRange.length == 0 else { return }
|
|
493
|
+
self.dismiss()
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private var selectedText: String {
|
|
498
|
+
(storage.string as NSString).substring(with: selectedRange)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/// The copy carries invisible colors; copy what React Native shows instead.
|
|
502
|
+
override func copy(_ sender: Any?) {
|
|
503
|
+
guard selectedRange.length > 0 else { return }
|
|
504
|
+
UIPasteboard.general.string = selectedText
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
@available(iOS 16.0, *)
|
|
508
|
+
func textView(
|
|
509
|
+
_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]
|
|
510
|
+
) -> UIMenu? {
|
|
511
|
+
menu(with: suggestedActions)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/// The system actions, led by Add note where this text can be annotated.
|
|
515
|
+
fileprivate func menu(with suggestedActions: [UIMenuElement]) -> UIMenu {
|
|
516
|
+
let selection = OpenGeniTextSelection.shared
|
|
517
|
+
guard !sourceKey.isEmpty, selection.canAnnotate, let title = selection.annotateTitle else {
|
|
518
|
+
return UIMenu(children: suggestedActions)
|
|
519
|
+
}
|
|
520
|
+
// The quote is read now: the action runs after the menu closes, when the
|
|
521
|
+
// selection may already be gone.
|
|
522
|
+
guard let quote = selectedQuote() else { return UIMenu(children: suggestedActions) }
|
|
523
|
+
let key = sourceKey
|
|
524
|
+
let annotate = UIAction(title: title, image: UIImage(systemName: "text.quote")) {
|
|
525
|
+
[weak self] _ in
|
|
526
|
+
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
527
|
+
OpenGeniTextSelection.shared.annotate(
|
|
528
|
+
key: key, quote: quote.quote, before: quote.before, after: quote.after)
|
|
529
|
+
self?.dismiss()
|
|
530
|
+
}
|
|
531
|
+
return UIMenu(children: [annotate] + suggestedActions)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private func selectedQuote() -> (quote: String, before: String, after: String)? {
|
|
535
|
+
let string = storage.string as NSString
|
|
536
|
+
let range = selectedRange
|
|
537
|
+
guard range.length > 0, NSMaxRange(range) <= string.length else { return nil }
|
|
538
|
+
return (
|
|
539
|
+
string.substring(with: range), string.substring(to: range.location),
|
|
540
|
+
string.substring(from: NSMaxRange(range))
|
|
541
|
+
)
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
@available(iOS 16.0, *)
|
|
546
|
+
extension OpenGeniSelectionTextView: UIEditMenuInteractionDelegate {
|
|
547
|
+
func editMenuInteraction(
|
|
548
|
+
_ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration,
|
|
549
|
+
suggestedActions: [UIMenuElement]
|
|
550
|
+
) -> UIMenu? {
|
|
551
|
+
menu(with: suggestedActions)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
func editMenuInteraction(
|
|
555
|
+
_ interaction: UIEditMenuInteraction, targetRectFor configuration: UIEditMenuConfiguration
|
|
556
|
+
) -> CGRect {
|
|
557
|
+
guard let range = selectedTextRange else { return .null }
|
|
558
|
+
return firstRect(for: range)
|
|
559
|
+
}
|
|
560
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/react-native",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.1",
|
|
4
4
|
"description": "React Native and Expo kit for Opengeni agents: the native session timeline, composer adapters, voice dictation, and realtime voice calls (CallKit) on the same SDK controllers as the web.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
"dist",
|
|
13
13
|
"src",
|
|
14
14
|
"ios",
|
|
15
|
+
"android/build.gradle",
|
|
16
|
+
"android/src",
|
|
15
17
|
"expo-module.config.json",
|
|
16
18
|
"README.md",
|
|
17
19
|
"app.plugin.js",
|
|
@@ -66,9 +68,9 @@
|
|
|
66
68
|
"prepublishOnly": "bash ../../scripts/prepublish-guard"
|
|
67
69
|
},
|
|
68
70
|
"dependencies": {
|
|
69
|
-
"@opengeni/contracts": "4.
|
|
70
|
-
"@opengeni/react": "4.
|
|
71
|
-
"@opengeni/sdk": "4.
|
|
71
|
+
"@opengeni/contracts": "4.3.1",
|
|
72
|
+
"@opengeni/react": "4.3.1",
|
|
73
|
+
"@opengeni/sdk": "4.3.1",
|
|
72
74
|
"thinking-orbs": "^0.3.1"
|
|
73
75
|
},
|
|
74
76
|
"peerDependencies": {
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
} from "./primitives";
|
|
41
41
|
import { fontStyle, useNativeTimelineTheme } from "./theme";
|
|
42
42
|
import { useNativeTimelineMessages } from "./messages";
|
|
43
|
+
import { annotationSourceTestId } from "./text-selection";
|
|
43
44
|
|
|
44
45
|
/* ----------------------------------------------------------------------------
|
|
45
46
|
Native activity rows. Tool rows draw from the shared presentation model
|
|
@@ -519,20 +520,23 @@ function ReasoningRow({ item, compact }: { item: ReasoningItem; compact: boolean
|
|
|
519
520
|
accessibilityLabel={item.streaming ? m.thinking : m.thought}
|
|
520
521
|
preview={plain || undefined}
|
|
521
522
|
>
|
|
522
|
-
{
|
|
523
|
-
options.renderMarkdown
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
523
|
+
<View testID={annotationSourceTestId(undefined)} collapsable={false}>
|
|
524
|
+
{options.renderMarkdown ? (
|
|
525
|
+
options.renderMarkdown(item.text, { tone: "muted", streaming: item.streaming })
|
|
526
|
+
) : (
|
|
527
|
+
<Text
|
|
528
|
+
selectable
|
|
529
|
+
style={{
|
|
530
|
+
...fontStyle(theme),
|
|
531
|
+
fontSize: theme.size.base,
|
|
532
|
+
lineHeight: 24,
|
|
533
|
+
color: theme.colors["fg-muted"],
|
|
534
|
+
}}
|
|
535
|
+
>
|
|
536
|
+
{item.text}
|
|
537
|
+
</Text>
|
|
538
|
+
)}
|
|
539
|
+
</View>
|
|
536
540
|
</ActivityDisclosure>
|
|
537
541
|
);
|
|
538
542
|
}
|
|
@@ -717,11 +721,16 @@ function ActivityNoteRow({ item }: { item: AgentMessageItem }) {
|
|
|
717
721
|
<View style={{ marginTop: 4 }}>
|
|
718
722
|
<Icon name="message-square-text" size={14} color={theme.colors["fg-subtle"]} />
|
|
719
723
|
</View>
|
|
720
|
-
<View
|
|
724
|
+
<View
|
|
725
|
+
testID={annotationSourceTestId(item.annotationSource?.eventId)}
|
|
726
|
+
collapsable={false}
|
|
727
|
+
style={{ flex: 1, minWidth: 0 }}
|
|
728
|
+
>
|
|
721
729
|
{options.renderMarkdown ? (
|
|
722
730
|
options.renderMarkdown(item.text, { tone: "muted", streaming: item.streaming })
|
|
723
731
|
) : (
|
|
724
732
|
<Text
|
|
733
|
+
selectable
|
|
725
734
|
style={{
|
|
726
735
|
...fontStyle(theme),
|
|
727
736
|
fontSize: theme.size.sm,
|