mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-25 14:28:51 +08:00
Merge branch 'apple-intelligent-2.0-2' into hwang/intelligence-ui
This commit is contained in:
@@ -514,14 +514,13 @@
|
|||||||
baseConfigurationReference = 3256F4410D881A03FE77D092 /* Pods-AFFiNE.debug.xcconfig */;
|
baseConfigurationReference = 3256F4410D881A03FE77D092 /* Pods-AFFiNE.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_IDENTITY = "Apple Distribution: TOEVERYTHING PTE. LTD. (73YMMDVT2M)";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 12;
|
CURRENT_PROJECT_VERSION = 12;
|
||||||
DEVELOPMENT_TEAM = 73YMMDVT2M;
|
DEVELOPMENT_TEAM = 964G86XT2P;
|
||||||
"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 = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -559,7 +558,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 = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -572,8 +571,7 @@
|
|||||||
ONLY_ACTIVE_ARCH = NO;
|
ONLY_ACTIVE_ARCH = NO;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro;
|
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "AppStore app.affine.pro";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "AppStore app.affine.pro";
|
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
|||||||
@@ -45,6 +45,15 @@
|
|||||||
"version" : "1.0.4"
|
"version" : "1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swifterswift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/SwifterSwift/SwifterSwift.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "39fa28c90a3ebe3d53f80289304fd880cf2c42d0",
|
||||||
|
"version" : "6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "then",
|
"identity" : "then",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class AFFiNEViewController: CAPBridgeViewController {
|
|||||||
edgesForExtendedLayout = []
|
edgesForExtendedLayout = []
|
||||||
let intelligentsButton = installIntelligentsButton()
|
let intelligentsButton = installIntelligentsButton()
|
||||||
intelligentsButton.delegate = self
|
intelligentsButton.delegate = self
|
||||||
presentIntelligentsButton() // from v2.0 always visible
|
dismissIntelligentsButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration {
|
override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration {
|
||||||
@@ -38,7 +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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ let package = Package(
|
|||||||
name: "Intelligents",
|
name: "Intelligents",
|
||||||
defaultLocalization: "en",
|
defaultLocalization: "en",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v17),
|
.iOS(.v16),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
.library(name: "Intelligents", targets: ["Intelligents"]),
|
.library(name: "Intelligents", targets: ["Intelligents"]),
|
||||||
@@ -18,16 +18,19 @@ let package = Package(
|
|||||||
.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/devxoul/Then", 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/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",
|
||||||
"SnapKit",
|
"SnapKit",
|
||||||
"Then",
|
"Then",
|
||||||
|
"SwifterSwift",
|
||||||
.product(name: "Apollo", package: "apollo-ios"),
|
.product(name: "Apollo", package: "apollo-ios"),
|
||||||
.product(name: "OrderedCollections", package: "swift-collections"),
|
.product(name: "OrderedCollections", package: "swift-collections"),
|
||||||
], resources: [
|
], resources: [
|
||||||
.process("Interface/View/InputBox/InputBox.xcassets")
|
.process("Resources/main.metal"),
|
||||||
|
.process("Interface/View/InputBox/InputBox.xcassets"),
|
||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
+9
-19
@@ -35,7 +35,6 @@ class BlurTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate
|
|||||||
|
|
||||||
class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
|
class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
|
||||||
private let presenting: Bool
|
private let presenting: Bool
|
||||||
private let duration: TimeInterval = 0.5
|
|
||||||
|
|
||||||
private let snapshotViewTag = "snapshotView".hashValue
|
private let snapshotViewTag = "snapshotView".hashValue
|
||||||
private let blurViewTag = "blurView".hashValue
|
private let blurViewTag = "blurView".hashValue
|
||||||
@@ -45,23 +44,8 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
|
|||||||
super.init()
|
super.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func performAnimation(
|
|
||||||
animations: @escaping () -> Void,
|
|
||||||
completion: @escaping (Bool) -> Void
|
|
||||||
) {
|
|
||||||
UIView.animate(
|
|
||||||
withDuration: duration,
|
|
||||||
delay: 0,
|
|
||||||
usingSpringWithDamping: 0.75,
|
|
||||||
initialSpringVelocity: 0.75,
|
|
||||||
options: [.beginFromCurrentState, .allowAnimatedContent, .curveEaseInOut],
|
|
||||||
animations: animations,
|
|
||||||
completion: completion
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func transitionDuration(using _: UIViewControllerContextTransitioning?) -> TimeInterval {
|
func transitionDuration(using _: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||||
duration
|
0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||||
@@ -106,11 +90,15 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
|
|||||||
toView.transform = CGAffineTransform(scaleX: 1.05, y: 1.05)
|
toView.transform = CGAffineTransform(scaleX: 1.05, y: 1.05)
|
||||||
containerView.addSubview(toView)
|
containerView.addSubview(toView)
|
||||||
|
|
||||||
performAnimation(animations: {
|
toView.layoutIfNeeded()
|
||||||
|
|
||||||
|
performWithAnimation(animations: {
|
||||||
blurEffectView.effect = UIBlurEffect(style: .systemMaterial)
|
blurEffectView.effect = UIBlurEffect(style: .systemMaterial)
|
||||||
fromViewSnapshot.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
|
fromViewSnapshot.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
|
||||||
toView.alpha = 1
|
toView.alpha = 1
|
||||||
toView.transform = .identity
|
toView.transform = .identity
|
||||||
|
fromView.layoutIfNeeded()
|
||||||
|
toView.layoutIfNeeded()
|
||||||
}) { _ in
|
}) { _ in
|
||||||
let success = !transitionContext.transitionWasCancelled
|
let success = !transitionContext.transitionWasCancelled
|
||||||
if !success {
|
if !success {
|
||||||
@@ -146,7 +134,7 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
performAnimation(animations: {
|
performWithAnimation(animations: {
|
||||||
fromViewSnapshot.transform = .identity
|
fromViewSnapshot.transform = .identity
|
||||||
blurEffectView.effect = nil
|
blurEffectView.effect = nil
|
||||||
fromView.transform = CGAffineTransform(scaleX: 1.05, y: 1.05)
|
fromView.transform = CGAffineTransform(scaleX: 1.05, y: 1.05)
|
||||||
@@ -157,6 +145,8 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
|
|||||||
toView.isHidden = false
|
toView.isHidden = false
|
||||||
fromViewSnapshot.removeFromSuperview()
|
fromViewSnapshot.removeFromSuperview()
|
||||||
blurEffectView.removeFromSuperview()
|
blurEffectView.removeFromSuperview()
|
||||||
|
fromView.layoutIfNeeded()
|
||||||
|
toView.layoutIfNeeded()
|
||||||
} else {
|
} else {
|
||||||
assertionFailure()
|
assertionFailure()
|
||||||
fromView.transform = .identity
|
fromView.transform = .identity
|
||||||
|
|||||||
+22
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+123
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-61
@@ -1,3 +1,4 @@
|
|||||||
|
import Combine
|
||||||
import SnapKit
|
import SnapKit
|
||||||
import Then
|
import Then
|
||||||
import UIKit
|
import UIKit
|
||||||
@@ -5,35 +6,28 @@ import UIKit
|
|||||||
class MainViewController: UIViewController {
|
class MainViewController: UIViewController {
|
||||||
// MARK: - UI Components
|
// MARK: - UI Components
|
||||||
|
|
||||||
private lazy var headerView = MainHeaderView().then {
|
lazy var headerView = MainHeaderView().then {
|
||||||
$0.delegate = self
|
$0.delegate = self
|
||||||
}
|
}
|
||||||
|
|
||||||
private lazy var inputBox = InputBox().then {
|
lazy var inputBox = InputBox().then {
|
||||||
$0.delegate = self
|
$0.delegate = self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private let intelligentContext = IntelligentContext.shared
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
setupUI()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
|
||||||
super.viewWillAppear(animated)
|
|
||||||
navigationController!.setNavigationBarHidden(true, animated: animated)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
|
||||||
super.viewWillDisappear(animated)
|
|
||||||
navigationController!.setNavigationBarHidden(false, animated: animated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Setup
|
|
||||||
|
|
||||||
private func setupUI() {
|
|
||||||
view.backgroundColor = .systemBackground
|
view.backgroundColor = .systemBackground
|
||||||
|
let inputBox = InputBox().then {
|
||||||
|
$0.delegate = self
|
||||||
|
}
|
||||||
|
self.inputBox = inputBox
|
||||||
|
|
||||||
view.addSubview(headerView)
|
view.addSubview(headerView)
|
||||||
view.addSubview(inputBox)
|
view.addSubview(inputBox)
|
||||||
@@ -48,51 +42,17 @@ class MainViewController: UIViewController {
|
|||||||
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
|
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - MainHeaderViewDelegate
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
extension MainViewController: MainHeaderViewDelegate {
|
navigationController!.setNavigationBarHidden(true, animated: animated)
|
||||||
func mainHeaderViewDidTapClose() {
|
DispatchQueue.main.async {
|
||||||
dismiss(animated: true)
|
self.inputBox.textView.becomeFirstResponder()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mainHeaderViewDidTapDropdown() {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
// TODO: 实现下拉功能
|
super.viewWillDisappear(animated)
|
||||||
print("Dropdown tapped")
|
navigationController!.setNavigationBarHidden(false, animated: animated)
|
||||||
}
|
|
||||||
|
|
||||||
func mainHeaderViewDidTapMenu() {
|
|
||||||
// TODO: 实现菜单功能
|
|
||||||
print("Menu tapped")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - InputBoxDelegate
|
|
||||||
|
|
||||||
extension MainViewController: InputBoxDelegate {
|
|
||||||
func inputBoxDidTapAddAttachment() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func inputBoxDidTapTool() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func inputBoxDidTapNetwork() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func inputBoxDidTapDeepThinking() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func inputBoxDidTapSend() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func inputBoxTextDidChange(_ text: String) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
+45
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+331
@@ -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()!
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -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)
|
||||||
|
}
|
||||||
+23
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
+166
-75
@@ -1,32 +1,26 @@
|
|||||||
|
import Combine
|
||||||
import SnapKit
|
import SnapKit
|
||||||
import Then
|
import Then
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
protocol InputBoxDelegate: AnyObject {
|
|
||||||
func inputBoxDidTapAddAttachment()
|
|
||||||
func inputBoxDidTapTool()
|
|
||||||
func inputBoxDidTapNetwork()
|
|
||||||
func inputBoxDidTapDeepThinking()
|
|
||||||
func inputBoxDidTapSend()
|
|
||||||
func inputBoxTextDidChange(_ text: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
class InputBox: UIView {
|
class InputBox: UIView {
|
||||||
weak var delegate: InputBoxDelegate?
|
weak var delegate: InputBoxDelegate?
|
||||||
|
|
||||||
|
|
||||||
private lazy var containerView = UIView().then {
|
private lazy var containerView = UIView().then {
|
||||||
$0.backgroundColor = UIColor.affineLayerBackgroundPrimary
|
$0.backgroundColor = UIColor.affineLayerBackgroundPrimary
|
||||||
$0.layer.cornerRadius = 12
|
$0.layer.cornerRadius = 12
|
||||||
$0.layer.borderWidth = 0.5
|
$0.layer.borderWidth = 0.5
|
||||||
$0.layer.borderColor = UIColor.affineLayerBorder.cgColor
|
$0.layer.borderColor = UIColor.affineLayerBorder.cgColor
|
||||||
|
|
||||||
$0.layer.shadowColor = UIColor.black.cgColor
|
$0.layer.shadowColor = UIColor.black.cgColor
|
||||||
$0.layer.shadowOffset = CGSize(width: 0, height: 2)
|
$0.layer.shadowOffset = CGSize(width: 0, height: 0)
|
||||||
$0.layer.shadowRadius = 6
|
$0.layer.shadowRadius = 12
|
||||||
$0.layer.shadowOpacity = 0.04
|
$0.layer.shadowOpacity = 0.075
|
||||||
$0.clipsToBounds = false
|
$0.clipsToBounds = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private lazy var textView = UITextView().then {
|
lazy var textView = UITextView().then {
|
||||||
$0.backgroundColor = .clear
|
$0.backgroundColor = .clear
|
||||||
$0.font = .systemFont(ofSize: 16)
|
$0.font = .systemFont(ofSize: 16)
|
||||||
$0.textColor = .label
|
$0.textColor = .label
|
||||||
@@ -34,10 +28,10 @@ class InputBox: UIView {
|
|||||||
$0.textContainer.lineFragmentPadding = 0
|
$0.textContainer.lineFragmentPadding = 0
|
||||||
$0.textContainerInset = .zero
|
$0.textContainerInset = .zero
|
||||||
$0.delegate = self
|
$0.delegate = self
|
||||||
$0.text = "This is AFFiNE AI"
|
$0.text = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
private lazy var placeholderLabel = UILabel().then {
|
lazy var placeholderLabel = UILabel().then {
|
||||||
$0.text = "Write your message..."
|
$0.text = "Write your message..."
|
||||||
$0.font = .systemFont(ofSize: 16)
|
$0.font = .systemFont(ofSize: 16)
|
||||||
$0.textColor = .systemGray3
|
$0.textColor = .systemGray3
|
||||||
@@ -100,29 +94,28 @@ class InputBox: UIView {
|
|||||||
$0.addArrangedSubview(webButton)
|
$0.addArrangedSubview(webButton)
|
||||||
$0.addArrangedSubview(reactButton)
|
$0.addArrangedSubview(reactButton)
|
||||||
$0.addArrangedSubview(sendButton)
|
$0.addArrangedSubview(sendButton)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private lazy var functionsStackView = UIStackView().then {
|
lazy var imageBar = InputBoxImageBar().then {
|
||||||
$0.axis = .horizontal
|
$0.imageBarDelegate = self
|
||||||
$0.spacing = 12
|
|
||||||
$0.alignment = .center
|
|
||||||
$0.addArrangedSubview(leftButtonsStackView)
|
|
||||||
$0.addArrangedSubview(UIView()) // spacer
|
|
||||||
$0.addArrangedSubview(rightButtonsStackView)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private lazy var mainStackView = UIStackView().then {
|
lazy var mainStackView = UIStackView().then {
|
||||||
$0.axis = .vertical
|
$0.axis = .vertical
|
||||||
$0.spacing = 16
|
$0.spacing = 16
|
||||||
$0.alignment = .fill
|
$0.alignment = .fill
|
||||||
|
$0.addArrangedSubview(imageBar)
|
||||||
$0.addArrangedSubview(textView)
|
$0.addArrangedSubview(textView)
|
||||||
$0.addArrangedSubview(functionsStackView)
|
$0.addArrangedSubview(functionBar)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private var textViewHeightConstraint: Constraint?
|
private var textViewHeightConstraint: Constraint?
|
||||||
private let minTextViewHeight: CGFloat = 48
|
private let minTextViewHeight: CGFloat = 48
|
||||||
private let maxTextViewHeight: CGFloat = 140
|
private let maxTextViewHeight: CGFloat = 140
|
||||||
|
|
||||||
|
|
||||||
var text: String {
|
var text: String {
|
||||||
get { textView.text ?? "" }
|
get { textView.text ?? "" }
|
||||||
set {
|
set {
|
||||||
@@ -132,26 +125,15 @@ class InputBox: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
override init(frame: CGRect = .zero) {
|
||||||
super.init(frame: .zero)
|
super.init(frame: frame)
|
||||||
setupViews()
|
|
||||||
setupConstraints()
|
|
||||||
updatePlaceholderVisibility()
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder _: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupViews() {
|
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
addSubview(containerView)
|
addSubview(containerView)
|
||||||
containerView.addSubview(mainStackView)
|
containerView.addSubview(mainStackView)
|
||||||
containerView.addSubview(placeholderLabel)
|
containerView.addSubview(placeholderLabel)
|
||||||
}
|
imageBar.isHidden = true
|
||||||
|
|
||||||
private func setupConstraints() {
|
|
||||||
containerView.snp.makeConstraints { make in
|
containerView.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview().inset(8)
|
make.edges.equalToSuperview().inset(8)
|
||||||
}
|
}
|
||||||
@@ -160,18 +142,8 @@ class InputBox: UIView {
|
|||||||
make.edges.equalToSuperview().inset(8)
|
make.edges.equalToSuperview().inset(8)
|
||||||
}
|
}
|
||||||
|
|
||||||
addButton.snp.makeConstraints { make in
|
imageBar.snp.makeConstraints { make in
|
||||||
make.size.equalTo(38)
|
make.left.right.equalToSuperview()
|
||||||
}
|
|
||||||
|
|
||||||
for button in [toolButton, webButton, reactButton] {
|
|
||||||
button.snp.makeConstraints { make in
|
|
||||||
make.size.equalTo(24)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendButton.snp.makeConstraints { make in
|
|
||||||
make.size.equalTo(38)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
textView.snp.makeConstraints { make in
|
textView.snp.makeConstraints { make in
|
||||||
@@ -182,58 +154,177 @@ class InputBox: UIView {
|
|||||||
make.left.right.equalTo(textView)
|
make.left.right.equalTo(textView)
|
||||||
make.top.equalTo(textView)
|
make.top.equalTo(textView)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupBindings()
|
||||||
|
updatePlaceholderVisibility()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateTextViewHeight() {
|
@available(*, unavailable)
|
||||||
|
required init?(coder _: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 size = textView.sizeThatFits(CGSize(width: textView.frame.width, height: CGFloat.greatestFiniteMagnitude))
|
||||||
let newHeight = max(minTextViewHeight, min(maxTextViewHeight, size.height))
|
let newHeight = max(minTextViewHeight, min(maxTextViewHeight, size.height))
|
||||||
|
|
||||||
|
let height = textView.frame.height
|
||||||
|
guard height != newHeight else { return }
|
||||||
|
|
||||||
textViewHeightConstraint?.update(offset: newHeight)
|
textViewHeightConstraint?.update(offset: newHeight)
|
||||||
textView.isScrollEnabled = size.height > maxTextViewHeight
|
textView.isScrollEnabled = size.height > maxTextViewHeight
|
||||||
|
|
||||||
UIView.animate(
|
if height == 0 || superview == nil || window == nil || isHidden { return }
|
||||||
withDuration: 0.5,
|
|
||||||
delay: 0,
|
performWithAnimation {
|
||||||
usingSpringWithDamping: 0.8,
|
|
||||||
initialSpringVelocity: 1.0,
|
|
||||||
options: [.curveEaseInOut]
|
|
||||||
) {
|
|
||||||
self.layoutIfNeeded()
|
self.layoutIfNeeded()
|
||||||
self.superview?.layoutIfNeeded()
|
self.superview?.layoutIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updatePlaceholderVisibility() {
|
func updatePlaceholderVisibility() {
|
||||||
placeholderLabel.isHidden = !textView.text.isEmpty
|
placeholderLabel.isHidden = !textView.text.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func addButtonTapped() {
|
func updateImageBarVisibility(_ hasAttachments: Bool) {
|
||||||
delegate?.inputBoxDidTapAddAttachment()
|
imageBar.isHidden = !hasAttachments
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func toolButtonTapped() {
|
func updateImageBarContent(_ attachments: [InputAttachment]) {
|
||||||
delegate?.inputBoxDidTapTool()
|
imageBar.updateImageBarContent(attachments)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func webButtonTapped() {
|
// MARK: - Public Methods
|
||||||
delegate?.inputBoxDidTapNetwork()
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func reactButtonTapped() {
|
public func addFileAttachment(_ url: URL) {
|
||||||
delegate?.inputBoxDidTapDeepThinking()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func sendButtonTapped() {
|
public var inputBoxData: InputBoxData {
|
||||||
delegate?.inputBoxDidTapSend()
|
viewModel.prepareSendData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UITextViewDelegate
|
// MARK: - InputBoxFunctionBarDelegate
|
||||||
|
|
||||||
extension InputBox: UITextViewDelegate {
|
extension InputBox: InputBoxFunctionBarDelegate {
|
||||||
func textViewDidChange(_ textView: UITextView) {
|
func functionBarDidTapTakePhoto(_: InputBoxFunctionBar) {
|
||||||
updatePlaceholderVisibility()
|
delegate?.inputBoxDidSelectTakePhoto(self)
|
||||||
updateTextViewHeight()
|
}
|
||||||
delegate?.inputBoxTextDidChange(textView.text)
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
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 = .secondaryLabel
|
||||||
|
private let selectedColor: UIColor = .accent
|
||||||
|
|
||||||
|
class InputBoxFunctionBar: UIView {
|
||||||
|
weak var delegate: InputBoxFunctionBarDelegate?
|
||||||
|
|
||||||
|
lazy var attachmentButton = UIButton(type: .system).then {
|
||||||
|
$0.setImage(UIImage(named: "inputbox.add.attachment", in: .module, with: nil), for: .normal)
|
||||||
|
$0.tintColor = unselectedColor
|
||||||
|
$0.imageView?.contentMode = .scaleAspectFit
|
||||||
|
$0.showsMenuAsPrimaryAction = true
|
||||||
|
$0.menu = createAttachmentMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy var toolButton = UIButton(type: .system).then {
|
||||||
|
$0.setImage(UIImage(named: "inputbox.tool", in: .module, with: nil), 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(named: "inputbox.network", in: .module, with: nil), 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(named: "inputbox.deep.thinking", in: .module, with: nil), 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(named: "inputbox.send", in: .module, with: nil), for: .normal)
|
||||||
|
$0.tintColor = .white
|
||||||
|
$0.imageView?.contentMode = .scaleAspectFit
|
||||||
|
$0.addTarget(self, action: #selector(sendButtonTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
setupViews()
|
||||||
|
setupConstraints()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder _: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupViews() {
|
||||||
|
addSubview(stackView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupConstraints() {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 createAttachmentMenu() -> UIMenu {
|
||||||
|
let takePhotoAction = UIAction(
|
||||||
|
title: "Take Photo or Video",
|
||||||
|
image: UIImage(systemName: "camera")
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
delegate?.functionBarDidTapTakePhoto(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
let photoLibraryAction = UIAction(
|
||||||
|
title: "Photo Library",
|
||||||
|
image: UIImage(systemName: "photo.on.rectangle")
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
delegate?.functionBarDidTapPhotoLibrary(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
let attachFilesAction = UIAction(
|
||||||
|
title: "Attach Files (pdf, txt, csv)",
|
||||||
|
image: UIImage(systemName: "arrow.up.doc")
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
delegate?.functionBarDidTapAttachFiles(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
let embedDocsAction = UIAction(
|
||||||
|
title: "Embed AFFINE Docs",
|
||||||
|
image: UIImage(systemName: "doc.text")
|
||||||
|
) { [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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
//
|
||||||
|
// 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)
|
||||||
|
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: 0, y: 0, width: constantHeight, height: constantHeight),
|
||||||
|
image: image,
|
||||||
|
attachmentId: attachment.id
|
||||||
|
)
|
||||||
|
imageCell.onRemove = { [weak self] cell in
|
||||||
|
self?.removeImageCell(cell)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+164
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-12
@@ -48,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()
|
||||||
@@ -78,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
-1
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SnapKit
|
import SnapKit
|
||||||
|
import SwifterSwift
|
||||||
import Then
|
import Then
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@@ -17,7 +18,10 @@ public class IntelligentsButton: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lazy var background = UIView().then {
|
lazy var background = UIView().then {
|
||||||
$0.backgroundColor = .white
|
$0.backgroundColor = .init(
|
||||||
|
light: .systemBackground,
|
||||||
|
dark: .darkGray.withAlphaComponent(0.25)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
lazy var activityIndicator = UIActivityIndicatorView()
|
lazy var activityIndicator = UIActivityIndicatorView()
|
||||||
|
|||||||
+2
@@ -68,7 +68,9 @@ class MainHeaderView: UIView {
|
|||||||
init() {
|
init() {
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
|
||||||
backgroundColor = UIColor.clear
|
backgroundColor = UIColor.clear
|
||||||
|
|
||||||
addSubview(mainStackView)
|
addSubview(mainStackView)
|
||||||
|
|
||||||
mainStackView.snp.makeConstraints { make in
|
mainStackView.snp.makeConstraints { make in
|
||||||
|
|||||||
+56
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-3
@@ -5,6 +5,7 @@
|
|||||||
// Created by 秋星桥 on 6/17/25.
|
// Created by 秋星桥 on 6/17/25.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
@@ -14,11 +15,30 @@ public class IntelligentContext {
|
|||||||
|
|
||||||
public var webView: WKWebView!
|
public var webView: WKWebView!
|
||||||
|
|
||||||
|
public lazy var temporaryDirectory: URL = {
|
||||||
|
let tempDir = FileManager.default.temporaryDirectory
|
||||||
|
return tempDir.appendingPathComponent("IntelligentContext")
|
||||||
|
}()
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
public func preparePresent(_ completion: @escaping () -> Void) {
|
public func preparePresent(_ completion: @escaping () -> Void) {
|
||||||
// used to gathering information, populate content from webview, etc.
|
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
||||||
// TODO: if needed
|
prepareTemporaryDirectory()
|
||||||
completion()
|
// 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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
#include <metal_stdlib>
|
||||||
|
|
||||||
|
using namespace metal;
|
||||||
|
|
||||||
|
namespace ParticleTransitionSystem {
|
||||||
|
|
||||||
|
struct TrollParticle {
|
||||||
|
float2 position;
|
||||||
|
float2 velocity;
|
||||||
|
float life;
|
||||||
|
float duration;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct TrollVertex {
|
||||||
|
float4 position [[position]];
|
||||||
|
float2 uv;
|
||||||
|
float opacity;
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顶点着色器 负责将粒子数据转换为顶点数据
|
||||||
|
vertex ParticleTransitionSystem::TrollVertex PTS_ParticleVertex(const device ParticleTransitionSystem::TrollVertex *vertices [[buffer(0)]],
|
||||||
|
const device float2 &resolution [[buffer(1)]],
|
||||||
|
const device ParticleTransitionSystem::TrollParticle *particles [[buffer(2)]],
|
||||||
|
const device float2 &targetFrameSize [[buffer(3)]],
|
||||||
|
const device float &stepSize [[buffer(4)]],
|
||||||
|
unsigned int vid [[vertex_id]],
|
||||||
|
unsigned int particleId [[instance_id]]) {
|
||||||
|
ParticleTransitionSystem::TrollVertex v = vertices[vid];
|
||||||
|
ParticleTransitionSystem::TrollParticle p = particles[particleId];
|
||||||
|
|
||||||
|
int particlesPerRow = int(targetFrameSize.x / stepSize);
|
||||||
|
int row = particleId / particlesPerRow;
|
||||||
|
int col = particleId % particlesPerRow;
|
||||||
|
|
||||||
|
float2 originalPos = float2(col * stepSize + stepSize / 2.0, row * stepSize + stepSize / 2.0);
|
||||||
|
float2 currentPos = p.position;
|
||||||
|
|
||||||
|
// 计算目标帧在屏幕中的居中偏移
|
||||||
|
float2 offset = (resolution - targetFrameSize) / 2.0;
|
||||||
|
|
||||||
|
// 设置UV坐标用于纹理采样
|
||||||
|
v.uv.x = originalPos.x / targetFrameSize.x;
|
||||||
|
v.uv.y = originalPos.y / targetFrameSize.y;
|
||||||
|
|
||||||
|
// 计算最终的屏幕位置
|
||||||
|
float particleSize = stepSize;
|
||||||
|
float2 worldPos = v.position.xy * particleSize + currentPos + offset;
|
||||||
|
|
||||||
|
// 转换到NDC坐标系 (-1到1)
|
||||||
|
v.position.x = (worldPos.x / resolution.x) * 2.0 - 1.0;
|
||||||
|
v.position.y = 1.0 - (worldPos.y / resolution.y) * 2.0;
|
||||||
|
|
||||||
|
// 逐渐消失
|
||||||
|
v.opacity = p.life / p.duration;
|
||||||
|
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 片段着色器 负责将顶点数据转换为像素颜色
|
||||||
|
fragment float4 PTS_ParticleFragment(ParticleTransitionSystem::TrollVertex in [[stage_in]],
|
||||||
|
const texture2d<float> texture [[texture(0)]],
|
||||||
|
const sampler textureSampler [[sampler(0)]]) {
|
||||||
|
constexpr sampler samplr;
|
||||||
|
float4 color = texture.sample(samplr, in.uv);
|
||||||
|
float a = color.a * in.opacity; // apply texture alpha
|
||||||
|
color *= in.opacity; // apply opacity from vertex shader aka pre-multiplied alpha
|
||||||
|
color.a = a;
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算粒子位置和速度的计算着色器 负责更新粒子的位置和速度
|
||||||
|
kernel void PTS_UpdateParticles(device ParticleTransitionSystem::TrollParticle *particles [[buffer(0)]],
|
||||||
|
unsigned int index [[thread_position_in_grid]]) {
|
||||||
|
if (particles[index].life >= 0) {
|
||||||
|
particles[index].position += particles[index].velocity;
|
||||||
|
|
||||||
|
// 模拟空气阻力,降低速度 x, y 分量
|
||||||
|
particles[index].velocity.x *= 0.99;
|
||||||
|
particles[index].velocity.y *= 0.99;
|
||||||
|
|
||||||
|
// 模拟重力影响,增加 y 分量
|
||||||
|
particles[index].velocity.y += 0.1;
|
||||||
|
}
|
||||||
|
particles[index].life -= 1.0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user