@swiftbrowser/native 0.17.7 → 0.18.0

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.
Files changed (100) hide show
  1. package/README.md +99 -11
  2. package/dist/blob.d.ts +60 -0
  3. package/dist/blob.d.ts.map +1 -0
  4. package/dist/blob.js +153 -0
  5. package/dist/blob.js.map +1 -0
  6. package/dist/bridge.d.ts +65 -1
  7. package/dist/bridge.d.ts.map +1 -1
  8. package/dist/bridge.js.map +1 -1
  9. package/dist/fetch.d.ts +11 -3
  10. package/dist/fetch.d.ts.map +1 -1
  11. package/dist/fetch.js +26 -2
  12. package/dist/fetch.js.map +1 -1
  13. package/dist/hostRequests.d.ts +55 -0
  14. package/dist/hostRequests.d.ts.map +1 -0
  15. package/dist/hostRequests.js +196 -0
  16. package/dist/hostRequests.js.map +1 -0
  17. package/dist/index.d.ts +8 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +8 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/matchMedia.d.ts +43 -0
  22. package/dist/matchMedia.d.ts.map +1 -0
  23. package/dist/matchMedia.js +63 -0
  24. package/dist/matchMedia.js.map +1 -0
  25. package/dist/notifications.d.ts +108 -0
  26. package/dist/notifications.d.ts.map +1 -0
  27. package/dist/notifications.js +251 -0
  28. package/dist/notifications.js.map +1 -0
  29. package/dist/run.d.ts.map +1 -1
  30. package/dist/run.js +23 -2
  31. package/dist/run.js.map +1 -1
  32. package/dist/runtime.d.ts +2 -0
  33. package/dist/runtime.d.ts.map +1 -1
  34. package/dist/runtime.js +69 -0
  35. package/dist/runtime.js.map +1 -1
  36. package/dist/secureStorage.d.ts +20 -0
  37. package/dist/secureStorage.d.ts.map +1 -0
  38. package/dist/secureStorage.js +28 -0
  39. package/dist/secureStorage.js.map +1 -0
  40. package/dist/site.d.ts +3 -3
  41. package/dist/site.d.ts.map +1 -1
  42. package/dist/site.js +1 -1
  43. package/dist/site.js.map +1 -1
  44. package/dist/subtle.d.ts +38 -0
  45. package/dist/subtle.d.ts.map +1 -0
  46. package/dist/subtle.js +72 -0
  47. package/dist/subtle.js.map +1 -0
  48. package/dist/undo.d.ts +23 -0
  49. package/dist/undo.d.ts.map +1 -0
  50. package/dist/undo.js +44 -0
  51. package/dist/undo.js.map +1 -0
  52. package/dist/webSocket.d.ts +73 -0
  53. package/dist/webSocket.d.ts.map +1 -0
  54. package/dist/webSocket.js +145 -0
  55. package/dist/webSocket.js.map +1 -0
  56. package/package.json +3 -3
  57. package/src/blob.ts +186 -0
  58. package/src/bridge.ts +74 -1
  59. package/src/fetch.ts +29 -3
  60. package/src/hostRequests.ts +216 -0
  61. package/src/index.ts +8 -0
  62. package/src/matchMedia.ts +79 -0
  63. package/src/notifications.ts +317 -0
  64. package/src/run.ts +21 -2
  65. package/src/runtime.ts +71 -0
  66. package/src/secureStorage.ts +39 -0
  67. package/src/site.ts +3 -3
  68. package/src/subtle.ts +87 -0
  69. package/src/undo.ts +47 -0
  70. package/src/webSocket.ts +175 -0
  71. package/swift/Sources/SwiftBrowserNative/Host/AudioRecorderBridge.swift +199 -0
  72. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +154 -0
  73. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +211 -0
  74. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +451 -0
  75. package/swift/Sources/SwiftBrowserNative/Host/NotificationBridge.swift +304 -0
  76. package/swift/Sources/SwiftBrowserNative/Host/SecureStorage.swift +60 -0
  77. package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +1 -0
  78. package/swift/Sources/SwiftBrowserNative/Host/TextMeasureBridge.swift +139 -0
  79. package/swift/Sources/SwiftBrowserNative/Host/UndoBridge.swift +170 -0
  80. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +118 -0
  81. package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +56 -0
  82. package/swift/Sources/SwiftBrowserNative/Runtime/NativeAppDelegate.swift +36 -0
  83. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +362 -16
  84. package/swift/Sources/SwiftBrowserNative/Views/Kinds/DeleteBackward.swift +147 -0
  85. package/swift/Sources/SwiftBrowserNative/Views/Kinds/KeyboardBar.swift +84 -0
  86. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +116 -11
  87. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +12 -1
  88. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Shapes.swift +30 -9
  89. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +163 -10
  90. package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextAndImages.swift +17 -4
  91. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +8 -3
  92. package/swift/Sources/SwiftBrowserNative/Views/NativeRootView.swift +8 -0
  93. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +41 -0
  94. package/swift/Sources/SwiftBrowserProtocol/Op.swift +27 -4
  95. package/swift/Sources/SwiftBrowserProtocol/Props.swift +110 -3
  96. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +47 -0
  97. package/swift/Sources/SwiftBrowserProtocol/SVGPath.swift +260 -0
  98. package/swift/Sources/SwiftBrowserProtocol/Style.swift +58 -0
  99. package/swift/Sources/SwiftBrowserTree/OpTree.swift +22 -2
  100. package/wasm/SwiftBrowserNative.wasm +2 -2
@@ -45,9 +45,10 @@ struct ButtonElement: View {
45
45
  }
46
46
  }()
47
47
  switch props.style {
48
- // `.borderless` reaches the protocol as `plain` too; outside a list,
49
- // where a plain-looking text button is tinted on iOS, draw it so.
50
- case "plain": node.ancestor(ofKind: "list") != nil ? AnyView(sized.buttonStyle(.plain)) : AnyView(sized.buttonStyle(.borderless))
48
+ // `plain` is the label in the foreground around it, as the page draws
49
+ // it (Numbers' document cards); `borderless` the tinted text button.
50
+ case "plain": sized.buttonStyle(.plain)
51
+ case "borderless": sized.buttonStyle(.borderless)
51
52
  case "bordered": sized.buttonStyle(.bordered)
52
53
  case "borderedProminent", "glass": sized.buttonStyle(.borderedProminent)
53
54
  default: sized.buttonStyle(.automatic)
@@ -62,28 +63,54 @@ struct ToggleElement: View {
62
63
  @Environment(\.nativeApp) private var app
63
64
 
64
65
  var body: some View {
65
- Toggle(isOn: Binding(
66
+ let toggle = Toggle(isOn: Binding(
66
67
  get: { ToggleProps(props: node.props).isOn },
67
68
  set: { app?.dispatch(.toggle(node.id, $0)) }
68
69
  )) {
69
70
  ChildrenView(node: node)
70
71
  }
72
+ switch ToggleProps(props: node.props).style {
73
+ case "button": AnyView(toggle.toggleStyle(.button))
74
+ case "switch": AnyView(toggle.toggleStyle(.switch))
75
+ default: AnyView(toggle)
76
+ }
71
77
  }
72
78
  }
73
79
 
74
- /// `textfield`: a controlled field. Each keystroke sends `text`; the app's
75
- /// state is the truth the getter reads back, with no frame in between
76
- /// because the dispatch is synchronous.
80
+ /// The `text` event for a write to a field's binding, or nil when the
81
+ /// field's text already is `value`. SwiftUI writes a text binding on a focus
82
+ /// and a commit with the text it has, not only on a keystroke; the page's
83
+ /// renderer sends `text` only for input, and an app that takes every `text`
84
+ /// as an edit (Amindia's Discard button, appearing on a tap into a field)
85
+ /// must hear only changes natively too.
86
+ func textChange(of node: ElementNode, to value: String) -> RendererEvent? {
87
+ (node.props.string("text") ?? "") == value ? nil : .text(node.id, value)
88
+ }
89
+
90
+ /// `textfield`: a controlled field. Each keystroke sends `text` (a write of
91
+ /// the text the field has sends nothing, `textChange`); the app's state is
92
+ /// the truth the getter reads back, with no frame in between because the
93
+ /// dispatch is synchronous. So is `submit`: Return dismisses the keyboard
94
+ /// after `onSubmit`, and the app's answer decides whether it may. A bound
95
+ /// `focused` the answer leaves true is held (`keeping`): the resign that
96
+ /// follows is taken back, not reported; one it turned false lets the
97
+ /// resign through; another field's turned true takes the focus in its own
98
+ /// `onChange` of the prop.
77
99
  struct TextFieldElement: View {
78
100
  let node: ElementNode
79
101
  @Environment(\.nativeApp) private var app
80
102
  @FocusState private var focused: Bool
103
+ /// Set by a submit the app answered keeping the field focused, until the resign it holds against comes (or would have).
104
+ @State private var keeping = false
105
+
106
+ /// How long after a submit a resign is Return's: SwiftUI's follows within the turn; macOS sends none.
107
+ private static let resignWindow: Duration = .milliseconds(300)
81
108
 
82
109
  var body: some View {
83
110
  let props = TextFieldProps(props: node.props)
84
111
  let text = Binding(
85
112
  get: { TextFieldProps(props: node.props).text },
86
- set: { app?.dispatch(.text(node.id, $0)) }
113
+ set: { if let event = textChange(of: node, to: $0) { app?.dispatch(event) } }
87
114
  )
88
115
  let field: AnyView = {
89
116
  if props.secure { return AnyView(SecureField(props.placeholder, text: text)) }
@@ -92,8 +119,16 @@ struct TextFieldElement: View {
92
119
  }()
93
120
  var styled = props.style == "roundedBorder" ? AnyView(field.textFieldStyle(.roundedBorder)) : AnyView(field.textFieldStyle(.plain))
94
121
  // Only what the app set: each of these is a trait of the field to SwiftUI.
95
- if props.submit { styled = AnyView(styled.onSubmit { app?.dispatch(.submit(node.id)) }) }
122
+ if props.submit {
123
+ styled = AnyView(styled.onSubmit {
124
+ app?.dispatch(.submit(node.id))
125
+ // The app's pass applied: a `focused` it left or set true wins over the resign on its way.
126
+ if TextFieldProps(props: node.props).focused == true { keep() }
127
+ })
128
+ }
96
129
  if let label = props.submitLabel { styled = AnyView(styled.submitLabel(SubmitLabel(name: label))) }
130
+ // Backspace at the start of the text (DeleteBackward.swift).
131
+ if props.deleteBackward { styled = AnyView(styled.onDeleteBackward(true) { app?.dispatch(.deleteBackward(node.id)) }) }
97
132
  if props.focused != nil {
98
133
  styled = AnyView(styled
99
134
  .focused($focused)
@@ -101,12 +136,29 @@ struct TextFieldElement: View {
101
136
  if let wanted, wanted != focused { focused = wanted }
102
137
  }
103
138
  .onChange(of: focused) { _, now in
104
- if props.focused != now { app?.dispatch(.focus(node.id, now)) }
139
+ if now {
140
+ keeping = false
141
+ } else if keeping {
142
+ // Return's resign of a field the app keeps: take the focus back and say nothing.
143
+ keeping = false
144
+ focused = true
145
+ return
146
+ }
147
+ if TextFieldProps(props: node.props).focused != now { app?.dispatch(.focus(node.id, now)) }
105
148
  }
106
149
  .onAppear { if props.focused == true { focused = true } })
107
150
  }
108
151
  return styled
109
152
  }
153
+
154
+ /// Holds the focus against the resign that follows Return, for the moment one can come.
155
+ private func keep() {
156
+ keeping = true
157
+ Task { @MainActor in
158
+ try? await Task.sleep(for: Self.resignWindow)
159
+ keeping = false
160
+ }
161
+ }
110
162
  }
111
163
 
112
164
  extension SubmitLabel {
@@ -125,19 +177,311 @@ extension SubmitLabel {
125
177
  }
126
178
  }
127
179
 
128
- /// `texteditor`: a multi-line controlled field.
180
+ /// `texteditor`: a multi-line controlled field. Each edit sends `text` (a
181
+ /// write of the text it has sends nothing, `textChange`). With `runs` it is
182
+ /// the rich editor: on iOS 26 a `TextEditor` of the runs as an
183
+ /// `AttributedString` (`RichTextEditorElement`), earlier the plain editor of
184
+ /// the same text. A bound `focused` moves the focus both ways, as a field's.
129
185
  struct TextEditorElement: View {
130
186
  let node: ElementNode
131
187
  @Environment(\.nativeApp) private var app
188
+ @FocusState private var focused: Bool
132
189
 
133
190
  var body: some View {
134
- TextEditor(text: Binding(
191
+ let props = TextEditorProps(props: node.props)
192
+ var editor = editorView(props)
193
+ // Backspace at the start of the text (DeleteBackward.swift).
194
+ if props.deleteBackward { editor = AnyView(editor.onDeleteBackward(true) { app?.dispatch(.deleteBackward(node.id)) }) }
195
+ if props.focused == nil { return editor }
196
+ return AnyView(editor
197
+ .focused($focused)
198
+ .onChange(of: props.focused) { _, wanted in
199
+ if let wanted, wanted != focused { focused = wanted }
200
+ }
201
+ .onChange(of: focused) { _, now in
202
+ if TextEditorProps(props: node.props).focused != now { app?.dispatch(.focus(node.id, now)) }
203
+ }
204
+ .onAppear { if props.focused == true { focused = true } })
205
+ }
206
+
207
+ private func editorView(_ props: TextEditorProps) -> AnyView {
208
+ #if !canImport(FoundationBridge) // not in the shim
209
+ if props.rich {
210
+ if #available(iOS 26, macOS 26, *) { return AnyView(RichTextEditorElement(node: node)) }
211
+ } else if props.selection != nil {
212
+ if #available(iOS 18, macOS 15, *) { return AnyView(PlainTextEditorElement(node: node)) }
213
+ }
214
+ #endif
215
+ return AnyView(TextEditor(text: Binding(
135
216
  get: { node.props.string("text") ?? "" },
136
- set: { app?.dispatch(.text(node.id, $0)) }
137
- ))
217
+ set: { if let event = textChange(of: node, to: $0) { app?.dispatch(event) } }
218
+ )))
138
219
  }
139
220
  }
140
221
 
222
+ #if !canImport(FoundationBridge)
223
+ /// The plain `texteditor` with `selection` (iOS 18, `TextEditor(text:selection:)`):
224
+ /// the viewer's selection reported in UTF-16 offsets, and the caret moved
225
+ /// to one the app sets, as the rich editor does.
226
+ @available(iOS 18, macOS 15, *)
227
+ struct PlainTextEditorElement: View {
228
+ let node: ElementNode
229
+ @Environment(\.nativeApp) private var app
230
+ @State private var selection: TextSelection?
231
+ @State private var shown = Shown()
232
+
233
+ /// The selection last sent, or last moved to (a class: set while drawing).
234
+ final class Shown {
235
+ var sent: TextSelectionSpec?
236
+ }
237
+
238
+ var body: some View {
239
+ TextEditor(
240
+ text: Binding(
241
+ get: { node.props.string("text") ?? "" },
242
+ set: { if let event = textChange(of: node, to: $0) { app?.dispatch(event) } }
243
+ ),
244
+ selection: Binding(
245
+ get: { selection },
246
+ set: { next in
247
+ selection = next
248
+ guard let next, let range = offsets(of: next, in: node.props.string("text") ?? "") else { return }
249
+ if range != shown.sent {
250
+ shown.sent = range
251
+ app?.dispatch(.selection(node.id, start: range.start, end: range.end))
252
+ }
253
+ }
254
+ )
255
+ )
256
+ .onChange(of: TextEditorProps(props: node.props).selection) { _, wanted in move(to: wanted) }
257
+ .onAppear { move(to: TextEditorProps(props: node.props).selection) }
258
+ }
259
+
260
+ /// A selection the app set (not the one this editor sent): the caret goes there.
261
+ private func move(to wanted: TextSelectionSpec?) {
262
+ guard let wanted, wanted != shown.sent else { return }
263
+ shown.sent = wanted
264
+ let text = node.props.string("text") ?? ""
265
+ let start = stringIndex(utf16: wanted.start, in: text)
266
+ let end = stringIndex(utf16: max(wanted.start, wanted.end), in: text)
267
+ selection = start == end ? TextSelection(insertionPoint: start) : TextSelection(range: start..<end)
268
+ }
269
+
270
+ private func offsets(of selection: TextSelection, in text: String) -> TextSelectionSpec? {
271
+ switch selection.indices {
272
+ case .selection(let range):
273
+ return TextSelectionSpec(start: utf16Offset(range.lowerBound, in: text), end: utf16Offset(range.upperBound, in: text))
274
+ case .multiSelection(let ranges):
275
+ guard let first = ranges.ranges.first, let last = ranges.ranges.last else { return nil }
276
+ return TextSelectionSpec(start: utf16Offset(first.lowerBound, in: text), end: utf16Offset(last.upperBound, in: text))
277
+ @unknown default:
278
+ return nil
279
+ }
280
+ }
281
+
282
+ private func utf16Offset(_ index: String.Index, in text: String) -> Int {
283
+ guard index >= text.startIndex, index <= text.endIndex else { return 0 }
284
+ return text.utf16.distance(from: text.utf16.startIndex, to: index)
285
+ }
286
+ }
287
+
288
+ /// The index `offset` UTF-16 units into `text` (its end, past it; a character's start, inside one).
289
+ private func stringIndex(utf16 offset: Int, in text: String) -> String.Index {
290
+ let utf16 = text.utf16
291
+ let index = utf16.index(utf16.startIndex, offsetBy: min(max(0, offset), utf16.count))
292
+ return index.samePosition(in: text) ?? text.endIndex
293
+ }
294
+
295
+ /// The rich `texteditor` (iOS 26): the runs as an `AttributedString`, each
296
+ /// with its weight and slant over the editor's font (or its own size and
297
+ /// design, `font`), its lines, its color and its background color.
298
+ /// What the viewer types goes to the app as plain `text`; the app keeps the
299
+ /// runs (the typed text taking the attributes around it, as here). While
300
+ /// the app's runs are the ones this editor's string stands for, the editor
301
+ /// keeps its own string, so an edit's echo never resets the text under the
302
+ /// caret. With `selection` bound it reports the viewer's in UTF-16 offsets,
303
+ /// and moves the caret to one the app sets. An edit of the attributes alone
304
+ /// (the editor's own Bold, Italic and Underline, from the edit menu) goes
305
+ /// to the app as `runs`, the app's runs cut where the formatting changed.
306
+ @available(iOS 26, macOS 26, *)
307
+ struct RichTextEditorElement: View {
308
+ let node: ElementNode
309
+ @Environment(\.nativeApp) private var app
310
+ @Environment(\.font) private var font
311
+ @Environment(\.fontResolutionContext) private var fontContext
312
+ @State private var selection = AttributedTextSelection()
313
+ @State private var shown = Shown()
314
+
315
+ /// The string the editor holds, the runs it stands for, and the selection
316
+ /// last sent (a class: the getter brings it up to date while drawing).
317
+ final class Shown {
318
+ var runs: [TextRunSpec]?
319
+ var string = AttributedString()
320
+ var sent: TextSelectionSpec?
321
+ }
322
+
323
+ var body: some View {
324
+ TextEditor(
325
+ text: Binding(
326
+ get: { current() },
327
+ set: { edited in
328
+ shown.string = edited
329
+ let text = String(edited.characters)
330
+ if let event = textChange(of: node, to: text) {
331
+ app?.dispatch(event)
332
+ } else if let runs = formatting(of: edited) {
333
+ app?.dispatch(.runs(node.id, runs))
334
+ }
335
+ // The app answered (synchronously): its runs are this string's,
336
+ // unless it changed the text (a newline taken elsewhere).
337
+ let props = TextEditorProps(props: node.props)
338
+ shown.runs = props.text == text ? props.runs : nil
339
+ }
340
+ ),
341
+ selection: Binding(
342
+ get: { selection },
343
+ set: { next in
344
+ selection = next
345
+ guard TextEditorProps(props: node.props).selection != nil else { return }
346
+ let range = offsets(of: next, in: shown.string)
347
+ if range != shown.sent {
348
+ shown.sent = range
349
+ app?.dispatch(.selection(node.id, start: range.start, end: range.end))
350
+ }
351
+ }
352
+ )
353
+ )
354
+ .onChange(of: TextEditorProps(props: node.props).selection) { _, wanted in move(to: wanted) }
355
+ .onAppear { move(to: TextEditorProps(props: node.props).selection) }
356
+ }
357
+
358
+ /// A selection the app set (not the one this editor sent): the caret goes there.
359
+ private func move(to wanted: TextSelectionSpec?) {
360
+ guard let wanted, wanted != shown.sent else { return }
361
+ shown.sent = wanted
362
+ let text = current()
363
+ let start = index(at: wanted.start, in: text)
364
+ let end = index(at: max(wanted.start, wanted.end), in: text)
365
+ selection = AttributedTextSelection(range: start..<end)
366
+ }
367
+
368
+ /// The index `offset` UTF-16 units into the text (its end, past it).
369
+ private func index(at offset: Int, in text: AttributedString) -> AttributedString.Index {
370
+ var index = text.startIndex
371
+ var count = 0
372
+ while index < text.endIndex, count < offset {
373
+ count += String(text.characters[index]).utf16.count
374
+ index = text.characters.index(after: index)
375
+ }
376
+ return index
377
+ }
378
+
379
+ private func current() -> AttributedString {
380
+ let runs = TextEditorProps(props: node.props).runs ?? []
381
+ if runs != shown.runs {
382
+ shown.runs = runs
383
+ shown.string = attributed(runs)
384
+ }
385
+ return shown.string
386
+ }
387
+
388
+ private func attributed(_ runs: [TextRunSpec]) -> AttributedString {
389
+ var string = AttributedString()
390
+ let base = font ?? .body
391
+ for run in runs {
392
+ var piece = AttributedString(run.text ?? "")
393
+ var runFont: Font?
394
+ let weight = run.font?.weight.map(Font.Weight.init(name:))
395
+ let design = run.font?.design.map(Font.Design.init(name:))
396
+ if let size = run.font?.size {
397
+ // A size of its own: a system font of it, with the run's weight and design.
398
+ runFont = .system(size: size, weight: weight, design: design)
399
+ } else if let design {
400
+ // A design over the editor's font: at the size the editor's resolves to.
401
+ runFont = .system(size: base.resolve(in: fontContext).pointSize, weight: weight, design: design)
402
+ } else if let weight {
403
+ runFont = base.weight(weight)
404
+ }
405
+ if run.italic { runFont = (runFont ?? base).italic() }
406
+ if let runFont { piece.font = runFont }
407
+ if run.underline { piece.underlineStyle = Text.LineStyle.single }
408
+ if run.strikethrough { piece.strikethroughStyle = Text.LineStyle.single }
409
+ if case .color(let color)? = run.foreground { piece.foregroundColor = Color(spec: color) }
410
+ if let background = run.background { piece.backgroundColor = Color(spec: background) }
411
+ string.append(piece)
412
+ }
413
+ return string
414
+ }
415
+
416
+ /// The weights the editor shows as bold (`Font.Resolved.isBold`: semibold and up).
417
+ private static let boldWeights: Set<String> = ["semibold", "bold", "heavy", "black"]
418
+
419
+ /// The runs `edited` stands for when its characters are the app's and
420
+ /// its formatting is not (the editor's own Bold, Italic or Underline):
421
+ /// the app's runs (their JSON, so a color, a background, a font's size
422
+ /// and design or a link stay as sent) cut where the editor's formatting
423
+ /// changes, each cut with the weight, slant and lines the editor shows.
424
+ /// Nil while the formatting is the app's.
425
+ private func formatting(of edited: AttributedString) -> [JSONValue]? {
426
+ guard let appRuns = node.props.array("runs") else { return nil }
427
+ let base = font ?? .body
428
+ let shows = edited.runs.map { run -> (range: Range<Int>, bold: Bool, italic: Bool, underline: Bool, strikethrough: Bool) in
429
+ let resolved = (run.font ?? base).resolve(in: fontContext)
430
+ let range = utf16Offset(run.range.lowerBound, in: edited)..<utf16Offset(run.range.upperBound, in: edited)
431
+ return (range, resolved.isBold, resolved.isItalic, run.underlineStyle != nil, run.strikethroughStyle != nil)
432
+ }
433
+ var out: [JSONValue] = []
434
+ var changed = false
435
+ var offset = 0
436
+ for json in appRuns {
437
+ guard let fields = json.object, let spec = TextRunSpec(json: json) else { continue }
438
+ let text = spec.text ?? ""
439
+ let range = offset..<(offset + text.utf16.count)
440
+ offset = range.upperBound
441
+ let bold = Self.boldWeights.contains(spec.font?.weight ?? "")
442
+ for show in shows {
443
+ let lower = max(show.range.lowerBound, range.lowerBound)
444
+ let upper = min(show.range.upperBound, range.upperBound)
445
+ guard lower < upper else { continue }
446
+ var cut = fields
447
+ cut["text"] = .string(String(text[stringIndex(utf16: lower - range.lowerBound, in: text)..<stringIndex(utf16: upper - range.lowerBound, in: text)]))
448
+ if show.bold != bold {
449
+ var fontFields = fields.object("font") ?? [:]
450
+ fontFields["weight"] = show.bold ? JSONValue.string("bold") : nil
451
+ cut["font"] = fontFields.isEmpty ? nil : JSONValue.object(fontFields)
452
+ }
453
+ if show.italic != spec.italic { cut["italic"] = show.italic ? JSONValue.bool(true) : nil }
454
+ if show.underline != spec.underline { cut["underline"] = show.underline ? JSONValue.object([:]) : nil }
455
+ if show.strikethrough != spec.strikethrough { cut["strikethrough"] = show.strikethrough ? JSONValue.object([:]) : nil }
456
+ if show.bold != bold || show.italic != spec.italic || show.underline != spec.underline || show.strikethrough != spec.strikethrough {
457
+ changed = true
458
+ }
459
+ out.append(.object(cut))
460
+ }
461
+ }
462
+ return changed ? out : nil
463
+ }
464
+
465
+ private func offsets(of selection: AttributedTextSelection, in text: AttributedString) -> TextSelectionSpec {
466
+ switch selection.indices(in: text) {
467
+ case .insertionPoint(let index):
468
+ let offset = utf16Offset(index, in: text)
469
+ return TextSelectionSpec(start: offset, end: offset)
470
+ case .ranges(let ranges):
471
+ guard let first = ranges.ranges.first, let last = ranges.ranges.last else { return TextSelectionSpec(start: 0, end: 0) }
472
+ return TextSelectionSpec(start: utf16Offset(first.lowerBound, in: text), end: utf16Offset(last.upperBound, in: text))
473
+ @unknown default:
474
+ return TextSelectionSpec(start: 0, end: 0)
475
+ }
476
+ }
477
+
478
+ private func utf16Offset(_ index: AttributedString.Index, in text: AttributedString) -> Int {
479
+ guard index >= text.startIndex, index <= text.endIndex else { return 0 }
480
+ return String(text[text.startIndex..<index].characters).utf16.count
481
+ }
482
+ }
483
+ #endif
484
+
141
485
  /// `picker`: the options are strings; choosing sends `select` with the index.
142
486
  struct PickerElement: View {
143
487
  let node: ElementNode
@@ -220,12 +564,14 @@ struct TapGestureElement: View {
220
564
  var body: some View {
221
565
  let props = TapGestureProps(props: node.props)
222
566
  let content = ChildrenView(node: node).contentShape(Rectangle())
567
+ // `count`: a double tap. SwiftUI delays a single tap attached outside
568
+ // (after) a double tap until the double tap can no longer land.
223
569
  if props.location {
224
- content.onTapGesture(coordinateSpace: .local) { point in
570
+ content.onTapGesture(count: props.count, coordinateSpace: .local) { point in
225
571
  app?.dispatch(.tap(node.id, x: point.x, y: point.y))
226
572
  }
227
573
  } else {
228
- content.onTapGesture { app?.dispatch(.tap(node.id)) }
574
+ content.onTapGesture(count: props.count) { app?.dispatch(.tap(node.id)) }
229
575
  }
230
576
  }
231
577
  }
@@ -0,0 +1,147 @@
1
+ #if canImport(SwiftUI)
2
+ import SwiftBrowserProtocol
3
+ import SwiftUI
4
+
5
+ #if os(iOS) && !canImport(FoundationBridge)
6
+ import ObjectiveC
7
+ import UIKit
8
+
9
+ /// `deleteBackward` on a `textfield` or `texteditor`: Backspace with nothing
10
+ /// selected and the caret at the start sends `deletebackward` and deletes
11
+ /// nothing (docs/ops-protocol.md, "The delete key at the start of a field").
12
+ ///
13
+ /// SwiftUI gives no hook for it: `.onKeyPress(.delete)` hears hardware
14
+ /// keyboards only, and the binding is not written when the text does not
15
+ /// change. The software keyboard (and a hardware Backspace, which UIKit
16
+ /// routes the same way) calls `UIKeyInput.deleteBackward()` on the text
17
+ /// view or text field behind SwiftUI's editor. So the editor carries a
18
+ /// `DeleteBackwardFinder` as its background: a `UIViewRepresentable` that,
19
+ /// once in a window, finds the nearest `UITextView` or `UITextField` beside
20
+ /// it in UIKit's hierarchy (up to six ancestors up, their subtrees down) and
21
+ /// marks it with the handler. `deleteBackward()` is swizzled once per class
22
+ /// (`UITextView`, `UITextField`): a marked input with an empty selection at
23
+ /// offset 0 runs the handler instead; everything else goes to UIKit's own.
24
+ ///
25
+ /// Limits: it relies on SwiftUI's fields staying UIKit-backed and on the
26
+ /// nearest text input being the editor's own (an editor's background is
27
+ /// nearer to it than to any other field); a private subclass that
28
+ /// overrides `deleteBackward()` without calling super would not be heard.
29
+ /// macOS (`NSTextView`) is not covered: the modifier is a no-op there.
30
+ extension View {
31
+ func onDeleteBackward(_ enabled: Bool, perform action: @escaping () -> Void) -> some View {
32
+ background {
33
+ if enabled { DeleteBackwardFinder(action: action) }
34
+ }
35
+ }
36
+ }
37
+
38
+ struct DeleteBackwardFinder: UIViewRepresentable {
39
+ let action: () -> Void
40
+
41
+ func makeUIView(context: Context) -> FinderView {
42
+ let view = FinderView()
43
+ view.isUserInteractionEnabled = false
44
+ return view
45
+ }
46
+
47
+ func updateUIView(_ view: FinderView, context: Context) {
48
+ view.action = action
49
+ view.attach()
50
+ }
51
+
52
+ final class FinderView: UIView {
53
+ var action: (() -> Void)?
54
+ /// The text input marked, while it is on screen.
55
+ private(set) weak var attached: UIView?
56
+
57
+ override func didMoveToWindow() {
58
+ super.didMoveToWindow()
59
+ attach()
60
+ // The text input may join the hierarchy after its background does.
61
+ DispatchQueue.main.async { [weak self] in self?.attach() }
62
+ }
63
+
64
+ func attach() {
65
+ guard window != nil, let action else { return }
66
+ if let attached, attached.window != nil {
67
+ DeleteBackwardHook.set(action, on: attached)
68
+ return
69
+ }
70
+ guard let input = nearestTextInput() else { return }
71
+ attached = input
72
+ DeleteBackwardHook.set(action, on: input)
73
+ }
74
+
75
+ private func nearestTextInput() -> UIView? {
76
+ var ancestor = superview
77
+ for _ in 0..<6 {
78
+ guard let candidate = ancestor else { return nil }
79
+ if let found = Self.textInput(in: candidate, skipping: self) { return found }
80
+ ancestor = candidate.superview
81
+ }
82
+ return nil
83
+ }
84
+
85
+ private static func textInput(in view: UIView, skipping: UIView) -> UIView? {
86
+ for subview in view.subviews where subview !== skipping {
87
+ if subview is UITextView || subview is UITextField { return subview }
88
+ if let found = textInput(in: subview, skipping: skipping) { return found }
89
+ }
90
+ return nil
91
+ }
92
+ }
93
+ }
94
+
95
+ /// The handler a text input carries, and the swizzle that runs it.
96
+ @MainActor
97
+ enum DeleteBackwardHook {
98
+ private final class Handler {
99
+ let action: () -> Void
100
+ init(_ action: @escaping () -> Void) { self.action = action }
101
+ }
102
+
103
+ private static var handlerKey = 0
104
+ private static var swizzled: Set<ObjectIdentifier> = []
105
+
106
+ static func set(_ action: @escaping () -> Void, on view: UIView) {
107
+ objc_setAssociatedObject(view, &handlerKey, Handler(action), .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
108
+ let cls: AnyClass = view is UITextView ? UITextView.self : UITextField.self
109
+ swizzle(cls)
110
+ }
111
+
112
+ static func handler(of view: UIView) -> (() -> Void)? {
113
+ (objc_getAssociatedObject(view, &handlerKey) as? Handler)?.action
114
+ }
115
+
116
+ /// Whether the input's selection is empty and at the start of its text.
117
+ static func isAtStart(_ input: UITextInput) -> Bool {
118
+ guard let range = input.selectedTextRange, range.isEmpty else { return false }
119
+ return input.offset(from: input.beginningOfDocument, to: range.start) == 0
120
+ }
121
+
122
+ private static func swizzle(_ cls: AnyClass) {
123
+ guard swizzled.insert(ObjectIdentifier(cls)).inserted else { return }
124
+ let selector = #selector(UIKeyInput.deleteBackward)
125
+ guard let method = class_getInstanceMethod(cls, selector) else { return }
126
+ typealias Original = @convention(c) (AnyObject, Selector) -> Void
127
+ let original = unsafeBitCast(method_getImplementation(method), to: Original.self)
128
+ let replacement: @convention(block) (AnyObject) -> Void = { object in
129
+ let handled = MainActor.assumeIsolated { () -> Bool in
130
+ guard let view = object as? UIView, let input = object as? UITextInput, let action = handler(of: view), isAtStart(input) else { return false }
131
+ action()
132
+ return true
133
+ }
134
+ if !handled { original(object, selector) }
135
+ }
136
+ method_setImplementation(method, imp_implementationWithBlock(replacement))
137
+ }
138
+ }
139
+ #else
140
+ extension View {
141
+ /// `deleteBackward` is heard on iOS only (DeleteBackward.swift); elsewhere the key deletes as it does.
142
+ func onDeleteBackward(_ enabled: Bool, perform action: @escaping () -> Void) -> some View {
143
+ self
144
+ }
145
+ }
146
+ #endif
147
+ #endif