1import UIKit
2
3private final class MetalView: UIView {
4 override class var layerClass: AnyClass { CAMetalLayer.self }
5}
6
7/// An offset into the active outline's shown text in UTF-16 units, paragraphs joined by a
8/// newline; collapsed paragraphs are not part of it.
9private final class Position: UITextPosition {
10 let value: Int
11 init(_ value: Int) { self.value = value }
12}
13
14private final class Range: UITextRange {
15 let lo: Int
16 let hi: Int
17 init(_ a: Int, _ b: Int) { (lo, hi) = (min(a, b), max(a, b)) }
18 override var start: UITextPosition { Position(lo) }
19 override var end: UITextPosition { Position(hi) }
20 override var isEmpty: Bool { lo == hi }
21}
22
23private final class SelectionRect: UITextSelectionRect {
24 private let frame: CGRect
25 private let first: Bool
26 private let last: Bool
27 init(_ frame: CGRect, first: Bool, last: Bool) { (self.frame, self.first, self.last) = (frame, first, last) }
28 override var rect: CGRect { frame }
29 override var writingDirection: NSWritingDirection { .leftToRight }
30 override var containsStart: Bool { first }
31 override var containsEnd: Bool { last }
32 override var isVertical: Bool { false }
33}
34
35private extension UITextPosition { var offset: Int { (self as! Position).value } }
36private extension UITextRange {
37 var lower: Int { (self as! Range).lo }
38 var upper: Int { (self as! Range).hi }
39}
40
41/// The page date's fields, from `sb_view_date_request`.
42enum DateField: Int8 {
43 case date, time
44}
45
46/// What a touch lands on; see `sb_view_target`.
47private enum Target: UInt8 {
48 case page, activeText, text, grip
49}
50
51/// Routes the system's undo (shake, three-finger swipe, the keyboard's undo key) to the
52/// canvas's history.
53private final class CanvasUndoManager: UndoManager {
54 weak var canvas: CanvasView?
55 override var canUndo: Bool { canvas?.canUndo(redo: false) ?? false }
56 override var canRedo: Bool { canvas?.canUndo(redo: true) ?? false }
57 override func undo() { canvas?.undo(redo: false) }
58 override func redo() { canvas?.undo(redo: true) }
59}
60
61/// A canvas page: a scroll view whose pan and pinch drive the canvas viewport, with the page
62/// drawn into a Metal layer pinned to the visible bounds, and the active outline's text
63/// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, text
64/// interaction, loupe and edit menu work on it.
65final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate,
66 UIGestureRecognizerDelegate, UIEditMenuInteractionDelegate,
67 UIPencilInteractionDelegate, UIScribbleInteractionDelegate
68{
69 /// The zoom a page opens at once the reader has pinched one.
70 private static let zoomKey = "zoom"
71
72 private let section: Section
73 private let page: String
74 private let metal = MetalView()
75 /// Sized to the page content at 100%, so the scroll view's zoom scale is the page zoom.
76 private let content = UIView()
77 private var handle: OpaquePointer?
78 private var link: CADisplayLink?
79 private var dirty = true
80 /// The content's corner at 100%, from which the scroll offset is measured.
81 private var origin = CGPoint.zero
82 private var syncing = false
83 private var scrolled = false
84 private var drag: Drag?
85 /// The zoom and finger spread a pinch started from.
86 private var pinchStart: (zoom: CGFloat, spread: CGFloat)?
87 private var inDetent = false
88 private let detentTick = UISelectionFeedbackGenerator()
89 /// How far right of the content's corner a page opens.
90 private var left: CGFloat = 0
91 /// The zoom a double tap returns to.
92 private var resting: CGFloat = 1
93 private var canvasPlace: (zoom: CGFloat, resting: CGFloat, corner: CGPoint, left: CGFloat, scrolled: Bool, edited: Bool)?
94 private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
95 private lazy var doubleTap: UITapGestureRecognizer = {
96 let recognizer = UITapGestureRecognizer(target: self, action: #selector(doubleTapped))
97 recognizer.numberOfTapsRequired = 2
98 return recognizer
99 }()
100 /// Moves or widens the focused outline from its grips.
101 private lazy var grip = UIPanGestureRecognizer(target: self, action: #selector(gripped))
102 /// Where the outline grip drag started.
103 private var touchStart = CGPoint.zero
104 private lazy var editMenu = UIEditMenuInteraction(delegate: self)
105 /// Owns text gestures and their caret, highlight, handles and loupe together.
106 private let interaction = UITextInteraction(for: .editable)
107 private let history = CanvasUndoManager()
108 private let spaceHint = Hint()
109 /// The Pencil's strokes, and a finger's while `fingerInks`.
110 private lazy var ink = InkGesture(target: self, action: #selector(inked))
111 /// The tool the canvas has, which touches switch between.
112 private var appliedTool = InkTool.select
113 /// The tool the Pencil draws with; the Pencil's double tap swaps it with the one before.
114 private(set) var inkTool = InkTool.pen(0)
115 private var previousInkTool = InkTool.eraser
116 /// A tool is picked for a finger too, from picking it until Select & Type or closing the
117 /// picker.
118 private var fingerDraws = false
119 /// The section's tab colour as a COLORREF, which its pen gallery and shapes follow.
120 private var sectionColor: UInt32 {
121 section.tab.color.enumerated().reduce(0) { $0 | UInt32($1.element) << (8 * UInt32($1.offset)) }
122 }
123 private lazy var picker = InkPicker(pens: Pen.gallery(sectionColor))
124
125 weak var inputDelegate: UITextInputDelegate?
126 lazy var tokenizer: UITextInputTokenizer = LineTokenizer(canvas: self)
127 /// Called when editing starts or stops and after each change, for the bar buttons.
128 var onChange: (() -> Void)?
129 /// Called once the page is first on screen.
130 var onOpened: (() -> Void)?
131 /// Called when a tap on the page date asks to change it, with the date it shows.
132 var onDate: ((DateField, Date) -> Void)?
133 /// Called when a tap on a recording, or beside a note linked to one, asks to play it.
134 var onPlay: ((PlayRequest) -> Void)?
135 /// Formatting above the keyboard.
136 let formatBar = FormatBar()
137 /// Room above the page for a bar over it.
138 var topInset: CGFloat = 0 {
139 didSet {
140 contentInset.top = topInset
141 home()
142 }
143 }
144
145 /// Room below the page, above the keyboard, for a bar over its foot.
146 var bottomInset: CGFloat = 0 {
147 didSet {
148 guard bottomInset != oldValue else { return }
149 contentInset.bottom += bottomInset - oldValue
150 verticalScrollIndicatorInsets.bottom = contentInset.bottom
151 revealCaret()
152 }
153 }
154
155 init(section: Section, page: String) {
156 self.section = section
157 self.page = page
158 super.init(frame: .zero)
159 backgroundColor = .systemBackground
160 delegate = self
161 minimumZoomScale = 0.25
162 maximumZoomScale = 4
163 keyboardDismissMode = .interactive
164 // A phone on its side keeps the page clear of the sensor housing, as it keeps the top.
165 contentInsetAdjustmentBehavior = .always
166 addSubview(content)
167 insertSubview(metal, at: 0)
168 // Touches land on the scroll view itself, where the text interaction looks for them.
169 metal.isUserInteractionEnabled = false
170 content.isUserInteractionEnabled = false
171 let layer = metal.layer as! CAMetalLayer
172 layer.isOpaque = true
173 // Frames reach the screen with the transaction that moves UIKit's caret and handles.
174 layer.presentsWithTransaction = true
175 for recognizer in [tap, doubleTap, grip, ink] {
176 recognizer.delegate = self
177 addGestureRecognizer(recognizer)
178 }
179 // The Pencil draws; fingers, a trackpad and a mouse scroll and select.
180 ink.allowedTouchTypes = [UITouch.TouchType.pencil, .direct].map { NSNumber(value: $0.rawValue) }
181 let hands = [UITouch.TouchType.direct, .indirectPointer].map { NSNumber(value: $0.rawValue) }
182 let recognizers: [UIGestureRecognizer?] = [tap, doubleTap, grip, panGestureRecognizer, pinchGestureRecognizer]
183 for recognizer in recognizers {
184 recognizer?.allowedTouchTypes = hands
185 }
186 let pencil = UIPencilInteraction()
187 pencil.delegate = self
188 addInteraction(pencil)
189 addInteraction(UIScribbleInteraction(delegate: self))
190 picker.onPick = { [weak self] tool in self?.pick(tool) }
191 picker.onClose = { [weak self] in self?.showPicker(false) }
192 tap.require(toFail: doubleTap)
193 panGestureRecognizer.require(toFail: grip)
194 interaction.textInput = self
195 interaction.delegate = self
196 addInteraction(interaction)
197 for recognizer in interaction.gesturesForFailureRequirements {
198 panGestureRecognizer.require(toFail: recognizer)
199 }
200 addInteraction(editMenu)
201 history.canvas = self
202 NotificationCenter.default.addObserver(
203 self, selector: #selector(keyboardChanged), name: UIResponder.keyboardWillChangeFrameNotification,
204 object: nil)
205 registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CanvasView, _) in view.paper() }
206 formatBar.onApply = { [weak self] command in self?.apply(command) }
207 formatBar.onStyle = { [weak self] place in self?.applyStyle(place) }
208 formatBar.stylePlace = { [weak self] in self?.stylePlace ?? -1 }
209 formatBar.onDismiss = { [weak self] in _ = self?.resignFirstResponder() }
210 }
211
212 required init?(coder: NSCoder) { fatalError() }
213
214 deinit {
215 link?.invalidate()
216 if let handle { sb_view_free(handle) }
217 }
218
219 // MARK: Drawing
220
221 override func layoutSubviews() {
222 super.layoutSubviews()
223 metal.frame = bounds
224 // Selection views go in at the back, where the opaque page would hide them.
225 sendSubviewToBack(metal)
226 let scale = window?.screen.scale ?? 3
227 metal.layer.contentsScale = scale
228 guard bounds.width > 0, bounds.height > 0 else { return }
229 let pixels = CGSize(width: (bounds.width * scale).rounded(), height: (bounds.height * scale).rounded())
230 if let handle {
231 // Scrolling lays the view out too; only a new size reconfigures the surface.
232 guard (metal.layer as! CAMetalLayer).drawableSize != pixels else { return }
233 sb_view_resize(handle, Float(bounds.width), Float(bounds.height), Float(scale))
234 if readingShown { reflowReading(readingWidth) } else { transform() }
235 } else {
236 handle = sb_view_new(
237 Unmanaged.passUnretained(metal.layer).toOpaque(), section.handle, page,
238 Float(bounds.width), Float(bounds.height), Float(scale))
239 guard let handle else { return }
240 sb_view_focus(handle, false)
241 paper()
242 sync()
243 let skip = margin
244 resting = openingZoom(skipping: skip)
245 zoomScale = resting
246 left = skip * resting
247 home()
248 onOpened?()
249 #if DEBUG
250 runScript()
251 #endif
252 }
253 }
254
255 override func safeAreaInsetsDidChange() {
256 super.safeAreaInsetsDidChange()
257 if readingShown { reflowReading(readingWidth) }
258 }
259
260 override func didMoveToWindow() {
261 super.didMoveToWindow()
262 link?.invalidate()
263 link = nil
264 guard window != nil else { return }
265 let link = CADisplayLink(target: self, selector: #selector(frame(_:)))
266 link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 120, preferred: 120)
267 link.add(to: .main, forMode: .common)
268 self.link = link
269 }
270
271 @objc private func frame(_ link: CADisplayLink) {
272 guard let handle else { return }
273 if sb_view_frame_pending(handle) { dirty = true }
274 guard dirty else { return }
275 dirty = false
276 // A frame presented with a transaction shows only once one commits, which an edit
277 // that moves no UIKit view would not otherwise cause.
278 CATransaction.begin()
279 sb_view_render(handle)
280 CATransaction.commit()
281 }
282
283 /// The page follows the system's appearance, on the desktop's dark paper in dark mode.
284 private func paper() {
285 guard let handle else { return }
286 sb_view_set_dark(handle, traitCollection.userInterfaceStyle == .dark)
287 dirty = true
288 }
289
290 // MARK: Scrolling and zoom
291
292 /// Sizes the scroll content to the page's after an edit, keeping the page point at the
293 /// view's corner. The scroll view owns the offset: the canvas's own clamping and caret
294 /// reveal know neither the navigation bar nor the keyboard, so `transform` overrides them.
295 private func sync() {
296 guard let handle else { return }
297 let shown = readingShown
298 var restored: CGPoint?
299 if !shown, let place = canvasPlace {
300 canvasPlace = nil
301 syncing = true
302 minimumZoomScale = 0.25
303 maximumZoomScale = 4
304 zoomScale = place.zoom
305 resting = place.resting
306 left = place.left
307 scrolled = place.scrolled
308 restored = place.corner
309 syncing = false
310 }
311 var bounds: [Float] = [0, 0, 0, 0]
312 sb_view_content(handle, &bounds)
313 let corner = restored ?? CGPoint(x: origin.x + contentOffset.x / zoomScale, y: origin.y + contentOffset.y / zoomScale)
314 origin = CGPoint(x: CGFloat(bounds[0]), y: CGFloat(bounds[1]))
315 let size = CGSize(width: CGFloat(bounds[2]) - origin.x, height: CGFloat(bounds[3]) - origin.y)
316 syncing = true
317 content.bounds = CGRect(origin: .zero, size: size)
318 content.center = CGPoint(x: size.width * zoomScale / 2, y: size.height * zoomScale / 2)
319 contentSize = CGSize(width: size.width * zoomScale, height: size.height * zoomScale)
320 contentOffset = CGPoint(x: (corner.x - origin.x) * zoomScale, y: (corner.y - origin.y) * zoomScale)
321 syncing = false
322 transform()
323 }
324
325 private func transform() {
326 guard let handle, !syncing else { return }
327 if readingShown {
328 syncing = true
329 zoomScale = 1
330 contentOffset.x = margin - adjustedContentInset.left
331 syncing = false
332 }
333 sb_view_set_transform(
334 handle, Float(zoomScale), Float(origin.x + contentOffset.x / zoomScale),
335 Float(origin.y + contentOffset.y / zoomScale))
336 metal.frame = bounds
337 dirty = true
338 }
339
340 /// The zoom the reader last pinched to, within reason, or before that 100%, where OneNote's
341 /// 11 pt body text shows at 17 points; less where that fits the page right of `skip`
342 /// across the screen.
343 private func openingZoom(skipping skip: CGFloat) -> CGFloat {
344 let pinched = UserDefaults.standard.object(forKey: Self.zoomKey) as? CGFloat ?? 1
345 let fit = bounds.inset(by: safeAreaInsets).width / max(1, content.bounds.width - skip)
346 // A page opens readable however far the last one was pinched or however wide it is.
347 return max(0.75, min(2, pinched, fit))
348 }
349
350 /// The empty page left of its first outline, which a phone skips on opening, keeping
351 /// room for the tags OneNote hangs left of the text.
352 private var margin: CGFloat {
353 guard let handle else { return 0 }
354 var block: [Float] = [0, 0, 0, 0]
355 guard sb_view_block(handle, 0, 0, &block) else { return 0 }
356 return max(0, CGFloat(block[0]) - origin.x - 34)
357 }
358
359 /// Until the reader scrolls, the page's corner stays below the navigation bar as its
360 /// insets settle.
361 override func adjustedContentInsetDidChange() {
362 super.adjustedContentInsetDidChange()
363 home()
364 }
365
366 private func home() {
367 if readingShown { transform() }
368 else if !scrolled { contentOffset = CGPoint(x: left - adjustedContentInset.left, y: -adjustedContentInset.top) }
369 }
370
371 /// A drag that starts within 30° of an axis sticks to it; a 24-point stretch of the
372 /// finger more than 40° off it lets go, and the page catches up over the next 48 points.
373 private struct Drag {
374 static let snap = tan(CGFloat.pi / 6)
375 static let release = tan(CGFloat.pi * 2 / 9)
376 static let stretch: CGFloat = 24
377 static let catchUp: CGFloat = 48
378
379 /// The axis the lock holds still.
380 let cross: WritableKeyPath<CGPoint, CGFloat>
381 var locked = true
382 /// The offset last shown, from which the scroll view's next step is measured.
383 var offset: CGPoint
384 /// The translation where the stretch of the gesture being judged began.
385 var stretch: CGPoint
386 /// How far the held axis trails the finger.
387 var lag: CGFloat = 0
388 }
389
390 func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
391 scrolled = true
392 if readingShown { drag = nil; return }
393 let moved = panGestureRecognizer.translation(in: self)
394 let cross: WritableKeyPath<CGPoint, CGFloat>? =
395 abs(moved.x) <= abs(moved.y) * Drag.snap ? \.x : abs(moved.y) <= abs(moved.x) * Drag.snap ? \.y : nil
396 drag = cross.map { Drag(cross: $0, offset: contentOffset, stretch: moved) }
397 }
398
399 func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
400 guard !readingShown else { return }
401 scrolled = true
402 drag = nil
403 pinchStart = spread.map { (zoomScale, $0) }
404 inDetent = abs(log(zoomScale)) < Self.detent
405 detentTick.prepare()
406 }
407
408 func viewForZooming(in scrollView: UIScrollView) -> UIView? { readingShown ? nil : content }
409
410 func scrollViewDidScroll(_ scrollView: UIScrollView) {
411 guard !syncing else { return }
412 if var drag, isDragging || isDecelerating, !isZooming {
413 let cross = drag.cross
414 let along = cross == \CGPoint.x ? \CGPoint.y : \CGPoint.x
415 var offset = contentOffset
416 let step = offset[keyPath: cross] - drag.offset[keyPath: cross]
417 if drag.locked {
418 drag.lag += step
419 offset[keyPath: cross] = drag.offset[keyPath: cross]
420 let moved = panGestureRecognizer.translation(in: self)
421 let stretch = CGPoint(x: moved.x - drag.stretch.x, y: moved.y - drag.stretch.y)
422 if isTracking, hypot(stretch.x, stretch.y) >= Drag.stretch {
423 drag.locked = abs(stretch[keyPath: cross]) <= abs(stretch[keyPath: along]) * Drag.release
424 drag.stretch = moved
425 }
426 } else {
427 let travel = hypot(step, offset[keyPath: along] - drag.offset[keyPath: along])
428 let paid = drag.lag * (1 - exp(-travel / Drag.catchUp))
429 offset[keyPath: cross] += paid
430 drag.lag -= paid
431 }
432 drag.offset = offset
433 self.drag = drag.locked || abs(drag.lag) >= 0.5 ? drag : nil
434 if offset != contentOffset { contentOffset = offset }
435 }
436 transform()
437 }
438
439 /// How far apart a pinch's two fingers are.
440 private var spread: CGFloat? {
441 guard let pinch = pinchGestureRecognizer, pinch.numberOfTouches == 2 else { return nil }
442 let a = pinch.location(ofTouch: 0, in: self), b = pinch.location(ofTouch: 1, in: self)
443 return hypot(a.x - b.x, a.y - b.y)
444 }
445
446 /// How far either side of 100% a pinch sticks, as a log of the zoom.
447 private static let detent = log(CGFloat(1.1))
448
449 /// Pinching near 100% sticks there: within `detent` of it the zoom follows the fingers
450 /// on a curve flat at 100% that meets them again at the band's edges.
451 func scrollViewDidZoom(_ scrollView: UIScrollView) {
452 guard !syncing else { return }
453 if readingShown {
454 transform()
455 inputDelegate?.selectionDidChange(self)
456 return
457 }
458 if let pinch = pinchGestureRecognizer, pinch.state == .changed, let pinchStart, let spread {
459 // The scroll view's pinch reports the zoom it shows, so the fingers give the raw one.
460 let raw = log(pinchStart.zoom * spread / pinchStart.spread)
461 let x = abs(raw) / Self.detent
462 if x < 1 {
463 if !inDetent { detentTick.selectionChanged() }
464 let shown = exp(Self.detent * x * x * (2 - x) * (raw < 0 ? -1 : 1))
465 if abs(shown - zoomScale) > 0.0001 {
466 let focus = pinch.location(in: content)
467 let screen = CGPoint(x: pinch.location(in: self).x - contentOffset.x, y: pinch.location(in: self).y - contentOffset.y)
468 zoomScale = shown
469 contentOffset = CGPoint(x: focus.x * shown - screen.x, y: focus.y * shown - screen.y)
470 }
471 }
472 inDetent = x < 1
473 }
474 transform()
475 // Scrolling carries the caret and handles along; zooming moves them within the page.
476 inputDelegate?.selectionDidChange(self)
477 }
478
479 func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
480 let pinched = pinchStart != nil
481 pinchStart = nil
482 guard !readingShown, pinched else { return }
483 // A pinch let go within the detent settles on 100%.
484 resting = abs(log(scale)) < Self.detent ? 1 : scale
485 if resting != scale { setZoomScale(resting, animated: true) }
486 UserDefaults.standard.set(Double(resting), forKey: Self.zoomKey)
487 }
488
489 @objc private func doubleTapped(_ recognizer: UITapGestureRecognizer) {
490 zoom(at: recognizer.location(in: self))
491 }
492
493 /// Zooms to fit the outline nearest `point` across the view, or back out, as a double
494 /// tap does in Safari.
495 func zoom(at point: CGPoint) {
496 guard let handle, !readingShown else { return }
497 var block: [Float] = [0, 0, 0, 0]
498 let local = visible(point)
499 guard zoomScale <= resting * 1.05, sb_view_block(handle, Float(local.x), Float(local.y), &block) else {
500 setZoomScale(resting, animated: true)
501 return
502 }
503 let width = bounds.inset(by: safeAreaInsets).width
504 let margin: CGFloat = 12
505 let scale = min(maximumZoomScale, (width - 2 * margin) / (CGFloat(block[2] - block[0])))
506 guard scale > zoomScale * 1.05 else {
507 setZoomScale(resting, animated: true)
508 return
509 }
510 let x = CGFloat(block[0]) - origin.x - margin / scale
511 let y = point.y / zoomScale - bounds.height / scale / 2
512 zoom(to: CGRect(x: x, y: y, width: width / scale, height: bounds.height / scale), animated: true)
513 }
514
515 @objc private func keyboardChanged(_ notification: Notification) {
516 guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
517 let window
518 else { return }
519 let keyboard = convert(frame, from: window.screen.coordinateSpace)
520 let overlap = max(0, bounds.maxY - keyboard.minY - safeAreaInsets.bottom)
521 contentInset.bottom = overlap + bottomInset
522 verticalScrollIndicatorInsets.bottom = contentInset.bottom
523 revealCaret()
524 }
525
526 // MARK: Touch
527
528 private func target(_ point: CGPoint) -> Target {
529 guard let handle else { return .page }
530 let local = visible(point)
531 return Target(rawValue: sb_view_target(handle, Float(local.x), Float(local.y))) ?? .page
532 }
533
534 func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
535 if recognizer === ink { return !readOnly && (touch.type == .pencil || fingerInks && touch.type == .direct) }
536 if recognizer === grip { touchStart = touch.location(in: self) }
537 return !fingerInks || ![tap, doubleTap, grip].contains(recognizer)
538 }
539
540 override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool {
541 switch recognizer {
542 case tap:
543 !readOnly && [.page, .grip].contains(target(recognizer.location(in: self)))
544 && !onHandle(recognizer.location(in: self))
545 case doubleTap: target(recognizer.location(in: self)) == .page
546 case grip: !readOnly && target(touchStart) == .grip
547 default: super.gestureRecognizerShouldBegin(recognizer)
548 }
549 }
550
551 /// The system's text interaction works in the outline taking input; a touch on another
552 /// outline's text focuses it first, as a tap there would.
553 func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool {
554 if readOnly || fingerInks || ink.state != .possible { return false }
555 if onHandle(point) { return true }
556 switch target(point) {
557 case .activeText: break
558 case .text: press(at: point)
559 case .page, .grip: return false
560 }
561 if !isFirstResponder { _ = becomeFirstResponder() }
562 return true
563 }
564
565 /// Whether `point` is on a selection handle, whose knob hangs off the text.
566 private func onHandle(_ point: CGPoint) -> Bool {
567 guard isFirstResponder, let range = selectedTextRange, !range.isEmpty else { return false }
568 return [range.start, range.end].contains {
569 caretRect(for: $0).insetBy(dx: -22, dy: -22).contains(point)
570 }
571 }
572
573 @objc private func tapped(_ recognizer: UITapGestureRecognizer) {
574 tap(at: recognizer.location(in: self))
575 }
576
577 /// A tap at `point` in the scroll view's bounds: places the caret, focuses an outline or
578 /// starts a new one, or picks a drawing.
579 func tap(at point: CGPoint) {
580 press(at: point)
581 var seconds: Int64 = 0
582 if let request = playRequest() {
583 onPlay?(request)
584 } else if let handle, let field = DateField(rawValue: sb_view_date_request(handle, &seconds)) {
585 onDate?(field, Date(timeIntervalSince1970: TimeInterval(seconds)))
586 } else if inkFrame != nil {
587 showInkMenu()
588 } else if !isFirstResponder {
589 _ = becomeFirstResponder()
590 }
591 }
592
593 /// Gives the page `date`, which the date and time under its title show.
594 func changeDate(_ date: Date) {
595 guard let handle else { return }
596 let (day, time) = titleDate(date)
597 edit(external: true) { sb_view_change_date(handle, Int64(date.timeIntervalSince1970.rounded()), day, time) }
598 }
599
600 private func press(at point: CGPoint) {
601 guard let handle else { return }
602 fingerTool(at: point)
603 let point = visible(point)
604 edit(external: true) {
605 let pressed = sb_view_press(handle, Float(point.x), Float(point.y))
606 return sb_view_release(handle) || pressed
607 }
608 }
609
610 @objc private func gripped(_ recognizer: UIPanGestureRecognizer) {
611 guard let handle else { return }
612 let point = visible(recognizer.location(in: self))
613 switch recognizer.state {
614 case .began:
615 inputDelegate?.selectionWillChange(self)
616 fingerTool(at: touchStart)
617 let start = visible(touchStart)
618 _ = sb_view_press(handle, Float(start.x), Float(start.y))
619 _ = sb_view_drag(handle, Float(point.x), Float(point.y))
620 inputDelegate?.selectionDidChange(self)
621 editMenu.dismissMenu()
622 dirty = true
623 case .changed:
624 _ = sb_view_drag(handle, Float(point.x), Float(point.y))
625 dirty = true
626 default:
627 edit(external: true) { sb_view_release(handle) }
628 spaceHint.hide()
629 }
630 }
631
632 // MARK: Drawing tools
633
634 /// Gives the canvas `tool` for the touch starting, where it has another: switching lets
635 /// picked drawings go.
636 private func use(_ tool: InkTool) {
637 guard let handle else { return }
638 if tool != .select, readingShown { showReading(false) }
639 guard tool != appliedTool else { return }
640 appliedTool = tool
641 sb_view_set_tool(handle, tool.code.tool, tool.code.detail, sectionColor)
642 dirty = true
643 }
644
645 /// A finger draws with the picked tool, unless the system keeps drawing to the Pencil.
646 private var fingerInks: Bool { fingerDraws && !UIPencilInteraction.prefersPencilOnlyDrawing }
647
648 /// A finger scrolls, selects and types, and drags what the lasso picked with the lasso.
649 private func fingerTool(at point: CGPoint) {
650 if inkFrame?.contains(point) != true { use(.select) }
651 }
652
653 /// The frame of the drawings the lasso or a tap picked, in the scroll view's bounds.
654 private var inkFrame: CGRect? {
655 guard let handle else { return nil }
656 var rect: [Float] = [0, 0, 0, 0]
657 return sb_view_ink_selection(handle, &rect) ? bounded(rect) : nil
658 }
659
660 /// The Pencil's tool, and a finger's while `fingerInks`.
661 func setInkTool(_ tool: InkTool) {
662 guard tool != .select else { return }
663 if tool != inkTool { previousInkTool = inkTool }
664 inkTool = tool
665 picker.show(inkTool, fingerDraws: fingerInks)
666 }
667
668 /// A tool picked in the picker, which a finger draws with too, or Select & Type as nil.
669 private func pick(_ tool: InkTool?) {
670 if let tool { setInkTool(tool) }
671 setFingerDraws(tool != nil)
672 }
673
674 private func setFingerDraws(_ draws: Bool) {
675 fingerDraws = draws
676 // Two fingers scroll while one draws.
677 panGestureRecognizer.minimumNumberOfTouches = fingerInks ? 2 : 1
678 if fingerInks { _ = resignFirstResponder() }
679 picker.show(inkTool, fingerDraws: fingerInks)
680 onChange?()
681 }
682
683 var pickerShown: Bool { picker.superview != nil }
684
685 /// Shows the drawing tools over the bottom of the page, or puts them away. Showing them from
686 /// the bar picks the Pencil's tool for a finger too; closing them returns it to Select & Type.
687 func showPicker(_ shown: Bool, fingerDraws draws: Bool = true) {
688 guard shown != pickerShown, let host = superview else { return }
689 if shown {
690 if readingShown { showReading(false) }
691 picker.translatesAutoresizingMaskIntoConstraints = false
692 host.addSubview(picker)
693 NSLayoutConstraint.activate([
694 picker.centerXAnchor.constraint(equalTo: host.centerXAnchor),
695 picker.widthAnchor.constraint(lessThanOrEqualTo: host.safeAreaLayoutGuide.widthAnchor, constant: -16),
696 picker.bottomAnchor.constraint(equalTo: host.keyboardLayoutGuide.topAnchor, constant: -12),
697 ])
698 setFingerDraws(draws)
699 } else {
700 picker.removeFromSuperview()
701 setFingerDraws(false)
702 }
703 }
704
705 /// The Pencil's double tap, as the reader set it in Settings.
706 func pencilInteractionDidTap(_ interaction: UIPencilInteraction) {
707 switch UIPencilInteraction.preferredTapAction {
708 case .switchEraser: setInkTool(inkTool == .eraser ? previousInkTool : .eraser)
709 case .switchPrevious: setInkTool(previousInkTool)
710 case .showColorPalette, .showInkAttributes: showPicker(!pickerShown, fingerDraws: fingerDraws)
711 default: break
712 }
713 }
714
715 /// Writing with the Pencil draws ink on the page rather than typing.
716 func scribbleInteraction(_ interaction: UIScribbleInteraction, shouldBeginAt location: CGPoint) -> Bool { false }
717
718 @objc private func inked(_ gesture: InkGesture) {
719 let points = gesture.points
720 gesture.points = []
721 switch gesture.state {
722 case .began:
723 inkPressed(points[0])
724 inkMoved(points.dropFirst())
725 case .changed: inkMoved(points[...])
726 case .ended:
727 inkMoved(points[...])
728 inkReleased()
729 default:
730 // A second finger scrolls instead: the gesture under way goes, storing nothing.
731 guard let handle else { return }
732 edit { sb_view_cancel(handle) }
733 }
734 }
735
736 /// The Pencil, or a drawing finger, comes down at `sample`'s point in the scroll view's bounds.
737 func inkPressed(_ sample: InkSample) {
738 guard let handle else { return }
739 // Drawing puts the keyboard away, as Notes does.
740 if isFirstResponder { _ = resignFirstResponder() }
741 use(inkTool)
742 editMenu.dismissMenu()
743 let point = visible(sample.point)
744 sb_view_pressure(handle, sample.pressure)
745 edit { sb_view_press(handle, Float(point.x), Float(point.y)) }
746 }
747
748 func inkMoved(_ samples: ArraySlice<InkSample>) {
749 guard let handle else { return }
750 for sample in samples {
751 let point = visible(sample.point)
752 sb_view_pressure(handle, sample.pressure)
753 _ = sb_view_drag(handle, Float(point.x), Float(point.y))
754 }
755 dirty = true
756 }
757
758 /// The stroke, erasure, lasso or drag ends: one edit, and the menu for what the lasso picked.
759 func inkReleased() {
760 guard let handle else { return }
761 edit { sb_view_release(handle) }
762 showInkMenu()
763 }
764
765 private static let inkMenu = "ink" as NSString
766
767 private func showInkMenu() {
768 guard let frame = inkFrame else { return }
769 editMenu.presentEditMenu(
770 with: UIEditMenuConfiguration(identifier: Self.inkMenu, sourcePoint: CGPoint(x: frame.midX, y: frame.minY)))
771 }
772
773 func editMenuInteraction(
774 _ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration,
775 suggestedActions: [UIMenuElement]
776 ) -> UIMenu? {
777 guard configuration.identifier as? NSString == Self.inkMenu else {
778 return selectedTextRange.flatMap { editMenu(for: $0, suggestedActions: suggestedActions) }
779 ?? UIMenu(children: suggestedActions)
780 }
781 return UIMenu(children: [
782 UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
783 self?.deleteInk()
784 }
785 ])
786 }
787
788 func editMenuInteraction(
789 _ interaction: UIEditMenuInteraction, targetRectFor configuration: UIEditMenuConfiguration
790 ) -> CGRect {
791 configuration.identifier as? NSString == Self.inkMenu ? inkFrame ?? .null : .null
792 }
793
794 /// Deletes the drawings the lasso or a tap picked.
795 func deleteInk() {
796 guard let handle, inkFrame != nil else { return }
797 edit(external: true) { sb_delete_backward(handle) }
798 }
799
800 /// Runs a change to the page, telling the system when it did not ask for it; returns
801 /// whether it changed.
802 @discardableResult
803 private func edit(external: Bool = false, _ change: () -> Bool) -> Bool {
804 if external {
805 inputDelegate?.selectionWillChange(self)
806 inputDelegate?.textWillChange(self)
807 }
808 let changed = change()
809 dirty = true
810 if changed {
811 canvasPlace?.edited = true
812 sync()
813 revealCaret()
814 }
815 if external {
816 inputDelegate?.textDidChange(self)
817 inputDelegate?.selectionDidChange(self)
818 }
819 changedSelection()
820 return changed
821 }
822
823 /// Tells the bar buttons and the format bar what the selection now has.
824 private func changedSelection() {
825 formatBar.show(format)
826 onChange?()
827 }
828
829 private func revealCaret() {
830 guard handle != nil, isFirstResponder, let range = selectedTextRange else { return }
831 reveal(caretRect(for: range.end))
832 }
833
834 private func reveal(_ rect: CGRect) {
835 scrollRectToVisible(rect.insetBy(dx: -8, dy: -16), animated: false)
836 }
837
838 // MARK: Responder
839
840 override var canBecomeFirstResponder: Bool { handle != nil && !readOnly }
841
842 override var inputAccessoryView: UIView? { formatBar }
843
844 override func becomeFirstResponder() -> Bool {
845 if Prototype.welcome, Author.name == nil, let controller = window?.rootViewController {
846 Prototype.askName(from: controller) { [weak self] in _ = self?.becomeFirstResponder() }
847 return false
848 }
849 guard super.becomeFirstResponder() else { return false }
850 if let handle { sb_view_focus(handle, true) }
851 dirty = true
852 changedSelection()
853 return true
854 }
855
856 override func resignFirstResponder() -> Bool {
857 guard super.resignFirstResponder() else { return false }
858 TagGrid.shown?.close()
859 if let handle { sb_view_focus(handle, false) }
860 dirty = true
861 onChange?()
862 return true
863 }
864
865 override var undoManager: UndoManager? { history }
866
867 func canUndo(redo: Bool) -> Bool { handle.map { sb_view_can_undo($0, redo) } ?? false }
868
869 func undo(redo: Bool) {
870 guard let handle else { return }
871 edit(external: true) { sb_view_undo(handle, redo) }
872 }
873
874 private var selection: (lo: Int, hi: Int) {
875 guard let range = selectedTextRange else { return (0, 0) }
876 return (range.lower, range.upper)
877 }
878
879 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
880 let (lo, hi) = selection
881 switch action {
882 case #selector(copy(_:)), #selector(cut(_:)), #selector(delete(_:)),
883 #selector(toggleBoldface(_:)), #selector(toggleItalics(_:)), #selector(toggleUnderline(_:)):
884 return lo < hi
885 case #selector(paste(_:)): return UIPasteboard.general.hasStrings || UIPasteboard.general.hasImages
886 case #selector(select(_:)): return lo == hi && hasText
887 case #selector(selectAll(_:)): return hasText && (lo > 0 || hi < endOfDocument.offset)
888 default: return super.canPerformAction(action, withSender: sender)
889 }
890 }
891
892 /// A page selection copies every outline, as OneNote 2010 copies it.
893 override func copy(_ sender: Any?) {
894 guard let handle, let text = take(sb_view_copy(handle, false)) else { return }
895 UIPasteboard.general.string = text
896 }
897
898 override func cut(_ sender: Any?) {
899 guard let handle else { return }
900 var text: String?
901 edit(external: true) {
902 text = take(sb_view_copy(handle, true))
903 return text != nil
904 }
905 if let text { UIPasteboard.general.string = text }
906 }
907
908 override func delete(_ sender: Any?) {
909 guard let range = selectedTextRange else { return }
910 edit(external: true) { replacing(range, with: "") }
911 }
912
913 /// Pasted text takes the keyboard's language, as Windows gives the clipboard. Text wins
914 /// over a picture, which apps put beside copied text.
915 override func paste(_ sender: Any?) {
916 guard let handle else { return }
917 if let text = UIPasteboard.general.string {
918 edit(external: true) { sb_paste(handle, text, textInputMode?.primaryLanguage ?? "") }
919 } else if let image = UIPasteboard.general.image {
920 insertPicture(image)
921 }
922 }
923
924 override func select(_ sender: Any?) {
925 guard let caret = selectedTextRange?.start,
926 let word = tokenizer.rangeEnclosingPosition(caret, with: .word, inDirection: .storage(.backward))
927 ?? tokenizer.rangeEnclosingPosition(caret, with: .word, inDirection: .storage(.forward))
928 else { return }
929 edit(external: true) { choose(word) }
930 }
931
932 override func selectAll(_ sender: Any?) {
933 guard let handle else { return }
934 edit(external: true) { sb_select_more(handle) }
935 }
936
937 override func toggleBoldface(_ sender: Any?) { apply(0) }
938 override func toggleItalics(_ sender: Any?) { apply(1) }
939 override func toggleUnderline(_ sender: Any?) { apply(2) }
940
941 /// OneNote's highlighter colours, COLORREF, as its Text Highlight Color offers them.
942 private static let highlights: [(UInt32, String)] = [
943 (0x00ffff, "Yellow"), (0x00ff00, "Bright Green"), (0xffff00, "Turquoise"), (0xff00ff, "Pink"),
944 (0xff0000, "Blue"), (0x0000ff, "Red"), (0x800000, "Dark Blue"), (0x808000, "Teal"), (0x008000, "Green"),
945 (0x800080, "Violet"), (0x000080, "Dark Red"), (0x008080, "Dark Yellow"), (0x808080, "Gray 50%"),
946 (0xc0c0c0, "Gray 25%"), (0x000000, "Black"),
947 ]
948
949 /// The highlighter leads the system's text actions for a selection, tags follow them.
950 func editMenu(for textRange: UITextRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
951 guard let handle else { return nil }
952 let bits = sb_view_format(handle)
953 let tag = UIMenu(
954 title: "Tag", image: UIImage(systemName: "tag"),
955 children: Tags.menu(bits: bits) { [weak self] in self?.apply($0) })
956 var highlight: [UIMenuElement] = []
957 if !textRange.isEmpty {
958 let colors = Self.highlights.map { color, name in
959 let swatch = UIGraphicsImageRenderer(size: CGSize(width: 20, height: 20)).image { _ in
960 let dot = UIBezierPath(ovalIn: CGRect(x: 1, y: 1, width: 18, height: 18))
961 UIColor(rgb: [UInt8(color & 0xff), UInt8(color >> 8 & 0xff), UInt8(color >> 16 & 0xff)]).setFill()
962 dot.fill()
963 UIColor.separator.setStroke()
964 dot.stroke()
965 }
966 return UIAction(title: name, image: swatch.withRenderingMode(.alwaysOriginal)) { [weak self] _ in
967 self?.highlight(Int64(color))
968 }
969 }
970 let none = UIAction(title: "No Highlight", image: UIImage(systemName: "circle.slash")) { [weak self] _ in
971 self?.highlight(-1)
972 }
973 highlight = [UIMenu(title: "Highlight", image: UIImage(systemName: "highlighter"), children: colors + [none])]
974 }
975 return UIMenu(
976 children: spellingActions() + highlight + suggestedActions + [UIMenu(options: .displayInline, children: [tag])])
977 }
978
979 private struct Correction: Decodable {
980 let word: String
981 let suggestions: [String]
982 let repeated: Bool
983 }
984
985 /// OneNote's corrections for the marked word at the selection, first in the menu.
986 private func spellingActions() -> [UIMenuElement] {
987 guard let handle, let correction = decode(Correction.self, sb_view_correction(handle)) else { return [] }
988 let correct = { [weak self] (command: UInt8, text: String?) in
989 guard let self, let handle = self.handle else { return }
990 self.edit(external: true) { sb_view_correct(handle, command, text) }
991 }
992 let replacements =
993 correction.repeated
994 ? [UIAction(title: "Delete Repeated Word") { _ in correct(0, "") }]
995 : correction.suggestions.map { word in UIAction(title: word) { _ in correct(0, word) } }
996 let ignore = UIAction(title: "Ignore") { _ in correct(1, nil) }
997 let learn = UIAction(title: "Add to Dictionary") { _ in correct(2, nil) }
998 return [
999 UIMenu(options: .displayInline, children: replacements + (correction.repeated ? [ignore] : [ignore, learn]))
1000 ]
1001 }
1002
1003 // MARK: Page
1004
1005 /// A conflict page, which shows what is stored and takes no edits.
1006 var readOnly: Bool { handle.map(sb_view_read_only) ?? false }
1007
1008 /// The column a reading view fills: the screen's width less the room tags hang in.
1009 private var readingWidth: Float { Float(bounds.inset(by: safeAreaInsets).width - 34 - 12) }
1010
1011 private var readingAnchor: [Float] {
1012 [Float(origin.x + (contentOffset.x + adjustedContentInset.left + 34) / zoomScale),
1013 Float(origin.y + (contentOffset.y + adjustedContentInset.top) / zoomScale)]
1014 }
1015
1016 struct Reading: Decodable {
1017 let offered: Bool
1018 let shown: Bool
1019 }
1020
1021 var reading: Reading? {
1022 guard let handle else { return nil }
1023 return decode(Reading.self, sb_view_reading(handle, readingWidth))
1024 }
1025
1026 var readingShown: Bool {
1027 guard let handle else { return false }
1028 return decode(Reading.self, sb_view_reading(handle, 0))?.shown ?? false
1029 }
1030
1031 /// Shows the page reflowed into the screen's width at 100%, or as laid out.
1032 func showReading(_ on: Bool) {
1033 guard on != readingShown else { return }
1034 reflowReading(on ? readingWidth : 0)
1035 }
1036
1037 private func reflowReading(_ width: Float) {
1038 guard let handle else { return }
1039 let wasShown = readingShown
1040 let place = (zoom: zoomScale, resting: resting,
1041 corner: CGPoint(x: origin.x + contentOffset.x / zoomScale, y: origin.y + contentOffset.y / zoomScale),
1042 left: left, scrolled: scrolled, edited: false)
1043 let edited = canvasPlace?.edited ?? false
1044 var anchor = readingAnchor
1045 inputDelegate?.selectionWillChange(self)
1046 let shown = sb_view_set_reading(handle, width, &anchor)
1047 if shown, !wasShown {
1048 canvasPlace = place
1049 syncing = true
1050 minimumZoomScale = 1
1051 maximumZoomScale = 1
1052 zoomScale = 1
1053 resting = 1
1054 scrolled = true
1055 drag = nil
1056 pinchStart = nil
1057 syncing = false
1058 }
1059 sync()
1060 if shown {
1061 let y = CGFloat(anchor[1]) - origin.y - adjustedContentInset.top
1062 contentOffset.y = max(-adjustedContentInset.top, min(y, contentSize.height - bounds.height + adjustedContentInset.bottom))
1063 } else if edited {
1064 revealCaret()
1065 }
1066 inputDelegate?.selectionDidChange(self)
1067 dirty = true
1068 onChange?()
1069 }
1070
1071 /// The title as typed; nil on a page without an editable title.
1072 var pageTitle: String? { handle.flatMap { take(sb_view_title($0)) } }
1073
1074 /// Keeps the page as shown as a page of its own after an edit was refused; false when nothing was kept.
1075 func keep() -> Bool { handle.map(sb_view_keep) ?? false }
1076
1077 /// The page on `paper`, in points, as OneNote 2010 prints it, each sheet's footer naming
1078 /// `section`.
1079 func pdf(paper: CGSize, section: String) -> Data? {
1080 guard let handle else { return nil }
1081 var length = 0
1082 guard let bytes = sb_view_pdf(handle, Float(paper.width), Float(paper.height), section, &length) else {
1083 return nil
1084 }
1085 defer { sb_bytes_free(bytes, length) }
1086 return Data(bytes: bytes, count: length)
1087 }
1088
1089 /// The selection's formatting, from `sb_view_format`.
1090 var format: UInt64 { handle.map(sb_view_format) ?? 0 }
1091
1092 var pagePaper: Paper? { handle.flatMap { decode(Paper.self, sb_view_paper($0)) } }
1093
1094 /// Gives the page colour `color` and rule lines `ruled` of `paper.rules`, or none.
1095 func setPaper(color: [UInt8]?, ruled: Int?) {
1096 guard let handle else { return }
1097 edit(external: true) {
1098 sb_view_set_paper(
1099 handle, color.map { Int16($0[0]) } ?? -1, color?[1] ?? 0, color?[2] ?? 0, Int8(ruled ?? -1))
1100 }
1101 }
1102
1103 /// Puts template `name`'s art behind the page, or none.
1104 func setArt(_ name: String?) {
1105 guard let handle else { return }
1106 edit(external: true) { sb_view_set_art(handle, name) }
1107 }
1108
1109 /// Insert Space: the next drag moves what lies below or right of where it starts.
1110 func insertSpace() {
1111 guard let handle else { return }
1112 if readingShown { showReading(false) }
1113 sb_view_insert_space(handle)
1114 spaceHint.show("Drag down or right to add space", in: self)
1115 }
1116
1117 /// Types the date, time or both as the system writes them short, and a space, as
1118 /// OneNote's Insert Date and Time do.
1119 func insertDate(_ date: Bool, time: Bool) {
1120 let now = Date()
1121 let text = DateFormatter.localizedString(
1122 from: now, dateStyle: date ? .short : .none, timeStyle: time ? .short : .none)
1123 if !isFirstResponder { _ = becomeFirstResponder() }
1124 insertText(text + " ")
1125 }
1126
1127 /// Selects paragraph `id` and brings it into view.
1128 func selectParagraph(_ id: String) -> Bool {
1129 guard let handle else { return false }
1130 let found = edit(external: true) { sb_view_select_paragraph(handle, id) }
1131 if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) }
1132 return found
1133 }
1134
1135 /// Applies `sb_view_apply` formatting to the selection.
1136 func apply(_ command: UInt8) {
1137 guard let handle else { return }
1138 edit(external: true) { sb_view_apply(handle, command) }
1139 }
1140
1141 /// Highlights the selection in COLORREF `color`, or takes its highlight away where negative.
1142 func highlight(_ color: Int64) {
1143 guard let handle else { return }
1144 edit(external: true) { sb_view_highlight(handle, color) }
1145 }
1146
1147 /// Gives the selected paragraphs style `place` of OneNote's gallery, in the page's theme.
1148 func applyStyle(_ place: UInt8) {
1149 guard let handle else { return }
1150 edit(external: true) { sb_view_style(handle, place) }
1151 }
1152
1153 /// The selection's style's place in the gallery, or -1.
1154 var stylePlace: Int32 { handle.map(sb_view_style_place) ?? -1 }
1155
1156 /// Shows the page as stored; with `discard`, after an edit was refused.
1157 func reload(discard: Bool, pendingOnly: Bool = false) {
1158 guard let handle else { return }
1159 if pendingOnly && !sb_view_reload_ready(handle) { return }
1160 guard readingShown else {
1161 edit(external: true) { sb_view_reload(handle, discard, nil) }
1162 return
1163 }
1164 var anchor = readingAnchor
1165 inputDelegate?.selectionWillChange(self)
1166 inputDelegate?.textWillChange(self)
1167 let changed = sb_view_reload(handle, discard, &anchor)
1168 sync()
1169 if readingShown {
1170 let y = CGFloat(anchor[1]) - origin.y - adjustedContentInset.top
1171 contentOffset.y = max(-adjustedContentInset.top, min(y, contentSize.height - bounds.height + adjustedContentInset.bottom))
1172 } else if changed {
1173 revealCaret()
1174 }
1175 inputDelegate?.textDidChange(self)
1176 inputDelegate?.selectionDidChange(self)
1177 dirty = true
1178 changedSelection()
1179 }
1180
1181 /// Selects the first place `query` occurs and brings it into view.
1182 func find(_ query: String) -> Bool {
1183 guard let handle else { return false }
1184 let found = edit(external: true) { sb_view_find(handle, query) }
1185 if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) }
1186 return found
1187 }
1188
1189 /// Puts the caret at the end of the page title.
1190 func focusTitle() -> Bool {
1191 guard let handle else { return false }
1192 return edit(external: true) { sb_view_focus_title(handle) }
1193 }
1194
1195 /// A photo as OneNote 2010 can read it: JPEG, at most 2048 pixels across, shown at most
1196 /// 220 points wide.
1197 func insertPicture(_ image: UIImage) {
1198 let longest: CGFloat = 2048
1199 let scale = min(1, longest / max(image.size.width * image.scale, image.size.height * image.scale))
1200 let pixels = CGSize(
1201 width: (image.size.width * image.scale * scale).rounded(),
1202 height: (image.size.height * image.scale * scale).rounded())
1203 let format = UIGraphicsImageRendererFormat()
1204 format.scale = 1
1205 let drawn = UIGraphicsImageRenderer(size: pixels, format: format).image { _ in
1206 image.draw(in: CGRect(origin: .zero, size: pixels))
1207 }
1208 guard let data = drawn.jpegData(compressionQuality: 0.85) else { return }
1209 // OneNote shows a picture's pixels at 96 per inch.
1210 var size = CGSize(width: pixels.width * 0.75, height: pixels.height * 0.75)
1211 if size.width > 220 { size = CGSize(width: 220, height: size.height * 220 / size.width) }
1212 insertPicture(data, size: size)
1213 }
1214
1215 /// Puts a JPEG or PNG at the caret at `size` points.
1216 func insertPicture(_ data: Data, size: CGSize) {
1217 guard let handle else { return }
1218 edit(external: true) {
1219 data.withUnsafeBytes { bytes in
1220 sb_view_insert_picture(
1221 handle, bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, Float(size.width),
1222 Float(size.height))
1223 }
1224 }
1225 }
1226
1227 // MARK: Recordings
1228
1229 /// Starts recording at the caret, under a line saying when; whether it started.
1230 func startRecording(video: Bool) -> Bool {
1231 guard let handle else { return false }
1232 let (day, time) = titleDate()
1233 return edit(external: true) { sb_view_start_recording(handle, video, day, time) }
1234 }
1235
1236 /// How far the recording has got in milliseconds, without its pauses, and whether it is
1237 /// paused; nil when nothing records.
1238 var recording: (ms: Int64, paused: Bool)? {
1239 guard let handle else { return nil }
1240 var paused = false
1241 let ms = sb_view_recording(handle, &paused)
1242 return ms < 0 ? nil : (ms, paused)
1243 }
1244
1245 /// Nothing typed while the recording is paused links to it.
1246 func pauseRecording(_ paused: Bool) {
1247 guard let handle else { return }
1248 sb_view_pause_recording(handle, paused)
1249 }
1250
1251 /// Puts the recording made where it started: a PCM WAV file, or with `video` the AVI
1252 /// file of `sb_movie_finish`. Nil forgets a recording whose file never came.
1253 func finishRecording(_ file: Data?, video: Bool) {
1254 guard let handle else { return }
1255 _ = edit(external: true) {
1256 guard let file else { return sb_view_finish_recording(handle, nil, 0, video) }
1257 return file.withUnsafeBytes { bytes in
1258 sb_view_finish_recording(handle, bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, video)
1259 }
1260 }
1261 }
1262
1263 /// The recording the last tap asked to play, with its files written out to play from.
1264 private func playRequest() -> PlayRequest? {
1265 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("Playback")
1266 guard let handle, let json = sb_view_play_request(handle, folder.path) else { return nil }
1267 defer { sb_string_free(json) }
1268 let decoder = JSONDecoder()
1269 decoder.keyDecodingStrategy = .convertFromSnakeCase
1270 return try? decoder.decode(PlayRequest.self, from: Data(String(cString: json).utf8))
1271 }
1272
1273 /// See Playback: the note linked at `ms` into the recording playing is highlighted; nil
1274 /// ends playback.
1275 func played(at ms: Int64?) {
1276 guard let handle, sb_view_played(handle, ms ?? -1) else { return }
1277 dirty = true
1278 }
1279
1280 /// Commits marked text, so it is stored before the app leaves the screen.
1281 func commitComposition() {
1282 guard markedTextRange != nil else { return }
1283 unmarkText()
1284 }
1285
1286 override var keyCommands: [UIKeyCommand]? {
1287 guard isFirstResponder else { return nil }
1288 // Ctrl+1 to Ctrl+9 in OneNote, where the Mac's Command stands for Control.
1289 let tags = Tags.all.prefix(Tags.keyed).enumerated().map { index, tag in
1290 UIKeyCommand(
1291 title: tag.name, action: #selector(formatted), input: "\(index + 1)", modifierFlags: .command,
1292 propertyList: 16 + index)
1293 }
1294 // The desktop's chords, as OneNote for Mac's.
1295 let formats: [(String, String, UIKeyModifierFlags, Int)] = [
1296 ("Strikethrough", "x", [.command, .shift], 3),
1297 ("Bullets", ".", .command, 4),
1298 ("Numbering", "/", .command, 5),
1299 ("Increase Indent", "]", .command, 6),
1300 ("Decrease Indent", "[", .command, 7),
1301 ("Remove Tag", "0", [.command, .control], 8),
1302 ("Clear Formatting", "n", [.command, .shift], 9),
1303 ("Highlight", "h", [.command, .control], 10),
1304 ("Decrease Indent", "\t", .shift, 7),
1305 ]
1306 let commands = tags + formats.map { title, input, modifiers, command in
1307 UIKeyCommand(
1308 title: title, action: #selector(formatted), input: input, modifierFlags: modifiers,
1309 propertyList: command)
1310 }
1311 for command in commands { command.wantsPriorityOverSystemBehavior = true }
1312 let done = UIKeyCommand(title: "Done", action: #selector(finished), input: UIKeyCommand.inputEscape)
1313 return commands + [done]
1314 }
1315
1316 @objc private func formatted(_ command: UIKeyCommand) {
1317 guard let command = command.propertyList as? Int else { return }
1318 apply(UInt8(command))
1319 }
1320
1321 @objc private func finished() { _ = resignFirstResponder() }
1322
1323 // MARK: Coordinates
1324
1325 /// A point in the scroll view's bounds as the canvas view's points from its corner.
1326 private func visible(_ point: CGPoint) -> CGPoint {
1327 CGPoint(x: point.x - contentOffset.x, y: point.y - contentOffset.y)
1328 }
1329
1330 private func bounded(_ rect: [Float]) -> CGRect {
1331 CGRect(
1332 x: CGFloat(rect[0]) + contentOffset.x, y: CGFloat(rect[1]) + contentOffset.y,
1333 width: CGFloat(rect[2]), height: CGFloat(rect[3]))
1334 }
1335
1336 // MARK: UIKeyInput
1337
1338 var hasText: Bool { handle.map { sb_text_length($0) > 0 } ?? false }
1339
1340 func insertText(_ text: String) {
1341 guard let handle else { return }
1342 edit { sb_insert(handle, text) }
1343 }
1344
1345 func deleteBackward() {
1346 guard let handle else { return }
1347 edit { sb_delete_backward(handle) }
1348 }
1349
1350 // MARK: UITextInput
1351
1352 func text(in range: UITextRange) -> String? {
1353 guard let handle, let text = sb_text(handle, UInt32(range.lower), UInt32(range.upper)) else { return nil }
1354 defer { sb_string_free(text) }
1355 return String(cString: text)
1356 }
1357
1358 private func replacing(_ range: UITextRange, with text: String) -> Bool {
1359 guard let handle else { return false }
1360 return sb_replace(handle, UInt32(range.lower), UInt32(range.upper), text)
1361 }
1362
1363 func replace(_ range: UITextRange, withText text: String) {
1364 edit { replacing(range, with: text) }
1365 }
1366
1367 private func choose(_ range: UITextRange) -> Bool {
1368 guard let handle else { return false }
1369 return sb_select(handle, UInt32(range.lower), UInt32(range.upper))
1370 }
1371
1372 var selectedTextRange: UITextRange? {
1373 get {
1374 // Outline previews move before their text geometry is committed.
1375 guard let handle, grip.state != .began, grip.state != .changed else { return nil }
1376 var range: [UInt32] = [0, 0]
1377 sb_selection(handle, &range)
1378 return Range(Int(range[0]), Int(range[1]))
1379 }
1380 set {
1381 guard let newValue else { return }
1382 if choose(newValue) { canvasPlace?.edited = true }
1383 sync()
1384 revealCaret()
1385 dirty = true
1386 changedSelection()
1387 }
1388 }
1389
1390 var markedTextRange: UITextRange? {
1391 guard let handle else { return nil }
1392 var range: [UInt32] = [0, 0]
1393 return sb_marked(handle, &range) ? Range(Int(range[0]), Int(range[1])) : nil
1394 }
1395
1396 var markedTextStyle: [NSAttributedString.Key: Any]?
1397
1398 func setMarkedText(_ markedText: String?, selectedRange: NSRange) {
1399 guard let handle else { return }
1400 edit {
1401 sb_set_marked(
1402 handle, markedText ?? "", UInt32(selectedRange.location),
1403 UInt32(selectedRange.location + selectedRange.length))
1404 }
1405 }
1406
1407 func unmarkText() {
1408 guard let handle else { return }
1409 inputDelegate?.selectionWillChange(self)
1410 inputDelegate?.textWillChange(self)
1411 sb_unmark(handle)
1412 canvasPlace?.edited = true
1413 sync()
1414 revealCaret()
1415 inputDelegate?.textDidChange(self)
1416 inputDelegate?.selectionDidChange(self)
1417 dirty = true
1418 changedSelection()
1419 }
1420
1421 var beginningOfDocument: UITextPosition { Position(0) }
1422 var endOfDocument: UITextPosition { Position(handle.map { Int(sb_text_length($0)) } ?? 0) }
1423
1424 func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? {
1425 Range(fromPosition.offset, toPosition.offset)
1426 }
1427
1428 func position(from position: UITextPosition, offset: Int) -> UITextPosition? {
1429 let target = position.offset + offset
1430 guard target >= 0, target <= endOfDocument.offset else { return nil }
1431 return Position(target)
1432 }
1433
1434 func position(
1435 from position: UITextPosition, in direction: UITextLayoutDirection, offset: Int
1436 ) -> UITextPosition? {
1437 switch direction {
1438 case .left: return self.position(from: position, offset: -offset)
1439 case .right: return self.position(from: position, offset: offset)
1440 default:
1441 let caret = caretRect(for: position)
1442 let step = (direction == .up ? -1 : 1) * CGFloat(offset) * caret.height
1443 return closestPosition(to: CGPoint(x: caret.midX, y: caret.midY + step))
1444 }
1445 }
1446
1447 func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult {
1448 position.offset < other.offset
1449 ? .orderedAscending : position.offset > other.offset ? .orderedDescending : .orderedSame
1450 }
1451
1452 func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int { toPosition.offset - from.offset }
1453
1454 func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? {
1455 direction == .left || direction == .up ? range.start : range.end
1456 }
1457
1458 func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection)
1459 -> UITextRange?
1460 {
1461 direction == .left || direction == .up
1462 ? Range(0, position.offset) : Range(position.offset, endOfDocument.offset)
1463 }
1464
1465 func baseWritingDirection(
1466 for position: UITextPosition, in direction: UITextStorageDirection
1467 ) -> NSWritingDirection { .leftToRight }
1468
1469 func setBaseWritingDirection(_ writingDirection: NSWritingDirection, for range: UITextRange) {}
1470
1471 func firstRect(for range: UITextRange) -> CGRect {
1472 selectionRects(for: range).first?.rect ?? caretRect(for: range.start)
1473 }
1474
1475 func caretRect(for position: UITextPosition) -> CGRect {
1476 guard let handle else { return .zero }
1477 var rect: [Float] = [0, 0, 0, 0]
1478 guard sb_caret_rect(handle, UInt32(position.offset), &rect) else { return .zero }
1479 var caret = bounded(rect)
1480 caret.origin.x -= 1
1481 caret.size.width = 2
1482 return caret
1483 }
1484
1485 func selectionRects(for range: UITextRange) -> [UITextSelectionRect] {
1486 guard let handle else { return [] }
1487 let capacity = 256
1488 var rects = [(Float, Float, Float, Float)](repeating: (0, 0, 0, 0), count: capacity)
1489 let count = min(capacity, sb_range_rects(handle, UInt32(range.lower), UInt32(range.upper), &rects, capacity))
1490 return (0..<count).map {
1491 let rect = rects[$0]
1492 return SelectionRect(
1493 bounded([rect.0, rect.1, rect.2, rect.3]), first: $0 == 0, last: $0 == count - 1)
1494 }
1495 }
1496
1497 func closestPosition(to point: CGPoint) -> UITextPosition? {
1498 guard let handle else { return nil }
1499 let local = visible(point)
1500 return Position(Int(sb_closest(handle, Float(local.x), Float(local.y))))
1501 }
1502
1503 func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? {
1504 let offset = closestPosition(to: point)?.offset ?? range.lower
1505 return Position(min(max(offset, range.lower), range.upper))
1506 }
1507
1508 func characterRange(at point: CGPoint) -> UITextRange? {
1509 guard let position = closestPosition(to: point) else { return nil }
1510 return Range(position.offset, min(position.offset + 1, endOfDocument.offset))
1511 }
1512}
1513
1514/// Words and sentences come from the text; lines come from the canvas's layout, which the
1515/// string tokenizer cannot see, so a tap after a line's last word stays on that line.
1516private final class LineTokenizer: UITextInputStringTokenizer {
1517 private unowned let canvas: CanvasView
1518
1519 init(canvas: CanvasView) {
1520 self.canvas = canvas
1521 super.init(textInput: canvas)
1522 }
1523
1524 private func forward(_ direction: UITextDirection) -> Bool {
1525 [UITextStorageDirection.forward.rawValue, UITextLayoutDirection.right.rawValue,
1526 UITextLayoutDirection.down.rawValue].contains(direction.rawValue)
1527 }
1528
1529 private func line(_ offset: Int) -> CGFloat { canvas.caretRect(for: Position(offset)).minY.rounded() }
1530
1531 private func atEdge(_ offset: Int, forward: Bool) -> Bool {
1532 let end = canvas.endOfDocument.offset
1533 return forward
1534 ? offset >= end || line(offset + 1) != line(offset)
1535 : offset <= 0 || line(offset - 1) != line(offset)
1536 }
1537
1538 private func edge(_ offset: Int, forward: Bool) -> Int {
1539 var offset = offset
1540 while !atEdge(offset, forward: forward) { offset += forward ? 1 : -1 }
1541 return offset
1542 }
1543
1544 override func isPosition(
1545 _ position: UITextPosition, atBoundary granularity: UITextGranularity, inDirection direction: UITextDirection
1546 ) -> Bool {
1547 guard granularity == .line else {
1548 return super.isPosition(position, atBoundary: granularity, inDirection: direction)
1549 }
1550 return atEdge(position.offset, forward: forward(direction))
1551 }
1552
1553 override func position(
1554 from position: UITextPosition, toBoundary granularity: UITextGranularity, inDirection direction: UITextDirection
1555 ) -> UITextPosition? {
1556 guard granularity == .line else {
1557 return super.position(from: position, toBoundary: granularity, inDirection: direction)
1558 }
1559 return Position(edge(position.offset, forward: forward(direction)))
1560 }
1561
1562 override func rangeEnclosingPosition(
1563 _ position: UITextPosition, with granularity: UITextGranularity, inDirection direction: UITextDirection
1564 ) -> UITextRange? {
1565 guard granularity == .line else {
1566 return super.rangeEnclosingPosition(position, with: granularity, inDirection: direction)
1567 }
1568 return Range(edge(position.offset, forward: false), edge(position.offset, forward: true))
1569 }
1570
1571 override func isPosition(
1572 _ position: UITextPosition, withinTextUnit granularity: UITextGranularity, inDirection direction: UITextDirection
1573 ) -> Bool {
1574 granularity == .line || super.isPosition(position, withinTextUnit: granularity, inDirection: direction)
1575 }
1576}
1577
1578/// A short instruction floating at the bottom of the page until the gesture it asks for.
1579private final class Hint: UIVisualEffectView {
1580 private let label = UILabel()
1581
1582 init() {
1583 super.init(effect: UIBlurEffect(style: .systemThickMaterial))
1584 label.font = .preferredFont(forTextStyle: .subheadline)
1585 label.textAlignment = .center
1586 label.translatesAutoresizingMaskIntoConstraints = false
1587 contentView.addSubview(label)
1588 NSLayoutConstraint.activate([
1589 label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
1590 label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
1591 label.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
1592 label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
1593 ])
1594 layer.cornerRadius = 18
1595 clipsToBounds = true
1596 isUserInteractionEnabled = false
1597 }
1598
1599 required init?(coder: NSCoder) { fatalError() }
1600
1601 /// Shows `text` over the bottom of `view`'s visible area, and says it.
1602 func show(_ text: String, in view: UIScrollView) {
1603 label.text = text
1604 guard let host = view.superview else { return }
1605 translatesAutoresizingMaskIntoConstraints = false
1606 host.addSubview(self)
1607 NSLayoutConstraint.activate([
1608 centerXAnchor.constraint(equalTo: host.centerXAnchor),
1609 bottomAnchor.constraint(equalTo: host.keyboardLayoutGuide.topAnchor, constant: -16),
1610 ])
1611 alpha = 0
1612 UIView.animate(withDuration: 0.2) { self.alpha = 1 }
1613 UIAccessibility.post(notification: .announcement, argument: text)
1614 }
1615
1616 func hide() {
1617 guard superview != nil else { return }
1618 UIView.animate(withDuration: 0.2, animations: { self.alpha = 0 }) { _ in self.removeFromSuperview() }
1619 }
1620}