chore: basic setup of v2 AI (#12864)
Co-authored-by: Hwang <hwangdev97@gmail.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 77;
|
objectVersion = 56;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
50802D612D112F8700694021 /* Intelligents in Frameworks */ = {isa = PBXBuildFile; productRef = 50802D602D112F8700694021 /* Intelligents */; };
|
50802D612D112F8700694021 /* Intelligents in Frameworks */ = {isa = PBXBuildFile; productRef = 50802D602D112F8700694021 /* Intelligents */; };
|
||||||
50A285D72D112A5E000D5A6D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */; };
|
50A285D72D112A5E000D5A6D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */; };
|
||||||
50A285D82D112A5E000D5A6D /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */; };
|
50A285D82D112A5E000D5A6D /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */; };
|
||||||
50A285DC2D112B24000D5A6D /* Intelligents in Frameworks */ = {isa = PBXBuildFile; productRef = 50A285DB2D112B24000D5A6D /* Intelligents */; };
|
|
||||||
50FF428A2D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */; };
|
50FF428A2D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */; };
|
||||||
50FF428C2D2E77CC0050AA83 /* AffineViewController+AIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */; };
|
50FF428C2D2E77CC0050AA83 /* AffineViewController+AIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */; };
|
||||||
9D52FC432D26CDBF00105D0A /* JSValueContainerExt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */; };
|
9D52FC432D26CDBF00105D0A /* JSValueContainerExt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */; };
|
||||||
@@ -78,6 +77,8 @@
|
|||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
C45499AB2D140B5000E21978 /* NBStore */ = {
|
C45499AB2D140B5000E21978 /* NBStore */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
);
|
||||||
path = NBStore;
|
path = NBStore;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -88,7 +89,6 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
50A285DC2D112B24000D5A6D /* Intelligents in Frameworks */,
|
|
||||||
9DFCD1462D27D1D70028C92B /* libaffine_mobile_native.a in Frameworks */,
|
9DFCD1462D27D1D70028C92B /* libaffine_mobile_native.a in Frameworks */,
|
||||||
50802D612D112F8700694021 /* Intelligents in Frameworks */,
|
50802D612D112F8700694021 /* Intelligents in Frameworks */,
|
||||||
2E0DD47B57B994A104B25EED /* Pods_AFFiNE.framework in Frameworks */,
|
2E0DD47B57B994A104B25EED /* Pods_AFFiNE.framework in Frameworks */,
|
||||||
@@ -521,7 +521,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 = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -559,7 +559,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 = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -613,10 +613,6 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = Intelligents;
|
productName = Intelligents;
|
||||||
};
|
};
|
||||||
50A285DB2D112B24000D5A6D /* Intelligents */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
productName = Intelligents;
|
|
||||||
};
|
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 504EC2FC1FED79650016851F /* Project object */;
|
rootObject = 504EC2FC1FED79650016851F /* Project object */;
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
{
|
|
||||||
"pins" : [
|
|
||||||
{
|
|
||||||
"identity" : "apollo-ios",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apollographql/apollo-ios",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "39fea7617346c0731be25f61afd537e7032fb562",
|
|
||||||
"version" : "1.22.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "chidorimenu",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/Lakr233/ChidoriMenu",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "3bb4323fe0f7f8f435d15656c3eeffcbb7c9c605",
|
|
||||||
"version" : "3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-collections",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-collections",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0",
|
|
||||||
"version" : "1.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-eventsource",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/LaunchDarkly/swift-eventsource.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "57051701c58a93603ffa2051f8e9cf0c8cff7814",
|
|
||||||
"version" : "3.3.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version" : 2
|
|
||||||
}
|
|
||||||
@@ -3,37 +3,28 @@
|
|||||||
{
|
{
|
||||||
"identity" : "apollo-ios",
|
"identity" : "apollo-ios",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apollographql/apollo-ios.git",
|
"location" : "https://github.com/apollographql/apollo-ios",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "39fea7617346c0731be25f61afd537e7032fb562",
|
"revision" : "9aa748d6f0526a744d49d59a2383dc7fdf9d645b",
|
||||||
"version" : "1.22.0"
|
"version" : "1.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identity" : "chidorimenu",
|
"identity" : "snapkit",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/Lakr233/ChidoriMenu",
|
"location" : "https://github.com/SnapKit/SnapKit.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "3bb4323fe0f7f8f435d15656c3eeffcbb7c9c605",
|
"revision" : "2842e6e84e82eb9a8dac0100ca90d9444b0307f4",
|
||||||
"version" : "3.0.0"
|
"version" : "5.7.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identity" : "splash",
|
"identity" : "sqlite.swift",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/JohnSundell/Splash",
|
"location" : "https://github.com/stephencelis/SQLite.swift.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "7f4df436eb78fe64fe2c32c58006e9949fa28ad8",
|
"revision" : "392dd6058624d9f6c5b4c769d165ddd8c7293394",
|
||||||
"version" : "0.16.0"
|
"version" : "0.15.4"
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-cmark",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/swiftlang/swift-cmark",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "b022b08312decdc46585e0b3440d97f6f22ef703",
|
|
||||||
"version" : "0.6.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -46,12 +37,30 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-eventsource",
|
"identity" : "swift-toolchain-sqlite",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/LaunchDarkly/swift-eventsource.git",
|
"location" : "https://github.com/swiftlang/swift-toolchain-sqlite",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "57051701c58a93603ffa2051f8e9cf0c8cff7814",
|
"revision" : "b626d3002773b1a1304166643e7f118f724b2132",
|
||||||
"version" : "3.3.0"
|
"version" : "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swifterswift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/SwifterSwift/SwifterSwift.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "39fa28c90a3ebe3d53f80289304fd880cf2c42d0",
|
||||||
|
"version" : "6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "then",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/devxoul/Then",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "d41ef523faef0f911369f79c0b96815d9dbb6d7a",
|
||||||
|
"version" : "3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,123 +5,18 @@
|
|||||||
// Created by 秋星桥 on 2025/1/8.
|
// Created by 秋星桥 on 2025/1/8.
|
||||||
//
|
//
|
||||||
|
|
||||||
import ChidoriMenu
|
|
||||||
import Intelligents
|
import Intelligents
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
extension AFFiNEViewController: IntelligentsButtonDelegate, IntelligentsFocusApertureViewDelegate {
|
extension AFFiNEViewController: IntelligentsButtonDelegate {
|
||||||
func onIntelligentsButtonTapped(_ button: IntelligentsButton) {
|
func onIntelligentsButtonTapped(_ button: IntelligentsButton) {
|
||||||
guard let webView else {
|
IntelligentContext.shared.webView = webView!
|
||||||
assertionFailure() // ? wdym ?
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
button.beginProgress()
|
button.beginProgress()
|
||||||
|
|
||||||
let group = DispatchGroup()
|
IntelligentContext.shared.preparePresent() {
|
||||||
|
button.stopProgress()
|
||||||
group.enter()
|
let controller = IntelligentsController()
|
||||||
webView.evaluateScript(.getCurrentServerBaseUrl) { result in
|
self.present(controller, animated: true)
|
||||||
self.baseUrl = result as? String
|
|
||||||
print("[*] setting baseUrl: \(self.baseUrl ?? "")")
|
|
||||||
group.leave()
|
|
||||||
}
|
|
||||||
|
|
||||||
group.enter()
|
|
||||||
webView.evaluateScript(.getCurrentDocId) { result in
|
|
||||||
self.documentID = result as? String
|
|
||||||
print("[*] setting documentID: \(self.documentID ?? "")")
|
|
||||||
group.leave()
|
|
||||||
}
|
|
||||||
|
|
||||||
group.enter()
|
|
||||||
webView.evaluateScript(.getCurrentWorkspaceId) { result in
|
|
||||||
self.workspaceID = result as? String
|
|
||||||
print("[*] setting workspaceID: \(self.workspaceID ?? "")")
|
|
||||||
group.leave()
|
|
||||||
}
|
|
||||||
|
|
||||||
group.enter()
|
|
||||||
webView.evaluateScript(.getCurrentDocContentInMarkdown) { input in
|
|
||||||
self.documentContent = input as? String
|
|
||||||
print("[*] setting documentContent: \(self.documentContent?.count ?? 0) chars")
|
|
||||||
group.leave()
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.global().asyncAfter(deadline: .now()) {
|
|
||||||
group.wait()
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
button.stopProgress()
|
|
||||||
webView.resignFirstResponder()
|
|
||||||
self.openIntelligentsSheet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func openIntelligentsSheet() -> IntelligentsFocusApertureView? {
|
|
||||||
dismissIntelligentsButton()
|
|
||||||
view.resignFirstResponder()
|
|
||||||
// stop scroll on webview
|
|
||||||
if let contentOffset = webView?.scrollView.contentOffset {
|
|
||||||
webView?.scrollView.contentOffset = contentOffset
|
|
||||||
}
|
|
||||||
let focus = IntelligentsFocusApertureView()
|
|
||||||
focus.prepareAnimationWith(
|
|
||||||
capturingTargetContentView: webView ?? .init(),
|
|
||||||
coveringRootViewController: self
|
|
||||||
)
|
|
||||||
focus.delegate = self
|
|
||||||
focus.executeAnimationKickIn()
|
|
||||||
dismissIntelligentsButton()
|
|
||||||
return focus
|
|
||||||
}
|
|
||||||
|
|
||||||
func openSimpleChat() {
|
|
||||||
let targetController = IntelligentsChatController()
|
|
||||||
presentIntoCurrentContext(withTargetController: targetController)
|
|
||||||
}
|
|
||||||
|
|
||||||
func focusApertureRequestAction(
|
|
||||||
from view: IntelligentsFocusApertureView,
|
|
||||||
actionType: IntelligentsFocusApertureViewActionType
|
|
||||||
) {
|
|
||||||
switch actionType {
|
|
||||||
case .translateTo:
|
|
||||||
var actions: [UIAction] = []
|
|
||||||
for lang in IntelligentsEphemeralActionController.EphemeralAction.Language.allCases {
|
|
||||||
actions.append(.init(title: lang.rawValue) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
let controller = IntelligentsEphemeralActionController(
|
|
||||||
action: .translate(to: lang)
|
|
||||||
)
|
|
||||||
controller.workspaceID = workspaceID ?? ""
|
|
||||||
controller.documentID = documentID ?? ""
|
|
||||||
controller.documentContent = documentContent ?? ""
|
|
||||||
controller.configure(previewImage: view.capturedImage ?? .init())
|
|
||||||
presentIntoCurrentContext(withTargetController: controller)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
view.present(menu: .init(children: actions)) { controller in
|
|
||||||
controller.overrideUserInterfaceStyle = .dark
|
|
||||||
} controllerDidPresent: { _ in }
|
|
||||||
case .summary:
|
|
||||||
let controller = IntelligentsEphemeralActionController(
|
|
||||||
action: .summarize
|
|
||||||
)
|
|
||||||
controller.configure(previewImage: view.capturedImage ?? .init())
|
|
||||||
controller.workspaceID = workspaceID ?? ""
|
|
||||||
controller.documentID = documentID ?? ""
|
|
||||||
controller.documentContent = documentContent ?? ""
|
|
||||||
presentIntoCurrentContext(withTargetController: controller)
|
|
||||||
case .chatWithAI:
|
|
||||||
let controller = IntelligentsChatController()
|
|
||||||
controller.metadata[.documentID] = documentID
|
|
||||||
controller.metadata[.workspaceID] = workspaceID
|
|
||||||
controller.metadata[.content] = documentContent
|
|
||||||
presentIntoCurrentContext(withTargetController: controller)
|
|
||||||
case .dismiss:
|
|
||||||
presentIntelligentsButton()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,6 @@ import Intelligents
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class AFFiNEViewController: CAPBridgeViewController {
|
class AFFiNEViewController: CAPBridgeViewController {
|
||||||
var baseUrl: String? {
|
|
||||||
didSet { Intelligents.setUpstreamEndpoint(baseUrl ?? "") }
|
|
||||||
}
|
|
||||||
var documentID: String?
|
|
||||||
var workspaceID: String?
|
|
||||||
var documentContent: String?
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
webView?.allowsBackForwardNavigationGestures = true
|
webView?.allowsBackForwardNavigationGestures = true
|
||||||
@@ -36,7 +29,7 @@ class AFFiNEViewController: CAPBridgeViewController {
|
|||||||
CookiePlugin(),
|
CookiePlugin(),
|
||||||
HashcashPlugin(),
|
HashcashPlugin(),
|
||||||
NavigationGesturePlugin(),
|
NavigationGesturePlugin(),
|
||||||
IntelligentsPlugin(representController: self),
|
// IntelligentsPlugin(representController: self), // no longer put in use
|
||||||
NbStorePlugin(),
|
NbStorePlugin(),
|
||||||
]
|
]
|
||||||
plugins.forEach { bridge?.registerPluginInstance($0) }
|
plugins.forEach { bridge?.registerPluginInstance($0) }
|
||||||
@@ -45,16 +38,11 @@ class AFFiNEViewController: CAPBridgeViewController {
|
|||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
navigationController?.setNavigationBarHidden(false, animated: animated)
|
navigationController?.setNavigationBarHidden(false, animated: animated)
|
||||||
}
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||||
|
self.presentIntelligentsButton()
|
||||||
#if DEBUG
|
|
||||||
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
|
|
||||||
super.motionEnded(motion, with: event)
|
|
||||||
if motion == .motionShake {
|
|
||||||
presentIntelligentsButton()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>AFFiNE requires access to select photos from your photo library and insert them into your documents</string>
|
<string>AFFiNE requires access to select photos from your photo library and insert them into your documents</string>
|
||||||
<key>NSUserTrackingUsageDescription</key>
|
<key>NSUserTrackingUsageDescription</key>
|
||||||
<string>Rest assured, enabling this permission won't access your private info on other sites. It's only used to identify your device and improve security and product experience.</string>
|
<string>Rest assured, enabling this permission won't access your private info on other sites. It's only used to identify your device and improve security and product experience.</string>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIImageName</key>
|
<key>UIImageName</key>
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
import Capacitor
|
import Capacitor
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@objc(IntelligentsPlugin)
|
//@objc(IntelligentsPlugin)
|
||||||
public class IntelligentsPlugin: CAPPlugin, CAPBridgedPlugin {
|
//public class IntelligentsPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
public let identifier = "IntelligentsPlugin"
|
// public let identifier = "IntelligentsPlugin"
|
||||||
public let jsName = "Intelligents"
|
// public let jsName = "Intelligents"
|
||||||
public let pluginMethods: [CAPPluginMethod] = [
|
// public let pluginMethods: [CAPPluginMethod] = [
|
||||||
CAPPluginMethod(name: "presentIntelligentsButton", returnType: CAPPluginReturnPromise),
|
// CAPPluginMethod(name: "presentIntelligentsButton", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "dismissIntelligentsButton", returnType: CAPPluginReturnPromise),
|
// CAPPluginMethod(name: "dismissIntelligentsButton", returnType: CAPPluginReturnPromise),
|
||||||
]
|
// ]
|
||||||
public private(set) weak var representController: UIViewController?
|
// public private(set) weak var representController: UIViewController?
|
||||||
|
//
|
||||||
init(representController: UIViewController) {
|
// init(representController: UIViewController) {
|
||||||
self.representController = representController
|
// self.representController = representController
|
||||||
super.init()
|
// super.init()
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
deinit {
|
// deinit {
|
||||||
representController = nil
|
// representController = nil
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
@objc public func presentIntelligentsButton(_ call: CAPPluginCall) {
|
// @objc public func presentIntelligentsButton(_ call: CAPPluginCall) {
|
||||||
DispatchQueue.main.async {
|
// DispatchQueue.main.async {
|
||||||
self.representController?.presentIntelligentsButton()
|
// self.representController?.presentIntelligentsButton()
|
||||||
call.resolve()
|
// call.resolve()
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
@objc public func dismissIntelligentsButton(_ call: CAPPluginCall) {
|
// @objc public func dismissIntelligentsButton(_ call: CAPPluginCall) {
|
||||||
DispatchQueue.main.async {
|
// DispatchQueue.main.async {
|
||||||
self.representController?.dismissIntelligentsButton()
|
// self.representController?.dismissIntelligentsButton()
|
||||||
call.resolve()
|
// call.resolve()
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|||||||
@@ -1711,6 +1711,7 @@ extension UniffiError: Equatable, Hashable {}
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
extension UniffiError: Foundation.LocalizedError {
|
extension UniffiError: Foundation.LocalizedError {
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
String(reflecting: self)
|
String(reflecting: self)
|
||||||
@@ -1718,6 +1719,8 @@ extension UniffiError: Foundation.LocalizedError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#if swift(>=5.8)
|
#if swift(>=5.8)
|
||||||
@_documentation(visibility: private)
|
@_documentation(visibility: private)
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
module affine_mobile_nativeFFI {
|
module affine_mobile_nativeFFI {
|
||||||
header "affine_mobile_nativeFFI.h"
|
header "affine_mobile_nativeFFI.h"
|
||||||
export *
|
export *
|
||||||
|
use "Darwin"
|
||||||
|
use "_Builtin_stdbool"
|
||||||
|
use "_Builtin_stdint"
|
||||||
}
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"pins" : [
|
|
||||||
{
|
|
||||||
"identity" : "apollo-ios",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apollographql/apollo-ios.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "6ecc75281dab2fa231cb0d5fed3a3713826fecae",
|
|
||||||
"version" : "1.21.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version" : 2
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@ let package = Package(
|
|||||||
.library(name: "AffineGraphQL", targets: ["AffineGraphQL"]),
|
.library(name: "AffineGraphQL", targets: ["AffineGraphQL"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/apollographql/apollo-ios", exact: "1.22.0"),
|
.package(url: "https://github.com/apollographql/apollo-ios", exact: "1.18.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ public class GetCopilotHistoriesQuery: GraphQLQuery {
|
|||||||
public static let operationName: String = "getCopilotHistories"
|
public static let operationName: String = "getCopilotHistories"
|
||||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||||
definition: .init(
|
definition: .init(
|
||||||
#"query getCopilotHistories($workspaceId: String!, $docId: String, $options: QueryChatHistoriesInput) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename histories(docId: $docId, options: $options) { __typename sessionId tokens action createdAt messages { __typename id role content attachments createdAt } } } } }"#
|
#"query getCopilotHistories($workspaceId: String!, $docId: String, $options: QueryChatHistoriesInput) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename histories(docId: $docId, options: $options) { __typename sessionId pinned tokens action createdAt messages { __typename id role content streamObjects { __typename type textDelta toolCallId toolName args result } attachments createdAt } } } } }"#
|
||||||
))
|
))
|
||||||
|
|
||||||
public var workspaceId: String
|
public var workspaceId: String
|
||||||
@@ -86,6 +86,7 @@ public class GetCopilotHistoriesQuery: GraphQLQuery {
|
|||||||
public static var __selections: [ApolloAPI.Selection] { [
|
public static var __selections: [ApolloAPI.Selection] { [
|
||||||
.field("__typename", String.self),
|
.field("__typename", String.self),
|
||||||
.field("sessionId", String.self),
|
.field("sessionId", String.self),
|
||||||
|
.field("pinned", Bool.self),
|
||||||
.field("tokens", Int.self),
|
.field("tokens", Int.self),
|
||||||
.field("action", String?.self),
|
.field("action", String?.self),
|
||||||
.field("createdAt", AffineGraphQL.DateTime.self),
|
.field("createdAt", AffineGraphQL.DateTime.self),
|
||||||
@@ -93,6 +94,7 @@ public class GetCopilotHistoriesQuery: GraphQLQuery {
|
|||||||
] }
|
] }
|
||||||
|
|
||||||
public var sessionId: String { __data["sessionId"] }
|
public var sessionId: String { __data["sessionId"] }
|
||||||
|
public var pinned: Bool { __data["pinned"] }
|
||||||
/// The number of tokens used in the session
|
/// The number of tokens used in the session
|
||||||
public var tokens: Int { __data["tokens"] }
|
public var tokens: Int { __data["tokens"] }
|
||||||
/// An mark identifying which view to use to display the session
|
/// An mark identifying which view to use to display the session
|
||||||
@@ -113,6 +115,7 @@ public class GetCopilotHistoriesQuery: GraphQLQuery {
|
|||||||
.field("id", AffineGraphQL.ID?.self),
|
.field("id", AffineGraphQL.ID?.self),
|
||||||
.field("role", String.self),
|
.field("role", String.self),
|
||||||
.field("content", String.self),
|
.field("content", String.self),
|
||||||
|
.field("streamObjects", [StreamObject]?.self),
|
||||||
.field("attachments", [String]?.self),
|
.field("attachments", [String]?.self),
|
||||||
.field("createdAt", AffineGraphQL.DateTime.self),
|
.field("createdAt", AffineGraphQL.DateTime.self),
|
||||||
] }
|
] }
|
||||||
@@ -120,8 +123,35 @@ public class GetCopilotHistoriesQuery: GraphQLQuery {
|
|||||||
public var id: AffineGraphQL.ID? { __data["id"] }
|
public var id: AffineGraphQL.ID? { __data["id"] }
|
||||||
public var role: String { __data["role"] }
|
public var role: String { __data["role"] }
|
||||||
public var content: String { __data["content"] }
|
public var content: String { __data["content"] }
|
||||||
|
public var streamObjects: [StreamObject]? { __data["streamObjects"] }
|
||||||
public var attachments: [String]? { __data["attachments"] }
|
public var attachments: [String]? { __data["attachments"] }
|
||||||
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
|
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
|
||||||
|
|
||||||
|
/// CurrentUser.Copilot.History.Message.StreamObject
|
||||||
|
///
|
||||||
|
/// Parent Type: `StreamObject`
|
||||||
|
public struct StreamObject: AffineGraphQL.SelectionSet {
|
||||||
|
public let __data: DataDict
|
||||||
|
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||||
|
|
||||||
|
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.StreamObject }
|
||||||
|
public static var __selections: [ApolloAPI.Selection] { [
|
||||||
|
.field("__typename", String.self),
|
||||||
|
.field("type", String.self),
|
||||||
|
.field("textDelta", String?.self),
|
||||||
|
.field("toolCallId", String?.self),
|
||||||
|
.field("toolName", String?.self),
|
||||||
|
.field("args", AffineGraphQL.JSON?.self),
|
||||||
|
.field("result", AffineGraphQL.JSON?.self),
|
||||||
|
] }
|
||||||
|
|
||||||
|
public var type: String { __data["type"] }
|
||||||
|
public var textDelta: String? { __data["textDelta"] }
|
||||||
|
public var toolCallId: String? { __data["toolCallId"] }
|
||||||
|
public var toolName: String? { __data["toolName"] }
|
||||||
|
public var args: AffineGraphQL.JSON? { __data["args"] }
|
||||||
|
public var result: AffineGraphQL.JSON? { __data["result"] }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ public class GetCopilotHistoryIdsQuery: GraphQLQuery {
|
|||||||
public static let operationName: String = "getCopilotHistoryIds"
|
public static let operationName: String = "getCopilotHistoryIds"
|
||||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||||
definition: .init(
|
definition: .init(
|
||||||
#"query getCopilotHistoryIds($workspaceId: String!, $docId: String, $options: QueryChatHistoriesInput) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename histories(docId: $docId, options: $options) { __typename sessionId messages { __typename id role createdAt } } } } }"#
|
#"query getCopilotHistoryIds($workspaceId: String!, $docId: String, $options: QueryChatHistoriesInput) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename histories(docId: $docId, options: $options) { __typename sessionId pinned messages { __typename id role createdAt } } } } }"#
|
||||||
))
|
))
|
||||||
|
|
||||||
public var workspaceId: String
|
public var workspaceId: String
|
||||||
@@ -86,10 +86,12 @@ public class GetCopilotHistoryIdsQuery: GraphQLQuery {
|
|||||||
public static var __selections: [ApolloAPI.Selection] { [
|
public static var __selections: [ApolloAPI.Selection] { [
|
||||||
.field("__typename", String.self),
|
.field("__typename", String.self),
|
||||||
.field("sessionId", String.self),
|
.field("sessionId", String.self),
|
||||||
|
.field("pinned", Bool.self),
|
||||||
.field("messages", [Message].self),
|
.field("messages", [Message].self),
|
||||||
] }
|
] }
|
||||||
|
|
||||||
public var sessionId: String { __data["sessionId"] }
|
public var sessionId: String { __data["sessionId"] }
|
||||||
|
public var pinned: Bool { __data["pinned"] }
|
||||||
public var messages: [Message] { __data["messages"] }
|
public var messages: [Message] { __data["messages"] }
|
||||||
|
|
||||||
/// CurrentUser.Copilot.History.Message
|
/// CurrentUser.Copilot.History.Message
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ public class GetCopilotSessionQuery: GraphQLQuery {
|
|||||||
public static let operationName: String = "getCopilotSession"
|
public static let operationName: String = "getCopilotSession"
|
||||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||||
definition: .init(
|
definition: .init(
|
||||||
#"query getCopilotSession($workspaceId: String!, $sessionId: String!) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename session(sessionId: $sessionId) { __typename id parentSessionId promptName model optionalModels } } } }"#
|
#"query getCopilotSession($workspaceId: String!, $sessionId: String!) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename session(sessionId: $sessionId) { __typename id parentSessionId docId pinned promptName model optionalModels } } } }"#
|
||||||
))
|
))
|
||||||
|
|
||||||
public var workspaceId: String
|
public var workspaceId: String
|
||||||
@@ -81,6 +81,8 @@ public class GetCopilotSessionQuery: GraphQLQuery {
|
|||||||
.field("__typename", String.self),
|
.field("__typename", String.self),
|
||||||
.field("id", AffineGraphQL.ID.self),
|
.field("id", AffineGraphQL.ID.self),
|
||||||
.field("parentSessionId", AffineGraphQL.ID?.self),
|
.field("parentSessionId", AffineGraphQL.ID?.self),
|
||||||
|
.field("docId", String?.self),
|
||||||
|
.field("pinned", Bool.self),
|
||||||
.field("promptName", String.self),
|
.field("promptName", String.self),
|
||||||
.field("model", String.self),
|
.field("model", String.self),
|
||||||
.field("optionalModels", [String].self),
|
.field("optionalModels", [String].self),
|
||||||
@@ -88,6 +90,8 @@ public class GetCopilotSessionQuery: GraphQLQuery {
|
|||||||
|
|
||||||
public var id: AffineGraphQL.ID { __data["id"] }
|
public var id: AffineGraphQL.ID { __data["id"] }
|
||||||
public var parentSessionId: AffineGraphQL.ID? { __data["parentSessionId"] }
|
public var parentSessionId: AffineGraphQL.ID? { __data["parentSessionId"] }
|
||||||
|
public var docId: String? { __data["docId"] }
|
||||||
|
public var pinned: Bool { __data["pinned"] }
|
||||||
public var promptName: String { __data["promptName"] }
|
public var promptName: String { __data["promptName"] }
|
||||||
public var model: String { __data["model"] }
|
public var model: String { __data["model"] }
|
||||||
public var optionalModels: [String] { __data["optionalModels"] }
|
public var optionalModels: [String] { __data["optionalModels"] }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ public class GetCopilotSessionsQuery: GraphQLQuery {
|
|||||||
public static let operationName: String = "getCopilotSessions"
|
public static let operationName: String = "getCopilotSessions"
|
||||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||||
definition: .init(
|
definition: .init(
|
||||||
#"query getCopilotSessions($workspaceId: String!, $docId: String, $options: QueryChatSessionsInput) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename sessions(docId: $docId, options: $options) { __typename id parentSessionId promptName model optionalModels } } } }"#
|
#"query getCopilotSessions($workspaceId: String!, $docId: String, $options: QueryChatSessionsInput) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename sessions(docId: $docId, options: $options) { __typename id parentSessionId docId pinned promptName model optionalModels } } } }"#
|
||||||
))
|
))
|
||||||
|
|
||||||
public var workspaceId: String
|
public var workspaceId: String
|
||||||
@@ -88,6 +88,8 @@ public class GetCopilotSessionsQuery: GraphQLQuery {
|
|||||||
.field("__typename", String.self),
|
.field("__typename", String.self),
|
||||||
.field("id", AffineGraphQL.ID.self),
|
.field("id", AffineGraphQL.ID.self),
|
||||||
.field("parentSessionId", AffineGraphQL.ID?.self),
|
.field("parentSessionId", AffineGraphQL.ID?.self),
|
||||||
|
.field("docId", String?.self),
|
||||||
|
.field("pinned", Bool.self),
|
||||||
.field("promptName", String.self),
|
.field("promptName", String.self),
|
||||||
.field("model", String.self),
|
.field("model", String.self),
|
||||||
.field("optionalModels", [String].self),
|
.field("optionalModels", [String].self),
|
||||||
@@ -95,6 +97,8 @@ public class GetCopilotSessionsQuery: GraphQLQuery {
|
|||||||
|
|
||||||
public var id: AffineGraphQL.ID { __data["id"] }
|
public var id: AffineGraphQL.ID { __data["id"] }
|
||||||
public var parentSessionId: AffineGraphQL.ID? { __data["parentSessionId"] }
|
public var parentSessionId: AffineGraphQL.ID? { __data["parentSessionId"] }
|
||||||
|
public var docId: String? { __data["docId"] }
|
||||||
|
public var pinned: Bool { __data["pinned"] }
|
||||||
public var promptName: String { __data["promptName"] }
|
public var promptName: String { __data["promptName"] }
|
||||||
public var model: String { __data["model"] }
|
public var model: String { __data["model"] }
|
||||||
public var optionalModels: [String] { __data["optionalModels"] }
|
public var optionalModels: [String] { __data["optionalModels"] }
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// @generated
|
||||||
|
// This file was automatically generated and should not be edited.
|
||||||
|
|
||||||
|
@_exported import ApolloAPI
|
||||||
|
|
||||||
|
public class GetRecentlyUpdatedDocsQuery: GraphQLQuery {
|
||||||
|
public static let operationName: String = "getRecentlyUpdatedDocs"
|
||||||
|
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||||
|
definition: .init(
|
||||||
|
#"query getRecentlyUpdatedDocs($workspaceId: String!, $pagination: PaginationInput!) { workspace(id: $workspaceId) { __typename recentlyUpdatedDocs(pagination: $pagination) { __typename totalCount pageInfo { __typename endCursor hasNextPage } edges { __typename node { __typename id title createdAt updatedAt creatorId lastUpdaterId } } } } }"#
|
||||||
|
))
|
||||||
|
|
||||||
|
public var workspaceId: String
|
||||||
|
public var pagination: PaginationInput
|
||||||
|
|
||||||
|
public init(
|
||||||
|
workspaceId: String,
|
||||||
|
pagination: PaginationInput
|
||||||
|
) {
|
||||||
|
self.workspaceId = workspaceId
|
||||||
|
self.pagination = pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
public var __variables: Variables? { [
|
||||||
|
"workspaceId": workspaceId,
|
||||||
|
"pagination": pagination
|
||||||
|
] }
|
||||||
|
|
||||||
|
public struct Data: AffineGraphQL.SelectionSet {
|
||||||
|
public let __data: DataDict
|
||||||
|
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||||
|
|
||||||
|
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
|
||||||
|
public static var __selections: [ApolloAPI.Selection] { [
|
||||||
|
.field("workspace", Workspace.self, arguments: ["id": .variable("workspaceId")]),
|
||||||
|
] }
|
||||||
|
|
||||||
|
/// Get workspace by id
|
||||||
|
public var workspace: Workspace { __data["workspace"] }
|
||||||
|
|
||||||
|
/// Workspace
|
||||||
|
///
|
||||||
|
/// Parent Type: `WorkspaceType`
|
||||||
|
public struct Workspace: AffineGraphQL.SelectionSet {
|
||||||
|
public let __data: DataDict
|
||||||
|
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||||
|
|
||||||
|
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceType }
|
||||||
|
public static var __selections: [ApolloAPI.Selection] { [
|
||||||
|
.field("__typename", String.self),
|
||||||
|
.field("recentlyUpdatedDocs", RecentlyUpdatedDocs.self, arguments: ["pagination": .variable("pagination")]),
|
||||||
|
] }
|
||||||
|
|
||||||
|
/// Get recently updated docs of a workspace
|
||||||
|
public var recentlyUpdatedDocs: RecentlyUpdatedDocs { __data["recentlyUpdatedDocs"] }
|
||||||
|
|
||||||
|
/// Workspace.RecentlyUpdatedDocs
|
||||||
|
///
|
||||||
|
/// Parent Type: `PaginatedDocType`
|
||||||
|
public struct RecentlyUpdatedDocs: AffineGraphQL.SelectionSet {
|
||||||
|
public let __data: DataDict
|
||||||
|
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||||
|
|
||||||
|
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.PaginatedDocType }
|
||||||
|
public static var __selections: [ApolloAPI.Selection] { [
|
||||||
|
.field("__typename", String.self),
|
||||||
|
.field("totalCount", Int.self),
|
||||||
|
.field("pageInfo", PageInfo.self),
|
||||||
|
.field("edges", [Edge].self),
|
||||||
|
] }
|
||||||
|
|
||||||
|
public var totalCount: Int { __data["totalCount"] }
|
||||||
|
public var pageInfo: PageInfo { __data["pageInfo"] }
|
||||||
|
public var edges: [Edge] { __data["edges"] }
|
||||||
|
|
||||||
|
/// Workspace.RecentlyUpdatedDocs.PageInfo
|
||||||
|
///
|
||||||
|
/// Parent Type: `PageInfo`
|
||||||
|
public struct PageInfo: AffineGraphQL.SelectionSet {
|
||||||
|
public let __data: DataDict
|
||||||
|
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||||
|
|
||||||
|
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.PageInfo }
|
||||||
|
public static var __selections: [ApolloAPI.Selection] { [
|
||||||
|
.field("__typename", String.self),
|
||||||
|
.field("endCursor", String?.self),
|
||||||
|
.field("hasNextPage", Bool.self),
|
||||||
|
] }
|
||||||
|
|
||||||
|
public var endCursor: String? { __data["endCursor"] }
|
||||||
|
public var hasNextPage: Bool { __data["hasNextPage"] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Workspace.RecentlyUpdatedDocs.Edge
|
||||||
|
///
|
||||||
|
/// Parent Type: `DocTypeEdge`
|
||||||
|
public struct Edge: AffineGraphQL.SelectionSet {
|
||||||
|
public let __data: DataDict
|
||||||
|
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||||
|
|
||||||
|
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.DocTypeEdge }
|
||||||
|
public static var __selections: [ApolloAPI.Selection] { [
|
||||||
|
.field("__typename", String.self),
|
||||||
|
.field("node", Node.self),
|
||||||
|
] }
|
||||||
|
|
||||||
|
public var node: Node { __data["node"] }
|
||||||
|
|
||||||
|
/// Workspace.RecentlyUpdatedDocs.Edge.Node
|
||||||
|
///
|
||||||
|
/// Parent Type: `DocType`
|
||||||
|
public struct Node: AffineGraphQL.SelectionSet {
|
||||||
|
public let __data: DataDict
|
||||||
|
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||||
|
|
||||||
|
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.DocType }
|
||||||
|
public static var __selections: [ApolloAPI.Selection] { [
|
||||||
|
.field("__typename", String.self),
|
||||||
|
.field("id", String.self),
|
||||||
|
.field("title", String?.self),
|
||||||
|
.field("createdAt", AffineGraphQL.DateTime?.self),
|
||||||
|
.field("updatedAt", AffineGraphQL.DateTime?.self),
|
||||||
|
.field("creatorId", String?.self),
|
||||||
|
.field("lastUpdaterId", String?.self),
|
||||||
|
] }
|
||||||
|
|
||||||
|
public var id: String { __data["id"] }
|
||||||
|
public var title: String? { __data["title"] }
|
||||||
|
public var createdAt: AffineGraphQL.DateTime? { __data["createdAt"] }
|
||||||
|
public var updatedAt: AffineGraphQL.DateTime? { __data["updatedAt"] }
|
||||||
|
public var creatorId: String? { __data["creatorId"] }
|
||||||
|
public var lastUpdaterId: String? { __data["lastUpdaterId"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,22 +11,29 @@ public struct CreateChatSessionInput: InputObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
docId: String,
|
docId: GraphQLNullable<String> = nil,
|
||||||
|
pinned: GraphQLNullable<Bool> = nil,
|
||||||
promptName: String,
|
promptName: String,
|
||||||
workspaceId: String
|
workspaceId: String
|
||||||
) {
|
) {
|
||||||
__data = InputDict([
|
__data = InputDict([
|
||||||
"docId": docId,
|
"docId": docId,
|
||||||
|
"pinned": pinned,
|
||||||
"promptName": promptName,
|
"promptName": promptName,
|
||||||
"workspaceId": workspaceId
|
"workspaceId": workspaceId
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
public var docId: String {
|
public var docId: GraphQLNullable<String> {
|
||||||
get { __data["docId"] }
|
get { __data["docId"] }
|
||||||
set { __data["docId"] = newValue }
|
set { __data["docId"] = newValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var pinned: GraphQLNullable<Bool> {
|
||||||
|
get { __data["pinned"] }
|
||||||
|
set { __data["pinned"] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
/// The prompt name to use for the session
|
/// The prompt name to use for the session
|
||||||
public var promptName: String {
|
public var promptName: String {
|
||||||
get { __data["promptName"] }
|
get { __data["promptName"] }
|
||||||
|
|||||||
@@ -11,17 +11,33 @@ public struct UpdateChatSessionInput: InputObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
promptName: String,
|
docId: GraphQLNullable<String> = nil,
|
||||||
|
pinned: GraphQLNullable<Bool> = nil,
|
||||||
|
promptName: GraphQLNullable<String> = nil,
|
||||||
sessionId: String
|
sessionId: String
|
||||||
) {
|
) {
|
||||||
__data = InputDict([
|
__data = InputDict([
|
||||||
|
"docId": docId,
|
||||||
|
"pinned": pinned,
|
||||||
"promptName": promptName,
|
"promptName": promptName,
|
||||||
"sessionId": sessionId
|
"sessionId": sessionId
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The workspace id of the session
|
||||||
|
public var docId: GraphQLNullable<String> {
|
||||||
|
get { __data["docId"] }
|
||||||
|
set { __data["docId"] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether to pin the session
|
||||||
|
public var pinned: GraphQLNullable<Bool> {
|
||||||
|
get { __data["pinned"] }
|
||||||
|
set { __data["pinned"] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
/// The prompt name to use for the session
|
/// The prompt name to use for the session
|
||||||
public var promptName: String {
|
public var promptName: GraphQLNullable<String> {
|
||||||
get { __data["promptName"] }
|
get { __data["promptName"] }
|
||||||
set { __data["promptName"] = newValue }
|
set { __data["promptName"] = newValue }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// @generated
|
||||||
|
// This file was automatically generated and should not be edited.
|
||||||
|
|
||||||
|
import ApolloAPI
|
||||||
|
|
||||||
|
public extension Objects {
|
||||||
|
static let StreamObject = ApolloAPI.Object(
|
||||||
|
typename: "StreamObject",
|
||||||
|
implementedInterfaces: [],
|
||||||
|
keyFields: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -81,6 +81,7 @@ public enum SchemaMetadata: ApolloAPI.SchemaMetadata {
|
|||||||
case "SearchResultObjectType": return AffineGraphQL.Objects.SearchResultObjectType
|
case "SearchResultObjectType": return AffineGraphQL.Objects.SearchResultObjectType
|
||||||
case "SearchResultPagination": return AffineGraphQL.Objects.SearchResultPagination
|
case "SearchResultPagination": return AffineGraphQL.Objects.SearchResultPagination
|
||||||
case "ServerConfigType": return AffineGraphQL.Objects.ServerConfigType
|
case "ServerConfigType": return AffineGraphQL.Objects.ServerConfigType
|
||||||
|
case "StreamObject": return AffineGraphQL.Objects.StreamObject
|
||||||
case "SubscriptionPrice": return AffineGraphQL.Objects.SubscriptionPrice
|
case "SubscriptionPrice": return AffineGraphQL.Objects.SubscriptionPrice
|
||||||
case "SubscriptionType": return AffineGraphQL.Objects.SubscriptionType
|
case "SubscriptionType": return AffineGraphQL.Objects.SubscriptionType
|
||||||
case "TranscriptionItemType": return AffineGraphQL.Objects.TranscriptionItemType
|
case "TranscriptionItemType": return AffineGraphQL.Objects.TranscriptionItemType
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
{
|
|
||||||
"pins" : [
|
|
||||||
{
|
|
||||||
"identity" : "apollo-ios",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apollographql/apollo-ios.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "39fea7617346c0731be25f61afd537e7032fb562",
|
|
||||||
"version" : "1.22.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "chidorimenu",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/Lakr233/ChidoriMenu",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "3bb4323fe0f7f8f435d15656c3eeffcbb7c9c605",
|
|
||||||
"version" : "3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-collections",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-collections",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0",
|
|
||||||
"version" : "1.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-eventsource",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/LaunchDarkly/swift-eventsource.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "57051701c58a93603ffa2051f8e9cf0c8cff7814",
|
|
||||||
"version" : "3.3.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version" : 2
|
|
||||||
}
|
|
||||||
@@ -7,29 +7,30 @@ let package = Package(
|
|||||||
name: "Intelligents",
|
name: "Intelligents",
|
||||||
defaultLocalization: "en",
|
defaultLocalization: "en",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v15),
|
.iOS(.v16),
|
||||||
.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/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"),
|
.package(url: "https://github.com/devxoul/Then", from: "3.0.0"),
|
||||||
|
.package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.0.1"),
|
||||||
|
.package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.0.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "Intelligents", dependencies: [
|
.target(name: "Intelligents", dependencies: [
|
||||||
"AffineGraphQL",
|
"AffineGraphQL",
|
||||||
"ChidoriMenu",
|
"SnapKit",
|
||||||
"MarkdownView",
|
"Then",
|
||||||
"ChidoriMenu",
|
"SwifterSwift",
|
||||||
.product(name: "Apollo", package: "apollo-ios"),
|
.product(name: "Apollo", package: "apollo-ios"),
|
||||||
.product(name: "LDSwiftEventSource", package: "swift-eventsource"),
|
|
||||||
.product(name: "OrderedCollections", package: "swift-collections"),
|
.product(name: "OrderedCollections", package: "swift-collections"),
|
||||||
|
], resources: [
|
||||||
|
.process("Resources/main.metal"),
|
||||||
|
.process("Interface/View/InputBox/InputBox.xcassets"),
|
||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/")!
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -5,19 +5,7 @@ import AffineGraphQL
|
|||||||
import Apollo
|
import Apollo
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum Intelligents {
|
public enum Intelligents {}
|
||||||
public private(set) static var qlClient: ApolloClient = createQlClient()
|
|
||||||
|
|
||||||
public static func setUpstreamEndpoint(_ upstream: String) {
|
|
||||||
guard let url = URL(string: upstream) else {
|
|
||||||
assertionFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
print("[*] setting up upstream endpoint to \(url.absoluteString)")
|
|
||||||
Constant.affineUpstreamURL = url
|
|
||||||
qlClient = createQlClient()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension Intelligents {
|
private extension Intelligents {
|
||||||
private final class URLSessionCookieClient: URLSessionClient {
|
private final class URLSessionCookieClient: URLSessionClient {
|
||||||
@@ -29,18 +17,4 @@ private extension Intelligents {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func createQlClient() -> ApolloClient {
|
|
||||||
let store = ApolloStore(cache: InMemoryNormalizedCache())
|
|
||||||
let provider = DefaultInterceptorProvider(
|
|
||||||
client: URLSessionCookieClient(),
|
|
||||||
shouldInvalidateClientOnDeinit: true,
|
|
||||||
store: store
|
|
||||||
)
|
|
||||||
let transport = RequestChainNetworkTransport(
|
|
||||||
interceptorProvider: provider,
|
|
||||||
endpointURL: Constant.affineUpstreamURL.appendingPathComponent("graphql")
|
|
||||||
)
|
|
||||||
return .init(networkTransport: transport, store: store)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
//
|
||||||
|
// BlurTransition.swift
|
||||||
|
// BlurTransition
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/16/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIViewController {
|
||||||
|
func presentWithFullScreenBlurTransition(_ viewController: UIViewController) {
|
||||||
|
viewController.modalPresentationStyle = .custom
|
||||||
|
viewController.transitioningDelegate = BlurTransitioningDelegate.shared
|
||||||
|
present(viewController, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BlurTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
|
||||||
|
static let shared = BlurTransitioningDelegate()
|
||||||
|
|
||||||
|
func animationController(
|
||||||
|
forPresented _: UIViewController,
|
||||||
|
presenting _: UIViewController,
|
||||||
|
source _: UIViewController
|
||||||
|
) -> UIViewControllerAnimatedTransitioning? {
|
||||||
|
BlurTransitionAnimator(presenting: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func animationController(
|
||||||
|
forDismissed _: UIViewController
|
||||||
|
) -> UIViewControllerAnimatedTransitioning? {
|
||||||
|
BlurTransitionAnimator(presenting: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
|
||||||
|
private let presenting: Bool
|
||||||
|
|
||||||
|
private let snapshotViewTag = "snapshotView".hashValue
|
||||||
|
private let blurViewTag = "blurView".hashValue
|
||||||
|
|
||||||
|
init(presenting: Bool) {
|
||||||
|
self.presenting = presenting
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func transitionDuration(using _: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||||
|
0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||||
|
if presenting {
|
||||||
|
animatePresentation(using: transitionContext)
|
||||||
|
} else {
|
||||||
|
animateDismissal(using: transitionContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func animatePresentation(using transitionContext: UIViewControllerContextTransitioning) {
|
||||||
|
guard let toViewController = transitionContext.viewController(forKey: .to),
|
||||||
|
let fromViewController = transitionContext.viewController(forKey: .from)
|
||||||
|
else {
|
||||||
|
transitionContext.completeTransition(false)
|
||||||
|
assertionFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let toView = toViewController.view!
|
||||||
|
let fromView = fromViewController.view!
|
||||||
|
|
||||||
|
let containerView = transitionContext.containerView
|
||||||
|
|
||||||
|
guard let fromViewSnapshot = fromView.snapshotView(afterScreenUpdates: false) else {
|
||||||
|
transitionContext.completeTransition(false)
|
||||||
|
assertionFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fromViewSnapshot.frame = fromView.frame
|
||||||
|
fromViewSnapshot.tag = snapshotViewTag
|
||||||
|
containerView.addSubview(fromViewSnapshot)
|
||||||
|
fromView.isHidden = true
|
||||||
|
|
||||||
|
let blurEffectView = UIVisualEffectView()
|
||||||
|
blurEffectView.frame = containerView.bounds
|
||||||
|
blurEffectView.tag = blurViewTag
|
||||||
|
containerView.addSubview(blurEffectView)
|
||||||
|
|
||||||
|
toView.frame = containerView.bounds
|
||||||
|
toView.alpha = 0
|
||||||
|
toView.transform = CGAffineTransform(scaleX: 1.05, y: 1.05)
|
||||||
|
containerView.addSubview(toView)
|
||||||
|
|
||||||
|
toView.layoutIfNeeded()
|
||||||
|
|
||||||
|
performWithAnimation(animations: {
|
||||||
|
blurEffectView.effect = UIBlurEffect(style: .systemMaterial)
|
||||||
|
fromViewSnapshot.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
|
||||||
|
toView.alpha = 1
|
||||||
|
toView.transform = .identity
|
||||||
|
fromView.layoutIfNeeded()
|
||||||
|
toView.layoutIfNeeded()
|
||||||
|
}) { _ in
|
||||||
|
let success = !transitionContext.transitionWasCancelled
|
||||||
|
if !success {
|
||||||
|
assertionFailure()
|
||||||
|
fromView.isHidden = false
|
||||||
|
fromViewSnapshot.removeFromSuperview()
|
||||||
|
blurEffectView.removeFromSuperview()
|
||||||
|
toView.removeFromSuperview()
|
||||||
|
}
|
||||||
|
transitionContext.completeTransition(success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func animateDismissal(using transitionContext: UIViewControllerContextTransitioning) {
|
||||||
|
guard let fromViewController = transitionContext.viewController(forKey: .from),
|
||||||
|
let toViewController = transitionContext.viewController(forKey: .to)
|
||||||
|
else {
|
||||||
|
transitionContext.completeTransition(false)
|
||||||
|
assertionFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let fromView = fromViewController.view!
|
||||||
|
let toView = toViewController.view!
|
||||||
|
let containerView = transitionContext.containerView
|
||||||
|
|
||||||
|
guard let fromViewSnapshot = containerView.viewWithTag(snapshotViewTag),
|
||||||
|
let blurEffectView = containerView.viewWithTag(blurViewTag) as? UIVisualEffectView
|
||||||
|
else {
|
||||||
|
toView.isHidden = false
|
||||||
|
assertionFailure()
|
||||||
|
transitionContext.completeTransition(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
performWithAnimation(animations: {
|
||||||
|
fromViewSnapshot.transform = .identity
|
||||||
|
blurEffectView.effect = nil
|
||||||
|
fromView.transform = CGAffineTransform(scaleX: 1.05, y: 1.05)
|
||||||
|
fromView.alpha = 0
|
||||||
|
}) { _ in
|
||||||
|
let success = !transitionContext.transitionWasCancelled
|
||||||
|
if success {
|
||||||
|
toView.isHidden = false
|
||||||
|
fromViewSnapshot.removeFromSuperview()
|
||||||
|
blurEffectView.removeFromSuperview()
|
||||||
|
fromView.layoutIfNeeded()
|
||||||
|
toView.layoutIfNeeded()
|
||||||
|
} else {
|
||||||
|
assertionFailure()
|
||||||
|
fromView.transform = .identity
|
||||||
|
fromView.alpha = 1
|
||||||
|
}
|
||||||
|
transitionContext.completeTransition(success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//
|
||||||
|
// IntelligentsController.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/17/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
public class IntelligentsController: UINavigationController {
|
||||||
|
public init() {
|
||||||
|
super.init(rootViewController: MainViewController())
|
||||||
|
modalPresentationStyle = .custom
|
||||||
|
transitioningDelegate = BlurTransitioningDelegate.shared
|
||||||
|
setNavigationBarHidden(true, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder _: NSCoder) {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override public func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .systemBackground
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// MainViewController+Header.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/19/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension MainViewController: MainHeaderViewDelegate {
|
||||||
|
func mainHeaderViewDidTapClose() {
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mainHeaderViewDidTapDropdown() {
|
||||||
|
print(#function)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mainHeaderViewDidTapMenu() {
|
||||||
|
print(#function)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
//
|
||||||
|
// MainViewController+Input.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/19/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import PhotosUI
|
||||||
|
import UIKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
extension MainViewController: InputBoxDelegate {
|
||||||
|
func inputBoxDidSelectTakePhoto(_: InputBox) {
|
||||||
|
let imagePickerController = UIImagePickerController()
|
||||||
|
imagePickerController.delegate = self
|
||||||
|
imagePickerController.sourceType = .camera
|
||||||
|
imagePickerController.allowsEditing = false
|
||||||
|
present(imagePickerController, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inputBoxDidSelectPhotoLibrary(_: InputBox) {
|
||||||
|
var configuration = PHPickerConfiguration()
|
||||||
|
configuration.filter = .images
|
||||||
|
configuration.selectionLimit = 0 // 0 means no limit
|
||||||
|
|
||||||
|
let picker = PHPickerViewController(configuration: configuration)
|
||||||
|
picker.delegate = self
|
||||||
|
present(picker, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inputBoxDidSelectAttachFiles(_: InputBox) {
|
||||||
|
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [
|
||||||
|
.pdf, .plainText, .commaSeparatedText, .data,
|
||||||
|
])
|
||||||
|
documentPicker.delegate = self
|
||||||
|
documentPicker.allowsMultipleSelection = false
|
||||||
|
present(documentPicker, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inputBoxDidSelectEmbedDocs(_ inputBox: InputBox) {
|
||||||
|
print(#function, inputBox)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inputBoxDidSelectAttachment(_ inputBox: InputBox) {
|
||||||
|
print(#function, inputBox)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inputBoxDidSend(_ inputBox: InputBox) {
|
||||||
|
print(#function, inputBox, inputBox.viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inputBoxTextDidChange(_ text: String) {
|
||||||
|
print(#function, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UIImagePickerControllerDelegate
|
||||||
|
|
||||||
|
extension MainViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||||
|
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||||
|
defer { picker.dismiss(animated: true) }
|
||||||
|
|
||||||
|
guard let image = info[.originalImage] as? UIImage else { return }
|
||||||
|
inputBox.addImageAttachment(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||||
|
picker.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PHPickerViewControllerDelegate
|
||||||
|
|
||||||
|
extension MainViewController: PHPickerViewControllerDelegate {
|
||||||
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||||
|
defer { picker.dismiss(animated: true) }
|
||||||
|
|
||||||
|
for result in results {
|
||||||
|
if result.itemProvider.canLoadObject(ofClass: UIImage.self) {
|
||||||
|
result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
|
||||||
|
guard let image = object as? UIImage, error == nil else { return }
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.inputBox.addImageAttachment(image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UIDocumentPickerDelegate
|
||||||
|
|
||||||
|
extension MainViewController: UIDocumentPickerDelegate {
|
||||||
|
func documentPicker(_: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||||
|
for url in urls {
|
||||||
|
// Start accessing security-scoped resource
|
||||||
|
guard url.startAccessingSecurityScopedResource() else { continue }
|
||||||
|
defer { url.stopAccessingSecurityScopedResource() }
|
||||||
|
|
||||||
|
// Copy file to temporary directory
|
||||||
|
let context = IntelligentContext.shared
|
||||||
|
context.prepareTemporaryDirectory()
|
||||||
|
|
||||||
|
let tempURL = context.temporaryDirectory.appendingPathComponent(url.lastPathComponent)
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Remove existing file if it exists
|
||||||
|
if FileManager.default.fileExists(atPath: tempURL.path) {
|
||||||
|
try FileManager.default.removeItem(at: tempURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy file to temporary directory
|
||||||
|
try FileManager.default.copyItem(at: url, to: tempURL)
|
||||||
|
|
||||||
|
// Add file attachment using the temporary URL
|
||||||
|
inputBox.addFileAttachment(tempURL)
|
||||||
|
} catch {
|
||||||
|
print("Failed to copy file: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import Combine
|
||||||
|
import SnapKit
|
||||||
|
import Then
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class MainViewController: UIViewController {
|
||||||
|
// MARK: - UI Components
|
||||||
|
|
||||||
|
lazy var headerView = MainHeaderView().then {
|
||||||
|
$0.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy var inputBox = InputBox().then {
|
||||||
|
$0.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private let intelligentContext = IntelligentContext.shared
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .affineLayerBackgroundPrimary
|
||||||
|
|
||||||
|
let inputBox = InputBox().then {
|
||||||
|
$0.delegate = self
|
||||||
|
}
|
||||||
|
self.inputBox = inputBox
|
||||||
|
|
||||||
|
view.addSubview(headerView)
|
||||||
|
view.addSubview(inputBox)
|
||||||
|
|
||||||
|
headerView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
inputBox.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
navigationController!.setNavigationBarHidden(true, animated: animated)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.inputBox.textView.becomeFirstResponder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
navigationController!.setNavigationBarHidden(false, animated: animated)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//
|
||||||
|
// ParticleView+Removal.swift
|
||||||
|
// UIEffectKit
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/13/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
public extension UIView {
|
||||||
|
func removeFromSuperviewWithExplodeEffect() {
|
||||||
|
guard let superview else { return }
|
||||||
|
guard let window else {
|
||||||
|
removeFromSuperview()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard MTLCreateSystemDefaultDevice() != nil else {
|
||||||
|
removeFromSuperview()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let image = createViewSnapshot()
|
||||||
|
guard let cgImage = image.cgImage else {
|
||||||
|
removeFromSuperview()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let frameInWindow = superview.convert(frame, to: window)
|
||||||
|
let particleView = ParticleView(frame: frameInWindow)
|
||||||
|
|
||||||
|
window.addSubview(particleView)
|
||||||
|
particleView.layer.zPosition = 1000
|
||||||
|
particleView.frame = frameInWindow
|
||||||
|
particleView.setNeedsLayout()
|
||||||
|
particleView.layoutIfNeeded()
|
||||||
|
|
||||||
|
particleView.beginWith(cgImage, targetFrame: frameInWindow, onComplete: {
|
||||||
|
particleView.removeFromSuperview()
|
||||||
|
}, onFirstFrameRendered: { [weak self] in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.removeFromSuperview()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
//
|
||||||
|
// ParticleView+Renderer.swift
|
||||||
|
// UIEffectKit
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/13/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import MetalKit
|
||||||
|
|
||||||
|
extension ParticleView {
|
||||||
|
class Renderer: NSObject, MTKViewDelegate {
|
||||||
|
private struct Particle {
|
||||||
|
var position: simd_float2
|
||||||
|
var velocity: simd_float2
|
||||||
|
var life: simd_float1
|
||||||
|
var duration: simd_float1
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Vertex {
|
||||||
|
var position: simd_float4
|
||||||
|
var uv: simd_float2
|
||||||
|
var opacity: simd_float1
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isPrepared = false
|
||||||
|
private var renderPipeline: MTLRenderPipelineState!
|
||||||
|
private var computePipeline: MTLComputePipelineState!
|
||||||
|
private var vertexBuffer: MTLBuffer!
|
||||||
|
private var particleBuffer: MTLBuffer!
|
||||||
|
private var particleCount: Int = 0
|
||||||
|
private var texture: MTLTexture!
|
||||||
|
private var targetFrameSize: simd_float2 = .zero
|
||||||
|
private var stepSize: Float = 0
|
||||||
|
private var commandQueue: MTLCommandQueue!
|
||||||
|
private var maxLife: Float = 0
|
||||||
|
private var onComplete: (() -> Void)?
|
||||||
|
private var onFirstFrameRendered: (() -> Void)?
|
||||||
|
private var hasRenderedFirstFrame = false
|
||||||
|
private var device: MTLDevice!
|
||||||
|
|
||||||
|
func prepareResources(
|
||||||
|
with device: MTLDevice,
|
||||||
|
image: CGImage,
|
||||||
|
targetFrame: CGRect,
|
||||||
|
onComplete: @escaping () -> Void,
|
||||||
|
onFirstFrameRendered: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
guard !isPrepared else { return }
|
||||||
|
|
||||||
|
self.device = device
|
||||||
|
self.onComplete = onComplete
|
||||||
|
self.onFirstFrameRendered = onFirstFrameRendered
|
||||||
|
let integralTargetFrame = targetFrame.integral
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
setupPipelineStates(with: device)
|
||||||
|
setupVertexBuffer(with: device)
|
||||||
|
setupParticleSystem(targetFrame: integralTargetFrame, device: device)
|
||||||
|
setupTexture(from: image, device: device)
|
||||||
|
finalizeSetup(targetFrame: integralTargetFrame, device: device)
|
||||||
|
|
||||||
|
DispatchQueue.main.async { self.isPrepared = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupPipelineStates(with device: MTLDevice) {
|
||||||
|
let library = try! device.makeDefaultLibrary(bundle: .module)
|
||||||
|
|
||||||
|
let particleVertexFunction = library.makeFunction(name: "PTS_ParticleVertex")!
|
||||||
|
let particleFragmentFunction = library.makeFunction(name: "PTS_ParticleFragment")!
|
||||||
|
let updateParticlesFunction = library.makeFunction(name: "PTS_UpdateParticles")!
|
||||||
|
|
||||||
|
let renderPipelineDescriptor = createRenderPipelineDescriptor(
|
||||||
|
vertexFunction: particleVertexFunction,
|
||||||
|
fragmentFunction: particleFragmentFunction
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
renderPipeline = try device.makeRenderPipelineState(descriptor: renderPipelineDescriptor)
|
||||||
|
computePipeline = try device.makeComputePipelineState(function: updateParticlesFunction)
|
||||||
|
} catch {
|
||||||
|
fatalError("failed to create pipeline states: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createRenderPipelineDescriptor(
|
||||||
|
vertexFunction: MTLFunction,
|
||||||
|
fragmentFunction: MTLFunction
|
||||||
|
) -> MTLRenderPipelineDescriptor {
|
||||||
|
let descriptor = MTLRenderPipelineDescriptor()
|
||||||
|
descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
|
||||||
|
descriptor.colorAttachments[0].isBlendingEnabled = true
|
||||||
|
descriptor.colorAttachments[0].rgbBlendOperation = .add
|
||||||
|
descriptor.colorAttachments[0].alphaBlendOperation = .add
|
||||||
|
descriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha
|
||||||
|
descriptor.colorAttachments[0].sourceAlphaBlendFactor = .sourceAlpha
|
||||||
|
descriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha
|
||||||
|
descriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha
|
||||||
|
descriptor.vertexFunction = vertexFunction
|
||||||
|
descriptor.fragmentFunction = fragmentFunction
|
||||||
|
return descriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
func mtkView(_: MTKView, drawableSizeWillChange _: CGSize) {
|
||||||
|
// No-op since view is not subject to resize
|
||||||
|
}
|
||||||
|
|
||||||
|
func draw(in view: MTKView) {
|
||||||
|
guard isPrepared else { return }
|
||||||
|
|
||||||
|
updateParticles()
|
||||||
|
|
||||||
|
if checkAllParticlesDead() {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.onComplete?()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderParticles(in: view)
|
||||||
|
|
||||||
|
if !hasRenderedFirstFrame {
|
||||||
|
hasRenderedFirstFrame = true
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.onFirstFrameRendered?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateParticles() {
|
||||||
|
let maxThreadsPerThreadgroup = computePipeline.maxTotalThreadsPerThreadgroup
|
||||||
|
let threadgroupSize = min(maxThreadsPerThreadgroup, 2048)
|
||||||
|
let threadgroupCount = (particleCount + threadgroupSize - 1) / threadgroupSize
|
||||||
|
|
||||||
|
let computeCommandBuffer = commandQueue.makeCommandBuffer()!
|
||||||
|
|
||||||
|
let computeCommandEncoder = computeCommandBuffer.makeComputeCommandEncoder()!
|
||||||
|
computeCommandEncoder.setComputePipelineState(computePipeline)
|
||||||
|
computeCommandEncoder.setBuffer(particleBuffer, offset: 0, index: 0)
|
||||||
|
computeCommandEncoder.dispatchThreadgroups(
|
||||||
|
.init(width: threadgroupCount, height: 1, depth: 1),
|
||||||
|
threadsPerThreadgroup: .init(width: threadgroupSize, height: 1, depth: 1)
|
||||||
|
)
|
||||||
|
computeCommandEncoder.endEncoding()
|
||||||
|
|
||||||
|
computeCommandBuffer.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkAllParticlesDead() -> Bool {
|
||||||
|
let particleData = particleBuffer
|
||||||
|
.contents()
|
||||||
|
.bindMemory(to: Particle.self, capacity: particleCount)
|
||||||
|
|
||||||
|
for i in 0 ..< particleCount {
|
||||||
|
if particleData[i].life >= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func renderParticles(in view: MTKView) {
|
||||||
|
let viewCGSize = view.bounds.size
|
||||||
|
var viewSize = simd_float2(Float(viewCGSize.width), Float(viewCGSize.height))
|
||||||
|
|
||||||
|
let renderCommandBuffer = commandQueue.makeCommandBuffer()!
|
||||||
|
|
||||||
|
guard let renderPassDescriptor = view.currentRenderPassDescriptor else { return }
|
||||||
|
renderPassDescriptor.colorAttachments[0].loadAction = .clear
|
||||||
|
renderPassDescriptor.colorAttachments[0].clearColor = .init(red: 0, green: 0, blue: 0, alpha: 0)
|
||||||
|
|
||||||
|
let renderCommandEncoder = renderCommandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)!
|
||||||
|
renderCommandEncoder.setRenderPipelineState(renderPipeline)
|
||||||
|
renderCommandEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
|
||||||
|
|
||||||
|
withUnsafeBytes(of: &viewSize) { pointer in
|
||||||
|
renderCommandEncoder.setVertexBytes(
|
||||||
|
pointer.baseAddress!,
|
||||||
|
length: MemoryLayout<simd_float2>.size,
|
||||||
|
index: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderCommandEncoder.setVertexBuffer(particleBuffer, offset: 0, index: 2)
|
||||||
|
withUnsafeBytes(of: &targetFrameSize) { pointer in
|
||||||
|
renderCommandEncoder.setVertexBytes(
|
||||||
|
pointer.baseAddress!,
|
||||||
|
length: MemoryLayout<simd_float2>.size,
|
||||||
|
index: 3
|
||||||
|
)
|
||||||
|
}
|
||||||
|
withUnsafeBytes(of: &stepSize) { pointer in
|
||||||
|
renderCommandEncoder.setVertexBytes(
|
||||||
|
pointer.baseAddress!,
|
||||||
|
length: MemoryLayout<Float>.size,
|
||||||
|
index: 4
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderCommandEncoder.setFragmentTexture(texture, index: 0)
|
||||||
|
|
||||||
|
setupSampler(renderCommandEncoder: renderCommandEncoder)
|
||||||
|
|
||||||
|
renderCommandEncoder.drawPrimitives(
|
||||||
|
type: .triangleStrip,
|
||||||
|
vertexStart: 0,
|
||||||
|
vertexCount: 4,
|
||||||
|
instanceCount: particleCount
|
||||||
|
)
|
||||||
|
renderCommandEncoder.endEncoding()
|
||||||
|
|
||||||
|
renderCommandBuffer.present(view.currentDrawable!)
|
||||||
|
renderCommandBuffer.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupSampler(renderCommandEncoder: MTLRenderCommandEncoder) {
|
||||||
|
let samplerDescriptor = MTLSamplerDescriptor()
|
||||||
|
samplerDescriptor.minFilter = .linear
|
||||||
|
samplerDescriptor.magFilter = .linear
|
||||||
|
samplerDescriptor.mipFilter = .notMipmapped
|
||||||
|
samplerDescriptor.sAddressMode = .clampToEdge
|
||||||
|
samplerDescriptor.tAddressMode = .clampToEdge
|
||||||
|
let samplerState = device.makeSamplerState(descriptor: samplerDescriptor)
|
||||||
|
renderCommandEncoder.setFragmentSamplerState(samplerState, index: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ParticleView.Renderer {
|
||||||
|
private func setupVertexBuffer(with device: MTLDevice) {
|
||||||
|
let vertices: [Vertex] = [
|
||||||
|
.init(position: .init(0, 0, 0, 1), uv: .init(0, 0), opacity: .zero),
|
||||||
|
.init(position: .init(1, 0, 0, 1), uv: .init(1, 0), opacity: .zero),
|
||||||
|
.init(position: .init(0, 1, 0, 1), uv: .init(0, 1), opacity: .zero),
|
||||||
|
.init(position: .init(1, 1, 0, 1), uv: .init(1, 1), opacity: .zero),
|
||||||
|
]
|
||||||
|
let vertexBuffer = vertices.withUnsafeBytes { pointer in
|
||||||
|
device.makeBuffer(
|
||||||
|
bytes: pointer.baseAddress!,
|
||||||
|
length: MemoryLayout<Vertex>.stride * vertices.count,
|
||||||
|
options: .storageModeShared
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.vertexBuffer = vertexBuffer!
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupParticleSystem(targetFrame: CGRect, device: MTLDevice) {
|
||||||
|
var particles = [Particle]()
|
||||||
|
let targetFrameHeight = Float(targetFrame.height)
|
||||||
|
let targetFrameWidth = Float(targetFrame.width)
|
||||||
|
let particleStep = 1
|
||||||
|
|
||||||
|
let estimatedParticleCount = 1
|
||||||
|
* Int(targetFrameWidth / Float(particleStep))
|
||||||
|
* Int(targetFrameHeight / Float(particleStep))
|
||||||
|
let pixelMultiplier = 1
|
||||||
|
particles.reserveCapacity(estimatedParticleCount * pixelMultiplier)
|
||||||
|
|
||||||
|
for y in stride(from: 0, to: Int(targetFrameHeight), by: particleStep) {
|
||||||
|
for x in stride(from: 0, to: Int(targetFrameWidth), by: particleStep) {
|
||||||
|
let particle = createParticle(x: x, y: y, step: particleStep)
|
||||||
|
for _ in 0 ..< pixelMultiplier {
|
||||||
|
particles.append(particle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
particleCount = particles.count
|
||||||
|
let particleBuffer = particles.withUnsafeBytes { pointer in
|
||||||
|
device.makeBuffer(
|
||||||
|
bytes: pointer.baseAddress!,
|
||||||
|
length: MemoryLayout<Particle>.stride * particles.count,
|
||||||
|
options: .storageModeShared
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.particleBuffer = particleBuffer!
|
||||||
|
stepSize = Float(particleStep)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createParticle(x: Int, y: Int, step: Int) -> Particle {
|
||||||
|
let particleDuration: Float = .random(in: 20 ... 60)
|
||||||
|
let initialX = Float(x) + Float(step) / 2.0
|
||||||
|
let initialY = Float(y) + Float(step) / 2.0
|
||||||
|
return .init(
|
||||||
|
position: .init(initialX, initialY),
|
||||||
|
velocity: .init(
|
||||||
|
cos(Float.random(in: 0 ... (2 * Float.pi))) * Float.random(in: 1 ... 4),
|
||||||
|
sin(Float.random(in: 0 ... (2 * Float.pi))) * Float.random(in: 1 ... 4) - 2.5
|
||||||
|
),
|
||||||
|
life: simd_float1(particleDuration),
|
||||||
|
duration: simd_float1(particleDuration)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupTexture(from image: CGImage, device: MTLDevice) {
|
||||||
|
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
|
||||||
|
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||||
|
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: nil,
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: 0,
|
||||||
|
space: colorSpace,
|
||||||
|
bitmapInfo: bitmapInfo.rawValue
|
||||||
|
) else { return }
|
||||||
|
context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
|
||||||
|
|
||||||
|
guard let convertedImage = context.makeImage() else { return }
|
||||||
|
|
||||||
|
let textureLoader = MTKTextureLoader(device: device)
|
||||||
|
let textureLoaderOptions: [MTKTextureLoader.Option: Any] = [
|
||||||
|
.textureStorageMode: MTLStorageMode.private.rawValue,
|
||||||
|
.SRGB: false,
|
||||||
|
]
|
||||||
|
guard let texture = try? textureLoader.newTexture(
|
||||||
|
cgImage: convertedImage,
|
||||||
|
options: textureLoaderOptions
|
||||||
|
) else { return }
|
||||||
|
|
||||||
|
self.texture = texture
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finalizeSetup(targetFrame: CGRect, device: MTLDevice) {
|
||||||
|
let targetFrameWidth = Float(targetFrame.width)
|
||||||
|
let targetFrameHeight = Float(targetFrame.height)
|
||||||
|
targetFrameSize = .init(targetFrameWidth, targetFrameHeight)
|
||||||
|
commandQueue = device.makeCommandQueue()!
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//
|
||||||
|
// ParticleView.swift
|
||||||
|
// TrollNFC
|
||||||
|
//
|
||||||
|
// Created by 砍砍 on 6/8/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import MetalKit
|
||||||
|
import simd
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class ParticleView: UIView {
|
||||||
|
private var device: MTLDevice!
|
||||||
|
private var metalView: MTKView!
|
||||||
|
private var renderer = Renderer()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
setupMetalDevice()
|
||||||
|
setupMetalView()
|
||||||
|
setupViewProperties()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupMetalDevice() {
|
||||||
|
guard let device = Self.createSystemDefaultDevice() else {
|
||||||
|
fatalError("failed to create Metal device")
|
||||||
|
}
|
||||||
|
self.device = device
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupMetalView() {
|
||||||
|
metalView = MTKView(frame: .zero, device: device)
|
||||||
|
configureMetalView()
|
||||||
|
addSubview(metalView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureMetalView() {
|
||||||
|
metalView.layer.isOpaque = false
|
||||||
|
metalView.backgroundColor = UIColor.clear
|
||||||
|
metalView.delegate = renderer
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupViewProperties() {
|
||||||
|
clipsToBounds = false
|
||||||
|
metalView.clipsToBounds = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func createSystemDefaultDevice() -> MTLDevice? {
|
||||||
|
MTLCreateSystemDefaultDevice()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder _: NSCoder) {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginWith(
|
||||||
|
_ image: CGImage,
|
||||||
|
targetFrame: CGRect,
|
||||||
|
onComplete: @escaping () -> Void,
|
||||||
|
onFirstFrameRendered: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
renderer.prepareResources(
|
||||||
|
with: device,
|
||||||
|
image: image,
|
||||||
|
targetFrame: targetFrame,
|
||||||
|
onComplete: onComplete,
|
||||||
|
onFirstFrameRendered: onFirstFrameRendered
|
||||||
|
)
|
||||||
|
metalView.draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
let expandedBounds = bounds.insetBy(dx: -bounds.width, dy: -bounds.height)
|
||||||
|
metalView.frame = expandedBounds
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// UIView+createViewSnapshot.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/19/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
public extension UIView {
|
||||||
|
func createViewSnapshot() -> UIImage {
|
||||||
|
let renderer = UIGraphicsImageRenderer(bounds: bounds)
|
||||||
|
return renderer.image { context in
|
||||||
|
// clear the background
|
||||||
|
context.cgContext.setFillColor(UIColor.clear.cgColor)
|
||||||
|
context.cgContext.fill(bounds)
|
||||||
|
|
||||||
|
// MUST USE DRAW HIERARCHY TO RENDER VISUAL EFFECT VIEW
|
||||||
|
self.drawHierarchy(in: bounds, afterScreenUpdates: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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] = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//
|
||||||
|
// AccentColor.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/19/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIColor {
|
||||||
|
static let accent: UIColor = .init(red: 30 / 255, green: 150 / 255, blue: 235 / 255, alpha: 1.0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// Animation.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/19/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
func performWithAnimation(
|
||||||
|
animations: @escaping () -> Void,
|
||||||
|
completion: @escaping (Bool) -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
UIView.animate(
|
||||||
|
withDuration: 0.5,
|
||||||
|
delay: 0,
|
||||||
|
usingSpringWithDamping: 0.8,
|
||||||
|
initialSpringVelocity: 0.8,
|
||||||
|
options: [.beginFromCurrentState, .allowAnimatedContent, .curveEaseInOut],
|
||||||
|
animations: animations,
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
//
|
|
||||||
// UIHostingView.swift
|
|
||||||
// Intelligents
|
|
||||||
//
|
|
||||||
// Created by 秋星桥 on 2024/12/13.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class UIHostingView<Content: View>: UIView {
|
|
||||||
private let hostingViewController: UIHostingController<Content>
|
|
||||||
|
|
||||||
var rootView: Content {
|
|
||||||
get { hostingViewController.rootView }
|
|
||||||
set { hostingViewController.rootView = newValue }
|
|
||||||
}
|
|
||||||
|
|
||||||
override var intrinsicContentSize: CGSize {
|
|
||||||
hostingViewController.view.intrinsicContentSize
|
|
||||||
}
|
|
||||||
|
|
||||||
init(rootView: Content) {
|
|
||||||
hostingViewController = UIHostingController(rootView: rootView)
|
|
||||||
hostingViewController.edgesForExtendedLayout = []
|
|
||||||
hostingViewController.extendedLayoutIncludesOpaqueBars = false
|
|
||||||
super.init(frame: .zero)
|
|
||||||
|
|
||||||
hostingViewController.view?.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
addSubview(hostingViewController.view)
|
|
||||||
if let view = hostingViewController.view {
|
|
||||||
view.removeFromSuperview()
|
|
||||||
view.backgroundColor = .clear
|
|
||||||
view.isOpaque = false
|
|
||||||
addSubview(view)
|
|
||||||
let constraints = [
|
|
||||||
view.topAnchor.constraint(equalTo: topAnchor),
|
|
||||||
view.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
||||||
view.leftAnchor.constraint(equalTo: leftAnchor),
|
|
||||||
view.rightAnchor.constraint(equalTo: rightAnchor),
|
|
||||||
]
|
|
||||||
NSLayoutConstraint.activate(constraints)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
|
||||||
hostingViewController.sizeThatFits(in: size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import Combine
|
||||||
|
import SnapKit
|
||||||
|
import Then
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class InputBox: UIView {
|
||||||
|
weak var delegate: InputBoxDelegate?
|
||||||
|
|
||||||
|
public let viewModel = InputBoxViewModel()
|
||||||
|
var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
lazy var containerView = UIView().then {
|
||||||
|
$0.backgroundColor = UIColor.affineLayerBackgroundPrimary
|
||||||
|
$0.layer.cornerRadius = 16
|
||||||
|
$0.layer.cornerCurve = .continuous
|
||||||
|
$0.layer.borderWidth = 1
|
||||||
|
$0.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
$0.layer.shadowOffset = CGSize(width: 0, height: 0)
|
||||||
|
$0.layer.shadowRadius = 12
|
||||||
|
$0.layer.shadowOpacity = 0.075
|
||||||
|
$0.clipsToBounds = false
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy var textView = UITextView().then {
|
||||||
|
$0.backgroundColor = .clear
|
||||||
|
$0.font = .systemFont(ofSize: 16)
|
||||||
|
$0.textColor = UIColor.affineTextPrimary
|
||||||
|
$0.isScrollEnabled = false
|
||||||
|
$0.textContainer.lineFragmentPadding = 0
|
||||||
|
$0.textContainerInset = .zero
|
||||||
|
$0.delegate = self
|
||||||
|
$0.text = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy var placeholderLabel = UILabel().then {
|
||||||
|
$0.text = "Write your message..."
|
||||||
|
$0.font = .systemFont(ofSize: 16)
|
||||||
|
$0.textColor = UIColor.affineTextPlaceholder
|
||||||
|
$0.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy var functionBar = InputBoxFunctionBar().then {
|
||||||
|
$0.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy var imageBar = InputBoxImageBar().then {
|
||||||
|
$0.imageBarDelegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy var mainStackView = UIStackView().then {
|
||||||
|
$0.axis = .vertical
|
||||||
|
$0.spacing = 16
|
||||||
|
$0.alignment = .fill
|
||||||
|
$0.clipsToBounds = true
|
||||||
|
$0.addArrangedSubview(imageBar)
|
||||||
|
$0.addArrangedSubview(textView)
|
||||||
|
$0.addArrangedSubview(functionBar)
|
||||||
|
}
|
||||||
|
|
||||||
|
var textViewHeightConstraint: Constraint?
|
||||||
|
let minTextViewHeight: CGFloat = 22
|
||||||
|
let maxTextViewHeight: CGFloat = 100
|
||||||
|
|
||||||
|
var text: String {
|
||||||
|
get { textView.text ?? "" }
|
||||||
|
set {
|
||||||
|
textView.text = newValue
|
||||||
|
updatePlaceholderVisibility()
|
||||||
|
updateTextViewHeight()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(frame: CGRect = .zero) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
backgroundColor = .clear
|
||||||
|
addSubview(containerView)
|
||||||
|
containerView.addSubview(mainStackView)
|
||||||
|
containerView.addSubview(placeholderLabel)
|
||||||
|
imageBar.isHidden = true
|
||||||
|
|
||||||
|
containerView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainStackView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
imageBar.snp.makeConstraints { make in
|
||||||
|
make.left.right.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
textView.snp.makeConstraints { make in
|
||||||
|
textViewHeightConstraint = make.height.equalTo(minTextViewHeight).constraint
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholderLabel.snp.makeConstraints { make in
|
||||||
|
make.left.right.equalTo(textView)
|
||||||
|
make.top.equalTo(textView)
|
||||||
|
}
|
||||||
|
|
||||||
|
setupBindings()
|
||||||
|
updatePlaceholderVisibility()
|
||||||
|
updateColors()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder _: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||||
|
super.traitCollectionDidChange(previousTraitCollection)
|
||||||
|
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
|
||||||
|
updateColors()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupBindings() {
|
||||||
|
// 绑定 ViewModel 到 UI
|
||||||
|
viewModel.$inputText
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [weak self] text in
|
||||||
|
if self?.textView.text != text {
|
||||||
|
self?.textView.text = text
|
||||||
|
self?.updatePlaceholderVisibility()
|
||||||
|
self?.updateTextViewHeight()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
viewModel.$isToolEnabled
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [weak self] enabled in
|
||||||
|
self?.functionBar.updateToolState(isEnabled: enabled)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
viewModel.$isNetworkEnabled
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [weak self] enabled in
|
||||||
|
self?.functionBar.updateNetworkState(isEnabled: enabled)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
viewModel.$isDeepThinkingEnabled
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [weak self] enabled in
|
||||||
|
self?.functionBar.updateDeepThinkingState(isEnabled: enabled)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
viewModel.$canSend
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [weak self] canSend in
|
||||||
|
self?.functionBar.updateSendState(canSend: canSend)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
viewModel.$hasAttachments
|
||||||
|
.dropFirst() // for view setup
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [weak self] hasAttachments in
|
||||||
|
performWithAnimation {
|
||||||
|
self?.updateImageBarVisibility(hasAttachments)
|
||||||
|
self?.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
viewModel.$attachments
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [weak self] attachments in
|
||||||
|
self?.updateImageBarContent(attachments)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateTextViewHeight() {
|
||||||
|
let size = textView.sizeThatFits(CGSize(width: textView.frame.width, height: CGFloat.greatestFiniteMagnitude))
|
||||||
|
let newHeight = max(minTextViewHeight, min(maxTextViewHeight, size.height))
|
||||||
|
|
||||||
|
let height = textView.frame.height
|
||||||
|
guard height != newHeight else { return }
|
||||||
|
|
||||||
|
textViewHeightConstraint?.update(offset: newHeight)
|
||||||
|
textView.isScrollEnabled = size.height > maxTextViewHeight
|
||||||
|
|
||||||
|
if height == 0 || superview == nil || window == nil || isHidden { return }
|
||||||
|
|
||||||
|
performWithAnimation {
|
||||||
|
self.layoutIfNeeded()
|
||||||
|
self.superview?.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePlaceholderVisibility() {
|
||||||
|
placeholderLabel.isHidden = !textView.text.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateImageBarVisibility(_ hasAttachments: Bool) {
|
||||||
|
imageBar.isHidden = !hasAttachments
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateImageBarContent(_ attachments: [InputAttachment]) {
|
||||||
|
imageBar.updateImageBarContent(attachments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateColors() {
|
||||||
|
containerView.layer.borderColor = UIColor.affineLayerBorder.cgColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
public func addImageAttachment(_ image: UIImage) {
|
||||||
|
guard let imageData = image.jpegData(compressionQuality: 0.8) else { return }
|
||||||
|
|
||||||
|
let attachment = InputAttachment(
|
||||||
|
type: .image,
|
||||||
|
data: imageData,
|
||||||
|
name: "image.jpg",
|
||||||
|
size: Int64(imageData.count)
|
||||||
|
)
|
||||||
|
|
||||||
|
performWithAnimation { [self] in
|
||||||
|
viewModel.addAttachment(attachment)
|
||||||
|
layoutIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func addFileAttachment(_ url: URL) {
|
||||||
|
guard let fileData = try? Data(contentsOf: url) else { return }
|
||||||
|
|
||||||
|
let attachment = InputAttachment(
|
||||||
|
type: .file,
|
||||||
|
data: fileData,
|
||||||
|
name: url.lastPathComponent,
|
||||||
|
size: Int64(fileData.count)
|
||||||
|
)
|
||||||
|
|
||||||
|
performWithAnimation { [self] in
|
||||||
|
viewModel.addAttachment(attachment)
|
||||||
|
layoutIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var inputBoxData: InputBoxData {
|
||||||
|
viewModel.prepareSendData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - InputBoxFunctionBarDelegate
|
||||||
|
|
||||||
|
extension InputBox: InputBoxFunctionBarDelegate {
|
||||||
|
func functionBarDidTapTakePhoto(_: InputBoxFunctionBar) {
|
||||||
|
delegate?.inputBoxDidSelectTakePhoto(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func functionBarDidTapPhotoLibrary(_: InputBoxFunctionBar) {
|
||||||
|
delegate?.inputBoxDidSelectPhotoLibrary(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func functionBarDidTapAttachFiles(_: InputBoxFunctionBar) {
|
||||||
|
delegate?.inputBoxDidSelectAttachFiles(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func functionBarDidTapEmbedDocs(_: InputBoxFunctionBar) {
|
||||||
|
delegate?.inputBoxDidSelectEmbedDocs(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func functionBarDidTapTool(_: InputBoxFunctionBar) {
|
||||||
|
viewModel.toggleTool()
|
||||||
|
}
|
||||||
|
|
||||||
|
func functionBarDidTapNetwork(_: InputBoxFunctionBar) {
|
||||||
|
viewModel.toggleNetwork()
|
||||||
|
}
|
||||||
|
|
||||||
|
func functionBarDidTapDeepThinking(_: InputBoxFunctionBar) {
|
||||||
|
viewModel.toggleDeepThinking()
|
||||||
|
}
|
||||||
|
|
||||||
|
func functionBarDidTapSend(_: InputBoxFunctionBar) {
|
||||||
|
delegate?.inputBoxDidSend(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "255",
|
||||||
|
"green" : "255",
|
||||||
|
"red" : "255"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "32",
|
||||||
|
"green" : "32",
|
||||||
|
"red" : "32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
//
|
||||||
|
// InputBoxDelegate.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/18/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
protocol InputBoxDelegate: AnyObject {
|
||||||
|
func inputBoxDidSelectTakePhoto(_ inputBox: InputBox)
|
||||||
|
func inputBoxDidSelectPhotoLibrary(_ inputBox: InputBox)
|
||||||
|
func inputBoxDidSelectAttachFiles(_ inputBox: InputBox)
|
||||||
|
func inputBoxDidSelectEmbedDocs(_ inputBox: InputBox)
|
||||||
|
func inputBoxDidSend(_ inputBox: InputBox)
|
||||||
|
func inputBoxTextDidChange(_ text: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension InputBox: InputBoxImageBarDelegate {
|
||||||
|
func inputBoxImageBar(_: InputBoxImageBar, didRemoveImageWithId id: InputAttachment.ID) {
|
||||||
|
performWithAnimation { [self] in
|
||||||
|
viewModel.removeAttachment(withId: id)
|
||||||
|
layoutIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension InputBox: UITextViewDelegate {
|
||||||
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
viewModel.updateText(textView.text ?? "")
|
||||||
|
delegate?.inputBoxTextDidChange(textView.text ?? "")
|
||||||
|
updatePlaceholderVisibility()
|
||||||
|
updateTextViewHeight()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import SnapKit
|
||||||
|
import Then
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
protocol InputBoxFunctionBarDelegate: AnyObject {
|
||||||
|
func functionBarDidTapTakePhoto(_ functionBar: InputBoxFunctionBar)
|
||||||
|
func functionBarDidTapPhotoLibrary(_ functionBar: InputBoxFunctionBar)
|
||||||
|
func functionBarDidTapAttachFiles(_ functionBar: InputBoxFunctionBar)
|
||||||
|
func functionBarDidTapEmbedDocs(_ functionBar: InputBoxFunctionBar)
|
||||||
|
func functionBarDidTapTool(_ functionBar: InputBoxFunctionBar)
|
||||||
|
func functionBarDidTapNetwork(_ functionBar: InputBoxFunctionBar)
|
||||||
|
func functionBarDidTapDeepThinking(_ functionBar: InputBoxFunctionBar)
|
||||||
|
func functionBarDidTapSend(_ functionBar: InputBoxFunctionBar)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let unselectedColor: UIColor = UIColor.affineIconPrimary
|
||||||
|
private let selectedColor: UIColor = UIColor.affineIconActivated
|
||||||
|
|
||||||
|
class InputBoxFunctionBar: UIView {
|
||||||
|
weak var delegate: InputBoxFunctionBarDelegate?
|
||||||
|
|
||||||
|
lazy var attachmentButton = UIButton(type: .system).then {
|
||||||
|
$0.setImage(UIImage.affinePlus, for: .normal)
|
||||||
|
$0.tintColor = unselectedColor
|
||||||
|
$0.layer.borderWidth = 1
|
||||||
|
$0.layer.cornerRadius = 4
|
||||||
|
$0.imageView?.contentMode = .scaleAspectFit
|
||||||
|
$0.showsMenuAsPrimaryAction = true
|
||||||
|
$0.menu = createAttachmentMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy var toolButton = UIButton(type: .system).then {
|
||||||
|
$0.setImage(UIImage.affineTools, for: .normal)
|
||||||
|
$0.tintColor = unselectedColor
|
||||||
|
$0.imageView?.contentMode = .scaleAspectFit
|
||||||
|
$0.addTarget(self, action: #selector(toolButtonTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy var networkButton = UIButton(type: .system).then {
|
||||||
|
$0.setImage(UIImage.affineWeb, for: .normal)
|
||||||
|
$0.tintColor = unselectedColor
|
||||||
|
$0.imageView?.contentMode = .scaleAspectFit
|
||||||
|
$0.addTarget(self, action: #selector(networkButtonTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy var deepThinkingButton = UIButton(type: .system).then {
|
||||||
|
$0.setImage(UIImage.affineThink, for: .normal)
|
||||||
|
$0.tintColor = unselectedColor
|
||||||
|
$0.imageView?.contentMode = .scaleAspectFit
|
||||||
|
$0.addTarget(self, action: #selector(deepThinkingButtonTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy var sendButton = UIButton(type: .system).then {
|
||||||
|
$0.setImage(UIImage.affineArrowUpBig, for: .normal)
|
||||||
|
$0.tintColor = UIColor.affineTextPureWhite
|
||||||
|
$0.backgroundColor = UIColor.affineButtonPrimary
|
||||||
|
$0.imageView?.contentMode = .scaleAspectFit
|
||||||
|
$0.addTarget(self, action: #selector(sendButtonTapped), for: .touchUpInside)
|
||||||
|
$0.clipsToBounds = true
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy var leftButtonsStackView = UIStackView().then {
|
||||||
|
$0.axis = .horizontal
|
||||||
|
$0.spacing = 16
|
||||||
|
$0.alignment = .center
|
||||||
|
$0.addArrangedSubview(attachmentButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy var rightButtonsStackView = UIStackView().then {
|
||||||
|
$0.axis = .horizontal
|
||||||
|
$0.spacing = 16
|
||||||
|
$0.alignment = .center
|
||||||
|
$0.addArrangedSubview(toolButton)
|
||||||
|
$0.addArrangedSubview(networkButton)
|
||||||
|
$0.addArrangedSubview(deepThinkingButton)
|
||||||
|
$0.addArrangedSubview(sendButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy var stackView = UIStackView().then {
|
||||||
|
$0.axis = .horizontal
|
||||||
|
$0.spacing = 12
|
||||||
|
$0.alignment = .center
|
||||||
|
$0.addArrangedSubview(leftButtonsStackView)
|
||||||
|
$0.addArrangedSubview(UIView()) // spacer
|
||||||
|
$0.addArrangedSubview(rightButtonsStackView)
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
addSubview(stackView)
|
||||||
|
stackView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
for button in [attachmentButton, toolButton, networkButton, deepThinkingButton, sendButton] {
|
||||||
|
button.snp.makeConstraints { make in
|
||||||
|
make.width.height.equalTo(32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateColors()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder _: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
sendButton.layer.cornerRadius = sendButton.bounds.height / 2
|
||||||
|
for button in [toolButton, networkButton, deepThinkingButton] {
|
||||||
|
button.layer.cornerRadius = button.bounds.height / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||||
|
super.traitCollectionDidChange(previousTraitCollection)
|
||||||
|
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
|
||||||
|
updateColors()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
func updateToolState(isEnabled: Bool) {
|
||||||
|
toolButton.tintColor = isEnabled ? selectedColor : unselectedColor
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNetworkState(isEnabled: Bool) {
|
||||||
|
networkButton.tintColor = isEnabled ? selectedColor : unselectedColor
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDeepThinkingState(isEnabled: Bool) {
|
||||||
|
deepThinkingButton.tintColor = isEnabled ? selectedColor : unselectedColor
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSendState(canSend: Bool) {
|
||||||
|
sendButton.isEnabled = canSend
|
||||||
|
sendButton.alpha = canSend ? 1.0 : 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
private func updateColors() {
|
||||||
|
attachmentButton.layer.borderColor = UIColor.affineLayerBorder.cgColor
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createAttachmentMenu() -> UIMenu {
|
||||||
|
let takePhotoAction = UIAction(
|
||||||
|
title: "Take Photo or Video",
|
||||||
|
image: UIImage.affineCamera
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
delegate?.functionBarDidTapTakePhoto(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
let photoLibraryAction = UIAction(
|
||||||
|
title: "Photo Library",
|
||||||
|
image: UIImage.affineImage
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
delegate?.functionBarDidTapPhotoLibrary(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
let attachFilesAction = UIAction(
|
||||||
|
title: "Attach Files (pdf, txt, csv)",
|
||||||
|
image: UIImage.affineUpload
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
delegate?.functionBarDidTapAttachFiles(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
let embedDocsAction = UIAction(
|
||||||
|
title: "Embed AFFINE Docs",
|
||||||
|
image: UIImage.affinePage
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
delegate?.functionBarDidTapEmbedDocs(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
return UIMenu(
|
||||||
|
options: [.displayInline],
|
||||||
|
children: [takePhotoAction, photoLibraryAction, attachFilesAction, embedDocsAction].reversed()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
@objc private func toolButtonTapped() {
|
||||||
|
delegate?.functionBarDidTapTool(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func networkButtonTapped() {
|
||||||
|
delegate?.functionBarDidTapNetwork(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func deepThinkingButtonTapped() {
|
||||||
|
delegate?.functionBarDidTapDeepThinking(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func sendButtonTapped() {
|
||||||
|
delegate?.functionBarDidTapSend(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
//
|
||||||
|
// InputBoxImageBar.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/18/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import Then
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
protocol InputBoxImageBarDelegate: AnyObject {
|
||||||
|
func inputBoxImageBar(_ imageBar: InputBoxImageBar, didRemoveImageWithId id: UUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class AttachmentViewModel {
|
||||||
|
let attachment: InputAttachment
|
||||||
|
let imageCell: InputBoxImageBar.ImageCell
|
||||||
|
|
||||||
|
init(attachment: InputAttachment, imageCell: InputBoxImageBar.ImageCell) {
|
||||||
|
self.attachment = attachment
|
||||||
|
self.imageCell = imageCell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InputBoxImageBar: UIScrollView {
|
||||||
|
weak var imageBarDelegate: InputBoxImageBarDelegate?
|
||||||
|
|
||||||
|
private var attachmentViewModels: [AttachmentViewModel] = []
|
||||||
|
private let cellSpacing: CGFloat = 8
|
||||||
|
private let constantHeight: CGFloat = 80
|
||||||
|
|
||||||
|
override init(frame: CGRect = .zero) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
showsHorizontalScrollIndicator = false
|
||||||
|
showsVerticalScrollIndicator = false
|
||||||
|
|
||||||
|
snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(constantHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder _: NSCoder) {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateImageBarContent(_ attachments: [InputAttachment]) {
|
||||||
|
let currentIds = Set(attachmentViewModels.map(\.attachment.id))
|
||||||
|
let imageAttachments = attachments.filter { $0.type == .image }
|
||||||
|
let newIds = Set(imageAttachments.map(\.id))
|
||||||
|
|
||||||
|
// 移除不再存在的附件
|
||||||
|
let idsToRemove = currentIds.subtracting(newIds)
|
||||||
|
for id in idsToRemove {
|
||||||
|
if let index = attachmentViewModels.firstIndex(where: { $0.attachment.id == id }) {
|
||||||
|
let viewModel = attachmentViewModels.remove(at: index)
|
||||||
|
viewModel.imageCell.removeFromSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加新的附件
|
||||||
|
let idsToAdd = newIds.subtracting(currentIds)
|
||||||
|
var initialXOffset = attachmentViewModels.reduce(0) { $0 + $1.imageCell.frame.width + cellSpacing }
|
||||||
|
for attachment in imageAttachments {
|
||||||
|
if idsToAdd.contains(attachment.id),
|
||||||
|
let data = attachment.data,
|
||||||
|
let image = UIImage(data: data)
|
||||||
|
{
|
||||||
|
let imageCell = ImageCell(
|
||||||
|
// for animation to work
|
||||||
|
frame: .init(x: initialXOffset, y: 0, width: constantHeight, height: constantHeight),
|
||||||
|
image: image,
|
||||||
|
attachmentId: attachment.id
|
||||||
|
)
|
||||||
|
initialXOffset += constantHeight + cellSpacing
|
||||||
|
imageCell.onRemove = { [weak self] cell in
|
||||||
|
self?.removeImageCell(cell)
|
||||||
|
}
|
||||||
|
imageCell.alpha = 0
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
performWithAnimation { imageCell.alpha = 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
let viewModel = AttachmentViewModel(attachment: attachment, imageCell: imageCell)
|
||||||
|
attachmentViewModels.append(viewModel)
|
||||||
|
addSubview(imageCell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutImageCells()
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeImageCell(_ cell: ImageCell) {
|
||||||
|
if let index = attachmentViewModels.firstIndex(where: { $0.imageCell === cell }) {
|
||||||
|
let viewModel = attachmentViewModels.remove(at: index)
|
||||||
|
viewModel.imageCell.removeFromSuperviewWithExplodeEffect()
|
||||||
|
imageBarDelegate?.inputBoxImageBar(self, didRemoveImageWithId: cell.attachmentId)
|
||||||
|
layoutImageCells()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
for viewModel in attachmentViewModels {
|
||||||
|
viewModel.imageCell.removeFromSuperview()
|
||||||
|
}
|
||||||
|
attachmentViewModels.removeAll()
|
||||||
|
contentSize = .zero
|
||||||
|
}
|
||||||
|
|
||||||
|
private func layoutImageCells() {
|
||||||
|
var xOffset: CGFloat = 0
|
||||||
|
|
||||||
|
for viewModel in attachmentViewModels {
|
||||||
|
viewModel.imageCell.frame = CGRect(x: xOffset, y: 0, width: constantHeight, height: constantHeight)
|
||||||
|
xOffset += constantHeight + cellSpacing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update content size
|
||||||
|
let totalWidth = max(0, xOffset - cellSpacing)
|
||||||
|
contentSize = CGSize(width: totalWidth, height: constantHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension InputBoxImageBar {
|
||||||
|
class ImageCell: UIView {
|
||||||
|
let attachmentId: UUID
|
||||||
|
var onRemove: ((ImageCell) -> Void)?
|
||||||
|
|
||||||
|
private lazy var imageView = UIImageView(frame: bounds).then {
|
||||||
|
$0.contentMode = .scaleAspectFill
|
||||||
|
$0.clipsToBounds = true
|
||||||
|
$0.layer.cornerRadius = 12
|
||||||
|
$0.layer.cornerCurve = .continuous
|
||||||
|
$0.backgroundColor = .systemGray6
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var removeButton = DeleteButtonView(frame: removeButtonFrame).then {
|
||||||
|
$0.onTapped = { [weak self] in
|
||||||
|
self?.removeButtonTapped()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(frame: CGRect, image: UIImage, attachmentId: UUID) {
|
||||||
|
self.attachmentId = attachmentId
|
||||||
|
super.init(frame: frame)
|
||||||
|
addSubview(imageView)
|
||||||
|
addSubview(removeButton)
|
||||||
|
imageView.image = image
|
||||||
|
}
|
||||||
|
|
||||||
|
var removeButtonFrame: CGRect {
|
||||||
|
let buttonSize: CGFloat = 18
|
||||||
|
let buttonInset: CGFloat = 6
|
||||||
|
return CGRect(
|
||||||
|
x: bounds.width - buttonSize - buttonInset,
|
||||||
|
y: buttonInset,
|
||||||
|
width: buttonSize,
|
||||||
|
height: buttonSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
|
||||||
|
imageView.frame = bounds
|
||||||
|
|
||||||
|
removeButton.frame = removeButtonFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder _: NSCoder) {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func removeButtonTapped() {
|
||||||
|
onRemove?(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
//
|
||||||
|
// InputBoxViewModel.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by AI Assistant on 6/17/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Data Models
|
||||||
|
|
||||||
|
public struct InputAttachment: Identifiable, Equatable, Hashable, Codable {
|
||||||
|
public var id: UUID = .init()
|
||||||
|
public var type: AttachmentType
|
||||||
|
public var data: Data?
|
||||||
|
public var url: URL?
|
||||||
|
public var name: String
|
||||||
|
public var size: Int64
|
||||||
|
|
||||||
|
public enum AttachmentType: String, Equatable, Hashable, Codable {
|
||||||
|
case image
|
||||||
|
case document
|
||||||
|
case file
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
type: AttachmentType,
|
||||||
|
data: Data? = nil,
|
||||||
|
url: URL? = nil,
|
||||||
|
name: String,
|
||||||
|
size: Int64 = 0
|
||||||
|
) {
|
||||||
|
self.type = type
|
||||||
|
self.data = data
|
||||||
|
self.url = url
|
||||||
|
self.name = name
|
||||||
|
self.size = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct InputBoxData {
|
||||||
|
public var text: String
|
||||||
|
public var attachments: [InputAttachment]
|
||||||
|
public var isToolEnabled: Bool
|
||||||
|
public var isNetworkEnabled: Bool
|
||||||
|
public var isDeepThinkingEnabled: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
text: String,
|
||||||
|
attachments: [InputAttachment],
|
||||||
|
isToolEnabled: Bool,
|
||||||
|
isNetworkEnabled: Bool,
|
||||||
|
isDeepThinkingEnabled: Bool
|
||||||
|
) {
|
||||||
|
self.text = text
|
||||||
|
self.attachments = attachments
|
||||||
|
self.isToolEnabled = isToolEnabled
|
||||||
|
self.isNetworkEnabled = isNetworkEnabled
|
||||||
|
self.isDeepThinkingEnabled = isDeepThinkingEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View Model
|
||||||
|
|
||||||
|
public class InputBoxViewModel: ObservableObject {
|
||||||
|
// MARK: - Published Properties
|
||||||
|
|
||||||
|
@Published public var inputText: String = ""
|
||||||
|
@Published public var isToolEnabled: Bool = false
|
||||||
|
@Published public var isNetworkEnabled: Bool = false
|
||||||
|
@Published public var isDeepThinkingEnabled: Bool = false
|
||||||
|
@Published public var hasAttachments: Bool = false
|
||||||
|
@Published public var attachments: [InputAttachment] = []
|
||||||
|
@Published public var canSend: Bool = false
|
||||||
|
|
||||||
|
// MARK: - Private Properties
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
setupBindings()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
private func setupBindings() {
|
||||||
|
// 监听文本变化,自动更新发送按钮状态
|
||||||
|
$inputText
|
||||||
|
.map { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||||
|
.assign(to: \.canSend, on: self)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// 监听附件变化
|
||||||
|
$attachments
|
||||||
|
.map { !$0.isEmpty }
|
||||||
|
.assign(to: \.hasAttachments, on: self)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Text Management
|
||||||
|
|
||||||
|
public extension InputBoxViewModel {
|
||||||
|
func updateText(_ text: String) {
|
||||||
|
inputText = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Feature Toggles
|
||||||
|
|
||||||
|
public extension InputBoxViewModel {
|
||||||
|
func toggleTool() {
|
||||||
|
isToolEnabled.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleNetwork() {
|
||||||
|
isNetworkEnabled.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleDeepThinking() {
|
||||||
|
isDeepThinkingEnabled.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Attachment Management
|
||||||
|
|
||||||
|
public extension InputBoxViewModel {
|
||||||
|
func addAttachment(_ attachment: InputAttachment) {
|
||||||
|
attachments.append(attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAttachment(withId id: UUID) {
|
||||||
|
attachments.removeAll { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAttachments() {
|
||||||
|
attachments.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Send Management
|
||||||
|
|
||||||
|
public extension InputBoxViewModel {
|
||||||
|
func prepareSendData() -> InputBoxData {
|
||||||
|
InputBoxData(
|
||||||
|
text: inputText.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
attachments: attachments,
|
||||||
|
isToolEnabled: isToolEnabled,
|
||||||
|
isNetworkEnabled: isNetworkEnabled,
|
||||||
|
isDeepThinkingEnabled: isDeepThinkingEnabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetInput() {
|
||||||
|
inputText = ""
|
||||||
|
attachments.removeAll()
|
||||||
|
isToolEnabled = false
|
||||||
|
isNetworkEnabled = false
|
||||||
|
isDeepThinkingEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
// Created by 秋星桥 on 2024/11/18.
|
// Created by 秋星桥 on 2024/11/18.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
public extension UIViewController {
|
public extension UIViewController {
|
||||||
@@ -16,15 +17,15 @@ public extension UIViewController {
|
|||||||
let button = IntelligentsButton()
|
let button = IntelligentsButton()
|
||||||
view.addSubview(button)
|
view.addSubview(button)
|
||||||
view.bringSubviewToFront(button)
|
view.bringSubviewToFront(button)
|
||||||
button.translatesAutoresizingMaskIntoConstraints = false
|
button.snp.makeConstraints { make in
|
||||||
[
|
make.trailing.equalTo(view.safeAreaLayoutGuide).offset(-20)
|
||||||
button.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -20),
|
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-20 - 44)
|
||||||
button.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20 - Constant.affineTabbarHeight),
|
make.width.height.equalTo(50)
|
||||||
button.widthAnchor.constraint(equalToConstant: 50),
|
}
|
||||||
button.heightAnchor.constraint(equalToConstant: 50),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
button.transform = .init(scaleX: 0, y: 0)
|
button.transform = .init(scaleX: 0, y: 0)
|
||||||
view.layoutIfNeeded()
|
if view.frame != .zero {
|
||||||
|
view.layoutIfNeeded()
|
||||||
|
}
|
||||||
return button
|
return button
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,12 +48,7 @@ public extension UIViewController {
|
|||||||
button.stopProgress()
|
button.stopProgress()
|
||||||
view.layoutIfNeeded()
|
view.layoutIfNeeded()
|
||||||
|
|
||||||
UIView.animate(
|
performWithAnimation {
|
||||||
withDuration: 0.5,
|
|
||||||
delay: 0,
|
|
||||||
usingSpringWithDamping: 1.0,
|
|
||||||
initialSpringVelocity: 0.8
|
|
||||||
) {
|
|
||||||
button.alpha = 1
|
button.alpha = 1
|
||||||
button.transform = .identity
|
button.transform = .identity
|
||||||
button.setNeedsLayout()
|
button.setNeedsLayout()
|
||||||
@@ -77,12 +73,7 @@ public extension UIViewController {
|
|||||||
button.stopProgress()
|
button.stopProgress()
|
||||||
button.setNeedsLayout()
|
button.setNeedsLayout()
|
||||||
view.layoutIfNeeded()
|
view.layoutIfNeeded()
|
||||||
UIView.animate(
|
performWithAnimation {
|
||||||
withDuration: 0.5,
|
|
||||||
delay: 0,
|
|
||||||
usingSpringWithDamping: 1.0,
|
|
||||||
initialSpringVelocity: 0.8
|
|
||||||
) {
|
|
||||||
button.alpha = 0
|
button.alpha = 0
|
||||||
button.transform = .init(scaleX: 0, y: 0)
|
button.transform = .init(scaleX: 0, y: 0)
|
||||||
button.setNeedsLayout()
|
button.setNeedsLayout()
|
||||||
@@ -5,13 +5,26 @@
|
|||||||
// Created by 秋星桥 on 2024/11/18.
|
// Created by 秋星桥 on 2024/11/18.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import SwifterSwift
|
||||||
|
import Then
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
// floating button to open intelligent panel
|
// floating button to open intelligent panel
|
||||||
public class IntelligentsButton: UIView {
|
public class IntelligentsButton: UIView {
|
||||||
let image = UIImageView()
|
lazy var image = UIImageView().then {
|
||||||
let background = UIView()
|
$0.image = .init(named: "spark", in: .module, with: .none)
|
||||||
let activityIndicator = UIActivityIndicatorView()
|
$0.contentMode = .scaleAspectFit
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy var background = UIView().then {
|
||||||
|
$0.backgroundColor = .init(
|
||||||
|
light: .systemBackground,
|
||||||
|
dark: .darkGray.withAlphaComponent(0.25)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy var activityIndicator = UIActivityIndicatorView()
|
||||||
|
|
||||||
public weak var delegate: (any IntelligentsButtonDelegate)? = nil {
|
public weak var delegate: (any IntelligentsButtonDelegate)? = nil {
|
||||||
didSet { assert(Thread.isMainThread) }
|
didSet { assert(Thread.isMainThread) }
|
||||||
@@ -19,45 +32,10 @@ public class IntelligentsButton: UIView {
|
|||||||
|
|
||||||
public init() {
|
public init() {
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
|
setupViews()
|
||||||
background.backgroundColor = .white
|
setupConstraints()
|
||||||
addSubview(background)
|
setupGesture()
|
||||||
background.translatesAutoresizingMaskIntoConstraints = false
|
setupAppearance()
|
||||||
[
|
|
||||||
background.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
||||||
background.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
||||||
background.topAnchor.constraint(equalTo: topAnchor),
|
|
||||||
background.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
image.image = .init(named: "spark", in: .module, with: .none)
|
|
||||||
image.contentMode = .scaleAspectFit
|
|
||||||
image.tintColor = .accent
|
|
||||||
addSubview(image)
|
|
||||||
let imageInsetValue: CGFloat = 12
|
|
||||||
image.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
[
|
|
||||||
image.leadingAnchor.constraint(equalTo: leadingAnchor, constant: imageInsetValue),
|
|
||||||
image.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -imageInsetValue),
|
|
||||||
image.topAnchor.constraint(equalTo: topAnchor, constant: imageInsetValue),
|
|
||||||
image.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -imageInsetValue),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
addSubview(activityIndicator)
|
|
||||||
[
|
|
||||||
activityIndicator.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
||||||
activityIndicator.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
||||||
].forEach { $0.isActive = true }
|
|
||||||
|
|
||||||
clipsToBounds = true
|
|
||||||
layer.borderWidth = 2
|
|
||||||
layer.borderColor = UIColor.gray.withAlphaComponent(0.1).cgColor
|
|
||||||
|
|
||||||
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
|
|
||||||
addGestureRecognizer(tap)
|
|
||||||
isUserInteractionEnabled = true
|
|
||||||
|
|
||||||
stopProgress()
|
stopProgress()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,3 +75,39 @@ public class IntelligentsButton: UIView {
|
|||||||
image.isHidden = false
|
image.isHidden = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Setup Methods
|
||||||
|
|
||||||
|
private extension IntelligentsButton {
|
||||||
|
func setupViews() {
|
||||||
|
addSubview(background)
|
||||||
|
addSubview(image)
|
||||||
|
addSubview(activityIndicator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupConstraints() {
|
||||||
|
background.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
image.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
activityIndicator.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupGesture() {
|
||||||
|
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
|
||||||
|
addGestureRecognizer(tap)
|
||||||
|
isUserInteractionEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupAppearance() {
|
||||||
|
clipsToBounds = true
|
||||||
|
layer.borderWidth = 2
|
||||||
|
layer.borderColor = UIColor.gray.withAlphaComponent(0.1).cgColor
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import SnapKit
|
||||||
|
import Then
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
protocol MainHeaderViewDelegate: AnyObject {
|
||||||
|
func mainHeaderViewDidTapClose()
|
||||||
|
func mainHeaderViewDidTapDropdown()
|
||||||
|
func mainHeaderViewDidTapMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
class MainHeaderView: UIView {
|
||||||
|
weak var delegate: MainHeaderViewDelegate?
|
||||||
|
|
||||||
|
private lazy var closeButton = UIButton(type: .system).then {
|
||||||
|
$0.imageView?.contentMode = .scaleAspectFit
|
||||||
|
$0.setImage(UIImage.affineClose, for: .normal)
|
||||||
|
$0.tintColor = UIColor.affineIconPrimary
|
||||||
|
$0.backgroundColor = UIColor.affineLayerBackgroundSecondary
|
||||||
|
$0.layer.cornerRadius = 8
|
||||||
|
$0.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside)
|
||||||
|
$0.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var titleLabel = UILabel().then {
|
||||||
|
$0.text = "AFFiNE AI"
|
||||||
|
$0.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
|
$0.textColor = UIColor.affineTextPrimary
|
||||||
|
$0.textAlignment = .center
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var dropdownButton = UIButton(type: .system).then {
|
||||||
|
$0.imageView?.contentMode = .scaleAspectFit
|
||||||
|
$0.setImage(UIImage.affineArrowDown, for: .normal)
|
||||||
|
$0.tintColor = UIColor.affineIconPrimary
|
||||||
|
$0.addTarget(self, action: #selector(dropdownButtonTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var centerStackView = UIStackView().then {
|
||||||
|
$0.axis = .horizontal
|
||||||
|
$0.spacing = 8
|
||||||
|
$0.alignment = .center
|
||||||
|
$0.addArrangedSubview(titleLabel)
|
||||||
|
$0.addArrangedSubview(dropdownButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var menuButton = UIButton(type: .system).then {
|
||||||
|
$0.imageView?.contentMode = .scaleAspectFit
|
||||||
|
$0.setImage(UIImage.affineMore, for: .normal)
|
||||||
|
$0.tintColor = UIColor.affineIconPrimary
|
||||||
|
$0.backgroundColor = UIColor.affineLayerBackgroundSecondary
|
||||||
|
$0.layer.cornerRadius = 8
|
||||||
|
$0.addTarget(self, action: #selector(menuButtonTapped), for: .touchUpInside)
|
||||||
|
$0.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var leftSpacerView = UIView()
|
||||||
|
|
||||||
|
private lazy var rightSpacerView = UIView()
|
||||||
|
|
||||||
|
private lazy var mainStackView = UIStackView().then {
|
||||||
|
$0.axis = .horizontal
|
||||||
|
$0.alignment = .center
|
||||||
|
$0.distribution = .fill
|
||||||
|
$0.spacing = 16
|
||||||
|
$0.addArrangedSubview(closeButton)
|
||||||
|
$0.addArrangedSubview(leftSpacerView)
|
||||||
|
$0.addArrangedSubview(centerStackView)
|
||||||
|
$0.addArrangedSubview(rightSpacerView)
|
||||||
|
$0.addArrangedSubview(menuButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
backgroundColor = UIColor.affineLayerBackgroundPrimary
|
||||||
|
addSubview(mainStackView)
|
||||||
|
|
||||||
|
mainStackView.snp.makeConstraints { make in
|
||||||
|
make.left.right.equalToSuperview().inset(16)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
closeButton.snp.makeConstraints { make in
|
||||||
|
make.size.equalTo(titleLabel.font.pointSize + 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
menuButton.snp.makeConstraints { make in
|
||||||
|
make.size.equalTo(titleLabel.font.pointSize + 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdownButton.snp.makeConstraints { make in
|
||||||
|
make.size.equalTo(titleLabel.font.pointSize + 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure center stack to be center
|
||||||
|
leftSpacerView.snp.makeConstraints { make in
|
||||||
|
make.width.equalTo(rightSpacerView)
|
||||||
|
}
|
||||||
|
|
||||||
|
snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(52)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func closeButtonTapped() {
|
||||||
|
delegate?.mainHeaderViewDidTapClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func dropdownButtonTapped() {
|
||||||
|
delegate?.mainHeaderViewDidTapDropdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func menuButtonTapped() {
|
||||||
|
delegate?.mainHeaderViewDidTapMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
class DeleteButtonView: UIView {
|
||||||
|
let imageView = UIImageView(image: .init(systemName: "xmark")).then {
|
||||||
|
$0.tintColor = .white
|
||||||
|
$0.contentMode = .scaleAspectFit
|
||||||
|
}
|
||||||
|
|
||||||
|
let blur = UIVisualEffectView(
|
||||||
|
effect: UIBlurEffect(style: .systemUltraThinMaterialDark)
|
||||||
|
).then {
|
||||||
|
$0.clipsToBounds = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var onTapped: () -> Void = {}
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
isUserInteractionEnabled = true
|
||||||
|
|
||||||
|
addSubview(blur)
|
||||||
|
addSubview(imageView)
|
||||||
|
|
||||||
|
blur.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
imageView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
let gesture = UITapGestureRecognizer(target: self, action: #selector(tapped))
|
||||||
|
addGestureRecognizer(gesture)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder _: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
||||||
|
if bounds.width < 50 || bounds.height < 50 {
|
||||||
|
return bounds.insetBy(dx: -20, dy: -20).contains(point)
|
||||||
|
}
|
||||||
|
return super.point(inside: point, with: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
blur.layer.cornerRadius = min(bounds.width, bounds.height) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func tapped() {
|
||||||
|
onTapped()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
//
|
||||||
|
// IntelligentContext.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/17/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
public class IntelligentContext {
|
||||||
|
// shared across the app, we expect our app to have a single context and webview
|
||||||
|
public static let shared = IntelligentContext()
|
||||||
|
|
||||||
|
public var webView: WKWebView!
|
||||||
|
|
||||||
|
public lazy var temporaryDirectory: URL = {
|
||||||
|
let tempDir = FileManager.default.temporaryDirectory
|
||||||
|
return tempDir.appendingPathComponent("IntelligentContext")
|
||||||
|
}()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
public func preparePresent(_ completion: @escaping () -> Void) {
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
||||||
|
prepareTemporaryDirectory()
|
||||||
|
// TODO: used to gathering information, populate content from webview, etc.
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareTemporaryDirectory() {
|
||||||
|
if FileManager.default.fileExists(atPath: temporaryDirectory.path) {
|
||||||
|
try? FileManager.default.removeItem(at: temporaryDirectory)
|
||||||
|
}
|
||||||
|
try? FileManager.default.createDirectory(
|
||||||
|
at: temporaryDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIColor {
|
||||||
|
/// Primary icon color
|
||||||
|
static var affineIconPrimary: UIColor {
|
||||||
|
UIColor(named: "affine.icon.primary", in: .module, compatibleWith: nil) ?? .black
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Primary background layer color
|
||||||
|
static var affineLayerBackgroundPrimary: UIColor {
|
||||||
|
UIColor(named: "affine.layer.background.primary", in: .module, compatibleWith: nil) ?? .white
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secondary background layer color
|
||||||
|
static var affineLayerBackgroundSecondary: UIColor {
|
||||||
|
UIColor(named: "affine.layer.background.secondary", in: .module, compatibleWith: nil) ?? .systemGray6
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Border layer color
|
||||||
|
static var affineLayerBorder: UIColor {
|
||||||
|
UIColor(named: "affine.layer.border", in: .module, compatibleWith: nil) ?? .gray
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure white layer color
|
||||||
|
static var affineLayerPureWhite: UIColor {
|
||||||
|
UIColor(named: "affine.layer.pureWhite", in: .module, compatibleWith: nil) ?? .white
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Primary button color
|
||||||
|
static var affineButtonPrimary: UIColor {
|
||||||
|
UIColor(named: "affine.button.primary", in: .module, compatibleWith: nil) ?? .blue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activated icon color
|
||||||
|
static var affineIconActivated: UIColor {
|
||||||
|
UIColor(named: "affine.icon.activated", in: .module, compatibleWith: nil) ?? .blue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text emphasis color
|
||||||
|
static var affineTextEmphasis: UIColor {
|
||||||
|
UIColor(named: "affine.text.emphasis", in: .module, compatibleWith: nil) ?? .blue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text link color
|
||||||
|
static var affineTextLink: UIColor {
|
||||||
|
UIColor(named: "affine.text.link", in: .module, compatibleWith: nil) ?? .blue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List dot and number color
|
||||||
|
static var affineTextListDotAndNumber: UIColor {
|
||||||
|
UIColor(named: "affine.text.listDotAndNumber", in: .module, compatibleWith: nil) ?? .blue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder text color
|
||||||
|
static var affineTextPlaceholder: UIColor {
|
||||||
|
UIColor(named: "affine.text.placeholder", in: .module, compatibleWith: nil) ?? .gray
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Primary text color
|
||||||
|
static var affineTextPrimary: UIColor {
|
||||||
|
UIColor(named: "affine.text.primary", in: .module, compatibleWith: nil) ?? .black
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure white text color
|
||||||
|
static var affineTextPureWhite: UIColor {
|
||||||
|
UIColor(named: "affine.text.pureWhite", in: .module, compatibleWith: nil) ?? .white
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secondary text color
|
||||||
|
static var affineTextSecondary: UIColor {
|
||||||
|
UIColor(named: "affine.text.secondary", in: .module, compatibleWith: nil) ?? .gray
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tertiary text color
|
||||||
|
static var affineTextTertiary: UIColor {
|
||||||
|
UIColor(named: "affine.text.tertiary", in: .module, compatibleWith: nil) ?? .gray
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIImage {
|
||||||
|
/// Check circle icon
|
||||||
|
static var affineCheckCircle: UIImage {
|
||||||
|
UIImage(named: "CheckCircle", in: .module, compatibleWith: nil) ?? UIImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// More options icon
|
||||||
|
static var affineMore: UIImage {
|
||||||
|
UIImage(named: "More", in: .module, compatibleWith: nil) ?? UIImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arrow down icon
|
||||||
|
static var affineArrowDown: UIImage {
|
||||||
|
UIImage(named: "ArrowDown", in: .module, compatibleWith: nil) ?? UIImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tools icon
|
||||||
|
static var affineTools: UIImage {
|
||||||
|
UIImage(named: "Tools", in: .module, compatibleWith: nil) ?? UIImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Box icon
|
||||||
|
static var affineBox: UIImage {
|
||||||
|
UIImage(named: "Box", in: .module, compatibleWith: nil) ?? UIImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Think icon
|
||||||
|
static var affineThink: UIImage {
|
||||||
|
UIImage(named: "Think", in: .module, compatibleWith: nil) ?? UIImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Web icon
|
||||||
|
static var affineWeb: UIImage {
|
||||||
|
UIImage(named: "Web", in: .module, compatibleWith: nil) ?? UIImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calendar icon
|
||||||
|
static var affineCalendar: UIImage {
|
||||||
|
UIImage(named: "Calendar", in: .module, compatibleWith: nil) ?? UIImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Camera icon
|
||||||
|
static var affineCamera: UIImage {
|
||||||
|
UIImage(named: "Camera", in: .module, compatibleWith: nil) ?? UIImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close icon
|
||||||
|
static var affineClose: UIImage {
|
||||||
|
UIImage(named: "Close", in: .module, compatibleWith: nil) ?? UIImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Big arrow up icon
|
||||||
|
static var affineArrowUpBig: UIImage {
|
||||||
|
UIImage(named: "ArrowUpBig", in: .module, compatibleWith: nil) ?? UIImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broom icon
|
||||||
|
static var affineBroom: UIImage {
|
||||||
|
UIImage(named: "Broom", in: .module, compatibleWith: nil) ?? UIImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bubble icon
|
||||||
|
static var affineBubble: UIImage {
|
||||||
|
UIImage(named: "Bubble", in: .module, compatibleWith: nil) ?? UIImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Image icon
|
||||||
|
static var affineImage: UIImage {
|
||||||
|
UIImage(named: "Image", in: .module, compatibleWith: nil) ?? UIImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page icon
|
||||||
|
static var affinePage: UIImage {
|
||||||
|
UIImage(named: "Page", in: .module, compatibleWith: nil) ?? UIImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plus icon
|
||||||
|
static var affinePlus: UIImage {
|
||||||
|
UIImage(named: "Plus", in: .module, compatibleWith: nil) ?? UIImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settings icon
|
||||||
|
static var affineSettings: UIImage {
|
||||||
|
UIImage(named: "Settings", in: .module, compatibleWith: nil) ?? UIImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload icon
|
||||||
|
static var affineUpload: UIImage {
|
||||||
|
UIImage(named: "Upload", in: .module, compatibleWith: nil) ?? UIImage()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0xEB",
|
||||||
|
"green" : "0x96",
|
||||||
|
"red" : "0x1E"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0xEB",
|
||||||
|
"green" : "0x96",
|
||||||
|
"red" : "0x1E"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0xEB",
|
||||||
|
"green" : "0x96",
|
||||||
|
"red" : "0x1E"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0xEB",
|
||||||
|
"green" : "0x96",
|
||||||
|
"red" : "0x1E"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0x7A",
|
||||||
|
"green" : "0x7A",
|
||||||
|
"red" : "0x7A"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0xF3",
|
||||||
|
"green" : "0xF3",
|
||||||
|
"red" : "0xF3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0xFF",
|
||||||
|
"green" : "0xFF",
|
||||||
|
"red" : "0xFF"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0x14",
|
||||||
|
"green" : "0x14",
|
||||||
|
"red" : "0x14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0xF5",
|
||||||
|
"green" : "0xF5",
|
||||||
|
"red" : "0xF5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0x25",
|
||||||
|
"green" : "0x25",
|
||||||
|
"red" : "0x25"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user