import Foundation #if os(macOS) import AppKit @MainActor class DocsPagedTextView: NSTextView { var paged = false { didSet { if oldValue != paged { needsLayout = true; needsDisplay = true } } } private var updatingGeometry = false private var updatingPagination = false private var separators: [CGFloat] = [] private var measuredRevision: UInt64? private var measuredWidth: CGFloat = 0 private var measuredPaged = false private var measuredContent: NSAttributedString? func invalidatePagination() { measuredRevision = nil // TextKit may ask for caret geometry before the next NSView layout. // Prepare the complete page plan now, on an independent layout stack. if !updatingGeometry { updatePagination() } needsLayout = true } override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) { let storage = NSTextStorage() let manager = DocsLayoutManager() manager.usesFontLeading = false let pages = DocsPageTextContainer(size: CGSize(width: max(320, frameRect.width), height: 1_000_000)) pages.lineFragmentPadding = 0 storage.addLayoutManager(manager) manager.addTextContainer(pages) super.init(frame: frameRect, textContainer: pages) textContainerInset = .zero // This view owns its paginated height; NSTextView auto-sizing uses a // different used rect and briefly clips the current line during input. isVerticallyResizable = false isHorizontallyResizable = false drawsBackground = true textColor = .black } override convenience init(frame: NSRect) { self.init(frame: frame, textContainer: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") } // Page margins are already part of DocsPageTextContainer's line geometry. // NSTextView's inferred origin moves during incremental layout and can put // its native insertion indicator at a different origin from the glyphs. override var textContainerOrigin: NSPoint { .zero } override func didChangeText() { // Size the document before AppKit scrolls the newly edited caret. needsLayout = true layoutSubtreeIfNeeded() super.didChangeText() } private var viewportWidth: CGFloat { max(636, enclosingScrollView?.contentView.bounds.width ?? bounds.width) } private func updatePagination() { guard !updatingPagination else { return } updatingPagination = true defer { updatingPagination = false } guard let pages = textContainer as? DocsPageTextContainer, let manager = layoutManager as? DocsLayoutManager, let storage = textStorage else { return } let width = viewportWidth let size = CGSize(width: width, height: 1_000_000) if pages.size != size { pages.size = size } pages.geometry = DocsPageGeometry(viewportWidth: width, paged: false) let revision = manager.storageRevision guard measuredRevision != revision || measuredWidth != width || measuredPaged != paged else { return } let content = paged ? DocumentPageLayout.measurementContent(storage) : nil if measuredWidth == width, measuredPaged == paged, let content, measuredContent?.isEqual(to: content) == true { measuredRevision = revision return } var plan = DocumentPageLayout.Result() if let content { // Never clear the live container to measure unpaginated content: doing // so exposes a second set of caret/scroll coordinates during edits. let measurement = NSTextStorage(attributedString: content) let measurementManager = DocsLayoutManager() let measurementContainer = DocsPageTextContainer(size: size) measurementContainer.lineFragmentPadding = 0 measurementContainer.geometry = pages.geometry measurement.addLayoutManager(measurementManager) measurementManager.addTextContainer(measurementContainer) measurementManager.ensureLayout(for: measurementContainer) plan = DocumentPageLayout.measure(storage: measurement, manager: measurementManager, container: measurementContainer) } if pages.blockStarts != plan.starts || separators != plan.separators { needsDisplay = true } pages.blockStarts = plan.starts separators = plan.separators measuredRevision = revision; measuredWidth = width; measuredPaged = paged measuredContent = content } override func layout() { guard !updatingGeometry, let pages = textContainer as? DocsPageTextContainer, let manager = layoutManager else { return } updatingGeometry = true defer { updatingGeometry = false } updatePagination() // AppKit must only lay out the caret after final page coordinates exist. super.layout() manager.ensureLayout(for: pages) let width = viewportWidth let bottom = manager.numberOfGlyphs > 0 ? manager.lineFragmentRect(forGlyphAt: manager.numberOfGlyphs - 1, effectiveRange: nil).maxY : DocsPageGeometry.verticalMargin let height = pages.geometry.documentHeight(usedHeight: bottom) if abs(frame.width - width) > 0.5 || abs(frame.height - height) > 0.5 { setFrameSize(CGSize(width: width, height: height)) } } override func drawBackground(in dirtyRect: NSRect) { NSGraphicsContext.saveGraphicsState() defer { NSGraphicsContext.restoreGraphicsState() } dirtyRect.clip() NSColor.windowBackgroundColor.setFill() dirtyRect.fill() if let pages = textContainer as? DocsPageTextContainer { let geometry = pages.geometry NSColor.white.setFill() CGRect(x: geometry.paperX, y: 0, width: geometry.paperWidth, height: bounds.height).fill() if paged { for (index, y) in separators.enumerated() where y + 18 >= dirtyRect.minY && y <= dirtyRect.maxY { NSColor.windowBackgroundColor.setFill() CGRect(x: geometry.paperX, y: y, width: geometry.paperWidth, height: 18).fill() let label = String(localized: "docs.editor.page.number", bundle: DocsResources.bundle) .replacingOccurrences(of: "{n}", with: String(index + 2)) (label as NSString).draw(at: CGPoint(x: geometry.paperX + geometry.paperWidth - 80, y: y + 3), withAttributes: [.font: NSFont.systemFont(ofSize: 10), .foregroundColor: NSColor.darkGray]) } } } } } #else import UIKit @MainActor class DocsPagedTextView: UITextView { var paged = false { didSet { if oldValue != paged { setNeedsLayout(); setNeedsDisplay() } } } private var updatingGeometry = false override init(frame: CGRect = .zero, textContainer: NSTextContainer? = nil) { let storage = NSTextStorage() let manager = NSLayoutManager() manager.usesFontLeading = false let pages = DocsPageTextContainer(size: CGSize(width: max(320, frame.width), height: 1_000_000)) pages.lineFragmentPadding = 0 storage.addLayoutManager(manager) manager.addTextContainer(pages) super.init(frame: frame, textContainer: pages) textContainerInset = .zero backgroundColor = .clear textColor = .black isOpaque = false } required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") } override func layoutSubviews() { super.layoutSubviews() guard !updatingGeometry, let pages = textContainer as? DocsPageTextContainer else { return } updatingGeometry = true defer { updatingGeometry = false } let width = max(160, bounds.width) let size = CGSize(width: width, height: 1_000_000) if pages.size != size { pages.size = size } pages.geometry = DocsPageGeometry(viewportWidth: width, paged: paged) layoutManager.ensureLayout(for: pages) let height = pages.geometry.documentHeight(usedHeight: layoutManager.usedRect(for: pages).maxY) if abs(contentSize.height - height) > 0.5 || abs(contentSize.width - width) > 0.5 { contentSize = CGSize(width: width, height: height) } setNeedsDisplay() } override func draw(_ rect: CGRect) { UIColor.systemGroupedBackground.setFill() UIRectFill(rect) if let pages = textContainer as? DocsPageTextContainer { let geometry = pages.geometry if paged { for page in geometry.page(at: rect.minY)...geometry.page(at: rect.maxY) { UIColor.white.setFill() UIRectFill(geometry.pageRect(page)) let label = String(localized: "docs.editor.page.number", bundle: DocsResources.bundle) .replacingOccurrences(of: "{n}", with: String(page + 1)) (label as NSString).draw(at: CGPoint(x: geometry.paperX + geometry.paperWidth - 80, y: CGFloat(page) * geometry.stride + DocsPageGeometry.paperHeight - 35), withAttributes: [.font: UIFont.systemFont(ofSize: 10), .foregroundColor: UIColor.darkGray]) } } else { UIColor.white.setFill() UIRectFill(CGRect(x: geometry.paperX, y: 0, width: geometry.paperWidth, height: max(bounds.height, contentSize.height))) } } super.draw(rect) } } #endif