diff --git a/Paicord.xcodeproj/project.pbxproj b/Paicord.xcodeproj/project.pbxproj index c66a6feb..1c696eb7 100644 --- a/Paicord.xcodeproj/project.pbxproj +++ b/Paicord.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 391A9AD92F30311E00215754 /* SelectableMarkdownText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 391A9AD82F30311E00215754 /* SelectableMarkdownText.swift */; }; 5724CD6F2E733D0E00931FEF /* MFASheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5724CD6E2E733D0900931FEF /* MFASheet.swift */; }; 572B694C2E7079FB00BCED00 /* PaicordLib in Frameworks */ = {isa = PBXBuildFile; productRef = 572B694B2E7079FB00BCED00 /* PaicordLib */; }; 5783AA902E6B8F6500F73F6D /* MeshGradient in Frameworks */ = {isa = PBXBuildFile; productRef = 5783AA8F2E6B8F6500F73F6D /* MeshGradient */; }; @@ -155,6 +156,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 391A9AD82F30311E00215754 /* SelectableMarkdownText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableMarkdownText.swift; sourceTree = ""; }; 5724CD6E2E733D0900931FEF /* MFASheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MFASheet.swift; sourceTree = ""; }; 5783AA932E6B8FCD00F73F6D /* MeshGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshGradient.swift; sourceTree = ""; }; 578E1CE02E67780B00C6DEA3 /* GradientText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientText.swift; sourceTree = ""; }; @@ -490,6 +492,7 @@ AA0C43D52E9EF91D000FA834 /* Markdown */ = { isa = PBXGroup; children = ( + 391A9AD82F30311E00215754 /* SelectableMarkdownText.swift */, 57A55C562E7560CC005C8226 /* MarkdownText.swift */, AA0C43D62E9F18AD000FA834 /* AttributedText.swift */, ); @@ -1131,6 +1134,7 @@ 57F5AF472E7CAD2500AD5674 /* Challenges.swift in Sources */, AA47AF242EDEF139008A50C9 /* ConnectionsSection.swift in Sources */, AA418C842F21AAFB00B51C18 /* UpdateCheck.swift in Sources */, + 391A9AD92F30311E00215754 /* SelectableMarkdownText.swift in Sources */, AA409ABF2EC74F5E00848045 /* ThemingSupport.swift in Sources */, AA13A1E32EA83766005A3613 /* TypingIndicatorBar.swift in Sources */, AA9D26B12EC95EE4006071FE /* FlowLayout.swift in Sources */, @@ -1307,10 +1311,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Paicord/Paicord.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 566RT33SA2; + DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = YES; ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; @@ -1370,10 +1375,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Paicord/Paicord.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 566RT33SA2; + DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = YES; ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; diff --git a/Paicord/Common/Chat/Messages/Message Body/Markdown/MarkdownText.swift b/Paicord/Common/Chat/Messages/Message Body/Markdown/MarkdownText.swift index 8a8dc5da..0ef90dd9 100644 --- a/Paicord/Common/Chat/Messages/Message Body/Markdown/MarkdownText.swift +++ b/Paicord/Common/Chat/Messages/Message Body/Markdown/MarkdownText.swift @@ -21,6 +21,8 @@ struct MarkdownText: View, Equatable { @Environment(\.dynamicTypeSize) var dynamicType @ViewStorage var dynamicTypeSizeStorage: DynamicTypeSize = .xSmall @Environment(\.theme) var theme + @Environment(\.enableCrossBlockTextSelection) + var enableCrossBlockTextSelection @State private var renderer: MarkdownRendererVM @State private var userPopover: PartialUser? @@ -68,12 +70,33 @@ struct MarkdownText: View, Equatable { Text(markdown: content) // Apple’s markdown fallback .opacity(0.6) } else { - ForEach(renderer.blocks) { block in - BlockView(block: block) - .equatable() - .debugRender() - .debugCompute() - } + #if os(macOS) + if enableCrossBlockTextSelection { + SelectableMarkdownText( + blocks: renderer.blocks.compactMap { block in + guard let attr = block.attributedContent else { return nil } + return SelectableMarkdownText.BlockInfo( + id: String(block.id), + attributedString: attr + ) + } + ) + } else { + ForEach(renderer.blocks) { block in + BlockView(block: block) + .equatable() + .debugRender() + .debugCompute() + } + } + #else + ForEach(renderer.blocks) { block in + BlockView(block: block) + .equatable() + .debugRender() + .debugCompute() + } + #endif } } .environment( @@ -1682,3 +1705,29 @@ final class EmojiAttachmentViewProvider: NSTextAttachmentViewProvider { container = nil } } + +// MARK: - Environment Key for Cross-Block Text Selection + +private struct CrossBlockTextSelectionKey: EnvironmentKey { + static let defaultValue: Bool = false +} + +extension EnvironmentValues { + var enableCrossBlockTextSelection: Bool { + get { self[CrossBlockTextSelectionKey.self] } + set { self[CrossBlockTextSelectionKey.self] = newValue } + } +} + +extension View { + /// Enables cross-block text selection for markdown text within this view + /// - Note: Only works on macOS. On iOS, this does nothing and the original + /// per-block rendering is used. + func enableCrossBlockTextSelection(_ enabled: Bool = true) -> some View { + #if os(macOS) + environment(\.enableCrossBlockTextSelection, enabled) + #else + self // No-op on iOS + #endif + } +} diff --git a/Paicord/Common/Chat/Messages/Message Body/Markdown/SelectableMarkdownText.swift b/Paicord/Common/Chat/Messages/Message Body/Markdown/SelectableMarkdownText.swift new file mode 100644 index 00000000..be6df6b2 --- /dev/null +++ b/Paicord/Common/Chat/Messages/Message Body/Markdown/SelectableMarkdownText.swift @@ -0,0 +1,183 @@ +// +// SelectableMarkdownText.swift +// Paicord +// +// Created by Tnixc on 01/02/2026. +// + +import SwiftUI + +#if canImport(AppKit) + import AppKit +#endif + +/// MVP multi select +struct SelectableMarkdownText: View { + let blocks: [BlockInfo] + + struct BlockInfo: Identifiable { + let id: String + let attributedString: NSAttributedString + } + + init(blocks: [BlockInfo]) { + self.blocks = blocks + } + + var body: some View { + #if os(macOS) + VStack(alignment: .leading, spacing: 4) { + SelectableTextContainer(blocks: blocks) + } + #else + // unreachable + EmptyView() + #endif + } +} + +#if os(macOS) + private struct SelectableTextContainer: NSViewRepresentable { + let blocks: [SelectableMarkdownText.BlockInfo] + @Environment(\.openURL) private var openURL + + func makeNSView(context: Context) -> CombinedTextView { + let textView = CombinedTextView() + textView.isEditable = false + textView.isSelectable = true + textView.drawsBackground = false + textView.textContainerInset = .zero + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.isAutomaticLinkDetectionEnabled = false + textView.linkTextAttributes = [:] + textView.delegate = context.coordinator + textView.usesAdaptiveColorMappingForDarkAppearance = true + + updateTextStorage(textView: textView, blocks: blocks) + + return textView + } + + func updateNSView(_ nsView: CombinedTextView, context: Context) { + updateTextStorage(textView: nsView, blocks: blocks) + } + + private func updateTextStorage( + textView: CombinedTextView, + blocks: [SelectableMarkdownText.BlockInfo] + ) { + let combined = NSMutableAttributedString() + + for (index, block) in blocks.enumerated() { + combined.append(block.attributedString) + + // add line break between blocks (except after the last one) + if index < blocks.count - 1 { + combined.append(NSAttributedString(string: "\n")) + } + } + + if textView.attributedString() != combined { + textView.textStorage?.setAttributedString(combined) + } + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + nsView: CombinedTextView, + context: Context + ) -> CGSize? { + let targetWidth = proposal.width ?? 400 + guard let layoutManager = nsView.layoutManager, + let textContainer = nsView.textContainer + else { return nil } + + textContainer.containerSize = CGSize( + width: targetWidth, + height: .greatestFiniteMagnitude + ) + layoutManager.ensureLayout(for: textContainer) + + let usedRect = layoutManager.usedRect(for: textContainer) + return CGSize(width: targetWidth, height: ceil(usedRect.height)) + } + + func makeCoordinator() -> Coordinator { + Coordinator(openURL: openURL) + } + + final class Coordinator: NSObject, NSTextViewDelegate { + let openURL: OpenURLAction + + init(openURL: OpenURLAction) { + self.openURL = openURL + } + + func textView( + _ textView: NSTextView, + clickedOnLink link: Any, + at charIndex: Int + ) -> Bool { + if let url = link as? URL { + openURL(url) + return true + } + return false + } + } + } + + final class CombinedTextView: NSTextView { + override func rightMouseDown(with event: NSEvent) { + nextResponder?.rightMouseDown(with: event) + } + + override func writeSelection( + to pboard: NSPasteboard, + type: NSPasteboard.PasteboardType + ) -> Bool { + let selected = attributedString().attributedSubstring( + from: selectedRange() + ) + let copy = NSMutableAttributedString(attributedString: selected) + + copy.enumerateAttribute( + .rawContent, + in: NSRange(location: 0, length: copy.length), + options: .reverse + ) { value, range, _ in + if let customText = value as? String { + var r = NSRange() + let attrs = copy.attributes(at: range.location, effectiveRange: &r) + copy.replaceCharacters( + in: range, + with: NSAttributedString(string: customText, attributes: attrs) + ) + } + } + + pboard.clearContents() + pboard.writeObjects([copy.string as NSString]) + return true + } + } +#endif + +#Preview { + let block1 = SelectableMarkdownText.BlockInfo( + id: "1", + attributedString: NSAttributedString( + string: "First paragraph with some text." + ) + ) + let block2 = SelectableMarkdownText.BlockInfo( + id: "2", + attributedString: NSAttributedString( + string: "Second paragraph with more text to select." + ) + ) + + SelectableMarkdownText(blocks: [block1, block2]) + .padding() +} diff --git a/Paicord/Common/Chat/Messages/Message Body/MessageBody.swift b/Paicord/Common/Chat/Messages/Message Body/MessageBody.swift index 5ed4fa70..7fff97ed 100644 --- a/Paicord/Common/Chat/Messages/Message Body/MessageBody.swift +++ b/Paicord/Common/Chat/Messages/Message Body/MessageBody.swift @@ -34,22 +34,24 @@ extension MessageCell { var body: some View { VStack(alignment: .leading, spacing: 4) { // Content - Group { - if !messageContentHidden { - let content = message.content ?? "" - if message.flags?.contains(.isComponentsV2) == true { - ComponentsV2View( /*components: message.components*/) - .equatable(by: message.components) - } else if !content.isEmpty { - MarkdownText( - content: content, - meta: message, - channelStore: channelStore - ) - .equatable() - } + // Group { + // totally breaks with Group idk why. + if !messageContentHidden { + let content = message.content ?? "" + if message.flags?.contains(.isComponentsV2) == true { + ComponentsV2View( /*components: message.components*/) + .equatable(by: message.components) + } else if !content.isEmpty { + MarkdownText( + content: content, + meta: message, + channelStore: channelStore + ) + .equatable() + .enableCrossBlockTextSelection(true) } } + // } // Attachments let attachments = message.attachments ?? []