mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-18 10:31:50 +08:00
Clean Up
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 56;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -326,9 +326,13 @@
|
|||||||
);
|
);
|
||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";
|
||||||
@@ -521,7 +525,7 @@
|
|||||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 73YMMDVT2M;
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 73YMMDVT2M;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -559,7 +563,7 @@
|
|||||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 73YMMDVT2M;
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 73YMMDVT2M;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
+6
-24
@@ -5,35 +5,17 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apollographql/apollo-ios",
|
"location" : "https://github.com/apollographql/apollo-ios",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "39fea7617346c0731be25f61afd537e7032fb562",
|
"revision" : "9aa748d6f0526a744d49d59a2383dc7fdf9d645b",
|
||||||
"version" : "1.22.0"
|
"version" : "1.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identity" : "chidorimenu",
|
"identity" : "sqlite.swift",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/Lakr233/ChidoriMenu",
|
"location" : "https://github.com/stephencelis/SQLite.swift.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "3bb4323fe0f7f8f435d15656c3eeffcbb7c9c605",
|
"revision" : "a95fc6df17d108bd99210db5e8a9bac90fe984b8",
|
||||||
"version" : "3.0.0"
|
"version" : "0.15.3"
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "splash",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/JohnSundell/Splash",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "7f4df436eb78fe64fe2c32c58006e9949fa28ad8",
|
|
||||||
"version" : "0.16.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-cmark",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/swiftlang/swift-cmark",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "b022b08312decdc46585e0b3440d97f6f22ef703",
|
|
||||||
"version" : "0.6.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+6
-24
@@ -5,35 +5,17 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apollographql/apollo-ios.git",
|
"location" : "https://github.com/apollographql/apollo-ios.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "39fea7617346c0731be25f61afd537e7032fb562",
|
"revision" : "9aa748d6f0526a744d49d59a2383dc7fdf9d645b",
|
||||||
"version" : "1.22.0"
|
"version" : "1.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identity" : "chidorimenu",
|
"identity" : "sqlite.swift",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/Lakr233/ChidoriMenu",
|
"location" : "https://github.com/stephencelis/SQLite.swift.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "3bb4323fe0f7f8f435d15656c3eeffcbb7c9c605",
|
"revision" : "a95fc6df17d108bd99210db5e8a9bac90fe984b8",
|
||||||
"version" : "3.0.0"
|
"version" : "0.15.3"
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "splash",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/JohnSundell/Splash",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "7f4df436eb78fe64fe2c32c58006e9949fa28ad8",
|
|
||||||
"version" : "0.16.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-cmark",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/swiftlang/swift-cmark",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "b022b08312decdc46585e0b3440d97f6f22ef703",
|
|
||||||
"version" : "0.6.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,26 +7,20 @@ let package = Package(
|
|||||||
name: "Intelligents",
|
name: "Intelligents",
|
||||||
defaultLocalization: "en",
|
defaultLocalization: "en",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v15),
|
.iOS(.v17),
|
||||||
.macCatalyst(.v15),
|
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
.library(name: "Intelligents", targets: ["Intelligents"]),
|
.library(name: "Intelligents", targets: ["Intelligents"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(path: "../AffineGraphQL"),
|
.package(path: "../AffineGraphQL"),
|
||||||
.package(path: "../MarkdownView"),
|
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.18.0"),
|
||||||
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.22.0"),
|
|
||||||
.package(url: "https://github.com/LaunchDarkly/swift-eventsource.git", from: "3.3.0"),
|
.package(url: "https://github.com/LaunchDarkly/swift-eventsource.git", from: "3.3.0"),
|
||||||
.package(url: "https://github.com/apple/swift-collections", from: "1.2.0"),
|
.package(url: "https://github.com/apple/swift-collections", from: "1.2.0"),
|
||||||
.package(url: "https://github.com/Lakr233/ChidoriMenu", from: "3.0.0"),
|
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "Intelligents", dependencies: [
|
.target(name: "Intelligents", dependencies: [
|
||||||
"AffineGraphQL",
|
"AffineGraphQL",
|
||||||
"ChidoriMenu",
|
|
||||||
"MarkdownView",
|
|
||||||
"ChidoriMenu",
|
|
||||||
.product(name: "Apollo", package: "apollo-ios"),
|
.product(name: "Apollo", package: "apollo-ios"),
|
||||||
.product(name: "LDSwiftEventSource", package: "swift-eventsource"),
|
.product(name: "LDSwiftEventSource", package: "swift-eventsource"),
|
||||||
.product(name: "OrderedCollections", package: "swift-collections"),
|
.product(name: "OrderedCollections", package: "swift-collections"),
|
||||||
|
|||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
//
|
|
||||||
// Constant.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/18.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
enum Constant {
|
|
||||||
static let affineTabbarHeight: CGFloat = 44
|
|
||||||
static let affineTintColor: UIColor = .init(red: 30 / 255, green: 150 / 255, blue: 235 / 255, alpha: 1.0)
|
|
||||||
|
|
||||||
static var affineUpstreamURL = URL(string: "https://app.affine.pro/")!
|
|
||||||
}
|
|
||||||
-45
@@ -1,45 +0,0 @@
|
|||||||
//
|
|
||||||
// UnableTo.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 4/1/25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
private let domain = "Intelligents"
|
|
||||||
|
|
||||||
enum UnableTo {
|
|
||||||
static let identifyDocumentOrWorkspace =
|
|
||||||
NSError(
|
|
||||||
domain: domain,
|
|
||||||
code: -1,
|
|
||||||
userInfo: [NSLocalizedDescriptionKey: "Unable to identify the document or workspace"]
|
|
||||||
)
|
|
||||||
|
|
||||||
static let createSession = NSError(
|
|
||||||
domain: domain,
|
|
||||||
code: -1,
|
|
||||||
userInfo: [NSLocalizedDescriptionKey: "Unable to create a session"]
|
|
||||||
)
|
|
||||||
|
|
||||||
static let createMessage = NSError(
|
|
||||||
domain: domain,
|
|
||||||
code: -1,
|
|
||||||
userInfo: [NSLocalizedDescriptionKey: "Unable to create a message"]
|
|
||||||
)
|
|
||||||
|
|
||||||
static let compressImage = NSError(
|
|
||||||
domain: domain,
|
|
||||||
code: -1,
|
|
||||||
userInfo: [
|
|
||||||
NSLocalizedDescriptionKey: "Failed to compress image data",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
static let clearHistory = NSError(
|
|
||||||
domain: domain,
|
|
||||||
code: -1,
|
|
||||||
userInfo: [NSLocalizedDescriptionKey: "Unable to clear history"]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
//
|
|
||||||
// Chat.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/18.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct Chat: Codable {
|
|
||||||
enum ParticipantType: String, Codable, Equatable {
|
|
||||||
case user
|
|
||||||
case bot
|
|
||||||
}
|
|
||||||
|
|
||||||
var participant: ParticipantType
|
|
||||||
|
|
||||||
typealias MarkdownDocument = String
|
|
||||||
var content: MarkdownDocument
|
|
||||||
var date: Date
|
|
||||||
|
|
||||||
init(participant: ParticipantType, content: MarkdownDocument, date: Date = .init()) {
|
|
||||||
self.participant = participant
|
|
||||||
self.content = content
|
|
||||||
self.date = date
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-53
@@ -1,53 +0,0 @@
|
|||||||
//
|
|
||||||
// Prompt.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/12/26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum Prompt: String {
|
|
||||||
#if DEBUG
|
|
||||||
case debug_action_dalle3 = "debug:action:dalle3"
|
|
||||||
case debug_action_fal_sd15 = "debug:action:fal-sd15"
|
|
||||||
case debug_action_fal_upscaler = "debug:action:fal-upscaler"
|
|
||||||
case debug_action_fal_remove_bg = "debug:action:fal-remove-bg"
|
|
||||||
case debug_action_fal_face_to_sticker = "debug:action:fal-face-to-sticker"
|
|
||||||
#endif
|
|
||||||
|
|
||||||
case general_Chat_With_AFFiNE_AI = "Chat With AFFiNE AI"
|
|
||||||
case general_Summary = "Summary"
|
|
||||||
case general_Generate_a_caption = "Generate a caption"
|
|
||||||
case general_Summary_the_webpage = "Summary the webpage"
|
|
||||||
case general_Explain_this = "Explain this"
|
|
||||||
case general_Explain_this_image = "Explain this image"
|
|
||||||
case general_Explain_this_code = "Explain this code"
|
|
||||||
case general_Translate_to = "Translate to"
|
|
||||||
case general_Write_an_article_about_this = "Write an article about this"
|
|
||||||
case general_Write_a_twitter_about_this = "Write a twitter about this"
|
|
||||||
case general_Write_a_poem_about_this = "Write a poem about this"
|
|
||||||
case general_Write_a_blog_post_about_this = "Write a blog post about this"
|
|
||||||
case general_Write_outline = "Write outline"
|
|
||||||
case general_Change_tone_to = "Change tone to"
|
|
||||||
case general_Brainstorm_ideas_about_this = "Brainstorm ideas about this"
|
|
||||||
case general_Expand_mind_map = "Expand mind map"
|
|
||||||
case general_Improve_writing_for_it = "Improve writing for it"
|
|
||||||
case general_Improve_grammar_for_it = "Improve grammar for it"
|
|
||||||
case general_Fix_spelling_for_it = "Fix spelling for it"
|
|
||||||
case general_Find_action_items_from_it = "Find action items from it"
|
|
||||||
case general_Check_code_error = "Check code error"
|
|
||||||
case general_Create_headings = "Create headings"
|
|
||||||
case general_Make_it_real = "Make it real"
|
|
||||||
case general_Make_it_real_with_text = "Make it real with text"
|
|
||||||
case general_Make_it_longer = "Make it longer"
|
|
||||||
case general_Make_it_shorter = "Make it shorter"
|
|
||||||
case general_Continue_writing = "Continue writing"
|
|
||||||
|
|
||||||
case workflow_presentation = "workflow:presentation"
|
|
||||||
case workflow_brainstorm = "workflow:brainstorm"
|
|
||||||
case workflow_image_sketch = "workflow:image-sketch"
|
|
||||||
case workflow_image_clay = "workflow:image-clay"
|
|
||||||
case workflow_image_anime = "workflow:image-anime"
|
|
||||||
case workflow_image_pixel = "workflow:image-pixel"
|
|
||||||
}
|
|
||||||
-37
@@ -1,37 +0,0 @@
|
|||||||
//
|
|
||||||
// Ext+EventHandler.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/12/26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import LDSwiftEventSource
|
|
||||||
|
|
||||||
class BlockEventHandler: EventHandler {
|
|
||||||
var onOpenedBlock: (() -> Void)?
|
|
||||||
var onClosedBlock: (() -> Void)?
|
|
||||||
var onMessageBlock: ((String, LDSwiftEventSource.MessageEvent) -> Void)?
|
|
||||||
var onCommentBlock: ((String) -> Void)?
|
|
||||||
var onErrorBlock: ((Error) -> Void)?
|
|
||||||
|
|
||||||
public func onOpened() {
|
|
||||||
onOpenedBlock?()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func onClosed() {
|
|
||||||
onClosedBlock?()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func onMessage(eventType: String, messageEvent: LDSwiftEventSource.MessageEvent) {
|
|
||||||
onMessageBlock?(eventType, messageEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func onComment(comment: String) {
|
|
||||||
onCommentBlock?(comment)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func onError(error: any Error) {
|
|
||||||
onErrorBlock?(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-19
@@ -1,19 +0,0 @@
|
|||||||
//
|
|
||||||
// Ext+String.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/18.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension String {
|
|
||||||
func localized() -> String {
|
|
||||||
let ans = NSLocalizedString(self, bundle: Bundle.module, comment: "")
|
|
||||||
guard !ans.isEmpty else {
|
|
||||||
assertionFailure()
|
|
||||||
return self
|
|
||||||
}
|
|
||||||
return ans
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
//
|
|
||||||
// Ext+UIColor.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/12/13.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension UIColor {
|
|
||||||
static var accent: UIColor {
|
|
||||||
Constant.affineTintColor
|
|
||||||
}
|
|
||||||
|
|
||||||
convenience init(light: UIColor, dark: UIColor) {
|
|
||||||
self.init(dynamicProvider: { traitCollection in
|
|
||||||
switch traitCollection.userInterfaceStyle {
|
|
||||||
case .light:
|
|
||||||
light
|
|
||||||
case .dark:
|
|
||||||
dark
|
|
||||||
default:
|
|
||||||
light
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-33
@@ -1,33 +0,0 @@
|
|||||||
//
|
|
||||||
// Ext+UIFont.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/21.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension UIFont {
|
|
||||||
static func preferredFont(for style: TextStyle, weight: Weight, italic: Bool = false) -> UIFont {
|
|
||||||
// Get the style's default pointSize
|
|
||||||
let traits = UITraitCollection(preferredContentSizeCategory: .large)
|
|
||||||
let desc = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style, compatibleWith: traits)
|
|
||||||
|
|
||||||
// Get the font at the default size and preferred weight
|
|
||||||
var font = UIFont.systemFont(ofSize: desc.pointSize, weight: weight)
|
|
||||||
if italic == true {
|
|
||||||
font = font.with([.traitItalic])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup the font to be auto-scalable
|
|
||||||
let metrics = UIFontMetrics(forTextStyle: style)
|
|
||||||
return metrics.scaledFont(for: font)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func with(_ traits: UIFontDescriptor.SymbolicTraits...) -> UIFont {
|
|
||||||
guard let descriptor = fontDescriptor.withSymbolicTraits(UIFontDescriptor.SymbolicTraits(traits).union(fontDescriptor.symbolicTraits)) else {
|
|
||||||
return self
|
|
||||||
}
|
|
||||||
return UIFont(descriptor: descriptor, size: 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-46
@@ -1,46 +0,0 @@
|
|||||||
//
|
|
||||||
// Ext+UIView.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/18.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension UIView {
|
|
||||||
var parentViewController: UIViewController? {
|
|
||||||
var responder: UIResponder? = self
|
|
||||||
while responder != nil {
|
|
||||||
if let responder = responder as? UIViewController {
|
|
||||||
return responder
|
|
||||||
}
|
|
||||||
responder = responder?.next
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeEveryAutoResizingMasks() {
|
|
||||||
var views: [UIView] = [self]
|
|
||||||
while let view = views.first {
|
|
||||||
views.removeFirst()
|
|
||||||
view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
view.subviews.forEach { views.append($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
func debugFrame() {
|
|
||||||
layer.borderWidth = 1
|
|
||||||
layer.borderColor = [
|
|
||||||
UIColor.red,
|
|
||||||
.green,
|
|
||||||
.blue,
|
|
||||||
.yellow,
|
|
||||||
.cyan,
|
|
||||||
.magenta,
|
|
||||||
.orange,
|
|
||||||
].map(\.cgColor).randomElement()
|
|
||||||
subviews.forEach { $0.debugFrame() }
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
-52
@@ -1,52 +0,0 @@
|
|||||||
//
|
|
||||||
// Ext+UIViewController.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/18.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public extension UIViewController {
|
|
||||||
func presentIntoCurrentContext(withTargetController targetController: UIViewController, animated: Bool = true) {
|
|
||||||
if let nav = self as? UINavigationController {
|
|
||||||
nav.pushViewController(targetController, animated: animated)
|
|
||||||
} else if let nav = navigationController {
|
|
||||||
nav.pushViewController(targetController, animated: animated)
|
|
||||||
} else {
|
|
||||||
present(targetController, animated: animated, completion: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func dismissInContext() {
|
|
||||||
if let nav = navigationController {
|
|
||||||
nav.popViewController(animated: true)
|
|
||||||
} else {
|
|
||||||
dismiss(animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func hideKeyboardWhenTappedAround() {
|
|
||||||
let tap = UITapGestureRecognizer(target: self, action: #selector(UIViewController.dismissKeyboard))
|
|
||||||
tap.cancelsTouchesInView = false
|
|
||||||
view.addGestureRecognizer(tap)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func dismissKeyboard() {
|
|
||||||
view.endEditing(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func presentError(_ error: Error, onDismiss: @escaping () -> Void = {}) {
|
|
||||||
DispatchQueue.main.async { [self] in
|
|
||||||
let alert = UIAlertController(
|
|
||||||
title: "Error".localized(),
|
|
||||||
message: error.localizedDescription,
|
|
||||||
preferredStyle: .alert
|
|
||||||
)
|
|
||||||
alert.addAction(UIAlertAction(title: "OK".localized(), style: .default) { _ in
|
|
||||||
onDismiss()
|
|
||||||
})
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
//
|
|
||||||
// Ext+print.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/18.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public func print(_ items: Any..., separator: String = " ", terminator: String = "\n") {
|
|
||||||
#if DEBUG
|
|
||||||
Swift.print(items, separator: separator, terminator: terminator)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
-163
@@ -1,163 +0,0 @@
|
|||||||
//
|
|
||||||
// AttachmentBannerView.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/18.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
private let attachmentSize: CGFloat = 100
|
|
||||||
private let attachmentSpacing: CGFloat = 16
|
|
||||||
|
|
||||||
class AttachmentBannerView: UIScrollView {
|
|
||||||
var readAttachments: (() -> ([UIImage]))?
|
|
||||||
var onAttachmentsDelete: ((Int) -> Void)?
|
|
||||||
var attachments: [UIImage] {
|
|
||||||
get { readAttachments?() ?? [] }
|
|
||||||
set { assertionFailure() }
|
|
||||||
}
|
|
||||||
|
|
||||||
override var intrinsicContentSize: CGSize {
|
|
||||||
if attachments.isEmpty { return .zero }
|
|
||||||
return .init(
|
|
||||||
width: (attachmentSize + attachmentSize) * CGFloat(attachments.count)
|
|
||||||
- attachmentSpacing,
|
|
||||||
height: attachmentSize
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let stackView = UIStackView()
|
|
||||||
|
|
||||||
init() {
|
|
||||||
super.init(frame: .zero)
|
|
||||||
|
|
||||||
translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
showsHorizontalScrollIndicator = false
|
|
||||||
showsVerticalScrollIndicator = false
|
|
||||||
|
|
||||||
stackView.axis = .horizontal
|
|
||||||
stackView.spacing = attachmentSpacing
|
|
||||||
stackView.alignment = .center
|
|
||||||
stackView.distribution = .fill
|
|
||||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
addSubview(stackView)
|
|
||||||
[
|
|
||||||
stackView.topAnchor.constraint(equalTo: topAnchor),
|
|
||||||
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
||||||
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
||||||
stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
rebuildViews()
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
var reusableViews = [AttachmentPreviewView]()
|
|
||||||
|
|
||||||
func rebuildViews() {
|
|
||||||
let attachments = attachments
|
|
||||||
|
|
||||||
if reusableViews.count > attachments.count {
|
|
||||||
for index in attachments.count ..< reusableViews.count {
|
|
||||||
reusableViews[index].removeFromSuperview()
|
|
||||||
}
|
|
||||||
reusableViews.removeLast(reusableViews.count - attachments.count)
|
|
||||||
}
|
|
||||||
if reusableViews.count < attachments.count {
|
|
||||||
for _ in reusableViews.count ..< attachments.count {
|
|
||||||
let view = AttachmentPreviewView()
|
|
||||||
view.alpha = 0
|
|
||||||
reusableViews.append(view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(reusableViews.count == attachments.count)
|
|
||||||
|
|
||||||
for (index, attachment) in attachments.enumerated() {
|
|
||||||
let view = reusableViews[index]
|
|
||||||
view.imageView.image = attachment
|
|
||||||
stackView.addArrangedSubview(view)
|
|
||||||
view.deleteButtonAction = { [weak self] in
|
|
||||||
self?.onAttachmentsDelete?(index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidateIntrinsicContentSize()
|
|
||||||
contentSize = intrinsicContentSize
|
|
||||||
UIView.performWithoutAnimation {
|
|
||||||
self.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
UIView.animate(withDuration: 0.3) {
|
|
||||||
for view in self.reusableViews {
|
|
||||||
view.alpha = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AttachmentBannerView {
|
|
||||||
class AttachmentPreviewView: UIView {
|
|
||||||
let imageView = UIImageView()
|
|
||||||
let deleteButton = UIButton()
|
|
||||||
|
|
||||||
var deleteButtonAction: (() -> Void)?
|
|
||||||
|
|
||||||
override var intrinsicContentSize: CGSize {
|
|
||||||
.init(width: attachmentSize, height: attachmentSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
super.init(frame: .zero)
|
|
||||||
addSubview(imageView)
|
|
||||||
addSubview(deleteButton)
|
|
||||||
|
|
||||||
layer.cornerRadius = 8
|
|
||||||
clipsToBounds = true
|
|
||||||
|
|
||||||
imageView.contentMode = .scaleAspectFill
|
|
||||||
imageView.clipsToBounds = true
|
|
||||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
[
|
|
||||||
imageView.topAnchor.constraint(equalTo: topAnchor),
|
|
||||||
imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
||||||
imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
||||||
imageView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
deleteButton.setImage(.init(named: "close", in: .module, with: nil), for: .normal)
|
|
||||||
deleteButton.imageView?.contentMode = .scaleAspectFit
|
|
||||||
deleteButton.tintColor = .white
|
|
||||||
deleteButton.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
[
|
|
||||||
deleteButton.topAnchor.constraint(equalTo: topAnchor, constant: 4),
|
|
||||||
deleteButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
|
|
||||||
deleteButton.widthAnchor.constraint(equalToConstant: 32),
|
|
||||||
deleteButton.heightAnchor.constraint(equalToConstant: 32),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
deleteButton.addTarget(self, action: #selector(deleteButtonTapped), for: .touchUpInside)
|
|
||||||
|
|
||||||
[
|
|
||||||
widthAnchor.constraint(equalToConstant: attachmentSize),
|
|
||||||
heightAnchor.constraint(equalToConstant: attachmentSize),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func deleteButtonTapped() {
|
|
||||||
deleteButtonAction?()
|
|
||||||
deleteButtonAction = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-63
@@ -1,63 +0,0 @@
|
|||||||
//
|
|
||||||
// InputEditView+Camera.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/12/6.
|
|
||||||
//
|
|
||||||
|
|
||||||
import AVKit
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension InputEditView: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
|
||||||
@objc func takePhoto() {
|
|
||||||
AVCaptureDevice.requestAccess(for: .video) { _ in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
let ctrl = UIImagePickerController()
|
|
||||||
ctrl.allowsEditing = false
|
|
||||||
ctrl.sourceType = .camera
|
|
||||||
ctrl.mediaTypes = [UTType.movie.identifier, UTType.image.identifier]
|
|
||||||
ctrl.cameraCaptureMode = .photo
|
|
||||||
ctrl.delegate = self
|
|
||||||
self.parentViewController?.present(ctrl, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func processJPEGImageData(_ image: UIImage) throws -> Data? {
|
|
||||||
guard let data = image.jpegData(compressionQuality: 0.75) else {
|
|
||||||
throw UnableTo.compressImage
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
|
||||||
picker.dismiss(animated: true) {
|
|
||||||
var itemUrl: URL?
|
|
||||||
|
|
||||||
if itemUrl == nil,
|
|
||||||
let image = info[.editedImage] as? UIImage ?? info[.originalImage] as? UIImage
|
|
||||||
{
|
|
||||||
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory())
|
|
||||||
.appendingPathComponent("Camera")
|
|
||||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
||||||
let tempFile = tempDir
|
|
||||||
.appendingPathComponent(UUID().uuidString)
|
|
||||||
.appendingPathExtension("jpeg")
|
|
||||||
try? self.processJPEGImageData(image)?.write(to: tempFile)
|
|
||||||
itemUrl = tempFile
|
|
||||||
}
|
|
||||||
if itemUrl == nil,
|
|
||||||
let url = info[.mediaURL] as? URL
|
|
||||||
{
|
|
||||||
itemUrl = url
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let url = itemUrl, FileManager.default.fileExists(atPath: url.path) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let image = UIImage(contentsOfFile: url.path) else { return }
|
|
||||||
try? FileManager.default.removeItem(at: url)
|
|
||||||
self.viewModel.attachments.append(image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-38
@@ -1,38 +0,0 @@
|
|||||||
//
|
|
||||||
// InputEditView+Photo.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/12/6.
|
|
||||||
//
|
|
||||||
|
|
||||||
import PhotosUI
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension InputEditView: PHPickerViewControllerDelegate {
|
|
||||||
@objc func selectPhoto() {
|
|
||||||
var config = PHPickerConfiguration(photoLibrary: .shared())
|
|
||||||
config.filter = .images
|
|
||||||
config.selectionLimit = 9
|
|
||||||
let picker = PHPickerViewController(configuration: config)
|
|
||||||
picker.modalPresentationStyle = .formSheet
|
|
||||||
picker.delegate = self
|
|
||||||
parentViewController?.present(picker, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
|
||||||
picker.dismiss(animated: true)
|
|
||||||
loadPNG(from: results)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadPNG(from results: [PHPickerResult]) {
|
|
||||||
for result in results {
|
|
||||||
result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in
|
|
||||||
if let image = image as? UIImage {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self?.viewModel.attachments.append(image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-48
@@ -1,48 +0,0 @@
|
|||||||
//
|
|
||||||
// InputEditView+ViewModel.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/18.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension InputEditView {
|
|
||||||
class ViewModel: ObservableObject {
|
|
||||||
var cancellables: Set<AnyCancellable> = []
|
|
||||||
|
|
||||||
@Published var text: String = ""
|
|
||||||
@Published var attachments: [UIImage] = []
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
cancellables.forEach { $0.cancel() }
|
|
||||||
cancellables.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset() {
|
|
||||||
text = ""
|
|
||||||
attachments = []
|
|
||||||
}
|
|
||||||
|
|
||||||
func duplicate() -> ViewModel {
|
|
||||||
let ans = ViewModel()
|
|
||||||
ans.text = text
|
|
||||||
ans.attachments = attachments
|
|
||||||
return ans
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension InputEditView.ViewModel: Hashable, Equatable {
|
|
||||||
func hash(into hasher: inout Hasher) {
|
|
||||||
hasher.combine(text)
|
|
||||||
hasher.combine(attachments)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func == (lhs: InputEditView.ViewModel, rhs: InputEditView.ViewModel) -> Bool {
|
|
||||||
lhs.hashValue == rhs.hashValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-131
@@ -1,131 +0,0 @@
|
|||||||
//
|
|
||||||
// InputEditView.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/18.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class InputEditView: UIView {
|
|
||||||
let mainStack = UIStackView()
|
|
||||||
let attachmentsEditor = AttachmentBannerView()
|
|
||||||
let textEditor = PlainTextEditView()
|
|
||||||
let placeholderLabel = UILabel()
|
|
||||||
let controlBanner = TextEditControlBanner()
|
|
||||||
|
|
||||||
let viewModel = ViewModel()
|
|
||||||
var placeholderText: String = "" {
|
|
||||||
didSet {
|
|
||||||
placeholderLabel.text = placeholderText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var submitAction: (() -> Void) = {}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
super.init(frame: .zero)
|
|
||||||
|
|
||||||
addSubview(mainStack)
|
|
||||||
mainStack.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
mainStack.axis = .vertical
|
|
||||||
mainStack.spacing = 16
|
|
||||||
mainStack.alignment = .fill
|
|
||||||
mainStack.distribution = .equalSpacing
|
|
||||||
[
|
|
||||||
mainStack.topAnchor.constraint(equalTo: topAnchor),
|
|
||||||
mainStack.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
||||||
mainStack.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
||||||
mainStack.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
textEditor.heightAnchor.constraint(greaterThanOrEqualToConstant: 64).isActive = true
|
|
||||||
|
|
||||||
[
|
|
||||||
attachmentsEditor, textEditor, controlBanner,
|
|
||||||
].forEach {
|
|
||||||
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
mainStack.addArrangedSubview($0)
|
|
||||||
[
|
|
||||||
$0.leadingAnchor.constraint(equalTo: mainStack.leadingAnchor),
|
|
||||||
$0.trailingAnchor.constraint(equalTo: mainStack.trailingAnchor),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
}
|
|
||||||
|
|
||||||
attachmentsEditor.readAttachments = { [weak self] in
|
|
||||||
self?.viewModel.attachments ?? []
|
|
||||||
}
|
|
||||||
attachmentsEditor.onAttachmentsDelete = { [weak self] index in
|
|
||||||
self?.viewModel.attachments.remove(at: index)
|
|
||||||
}
|
|
||||||
|
|
||||||
controlBanner.cameraButton.addTarget(
|
|
||||||
self,
|
|
||||||
action: #selector(takePhoto),
|
|
||||||
for: .touchUpInside
|
|
||||||
)
|
|
||||||
controlBanner.photoButton.addTarget(
|
|
||||||
self,
|
|
||||||
action: #selector(selectPhoto),
|
|
||||||
for: .touchUpInside
|
|
||||||
)
|
|
||||||
|
|
||||||
textEditor.returnKeyType = .send
|
|
||||||
textEditor.addSubview(placeholderLabel)
|
|
||||||
placeholderLabel.textColor = .label.withAlphaComponent(0.25)
|
|
||||||
placeholderLabel.font = textEditor.font
|
|
||||||
placeholderLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
[
|
|
||||||
placeholderLabel.leadingAnchor.constraint(equalTo: textEditor.leadingAnchor, constant: 2),
|
|
||||||
placeholderLabel.trailingAnchor.constraint(equalTo: textEditor.trailingAnchor, constant: -2),
|
|
||||||
placeholderLabel.topAnchor.constraint(equalTo: textEditor.topAnchor, constant: 0),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
viewModel.objectWillChange
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.updateValues()
|
|
||||||
}
|
|
||||||
.store(in: &viewModel.cancellables)
|
|
||||||
|
|
||||||
updateValues()
|
|
||||||
|
|
||||||
textEditor.textDidChange = { [weak self] text in
|
|
||||||
self?.viewModel.text = text
|
|
||||||
self?.updatePlaceholderVisibility()
|
|
||||||
}
|
|
||||||
|
|
||||||
textEditor.textDidReturn = { [weak self] in
|
|
||||||
self?.submitAction()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
func updatePlaceholderVisibility() {
|
|
||||||
let visible = viewModel.text.isEmpty && !textEditor.isFirstResponder
|
|
||||||
UIView.animate(withDuration: 0.25) {
|
|
||||||
self.placeholderLabel.alpha = visible ? 1 : 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateValues() {
|
|
||||||
UIView.animate(
|
|
||||||
withDuration: 0.5,
|
|
||||||
delay: 0,
|
|
||||||
usingSpringWithDamping: 1.0,
|
|
||||||
initialSpringVelocity: 0.8
|
|
||||||
) { [self] in
|
|
||||||
if textEditor.text != viewModel.text {
|
|
||||||
textEditor.text = viewModel.text
|
|
||||||
}
|
|
||||||
attachmentsEditor.rebuildViews()
|
|
||||||
parentViewController?.view.layoutIfNeeded()
|
|
||||||
updatePlaceholderVisibility()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-69
@@ -1,69 +0,0 @@
|
|||||||
//
|
|
||||||
// PlainTextEditView.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/18.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class PlainTextEditView: UITextView, UITextViewDelegate {
|
|
||||||
var textDidChange: ((String) -> Void) = { _ in }
|
|
||||||
var textDidReturn: (() -> Void) = {}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
super.init(frame: .zero, textContainer: nil)
|
|
||||||
|
|
||||||
delegate = self
|
|
||||||
tintColor = .accent
|
|
||||||
|
|
||||||
linkTextAttributes = [:]
|
|
||||||
showsVerticalScrollIndicator = false
|
|
||||||
showsHorizontalScrollIndicator = false
|
|
||||||
textContainer.lineFragmentPadding = .zero
|
|
||||||
textAlignment = .natural
|
|
||||||
backgroundColor = .clear
|
|
||||||
textContainerInset = .zero
|
|
||||||
textContainer.lineBreakMode = .byTruncatingTail
|
|
||||||
isScrollEnabled = false
|
|
||||||
clipsToBounds = false
|
|
||||||
|
|
||||||
isEditable = true
|
|
||||||
isSelectable = true
|
|
||||||
isScrollEnabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
func textViewDidChange(_ textView: UITextView) {
|
|
||||||
textDidChange(textView.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
|
||||||
textDidChange(textView.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
func textViewDidEndEditing(_ textView: UITextView) {
|
|
||||||
textDidChange(textView.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
func textView(_: UITextView, editMenuForTextIn _: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
|
||||||
.init(children: suggestedActions + [
|
|
||||||
UIAction(title: "Insert Newline") { [weak self] _ in
|
|
||||||
self?.insertText("\n")
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
func textView(_: UITextView, shouldChangeTextIn _: NSRange, replacementText text: String) -> Bool {
|
|
||||||
if text == "\n" {
|
|
||||||
textDidReturn()
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-62
@@ -1,62 +0,0 @@
|
|||||||
//
|
|
||||||
// TextEditControlBanner.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/18.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class TextEditControlBanner: UIStackView {
|
|
||||||
static let height: CGFloat = 32
|
|
||||||
|
|
||||||
let cameraButton = UIButton()
|
|
||||||
let photoButton = UIButton()
|
|
||||||
|
|
||||||
let spacer = UIView()
|
|
||||||
|
|
||||||
let sendButton = UIButton()
|
|
||||||
|
|
||||||
init() {
|
|
||||||
super.init(frame: .zero)
|
|
||||||
|
|
||||||
axis = .horizontal
|
|
||||||
spacing = 16
|
|
||||||
alignment = .center
|
|
||||||
distribution = .fill
|
|
||||||
|
|
||||||
[
|
|
||||||
heightAnchor.constraint(equalToConstant: Self.height),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
[
|
|
||||||
cameraButton, photoButton,
|
|
||||||
sendButton,
|
|
||||||
].forEach {
|
|
||||||
$0.widthAnchor.constraint(equalToConstant: Self.height).isActive = true
|
|
||||||
$0.heightAnchor.constraint(equalToConstant: Self.height).isActive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
[
|
|
||||||
cameraButton, photoButton,
|
|
||||||
spacer,
|
|
||||||
sendButton,
|
|
||||||
].forEach {
|
|
||||||
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
addArrangedSubview($0)
|
|
||||||
}
|
|
||||||
|
|
||||||
cameraButton.setImage(.init(systemName: "camera"), for: .normal)
|
|
||||||
cameraButton.tintColor = .label
|
|
||||||
photoButton.setImage(.init(systemName: "photo"), for: .normal)
|
|
||||||
photoButton.tintColor = .label
|
|
||||||
|
|
||||||
sendButton.setImage(.init(systemName: "paperplane.fill"), for: .normal)
|
|
||||||
sendButton.tintColor = .label
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-372
@@ -1,372 +0,0 @@
|
|||||||
//
|
|
||||||
// IntelligentsChatController+Chat.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/12/26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import AffineGraphQL
|
|
||||||
import LDSwiftEventSource
|
|
||||||
import MarkdownParser
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension IntelligentsChatController {
|
|
||||||
@objc func chat_onLoad() {
|
|
||||||
beginProgress()
|
|
||||||
chat_createSession { session in
|
|
||||||
self.sessionID = session ?? ""
|
|
||||||
self.chat_retrieveHistories {
|
|
||||||
self.dispatchToMain {
|
|
||||||
self.endProgress()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} onFailure: { error in
|
|
||||||
self.presentError(error) {
|
|
||||||
if let nav = self.navigationController {
|
|
||||||
nav.popViewController(animated: true)
|
|
||||||
} else {
|
|
||||||
self.dismiss(animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func chat_onSend() {
|
|
||||||
beginProgress()
|
|
||||||
let viewModel = inputBox.editor.viewModel.duplicate()
|
|
||||||
viewModel.text = viewModel.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
inputBox.editor.viewModel.reset()
|
|
||||||
inputBox.editor.updateValues()
|
|
||||||
DispatchQueue.global().async {
|
|
||||||
self.chat_onSendExecute(viewModel: viewModel)
|
|
||||||
self.endProgress()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func chat_clearHistory() {
|
|
||||||
beginProgress()
|
|
||||||
Intelligents.qlClient.perform(mutation: CleanupCopilotSessionMutation(input: .init(
|
|
||||||
docId: metadata[.documentID] ?? "",
|
|
||||||
sessionIds: [sessionID],
|
|
||||||
workspaceId: metadata[.workspaceID] ?? ""
|
|
||||||
))) { result in
|
|
||||||
self.dispatchToMain {
|
|
||||||
self.endProgress()
|
|
||||||
if case let .success(value) = result,
|
|
||||||
let sessions = value.data?.cleanupCopilotSession,
|
|
||||||
sessions.contains(self.sessionID)
|
|
||||||
{
|
|
||||||
self.simpleChatContents.removeAll()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.presentError(UnableTo.clearHistory)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func chat_retrieveHistories(_ completion: @escaping () -> Void) {
|
|
||||||
Intelligents.qlClient.fetch(query: GetCopilotHistoriesQuery(
|
|
||||||
workspaceId: metadata[.workspaceID] ?? "",
|
|
||||||
docId: .init(stringLiteral: metadata[.documentID] ?? ""),
|
|
||||||
options: .some(.init(
|
|
||||||
action: false,
|
|
||||||
fork: false,
|
|
||||||
limit: .init(nilLiteral: ()),
|
|
||||||
messageOrder: .some(.case(.asc)),
|
|
||||||
sessionId: .init(stringLiteral: sessionID),
|
|
||||||
sessionOrder: .some(.case(.desc)),
|
|
||||||
skip: .init(nilLiteral: ()),
|
|
||||||
withPrompt: .init(booleanLiteral: false)
|
|
||||||
))
|
|
||||||
)) { [weak self] result in
|
|
||||||
if let self,
|
|
||||||
case let .success(value) = result,
|
|
||||||
let object = value.data,
|
|
||||||
let currentUser = object.__data._data["currentUser"] as? DataDict,
|
|
||||||
let copilot = currentUser._data["copilot"] as? DataDict,
|
|
||||||
let histories = copilot._data["histories"] as? [DataDict],
|
|
||||||
let mostRecent = histories.first,
|
|
||||||
let messages = mostRecent._data["messages"] as? [DataDict],
|
|
||||||
!messages.isEmpty
|
|
||||||
{
|
|
||||||
print("[*] retrieved \(messages.count) messages")
|
|
||||||
tableView.scrollToBottomOnNextUpdate = true
|
|
||||||
tableView.alpha = 0
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.8) {
|
|
||||||
self.tableView.alpha = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for message in messages {
|
|
||||||
guard let role = message._data["role"] as? String,
|
|
||||||
let content = message._data["content"] as? String
|
|
||||||
// TODO: ATTACHMENTS
|
|
||||||
else { continue }
|
|
||||||
switch role {
|
|
||||||
case "assistant":
|
|
||||||
simpleChatContents.updateValue(
|
|
||||||
.assistant(document: content),
|
|
||||||
forKey: UUID()
|
|
||||||
)
|
|
||||||
case "user":
|
|
||||||
simpleChatContents.updateValue(
|
|
||||||
.user(document: content),
|
|
||||||
forKey: UUID()
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
assertionFailure()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
completion()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension IntelligentsChatController {
|
|
||||||
func dispatchToMain(_ block: @escaping () -> Void) {
|
|
||||||
if Thread.isMainThread {
|
|
||||||
block()
|
|
||||||
} else {
|
|
||||||
DispatchQueue.main.async(execute: block)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func beginProgress() {
|
|
||||||
dispatchToMain { [self] in
|
|
||||||
header.isUserInteractionEnabled = false
|
|
||||||
inputBox.isUserInteractionEnabled = false
|
|
||||||
progressView.isHidden = false
|
|
||||||
progressView.alpha = 0
|
|
||||||
progressView.startAnimating()
|
|
||||||
UIView.animate(withDuration: 0.25) {
|
|
||||||
self.inputBox.editor.alpha = 0
|
|
||||||
self.progressView.alpha = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func endProgress() {
|
|
||||||
dispatchToMain { [self] in
|
|
||||||
UIView.animate(withDuration: 0.3) {
|
|
||||||
self.inputBox.editor.alpha = 1
|
|
||||||
self.progressView.alpha = 0
|
|
||||||
self.header.isUserInteractionEnabled = true
|
|
||||||
} completion: { _ in
|
|
||||||
self.inputBox.isUserInteractionEnabled = true
|
|
||||||
self.progressView.stopAnimating()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension IntelligentsChatController {
|
|
||||||
func chat_onError(_ error: Error) {
|
|
||||||
print("[*] chat error", error)
|
|
||||||
dispatchToMain {
|
|
||||||
let key = UUID()
|
|
||||||
let content = ChatContent.error(text: error.localizedDescription)
|
|
||||||
self.simpleChatContents.updateValue(content, forKey: key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func chat_createSession(
|
|
||||||
forceCreateNewSession: Bool = false,
|
|
||||||
onSuccess: @escaping (String?) -> Void,
|
|
||||||
onFailure: @escaping (Error) -> Void
|
|
||||||
) {
|
|
||||||
if !forceCreateNewSession,
|
|
||||||
let doc = metadata[.documentID],
|
|
||||||
!doc.isEmpty
|
|
||||||
{
|
|
||||||
Intelligents.qlClient.fetch(query: GetCopilotSessionsQuery(
|
|
||||||
workspaceId: .init(stringLiteral: metadata[.workspaceID] ?? ""),
|
|
||||||
docId: .init(stringLiteral: doc),
|
|
||||||
options: .some(QueryChatSessionsInput(InputDict([
|
|
||||||
"action": false,
|
|
||||||
])))
|
|
||||||
)) { result in
|
|
||||||
switch result {
|
|
||||||
case let .success(value):
|
|
||||||
if let result = value.data,
|
|
||||||
let currentUser = result.__data._data["currentUser"] as? DataDict,
|
|
||||||
let copilot = currentUser._data["copilot"] as? DataDict,
|
|
||||||
let sessions = copilot._data["sessions"] as? [DataDict],
|
|
||||||
let mostRecent = sessions.last,
|
|
||||||
let sessionID = mostRecent._data["id"] as? String
|
|
||||||
{
|
|
||||||
print("[*] using existing session", sessionID)
|
|
||||||
self.dispatchToMain { onSuccess(sessionID) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.chat_createSession(
|
|
||||||
forceCreateNewSession: true,
|
|
||||||
onSuccess: onSuccess,
|
|
||||||
onFailure: onFailure
|
|
||||||
)
|
|
||||||
case let .failure(error):
|
|
||||||
self.dispatchToMain { onFailure(error) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Intelligents.qlClient.perform(
|
|
||||||
mutation: CreateCopilotSessionMutation(options: .init(
|
|
||||||
docId: metadata[.documentID] ?? "",
|
|
||||||
promptName: Prompt.general_Chat_With_AFFiNE_AI.rawValue,
|
|
||||||
workspaceId: metadata[.workspaceID] ?? ""
|
|
||||||
)),
|
|
||||||
queue: .global()
|
|
||||||
) { result in
|
|
||||||
switch result {
|
|
||||||
case let .success(value):
|
|
||||||
if let session = value.data?.createCopilotSession {
|
|
||||||
self.dispatchToMain { onSuccess(session) }
|
|
||||||
} else {
|
|
||||||
self.dispatchToMain {
|
|
||||||
onFailure(UnableTo.createSession)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case let .failure(error):
|
|
||||||
self.dispatchToMain { onFailure(error) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func chat_onSendExecute(viewModel: InputEditView.ViewModel) {
|
|
||||||
let text = viewModel.text
|
|
||||||
// let images = viewModel.attachments
|
|
||||||
|
|
||||||
let assistantContentID = UUID()
|
|
||||||
dispatchToMain {
|
|
||||||
let content = ChatContent.user(document: text)
|
|
||||||
self.simpleChatContents.updateValue(content, forKey: .init())
|
|
||||||
self.simpleChatContents.updateValue(
|
|
||||||
.assistant(document: "..."),
|
|
||||||
forKey: assistantContentID
|
|
||||||
)
|
|
||||||
self.tableView.scrollToBottomOnNextUpdate = true
|
|
||||||
}
|
|
||||||
|
|
||||||
let sem = DispatchSemaphore(value: 0)
|
|
||||||
let sessionID = sessionID
|
|
||||||
Intelligents.qlClient.perform(
|
|
||||||
mutation: CreateCopilotMessageMutation(options: .init(
|
|
||||||
content: .init(stringLiteral: text),
|
|
||||||
params: .some(.dictionary([
|
|
||||||
"docs": [
|
|
||||||
"docId": metadata[.documentID] ?? "",
|
|
||||||
"docContent": metadata[.content] ?? "",
|
|
||||||
],
|
|
||||||
])),
|
|
||||||
sessionId: sessionID
|
|
||||||
)),
|
|
||||||
queue: .global()
|
|
||||||
) { result in
|
|
||||||
defer { sem.signal() }
|
|
||||||
switch result {
|
|
||||||
case let .success(value):
|
|
||||||
if let messageID = value.data?.createCopilotMessage {
|
|
||||||
print("[*] messageID", messageID)
|
|
||||||
self.chat_processWithMessageID(
|
|
||||||
sessionID: sessionID,
|
|
||||||
messageID: messageID,
|
|
||||||
cellID: assistantContentID
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
self.chat_onError(UnableTo.createMessage)
|
|
||||||
}
|
|
||||||
case let .failure(error):
|
|
||||||
self.chat_onError(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sem.wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func chat_processWithMessageID(sessionID: String, messageID: String, cellID: UUID) {
|
|
||||||
let url = Constant.affineUpstreamURL
|
|
||||||
.appendingPathComponent("api")
|
|
||||||
.appendingPathComponent("copilot")
|
|
||||||
.appendingPathComponent("chat")
|
|
||||||
.appendingPathComponent(sessionID)
|
|
||||||
.appendingPathComponent("stream")
|
|
||||||
var comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
||||||
comps?.queryItems = [URLQueryItem(name: "messageId", value: messageID)]
|
|
||||||
|
|
||||||
guard let url = comps?.url else {
|
|
||||||
assertionFailure()
|
|
||||||
chat_onError(UnableTo.createMessage)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatchToMain {
|
|
||||||
self.simpleChatContents.updateValue(
|
|
||||||
.assistant(document: "..."),
|
|
||||||
forKey: cellID
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let sem = DispatchSemaphore(value: 0)
|
|
||||||
|
|
||||||
let eventHandler = BlockEventHandler()
|
|
||||||
eventHandler.onOpenedBlock = {
|
|
||||||
print("[*] chat opened")
|
|
||||||
}
|
|
||||||
eventHandler.onClosedBlock = {
|
|
||||||
sem.signal()
|
|
||||||
self.chatTask?.stop()
|
|
||||||
self.chatTask = nil
|
|
||||||
}
|
|
||||||
eventHandler.onErrorBlock = { error in
|
|
||||||
self.chat_onError(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
var document = ""
|
|
||||||
eventHandler.onMessageBlock = { _, message in
|
|
||||||
self.dispatchToMain {
|
|
||||||
document += message.data
|
|
||||||
let content = ChatContent.assistant(document: document)
|
|
||||||
self.simpleChatContents.updateValue(content, forKey: cellID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let eventSource = EventSource(config: .init(handler: eventHandler, url: url))
|
|
||||||
chatTask = eventSource
|
|
||||||
eventSource.start()
|
|
||||||
|
|
||||||
sem.wait()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension IntelligentsChatController {
|
|
||||||
func updateContentToPublisher() {
|
|
||||||
assert(Thread.isMainThread)
|
|
||||||
let copy = simpleChatContents
|
|
||||||
let input: [MessageListView.Element] = copy.map { key, value in
|
|
||||||
switch value {
|
|
||||||
case let .assistant(document):
|
|
||||||
let nodes = MarkdownParser().feed(document)
|
|
||||||
return .init(
|
|
||||||
id: key,
|
|
||||||
cell: .assistant,
|
|
||||||
viewModel: MessageListView.AssistantCell.ViewModel(blocks: nodes),
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
case let .user(document):
|
|
||||||
return .init(
|
|
||||||
id: key,
|
|
||||||
cell: .user,
|
|
||||||
viewModel: MessageListView.UserCell.ViewModel(text: document),
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
case let .error(text):
|
|
||||||
return .init(
|
|
||||||
id: key,
|
|
||||||
cell: .hint,
|
|
||||||
viewModel: MessageListView.HintCell.ViewModel(hint: text),
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
publisher.send(input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-124
@@ -1,124 +0,0 @@
|
|||||||
//
|
|
||||||
// IntelligentsChatController+Header.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/18.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension IntelligentsChatController {
|
|
||||||
class Header: UIView {
|
|
||||||
static let height: CGFloat = 44
|
|
||||||
|
|
||||||
let contentView = UIView()
|
|
||||||
let titleLabel = UILabel()
|
|
||||||
let dropMenu = UIButton()
|
|
||||||
let backButton = UIButton()
|
|
||||||
let rightBarItemsStack = UIStackView()
|
|
||||||
let moreMenu = UIButton()
|
|
||||||
|
|
||||||
init() {
|
|
||||||
super.init(frame: .zero)
|
|
||||||
setupLayout()
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override var isUserInteractionEnabled: Bool {
|
|
||||||
didSet { updateAvailabilityStyles() }
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateAvailabilityStyles() {
|
|
||||||
if isUserInteractionEnabled {
|
|
||||||
backButton.isEnabled = true
|
|
||||||
dropMenu.isEnabled = true
|
|
||||||
moreMenu.isEnabled = true
|
|
||||||
} else {
|
|
||||||
backButton.isEnabled = false
|
|
||||||
dropMenu.isEnabled = false
|
|
||||||
moreMenu.isEnabled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func navigateActionBack() {
|
|
||||||
parentViewController?.dismissInContext()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension IntelligentsChatController.Header {
|
|
||||||
func setupLayout() {
|
|
||||||
contentView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
addSubview(contentView)
|
|
||||||
[
|
|
||||||
contentView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
||||||
contentView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
||||||
contentView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
||||||
contentView.heightAnchor.constraint(equalToConstant: Self.height),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
titleLabel.textColor = .label
|
|
||||||
titleLabel.font = .systemFont(
|
|
||||||
ofSize: UIFont.labelFontSize,
|
|
||||||
weight: .semibold
|
|
||||||
)
|
|
||||||
|
|
||||||
backButton.setImage(
|
|
||||||
UIImage(systemName: "chevron.left"),
|
|
||||||
for: .normal
|
|
||||||
)
|
|
||||||
backButton.tintColor = .accent
|
|
||||||
backButton.addTarget(self, action: #selector(navigateActionBack), for: .touchUpInside)
|
|
||||||
|
|
||||||
dropMenu.setImage(
|
|
||||||
.init(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate),
|
|
||||||
for: .normal
|
|
||||||
)
|
|
||||||
dropMenu.tintColor = .gray.withAlphaComponent(0.5)
|
|
||||||
|
|
||||||
contentView.addSubview(titleLabel)
|
|
||||||
contentView.addSubview(backButton)
|
|
||||||
contentView.addSubview(dropMenu)
|
|
||||||
contentView.addSubview(rightBarItemsStack)
|
|
||||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
backButton.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
dropMenu.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
rightBarItemsStack.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
rightBarItemsStack.axis = .horizontal
|
|
||||||
rightBarItemsStack.spacing = 10
|
|
||||||
rightBarItemsStack.alignment = .center
|
|
||||||
rightBarItemsStack.distribution = .equalSpacing
|
|
||||||
|
|
||||||
[
|
|
||||||
backButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
|
||||||
backButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10),
|
|
||||||
backButton.widthAnchor.constraint(equalToConstant: 44),
|
|
||||||
backButton.heightAnchor.constraint(equalToConstant: 44),
|
|
||||||
|
|
||||||
rightBarItemsStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
|
||||||
rightBarItemsStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
|
|
||||||
rightBarItemsStack.heightAnchor.constraint(equalToConstant: 44),
|
|
||||||
|
|
||||||
titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
|
|
||||||
titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
|
||||||
titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: backButton.trailingAnchor, constant: 10),
|
|
||||||
|
|
||||||
dropMenu.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
|
||||||
dropMenu.widthAnchor.constraint(equalToConstant: 44),
|
|
||||||
dropMenu.heightAnchor.constraint(equalToConstant: 44),
|
|
||||||
titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: dropMenu.leadingAnchor, constant: -10),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
rightBarItemsStack.addArrangedSubview(moreMenu)
|
|
||||||
moreMenu.setImage(
|
|
||||||
.init(systemName: "ellipsis.circle"),
|
|
||||||
for: .normal
|
|
||||||
)
|
|
||||||
moreMenu.tintColor = .accent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-64
@@ -1,64 +0,0 @@
|
|||||||
//
|
|
||||||
// IntelligentsChatController+InputBox.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/18.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension IntelligentsChatController {
|
|
||||||
class InputBox: UIView {
|
|
||||||
let backgroundView = UIView()
|
|
||||||
let editor = InputEditView()
|
|
||||||
|
|
||||||
init() {
|
|
||||||
super.init(frame: .zero)
|
|
||||||
|
|
||||||
setupLayout()
|
|
||||||
|
|
||||||
editor.textEditor.font = UIFont.systemFont(ofSize: UIFont.labelFontSize)
|
|
||||||
editor.placeholderText = "Summarize this article for me...".localized()
|
|
||||||
|
|
||||||
backgroundView.backgroundColor = .init(
|
|
||||||
light: .init(white: 1, alpha: 1),
|
|
||||||
dark: .init(white: 0.15, alpha: 1)
|
|
||||||
)
|
|
||||||
backgroundView.layer.cornerRadius = 16
|
|
||||||
backgroundView.layer.shadowColor = UIColor.black.withAlphaComponent(0.25).cgColor
|
|
||||||
backgroundView.layer.shadowOffset = .init(width: 0, height: 0)
|
|
||||||
backgroundView.layer.shadowRadius = 8
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension IntelligentsChatController.InputBox {
|
|
||||||
func setupLayout() {
|
|
||||||
addSubview(backgroundView)
|
|
||||||
backgroundView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
addSubview(editor)
|
|
||||||
editor.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
let inset: CGFloat = 16
|
|
||||||
|
|
||||||
[
|
|
||||||
editor.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset),
|
|
||||||
editor.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset),
|
|
||||||
editor.topAnchor.constraint(equalTo: topAnchor, constant: inset),
|
|
||||||
editor.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -inset),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
[
|
|
||||||
backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0),
|
|
||||||
backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0),
|
|
||||||
backgroundView.topAnchor.constraint(equalTo: topAnchor, constant: 0),
|
|
||||||
backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 128),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-150
@@ -1,150 +0,0 @@
|
|||||||
//
|
|
||||||
// IntelligentsChatController.swift
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/18.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import LDSwiftEventSource
|
|
||||||
import OrderedCollections
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public class IntelligentsChatController: UIViewController {
|
|
||||||
let header = Header()
|
|
||||||
let inputBox = InputBox()
|
|
||||||
let progressView = UIActivityIndicatorView()
|
|
||||||
|
|
||||||
let publisher = PassthroughSubject<MessageListView.ElementPublisher.Output, Never>()
|
|
||||||
lazy var tableView = MessageListView(dataPublisher: publisher.eraseToAnyPublisher())
|
|
||||||
|
|
||||||
var inputBoxKeyboardAdapterHeightConstraint = NSLayoutConstraint()
|
|
||||||
|
|
||||||
enum ChatContent {
|
|
||||||
case user(document: String)
|
|
||||||
case assistant(document: String)
|
|
||||||
case error(text: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
var simpleChatContents: OrderedDictionary<UUID, ChatContent> = [:] {
|
|
||||||
didSet { updateContentToPublisher() }
|
|
||||||
}
|
|
||||||
|
|
||||||
var sessionID: String = ""
|
|
||||||
|
|
||||||
public enum MetadataKey: String {
|
|
||||||
case documentID
|
|
||||||
case workspaceID
|
|
||||||
case content
|
|
||||||
}
|
|
||||||
|
|
||||||
public var metadata: [MetadataKey: String] = [:]
|
|
||||||
|
|
||||||
var chatTask: EventSource?
|
|
||||||
|
|
||||||
override public var title: String? {
|
|
||||||
set {
|
|
||||||
super.title = newValue
|
|
||||||
header.titleLabel.text = newValue
|
|
||||||
}
|
|
||||||
get {
|
|
||||||
super.title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public init() {
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
title = "Chat with AI".localized()
|
|
||||||
|
|
||||||
overrideUserInterfaceStyle = .dark
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
chatTask?.stop()
|
|
||||||
chatTask = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
override public func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
assert(navigationController != nil)
|
|
||||||
view.backgroundColor = .secondarySystemBackground
|
|
||||||
|
|
||||||
hideKeyboardWhenTappedAround()
|
|
||||||
|
|
||||||
view.addSubview(header)
|
|
||||||
view.addSubview(tableView)
|
|
||||||
view.addSubview(inputBox)
|
|
||||||
view.addSubview(progressView)
|
|
||||||
setupLayout()
|
|
||||||
|
|
||||||
header.moreMenu.showsMenuAsPrimaryAction = true
|
|
||||||
header.moreMenu.menu = .init(children: [
|
|
||||||
UIAction(title: "Clear History".localized(), image: UIImage(systemName: "eraser")) { [weak self] _ in
|
|
||||||
self?.chat_clearHistory()
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// TODO: IMPL
|
|
||||||
header.dropMenu.isHidden = true
|
|
||||||
inputBox.editor.controlBanner.cameraButton.isHidden = true
|
|
||||||
inputBox.editor.controlBanner.photoButton.isHidden = true
|
|
||||||
|
|
||||||
updateContentToPublisher()
|
|
||||||
chat_onLoad()
|
|
||||||
}
|
|
||||||
|
|
||||||
override public func viewDidDisappear(_ animated: Bool) {
|
|
||||||
super.viewDidDisappear(animated)
|
|
||||||
chatTask?.stop()
|
|
||||||
chatTask = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupLayout() {
|
|
||||||
header.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
[
|
|
||||||
header.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
header.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
header.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
header.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 44),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
inputBox.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
[
|
|
||||||
inputBox.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
inputBox.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
inputBox.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
[
|
|
||||||
tableView.topAnchor.constraint(equalTo: header.bottomAnchor),
|
|
||||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
tableView.bottomAnchor.constraint(equalTo: inputBox.topAnchor),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
inputBox.editor.controlBanner.sendButton.addTarget(
|
|
||||||
self,
|
|
||||||
action: #selector(chat_onSend),
|
|
||||||
for: .touchUpInside
|
|
||||||
)
|
|
||||||
inputBox.editor.submitAction = { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
chat_onSend()
|
|
||||||
}
|
|
||||||
|
|
||||||
progressView.hidesWhenStopped = true
|
|
||||||
progressView.stopAnimating()
|
|
||||||
progressView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
[
|
|
||||||
progressView.centerXAnchor.constraint(equalTo: inputBox.centerXAnchor),
|
|
||||||
progressView.centerYAnchor.constraint(equalTo: inputBox.centerYAnchor),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
progressView.style = .large
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-150
@@ -1,150 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageListView+AssistantCell.swift
|
|
||||||
// FlowDown
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/2.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import MarkdownParser
|
|
||||||
import MarkdownView
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension MessageListView {
|
|
||||||
class AssistantCell: BaseCell {
|
|
||||||
let avatarView = UIImageView()
|
|
||||||
let usernameView = UILabel()
|
|
||||||
let markdownView = MarkdownView()
|
|
||||||
|
|
||||||
override func initializeContent() {
|
|
||||||
super.initializeContent()
|
|
||||||
|
|
||||||
avatarView.contentMode = .scaleAspectFit
|
|
||||||
avatarView.image = UIImage(named: "spark", in: .module, with: nil)
|
|
||||||
usernameView.text = "AFFiNE AI"
|
|
||||||
usernameView.font = .preferredFont(forTextStyle: .body).bold
|
|
||||||
usernameView.textColor = .label
|
|
||||||
|
|
||||||
containerView.addSubview(avatarView)
|
|
||||||
containerView.addSubview(usernameView)
|
|
||||||
containerView.addSubview(markdownView)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func prepareForReuse() {
|
|
||||||
super.prepareForReuse()
|
|
||||||
markdownView.prepareForReuse()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func updateContent(
|
|
||||||
object: any MessageListView.Element.ViewModel,
|
|
||||||
originalObject _: Element.UserObject?
|
|
||||||
) {
|
|
||||||
guard let object = object as? ViewModel else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = object
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutContent(cache: any MessageListView.TableLayoutEngine.LayoutCache) {
|
|
||||||
super.layoutContent(cache: cache)
|
|
||||||
guard let cache = cache as? LayoutCache else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
avatarView.frame = cache.avatarRect
|
|
||||||
usernameView.frame = cache.usernameRect
|
|
||||||
markdownView.frame = cache.markdownFrame
|
|
||||||
|
|
||||||
UIView.performWithoutAnimation {
|
|
||||||
markdownView.updateContentViews(cache.manifests)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override class func layoutInsideContainer(
|
|
||||||
containerWidth: CGFloat,
|
|
||||||
object: any MessageListView.Element.ViewModel
|
|
||||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
|
||||||
guard let object = object as? ViewModel else {
|
|
||||||
assertionFailure()
|
|
||||||
return LayoutCache()
|
|
||||||
}
|
|
||||||
let cache = LayoutCache()
|
|
||||||
cache.width = containerWidth
|
|
||||||
|
|
||||||
let inset: CGFloat = 8
|
|
||||||
let bubbleInset = UIEdgeInsets(top: inset, left: inset, bottom: inset, right: inset)
|
|
||||||
|
|
||||||
let avatarRect = CGRect(x: bubbleInset.left, y: bubbleInset.top, width: 24, height: 24)
|
|
||||||
let usernameRect = CGRect(
|
|
||||||
x: avatarRect.maxX + bubbleInset.right,
|
|
||||||
y: bubbleInset.top,
|
|
||||||
width: containerWidth - avatarRect.maxX - bubbleInset.right,
|
|
||||||
height: 24
|
|
||||||
)
|
|
||||||
|
|
||||||
let textWidth = containerWidth - bubbleInset.left - bubbleInset.right
|
|
||||||
|
|
||||||
var height: CGFloat = 0
|
|
||||||
let manifests = object.blocks.map {
|
|
||||||
let ret = $0.manifest(theme: object.theme)
|
|
||||||
ret.setLayoutTheme(.default)
|
|
||||||
ret.setLayoutWidth(textWidth)
|
|
||||||
ret.layoutIfNeeded()
|
|
||||||
height += ret.size.height + Theme.default.spacings.final
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
if height > 0 { height -= Theme.default.spacings.final }
|
|
||||||
let textRect = CGRect(
|
|
||||||
x: bubbleInset.left,
|
|
||||||
y: usernameRect.maxY + bubbleInset.bottom,
|
|
||||||
width: textWidth,
|
|
||||||
height: height
|
|
||||||
)
|
|
||||||
cache.markdownFrame = textRect
|
|
||||||
cache.avatarRect = avatarRect
|
|
||||||
cache.usernameRect = usernameRect
|
|
||||||
cache.manifests = manifests
|
|
||||||
cache.height = textRect.maxY + bubbleInset.bottom
|
|
||||||
|
|
||||||
return cache
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MessageListView.AssistantCell {
|
|
||||||
class ViewModel: MessageListView.Element.ViewModel {
|
|
||||||
var theme: Theme
|
|
||||||
var blocks: [BlockNode]
|
|
||||||
|
|
||||||
enum GroupLocation {
|
|
||||||
case begin
|
|
||||||
case center
|
|
||||||
case end
|
|
||||||
}
|
|
||||||
|
|
||||||
var groupLocation: GroupLocation = .center
|
|
||||||
|
|
||||||
init(theme: Theme = .default, blocks: [BlockNode]) {
|
|
||||||
self.theme = theme
|
|
||||||
self.blocks = blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
func contentIdentifier(hasher: inout Hasher) {
|
|
||||||
hasher.combine(blocks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MessageListView.AssistantCell {
|
|
||||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
|
||||||
var width: CGFloat = 0
|
|
||||||
var height: CGFloat = 0
|
|
||||||
|
|
||||||
var avatarRect: CGRect = .zero
|
|
||||||
var usernameRect: CGRect = .zero
|
|
||||||
|
|
||||||
var markdownFrame: CGRect = .zero
|
|
||||||
var manifests: [AnyBlockManifest] = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-143
@@ -1,143 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageListView+BaseCell.swift
|
|
||||||
// FlowDown
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/2.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension MessageListView {
|
|
||||||
class BaseCell: UITableViewCell, MessageListView.TableLayoutEngine.LayoutableCell {
|
|
||||||
var associatedObject: Element? = nil
|
|
||||||
var cancellable: Set<AnyCancellable> = []
|
|
||||||
let containerView: UIView = .init()
|
|
||||||
|
|
||||||
var layoutEngine: MessageListView.TableLayoutEngine? = nil
|
|
||||||
|
|
||||||
func layoutCache() -> MessageListView.TableLayoutEngine.LayoutCache {
|
|
||||||
guard let associatedObject, let engine = layoutEngine else {
|
|
||||||
return MessageListView.TableLayoutEngine.ZeroLayoutCache()
|
|
||||||
}
|
|
||||||
let cache = engine.requestLayoutCacheFromCell(
|
|
||||||
forElement: associatedObject,
|
|
||||||
atWidth: bounds.width
|
|
||||||
)
|
|
||||||
return cache
|
|
||||||
}
|
|
||||||
|
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
|
||||||
commitInit()
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
super.init(coder: coder)
|
|
||||||
commitInit()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func commitInit() {
|
|
||||||
selectionStyle = .none
|
|
||||||
separatorInset = .zero
|
|
||||||
contentView.addSubview(containerView)
|
|
||||||
contentView.clipsToBounds = false
|
|
||||||
clipsToBounds = false
|
|
||||||
initializeContent()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews() {
|
|
||||||
super.layoutSubviews()
|
|
||||||
guard let cache = layoutCache() as? LayoutCache else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
containerView.frame = cache.containerRect
|
|
||||||
layoutContent(cache: cache.containerLayoutCache)
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerViewModel(element: Element) {
|
|
||||||
removeViewModelObject()
|
|
||||||
associatedObject = element
|
|
||||||
updateContent(object: element.viewModel, originalObject: element.object)
|
|
||||||
setNeedsLayout()
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeViewModelObject() {
|
|
||||||
associatedObject = nil
|
|
||||||
cancellable.forEach { $0.cancel() }
|
|
||||||
cancellable.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
func initializeContent() {}
|
|
||||||
func updateContent(object: any Element.ViewModel, originalObject: Element.UserObject?) {
|
|
||||||
_ = object
|
|
||||||
_ = originalObject
|
|
||||||
}
|
|
||||||
|
|
||||||
func layoutContent(cache: MessageListView.TableLayoutEngine.LayoutCache) {
|
|
||||||
_ = cache
|
|
||||||
}
|
|
||||||
|
|
||||||
class func layoutInsideContainer(
|
|
||||||
containerWidth: CGFloat,
|
|
||||||
object: any Element.ViewModel
|
|
||||||
) -> MessageListView.TableLayoutEngine.LayoutCache {
|
|
||||||
_ = containerWidth
|
|
||||||
_ = object
|
|
||||||
assertionFailure("must override")
|
|
||||||
return MessageListView.TableLayoutEngine.ZeroLayoutCache()
|
|
||||||
}
|
|
||||||
|
|
||||||
class func containerInset() -> UIEdgeInsets {
|
|
||||||
let inset: CGFloat = 16
|
|
||||||
let containerInset = UIEdgeInsets(top: inset / 2, left: inset, bottom: inset / 2, right: inset)
|
|
||||||
return containerInset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MessageListView.BaseCell {
|
|
||||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
|
||||||
var width: CGFloat
|
|
||||||
var height: CGFloat
|
|
||||||
var containerRect: CGRect
|
|
||||||
var containerLayoutCache: any MessageListView.TableLayoutEngine.LayoutCache
|
|
||||||
|
|
||||||
init(
|
|
||||||
width: CGFloat,
|
|
||||||
height: CGFloat,
|
|
||||||
containerRect: CGRect,
|
|
||||||
containerLayoutCache: any MessageListView.TableLayoutEngine.LayoutCache
|
|
||||||
) {
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self.containerRect = containerRect
|
|
||||||
self.containerLayoutCache = containerLayoutCache
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class func resolveLayout(
|
|
||||||
dataElement element: MessageListView.Element,
|
|
||||||
contentWidth width: CGFloat
|
|
||||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
|
||||||
let object = element.viewModel
|
|
||||||
let containerInset = MessageListView.BaseCell.containerInset()
|
|
||||||
let containerWidth = width - containerInset.left - containerInset.right
|
|
||||||
let containerCache = Self.layoutInsideContainer(containerWidth: containerWidth, object: object)
|
|
||||||
let cellHeight = containerCache.height + containerInset.top + containerInset.bottom
|
|
||||||
let containerRect = CGRect(
|
|
||||||
x: containerInset.left,
|
|
||||||
y: containerInset.top,
|
|
||||||
width: containerWidth,
|
|
||||||
height: containerCache.height
|
|
||||||
)
|
|
||||||
let cache = LayoutCache(
|
|
||||||
width: width,
|
|
||||||
height: cellHeight,
|
|
||||||
containerRect: containerRect,
|
|
||||||
containerLayoutCache: containerCache
|
|
||||||
)
|
|
||||||
return cache
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-92
@@ -1,92 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageListView+HintCell.swift
|
|
||||||
// FlowDown
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/2.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension MessageListView {
|
|
||||||
class HintCell: BaseCell {
|
|
||||||
let label = UILabel()
|
|
||||||
|
|
||||||
override func initializeContent() {
|
|
||||||
super.initializeContent()
|
|
||||||
label.font = .preferredFont(forTextStyle: .footnote)
|
|
||||||
label.alpha = 0.5
|
|
||||||
label.numberOfLines = 0
|
|
||||||
containerView.addSubview(label)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func updateContent(
|
|
||||||
object: any MessageListView.Element.ViewModel,
|
|
||||||
originalObject: Element.UserObject?
|
|
||||||
) {
|
|
||||||
super.updateContent(object: object, originalObject: originalObject)
|
|
||||||
guard let object = object as? ViewModel else { return }
|
|
||||||
label.attributedText = object.hint
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutContent(cache: any MessageListView.TableLayoutEngine.LayoutCache) {
|
|
||||||
super.layoutContent(cache: cache)
|
|
||||||
guard let cache = cache as? LayoutCache else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
label.frame = cache.labelFrame
|
|
||||||
}
|
|
||||||
|
|
||||||
override class func layoutInsideContainer(
|
|
||||||
containerWidth: CGFloat,
|
|
||||||
object: any MessageListView.Element.ViewModel
|
|
||||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
|
||||||
guard let object = object as? ViewModel else {
|
|
||||||
assertionFailure()
|
|
||||||
return LayoutCache()
|
|
||||||
}
|
|
||||||
let cache = LayoutCache()
|
|
||||||
cache.width = containerWidth
|
|
||||||
cache.height = object.hint.measureHeight(usingWidth: containerWidth)
|
|
||||||
cache.labelFrame = .init(x: 0, y: 0, width: containerWidth, height: cache.height)
|
|
||||||
return cache
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MessageListView.HintCell {
|
|
||||||
class ViewModel: MessageListView.Element.ViewModel {
|
|
||||||
var hint: NSAttributedString = .init()
|
|
||||||
|
|
||||||
init(hint: NSAttributedString) {
|
|
||||||
self.hint = hint
|
|
||||||
}
|
|
||||||
|
|
||||||
convenience init(hint: String) {
|
|
||||||
let paragraphStyle = NSMutableParagraphStyle()
|
|
||||||
paragraphStyle.alignment = .center
|
|
||||||
let attributes: [NSAttributedString.Key: Any] = [
|
|
||||||
.font: UIFont.preferredFont(forTextStyle: .footnote),
|
|
||||||
.originalFont: UIFont.preferredFont(forTextStyle: .footnote),
|
|
||||||
.foregroundColor: UIColor.label,
|
|
||||||
.paragraphStyle: paragraphStyle,
|
|
||||||
]
|
|
||||||
let text = NSMutableAttributedString(string: hint, attributes: attributes)
|
|
||||||
self.init(hint: text)
|
|
||||||
}
|
|
||||||
|
|
||||||
func contentIdentifier(hasher: inout Hasher) {
|
|
||||||
hasher.combine(hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MessageListView.HintCell {
|
|
||||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
|
||||||
var width: CGFloat = 0
|
|
||||||
var height: CGFloat = 0
|
|
||||||
|
|
||||||
var labelFrame: CGRect = .zero
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-47
@@ -1,47 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageListView+SpacerCell.swift
|
|
||||||
// FlowDown
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/12.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension MessageListView {
|
|
||||||
class SpacerCell: BaseCell {
|
|
||||||
override class func layoutInsideContainer(
|
|
||||||
containerWidth: CGFloat,
|
|
||||||
object: any MessageListView.Element.ViewModel
|
|
||||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
|
||||||
guard let object = object as? ViewModel else {
|
|
||||||
assertionFailure()
|
|
||||||
return LayoutCache()
|
|
||||||
}
|
|
||||||
let cache = LayoutCache()
|
|
||||||
cache.width = containerWidth
|
|
||||||
cache.height = object.height
|
|
||||||
return cache
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MessageListView.SpacerCell {
|
|
||||||
class ViewModel: MessageListView.Element.ViewModel {
|
|
||||||
var height: CGFloat
|
|
||||||
init(height: CGFloat) {
|
|
||||||
self.height = height
|
|
||||||
}
|
|
||||||
|
|
||||||
func contentIdentifier(hasher: inout Hasher) {
|
|
||||||
hasher.combine(height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MessageListView.SpacerCell {
|
|
||||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
|
||||||
var width: CGFloat = 0
|
|
||||||
var height: CGFloat = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-167
@@ -1,167 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageListView+UserCell.swift
|
|
||||||
// FlowDown
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/2.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension MessageListView {
|
|
||||||
class UserCell: BaseCell {
|
|
||||||
let avatarView = UIImageView()
|
|
||||||
let usernameView = UILabel()
|
|
||||||
let bubbleView = UIView()
|
|
||||||
let textView = UITextView()
|
|
||||||
|
|
||||||
override func initializeContent() {
|
|
||||||
super.initializeContent()
|
|
||||||
|
|
||||||
textView.isSelectable = true
|
|
||||||
textView.isScrollEnabled = true
|
|
||||||
textView.isEditable = false
|
|
||||||
textView.showsVerticalScrollIndicator = false
|
|
||||||
textView.showsHorizontalScrollIndicator = false
|
|
||||||
textView.textColor = .label
|
|
||||||
textView.textContainer.lineFragmentPadding = .zero
|
|
||||||
textView.textAlignment = .natural
|
|
||||||
textView.backgroundColor = .clear
|
|
||||||
textView.textContainerInset = .zero
|
|
||||||
textView.textContainer.lineBreakMode = .byTruncatingTail
|
|
||||||
|
|
||||||
avatarView.contentMode = .scaleAspectFit
|
|
||||||
avatarView.image = UIImage(systemName: "person.fill")
|
|
||||||
usernameView.text = "You"
|
|
||||||
usernameView.font = .preferredFont(forTextStyle: .body).bold
|
|
||||||
usernameView.textColor = .label
|
|
||||||
|
|
||||||
bubbleView.layer.cornerRadius = 8
|
|
||||||
bubbleView.backgroundColor = .gray.withAlphaComponent(0.1)
|
|
||||||
|
|
||||||
containerView.addSubview(bubbleView)
|
|
||||||
containerView.addSubview(avatarView)
|
|
||||||
containerView.addSubview(usernameView)
|
|
||||||
containerView.addSubview(textView)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func updateContent(
|
|
||||||
object: any MessageListView.Element.ViewModel,
|
|
||||||
originalObject: Element.UserObject?
|
|
||||||
) {
|
|
||||||
super.updateContent(object: object, originalObject: originalObject)
|
|
||||||
guard let object = object as? ViewModel else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
textView.attributedText = object.text
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutContent(cache: any MessageListView.TableLayoutEngine.LayoutCache) {
|
|
||||||
super.layoutContent(cache: cache)
|
|
||||||
guard let cache = cache as? LayoutCache else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bubbleView.frame = cache.bubbleFrame
|
|
||||||
avatarView.frame = cache.avatarFrame
|
|
||||||
usernameView.frame = cache.usernameFrame
|
|
||||||
textView.frame = cache.labelFrame
|
|
||||||
}
|
|
||||||
|
|
||||||
override class func layoutInsideContainer(
|
|
||||||
containerWidth: CGFloat,
|
|
||||||
object: any MessageListView.Element.ViewModel
|
|
||||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
|
||||||
guard let object = object as? ViewModel else {
|
|
||||||
assertionFailure()
|
|
||||||
return LayoutCache()
|
|
||||||
}
|
|
||||||
let cache = LayoutCache()
|
|
||||||
cache.width = containerWidth
|
|
||||||
|
|
||||||
let inset: CGFloat = 8
|
|
||||||
let bubbleInset = UIEdgeInsets(top: inset, left: inset, bottom: inset, right: inset)
|
|
||||||
|
|
||||||
let avatarRect = CGRect(
|
|
||||||
x: bubbleInset.left,
|
|
||||||
y: bubbleInset.top,
|
|
||||||
width: 24,
|
|
||||||
height: 24
|
|
||||||
)
|
|
||||||
let usernameFrame = CGRect(
|
|
||||||
x: avatarRect.maxX + inset,
|
|
||||||
y: bubbleInset.top,
|
|
||||||
width: containerWidth - avatarRect.maxX - bubbleInset.right,
|
|
||||||
height: 24
|
|
||||||
)
|
|
||||||
|
|
||||||
let textWidth = min(
|
|
||||||
object.text.measureWidth(),
|
|
||||||
containerWidth - inset * 2
|
|
||||||
)
|
|
||||||
let textHeight = object.text.measureHeight(usingWidth: textWidth)
|
|
||||||
let textRect = CGRect(
|
|
||||||
x: bubbleInset.left,
|
|
||||||
y: avatarRect.maxY + bubbleInset.top,
|
|
||||||
width: textWidth,
|
|
||||||
height: textHeight
|
|
||||||
)
|
|
||||||
let bubbleRect = CGRect(
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: containerWidth,
|
|
||||||
height: textRect.maxY + bubbleInset.bottom
|
|
||||||
)
|
|
||||||
cache.bubbleFrame = bubbleRect
|
|
||||||
cache.avatarFrame = avatarRect
|
|
||||||
cache.usernameFrame = usernameFrame
|
|
||||||
cache.labelFrame = textRect
|
|
||||||
cache.height = bubbleRect.maxY
|
|
||||||
return cache
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MessageListView.UserCell {
|
|
||||||
class ViewModel: MessageListView.Element.ViewModel {
|
|
||||||
var text: NSAttributedString = .init()
|
|
||||||
|
|
||||||
init(text: NSAttributedString) {
|
|
||||||
self.text = text
|
|
||||||
}
|
|
||||||
|
|
||||||
convenience init(text: String) {
|
|
||||||
let paragraphStyle = NSMutableParagraphStyle()
|
|
||||||
paragraphStyle.alignment = .natural
|
|
||||||
let attributes: [NSAttributedString.Key: Any] = [
|
|
||||||
.font: UIFont.preferredFont(forTextStyle: .body),
|
|
||||||
.originalFont: UIFont.preferredFont(forTextStyle: .body),
|
|
||||||
.foregroundColor: UIColor.label,
|
|
||||||
.paragraphStyle: paragraphStyle,
|
|
||||||
]
|
|
||||||
var text = text
|
|
||||||
while text.contains("\n\n\n") {
|
|
||||||
text = text.replacingOccurrences(of: "\n\n\n", with: "\n\n")
|
|
||||||
}
|
|
||||||
print(text)
|
|
||||||
self.init(text: NSMutableAttributedString(string: text, attributes: attributes))
|
|
||||||
}
|
|
||||||
|
|
||||||
func contentIdentifier(hasher: inout Hasher) {
|
|
||||||
hasher.combine(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MessageListView.UserCell {
|
|
||||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
|
||||||
var width: CGFloat = 0
|
|
||||||
var height: CGFloat = 0
|
|
||||||
|
|
||||||
var bubbleFrame: CGRect = .zero
|
|
||||||
var labelFrame: CGRect = .zero
|
|
||||||
var avatarFrame: CGRect = .zero
|
|
||||||
var usernameFrame: CGRect = .zero
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-61
@@ -1,61 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageListView+DataElement.swift
|
|
||||||
// FlowDown
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/2.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension MessageListView {
|
|
||||||
struct Element: Identifiable {
|
|
||||||
let id: AnyHashable // equals to message id if applicable
|
|
||||||
|
|
||||||
enum Cell: String, CaseIterable {
|
|
||||||
case base
|
|
||||||
case hint
|
|
||||||
case user
|
|
||||||
case assistant
|
|
||||||
case spacer
|
|
||||||
}
|
|
||||||
|
|
||||||
let cell: Cell
|
|
||||||
let viewModel: any ViewModel
|
|
||||||
|
|
||||||
typealias UserObject = any(Identifiable & Hashable)
|
|
||||||
let object: UserObject?
|
|
||||||
|
|
||||||
init(id: AnyHashable, cell: Cell, viewModel: any ViewModel, object: UserObject?) {
|
|
||||||
assert(cell != .base)
|
|
||||||
self.id = id
|
|
||||||
self.cell = cell
|
|
||||||
self.viewModel = viewModel
|
|
||||||
self.object = object
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MessageListView.Element.Cell {
|
|
||||||
var cellClass: MessageListView.BaseCell.Type {
|
|
||||||
switch self {
|
|
||||||
case .base:
|
|
||||||
MessageListView.BaseCell.self
|
|
||||||
case .hint:
|
|
||||||
MessageListView.HintCell.self
|
|
||||||
case .user:
|
|
||||||
MessageListView.UserCell.self
|
|
||||||
case .assistant:
|
|
||||||
MessageListView.AssistantCell.self
|
|
||||||
case .spacer:
|
|
||||||
MessageListView.SpacerCell.self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MessageListView.Element {
|
|
||||||
protocol ViewModel {
|
|
||||||
func contentIdentifier(hasher: inout Hasher)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-68
@@ -1,68 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageListView+Delegate.swift
|
|
||||||
// FlowDown
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/6.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension MessageListView: UITableViewDelegate, UITableViewDataSource {
|
|
||||||
func item(forIndexPath indexPath: IndexPath) -> Element? {
|
|
||||||
guard indexPath.row < elements.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard indexPath.row >= 0 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return elements.values[indexPath.row]
|
|
||||||
}
|
|
||||||
|
|
||||||
func numberOfSections(in _: UITableView) -> Int {
|
|
||||||
1
|
|
||||||
}
|
|
||||||
|
|
||||||
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
|
|
||||||
elements.count
|
|
||||||
}
|
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
|
||||||
guard let item = item(forIndexPath: indexPath) else {
|
|
||||||
assertionFailure()
|
|
||||||
return UITableViewCell()
|
|
||||||
}
|
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: item.cell.rawValue, for: indexPath)
|
|
||||||
if let cell = cell as? BaseCell {
|
|
||||||
cell.layoutEngine = layoutEngine
|
|
||||||
cell.registerViewModel(element: item)
|
|
||||||
}
|
|
||||||
cell.backgroundColor = .clear
|
|
||||||
return cell
|
|
||||||
}
|
|
||||||
|
|
||||||
func tableView(_: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
|
||||||
guard let item = item(forIndexPath: indexPath) else {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if let height = layoutEngine.height(forElement: item) {
|
|
||||||
heightKeeper[item.id] = height
|
|
||||||
return height
|
|
||||||
}
|
|
||||||
let ret = layoutEngine.resolveLayoutNow(item).height
|
|
||||||
heightKeeper[item.id] = ret
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func tableView(_: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
|
||||||
guard let item = item(forIndexPath: indexPath) else {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if let height = layoutEngine.height(forElement: item) {
|
|
||||||
return height
|
|
||||||
}
|
|
||||||
if let height = heightKeeper[item.id] {
|
|
||||||
return height
|
|
||||||
}
|
|
||||||
return UITableView.automaticDimension
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-143
@@ -1,143 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageListView+LayoutEngine.swift
|
|
||||||
// FlowDown
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/2.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension MessageListView {
|
|
||||||
class TableLayoutEngine {
|
|
||||||
private let lock = NSLock()
|
|
||||||
struct LayoutCacheBox {
|
|
||||||
var cache: LayoutCache
|
|
||||||
var contentIdentifier: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
private var layoutCache: [Element.ID: LayoutCacheBox] = [:]
|
|
||||||
private(set) var contentWidth: CGFloat = .zero
|
|
||||||
var layoutSession: UUID = .init()
|
|
||||||
|
|
||||||
func setContentWidth(_ width: CGFloat) {
|
|
||||||
accessLayoutCache { _ in
|
|
||||||
contentWidth = width
|
|
||||||
layoutSession = .init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createSession() -> UUID {
|
|
||||||
let session = UUID()
|
|
||||||
layoutSession = session
|
|
||||||
return session
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func accessLayoutCache<T>(_ block: (inout [Element.ID: LayoutCacheBox]) -> T) -> T {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
return block(&layoutCache)
|
|
||||||
}
|
|
||||||
|
|
||||||
func contentIdentifier(forElement dataElement: Element) -> Int? {
|
|
||||||
accessLayoutCache { pool in
|
|
||||||
guard let box = pool[dataElement.id] else { return nil }
|
|
||||||
return box.contentIdentifier
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func viewCallingUpdateLayoutEngineWidth() {
|
|
||||||
guard layoutEngine.contentWidth != tableView.bounds.width else { return }
|
|
||||||
layoutEngine.setContentWidth(tableView.bounds.width)
|
|
||||||
reconfigure(enforceReload: false)
|
|
||||||
NSObject.cancelPreviousPerformRequests(
|
|
||||||
withTarget: self,
|
|
||||||
selector: #selector(resolveAllLayoutInBackground),
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
perform(#selector(resolveAllLayoutInBackground), with: nil, afterDelay: 0.1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func resolveAllLayoutInBackground() {
|
|
||||||
let items = Array(elements.values)
|
|
||||||
let date = Date()
|
|
||||||
DispatchQueue.global().async {
|
|
||||||
let session = self.layoutEngine.createSession()
|
|
||||||
for element in items {
|
|
||||||
guard self.layoutEngine.layoutSession == session else { continue }
|
|
||||||
self.layoutEngine.resolveLayoutNow(element)
|
|
||||||
}
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.reconfigure(enforceReload: true)
|
|
||||||
print("[*] layout engine updated \(items.count) items in \(Date().timeIntervalSince(date)) seconds")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MessageListView.TableLayoutEngine {
|
|
||||||
protocol LayoutableCell: AnyObject {
|
|
||||||
static func resolveLayout(
|
|
||||||
dataElement: MessageListView.Element,
|
|
||||||
contentWidth: CGFloat
|
|
||||||
) -> LayoutCache
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol LayoutCache: AnyObject {
|
|
||||||
var width: CGFloat { get }
|
|
||||||
var height: CGFloat { get }
|
|
||||||
}
|
|
||||||
|
|
||||||
class ZeroLayoutCache: LayoutCache {
|
|
||||||
var width: CGFloat = 0
|
|
||||||
var height: CGFloat = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MessageListView.TableLayoutEngine {
|
|
||||||
@discardableResult
|
|
||||||
func resolveLayoutNow(_ element: MessageListView.Element) -> LayoutCache {
|
|
||||||
var hasher = Hasher()
|
|
||||||
element.viewModel.contentIdentifier(hasher: &hasher)
|
|
||||||
let contentIdentifier = hasher.finalize()
|
|
||||||
|
|
||||||
if let cacheBox = accessLayoutCache({ $0[element.id] }) {
|
|
||||||
if cacheBox.cache.width == contentWidth,
|
|
||||||
cacheBox.contentIdentifier == contentIdentifier
|
|
||||||
{ return cacheBox.cache }
|
|
||||||
}
|
|
||||||
|
|
||||||
let target = element.cell.cellClass.self
|
|
||||||
let cache = target.resolveLayout(dataElement: element, contentWidth: contentWidth)
|
|
||||||
let cacheBox = LayoutCacheBox(cache: cache, contentIdentifier: contentIdentifier)
|
|
||||||
accessLayoutCache { $0[element.id] = cacheBox }
|
|
||||||
return cache
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestLayoutCacheFromCell(
|
|
||||||
forElement dataElement: MessageListView.Element,
|
|
||||||
atWidth width: CGFloat
|
|
||||||
) -> LayoutCache {
|
|
||||||
let cache = accessLayoutCache { pool -> LayoutCache? in
|
|
||||||
guard let box = pool[dataElement.id] else { return nil }
|
|
||||||
guard box.contentIdentifier == dataElement.object?.hashValue else { return nil }
|
|
||||||
guard box.cache.width == contentWidth else { return nil }
|
|
||||||
guard box.cache.width == width else { return nil }
|
|
||||||
return box.cache
|
|
||||||
}
|
|
||||||
if let cache { return cache }
|
|
||||||
return resolveLayoutNow(dataElement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MessageListView.TableLayoutEngine {
|
|
||||||
func height(forElement dataElement: MessageListView.Element) -> CGFloat? {
|
|
||||||
accessLayoutCache { pool in
|
|
||||||
guard let box = pool[dataElement.id] else { return nil }
|
|
||||||
guard box.contentIdentifier == dataElement.object?.hashValue else { return nil }
|
|
||||||
guard box.cache.width == contentWidth else { return nil }
|
|
||||||
return box.cache.height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-148
@@ -1,148 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageListView+Update.swift
|
|
||||||
// FlowDown
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/2.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
import OrderedCollections
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension MessageListView {
|
|
||||||
func setupPublishers(dataPublisher: AnyPublisher<[Element], Never>) {
|
|
||||||
// process input from data source where we transform those to view model
|
|
||||||
let publisher = dataPublisher
|
|
||||||
.map { input -> [Element] in input + [Element(
|
|
||||||
id: "spacer",
|
|
||||||
cell: .spacer,
|
|
||||||
viewModel: MessageListView.SpacerCell.ViewModel(height: 32),
|
|
||||||
object: nil
|
|
||||||
)] }
|
|
||||||
.map { output in
|
|
||||||
OrderedDictionary<Element.ID, Element>(
|
|
||||||
uniqueKeysWithValues: output.map { ($0.id, $0) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
// after so, limit the refresh rate so we can handle them better
|
|
||||||
let updateQueue = DispatchQueue(label: "affine.message-list-update-queue", qos: .userInteractive)
|
|
||||||
let inQueuePublisher = publisher
|
|
||||||
.throttle(for: .seconds(1 / 5), scheduler: updateQueue, latest: true)
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
// finally before sending to display, call layout engine to process those items
|
|
||||||
inQueuePublisher
|
|
||||||
.sink { [weak self] output in self?.prepare(forNewElements: output) }
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepare(forNewElements elements: Elements) {
|
|
||||||
print("[*] received \(elements.count) for update at \(Date())")
|
|
||||||
elementUpdateProcessLock.lock()
|
|
||||||
distributedPendingUpdateElements = elements
|
|
||||||
elementUpdateProcessLock.unlock()
|
|
||||||
performSelector(onMainThread: #selector(elementsUpdateExecute), with: nil, waitUntilDone: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pickupElementsPair() -> (oldValue: Elements, newValue: Elements)? {
|
|
||||||
#if DEBUG // just make sure assert is not called in release mode
|
|
||||||
assert(!elementUpdateProcessLock.try(), "should not call this method without lock")
|
|
||||||
#endif
|
|
||||||
guard let distributedPendingUpdateElements else { return nil }
|
|
||||||
|
|
||||||
let oldValue = elements
|
|
||||||
elements = distributedPendingUpdateElements
|
|
||||||
self.distributedPendingUpdateElements = nil
|
|
||||||
print("[*] pikup is sending \(elements.count) for update at \(Date())")
|
|
||||||
return (oldValue, distributedPendingUpdateElements)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func elementsUpdateExecute() {
|
|
||||||
assert(Thread.isMainThread)
|
|
||||||
elementUpdateProcessLock.lock()
|
|
||||||
defer { elementUpdateProcessLock.unlock() }
|
|
||||||
let pickup = pickupElementsPair()
|
|
||||||
guard let (oldValue, newValue) = pickup else { return }
|
|
||||||
guard window != nil else { return }
|
|
||||||
for value in heightKeeper.keys where !newValue.keys.contains(value) {
|
|
||||||
heightKeeper.removeValue(forKey: value)
|
|
||||||
}
|
|
||||||
|
|
||||||
let shouldRealodTableView = newValue.count != oldValue.count
|
|
||||||
let contentOffset = tableView.contentOffset
|
|
||||||
UIView.performWithoutAnimation {
|
|
||||||
self.reconfigure(enforceReload: shouldRealodTableView)
|
|
||||||
self.tableView.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
tableView.contentOffset = contentOffset
|
|
||||||
|
|
||||||
if scrollToBottomOnNextUpdate {
|
|
||||||
scrollToBottomOnNextUpdate = false
|
|
||||||
scrollToBottom(useTableViewAnimation: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reconfigure(enforceReload: Bool) {
|
|
||||||
if enforceReload || tableView(tableView, numberOfRowsInSection: 0) != elements.count {
|
|
||||||
tableView.reloadData()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var requiresReload = [IndexPath]()
|
|
||||||
for indexPath in tableView.indexPathsForVisibleRows ?? [] {
|
|
||||||
guard let item = item(forIndexPath: indexPath) else { continue }
|
|
||||||
guard let cell = tableView.cellForRow(at: indexPath) as? BaseCell else { continue }
|
|
||||||
guard type(of: cell) == item.cell.cellClass else {
|
|
||||||
requiresReload.append(indexPath)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
layoutEngine.resolveLayoutNow(item)
|
|
||||||
cell.registerViewModel(element: item)
|
|
||||||
}
|
|
||||||
tableView.beginUpdates()
|
|
||||||
tableView.reloadRows(at: requiresReload, with: .none)
|
|
||||||
tableView.endUpdates()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MessageListView {
|
|
||||||
func scrollToBottom(useTableViewAnimation: Bool = false) {
|
|
||||||
guard elements.count > 0 else { return }
|
|
||||||
guard tableView.contentSize.height > tableView.frame.height else { return }
|
|
||||||
let targetIndexPath = IndexPath(row: elements.count - 1, section: 0)
|
|
||||||
let cellRect = tableView.rectForRow(at: targetIndexPath)
|
|
||||||
if tableView.contentOffset.y + tableView.frame.height >= cellRect.origin.y + cellRect.height { return }
|
|
||||||
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.8) {
|
|
||||||
self.tableView.scrollToRow(
|
|
||||||
at: targetIndexPath,
|
|
||||||
at: .bottom,
|
|
||||||
animated: useTableViewAnimation
|
|
||||||
)
|
|
||||||
self.tableView.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(finishAutomaticScroll), object: nil)
|
|
||||||
isAutomaticScrollAnimating = true
|
|
||||||
perform(#selector(finishAutomaticScroll), with: nil, afterDelay: 0.5)
|
|
||||||
}
|
|
||||||
|
|
||||||
// func scrollLastCellToTop(useTableViewAnimation: Bool = false) {
|
|
||||||
// guard elements.count > 1 else { return }
|
|
||||||
// guard tableView.contentSize.height > tableView.frame.height else { return }
|
|
||||||
// UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.8) {
|
|
||||||
// self.tableView.scrollToRow(
|
|
||||||
// at: IndexPath(row: self.elements.count - 1, section: 0),
|
|
||||||
// at: .top,
|
|
||||||
// animated: useTableViewAnimation
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(finishAutomaticScroll), object: nil)
|
|
||||||
// isAutomaticScrollAnimating = true
|
|
||||||
// perform(#selector(finishAutomaticScroll), with: nil, afterDelay: 0.5)
|
|
||||||
// }
|
|
||||||
|
|
||||||
@objc private func finishAutomaticScroll() {
|
|
||||||
isAutomaticScrollAnimating = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-72
@@ -1,72 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageListView.swift
|
|
||||||
// FlowDown
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/2.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import OrderedCollections
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class MessageListView: UIView {
|
|
||||||
typealias ElementPublisher = AnyPublisher<[Element], Never>
|
|
||||||
typealias Elements = OrderedDictionary<Element.ID, Element>
|
|
||||||
var elements: Elements = .init()
|
|
||||||
|
|
||||||
var cancellables: Set<AnyCancellable> = []
|
|
||||||
|
|
||||||
let tableView: UITableView = .init(frame: .zero, style: .plain)
|
|
||||||
|
|
||||||
let layoutEngine = TableLayoutEngine()
|
|
||||||
var heightKeeper: [Element.ID: CGFloat] = [:]
|
|
||||||
let elementUpdateProcessLock = NSLock()
|
|
||||||
var distributedPendingUpdateElements: Elements? = nil
|
|
||||||
var isAutomaticScrollAnimating: Bool = false
|
|
||||||
var scrollToBottomOnNextUpdate = false
|
|
||||||
|
|
||||||
let footerView = UIView(frame: .init(x: 0, y: 0, width: 0, height: 200))
|
|
||||||
|
|
||||||
init(dataPublisher: AnyPublisher<[Element], Never>) {
|
|
||||||
super.init(frame: .zero)
|
|
||||||
|
|
||||||
tableView.delegate = self
|
|
||||||
tableView.dataSource = self
|
|
||||||
tableView.allowsSelection = false
|
|
||||||
tableView.allowsMultipleSelection = false
|
|
||||||
tableView.allowsFocus = false
|
|
||||||
tableView.selectionFollowsFocus = true
|
|
||||||
tableView.separatorColor = .clear
|
|
||||||
tableView.backgroundColor = .clear
|
|
||||||
for cellIdentifier in Element.Cell.allCases {
|
|
||||||
tableView.register(cellIdentifier.cellClass, forCellReuseIdentifier: cellIdentifier.rawValue)
|
|
||||||
}
|
|
||||||
addSubview(tableView)
|
|
||||||
|
|
||||||
tableView.tableFooterView = footerView
|
|
||||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
tableView.topAnchor.constraint(equalTo: topAnchor),
|
|
||||||
tableView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
||||||
tableView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
||||||
tableView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
||||||
])
|
|
||||||
|
|
||||||
setupPublishers(dataPublisher: dataPublisher)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
cancellables.forEach { $0.cancel() }
|
|
||||||
cancellables.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews() {
|
|
||||||
super.layoutSubviews()
|
|
||||||
viewCallingUpdateLayoutEngineWidth()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-48
@@ -1,48 +0,0 @@
|
|||||||
//
|
|
||||||
// EphemeralAction.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/8.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public extension IntelligentsEphemeralActionController {
|
|
||||||
enum EphemeralAction {
|
|
||||||
public enum Language: String, CaseIterable {
|
|
||||||
case langEnglish = "English"
|
|
||||||
case langSpanish = "Spanish"
|
|
||||||
case langGerman = "German"
|
|
||||||
case langFrench = "French"
|
|
||||||
case langItalian = "Italian"
|
|
||||||
case langSimplifiedChinese = "Simplified Chinese"
|
|
||||||
case langTraditionalChinese = "Traditional Chinese"
|
|
||||||
case langJapanese = "Japanese"
|
|
||||||
case langRussian = "Russian"
|
|
||||||
case langKorean = "Korean"
|
|
||||||
}
|
|
||||||
|
|
||||||
case translate(to: Language)
|
|
||||||
case summarize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension IntelligentsEphemeralActionController.EphemeralAction {
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case let .translate(to):
|
|
||||||
String(format: NSLocalizedString("Translate to %@", comment: ""), to.rawValue)
|
|
||||||
case .summarize:
|
|
||||||
NSLocalizedString("Summarize", comment: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var prompt: Prompt {
|
|
||||||
switch self {
|
|
||||||
case .translate:
|
|
||||||
.general_Translate_to
|
|
||||||
case .summarize:
|
|
||||||
.general_Summary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-60
@@ -1,60 +0,0 @@
|
|||||||
//
|
|
||||||
// ImageRotatedPreview.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/8.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public class RotatedImagePreview: UIView {
|
|
||||||
let imageView = UIImageView()
|
|
||||||
let rotationDegree: CGFloat = 5
|
|
||||||
|
|
||||||
public init() {
|
|
||||||
super.init(frame: .zero)
|
|
||||||
imageView.contentMode = .scaleAspectFill
|
|
||||||
imageView.layer.cornerRadius = 16
|
|
||||||
imageView.clipsToBounds = true
|
|
||||||
addSubview(imageView)
|
|
||||||
|
|
||||||
clipsToBounds = false
|
|
||||||
|
|
||||||
heightAnchor.constraint(equalToConstant: 300).isActive = true
|
|
||||||
imageView.transform = CGAffineTransform(rotationAngle: rotationDegree * CGFloat.pi / 180)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
public required init?(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func configure(previewImage: UIImage) {
|
|
||||||
imageView.image = previewImage
|
|
||||||
setNeedsLayout()
|
|
||||||
}
|
|
||||||
|
|
||||||
override public func layoutSubviews() {
|
|
||||||
super.layoutSubviews()
|
|
||||||
|
|
||||||
guard let image = imageView.image else {
|
|
||||||
imageView.frame = .zero
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let viewHeight = bounds.height // limiter
|
|
||||||
guard bounds.height > 0 else { return }
|
|
||||||
|
|
||||||
// fit in side
|
|
||||||
let imageAspectRatio = image.size.width / image.size.height
|
|
||||||
let imageHeight = viewHeight
|
|
||||||
let imageWidth = imageHeight * imageAspectRatio
|
|
||||||
|
|
||||||
imageView.frame = CGRect(
|
|
||||||
x: (bounds.width - imageWidth) / 2,
|
|
||||||
y: (bounds.height - imageHeight) / 2,
|
|
||||||
width: imageWidth,
|
|
||||||
height: imageHeight
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-143
@@ -1,143 +0,0 @@
|
|||||||
//
|
|
||||||
// IntelligentsEphemeralActionController+API.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/15.
|
|
||||||
//
|
|
||||||
|
|
||||||
import AffineGraphQL
|
|
||||||
import Foundation
|
|
||||||
import LDSwiftEventSource
|
|
||||||
|
|
||||||
extension IntelligentsEphemeralActionController {
|
|
||||||
func beginAction() {
|
|
||||||
print("[*] begin ephemeral action for did \(documentID) wid \(workspaceID)")
|
|
||||||
chatTask?.stop()
|
|
||||||
chatTask = nil
|
|
||||||
copilotDocumentStorage = ""
|
|
||||||
sessionID = ""
|
|
||||||
messageID = ""
|
|
||||||
chat_createSession(
|
|
||||||
documentIdentifier: documentID,
|
|
||||||
workspaceIdentifier: workspaceID
|
|
||||||
) { session in
|
|
||||||
self.sessionID = session
|
|
||||||
self.beginThisRound()
|
|
||||||
} onFailure: { error in
|
|
||||||
self.presentError(error) {
|
|
||||||
self.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func chat_createSession(
|
|
||||||
documentIdentifier: String,
|
|
||||||
workspaceIdentifier: String,
|
|
||||||
onSuccess: @escaping (String) -> Void,
|
|
||||||
onFailure: @escaping (Error) -> Void
|
|
||||||
) {
|
|
||||||
if documentIdentifier.isEmpty || workspaceIdentifier.isEmpty {
|
|
||||||
onFailure(UnableTo.identifyDocumentOrWorkspace)
|
|
||||||
}
|
|
||||||
Intelligents.qlClient.perform(
|
|
||||||
mutation: CreateCopilotSessionMutation(options: .init(
|
|
||||||
docId: documentIdentifier,
|
|
||||||
promptName: action.prompt.rawValue,
|
|
||||||
workspaceId: workspaceIdentifier
|
|
||||||
)),
|
|
||||||
queue: .global()
|
|
||||||
) { result in
|
|
||||||
switch result {
|
|
||||||
case let .success(value):
|
|
||||||
if let session = value.data?.createCopilotSession, !session.isEmpty {
|
|
||||||
DispatchQueue.main.async { onSuccess(session) }
|
|
||||||
} else {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
onFailure(UnableTo.createSession)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case let .failure(error):
|
|
||||||
DispatchQueue.main.async { onFailure(error) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func beginThisRound() {
|
|
||||||
let parms: [String: AnyHashable] = switch action {
|
|
||||||
case let .translate(lang):
|
|
||||||
["language": lang.rawValue]
|
|
||||||
case .summarize:
|
|
||||||
[:]
|
|
||||||
}
|
|
||||||
let json = try! CustomJSON(_jsonValue: parms)
|
|
||||||
Intelligents.qlClient.perform(
|
|
||||||
mutation: CreateCopilotMessageMutation(options: .init(
|
|
||||||
content: .init(stringLiteral: "\(documentContent)"),
|
|
||||||
params: .some(json),
|
|
||||||
sessionId: sessionID
|
|
||||||
)),
|
|
||||||
queue: .global()
|
|
||||||
) { result in
|
|
||||||
switch result {
|
|
||||||
case let .success(value):
|
|
||||||
if let messageID = value.data?.createCopilotMessage {
|
|
||||||
self.messageID = messageID
|
|
||||||
self.chat_processWithMessageID(sessionID: self.sessionID, messageID: messageID)
|
|
||||||
} else {
|
|
||||||
self.presentError(UnableTo.createMessage) {
|
|
||||||
self.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case let .failure(error):
|
|
||||||
self.presentError(error) {
|
|
||||||
self.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func chat_processWithMessageID(sessionID: String, messageID: String) {
|
|
||||||
let url = Constant.affineUpstreamURL
|
|
||||||
.appendingPathComponent("api")
|
|
||||||
.appendingPathComponent("copilot")
|
|
||||||
.appendingPathComponent("chat")
|
|
||||||
.appendingPathComponent(sessionID)
|
|
||||||
.appendingPathComponent("stream")
|
|
||||||
var comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
||||||
comps?.queryItems = [URLQueryItem(name: "messageId", value: messageID)]
|
|
||||||
|
|
||||||
guard let url = comps?.url else {
|
|
||||||
assertionFailure()
|
|
||||||
presentError(UnableTo.createMessage)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let eventHandler = BlockEventHandler()
|
|
||||||
eventHandler.onOpenedBlock = {
|
|
||||||
print("[*] chat opened")
|
|
||||||
}
|
|
||||||
eventHandler.onErrorBlock = { error in
|
|
||||||
self.presentError(error) { self.close() }
|
|
||||||
}
|
|
||||||
eventHandler.onMessageBlock = { _, message in
|
|
||||||
self.chat_onEvent(message.data)
|
|
||||||
}
|
|
||||||
eventHandler.onClosedBlock = {
|
|
||||||
self.chatTask?.stop()
|
|
||||||
self.chatTask = nil
|
|
||||||
}
|
|
||||||
let eventSource = EventSource(config: .init(handler: eventHandler, url: url))
|
|
||||||
eventSource.start()
|
|
||||||
chatTask = eventSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func chat_onEvent(_ data: String) {
|
|
||||||
if Thread.isMainThread {
|
|
||||||
copilotDocumentStorage += data
|
|
||||||
} else {
|
|
||||||
DispatchQueue.main.asyncAndWait {
|
|
||||||
self.copilotDocumentStorage += data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-80
@@ -1,80 +0,0 @@
|
|||||||
//
|
|
||||||
// IntelligentsEphemeralActionController+ActionBar.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/15.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension IntelligentsEphemeralActionController {
|
|
||||||
class ActionBar: UIView {
|
|
||||||
let retryButton = DarkActionButton()
|
|
||||||
let continueToChat = DarkActionButton()
|
|
||||||
let createNewDoc = DarkActionButton()
|
|
||||||
|
|
||||||
init() {
|
|
||||||
super.init(frame: .zero)
|
|
||||||
defer { removeEveryAutoResizingMasks() }
|
|
||||||
|
|
||||||
let contentSpacing: CGFloat = 16
|
|
||||||
let buttonGroupHeight: CGFloat = 55
|
|
||||||
|
|
||||||
let firstButtonSectionGroup = UIView()
|
|
||||||
addSubview(firstButtonSectionGroup)
|
|
||||||
[
|
|
||||||
firstButtonSectionGroup.topAnchor.constraint(equalTo: topAnchor, constant: contentSpacing),
|
|
||||||
firstButtonSectionGroup.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
||||||
firstButtonSectionGroup.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
||||||
firstButtonSectionGroup.heightAnchor.constraint(equalToConstant: buttonGroupHeight),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
retryButton.title = NSLocalizedString("Retry", comment: "")
|
|
||||||
retryButton.iconSystemName = "arrow.clockwise"
|
|
||||||
continueToChat.title = NSLocalizedString("Continue to Chat", comment: "")
|
|
||||||
continueToChat.iconSystemName = "paperplane"
|
|
||||||
firstButtonSectionGroup.addSubview(retryButton)
|
|
||||||
firstButtonSectionGroup.addSubview(continueToChat)
|
|
||||||
[
|
|
||||||
retryButton.topAnchor.constraint(equalTo: firstButtonSectionGroup.topAnchor),
|
|
||||||
retryButton.leadingAnchor.constraint(equalTo: firstButtonSectionGroup.leadingAnchor),
|
|
||||||
retryButton.bottomAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor),
|
|
||||||
|
|
||||||
continueToChat.topAnchor.constraint(equalTo: firstButtonSectionGroup.topAnchor),
|
|
||||||
continueToChat.trailingAnchor.constraint(equalTo: firstButtonSectionGroup.trailingAnchor),
|
|
||||||
continueToChat.bottomAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor),
|
|
||||||
|
|
||||||
retryButton.widthAnchor.constraint(equalTo: continueToChat.widthAnchor),
|
|
||||||
retryButton.trailingAnchor.constraint(equalTo: continueToChat.leadingAnchor, constant: -contentSpacing),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
let secondButtonSectionGroup = UIView()
|
|
||||||
addSubview(secondButtonSectionGroup)
|
|
||||||
[
|
|
||||||
secondButtonSectionGroup.topAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor, constant: contentSpacing),
|
|
||||||
secondButtonSectionGroup.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
||||||
secondButtonSectionGroup.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
||||||
secondButtonSectionGroup.heightAnchor.constraint(equalToConstant: buttonGroupHeight),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
secondButtonSectionGroup.addSubview(createNewDoc)
|
|
||||||
createNewDoc.title = NSLocalizedString("Create New Doc", comment: "")
|
|
||||||
createNewDoc.iconSystemName = "doc.badge.plus"
|
|
||||||
[
|
|
||||||
createNewDoc.topAnchor.constraint(equalTo: secondButtonSectionGroup.topAnchor),
|
|
||||||
createNewDoc.leadingAnchor.constraint(equalTo: secondButtonSectionGroup.leadingAnchor),
|
|
||||||
createNewDoc.bottomAnchor.constraint(equalTo: secondButtonSectionGroup.bottomAnchor),
|
|
||||||
createNewDoc.trailingAnchor.constraint(equalTo: secondButtonSectionGroup.trailingAnchor),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
[
|
|
||||||
secondButtonSectionGroup.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-115
@@ -1,115 +0,0 @@
|
|||||||
//
|
|
||||||
// IntelligentsEphemeralActionController+Header.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/8.
|
|
||||||
//
|
|
||||||
|
|
||||||
//
|
|
||||||
// IntelligentsChatController+Header.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/18.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension IntelligentsEphemeralActionController {
|
|
||||||
class Header: UIView {
|
|
||||||
static let height: CGFloat = 44
|
|
||||||
|
|
||||||
let contentView = UIView()
|
|
||||||
let titleLabel = UILabel()
|
|
||||||
let dropMenu = UIButton()
|
|
||||||
let backButton = UIButton()
|
|
||||||
let rightBarItemsStack = UIStackView()
|
|
||||||
let moreMenu = UIButton()
|
|
||||||
|
|
||||||
init() {
|
|
||||||
super.init(frame: .zero)
|
|
||||||
setupLayout()
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func navigateActionBack() {
|
|
||||||
parentViewController?.dismissInContext()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension IntelligentsEphemeralActionController.Header {
|
|
||||||
func setupLayout() {
|
|
||||||
contentView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
addSubview(contentView)
|
|
||||||
[
|
|
||||||
contentView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
||||||
contentView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
||||||
contentView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
||||||
contentView.heightAnchor.constraint(equalToConstant: Self.height),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
titleLabel.textColor = .label
|
|
||||||
titleLabel.font = .systemFont(
|
|
||||||
ofSize: UIFont.labelFontSize,
|
|
||||||
weight: .semibold
|
|
||||||
)
|
|
||||||
|
|
||||||
backButton.setImage(
|
|
||||||
UIImage(systemName: "chevron.left"),
|
|
||||||
for: .normal
|
|
||||||
)
|
|
||||||
backButton.tintColor = .accent
|
|
||||||
backButton.addTarget(self, action: #selector(navigateActionBack), for: .touchUpInside)
|
|
||||||
|
|
||||||
dropMenu.setImage(
|
|
||||||
.init(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate),
|
|
||||||
for: .normal
|
|
||||||
)
|
|
||||||
dropMenu.tintColor = .gray.withAlphaComponent(0.5)
|
|
||||||
|
|
||||||
contentView.addSubview(titleLabel)
|
|
||||||
contentView.addSubview(backButton)
|
|
||||||
contentView.addSubview(dropMenu)
|
|
||||||
contentView.addSubview(rightBarItemsStack)
|
|
||||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
backButton.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
dropMenu.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
rightBarItemsStack.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
rightBarItemsStack.axis = .horizontal
|
|
||||||
rightBarItemsStack.spacing = 10
|
|
||||||
rightBarItemsStack.alignment = .center
|
|
||||||
rightBarItemsStack.distribution = .equalSpacing
|
|
||||||
|
|
||||||
[
|
|
||||||
backButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
|
||||||
backButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10),
|
|
||||||
backButton.widthAnchor.constraint(equalToConstant: 44),
|
|
||||||
backButton.heightAnchor.constraint(equalToConstant: 44),
|
|
||||||
|
|
||||||
rightBarItemsStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
|
||||||
rightBarItemsStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
|
|
||||||
rightBarItemsStack.heightAnchor.constraint(equalToConstant: 44),
|
|
||||||
|
|
||||||
titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
|
|
||||||
titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
|
||||||
titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: backButton.trailingAnchor, constant: 10),
|
|
||||||
|
|
||||||
dropMenu.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
|
||||||
dropMenu.widthAnchor.constraint(equalToConstant: 44),
|
|
||||||
dropMenu.heightAnchor.constraint(equalToConstant: 44),
|
|
||||||
titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: dropMenu.leadingAnchor, constant: -10),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
rightBarItemsStack.addArrangedSubview(moreMenu)
|
|
||||||
moreMenu.setImage(
|
|
||||||
.init(systemName: "ellipsis.circle"),
|
|
||||||
for: .normal
|
|
||||||
)
|
|
||||||
moreMenu.tintColor = .accent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-297
@@ -1,297 +0,0 @@
|
|||||||
//
|
|
||||||
// IntelligentsEphemeralActionController.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/8.
|
|
||||||
//
|
|
||||||
|
|
||||||
import LDSwiftEventSource
|
|
||||||
import MarkdownParser
|
|
||||||
import MarkdownView
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public class IntelligentsEphemeralActionController: UIViewController {
|
|
||||||
let action: EphemeralAction
|
|
||||||
let scrollView = UIScrollView()
|
|
||||||
let stackView = UIStackView()
|
|
||||||
|
|
||||||
let header = Header()
|
|
||||||
let preview = RotatedImagePreview()
|
|
||||||
|
|
||||||
let markdownView = MarkdownView()
|
|
||||||
let indicator = UIActivityIndicatorView(style: .large)
|
|
||||||
var responseContainer: UIView = .init()
|
|
||||||
var responseHeightAnchor: NSLayoutConstraint?
|
|
||||||
|
|
||||||
let actionBar = ActionBar()
|
|
||||||
|
|
||||||
public var documentID: String = ""
|
|
||||||
public var workspaceID: String = ""
|
|
||||||
public var documentContent: String = ""
|
|
||||||
public internal(set) var sessionID: String = "" {
|
|
||||||
didSet { print(#fileID, #function, sessionID) }
|
|
||||||
}
|
|
||||||
|
|
||||||
public internal(set) var messageID: String = "" {
|
|
||||||
didSet { print(#fileID, #function, messageID) }
|
|
||||||
}
|
|
||||||
|
|
||||||
var chatTask: EventSource?
|
|
||||||
var copilotDocumentStorage: String = "" {
|
|
||||||
didSet {
|
|
||||||
updateDocumentPresentationView()
|
|
||||||
scrollToBottom()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(action: EphemeralAction) {
|
|
||||||
self.action = action
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
title = action.title
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override public func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
overrideUserInterfaceStyle = .dark
|
|
||||||
hideKeyboardWhenTappedAround()
|
|
||||||
view.backgroundColor = .systemBackground
|
|
||||||
|
|
||||||
header.titleLabel.text = title
|
|
||||||
header.dropMenu.isHidden = true
|
|
||||||
header.moreMenu.isHidden = true
|
|
||||||
view.addSubview(header)
|
|
||||||
header.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
[
|
|
||||||
header.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
header.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
header.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
header.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 44),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
view.addSubview(actionBar)
|
|
||||||
actionBar.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
[
|
|
||||||
actionBar.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8),
|
|
||||||
actionBar.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8),
|
|
||||||
actionBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
scrollView.clipsToBounds = true
|
|
||||||
scrollView.alwaysBounceVertical = true
|
|
||||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
view.addSubview(scrollView)
|
|
||||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
[
|
|
||||||
scrollView.topAnchor.constraint(equalTo: header.bottomAnchor),
|
|
||||||
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
scrollView.bottomAnchor.constraint(equalTo: actionBar.topAnchor),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
let contentView = UIView()
|
|
||||||
scrollView.addSubview(contentView)
|
|
||||||
contentView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
[
|
|
||||||
contentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
|
|
||||||
contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
|
|
||||||
contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
|
|
||||||
contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
|
|
||||||
contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
|
|
||||||
contentView.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.heightAnchor),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
contentView.addSubview(stackView)
|
|
||||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
stackView.axis = .vertical
|
|
||||||
stackView.spacing = 16
|
|
||||||
stackView.alignment = .fill
|
|
||||||
stackView.distribution = .fill
|
|
||||||
contentView.addSubview(stackView)
|
|
||||||
|
|
||||||
let stackViewInset: CGFloat = 8
|
|
||||||
[
|
|
||||||
stackView.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: stackViewInset),
|
|
||||||
stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: stackViewInset),
|
|
||||||
stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -stackViewInset),
|
|
||||||
stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -stackViewInset),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
setupContentViews()
|
|
||||||
|
|
||||||
actionBar.retryButton.action = { [weak self] in
|
|
||||||
self?.beginAction()
|
|
||||||
}
|
|
||||||
actionBar.continueToChat.action = { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
continueToChat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupContentViews() {
|
|
||||||
defer { stackView.addArrangedSubview(UIView()) }
|
|
||||||
|
|
||||||
preview.layer.cornerRadius = 16
|
|
||||||
preview.clipsToBounds = true
|
|
||||||
preview.contentMode = .scaleAspectFill
|
|
||||||
preview.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
stackView.addArrangedSubview(preview)
|
|
||||||
|
|
||||||
let headerGroup = UIView()
|
|
||||||
headerGroup.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
stackView.addArrangedSubview(headerGroup)
|
|
||||||
|
|
||||||
let headerLabel = UILabel()
|
|
||||||
let headerIcon = UIImageView()
|
|
||||||
|
|
||||||
headerLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
headerLabel.text = NSLocalizedString("AFFiNE AI", comment: "")
|
|
||||||
headerLabel.font = .preferredFont(for: .title3, weight: .bold)
|
|
||||||
headerLabel.textColor = .white
|
|
||||||
headerLabel.textAlignment = .left
|
|
||||||
headerIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
headerIcon.image = .init(named: "spark", in: .module, with: nil)
|
|
||||||
headerIcon.contentMode = .scaleAspectFit
|
|
||||||
headerIcon.tintColor = .accent
|
|
||||||
headerGroup.addSubview(headerLabel)
|
|
||||||
headerGroup.addSubview(headerIcon)
|
|
||||||
[
|
|
||||||
headerIcon.leadingAnchor.constraint(equalTo: headerGroup.leadingAnchor),
|
|
||||||
headerIcon.centerYAnchor.constraint(equalTo: headerGroup.centerYAnchor),
|
|
||||||
headerIcon.widthAnchor.constraint(equalToConstant: 32),
|
|
||||||
|
|
||||||
headerLabel.leadingAnchor.constraint(equalTo: headerIcon.trailingAnchor, constant: 16),
|
|
||||||
headerLabel.topAnchor.constraint(equalTo: headerGroup.topAnchor),
|
|
||||||
headerLabel.bottomAnchor.constraint(equalTo: headerGroup.bottomAnchor),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
responseContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
responseContainer.setContentHuggingPriority(.required, for: .vertical)
|
|
||||||
responseContainer.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
||||||
responseContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: 350).isActive = true
|
|
||||||
stackView.addArrangedSubview(responseContainer)
|
|
||||||
|
|
||||||
responseContainer.addSubview(markdownView)
|
|
||||||
|
|
||||||
markdownView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
[
|
|
||||||
markdownView.topAnchor.constraint(equalTo: responseContainer.topAnchor),
|
|
||||||
markdownView.leadingAnchor.constraint(equalTo: responseContainer.leadingAnchor),
|
|
||||||
markdownView.trailingAnchor.constraint(equalTo: responseContainer.trailingAnchor),
|
|
||||||
markdownView.bottomAnchor.constraint(equalTo: responseContainer.bottomAnchor),
|
|
||||||
].forEach {
|
|
||||||
$0.isActive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
indicator.startAnimating()
|
|
||||||
indicator.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
responseContainer.addSubview(indicator)
|
|
||||||
[
|
|
||||||
indicator.centerXAnchor.constraint(equalTo: responseContainer.centerXAnchor),
|
|
||||||
indicator.centerYAnchor.constraint(equalTo: responseContainer.centerYAnchor),
|
|
||||||
indicator.heightAnchor.constraint(equalToConstant: 200),
|
|
||||||
].forEach {
|
|
||||||
$0.isActive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
updateDocumentPresentationView()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func configure(previewImage: UIImage) {
|
|
||||||
preview.configure(previewImage: previewImage)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isFirstAppear: Bool = true
|
|
||||||
override public func viewDidAppear(_ animated: Bool) {
|
|
||||||
super.viewDidAppear(animated)
|
|
||||||
guard isFirstAppear else { return }
|
|
||||||
isFirstAppear = false
|
|
||||||
onFirstAppear()
|
|
||||||
}
|
|
||||||
|
|
||||||
func onFirstAppear() {
|
|
||||||
beginAction()
|
|
||||||
}
|
|
||||||
|
|
||||||
func close() {
|
|
||||||
if let navigationController {
|
|
||||||
navigationController.popViewController(animated: true)
|
|
||||||
} else {
|
|
||||||
dismiss(animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var previousLayoutWidth: CGFloat = 0
|
|
||||||
|
|
||||||
override public func viewWillLayoutSubviews() {
|
|
||||||
super.viewWillLayoutSubviews()
|
|
||||||
|
|
||||||
if previousLayoutWidth != view.bounds.width {
|
|
||||||
previousLayoutWidth = view.bounds.width
|
|
||||||
updateDocumentPresentationView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateDocumentPresentationView() {
|
|
||||||
assert(Thread.isMainThread)
|
|
||||||
|
|
||||||
responseHeightAnchor?.isActive = false
|
|
||||||
responseHeightAnchor = nil
|
|
||||||
|
|
||||||
if copilotDocumentStorage.isEmpty {
|
|
||||||
indicator.isHidden = false
|
|
||||||
indicator.startAnimating()
|
|
||||||
responseHeightAnchor = responseContainer.heightAnchor.constraint(equalToConstant: 200)
|
|
||||||
responseHeightAnchor?.isActive = true
|
|
||||||
markdownView.updateContentViews([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
indicator.isHidden = true
|
|
||||||
indicator.stopAnimating()
|
|
||||||
|
|
||||||
let document = MarkdownParser().feed(copilotDocumentStorage)
|
|
||||||
var height: CGFloat = 0
|
|
||||||
let manifests = document.map {
|
|
||||||
let ret = $0.manifest(theme: .default)
|
|
||||||
ret.setLayoutWidth(responseContainer.bounds.width)
|
|
||||||
ret.layoutIfNeeded()
|
|
||||||
height += ret.size.height
|
|
||||||
height += Theme.default.spacings.final
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
markdownView.updateContentViews(manifests)
|
|
||||||
if height > 0 { height -= Theme.default.spacings.final }
|
|
||||||
responseHeightAnchor = responseContainer.heightAnchor.constraint(equalToConstant: height)
|
|
||||||
responseHeightAnchor?.isActive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func scrollToBottom() {
|
|
||||||
guard !copilotDocumentStorage.isEmpty else { return }
|
|
||||||
let bottomOffset = CGPoint(
|
|
||||||
x: 0,
|
|
||||||
y: max(0, scrollView.contentSize.height - scrollView.bounds.size.height)
|
|
||||||
)
|
|
||||||
UIView.animate(
|
|
||||||
withDuration: 0.5,
|
|
||||||
delay: 0,
|
|
||||||
usingSpringWithDamping: 1.0,
|
|
||||||
initialSpringVelocity: 0.8
|
|
||||||
) { self.scrollView.setContentOffset(bottomOffset, animated: false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension IntelligentsEphemeralActionController {
|
|
||||||
func continueToChat() {
|
|
||||||
let chatController = IntelligentsChatController()
|
|
||||||
chatController.metadata[.documentID] = documentID
|
|
||||||
chatController.metadata[.workspaceID] = workspaceID
|
|
||||||
chatController.metadata[.content] = documentContent
|
|
||||||
navigationController?.pushViewController(chatController, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-30
@@ -1,30 +0,0 @@
|
|||||||
//
|
|
||||||
// IntelligentsFocusApertureView+Capture.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/21.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension IntelligentsFocusApertureView {
|
|
||||||
func captureImageBuffer(_ targetContentView: UIView) {
|
|
||||||
let contentSize = targetContentView.frame.size
|
|
||||||
|
|
||||||
let renderer = UIGraphicsImageRenderer(size: contentSize)
|
|
||||||
let image = renderer.image { _ in
|
|
||||||
let drawRect = CGRect(
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: contentSize.width,
|
|
||||||
height: contentSize.height
|
|
||||||
)
|
|
||||||
|
|
||||||
targetContentView.drawHierarchy(
|
|
||||||
in: drawRect,
|
|
||||||
afterScreenUpdates: true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
capturedImage = image
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
//
|
|
||||||
// IntelligentsFocusApertureView+Delegate.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/21.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public enum IntelligentsFocusApertureViewActionType: String {
|
|
||||||
case translateTo
|
|
||||||
case summary
|
|
||||||
case chatWithAI
|
|
||||||
case dismiss
|
|
||||||
}
|
|
||||||
|
|
||||||
public protocol IntelligentsFocusApertureViewDelegate: AnyObject {
|
|
||||||
func focusApertureRequestAction(
|
|
||||||
from: IntelligentsFocusApertureView,
|
|
||||||
actionType: IntelligentsFocusApertureViewActionType
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-89
@@ -1,89 +0,0 @@
|
|||||||
//
|
|
||||||
// IntelligentsFocusApertureView+Layout.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/21.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension IntelligentsFocusApertureView {
|
|
||||||
func prepareFrameLayout() {
|
|
||||||
guard let viewController = targetViewController,
|
|
||||||
let view = viewController.view
|
|
||||||
else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let safeLayout = viewController.view.safeAreaLayoutGuide
|
|
||||||
|
|
||||||
frameConstraints = [
|
|
||||||
// use safe area to layout content views
|
|
||||||
leadingAnchor.constraint(equalTo: safeLayout.leadingAnchor),
|
|
||||||
trailingAnchor.constraint(equalTo: safeLayout.trailingAnchor),
|
|
||||||
topAnchor.constraint(equalTo: safeLayout.topAnchor),
|
|
||||||
bottomAnchor.constraint(equalTo: safeLayout.bottomAnchor),
|
|
||||||
// cover all safe area so use constraints over view
|
|
||||||
backgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
backgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
backgroundView.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
backgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareContentLayouts() {
|
|
||||||
guard let targetView else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
contentBeginConstraints = [
|
|
||||||
snapshotImageView.leftAnchor.constraint(equalTo: targetView.leftAnchor),
|
|
||||||
snapshotImageView.rightAnchor.constraint(equalTo: targetView.rightAnchor),
|
|
||||||
snapshotImageView.topAnchor.constraint(equalTo: targetView.topAnchor),
|
|
||||||
snapshotImageView.bottomAnchor.constraint(equalTo: targetView.bottomAnchor),
|
|
||||||
|
|
||||||
controlButtonsPanel.leftAnchor.constraint(equalTo: leftAnchor),
|
|
||||||
controlButtonsPanel.rightAnchor.constraint(equalTo: rightAnchor),
|
|
||||||
controlButtonsPanel.topAnchor.constraint(equalTo: bottomAnchor),
|
|
||||||
]
|
|
||||||
|
|
||||||
let sharedInset: CGFloat = 32
|
|
||||||
contentFinalConstraints = [
|
|
||||||
snapshotImageView.leftAnchor.constraint(equalTo: leftAnchor, constant: sharedInset),
|
|
||||||
snapshotImageView.rightAnchor.constraint(equalTo: rightAnchor, constant: -sharedInset),
|
|
||||||
snapshotImageView.topAnchor.constraint(equalTo: topAnchor),
|
|
||||||
snapshotImageView.bottomAnchor.constraint(equalTo: controlButtonsPanel.topAnchor, constant: -sharedInset / 2),
|
|
||||||
|
|
||||||
controlButtonsPanel.leftAnchor.constraint(equalTo: leftAnchor, constant: sharedInset),
|
|
||||||
controlButtonsPanel.rightAnchor.constraint(equalTo: rightAnchor, constant: -sharedInset),
|
|
||||||
controlButtonsPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
enum LayoutType {
|
|
||||||
case begin
|
|
||||||
case complete
|
|
||||||
}
|
|
||||||
|
|
||||||
func activateLayoutForAnimation(_ type: LayoutType) {
|
|
||||||
NSLayoutConstraint.activate(frameConstraints)
|
|
||||||
switch type {
|
|
||||||
case .begin:
|
|
||||||
NSLayoutConstraint.deactivate(contentFinalConstraints)
|
|
||||||
NSLayoutConstraint.activate(contentBeginConstraints)
|
|
||||||
|
|
||||||
snapshotImageView.layer.cornerRadius = 0
|
|
||||||
case .complete:
|
|
||||||
NSLayoutConstraint.deactivate(contentBeginConstraints)
|
|
||||||
NSLayoutConstraint.activate(contentFinalConstraints)
|
|
||||||
|
|
||||||
snapshotImageView.layer.cornerRadius = 32
|
|
||||||
}
|
|
||||||
let effectiveView = superview ?? self
|
|
||||||
effectiveView.setNeedsUpdateConstraints()
|
|
||||||
effectiveView.setNeedsLayout()
|
|
||||||
updateConstraints()
|
|
||||||
layoutIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-115
@@ -1,115 +0,0 @@
|
|||||||
//
|
|
||||||
// IntelligentsFocusApertureView+Panel.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/21.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension IntelligentsFocusApertureView {
|
|
||||||
class ControlButtonsPanel: UIView {
|
|
||||||
let headerLabel = UILabel()
|
|
||||||
let headerIcon = UIImageView()
|
|
||||||
|
|
||||||
let translateButton = DarkActionButton()
|
|
||||||
let summaryButton = DarkActionButton()
|
|
||||||
let chatWithAIButton = DarkActionButton()
|
|
||||||
|
|
||||||
init() {
|
|
||||||
super.init(frame: .zero)
|
|
||||||
defer { removeEveryAutoResizingMasks() }
|
|
||||||
|
|
||||||
let contentSpacing: CGFloat = 16
|
|
||||||
let buttonGroupHeight: CGFloat = 55
|
|
||||||
|
|
||||||
let headerGroup = UIView()
|
|
||||||
addSubview(headerGroup)
|
|
||||||
[
|
|
||||||
headerGroup.topAnchor.constraint(equalTo: topAnchor),
|
|
||||||
headerGroup.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
||||||
headerGroup.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
headerLabel.text = NSLocalizedString("AFFiNE AI", comment: "") // TODO: FREE TRAIL???
|
|
||||||
// title 3 with bold
|
|
||||||
headerLabel.font = .preferredFont(for: .title3, weight: .bold)
|
|
||||||
headerLabel.textColor = .white
|
|
||||||
headerLabel.textAlignment = .left
|
|
||||||
headerIcon.image = .init(named: "spark", in: .module, with: nil)
|
|
||||||
headerIcon.contentMode = .scaleAspectFit
|
|
||||||
headerIcon.tintColor = .accent
|
|
||||||
headerGroup.addSubview(headerLabel)
|
|
||||||
headerGroup.addSubview(headerIcon)
|
|
||||||
[
|
|
||||||
headerLabel.topAnchor.constraint(equalTo: headerGroup.topAnchor),
|
|
||||||
headerLabel.leadingAnchor.constraint(equalTo: headerGroup.leadingAnchor),
|
|
||||||
headerLabel.bottomAnchor.constraint(equalTo: headerGroup.bottomAnchor),
|
|
||||||
|
|
||||||
headerIcon.topAnchor.constraint(equalTo: headerGroup.topAnchor),
|
|
||||||
headerIcon.trailingAnchor.constraint(equalTo: headerGroup.trailingAnchor),
|
|
||||||
headerIcon.bottomAnchor.constraint(equalTo: headerGroup.bottomAnchor),
|
|
||||||
|
|
||||||
headerIcon.widthAnchor.constraint(equalToConstant: 32),
|
|
||||||
headerIcon.trailingAnchor.constraint(equalTo: headerGroup.trailingAnchor),
|
|
||||||
headerIcon.leadingAnchor.constraint(equalTo: headerLabel.trailingAnchor, constant: contentSpacing),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
let firstButtonSectionGroup = UIView()
|
|
||||||
addSubview(firstButtonSectionGroup)
|
|
||||||
[
|
|
||||||
firstButtonSectionGroup.topAnchor.constraint(equalTo: headerGroup.bottomAnchor, constant: contentSpacing),
|
|
||||||
firstButtonSectionGroup.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
||||||
firstButtonSectionGroup.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
||||||
firstButtonSectionGroup.heightAnchor.constraint(equalToConstant: buttonGroupHeight),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
translateButton.title = NSLocalizedString("Translate", comment: "")
|
|
||||||
translateButton.iconSystemName = "textformat"
|
|
||||||
summaryButton.title = NSLocalizedString("Summary", comment: "")
|
|
||||||
summaryButton.iconSystemName = "doc.text"
|
|
||||||
firstButtonSectionGroup.addSubview(translateButton)
|
|
||||||
firstButtonSectionGroup.addSubview(summaryButton)
|
|
||||||
[
|
|
||||||
translateButton.topAnchor.constraint(equalTo: firstButtonSectionGroup.topAnchor),
|
|
||||||
translateButton.leadingAnchor.constraint(equalTo: firstButtonSectionGroup.leadingAnchor),
|
|
||||||
translateButton.bottomAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor),
|
|
||||||
|
|
||||||
summaryButton.topAnchor.constraint(equalTo: firstButtonSectionGroup.topAnchor),
|
|
||||||
summaryButton.trailingAnchor.constraint(equalTo: firstButtonSectionGroup.trailingAnchor),
|
|
||||||
summaryButton.bottomAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor),
|
|
||||||
|
|
||||||
translateButton.widthAnchor.constraint(equalTo: summaryButton.widthAnchor),
|
|
||||||
translateButton.trailingAnchor.constraint(equalTo: summaryButton.leadingAnchor, constant: -contentSpacing),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
let secondButtonSectionGroup = UIView()
|
|
||||||
addSubview(secondButtonSectionGroup)
|
|
||||||
[
|
|
||||||
secondButtonSectionGroup.topAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor, constant: contentSpacing),
|
|
||||||
secondButtonSectionGroup.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
||||||
secondButtonSectionGroup.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
||||||
secondButtonSectionGroup.heightAnchor.constraint(equalToConstant: buttonGroupHeight),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
secondButtonSectionGroup.addSubview(chatWithAIButton)
|
|
||||||
chatWithAIButton.title = NSLocalizedString("Chat with AI", comment: "")
|
|
||||||
chatWithAIButton.iconSystemName = "paperplane"
|
|
||||||
[
|
|
||||||
chatWithAIButton.topAnchor.constraint(equalTo: secondButtonSectionGroup.topAnchor),
|
|
||||||
chatWithAIButton.leadingAnchor.constraint(equalTo: secondButtonSectionGroup.leadingAnchor),
|
|
||||||
chatWithAIButton.bottomAnchor.constraint(equalTo: secondButtonSectionGroup.bottomAnchor),
|
|
||||||
chatWithAIButton.trailingAnchor.constraint(equalTo: secondButtonSectionGroup.trailingAnchor),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
[
|
|
||||||
secondButtonSectionGroup.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-133
@@ -1,133 +0,0 @@
|
|||||||
//
|
|
||||||
// IntelligentsFocusApertureView.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/11/21.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public class IntelligentsFocusApertureView: UIView {
|
|
||||||
public let backgroundView = UIView()
|
|
||||||
public let snapshotImageView = UIImageView()
|
|
||||||
let controlButtonsPanel = ControlButtonsPanel()
|
|
||||||
|
|
||||||
public var animationDuration: TimeInterval = 0.75
|
|
||||||
|
|
||||||
public internal(set) weak var targetView: UIView?
|
|
||||||
public internal(set) weak var targetViewController: UIViewController?
|
|
||||||
public internal(set) weak var capturedImage: UIImage? {
|
|
||||||
get { snapshotImageView.image }
|
|
||||||
set { snapshotImageView.image = newValue }
|
|
||||||
}
|
|
||||||
|
|
||||||
var frameConstraints: [NSLayoutConstraint] = []
|
|
||||||
var contentBeginConstraints: [NSLayoutConstraint] = []
|
|
||||||
var contentFinalConstraints: [NSLayoutConstraint] = []
|
|
||||||
|
|
||||||
public weak var delegate: (any IntelligentsFocusApertureViewDelegate)?
|
|
||||||
|
|
||||||
public init() {
|
|
||||||
super.init(frame: .zero)
|
|
||||||
|
|
||||||
backgroundView.backgroundColor = .black
|
|
||||||
backgroundView.isUserInteractionEnabled = true
|
|
||||||
let tap = UITapGestureRecognizer(
|
|
||||||
target: self,
|
|
||||||
action: #selector(dismissFocus)
|
|
||||||
)
|
|
||||||
tap.cancelsTouchesInView = true
|
|
||||||
backgroundView.addGestureRecognizer(tap)
|
|
||||||
|
|
||||||
snapshotImageView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
||||||
snapshotImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
||||||
snapshotImageView.layer.contentsGravity = .top
|
|
||||||
snapshotImageView.layer.masksToBounds = true
|
|
||||||
snapshotImageView.contentMode = .scaleAspectFill
|
|
||||||
snapshotImageView.isUserInteractionEnabled = true
|
|
||||||
snapshotImageView.addGestureRecognizer(UITapGestureRecognizer(
|
|
||||||
target: self,
|
|
||||||
action: #selector(dismissFocus)
|
|
||||||
))
|
|
||||||
|
|
||||||
addSubview(backgroundView)
|
|
||||||
addSubview(controlButtonsPanel)
|
|
||||||
addSubview(snapshotImageView)
|
|
||||||
bringSubviewToFront(snapshotImageView)
|
|
||||||
|
|
||||||
controlButtonsPanel.translateButton.action = { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
delegate?.focusApertureRequestAction(from: self, actionType: .translateTo)
|
|
||||||
}
|
|
||||||
controlButtonsPanel.summaryButton.action = { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
delegate?.focusApertureRequestAction(from: self, actionType: .summary)
|
|
||||||
}
|
|
||||||
controlButtonsPanel.chatWithAIButton.action = { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
delegate?.focusApertureRequestAction(from: self, actionType: .chatWithAI)
|
|
||||||
}
|
|
||||||
removeEveryAutoResizingMasks()
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func prepareAnimationWith(
|
|
||||||
capturingTargetContentView targetContentView: UIView,
|
|
||||||
coveringRootViewController viewController: UIViewController
|
|
||||||
) {
|
|
||||||
captureImageBuffer(targetContentView)
|
|
||||||
|
|
||||||
targetView = targetContentView
|
|
||||||
targetViewController = viewController
|
|
||||||
|
|
||||||
viewController.view.addSubview(self)
|
|
||||||
|
|
||||||
prepareFrameLayout()
|
|
||||||
prepareContentLayouts()
|
|
||||||
activateLayoutForAnimation(.begin)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func executeAnimationKickIn(_ completion: @escaping () -> Void = {}) {
|
|
||||||
activateLayoutForAnimation(.begin)
|
|
||||||
isUserInteractionEnabled = false
|
|
||||||
UIView.animate(
|
|
||||||
withDuration: animationDuration,
|
|
||||||
delay: 0,
|
|
||||||
usingSpringWithDamping: 1.0,
|
|
||||||
initialSpringVelocity: 0.8
|
|
||||||
) {
|
|
||||||
self.activateLayoutForAnimation(.complete)
|
|
||||||
} completion: { _ in
|
|
||||||
self.isUserInteractionEnabled = true
|
|
||||||
completion()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func executeAnimationDismiss(_ completion: @escaping () -> Void = {}) {
|
|
||||||
activateLayoutForAnimation(.complete)
|
|
||||||
isUserInteractionEnabled = false
|
|
||||||
UIView.animate(
|
|
||||||
withDuration: animationDuration,
|
|
||||||
delay: 0,
|
|
||||||
usingSpringWithDamping: 1.0,
|
|
||||||
initialSpringVelocity: 0.8
|
|
||||||
) {
|
|
||||||
self.activateLayoutForAnimation(.begin)
|
|
||||||
} completion: { _ in
|
|
||||||
self.isUserInteractionEnabled = true
|
|
||||||
completion()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func dismissFocus() {
|
|
||||||
isUserInteractionEnabled = false
|
|
||||||
executeAnimationDismiss {
|
|
||||||
self.removeFromSuperview()
|
|
||||||
self.delegate?.focusApertureRequestAction(from: self, actionType: .dismiss)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings
|
|
||||||
Intelligents
|
|
||||||
|
|
||||||
Created by 秋星桥 on 2024/11/18.
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
"Chat with AI" = "Chat with AI";
|
|
||||||
"AFFiNE AI" = "AFFiNE AI";
|
|
||||||
"Translate" = "Translate";
|
|
||||||
"Summary" = "Summary";
|
|
||||||
"Summarize this article for me..." = "Summarize this article for me...";
|
|
||||||
"System" = "System";
|
|
||||||
"AFFiNE AI" = "AFFiNE AI";
|
|
||||||
"You" = "You";
|
|
||||||
"Error" = "Error";
|
|
||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings
|
|
||||||
Intelligents
|
|
||||||
|
|
||||||
Created by 秋星桥 on 2024/11/18.
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
"Chat with AI" = "与 AI 聊天";
|
|
||||||
"AFFiNE AI" = "AFFiNE 人工智能";
|
|
||||||
"Translate" = "翻译";
|
|
||||||
"Summary" = "总结";
|
|
||||||
"Summarize this article for me..." = "请为我总结这份文档...";
|
|
||||||
"System" = "系统";
|
|
||||||
"AFFiNE AI" = "AFFiNE AI";
|
|
||||||
"You" = "你";
|
|
||||||
"Error" = "错误";
|
|
||||||
"OK" = "确定";
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
.DS_Store
|
|
||||||
/.build
|
|
||||||
/Packages
|
|
||||||
xcuserdata/
|
|
||||||
DerivedData/
|
|
||||||
.swiftpm/configuration/registries.json
|
|
||||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
|
||||||
.netrc
|
|
||||||
-67
@@ -1,67 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "1620"
|
|
||||||
version = "1.7">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES"
|
|
||||||
buildArchitectures = "Automatic">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "FlowMarkdownView"
|
|
||||||
BuildableName = "FlowMarkdownView"
|
|
||||||
BlueprintName = "FlowMarkdownView"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
shouldAutocreateTestPlan = "YES">
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<MacroExpansion>
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "FlowMarkdownView"
|
|
||||||
BuildableName = "FlowMarkdownView"
|
|
||||||
BlueprintName = "FlowMarkdownView"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</MacroExpansion>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
-411
@@ -1,411 +0,0 @@
|
|||||||
// !$*UTF8*$!
|
|
||||||
{
|
|
||||||
archiveVersion = 1;
|
|
||||||
classes = {
|
|
||||||
};
|
|
||||||
objectVersion = 77;
|
|
||||||
objects = {
|
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
|
||||||
50219C662D3E2304006CB93C /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50219C652D3E2304006CB93C /* App.swift */; };
|
|
||||||
507C16722D2719F100B478D2 /* MarkdownView in Frameworks */ = {isa = PBXBuildFile; productRef = 507C16712D2719F100B478D2 /* MarkdownView */; };
|
|
||||||
5084C6742D281A41007310F0 /* LookinServer in Frameworks */ = {isa = PBXBuildFile; productRef = 5084C6732D281A41007310F0 /* LookinServer */; };
|
|
||||||
/* End PBXBuildFile section */
|
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
|
||||||
50219C652D3E2304006CB93C /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; };
|
|
||||||
505E99EA2D26D8380014A6D3 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
/* End PBXFileReference section */
|
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
|
||||||
505E99E72D26D8380014A6D3 /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
507C16722D2719F100B478D2 /* MarkdownView in Frameworks */,
|
|
||||||
5084C6742D281A41007310F0 /* LookinServer in Frameworks */,
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXFrameworksBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
|
||||||
5015F6D32D26DCFB005FA7D2 /* Frameworks */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
);
|
|
||||||
name = Frameworks;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
50219C642D3E22FB006CB93C /* Example */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
50219C652D3E2304006CB93C /* App.swift */,
|
|
||||||
);
|
|
||||||
path = Example;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
505E99E12D26D8380014A6D3 = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
50219C642D3E22FB006CB93C /* Example */,
|
|
||||||
5015F6D32D26DCFB005FA7D2 /* Frameworks */,
|
|
||||||
505E99EB2D26D8380014A6D3 /* Products */,
|
|
||||||
);
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
505E99EB2D26D8380014A6D3 /* Products */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
505E99EA2D26D8380014A6D3 /* Example.app */,
|
|
||||||
);
|
|
||||||
name = Products;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXGroup section */
|
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
|
||||||
505E99E92D26D8380014A6D3 /* Example */ = {
|
|
||||||
isa = PBXNativeTarget;
|
|
||||||
buildConfigurationList = 505E99F82D26D8390014A6D3 /* Build configuration list for PBXNativeTarget "Example" */;
|
|
||||||
buildPhases = (
|
|
||||||
5015F6CD2D26DB1B005FA7D2 /* Format Source */,
|
|
||||||
505E99E62D26D8380014A6D3 /* Sources */,
|
|
||||||
505E99E72D26D8380014A6D3 /* Frameworks */,
|
|
||||||
505E99E82D26D8380014A6D3 /* Resources */,
|
|
||||||
);
|
|
||||||
buildRules = (
|
|
||||||
);
|
|
||||||
dependencies = (
|
|
||||||
);
|
|
||||||
name = Example;
|
|
||||||
packageProductDependencies = (
|
|
||||||
507C16712D2719F100B478D2 /* MarkdownView */,
|
|
||||||
5084C6732D281A41007310F0 /* LookinServer */,
|
|
||||||
);
|
|
||||||
productName = Example;
|
|
||||||
productReference = 505E99EA2D26D8380014A6D3 /* Example.app */;
|
|
||||||
productType = "com.apple.product-type.application";
|
|
||||||
};
|
|
||||||
/* End PBXNativeTarget section */
|
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
|
||||||
505E99E22D26D8380014A6D3 /* Project object */ = {
|
|
||||||
isa = PBXProject;
|
|
||||||
attributes = {
|
|
||||||
BuildIndependentTargetsInParallel = 1;
|
|
||||||
LastSwiftUpdateCheck = 1620;
|
|
||||||
LastUpgradeCheck = 1620;
|
|
||||||
TargetAttributes = {
|
|
||||||
505E99E92D26D8380014A6D3 = {
|
|
||||||
CreatedOnToolsVersion = 16.2;
|
|
||||||
LastSwiftMigration = 1620;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
buildConfigurationList = 505E99E52D26D8380014A6D3 /* Build configuration list for PBXProject "Example" */;
|
|
||||||
developmentRegion = en;
|
|
||||||
hasScannedForEncodings = 0;
|
|
||||||
knownRegions = (
|
|
||||||
en,
|
|
||||||
Base,
|
|
||||||
);
|
|
||||||
mainGroup = 505E99E12D26D8380014A6D3;
|
|
||||||
minimizedProjectReferenceProxies = 1;
|
|
||||||
packageReferences = (
|
|
||||||
5084C6722D281A38007310F0 /* XCRemoteSwiftPackageReference "LookinServer" */,
|
|
||||||
);
|
|
||||||
preferredProjectObjectVersion = 77;
|
|
||||||
productRefGroup = 505E99EB2D26D8380014A6D3 /* Products */;
|
|
||||||
projectDirPath = "";
|
|
||||||
projectRoot = "";
|
|
||||||
targets = (
|
|
||||||
505E99E92D26D8380014A6D3 /* Example */,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
/* End PBXProject section */
|
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
|
||||||
505E99E82D26D8380014A6D3 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXResourcesBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
|
||||||
5015F6CD2D26DB1B005FA7D2 /* Format Source */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
alwaysOutOfDate = 1;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputFileListPaths = (
|
|
||||||
);
|
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "Format Source";
|
|
||||||
outputFileListPaths = (
|
|
||||||
);
|
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "/opt/homebrew/bin/swiftformat . --swiftversion 6.0\n\n";
|
|
||||||
};
|
|
||||||
/* End PBXShellScriptBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
|
||||||
505E99E62D26D8380014A6D3 /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
50219C662D3E2304006CB93C /* App.swift in Sources */,
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXSourcesBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
|
||||||
505E99F62D26D8390014A6D3 /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CLANG_ENABLE_OBJC_ARC = YES;
|
|
||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
|
||||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
|
||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_COMMA = YES;
|
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
|
||||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
|
||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
|
||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
|
||||||
COPY_PHASE_STRIP = NO;
|
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
|
||||||
ENABLE_TESTABILITY = YES;
|
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
|
||||||
GCC_DYNAMIC_NO_PIC = NO;
|
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
|
||||||
GCC_OPTIMIZATION_LEVEL = 0;
|
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
|
||||||
"DEBUG=1",
|
|
||||||
"$(inherited)",
|
|
||||||
);
|
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
|
||||||
MTL_FAST_MATH = YES;
|
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
|
||||||
SDKROOT = iphoneos;
|
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
505E99F72D26D8390014A6D3 /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CLANG_ENABLE_OBJC_ARC = YES;
|
|
||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
|
||||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
|
||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_COMMA = YES;
|
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
|
||||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
|
||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
|
||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
|
||||||
COPY_PHASE_STRIP = NO;
|
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
|
||||||
MTL_FAST_MATH = YES;
|
|
||||||
SDKROOT = iphoneos;
|
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
|
||||||
VALIDATE_PRODUCT = YES;
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
505E99F92D26D8390014A6D3 /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = 964G86XT2P;
|
|
||||||
ENABLE_PREVIEWS = YES;
|
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = wiki.qaq.Example;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
|
||||||
SUPPORTS_MACCATALYST = YES;
|
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
505E99FA2D26D8390014A6D3 /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = 964G86XT2P;
|
|
||||||
ENABLE_PREVIEWS = YES;
|
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = wiki.qaq.Example;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
|
||||||
SUPPORTS_MACCATALYST = YES;
|
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
/* End XCBuildConfiguration section */
|
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
|
||||||
505E99E52D26D8380014A6D3 /* Build configuration list for PBXProject "Example" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
505E99F62D26D8390014A6D3 /* Debug */,
|
|
||||||
505E99F72D26D8390014A6D3 /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
505E99F82D26D8390014A6D3 /* Build configuration list for PBXNativeTarget "Example" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
505E99F92D26D8390014A6D3 /* Debug */,
|
|
||||||
505E99FA2D26D8390014A6D3 /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
/* End XCConfigurationList section */
|
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
|
||||||
5084C6722D281A38007310F0 /* XCRemoteSwiftPackageReference "LookinServer" */ = {
|
|
||||||
isa = XCRemoteSwiftPackageReference;
|
|
||||||
repositoryURL = "https://github.com/QMUI/LookinServer/";
|
|
||||||
requirement = {
|
|
||||||
kind = upToNextMajorVersion;
|
|
||||||
minimumVersion = 1.2.8;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
|
||||||
507C16712D2719F100B478D2 /* MarkdownView */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
productName = MarkdownView;
|
|
||||||
};
|
|
||||||
5084C6732D281A41007310F0 /* LookinServer */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = 5084C6722D281A38007310F0 /* XCRemoteSwiftPackageReference "LookinServer" */;
|
|
||||||
productName = LookinServer;
|
|
||||||
};
|
|
||||||
/* End XCSwiftPackageProductDependency section */
|
|
||||||
};
|
|
||||||
rootObject = 505E99E22D26D8380014A6D3 /* Project object */;
|
|
||||||
}
|
|
||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Workspace
|
|
||||||
version = "1.0">
|
|
||||||
<FileRef
|
|
||||||
location = "self:">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Workspace
|
|
||||||
version = "1.0">
|
|
||||||
<FileRef
|
|
||||||
location = "container:Example.xcodeproj">
|
|
||||||
</FileRef>
|
|
||||||
<FileRef
|
|
||||||
location = "group:../">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
//
|
|
||||||
// App.swift
|
|
||||||
// Example
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 1/20/25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@main
|
|
||||||
struct TheApp: App {
|
|
||||||
var body: some Scene {
|
|
||||||
WindowGroup {
|
|
||||||
NavigationView {
|
|
||||||
Content()
|
|
||||||
.navigationTitle("MarkdownView")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
}
|
|
||||||
.navigationViewStyle(.stack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
import MarkdownParser
|
|
||||||
import MarkdownView
|
|
||||||
|
|
||||||
class ContentController: UIViewController {
|
|
||||||
let document = MarkdownParser().feed(testDocument)
|
|
||||||
let scrollView = UIScrollView()
|
|
||||||
let markdownView = MarkdownView(theme: .default)
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
view.addSubview(scrollView)
|
|
||||||
scrollView.addSubview(markdownView)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillLayoutSubviews() {
|
|
||||||
super.viewWillLayoutSubviews()
|
|
||||||
scrollView.frame = view.bounds
|
|
||||||
let width = view.bounds.width - 32
|
|
||||||
let manifest = document.map {
|
|
||||||
let manifest = $0.manifest(theme: markdownView.theme)
|
|
||||||
manifest.setLayoutWidth(width)
|
|
||||||
manifest.layoutIfNeeded()
|
|
||||||
return manifest
|
|
||||||
}
|
|
||||||
markdownView.updateContentViews(manifest)
|
|
||||||
markdownView.frame = .init(
|
|
||||||
x: 16,
|
|
||||||
y: 16,
|
|
||||||
width: width,
|
|
||||||
height: markdownView.height
|
|
||||||
)
|
|
||||||
scrollView.contentSize = .init(
|
|
||||||
width: width,
|
|
||||||
height: markdownView.height + 100
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Content: UIViewControllerRepresentable {
|
|
||||||
func makeUIViewController(context _: Context) -> ContentController {
|
|
||||||
ContentController()
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_: ContentController, context _: Context) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
let testDocument = ###"""
|
|
||||||
# Markdown 测试文稿
|
|
||||||
|
|
||||||
这是一篇用于测试渲染引擎性能的 Markdown 文档,包含多种格式和元素。以下是不同 Markdown 语法的示例:
|
|
||||||
|
|
||||||
## 标题
|
|
||||||
|
|
||||||
### 三级标题
|
|
||||||
|
|
||||||
#### 四级标题
|
|
||||||
|
|
||||||
##### 五级标题
|
|
||||||
|
|
||||||
###### 六级标题
|
|
||||||
|
|
||||||
## 段落与文本格式
|
|
||||||
|
|
||||||
这是一个普通段落。**这是加粗的文字**,*这是斜体的文字*,***这是加粗且斜体的文字***。~~这是删除线~~。
|
|
||||||
|
|
||||||
这是`行内代码`的示例。
|
|
||||||
|
|
||||||
## 列表
|
|
||||||
|
|
||||||
### 无序列表
|
|
||||||
|
|
||||||
- 项目 1
|
|
||||||
- 项目 2
|
|
||||||
- 子项目 2.1
|
|
||||||
- 子项目 2.2
|
|
||||||
- 项目 3
|
|
||||||
|
|
||||||
### 有序列表
|
|
||||||
|
|
||||||
1. 第一项
|
|
||||||
2. 第二项
|
|
||||||
1. 子项 2.1
|
|
||||||
2. 子项 2.2
|
|
||||||
3. 第三项
|
|
||||||
|
|
||||||
## 引用
|
|
||||||
|
|
||||||
> 这是一个引用块。引用块可以包含多行文字,甚至可以包含其他 Markdown 元素,比如**加粗**或`代码`。
|
|
||||||
|
|
||||||
## 代码块
|
|
||||||
|
|
||||||
```python
|
|
||||||
def hello_world():
|
|
||||||
print("Hello, World!")
|
|
||||||
```
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
function helloWorld() {
|
|
||||||
console.log("Hello, World!");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 表格
|
|
||||||
|
|
||||||
| 序号 | 名称 | 描述 |
|
|
||||||
| ---- | ---------- | ------------- |
|
|
||||||
| 1 | 项目 A | 这是项目 A |
|
|
||||||
| 2 | 项目 B | 这是项目 B |
|
|
||||||
| 3 | 项目 C | 这是项目 C |
|
|
||||||
|
|
||||||
## 链接与图片
|
|
||||||
|
|
||||||
这是一个[短链接](https://example.com)的示例。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## HTML 嵌入
|
|
||||||
|
|
||||||
<p style="color: red;">这是一个红色的段落,使用 HTML 标签实现。</p>
|
|
||||||
|
|
||||||
<a href="https://example.com">这是一个短链接</a>
|
|
||||||
|
|
||||||
## 分隔线
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 脚注
|
|
||||||
|
|
||||||
这是一个脚注的示例[^1]。
|
|
||||||
|
|
||||||
[^1]: 这是脚注的内容。
|
|
||||||
|
|
||||||
## 内嵌 HTML
|
|
||||||
|
|
||||||
<div style="border: 1px solid black; padding: 10px;">
|
|
||||||
这是一个带有边框的 HTML 块。
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## 数学公式(如果支持)
|
|
||||||
|
|
||||||
这是一个行内公式:$E = mc^2$。
|
|
||||||
|
|
||||||
这是一个块级公式:
|
|
||||||
$$
|
|
||||||
\int_{a}^{b} x^2 dx
|
|
||||||
$$
|
|
||||||
|
|
||||||
## 任务列表
|
|
||||||
|
|
||||||
- [x] 完成任务 1
|
|
||||||
- [ ] 完成任务 2
|
|
||||||
- [ ] 完成任务 3
|
|
||||||
|
|
||||||
## 结束语
|
|
||||||
|
|
||||||
这篇文档包含了多种 Markdown 格式和 HTML 元素,适合用于测试渲染引擎的性能和兼容性。希望它能帮助你完成测试!
|
|
||||||
"""###
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
// swift-tools-version: 5.9
|
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
|
||||||
|
|
||||||
import PackageDescription
|
|
||||||
|
|
||||||
let package = Package(
|
|
||||||
name: "MarkdownView",
|
|
||||||
platforms: [
|
|
||||||
.iOS(.v14),
|
|
||||||
.macCatalyst(.v14),
|
|
||||||
],
|
|
||||||
products: [
|
|
||||||
.library(name: "MarkdownView", targets: ["MarkdownView"]),
|
|
||||||
],
|
|
||||||
dependencies: [
|
|
||||||
.package(url: "https://github.com/JohnSundell/Splash", from: "0.16.0"),
|
|
||||||
.package(url: "https://github.com/swiftlang/swift-cmark", from: "0.6.0"),
|
|
||||||
],
|
|
||||||
targets: [
|
|
||||||
.target(name: "MarkdownView", dependencies: [
|
|
||||||
"MarkdownParser",
|
|
||||||
"Splash",
|
|
||||||
]),
|
|
||||||
.target(name: "MarkdownParser", dependencies: [
|
|
||||||
.product(name: "cmark-gfm", package: "swift-cmark"),
|
|
||||||
.product(name: "cmark-gfm-extensions", package: "swift-cmark"),
|
|
||||||
]),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
-104
@@ -1,104 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
extension Sequence<BlockNode> {
|
|
||||||
func rewrite(_ r: (BlockNode) throws -> [BlockNode]) rethrows -> [BlockNode] {
|
|
||||||
try flatMap { try $0.rewrite(r) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func rewrite(_ r: (InlineNode) throws -> [InlineNode]) rethrows -> [BlockNode] {
|
|
||||||
try flatMap { try $0.rewrite(r) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension BlockNode {
|
|
||||||
func rewrite(_ r: (BlockNode) throws -> [BlockNode]) rethrows -> [BlockNode] {
|
|
||||||
switch self {
|
|
||||||
case let .blockquote(children):
|
|
||||||
try r(.blockquote(children: children.rewrite(r)))
|
|
||||||
case let .bulletedList(isTight, items):
|
|
||||||
try r(
|
|
||||||
.bulletedList(
|
|
||||||
isTight: isTight,
|
|
||||||
items: items.map {
|
|
||||||
try RawListItem(children: $0.children.rewrite(r))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
case let .numberedList(isTight, start, items):
|
|
||||||
try r(
|
|
||||||
.numberedList(
|
|
||||||
isTight: isTight,
|
|
||||||
start: start,
|
|
||||||
items: items.map {
|
|
||||||
try RawListItem(children: $0.children.rewrite(r))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
case let .taskList(isTight, items):
|
|
||||||
try r(
|
|
||||||
.taskList(
|
|
||||||
isTight: isTight,
|
|
||||||
items: items.map {
|
|
||||||
try RawTaskListItem(isCompleted: $0.isCompleted, children: $0.children.rewrite(r))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
try r(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func rewrite(_ r: (InlineNode) throws -> [InlineNode]) rethrows -> [BlockNode] {
|
|
||||||
switch self {
|
|
||||||
case let .blockquote(children):
|
|
||||||
try [.blockquote(children: children.rewrite(r))]
|
|
||||||
case let .bulletedList(isTight, items):
|
|
||||||
try [
|
|
||||||
.bulletedList(
|
|
||||||
isTight: isTight,
|
|
||||||
items: items.map {
|
|
||||||
try RawListItem(children: $0.children.rewrite(r))
|
|
||||||
}
|
|
||||||
),
|
|
||||||
]
|
|
||||||
case let .numberedList(isTight, start, items):
|
|
||||||
try [
|
|
||||||
.numberedList(
|
|
||||||
isTight: isTight,
|
|
||||||
start: start,
|
|
||||||
items: items.map {
|
|
||||||
try RawListItem(children: $0.children.rewrite(r))
|
|
||||||
}
|
|
||||||
),
|
|
||||||
]
|
|
||||||
case let .taskList(isTight, items):
|
|
||||||
try [
|
|
||||||
.taskList(
|
|
||||||
isTight: isTight,
|
|
||||||
items: items.map {
|
|
||||||
try RawTaskListItem(isCompleted: $0.isCompleted, children: $0.children.rewrite(r))
|
|
||||||
}
|
|
||||||
),
|
|
||||||
]
|
|
||||||
case let .paragraph(content):
|
|
||||||
try [.paragraph(content: content.rewrite(r))]
|
|
||||||
case let .heading(level, content):
|
|
||||||
try [.heading(level: level, content: content.rewrite(r))]
|
|
||||||
case let .table(columnAlignments, rows):
|
|
||||||
try [
|
|
||||||
.table(
|
|
||||||
columnAlignments: columnAlignments,
|
|
||||||
rows: rows.map {
|
|
||||||
try RawTableRow(
|
|
||||||
cells: $0.cells.map {
|
|
||||||
try RawTableCell(content: $0.content.rewrite(r))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
]
|
|
||||||
default:
|
|
||||||
[self]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-85
@@ -1,85 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public enum BlockNode: Hashable, Equatable, Codable {
|
|
||||||
case blockquote(children: [BlockNode])
|
|
||||||
case bulletedList(isTight: Bool, items: [RawListItem])
|
|
||||||
case numberedList(isTight: Bool, start: Int, items: [RawListItem])
|
|
||||||
case taskList(isTight: Bool, items: [RawTaskListItem])
|
|
||||||
case codeBlock(fenceInfo: String?, content: String)
|
|
||||||
// case htmlBlock(content: String)
|
|
||||||
case paragraph(content: [InlineNode])
|
|
||||||
case heading(level: Int, content: [InlineNode])
|
|
||||||
case table(columnAlignments: [RawTableColumnAlignment], rows: [RawTableRow])
|
|
||||||
case thematicBreak
|
|
||||||
//
|
|
||||||
// public var typeIdentifier: String {
|
|
||||||
// switch self {
|
|
||||||
// case .blockquote:
|
|
||||||
// "blockquote"
|
|
||||||
// case .bulletedList:
|
|
||||||
// "bulletedList"
|
|
||||||
// case .numberedList:
|
|
||||||
// "numberedList"
|
|
||||||
// case .taskList:
|
|
||||||
// "taskList"
|
|
||||||
// case .codeBlock:
|
|
||||||
// "codeBlock"
|
|
||||||
// case .htmlBlock:
|
|
||||||
// "htmlBlock"
|
|
||||||
// case .paragraph:
|
|
||||||
// "paragraph"
|
|
||||||
// case .heading:
|
|
||||||
// "heading"
|
|
||||||
// case .table:
|
|
||||||
// "table"
|
|
||||||
// case .thematicBreak:
|
|
||||||
// "thematicBreak"
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension BlockNode {
|
|
||||||
var children: [BlockNode] {
|
|
||||||
switch self {
|
|
||||||
case let .blockquote(children):
|
|
||||||
children
|
|
||||||
case let .bulletedList(_, items):
|
|
||||||
items.map(\.children).flatMap(\.self)
|
|
||||||
case let .numberedList(_, _, items):
|
|
||||||
items.map(\.children).flatMap(\.self)
|
|
||||||
case let .taskList(_, items):
|
|
||||||
items.map(\.children).flatMap(\.self)
|
|
||||||
default:
|
|
||||||
[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isParagraph: Bool {
|
|
||||||
guard case .paragraph = self else { return false }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RawListItem: Hashable, Equatable, Codable {
|
|
||||||
public let children: [BlockNode]
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RawTaskListItem: Hashable, Equatable, Codable {
|
|
||||||
public let isCompleted: Bool
|
|
||||||
public let children: [BlockNode]
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum RawTableColumnAlignment: Character, Equatable, Codable {
|
|
||||||
case none = "\0"
|
|
||||||
case left = "l"
|
|
||||||
case center = "c"
|
|
||||||
case right = "r"
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RawTableRow: Hashable, Equatable, Codable {
|
|
||||||
public let cells: [RawTableCell]
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RawTableCell: Hashable, Equatable, Codable {
|
|
||||||
public let content: [InlineNode]
|
|
||||||
}
|
|
||||||
-13
@@ -1,13 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
extension Sequence<InlineNode> {
|
|
||||||
func collect<Result>(_ c: (InlineNode) throws -> [Result]) rethrows -> [Result] {
|
|
||||||
try flatMap { try $0.collect(c) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension InlineNode {
|
|
||||||
func collect<Result>(_ c: (InlineNode) throws -> [Result]) rethrows -> [Result] {
|
|
||||||
try children.collect(c) + c(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
extension Sequence<InlineNode> {
|
|
||||||
func rewrite(_ r: (InlineNode) throws -> [InlineNode]) rethrows -> [InlineNode] {
|
|
||||||
try flatMap { try $0.rewrite(r) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension InlineNode {
|
|
||||||
func rewrite(_ r: (InlineNode) throws -> [InlineNode]) rethrows -> [InlineNode] {
|
|
||||||
var inline = self
|
|
||||||
inline.children = try children.rewrite(r)
|
|
||||||
return try r(inline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-52
@@ -1,52 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public enum InlineNode: Hashable, Sendable, Equatable, Codable {
|
|
||||||
case text(String)
|
|
||||||
case softBreak
|
|
||||||
case lineBreak
|
|
||||||
case code(String)
|
|
||||||
case html(String)
|
|
||||||
case emphasis(children: [InlineNode])
|
|
||||||
case strong(children: [InlineNode])
|
|
||||||
case strikethrough(children: [InlineNode])
|
|
||||||
case link(destination: String, children: [InlineNode])
|
|
||||||
case image(source: String, children: [InlineNode])
|
|
||||||
}
|
|
||||||
|
|
||||||
extension InlineNode {
|
|
||||||
var children: [InlineNode] {
|
|
||||||
get {
|
|
||||||
switch self {
|
|
||||||
case let .emphasis(children):
|
|
||||||
children
|
|
||||||
case let .strong(children):
|
|
||||||
children
|
|
||||||
case let .strikethrough(children):
|
|
||||||
children
|
|
||||||
case let .link(_, children):
|
|
||||||
children
|
|
||||||
case let .image(_, children):
|
|
||||||
children
|
|
||||||
default:
|
|
||||||
[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
set {
|
|
||||||
switch self {
|
|
||||||
case .emphasis:
|
|
||||||
self = .emphasis(children: newValue)
|
|
||||||
case .strong:
|
|
||||||
self = .strong(children: newValue)
|
|
||||||
case .strikethrough:
|
|
||||||
self = .strikethrough(children: newValue)
|
|
||||||
case let .link(destination, _):
|
|
||||||
self = .link(destination: destination, children: newValue)
|
|
||||||
case let .image(source, _):
|
|
||||||
self = .image(source: source, children: newValue)
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
//
|
|
||||||
// MarkdownParser+Delegate.swift
|
|
||||||
// FlowMarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public extension MarkdownParser {
|
|
||||||
protocol Delegate: AnyObject {
|
|
||||||
func updateBlockNodes(_ blockNodes: [BlockNode])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
//
|
|
||||||
// MarkdownParser+Node.swift
|
|
||||||
// FlowMarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import cmark_gfm
|
|
||||||
import cmark_gfm_extensions
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension MarkdownParser {
|
|
||||||
func dumpBlocks(root: UnsafeNode?) {
|
|
||||||
guard let root else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
assert(root.pointee.type == CMARK_NODE_DOCUMENT.rawValue)
|
|
||||||
|
|
||||||
blocks = root.children.compactMap(BlockNode.init(unsafeNode:))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
//
|
|
||||||
// MarkdownParser+Setup.swift
|
|
||||||
// FlowMarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import cmark_gfm
|
|
||||||
import cmark_gfm_extensions
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension MarkdownParser {
|
|
||||||
func setupExtensions(parser: UnsafeMutablePointer<cmark_parser>) {
|
|
||||||
cmark_gfm_core_extensions_ensure_registered()
|
|
||||||
let extensionNames = ["autolink", "strikethrough", "tagfilter", "tasklist", "table"]
|
|
||||||
for extensionName in extensionNames {
|
|
||||||
guard let syntaxExtension = cmark_find_syntax_extension(extensionName) else {
|
|
||||||
assertionFailure()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cmark_parser_attach_syntax_extension(parser, syntaxExtension)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-34
@@ -1,34 +0,0 @@
|
|||||||
//
|
|
||||||
// MarkdownParser.swift
|
|
||||||
// FlowMarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/2.
|
|
||||||
//
|
|
||||||
|
|
||||||
import cmark_gfm
|
|
||||||
import cmark_gfm_extensions
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class MarkdownParser {
|
|
||||||
public internal(set) var blocks: [BlockNode] = [] {
|
|
||||||
didSet { delegate?.updateBlockNodes(blocks) }
|
|
||||||
}
|
|
||||||
|
|
||||||
public weak var delegate: Delegate?
|
|
||||||
var currentDoc = String()
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
public func feed(_ text: String) -> [BlockNode] {
|
|
||||||
currentDoc += text
|
|
||||||
let parser = cmark_parser_new(CMARK_OPT_DEFAULT)!
|
|
||||||
defer { cmark_parser_free(parser) }
|
|
||||||
setupExtensions(parser: parser)
|
|
||||||
cmark_parser_feed(parser, currentDoc, currentDoc.utf8.count)
|
|
||||||
let node = cmark_parser_finish(parser)
|
|
||||||
defer { cmark_node_free(node) }
|
|
||||||
dumpBlocks(root: node)
|
|
||||||
return blocks
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-289
@@ -1,289 +0,0 @@
|
|||||||
import cmark_gfm
|
|
||||||
import cmark_gfm_extensions
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
typealias UnsafeNode = UnsafeMutablePointer<cmark_node>
|
|
||||||
|
|
||||||
extension BlockNode {
|
|
||||||
init?(unsafeNode: UnsafeNode) {
|
|
||||||
switch unsafeNode.nodeType {
|
|
||||||
case .blockquote:
|
|
||||||
self = .blockquote(children: unsafeNode.children.compactMap(BlockNode.init(unsafeNode:)))
|
|
||||||
case .list:
|
|
||||||
if unsafeNode.children.contains(where: \.isTaskListItem) {
|
|
||||||
self = .taskList(
|
|
||||||
isTight: unsafeNode.isTightList,
|
|
||||||
items: unsafeNode.children.map(RawTaskListItem.init(unsafeNode:))
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
switch unsafeNode.listType {
|
|
||||||
case CMARK_BULLET_LIST:
|
|
||||||
self = .bulletedList(
|
|
||||||
isTight: unsafeNode.isTightList,
|
|
||||||
items: unsafeNode.children.map(RawListItem.init(unsafeNode:))
|
|
||||||
)
|
|
||||||
case CMARK_ORDERED_LIST:
|
|
||||||
self = .numberedList(
|
|
||||||
isTight: unsafeNode.isTightList,
|
|
||||||
start: unsafeNode.listStart,
|
|
||||||
items: unsafeNode.children.map(RawListItem.init(unsafeNode:))
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
fatalError("cmark reported a list node without a list type.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .codeBlock:
|
|
||||||
self = .codeBlock(fenceInfo: unsafeNode.fenceInfo, content: unsafeNode.literal ?? "")
|
|
||||||
case .htmlBlock:
|
|
||||||
// self = .htmlBlock(content: unsafeNode.literal ?? "")
|
|
||||||
self = .codeBlock(fenceInfo: "html", content: unsafeNode.literal ?? "")
|
|
||||||
case .paragraph:
|
|
||||||
self = .paragraph(content: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:)))
|
|
||||||
case .heading:
|
|
||||||
self = .heading(
|
|
||||||
level: unsafeNode.headingLevel,
|
|
||||||
content: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:))
|
|
||||||
)
|
|
||||||
case .table:
|
|
||||||
self = .table(
|
|
||||||
columnAlignments: unsafeNode.tableAlignments,
|
|
||||||
rows: unsafeNode.children.map(RawTableRow.init(unsafeNode:))
|
|
||||||
)
|
|
||||||
case .thematicBreak:
|
|
||||||
self = .thematicBreak
|
|
||||||
default:
|
|
||||||
assertionFailure("Unhandled node type '\(unsafeNode.nodeType)' in BlockNode.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension RawListItem {
|
|
||||||
init(unsafeNode: UnsafeNode) {
|
|
||||||
guard unsafeNode.nodeType == .item else {
|
|
||||||
fatalError("Expected a list item but got a '\(unsafeNode.nodeType)' instead.")
|
|
||||||
}
|
|
||||||
self.init(children: unsafeNode.children.compactMap(BlockNode.init(unsafeNode:)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension RawTaskListItem {
|
|
||||||
init(unsafeNode: UnsafeNode) {
|
|
||||||
guard unsafeNode.nodeType == .taskListItem || unsafeNode.nodeType == .item else {
|
|
||||||
fatalError("Expected a list item but got a '\(unsafeNode.nodeType)' instead.")
|
|
||||||
}
|
|
||||||
self.init(
|
|
||||||
isCompleted: unsafeNode.isTaskListItemChecked,
|
|
||||||
children: unsafeNode.children.compactMap(BlockNode.init(unsafeNode:))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension RawTableRow {
|
|
||||||
init(unsafeNode: UnsafeNode) {
|
|
||||||
guard unsafeNode.nodeType == .tableRow || unsafeNode.nodeType == .tableHead else {
|
|
||||||
fatalError("Expected a table row but got a '\(unsafeNode.nodeType)' instead.")
|
|
||||||
}
|
|
||||||
self.init(cells: unsafeNode.children.map(RawTableCell.init(unsafeNode:)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension RawTableCell {
|
|
||||||
init(unsafeNode: UnsafeNode) {
|
|
||||||
guard unsafeNode.nodeType == .tableCell else {
|
|
||||||
fatalError("Expected a table cell but got a '\(unsafeNode.nodeType)' instead.")
|
|
||||||
}
|
|
||||||
self.init(content: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension InlineNode {
|
|
||||||
init?(unsafeNode: UnsafeNode) {
|
|
||||||
switch unsafeNode.nodeType {
|
|
||||||
case .text:
|
|
||||||
self = .text(unsafeNode.literal ?? "")
|
|
||||||
case .softBreak:
|
|
||||||
self = .softBreak
|
|
||||||
case .lineBreak:
|
|
||||||
self = .lineBreak
|
|
||||||
case .code:
|
|
||||||
self = .code(unsafeNode.literal ?? "")
|
|
||||||
case .html:
|
|
||||||
self = .html(unsafeNode.literal ?? "")
|
|
||||||
case .emphasis:
|
|
||||||
self = .emphasis(children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:)))
|
|
||||||
case .strong:
|
|
||||||
self = .strong(children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:)))
|
|
||||||
case .strikethrough:
|
|
||||||
self = .strikethrough(children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:)))
|
|
||||||
case .link:
|
|
||||||
self = .link(
|
|
||||||
destination: unsafeNode.url ?? "",
|
|
||||||
children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:))
|
|
||||||
)
|
|
||||||
case .image:
|
|
||||||
self = .image(
|
|
||||||
source: unsafeNode.url ?? "",
|
|
||||||
children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:))
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
assertionFailure("Unhandled node type '\(unsafeNode.nodeType)' in InlineNode.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UnsafeNode {
|
|
||||||
var nodeType: NodeType {
|
|
||||||
let typeString = String(cString: cmark_node_get_type_string(self))
|
|
||||||
guard let nodeType = NodeType(rawValue: typeString) else {
|
|
||||||
fatalError("Unknown node type '\(typeString)' found.")
|
|
||||||
}
|
|
||||||
return nodeType
|
|
||||||
}
|
|
||||||
|
|
||||||
var children: UnsafeNodeSequence {
|
|
||||||
.init(cmark_node_first_child(self))
|
|
||||||
}
|
|
||||||
|
|
||||||
var literal: String? {
|
|
||||||
cmark_node_get_literal(self).map(String.init(cString:))
|
|
||||||
}
|
|
||||||
|
|
||||||
var url: String? {
|
|
||||||
cmark_node_get_url(self).map(String.init(cString:))
|
|
||||||
}
|
|
||||||
|
|
||||||
var isTaskListItem: Bool {
|
|
||||||
nodeType == .taskListItem
|
|
||||||
}
|
|
||||||
|
|
||||||
var listType: cmark_list_type {
|
|
||||||
cmark_node_get_list_type(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
var listStart: Int {
|
|
||||||
Int(cmark_node_get_list_start(self))
|
|
||||||
}
|
|
||||||
|
|
||||||
var isTaskListItemChecked: Bool {
|
|
||||||
cmark_gfm_extensions_get_tasklist_item_checked(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
var isTightList: Bool {
|
|
||||||
cmark_node_get_list_tight(self) != 0
|
|
||||||
}
|
|
||||||
|
|
||||||
var fenceInfo: String? {
|
|
||||||
cmark_node_get_fence_info(self).map(String.init(cString:))
|
|
||||||
}
|
|
||||||
|
|
||||||
var headingLevel: Int {
|
|
||||||
Int(cmark_node_get_heading_level(self))
|
|
||||||
}
|
|
||||||
|
|
||||||
var tableColumns: Int {
|
|
||||||
Int(cmark_gfm_extensions_get_table_columns(self))
|
|
||||||
}
|
|
||||||
|
|
||||||
var tableAlignments: [RawTableColumnAlignment] {
|
|
||||||
(0 ..< tableColumns).map { column in
|
|
||||||
let ascii = cmark_gfm_extensions_get_table_alignments(self)[column]
|
|
||||||
let scalar = UnicodeScalar(ascii)
|
|
||||||
let character = Character(scalar)
|
|
||||||
return .init(rawValue: character) ?? .none
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum NodeType: String {
|
|
||||||
case document
|
|
||||||
case blockquote = "block_quote"
|
|
||||||
case list
|
|
||||||
case item
|
|
||||||
case codeBlock = "code_block"
|
|
||||||
case htmlBlock = "html_block"
|
|
||||||
case customBlock = "custom_block"
|
|
||||||
case paragraph
|
|
||||||
case heading
|
|
||||||
case thematicBreak = "thematic_break"
|
|
||||||
case text
|
|
||||||
case softBreak = "softbreak"
|
|
||||||
case lineBreak = "linebreak"
|
|
||||||
case code
|
|
||||||
case html = "html_inline"
|
|
||||||
case customInline = "custom_inline"
|
|
||||||
case emphasis = "emph"
|
|
||||||
case strong
|
|
||||||
case link
|
|
||||||
case image
|
|
||||||
case inlineAttributes = "attribute"
|
|
||||||
case none = "NONE"
|
|
||||||
case unknown = "<unknown>"
|
|
||||||
|
|
||||||
// Extensions
|
|
||||||
|
|
||||||
case strikethrough
|
|
||||||
case table
|
|
||||||
case tableHead = "table_header"
|
|
||||||
case tableRow = "table_row"
|
|
||||||
case tableCell = "table_cell"
|
|
||||||
case taskListItem = "tasklist"
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UnsafeNodeSequence: Sequence {
|
|
||||||
struct Iterator: IteratorProtocol {
|
|
||||||
var node: UnsafeNode?
|
|
||||||
|
|
||||||
init(_ node: UnsafeNode?) {
|
|
||||||
self.node = node
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func next() -> UnsafeNode? {
|
|
||||||
guard let node else { return nil }
|
|
||||||
defer { self.node = cmark_node_next(node) }
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let node: UnsafeNode?
|
|
||||||
|
|
||||||
init(_ node: UnsafeNode?) {
|
|
||||||
self.node = node
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeIterator() -> Iterator {
|
|
||||||
.init(node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extension node types are not exported in `cmark_gfm_extensions`,
|
|
||||||
// so we need to look for them in the symbol table
|
|
||||||
struct ExtensionNodeTypes {
|
|
||||||
let CMARK_NODE_TABLE: cmark_node_type
|
|
||||||
let CMARK_NODE_TABLE_ROW: cmark_node_type
|
|
||||||
let CMARK_NODE_TABLE_CELL: cmark_node_type
|
|
||||||
let CMARK_NODE_STRIKETHROUGH: cmark_node_type
|
|
||||||
|
|
||||||
static let shared = ExtensionNodeTypes()
|
|
||||||
|
|
||||||
init() {
|
|
||||||
func findNodeType(_ name: String, in handle: UnsafeMutableRawPointer!) -> cmark_node_type? {
|
|
||||||
guard let symbol = dlsym(handle, name) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return symbol.assumingMemoryBound(to: cmark_node_type.self).pointee
|
|
||||||
}
|
|
||||||
|
|
||||||
let handle = dlopen(nil, RTLD_LAZY)
|
|
||||||
|
|
||||||
CMARK_NODE_TABLE = findNodeType("CMARK_NODE_TABLE", in: handle) ?? CMARK_NODE_NONE
|
|
||||||
CMARK_NODE_TABLE_ROW = findNodeType("CMARK_NODE_TABLE_ROW", in: handle) ?? CMARK_NODE_NONE
|
|
||||||
CMARK_NODE_TABLE_CELL =
|
|
||||||
findNodeType("CMARK_NODE_TABLE_CELL", in: handle) ?? CMARK_NODE_NONE
|
|
||||||
CMARK_NODE_STRIKETHROUGH =
|
|
||||||
findNodeType("CMARK_NODE_STRIKETHROUGH", in: handle) ?? CMARK_NODE_NONE
|
|
||||||
|
|
||||||
dlclose(handle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
//
|
|
||||||
// Ext+Array.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension Array {
|
|
||||||
subscript(safe index: Int) -> Element? {
|
|
||||||
guard index >= 0, index < count else { return nil }
|
|
||||||
return self[index]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-111
@@ -1,111 +0,0 @@
|
|||||||
//
|
|
||||||
// BlockquoteView.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import MarkdownParser
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class BlockquoteView: BlockView {
|
|
||||||
var manifest: Manifest { _manifest as! Manifest }
|
|
||||||
|
|
||||||
let backgroundView = UIView()
|
|
||||||
let childrenContainer = UIView()
|
|
||||||
var childrenViews: [BlockView] = []
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
addSubview(backgroundView)
|
|
||||||
backgroundView.backgroundColor = .gray.withAlphaComponent(0.05)
|
|
||||||
backgroundView.layer.cornerRadius = 8
|
|
||||||
backgroundView.clipsToBounds = true
|
|
||||||
backgroundView.layer.masksToBounds = true
|
|
||||||
addSubview(childrenContainer)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayout() {
|
|
||||||
super.viewDidLayout()
|
|
||||||
backgroundView.frame = bounds
|
|
||||||
childrenViews.first?.frame = manifest.childrenGroupRect
|
|
||||||
childrenContainer.frame = bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidUpdate() {
|
|
||||||
super.viewDidUpdate()
|
|
||||||
childrenContainer.diffableUpdate(
|
|
||||||
reusingViews: &childrenViews,
|
|
||||||
manifests: [manifest.childrenGroup]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func accept(_ manifest: AnyBlockManifest) -> Bool {
|
|
||||||
manifest is Manifest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension BlockquoteView {
|
|
||||||
class Manifest: BlockManifest {
|
|
||||||
var size: CGSize = .zero
|
|
||||||
var theme: Theme = .default
|
|
||||||
var dirty: Bool = true
|
|
||||||
|
|
||||||
var childrenGroupWidth: CGFloat {
|
|
||||||
max(0, size.width - spacings.general * 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
var intrinsicWidth: CGFloat {
|
|
||||||
childrenGroup.intrinsicWidth + spacings.general * 2
|
|
||||||
}
|
|
||||||
|
|
||||||
let childrenGroup: GroupBlockView.Manifest = .init()
|
|
||||||
var childrenGroupRect: CGRect = .zero
|
|
||||||
|
|
||||||
required init() {}
|
|
||||||
|
|
||||||
var block: BlockNode? = nil
|
|
||||||
func load(block: BlockNode) {
|
|
||||||
guard self.block != block else { return }
|
|
||||||
dirty = true
|
|
||||||
self.block = block
|
|
||||||
guard case let .blockquote(children) = block else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
childrenGroup.setChildren(nodes: children)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLayoutWidth(_ width: CGFloat) {
|
|
||||||
guard size.width != width else { return }
|
|
||||||
assert(width >= 0)
|
|
||||||
dirty = true
|
|
||||||
size.width = width
|
|
||||||
childrenGroup.setLayoutWidth(childrenGroupWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLayoutTheme(_ theme: Theme) {
|
|
||||||
guard self.theme != theme else { return }
|
|
||||||
dirty = true
|
|
||||||
self.theme = theme
|
|
||||||
childrenGroup.setLayoutTheme(theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
func layoutIfNeeded() {
|
|
||||||
guard dirty, size.width > 0 else { return }
|
|
||||||
defer { dirty = false }
|
|
||||||
childrenGroup.layoutIfNeeded()
|
|
||||||
size.height = childrenGroup.size.height + spacings.general * 2
|
|
||||||
childrenGroupRect = CGRect(
|
|
||||||
x: spacings.general,
|
|
||||||
y: spacings.general,
|
|
||||||
width: childrenGroup.size.width,
|
|
||||||
height: childrenGroup.size.height
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func determineViewType() -> BlockView.Type {
|
|
||||||
BlockquoteView.self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-118
@@ -1,118 +0,0 @@
|
|||||||
//
|
|
||||||
// BulletedItemView.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import MarkdownParser
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class BulletedItemView: BlockView {
|
|
||||||
let bulletedIcon = CircleView()
|
|
||||||
var manifest: Manifest { _manifest as! Manifest }
|
|
||||||
let childrenContainer = UIView()
|
|
||||||
var childrenViews: [BlockView] = []
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
addSubview(bulletedIcon)
|
|
||||||
addSubview(childrenContainer)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayout() {
|
|
||||||
super.viewDidLayout()
|
|
||||||
bulletedIcon.frame = manifest.iconRect
|
|
||||||
childrenViews.first?.frame = manifest.childrenRect
|
|
||||||
childrenContainer.frame = bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidUpdate() {
|
|
||||||
super.viewDidUpdate()
|
|
||||||
childrenContainer.diffableUpdate(
|
|
||||||
reusingViews: &childrenViews,
|
|
||||||
manifests: [manifest.childrenGroup]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func accept(_ manifest: AnyBlockManifest) -> Bool {
|
|
||||||
manifest is Manifest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension BulletedItemView {
|
|
||||||
class Manifest: BlockManifest {
|
|
||||||
var size: CGSize = .zero
|
|
||||||
var theme: Theme = .default
|
|
||||||
var dirty: Bool = true
|
|
||||||
|
|
||||||
var intrinsicWidth: CGFloat {
|
|
||||||
childrenGroup.intrinsicWidth + iconLayoutGuideRect.width
|
|
||||||
}
|
|
||||||
|
|
||||||
var iconLayoutGuideRect: CGRect {
|
|
||||||
.init(x: 0, y: 0, width: sizes.bullet + spacings.list, height: fonts.body.baseLineHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
var iconRect: CGRect = .zero
|
|
||||||
let childrenGroup: GroupBlockView.Manifest = .init()
|
|
||||||
var childrenRect: CGRect = .zero
|
|
||||||
|
|
||||||
var childrenGroupWidth: CGFloat {
|
|
||||||
max(0, size.width - iconLayoutGuideRect.width)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init() {
|
|
||||||
childrenGroup.overrideGroupSpacing = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func load(block _: BlockNode) {
|
|
||||||
assertionFailure("should not be called")
|
|
||||||
}
|
|
||||||
|
|
||||||
func setItems(_ items: RawListItem) {
|
|
||||||
dirty = true
|
|
||||||
childrenGroup.setChildren(nodes: items.children)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLayoutWidth(_ width: CGFloat) {
|
|
||||||
guard size.width != width else { return }
|
|
||||||
assert(width >= 0)
|
|
||||||
dirty = true
|
|
||||||
size.width = width
|
|
||||||
childrenGroup.setLayoutWidth(childrenGroupWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLayoutTheme(_ theme: Theme) {
|
|
||||||
guard self.theme != theme else { return }
|
|
||||||
dirty = true
|
|
||||||
self.theme = theme
|
|
||||||
childrenGroup.setLayoutTheme(theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
func layoutIfNeeded() {
|
|
||||||
guard dirty, size.width > 0 else { return }
|
|
||||||
defer { dirty = false }
|
|
||||||
let iconLayoutGuideRect = iconLayoutGuideRect
|
|
||||||
|
|
||||||
childrenGroup.layoutIfNeeded()
|
|
||||||
size.height = max(iconLayoutGuideRect.height, childrenGroup.size.height)
|
|
||||||
iconRect = CGRect(
|
|
||||||
x: 0,
|
|
||||||
y: (iconLayoutGuideRect.height - sizes.bullet) / 2,
|
|
||||||
width: sizes.bullet,
|
|
||||||
height: sizes.bullet
|
|
||||||
)
|
|
||||||
childrenRect = CGRect(
|
|
||||||
x: iconLayoutGuideRect.maxX,
|
|
||||||
y: 0,
|
|
||||||
width: childrenGroup.size.width,
|
|
||||||
height: childrenGroup.size.height
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func determineViewType() -> BlockView.Type {
|
|
||||||
BulletedItemView.self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-106
@@ -1,106 +0,0 @@
|
|||||||
//
|
|
||||||
// BulletedListView.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import MarkdownParser
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class BulletedListView: BlockView {
|
|
||||||
var manifest: Manifest { _manifest as! Manifest }
|
|
||||||
let childrenContainer = UIView()
|
|
||||||
var childrenViews: [BlockView] = []
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
addSubview(childrenContainer)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayout() {
|
|
||||||
super.viewDidLayout()
|
|
||||||
childrenViews.first?.frame = manifest.childrenGroupRect
|
|
||||||
childrenContainer.frame = bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidUpdate() {
|
|
||||||
super.viewDidUpdate()
|
|
||||||
childrenContainer.diffableUpdate(
|
|
||||||
reusingViews: &childrenViews,
|
|
||||||
manifests: [manifest.childrenGroup]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func accept(_ manifest: AnyBlockManifest) -> Bool {
|
|
||||||
manifest is Manifest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension BulletedListView {
|
|
||||||
class Manifest: BlockManifest {
|
|
||||||
var size: CGSize = .zero
|
|
||||||
var theme: Theme = .default
|
|
||||||
var dirty: Bool = true
|
|
||||||
|
|
||||||
var intrinsicWidth: CGFloat {
|
|
||||||
childrenGroup.intrinsicWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
required init() {
|
|
||||||
childrenGroup.overrideGroupSpacing = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
let childrenGroup: GroupBlockView.Manifest = .init()
|
|
||||||
var childrenGroupRect: CGRect = .zero
|
|
||||||
|
|
||||||
var block: BlockNode? = nil
|
|
||||||
func load(block: BlockNode) {
|
|
||||||
guard self.block != block else { return }
|
|
||||||
dirty = true
|
|
||||||
self.block = block
|
|
||||||
guard case let .bulletedList(_, items) = block else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
childrenGroup.setChildren(manifests: items.map { listItem in
|
|
||||||
let manifest = BulletedItemView.Manifest()
|
|
||||||
manifest.setItems(listItem)
|
|
||||||
return manifest
|
|
||||||
})
|
|
||||||
childrenGroup.setLayoutWidth(size.width)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLayoutWidth(_ width: CGFloat) {
|
|
||||||
guard size.width != width else { return }
|
|
||||||
assert(width >= 0)
|
|
||||||
dirty = true
|
|
||||||
size.width = width
|
|
||||||
childrenGroup.setLayoutWidth(width)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLayoutTheme(_ theme: Theme) {
|
|
||||||
guard self.theme != theme else { return }
|
|
||||||
dirty = true
|
|
||||||
self.theme = theme
|
|
||||||
childrenGroup.setLayoutTheme(theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
func layoutIfNeeded() {
|
|
||||||
guard dirty, size.width > 0 else { return }
|
|
||||||
defer { dirty = false }
|
|
||||||
childrenGroup.layoutIfNeeded()
|
|
||||||
size.height = childrenGroup.size.height
|
|
||||||
childrenGroupRect = CGRect(
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: size.width,
|
|
||||||
height: size.height
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func determineViewType() -> BlockView.Type {
|
|
||||||
BulletedListView.self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-197
@@ -1,197 +0,0 @@
|
|||||||
//
|
|
||||||
// CodeBlockView.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import MarkdownParser
|
|
||||||
import Splash
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class CodeBlockView: BlockView {
|
|
||||||
let backgroundView = UIView()
|
|
||||||
let fenceView = UIView()
|
|
||||||
let fenceLabel = TextLabel()
|
|
||||||
let fenceCopyButton = UIButton()
|
|
||||||
let scrollView = UIScrollView()
|
|
||||||
let codeTextView = TextLabel()
|
|
||||||
|
|
||||||
var manifest: Manifest { _manifest as! Manifest }
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
clipsToBounds = true
|
|
||||||
layer.cornerRadius = 8
|
|
||||||
layer.masksToBounds = true
|
|
||||||
|
|
||||||
addSubview(backgroundView)
|
|
||||||
backgroundView.backgroundColor = .gray.withAlphaComponent(0.05)
|
|
||||||
|
|
||||||
addSubview(fenceView)
|
|
||||||
fenceView.backgroundColor = .gray.withAlphaComponent(0.05)
|
|
||||||
|
|
||||||
addSubview(fenceCopyButton)
|
|
||||||
fenceCopyButton.setImage(UIImage(systemName: "doc.on.doc"), for: .normal)
|
|
||||||
fenceCopyButton.tintColor = .label
|
|
||||||
fenceCopyButton.addTarget(self, action: #selector(copyButtonTapped), for: .touchUpInside)
|
|
||||||
fenceCopyButton.imageView?.contentMode = .scaleAspectFit
|
|
||||||
|
|
||||||
addSubview(fenceLabel)
|
|
||||||
fenceLabel.isSelectable = false
|
|
||||||
fenceLabel.textColor = .label
|
|
||||||
fenceLabel.font = .preferredFont(forTextStyle: .caption1)
|
|
||||||
fenceLabel.textAlignment = .left
|
|
||||||
|
|
||||||
addSubview(scrollView)
|
|
||||||
scrollView.showsVerticalScrollIndicator = false
|
|
||||||
scrollView.showsHorizontalScrollIndicator = false
|
|
||||||
scrollView.addSubview(codeTextView)
|
|
||||||
|
|
||||||
scrollView.clipsToBounds = false
|
|
||||||
scrollView.layer.masksToBounds = false
|
|
||||||
scrollView.bringSubviewToFront(codeTextView)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayout() {
|
|
||||||
super.viewDidLayout()
|
|
||||||
backgroundView.frame = bounds
|
|
||||||
fenceView.frame = manifest.fenceRect
|
|
||||||
fenceLabel.frame = manifest.fenceLabelRect
|
|
||||||
fenceCopyButton.frame = manifest.fenceCopyButtonRect
|
|
||||||
scrollView.frame = manifest.scrollRect
|
|
||||||
codeTextView.frame = manifest.codeContentRect
|
|
||||||
scrollView.contentSize = manifest.scrollableContentSize
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidUpdate() {
|
|
||||||
super.viewDidUpdate()
|
|
||||||
fenceLabel.attributedText = manifest.fenceInfo
|
|
||||||
codeTextView.attributedText = manifest.content
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func copyButtonTapped() {
|
|
||||||
UIPasteboard.general.string = manifest.content.string
|
|
||||||
fenceCopyButton.setImage(UIImage(systemName: "checkmark"), for: .normal)
|
|
||||||
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(setButtonImageBack), object: nil)
|
|
||||||
perform(#selector(setButtonImageBack), with: nil, afterDelay: 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setButtonImageBack() {
|
|
||||||
fenceCopyButton.setImage(UIImage(systemName: "doc.on.doc"), for: .normal)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func accept(_ manifest: AnyBlockManifest) -> Bool {
|
|
||||||
manifest is Manifest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension CodeBlockView {
|
|
||||||
class Manifest: BlockManifest {
|
|
||||||
var size: CGSize = .zero
|
|
||||||
var theme: Theme = .default
|
|
||||||
var dirty: Bool = true
|
|
||||||
|
|
||||||
var intrinsicWidth: CGFloat {
|
|
||||||
content.measureWidth() + spacings.general * 4
|
|
||||||
}
|
|
||||||
|
|
||||||
var fenceInfo: NSAttributedString = .init()
|
|
||||||
var content: NSAttributedString = .init()
|
|
||||||
|
|
||||||
var fenceRect: CGRect = .zero
|
|
||||||
var fenceCopyButtonRect: CGRect = .zero
|
|
||||||
var fenceLabelRect: CGRect = .zero
|
|
||||||
var scrollRect: CGRect = .zero
|
|
||||||
var codeContentRect: CGRect = .zero
|
|
||||||
var scrollableContentSize: CGSize = .zero
|
|
||||||
|
|
||||||
required init() {}
|
|
||||||
|
|
||||||
var block: BlockNode? = nil
|
|
||||||
func load(block: BlockNode) {
|
|
||||||
guard self.block != block else { return }
|
|
||||||
dirty = true
|
|
||||||
self.block = block
|
|
||||||
guard case let .codeBlock(info, content) = block else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var infoText: String = info ?? ""
|
|
||||||
infoText = infoText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if infoText.isEmpty { infoText = "#" }
|
|
||||||
fenceInfo = NSAttributedString(string: infoText, attributes: [
|
|
||||||
.font: theme.fonts.code,
|
|
||||||
.foregroundColor: theme.colors.body,
|
|
||||||
.originalFont: theme.fonts.code,
|
|
||||||
])
|
|
||||||
let code = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let codeTheme = theme.codeTheme(withFont: theme.fonts.code)
|
|
||||||
let output = AttributedStringOutputFormat(theme: codeTheme)
|
|
||||||
let result: NSMutableAttributedString?
|
|
||||||
switch info?.lowercased() {
|
|
||||||
case "swift":
|
|
||||||
let splash = SyntaxHighlighter(format: output, grammar: SwiftGrammar())
|
|
||||||
result = splash.highlight(code).mutableCopy() as? NSMutableAttributedString
|
|
||||||
default:
|
|
||||||
let splash = SyntaxHighlighter(format: output)
|
|
||||||
result = splash.highlight(code).mutableCopy() as? NSMutableAttributedString
|
|
||||||
}
|
|
||||||
let defaultAttrs: [NSAttributedString.Key: Any] = [
|
|
||||||
.font: theme.fonts.code,
|
|
||||||
.originalFont: theme.fonts.code,
|
|
||||||
]
|
|
||||||
result?.addAttributes(defaultAttrs, range: NSRange(location: 0, length: result?.length ?? 0))
|
|
||||||
self.content = result ?? .init(string: code, attributes: defaultAttrs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func layoutIfNeeded() {
|
|
||||||
guard dirty, size.width > 0 else { return }
|
|
||||||
defer { dirty = false }
|
|
||||||
let fenceLabelHeight = fenceInfo.measureHeight(usingWidth: .greatestFiniteMagnitude)
|
|
||||||
let fenceHeight = fenceLabelHeight + spacings.general * 2
|
|
||||||
let fenceCopyButtonSize = fenceLabelHeight
|
|
||||||
fenceRect = CGRect(
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: size.width,
|
|
||||||
height: fenceHeight
|
|
||||||
)
|
|
||||||
fenceLabelRect = .init(
|
|
||||||
x: spacings.general,
|
|
||||||
y: spacings.general,
|
|
||||||
width: size.width - fenceCopyButtonSize - spacings.general * 2,
|
|
||||||
height: fenceLabelHeight
|
|
||||||
)
|
|
||||||
fenceCopyButtonRect = .init(
|
|
||||||
x: size.width - fenceCopyButtonSize - spacings.general,
|
|
||||||
y: fenceLabelRect.minY,
|
|
||||||
width: fenceCopyButtonSize,
|
|
||||||
height: fenceCopyButtonSize
|
|
||||||
)
|
|
||||||
let contentWidth = content.measureWidth()
|
|
||||||
let contentHeight = content.measureHeight(usingWidth: .greatestFiniteMagnitude)
|
|
||||||
scrollRect = .init(
|
|
||||||
x: 0,
|
|
||||||
y: fenceRect.maxY,
|
|
||||||
width: size.width,
|
|
||||||
height: contentHeight + spacings.general * 2
|
|
||||||
)
|
|
||||||
size.height = scrollRect.maxY
|
|
||||||
codeContentRect = .init(
|
|
||||||
x: spacings.general,
|
|
||||||
y: spacings.general,
|
|
||||||
width: contentWidth + spacings.general * 2,
|
|
||||||
height: contentHeight
|
|
||||||
)
|
|
||||||
scrollableContentSize = .init(
|
|
||||||
width: codeContentRect.maxX + spacings.general,
|
|
||||||
height: codeContentRect.maxY + spacings.general
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func determineViewType() -> BlockView.Type {
|
|
||||||
CodeBlockView.self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-142
@@ -1,142 +0,0 @@
|
|||||||
//
|
|
||||||
// GroupBlockView.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import MarkdownParser
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class GroupBlockView: BlockView {
|
|
||||||
var manifest: Manifest { _manifest as! Manifest }
|
|
||||||
|
|
||||||
let childrenContainer = UIView()
|
|
||||||
var childrenViews: [BlockView] = []
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
addSubview(childrenContainer)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayout() {
|
|
||||||
super.viewDidLayout()
|
|
||||||
childrenContainer.frame = bounds
|
|
||||||
for (index, view) in childrenViews.enumerated() {
|
|
||||||
view.frame = manifest.children[index].rect
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidUpdate() {
|
|
||||||
childrenContainer.diffableUpdate(
|
|
||||||
reusingViews: &childrenViews,
|
|
||||||
manifests: manifest.children.map(\.manifest)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func accept(_ manifest: AnyBlockManifest) -> Bool {
|
|
||||||
manifest is Manifest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension GroupBlockView {
|
|
||||||
class Manifest: BlockManifest {
|
|
||||||
var size: CGSize = .zero
|
|
||||||
var theme: Theme = .default
|
|
||||||
var dirty: Bool = true
|
|
||||||
|
|
||||||
var intrinsicWidth: CGFloat {
|
|
||||||
children.map(\.manifest.intrinsicWidth).max() ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
var overrideGroupSpacing: CGFloat? = nil {
|
|
||||||
didSet { dirty = true }
|
|
||||||
}
|
|
||||||
|
|
||||||
var spacing: CGFloat {
|
|
||||||
if let overrideGroupSpacing { return overrideGroupSpacing }
|
|
||||||
return theme.spacings.general
|
|
||||||
}
|
|
||||||
|
|
||||||
required init() {}
|
|
||||||
|
|
||||||
func load(block _: BlockNode) {
|
|
||||||
assertionFailure("should not be called")
|
|
||||||
}
|
|
||||||
|
|
||||||
var children: [Child] = [] {
|
|
||||||
didSet { dirty = true }
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLayoutWidth(_ width: CGFloat) {
|
|
||||||
guard size.width != width else { return }
|
|
||||||
assert(width >= 0)
|
|
||||||
dirty = true
|
|
||||||
size.width = width
|
|
||||||
children.forEach { $0.manifest.setLayoutWidth(width) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func setChildren(manifests: [AnyBlockManifest]) {
|
|
||||||
dirty = true
|
|
||||||
children = manifests.map { Child(manifest: $0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func setChildren(nodes: [BlockNode]) {
|
|
||||||
setChildren(manifests: nodes.map { $0.manifest(theme: theme) })
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLayoutTheme(_ theme: Theme) {
|
|
||||||
guard self.theme != theme else { return }
|
|
||||||
dirty = true
|
|
||||||
self.theme = theme
|
|
||||||
children.forEach { $0.manifest.setLayoutTheme(theme) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func layoutIfNeeded() {
|
|
||||||
guard dirty, size.width > 0 else { return }
|
|
||||||
defer { dirty = false }
|
|
||||||
var anchor: CGFloat = 0
|
|
||||||
for child in children {
|
|
||||||
child.manifest.setLayoutWidth(size.width)
|
|
||||||
child.manifest.layoutIfNeeded()
|
|
||||||
child.rect = CGRect(
|
|
||||||
x: 0,
|
|
||||||
y: anchor + spacing,
|
|
||||||
width: child.manifest.size.width,
|
|
||||||
height: child.manifest.size.height
|
|
||||||
)
|
|
||||||
anchor = child.rect.maxY
|
|
||||||
}
|
|
||||||
size.height = anchor
|
|
||||||
}
|
|
||||||
|
|
||||||
func determineViewType() -> BlockView.Type {
|
|
||||||
GroupBlockView.self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension GroupBlockView.Manifest {
|
|
||||||
class Child {
|
|
||||||
let manifest: AnyBlockManifest
|
|
||||||
var rect: CGRect
|
|
||||||
|
|
||||||
init(manifest: AnyBlockManifest, rect: CGRect = .zero) {
|
|
||||||
self.manifest = manifest
|
|
||||||
self.rect = rect
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func build(reusingChildren: [Child], forManifests manifests: [AnyBlockManifest]) -> [Child] {
|
|
||||||
var ans = [Child]()
|
|
||||||
for idx in manifests.indices {
|
|
||||||
let manifest = manifests[idx]
|
|
||||||
if let child = reusingChildren[safe: idx], type(of: child.manifest) == type(of: manifest) {
|
|
||||||
ans.append(child)
|
|
||||||
} else {
|
|
||||||
ans.append(Child(manifest: manifest))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ans
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-90
@@ -1,90 +0,0 @@
|
|||||||
////
|
|
||||||
//// HTMLBlockView.swift
|
|
||||||
//// MarkdownView
|
|
||||||
////
|
|
||||||
//// Created by 秋星桥 on 2025/1/3.
|
|
||||||
////
|
|
||||||
//
|
|
||||||
// import Foundation
|
|
||||||
// import MarkdownParser
|
|
||||||
// import UIKit
|
|
||||||
//
|
|
||||||
// class HTMLBlockView: BlockView {
|
|
||||||
// let text = TextView()
|
|
||||||
// var manifest: Manifest { _manifest as! Manifest }
|
|
||||||
//
|
|
||||||
// override func viewDidLoad() {
|
|
||||||
// super.viewDidLoad()
|
|
||||||
// addSubview(text)
|
|
||||||
// text.isEditable = false
|
|
||||||
// text.isSelectable = true
|
|
||||||
// text.isScrollEnabled = false
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override func viewDidLayout() {
|
|
||||||
// super.viewDidLayout()
|
|
||||||
// text.frame = manifest.contentRect
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override func viewDidUpdate() {
|
|
||||||
// super.viewDidUpdate()
|
|
||||||
// text.attributedText = manifest.content
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override func accept(_ manifest: AnyBlockManifest) -> Bool {
|
|
||||||
// manifest is Manifest
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// extension HTMLBlockView {
|
|
||||||
// class Manifest: BlockManifest {
|
|
||||||
// var size: CGSize = .zero
|
|
||||||
// var theme: Theme = .default
|
|
||||||
// var dirty: Bool = true
|
|
||||||
//
|
|
||||||
// var intrinsicWidth: CGFloat {
|
|
||||||
// size.width
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// var content: NSMutableAttributedString = .init()
|
|
||||||
// var contentRect: CGRect = .zero
|
|
||||||
//
|
|
||||||
// required init() {}
|
|
||||||
//
|
|
||||||
// var block: BlockNode? = nil
|
|
||||||
// func load(block: BlockNode) {
|
|
||||||
// guard self.block != block else { return }
|
|
||||||
// dirty = true
|
|
||||||
// self.block = block
|
|
||||||
// guard case let .htmlBlock(contents) = block else {
|
|
||||||
// assertionFailure()
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// let htmlData = NSString(string: contents).data(using: String.Encoding.unicode.rawValue)
|
|
||||||
// let options = [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.html]
|
|
||||||
// let ans = try? NSMutableAttributedString(
|
|
||||||
// data: htmlData ?? Data(),
|
|
||||||
// options: options,
|
|
||||||
// documentAttributes: nil
|
|
||||||
// )
|
|
||||||
// let content = ans ?? .init()
|
|
||||||
// content.addAttributes(
|
|
||||||
// [.originalFont: theme.fonts.body],
|
|
||||||
// range: .init(location: 0, length: content.length)
|
|
||||||
// )
|
|
||||||
// self.content = content
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func layoutIfNeeded() {
|
|
||||||
// guard dirty, size.width > 0 else { return }
|
|
||||||
// defer { dirty = false }
|
|
||||||
// let textHeight = content.measureHeight(usingWidth: size.width)
|
|
||||||
// contentRect = .init(x: 0, y: 0, width: size.width, height: textHeight)
|
|
||||||
// size.height = textHeight
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func determineViewType() -> BlockView.Type {
|
|
||||||
// HTMLBlockView.self
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
-87
@@ -1,87 +0,0 @@
|
|||||||
//
|
|
||||||
// HeadingView.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import MarkdownParser
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class HeadingView: BlockView {
|
|
||||||
let text = TextLabel()
|
|
||||||
var manifest: Manifest { _manifest as! Manifest }
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
addSubview(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayout() {
|
|
||||||
super.viewDidLayout()
|
|
||||||
text.frame = manifest.contentRect
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidUpdate() {
|
|
||||||
super.viewDidUpdate()
|
|
||||||
text.attributedText = manifest.content
|
|
||||||
}
|
|
||||||
|
|
||||||
override func accept(_ manifest: AnyBlockManifest) -> Bool {
|
|
||||||
manifest is Manifest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension HeadingView {
|
|
||||||
class Manifest: BlockManifest {
|
|
||||||
var size: CGSize = .zero
|
|
||||||
var theme: Theme = .default
|
|
||||||
var dirty: Bool = true
|
|
||||||
|
|
||||||
var intrinsicWidth: CGFloat {
|
|
||||||
size.width
|
|
||||||
}
|
|
||||||
|
|
||||||
var content: NSMutableAttributedString = .init()
|
|
||||||
var contentRect: CGRect = .zero
|
|
||||||
|
|
||||||
required init() {}
|
|
||||||
|
|
||||||
var block: BlockNode? = nil
|
|
||||||
func load(block: BlockNode) {
|
|
||||||
guard self.block != block else { return }
|
|
||||||
dirty = true
|
|
||||||
self.block = block
|
|
||||||
guard case let .heading(level, inlines) = block else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let attrText = inlines.render(theme: theme)
|
|
||||||
var supposeFont: UIFont = theme.fonts.title
|
|
||||||
if level <= 1 {
|
|
||||||
supposeFont = theme.fonts.largeTitle
|
|
||||||
}
|
|
||||||
attrText.addAttributes(
|
|
||||||
[
|
|
||||||
.font: supposeFont,
|
|
||||||
.originalFont: supposeFont,
|
|
||||||
.foregroundColor: theme.colors.body,
|
|
||||||
],
|
|
||||||
range: .init(location: 0, length: attrText.length)
|
|
||||||
)
|
|
||||||
content = attrText
|
|
||||||
}
|
|
||||||
|
|
||||||
func layoutIfNeeded() {
|
|
||||||
guard dirty, size.width > 0 else { return }
|
|
||||||
defer { dirty = false }
|
|
||||||
let textHeight = content.measureHeight(usingWidth: size.width)
|
|
||||||
contentRect = .init(x: 0, y: 0, width: size.width, height: textHeight)
|
|
||||||
size.height = textHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
func determineViewType() -> BlockView.Type {
|
|
||||||
HeadingView.self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-138
@@ -1,138 +0,0 @@
|
|||||||
//
|
|
||||||
// BulletedItemView 2.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import MarkdownParser
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class NumberedItemView: BlockView {
|
|
||||||
let numberView = TextLabel()
|
|
||||||
var manifest: Manifest { _manifest as! Manifest }
|
|
||||||
|
|
||||||
let childrenContainer = UIView()
|
|
||||||
var childrenViews: [BlockView] = []
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
numberView.textAlignment = .left
|
|
||||||
numberView.font = .preferredFont(forTextStyle: .body)
|
|
||||||
numberView.textColor = .label
|
|
||||||
addSubview(numberView)
|
|
||||||
addSubview(childrenContainer)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayout() {
|
|
||||||
super.viewDidLayout()
|
|
||||||
childrenContainer.frame = bounds
|
|
||||||
numberView.frame = manifest.iconRect
|
|
||||||
childrenViews.first?.frame = manifest.childrenRect
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidUpdate() {
|
|
||||||
super.viewDidUpdate()
|
|
||||||
numberView.attributedText = manifest.number
|
|
||||||
childrenContainer.diffableUpdate(
|
|
||||||
reusingViews: &childrenViews,
|
|
||||||
manifests: [manifest.childrenGroup]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func accept(_ manifest: AnyBlockManifest) -> Bool {
|
|
||||||
manifest is Manifest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension NumberedItemView {
|
|
||||||
class Manifest: BlockManifest {
|
|
||||||
var size: CGSize = .zero
|
|
||||||
var theme: Theme = .default
|
|
||||||
var dirty: Bool = true
|
|
||||||
|
|
||||||
var intrinsicWidth: CGFloat {
|
|
||||||
childrenGroup.intrinsicWidth + iconLayoutGuideRect.width
|
|
||||||
}
|
|
||||||
|
|
||||||
var number: NSAttributedString = .init()
|
|
||||||
var iconRect: CGRect = .zero
|
|
||||||
let childrenGroup: GroupBlockView.Manifest = .init()
|
|
||||||
var childrenRect: CGRect = .zero
|
|
||||||
|
|
||||||
var iconWidth: CGFloat {
|
|
||||||
"99.".size(withAttributes: [.font: theme.fonts.body]).width
|
|
||||||
}
|
|
||||||
|
|
||||||
var iconLayoutGuideRect: CGRect {
|
|
||||||
.init(x: 0, y: 0, width: iconWidth + spacings.list, height: fonts.body.baseLineHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
var childrenGroupWidth: CGFloat {
|
|
||||||
max(0, size.width - iconLayoutGuideRect.width)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init() {
|
|
||||||
childrenGroup.overrideGroupSpacing = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func load(block _: BlockNode) {
|
|
||||||
assertionFailure("should not be called")
|
|
||||||
}
|
|
||||||
|
|
||||||
func setNumber(_ number: Int) {
|
|
||||||
dirty = true
|
|
||||||
self.number = NSMutableAttributedString(string: "\(number).", attributes: [
|
|
||||||
.font: theme.fonts.body,
|
|
||||||
.originalFont: theme.fonts.body,
|
|
||||||
.foregroundColor: UIColor.label,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
func setItems(_ items: RawListItem) {
|
|
||||||
dirty = true
|
|
||||||
childrenGroup.setChildren(nodes: items.children)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLayoutWidth(_ width: CGFloat) {
|
|
||||||
guard size.width != width else { return }
|
|
||||||
assert(width >= 0)
|
|
||||||
dirty = true
|
|
||||||
size.width = width
|
|
||||||
childrenGroup.setLayoutWidth(childrenGroupWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLayoutTheme(_ theme: Theme) {
|
|
||||||
guard self.theme != theme else { return }
|
|
||||||
dirty = true
|
|
||||||
self.theme = theme
|
|
||||||
childrenGroup.setLayoutTheme(theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
func layoutIfNeeded() {
|
|
||||||
guard dirty, size.width > 0 else { return }
|
|
||||||
defer { dirty = false }
|
|
||||||
let iconLayoutGuideRect = iconLayoutGuideRect
|
|
||||||
|
|
||||||
childrenGroup.setLayoutWidth(childrenGroupWidth)
|
|
||||||
childrenGroup.layoutIfNeeded()
|
|
||||||
size.height = max(iconLayoutGuideRect.height, childrenGroup.size.height)
|
|
||||||
iconRect = CGRect(
|
|
||||||
x: 0,
|
|
||||||
y: (iconLayoutGuideRect.height - iconLayoutGuideRect.height) / 2,
|
|
||||||
width: iconWidth,
|
|
||||||
height: iconLayoutGuideRect.height
|
|
||||||
)
|
|
||||||
childrenRect = CGRect(
|
|
||||||
x: iconLayoutGuideRect.maxX,
|
|
||||||
y: 0,
|
|
||||||
width: childrenGroup.size.width,
|
|
||||||
height: childrenGroup.size.height
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func determineViewType() -> BlockView.Type {
|
|
||||||
NumberedItemView.self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-110
@@ -1,110 +0,0 @@
|
|||||||
//
|
|
||||||
// NumberedListView.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import MarkdownParser
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class NumberedListView: BlockView {
|
|
||||||
var manifest: Manifest { _manifest as! Manifest }
|
|
||||||
|
|
||||||
let childrenContainer = UIView()
|
|
||||||
var childrenViews: [BlockView] = []
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
addSubview(childrenContainer)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayout() {
|
|
||||||
super.viewDidLayout()
|
|
||||||
childrenContainer.frame = bounds
|
|
||||||
childrenViews.first?.frame = manifest.childrenGroupRect
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidUpdate() {
|
|
||||||
super.viewDidUpdate()
|
|
||||||
childrenContainer.diffableUpdate(
|
|
||||||
reusingViews: &childrenViews,
|
|
||||||
manifests: [manifest.childrenGroup]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func accept(_ manifest: AnyBlockManifest) -> Bool {
|
|
||||||
manifest is Manifest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension NumberedListView {
|
|
||||||
class Manifest: BlockManifest {
|
|
||||||
var size: CGSize = .zero
|
|
||||||
var theme: Theme = .default
|
|
||||||
var dirty: Bool = true
|
|
||||||
|
|
||||||
var intrinsicWidth: CGFloat {
|
|
||||||
childrenGroup.intrinsicWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
required init() {
|
|
||||||
childrenGroup.overrideGroupSpacing = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
let childrenGroup: GroupBlockView.Manifest = .init()
|
|
||||||
var childrenGroupRect: CGRect = .zero
|
|
||||||
|
|
||||||
var block: BlockNode? = nil
|
|
||||||
func load(block: BlockNode) {
|
|
||||||
guard self.block != block else { return }
|
|
||||||
dirty = true
|
|
||||||
self.block = block
|
|
||||||
guard case let .numberedList(_, start, items) = block else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var number = start
|
|
||||||
childrenGroup.setChildren(manifests: items.map { listItem in
|
|
||||||
defer { number += 1 }
|
|
||||||
let manifest = NumberedItemView.Manifest()
|
|
||||||
manifest.setNumber(number)
|
|
||||||
manifest.setItems(listItem)
|
|
||||||
return manifest
|
|
||||||
})
|
|
||||||
childrenGroup.setLayoutWidth(size.width)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLayoutWidth(_ width: CGFloat) {
|
|
||||||
guard size.width != width else { return }
|
|
||||||
assert(width >= 0)
|
|
||||||
dirty = true
|
|
||||||
size.width = width
|
|
||||||
childrenGroup.setLayoutWidth(width)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLayoutTheme(_ theme: Theme) {
|
|
||||||
guard self.theme != theme else { return }
|
|
||||||
dirty = true
|
|
||||||
self.theme = theme
|
|
||||||
childrenGroup.setLayoutTheme(theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
func layoutIfNeeded() {
|
|
||||||
guard dirty, size.width > 0 else { return }
|
|
||||||
defer { dirty = false }
|
|
||||||
childrenGroup.layoutIfNeeded()
|
|
||||||
size.height = childrenGroup.size.height
|
|
||||||
childrenGroupRect = CGRect(
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: size.width,
|
|
||||||
height: size.height
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func determineViewType() -> BlockView.Type {
|
|
||||||
NumberedListView.self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-74
@@ -1,74 +0,0 @@
|
|||||||
//
|
|
||||||
// ParagraphView.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import MarkdownParser
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class ParagraphView: BlockView {
|
|
||||||
let text = TextLabel()
|
|
||||||
var manifest: Manifest { _manifest as! Manifest }
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
addSubview(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayout() {
|
|
||||||
super.viewDidLayout()
|
|
||||||
text.frame = manifest.contentRect
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidUpdate() {
|
|
||||||
super.viewDidUpdate()
|
|
||||||
text.attributedText = manifest.content
|
|
||||||
}
|
|
||||||
|
|
||||||
override func accept(_ manifest: AnyBlockManifest) -> Bool {
|
|
||||||
manifest is Manifest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ParagraphView {
|
|
||||||
class Manifest: BlockManifest {
|
|
||||||
var size: CGSize = .zero
|
|
||||||
var theme: Theme = .default
|
|
||||||
var dirty: Bool = true
|
|
||||||
|
|
||||||
var intrinsicWidth: CGFloat {
|
|
||||||
content.measureWidth()
|
|
||||||
}
|
|
||||||
|
|
||||||
var content: NSMutableAttributedString = .init()
|
|
||||||
var contentRect: CGRect = .zero
|
|
||||||
|
|
||||||
required init() {}
|
|
||||||
|
|
||||||
var block: BlockNode? = nil
|
|
||||||
func load(block: BlockNode) {
|
|
||||||
guard self.block != block else { return }
|
|
||||||
dirty = true
|
|
||||||
self.block = block
|
|
||||||
guard case let .paragraph(contents) = block else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
content = contents.render(theme: theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
func layoutIfNeeded() {
|
|
||||||
guard dirty, size.width > 0 else { return }
|
|
||||||
defer { dirty = false }
|
|
||||||
let textHeight = content.measureHeight(usingWidth: size.width)
|
|
||||||
contentRect = .init(x: 0, y: 0, width: size.width, height: textHeight)
|
|
||||||
size.height = textHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
func determineViewType() -> BlockView.Type {
|
|
||||||
ParagraphView.self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-55
@@ -1,55 +0,0 @@
|
|||||||
//
|
|
||||||
// BlockManifest.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import MarkdownParser
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public typealias AnyBlockManifest = any BlockManifest
|
|
||||||
|
|
||||||
public protocol BlockManifest: AnyObject {
|
|
||||||
var size: CGSize { get set }
|
|
||||||
var theme: Theme { get set }
|
|
||||||
var dirty: Bool { get set }
|
|
||||||
|
|
||||||
var intrinsicWidth: CGFloat { get }
|
|
||||||
|
|
||||||
init()
|
|
||||||
|
|
||||||
func setLayoutWidth(_ width: CGFloat)
|
|
||||||
func setLayoutTheme(_ theme: Theme)
|
|
||||||
func load(block: BlockNode)
|
|
||||||
func layoutIfNeeded()
|
|
||||||
|
|
||||||
@inline(__always) func determineViewType() -> BlockView.Type
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension BlockManifest {
|
|
||||||
func setLayoutWidth(_ width: CGFloat) {
|
|
||||||
guard size.width != width else { return }
|
|
||||||
assert(width >= 0)
|
|
||||||
size.width = width
|
|
||||||
size.height = .zero
|
|
||||||
dirty = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLayoutTheme(_ theme: Theme) {
|
|
||||||
guard self.theme != theme else { return }
|
|
||||||
self.theme = theme
|
|
||||||
dirty = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func layoutIfNeeded() {
|
|
||||||
dirty = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension BlockManifest {
|
|
||||||
var fonts: Theme.Fonts { theme.fonts }
|
|
||||||
var colors: Theme.Colors { theme.colors }
|
|
||||||
var spacings: Theme.Spacings { theme.spacings }
|
|
||||||
var sizes: Theme.Sizes { theme.sizes }
|
|
||||||
}
|
|
||||||
-49
@@ -1,49 +0,0 @@
|
|||||||
//
|
|
||||||
// BlockView+DiffableUpdate.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension UIView {
|
|
||||||
func diffableUpdate(reusingViews blockViews: inout [BlockView], manifests: [AnyBlockManifest]) {
|
|
||||||
defer {
|
|
||||||
assert(blockViews.count == manifests.count, "blockViews count must match manifests count")
|
|
||||||
}
|
|
||||||
var shouldRemovedIndices: [Int] = []
|
|
||||||
defer {
|
|
||||||
for index in shouldRemovedIndices.sorted(by: >) {
|
|
||||||
blockViews.remove(at: index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for idx in 0 ..< max(blockViews.count, manifests.count) {
|
|
||||||
guard let manifest = manifests[safe: idx] else {
|
|
||||||
if let view = blockViews[safe: idx] {
|
|
||||||
view.removeFromSuperview()
|
|
||||||
shouldRemovedIndices.append(idx)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lazy var view = {
|
|
||||||
let view = manifest.determineViewType().init(manifest: manifest)
|
|
||||||
addSubview(view)
|
|
||||||
return view
|
|
||||||
}()
|
|
||||||
if let currentView = blockViews[safe: idx] {
|
|
||||||
if currentView.accept(manifest) {
|
|
||||||
currentView.set(manifest)
|
|
||||||
continue
|
|
||||||
} else {
|
|
||||||
currentView.removeFromSuperview()
|
|
||||||
blockViews[idx] = view
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
addSubview(view)
|
|
||||||
blockViews.insert(view, at: idx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-84
@@ -1,84 +0,0 @@
|
|||||||
//
|
|
||||||
// BlockView.swift
|
|
||||||
// FlowMarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import MarkdownParser
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public class BlockView: UIView {
|
|
||||||
private(set) var _manifest: AnyBlockManifest
|
|
||||||
required init(manifest: AnyBlockManifest) {
|
|
||||||
_manifest = manifest
|
|
||||||
super.init(frame: .zero)
|
|
||||||
backgroundColor = .clear
|
|
||||||
|
|
||||||
// NSLog("[*] \(type(of: self)) was initialized at \(Date()) \(debugDescription)")
|
|
||||||
|
|
||||||
viewDidLoad()
|
|
||||||
viewDidUpdate()
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override public func action(for _: CALayer, forKey _: String) -> (any CAAction)? {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
|
|
||||||
override public func layoutSubviews() {
|
|
||||||
super.layoutSubviews()
|
|
||||||
assert(_manifest.size.width >= 0, "\(type(of: self))'s manifest has invalid size")
|
|
||||||
assert(_manifest.size.height >= 0, "\(type(of: self))'s manifest has invalid size")
|
|
||||||
viewDidLayout()
|
|
||||||
}
|
|
||||||
|
|
||||||
func viewDidLoad() {
|
|
||||||
assert(Thread.isMainThread)
|
|
||||||
}
|
|
||||||
|
|
||||||
func viewDidUpdate() {
|
|
||||||
assert(Thread.isMainThread)
|
|
||||||
}
|
|
||||||
|
|
||||||
func viewDidLayout() {
|
|
||||||
assert(Thread.isMainThread)
|
|
||||||
}
|
|
||||||
|
|
||||||
func accept(_ manifest: AnyBlockManifest) -> Bool {
|
|
||||||
manifest is Manifest
|
|
||||||
}
|
|
||||||
|
|
||||||
func set(_ manifest: AnyBlockManifest) {
|
|
||||||
_manifest = manifest
|
|
||||||
viewDidUpdate()
|
|
||||||
setNeedsLayout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension BlockView {
|
|
||||||
class Manifest: BlockManifest {
|
|
||||||
public var size: CGSize = .zero
|
|
||||||
public var theme: Theme = .default
|
|
||||||
public var dirty: Bool = true
|
|
||||||
|
|
||||||
public var intrinsicWidth: CGFloat { 0 }
|
|
||||||
|
|
||||||
public required init() {}
|
|
||||||
|
|
||||||
public func load(block _: BlockNode) {}
|
|
||||||
|
|
||||||
public func layoutIfNeeded() {
|
|
||||||
dirty = false
|
|
||||||
}
|
|
||||||
|
|
||||||
public func determineViewType() -> BlockView.Type {
|
|
||||||
BlockView.self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-205
@@ -1,205 +0,0 @@
|
|||||||
//
|
|
||||||
// TableView.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import MarkdownParser
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class TableView: BlockView {
|
|
||||||
var manifest: Manifest { _manifest as! Manifest }
|
|
||||||
var childrenViews: [TextLabel] = []
|
|
||||||
let scrollView = UIScrollView()
|
|
||||||
let gridView = GridView()
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
scrollView.isScrollEnabled = true
|
|
||||||
scrollView.contentInset = .zero
|
|
||||||
scrollView.showsVerticalScrollIndicator = false
|
|
||||||
scrollView.showsHorizontalScrollIndicator = false
|
|
||||||
scrollView.alwaysBounceVertical = false
|
|
||||||
scrollView.alwaysBounceHorizontal = false
|
|
||||||
scrollView.clipsToBounds = true
|
|
||||||
scrollView.backgroundColor = .clear
|
|
||||||
addSubview(scrollView)
|
|
||||||
scrollView.addSubview(gridView)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayout() {
|
|
||||||
super.viewDidLayout()
|
|
||||||
scrollView.frame = bounds
|
|
||||||
|
|
||||||
let flatCells = manifest.cells.flatMap(\.self)
|
|
||||||
for (index, view) in childrenViews.enumerated() {
|
|
||||||
view.frame = flatCells[index].contentRect
|
|
||||||
}
|
|
||||||
gridView.frame = .init(
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: scrollView.contentSize.width,
|
|
||||||
height: scrollView.contentSize.height
|
|
||||||
)
|
|
||||||
|
|
||||||
scrollView.contentSize = manifest.contentSize
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidUpdate() {
|
|
||||||
super.viewDidUpdate()
|
|
||||||
let currentCells = childrenViews
|
|
||||||
let targetCells = manifest.cells.flatMap(\.self)
|
|
||||||
for idx in 0 ..< max(currentCells.count, targetCells.count) {
|
|
||||||
if let target = targetCells[safe: idx] {
|
|
||||||
if let current = currentCells[safe: idx] {
|
|
||||||
current.attributedText = target.content
|
|
||||||
current.frame = target.rect
|
|
||||||
} else {
|
|
||||||
let view = TextLabel(frame: target.rect)
|
|
||||||
view.attributedText = target.content
|
|
||||||
scrollView.addSubview(view)
|
|
||||||
childrenViews.append(view)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
currentCells[safe: idx]?.removeFromSuperview()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
gridView.lines = manifest.drawLine.map { start, end in
|
|
||||||
.init(start: start, end: end)
|
|
||||||
}
|
|
||||||
scrollView.contentSize = manifest.contentSize
|
|
||||||
}
|
|
||||||
|
|
||||||
override func accept(_ manifest: AnyBlockManifest) -> Bool {
|
|
||||||
manifest is Manifest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TableView {
|
|
||||||
class Manifest: BlockManifest {
|
|
||||||
var size: CGSize = .zero
|
|
||||||
var theme: Theme = .default
|
|
||||||
var dirty: Bool = true
|
|
||||||
|
|
||||||
var intrinsicWidth: CGFloat {
|
|
||||||
contentSize.width
|
|
||||||
}
|
|
||||||
|
|
||||||
var cells: [[Cell]] = []
|
|
||||||
var drawLine: [(CGPoint, CGPoint)] = []
|
|
||||||
|
|
||||||
var contentSize: CGSize {
|
|
||||||
let rect = cells.last?.last?.rect ?? .zero
|
|
||||||
return .init(width: rect.maxX, height: rect.maxY)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init() {}
|
|
||||||
|
|
||||||
var block: BlockNode? = nil
|
|
||||||
func load(block: BlockNode) {
|
|
||||||
guard self.block != block else { return }
|
|
||||||
dirty = true
|
|
||||||
self.block = block
|
|
||||||
guard case let .table(_, rawCells) = block else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cells = rawCells.map { row in
|
|
||||||
row.cells.map { cell in
|
|
||||||
let content = cell.content.render(theme: theme)
|
|
||||||
return Cell(content: content, rect: .zero)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func layoutIfNeeded() {
|
|
||||||
guard dirty, size.width > 0 else { return }
|
|
||||||
defer { dirty = false }
|
|
||||||
let cols = cells.first?.count ?? 0
|
|
||||||
let rows = cells.count
|
|
||||||
drawLine.removeAll()
|
|
||||||
|
|
||||||
guard rows > 0, cols > 0 else {
|
|
||||||
size.height = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// first pass calculate intrinsic width of each column to get the width
|
|
||||||
var colWidths: [Int: CGFloat] = [:]
|
|
||||||
var rowHeights: [Int: CGFloat] = [:]
|
|
||||||
for col in 0 ..< cols {
|
|
||||||
for row in 0 ..< rows {
|
|
||||||
let cell = cells[row][col]
|
|
||||||
colWidths[col] = max(colWidths[col] ?? 0, cell.intrinsicSize.width + 32)
|
|
||||||
rowHeights[row] = max(rowHeights[row] ?? 0, cell.intrinsicSize.height + 16)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// now calculate the rects and points
|
|
||||||
var anchorY: CGFloat = 0
|
|
||||||
var linePoints: [CGFloat] = [0]
|
|
||||||
var colPoints: [CGFloat] = [0]
|
|
||||||
for rowIdx in 0 ..< rows {
|
|
||||||
var anchorX: CGFloat = 0
|
|
||||||
let rowHeight = rowHeights[rowIdx] ?? 0
|
|
||||||
linePoints.append(anchorY)
|
|
||||||
for colIdx in 0 ..< cols { // column
|
|
||||||
let colWidth = colWidths[colIdx] ?? 0
|
|
||||||
if rowIdx == 0 { colPoints.append(anchorX) }
|
|
||||||
let rect = CGRect(x: anchorX, y: anchorY, width: colWidth, height: rowHeight)
|
|
||||||
cells[rowIdx][colIdx].rect = rect
|
|
||||||
anchorX = rect.maxX
|
|
||||||
}
|
|
||||||
colPoints.append(anchorX + spacings.general)
|
|
||||||
anchorY += rowHeight
|
|
||||||
}
|
|
||||||
linePoints.append(anchorY)
|
|
||||||
|
|
||||||
for x in colPoints {
|
|
||||||
drawLine.append((CGPoint(x: x, y: 0), CGPoint(x: x, y: linePoints.last ?? 0)))
|
|
||||||
}
|
|
||||||
|
|
||||||
for y in linePoints {
|
|
||||||
drawLine.append((CGPoint(x: 0, y: y), CGPoint(x: colPoints.last ?? 0, y: y)))
|
|
||||||
}
|
|
||||||
|
|
||||||
size.height = contentSize.height
|
|
||||||
}
|
|
||||||
|
|
||||||
func determineViewType() -> BlockView.Type {
|
|
||||||
TableView.self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TableView.Manifest {
|
|
||||||
class Cell {
|
|
||||||
let content: NSMutableAttributedString
|
|
||||||
var rect: CGRect { didSet { updateContentRect() } }
|
|
||||||
let intrinsicSize: CGSize
|
|
||||||
var contentRect: CGRect
|
|
||||||
init(
|
|
||||||
content: NSMutableAttributedString,
|
|
||||||
rect: CGRect = .zero
|
|
||||||
) {
|
|
||||||
self.content = content
|
|
||||||
self.rect = rect
|
|
||||||
intrinsicSize = .init(
|
|
||||||
width: content.measureWidth(),
|
|
||||||
height: content.measureHeight(usingWidth: .greatestFiniteMagnitude)
|
|
||||||
)
|
|
||||||
contentRect = .zero
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateContentRect() {
|
|
||||||
contentRect = .init(
|
|
||||||
x: rect.minX + (rect.width - intrinsicSize.width) / 2,
|
|
||||||
y: rect.minY + (rect.height - intrinsicSize.height) / 2,
|
|
||||||
width: intrinsicSize.width,
|
|
||||||
height: intrinsicSize.height
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-119
@@ -1,119 +0,0 @@
|
|||||||
//
|
|
||||||
// TaskItemView.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import MarkdownParser
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class TaskItemView: BlockView {
|
|
||||||
let bulletedIcon = CircleView()
|
|
||||||
var manifest: Manifest { _manifest as! Manifest }
|
|
||||||
|
|
||||||
let childrenContainer = UIView()
|
|
||||||
var childrenViews: [BlockView] = []
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
addSubview(bulletedIcon)
|
|
||||||
addSubview(childrenContainer)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayout() {
|
|
||||||
super.viewDidLayout()
|
|
||||||
childrenContainer.frame = bounds
|
|
||||||
bulletedIcon.frame = manifest.iconRect
|
|
||||||
childrenViews.first?.frame = manifest.childrenRect
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidUpdate() {
|
|
||||||
super.viewDidUpdate()
|
|
||||||
childrenContainer.diffableUpdate(
|
|
||||||
reusingViews: &childrenViews,
|
|
||||||
manifests: [manifest.childrenGroup]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func accept(_ manifest: AnyBlockManifest) -> Bool {
|
|
||||||
manifest is Manifest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TaskItemView {
|
|
||||||
class Manifest: BlockManifest {
|
|
||||||
var size: CGSize = .zero
|
|
||||||
var theme: Theme = .default
|
|
||||||
var dirty: Bool = true
|
|
||||||
|
|
||||||
var intrinsicWidth: CGFloat {
|
|
||||||
childrenGroup.intrinsicWidth + iconLayoutGuideRect.width
|
|
||||||
}
|
|
||||||
|
|
||||||
var iconLayoutGuideRect: CGRect {
|
|
||||||
.init(x: 0, y: 0, width: sizes.bullet + spacings.list, height: fonts.body.baseLineHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
var iconRect: CGRect = .zero
|
|
||||||
let childrenGroup: GroupBlockView.Manifest = .init()
|
|
||||||
var childrenRect: CGRect = .zero
|
|
||||||
|
|
||||||
var childrenGroupWidth: CGFloat {
|
|
||||||
max(0, size.width - iconLayoutGuideRect.width)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init() {
|
|
||||||
childrenGroup.overrideGroupSpacing = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func load(block _: BlockNode) {
|
|
||||||
assertionFailure("should not be called")
|
|
||||||
}
|
|
||||||
|
|
||||||
func setItems(_ items: RawTaskListItem) {
|
|
||||||
dirty = true
|
|
||||||
childrenGroup.setChildren(nodes: items.children)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLayoutWidth(_ width: CGFloat) {
|
|
||||||
guard size.width != width else { return }
|
|
||||||
assert(width >= 0)
|
|
||||||
dirty = true
|
|
||||||
size.width = width
|
|
||||||
childrenGroup.setLayoutWidth(childrenGroupWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLayoutTheme(_ theme: Theme) {
|
|
||||||
guard self.theme != theme else { return }
|
|
||||||
dirty = true
|
|
||||||
self.theme = theme
|
|
||||||
childrenGroup.setLayoutTheme(theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
func layoutIfNeeded() {
|
|
||||||
guard dirty, size.width > 0 else { return }
|
|
||||||
defer { dirty = false }
|
|
||||||
let iconLayoutGuideRect = iconLayoutGuideRect
|
|
||||||
|
|
||||||
childrenGroup.layoutIfNeeded()
|
|
||||||
size.height = max(iconLayoutGuideRect.height, childrenGroup.size.height)
|
|
||||||
iconRect = CGRect(
|
|
||||||
x: 0,
|
|
||||||
y: (iconLayoutGuideRect.height - sizes.bullet) / 2,
|
|
||||||
width: sizes.bullet,
|
|
||||||
height: sizes.bullet
|
|
||||||
)
|
|
||||||
childrenRect = CGRect(
|
|
||||||
x: iconLayoutGuideRect.maxX,
|
|
||||||
y: 0,
|
|
||||||
width: childrenGroup.size.width,
|
|
||||||
height: childrenGroup.size.height
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func determineViewType() -> BlockView.Type {
|
|
||||||
TaskItemView.self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-107
@@ -1,107 +0,0 @@
|
|||||||
//
|
|
||||||
// TaskListView.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import MarkdownParser
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class TaskListView: BlockView {
|
|
||||||
var manifest: Manifest { _manifest as! Manifest }
|
|
||||||
|
|
||||||
let childrenContainer = UIView()
|
|
||||||
var childrenViews: [BlockView] = []
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
addSubview(childrenContainer)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayout() {
|
|
||||||
super.viewDidLayout()
|
|
||||||
childrenContainer.frame = bounds
|
|
||||||
childrenViews.first?.frame = manifest.childrenGroupRect
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidUpdate() {
|
|
||||||
super.viewDidUpdate()
|
|
||||||
childrenContainer.diffableUpdate(
|
|
||||||
reusingViews: &childrenViews,
|
|
||||||
manifests: [manifest.childrenGroup]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func accept(_ manifest: AnyBlockManifest) -> Bool {
|
|
||||||
manifest is Manifest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TaskListView {
|
|
||||||
class Manifest: BlockManifest {
|
|
||||||
var size: CGSize = .zero
|
|
||||||
var theme: Theme = .default
|
|
||||||
var dirty: Bool = true
|
|
||||||
|
|
||||||
var intrinsicWidth: CGFloat {
|
|
||||||
childrenGroup.intrinsicWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
required init() {
|
|
||||||
childrenGroup.overrideGroupSpacing = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
let childrenGroup: GroupBlockView.Manifest = .init()
|
|
||||||
var childrenGroupRect: CGRect = .zero
|
|
||||||
|
|
||||||
var block: BlockNode? = nil
|
|
||||||
func load(block: BlockNode) {
|
|
||||||
guard self.block != block else { return }
|
|
||||||
dirty = true
|
|
||||||
self.block = block
|
|
||||||
guard case let .taskList(_, items) = block else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
childrenGroup.setChildren(manifests: items.map { listItem in
|
|
||||||
let manifest = TaskItemView.Manifest()
|
|
||||||
manifest.setItems(listItem)
|
|
||||||
return manifest
|
|
||||||
})
|
|
||||||
childrenGroup.setLayoutWidth(size.width)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLayoutWidth(_ width: CGFloat) {
|
|
||||||
guard size.width != width else { return }
|
|
||||||
assert(width >= 0)
|
|
||||||
dirty = true
|
|
||||||
size.width = width
|
|
||||||
childrenGroup.setLayoutWidth(width)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLayoutTheme(_ theme: Theme) {
|
|
||||||
guard self.theme != theme else { return }
|
|
||||||
dirty = true
|
|
||||||
self.theme = theme
|
|
||||||
childrenGroup.setLayoutTheme(theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
func layoutIfNeeded() {
|
|
||||||
guard dirty, size.width > 0 else { return }
|
|
||||||
defer { dirty = false }
|
|
||||||
childrenGroup.layoutIfNeeded()
|
|
||||||
size.height = childrenGroup.size.height
|
|
||||||
childrenGroupRect = CGRect(
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: size.width,
|
|
||||||
height: size.height
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func determineViewType() -> BlockView.Type {
|
|
||||||
TaskListView.self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-63
@@ -1,63 +0,0 @@
|
|||||||
//
|
|
||||||
// ThematicBreakView.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import MarkdownParser
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class ThematicBreakView: BlockView {
|
|
||||||
let separateView = UIView()
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
addSubview(separateView)
|
|
||||||
separateView.backgroundColor = .label.withAlphaComponent(0.1)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayout() {
|
|
||||||
super.viewDidLayout()
|
|
||||||
separateView.frame = bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
override func accept(_ manifest: AnyBlockManifest) -> Bool {
|
|
||||||
manifest is Manifest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ThematicBreakView {
|
|
||||||
class Manifest: BlockManifest {
|
|
||||||
var size: CGSize = .zero
|
|
||||||
var theme: Theme = .default
|
|
||||||
var dirty: Bool = true
|
|
||||||
|
|
||||||
var intrinsicWidth: CGFloat {
|
|
||||||
32
|
|
||||||
}
|
|
||||||
|
|
||||||
required init() {}
|
|
||||||
|
|
||||||
var block: BlockNode? = nil
|
|
||||||
func load(block: BlockNode) {
|
|
||||||
guard self.block != block else { return }
|
|
||||||
dirty = true
|
|
||||||
self.block = block
|
|
||||||
guard case .thematicBreak = block else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func layoutIfNeeded() {
|
|
||||||
guard dirty, size.width > 0 else { return }
|
|
||||||
defer { dirty = false }
|
|
||||||
size.height = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func determineViewType() -> BlockView.Type {
|
|
||||||
ThematicBreakView.self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-45
@@ -1,45 +0,0 @@
|
|||||||
// The Swift Programming Language
|
|
||||||
// https://docs.swift.org/swift-book
|
|
||||||
|
|
||||||
import MarkdownParser
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public class MarkdownView: UIView {
|
|
||||||
public var height: CGFloat = 0
|
|
||||||
|
|
||||||
var blockViews: [BlockView] = []
|
|
||||||
|
|
||||||
public var theme: Theme
|
|
||||||
public init(theme: Theme = .default) {
|
|
||||||
self.theme = theme
|
|
||||||
super.init(frame: .zero)
|
|
||||||
clipsToBounds = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
public required init?(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func prepareForReuse() {
|
|
||||||
blockViews.forEach { $0.removeFromSuperview() }
|
|
||||||
blockViews.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func updateContentViews(_ manifest: [AnyBlockManifest]) {
|
|
||||||
assert(Thread.isMainThread)
|
|
||||||
diffableUpdate(reusingViews: &blockViews, manifests: manifest)
|
|
||||||
var anchorY: CGFloat = 0
|
|
||||||
for view in blockViews {
|
|
||||||
view.frame = CGRect(
|
|
||||||
x: 0,
|
|
||||||
y: anchorY,
|
|
||||||
width: view._manifest.size.width,
|
|
||||||
height: view._manifest.size.height
|
|
||||||
)
|
|
||||||
anchorY = view.frame.maxY + theme.spacings.final
|
|
||||||
}
|
|
||||||
height = blockViews.map(\.frame.maxY).max() ?? 0
|
|
||||||
assert(subviews.count == blockViews.count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
//
|
|
||||||
// CircleView.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class CircleView: UIView {
|
|
||||||
init() {
|
|
||||||
super.init(frame: .zero)
|
|
||||||
backgroundColor = .label
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews() {
|
|
||||||
super.layoutSubviews()
|
|
||||||
layer.cornerRadius = (frame.height + frame.width) / 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-51
@@ -1,51 +0,0 @@
|
|||||||
//
|
|
||||||
// Ext+Array.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension Array {
|
|
||||||
subscript(safe index: Int) -> Element? {
|
|
||||||
guard index >= 0, index < count else { return nil }
|
|
||||||
return self[index]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private let maxConcurrentDefaultValue: Int = max(1, ProcessInfo.processInfo.processorCount)
|
|
||||||
|
|
||||||
extension Array {
|
|
||||||
@inline(__always)
|
|
||||||
func splitInSubArrays(into size: Int) -> [[Element]] {
|
|
||||||
(0 ..< size).map {
|
|
||||||
stride(from: $0, to: count, by: size).map { self[$0] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func forParallelEach(
|
|
||||||
maxConcurrent: Int = maxConcurrentDefaultValue,
|
|
||||||
block: @escaping (Element) -> Void
|
|
||||||
) {
|
|
||||||
assert(maxConcurrent > 0)
|
|
||||||
if count < maxConcurrent || maxConcurrent <= 1 {
|
|
||||||
for element in self {
|
|
||||||
block(element)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let cuts = splitInSubArrays(into: maxConcurrent)
|
|
||||||
|
|
||||||
let group = DispatchGroup()
|
|
||||||
for cut in cuts {
|
|
||||||
group.enter()
|
|
||||||
DispatchQueue.global().async {
|
|
||||||
cut.forEach(block)
|
|
||||||
group.leave()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
group.wait()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-43
@@ -1,43 +0,0 @@
|
|||||||
//
|
|
||||||
// Ext+BlockNode.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import MarkdownParser
|
|
||||||
|
|
||||||
public extension BlockNode {
|
|
||||||
var manifestType: BlockManifest.Type {
|
|
||||||
switch self {
|
|
||||||
case .blockquote:
|
|
||||||
BlockquoteView.Manifest.self
|
|
||||||
case .bulletedList:
|
|
||||||
BulletedListView.Manifest.self
|
|
||||||
case .numberedList:
|
|
||||||
NumberedListView.Manifest.self
|
|
||||||
case .taskList:
|
|
||||||
TaskListView.Manifest.self
|
|
||||||
case .codeBlock:
|
|
||||||
CodeBlockView.Manifest.self
|
|
||||||
// case .htmlBlock:
|
|
||||||
// HTMLBlockView.Manifest.self
|
|
||||||
case .paragraph:
|
|
||||||
ParagraphView.Manifest.self
|
|
||||||
case .heading:
|
|
||||||
HeadingView.Manifest.self
|
|
||||||
case .table:
|
|
||||||
TableView.Manifest.self
|
|
||||||
case .thematicBreak:
|
|
||||||
ThematicBreakView.Manifest.self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func manifest(theme: Theme) -> AnyBlockManifest {
|
|
||||||
let object = manifestType.init()
|
|
||||||
object.setLayoutTheme(theme)
|
|
||||||
object.load(block: self)
|
|
||||||
return object
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
//
|
|
||||||
// Ext+DispatchQueue.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public extension DispatchQueue {
|
|
||||||
static func isCurrent(_ queue: DispatchQueue) -> Bool {
|
|
||||||
let key = DispatchSpecificKey<Void>()
|
|
||||||
queue.setSpecific(key: key, value: ())
|
|
||||||
defer { queue.setSpecific(key: key, value: nil) }
|
|
||||||
return DispatchQueue.getSpecific(key: key) != nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-121
@@ -1,121 +0,0 @@
|
|||||||
//
|
|
||||||
// Ext+InlineNode.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import MarkdownParser
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension [InlineNode] {
|
|
||||||
func render(theme: Theme) -> NSMutableAttributedString {
|
|
||||||
let result = NSMutableAttributedString()
|
|
||||||
for node in self {
|
|
||||||
result.append(node.render(theme: theme))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension InlineNode {
|
|
||||||
func render(theme: Theme) -> NSAttributedString {
|
|
||||||
switch self {
|
|
||||||
case let .text(string):
|
|
||||||
return NSAttributedString(
|
|
||||||
string: string,
|
|
||||||
attributes: [
|
|
||||||
.font: theme.fonts.body,
|
|
||||||
.foregroundColor: theme.colors.body,
|
|
||||||
.originalFont: theme.fonts.body,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
case .softBreak:
|
|
||||||
return NSAttributedString(string: " ", attributes: [
|
|
||||||
.font: theme.fonts.body,
|
|
||||||
.foregroundColor: theme.colors.body,
|
|
||||||
.originalFont: theme.fonts.body,
|
|
||||||
])
|
|
||||||
case .lineBreak:
|
|
||||||
return NSAttributedString(string: "\n", attributes: [
|
|
||||||
.font: theme.fonts.body,
|
|
||||||
.foregroundColor: theme.colors.body,
|
|
||||||
.originalFont: theme.fonts.body,
|
|
||||||
])
|
|
||||||
case let .code(string):
|
|
||||||
return NSAttributedString(
|
|
||||||
string: "\(string)",
|
|
||||||
attributes: [
|
|
||||||
.font: theme.fonts.codeInline,
|
|
||||||
.originalFont: theme.fonts.codeInline,
|
|
||||||
.foregroundColor: theme.colors.code,
|
|
||||||
.backgroundColor: theme.colors.codeBackground.withAlphaComponent(0.05),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
case let .html(content):
|
|
||||||
return NSAttributedString(
|
|
||||||
string: "\(content)",
|
|
||||||
attributes: [
|
|
||||||
.font: theme.fonts.codeInline,
|
|
||||||
.originalFont: theme.fonts.codeInline,
|
|
||||||
.foregroundColor: theme.colors.code,
|
|
||||||
.backgroundColor: theme.colors.codeBackground.withAlphaComponent(0.05),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
// let htmlData = NSString(string: content).data(using: String.Encoding.unicode.rawValue)
|
|
||||||
// let options = [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.html]
|
|
||||||
// let ans = try? NSMutableAttributedString(
|
|
||||||
// data: htmlData ?? Data(),
|
|
||||||
// options: options,
|
|
||||||
// documentAttributes: nil
|
|
||||||
// )
|
|
||||||
// return ans ?? .init()
|
|
||||||
case let .emphasis(children):
|
|
||||||
let ans = NSMutableAttributedString()
|
|
||||||
children.map { $0.render(theme: theme) }.forEach { ans.append($0) }
|
|
||||||
ans.addAttributes(
|
|
||||||
[
|
|
||||||
.underlineStyle: NSUnderlineStyle.thick.rawValue,
|
|
||||||
.underlineColor: theme.colors.emphasis,
|
|
||||||
],
|
|
||||||
range: NSRange(location: 0, length: ans.length)
|
|
||||||
)
|
|
||||||
return ans
|
|
||||||
case let .strong(children):
|
|
||||||
let ans = NSMutableAttributedString()
|
|
||||||
children.map { $0.render(theme: theme) }.forEach { ans.append($0) }
|
|
||||||
ans.addAttributes(
|
|
||||||
[.font: theme.fonts.bold],
|
|
||||||
range: NSRange(location: 0, length: ans.length)
|
|
||||||
)
|
|
||||||
return ans
|
|
||||||
case let .strikethrough(children):
|
|
||||||
let ans = NSMutableAttributedString()
|
|
||||||
children.map { $0.render(theme: theme) }.forEach { ans.append($0) }
|
|
||||||
ans.addAttributes(
|
|
||||||
[.strikethroughStyle: NSUnderlineStyle.thick.rawValue],
|
|
||||||
range: NSRange(location: 0, length: ans.length)
|
|
||||||
)
|
|
||||||
return ans
|
|
||||||
case let .link(destination, children):
|
|
||||||
let ans = NSMutableAttributedString()
|
|
||||||
children.map { $0.render(theme: theme) }.forEach { ans.append($0) }
|
|
||||||
ans.addAttributes(
|
|
||||||
[.link: destination],
|
|
||||||
range: NSRange(location: 0, length: ans.length)
|
|
||||||
)
|
|
||||||
return ans
|
|
||||||
case let .image(source, _): // children => alternative text can be ignored?
|
|
||||||
return NSAttributedString(
|
|
||||||
string: source,
|
|
||||||
attributes: [
|
|
||||||
.link: source,
|
|
||||||
.font: theme.fonts.body,
|
|
||||||
.originalFont: theme.fonts.body,
|
|
||||||
.foregroundColor: theme.colors.body,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-83
@@ -1,83 +0,0 @@
|
|||||||
//
|
|
||||||
// Ext+NSAttributedString.swift
|
|
||||||
// FlowDown
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/2.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class TextMeasurementHelper {
|
|
||||||
static let shared = TextMeasurementHelper()
|
|
||||||
|
|
||||||
private var textStorage: NSTextStorage
|
|
||||||
private var textContainer: NSTextContainer
|
|
||||||
private var layoutManager: NSLayoutManager
|
|
||||||
|
|
||||||
private let lock = NSLock()
|
|
||||||
|
|
||||||
init() {
|
|
||||||
textStorage = NSTextStorage()
|
|
||||||
textContainer = NSTextContainer(size: CGSize(width: CGFloat.infinity, height: CGFloat.infinity))
|
|
||||||
layoutManager = NSLayoutManager()
|
|
||||||
layoutManager.addTextContainer(textContainer)
|
|
||||||
textStorage.addLayoutManager(layoutManager)
|
|
||||||
textContainer.lineFragmentPadding = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func measureSize(
|
|
||||||
of attributedString: NSAttributedString,
|
|
||||||
usingWidth width: CGFloat,
|
|
||||||
lineLimit: Int = 0,
|
|
||||||
lineBreakMode: NSLineBreakMode = .byTruncatingTail
|
|
||||||
) -> CGSize {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
|
|
||||||
textContainer.size = CGSize(width: width, height: .infinity)
|
|
||||||
textContainer.maximumNumberOfLines = lineLimit
|
|
||||||
textContainer.lineBreakMode = lineBreakMode
|
|
||||||
textStorage.beginEditing()
|
|
||||||
textStorage.setAttributedString(attributedString)
|
|
||||||
textStorage.endEditing()
|
|
||||||
|
|
||||||
let size = layoutManager.usedRect(for: textContainer).size
|
|
||||||
return .init(width: ceil(size.width), height: ceil(size.height))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension NSAttributedString: @unchecked @retroactive Sendable {}
|
|
||||||
|
|
||||||
public extension NSAttributedString {
|
|
||||||
func measureWidth() -> CGFloat {
|
|
||||||
if string.trimmingCharacters(in: .whitespacesAndNewlines).count <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return TextMeasurementHelper.shared.measureSize(
|
|
||||||
of: self,
|
|
||||||
usingWidth: .infinity
|
|
||||||
).width
|
|
||||||
}
|
|
||||||
|
|
||||||
func measureHeight(
|
|
||||||
usingWidth width: CGFloat,
|
|
||||||
lineLimit: Int = 0,
|
|
||||||
lineBreakMode: NSLineBreakMode = .byTruncatingTail
|
|
||||||
) -> CGFloat {
|
|
||||||
if string.trimmingCharacters(in: .whitespacesAndNewlines).count <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return TextMeasurementHelper.shared.measureSize(
|
|
||||||
of: self,
|
|
||||||
usingWidth: width,
|
|
||||||
lineLimit: lineLimit,
|
|
||||||
lineBreakMode: lineBreakMode
|
|
||||||
).height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension NSAttributedString.Key {
|
|
||||||
@inline(__always) static let coreTextRunDelegate = NSAttributedString.Key(rawValue: kCTRunDelegateAttributeName as String)
|
|
||||||
@inline(__always) static let originalFont = NSAttributedString.Key(rawValue: "NSOriginalFont")
|
|
||||||
}
|
|
||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
//
|
|
||||||
// Ext+UIColor.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/7.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension UIColor {
|
|
||||||
convenience init(light: UIColor, dark: UIColor) {
|
|
||||||
if #available(iOS 13.0, tvOS 13.0, *) {
|
|
||||||
self.init(dynamicProvider: { $0.userInterfaceStyle == .dark ? dark : light })
|
|
||||||
} else {
|
|
||||||
self.init(cgColor: light.cgColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-29
@@ -1,29 +0,0 @@
|
|||||||
//
|
|
||||||
// Ext+UIFont.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public extension UIFont {
|
|
||||||
var bold: UIFont {
|
|
||||||
UIFont(descriptor: fontDescriptor.withSymbolicTraits(.traitBold)!, size: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
var italic: UIFont {
|
|
||||||
UIFont(descriptor: fontDescriptor.withSymbolicTraits(.traitItalic)!, size: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
var monospaced: UIFont {
|
|
||||||
let settings = [[
|
|
||||||
UIFontDescriptor.FeatureKey.featureIdentifier: kNumberSpacingType,
|
|
||||||
UIFontDescriptor.FeatureKey.typeIdentifier: kMonospacedNumbersSelector,
|
|
||||||
]]
|
|
||||||
|
|
||||||
let attributes = [UIFontDescriptor.AttributeName.featureSettings: settings]
|
|
||||||
let newDescriptor = fontDescriptor.addingAttributes(attributes)
|
|
||||||
return UIFont(descriptor: newDescriptor, size: 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-64
@@ -1,64 +0,0 @@
|
|||||||
//
|
|
||||||
// GridView.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class GridView: UIView {
|
|
||||||
var stokeColor: UIColor = .label.withAlphaComponent(0.25) {
|
|
||||||
didSet {
|
|
||||||
layer.borderWidth = 1
|
|
||||||
layer.borderColor = stokeColor.cgColor
|
|
||||||
setNeedsDisplay()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines: [CGPointPair] = [] {
|
|
||||||
didSet { setNeedsDisplay() }
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
super.init(frame: .zero)
|
|
||||||
backgroundColor = .clear
|
|
||||||
layer.borderWidth = 1
|
|
||||||
layer.borderColor = stokeColor.cgColor
|
|
||||||
layer.contentsGravity = .top
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override var frame: CGRect {
|
|
||||||
set { UIView.performWithoutAnimation { super.frame = newValue } }
|
|
||||||
get { super.frame }
|
|
||||||
}
|
|
||||||
|
|
||||||
override func draw(_ rect: CGRect) {
|
|
||||||
super.draw(rect)
|
|
||||||
|
|
||||||
guard let context = UIGraphicsGetCurrentContext() else { return }
|
|
||||||
|
|
||||||
context.setStrokeColor(stokeColor.cgColor)
|
|
||||||
context.setLineWidth(1)
|
|
||||||
|
|
||||||
for pathPair in lines {
|
|
||||||
let adjustedStart = CGPoint(x: pathPair.start.x, y: pathPair.start.y)
|
|
||||||
let adjustedEnd = CGPoint(x: pathPair.end.x, y: pathPair.end.y)
|
|
||||||
context.move(to: adjustedStart)
|
|
||||||
context.addLine(to: adjustedEnd)
|
|
||||||
}
|
|
||||||
context.strokePath()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension GridView {
|
|
||||||
struct CGPointPair: Equatable {
|
|
||||||
let start: CGPoint
|
|
||||||
let end: CGPoint
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-53
@@ -1,53 +0,0 @@
|
|||||||
//
|
|
||||||
// TextLabel.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/12.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class TextLabel: UITextView {
|
|
||||||
#if DEBUG
|
|
||||||
private var setupCompleted: Bool = false
|
|
||||||
#endif
|
|
||||||
|
|
||||||
override required init(frame: CGRect, textContainer: NSTextContainer?) {
|
|
||||||
super.init(frame: frame, textContainer: textContainer)
|
|
||||||
commitInit()
|
|
||||||
}
|
|
||||||
|
|
||||||
convenience init(frame: CGRect = .zero) {
|
|
||||||
if #available(iOS 16.0, macCatalyst 16.0, *) {
|
|
||||||
self.init(usingTextLayoutManager: false)
|
|
||||||
} else {
|
|
||||||
self.init(frame: frame, textContainer: nil)
|
|
||||||
_ = layoutManager.textContainers
|
|
||||||
}
|
|
||||||
commitInit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func commitInit() {
|
|
||||||
#if DEBUG
|
|
||||||
assert(!setupCompleted)
|
|
||||||
setupCompleted = true
|
|
||||||
#endif
|
|
||||||
showsVerticalScrollIndicator = false
|
|
||||||
showsHorizontalScrollIndicator = false
|
|
||||||
textColor = .label
|
|
||||||
textContainer.lineFragmentPadding = .zero
|
|
||||||
textAlignment = .natural
|
|
||||||
backgroundColor = .clear
|
|
||||||
textContainerInset = .zero
|
|
||||||
textContainer.lineBreakMode = .byTruncatingTail
|
|
||||||
clipsToBounds = false
|
|
||||||
isSelectable = true
|
|
||||||
isScrollEnabled = false
|
|
||||||
isEditable = false
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-121
@@ -1,121 +0,0 @@
|
|||||||
//
|
|
||||||
// Theme.swift
|
|
||||||
// MarkdownView
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2025/1/3.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Splash
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public extension Theme {
|
|
||||||
static var `default`: Theme = .init()
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Theme: Equatable {
|
|
||||||
public struct Fonts: Equatable {
|
|
||||||
public var body = UIFont.preferredFont(forTextStyle: .body)
|
|
||||||
public var codeInline = UIFont.monospacedSystemFont(
|
|
||||||
ofSize: UIFont.preferredFont(forTextStyle: .body).pointSize,
|
|
||||||
weight: .regular
|
|
||||||
)
|
|
||||||
public var bold = UIFont.preferredFont(forTextStyle: .body).bold
|
|
||||||
public var italic = UIFont.preferredFont(forTextStyle: .body).italic
|
|
||||||
public var code = UIFont.monospacedSystemFont(
|
|
||||||
ofSize: UIFont.preferredFont(forTextStyle: .body).pointSize * 0.85,
|
|
||||||
weight: .regular
|
|
||||||
)
|
|
||||||
public var largeTitle = UIFont.preferredFont(forTextStyle: .body).bold
|
|
||||||
public var title = UIFont.preferredFont(forTextStyle: .body).bold
|
|
||||||
}
|
|
||||||
|
|
||||||
public var fonts: Fonts = .init()
|
|
||||||
|
|
||||||
public struct Colors: Equatable {
|
|
||||||
public var body = UIColor.label
|
|
||||||
public var emphasis = UIColor.systemOrange
|
|
||||||
public var code = UIColor.label
|
|
||||||
public var codeBackground = UIColor.gray.withAlphaComponent(0.25)
|
|
||||||
}
|
|
||||||
|
|
||||||
public var colors: Colors = .init()
|
|
||||||
|
|
||||||
public struct Spacings: Equatable {
|
|
||||||
public var final: CGFloat = 16
|
|
||||||
public var general: CGFloat = 8
|
|
||||||
public var list: CGFloat = 12
|
|
||||||
public var cell: CGFloat = 32
|
|
||||||
}
|
|
||||||
|
|
||||||
public var spacings: Spacings = .init()
|
|
||||||
|
|
||||||
public struct Sizes: Equatable {
|
|
||||||
public var bullet: CGFloat = 4
|
|
||||||
}
|
|
||||||
|
|
||||||
public var sizes: Sizes = .init()
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIFont {
|
|
||||||
var baseLineHeight: CGFloat {
|
|
||||||
NSAttributedString(string: "88", attributes: [
|
|
||||||
.font: self,
|
|
||||||
.originalFont: self,
|
|
||||||
]).measureHeight(usingWidth: .greatestFiniteMagnitude)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private let codeThemeTemplate: Splash.Theme = .init(
|
|
||||||
font: .init(size: Double(0)),
|
|
||||||
plainTextColor: .label,
|
|
||||||
tokenColors: [
|
|
||||||
.keyword: Color(
|
|
||||||
light: Color(red: 0.948, green: 0.140, blue: 0.547, alpha: 1),
|
|
||||||
dark: Color(red: 0.948, green: 0.140, blue: 0.547, alpha: 1)
|
|
||||||
),
|
|
||||||
.string: Color(
|
|
||||||
light: Color(red: 0.988, green: 0.273, blue: 0.317, alpha: 1),
|
|
||||||
dark: Color(red: 0.988, green: 0.273, blue: 0.317, alpha: 1)
|
|
||||||
),
|
|
||||||
.type: Color(
|
|
||||||
light: Color(red: 0.384, green: 0.698, blue: 0.161, alpha: 1),
|
|
||||||
dark: Color(red: 0.584, green: 0.898, blue: 0.361, alpha: 1)
|
|
||||||
),
|
|
||||||
.call: Color(
|
|
||||||
light: Color(red: 0.384, green: 0.698, blue: 0.161, alpha: 1),
|
|
||||||
dark: Color(red: 0.584, green: 0.898, blue: 0.361, alpha: 1)
|
|
||||||
),
|
|
||||||
.number: Color(
|
|
||||||
light: Color(red: 0.387, green: 0.317, blue: 0.774, alpha: 1),
|
|
||||||
dark: Color(red: 0.587, green: 0.517, blue: 0.974, alpha: 1)
|
|
||||||
),
|
|
||||||
.comment: Color(
|
|
||||||
light: Color(red: 0.424, green: 0.475, blue: 0.529, alpha: 1),
|
|
||||||
dark: Color(red: 0.424, green: 0.475, blue: 0.529, alpha: 1)
|
|
||||||
),
|
|
||||||
.property: Color(
|
|
||||||
light: Color(red: 0.384, green: 0.698, blue: 0.161, alpha: 1),
|
|
||||||
dark: Color(red: 0.584, green: 0.898, blue: 0.361, alpha: 1)
|
|
||||||
),
|
|
||||||
.dotAccess: Color(
|
|
||||||
light: Color(red: 0.384, green: 0.698, blue: 0.161, alpha: 1),
|
|
||||||
dark: Color(red: 0.584, green: 0.898, blue: 0.361, alpha: 1)
|
|
||||||
),
|
|
||||||
.preprocessing: Color(
|
|
||||||
light: Color(red: 0.752, green: 0.326, blue: 0.12, alpha: 19),
|
|
||||||
dark: Color(red: 0.952, green: 0.526, blue: 0.22, alpha: 19)
|
|
||||||
),
|
|
||||||
],
|
|
||||||
backgroundColor: .clear
|
|
||||||
)
|
|
||||||
|
|
||||||
public extension Theme {
|
|
||||||
func codeTheme(withFont font: UIFont) -> Splash.Theme {
|
|
||||||
var ret = codeThemeTemplate
|
|
||||||
ret.font = .init(size: Double(font.pointSize))
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
@testable import FlowMarkdownView
|
|
||||||
import Testing
|
|
||||||
|
|
||||||
@Test func example() async throws {
|
|
||||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user