mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 01:09:54 +08:00
feat(ios): intelligent Switch Markdown View & Ephemeral Action View (#9823)
Co-authored-by: EYHN <cneyhn@gmail.com>
This commit is contained in:
@@ -14,8 +14,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Lakr233/MSDisplayLink",
|
||||
"state" : {
|
||||
"revision" : "c2fcd28cb99300d83acc30860ce252ef97c20b61",
|
||||
"version" : "1.1.1"
|
||||
"revision" : "6e92b5513e3473e064685e64056c4ac46470e7b0",
|
||||
"version" : "2.0.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,21 +14,21 @@ let package = Package(
|
||||
.library(name: "Intelligents", targets: ["Intelligents"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.1"),
|
||||
.package(url: "https://github.com/Lakr233/SpringInterpolation", from: "1.3.0"),
|
||||
.package(url: "https://github.com/Lakr233/MSDisplayLink", from: "1.1.1"),
|
||||
.package(path: "../AffineGraphQL"),
|
||||
.package(path: "../ChidoriMenu"),
|
||||
.package(path: "../MarkdownView"),
|
||||
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.18.0"),
|
||||
.package(url: "https://github.com/LaunchDarkly/swift-eventsource.git", from: "3.3.0"),
|
||||
.package(url: "https://github.com/apple/swift-collections", from: "1.1.4"),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "Intelligents", dependencies: [
|
||||
"AffineGraphQL",
|
||||
"SpringInterpolation",
|
||||
"MSDisplayLink",
|
||||
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
|
||||
"ChidoriMenu",
|
||||
"MarkdownView",
|
||||
.product(name: "Apollo", package: "apollo-ios"),
|
||||
.product(name: "LDSwiftEventSource", package: "swift-eventsource"),
|
||||
.product(name: "OrderedCollections", package: "swift-collections"),
|
||||
]),
|
||||
]
|
||||
)
|
||||
|
||||
+11
-9
@@ -37,14 +37,16 @@ public extension UIViewController {
|
||||
}
|
||||
|
||||
func presentError(_ error: Error, onDismiss: @escaping () -> Void = {}) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -57,13 +57,23 @@ public extension UIViewController {
|
||||
button.transform = .identity
|
||||
button.setNeedsLayout()
|
||||
self.view.layoutIfNeeded()
|
||||
} completion: { _ in
|
||||
button.isUserInteractionEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
func dismissIntelligentsButton() {
|
||||
func dismissIntelligentsButton(animated: Bool = true) {
|
||||
guard let button = findIntelligentsButton() else { return }
|
||||
print("[*] \(button) is calling \(#function)")
|
||||
|
||||
button.isUserInteractionEnabled = false
|
||||
|
||||
if !animated {
|
||||
button.stopProgress()
|
||||
button.isHidden = true
|
||||
return
|
||||
}
|
||||
|
||||
button.stopProgress()
|
||||
button.setNeedsLayout()
|
||||
view.layoutIfNeeded()
|
||||
|
||||
+9
@@ -43,6 +43,7 @@ public class IntelligentsButton: UIView {
|
||||
image.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -imageInsetValue),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(activityIndicator)
|
||||
[
|
||||
activityIndicator.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
@@ -74,17 +75,25 @@ public class IntelligentsButton: UIView {
|
||||
layer.cornerRadius = bounds.width / 2
|
||||
}
|
||||
|
||||
private var allowedTap = true
|
||||
|
||||
@objc func tapped() {
|
||||
guard allowedTap else { return }
|
||||
delegate?.onIntelligentsButtonTapped(self)
|
||||
}
|
||||
|
||||
public func beginProgress() {
|
||||
allowedTap = false
|
||||
activityIndicator.startAnimating()
|
||||
activityIndicator.isHidden = false
|
||||
image.isHidden = true
|
||||
bringSubviewToFront(activityIndicator)
|
||||
}
|
||||
|
||||
public func stopProgress() {
|
||||
allowedTap = true
|
||||
activityIndicator.stopAnimating()
|
||||
activityIndicator.isHidden = true
|
||||
image.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
//
|
||||
// ChatTableView+BaseCell.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
private let initialInsetValue: CGFloat = 24
|
||||
|
||||
extension ChatTableView {
|
||||
class BaseCell: UITableViewCell {
|
||||
var inset: UIEdgeInsets { // available for overrides
|
||||
.init(
|
||||
top: initialInsetValue / 2,
|
||||
left: initialInsetValue,
|
||||
bottom: initialInsetValue / 2,
|
||||
right: initialInsetValue
|
||||
)
|
||||
}
|
||||
|
||||
let containerView = UIView()
|
||||
let roundedBackgroundView = UIView()
|
||||
|
||||
var viewModel: AnyObject? {
|
||||
didSet { update(via: viewModel) }
|
||||
}
|
||||
|
||||
enum BackgroundColorType {
|
||||
case clear
|
||||
case highlight
|
||||
case warning
|
||||
case lightGray
|
||||
}
|
||||
|
||||
var backgroundColorType: BackgroundColorType = .clear {
|
||||
didSet {
|
||||
roundedBackgroundView.backgroundColor = backgroundColorType.color
|
||||
}
|
||||
}
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
|
||||
roundedBackgroundView.clipsToBounds = true
|
||||
roundedBackgroundView.layer.cornerRadius = 8
|
||||
roundedBackgroundView.layer.masksToBounds = true
|
||||
|
||||
contentView.addSubview(roundedBackgroundView)
|
||||
roundedBackgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[ // inset half of the container view
|
||||
roundedBackgroundView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: inset.left / 2),
|
||||
roundedBackgroundView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -inset.right / 2),
|
||||
roundedBackgroundView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: inset.top / 2),
|
||||
roundedBackgroundView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -inset.bottom / 2),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
contentView.addSubview(containerView)
|
||||
containerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: inset.left),
|
||||
containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -inset.right),
|
||||
containerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: inset.top),
|
||||
containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -inset.bottom),
|
||||
].forEach { $0.isActive = true }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
viewModel = nil
|
||||
}
|
||||
|
||||
func update(via object: AnyObject?) {
|
||||
_ = object
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatTableView.BaseCell.BackgroundColorType {
|
||||
var color: UIColor {
|
||||
switch self {
|
||||
case .clear:
|
||||
.clear
|
||||
case .highlight:
|
||||
.accent
|
||||
case .warning:
|
||||
.systemRed.withAlphaComponent(0.1)
|
||||
case .lightGray:
|
||||
.systemGray.withAlphaComponent(0.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
//
|
||||
// ChatTableView+ChatCell.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import MarkdownUI
|
||||
import UIKit
|
||||
|
||||
extension ChatTableView {
|
||||
class ChatCell: BaseCell {
|
||||
let avatarView = CircleImageView()
|
||||
let titleLabel = UILabel()
|
||||
let markdownContainer = UIView()
|
||||
|
||||
var markdownView: UIView?
|
||||
var removableConstraints: [NSLayoutConstraint] = []
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
let spacingElement: CGFloat = 12
|
||||
let avatarSize: CGFloat = 24
|
||||
|
||||
containerView.addSubview(avatarView)
|
||||
avatarView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
avatarView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
avatarView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
avatarView.widthAnchor.constraint(equalToConstant: avatarSize),
|
||||
avatarView.heightAnchor.constraint(equalToConstant: avatarSize),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: UIFont.labelFontSize, weight: .bold)
|
||||
containerView.addSubview(titleLabel)
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
titleLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: spacingElement),
|
||||
titleLabel.centerYAnchor.constraint(equalTo: avatarView.centerYAnchor),
|
||||
titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
containerView.addSubview(markdownContainer)
|
||||
markdownContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
markdownContainer.topAnchor.constraint(greaterThanOrEqualTo: avatarView.bottomAnchor, constant: spacingElement),
|
||||
markdownContainer.topAnchor.constraint(greaterThanOrEqualTo: titleLabel.bottomAnchor, constant: spacingElement),
|
||||
markdownContainer.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 0),
|
||||
markdownContainer.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 0),
|
||||
markdownContainer.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 0),
|
||||
].forEach { $0.isActive = true }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override func update(via object: AnyObject?) {
|
||||
super.update(via: object)
|
||||
|
||||
guard let viewModel = object as? ViewModel else {
|
||||
return
|
||||
}
|
||||
|
||||
switch viewModel.participant {
|
||||
case .system:
|
||||
avatarView.image = UIImage(systemName: "gearshape.fill")
|
||||
titleLabel.text = "System".localized()
|
||||
backgroundColorType = .warning
|
||||
case .assistant:
|
||||
avatarView.image = UIImage(named: "spark", in: .module, with: .none)
|
||||
titleLabel.text = "AFFiNE AI".localized()
|
||||
backgroundColorType = .lightGray
|
||||
case .user:
|
||||
avatarView.image = UIImage(systemName: "person.fill")
|
||||
titleLabel.text = "You".localized()
|
||||
backgroundColorType = .clear
|
||||
}
|
||||
|
||||
removableConstraints.forEach { $0.isActive = false }
|
||||
if let markdownView { markdownView.removeFromSuperview() }
|
||||
markdownContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
|
||||
let hostingView: UIView = UIHostingView(
|
||||
rootView: Markdown(.init(viewModel.markdownDocument))
|
||||
)
|
||||
defer { markdownView = hostingView }
|
||||
markdownContainer.addSubview(hostingView)
|
||||
|
||||
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
hostingView.topAnchor.constraint(equalTo: markdownContainer.topAnchor),
|
||||
hostingView.leadingAnchor.constraint(equalTo: markdownContainer.leadingAnchor),
|
||||
hostingView.trailingAnchor.constraint(lessThanOrEqualTo: markdownContainer.trailingAnchor),
|
||||
hostingView.bottomAnchor.constraint(equalTo: markdownContainer.bottomAnchor),
|
||||
].forEach {
|
||||
$0.isActive = true
|
||||
removableConstraints.append($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatTableView.ChatCell {
|
||||
class ViewModel {
|
||||
let participant: Participant
|
||||
var markdownDocument: String
|
||||
|
||||
init(participant: Participant, markdownDocument: String) {
|
||||
self.participant = participant
|
||||
self.markdownDocument = markdownDocument
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatTableView.ChatCell.ViewModel {
|
||||
enum Participant {
|
||||
case user
|
||||
case assistant
|
||||
case system
|
||||
}
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
//
|
||||
// ChatTableView+Data.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension ChatTableView {
|
||||
struct DataElement {
|
||||
enum CellType: String, CaseIterable {
|
||||
case base
|
||||
case chat
|
||||
}
|
||||
|
||||
let type: CellType
|
||||
let object: AnyObject?
|
||||
|
||||
init(type: CellType, object: AnyObject?) {
|
||||
self.type = type
|
||||
self.object = object
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatTableView.DataElement.CellType {
|
||||
var cellClassType: ChatTableView.BaseCell.Type {
|
||||
switch self {
|
||||
case .base:
|
||||
ChatTableView.BaseCell.self
|
||||
case .chat:
|
||||
ChatTableView.ChatCell.self
|
||||
}
|
||||
}
|
||||
|
||||
var cellIdentifier: String {
|
||||
NSStringFromClass(cellClassType)
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatTableView: UITableViewDelegate, UITableViewDataSource {
|
||||
func numberOfSections(in _: UITableView) -> Int {
|
||||
1
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
|
||||
dataSource.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: dataSource[indexPath.row].type.cellIdentifier, for: indexPath) as! BaseCell
|
||||
let object = dataSource[indexPath.row].object
|
||||
cell.update(via: object)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
-113
@@ -1,113 +0,0 @@
|
||||
//
|
||||
// ChatTableView+Scroll.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/12/23.
|
||||
//
|
||||
|
||||
import MSDisplayLink
|
||||
import SpringInterpolation
|
||||
import UIKit
|
||||
|
||||
extension ChatTableView: UIScrollViewDelegate, DisplayLinkDelegate {
|
||||
func synchronization() {
|
||||
let now = Date()
|
||||
defer { scrollAnimationDeltaTimeHolder = now }
|
||||
var deltaTime = now.timeIntervalSince(scrollAnimationDeltaTimeHolder)
|
||||
if deltaTime > 0.5 { deltaTime = 0.5 }
|
||||
guard scrollToBottomEnabled else { return }
|
||||
DispatchQueue.main.async { self.tikVsync(deltaTime: deltaTime * 2) }
|
||||
}
|
||||
|
||||
var bottomLocationY: CGFloat {
|
||||
tableView.contentSize.height - tableView.bounds.height
|
||||
}
|
||||
|
||||
private func tikVsync(deltaTime: TimeInterval) {
|
||||
guard scrollToBottomEnabled else { return }
|
||||
guard scrollToBottomAllowed else { return }
|
||||
// read from contentSize if not needed to scroll
|
||||
guard tableView.contentSize.height > tableView.bounds.height else {
|
||||
resetAnimationContext(to: tableView.contentOffset.y)
|
||||
return
|
||||
}
|
||||
guard abs(bottomLocationY - tableView.contentOffset.y) > 1 else {
|
||||
return
|
||||
}
|
||||
scrollAnimationContext.setTarget(bottomLocationY)
|
||||
scrollAnimationContext.update(withDeltaTime: deltaTime)
|
||||
tableView.contentOffset.y = scrollAnimationContext.value
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
func resetAnimationContext(to offset: CGFloat) {
|
||||
scrollAnimationContext.context = .init(
|
||||
currentPos: offset,
|
||||
currentVel: 0,
|
||||
targetPos: offset
|
||||
)
|
||||
}
|
||||
|
||||
func presentScrollBottomIfNeeded() {
|
||||
let visible = !scrollToBottomEnabled && scrollToBottomAllowed
|
||||
UIView.animate(withDuration: 0.25) { [self] in
|
||||
scrollDownButton.alpha = visible ? 1 : 0
|
||||
}
|
||||
}
|
||||
|
||||
func animationEnabledToggleDidSet(oldValue: Bool) {
|
||||
assert(Thread.isMainThread)
|
||||
guard scrollToBottomEnabled != oldValue else { return }
|
||||
resetAnimationContext(to: tableView.contentOffset.y)
|
||||
}
|
||||
|
||||
func animationAllowedToggleDidSet(oldValue: Bool) {
|
||||
assert(Thread.isMainThread)
|
||||
guard scrollToBottomAllowed != oldValue else { return }
|
||||
resetAnimationContext(to: tableView.contentOffset.y)
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
processScrollView(scrollView)
|
||||
}
|
||||
|
||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate _: Bool) {
|
||||
processScrollView(scrollView)
|
||||
}
|
||||
|
||||
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
||||
processScrollView(scrollView)
|
||||
}
|
||||
|
||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
processScrollView(scrollView)
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
private func processScrollView(_ scrollView: UIScrollView) {
|
||||
guard let tableView = scrollView as? UITableView else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
processTableViewMovements(tableView)
|
||||
}
|
||||
|
||||
private func processTableViewMovements(_ tableView: UITableView) {
|
||||
defer { presentScrollBottomIfNeeded() }
|
||||
if tableView.isDragging {
|
||||
scrollToBottomEnabled = false
|
||||
scrollToBottomAllowed = false
|
||||
} else {
|
||||
// 如果没在减速 就开启 允许滚动
|
||||
scrollToBottomAllowed = !tableView.isDecelerating
|
||||
// 如果滚到底了 就开启 自动滚动
|
||||
let isBottom = tableView.contentOffset.y >= bottomLocationY
|
||||
if isBottom { scrollToBottomEnabled = true }
|
||||
}
|
||||
}
|
||||
|
||||
func scrollToBottom() {
|
||||
scrollToBottomEnabled = true
|
||||
scrollToBottomAllowed = true
|
||||
}
|
||||
}
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
//
|
||||
// ChatTableView.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import MSDisplayLink
|
||||
import SpringInterpolation
|
||||
import UIKit
|
||||
|
||||
class ChatTableView: UIView {
|
||||
let tableView = UITableView()
|
||||
let footerView = UIView()
|
||||
let scrollDownButton = ScrollBottom()
|
||||
|
||||
var dataSource: [DataElement] = []
|
||||
|
||||
// 控制自动滚动
|
||||
var scrollToBottomEnabled = false {
|
||||
didSet { animationEnabledToggleDidSet(oldValue: oldValue) }
|
||||
}
|
||||
|
||||
// 要让系统自己的动画完成以后再帮用户去挪
|
||||
var scrollToBottomAllowed = false {
|
||||
didSet { animationAllowedToggleDidSet(oldValue: oldValue) }
|
||||
}
|
||||
|
||||
var scrollAnimationController: DisplayLink = .init()
|
||||
var scrollAnimationContext: SpringInterpolation = .init()
|
||||
var scrollAnimationDeltaTimeHolder: Date = .init()
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
for eachCase in DataElement.CellType.allCases {
|
||||
let cellClass = eachCase.cellClassType
|
||||
tableView.register(cellClass, forCellReuseIdentifier: eachCase.cellIdentifier)
|
||||
}
|
||||
|
||||
tableView.backgroundColor = .clear
|
||||
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
addSubview(tableView)
|
||||
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
tableView.topAnchor.constraint(equalTo: topAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
footerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
footerView.heightAnchor.constraint(equalToConstant: 128).isActive = true
|
||||
footerView.widthAnchor.constraint(equalToConstant: 128).isActive = true
|
||||
tableView.tableFooterView = footerView
|
||||
tableView.separatorStyle = .none
|
||||
|
||||
addSubview(scrollDownButton)
|
||||
scrollDownButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
// right bottom inset 16 size 32x32
|
||||
scrollDownButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
|
||||
scrollDownButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -32),
|
||||
scrollDownButton.widthAnchor.constraint(equalToConstant: 32),
|
||||
scrollDownButton.heightAnchor.constraint(equalToConstant: 32),
|
||||
].forEach { $0.isActive = true }
|
||||
scrollDownButton.alpha = 0
|
||||
scrollDownButton.onTap = { [weak self] in
|
||||
self?.scrollToBottom()
|
||||
}
|
||||
|
||||
scrollAnimationController.delegatingObject(self)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func reloadData() {
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
//
|
||||
// ScrollBottom.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/12/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
class ScrollBottom: UIView {
|
||||
let imageView = UIImageView()
|
||||
let backgroundView: UIView = UIVisualEffectView(
|
||||
effect: UIBlurEffect(style: .systemUltraThinMaterialDark)
|
||||
)
|
||||
|
||||
var onTap: (() -> Void)?
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
addSubview(backgroundView)
|
||||
addSubview(imageView)
|
||||
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.image = UIImage(systemName: "arrow.down")
|
||||
imageView.tintColor = .accent
|
||||
|
||||
isUserInteractionEnabled = true
|
||||
let tapGesture = UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(tapAction)
|
||||
)
|
||||
addGestureRecognizer(tapGesture)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
clipsToBounds = true
|
||||
|
||||
layer.cornerRadius = (bounds.width + bounds.height) / 4
|
||||
layer.masksToBounds = true
|
||||
|
||||
backgroundView.frame = bounds
|
||||
let imageInset = (bounds.width + bounds.height) / 8
|
||||
imageView.frame = CGRect(
|
||||
x: imageInset,
|
||||
y: imageInset,
|
||||
width: bounds.width - 2 * imageInset,
|
||||
height: bounds.height - 2 * imageInset
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func tapAction() {
|
||||
onTap?()
|
||||
}
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
//
|
||||
// IntelligentsChatController+Cell.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/12/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension IntelligentsChatController {
|
||||
func insertIntoTableView(viewModel: ChatTableView.DataElement) {
|
||||
assert(Thread.isMainThread)
|
||||
tableView.dataSource.append(viewModel)
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
func insertIntoTableView(withChatModel chatModel: ChatTableView.ChatCell.ViewModel) {
|
||||
insertIntoTableView(viewModel: .init(
|
||||
type: .chat,
|
||||
object: chatModel
|
||||
))
|
||||
}
|
||||
|
||||
func insertIntoTableView(withError error: Error) {
|
||||
insertIntoTableView(withChatModel: .init(
|
||||
participant: .system,
|
||||
markdownDocument: error.localizedDescription
|
||||
))
|
||||
}
|
||||
}
|
||||
+58
-17
@@ -7,6 +7,7 @@
|
||||
|
||||
import AffineGraphQL
|
||||
import LDSwiftEventSource
|
||||
import MarkdownParser
|
||||
import UIKit
|
||||
|
||||
extension IntelligentsChatController {
|
||||
@@ -29,6 +30,7 @@ extension IntelligentsChatController {
|
||||
@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 {
|
||||
@@ -75,8 +77,12 @@ private extension IntelligentsChatController {
|
||||
|
||||
private extension IntelligentsChatController {
|
||||
func chat_onError(_ error: Error) {
|
||||
// TODO: IMPL add error cell
|
||||
print("[*] chat error", error)
|
||||
dispatchToMain {
|
||||
let key = UUID()
|
||||
let content = ChatContent.error(text: error.localizedDescription)
|
||||
self.simpleChatContents.updateValue(content, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
func chat_createSession(
|
||||
@@ -85,9 +91,9 @@ private extension IntelligentsChatController {
|
||||
) {
|
||||
Intelligents.qlClient.perform(
|
||||
mutation: CreateCopilotSessionMutation(options: .init(
|
||||
docId: "", // TODO: put the real data
|
||||
docId: metadata[.documentID] ?? "",
|
||||
promptName: Prompt.general_Chat_With_AFFiNE_AI.rawValue,
|
||||
workspaceId: "" // TODO: put the real data
|
||||
workspaceId: metadata[.workspaceID] ?? ""
|
||||
)),
|
||||
queue: .global()
|
||||
) { result in
|
||||
@@ -117,10 +123,10 @@ private extension IntelligentsChatController {
|
||||
// let images = viewModel.attachments
|
||||
|
||||
dispatchToMain {
|
||||
self.insertIntoTableView(withChatModel: .init(
|
||||
participant: .user,
|
||||
markdownDocument: text
|
||||
))
|
||||
let content = ChatContent.user(document: text)
|
||||
let key = UUID()
|
||||
self.simpleChatContents.updateValue(content, forKey: key)
|
||||
self.tableView.scrollLastCellToTop()
|
||||
}
|
||||
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
@@ -173,11 +179,13 @@ private extension IntelligentsChatController {
|
||||
return
|
||||
}
|
||||
|
||||
let chatModel = ChatTableView.ChatCell.ViewModel(
|
||||
participant: .assistant,
|
||||
markdownDocument: ""
|
||||
)
|
||||
dispatchToMain { self.insertIntoTableView(withChatModel: chatModel) }
|
||||
let contentIdentifier = UUID()
|
||||
dispatchToMain {
|
||||
self.simpleChatContents.updateValue(
|
||||
.assistant(document: "..."),
|
||||
forKey: contentIdentifier
|
||||
)
|
||||
}
|
||||
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
|
||||
@@ -193,8 +201,14 @@ private extension IntelligentsChatController {
|
||||
eventHandler.onErrorBlock = { error in
|
||||
self.chat_onError(error)
|
||||
}
|
||||
|
||||
var document = ""
|
||||
eventHandler.onMessageBlock = { _, message in
|
||||
self.chat_onEvent(message.data, chatModel: chatModel)
|
||||
self.dispatchToMain {
|
||||
document += message.data
|
||||
let content = ChatContent.assistant(document: document)
|
||||
self.simpleChatContents.updateValue(content, forKey: contentIdentifier)
|
||||
}
|
||||
}
|
||||
let eventSource = EventSource(config: .init(handler: eventHandler, url: url))
|
||||
chatTask = eventSource
|
||||
@@ -202,11 +216,38 @@ private extension IntelligentsChatController {
|
||||
|
||||
sem.wait()
|
||||
}
|
||||
}
|
||||
|
||||
func chat_onEvent(_ data: String, chatModel: ChatTableView.ChatCell.ViewModel) {
|
||||
dispatchToMain { [self] in
|
||||
chatModel.markdownDocument += data
|
||||
tableView.reloadData()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
//
|
||||
// IntelligentsChatController+Keyboard.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/12/6.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension IntelligentsChatController {
|
||||
@objc func keyboardWillAppear(_ notification: Notification) {
|
||||
let info = notification.userInfo ?? [:]
|
||||
let keyboardHeight = (info[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?
|
||||
.cgRectValue
|
||||
.height ?? 0
|
||||
inputBoxKeyboardAdapterHeightConstraint.constant = keyboardHeight
|
||||
view.setNeedsUpdateConstraints()
|
||||
animateWithKeyboard(userInfo: info)
|
||||
}
|
||||
|
||||
@objc func keyboardWillDisappear(_ notification: Notification) {
|
||||
let info = notification.userInfo ?? [:]
|
||||
inputBoxKeyboardAdapterHeightConstraint.constant = 0
|
||||
view.setNeedsUpdateConstraints()
|
||||
animateWithKeyboard(userInfo: info)
|
||||
}
|
||||
|
||||
private func animateWithKeyboard(userInfo info: [AnyHashable: Any]) {
|
||||
let keyboardAnimationDuration = (info[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?
|
||||
.doubleValue ?? 0
|
||||
let keyboardAnimationCurve = (info[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?
|
||||
.uintValue ?? 0
|
||||
UIView.animate(
|
||||
withDuration: keyboardAnimationDuration,
|
||||
delay: 0,
|
||||
options: UIView.AnimationOptions(rawValue: keyboardAnimationCurve),
|
||||
animations: {
|
||||
self.view.layoutIfNeeded()
|
||||
},
|
||||
completion: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
+29
-35
@@ -5,22 +5,43 @@
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import LDSwiftEventSource
|
||||
import OrderedCollections
|
||||
import UIKit
|
||||
|
||||
public class IntelligentsChatController: UIViewController {
|
||||
let header = Header()
|
||||
let inputBoxKeyboardAdapter = UIView()
|
||||
let inputBox = InputBox()
|
||||
let progressView = UIActivityIndicatorView()
|
||||
let tableView = ChatTableView()
|
||||
|
||||
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 = "" {
|
||||
didSet { print("[*] new sessionID: \(sessionID)") }
|
||||
}
|
||||
|
||||
public enum MetadataKey: String {
|
||||
case documentID
|
||||
case workspaceID
|
||||
case content
|
||||
}
|
||||
|
||||
public var metadata: [MetadataKey: String] = [:]
|
||||
|
||||
var chatTask: EventSource?
|
||||
|
||||
override public var title: String? {
|
||||
@@ -38,19 +59,6 @@ public class IntelligentsChatController: UIViewController {
|
||||
title = "Chat with AI".localized()
|
||||
|
||||
overrideUserInterfaceStyle = .dark
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(keyboardWillDisappear),
|
||||
name: UIResponder.keyboardWillHideNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(keyboardWillAppear),
|
||||
name: UIResponder.keyboardWillShowNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@@ -59,7 +67,6 @@ public class IntelligentsChatController: UIViewController {
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
chatTask?.stop()
|
||||
chatTask = nil
|
||||
}
|
||||
@@ -73,11 +80,14 @@ public class IntelligentsChatController: UIViewController {
|
||||
|
||||
view.addSubview(header)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(inputBoxKeyboardAdapter)
|
||||
view.addSubview(inputBox)
|
||||
view.addSubview(progressView)
|
||||
setupLayout()
|
||||
|
||||
// TODO: IMPL
|
||||
inputBox.editor.controlBanner.cameraButton.isHidden = true
|
||||
inputBox.editor.controlBanner.photoButton.isHidden = true
|
||||
|
||||
chat_onLoad()
|
||||
}
|
||||
|
||||
@@ -96,21 +106,11 @@ public class IntelligentsChatController: UIViewController {
|
||||
header.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 44),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
inputBoxKeyboardAdapter.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
inputBoxKeyboardAdapter.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
inputBoxKeyboardAdapter.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
inputBoxKeyboardAdapter.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
inputBoxKeyboardAdapterHeightConstraint = inputBoxKeyboardAdapter.heightAnchor.constraint(equalToConstant: 0)
|
||||
inputBoxKeyboardAdapterHeightConstraint.isActive = true
|
||||
inputBoxKeyboardAdapter.backgroundColor = inputBox.backgroundView.backgroundColor
|
||||
|
||||
inputBox.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
inputBox.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
inputBox.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
inputBox.bottomAnchor.constraint(equalTo: inputBoxKeyboardAdapter.topAnchor),
|
||||
inputBox.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -118,7 +118,7 @@ public class IntelligentsChatController: UIViewController {
|
||||
tableView.topAnchor.constraint(equalTo: header.bottomAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: inputBox.topAnchor, constant: 16),
|
||||
tableView.bottomAnchor.constraint(equalTo: inputBox.topAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
inputBox.editor.controlBanner.sendButton.addTarget(
|
||||
@@ -136,10 +136,4 @@ public class IntelligentsChatController: UIViewController {
|
||||
].forEach { $0.isActive = true }
|
||||
progressView.style = .large
|
||||
}
|
||||
|
||||
override public func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
tableView.scrollToBottomEnabled = true
|
||||
tableView.scrollToBottomAllowed = true
|
||||
}
|
||||
}
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// MessageListView+AssistantCell.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import MarkdownParser
|
||||
import MarkdownView
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
class AssistantCell: BaseCell {
|
||||
let avatarView = UIImageView()
|
||||
let usernameView = UILabel()
|
||||
let markdownView = MarkdownView()
|
||||
|
||||
override func initializeContent() {
|
||||
super.initializeContent()
|
||||
|
||||
avatarView.contentMode = .scaleAspectFit
|
||||
avatarView.image = UIImage(named: "spark", in: .module, with: nil)
|
||||
usernameView.text = "AFFiNE AI"
|
||||
usernameView.font = .preferredFont(forTextStyle: .body).bold
|
||||
usernameView.textColor = .label
|
||||
|
||||
containerView.addSubview(avatarView)
|
||||
containerView.addSubview(usernameView)
|
||||
containerView.addSubview(markdownView)
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
markdownView.prepareForReuse()
|
||||
}
|
||||
|
||||
override func updateContent(
|
||||
object: any MessageListView.Element.ViewModel,
|
||||
originalObject _: Element.UserObject?
|
||||
) {
|
||||
guard let object = object as? ViewModel else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
_ = object
|
||||
}
|
||||
|
||||
override func layoutContent(cache: any MessageListView.TableLayoutEngine.LayoutCache) {
|
||||
super.layoutContent(cache: cache)
|
||||
guard let cache = cache as? LayoutCache else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
avatarView.frame = cache.avatarRect
|
||||
usernameView.frame = cache.usernameRect
|
||||
markdownView.frame = cache.markdownFrame
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
markdownView.updateContentViews(cache.manifests)
|
||||
}
|
||||
}
|
||||
|
||||
override class func layoutInsideContainer(
|
||||
containerWidth: CGFloat,
|
||||
object: any MessageListView.Element.ViewModel
|
||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
||||
guard let object = object as? ViewModel else {
|
||||
assertionFailure()
|
||||
return LayoutCache()
|
||||
}
|
||||
let cache = LayoutCache()
|
||||
cache.width = containerWidth
|
||||
|
||||
let inset: CGFloat = 8
|
||||
let bubbleInset = UIEdgeInsets(top: inset, left: inset, bottom: inset, right: inset)
|
||||
|
||||
let avatarRect = CGRect(x: bubbleInset.left, y: bubbleInset.top, width: 24, height: 24)
|
||||
let usernameRect = CGRect(
|
||||
x: avatarRect.maxX + bubbleInset.right,
|
||||
y: bubbleInset.top,
|
||||
width: containerWidth - avatarRect.maxX - bubbleInset.right,
|
||||
height: 24
|
||||
)
|
||||
|
||||
let textWidth = containerWidth - bubbleInset.left - bubbleInset.right
|
||||
|
||||
var height: CGFloat = 0
|
||||
let manifests = object.blocks.map {
|
||||
let ret = $0.manifest(theme: object.theme)
|
||||
ret.setLayoutTheme(.default)
|
||||
ret.setLayoutWidth(textWidth)
|
||||
ret.layoutIfNeeded()
|
||||
height += ret.size.height + Theme.default.spacings.final
|
||||
return ret
|
||||
}
|
||||
if height > 0 { height -= Theme.default.spacings.final }
|
||||
let textRect = CGRect(
|
||||
x: bubbleInset.left,
|
||||
y: usernameRect.maxY + bubbleInset.bottom,
|
||||
width: textWidth,
|
||||
height: height
|
||||
)
|
||||
cache.markdownFrame = textRect
|
||||
cache.avatarRect = avatarRect
|
||||
cache.usernameRect = usernameRect
|
||||
cache.manifests = manifests
|
||||
cache.height = textRect.maxY + bubbleInset.bottom
|
||||
|
||||
return cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.AssistantCell {
|
||||
class ViewModel: MessageListView.Element.ViewModel {
|
||||
var theme: Theme
|
||||
var blocks: [BlockNode]
|
||||
|
||||
enum GroupLocation {
|
||||
case begin
|
||||
case center
|
||||
case end
|
||||
}
|
||||
|
||||
var groupLocation: GroupLocation = .center
|
||||
|
||||
init(theme: Theme = .default, blocks: [BlockNode]) {
|
||||
self.theme = theme
|
||||
self.blocks = blocks
|
||||
}
|
||||
|
||||
func contentIdentifier(hasher: inout Hasher) {
|
||||
hasher.combine(blocks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.AssistantCell {
|
||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
||||
var width: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
|
||||
var avatarRect: CGRect = .zero
|
||||
var usernameRect: CGRect = .zero
|
||||
|
||||
var markdownFrame: CGRect = .zero
|
||||
var manifests: [AnyBlockManifest] = []
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
//
|
||||
// MessageListView+BaseCell.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
class BaseCell: UITableViewCell, MessageListView.TableLayoutEngine.LayoutableCell {
|
||||
var associatedObject: Element? = nil
|
||||
var cancellable: Set<AnyCancellable> = []
|
||||
let containerView: UIView = .init()
|
||||
|
||||
var layoutEngine: MessageListView.TableLayoutEngine? = nil
|
||||
|
||||
func layoutCache() -> MessageListView.TableLayoutEngine.LayoutCache {
|
||||
guard let associatedObject, let engine = layoutEngine else {
|
||||
return MessageListView.TableLayoutEngine.ZeroLayoutCache()
|
||||
}
|
||||
let cache = engine.requestLayoutCacheFromCell(
|
||||
forElement: associatedObject,
|
||||
atWidth: bounds.width
|
||||
)
|
||||
return cache
|
||||
}
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
commitInit()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
commitInit()
|
||||
}
|
||||
|
||||
private func commitInit() {
|
||||
selectionStyle = .none
|
||||
separatorInset = .zero
|
||||
contentView.addSubview(containerView)
|
||||
contentView.clipsToBounds = false
|
||||
clipsToBounds = false
|
||||
initializeContent()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
guard let cache = layoutCache() as? LayoutCache else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
containerView.frame = cache.containerRect
|
||||
layoutContent(cache: cache.containerLayoutCache)
|
||||
}
|
||||
|
||||
func registerViewModel(element: Element) {
|
||||
removeViewModelObject()
|
||||
associatedObject = element
|
||||
updateContent(object: element.viewModel, originalObject: element.object)
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func removeViewModelObject() {
|
||||
associatedObject = nil
|
||||
cancellable.forEach { $0.cancel() }
|
||||
cancellable.removeAll()
|
||||
}
|
||||
|
||||
func initializeContent() {}
|
||||
func updateContent(object: any Element.ViewModel, originalObject: Element.UserObject?) {
|
||||
_ = object
|
||||
_ = originalObject
|
||||
}
|
||||
|
||||
func layoutContent(cache: MessageListView.TableLayoutEngine.LayoutCache) {
|
||||
_ = cache
|
||||
}
|
||||
|
||||
class func layoutInsideContainer(
|
||||
containerWidth: CGFloat,
|
||||
object: any Element.ViewModel
|
||||
) -> MessageListView.TableLayoutEngine.LayoutCache {
|
||||
_ = containerWidth
|
||||
_ = object
|
||||
assertionFailure("must override")
|
||||
return MessageListView.TableLayoutEngine.ZeroLayoutCache()
|
||||
}
|
||||
|
||||
class func containerInset() -> UIEdgeInsets {
|
||||
let inset: CGFloat = 16
|
||||
let containerInset = UIEdgeInsets(top: inset / 2, left: inset, bottom: inset / 2, right: inset)
|
||||
return containerInset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.BaseCell {
|
||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
||||
var width: CGFloat
|
||||
var height: CGFloat
|
||||
var containerRect: CGRect
|
||||
var containerLayoutCache: any MessageListView.TableLayoutEngine.LayoutCache
|
||||
|
||||
init(
|
||||
width: CGFloat,
|
||||
height: CGFloat,
|
||||
containerRect: CGRect,
|
||||
containerLayoutCache: any MessageListView.TableLayoutEngine.LayoutCache
|
||||
) {
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.containerRect = containerRect
|
||||
self.containerLayoutCache = containerLayoutCache
|
||||
}
|
||||
}
|
||||
|
||||
class func resolveLayout(
|
||||
dataElement element: MessageListView.Element,
|
||||
contentWidth width: CGFloat
|
||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
||||
let object = element.viewModel
|
||||
let containerInset = MessageListView.BaseCell.containerInset()
|
||||
let containerWidth = width - containerInset.left - containerInset.right
|
||||
let containerCache = Self.layoutInsideContainer(containerWidth: containerWidth, object: object)
|
||||
let cellHeight = containerCache.height + containerInset.top + containerInset.bottom
|
||||
let containerRect = CGRect(
|
||||
x: containerInset.left,
|
||||
y: containerInset.top,
|
||||
width: containerWidth,
|
||||
height: containerCache.height
|
||||
)
|
||||
let cache = LayoutCache(
|
||||
width: width,
|
||||
height: cellHeight,
|
||||
containerRect: containerRect,
|
||||
containerLayoutCache: containerCache
|
||||
)
|
||||
return cache
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// MessageListView+HintCell.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
class HintCell: BaseCell {
|
||||
let label = UILabel()
|
||||
|
||||
override func initializeContent() {
|
||||
super.initializeContent()
|
||||
label.font = .preferredFont(forTextStyle: .footnote)
|
||||
label.alpha = 0.5
|
||||
label.numberOfLines = 0
|
||||
containerView.addSubview(label)
|
||||
}
|
||||
|
||||
override func updateContent(
|
||||
object: any MessageListView.Element.ViewModel,
|
||||
originalObject: Element.UserObject?
|
||||
) {
|
||||
super.updateContent(object: object, originalObject: originalObject)
|
||||
guard let object = object as? ViewModel else { return }
|
||||
label.attributedText = object.hint
|
||||
}
|
||||
|
||||
override func layoutContent(cache: any MessageListView.TableLayoutEngine.LayoutCache) {
|
||||
super.layoutContent(cache: cache)
|
||||
guard let cache = cache as? LayoutCache else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
label.frame = cache.labelFrame
|
||||
}
|
||||
|
||||
override class func layoutInsideContainer(
|
||||
containerWidth: CGFloat,
|
||||
object: any MessageListView.Element.ViewModel
|
||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
||||
guard let object = object as? ViewModel else {
|
||||
assertionFailure()
|
||||
return LayoutCache()
|
||||
}
|
||||
let cache = LayoutCache()
|
||||
cache.width = containerWidth
|
||||
cache.height = object.hint.measureHeight(usingWidth: containerWidth)
|
||||
cache.labelFrame = .init(x: 0, y: 0, width: containerWidth, height: cache.height)
|
||||
return cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.HintCell {
|
||||
class ViewModel: MessageListView.Element.ViewModel {
|
||||
var hint: NSAttributedString = .init()
|
||||
|
||||
init(hint: NSAttributedString) {
|
||||
self.hint = hint
|
||||
}
|
||||
|
||||
convenience init(hint: String) {
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.alignment = .center
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: UIFont.preferredFont(forTextStyle: .footnote),
|
||||
.originalFont: UIFont.preferredFont(forTextStyle: .footnote),
|
||||
.foregroundColor: UIColor.label,
|
||||
.paragraphStyle: paragraphStyle,
|
||||
]
|
||||
let text = NSMutableAttributedString(string: hint, attributes: attributes)
|
||||
self.init(hint: text)
|
||||
}
|
||||
|
||||
func contentIdentifier(hasher: inout Hasher) {
|
||||
hasher.combine(hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.HintCell {
|
||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
||||
var width: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
|
||||
var labelFrame: CGRect = .zero
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// MessageListView+SpacerCell.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/12.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
class SpacerCell: BaseCell {
|
||||
override class func layoutInsideContainer(
|
||||
containerWidth: CGFloat,
|
||||
object: any MessageListView.Element.ViewModel
|
||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
||||
guard let object = object as? ViewModel else {
|
||||
assertionFailure()
|
||||
return LayoutCache()
|
||||
}
|
||||
let cache = LayoutCache()
|
||||
cache.width = containerWidth
|
||||
cache.height = object.height
|
||||
return cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.SpacerCell {
|
||||
class ViewModel: MessageListView.Element.ViewModel {
|
||||
var height: CGFloat
|
||||
init(height: CGFloat) {
|
||||
self.height = height
|
||||
}
|
||||
|
||||
func contentIdentifier(hasher: inout Hasher) {
|
||||
hasher.combine(height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.SpacerCell {
|
||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
||||
var width: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
//
|
||||
// MessageListView+UserCell.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
class UserCell: BaseCell {
|
||||
let avatarView = UIImageView()
|
||||
let usernameView = UILabel()
|
||||
let bubbleView = UIView()
|
||||
let textView = UITextView()
|
||||
|
||||
override func initializeContent() {
|
||||
super.initializeContent()
|
||||
|
||||
textView.isSelectable = true
|
||||
textView.isScrollEnabled = true
|
||||
textView.isEditable = false
|
||||
textView.showsVerticalScrollIndicator = false
|
||||
textView.showsHorizontalScrollIndicator = false
|
||||
textView.textColor = .label
|
||||
textView.textContainer.lineFragmentPadding = .zero
|
||||
textView.textAlignment = .natural
|
||||
textView.backgroundColor = .clear
|
||||
textView.textContainerInset = .zero
|
||||
textView.textContainer.lineBreakMode = .byTruncatingTail
|
||||
|
||||
avatarView.contentMode = .scaleAspectFit
|
||||
avatarView.image = UIImage(systemName: "person.fill")
|
||||
usernameView.text = "You"
|
||||
usernameView.font = .preferredFont(forTextStyle: .body).bold
|
||||
usernameView.textColor = .label
|
||||
|
||||
bubbleView.layer.cornerRadius = 8
|
||||
bubbleView.backgroundColor = .gray.withAlphaComponent(0.1)
|
||||
|
||||
containerView.addSubview(bubbleView)
|
||||
containerView.addSubview(avatarView)
|
||||
containerView.addSubview(usernameView)
|
||||
containerView.addSubview(textView)
|
||||
}
|
||||
|
||||
override func updateContent(
|
||||
object: any MessageListView.Element.ViewModel,
|
||||
originalObject: Element.UserObject?
|
||||
) {
|
||||
super.updateContent(object: object, originalObject: originalObject)
|
||||
guard let object = object as? ViewModel else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
textView.attributedText = object.text
|
||||
}
|
||||
|
||||
override func layoutContent(cache: any MessageListView.TableLayoutEngine.LayoutCache) {
|
||||
super.layoutContent(cache: cache)
|
||||
guard let cache = cache as? LayoutCache else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
bubbleView.frame = cache.bubbleFrame
|
||||
avatarView.frame = cache.avatarFrame
|
||||
usernameView.frame = cache.usernameFrame
|
||||
textView.frame = cache.labelFrame
|
||||
}
|
||||
|
||||
override class func layoutInsideContainer(
|
||||
containerWidth: CGFloat,
|
||||
object: any MessageListView.Element.ViewModel
|
||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
||||
guard let object = object as? ViewModel else {
|
||||
assertionFailure()
|
||||
return LayoutCache()
|
||||
}
|
||||
let cache = LayoutCache()
|
||||
cache.width = containerWidth
|
||||
|
||||
let inset: CGFloat = 8
|
||||
let bubbleInset = UIEdgeInsets(top: inset, left: inset, bottom: inset, right: inset)
|
||||
|
||||
let avatarRect = CGRect(
|
||||
x: bubbleInset.left,
|
||||
y: bubbleInset.top,
|
||||
width: 24,
|
||||
height: 24
|
||||
)
|
||||
let usernameFrame = CGRect(
|
||||
x: avatarRect.maxX + inset,
|
||||
y: bubbleInset.top,
|
||||
width: containerWidth - avatarRect.maxX - bubbleInset.right,
|
||||
height: 24
|
||||
)
|
||||
|
||||
let textWidth = min(
|
||||
object.text.measureWidth(),
|
||||
containerWidth - inset * 2
|
||||
)
|
||||
let textHeight = object.text.measureHeight(usingWidth: textWidth)
|
||||
let textRect = CGRect(
|
||||
x: bubbleInset.left,
|
||||
y: avatarRect.maxY + bubbleInset.top,
|
||||
width: textWidth,
|
||||
height: textHeight
|
||||
)
|
||||
let bubbleRect = CGRect(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: containerWidth,
|
||||
height: textRect.maxY + bubbleInset.bottom
|
||||
)
|
||||
cache.bubbleFrame = bubbleRect
|
||||
cache.avatarFrame = avatarRect
|
||||
cache.usernameFrame = usernameFrame
|
||||
cache.labelFrame = textRect
|
||||
cache.height = bubbleRect.maxY
|
||||
return cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.UserCell {
|
||||
class ViewModel: MessageListView.Element.ViewModel {
|
||||
var text: NSAttributedString = .init()
|
||||
|
||||
init(text: NSAttributedString) {
|
||||
self.text = text
|
||||
}
|
||||
|
||||
convenience init(text: String) {
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.alignment = .natural
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: UIFont.preferredFont(forTextStyle: .body),
|
||||
.originalFont: UIFont.preferredFont(forTextStyle: .body),
|
||||
.foregroundColor: UIColor.label,
|
||||
.paragraphStyle: paragraphStyle,
|
||||
]
|
||||
var text = text
|
||||
while text.contains("\n\n\n") {
|
||||
text = text.replacingOccurrences(of: "\n\n\n", with: "\n\n")
|
||||
}
|
||||
print(text)
|
||||
self.init(text: NSMutableAttributedString(string: text, attributes: attributes))
|
||||
}
|
||||
|
||||
func contentIdentifier(hasher: inout Hasher) {
|
||||
hasher.combine(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.UserCell {
|
||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
||||
var width: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
|
||||
var bubbleFrame: CGRect = .zero
|
||||
var labelFrame: CGRect = .zero
|
||||
var avatarFrame: CGRect = .zero
|
||||
var usernameFrame: CGRect = .zero
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// MessageListView+DataElement.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
struct Element: Identifiable {
|
||||
let id: AnyHashable // equals to message id if applicable
|
||||
|
||||
enum Cell: String, CaseIterable {
|
||||
case base
|
||||
case hint
|
||||
case user
|
||||
case assistant
|
||||
case spacer
|
||||
}
|
||||
|
||||
let cell: Cell
|
||||
let viewModel: any ViewModel
|
||||
|
||||
typealias UserObject = any(Identifiable & Hashable)
|
||||
let object: UserObject?
|
||||
|
||||
init(id: AnyHashable, cell: Cell, viewModel: any ViewModel, object: UserObject?) {
|
||||
assert(cell != .base)
|
||||
self.id = id
|
||||
self.cell = cell
|
||||
self.viewModel = viewModel
|
||||
self.object = object
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.Element.Cell {
|
||||
var cellClass: MessageListView.BaseCell.Type {
|
||||
switch self {
|
||||
case .base:
|
||||
MessageListView.BaseCell.self
|
||||
case .hint:
|
||||
MessageListView.HintCell.self
|
||||
case .user:
|
||||
MessageListView.UserCell.self
|
||||
case .assistant:
|
||||
MessageListView.AssistantCell.self
|
||||
case .spacer:
|
||||
MessageListView.SpacerCell.self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.Element {
|
||||
protocol ViewModel {
|
||||
func contentIdentifier(hasher: inout Hasher)
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// MessageListView+Delegate.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/6.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension MessageListView: UITableViewDelegate, UITableViewDataSource {
|
||||
func item(forIndexPath indexPath: IndexPath) -> Element? {
|
||||
guard indexPath.row < elements.count else {
|
||||
return nil
|
||||
}
|
||||
guard indexPath.row >= 0 else {
|
||||
return nil
|
||||
}
|
||||
return elements.values[indexPath.row]
|
||||
}
|
||||
|
||||
func numberOfSections(in _: UITableView) -> Int {
|
||||
1
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
|
||||
elements.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard let item = item(forIndexPath: indexPath) else {
|
||||
assertionFailure()
|
||||
return UITableViewCell()
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: item.cell.rawValue, for: indexPath)
|
||||
if let cell = cell as? BaseCell {
|
||||
cell.layoutEngine = layoutEngine
|
||||
cell.registerViewModel(element: item)
|
||||
}
|
||||
cell.backgroundColor = .clear
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
guard let item = item(forIndexPath: indexPath) else {
|
||||
return 0
|
||||
}
|
||||
if let height = layoutEngine.height(forElement: item) {
|
||||
heightKeeper[item.id] = height
|
||||
return height
|
||||
}
|
||||
let ret = layoutEngine.resolveLayoutNow(item).height
|
||||
heightKeeper[item.id] = ret
|
||||
return ret
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
guard let item = item(forIndexPath: indexPath) else {
|
||||
return 0
|
||||
}
|
||||
if let height = layoutEngine.height(forElement: item) {
|
||||
return height
|
||||
}
|
||||
if let height = heightKeeper[item.id] {
|
||||
return height
|
||||
}
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
//
|
||||
// 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: "flowdown.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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
let footerView = UIView(frame: .init(x: 0, y: 0, width: 0, height: 500))
|
||||
|
||||
init(dataPublisher: AnyPublisher<[Element], Never>) {
|
||||
super.init(frame: .zero)
|
||||
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
tableView.allowsSelection = false
|
||||
tableView.allowsMultipleSelection = false
|
||||
tableView.allowsFocus = false
|
||||
tableView.selectionFollowsFocus = true
|
||||
tableView.separatorColor = .clear
|
||||
tableView.backgroundColor = .clear
|
||||
for cellIdentifier in Element.Cell.allCases {
|
||||
tableView.register(cellIdentifier.cellClass, forCellReuseIdentifier: cellIdentifier.rawValue)
|
||||
}
|
||||
addSubview(tableView)
|
||||
|
||||
tableView.tableFooterView = footerView
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
tableView.topAnchor.constraint(equalTo: topAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
|
||||
setupPublishers(dataPublisher: dataPublisher)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancellables.forEach { $0.cancel() }
|
||||
cancellables.removeAll()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
viewCallingUpdateLayoutEngineWidth()
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// EphemeralAction.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/8.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension IntelligentsEphemeralActionController {
|
||||
enum EphemeralAction {
|
||||
public enum Language: String, CaseIterable {
|
||||
case langEnglish = "English"
|
||||
case langSpanish = "Spanish"
|
||||
case langGerman = "German"
|
||||
case langFrench = "French"
|
||||
case langItalian = "Italian"
|
||||
case langSimplifiedChinese = "Simplified Chinese"
|
||||
case langTraditionalChinese = "Traditional Chinese"
|
||||
case langJapanese = "Japanese"
|
||||
case langRussian = "Russian"
|
||||
case langKorean = "Korean"
|
||||
}
|
||||
|
||||
case translate(to: Language)
|
||||
case summarize
|
||||
}
|
||||
}
|
||||
|
||||
extension IntelligentsEphemeralActionController.EphemeralAction {
|
||||
var title: String {
|
||||
switch self {
|
||||
case let .translate(to):
|
||||
String(format: NSLocalizedString("Translate to %@", comment: ""), to.rawValue)
|
||||
case .summarize:
|
||||
NSLocalizedString("Summarize", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
var prompt: Prompt {
|
||||
switch self {
|
||||
case .translate:
|
||||
.general_Translate_to
|
||||
case .summarize:
|
||||
.general_Summary
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
//
|
||||
// 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 = ""
|
||||
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(
|
||||
NSError(
|
||||
domain: "Intelligents",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Unable to identify the document or workspace"]
|
||||
)
|
||||
)
|
||||
}
|
||||
Intelligents.qlClient.perform(
|
||||
mutation: CreateCopilotSessionMutation(options: .init(
|
||||
docId: documentIdentifier,
|
||||
promptName: ation.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(
|
||||
NSError(
|
||||
domain: "Intelligents",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No session created"]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
case let .failure(error):
|
||||
DispatchQueue.main.async { onFailure(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func beginThisRound() {
|
||||
Intelligents.qlClient.perform(
|
||||
mutation: CreateCopilotMessageMutation(options: .init(
|
||||
content: .init(stringLiteral: "\(documentContent)"),
|
||||
sessionId: sessionID
|
||||
)),
|
||||
queue: .global()
|
||||
) { result in
|
||||
switch result {
|
||||
case let .success(value):
|
||||
if let messageID = value.data?.createCopilotMessage {
|
||||
print("[*] messageID", messageID)
|
||||
self.chat_processWithMessageID(sessionID: self.sessionID, messageID: messageID)
|
||||
}
|
||||
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(NSError(
|
||||
domain: "Intelligents",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No message created"]
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
let eventHandler = BlockEventHandler()
|
||||
eventHandler.onOpenedBlock = {
|
||||
print("[*] chat opened")
|
||||
}
|
||||
eventHandler.onErrorBlock = { error in
|
||||
self.presentError(error) { self.close() }
|
||||
}
|
||||
eventHandler.onMessageBlock = { _, message in
|
||||
self.chat_onEvent(message.data)
|
||||
}
|
||||
eventHandler.onClosedBlock = {
|
||||
self.chatTask?.stop()
|
||||
self.chatTask = nil
|
||||
}
|
||||
let eventSource = EventSource(config: .init(handler: eventHandler, url: url))
|
||||
eventSource.start()
|
||||
chatTask = eventSource
|
||||
}
|
||||
|
||||
func chat_onEvent(_ data: String) {
|
||||
if Thread.isMainThread {
|
||||
copilotDocumentStorage += data
|
||||
} else {
|
||||
DispatchQueue.main.asyncAndWait {
|
||||
self.copilotDocumentStorage += data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
//
|
||||
// IntelligentsEphemeralActionController+ActionBar.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/15.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension IntelligentsEphemeralActionController {
|
||||
class ActionBar: UIView {
|
||||
let retryButton = DarkActionButton()
|
||||
let continueToChat = DarkActionButton()
|
||||
let createNewDoc = DarkActionButton()
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
defer { removeEveryAutoResizingMasks() }
|
||||
|
||||
let contentSpacing: CGFloat = 16
|
||||
let buttonGroupHeight: CGFloat = 55
|
||||
|
||||
let firstButtonSectionGroup = UIView()
|
||||
addSubview(firstButtonSectionGroup)
|
||||
[
|
||||
firstButtonSectionGroup.topAnchor.constraint(equalTo: topAnchor, constant: contentSpacing),
|
||||
firstButtonSectionGroup.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
firstButtonSectionGroup.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
firstButtonSectionGroup.heightAnchor.constraint(equalToConstant: buttonGroupHeight),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
retryButton.title = NSLocalizedString("Retry", comment: "")
|
||||
retryButton.iconSystemName = "arrow.clockwise"
|
||||
continueToChat.title = NSLocalizedString("Continue to Chat", comment: "")
|
||||
continueToChat.iconSystemName = "paperplane"
|
||||
firstButtonSectionGroup.addSubview(retryButton)
|
||||
firstButtonSectionGroup.addSubview(continueToChat)
|
||||
[
|
||||
retryButton.topAnchor.constraint(equalTo: firstButtonSectionGroup.topAnchor),
|
||||
retryButton.leadingAnchor.constraint(equalTo: firstButtonSectionGroup.leadingAnchor),
|
||||
retryButton.bottomAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor),
|
||||
|
||||
continueToChat.topAnchor.constraint(equalTo: firstButtonSectionGroup.topAnchor),
|
||||
continueToChat.trailingAnchor.constraint(equalTo: firstButtonSectionGroup.trailingAnchor),
|
||||
continueToChat.bottomAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor),
|
||||
|
||||
retryButton.widthAnchor.constraint(equalTo: continueToChat.widthAnchor),
|
||||
retryButton.trailingAnchor.constraint(equalTo: continueToChat.leadingAnchor, constant: -contentSpacing),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
let secondButtonSectionGroup = UIView()
|
||||
addSubview(secondButtonSectionGroup)
|
||||
[
|
||||
secondButtonSectionGroup.topAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor, constant: contentSpacing),
|
||||
secondButtonSectionGroup.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
secondButtonSectionGroup.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
secondButtonSectionGroup.heightAnchor.constraint(equalToConstant: buttonGroupHeight),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
secondButtonSectionGroup.addSubview(createNewDoc)
|
||||
createNewDoc.title = NSLocalizedString("Create New Doc", comment: "")
|
||||
createNewDoc.iconSystemName = "doc.badge.plus"
|
||||
[
|
||||
createNewDoc.topAnchor.constraint(equalTo: secondButtonSectionGroup.topAnchor),
|
||||
createNewDoc.leadingAnchor.constraint(equalTo: secondButtonSectionGroup.leadingAnchor),
|
||||
createNewDoc.bottomAnchor.constraint(equalTo: secondButtonSectionGroup.bottomAnchor),
|
||||
createNewDoc.trailingAnchor.constraint(equalTo: secondButtonSectionGroup.trailingAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
[
|
||||
secondButtonSectionGroup.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
//
|
||||
// IntelligentsEphemeralActionController.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/8.
|
||||
//
|
||||
|
||||
import LDSwiftEventSource
|
||||
import MarkdownParser
|
||||
import MarkdownView
|
||||
import UIKit
|
||||
|
||||
public class IntelligentsEphemeralActionController: UIViewController {
|
||||
let ation: 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 = ""
|
||||
var sessionID: String = ""
|
||||
|
||||
var chatTask: EventSource?
|
||||
var copilotDocumentStorage: String = "" {
|
||||
didSet {
|
||||
guard copilotDocumentStorage != oldValue else { return }
|
||||
updateDocumentPresentationView()
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
public init(action: EphemeralAction) {
|
||||
ation = 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()
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
//
|
||||
// IntelligentsFocusApertureView+ActionButton.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/21.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension IntelligentsFocusApertureView.ControlButtonsPanel {
|
||||
class DarkActionButton: UIView {
|
||||
var iconSystemName: String {
|
||||
set { iconView.image = UIImage(systemName: newValue) }
|
||||
get { fatalError() }
|
||||
}
|
||||
|
||||
var title: String {
|
||||
set { titleLabel.text = newValue }
|
||||
get { titleLabel.text ?? "" }
|
||||
}
|
||||
|
||||
let titleLabel = UILabel()
|
||||
let iconView = UIImageView()
|
||||
var action: (() -> Void)? = nil
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white.withAlphaComponent(0.25)
|
||||
layer.cornerRadius = 12
|
||||
|
||||
let layoutGuide = UILayoutGuide()
|
||||
addLayoutGuide(layoutGuide)
|
||||
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.font = .systemFont(ofSize: UIFont.labelFontSize, weight: .semibold)
|
||||
titleLabel.textColor = .white
|
||||
addSubview(titleLabel)
|
||||
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
iconView.tintColor = .white
|
||||
addSubview(iconView)
|
||||
|
||||
[
|
||||
layoutGuide.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
layoutGuide.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
|
||||
iconView.topAnchor.constraint(greaterThanOrEqualTo: layoutGuide.topAnchor),
|
||||
iconView.leadingAnchor.constraint(equalTo: layoutGuide.leadingAnchor),
|
||||
iconView.bottomAnchor.constraint(lessThanOrEqualTo: layoutGuide.bottomAnchor),
|
||||
iconView.centerYAnchor.constraint(equalTo: layoutGuide.centerYAnchor),
|
||||
|
||||
titleLabel.topAnchor.constraint(greaterThanOrEqualTo: layoutGuide.topAnchor),
|
||||
titleLabel.trailingAnchor.constraint(equalTo: layoutGuide.trailingAnchor),
|
||||
titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: layoutGuide.bottomAnchor),
|
||||
titleLabel.centerYAnchor.constraint(equalTo: layoutGuide.centerYAnchor),
|
||||
|
||||
titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
isUserInteractionEnabled = true
|
||||
addGestureRecognizer(UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(onTapped)
|
||||
))
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
@objc func onTapped() {
|
||||
action?()
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -9,12 +9,20 @@ import UIKit
|
||||
|
||||
extension IntelligentsFocusApertureView {
|
||||
func captureImageBuffer(_ targetContentView: UIView) {
|
||||
let imageSize = targetContentView.frame.size
|
||||
let renderer = UIGraphicsImageRenderer(size: imageSize)
|
||||
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: targetContentView.bounds,
|
||||
afterScreenUpdates: false
|
||||
in: drawRect,
|
||||
afterScreenUpdates: true
|
||||
)
|
||||
}
|
||||
capturedImage = image
|
||||
|
||||
+4
-1
@@ -15,5 +15,8 @@ public enum IntelligentsFocusApertureViewActionType: String {
|
||||
}
|
||||
|
||||
public protocol IntelligentsFocusApertureViewDelegate: AnyObject {
|
||||
func focusApertureRequestAction(actionType: IntelligentsFocusApertureViewActionType)
|
||||
func focusApertureRequestAction(
|
||||
from: IntelligentsFocusApertureView,
|
||||
actionType: IntelligentsFocusApertureViewActionType
|
||||
)
|
||||
}
|
||||
|
||||
+10
-10
@@ -38,10 +38,10 @@ extension IntelligentsFocusApertureView {
|
||||
}
|
||||
|
||||
contentBeginConstraints = [
|
||||
snapshotView.leftAnchor.constraint(equalTo: targetView.leftAnchor),
|
||||
snapshotView.rightAnchor.constraint(equalTo: targetView.rightAnchor),
|
||||
snapshotView.topAnchor.constraint(equalTo: targetView.topAnchor),
|
||||
snapshotView.bottomAnchor.constraint(equalTo: targetView.bottomAnchor),
|
||||
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),
|
||||
@@ -50,10 +50,10 @@ extension IntelligentsFocusApertureView {
|
||||
|
||||
let sharedInset: CGFloat = 32
|
||||
contentFinalConstraints = [
|
||||
snapshotView.leftAnchor.constraint(equalTo: leftAnchor, constant: sharedInset),
|
||||
snapshotView.rightAnchor.constraint(equalTo: rightAnchor, constant: -sharedInset),
|
||||
snapshotView.topAnchor.constraint(equalTo: topAnchor),
|
||||
snapshotView.bottomAnchor.constraint(equalTo: controlButtonsPanel.topAnchor, constant: -sharedInset / 2),
|
||||
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),
|
||||
@@ -73,12 +73,12 @@ extension IntelligentsFocusApertureView {
|
||||
NSLayoutConstraint.deactivate(contentFinalConstraints)
|
||||
NSLayoutConstraint.activate(contentBeginConstraints)
|
||||
|
||||
snapshotView.layer.cornerRadius = 0
|
||||
snapshotImageView.layer.cornerRadius = 0
|
||||
case .complete:
|
||||
NSLayoutConstraint.deactivate(contentBeginConstraints)
|
||||
NSLayoutConstraint.activate(contentFinalConstraints)
|
||||
|
||||
snapshotView.layer.cornerRadius = 32
|
||||
snapshotImageView.layer.cornerRadius = 32
|
||||
}
|
||||
let effectiveView = superview ?? self
|
||||
effectiveView.setNeedsUpdateConstraints()
|
||||
|
||||
+24
-19
@@ -8,8 +8,8 @@
|
||||
import UIKit
|
||||
|
||||
public class IntelligentsFocusApertureView: UIView {
|
||||
let backgroundView = UIView()
|
||||
let snapshotView = UIImageView()
|
||||
public let backgroundView = UIView()
|
||||
public let snapshotImageView = UIImageView()
|
||||
let controlButtonsPanel = ControlButtonsPanel()
|
||||
|
||||
public var animationDuration: TimeInterval = 0.75
|
||||
@@ -17,8 +17,8 @@ public class IntelligentsFocusApertureView: UIView {
|
||||
public internal(set) weak var targetView: UIView?
|
||||
public internal(set) weak var targetViewController: UIViewController?
|
||||
public internal(set) weak var capturedImage: UIImage? {
|
||||
get { snapshotView.image }
|
||||
set { snapshotView.image = newValue }
|
||||
get { snapshotImageView.image }
|
||||
set { snapshotImageView.image = newValue }
|
||||
}
|
||||
|
||||
var frameConstraints: [NSLayoutConstraint] = []
|
||||
@@ -32,35 +32,40 @@ public class IntelligentsFocusApertureView: UIView {
|
||||
|
||||
backgroundView.backgroundColor = .black
|
||||
backgroundView.isUserInteractionEnabled = true
|
||||
backgroundView.addGestureRecognizer(UITapGestureRecognizer(
|
||||
let tap = UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(dismissFocus)
|
||||
))
|
||||
)
|
||||
tap.cancelsTouchesInView = true
|
||||
backgroundView.addGestureRecognizer(tap)
|
||||
|
||||
snapshotView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||
snapshotView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||
snapshotView.layer.contentsGravity = .top
|
||||
snapshotView.layer.masksToBounds = true
|
||||
snapshotView.contentMode = .scaleAspectFill
|
||||
snapshotView.isUserInteractionEnabled = true
|
||||
snapshotView.addGestureRecognizer(UITapGestureRecognizer(
|
||||
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(snapshotView)
|
||||
bringSubviewToFront(snapshotView)
|
||||
addSubview(snapshotImageView)
|
||||
bringSubviewToFront(snapshotImageView)
|
||||
|
||||
controlButtonsPanel.translateButton.action = { [weak self] in
|
||||
self?.delegate?.focusApertureRequestAction(actionType: .translateTo)
|
||||
guard let self else { return }
|
||||
delegate?.focusApertureRequestAction(from: self, actionType: .translateTo)
|
||||
}
|
||||
controlButtonsPanel.summaryButton.action = { [weak self] in
|
||||
self?.delegate?.focusApertureRequestAction(actionType: .summary)
|
||||
guard let self else { return }
|
||||
delegate?.focusApertureRequestAction(from: self, actionType: .summary)
|
||||
}
|
||||
controlButtonsPanel.chatWithAIButton.action = { [weak self] in
|
||||
self?.delegate?.focusApertureRequestAction(actionType: .chatWithAI)
|
||||
guard let self else { return }
|
||||
delegate?.focusApertureRequestAction(from: self, actionType: .chatWithAI)
|
||||
}
|
||||
removeEveryAutoResizingMasks()
|
||||
}
|
||||
@@ -122,7 +127,7 @@ public class IntelligentsFocusApertureView: UIView {
|
||||
isUserInteractionEnabled = false
|
||||
executeAnimationDismiss {
|
||||
self.removeFromSuperview()
|
||||
self.delegate?.focusApertureRequestAction(actionType: .dismiss)
|
||||
self.delegate?.focusApertureRequestAction(from: self, actionType: .dismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// DarkActionButton.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/21.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class DarkActionButton: UIView {
|
||||
var iconSystemName: String {
|
||||
set { iconView.image = UIImage(systemName: newValue) }
|
||||
get { fatalError() }
|
||||
}
|
||||
|
||||
var title: String {
|
||||
set { titleLabel.text = newValue }
|
||||
get { titleLabel.text ?? "" }
|
||||
}
|
||||
|
||||
let titleLabel = UILabel()
|
||||
let iconView = UIImageView()
|
||||
var action: (() -> Void)? = nil
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white.withAlphaComponent(0.25)
|
||||
layer.cornerRadius = 12
|
||||
|
||||
let layoutGuide = UILayoutGuide()
|
||||
addLayoutGuide(layoutGuide)
|
||||
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.font = .systemFont(ofSize: UIFont.labelFontSize, weight: .semibold)
|
||||
titleLabel.textColor = .white
|
||||
addSubview(titleLabel)
|
||||
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
iconView.tintColor = .white
|
||||
addSubview(iconView)
|
||||
|
||||
[
|
||||
layoutGuide.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
layoutGuide.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
|
||||
iconView.topAnchor.constraint(greaterThanOrEqualTo: layoutGuide.topAnchor),
|
||||
iconView.leadingAnchor.constraint(equalTo: layoutGuide.leadingAnchor),
|
||||
iconView.bottomAnchor.constraint(lessThanOrEqualTo: layoutGuide.bottomAnchor),
|
||||
iconView.centerYAnchor.constraint(equalTo: layoutGuide.centerYAnchor),
|
||||
|
||||
titleLabel.topAnchor.constraint(greaterThanOrEqualTo: layoutGuide.topAnchor),
|
||||
titleLabel.trailingAnchor.constraint(equalTo: layoutGuide.trailingAnchor),
|
||||
titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: layoutGuide.bottomAnchor),
|
||||
titleLabel.centerYAnchor.constraint(equalTo: layoutGuide.centerYAnchor),
|
||||
|
||||
titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
isUserInteractionEnabled = true
|
||||
addGestureRecognizer(UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(onTapped)
|
||||
))
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
@objc func onTapped() {
|
||||
action?()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user