#はじめに
エディタ上のテキストを翻訳したり、選択中のテキストを翻訳結果に置き換えたりするプラグインをマーケットプレイスに出品しました!https://t.co/qg57B3J9L5#UE4 #UnrealEngine pic.twitter.com/EREkC3GWYV
— Naotsun (@Naotsun_UE) November 7, 2021
こちらの翻訳プラグインで実装した機能の中からエディタ上のテキストに無理やり(?)アクセスする方法をご紹介します。
こちらのプラグインでは、マウスポインタ直下にあるテキストを翻訳する機能や、編集中のテキストを翻訳して置き換える機能、ツールチップのテキストを翻訳して置き換える機能があり、それらの翻訳部分を除いた部分の実装方法を見ていきます。
今回の記事で使用するプロジェクトはUE4.27ですが、UE5でも同様に動作します。
#やり方
###Slateのウィジェットクラスのキャスト
まずこれらの機能を作るにあたって去年のアドカレで書いたこちらの記事の内容を使います。
UnrealC++でSlateのクラスをキャストする
今回のサンプルプロジェクトでは専用のヘッダーを用意しました。
CastSlateWidget.h
namespace SamplePlugin
{
namespace Private
{
/**
* Cast function for classes that inherit from SWidget.
*/
template<class To, class From>
TSharedPtr<To> CastSlateWidget(TSharedPtr<From> FromPtr, const FName& ToClassName)
{
static_assert(TIsDerivedFrom<From, SWidget>::IsDerived, "This implementation wasn't tested for a filter that isn't a child of SWidget.");
static_assert(TIsDerivedFrom<To, SWidget>::IsDerived, "This implementation wasn't tested for a filter that isn't a child of SWidget.");
if (FromPtr.IsValid())
{
if (FromPtr->GetType() == ToClassName)
{
return StaticCastSharedPtr<To>(FromPtr);
}
}
return nullptr;
}
}
}
#define CAST_SLATE_WIDGET(ToClass, FromPtr) SamplePlugin::Private::CastSlateWidget<ToClass>(FromPtr, #ToClass)
###テキストにアクセスする仕組み
今回、読み取り専用のテキストと編集可能なテキスト、ツールチップのテキストのアクセス方法についてご紹介しますが、いずれも仕組みは同じでアクセスに使用するインターフェースで定義されている関数が違うだけです。
読み取り専用のテキストを例としてテキストにアクセスする仕組みをご紹介します。
/**
* Accessor interface for read-only text widgets.
*/
class SAMPLEPLUGIN_API IReadOnlyTextAccessor
{
public:
// Destructor.
virtual ~IReadOnlyTextAccessor() = default;
// Returns the string set in the widget.
virtual FText GetText() const = 0;
// Returns the wrapped widget.
virtual TSharedPtr<SWidget> AsWidget() const = 0;
};
こちらが読み取り専用のテキストにアクセスするためのインターフェースで、テキストと対象のウィジェットを取得する関数のみが定義されています。
// The process of converting the passed widget to IReadOnlyTextAccessor.
DECLARE_DELEGATE_RetVal_OneParam(TSharedPtr<IReadOnlyTextAccessor>, FOnGetReadOnlyTextAccessor, TSharedPtr<SWidget> /* InWidget */);
/**
* Factory class for read-only text widgets accessor.
*/
class SAMPLEPLUGIN_API FReadOnlyTextAccessorFactory
{
public:
// Register the widget type name and accessor generation function.
static void RegisterAccessor(const FName& WidgetTypeName, const FOnGetReadOnlyTextAccessor& Generator);
// Unregister the widget type name and accessor generation function.
static void UnregisterAccessor(const FName& WidgetTypeName);
// Returns whether the specified widget type is registered.
static bool IsAccessorRegistered(const FName& WidgetTypeName);
// Create a accessor according to the registered widget type.
static TSharedPtr<IReadOnlyTextAccessor> CreateAccessor(TSharedPtr<SWidget> InWidget);
private:
// Registry of widget type name and accessor generation function.
static TMap<FName, FOnGetReadOnlyTextAccessor> ReadOnlyTextAccessorRegistry;
};
次にこちらがウィジェットのクラス名と上のインターフェースを実装した各ウィジェット用のクラスの生成関数を登録するクラスです。
TSharedPtr<IReadOnlyTextAccessor> FReadOnlyTextAccessorFactory::CreateAccessor(TSharedPtr<SWidget> InWidget)
{
if (InWidget.IsValid())
{
if (const FOnGetReadOnlyTextAccessor* Generator = ReadOnlyTextAccessorRegistry.Find(InWidget->GetType()))
{
return Generator->Execute(InWidget);
}
}
return nullptr;
}
CreateAccessor
の引数で渡されたウィジェットの型が登録されていたら生成関数を実行しIReadOnlyTextAccessor
を生成します。(登録されてなかったらnullptrを返す)
/**
* Template class used when registering widgets in FReadOnlyTextAccessorFactory.
* Only classes with functions that must be overridden in
* IReadOnlyTextAccessor can be specified in TWidgetClass.
*/
template<class TWidgetClass>
class TReadOnlyText : public IReadOnlyTextAccessor
{
public:
// Constructor.
TReadOnlyText(TSharedPtr<TWidgetClass> InReadOnlyText)
: IReadOnlyTextAccessor()
, ReadOnlyText(InReadOnlyText)
{
}
// IReadOnlyTextWrapper interface.
virtual FText GetText() const override
{
if (ReadOnlyText.IsValid())
{
return ReadOnlyText->GetText();
}
return FText();
}
virtual TSharedPtr<SWidget> AsWidget() const override
{
return ReadOnlyText;
}
// End of IReadOnlyTextWrapper interface.
private:
// An instance of the actual widget.
TSharedPtr<TWidgetClass> ReadOnlyText;
};
こちらはIReadOnlyTextAccessor
を実装するクラスの基本的な形です。
特別な実装をする場合以外はこのクラステンプレートを使用する感じになります。
FReadOnlyTextAccessorFactory::RegisterAccessor(
GET_CLASS_NAME(STextBlock),
FOnGetReadOnlyTextAccessor::CreateLambda(
[](TSharedPtr<SWidget> InWidget) -> TSharedPtr<IReadOnlyTextAccessor>
{
if (TSharedPtr<STextBlock> TextBlock = CAST_SLATE_WIDGET(STextBlock, InWidget))
{
return MakeShared<TReadOnlyText<STextBlock>>(TextBlock);
}
return nullptr;
})
);
実際の使用例としてSTextBlock
を登録するコードです。
STextBlock
は先程のクラステンプレートで問題ないので引数で渡されてきたウィジェットのクラスをSTextBlock
にキャストしてTReadOnlyText
のコンストラクタで渡し、TReadOnlyText
を返してあげます。
class FHyperlinkImpl : public IReadOnlyTextAccessor
{
public:
// Constructor.
FHyperlinkImpl(TSharedPtr<SHyperlink> InHyperlink)
: IReadOnlyTextAccessor()
, Hyperlink(InHyperlink)
{
}
// IReadOnlyTextAccessor interface.
virtual FText GetText() const override
{
if (Hyperlink.IsValid())
{
return Hyperlink->GetAccessibleText();
}
return FText();
}
virtual TSharedPtr<SWidget> AsWidget() const override
{
return Hyperlink;
}
// End of IReadOnlyTextAccessor interface.
private:
TSharedPtr<SHyperlink> Hyperlink;
};
SHyperlink
の様にTReadOnlyText
が使用できない場合はこのようにそのウィジェットクラス用のIReadOnlyTextAccessor
を実装したクラスを用意する必要があります。
ざっくり要約すると、Factoryクラスにウィジェットの型名とその型用の共通のインターフェースを実装したクラスを生成する関数を登録して、生成関数にTSharedPtr<SWidget>
を渡した時に登録されている型とマッチするのであればその型用のクラスを生成して返すという感じです。
インターフェースからテキストの取得やその他の操作を行うことができます。
一通りの実装の流れを紹介しましたが、後述する編集可能テキストやツールチップのテキストも同様の方法で実装されていますので、以降この辺りの実装についての紹介は省きます。
###マウスポインタ直下にあるテキストにアクセスしてみる
では、ここまでで確認したIReadOnlyTextAccessor
を使用して本題であるマウスポインタ直下にあるテキストへのアクセスを行う部分を見ていきましょう。
TSharedPtr<SWidget> FSamplePluginSlateHelpers::GetWidgetUnderMouseCursor()
{
FSlateApplication& SlateApplication = FSlateApplication::Get();
const FWidgetPath WidgetsUnderCursor = SlateApplication.LocateWindowUnderMouse(
SlateApplication.GetCursorPos(), SlateApplication.GetInteractiveTopLevelWindows()
);
if (WidgetsUnderCursor.IsValid())
{
const FArrangedChildren& Widgets = WidgetsUnderCursor.Widgets;
if (Widgets.Num() > 0)
{
return Widgets.Last().Widget;
}
}
return nullptr;
}
まずはマウスポインタ直下にあるウィジェットを取得する関数を見ていきましょう。
FSlateApplication
にLocateWindowUnderMouse
というマウスポインタ直下にあるウィジェットパス(使用中のウィンドウから最下層のウィジェットまでのウィジェット)を返す関数があるので、それを使用して最下層のウィジェットを返すようにしています。
こちらの関数はウィジェット関連のデバッグなどでも便利なので覚えておくといつか助かるかもしれません。
void FSamplePluginActions::TestReadOnlyTextAccessor()
{
// Find the read only text to process.
TSharedPtr<IReadOnlyTextAccessor> ReadOnlyText = nullptr;
{
const TSharedPtr<SWidget> Widget = FSamplePluginSlateHelpers::GetWidgetUnderMouseCursor();
if (Widget.IsValid())
{
ReadOnlyText = FReadOnlyTextAccessorFactory::CreateAccessor(Widget);
}
}
if (ReadOnlyText.IsValid())
{
const FText& Text = ReadOnlyText->GetText();
if (!Text.IsEmpty())
{
UE_LOG(LogSamplePlugin, Log, TEXT("%s"), *Text.ToString());
}
}
}
続いて取得したウィジェットからテキストを取り出す部分ですが、上記の関数でマウスポインタ直下にあるウィジェットを取得し、取得したウィジェットをFReadOnlyTextAccessorFactory::CreateAccessor
に渡してIReadOnlyTextAccessor
を生成します。
ここで取得したウィジェットがFReadOnlyTextAccessorFactory
に登録されていたらIReadOnlyTextAccessor
を通してテキストを取得しログ出力します。
サンプルプロジェクトではShift+Ctrl+Z
でマウスポインタ直下にあるテキストをログ出力する機能を確認することができます。
また、冒頭でご紹介した翻訳プラグインではマウスポインタ直下にあるテキストを翻訳し、その結果をポップアップウィンドウで表示する機能で使用されています。
###編集中のテキストにアクセスしてみる
/**
* Accessor interface for editable text widgets.
*/
class SAMPLEPLUGIN_API IEditableTextAccessor
{
public:
// Destructor.
virtual ~IEditableTextAccessor() = default;
// Returns the string set in the widget.
virtual FText GetText() const = 0;
// Returns the string selected in the widget.
virtual FText GetSelectedText() const = 0;
// Sets the text of the widget.
virtual void SetText(const FText& Text) = 0;
// Query to see if any text is selected within the widget.
virtual bool AnyTextSelected() const = 0;
// Returns the wrapped widget.
virtual TSharedPtr<SWidget> AsWidget() const = 0;
};
こちらが編集中のテキストにアクセスするインターフェースです。
読み取り専用のテキストのインターフェースであった機能に加えて選択しているテキストのみを取得する関数や、テキストを設定する関数が追加されています。
/**
* A utility structure for replacing only selected parts.
*/
class SAMPLEPLUGIN_API FEditableTextHandle
{
public:
// Constructor.
FEditableTextHandle(const TSharedPtr<IEditableTextAccessor>& InEditableText);
// If the text is selected, only the selected part is returned.
FString GetText() const;
// If the text is selected, replace only that part.
void SetText(const FString& InText);
// Returns whether the editable text on which this data is based is valid.
bool IsValid() const;
// Returns editable text from which this data is based.
TSharedPtr<IEditableTextAccessor> GetEditableTextAccessor() const;
// Returns text at the time this handle was generated.
FString GetCachedText() const;
// Returns whether any character was selected.
bool IsSelectedText() const;
// Returns selected text at the time this handle was generated.
FString GetCachedSelectedText() const;
private:
// The editable text from which this data is based.
TSharedPtr<IEditableTextAccessor> EditableTextAccessor;
// The text at the time this handle was generated.
FString CachedText;
// Whether any character was selected.
bool bIsSelectedText;
// The selected text at the time this handle was generated.
FString CachedSelectedText;
// Owner of EditableText.
TSharedPtr<SInlineEditableTextBlock> InlineEditableTextBlock;
};
上記のインターフェースだけだと少し使い勝手が悪い(例えばインターフェースを生成してから非同期で処理が入り、その後選択していた部分だけを置き換える、など...)ので、FEditableTextHandle
を使います。
子のクラスを生成した時点のテキストを保持していたり、選択中のテキストのみを置き換えたりできるクラスです。
また、このように常に編集モードのテキストならインターフェースを使ってウィジェットにテキストを設定すればテキストの置き換えを行えますが、
変数名や関数名のように編集モードと読み取り専用モードがあるテキストの場合は少し工夫をする必要があります。
TSharedPtr<SInlineEditableTextBlock> FindInlineEditableTextBlock(const TSharedPtr<SWidget>& SearchTarget)
{
if (!SearchTarget.IsValid())
{
return nullptr;
}
if (!SearchTarget->IsParentValid())
{
return nullptr;
}
const TSharedPtr<SWidget> Parent = SearchTarget->GetParentWidget();
const TSharedPtr<SInlineEditableTextBlock> InlineEditableTextBlock = CAST_SLATE_WIDGET(SInlineEditableTextBlock, Parent);
if (InlineEditableTextBlock.IsValid())
{
return InlineEditableTextBlock;
}
return FindInlineEditableTextBlock(Parent);
}
FEditableTextHandle::FEditableTextHandle(const TSharedPtr<IEditableTextAccessor>& InEditableText)
: EditableTextAccessor(InEditableText)
, bIsSelectedText(false)
{
// ~~~
// When the edit mode and display mode are switched like a comment node or function name,
// it is necessary to switch the mode from this handle, so the it is cached.
InlineEditableTextBlock = EditableTextHandleInternal::FindInlineEditableTextBlock(
EditableTextAccessor->AsWidget()
);
}
まず、このタイプのテキストブロックはSInlineEditableTextBlock
が生成した編集用のテキストボックスで読み取り専用モードになると削除されてしまいます。
そのため、親のウィジェットのSInlineEditableTextBlock
を再帰的に探す関数を使って、コンストラクタでキャッシュしておきます。
void FEditableTextHandle::SetText(const FString& InText)
{
if (!EditableTextAccessor.IsValid())
{
return;
}
// Switch to edit mode if there is an InlineEditableTextBlock.
if (InlineEditableTextBlock.IsValid())
{
const TSharedPtr<FSlateUser> SlateUser = FSamplePluginSlateHelpers::GetLocalSlateUser();
if (SlateUser.IsValid())
{
SlateUser->ReleaseAllCapture();
InlineEditableTextBlock->EnterEditingMode();
}
}
if (bIsSelectedText)
{
const FString ReplacedString = CachedText.Replace(*CachedSelectedText, *InText);
EditableTextAccessor->SetText(FText::FromString(ReplacedString));
}
else
{
EditableTextAccessor->SetText(FText::FromString(InText));
}
}
SInlineEditableTextBlock
がある場合は編集モードにしてからテキストの設定を行います。
また、テキストが選択されている場合は選択している部分のみを置き換えて設定しています。
TSharedPtr<FSlateUser> FSamplePluginSlateHelpers::GetLocalSlateUser()
{
return FSlateApplication::Get().GetCursorUser();
}
// ~~~
TSharedPtr<SWidget> FSamplePluginSlateHelpers::GetEditingWidget()
{
const TSharedPtr<FSlateUser> SlateUser = GetLocalSlateUser();
if (SlateUser.IsValid())
{
return SlateUser->GetFocusedWidget();
}
return nullptr;
}
続いて編集中のウィジェットの取得の仕方です。
こちらはそこまで難しくなく、FSlateUser
(GameFrameworkで言うPlayerController的なやつ)からフォーカス中のウィジェットを取得するだけです。
void FSamplePluginActions::TestEditableTextAccessor()
{
// Find the editable text to process.
TSharedPtr<IEditableTextAccessor> EditableText = nullptr;
{
const TSharedPtr<SWidget> Widget = FSamplePluginSlateHelpers::GetEditingWidget();
if (Widget.IsValid())
{
EditableText = FEditableTextAccessorFactory::CreateAccessor(Widget);
}
}
if (!EditableText.IsValid())
{
return;
}
if (EditableText->GetText().IsEmpty() || !EditableText->AnyTextSelected())
{
return;
}
const TSharedPtr<FEditableTextHandle> EditableTextHandle = MakeShared<FEditableTextHandle>(EditableText);
check(EditableTextHandle.IsValid());
EditableTextHandle->SetText(TEXT("Test Editable Text Accessor"));
}
こちらが編集中のテキストの選択している部分をTest Editable Text Accessor
に置き換える部分です。
基本的な流れはマウスポインタ直下にあるテキストにアクセスする場合と同じですが、テキストを設定するためにFEditableTextHandle
を使用しています。
サンプルプロジェクトではShift+Ctrl+X
で編集中のテキストの選択している部分を置き換える機能を確認することができます。
また、冒頭でご紹介した翻訳プラグインでは編集中のテキストの選択している部分を翻訳結果に置き換える機能で使用されています。
###ツールチップのテキストにアクセスしてみる
/**
* Accessor interface for tooltip text widgets.
*/
class SAMPLEPLUGIN_API ITooltipTextAccessor
{
public:
// Destructor.
virtual ~ITooltipTextAccessor() = default;
// Returns the tooltip text set in the widget.
virtual FText GetTextTooltip() const = 0;
// Returns the wrapped widget.
virtual TSharedPtr<SWidget> AsWidget() const = 0;
};
インターフェースは読み取り専用テキストと同じです。
/**
* A utility structure for replace widget tooltips until mouse is moved.
*/
class SAMPLEPLUGIN_API FTooltipTextHandle
{
public:
// Create this handle and update active instance.
static TSharedPtr<FTooltipTextHandle> CreateTooltipTextHandle();
// Replace the tooltip of the owner's widget with this handle.
void SetText(const FString& InText);
// Returns false if the mouse is already moved or the owner's widget is incorrect.
bool IsValid() const;
private:
// Constructor.
FTooltipTextHandle();
// Called when the mouse cursor is moved from the widget of the owner of this handle.
void HandleOnMouseLeave(const FPointerEvent& InEvent);
private:
// The tooltip owner widget indicated by this handle.
TSharedPtr<SWidget> TooltipOwner;
// The original tooltip that is returned when the mouse cursor is moved.
TSharedPtr<IToolTip> OriginalTooltip;
// Whether the mouse cursor has already been moved.
bool bHasMouseCursorLeft;
// An instance of the currently active handle.
static TSharedPtr<FTooltipTextHandle> ActiveInstance;
};
インターフェースだけだとテキストの取得しかできないため、ツールチップが消えるまで内容を上書きするためにFTooltipTextHandle
を使います。
FTooltipTextHandle::FTooltipTextHandle()
: bHasMouseCursorLeft(false)
{
TooltipOwner = FSamplePluginSlateHelpers::GetWidgetUnderMouseCursor();
if (!TooltipOwner.IsValid())
{
bHasMouseCursorLeft = true;
return;
}
TooltipOwner->SetOnMouseLeave(FSimpleNoReplyPointerEventHandler::CreateRaw(this, &FTooltipTextHandle::HandleOnMouseLeave));
}
void FTooltipTextHandle::HandleOnMouseLeave(const FPointerEvent& InEvent)
{
if (IsValid())
{
TooltipOwner->SetToolTip(OriginalTooltip);
}
bHasMouseCursorLeft = true;
TooltipOwner->SetOnMouseLeave(FSimpleNoReplyPointerEventHandler());
ActiveInstance.Reset();
}
仕組みとしては簡単で、コンストラクタでマウスポインタ直下にあウィジェットをキャッシュし、そのウィジェットからマウスポインタが離れた時のイベントをバインドしていて、マウスポインタが離れた時にキャッシュしておいたオリジナルのツールチップに戻す感じです。
void FTooltipTextHandle::SetText(const FString& InText)
{
if (!IsValid())
{
return;
}
auto& SlateApplication = FSlateApplication::Get();
const TSharedPtr<IToolTip> NewTooltip = SlateApplication.MakeToolTip(FText::FromString(InText));
if (NewTooltip.IsValid())
{
OriginalTooltip = TooltipOwner->GetToolTip();
TooltipOwner->SetToolTip(NewTooltip);
SlateApplication.UpdateToolTip(true);
}
}
ツールチップはIToolTip
を継承したウィジェットをSWidget
が持っているので、置き換えたいテキストから生成したツールチップを対象のSWidget
に設定しています。
TSharedPtr<SWidget> FSamplePluginSlateHelpers::GetTooltipWidget()
{
// Looking to tooltip window from all visible windows,
// because we can't get active tooltip window from outside.
TArray<TSharedRef<SWindow>> VisibleWindows;
FSlateApplication::Get().GetAllVisibleWindowsOrdered(VisibleWindows);
for (const auto& VisibleWindow : VisibleWindows)
{
// The tooltip window should be the top window with no title.
const bool bHasNoTitle = VisibleWindow->GetTitle().IsEmpty();
const bool bIsTopmostWindow = VisibleWindow->IsTopmostWindow();
if (!bHasNoTitle || !bIsTopmostWindow)
{
continue;
}
const FChildren* Children = VisibleWindow->GetChildren();
if (Children == nullptr)
{
continue;
}
for (int32 Index = 0; Index < Children->Num(); Index++)
{
const TSharedRef<const SWidget> ChildWidget = Children->GetChildAt(Index);
const TSharedPtr<SWidget> ChildWidgetPtr = ConstCastSharedRef<SWidget>(ChildWidget);
const TSharedPtr<SWeakWidget> WeakWidget = CAST_SLATE_WIDGET(SWeakWidget, ChildWidgetPtr);
if (WeakWidget.IsValid())
{
return WeakWidget;
}
}
}
return nullptr;
}
意外にも(?)現在表示しているツールチップを取得する関数がない(FSlateApplication
のprivate関数はある)ので、全ウィンドウからツールチップで使用しているウィンドウのウィジェットを探す関数を作りました。
void FSamplePluginActions::TestTooltipTextAccessor()
{
const TSharedPtr<SWidget> TooltipWidget = FSamplePluginSlateHelpers::GetTooltipWidget();
// Find the SToolTip contained in the tooltip widget and get the text.
TSharedPtr<ITooltipTextAccessor> TooltipText = nullptr;
{
TArray<TSharedPtr<SWidget>> ChildWidgets;
FSamplePluginSlateHelpers::GetAllChildWidgets(TooltipWidget, ChildWidgets);
for (const auto& ChildWidget : ChildWidgets)
{
TooltipText = FTooltipTextAccessorFactory::CreateAccessor(ChildWidget);
if (TooltipText.IsValid())
{
break;
}
}
}
if (TooltipText.IsValid())
{
const FText& TextTooltip = TooltipText->GetTextTooltip();
if (!TextTooltip.IsEmpty())
{
UE_LOG(LogSamplePlugin, Log, TEXT("%s"), *TextTooltip.ToString());
const TSharedPtr<FTooltipTextHandle> TooltipTextHandle = FTooltipTextHandle::CreateTooltipTextHandle();
check(TooltipTextHandle.IsValid());
TooltipTextHandle->SetText(TEXT("Test Tooltip Text Accessor"));
}
}
}
先程の関数でツールチップのウィンドウウィジェットを取得し、その子ウィジェットを走査してツールチップ本体を見つけています。
FTooltipTextHandle
はFEditableTextHandle
と同様に作成からテキストの設定まで非同期の処理を挟むことができます。
サンプルプロジェクトではShift+Ctrl+C
でツールチップのテキストをログ出力してから置き換える機能を確認することができます。
また、冒頭でご紹介した翻訳プラグインではツールチップのテキストを翻訳結果に置き換える機能で使用されています。
#おわりに
翻訳プラグインの中でも作るのに苦労した部分だったので記事にしようと思ったのですが、思いのほか説明するのが難しくコードを見せながら少し説明する急ぎ足な記事になってしまいました。
そもそもエディタ上のテキストにアクセスする需要がないのでは?という話ですが、珍しいケースではあると思うので、こんなやり方もあるんだなぁくらいに思って貰えたら助かります。(Slateクラスをキャストできればなんでもできるよ!)
この記事で紹介したプロジェクトは以下でダウンロードできます。
https://github.com/Naotsun19B/SlateTextAccessor
明日は、@nana_321321さんの「BlenderのAutoRigを使ってUE4に持っていくまでのお話」です!