Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Question: guidelines for how to implement visitBlockQuote for NSAttributedString renderer? #198

Open
yoasha opened this issue Sep 13, 2024 · 6 comments

Comments

@yoasha
Copy link

yoasha commented Sep 13, 2024

Sorry for creating an issue for a question. I couldn't find a Discussion section on this repository nor did I find a better place to post a question on this topic.

I am implementing NSAttributedString renderer using the Visitor pattern of the swift-markdown library and successfully targeted most elements. This is thanks to the code available at https://github.com/christianselig/Markdownosaur.

However, I am facing hard time with implementing visitBlockQuote, especially implementing multiple quote levels. This seems like a difficult task for the NSAttributedString renderer.

Can anyone kindly provide some guidelines, sample code, or any lead to help with that?

@yoasha yoasha changed the title Question: any guidelines of how to implement visitBlockQuote for NSAttributedString? Question: guidelines for how to implement visitBlockQuote for NSAttributedString render? Sep 13, 2024
@yoasha yoasha changed the title Question: guidelines for how to implement visitBlockQuote for NSAttributedString render? Question: guidelines for how to implement visitBlockQuote for NSAttributedString renderer? Sep 13, 2024
@QuietMisdreavus
Copy link
Contributor

cc @christianselig in case you have some pointers.

But also, if you're looking for more of a discussion forum to ask questions in, you might have better luck in the Swift Forums. Since Swift-Markdown is part of the Swift-DocC project, asking in that subforum might get you in front of people who have tried to do the same thing.

@yoasha
Copy link
Author

yoasha commented Sep 13, 2024

Thanks for the quick response.
@christianselig - any thoughts of how to implement visitBlockQuote for multiple quote levels?

@yoasha
Copy link
Author

yoasha commented Sep 14, 2024

@christianselig
Copy link
Contributor

christianselig commented Sep 14, 2024

Thanks for the ping y'all! So cool you're using Markdownosaur or it at least helped :D

It definitely grew some when I was using it in my app Apollo, and I'm 99% sure Apollo supported nested blockquotes so hopefully this works. Some of it is specific to Apollo but hopefully should be general enough overall to be helpful. If this doesn't solve your problem let me know and I'll poke around the codebase more haha.

Here's the most recent implementation I had of visitBlockquote:

mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> NSAttributedString {
    let result = NSMutableAttributedString()
    
    for child in blockQuote.children {
        let quoteAttributedString = visit(child).mutableCopy() as! NSMutableAttributedString
        
        // Simple algorithm: iterate over all the existing indents and increase their indent (due to being in a blockquote). If we didn't find any existing indents to increase, introduce the indent manually.
        var didIndentExisting = false
        
        quoteAttributedString.enumerateAttribute(.indent, in: NSRange(location: 0, length: quoteAttributedString.length), options: []) { value, range, stop in
            guard value != nil else { return }
            
            let paragraphStyle = (quoteAttributedString.attribute(.paragraphStyle, at: range.location, effectiveRange: nil) as! NSParagraphStyle).mutableCopy() as! NSMutableParagraphStyle
            paragraphStyle.headIndent += 15.0
            paragraphStyle.tabStops = paragraphStyle.tabStops.map { NSTextTab(textAlignment: $0.alignment, location: $0.location + 15.0) }
            
            quoteAttributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: range)
            
            didIndentExisting = true
        }
        
        if !didIndentExisting {
            // No existing indents, so introduce an indent
            let paragraphStyle = NSMutableParagraphStyle()
            paragraphStyle.headIndent = 15.0
            paragraphStyle.tabStops = [NSTextTab(textAlignment: .left, location: 15.0)]
            let tabAttributedString = NSAttributedString(string: "\t", attributes: [.paragraphStyle: paragraphStyle, .font: UIFont.systemFont(ofSize: FontManager.shared.regularFont().pointSize), .indent: true])
            
            if child is CodeBlock || child is Paragraph {
                // For codeblocks we have to do things a little differently as they span multiple lines, so instead of just inserting a single tab character at the beginning, we also have to insert one at the beginning of each line
                // This is also true of Paragraphs, as if they use the single newline trick they can have multiple lines needing to be indented separately (see https://redd.it/ul6261)
                quoteAttributedString.insert(tabAttributedString, at: 0)
                
                // Insert for the rest of the lines
                let regex = try! NSRegularExpression(pattern: "\n", options: [])
                
                // Track how many we've found because with each insertion we'll be increasing the offset by 1 and we need to account for that
                var totalFound = 0
                
                regex.enumerateMatches(in: quoteAttributedString.string, options: [], range: NSRange(location: 0, length: quoteAttributedString.length)) { result, flags, stop in
                    guard let result = result else { return }
                    
                    // Insert immediately after the newline, plus remembering to offset for each insertion
                    quoteAttributedString.insert(tabAttributedString, at: result.range.location + 1 + totalFound)
                    totalFound += 1
                }
            } else {
                quoteAttributedString.insert(tabAttributedString, at: 0)
            }
        }

        quoteAttributedString.addAttribute(.blockQuote, value: true)
        
        // Add the quote depth attribute, but only to parts of the attributed string that don't already have one (we don't want to overwrite).
        // Note that enumerated attributed strings is weird, it enumerates the substrings where the attribute *isn't*, as well as the ones where it is, so checking value is important
        quoteAttributedString.enumerateAttribute(.quoteDepth, in: NSRange(location: 0, length: quoteAttributedString.length), options: []) { value, range, stop in
            guard value == nil else { return }
            quoteAttributedString.addAttribute(.quoteDepth, value: blockQuote.quoteDepth, range: range)
        }
        
        // Similar to the above with quote depth, we don't want to overwrite the code block syntax highlighting colors, so only do it where it isn't
        quoteAttributedString.enumerateAttribute(.codeBlock, in: NSRange(location: 0, length: quoteAttributedString.length), options: []) { value, range, stop in
            guard value == nil else { return }
            
            let quoteColor: UIColor = {
                if let forceTextColor = forceTextColor {
                    // If we're forcing a text color, make it a slightly lighter version of it via transparency
                    return forceTextColor.withAlphaComponent(0.7)
                } else {
                    return ThemeManager.shared.currentTheme.textColor(for: .small, isRead: false)
                }
            }()
                
            quoteAttributedString.addAttribute(.foregroundColor, value: quoteColor, range: range)
        }
        
        // By changing the text color of anything that isn't a code block, you probably just changed link colors to gray too (which should be tint colored).
        // As a result, just go back over all the link attributes and re-color them.
        // Note: yes it would be nice if there was an API where you could enumerate over either code blocks or link attributes, but this seems like the cleanest alternative.
        quoteAttributedString.enumerateAttribute(.apolloLink, in: NSRange(location: 0, length: quoteAttributedString.length), options: []) { value, range, stop in
            guard value != nil else { return }
            
            let linkColor = forceTextColor ?? ThemeManager.shared.currentTheme.linkTintColor(isRead: false)
            quoteAttributedString.addAttribute(.foregroundColor, value: linkColor, range: range)
        }
        
        result.append(quoteAttributedString)
    }
    
    if blockQuote.hasValidSuccessor(parseTablesInline: parseTablesInline) {
        result.append(.doubleNewline(withFontSize: FontManager.shared.regularFont().pointSize))
    }
    
    return result
}

And a bonus extension:

extension BlockQuote {
    /// Depth of the quote if nested within others. Index starts at 0.
    var quoteDepth: Int {
        var index = 0

        var currentElement = parent

        while currentElement != nil {
            if currentElement is BlockQuote {
                index += 1
            }

            currentElement = currentElement?.parent
        }
        
        return index
    }
}

@yoasha
Copy link
Author

yoasha commented Sep 15, 2024

@christianselig - thank you for responding so quickly. I will surely try to integrate this code into my app.

BTW, I've just integrated Markdownosaur into my apps with only minor changes, and it works perfectly! Thank you so much for publishing this project.

@christianselig
Copy link
Contributor

Yay that makes me so happy to hear! But truthfully Victoria and co are the real heroes of the effort, I just put it in a little box :p

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants