Today I’ll deal with another demand from Brendan (Yes he asks lots of stuff. You don’t wannna know… but also, he is not shy. If you also have ideas for this blog, please tell me about them, don’t be shy!). He would like to highlight text in the document preview. The highlighted text should be the keywords from the search. So what I’m going to do is inject some JavaScript in the preview frame using BlobPostProcessor like I did for the waiter. This is gonna be the easy part. The less easy part is to find the search keywords from the preview frame.

Highlight search keywords in the previewHighlight search keywords in the preview

So about that search box, I’ve found a very nice JQuery script from Johann Burkard that does most of the work. It highlights the given String in a DOM element. I’ve modified it slightly so that each time it adds a span with the highlighted class, it stacks it in the anchors array. This way I’ll be able to go through all the highlighted terms back and forth. I’ve added the backwardScroll and forwardScroll method for that matter. They do a nice, sleek scroll through the different anchors. I use the idx variables to keep track of where we are. Those methods also highlights the selected anchor in a different color to give visual feedback to the user. The floating box containing the search, scroll forward and backward button is also created by the script with the createFloatingBox method.


highlight v3

Highlights arbitrary terms.


MIT license.

Johann Burkard <> <mailto:[email protected]>

Laurent Doguin <> <mailto:[email protected]> */ var anchors; var idx; jQuery.fn.highlight = function(pat) { function innerHighlight(node, pat) { var skip = 0; if (node.nodeType == 3) { var pos =; if (pos >= 0) { var spannode = document.createElement('span'); anchors.push(spannode); spannode.className = 'highlight'; var middlebit = node.splitText(pos); var endbit = middlebit.splitText(pat.length); var middleclone = middlebit.cloneNode(true); spannode.appendChild(middleclone); middlebit.parentNode.replaceChild(spannode, middlebit); skip = 1; } } else if (node.nodeType == 1 && node.childNodes && !/(script|style)/i.test(node.tagName)) { for (var i = 0; i < node.childNodes.length; ++i) { i += innerHighlight(node.childNodes[i], pat); } } return skip; } return this.each(function() { innerHighlight(this, pat.toUpperCase()); }); };

jQuery.fn.removeHighlight = function() { anchors = new Array(); idx = -1; return this.find("span.highlight").each(function() { this.parentNode.firstChild.nodeName; with (this.parentNode) { replaceChild(this.firstChild, this); normalize(); } }).end(); };

jQuery.fn.backwardScroll = function(){ if (anchors.length == 0) return; if (idx < 0) { idx = anchors.length - 1; } else { idx = idx - 1; if (idx < 0) { idx = anchors.length - 1; } } $().scrollToAnchor(idx); } jQuery.fn.forwardScroll = function(){ if (anchors.length == 0) return; if (idx < 0) { idx = -1; } idx = idx + 1; if (idx == anchors.length) { idx = 0;

} $().scrollToAnchor(idx); }

jQuery.fn.scrollToAnchor = function(idx){ selectedKeyword = $('.highlight-selected'); if (selectedKeyword) selectedKeyword.removeClass('highlight-selected'); $(anchors[idx]).addClass('highlight-selected'); $('html,body').animate({scrollTop: $(anchors[idx]).offset().top},'fast'); }

function show() { $('#search-box').show(); $('#showBoxButton').hide(); };

function hide() { $('#search-box').hide(); $('#showBoxButton').show(); };

function search() { keyword = $('#query').val(); if (keyword) { $('body').removeHighlight().highlight(keyword); } };

function createFloatingBox(contextPath, searchKeyword) { var $floatingBox = $("<div>").attr("id", "floating-box").attr("style","position: fixed;"); var $showBoxButton = $("<div>").attr("id", "showBoxButton").attr("style","display: none;").appendTo($floatingBox); var $showBoxButtonInput = $("<input>").attr("src", contextPath + "/icons/search.png").attr("type", "image").click(show).appendTo($showBoxButton); var $searchBox = $("<div>").attr("id", "search-box").attr("style","display: block;").appendTo($floatingBox); var $queryInput = $("<input>").attr("value", searchKeyword).attr("id", "query").attr("type", "text").appendTo($searchBox); var $showBoxButtonInput = $("<input>").attr("value", "Search").attr("type", "button").click(search).appendTo($searchBox); var $showBoxButtonInput = $("<input>").attr("src", contextPath + "/icons/action_page_previous.gif").attr("type", "image").click( $().backwardScroll).appendTo($searchBox); var $showBoxButtonInput = $("<input>").attr("src", contextPath + "/icons/action_page_next.gif").attr("type", "image").click($().forwardScroll).appendTo($searchBox); var $showBoxButtonInput = $("<input>").attr("src", contextPath + "/icons/action_delete_mini.png").attr("type", "image").click(hide).appendTo($searchBox); $floatingBox.appendTo($("body")); };

function initSearchBox(contextPath) { var searchKeyword; keywordSpan = parent.document.getElementById('searchKeyWords'); if (keywordSpan) searchKeyword = keywordSpan.getAttribute('key'); createFloatingBox(contextPath, searchKeyword); keyword = $('#query').attr('value'); if (keyword) { $('body').removeHighlight().highlight(keyword); $('#search-box').show(); $('#showBoxButton').hide(); } else { $('#search-box').hide(); $('#showBoxButton').show(); } }; 

Now what we have to do is inject the necessary JavaScript using the following BlobPostProcessor. It injects the above script, some CSS and the call to the initSearchBox JavaScript function to actually add the search box. We give VirtualHostHelper.getContextPathProperty() as parameter to the script so that we can display icons from Nuxeo in the search box.

/* * (C) Copyright 2006-2012 Nuxeo SAS ( and contributors. * * All rights reserved. This program and the accompanying materials * are made available under the terms of the GNU Lesser General Public License * (LGPL) version 2.1 which accompanies this distribution, and is available at * * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * Contributors: * ldoguin */ package org.nuxeo.ecm.platform.annotations.preview;

import; import; import; import java.util.regex.Matcher; import java.util.regex.Pattern;

import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.nuxeo.ecm.core.api.Blob; import org.nuxeo.ecm.core.api.impl.blob.ByteArrayBlob; import org.nuxeo.ecm.platform.preview.adapter.BlobPostProcessor; import org.nuxeo.ecm.platform.web.common.vh.VirtualHostHelper;

/** * @author <a href="mailto:[email protected]">Laurent Doguin</a> * */ public class SearchKeywordBlobPostProcessor implements BlobPostProcessor {

private static final Log log = LogFactory.getLog(SearchKeywordBlobPostProcessor.class);

protected static final int BUFFER_SIZE = 4096 * 16;

protected static final String SEARCH_KEYWORDS_CSS = "<link href='" + VirtualHostHelper.getContextPathProperty() + "/css/SearchKeywords.css' rel="stylesheet" type="text/css" />";

protected static final String SEARCH_KEYWORDS_JS = "<script type="text/javascript" src='" + VirtualHostHelper.getContextPathProperty() + "/scripts/SearchKeywords.js'></script>";

protected static final String JQUERY_JS = "<script type="text/javascript" src='" + "'></script>";

protected static final String INIT_SEARCHBOX_JS = "<script type="text/javascript" >initSearchBox('" + VirtualHostHelper.getContextPathProperty() + "');</script>";

protected Pattern headPattern = Pattern.compile("(.*)(<head>)(.*)", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);

protected Pattern htmlPattern = Pattern.compile("(.*)(<html>)(.*)", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);

protected Pattern bodyPattern = Pattern.compile( "(.*)(<body(?:"[^"]*"['"]*|'[^']*'['"]*|[^'">])+>)(.*)", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);

protected Pattern endBodyPattern = Pattern.compile("(.*)(</body>)(.*)", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);

protected Pattern charsetPattern = Pattern.compile( "(.*) charset=(.*?)"(.*)", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);

@Override public Blob process(Blob blob) { String mimetype = blob.getMimeType(); if (mimetype == null || !mimetype.startsWith("text/")) { // blob does not carry HTML payload hence there is no need to try to // inject HTML metadata return blob; } try { String encoding = null; if (blob.getEncoding() == null) { Matcher m = charsetPattern.matcher(blob.getString()); if (m.matches()) { encoding =; } } else { encoding = blob.getEncoding(); }

String blobAsString = getBlobAsString(blob, encoding); String processedBlob = addSearchKeyWordScript(blobAsString); processedBlob = addInitSearchBoxJS(processedBlob);

byte[] bytes = encoding == null ? processedBlob.getBytes() : processedBlob.getBytes(encoding); blob = new ByteArrayBlob(bytes, blob.getMimeType(), encoding); } catch (IOException e) { log.debug("Unable to process Blob", e); } return blob; }

protected String getBlobAsString(Blob blob, String encoding) throws IOException { if (encoding == null) { return blob.getString(); } Reader reader = new InputStreamReader(blob.getStream(), encoding); return readString(reader); }

protected String addSearchKeyWordScript(String blob) { StringBuilder sb = new StringBuilder(); Matcher m = headPattern.matcher(blob); if (m.matches()) { sb.append(; sb.append(; sb.append(SEARCH_KEYWORDS_CSS); sb.append(JQUERY_JS); sb.append(SEARCH_KEYWORDS_JS); sb.append(; } else { log.debug("Unable to inject Annotation module javascript"); sb.append(blob); } return sb.toString(); }

protected String addInitSearchBoxJS(String blob) { StringBuilder sb = new StringBuilder(); Matcher m = endBodyPattern.matcher(blob); if (m.matches()) { sb.append(; sb.append(INIT_SEARCHBOX_JS); sb.append(; sb.append(; } else { m = bodyPattern.matcher(blob); if (m.matches()) { sb.append(; sb.append(; sb.append(INIT_SEARCHBOX_JS); sb.append(; } else { log.debug("Unable to inject Annotation module javascript"); sb.append(blob); } } return sb.toString(); }

public static String readString(Reader reader) throws IOException { StringBuilder sb = new StringBuilder(BUFFER_SIZE); try { char[] buffer = new char[BUFFER_SIZE]; int read; while ((read =, 0, BUFFER_SIZE)) != -1) { sb.append(buffer, 0, read); } } finally { if (reader != null) { reader.close(); } } return sb.toString(); }


To add a BlobPostProcessor, you must as usual contribute it through the appropriate extension point:

<extension target="org.nuxeo.ecm.platform.preview.adapter.PreviewAdapterManagerComponent" point="blobPostProcessor"> <blobPostProcessor class="org.nuxeo.ecm.platform.annotations.preview.SearchKeywordBlobPostProcessor" /> </extension> 

And now we can display a search box in any postProcessed preview. Next step is to retrieve the search keywords if they exist.

Use search keywords as default if exists

You might have in seen the following lines in the script:

var searchKeyword; keywordSpan = parent.document.getElementById('searchKeyWords'); if (keywordSpan) searchKeyword = keywordSpan.getAttribute('key'); 

They are used to retrieve the content of a span with searchKeyWords id. This span contains the search keyWords. We cannot directly ask for #{documentSearchActions.simpleSearchKeywords} since don’t have any Seam Context here. It’s the same in the parent Frame. So we’ll have to use JavaScript again to get the keyword from the parent window field of the preview fancybox (which is itself the parent of the preview frame containing our searchBox).

So we have to modify the preview.xhtml file to add the searchKeyWords span and to fill it with the simpleSearchKeywords founded in it’s parent window:

keyWord = window.parent.document.getElementById('userServicesSearchForm:faceted_search_suggest_box'); if (keyWord) jQuery('#searchKeyWords').attr('key',keyWord.value); 

So that was a little tricky but now we’re able to find the search keywords from the preview. We fill the search fill using the keywords if they aren’t null.

The sources are available on GitHub as usual.

That’s it for today, see you Friday :-)