diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 5a609ae6..42db805a 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -87,6 +87,9 @@ A8AD35592D31BF2C003A28B4 /* PageInfoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8AD35582D31BF29003A28B4 /* PageInfoManager.swift */; }; A8B516E32D2666CA0047047C /* CoreDataHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8B516E22D2666CA0047047C /* CoreDataHelper.swift */; }; A8B516E62D2668170047047C /* DateTimeUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8B516E52D2668020047047C /* DateTimeUtil.swift */; }; + A8B645FC2D70C10300125819 /* CollapsingMarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8B645FB2D70C10300125819 /* CollapsingMarkdownView.swift */; }; + A8B645FF2D70C1AD00125819 /* MarkdownKit in Frameworks */ = {isa = PBXBuildFile; productRef = A8B645FE2D70C1AD00125819 /* MarkdownKit */; }; + A8B646012D70C23E00125819 /* MarkdownKit in Frameworks */ = {isa = PBXBuildFile; productRef = A8B646002D70C23E00125819 /* MarkdownKit */; }; A8BB34E52D04EC8E000A8B4D /* minimuxer-helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = A809F6A52D04DA1900F0F0F3 /* minimuxer-helpers.swift */; }; A8C38C242D206A3A00E83DBD /* ConsoleLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C38C1D2D206A3A00E83DBD /* ConsoleLogger.swift */; }; A8C38C262D206A3A00E83DBD /* ConsoleLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C38C1E2D206A3A00E83DBD /* ConsoleLog.swift */; }; @@ -686,6 +689,7 @@ A8AD35582D31BF29003A28B4 /* PageInfoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageInfoManager.swift; sourceTree = ""; }; A8B516E22D2666CA0047047C /* CoreDataHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataHelper.swift; sourceTree = ""; }; A8B516E52D2668020047047C /* DateTimeUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTimeUtil.swift; sourceTree = ""; }; + A8B645FB2D70C10300125819 /* CollapsingMarkdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsingMarkdownView.swift; sourceTree = ""; }; A8C38C1D2D206A3A00E83DBD /* ConsoleLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleLogger.swift; sourceTree = ""; }; A8C38C1E2D206A3A00E83DBD /* ConsoleLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleLog.swift; sourceTree = ""; }; A8C38C282D206AC100E83DBD /* OutputStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputStream.swift; sourceTree = ""; }; @@ -1137,6 +1141,7 @@ files = ( A8C6D5172D1EE95B00DF01F1 /* OpenSSL.xcframework in Frameworks */, A8A543302D04F14400D72399 /* libfragmentzip.a in Frameworks */, + A8B646012D70C23E00125819 /* MarkdownKit in Frameworks */, A805C3CD2D0C316A00E76BDD /* Pods_SideStore.framework in Frameworks */, A8F838942D048ECE00ED425D /* libimobiledevice.a in Frameworks */, A8F838922D048E8F00ED425D /* libEmotionalDamage.a in Frameworks */, @@ -1144,6 +1149,7 @@ D533E8B72727841800A9B5DD /* libAppleArchive.tbd in Frameworks */, D533E8BE2727BBF800A9B5DD /* libcurl.a in Frameworks */, BF1614F1250822F100767AEA /* Roxas.framework in Frameworks */, + A8B645FF2D70C1AD00125819 /* MarkdownKit in Frameworks */, BF66EE852501AE50007EE018 /* AltStoreCore.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1346,6 +1352,22 @@ path = database; sourceTree = ""; }; + A8B645F82D70C0DD00125819 /* Views */ = { + isa = PBXGroup; + children = ( + A8B645FA2D70C0F600125819 /* UIKit */, + ); + path = Views; + sourceTree = ""; + }; + A8B645FA2D70C0F600125819 /* UIKit */ = { + isa = PBXGroup; + children = ( + A8B645FB2D70C10300125819 /* CollapsingMarkdownView.swift */, + ); + path = UIKit; + sourceTree = ""; + }; A8C38C1C2D2068D100E83DBD /* Utils */ = { isa = PBXGroup; children = ( @@ -1427,6 +1449,7 @@ A8F66C072D04C025009689E6 /* SideStore */ = { isa = PBXGroup; children = ( + A8B645F82D70C0DD00125819 /* Views */, A8E2DB352D6850A9009E5D31 /* Tests */, A8F66C5C2D04D433009689E6 /* minimuxer */, A8F66C602D04D464009689E6 /* minimuxer.xcodeproj */, @@ -2682,6 +2705,7 @@ D58D5F2C26DFE68E00E55E38 /* XCRemoteSwiftPackageReference "LaunchAtLogin" */, D5FB7A2C2AA2859400EF863D /* XCRemoteSwiftPackageReference "swift-argument-parser" */, A82067C22D03E0DE00645C0D /* XCRemoteSwiftPackageReference "SemanticVersion" */, + A8B645FD2D70C1AD00125819 /* XCRemoteSwiftPackageReference "MarkdownKit" */, ); productRefGroup = BFD2476B2284B9A500981D42 /* Products */; projectDirPath = ""; @@ -3292,6 +3316,7 @@ D57FE84428C7DB7100216002 /* ErrorLogViewController.swift in Sources */, A88B8C552D35F1EC00F53F9D /* OperationsLoggingControl.swift in Sources */, D50107EC2ADF2E1A0069F2A1 /* AddSourceTextFieldCell.swift in Sources */, + A8B645FC2D70C10300125819 /* CollapsingMarkdownView.swift in Sources */, D5151BD92A8FF64300C96F28 /* RefreshAllAppsIntent.swift in Sources */, BFBE0007250AD0E70080826E /* ViewAppIntentHandler.swift in Sources */, BFDB6A0822AAED73007EA6D6 /* ResignAppOperation.swift in Sources */, @@ -4259,6 +4284,14 @@ minimumVersion = 0.4.0; }; }; + A8B645FD2D70C1AD00125819 /* XCRemoteSwiftPackageReference "MarkdownKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/bmoliveira/MarkdownKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.7.1; + }; + }; D58D5F2C26DFE68E00E55E38 /* XCRemoteSwiftPackageReference "LaunchAtLogin" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/sindresorhus/LaunchAtLogin.git"; @@ -4283,6 +4316,16 @@ package = A82067C22D03E0DE00645C0D /* XCRemoteSwiftPackageReference "SemanticVersion" */; productName = SemanticVersion; }; + A8B645FE2D70C1AD00125819 /* MarkdownKit */ = { + isa = XCSwiftPackageProductDependency; + package = A8B645FD2D70C1AD00125819 /* XCRemoteSwiftPackageReference "MarkdownKit" */; + productName = MarkdownKit; + }; + A8B646002D70C23E00125819 /* MarkdownKit */ = { + isa = XCSwiftPackageProductDependency; + package = A8B645FD2D70C1AD00125819 /* XCRemoteSwiftPackageReference "MarkdownKit" */; + productName = MarkdownKit; + }; A8C6D50B2D1EE87600DF01F1 /* AltSign-Static */ = { isa = XCSwiftPackageProductDependency; productName = "AltSign-Static"; diff --git a/AltStore/App Detail/AppContentViewController.swift b/AltStore/App Detail/AppContentViewController.swift index 692c2557..10fd0993 100644 --- a/AltStore/App Detail/AppContentViewController.swift +++ b/AltStore/App Detail/AppContentViewController.swift @@ -44,7 +44,8 @@ final class AppContentViewController: UITableViewController @IBOutlet private var subtitleLabel: UILabel! @IBOutlet private var descriptionTextView: CollapsingTextView! - @IBOutlet private var versionDescriptionTextView: CollapsingTextView! +// @IBOutlet private var versionDescriptionTextView: CollapsingTextView! + @IBOutlet private var versionDescriptionTextView: CollapsingMarkdownView! @IBOutlet private var versionLabel: UILabel! @IBOutlet private var versionDateLabel: UILabel! @IBOutlet private var sizeLabel: UILabel! @@ -54,9 +55,39 @@ final class AppContentViewController: UITableViewController @IBOutlet private(set) var appDetailCollectionViewController: AppDetailCollectionViewController! @IBOutlet private var appDetailCollectionViewHeightConstraint: NSLayoutConstraint! +// +// override func viewDidLoad() +// { +// super.viewDidLoad() +// +// self.tableView.contentInset.bottom = 20 +// +// self.subtitleLabel.text = self.app.subtitle +// self.descriptionTextView.text = self.app.localizedDescription +// +// if let version = self.app.latestAvailableVersion +// { +// self.versionDescriptionTextView.text = version.localizedDescription ?? "nil" +// self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), version.localizedVersion) +// self.versionDateLabel.text = Date().relativeDateString(since: version.date) +// self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: version.size) +// } +// else +// { +// self.versionDescriptionTextView.text = "nil" +// self.versionLabel.text = nil +// self.versionDateLabel.text = nil +// self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: 0) +// } +// +// self.descriptionTextView.maximumNumberOfLines = 5 +// self.descriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) +// +// self.versionDescriptionTextView.maximumNumberOfLines = 3 +// self.versionDescriptionTextView.toggleButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) +// } - override func viewDidLoad() - { + override func viewDidLoad() { super.viewDidLoad() self.tableView.contentInset.bottom = 20 @@ -64,26 +95,32 @@ final class AppContentViewController: UITableViewController self.subtitleLabel.text = self.app.subtitle self.descriptionTextView.text = self.app.localizedDescription - if let version = self.app.latestAvailableVersion - { - self.versionDescriptionTextView.text = version.localizedDescription + if let version = self.app.latestAvailableVersion { + self.versionDescriptionTextView.text = version.localizedDescription ?? "nil" self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), version.localizedVersion) self.versionDateLabel.text = Date().relativeDateString(since: version.date) - self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: version.size) - } - else - { - self.versionDescriptionTextView.text = nil + self.sizeLabel.text = ByteCountFormatter.string(fromByteCount: version.size, countStyle: .file) + } else { + self.versionDescriptionTextView.text = "nil" self.versionLabel.text = nil self.versionDateLabel.text = nil - self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: 0) + self.sizeLabel.text = ByteCountFormatter.string(fromByteCount: 0, countStyle: .file) } + // Set maximum number of lines (no extra target-action needed) self.descriptionTextView.maximumNumberOfLines = 5 - self.descriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) - self.versionDescriptionTextView.maximumNumberOfLines = 3 - self.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) + + // Instead of adding another target for toggle, set the didToggleCollapse callback: + self.descriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) + + self.versionDescriptionTextView.didToggleCollapse = { [weak self] in + guard let self = self else { return } + UIView.animate(withDuration: 0.25) { + self.tableView.beginUpdates() + self.tableView.endUpdates() + } + } } override func viewDidLayoutSubviews() @@ -162,14 +199,37 @@ private extension AppContentViewController switch sender { - case self.descriptionTextView.moreButton: indexPath = IndexPath(row: Row.description.rawValue, section: 0) - case self.versionDescriptionTextView.moreButton: indexPath = IndexPath(row: Row.versionDescription.rawValue, section: 0) + case self.descriptionTextView.moreButton: + indexPath = IndexPath(row: Row.description.rawValue, section: 0) + // Toggle the state for the text view + self.descriptionTextView.isCollapsed.toggle() + + case self.versionDescriptionTextView.toggleButton: + indexPath = IndexPath(row: Row.versionDescription.rawValue, section: 0) + // Toggle the state for the markdown view + self.versionDescriptionTextView.isCollapsed.toggle() + default: return } - // Disable animations to prevent some potentially strange ones. - UIView.performWithoutAnimation { - self.tableView.reloadRows(at: [indexPath], with: .none) + // First apply the new state to the views + switch Row.allCases[indexPath.row] { + case .description: + self.descriptionTextView.setNeedsLayout() + self.descriptionTextView.layoutIfNeeded() + + case .versionDescription: + self.versionDescriptionTextView.setNeedsLayout() + self.versionDescriptionTextView.layoutIfNeeded() + + default: break + } + + // Then reload the row with animation + UIView.animate(withDuration: 0.25) { + // Force table view to recalculate height + self.tableView.beginUpdates() + self.tableView.endUpdates() } } } diff --git a/AltStore/Base.lproj/Main.storyboard b/AltStore/Base.lproj/Main.storyboard index 2b60ee1d..5962d35f 100644 --- a/AltStore/Base.lproj/Main.storyboard +++ b/AltStore/Base.lproj/Main.storyboard @@ -353,7 +353,7 @@ - + diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index 2cdb8df5..7906e71a 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -235,7 +235,7 @@ private extension MyAppsViewController cell.layoutMargins.right = self.view.layoutMargins.right cell.tintColor = app.tintColor ?? .altPrimary - cell.versionDescriptionTextView.text = latestSupportedVersion.localizedDescription + cell.versionDescriptionTextView.text = latestSupportedVersion.localizedDescription ?? "nil" cell.bannerView.iconImageView.image = nil cell.bannerView.iconImageView.isIndicatingActivity = true @@ -281,7 +281,7 @@ private extension MyAppsViewController cell.mode = .collapsed } - cell.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(MyAppsViewController.toggleUpdateCellMode(_:)), for: .primaryActionTriggered) + cell.versionDescriptionTextView.toggleButton.addTarget(self, action: #selector(MyAppsViewController.toggleUpdateCellMode(_:)), for: .primaryActionTriggered) cell.setNeedsLayout() @@ -724,22 +724,29 @@ private extension MyAppsViewController let cell = self.collectionView.cellForItem(at: indexPath) as? UpdateCollectionViewCell + // Toggle the state if self.expandedAppUpdates.contains(installedApp.bundleIdentifier) { self.expandedAppUpdates.remove(installedApp.bundleIdentifier) + // Set collapsed mode on the cell cell?.mode = .collapsed } else { self.expandedAppUpdates.insert(installedApp.bundleIdentifier) + // Set expanded mode on the cell cell?.mode = .expanded } + // Clear cached size so it's recalculated self.cachedUpdateSizes[installedApp.bundleIdentifier] = nil - self.collectionView.performBatchUpdates({ - self.collectionView.collectionViewLayout.invalidateLayout() - }, completion: nil) + // Animate the change smoothly with a duration + UIView.animate(withDuration: 0.25) { + self.collectionView.performBatchUpdates({ + self.collectionView.collectionViewLayout.invalidateLayout() + }, completion: nil) + } } @IBAction func refreshApp(_ sender: UIButton) diff --git a/AltStore/My Apps/UpdateCollectionViewCell.swift b/AltStore/My Apps/UpdateCollectionViewCell.swift index 7e72ca32..4dce613e 100644 --- a/AltStore/My Apps/UpdateCollectionViewCell.swift +++ b/AltStore/My Apps/UpdateCollectionViewCell.swift @@ -21,12 +21,19 @@ extension UpdateCollectionViewCell { var mode: Mode = .expanded { didSet { - self.update() + switch self.mode { + case .collapsed: + self.versionDescriptionTextView.isCollapsed = true + case .expanded: + self.versionDescriptionTextView.isCollapsed = false + } + self.setNeedsLayout() } } @IBOutlet var bannerView: AppBannerView! - @IBOutlet var versionDescriptionTextView: CollapsingTextView! +// @IBOutlet var versionDescriptionTextView: CollapsingTextView! + @IBOutlet var versionDescriptionTextView: CollapsingMarkdownView! @IBOutlet private var blurView: UIVisualEffectView! diff --git a/AltStore/My Apps/UpdateCollectionViewCell.xib b/AltStore/My Apps/UpdateCollectionViewCell.xib index 2330ed32..117e0338 100644 --- a/AltStore/My Apps/UpdateCollectionViewCell.xib +++ b/AltStore/My Apps/UpdateCollectionViewCell.xib @@ -1,9 +1,9 @@ - + - + @@ -11,7 +11,7 @@ - + @@ -30,7 +30,7 @@ - + @@ -39,7 +39,7 @@ - + @@ -91,7 +91,7 @@ - + diff --git a/SideStore/Views/UIKit/CollapsingMarkdownView.swift b/SideStore/Views/UIKit/CollapsingMarkdownView.swift new file mode 100644 index 00000000..c1ce6d46 --- /dev/null +++ b/SideStore/Views/UIKit/CollapsingMarkdownView.swift @@ -0,0 +1,267 @@ +// +// CollapsingMarkdownView.swift +// AltStore +// +// Created by Magesh K on 27/02/25. +// Copyright © 2025 SideStore. All rights reserved. +// + + +import UIKit +import MarkdownKit + +struct MarkdownManager +{ + struct Fonts{ + static let body: UIFont = .systemFont(ofSize: UIFont.systemFontSize) +// static let body: UIFont = .systemFont(ofSize: UIFont.labelFontSize) + + static let header: UIFont = .boldSystemFont(ofSize: 14) + static let list: UIFont = .systemFont(ofSize: 14) + static let bold: UIFont = .boldSystemFont(ofSize: 14) + static let italic: UIFont = .italicSystemFont(ofSize: 14) + static let quote: UIFont = .italicSystemFont(ofSize: 14) + } + + static var enabledElements: MarkdownParser.EnabledElements { + [ + .header, + .list, + .quote, + .code, + .link, + .bold, + .italic, + ] + } + + var markdownParser: MarkdownParser { + MarkdownParser(font: Self.Fonts.body) + } +} + +final class CollapsingMarkdownView: UIView { + /// Called when the collapse state toggles. + var didToggleCollapse: (() -> Void)? + + + // MARK: - Properties + var isCollapsed = true { + didSet { + guard self.isCollapsed != oldValue else { return } + self.updateToggleButtonTitle() + self.updateCollapsedState() + } + } + + var maximumNumberOfLines = 3 { + didSet { + self.updateCollapsedState() + self.setNeedsLayout() + } + } + + var text: String = "" { + didSet { + self.updateMarkdownContent() + self.shouldResetLayout = true + self.setNeedsLayout() + } + } + + var lineSpacing: Double = 2 { + didSet { + self.shouldResetLayout = true + self.setNeedsLayout() + } + } + + let toggleButton = UIButton(type: .system) + + private let textView = UITextView() + private let markdownParser = MarkdownManager().markdownParser + + private var shouldResetLayout: Bool = false + private var previousSize: CGSize? + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + initialize() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + initialize() + } + + override func awakeFromNib() { + super.awakeFromNib() + initialize() + } + + private func updateCollapsedState() { + // Update the button title + let title = isCollapsed ? NSLocalizedString("More", comment: "") : NSLocalizedString("Less", comment: "") + toggleButton.setTitle(title, for: .normal) + + // Update text view constraints + if isCollapsed { + textView.textContainer.maximumNumberOfLines = maximumNumberOfLines + + // Create exclusion path for button + let buttonSize = toggleButton.sizeThatFits(CGSize(width: 1000, height: 1000)) + let buttonY = (textView.font?.lineHeight ?? 0) * CGFloat(maximumNumberOfLines - 1) + + let exclusionFrame = CGRect( + x: bounds.width - buttonSize.width - 5, // Add some padding + y: buttonY, + width: buttonSize.width + 10, // Add padding around button + height: (textView.font?.lineHeight ?? 0) + 5 + ) + + textView.textContainer.exclusionPaths = [UIBezierPath(rect: exclusionFrame)] + } else { + textView.textContainer.maximumNumberOfLines = 0 + textView.textContainer.exclusionPaths = [] + } + + // Force layout update + textView.layoutIfNeeded() + self.invalidateIntrinsicContentSize() + } + + private func initialize() { + // Configure text view + textView.isEditable = false + textView.isScrollEnabled = false + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.textContainer.lineBreakMode = .byTruncatingTail + textView.backgroundColor = .clear + + // Make textView selectable to enable link interactions + textView.isSelectable = true + textView.delegate = self + + // Important: This prevents selection handles from appearing + textView.dataDetectorTypes = .link + + // Configure markdown parser + configureMarkdownParser() + + // Add subviews + addSubview(textView) + + // Configure toggle button instead of more button + toggleButton.addTarget(self, action: #selector(toggleCollapsed(_:)), for: .primaryActionTriggered) + addSubview(toggleButton) + + // Update the button title based on current state + updateToggleButtonTitle() + + setNeedsLayout() + } + + private func configureMarkdownParser() { + // Configure markdown parser with desired settings + markdownParser.enabledElements = MarkdownManager.enabledElements + + // You can also customize the styling if needed + markdownParser.header.font = MarkdownManager.Fonts.header + markdownParser.list.font = MarkdownManager.Fonts.list + markdownParser.bold.font = MarkdownManager.Fonts.bold + markdownParser.italic.font = MarkdownManager.Fonts.italic + markdownParser.quote.font = MarkdownManager.Fonts.quote + } + + // Make sure this is called properly + private func updateToggleButtonTitle() { + let title = isCollapsed ? NSLocalizedString("More", comment: "") : NSLocalizedString("Less", comment: "") + toggleButton.setTitle(title, for: .normal) + } + + + // MARK: - Layout + override func layoutSubviews() { + super.layoutSubviews() + + textView.frame = bounds + + // Position toggle button + let buttonSize = toggleButton.sizeThatFits(CGSize(width: 1000, height: 1000)) + + if isCollapsed { + let buttonY = (textView.font?.lineHeight ?? 0) * CGFloat(maximumNumberOfLines - 1) + toggleButton.frame = CGRect( + x: bounds.width - buttonSize.width, + y: buttonY, + width: buttonSize.width, + height: textView.font?.lineHeight ?? 0 + ) + } else { + // Position at the end of content when expanded + let textHeight = textView.sizeThatFits(bounds.size).height + let lineHeight = textView.font?.lineHeight ?? 0 + toggleButton.frame = CGRect( + x: bounds.width - buttonSize.width, + y: textHeight - lineHeight, + width: buttonSize.width, + height: lineHeight + ) + } + } + + @objc private func toggleCollapsed(_ sender: UIButton) { + isCollapsed.toggle() + updateToggleButtonTitle() + // Notify any observer that a toggle occurred + didToggleCollapse?() + } + + override var intrinsicContentSize: CGSize { + if isCollapsed { + guard let font = textView.font else { return super.intrinsicContentSize } + let height = font.lineHeight * CGFloat(maximumNumberOfLines) + lineSpacing * CGFloat(maximumNumberOfLines - 1) + return CGSize(width: UIView.noIntrinsicMetric, height: height) + } else { + // When expanded, use the full content size of the text view + let size = textView.sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude)) + return CGSize(width: UIView.noIntrinsicMetric, height: size.height) + } + } + + // MARK: - Markdown Processing + private func updateMarkdownContent() { + let attributedString = markdownParser.parse(text) + + // Apply line spacing + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = lineSpacing + + mutableAttributedString.addAttribute( + .paragraphStyle, + value: paragraphStyle, + range: NSRange(location: 0, length: mutableAttributedString.length) + ) + + textView.attributedText = mutableAttributedString + } + +} + +extension CollapsingMarkdownView: UITextViewDelegate { + // This enables tapping on links while preventing text selection + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + // Open the URL using UIApplication + UIApplication.shared.open(URL) + return false // Return false to prevent the default behavior + } + + // This prevents text selection + func textViewDidChangeSelection(_ textView: UITextView) { + textView.selectedTextRange = nil + } +}