NSTextAttachment contents property nil after reading from pasteboard - swift

I have a custom NSTextView subclass that displays one or more NSTextAttachment objects that represent tokens (similar to NSTokenField).
The problem is that the contents property on NSTextAttachment is always nil after reading the NSAttributedString from the pasteboard, even though I see this data is being saved to the pasteboard.
Create the text attachment and insert it into the text storage
let attachmentData = "\(key):\(value)".data(using: .utf8)
let attachment = NSTextAttachment(data: attachmentData, ofType: kUTTypeUTF8PlainText as String)
attachment.attachmentCell = CustomAttachmentCell(key: key, value: value)
...
textStorage.replaceCharacters(in: range, with: NSAttributedString(attachment: attachment))
CustomAttachmentCell is a standard NSTextAttachmentCell subclass that only handles custom drawing. It draws the text attachment within the text view as intended and uses standard CoreGraphics API calls to draw a background and text for the attachment's visual representation.
class CustomAttachmentCell: NSTextAttachmentCell {
let key: String
let value: String
override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {
drawKey(withFrame: cellFrame, in: controlView)
drawValue(withFrame: cellFrame, in: controlView)
}
}
Configure NSTextViewDelegate methods to handle writing the attachment to the pasteboard
// If the previous method is not used, this method and the next allow the textview to take care of attachment dragging and pasting, with the delegate responsible only for writing the attachment to the pasteboard.
// In this method, the delegate should return an array of types that it can write to the pasteboard for the given attachment.
func textView(_ view: NSTextView, writablePasteboardTypesFor cell: NSTextAttachmentCellProtocol, at charIndex: Int) -> [NSPasteboard.PasteboardType] {
return [(kUTTypeFlatRTFD as NSPasteboard.PasteboardType)]
}
// In this method, the delegate should attempt to write the given attachment to the pasteboard with the given type, and return success or failure.
func textView(_ view: NSTextView, write cell: NSTextAttachmentCellProtocol, at charIndex: Int, to pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool {
guard type == kUTTypeFlatRTFD as NSPasteboard.PasteboardType else { return false }
guard let attachment = cell.attachment else { return false }
return pboard.writeObjects([NSAttributedString(attachment: attachment)])
}
After right-clicking the text attachment in the text view and choosing "cut" from the context menu, I can see the RTFD data in the pasteboard using the Clipboard Viewer.app:
rtfd............
...Attachment.........TXT.rtf....M...÷...........exampleKey:exampleValue....E.......
...Attachment....TXT.rtf..........Ñ`¶...........².Ñ`¶...............ï...{\rtf1\ansi\ansicpg1252\cocoartf1671\cocoasubrtf600
{\fonttbl\f0\fswiss\fcharset0 Helvetica;}
{\colortbl;\red255\green255\blue255;}
{\*\expandedcolortbl;;}
\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\partightenfactor0
\f0\fs24 \cf0 {{\NeXTGraphic Attachment \width640 \height640 \appleattachmentpadding0 \appleembedtype0 \appleaqc
}¬}\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\partightenfactor0
\cf0 \
\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\partightenfactor0
\cf0 {{\NeXTGraphic Attachment \width640 \height640 \appleattachmentpadding0 \appleembedtype0 \appleaqc
}¬}}
Now when I paste back into the text view and textStorageWillProcessEditing() is called. I'm expecting to be able to retrieve the text attachment contents property at this point, but it is always nil. Am I missing some critical piece in this chain?
override func textStorageWillProcessEditing(_ notification: Notification) {
guard let attributedString = notification.object as? NSAttributedString else { return }
attributedString.enumerateAttribute(.attachment, in: NSRange(location: 0, length: attributedString.length), options: [], using: { (value, range, stop) in
if let attachment = value as? NSTextAttachment {
// attachment.contents is nil here, I was expecting a Data object representing the UTF-8 string "exampleKey:exampleValue"
}
})
}
Alternatively I've tried overriding some of the pasteboard-related methods in my NSTextView subclass to do the same thing (read/write rftd data).
Overrode readablePasteboardTypes() to reorder so that the richest data (e.g. com.apple.flat-rtfd / NeXT RTFD pasteboard type) are first in the array.
Overrode preferredPasteboardType(from: restrictedToTypesFrom:) to return (kUTTypeFlatRTFD as NSPasteboard.PasteboardType)
Overrode readSelection(from:type:)
When overriding the methods above I see the same behavior where I can:
Read NSAttributedString from the pasteboard
Enumerate the NSTextAttachment objects contained within the attributed string
The enumerated NSTextAttachment objects contents property is always set to nil instead of the Data representing the UTF-8 string "exampleKey:exampleValue" that was written to the pasteboard inside the NSTextAttachment which is inside the NSAttributedString.
It appears that this is quite an esoteric problem as all the sample code and search results I've found all either use NSFileWrapper or NSImage/UIImage to back their NSTextAttachment rather than the NSTextAttachment(data:ofType:) initializer.
Thanks!

Related

Custom keyboard and shouldChangeCharactersIn

I have custom keyboard which I added as textfield.inputView. But my shouldChangeCharactersIn at UITextFieldDelegate doesn’t work? Any idea?
You have to implement the call to the delegate method yourself prior to inserting the text into the control.
For example, let’s assume you have some function for handling tap of a button, which calls UIKeyInput method insertText. Just check that the delegate implements the method and that it did not return false:
#objc func didTapButton(_ sender: ...) {
guard let range = target?.selectedRange else { return } // assumes `target` was defined to conform to `UITextInput`, using extension shared below
let string = ...
if let textField = target as? UITextField, textField.delegate?.textField?(textField, shouldChangeCharactersIn: range, replacementString: string) == false {
return
}
if let textView = target as? UITextView, textView.delegate?.textView?(textView, shouldChangeTextIn: range, replacementText: string) == false {
return
}
target?.insertText(string) // assumes `target` was defined to conform to `UIKeyInput`
}
Where:
extension UITextInput {
var selectedRange: NSRange? {
guard let textRange = selectedTextRange else { return nil }
let location = offset(from: beginningOfDocument, to: textRange.start)
let length = offset(from: textRange.start, to: textRange.end)
return NSRange(location: location, length: length)
}
}
Clearly, the details are dependent upon your custom keyboard implementation (the above is based upon https://stackoverflow.com/a/57275689/1271826), but hopefully it illustrates the basic idea.

macOS: Take Emoji from CharacterPalette (revised)

(This is a revised question - including answer - following on from macOS: Take emoji from characterPalette which describes the problems encountered in more detail)
Background/use case
I have an app where, instead of creating and maintaining an icon library, I let users type an emoji as a placeholder graphic. This works beautifully within the context of my app, but I am not happy with the input mechanism I use.
Problem
I would like to simplify this so I open the characterPalette, select an emoji, and display it either as the button's StringValue or in a Label (=non-editable NSTextField).
This does not seem possible. Unlike NSColorPanel or NSFontPanel, the characterPanel is not exposed to the Cocoa framework, so I cannot take its selectedValue, set its action, or catch a notification. The documentation for orderFrontCharacterPalette simply says Opens the character palette which ... is not helpful.
Attempted solutions and problems encountered
I tried to work with making my receiver the firstResponder, but unlike NSTextView, NSTextField cannot process emoji. I found a workaround using an NSTextView with an NSBox in front, making it the firstResponder, and using NSApp.orderFrontCharacterPalette(sender)but found that under various circumstances which all seem to involve an extra drawing call – setting the button's title, showing a label in SystemFont Mini size (regular size worked fine) the CharacterPalette will open (=the system menu now offers 'Hide Emoji & Symbols') without being displayed. (This persists until the application closes, even if you try to open the CharacterPalette through the regular menu/shortcut)
For the partial solution involving NSTextInputClient (the no-show seems to be a persistent bug), see answer below.
The emoji picker needs a minimal implementation of NSTextInputClient. For example a button:
class MyButton: NSButton, NSTextInputClient {
override var acceptsFirstResponder: Bool {
get {
return true
}
}
override func becomeFirstResponder() -> Bool {
return true
}
override func resignFirstResponder() -> Bool {
return true
}
func insertText(_ string: Any, replacementRange: NSRange) {
// this method is called when the user selects an emoji
if let string = string as? String {
self.title = string
}
}
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
}
func unmarkText() {
}
func selectedRange() -> NSRange {
return NSMakeRange(0, 0)
}
func markedRange() -> NSRange {
return NSMakeRange(NSNotFound, 0)
}
func hasMarkedText() -> Bool {
return false
}
func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
return nil
}
func validAttributesForMarkedText() -> [NSAttributedString.Key] {
return []
}
func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
// the emoji picker uses the returned rect to position itself
var rect = self.bounds
rect.origin.x = NSMidX(rect)
rect.size.width = 0
return self.window!.convertToScreen(self.convert(rect, to:nil))
}
func characterIndex(for point: NSPoint) -> Int {
return 0
}
}
NSTextInputClient needs a NSTextInputContext. NSView returns a context from inputContext if the class conforms to NSTextInputClient unless isEditable is implemented and returns false. A label doesn't return a NSTextInputContext, the solution is to override inputContext:
class MyTextField: NSTextField, NSTextInputClient {
var myInputContext : NSTextInputContext?
override var inputContext: NSTextInputContext? {
get {
if myInputContext == nil {
myInputContext = NSTextInputContext(client:self)
}
return myInputContext
}
}
// and the same methods as the button, set self.stringValue instead of self.title in insertText(_:replacementRange:)
}
Willeke pointed me at NSTextInputClient which has provided the best solution so far. Apple's only example is in ObjectiveC, convoluted, and overly complex for what I was trying to do, so I am reproducing my code here.
Caveat: this is not a full implementation of NSTextInputClient, just enough to capture emoji input
I have created an NSButton subclass:
class TextReceiverButton: NSButton, NSTextInputClient {
//specific methods
func setButtonTitle(_ string: String?){
self.title = string ?? 🦊
}
//NSTextInputClient methods
func insertText(_ string: Any, replacementRange: NSRange) {
let receivedText = string as? String
setButtonTitle(receivedText)
}
func validAttributesForMarkedText() -> [NSAttributedString.Key] {
return [.font, .paragraphStyle, .writingDirection]
}
//Omitted: For anything else that wants a value, I return NSMakeRange(0, 0)/NSRect.zero or 0 as well as false for marked text and nil for attributed substring
}
(If you add the protocol to your class, it will offer stubs for the other methods)
The full set for NSAttributedString.Key is
[.font, .foregroundColor, .glyphInfo, .kern, .ligature, .link, .markedClauseSegment, .obliqueness, .paragraphStyle, .shadow, .spellingState, .strikethroughColor, .strikethroughStyle, .strokeColor, .strokeWidth, .superscript, .textAlternatives, .textEffect, .toolTip, .underlineColor, .underlineStyle, .verticalGlyphForm, .writingDirection]
(I have tested the short form with simple and composite emoji and nothing else seems necessary.)
The button's action is
#IBAction func displayEmojiInButton(_ sender: Any) {
NSApp.orderFrontCharacterPalette(self)
view.window?.makeFirstResponder(textReceiverButton)
}
Problems/Bugs
The NSTextInputClient document says 'you can subclass NSView' and Apple's code turns an NSView into a fully functional (receiving and drawing) text view class (I can't built it, but I assume it worked). So theoretically, you should be able to use the same code for NSTextField, which also ultimately inherits from NSView.
However, it turns out that NSTextField displays the 'CharacterPalette allegedly opens but never displays' bug I talked about earlier; though it does work with NSView. (I have not tested this further).
Furthermore, NSTextInputClient is not a complete replacement for NSTextView: it does not receive input from the keyboard viewer. (See Willecke's answer/comment for explanation/solution to these).
Verdict
NSApp.orderFrontCharacterPalette(self) fails 95% of the time when called from a view in the vincinity of a tab view (in splitView next to TabViewController, embedded in TabViewController), so while this code may be correct, it's also useless a lot of the time, at least under 10.13.

iOS Swift: How to access selected text in WKWebView

I would like to be able to use a menu button to copy selected text from a web page in WKWebView to the pasteboard. I would like to get the text from the pasteboard into a text view in a second view controller. How do I access and copy the selected text in the WKWebView?
Swift 4
You can access the general pasteboard with the following line:
let generalPasteboard = UIPasteboard.general
In the view controller, you can add an observer to observe when something is copied to the pasteboard.
override func viewDidLoad() {
super.viewDidLoad()
// https://stackoverflow.com/questions/35711080/how-can-i-edit-the-text-copied-into-uipasteboard
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged(_:)), name: UIPasteboard.changedNotification, object: generalPasteboard)
}
override func viewDidDisappear(_ animated: Bool) {
NotificationCenter.default.removeObserver(UIPasteboard.changedNotification)
super.viewDidDisappear(animated)
}
#objc
func pasteboardChanged(_ notification: Notification) {
print("Pasteboard has been changed")
if let data = generalPasteboard.data(forPasteboardType: kUTTypeHTML as String) {
let dataStr = String(data: data, encoding: .ascii)!
print("data str = \(dataStr)")
}
}
In the above pasteboardChanged function, I get the data as HTML in order to display the copied as formatted text in a second controller in a WKWebView. You must import MobileCoreServices in order to reference the UTI kUTTypeHTML. To see other UTI's, please see the following link: Apple Developer - UTI Text Types
import MobileCoreServices
In your original question, you mentioned you want to put the copied content into a second textview. If you want to keep the formatting, you will need to get the copied data as RTFD then convert it to an attributed string. Then set the textview to display the attributed string.
let rtfdStringType = "com.apple.flat-rtfd"
// Get the last copied data in the pasteboard as RTFD
if let data = pasteboard.data(forPasteboardType: rtfdStringType) {
do {
print("rtfd data str = \(String(data: data, encoding: .ascii) ?? "")")
// Convert rtfd data to attributedString
let attStr = try NSAttributedString(data: data, options: [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.rtfd], documentAttributes: nil)
// Insert it into textview
print("attr str = \(attStr)")
copiedTextView.attributedText = attStr
}
catch {
print("Couldn't convert pasted rtfd")
}
}
Because I don't know your exact project or use case so you may need to alter the code a little but I hope I provided you with pieces you need for project. Please comment if there's anything I missed.

How to create NSPasteboardWriting for Drag and Drop in NSCollectionView

I have a one-section collection view and would like to implement Drag and Drop to allow reordering of the items. The CollectionViewItem has several textviews showing properties form my Parameter objects. Reading the doc I need to implement the NSCollectionView delegate:
func collectionView(_ collectionView: NSCollectionView, pasteboardWriterForItemAt indexPath: IndexPath) -> NSPasteboardWriting? {
let parameter = parameterForIndexPath(indexPath: indexPath)
return parameter // throws an error "Cannot convert return expression of type 'Parameter' to return type 'NSPasteboardWriting?'"
}
I have not found any information understandable for me describing the nature of the NSPasteboardWriting object. So, I have no idea how to proceed...
What is the NSPasteboardWriting object and what do I need to write in the pasteboard?
Thanks!
Disclaimer: I have struggled to find anything out there explaining this in a way that made sense to me, especially for Swift, and have had to piece the following together with a great deal of difficulty. If you know better, please tell me and I will correct it!
The "pasteboardwriter" methods (such as the one in your question) must return something identifiable for the item about to be dragged, that can be written to a pasteboard. The drag and drop methods then pass around this pasteboard item.
Most examples I've seen simply use a string representation of the object. You need this so that in the acceptDrop method you can get your hands back on the originating object (the item being dragged). Then you can re-order that item's position, or whatever action you need to take with it.
Drag and drop involves four principal steps. I'm currently doing this with a sourcelist view, so I will use that example instead of your collection view.
in viewDidLoad() register the sourcelist view to accept dropped objects. Do this by telling it which pasteboard type(s) it should accept.
// Register for the dropped object types we can accept.
sourceList.register(forDraggedTypes: [REORDER_SOURCELIST_PASTEBOARD_TYPE])
Here I'm using a custom type, REORDER_SOURCELIST_PASTEBOARD_TYPE that I define as a constant like so:
`let REORDER_SOURCELIST_PASTEBOARD_TYPE = "com.yourdomain.sourcelist.item"`
...where the value is something unique to your app ie yourdomain should be changed to something specific to your app eg com.myapp.sourcelist.item.
I define this outside any class (so it can be accessed from several classes) like so:
import Cocoa
let REORDER_SOURCELIST_PASTEBOARD_TYPE = "com.yourdomain.sourcelist.item"`
class Something {
// ...etc...
implement the view's pasteboardWriterForItem method. This varies slightly depending on the view you're using (i.e. sourcelist, collection view or whatever). For a sourcelist it looks like this:
// Return a pasteboard writer if this outlineview's item should be able to
// drag-and-drop.
func outlineView(_ outlineView: NSOutlineView, pasteboardWriterForItem item: Any) -> NSPasteboardWriting? {
let pbItem = NSPasteboardItem()
// See if the item is of the draggable kind. If so, set the pasteboard item.
if let draggableThing = ((item as? NSTreeNode)?.representedObject) as? DraggableThing {
pbItem.setString(draggableThing.uuid, forType: REORDER_SOURCELIST_PASTEBOARD_TYPE)
return pbItem;
}
return nil
}
The most notable part of that is draggableThing.uuid which is simply a string that can uniquely identify the dragged object via its pasteboard.
Figure out if your dragged item(s) can be dropped on the proposed item at the index given, and if so, return the kind of drop that should be.
func outlineView(_ outlineView: NSOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: Any?, proposedChildIndex index: Int) -> NSDragOperation {
// Get the pasteboard types that this dragging item can offer. If none
// then bail out.
guard let draggingTypes = info.draggingPasteboard().types else {
return []
}
if draggingTypes.contains(REORDER_SOURCELIST_PASTEBOARD_TYPE) {
if index >= 0 && item != nil {
return .move
}
}
return []
}
Process the drop event. Do things such as moving the dragged item(s) to their new position in the data model and reload the view, or move the rows in the view.
func outlineView(_ outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: Any?, childIndex index: Int) -> Bool {
let pasteboard = info.draggingPasteboard()
let uuid = pasteboard.string(forType: REORDER_SOURCELIST_PASTEBOARD_TYPE)
// Go through each of the tree nodes, to see if this uuid is one of them.
var sourceNode: NSTreeNode?
if let item = item as? NSTreeNode, item.children != nil {
for node in item.children! {
if let collection = node.representedObject as? Collection {
if collection.uuid == uuid {
sourceNode = node
}
}
}
}
if sourceNode == nil {
return false
}
// Reorder the items.
let indexArr: [Int] = [1, index]
let toIndexPath = NSIndexPath(indexes: indexArr, length: 2)
treeController.move(sourceNode!, to: toIndexPath as IndexPath)
return true
}
Aside: The Cocoa mandate that we use pasteboard items for drag and drop seems very unnecessary to me --- why it can't simply pass around the originating (i.e. dragged) object I don't know! Obviously some drags originate outside the application, but for those that originate inside it, surely passing the object around would save all the hoop-jumping with the pasteboard.
The NSPasteboardWriting protocol provides methods that NSPasteboard (well, technically anyone, I guess) can use to generate different representations of an object for transferring around pasteboards, which is an older Apple concept that is used for copy/paste (hence Pasteboard) and, apparently, drag and drop in some cases.
It seems that, basically, a custom implementation of the protocol needs to implement methods that:
tell what UTI types (Apple's way of identifying file types [JPEG, GIF, TXT, DOCX, etc], similar to MIME-types—and that's a fun Google search 😬) your type can be transformed into
writeableTypes(for:) & writingOptions(forType:pasteboard:) to a lesser extent
provide a representation of your class for each of the UTI types you claimed to support
pasteboardPropertyList(forType:)
The other answer provides a straightforward implementation of this protocol for use within a single app.
But practically?
The Cocoa framework classes NSString, NSAttributedString, NSURL, NSColor, NSSound, NSImage, and NSPasteboardItem implement this protocol.
So if you've got a draggable item that can be completely represented as a URL (or String, or Color, or Sound, or Image, etc), just take the URL you have and cast it to NSPasteboardWriting?:
func collectionView(_ collectionView: NSCollectionView, pasteboardWriterForItemAt indexPath: IndexPath) -> NSPasteboardWriting? {
let url: URL? = someCodeToGetTheURL(for: indexPath) // or NSURL
return url as NSPasteboardWriting?
}
This is not helpful if you have a complicated type, but I hope it's helpful if you have a collection view of images or some other basic item.

How can I save the attributed string (text) into file (swift, cocoa)?

I have NSTextView and I can have text as nsattributedstring. I can save text into .txt file using NSSavePanel, as plain text, but not as formatted text.
#IBAction func saveDNA(sender: AnyObject)
{
let saveDNAtoFile: NSSavePanel = NSSavePanel()
saveDNAtoFile.canSelectHiddenExtension = true
saveDNAtoFile.runModal()
do
{
let exportedFileURL = saveDNAtoFile.URL
let textDNA = self.inputDnaFromUser.string
if exportedFileURL != nil
{
try textDNA!.writeToURL(exportedFileURL!, atomically: false, encoding: NSUTF8StringEncoding)
}
} catch
{
}
}
How can I save the attributedstring (text) into file using NSSavePanel, to be able later to open this file to have all made before formatting in the text? What I should change in the code above, if I can use NSSavePanel for this ?
One day out ... Ok, I have figured out the code for Swift 2 (note this - options: NSFileWrapperWritingOptions.Atomic). Below. I am sure it will save time for beginners like me, more time to write necessary and more interesting algorithms, than this standard functionality.
#IBAction func saveDNA(sender: AnyObject)
{
let saveDNAtoFile: NSSavePanel = NSSavePanel()
saveDNAtoFile.canSelectHiddenExtension = true
saveDNAtoFile.runModal()
do
{
let exportedFileURL = saveDNAtoFile.URL
let textDNA = inputDnaFromUser.textStorage
if exportedFileURL != nil
{
let range = NSRange(0..<textDNA!.length)
let textTSave = try textDNA!.fileWrapperFromRange(range, documentAttributes: [NSDocumentTypeDocumentAttribute:NSRTFTextDocumentType])
try textTSave.writeToURL(exportedFileURL!, options: NSFileWrapperWritingOptions.Atomic, originalContentsURL: nil)
}
} catch
{
}
}
AppKit and UIKit add many methods to NSAttributedString for serializing and deserializing. Formerly they were documented separately, but now they are part of the unified NSAttributedString documentation.
There are too many methods to list here, but in the documentation you will find methods to convert NSAttributedString to/from several formats including Rich Text Format (RTF), HTML (starting in macOS 10.15 and iOS 13), Markdown (starting in macOS 12 and iOS 15), and others. You can also convert to/from Data, in which case you specify the format by setting the appropriate documentType in the documentAttributes dictionary. The conversions to/from Data support a few formats for which there are no dedicated methods.