| 1 | import Foundation |
| 2 | #if os(macOS) |
| 3 | import AppKit |
| 4 | #else |
| 5 | import UIKit |
| 6 | #endif |
| 7 | |
| 8 | extension NSAttributedString.Key { static let docsFirstMargin = NSAttributedString.Key("OficinaDocsFirstMargin") } |
| 9 | |
| 10 | /// Collapse CSS block margins before handing paragraphs to TextKit, whose |
| 11 | /// paragraph spacing otherwise adds adjacent margins together. |
| 12 | @MainActor enum DocumentBlockSpacing { |
| 13 | private struct Block { var id: String; var before: CGFloat; var after: CGFloat } |
| 14 | static func apply(_ nodes: [CRDTNode], to output: NSMutableAttributedString) { |
| 15 | let blocks = nodes.enumerated().flatMap { index, node in |
| 16 | flatten(node, clearBefore: index == 0, clearAfter: index == nodes.count - 1) |
| 17 | } |
| 18 | guard let first = blocks.first, output.length > 0 else { return } |
| 19 | output.addAttribute(.docsFirstMargin, value: first.before, range: NSRange(location: 0, length: 1)) |
| 20 | var ranges: [String: NSRange] = [:] |
| 21 | output.enumerateAttribute(.docsBlock, in: NSRange(location: 0, length: output.length)) { value, range, _ in |
| 22 | guard let id = value as? String else { return } |
| 23 | ranges[id] = ranges[id].map { NSUnionRange($0, range) } ?? range |
| 24 | } |
| 25 | for (index, block) in blocks.enumerated() { |
| 26 | guard let range = ranges[block.id], let existing = output.attribute(.paragraphStyle, at: range.location, effectiveRange: nil) as? NSParagraphStyle, |
| 27 | let style = existing.mutableCopy() as? NSMutableParagraphStyle else { continue } |
| 28 | style.paragraphSpacingBefore = 0 |
| 29 | style.paragraphSpacing = index + 1 < blocks.count ? max(block.after, blocks[index + 1].before) : block.after |
| 30 | output.addAttribute(.paragraphStyle, value: style, range: range) |
| 31 | } |
| 32 | } |
| 33 | static func margins(_ node: CRDTNode) -> (CGFloat, CGFloat) { |
| 34 | let blocks = flatten(node) |
| 35 | return (blocks.first?.before ?? 0, blocks.last?.after ?? 0) |
| 36 | } |
| 37 | private static func flatten(_ node: CRDTNode, clearBefore: Bool = false, clearAfter: Bool = false) -> [Block] { |
| 38 | if ["bullet_list", "ordered_list", "list_item", "blockquote"].contains(node.type) { |
| 39 | let children = node.children ?? [] |
| 40 | var blocks = children.enumerated().flatMap { index, child in |
| 41 | flatten(child, clearAfter: node.type == "list_item" && index == children.count - 1) |
| 42 | } |
| 43 | guard !blocks.isEmpty else { return [] } |
| 44 | if node.type == "blockquote" { |
| 45 | blocks[0].before = max(clearBefore ? 0 : 12, blocks[0].before) |
| 46 | blocks[blocks.count - 1].after = max(clearAfter ? 0 : 12, blocks[blocks.count - 1].after) |
| 47 | } |
| 48 | return blocks |
| 49 | } |
| 50 | let level = node.type == "heading" ? max(1, min(6, Int(node.attrs?["level"] ?? "1") ?? 1)) : 0 |
| 51 | let size: CGFloat = level == 1 ? 19.2 : level == 2 ? 15.6 : level == 3 ? 13.2 : 12 |
| 52 | return [Block(id: node.id, before: level > 0 && !clearBefore ? size * 1.5 : 0, after: level > 0 && !clearAfter ? size * 0.5 : 0)] |
| 53 | } |
| 54 | } |
| 55 | |