浏览器之文本复制问题小记
问题背景
当在浏览器上复制的时候会发生什么?
web开发会为了方便用户,帮他们推广分享,自动写入广告推广语之类的剪贴板内容的需要。在github比较容易找到的库:
copy-to-clipboard
clipboardjs
copy-to-clipboard的使用方式比较便捷,用户也只有纯文本的复制需求,项目也就选用这个。
import copy from 'copy-to-clipboard' copy('第一行\n第二行') 复制代码
但是用户在分享微信的时候遇到一个问题:chrome上项目的复制功能,在windows桌面微信聊天窗口粘贴的时候,内容都挤在一起没有换行,像是上面的文本为直接变成【第一行第二行】,而macOs是OK的。虽然后面发现copy
其实是有配置参数,可以设置为text/plain
来避免这个问题,但是为什么一定得设置?毕竟其他地方全都使用正常,就只有windows微信客户端存在问题
在windows才会产生换行符丢失的问题,自然觉得是类Unix和windows上关于换行符的差异。但是一个大众的库应该会考虑到这种情况,而copy-to-clipboard库却是没有发现相关的内容处理。
而通过py来查看剪贴板内容,可以发现换行符\r\n
其实存在的,而在像系统记事本、vscode等工具上,都是可以粘贴出来换行符的,和微信一样基于duilib的钉钉也是ok的。
import win32clipboard import win32con def printCopyText(): win32clipboard.OpenClipboard() print(win32clipboard.GetClipboardData(win32con.CF_TEXT)) win32clipboard.CloseClipboard() printCopyText() # 对“我是第一行\n我是第二行”使用copy-to-clipboard复制 # 输出结果: # b'\xb5\xda\xd2\xbb\xd0\xd0\r\n\xb5\xda\xb6\xfe\xd0\xd0' 复制代码
所以目前存在如下疑问:
换行符\n是怎么变成\r\n
许多软件粘贴换行符正常,而
text/plain
才能让微信客户端处理好换行符
copy-to-clipboard原理
import copy from 'copy-to-clipboard' copy('第一行\n第二行', { format:'text/plain' }) 复制代码
如果没有配置text/plain
,copy-to-clipboard
文档说默认是用text/html
。
copy-to-clipboard实现复制的过程主要是由以下几步:
新建一个
span
,设置textContent
为需要复制的内容,添加样式如white-space:pre
textContent表示一个节点及其后代的文本内容。有个与textContent很类似的属性是innerText,这两者的明显差别是:
innerText获取的是渲染之后的文本,也就是说display: none的内容是得不到的,也就会触发页面的重绘,所以比textContent会更耗性能。如果元素本身没有被渲染,跟textContent是一样的
innerHtml也挺类似,但与前面俩有明显区别就是innerText和textContent是不会解析html的,可以有效避免XSS攻击(但是script标签也可以被XSS)。把textContent换成 innerText,在微信的粘贴却是可以正常显示换行的。
生成
range
加入selection
来实现选中span
如果配置传入了
format
参数,就会拦截span
的copy事件回调,调用回调事件的clipboardData
来设置如text/plain
的样式。执行
execCommand('copy')
总体流程就是模拟用户select了一个span
标签上的内容,如果有传入自定义配置format
,就拦截默认行为,手动设置clipboardData
。最后执行execCommand
默认这个复制行为的启动,这里也就解释了为什么传入text/plain
就可以了。但也没有反映出为什么默认行为不行。
上面之所以在默认format
是text/html
重点突出文档说,是因为这不是实现的text/html
,而是它认为浏览器的默认行为是text/html
。
同时这里没有发现对系统换行符差异的考虑。
clipboardjs原理
而我们用clipboardjs
的复制源码来替换copy-to-clipboard
却是不会存在换行问题,它是这样实现的:
创建一个
textarea
。需要复制的文本置为
textarea
的value
。新建
selection
为textarea
的value
长度选区,执行execCommand
。
这里既不需要clipboardData
来设置复制文本的格式,同时也没有系统换行符的处理。
execCommand('copy')的原理
从copy-to-clipboard
和clipboardjs
的步骤来看,对换行符进行的处理更有可能在最后的execCommand
模拟复制这一步,因为写入剪贴板会在这一步,接着应该在chromium找关于execCommand的相关执行代码。
1. execCommand各种命令的执行配置
// third_party\blink\renderer\core\editing\commands\editor_command.cc // 对于execCommand其他具体指令的了解也可以在这个文件找到相应的配置 static const EditorInternalCommand kEditorCommands[] = { //... { EditingCommandType::kCopy, ClipboardCommands::ExecuteCopy, Supported, ClipboardCommands::EnabledCopy, StateNone, ValueStateOrNull, kNotTextInsertion, ClipboardCommands::CanWriteClipboard }, // ... } 复制代码
在配置文件中找到相应的执行动作:ExecuteCopy
2. ExecuteCopy
// third_party\blink\renderer\core\editing\commands\clipboard_commands.cc // execCommand('copy')相应的执行动作 bool ClipboardCommands::ExecuteCopy(LocalFrame& frame, Event*, EditorCommandSource source, const String&) { // ... if (EnclosingTextControl( frame.Selection().ComputeVisibleSelectionInDOMTree().Start())) { frame.GetSystemClipboard()->WritePlainText(frame.SelectedTextForClipboard(), GetSmartReplaceOption(frame)); frame.GetSystemClipboard()->CommitWrite(); return true; } WriteSelectionToClipboard(frame); return true; //.. } 复制代码
textControl
首先存在明显的关于selection的判断条件:
EnclosingTextControl( frame.Selection().ComputeVisibleSelectionInDOMTree().Start()) 复制代码
从变量定义推测大致逻辑,可以大概整理出如下要点:
ComputeVisibleSelectionInDOMTree指选区的可视化部分开头,即排除掉
display:none
,visibility: hidden
。判断selection开始位置是否属于
textControl
类型
chromium 定义textarea和部分input为
textControl
类型
WritePlainText写入纯文本内容,CommitWrite提交这次修改
SelectedTextForClipboard
是对选区内文本的获取:
SetEmitsImageAltText - 提取
image
设置的alt内容。SetSkipsUnselectableContent - 忽略不可选的内容,如
user-select: none
的节点不会出现在粘贴内容上。
String FrameSelection::SelectedTextForClipboard() const { return ExtractSelectedText( *this, TextIteratorBehavior::Builder() .SetEmitsImageAltText( frame_->GetSettings() && frame_->GetSettings()->GetSelectionIncludesAltImageText()) .SetSkipsUnselectableContent(true) .SetEntersTextControls(true) .Build()); } 复制代码
非textContronl
当选区可视节点的起始位置不属于textControl类型的话,就直接调用了WriteSelectionToClipboard方法。
void ClipboardCommands::WriteSelectionToClipboard(LocalFrame& frame) { const KURL& url = frame.GetDocument()->Url(); const String html = frame.Selection().SelectedHTMLForClipboard(); String plain_text = frame.SelectedTextForClipboard(); frame.GetSystemClipboard()->WriteHTML(html, url, GetSmartReplaceOption(frame)); ReplaceNBSPWithSpace(plain_text); frame.GetSystemClipboard()->WritePlainText(plain_text, GetSmartReplaceOption(frame)); frame.GetSystemClipboard()->CommitWrite(); } 复制代码
跟textControl
明显的区别在于,非textControl在既WritePlainText
写入纯文本的同时,也用WriteHTML
写入了html内容。 这里开始反映出了clipboardjs和copy-to-clipboad的默认行为在本质上的区别了。
3. WritePlainText
// third_party\blink\renderer\core\clipboard\system_clipboard.cc void SystemClipboard::WritePlainText(const String& plain_text, SmartReplaceOption) { // TODO(https://crbug.com/106449): add support for smart replace, which is // currently under-specified. String text = plain_text; #if defined(OS_WIN) ReplaceNewlinesWithWindowsStyleNewlines(text); #endif clipboard_->WriteText(NonNullString(text)); } // third_party\blink\renderer\core\clipboard\clipboard_utilities_win.cc void ReplaceNewlinesWithWindowsStyleNewlines(String& str) { DEFINE_STATIC_LOCAL(String, windows_newline, ("\r\n")); StringBuilder result; for (unsigned index = 0; index < str.length(); ++index) { if (str[index] != '\n' || (index > 0 && str[index - 1] == '\r')) result.Append(str[index]); else result.Append(windows_newline); } str = result.ToString(); } 复制代码
到了这里,换行符的处理就破案了,浏览器本身会判断所处的系统来决定\r\n的替换。
总结
execCommand('copy')
的流程可以总结为:
判断
selection
的开始节点为textControl
类型(textarea
和部分input
组件)复制获取selection
的纯文本。像图片获取alt,不可选择区域忽略不计。判断
selection
的开始节点不为textControl
类型,同样是用SelectedTextForClipboard
获取selection
的文本,同时写入html样式的内容,此时剪贴板的一份数据下,有两种不一样类型的解释。
剪贴板对象存在不同的格式内容,而粘贴信息的窗口对于各种格式有不同的优先级获取,像是复制一段网页的文本,在富文本编辑器就会优先获取html内容显示出来,而在聊天窗口却是显示的文本。docs.microsoft.com/en-us/windo…
这时再回到前面关于executeCopy
的判断分支可以发现clipboardjs
和copy-to-clipboad
这两个库是不同逻辑的。
clipboardjs
的textarea
是属于textControl
,直接就复制selection
的文本内容。copy-to-clipboard
是以span
为载体,当前的剪贴板对象存在两种解释-text(CF_TEXT)
和html(CF_HTML)
。
在粘贴的窗口优先获取CF_TEXT
的情况下,两个库粘贴的结果是一致的。
而在window微信客户端复制粘贴产生问题,则可能是chrome写入html内容所设置的剪切板格式,相比于文本,优先被其匹配。
找了段查看剪切板内容的代码看看CF_HTML内容,clipboardjs
复制操作的剪切板html内容是空的,而copy-to-clipboard
的html有内容。
python引入的win32好像不支持直接读取这个格式,但是win32官方标准格式却有定义CF_HTML。
有了上面的经验,做个小实验,往剪贴板同时写入CF_TEXT和CF_HTML,然后在window微信客户端粘贴。得到的实验结果是:window微信客户端会读取CF_HTML的内容,而其他一开始跟它比较的工具如钉钉等,则是读取文本。
因此开头提到的换行问题的原因在于:
windows客户端在读取剪切板的时候优先获取CF_HTML的内容,而HTML内容的换行符是用\n,直接导致了换行失效。而前面提到的textContent换成innerText就没问题,是因为innerText获取的是渲染后的结果,其剪切板上html的换行符是<br>
番外
navigator.clipboard.writeText
execCommand来实现复制是比较传统的方法了,同时也在废弃中的了,新版的剪贴板操作依赖于暴露在全局对象上的navigator.clipboard,而writeText方法可以实现自定义输入想要复制的文本内容
setTimeout(()=>{ navigator.clipboard.writeText('第一行\n第二行') }, 2000) // 需要focus在html文档上才可以执行 复制代码
而这个方式写入剪贴板也跟execComand差不多,不会存在换行符的兼容性问题。
可以看到新版api多了PermissionStatus
的权限判断,会向用户弹起授权窗口。
也有异步操作避免在复制内容过多的情况下,线程阻塞导致页面卡住的问题。
// third_party\blink\renderer\modules\clipboard\clipboard_promise.cc void ClipboardPromise::HandleWriteTextWithPermission(PermissionStatus status) { DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); if (!GetExecutionContext()) return; if (status != PermissionStatus::GRANTED) { script_promise_resolver_->Reject(MakeGarbageCollected<DOMException>( DOMExceptionCode::kNotAllowedError, "Write permission denied.")); return; } SystemClipboard* system_clipboard = GetLocalFrame()->GetSystemClipboard(); system_clipboard->WritePlainText(plain_text_); system_clipboard->CommitWrite(); script_promise_resolver_->Resolve(); } 复制代码
另外chrome提供的write也可以支持二进制内容的复制。
作者:土豆喵酱
链接:https://juejin.cn/post/7022698848145375239