oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import UniformTypeIdentifiers
3#if os(macOS)
4import AppKit
5#else
6import UIKit
7#endif
8
9@MainActor extension DocsRichClipboard {
10 private static let nativeType = "net.amadan.oficina.docs.fragment"
11 static func readNative() -> [JSONValue]? {
12 #if os(macOS)
13 let data = NSPasteboard.general.data(forType: NSPasteboard.PasteboardType(nativeType))
14 #else
15 let data = UIPasteboard.general.data(forPasteboardType: nativeType)
16 #endif
17 guard let data, data.count <= 16 << 20,
18 let values = try? JSONDecoder().decode([JSONValue].self, from: data), !values.isEmpty else { return nil }
19 // This is still untrusted input. The replica validates the same node
20 // vocabulary and transaction limits as every other paste command.
21 return values
22 }
23 @discardableResult static func write(_ source: NSAttributedString) -> Bool {
24 guard source.length > 0 else { return false }
25 let values = blocks(source)
26 guard !values.isEmpty, let data = try? JSONEncoder().encode(values), data.count <= 16 << 20 else { return false }
27 let html = html(values)
28 let rtf = try? source.data(from: NSRange(location: 0, length: source.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf])
29 #if os(macOS)
30 let board = NSPasteboard.general
31 board.clearContents()
32 guard board.setData(data, forType: NSPasteboard.PasteboardType(nativeType)) else { return false }
33 _ = board.setString(source.string, forType: .string)
34 _ = board.setString(html, forType: .html)
35 if let rtf { _ = board.setData(rtf, forType: .rtf) }
36 #else
37 var item: [String: Any] = [nativeType: data, UTType.utf8PlainText.identifier: source.string, UTType.html.identifier: Data(html.utf8)]
38 if let rtf { item[UTType.rtf.identifier] = rtf }
39 UIPasteboard.general.setItems([item])
40 #endif
41 return true
42 }
43 static func html(_ values: [JSONValue]) -> String { values.map(htmlNode).joined() }
44 private static func escape(_ value: String) -> String {
45 value.replacingOccurrences(of: "&", with: "&amp;").replacingOccurrences(of: "<", with: "&lt;")
46 .replacingOccurrences(of: ">", with: "&gt;").replacingOccurrences(of: "\"", with: "&quot;")
47 }
48 private static func string(_ value: JSONValue?) -> String {
49 if case .string(let result) = value { return result }; return ""
50 }
51 private static func htmlNode(_ node: JSONValue) -> String {
52 guard case .object(let object) = node else { return "" }
53 let type = string(object["type"])
54 let attrs: [String: JSONValue]
55 if case .object(let value)? = object["attrs"] { attrs = value } else { attrs = [:] }
56 if type == "text", case .array(let runs)? = object["runs"] { return runs.map(htmlRun).joined() }
57 let children: [JSONValue]
58 if case .array(let value)? = object["children"] { children = value } else { children = [] }
59 let body = html(children)
60 if type == "hard_break" { return "<br>" }
61 if type == "horizontal_rule" { return "<hr>" }
62 if type == "image" {
63 return "<img" + ["src", "alt", "title"].compactMap { key in attrs[key].map { " \(key)=\"\(escape(string($0)))\"" } }.joined() + ">"
64 }
65 let tag: String
66 switch type {
67 case "heading": tag = "h" + String(max(1, min(6, Int(string(attrs["level"])) ?? 1)))
68 case "bullet_list": tag = "ul"
69 case "ordered_list": tag = "ol"
70 case "list_item": tag = "li"
71 case "blockquote": tag = "blockquote"
72 case "code_block": tag = "pre"
73 default: tag = "p"
74 }
75 var attributes = ""
76 if type == "ordered_list", let order = Int(string(attrs["order"])) { attributes += " start=\"\(order)\"" }
77 var classes: [String] = []
78 if ["center", "right"].contains(string(attrs["align"])) { classes.append("docs-align-" + string(attrs["align"])) }
79 if let indent = Int(string(attrs["indent"])), indent > 0 { classes.append("docs-indent-\(min(8, indent))") }
80 if !classes.isEmpty { attributes += " class=\"" + classes.joined(separator: " ") + "\"" }
81 return "<\(tag)\(attributes)>\(body)</\(tag)>"
82 }
83 private static func htmlRun(_ run: JSONValue) -> String {
84 guard case .object(let values) = run else { return "" }
85 var output = escape(string(values["insert"]))
86 guard case .object(let marks)? = values["marks"] else { return output }
87 for key in marks.keys.sorted() {
88 if key == "strong" { output = "<strong>\(output)</strong>" }
89 else if key == "em" { output = "<em>\(output)</em>" }
90 else if key == "code" { output = "<code>\(output)</code>" }
91 else if key == "link", case .object(let link)? = marks[key], safeLink(string(link["href"])) {
92 let title = link["title"].map { " title=\"\(escape(string($0)))\"" } ?? ""
93 output = "<a href=\"\(escape(string(link["href"])))\"\(title)>\(output)</a>"
94 }
95 else if ["font_sans", "font_serif", "font_mono"].contains(key) {
96 let family = key == "font_serif" ? "serif" : key == "font_mono" ? "monospace" : "sans-serif"
97 output = "<span class=\"docs-\(key.replacingOccurrences(of: "_", with: "-"))\" style=\"font-family:\(family)\">\(output)</span>"
98 } else if key.hasPrefix("size_") {
99 let suffix = String(key.dropFirst(5))
100 let size = ["sm": 10, "lg": 14, "xl": 18][suffix] ?? Int(suffix)
101 if let size, sizes.contains(size), size != 12 { output = "<span class=\"docs-size-\(suffix)\" style=\"font-size:\(size)pt\">\(output)</span>" }
102 }
103 }
104 return output
105 }
106}
107