Saving and pasting an attributed string with a custom NSTextBlock
Asked Answered
L

1

10

I am trying to create a custom NSTextBlock, much like the one Apple did at WWDC 18 (23 mins in).

Full demo project here.

Okay, so it works great when I'm editing and marking a paragraph with my paragraph style that has the text block attached.

enter image description here

But when I cut and paste it (or archive/unarchive from disk), it loses it. EDIT: It actually turns my TweetTextBlock subclass into a NSTableViewTextBlock, which also explains the borders.

enter image description here

Implementation

Here's a full Xcode project. Use the Format top menu item to trigger the markTweet function.

Here's how I add the attributes to the paragraph

    @IBAction func markTweet(_ sender : Any?){
    print("now we are marking")
    let location = textView.selectedRange().location


    guard let nsRange = textView.string.extractRange(by: .byParagraphs, at: location) else { print("Not in a paragraph"); return }

    let substring = (textView.string as NSString).substring(with: nsRange)

    let tweetParagraph = NSMutableParagraphStyle()
    tweetParagraph.textBlocks = [TweetTextBlock()]

    let twitterAttributes : [AttKey : Any] = [
        AttKey.paragraphStyle : tweetParagraph,
        AttKey.font : NSFont(name: "HelveticaNeue", size: 15)
    ]

    textView.textStorage?.addAttributes(twitterAttributes, range: nsRange)
}

And this is my NSTextBlock subclass

import Cocoa

class TweetTextBlock: NSTextBlock {

    override init() {
        super.init()
        setWidth(33.0, type: .absoluteValueType, for: .padding)
        setWidth(70.0, type: .absoluteValueType, for: .padding, edge: .minX)

        setValue(100, type: .absoluteValueType, for: .minimumHeight)

        setValue(300, type: .absoluteValueType, for: .width)
        setValue(590, type: .absoluteValueType, for: .maximumWidth)


        backgroundColor = NSColor(white: 0.97, alpha: 1.0)

    }


    override func drawBackground(withFrame frameRect: NSRect, in controlView: NSView,
        characterRange charRange: NSRange, layoutManager: NSLayoutManager) {

        let frame = frameRect
        let fo = frameRect.origin

        super.drawBackground(withFrame: frame, in: controlView, characterRange:
        charRange, layoutManager: layoutManager)

        // draw string
        let context = NSGraphicsContext.current
        context?.shouldAntialias = true

        let drawPoint: NSPoint = CGPoint(x: fo.x + 70, y: fo.y + 10)


        let nameAttributes = [AttKey.font: NSFont(name: "HelveticaNeue-Bold", size: 15),  .foregroundColor: NSColor.black]
        var handleAttributes = [AttKey.font: NSFont(name: "HelveticaNeue", size: 15),  .foregroundColor: NSColor(red: 0.3936756253, green: 0.4656872749, blue: 0.5323709249, alpha: 1)]

        let nameAStr = NSMutableAttributedString(string: "Johanna Appleseed", attributes: nameAttributes)
        let handleAStr = NSAttributedString(string: "  @johappleseed ·  3h", attributes: handleAttributes)
        nameAStr.append(handleAStr)
        nameAStr.draw(at: drawPoint)

        let im = NSImage(named: "profile-twitter")!
        im.draw(in: NSRect(x: fo.x + 10, y: fo.y + 10, width: 50, height: 50))

        }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }

}

What I tried

My thinking is that this might happen because TextKit doesn't know how to archive the attributes from the custom block. But I tried overriding init:fromCoder and encode. They don't get called. Not on copy, paste, archiving, unarchiving. So I suppose that was not it. This leads me to think that all this custom drawing logic can't be saved in an attributed string, and that this is all happening in the layout manager. That makes sense. But how do I persist the block, then?

UPDATE: I tried reading the attributes. It has a paragraph style, and that paragraph style has an item in the textBlocks array property. But that text block is an NSTextBlock and not my subclass (i tried if block is TweetTextBlock which returns false)

UPDATE 2: I tried overriding properties like classForArchiver, and then reading them with e.g. print("twb: Class for archiver", block.classForArchiver). What's interesting here is that the text block has been turned into a NSTextTableBlock! I'm so deep in hacking this now that I'm looking for a way to store the className somewhere in the text block. So far, the only one I can think of is the tooltip property, but that's visible to the user, and I might want to use that for something else.

UPDATE 3: The tooltip is also not preserved. That's weird. The next big hack I can think of is setting the text color to HSB (n, 0, 0), where n is the identifier for the NSTextBlock subclass. Let's hope I don't have to go there.

UPDATE 4. This is most likely caused by both archiving and copy/pasting transforms the string into RTF. Here's public.rtf from my clipboard

{\rtf1\ansi\ansicpg1252\cocoartf2509
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 HelveticaNeue;}
{\colortbl;\red255\green255\blue255;\red245\green245\blue245;}
{\*\expandedcolortbl;;\csgray\c97000;}
\pard\intbl\itap1\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\partightenfactor0

\f0\fs30 \cf0 THIS text is in a TweetTextBlock}
Lecherous answered 30/10, 2019 at 8:10 Comment(1)
thanks for posting your debugging steps. did you end up finding a solution?Mosquito
T
1

It appears the NSAttributedString is somehow at fault. I tried subclassing NSMutableParagraphStyle and using it and it is NOT being encoded or decoded (init).

It may be possible to simply annotate the text run with a custom Attribute.Key indicating the delineation of the block content and its "type" and then post-process the AttributedString after the paste.

Alternatively, the out-of-the-box Pasteboard types may not support and archived NSAttributedString. Rather, (and I'm guessing) the highest fidelity text type may be RTF which may account for the fact that the TextBlock NSCoding methods aren't invoked at all.

Looking at NSPasteboard.PasteboardType my vote is option 2.

Triform answered 1/2, 2020 at 16:8 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.