import Foundation #if os(macOS) import AppKit #else import UIKit #endif extension NSAttributedString.Key { static let docsFirstMargin = NSAttributedString.Key("OficinaDocsFirstMargin") } /// Collapse CSS block margins before handing paragraphs to TextKit, whose /// paragraph spacing otherwise adds adjacent margins together. @MainActor enum DocumentBlockSpacing { private struct Block { var id: String; var before: CGFloat; var after: CGFloat } static func apply(_ nodes: [CRDTNode], to output: NSMutableAttributedString) { let blocks = nodes.enumerated().flatMap { index, node in flatten(node, clearBefore: index == 0, clearAfter: index == nodes.count - 1) } guard let first = blocks.first, output.length > 0 else { return } output.addAttribute(.docsFirstMargin, value: first.before, range: NSRange(location: 0, length: 1)) var ranges: [String: NSRange] = [:] output.enumerateAttribute(.docsBlock, in: NSRange(location: 0, length: output.length)) { value, range, _ in guard let id = value as? String else { return } ranges[id] = ranges[id].map { NSUnionRange($0, range) } ?? range } for (index, block) in blocks.enumerated() { guard let range = ranges[block.id], let existing = output.attribute(.paragraphStyle, at: range.location, effectiveRange: nil) as? NSParagraphStyle, let style = existing.mutableCopy() as? NSMutableParagraphStyle else { continue } style.paragraphSpacingBefore = 0 style.paragraphSpacing = index + 1 < blocks.count ? max(block.after, blocks[index + 1].before) : block.after output.addAttribute(.paragraphStyle, value: style, range: range) } } static func margins(_ node: CRDTNode) -> (CGFloat, CGFloat) { let blocks = flatten(node) return (blocks.first?.before ?? 0, blocks.last?.after ?? 0) } private static func flatten(_ node: CRDTNode, clearBefore: Bool = false, clearAfter: Bool = false) -> [Block] { if ["bullet_list", "ordered_list", "list_item", "blockquote"].contains(node.type) { let children = node.children ?? [] var blocks = children.enumerated().flatMap { index, child in flatten(child, clearAfter: node.type == "list_item" && index == children.count - 1) } guard !blocks.isEmpty else { return [] } if node.type == "blockquote" { blocks[0].before = max(clearBefore ? 0 : 12, blocks[0].before) blocks[blocks.count - 1].after = max(clearAfter ? 0 : 12, blocks[blocks.count - 1].after) } return blocks } let level = node.type == "heading" ? max(1, min(6, Int(node.attrs?["level"] ?? "1") ?? 1)) : 0 let size: CGFloat = level == 1 ? 19.2 : level == 2 ? 15.6 : level == 3 ? 13.2 : 12 return [Block(id: node.id, before: level > 0 && !clearBefore ? size * 1.5 : 0, after: level > 0 && !clearAfter ? size * 0.5 : 0)] } }