@swiftbrowser/native 0.17.8 → 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.
- package/README.md +20 -9
- package/dist/bridge.d.ts +27 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/hostRequests.d.ts +10 -4
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +65 -3
- package/dist/hostRequests.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +14 -1
- package/dist/run.js.map +1 -1
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +12 -1
- package/dist/runtime.js.map +1 -1
- package/dist/site.d.ts +3 -3
- package/dist/site.d.ts.map +1 -1
- package/dist/site.js +1 -1
- package/dist/site.js.map +1 -1
- package/dist/undo.d.ts +23 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +44 -0
- package/dist/undo.js.map +1 -0
- package/package.json +3 -3
- package/src/bridge.ts +26 -1
- package/src/hostRequests.ts +72 -4
- package/src/index.ts +1 -0
- package/src/run.ts +11 -1
- package/src/runtime.ts +12 -1
- package/src/site.ts +3 -3
- package/src/undo.ts +47 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioRecorderBridge.swift +199 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +154 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +45 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +16 -2
- package/swift/Sources/SwiftBrowserNative/Host/TextMeasureBridge.swift +139 -0
- package/swift/Sources/SwiftBrowserNative/Host/UndoBridge.swift +170 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +30 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +347 -12
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/DeleteBackward.swift +147 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/KeyboardBar.swift +84 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +116 -11
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +12 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Shapes.swift +30 -9
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +163 -10
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextAndImages.swift +17 -4
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +8 -3
- package/swift/Sources/SwiftBrowserNative/Views/NativeRootView.swift +8 -0
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +41 -0
- package/swift/Sources/SwiftBrowserProtocol/Op.swift +27 -4
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +110 -3
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +47 -0
- package/swift/Sources/SwiftBrowserProtocol/SVGPath.swift +260 -0
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +58 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +22 -2
- package/wasm/SwiftBrowserNative.wasm +2 -2
|
@@ -45,9 +45,10 @@ struct ButtonElement: View {
|
|
|
45
45
|
}
|
|
46
46
|
}()
|
|
47
47
|
switch props.style {
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
case "plain":
|
|
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,12 +63,17 @@ 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
|
|
|
@@ -84,11 +90,21 @@ func textChange(of node: ElementNode, to value: String) -> RendererEvent? {
|
|
|
84
90
|
/// `textfield`: a controlled field. Each keystroke sends `text` (a write of
|
|
85
91
|
/// the text the field has sends nothing, `textChange`); the app's state is
|
|
86
92
|
/// the truth the getter reads back, with no frame in between because the
|
|
87
|
-
/// dispatch is synchronous.
|
|
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.
|
|
88
99
|
struct TextFieldElement: View {
|
|
89
100
|
let node: ElementNode
|
|
90
101
|
@Environment(\.nativeApp) private var app
|
|
91
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)
|
|
92
108
|
|
|
93
109
|
var body: some View {
|
|
94
110
|
let props = TextFieldProps(props: node.props)
|
|
@@ -103,8 +119,16 @@ struct TextFieldElement: View {
|
|
|
103
119
|
}()
|
|
104
120
|
var styled = props.style == "roundedBorder" ? AnyView(field.textFieldStyle(.roundedBorder)) : AnyView(field.textFieldStyle(.plain))
|
|
105
121
|
// Only what the app set: each of these is a trait of the field to SwiftUI.
|
|
106
|
-
if props.submit {
|
|
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
|
+
}
|
|
107
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)) }) }
|
|
108
132
|
if props.focused != nil {
|
|
109
133
|
styled = AnyView(styled
|
|
110
134
|
.focused($focused)
|
|
@@ -112,12 +136,29 @@ struct TextFieldElement: View {
|
|
|
112
136
|
if let wanted, wanted != focused { focused = wanted }
|
|
113
137
|
}
|
|
114
138
|
.onChange(of: focused) { _, now in
|
|
115
|
-
if
|
|
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)) }
|
|
116
148
|
}
|
|
117
149
|
.onAppear { if props.focused == true { focused = true } })
|
|
118
150
|
}
|
|
119
151
|
return styled
|
|
120
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
|
+
}
|
|
121
162
|
}
|
|
122
163
|
|
|
123
164
|
extension SubmitLabel {
|
|
@@ -136,19 +177,311 @@ extension SubmitLabel {
|
|
|
136
177
|
}
|
|
137
178
|
}
|
|
138
179
|
|
|
139
|
-
/// `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.
|
|
140
185
|
struct TextEditorElement: View {
|
|
141
186
|
let node: ElementNode
|
|
142
187
|
@Environment(\.nativeApp) private var app
|
|
188
|
+
@FocusState private var focused: Bool
|
|
143
189
|
|
|
144
190
|
var body: some View {
|
|
145
|
-
|
|
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(
|
|
146
216
|
get: { node.props.string("text") ?? "" },
|
|
147
217
|
set: { if let event = textChange(of: node, to: $0) { app?.dispatch(event) } }
|
|
148
|
-
))
|
|
218
|
+
)))
|
|
219
|
+
}
|
|
220
|
+
}
|
|
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)
|
|
149
285
|
}
|
|
150
286
|
}
|
|
151
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
|
+
|
|
152
485
|
/// `picker`: the options are strings; choosing sends `select` with the index.
|
|
153
486
|
struct PickerElement: View {
|
|
154
487
|
let node: ElementNode
|
|
@@ -231,12 +564,14 @@ struct TapGestureElement: View {
|
|
|
231
564
|
var body: some View {
|
|
232
565
|
let props = TapGestureProps(props: node.props)
|
|
233
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.
|
|
234
569
|
if props.location {
|
|
235
|
-
content.onTapGesture(coordinateSpace: .local) { point in
|
|
570
|
+
content.onTapGesture(count: props.count, coordinateSpace: .local) { point in
|
|
236
571
|
app?.dispatch(.tap(node.id, x: point.x, y: point.y))
|
|
237
572
|
}
|
|
238
573
|
} else {
|
|
239
|
-
content.onTapGesture { app?.dispatch(.tap(node.id)) }
|
|
574
|
+
content.onTapGesture(count: props.count) { app?.dispatch(.tap(node.id)) }
|
|
240
575
|
}
|
|
241
576
|
}
|
|
242
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
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#if canImport(SwiftUI)
|
|
2
|
+
import SwiftBrowserProtocol
|
|
3
|
+
import SwiftBrowserTree
|
|
4
|
+
import SwiftUI
|
|
5
|
+
|
|
6
|
+
/// A screen's `keyboard` toolbars (`ToolbarItemGroup(placement: .keyboard)`)
|
|
7
|
+
/// as a bottom safe-area inset of its content, drawn by `KeyboardBar` on
|
|
8
|
+
/// iOS; elsewhere, and against the shim, the content is left as it is and
|
|
9
|
+
/// the toolbars go to SwiftUI's own placement.
|
|
10
|
+
struct KeyboardBarInset: ViewModifier {
|
|
11
|
+
let toolbars: [ElementNode]
|
|
12
|
+
|
|
13
|
+
func body(content: Content) -> some View {
|
|
14
|
+
#if os(iOS) && !canImport(FoundationBridge)
|
|
15
|
+
if toolbars.contains(where: { ToolbarProps(props: $0.props).spacer == nil }) {
|
|
16
|
+
content.safeAreaInset(edge: .bottom, spacing: 0) { KeyboardBar(toolbars: toolbars) }
|
|
17
|
+
} else {
|
|
18
|
+
content
|
|
19
|
+
}
|
|
20
|
+
#else
|
|
21
|
+
content
|
|
22
|
+
#endif
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#if os(iOS) && !canImport(FoundationBridge)
|
|
27
|
+
import Combine
|
|
28
|
+
import UIKit
|
|
29
|
+
|
|
30
|
+
/// The bar above the keyboard as Notes draws its format bar on iOS 26: a
|
|
31
|
+
/// glass capsule 48 tall, inset 10 from the screen's sides, floating 10
|
|
32
|
+
/// above the keyboard, there while the keyboard is up and gone with it.
|
|
33
|
+
/// SwiftUI's `.keyboard` placement is the input accessory view, a bar flush
|
|
34
|
+
/// with the keyboard across the screen, which the system's apps no longer
|
|
35
|
+
/// show. The items are the screen's `keyboard` toolbars' children in one
|
|
36
|
+
/// row, a `spacer` toolbar a `Spacer` (8 wide when fixed), as the page lays
|
|
37
|
+
/// them out (docs/ops-protocol.md, "The keyboard toolbar"). As a safe-area
|
|
38
|
+
/// inset of the content, the bar keeps the content above it, as the
|
|
39
|
+
/// keyboard does; with the keyboard down it has no height.
|
|
40
|
+
struct KeyboardBar: View {
|
|
41
|
+
let toolbars: [ElementNode]
|
|
42
|
+
@State private var shown = false
|
|
43
|
+
|
|
44
|
+
var body: some View {
|
|
45
|
+
Group {
|
|
46
|
+
if shown {
|
|
47
|
+
HStack(spacing: 0) {
|
|
48
|
+
ForEach(toolbars) { toolbar in
|
|
49
|
+
if let spacer = ToolbarProps(props: toolbar.props).spacer {
|
|
50
|
+
if spacer == "fixed" { Spacer().frame(width: 8) } else { Spacer(minLength: 0) }
|
|
51
|
+
} else {
|
|
52
|
+
ChildrenView(node: toolbar)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
.padding(.horizontal, 16)
|
|
57
|
+
.frame(maxWidth: .infinity, minHeight: 48)
|
|
58
|
+
.modifier(CapsuleGlass())
|
|
59
|
+
.padding(.horizontal, 10)
|
|
60
|
+
.padding(.bottom, 10)
|
|
61
|
+
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
|
|
65
|
+
withAnimation(.easeOut(duration: 0.25)) { shown = true }
|
|
66
|
+
}
|
|
67
|
+
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
|
|
68
|
+
withAnimation(.easeOut(duration: 0.25)) { shown = false }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// Liquid Glass from iOS 26; a material before it.
|
|
74
|
+
private struct CapsuleGlass: ViewModifier {
|
|
75
|
+
func body(content: Content) -> some View {
|
|
76
|
+
if #available(iOS 26, *) {
|
|
77
|
+
content.glassEffect(.regular, in: .capsule)
|
|
78
|
+
} else {
|
|
79
|
+
content.background(.regularMaterial, in: Capsule())
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
#endif
|
|
84
|
+
#endif
|