oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2#if os(macOS)
3import AppKit
4#else
5import UIKit
6#endif
7
8extension 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