📋

Webの書式付き貼り付けチュートリアル!

2024/12/21に公開

みなさんこんにちは、最近リッチエディターのことがとても気になっている@iricoです。

突然ですが何かをコピーしてリッチエディターに貼り付ける際に、コピー元の書式がいい感じに反映されることってありませんか?

今回はその機能(以降書式付き貼り付けと呼びます)について簡単に説明していこうと思います。

※この記事はChorme,MacOSで検証しています。他の環境では違った結果になる可能性があります。

書式が維持されるのはなぜ?

まずは、スタイルを維持している基本的な仕組みについて解説していきます。

クリップボードは基本的に複数のデータ形式を同時に保存しています。

前回の記事でOS側のクリップボードの仕組みについて解説しているので、
興味があればこちらもご確認ください。
https://zenn.dev/cybozu_frontend/articles/d2782f5ad615f0

webでスタイルをペーストするには、text/html形式のクリップボードデータが必要になります。
実際にOSクリップボードデータがwebにどのように解析されているかを確認するには、Clipboard Inspectorを利用するのがおすすめです。
Clipboard Inspectorの画面スクリーンショット

contenteditable

DOMにcontenteditable属性を使うことでコンテンツが編集可能となります。
リッチエディタのライブラリの多くが、このcontenteditableにより編集機能を実現しています。

contenteditable属性がついたDOMにWebコンテンツをコピーして貼り付けてみましょう。
今回は MacOS版のExcelをコピーして貼り付けます。

貼り付け元のデータ

貼り付けた結果

フォントカラーや背景色が引き継がれていますね。
このように、contenteditableはデフォルトで書式付き貼り付けがサポートされています。

pasteイベント

では、contenteditableを使わずにスタイルをコピーする方法はあるのでしょうか。
pasteイベントを使って、contenteditableに似た機能を作ってみます。


貼り付け元のデータ

貼り付けた結果

おや、スタイルが引き継がれていませんが…?

下準備は整ったので、実際の貼り付けのケースを見ていきましょう。

headのスタイル問題

paste event vs contenteditable

contenteditableとpasteの先程の貼り付けた結果を見比べてみます。

contenteditableの貼り付け結果

pasteの貼り付け結果

pasteの方は色々とスタイルが落ちていますね。

まず、先程紹介したClipboard Inspectorでクリップボードデータの詳細を確認します。
長いので折りたたみで載せています。

text/htmlのデータ内容

<html xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:x="urn:schemas-microsoft-com:office:excel"
xmlns="http://www.w3.org/TR/REC-html40">

<head>
<meta http-equiv=Content-Type content="text/html; charset=utf-8">
<meta name=ProgId content=Excel.Sheet>
<meta name=Generator content="Microsoft Excel 15">
<link id=Main-File rel=Main-File
href="file:////Users/k002265/Library/Group%20Containers/UBF8T346G9.Office/TemporaryItems/msohtmlclip/clip.htm">
<link rel=File-List
href="file:////Users/k002265/Library/Group%20Containers/UBF8T346G9.Office/TemporaryItems/msohtmlclip/clip_filelist.xml">
<style>
<!--table
{mso-displayed-decimal-separator:".";
mso-displayed-thousand-separator:",";}
@page
{margin:.75in .7in .75in .7in;
mso-header-margin:.3in;
mso-footer-margin:.3in;}
.font5
{color:windowtext;
font-size:6.0pt;
font-weight:400;
font-style:normal;
text-decoration:none;
font-family:游ゴシック;
mso-generic-font-family:auto;
mso-font-charset:128;}
tr
{mso-height-source:auto;
mso-ruby-visibility:none;}
col
{mso-width-source:auto;
mso-ruby-visibility:none;}
br
{mso-data-placement:same-cell;}
td
{padding-top:1px;
padding-right:1px;
padding-left:1px;
mso-ignore:padding;
color:black;
font-size:12.0pt;
font-weight:400;
font-style:normal;
text-decoration:none;
font-family:游ゴシック;
mso-generic-font-family:auto;
mso-font-charset:128;
mso-number-format:General;
text-align:general;
vertical-align:middle;
border:none;
mso-background-source:auto;
mso-pattern:auto;
mso-protection:locked visible;
white-space:nowrap;
mso-rotate:0;}
.xl65
{color:red;
font-weight:700;
background:yellow;
mso-pattern:black none;}
ruby
{ruby-align:left;}
rt
{color:windowtext;
font-size:6.0pt;
font-weight:400;
font-style:normal;
text-decoration:none;
font-family:游ゴシック;
mso-generic-font-family:auto;
mso-font-charset:128;
mso-char-type:katakana;
display:none;}
-->
</style>
</head>

<body link="#467886" vlink="#96607D">

<table border=0 cellpadding=0 cellspacing=0 width=100 style='border-collapse:
collapse;width:75pt'>
<col width=100 style='width:75pt'>
<tr height=27 style='height:20.0pt'>

<td height=27 class=xl65 width=100 style='height:20.0pt;width:75pt'>aaa</td>

</tr>
</table>

</body>

</html>

ここで注目したいのは

<td
 height=27
 class=xl65
 width=100
 style='height:20.0pt;width:75pt'
>
 aaa
</td>

のstyle属性です。colorbackground-colorはありませんが、classのxl65 が指定されています。

.xl65
	{color:red;
	font-weight:700;
	background:yellow;
	mso-pattern:black none;}

とあるように、このDOMはクラス経由でフォントカラーや背景色、文字の太さが当てられていることがわかります。
この記述のあるstyleタグはheadタグの子に配置されています。

コード例のpasteの処理は

target.append(doc.body)

とあるように、bodyタグのみを挿入しているため、headの記述は当然反映されません。

では、contenteditableはどうして書式が反映されているのでしょうか?

contenteditableで貼り付けられたDOM結果

<table border="0" cellpadding="0" cellspacing="0" width="100" style="border-collapse: collapse; width: 75pt;"><colgroup><col width="100" style="width: 75pt;"></colgroup><tbody><tr height="27" style="height: 20pt;"><td height="27" class="xl65" width="100" style="padding-top: 1px; padding-right: 1px; padding-left: 1px; color: red; font-size: 12pt; font-weight: 700; font-family: 游ゴシック; vertical-align: middle; border: none; text-wrap-mode: nowrap; background: yellow; height: 20pt; width: 75pt;">aaa</td></tr></tbody></table>

<td
 height="27"
 class="xl65"
 width="100"
 style="padding-top: 1px; padding-right: 1px; padding-left: 1px; color: red; font-size: 12pt; font-weight: 700; font-family: 游ゴシック; vertical-align: middle; border: none; text-wrap-mode: nowrap; background: yellow; height: 20pt; width: 75pt;">
 aaa
</td>

このように、個別のDOMにCSSの計算結果がinlineで挿入されています。
よって、headに記述されているCSSも各DOMに当たるようになっています。

paste eventでも書式を維持したい!

どうすればPasteイベントでもcontenteditableのように書式を維持することができるでしょうか?

貼り付けられる側のheadタグに貼り付ける側のstyleを動的に埋め込む方法が考えられます。
しかし、フレームワークによってはそのような操作が難しい場合がありますし、HTML全体に書式付き貼り付けのスタイルが効いてしまう可能性は避けたいところです。

contenteditableと同様に、各DOMに計算結果のcssをinlineで埋め込む方法について考えてみます。
Window.getComputedStyleでスタイルを取得すると、スタイルシートから計算したスタイルを取得するとこができます。
getComputedStyleを利用するにはwindowオブジェクトが必要なので、不可視のiframeを用意し、iframe経由でgetcomputedStyleを利用してみます。

これでスタイルを完全に保持することができました。
ただし、

if (!(element instanceof iframe.contentWindow.HTMLTableCellElement)) continue;

となっている点に注意してください。
これによって、tdタグのみをinline化しています。
DOM全てをinline化すると、cssの継承によって元のスタイルが再現できない場合があります。
今回のケースだとbodytタグに-webkit-text-fill-color: rgb(0, 0, 0);が埋め込まれます。-webkit-text-fill-colorのカラーは通常のcolorよりも優先されるため、子の文字色も黒になってしまいます。

これを回避するために、どのスタイルやDOMをinline化するかのフィルターの仕組みなどが別途必要になります。

RTEライブリラリではどうなっているか?

実は多くのリッチエディターのライブラリでは、contenteditableを利用しつつpasteイベントで各DOMを変換する、という構成になっているため似た問題が発生します。

下記はリッチエディターライブラリの一つであるproseMirrorのDOM変換の実装コードの一部です。
コア部分がDOMを引き回して一つ一つ処理を行う形になっており、styleタグの情報を拾うことがこの流れからでは難しいことがわかります。
https://github.com/ProseMirror/prosemirror-model/blob/3c07503688f045f646f2a3503bb4b01b2c27da10/src/from_dom.ts#L592-L600

paste eventでの実装例のように、ペーストのhtmlにおいて自力でスタイル計算を行うようなpluginを作ることで実現できるかもしれませんが、-webkit-text-fill-colorのようなinline化で予期せぬスタイルが当たる問題や、不要なinlineのCSSコードの増大という問題が発生します。

おまけ: Clipboardのセキュリティ事情

今回はユーザーがペーストすることでClipboardのデータにアクセスしましたが、Clipboard APIを利用して直接コードからClipboardのデータを読み込んだり書き込むことができます。

こんな状況を想像してみてください。
自分の知らないところでいつの間にかクリップボードのデータが読み込まれており、それがパスワードやカード番号などの重要な情報であったら…

この行為はクリップボードスティーラーやクリッパーマルウェアなどと呼ばれたりします。
Clipboard APIは悪用されるととても危険なことがわかりますね。
それを防ぐためにClipboard APIを利用するには以下の二つの制約があります。

  • 安全なコンテキストであること
    • HTTPS配信されているかなどの制約
    • ローカルで配信されたリソース(http://localhostなど)は例外的に安全なコンテキストとしてみなされる
  • セキュリティの考慮がなされていること
    • ブラウザによって対応がバラバラなので詳細はリンクを参照
    • Chromeだとポップアップで権限の確認が行われる
      Chromeのクリップボードへのアクセスの確認
      Clipboard APIを扱う際にはこれらの制約に注意しましょう。

まとめ

書式付き貼り付けはここで取り上げたこと以外にも、不思議な現象が沢山起きる楽しい遊び場です。
また機会があれば他の事象についても取り上げてみたいと思います。

サイボウズ フロントエンド

Discussion