Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions Paicord.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -155,6 +156,7 @@
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
391A9AD82F30311E00215754 /* SelectableMarkdownText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableMarkdownText.swift; sourceTree = "<group>"; };
5724CD6E2E733D0900931FEF /* MFASheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MFASheet.swift; sourceTree = "<group>"; };
5783AA932E6B8FCD00F73F6D /* MeshGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshGradient.swift; sourceTree = "<group>"; };
578E1CE02E67780B00C6DEA3 /* GradientText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientText.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -490,6 +492,7 @@
AA0C43D52E9EF91D000FA834 /* Markdown */ = {
isa = PBXGroup;
children = (
391A9AD82F30311E00215754 /* SelectableMarkdownText.swift */,
57A55C562E7560CC005C8226 /* MarkdownText.swift */,
AA0C43D62E9F18AD000FA834 /* AttributedText.swift */,
);
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
30 changes: 16 additions & 14 deletions Paicord/Common/Chat/Messages/Message Body/MessageBody.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? []
Expand Down