soul-browser/sources/java/com/mycompany/app/view/FindWholeWordHelper.java
KaKi87 b6d8280e2d
Add whole-word toggle to Find in page (#100)
* Add a whole-word toggle to Find in page.

Match-whole-word uses window.find so substring hits inside larger words are skipped; the Ab icon dims when off.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix Find in page crash from MainApp.E1 type mismatch.

The recompiled find bar loaded E1 as float; it is an int, so promote with int-to-float in dispatchDraw.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix Find in page crash from MainUtil.c8/X4 signatures.

Those helpers take View, not EditText; the recompiled find bar used the wrong descriptor.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Tighten Find bar layout and keep whole-word highlights.

Place the whole-word toggle between clear and the match count, use tighter equal action-icon spacing, and highlight every whole-word hit with mark tags instead of only the current selection.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix whole-word find returning 0/0 after the first mark.

Stop reassigning the text node to the right remnant when wrapping matches end-to-start, which broke later splitText offsets.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add a case-sensitive toggle left of whole-word in Find.

Native findAllAsync has no match-case option, so case-sensitive (alone or with whole-word) uses the same mark-based highlighter.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 18:44:54 +02:00

156 lines
7 KiB
Java

package com.mycompany.app.view;
import android.text.TextUtils;
import android.webkit.ValueCallback;
import android.webkit.WebView;
import org.json.JSONObject;
/**
* Custom find highlighting via injected {@code <mark>} tags.
* Used when native {@code findAllAsync} cannot express whole-word and/or
* case-sensitive matching.
*/
public final class FindWholeWordHelper {
private static final String CLEAR_JS =
"(function(){try{"
+ "var marks=document.querySelectorAll('mark[data-soul-ww]');"
+ "for(var i=0;i<marks.length;i++){var m=marks[i],p=m.parentNode;if(!p)continue;"
+ "while(m.firstChild)p.insertBefore(m.firstChild,m);p.removeChild(m);p.normalize();}"
+ "window.__soulWwMarks=null;window.__soulWwIndex=-1;"
+ "var s=window.getSelection();if(s)s.removeAllRanges();"
+ "}catch(e){}})();";
private static final String SEARCH_JS_PREFIX =
"(function(q,ww,cs){try{"
+ "function clear(){var marks=document.querySelectorAll('mark[data-soul-ww]');"
+ "for(var i=0;i<marks.length;i++){var m=marks[i],p=m.parentNode;if(!p)continue;"
+ "while(m.firstChild)p.insertBefore(m.firstChild,m);p.removeChild(m);p.normalize();}"
+ "window.__soulWwMarks=null;window.__soulWwIndex=-1;}"
+ "clear();"
+ "if(!q)return 0;"
+ "var esc=q.replace(/[.*+?^${}()|[\\]\\\\]/g,'\\\\$&');"
+ "var re=new RegExp(esc,cs?'g':'gi');"
+ "function isWord(ch){return !!ch && /[A-Za-z0-9_\\u00C0-\\u024F]/.test(ch);}"
+ "var root=document.body;if(!root)return 0;"
+ "var tw=document.createTreeWalker(root,NodeFilter.SHOW_TEXT,{"
+ "acceptNode:function(n){if(!n.nodeValue||!String(n.nodeValue).trim())return NodeFilter.FILTER_REJECT;"
+ "var el=n.parentElement;if(!el)return NodeFilter.FILTER_REJECT;"
+ "var tag=el.closest('script,style,noscript,textarea,input,mark[data-soul-ww]');"
+ "return tag?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT;}});"
+ "var nodes=[];while(tw.nextNode())nodes.push(tw.currentNode);"
+ "var out=[];"
+ "for(var ni=0;ni<nodes.length;ni++){"
+ "var node=nodes[ni],text=node.nodeValue,matches=[],m;re.lastIndex=0;"
+ "while((m=re.exec(text))){var s=m.index,e=s+m[0].length;"
+ "if(ww){var b=s>0?text.charAt(s-1):'';var a=e<text.length?text.charAt(e):'';"
+ "if(isWord(b)||isWord(a))continue;}"
+ "matches.push([s,e]);}"
+ "if(!matches.length)continue;"
+ "for(var mi=matches.length-1;mi>=0;mi--){"
+ "var start=matches[mi][0],end=matches[mi][1];"
+ "node.splitText(end);var mid=node.splitText(start);"
+ "var mark=document.createElement('mark');mark.setAttribute('data-soul-ww','1');"
+ "mark.style.backgroundColor='#f1e189';mark.style.color='inherit';"
+ "mid.parentNode.insertBefore(mark,mid);mark.appendChild(mid);"
+ "out.unshift(mark);window.__soulWwMarks=out;}"
+ "}"
+ "window.__soulWwMarks=out;window.__soulWwIndex=out.length?0:-1;"
+ "if(out.length){out[0].style.backgroundColor='#ff9632';"
+ "try{out[0].scrollIntoView({block:'center',inline:'nearest'});}catch(e2){out[0].scrollIntoView(true);}}"
+ "return out.length;"
+ "}catch(e){try{return (window.__soulWwMarks&&window.__soulWwMarks.length)||0;}catch(e2){return 0;}}})(";
private static final String NEXT_JS_PREFIX =
"(function(fwd){try{"
+ "var marks=window.__soulWwMarks;if(!marks||!marks.length)return 0;"
+ "var i=window.__soulWwIndex|0;if(i<0||i>=marks.length)i=0;"
+ "marks[i].style.backgroundColor='#f1e189';"
+ "i=fwd?(i+1)%marks.length:(i-1+marks.length)%marks.length;"
+ "window.__soulWwIndex=i;"
+ "marks[i].style.backgroundColor='#ff9632';"
+ "try{marks[i].scrollIntoView({block:'center',inline:'nearest'});}catch(e){marks[i].scrollIntoView(true);}"
+ "return 1;"
+ "}catch(e){return 0;}})(";
private FindWholeWordHelper() {
}
public static void clear(WebView webView) {
if (webView == null) {
return;
}
webView.evaluateJavascript(CLEAR_JS, null);
}
public static void search(
WebView webView,
String query,
boolean wholeWord,
boolean caseSensitive,
final ValueCallback<Integer> callback) {
if (webView == null) {
if (callback != null) {
callback.onReceiveValue(0);
}
return;
}
if (TextUtils.isEmpty(query)) {
clear(webView);
if (callback != null) {
callback.onReceiveValue(0);
}
return;
}
String js = SEARCH_JS_PREFIX
+ JSONObject.quote(query)
+ ","
+ (wholeWord ? "true" : "false")
+ ","
+ (caseSensitive ? "true" : "false")
+ ")";
webView.evaluateJavascript(js, new ValueCallback<String>() {
@Override
public void onReceiveValue(String value) {
if (callback == null) {
return;
}
callback.onReceiveValue(Integer.valueOf(parseCount(value)));
}
});
}
public static void findNext(WebView webView, boolean forward, final ValueCallback<Boolean> callback) {
if (webView == null) {
if (callback != null) {
callback.onReceiveValue(Boolean.FALSE);
}
return;
}
String js = NEXT_JS_PREFIX + (forward ? "true" : "false") + ")";
webView.evaluateJavascript(js, new ValueCallback<String>() {
@Override
public void onReceiveValue(String value) {
if (callback == null) {
return;
}
callback.onReceiveValue(Boolean.valueOf(parseCount(value) > 0));
}
});
}
static int parseCount(String value) {
if (value == null || value.length() == 0 || "null".equals(value)) {
return 0;
}
String t = value.trim();
if (t.length() >= 2 && t.charAt(0) == '"' && t.charAt(t.length() - 1) == '"') {
t = t.substring(1, t.length() - 1);
}
try {
return Integer.parseInt(t);
} catch (Exception unused) {
return 0;
}
}
}