diff --git a/web/viewer.css b/web/viewer.css index 0f615094a..caf3a09f1 100644 --- a/web/viewer.css +++ b/web/viewer.css @@ -245,7 +245,7 @@ html[dir='rtl'] #sidebarContent { 0 1px 1px hsla(0,0%,0%,.1); } -#toolbarViewer { +#toolbarViewer, .findbar { position: relative; height: 32px; background-image: url(images/texture.png), @@ -265,6 +265,32 @@ html[dir='rtl'] #sidebarContent { 0 1px 0 hsla(0,0%,0%,.15), 0 1px 1px hsla(0,0%,0%,.1); } + +.findbar { + top: 40px; + left: 40px; + position: absolute; + z-index: 100; + height: 20px; + + min-width: 16px; + padding: 3px 6px 3px 6px; + margin: 4px 2px 4px 0; + border: 1px solid transparent; + border-radius: 2px; + color: hsl(0,0%,85%); + font-size: 12px; + line-height: 14px; + text-align: left; + -webkit-user-select:none; + -moz-user-select:none; + cursor: default; +} + +.notFound { + background-color: rgb(255, 137, 153); +} + html[dir='ltr'] #toolbarViewerLeft { margin-left: -1px; } @@ -1069,6 +1095,30 @@ canvas { white-space:pre; } +.textLayer .highlight { + margin: -1px; + padding: 1px; + + background-color: rgba(0, 137, 26, 0.2); + border-radius: 4px; +} + +.textLayer .highlight.begin { + border-radius: 4px 0px 0px 4px; +} + +.textLayer .highlight.end { + border-radius: 0px 4px 4px 0px; +} + +.textLayer .highlight.middle { + border-radius: 0px; +} + +.textLayer .highlight.selected { + background-color: rgba(255, 0, 0, 0.2); +} + /* TODO: file FF bug to support ::-moz-selection:window-inactive so we can override the opaque grey background when the window is inactive; see https://bugzilla.mozilla.org/show_bug.cgi?id=706209 */ diff --git a/web/viewer.html b/web/viewer.html index 70cd8cdb0..0ee6313a6 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -108,9 +108,20 @@ limitations under the License. </div> <!-- sidebarContainer --> <div id="mainContainer"> + <div class="findbar hidden" id="findbar"> + <label for="findInput">Find: </label> + <input id="findInput" type="search"> + <button id="findPrevious"><</button> + <button id="findNext">></button> + <input type="checkbox" id="findHighlightAll"> + <label for="findHighlightAll">Highlight all</label> + <input type="checkbox" id="findMatchCase"> + <label for="findMatchCase">Match case</label> + <span id="findMsgWrap" class="hidden">Reached end of page, continued from top</span> + <span id="findMsgNotFound" class="hidden">Phrase not found</span> + </div> <div class="toolbar"> <div id="toolbarContainer"> - <div id="toolbarViewer"> <div id="toolbarViewerLeft"> <button id="sidebarToggle" class="toolbarButton" title="Toggle Sidebar" tabindex="4" data-l10n-id="toggle_slider"> diff --git a/web/viewer.js b/web/viewer.js index 2662adff7..65c87740f 100644 --- a/web/viewer.js +++ b/web/viewer.js @@ -208,6 +208,345 @@ var Settings = (function SettingsClosure() { var cache = new Cache(kCacheSize); var currentPageNumber = 1; +var PDFFindController = { + startedTextExtraction: false, + + // If active, search resulsts will be highlighted. + active: false, + + // Stores the text for each page. + pageContents: [], + + pageMatches: [], + + selected: { + pageIdx: 0, + matchIdx: 0 + }, + + dirtyMatch: false, + + findTimeout: null, + + initialize: function() { + var events = [ + 'find', + 'findagain', + 'findhighlightallchange', + 'findcasesensitivitychange' + ]; + + this.handelEvent = this.handelEvent.bind(this); + + for (var i = 0; i < events.length; i++) { + window.addEventListener(events[i], this.handelEvent); + } + }, + + calcFindMatch: function(pageContent) { + // TODO: Handle the other search options here as well. + + var query = this.state.query; + var queryLen = query.length; + + if (queryLen === 0) + return []; + + var matches = []; + + var matchIdx = -queryLen; + while (true) { + matchIdx = pageContent.indexOf(query, matchIdx + queryLen); + if (matchIdx === -1) { + break; + } + + matches.push(matchIdx); + } + return matches; + }, + + extractText: function() { + if (this.startedTextExtraction) + return; + this.startedTextExtraction = true; + var self = this; + function extractPageText(pageIndex) { + PDFView.pages[pageIndex].getTextContent().then( + function textContentResolved(data) { + // Bulid the search string. + var bidiTexts = data.bidiTexts; + var str = ''; + + for (var i = 0; i < bidiTexts.length; i++) { + str += bidiTexts[i].str; + } + + // Store the pageContent as a string. + self.pageContents.push(str); + // Ensure there is a empty array of matches. + self.pageMatches.push([]); + + if ((pageIndex + 1) < PDFView.pages.length) + extractPageText(pageIndex + 1); + } + ); + } + extractPageText(0); + }, + + handelEvent: function(e) { + this.state = e.detail; + if (e.detail.findPrevious === undefined) { + this.dirtyMatch = true; + } + + clearTimeout(this.findTimeout); + if (e.type === 'find') { + // Only trigger the find action after 250ms of silence. + this.findTimeout = setTimeout(this.performFind.bind(this), 250); + } else { + this.performFind(); + } + }, + + updatePage: function(idx) { + var page = PDFView.pages[idx]; + + if (page.textLayer) { + page.textLayer.updateMatches(); + } else if (this.selected.pageIdx === idx) { + // If the page is selected, scroll the page into view, which triggers + // rendering the page, which adds the textLayer. Once the textLayer is + // build, it will scroll onto the selected match. + page.scrollIntoView(); + } + }, + + performFind: function() { + // Recalculate all the matches. + // TODO: Make one match show up as the current match + + var pages = PDFView.pages; + var pageContents = this.pageContents; + var pageMatches = this.pageMatches; + + this.active = true; + + if (this.dirtyMatch) { + // Need to recalculate the matches. + this.dirtyMatch = false; + + this.selected = { + pageIdx: -1, + matchIdx: -1 + }; + + // TODO: Make this way more lasily (aka. efficient) - e.g. calculate only + // the matches for the current visible pages. + var firstMatch = true; + for (var i = 0; i < pageContents.length; i++) { + var matches = pageMatches[i] = this.calcFindMatch(pageContents[i]); + if (firstMatch && matches.length !== 0) { + firstMatch = false; + this.selected = { + pageIdx: i, + matchIdx: 0 + }; + } + this.updatePage(i, true); + } + } else { + // If there is NO selection, then there is no match at all -> no sense to + // handel previous/next action. + if (this.selected.pageIdx === -1) + return; + + // Handle findAgain case. + var previous = this.state.findPrevious; + var sPageIdx = this.selected.pageIdx; + var sMatchIdx = this.selected.matchIdx; + + if (previous) { + // Select previous match. + + if (sMatchIdx !== 0) { + this.selected.matchIdx -= 1; + } else { + var len = pageMatches.length; + for (var i = sPageIdx - 1; i != sPageIdx; i--) { + if (i < 0) + i += len; + + if (pageMatches[i].length !== 0) { + this.selected = { + pageIdx: i, + matchIdx: pageMatches[i].length - 1 + }; + break; + } + } + // If pageIdx stayed the same, select last match on the page. + if (this.selected.pageIdx === sPageIdx) { + this.selected.matchIdx = pageMatches[sPageIdx].length - 1; + } + } + } else { + // Select next match. + + if (pageMatches[sPageIdx].length !== sMatchIdx + 1) { + this.selected.matchIdx += 1; + } else { + var len = pageMatches.length; + for (var i = sPageIdx + 1; i < len + sPageIdx; i++) { + if (pageMatches[i % len].length !== 0) { + this.selected = { + pageIdx: i % len, + matchIdx: 0 + }; + break; + } + } + // If pageIdx stayed the same, select last match on the page. + if (this.selected.pageIdx === sPageIdx) { + this.selected.matchIdx = 0; + } + } + } + + this.updatePage(sPageIdx, sPageIdx === this.selected.pageIdx); + if (sPageIdx !== this.selected.pageIdx) { + this.updatePage(this.selected.pageIdx, true); + } + } + } +}; + +var PDFFindBar = { + // TODO: Enable the FindBar *AFTER* the pagesPromise in the load function + // got resolved + + opened: false, + + FIND_FOUND: 0, // Successful find + FIND_NOTFOUND: 1, // Unsuccessful find + FIND_WRAPPED: 2, // Successful find, but wrapped around + + initialize: function() { + this.bar = document.getElementById('findbar'); + this.toggleButton = document.getElementById('viewSearch'); + this.findField = document.getElementById('findInput'); + this.highlightAll = document.getElementById('findHighlightAll'); + this.caseSensitive = document.getElementById('findMatchCase'); + this.findMsgWrap = document.getElementById('findMsgWrap'); + this.findMsgNotFound = document.getElementById('findMsgNotFound'); + + var self = this; + this.toggleButton.addEventListener('click', + function() { + self.toggle(); + }); + + this.findField.addEventListener('input', function() { + self.dispatchEvent(''); + }); + + // TODO: Add keybindings like enter, shift-enter, CMD-G etc. to go to prev/ + // next match when the findField is selected. + + document.getElementById('findPrevious').addEventListener('click', + function() { + self.dispatchEvent('again', true); + }); + + document.getElementById('findNext').addEventListener('click', + function() { + self.dispatchEvent('again', false); + }); + + this.highlightAll.addEventListener('click', + function() { + self.dispatchEvent('highlightallchange'); + }); + + this.caseSensitive.addEventListener('click', + function() { + self.dispatchEvent('casesensitivitychange'); + }); + }, + + dispatchEvent: function(aType, aFindPrevious) { + var event = document.createEvent('CustomEvent'); + event.initCustomEvent('find' + aType, true, true, { + query: this.findField.value, + caseSensitive: this.caseSensitive.checked, + highlightAll: this.highlightAll.checked, + findPrevious: aFindPrevious + }); + return window.dispatchEvent(event); + }, + + updateUIState: function(aState) { + var notFound = false; + var wrapped = false; + + switch (aState) { + case this.FIND_FOUND: + break; + + case this.FIND_NOTFOUND: + notFound = true; + break; + + case this.FIND_WRAPPED: + wrapped = true; + break; + } + + if (notFound) { + this.findField.classList.add('notFound'); + this.findMsgNotFound.classList.remove('hidden'); + } else { + this.findField.classList.remove('notFound'); + this.findMsgNotFound.classList.add('hidden'); + } + + if (wrapped) { + this.findMsgWrap.classList.remove('hidden'); + } else { + this.findMsgWrap.classList.add('hidden'); + } + }, + + open: function() { + if (this.opened) return; + + this.opened = true; + this.toggleButton.classList.add('toggled'); + this.bar.classList.remove('hidden'); + this.findField.select(); + this.findField.focus(); + }, + + close: function() { + if (!this.opened) return; + + this.opened = false; + this.toggleButton.classList.remove('toggled'); + this.bar.classList.add('hidden'); + + PDFFindController.active = false; + }, + + toggle: function() { + if (this.opened) { + this.close(); + } else { + this.open(); + } + } +}; + var PDFView = { pages: [], thumbnails: [], @@ -242,6 +581,9 @@ var PDFView = { this.watchScroll(thumbnailContainer, this.thumbnailViewScroll, this.renderHighestPriority.bind(this)); + PDFFindBar.initialize(); + PDFFindController.initialize(); + this.initialized = true; container.addEventListener('scroll', function() { self.lastScroll = Date.now(); @@ -736,6 +1078,9 @@ var PDFView = { thumbnails.push(thumbnailView); var pageRef = page.ref; pagesRefMap[pageRef.num + ' ' + pageRef.gen + ' R'] = i; + + // Trigger text extraction. TODO: Make this happen lasyliy if needed. + PDFFindController.extractText(); } self.pagesRefMap = pagesRefMap; @@ -1229,6 +1574,7 @@ var PageView = function pageView(container, pdfPage, id, scale, this.resume = null; this.textContent = null; + this.textLayer = null; var anchor = document.createElement('a'); anchor.name = '' + this.id; @@ -1475,7 +1821,8 @@ var PageView = function pageView(container, pdfPage, id, scale, textLayerDiv.className = 'textLayer'; div.appendChild(textLayerDiv); } - var textLayer = textLayerDiv ? new TextLayerBuilder(textLayerDiv) : null; + var textLayer = this.textLayer = + textLayerDiv ? new TextLayerBuilder(textLayerDiv, this.id - 1) : null; var scale = this.scale, viewport = this.viewport; canvas.width = viewport.width; @@ -1834,21 +2181,25 @@ var CustomStyle = (function CustomStyleClosure() { return CustomStyle; })(); -var TextLayerBuilder = function textLayerBuilder(textLayerDiv) { +var TextLayerBuilder = function textLayerBuilder(textLayerDiv, pageIdx) { var textLayerFrag = document.createDocumentFragment(); + this.textLayerDiv = textLayerDiv; this.layoutDone = false; this.divContentDone = false; + this.pageIdx = pageIdx; + this.matches = []; this.beginLayout = function textLayerBuilderBeginLayout() { this.textDivs = []; this.textLayerQueue = []; + this.renderingDone = false; }; this.endLayout = function textLayerBuilderEndLayout() { this.layoutDone = true; this.insertDivContent(); - }, + }; this.renderLayer = function textLayerBuilderRenderLayer() { var self = this; @@ -1862,8 +2213,11 @@ var TextLayerBuilder = function textLayerBuilder(textLayerDiv) { if (textDivs.length > 100000) return; - while (textDivs.length > 0) { - var textDiv = textDivs.shift(); + var i = textDivs.length; + + while (i !== 0) { + i--; + var textDiv = textDivs[i]; textLayerFrag.appendChild(textDiv); ctx.font = textDiv.style.fontSize + ' ' + textDiv.style.fontFamily; @@ -1875,9 +2229,14 @@ var TextLayerBuilder = function textLayerBuilder(textLayerDiv) { CustomStyle.setProp('transform' , textDiv, 'scale(' + textScale + ', 1)'); CustomStyle.setProp('transformOrigin' , textDiv, '0% 0%'); + + textLayerDiv.appendChild(textDiv); } } + this.renderingDone = true; + this.updateMatches(); + textLayerDiv.appendChild(textLayerFrag); }; @@ -1944,6 +2303,184 @@ var TextLayerBuilder = function textLayerBuilder(textLayerDiv) { this.textContent = textContent; this.insertDivContent(); }; + + this.convertMatches = function textLayerBuilderConvertMatches(matches) { + var i = 0; + var iIndex = 0; + var bidiTexts = this.textContent.bidiTexts; + var end = bidiTexts.length - 1; + var queryLen = PDFFindController.state.query.length; + + var lastDivIdx = -1; + var pos; + + var ret = []; + + // Loop over all the matches. + for (var m = 0; m < matches.length; m++) { + var matchIdx = matches[m]; + // # Calculate the begin position. + + // Loop over the divIdxs. + while (i !== end && matchIdx >= (iIndex + bidiTexts[i].str.length)) { + iIndex += bidiTexts[i].str.length; + i++; + } + + // TODO: Do proper handling here if something goes wrong. + if (i == bidiTexts.length) { + console.error('Could not find matching mapping'); + } + + var match = { + begin: { + divIdx: i, + offset: matchIdx - iIndex + } + }; + + // # Calculate the end position. + matchIdx += queryLen; + + // Somewhat same array as above, but use a > instead of >= to get the end + // position right. + while (i !== end && matchIdx > (iIndex + bidiTexts[i].str.length)) { + iIndex += bidiTexts[i].str.length; + i++; + } + + match.end = { + divIdx: i, + offset: matchIdx - iIndex + }; + ret.push(match); + } + + return ret; + }; + + this.renderMatches = function textLayerBuilder_renderMatches(matches) { + var bidiTexts = this.textContent.bidiTexts; + var textDivs = this.textDivs; + var prevEnd = null; + var isSelectedPage = this.pageIdx === PDFFindController.selected.pageIdx; + var selectedMatchIdx = PDFFindController.selected.matchIdx; + + var infty = { + divIdx: -1, + offset: undefined + }; + + function beginText(begin, className) { + var divIdx = begin.divIdx; + var div = textDivs[divIdx]; + div.innerHTML = ''; + + var content = bidiTexts[divIdx].str.substring(0, begin.offset); + var node = document.createTextNode(content); + if (className) { + var isSelected = isSelectedPage && + divIdx === selectedMatchIdx; + var span = document.createElement('span'); + span.className = className + (isSelected ? ' selected' : ''); + span.appendChild(node); + div.appendChild(span); + return; + } + div.appendChild(node); + } + + function appendText(from, to, className) { + var divIdx = from.divIdx; + var div = textDivs[divIdx]; + + var content = bidiTexts[divIdx].str.substring(from.offset, to.offset); + var node = document.createTextNode(content); + if (className) { + var span = document.createElement('span'); + span.className = className; + span.appendChild(node); + div.appendChild(span); + return; + } + div.appendChild(node); + } + + function highlightDiv(divIdx, className) { + textDivs[divIdx].className = className; + } + + for (var i = 0; i < matches.length; i++) { + var match = matches[i]; + var begin = match.begin; + var end = match.end; + + var isSelected = isSelectedPage && i === selectedMatchIdx; + var highlightSuffix = (isSelected ? ' selected' : ''); + if (isSelected) + scrollIntoView(textDivs[begin.divIdx], {top: -50}); + + // Match inside new div. + if (!prevEnd || begin.divIdx !== prevEnd.divIdx) { + // If there was a previous div, then add the text at the end + if (prevEnd !== null) { + appendText(prevEnd, infty); + } + // clears the divs and set the content until the begin point. + beginText(begin); + } else { + appendText(prevEnd, begin); + } + + if (begin.divIdx === end.divIdx) { + appendText(begin, end, 'highlight' + highlightSuffix); + } else { + appendText(begin, infty, 'highlight begin' + highlightSuffix); + for (var n = begin.divIdx + 1; n < end.divIdx; n++) { + highlightDiv(n, 'highlight middle' + highlightSuffix); + } + beginText(end, 'highlight end' + highlightSuffix); + } + prevEnd = end; + } + + if (prevEnd) { + appendText(prevEnd, infty); + } + }; + + this.updateMatches = function textLayerUpdateMatches() { + // Only show matches, once all rendering is done. + if (!this.renderingDone) + return; + + // Clear out all matches. + var matches = this.matches; + var textDivs = this.textDivs; + var bidiTexts = this.textContent.bidiTexts; + var clearedUntilDivIdx = -1; + + for (var i = 0; i < matches.length; i++) { + var match = matches[i]; + var begin = Math.max(clearedUntilDivIdx, match.begin.divIdx); + for (var n = begin; n <= match.end.divIdx; n++) { + var div = bidiTexts[n].str; + div.textContent = div.textContent; + div.className = ''; + } + clearedUntilDivIdx = match.end.divIdx + 1; + } + + if (!PDFFindController.active) + return; + + // Convert the matches on the page controller into the match format used + // for the textLayer. + this.matches = matches = + this.convertMatches(PDFFindController.pageMatches[this.pageIdx] || []); + + this.renderMatches(this.matches); + }; }; document.addEventListener('DOMContentLoaded', function webViewerLoad(evt) { @@ -2059,16 +2596,6 @@ document.addEventListener('DOMContentLoaded', function webViewerLoad(evt) { PDFView.switchSidebarView('outline'); }); - document.getElementById('viewSearch').addEventListener('click', - function() { - PDFView.switchSidebarView('search'); - }); - - document.getElementById('searchButton').addEventListener('click', - function() { - PDFView.search(); - }); - document.getElementById('previous').addEventListener('click', function() { PDFView.page--; @@ -2331,6 +2858,12 @@ window.addEventListener('keydown', function keydown(evt) { // control is selected or not. if (cmd == 1 || cmd == 8) { // either CTRL or META key. switch (evt.keyCode) { +//#if !(FIREFOX || MOZCENTRAL) + case 70: + PDFFindBar.toggle(); + handled = true; + break; +//#endif case 61: // FF/Mac '=' case 107: // FF '+' and '=' case 187: // Chrome '+'