JavaScript
TypeScript
React
clipboard
Electron

Electronでコピペを実装する - 俺がコピーしたいのはテキストだけじゃないんだ

TypeScript製映像編集ソフト Delirを開発しているラグです :sparkles:

Electronでコピーペーストといえば「MenuItemOptionにrole: 'copy'をつける」が基礎的な方法です。
しかしこの方法で実装できるコピー&ペーストはBrowserWindow上のテキストのみで、アプリケーション上のコピー可能なオブジェクトのコピーペーストの実装には別の方法が必要です。今回は、Delirでどのように「テキストじゃないもののコピーペースト」を実装したかをお話します。

前提

  • ElectronのMenuItemを触ったことがある人が対象

なにをコピーしたいの?

Delirには「クリップ」と呼ばれる画像や動画を表すオブジェクトがあり、今回コピペするのはこのオブジェクトです。また、テキストエリアも存在するため、通常のテキストもコピペできる必要があります。

clips.png

アーキテクチャ

Delirフロントエンドは自作のFluxフレームワークで動いています。
アプリの状態はStoreにある、という点は他のFluxフレームワークと変わりません。

コピー/ペーストの発火経路

コピー/ペーストは以下の2つの点から発火されます。

  • アプリケーションの編集メニュー
  • ショートカットキー(CmdOrCtrl+C, CmdOrCtrl+V)

ショートカットキーだけであれば、mousetrapを使ってイベントフックしてあげればいいのですが、アプリケーションメニューがあるのがかなり曲者です。「何が選択されているか」に応じてコピペの挙動を変化させる必要があります。

実装

初手、role: 'copy' / role: 'paste'を外す

はい、外します。role: '***'がついているメニューは、クリックないしショートカットキーが押下されても、MenuItemOptionに設定されたclick()コールバックを発火しません。

これがアプリケーションでハンドルできなければ何もできませんので外します。(これにより、ネイティブのテキストコピーが利用できなくなるので、自分でハンドルする必要があります。後述)

コピーペーストと文脈

では次に、「文脈によってコピー・ペースト対象を変えてコピペする」をやります。
Delirではアプリーケーションに一つのグローバルなEventEmitterを介して、「最近選択されたコンポーネントに処理させる」という方法を採用しています。

AppMenu.tsx
class AppMenu extends React.Component {
  public componentDidUpdate() {
    electron.remote.Menu.setApplicationMenu([{
      label: t('edit.label'),
      submenu: [
        {
          label: t('edit.copy'),
          accelerator: 'CmdOrCtrl+C',
          click() { 
            // コピーイベントを発火させる
            GlobalEvents.emit(GlobalEvent.copy)
          }
        }
      ]
    }]) 
  }
}
Clip.tsx
class Clip extends React.Component {
  private handleFocus = () => {
    GlobalEvents.on(GlobalEvent.copy, this.handleGlobalCopy)
  }

  private handleBlur = () => {
    GlobalEvents.off(GlobalEvent.copy, this.handleGlobalCopy)
  }

  private handleGlobalCopy = () => {
    // アプリケーション内のクリップボードへコピー
    this.props.context.executeOperation(EditorOps.copyEntity, {
      type: 'clip',
      entity: this.props.clip,
    })
  }
}

コピーに対応しているコンポーネント側で、focus時にイベントをリッスンし、blur時にリスナーを外します。

コピーされたエンティティは単純なJSON化が難しいため、無理にclipboardモジュールを使ったシステムのクリップボードへのコピーを行っていません。

単純でパルス的なイベントについて、Fluxのフローに乗せて通知を行うのはかえってアプリケーションコードを複雑にする、と考えてグローバルなEventEmitterを使っています。

ペーストについても同じ要領で、最近選択されたコンポーネントがpasteイベントをリッスンし、自身にペースト可能なものがクリップボードに設定されている場合にFluxフローでペースト処理を行います。

ちなみにペースト側はこんな感じの処理をしています。本筋じゃないので興味があったら見てくれ

ペーストイベントのハンドラ: https://github.com/ra-gg/Delir/blob/master/packages/delir/views/Timeline/Layer.tsx#L203
ペーストの実処理: https://github.com/ra-gg/Delir/blob/master/packages/delir/domain/Project/operations.ts#L548

テキストコンテンツのコピー/ペーストのハンドル

さて、これでアプリケーションで任意の対象をコピーできるようになりましたが、テキストコンテンツについて再対応する必要があります。。

テキストコンテンツを上述のフローでフックしだすとありとあらゆる選択可能な要素にイベントリスナーをつけて回る地獄が待っているので、アプリケーションメニューのコールバックを以下のようにすることで対応しています。

AppMenu.tsx
const isSelectionInputElement = (el: Element) => {
  return (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement)
    && (el.selectionStart !== el.selectionEnd)
}

remote.Menu.setApplicationMenu([{
  label: t('edit.label'),
  submenu: [
    {
      label: t('edit.copy'),
      accelerator: 'CmdOrCtrl+C',
      click() { 
        if (isSelectionInputElement(document.activeElement)) {
          document.execCommand('copy')
        } else {
          GlobalEvents.emit(GlobalEvent.copy, {})
        }
      }
    }
  ]
}]) 

コピーが発火した時のアクティブな要素を見てinput要素っぽいものだったらdocument.execCommand('copy')などとしてネイティブのコピーを実行します。

Delirではinput系以外の要素はuser-select: noneで選択不可能にされているのでここまで割り切っても大丈夫でしたが、大変なアプリだと大変になりそうです。

実は死ぬ!DevToolsのコピー/ペースト

実はここまでの対応でもまだ問題があって、DevToolsのコピペが死にます!エラーログのコピーができなくて非常に厄介ですね。DXに悪いのでなんとかします。

今回のケースではアプリケーションメニューがAppMenuコンポーネントにラップされているので、AppMenuコンポーネントでdevtoolsの開閉をフックしてなんとかします。

AppMenu.tsx
class AppMenu extends React.Component {
  public state = { devToolsFocused: false }

  public componentDidMount() {
    const {webContents} = electron.remote.getCurrentWindow()

    window.addEventListener('focus', () => {
      this.setState({ devToolsFocused: false })
    })

    webContents.on('devtools-focused', () => {
      this.setState({ devToolsFocused: true })
    })
  }

  public componentDidUpdate() {
    electron.remote.Menu.setApplicationMenu([{
      label: t('edit.label'),
      submenu: [
        {
          label: t('edit.copy'),
          accelerator: 'CmdOrCtrl+C',
          click() { 
            GlobalEvents.emit(GlobalEvent.copy)
          },
          // DevToolsにフォーカスされている時に `role` オプションを付与する
          ...(this.state.devToolsFocused ? { role: 'copy' } : {})
        }
      ]
    }]) 
  }
}

とてもつらい… _:(´ཀ`」 ∠):_

残念ながらdevTools内のdocumentに対してアクセスする術がないため、roleオプションを適宜復活させてあげる必要があります…

devToolにフォーカスされたことはwebContentsのイベントから知ることができますが、blurのタイミングは知ることができないため、window(メインコンテンツのほう)のfocusイベントをもって「あっコンテンツにフォーカスされたんだな!」と察してあげましょう。

まとめ

Electronのテキスト以外のコンテンツのコピペはつらい、VSCodeのコードを漁ったけどどこでハンドルしてるのか全くわからなくてやっぱりつらかった