Files
edgeever/apps/ios/EdgeEver/Editor/TipTapContentSource.swift
T

147 lines
7.2 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// Pure policy for which TipTap payload to push (JSON vs Markdown).
/// Extracted so unit tests can lock viewer Markdown rendering without a simulator UI path.
enum TipTapContentSource: Sendable {
struct Decision: Equatable, Sendable {
var useJSON: Bool
var payload: String
var fingerprint: String
}
private static let emptyStub = "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"}]}"
static func resolve(mode: TipTapMode, documentJSON: String, markdown: String) -> Decision {
let json = documentJSON.trimmingCharacters(in: .whitespacesAndNewlines)
let mdTrim = markdown.trimmingCharacters(in: .whitespacesAndNewlines)
let jsonUsable = !json.isEmpty && json != emptyStub
// Detail/viewer: prefer JSON when it carries image width attrs (markdown drops them).
// Fall back to markdown for empty/stub JSON or when markdown encodes richer structure
// (tables / headings) that a flattened contentJson lost.
if mode == .viewer {
// Visual diagram metadata lives only in Markdown. The editor bundle
// parses the IR and mounts read-only X6; invalid envelopes keep the
// Mermaid fence as degraded viewer content.
if markdown.contains("<!-- edgeever-diagram-v1:") {
return Decision(useJSON: false, payload: markdown, fingerprint: "md:\(markdown)")
}
// Structured tables keep their JSON record model in the same comment.
// The viewer shows the readable Markdown table and never the marker.
if markdown.contains("<!-- edgeever-table-v1:") {
let stripped = stripStructuredTableMarker(markdown)
return Decision(useJSON: false, payload: stripped, fingerprint: "table:\(stripped)")
}
if markdown.contains("<!-- edgeever-poster-v1:") {
let stripped = markdown.replacingOccurrences(of: #"<!--\s*edgeever-poster-v1:[\s\S]*?-->"#, with: "", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines)
return Decision(useJSON: false, payload: stripped, fingerprint: "poster:\(stripped)")
}
if markdown.contains("<!-- edgeever-infographic-v1:") {
let stripped = stripInfographicMarker(markdown)
return Decision(useJSON: false, payload: stripped, fingerprint: "infographic:\(stripped)")
}
if jsonUsable {
if jsonHasImageWidth(json) {
return Decision(useJSON: true, payload: documentJSON, fingerprint: "json:\(json)")
}
if !mdTrim.isEmpty, markdownIsStructurallyRicher(markdown, thanJSON: json) {
return Decision(useJSON: false, payload: markdown, fingerprint: "md:\(markdown)")
}
// Non-empty JSON still preferred so image nodes keep nodeView + ⋯ chrome.
return Decision(useJSON: true, payload: documentJSON, fingerprint: "json:\(json)")
}
if !mdTrim.isEmpty {
return Decision(useJSON: false, payload: markdown, fingerprint: "md:\(markdown)")
}
}
// Editor: open from markdown when it encodes richer structure than JSON
// (demo / web-authored notes), so the user does not edit flattened plain text.
if mode == .editor, !mdTrim.isEmpty, markdownIsStructurallyRicher(markdown, thanJSON: json) {
return Decision(useJSON: false, payload: markdown, fingerprint: "md:\(markdown)")
}
if jsonUsable {
return Decision(useJSON: true, payload: documentJSON, fingerprint: "json:\(json)")
}
return Decision(useJSON: false, payload: markdown, fingerprint: "md:\(markdown)")
}
static func stripStructuredTableMarker(_ markdown: String) -> String {
markdown.replacingOccurrences(
of: #"<!--\s*edgeever-table-v1:[\s\S]*?-->"#,
with: "",
options: .regularExpression
).trimmingCharacters(in: .whitespacesAndNewlines)
}
static func stripInfographicMarker(_ markdown: String) -> String {
markdown.replacingOccurrences(
of: #"<!--\s*edgeever-infographic-v1:[\s\S]*?-->"#,
with: "",
options: .regularExpression
).trimmingCharacters(in: .whitespacesAndNewlines)
}
/// True when TipTap JSON stores at least one image `width` (25–100 display size).
static func jsonHasImageWidth(_ json: String) -> Bool {
// Match `"width":50` / `"width": 50` near image nodes without full parse.
json.range(of: #""width"\s*:\s*\d+"#, options: .regularExpression) != nil
}
static func markdownIsStructurallyRicher(_ markdown: String, thanJSON json: String) -> Bool {
let markdownHasMath = markdown.range(
of: #"(?s)(?<!\\)\$\$.*?(?<!\\)\$\$|(?<!\\)\$(?!\$|\d)[^\n$]+?(?<!\\)\$"#,
options: .regularExpression
) != nil
let jsonHasMath = json.contains(#""type":"inlineMath""#)
|| json.contains(#""type": "inlineMath""#)
|| json.contains(#""type":"blockMath""#)
|| json.contains(#""type": "blockMath""#)
if markdownHasMath && !jsonHasMath {
return true
}
let markdownHasTaskList = markdown.range(
of: #"(?m)^\s*[-*+]\s+\[[ xX]\]\s*"#,
options: .regularExpression
) != nil
let jsonHasTaskList = json.contains(#""type":"taskList""#)
|| json.contains(#""type": "taskList""#)
if markdownHasTaskList && !jsonHasTaskList {
return true
}
let md = structureScore(markdown: markdown)
let js = structureScore(json: json)
return md >= 2 && md > js
}
static func structureScore(markdown: String) -> Int {
var score = 0
if markdown.contains("```") { score += 3 }
if markdown.range(of: #"(?m)^#{1,6}\s+\S"#, options: .regularExpression) != nil { score += 3 }
if markdown.contains("|") && markdown.contains("\n") { score += 2 }
if markdown.range(of: #"(?m)^(\s*[-*+]|\s*\d+\.)\s+\S"#, options: .regularExpression) != nil { score += 2 }
if markdown.range(of: #"(?m)^>\s+\S"#, options: .regularExpression) != nil { score += 1 }
if markdown.contains("**") || markdown.contains("__") { score += 1 }
if markdown.contains("![") { score += 1 }
if markdown.contains("$$") { score += 3 }
return score
}
static func structureScore(json: String) -> Int {
guard !json.isEmpty else { return 0 }
var score = 0
if json.contains("\"heading\"") { score += 3 }
if json.contains("\"table\"") || json.contains("\"tableRow\"") { score += 2 }
if json.contains("\"codeBlock\"") { score += 3 }
if json.contains("\"bulletList\"") || json.contains("\"orderedList\"") || json.contains("\"taskList\"") { score += 2 }
if json.contains("\"blockquote\"") { score += 1 }
if json.contains("\"bold\"") || json.contains("\"italic\"") { score += 1 }
if json.contains("\"type\":\"image\"") || json.contains("\"type\": \"image\"") { score += 1 }
if json.contains("\"inlineMath\"") || json.contains("\"blockMath\"") { score += 3 }
return score
}
}