| 1 | import Foundation |
| 2 | import UniformTypeIdentifiers |
| 3 | #if os(macOS) |
| 4 | import AppKit |
| 5 | #else |
| 6 | import 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: "&").replacingOccurrences(of: "<", with: "<") |
| 46 | .replacingOccurrences(of: ">", with: ">").replacingOccurrences(of: "\"", with: """) |
| 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 | |