| 1 | import Foundation |
| 2 | #if os(macOS) |
| 3 | import AppKit |
| 4 | #else |
| 5 | import UIKit |
| 6 | #endif |
| 7 | |
| 8 | /// Personal layout only: never adds characters or attributes to CRDT content. |
| 9 | /// Letter paper uses the exporter's 612 × 792 point geometry and 72 point margins. |
| 10 | /// Narrow screens reflow text with smaller side margins, like the web editor. |
| 11 | struct DocsPageGeometry: Equatable, Sendable { |
| 12 | var viewportWidth: CGFloat |
| 13 | var paged: Bool |
| 14 | static let paperHeight: CGFloat = 792 |
| 15 | static let verticalMargin: CGFloat = 72 |
| 16 | static let gap: CGFloat = 24 |
| 17 | var paperWidth: CGFloat { |
| 18 | #if os(macOS) |
| 19 | 612 |
| 20 | #else |
| 21 | min(612, max(160, viewportWidth - 24)) |
| 22 | #endif |
| 23 | } |
| 24 | var paperX: CGFloat { max(0, (viewportWidth - paperWidth) / 2) } |
| 25 | var sideMargin: CGFloat { paperWidth < 480 ? 20 : 72 } |
| 26 | var textX: CGFloat { paperX + sideMargin } |
| 27 | var textWidth: CGFloat { paperWidth - sideMargin * 2 } |
| 28 | var stride: CGFloat { Self.paperHeight + Self.gap } |
| 29 | var bodyHeight: CGFloat { Self.paperHeight - Self.verticalMargin * 2 } |
| 30 | func page(at y: CGFloat) -> Int { paged ? max(0, Int(floor(max(0, y) / stride))) : 0 } |
| 31 | func pageRect(_ index: Int) -> CGRect { |
| 32 | CGRect(x: paperX, y: CGFloat(index) * stride, width: paperWidth, height: Self.paperHeight) |
| 33 | } |
| 34 | func lineRect(_ proposal: CGRect) -> CGRect { |
| 35 | var rect = proposal |
| 36 | rect.origin.x = textX + proposal.minX |
| 37 | rect.size.width = max(0, min(proposal.width, textWidth - proposal.minX)) |
| 38 | if !paged { |
| 39 | rect.origin.y = max(Self.verticalMargin, proposal.minY) |
| 40 | return rect |
| 41 | } |
| 42 | let index = page(at: proposal.minY) |
| 43 | let top = CGFloat(index) * stride + Self.verticalMargin |
| 44 | let bottom = top + bodyHeight |
| 45 | rect.origin.y = max(top, proposal.minY) |
| 46 | // Oversized attachment lines stay on one page instead of causing a loop. |
| 47 | if rect.height <= bodyHeight && rect.maxY > bottom { |
| 48 | rect.origin.y = CGFloat(index + 1) * stride + Self.verticalMargin |
| 49 | } |
| 50 | return rect |
| 51 | } |
| 52 | func documentHeight(usedHeight: CGFloat) -> CGFloat { |
| 53 | if !paged { return max(Self.paperHeight, usedHeight + Self.verticalMargin) } |
| 54 | return CGFloat(page(at: max(Self.verticalMargin, usedHeight - 1)) + 1) * stride - Self.gap |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | final class DocsPageTextContainer: NSTextContainer { |
| 59 | #if os(macOS) |
| 60 | var blockStarts: [Int: CGFloat] = [:] { |
| 61 | didSet { if oldValue != blockStarts { layoutManager?.textContainerChangedGeometry(self) } } |
| 62 | } |
| 63 | #endif |
| 64 | var geometry = DocsPageGeometry(viewportWidth: 612, paged: false) { |
| 65 | didSet { |
| 66 | if oldValue != geometry { layoutManager?.textContainerChangedGeometry(self) } |
| 67 | } |
| 68 | } |
| 69 | override var isSimpleRectangularTextContainer: Bool { false } |
| 70 | override func lineFragmentRect( |
| 71 | forProposedRect proposedRect: CGRect, at characterIndex: Int, |
| 72 | writingDirection baseWritingDirection: NSWritingDirection, |
| 73 | remaining remainingRect: UnsafeMutablePointer<CGRect>? |
| 74 | ) -> CGRect { |
| 75 | remainingRect?.pointee = .zero |
| 76 | var rect = geometry.lineRect(proposedRect) |
| 77 | #if os(macOS) |
| 78 | if let top = blockStarts[characterIndex] { rect.origin.y = max(rect.minY, top) } |
| 79 | #endif |
| 80 | if let storage = layoutManager?.textStorage, characterIndex < storage.length { |
| 81 | if characterIndex == 0, let margin = storage.attribute(.docsFirstMargin, at: 0, effectiveRange: nil) as? CGFloat { |
| 82 | rect.origin.y = max(rect.minY, DocsPageGeometry.verticalMargin + margin) |
| 83 | } |
| 84 | var range = NSRange() |
| 85 | if let padding = storage.attribute(.docsCodeStart, at: characterIndex, effectiveRange: &range) as? CGFloat, |
| 86 | range.location == characterIndex { |
| 87 | rect.origin.x += padding |
| 88 | rect.size.width = max(0, rect.width - padding) |
| 89 | } |
| 90 | } |
| 91 | return rect |
| 92 | } |
| 93 | } |
| 94 | |