一、Web端实现
最近遇到了一个问题,老大要求实现对WebView加载的html页面实现选中修改背景色以及添加下划线并可以删除,最后可以保存这些修改,下次进入该html界面时仍然能够显示之前添加的背景色以及下划线。
1.首先需要解决的问题是如何实现对选中的html页面区域添加背景色以及添加下划线。
因为考虑到要保存的问题,这部分实现应该在前端实现,通过查找资料,一开始想到的是使用surroundContents来实现。如下代码:
function setColor(){
var tr = window.getSelection().getRangeAt(0);
var span = document.createElement("span");
span.style.cssText = "color:#ff0000";
tr.surroundContents(span);
}
上述代码确实能够实现给选中区域添加背景色,但它有一个不足就是无法实现跨标签实现选中添加背景色。即选中单个<div></div>中内容时可以实现效果,但是如果连续选中两个div时,这种方法就不起作用了。并且通过console控制台发现如下报错:
所以这个方案pass掉。
很快,又找到了另一套方案,使用range.insertNode实现,代码如下:
function setColor() {
var range = window.getSelection().getRangeAt(0),
span = document.createElement('span');
span.className = 'highlight';
span.style.cssText = "color:#ff0000";
span.appendChild(range.extractContents());
range.insertNode(span);
}
但是上述代码也确实能够实现给选中区域添加背景色,并且不存在跨标签的问题,但是它也有一个问题:就是会破坏原有的标签结构。举个栗子:
比如原本的标签结构是:
原本的界面如下:
执行第二种方案的js方法后,如果是跨标签选中,标签结构改变为:
界面改变为:
通过对比发现第二套方案的问题是其改变了原本的标签结构,导致界面变样。
终于找到了第三种实现方案,使用mark.js实现,这个库真的很厉害,实现这个库实现选中区域添加背景色以及添加下划线,可以避免上述两种方案的不足。
代码如下:
<script type="text/javascript">
function callInterface(spanId){
JSCallBackInterface.callback(spanId);
}
function removeSpan(spanId) {
$(spanId).unmark();
}
function setColor() {
highlight("", "red", 1);
}
function setUnderLine(){
highlight("", "red", 2);
}
var selectionRange;
function highlight(highlightID, color, type) {
console.log("highlight:" + color);
if (window.getSelection && window.getSelection().toString()) {
var node = getSelectionParentElement();
if (node != null) {
var text = getSelectionText();
console.log("Selected text: " + text);
if (type === 1) {
markFunc(node, text, /*HIGHLIGHT_CLASS + " " + */color);
} else {
markFuncUnderLine(node,text);
}
} else {
console.log("Parent nde is null for some reason");
}
} else {
console.log("tapped without selection");
}
}
function getSelectionText() {
if (window.getSelection) {
var sel = window.getSelection();
return sel.toString();
}
};
function getSelectionParentElement() {
var parentEl = null,
sel;
if (window.getSelection) {
sel = window.getSelection();
if (sel.rangeCount) {
selectionRange = sel.getRangeAt(0);
parentEl = selectionRange.commonAncestorContainer;
if (parentEl.nodeType != 1) {
parentEl = parentEl.parentNode;
}
}
} else if ((sel = document.selection) && sel.type != "Control") {
parentEl = sel.createRange().parentElement();
}
return parentEl;
};
function markFunc(node, text, color) {
var instance = new Mark(node);
instance.mark(text, {
"element": "span",
"className": color,
"acrossElements": true,
"separateWordSearch": false,
"accuracy": "partially",
"diacritics": true,
"ignoreJoiners": true,
"each": function(element) {
element.setAttribute("id", "span_" + (new Date().getTime()));
element.setAttribute("title", "sohayb_title");
element.setAttribute("onclick", "callInterface($(this).attr('id'))");
element.setAttribute("onmouseover", "callInterface($(this).attr('id'))");
},
"done":function(totalMarks) {
window.getSelection().empty();
console.log("total marks: " + totalMarks);
},
"filter": function(node, term, totalCounter, counter) {
var res = false;
if (counter == 0) {
res = selectionRange.isPointInRange(node, selectionRange.startOffset);
} else {
res = selectionRange.isPointInRange(node, 1);
}
console.log("Counter: " + counter + ", startOffset: " + selectionRange.startOffset);
return res;
}
});
};
function markFuncUnderLine(node, text) {
var instance = new Mark(node);
instance.mark(text, {
"element": "span",
"acrossElements": true,
"separateWordSearch": false,
"accuracy": "partially",
"diacritics": true,
"ignoreJoiners": true,
"each": function(element) {
element.setAttribute("id", "span_" + (new Date().getTime()));
element.setAttribute("title", "sohayb_title");
element.setAttribute("style","text-decoration:underline blue");
element.setAttribute("onclick", "callInterface($(this).attr('id'))");
element.setAttribute("onmouseover", "callInterface($(this).attr('id'))");
},
"done":function(totalMarks) {
window.getSelection().empty();
console.log("total marks: " + totalMarks);
},
"filter": function(node, term, totalCounter, counter) {
var res = false;
if (counter == 0) {
res = selectionRange.isPointInRange(node, selectionRange.startOffset);
} else {
res = selectionRange.isPointInRange(node, 1);
}
console.log("Counter: " + counter + ", startOffset: " + selectionRange.startOffset);
return res;
}
});
};
</script>
下面讲述一下上述js方法的作用:
function callInterface(spanId){
JSCallBackInterface.callback(spanId);
}
callInterface的作用是将需要删除的高亮,或者下划线的id告知客户端。该方法在用户点击添加了高亮或者下划线的<span></span>块时调用,代码如下:
element.setAttribute("onclick", "callInterface($(this).attr('id'))");
element.setAttribute("onmouseover", "callInterface($(this).attr('id'))");
客户端拿到spanId后可调用以下方法删除高亮后者下划线:
function removeSpan(spanId) {
$(spanId).unmark();
}
实现高亮和下划线的方法是:setColor和setUnderLine方法,它们都调用了highlight方法,参数type为1表示调用mark.js实现高亮,参数type为2表示调用make.js实现添加下划线。
function setColor() {
highlight("", "red", 1);
}
function setUnderLine(){
highlight("", "red", 2);
}
实现高亮和下划线的思路是:
获取选中内容,以及内容的首个父元素节点,交给mark.js处理。高亮通过给mark方法中className属性设置颜色指定,下划线则通过给元素添加element.setAttribute("style","text-decoration:underline blue")属性指定。
简单介绍一下mark.js:
mark.js是用JavaScript编写的实现文本高亮的方案。 它可用于动态标记搜索词或自定义正则表达式,并为您提供内置选项,如变音符号支持,单独的单词搜索,自定义同义词,iframes支持,自定义过滤器,精度定义,自定义元素,自定义类名等。
一些基本API的介绍:
mark()方法:显示高亮的方法。
例子:
JavaScript:
var context = document.querySelector(".context");
var instance = new Mark(context);
instance.mark(keyword [, options]);
注意:即使这是一个链式方法,因此允许您在返回对象上调用更多方法,建议始终使用完成回调,因为mark.js可以异步工作。
参数 keywords:字符串或字符串数组。 要标记的关键字。 也可以是包含多个关键字的数组。 请注意,关键字将被转义。 如果您需要标记未转义的关键字(例如包含模式),请查看下面的markRegExp()方法。
options 类型:对象
可选选项:
element 用于包装匹配的HTML元素,例如 span
className 将附加到元素的类名
separateWordSearch 是否搜索由空格分隔的每个单词而不是完整的单词
acrossElements 是否跨元素搜索匹配项
diacritics 如果应该匹配变音字符。 例如“piękny”也匹配“piekny”,“doner”也匹配“döner”
ignoreJoiners 是否还找到包含软连字符,零宽度空间,零宽度非连接器和零宽度连接器的匹配项。 它们用于指示换行点,其中没有足够的空间来显示完整的单词
each 每个标记元素的回调。 接收标记的DOM元素作为参数
done 完成所有标记后的回调函数。 接收标记总数作为参数
filter function一个回调来过滤或限制匹配。
将为每个匹配调用它并接收以下参数:
包含匹配的文本节点,
已找到的匹配数,
总的标记数,
对匹配的标记数,
如果标记应该停止则该函数必须返回false,否则为true。
二、android客户端实现
客户端的要求是长按选中WebView中的文字可以选择高亮显示、或者选择添加下划线,对于已经添加了高亮或者下划线的文字,可以删除高亮、或者下划线。
1.要实现长按,弹出可供用户选择的弹框,并且弹框中要保留系统原有的功能(复制、分析、全选、网页搜索)。
所以我采用ActionMode实现,通过自定义WebView,重写其startActionMode方法,来实现想要的效果。
代码如下:
@Override
public ActionMode startActionMode(ActionMode.Callback callback) {
ActionMode actionMode = super.startActionMode(callback);
return resolveActionMode(actionMode);
}
@Override
public ActionMode startActionMode(ActionMode.Callback callback, int type) {
ActionMode actionMode = super.startActionMode(callback, type);
return resolveActionMode(actionMode);
}
/**
* 处理item,处理点击
* @param actionMode
*/
private ActionMode resolveActionMode(ActionMode actionMode) {
if (actionMode != null) {
final Menu menu = actionMode.getMenu();
mActionMode = actionMode;
// menu.clear();
for (int i = 0; i < mActionList.size(); i++) {
menu.add(mActionList.get(i));
}
for (int i = 0; i < menu.size(); i++) {
MenuItem menuItem = menu.getItem(i);
menuItem.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
@Override
public boolean onMenuItemClick(MenuItem item) {
getSelectedData((String) item.getTitle());
releaseAction();
return true;
}
});
}
}
mActionMode = actionMode;
return actionMode;
}
上述代码中的集合mActionList 用于存储自定义的按钮,比如(添加高亮、添加下划线、删除)。
然后要对每个自定义的menu添加点击事件,如上述代码中的menuItem.setOnMenuItemClickListener中重写onMenuItemClick方法,具体代码如下:
private void getSelectedData(String title) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
if (getResources().getString(R.string.add_highlight).equals(title)) {//高亮
evaluateJavascript("javascript:setColor()", null);
} else if (getResources().getString(R.string.add_underline).equals(title)) {//添加下划线
evaluateJavascript("javascript:setUnderLine()", null);
} else if (getResources().getString(R.string.remove_span).equals(title)) {//删除高亮或者下划线
evaluateJavascript("javascript:removeSpan("+ mRemoveSpanId +")", null);
mRemoveSpanId = null;
}
} else {
if (getResources().getString(R.string.add_highlight).equals(title)) {//高亮
loadUrl("javascript:setColor()");
} else if (getResources().getString(R.string.add_underline).equals(title)) {//添加下划线
loadUrl("javascript:setUnderLine()");
} else if (getResources().getString(R.string.remove_span).equals(title)) {//删除高亮或者下划线
loadUrl("javascript:removeSpan("+ mRemoveSpanId +")");
mRemoveSpanId = null;
}
}
}
在实现高亮以及添加下划线、删除的功能都是调用js实现,注意在删除的时候需要上传参数mRemoveSpanId,来告知要删除哪一个高亮或者下划线。
客户端获取mRemoveSpanId的方式是通过Js调用android代码,客户端记录mRemoveSpanId,代码如下:
private class JsCallAndroidInterface {
@JavascriptInterface
public void callback(String spanId) {
mRemoveSpanId = spanId;
}
@JavascriptInterface
public void showSource(String html) {
mContent = html;
}
}
public void removeSpanJSInterface() {
addJavascriptInterface(new JsCallAndroidInterface(), "JSCallBackInterface");
}
下面是使用WebView的基本设置:
private void configWebView() {
WebSettings webSettings = mWebView.getSettings();
webSettings.setAllowContentAccess(true);
webSettings.setAllowFileAccess(true);
webSettings.setAllowFileAccessFromFileURLs(true);
webSettings.setAllowUniversalAccessFromFileURLs(true);
webSettings.setAppCacheEnabled(true);
webSettings.setCacheMode(WebSettings.LOAD_DEFAULT);
webSettings.setDatabaseEnabled(true);
webSettings.setDomStorageEnabled(true);
webSettings.setRenderPriority(WebSettings.RenderPriority.HIGH);
webSettings.setSupportZoom(false);
webSettings.setBuiltInZoomControls(false);
webSettings.setDisplayZoomControls(false);
webSettings.setPluginState(WebSettings.PluginState.ON); //player net video
webSettings.setLoadsImagesAutomatically(true);
webSettings.setLoadWithOverviewMode(true);
webSettings.setTextZoom(100);
webSettings.setUseWideViewPort(true);
webSettings.setJavaScriptEnabled(true);
webSettings.setDomStorageEnabled(true);
webSettings.setJavaScriptCanOpenWindowsAutomatically(true);
webSettings.setGeolocationEnabled(true);
webSettings.setSupportMultipleWindows(false);
webSettings.setSaveFormData(true);
mWebView.setActionList(mItems);//添加自定义Item
mWebView.removeSpanJSInterface();//添加javascriptInterface
}
/**
* 添加javascriptInterface
* 第一个参数:这里需要一个与js映射的java对象
* 第二个参数:该java对象被映射为js对象后在js里面的对象名,在js中要调用该对象的方法就是通过这个来调用
*/
public void removeSpanJSInterface() {
addJavascriptInterface(new JsCallAndroidInterface(), "JSCallBackInterface");
}
最后千万别忘了添加网络权限: <uses-permission android:name="android.permission.INTERNET" />
还有一些注意点:
1.比如:webview 网络页面从本地(assets)加载js库的方式:
客户端:
String local = "file:///android_asset";
mWebView.loadDataWithBaseURL(local, content, "text/html", "UTF-8", null);
Web端:
<link rel="stylesheet" href="file:///android_asset/zEditor.css">
<script type="text/javascript" src="file:///android_asset/jquery.min.js"></script>
<script type="text/javascript" src="file:///android_asset/jquery.mark.js"></script>
2.注意WebView的内存泄漏问题
@Override
protected void onDestroy() {
if (mWebView != null) {
mWebView.loadDataWithBaseURL(null, "", "text/html", "utf-8", null);
((ViewGroup) mWebView.getParent()).removeView(mWebView);
mWebView.stopLoading();
mWebView.getSettings().setJavaScriptEnabled(false);
mWebView.clearHistory();
mWebView.clearView();
mWebView.removeAllViews();
mWebView = null;
}
super.onDestroy();
}