ホームページ開発ツール>Xojo / Real Studio Trial and Error・CocoaのDeclareでリッチテキストを扱う・アンドゥを試す(改訂版)

 Xojo / Real Studio Trial and Error

CocoaのDeclareでリッチテキストを扱う・アンドゥを試す(改訂版)

目次
 はじめに

 以下は、Xojo Cocoaビルドについての話題です。

 NSTextViewのアンドゥを試してみました。

 なお検証には、Xojo 2015 Release 4.1を用いています。(Mac mini mid 2010 + OS X 10.11.4 El Capitan)
注1)アンドゥ機能は、リッチテキストに限定されるものではありませんが、話題の流れ上、ここに置いています。
注2)初出時に懸案となっていた事項を解決する目処が立ちましたので、それらをもとに全面改訂しました。(旧版はこちら

 テキスト入力のアンドゥ

 Cocoaは標準でアンドゥの仕組み(NSUndoManager)を持っていて、NSTextViewからも利用できます。
 しかもテキスト関連については、NSTextViewがNSUndoManagerへの登録を内部で行ってくれるため、操作ごとに自分で登録作業をする必要はありません。
 なので、設定をオンにするだけで(デフォルトで設定がオンになっているので)何もしなくても、文字入力だけでなく、文字修飾や段落設定等も含めたアンドゥが実現できます。(2016.06.28訂正)

 一方、文字入力に関しては、一連の文字列の入力がアンドゥの単位となるため、使いにくいというケースも考えられます。
(OS標準のテキストエディットがそうなっているので、アンドゥしてみると分かります。)
 そこで、一文字ずつアンドゥする方法がないか調べたところ、以下のサイトで公開されていました。

 参考サイト(1):UITextViewで一文字ずつUndo/Redoする方法 - Qiita

 Xcodeで上記サイトのコードを試してみた(注3)ところ、期待通りのものであることが確認できました。
注3)iOS用なのでOSX用に、replaceRange:withText:を、shouldChangeTextInRange:replacementString:に変更した。
 さて、上記サイトのコードではMethod Swizzlingという手法が使われています。
(Method Swizzlingについては、ググると、(iOS向けも含めて)有益な情報をご提供頂いている様々なサイトがヒットします。)
 この手法では、クラスの拡張に、カテゴリの機能を利用しています。

 問題は、Xojoでカテゴリをどう扱うか、ですが、これにはお馴染みのObjective-CのランタイムAPIを用いた動的クラス生成を利用します。
 ランタイムAPIは、既にDelegateやTarget/Actionの実装に利用していますが、以下のサイトによると、カテゴリとしても利用できるということです。
(というか、class_addMethodsはカテゴリの仕組みを実現するために用意された、ようだ。)

 参考サイト(2):ダイナミックObjective-C (28) ランタイムAPIでさらに動的に(2) - メソッドの追加 | マイナビニュース

 なお、既存のクラスを拡張する場合は、そのクラスに対してclass_addMethodsしてやればいいので、クラスの生成は不要となります。
メソッド生成時に指定するType Encodingsは、オリジナルのメソッドの値をそのままコピーするのがベターと思われます。
取得には、例えば以下のサイトの関数を使わせて頂くと便利です。
参考サイト(3):Safx: NSObjectを継承しないクラスをランタイムAPIで作成する方法について
 また、メソッドの入れ替え登録部分(参考サイト(1)の例ではsetupHook())は、まんまランタイムAPIなので、こちらも問題なく実装できます。


 ペースト(ドロップ)処理を見直す

 アンドゥの前に、まず、画像を含むテキストのペースト(ドロップもペーストの一種)処理それ自体を見直すことにしました。
 と言うのも、現状では、以下の制限が確認されているからです。(他にもあるかも。)
  1. Deleteキーで、画像を削除できない
  2. メニュー(コンテキストメニュー含む)のカットは機能するが、ペーストは不十分(画像単体ではペーストされず、文字と混在した場合は文字だけペーストされる)
  3. プレビュー等の別アプリでコピーした画像をペーストできない
 調べたところ、Deleteキーでの削除は、DropObjectイベントの代わりにNSTextViewネイティブのメソッドであるperformDragOperation:を使用することで、(Deleteキー処理を自分で実装しなくても)対応できることが分かりました。
 また、ペーストメニューについては、paste:メソッドをオーバーライドして自分で実装する(ペーストボードから画像または文字列を抽出してしまえば、後はドロップと同じ処理)ことにより、(機能チェックが不十分なので、途上版レベルではありますが)対応できました。

 参考サイト(4):iOS 8リマインダーアプリの挙動調査 その2 - ObjecTips(iOS用なので、一部読み替えが必要)
 参考サイト(5):Accessing Attributes(iOS用ですが、趣旨は同じ)
別アプリでコピーした画像をペーストできない点については、仕様ですと云ってしまってもいいかも。
 なお、performDragOperation:とpaste:のオーバーライドには、こちらもMethod Swizzlingを利用します。


 ペースト(ドロップ)時のアンドゥ

 ペースト(ドロップ)時のアンドゥ登録は自動では行われませんので、自分で行うことになります。

 参考サイト(6):(旧) Cocoaの日々: rubberBand(その19)Undoの実装 / NSUndoManager

 上記サイトにあるように、アンドゥだけならsetAttributedString:メソッドとペースト(ドロップ)前のデータを登録するだけで事足りました。
(以下は、前回プロジェクト>TextArea1のDropObjectイベント>最後の部分、による例)
// 画像を追加したストレージを書き戻す(既存分)
Declare Sub setAttributedString Lib "Cocoa" Selector "setAttributedString:" (receiver As Ptr, identifier As Ptr)

// Undo登録用に現在のデータを複製しておく(追加分)
Declare Function mutableCopy Lib "Cocoa" Selector "mutableCopy" (receiver As Ptr) As Ptr
Dim oldAttrString As Ptr = mutableCopy(attrString)
// NSUndoManagerの取得(追加分)
declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
Dim pnt11 As Ptr = undoManager(Window1.Handle)
// UndoManagerにUndo時に使うメソッドとデータを登録(追加分)
declare function prepareWithInvocationTarget lib "Cocoa" selector "prepareWithInvocationTarget:" (receiver as Ptr, target As Ptr) As Ptr
Dim pnt12 As Ptr = prepareWithInvocationTarget(pnt11, pnt2)
setAttributedString(pnt12, oldAttrString)

setAttributedString(pnt2, attrString)  // (既存分)
 ですが、これだけだとリドゥができません。(しかも、場合によっては異常終了します。)
 リドゥに対応させるには、これも上記サイトにあるように、アンドゥ登録したメソッド(ここではsetAttributedString:)内でもNSUndoManagerへの登録処理が必要になります。

 setAttributedString:のオーバーライドには、Method Swizzlingを使う手もありそうですが、ここでは、setAttributedString:とリドゥ用の登録処理を記述したラッパーメソッドを用意して、それをNSUndoManagerに登録することとしました。
 なお、ラッパーメソッドはXojoのメソッドなので、NSUndoManagerに直接登録することはできない(と思われる)ため、こちらもObjective-CのランタイムAPIを用いた動的クラス生成を使用します。


 サンプルの仕様

 以上を踏まえて今回は、設定をオンにするだけのシンプルなものと、一文字ずつ&ペースト(ドロップ)処理のアンドゥが可能なものを試してみることにしました。
 それぞれの仕様は以下の通りとしました。
追記:例1の内容は誤りではありませんが、リドゥメニューを追加するだけで、他は何もしなくても同等の機能が実現できることを確認しました。(2016.06.28)
 例1(シンプル)
 例2(一文字ずつ&ペースト(ドロップ)処理のアンドゥ)

 Xojoでの実装(例1)
  1. 前回プロジェクトをベースとする
  2. MainMenuBarのEditMenuにEditRedoを追加
  3. 以下をWindow1のEnableMenuItemsイベントに記述
    // NSUndoManagerの取得
    declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
    Dim pnt1 As Ptr = undoManager(Window1.Handle)
    
    declare function canUndo lib "Cocoa" selector "canUndo" (receiver as Ptr) as Boolean  // Return BOOL
    if canUndo(pnt1) then
        EditUndo.Enabled=true
    else
        EditUndo.Enabled=false
    end if
    
    declare function canRedo lib "Cocoa" selector "canRedo" (receiver as Ptr) as Boolean  // Return BOOL
    if canRedo(pnt1) then
        EditRedo.Enabled=true
    else
        EditRedo.Enabled=false
    end if
    
  4. 以下をWindow1のOpenイベントに追加(前回プロジェクトで記述したコードの後に追加)
    // Delegate設定
    Declare Sub setDelegate Lib "Cocoa" Selector "setDelegate:" (receiver As Ptr, id As Ptr)
    setDelegate(pnt1, nil)  // なぜか設定しないとUndoできない。Delegate先はnilで構わない。
    
    // Undoを有効に
    declare sub setAllowsUndo lib "Cocoa" selector "setAllowsUndo:" (receiver as Ptr, flg As Boolean)
    setAllowsUndo(pnt1, true)
    
  5. 以下をWindow1のメニューハンドラ(Menu Handlers)に追加
    メニュー項目名: EditRedo
    
    declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
    Dim pnt1 As Ptr = undoManager(Window1.Handle)
    
    declare sub redo lib "Cocoa" selector "redo" (receiver as Ptr)
    redo(pnt1)
    
    Return True
    
  6. 以下をWindow1のメニューハンドラ(Menu Handlers)に追加
    メニュー項目名: EditUndo
    
    declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
    Dim pnt1 As Ptr = undoManager(Window1.Handle)
    
    declare sub undo lib "Cocoa" selector "undo" (receiver as Ptr)
    undo(pnt1)
    
    Return True
    
 実行してみたところ、アンドゥが機能することを確認しました。


 Xojoでの実装(例2)
  1. 例1のプロジェクトをベースとする
  2. TextArea1のDropObjectイベントとOpenイベントを削除
  3. 以下をWindow1のOpenイベントに追加(既プロジェクトで記述したコードの後に追加)
    // UndoManagerに登録するメソッドを動的に生成
    RegisterMethod()
    
    // カテゴリ機能を使ってメソッドをNSTextViewに追加
    RegisterMethodNSTextView()
    
    // メソッドの入れ替え
    setupHook()
    
  4. 以下をWindow1の共有メソッド(Shared Methods)に追加(不具合が確認されたので、書き直しました。詳細はこちら。 )
    メソッド名: myPaste
    引数: id As Ptr, SEL As CString Ptr, sender As Ptr
    
    Dim flg1 As Boolean = false
    Dim flg2 As Boolean = false
    
    // 文字列を指定してクラスオブジェクトを取得する。最初に一回宣言しておけばよい。
    Declare Function NSClassFromString Lib "Cocoa" (aClassName As CFStringRef) As Ptr
    
    // ペーストボードの取得
    Dim pnt1 As Ptr = NSClassFromString("NSPasteboard")
    Declare Function generalPasteboard Lib "Cocoa" Selector "generalPasteboard" (receiver As Ptr) As Ptr
    Dim pboard As Ptr = generalPasteboard(pnt1)
    
    // ペーストボード内に含まれるタイプを順に取得
    Declare Function types Lib "Cocoa" Selector "types" (receiver As Ptr) As Ptr
    Dim ary As Ptr = types(pboard)
    Declare Function count Lib "Cocoa" Selector "count" (receiver As Ptr) As Integer
    Dim cnt As Integer = count(ary)
    Declare Function objectAtIndexString Lib "Cocoa" Selector "objectAtIndex:" (receiver As Ptr, idx As Integer) As CFStringRef
    for i As Integer = 0 to cnt-1
        if objectAtIndexString(ary, i) = "com.apple.flat-rtfd" then  // 画像を含むタイプが見つかったらフラグをオンに
            flg1 = true
            exit
        end if
    next
    
    Declare Function alloc Lib "Cocoa" Selector "alloc" (receiver As Ptr) As Ptr
    if flg1 then// 画像を含んでいるので独自のペースト処理
        
        // com.apple.flat-rtfdからNSAttributedStringを復元
        Declare Function dataForType Lib "Cocoa" Selector "dataForType:" (receiver As Ptr, type As CFStringRef) As Ptr
        Dim data As Ptr = dataForType(pboard, "com.apple.flat-rtfd")
        Dim option As Ptr = NSClassFromString("NSMutableDictionary")
        Declare Function dictionary Lib "Cocoa" Selector "dictionary" (receiver As Ptr) As Ptr
        option = dictionary(option)
        Declare Sub setObject Lib "Cocoa" Selector "setObject:forKey:" (receiver As Ptr, obj As CFStringRef, key As CFStringRef)
        setObject(option, "NSRTFDTextDocumentType", "NSDocumentTypeDocumentAttribute")
        Dim atstr As Ptr = NSClassFromString("NSAttributedString")
        atstr = alloc(atstr)
        Declare Function dataInit Lib "Cocoa" Selector "initWithData:options:documentAttributes:error:" (receiver As Ptr, data As Ptr, optn As Ptr, attr As Ptr, err As Ptr) As Ptr
        atstr = dataInit(atstr, data, option, nil, nil)
        
        Declare Function myString Lib "Cocoa" Selector "string" (receiver As Ptr) As CFStringRef
        Dim str As CFStringRef = myString(atstr)  // 文字列
        Declare Function length Lib "Cocoa" Selector "length" (receiver As Ptr) As Integer
        Dim ll As Integer = length(atstr)  // 文字列長
        
        // 個々のアトリビュートごとに処理
        Dim attrib As Ptr
        Dim rng As NSRange
        rng.location = 0
        rng.length = 0
        Dim attachment As Ptr
        Dim wrap As Ptr
        Dim atstr2 As Ptr = NSClassFromString("NSAttributedString")
        atstr2 = alloc(atstr2)
        do until (rng.location + rng.length) >= ll
          
            Declare Function attributesAtIndex Lib "Cocoa" Selector "attributesAtIndex:effectiveRange:" (receiver As Ptr, idx As Integer, byRef rng As NSRange) As Ptr  // NSRangeをbyRef指定することで戻り値が取得できる
            attrib = attributesAtIndex(atstr, (rng.location + rng.length), rng)  // アトリビュートが適用される範囲がrngに返ってくる
            Declare Function objectForKey Lib "Cocoa" Selector "objectForKey:" (receiver As Ptr, key As CFStringRef) As Ptr
            attachment = objectForKey(attrib, "NSAttachment")  // NSAttachmentAttributeNameでは取れないので、NSAttachmentを指定
          
            if attachment = nil then  // 文字列
                Declare Function dataInit Lib "Cocoa" Selector "initWithString:attributes:" (receiver As Ptr, str As CFStringRef, attr As Ptr) As Ptr
                atstr2 = dataInit(atstr2, Mid(str, rng.location+1, rng.length), attrib)  // アトリビュートが適用される範囲の文字列を取得(文字単位となるので、MidBではなくMidを使う)
                insTextStrage("String", id, atstr2)  // TextStrageに文字列を挿入
                flg2 = true  // 文字列の挿入が終了した
            
            else  // 画像
                Declare Function fileWrapper Lib "Cocoa" Selector "fileWrapper" (receiver As Ptr) As Ptr
                wrap = fileWrapper(attachment)
                if wrap <> nil then
                    insTextStrage("Image", id, wrap)  // TextStrageに画像を挿入
                    flg2 = true  // 画像の挿入が終了した
                end if
            
            end if
        loop
        
    end if
    
    if not flg2 then  // 独自のペースト処理対象外のアイテム
        
        // 本来のpaste:を実行
        declare sub myPaste lib "Cocoa" selector "myPaste:" (receiver as Ptr, txt As Ptr)
        myPaste(id, sender)
        
    end if
    
    注1)テストが不十分な途上版なので、参考扱いです。
    注2)NSRangeをbyRef指定することで戻り値が取得できることが判ったため、書き直した。(2018.07.23)
    注3)SELの型をPtrとしていたが、CStringの方が相応しいので変更した。(未使用なので変更しなくても実害はなし。)(2018.07.30)
  5. 以下をWindow1の共有メソッド(Shared Methods)に追加
    メソッド名: myPerformDragOperation
    引数: id As Ptr, SEL As CString, sender  As Ptr
    
    // 文字列を指定してクラスオブジェクトを取得する。最初に一回宣言しておけばよい。
    Declare Function NSClassFromString Lib "Cocoa" (aClassName As CFStringRef) As Ptr
    
    Declare Function draggingPasteboard Lib "Cocoa" Selector "draggingPasteboard" (receiver As Ptr) As Ptr
    Dim pboard As Ptr = draggingPasteboard(sender)
    
    Declare Function types Lib "Cocoa" Selector "types" (receiver As Ptr) As Ptr
    Dim typ As Ptr = types(pboard)
    
    Declare Function containsObject Lib "Cocoa" Selector "containsObject:" (receiver As Ptr, obj As CFStringRef) As Boolean
    Dim folderItemAvailable As Boolean = containsObject(typ, "NSFilenamesPboardType")
    
    Dim ret As Boolean
    if folderItemAvailable then  // フォルダアイテムがドロップされた
        
        // パスの取得(最初の一個目のみ)
        Declare Function propertyListForType Lib "Cocoa" Selector "propertyListForType:" (receiver As Ptr, obj As CFStringRef) As Ptr
        Dim ary As Ptr = propertyListForType(pboard, "NSFilenamesPboardType")
        Declare Function objectAtIndex Lib "Cocoa" Selector "objectAtIndex:" (receiver As Ptr, info As Integer) As CFStringRef
        Dim filepath As String = objectAtIndex(ary, 0)
        
        // フォルダアイテムの内容(画像)を取得
        Dim wrap As Ptr = NSClassFromString("NSFileWrapper")
        Declare Function alloc Lib "Cocoa" Selector "alloc" (receiver As Ptr) As Ptr
        wrap = alloc(wrap)
        Declare Function pathInit Lib "Cocoa" Selector "initWithPath:" (receiver As Ptr, path As CFStringRef) As Ptr
        wrap = pathInit(wrap, filepath)
        
        // TextStrageに挿入
        insTextStrage("Image", id, wrap)
        
        // 戻り値は常にtrue
        ret = true
        
    else  // フォルダアイテム以外がドロップされた
        
        // 本来のperformDragOperation:を実行
        declare function myPerformDragOperation lib "Cocoa" selector "myPerformDragOperation:" (receiver as Ptr, sender As Ptr) As Boolean
        ret = myPerformDragOperation(id, sender)
        
    end if
    
    // 処理結果を返す(trueでないとドロップを受け付けたことにならない?)
    return ret
    
    注)SELの型をPtrとしていたが、CStringの方が相応しいので変更した。(未使用なので変更しなくても実害はなし。)(2018.07.30)
  6. 以下をWindow1の共有メソッド(Shared Methods)に追加
    メソッド名: insTextStrage
    引数: kind As String, id as Ptr, obj As Ptr
    
    // 文字列を指定してクラスオブジェクトを取得する。最初に一回宣言しておけばよい。
    Declare Function NSClassFromString Lib "Cocoa" (aClassName As CFStringRef) As Ptr
    
    // NSTextStorageの取得
    declare function textStorage lib "Cocoa" selector "textStorage" (obj_id as Ptr) As Ptr  // Return NSTextStorage*
    Dim pnt2 As Ptr = textStorage(id)
    
    Declare Function alloc Lib "Cocoa" Selector "alloc" (receiver As Ptr) As Ptr
    
    Dim attachChar As Ptr
    if kind = "String" then  // 文字列
        attachChar = obj
    elseif kind = "Image" then  // 画像
        Dim attachment As Ptr = NSClassFromString("NSTextAttachment")
        attachment = alloc(attachment)
        Declare Function wrapInit Lib "Cocoa" Selector "initWithFileWrapper:" (receiver As Ptr, wrapper As Ptr) As Ptr
        attachment = wrapInit(attachment, obj)
        
        Dim attachChar1 As Ptr = NSClassFromString("NSAttributedString")
        Declare Function attributedStringWithAttachment Lib "Cocoa" Selector "attributedStringWithAttachment:" (receiver As Ptr, attachment As Ptr) As Ptr
        attachChar = attributedStringWithAttachment(attachChar1, attachment)
    end if
      
    Dim attrString As Ptr = NSClassFromString("NSMutableAttributedString")
    attrString = alloc(attrString)
      
    Declare Function initWithString Lib "Cocoa" Selector "initWithAttributedString:" (receiver As Ptr, str As Ptr) As Ptr
    attrString = initWithString(attrString, pnt2)
    
    Declare Function mutableCopy Lib "Cocoa" Selector "mutableCopy" (receiver As Ptr) As Ptr
    Dim oldAttrString As Ptr = mutableCopy(attrString)  // Undo登録用に現在の値を複製しておく
    
    Declare Sub beginEditing Lib "Cocoa" Selector "beginEditing" (receiver As Ptr)
    beginEditing(attrString)
    
    Declare Sub insertAttributedString Lib "Cocoa" Selector "insertAttributedString:atIndex:" (receiver As Ptr, attachChar As Ptr, index As Integer)
    insertAttributedString(attrString, attachChar, Window1.TextArea1.SelStart)  // Xojo側からキャレット位置を取得。NSTextView or NSTextStorageから取得した方がいいかも。
      
    Declare Sub endEditing Lib "Cocoa" Selector "endEditing" (receiver As Ptr)
    endEditing(attrString)
    
    // NSUndoManagerの取得
    declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
    Dim pnt11 As Ptr = undoManager(Window1.Handle)
    
    // UndoManagerにUndo時に使うメソッドと値を登録
    declare function prepareWithInvocationTarget lib "Cocoa" selector "prepareWithInvocationTarget:" (receiver as Ptr, target As Ptr) As Ptr
    Dim pnt12 As Ptr = prepareWithInvocationTarget(pnt11, stAttrStrInstance)
    Declare Sub tStragesetAttrString Lib "Cocoa" Selector "tStrage:setAttrString:" (receiver As Ptr, tStrage As Ptr, attrString As Ptr)
    tStragesetAttrString(pnt12, pnt2, oldAttrString)
      
    // オブジェクトを追加したストレージを書き戻す
    mySetAttributedString(nil, nil, pnt2, attrString)
    
  7. 以下をWindow1の共有メソッド(Shared Methods)に追加
    メソッド名: myDeleteBackward
    引数: id As Ptr, SEL As CString
    
    // NSUndoManagerの取得
    declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
    Dim pnt1 As Ptr = undoManager(Window1.Handle)
    
    // UndoGrouping開始
    declare sub beginUndoGrouping lib "Cocoa" selector "beginUndoGrouping" (receiver as Ptr)
    beginUndoGrouping(pnt1)
    
    // 本来のdeleteBackwardを実行
    declare sub myDeleteBackward lib "Cocoa" selector "myDeleteBackward" (receiver as Ptr)
    myDeleteBackward(id)
    
    // UndoGrouping終了
    declare sub endUndoGrouping lib "Cocoa" selector "endUndoGrouping" (receiver as Ptr)
    endUndoGrouping(pnt1)
    
    注)SELの型をPtrとしていたが、CStringの方が相応しいので変更した。(未使用なので変更しなくても実害はなし。)(2018.07.30)
  8. 以下をWindow1の共有メソッド(Shared Methods)に追加
    メソッド名: myInsertText
    引数: id As Ptr, SEL As CString, txt As Ptr
    
    // NSUndoManagerの取得
    declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
    Dim pnt1 As Ptr = undoManager(Window1.Handle)
    
    // UndoGrouping開始
    declare sub beginUndoGrouping lib "Cocoa" selector "beginUndoGrouping" (receiver as Ptr)
    beginUndoGrouping(pnt1)
    
    // 本来のinsertText:を実行
    declare sub myInsertText lib "Cocoa" selector "myInsertText:" (receiver as Ptr, txt As Ptr)
    myInsertText(id, txt)
    
    // UndoGrouping終了
    declare sub endUndoGrouping lib "Cocoa" selector "endUndoGrouping" (receiver as Ptr)
    endUndoGrouping(pnt1)
    
    注)SELの型をPtrとしていたが、CStringの方が相応しいので変更した。(未使用なので変更しなくても実害はなし。)(2018.07.30)
  9. 以下をWindow1の共有メソッド(Shared Methods)に追加(不具合が確認されたので、書き直しました。詳細はこちら。)
    メソッド名: mySetAttributedString
    引数: id As Ptr, SEL As CString, tStrage As Ptr, attrString As Ptr
    
    // NSUndoManagerの取得
    declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
    Dim pnt11 As Ptr = undoManager(Window1.Handle)
      
    // Redo用
    declare function prepareWithInvocationTarget lib "Cocoa" selector "prepareWithInvocationTarget:" (receiver as Ptr, target As Ptr) As Ptr
    Dim pnt12 As Ptr = prepareWithInvocationTarget(pnt11, stAttrStrInstance)
    Declare Sub tStragesetAttrString Lib "Cocoa" Selector "tStrage:setAttrString:" (receiver As Ptr, tStrage As Ptr, attrString As Ptr)
    tStragesetAttrString(pnt12, tStrage, attrString)
    
    // 画像を追加したストレージを書き戻す
    Declare Sub setAttributedString Lib "Cocoa" Selector "setAttributedString:" (receiver As Ptr, identifier As Ptr)
    setAttributedString(tStrage, attrString)
    
    注)SELの型をPtrとしていたが、CStringの方が相応しいので変更した。(未使用なので変更しなくても実害はなし。)(2018.07.30)
  10. 以下をWindow1の共有メソッド(Shared Methods)に追加
    メソッド名: mySetMarkedTextSelectedRange
    引数: id As Ptr, SEL As CString, txt As Ptr, range As NSRange
    
    // txtをPtr型で受けているため、CFStringRef型に変換(macoslibによる)
    soft declare function CFRetain lib "Carbon" (cf as Ptr) as CFStringRef
    Dim st As CFStringRef = CFRetain(txt)
    
    if 0 < LenB(st) then
        
        // NSUndoManagerの取得
        declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
        Dim pnt1 As Ptr = undoManager(Window1.Handle)
        
        // UndoGrouping開始
        declare sub beginUndoGrouping lib "Cocoa" selector "beginUndoGrouping" (receiver as Ptr)
        beginUndoGrouping(pnt1)
        
        // 本来のsetMarkedText:SelectedRange:を実行
        declare sub mySetMarkedText lib "Cocoa" selector "mySetMarkedText:selectedRange:" (receiver as Ptr, txt As Ptr, range As NSRange)
        mySetMarkedText(id, txt, range)
        
        // UndoGrouping終了
        declare sub endUndoGrouping lib "Cocoa" selector "endUndoGrouping" (receiver as Ptr)
        endUndoGrouping(pnt1)
        
    end if
    
    注1)引数の型にNSRangeを指定しているが、これはcocoaのクラスではなく、構造体の名前(15項参照)。
    注2)SELの型をPtrとしていたが、CStringの方が相応しいので変更した。(未使用なので変更しなくても実害はなし。)(2018.07.30)
  11. 以下をWindow1の共有メソッド(Shared Methods)に追加
    メソッド名: myShouldChangeTextInRangeReplacementString
    引数: id As Ptr, SEL As CString, range As NSRange, txt As Ptr
    
    // NSUndoManagerの取得
    declare function undoManager lib "Cocoa" selector "undoManager" (obj_id as Integer) as Ptr  // Return NSUndoManager*
    Dim pnt1 As Ptr = undoManager(Window1.Handle)
    
    // UndoGrouping開始
    declare sub beginUndoGrouping lib "Cocoa" selector "beginUndoGrouping" (receiver as Ptr)
    beginUndoGrouping(pnt1)
    
    // 本来のshouldChangeTextInRange:replacementString:を実行
    declare function myShouldChangeText lib "Cocoa" selector "myShouldChangeTextInRange:replacementString:" (receiver as Ptr, range As NSRange, txt As Ptr) As Boolean
    Dim ret As Boolean = myShouldChangeText(id, range, txt)
    
    // UndoGrouping終了
    declare sub endUndoGrouping lib "Cocoa" selector "endUndoGrouping" (receiver as Ptr)
    endUndoGrouping(pnt1)
    
    // 本来のshouldChangeTextInRange:replacementString:の結果を返す(trueでないと文字が入力されない?)
    return ret
    
    注1)引数の型にNSRangeを指定しているが、これはcocoaのクラスではなく、構造体の名前(15項参照)。
    注2)SELの型をPtrとしていたが、CStringの方が相応しいので変更した。(未使用なので変更しなくても実害はなし。)(2018.07.30)
  12. 以下をWindow1の共有メソッド(Shared Methods)に追加
    メソッド名: RegisterMethod
    
    // 文字列を指定してクラスオブジェクト/セレクタを取得する。最初に一回宣言しておけばよい。
    Declare Function NSClassFromString Lib "Cocoa" (aClassName As CFStringRef) As Ptr
    Declare Function NSSelectorFromString Lib "Cocoa" (aSelName As CFStringRef) As Ptr
    
    // Declare宣言
    Declare Function objc_allocateClassPair Lib "Cocoa" (superclass As Ptr, name As CString, extraBytes As Integer) as Ptr
    Declare Sub objc_registerClassPair Lib "Cocoa" (cls As Ptr)
    Declare Function class_addMethod Lib "Cocoa" (cls As Ptr, name As Ptr, imp As Ptr, types As CString) As Boolean
    
    // 既にインスタンス作成済なら戻る
    if stAttrStrInstance <> nil then
        return
    end if
      
    // クラス名をmyIdentifier、メタクラス名をNSObjectにして、生成
    Dim newClassId As Ptr = objc_allocateClassPair(NSClassFromString("NSObject"), "myIdentifier", 0)
    // ランタイムに登録(参照を可能とするため)
    objc_registerClassPair newClassId
    // UndoManagerから呼ばれるメソッドの受け口となるメソッドを追加(tStrage:setAttrString:をXojo側で用意したmySetAttributedStringメソッドで受け取る。)
    if not class_addMethod (newClassId, NSSelectorFromString("tStrage:setAttrString:"), AddressOf mySetAttributedString, "@@:@@") then
        msgBox "error."
        return
    end if
    
    // 上記で生成したクラスのインスタンスを作成
    Declare Function alloc Lib "Cocoa" selector "alloc" (class_id As Ptr) As Ptr
    Declare Function init Lib "Cocoa" selector "init" (obj_id As Ptr) As Ptr
    Dim targetId As Ptr = init(alloc(newClassId))
    
    // インスタンスを保持
    stAttrStrInstance = targetId
    
  13. 以下をWindow1の共有メソッド(Shared Methods)に追加
    メソッド名: RegisterMethodNSTextView
    
    // 既に設定済なら戻る
    if nstextviewDone then
        return
      end if
    nstextviewDone = true
    
    // 文字列を指定してクラスオブジェクト/セレクタを取得する。最初に一回宣言しておけばよい。
    Declare Function NSClassFromString Lib "Cocoa" (aClassName As CFStringRef) As Ptr
    Declare Function NSSelectorFromString Lib "Cocoa" (aSelName As CFStringRef) As Ptr
    
    Dim TextViewPtr As Ptr = NSClassFromString("NSTextView")
    
    // Declare宣言
    Declare Function class_addMethod Lib "Cocoa" (cls As Ptr, name As Ptr, imp As Ptr, types As CString) As Boolean
    
    // 以下、一文字ずつアンドゥ用
    // NSTextViewクラスにmyShouldChangeTextInRange:replacementString:メソッドを追加(Xojo側で用意したmyShouldChangeTextInRangeReplacementStringメソッドで受け取る。)
    if not class_addMethod (TextViewPtr, NSSelectorFromString("myShouldChangeTextInRange:replacementString:"), AddressOf myShouldChangeTextInRangeReplacementString, "c40@0:8{_NSRange=QQ}16@32") then
        msgBox "error1."
        return
    end if
    // NSTextViewクラスにmySetMarkedText:selectedRange:メソッドを追加(Xojo側で用意したmySetMarkedTextSelectedRangeメソッドで受け取る。)
    if not class_addMethod (TextViewPtr, NSSelectorFromString("mySetMarkedText:selectedRange:"), AddressOf mySetMarkedTextSelectedRange, "v40@0:8@16{_NSRange=QQ}24") then
        msgBox "error2."
        return
    end if
    // NSTextViewクラスにmyInsertText:メソッドを追加(Xojo側で用意したmyInsertTextメソッドで受け取る。)
    if not class_addMethod (TextViewPtr, NSSelectorFromString("myInsertText:"), AddressOf myInsertText, "v24@0:8@16") then
        msgBox "error3."
        return
    end if
    // NSTextViewクラスにmyDeleteBackwardメソッドを追加(Xojo側で用意したmyDeleteBackwardメソッドで受け取る。)
    if not class_addMethod (TextViewPtr, NSSelectorFromString("myDeleteBackward"), AddressOf myDeleteBackward, "v24@0:8@16") then
        msgBox "error4."
        return
    end if
    
    // 以下、ペースト(ドロップ)処理用
    // NSTextViewクラスにmyPaste:メソッドを追加(Xojo側で用意したmyPasteメソッドで受け取る。)
    if not class_addMethod (TextViewPtr, NSSelectorFromString("myPaste:"), AddressOf myPaste, "v24@0:8@16") then
        msgBox "error6."
        return
    end if
    // NSTextViewクラスにmyPerformDragOperation:メソッドを追加(Xojo側で用意したmyPerformDragOperationメソッドで受け取る。)
    if not class_addMethod (TextViewPtr, NSSelectorFromString("myPerformDragOperation:"), AddressOf myPerformDragOperation, "c24@0:8@16") then
        msgBox "error5."
        return
    end if
    
  14. 以下をWindow1の共有メソッド(Shared Methods)に追加
    メソッド名: setupHook
    
    // 既に設定済なら戻る
    if hookDone then
        return
    end if
    hookDone = true
    
    // 文字列を指定してクラスオブジェクト/セレクタを取得する。最初に一回宣言しておけばよい。
    Declare Function NSClassFromString Lib "Cocoa" (aClassName As CFStringRef) As Ptr
    Declare Function NSSelectorFromString Lib "Cocoa" (aSelName As CFStringRef) As Ptr
    
    Dim TextViewPtr As Ptr = NSClassFromString("NSTextView")
    
    declare function class_getInstanceMethod lib "Cocoa" ( clsID as Ptr, SEL as Ptr ) as Ptr
    declare sub method_exchangeImplementations lib "Cocoa" ( clsID1 as Ptr, clsID2 as Ptr )
    
    // 以下、一文字ずつアンドゥ用
    Dim orgMethod As Ptr = class_getInstanceMethod(TextViewPtr, NSSelectorFromString("shouldChangeTextInRange:replacementString:"))
    Dim myMethod As Ptr = class_getInstanceMethod(TextViewPtr, NSSelectorFromString("myShouldChangeTextInRange:replacementString:"))
    method_exchangeImplementations(orgMethod, myMethod)
    
    orgMethod = class_getInstanceMethod(TextViewPtr, NSSelectorFromString("setMarkedText:selectedRange:"))
    myMethod = class_getInstanceMethod(TextViewPtr, NSSelectorFromString("mySetMarkedText:selectedRange:"))
    method_exchangeImplementations(orgMethod, myMethod)
    
    orgMethod = class_getInstanceMethod(TextViewPtr, NSSelectorFromString("insertText:"))
    myMethod = class_getInstanceMethod(TextViewPtr, NSSelectorFromString("myInsertText:"))
    method_exchangeImplementations(orgMethod, myMethod)
    
    orgMethod = class_getInstanceMethod(TextViewPtr, NSSelectorFromString("deleteBackward"))
    myMethod = class_getInstanceMethod(TextViewPtr, NSSelectorFromString("myDeleteBackward"))
    method_exchangeImplementations(orgMethod, myMethod)
    
    // 以下、ペースト(ドロップ)処理用
    orgMethod = class_getInstanceMethod(TextViewPtr, NSSelectorFromString("paste:"))
    myMethod = class_getInstanceMethod(TextViewPtr, NSSelectorFromString("myPaste:"))
    method_exchangeImplementations(orgMethod, myMethod)
    
    orgMethod = class_getInstanceMethod(TextViewPtr, NSSelectorFromString("performDragOperation:"))
    myMethod = class_getInstanceMethod(TextViewPtr, NSSelectorFromString("myPerformDragOperation:"))
    method_exchangeImplementations(orgMethod, myMethod)
    
  15. 以下をWindow1の共有プロパティ(Shared Properties)に追加
    プロパティ名: hookDone
    データ型: Boolean
    標準値: なし
    
  16. 以下をWindow1の共有プロパティ(Shared Properties)に追加
    プロパティ名: nstextviewDone
    データ型: Boolean
    標準値: なし
    
  17. 以下をWindow1の共有プロパティ(Shared Properties)に追加
    プロパティ名: stAttrStrInstance
    データ型: Ptr
    標準値: なし
    
  18. 他に、NSRange(構造体)が必要ですが、これはmacoslibからコピーさせて頂きました。
 実行してみたところ、アンドゥおよび画像を含むペースト(ドロップ)操作やDeleteキーによる画像削除が機能することを確認しました。


 おわりに

 一文字ずつのアンドゥの方は、日本語の変換中も逐一記録されるので、ちょっと気持ち悪いかも。
 別アプリでコピーした画像のペーストについては、機会があればトライしてみたいと思います。

 なお、今回も仕組みを理解するために、極力シンプルな書き方を心懸けています。
(例えば、allocした変数の一部は然るべきタイミングでReleaseするべきと思われますが、そのためにはコードを工夫する必要があります。)


 お世話になったサイト

 貴重な情報をご提供頂いている皆様に、お礼申し上げます。(以下、順不同)

 参考サイト(1):UITextViewで一文字ずつUndo/Redoする方法 - Qiita
 参考サイト(2):ダイナミックObjective-C (28) ランタイムAPIでさらに動的に(2) - メソッドの追加 | マイナビニュース
 参考サイト(3):Safx: NSObjectを継承しないクラスをランタイムAPIで作成する方法について
 参考サイト(4):iOS 8リマインダーアプリの挙動調査 その2 - ObjecTips
 参考サイト(5):Accessing Attributes
 参考サイト(6):(旧) Cocoaの日々: rubberBand(その19)Undoの実装 / NSUndoManager


 更新履歴

 2024.02.28 不具合が確認されたので、別記事(詳細はこちら)に対応策を纏めた。
 2018.07.30 Xojoでの実装(例2)の4,5,7,8,9,10項を改訂
 2018.07.23 Xojoでの実装(例2)の4項を改訂
 2016.06.28 TextAreaはデフォルトでアンドゥが有効になっていることに基づいて、記事を加筆訂正。
 2016.05.01 改訂版・新規作成


[Home]  [MacSoft]  [Donation]  [History]  [Privacy Policy]  [Affiliate Policy]