问题背景
当在浏览器上复制的时候会发生什么?
web开发会为了方便用户,帮他们推广分享,自动写入广告推广语之类的剪贴板内容的需要。在github比较容易找到的库:
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也可以支持二进制内容的复制。