Find.ts: Implement :find, :findnext

This commit is contained in:
glacambre 2018-06-24 13:36:06 +02:00
parent b3730c0801
commit b8f7d5f389
No known key found for this signature in database
GPG key ID: B9625DB1767553AC
5 changed files with 248 additions and 61 deletions

View file

@ -7,16 +7,17 @@ import * as config from "../config"
class FindCompletionOption extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse {
public fuseKeys = []
constructor(m) {
constructor(m, reverse = false) {
super()
this.value = m.rangeData.text
this.fuseKeys.push(this.value)
this.value =
(reverse ? "-? " : "") + ("-: " + m.index) + " " + m.rangeData.text
this.fuseKeys.push(m.rangeData.text)
let contextLength = 4
// Create HTMLElement
this.html = html`<tr class="FindCompletionOption option">
<td class="content">${m.precontext}<span class="match">${
this.value
m.rangeData.text
}</span>${m.postcontext}</td>
</tr>`
}
@ -28,7 +29,7 @@ export class FindCompletionSource extends Completions.CompletionSourceFuse {
public completionCount = 0
constructor(private _parent) {
super(["find"], "FindCompletionSource", "Matches")
super(["find "], "FindCompletionSource", "Matches")
this.updateOptions()
this._parent.appendChild(this.node)
@ -48,26 +49,35 @@ export class FindCompletionSource extends Completions.CompletionSourceFuse {
private async updateOptions(exstr?: string) {
if (!exstr) return
let query = exstr.substring(exstr.trim().indexOf(" ") + 1)
if (!query || query == exstr) return
// Flag parsing because -? should reverse completions
let tokens = exstr.split(" ")
let flagpos = tokens.indexOf("-?")
let reverse = flagpos >= 0
if (reverse) {
tokens.splice(flagpos, 1)
}
let query = tokens.slice(1).join(" ")
// No point if continuing if the user hasn't started searching yet
if (query.length == 0) return
let findresults = await config.getAsync("findresults")
if (findresults === 0) return
let findcase = await config.getAsync("findcase")
let caseSensitive =
findcase == "sensitive" ||
(findcase == "smart" && query.search(/[A-Z]/) >= 0)
this.lastExstr = exstr
// Note: the use of activeTabId here might break completions if the user starts searching for a pattern in a really big page and then switches to another tab.
// Getting the tabId should probably be done in the constructor but you can't have async constructors.
let tabId = await activeTabId()
let findings = browserBg.find.find(query, {
let findings = await Messaging.messageTab(
tabId,
caseSensitive,
includeRangeData: true,
})
findings = await findings
"finding_content",
"find",
[query, findresults, reverse],
)
// If the search was successful
if (findings.count > 0) {
if (findresults != -1 && findresults < findings.count)
findings.count = findresults
// Get match context
let len = await config.getAsync("findcontextlen")
let matches = await Messaging.messageTab(
tabId,
@ -75,9 +85,11 @@ export class FindCompletionSource extends Completions.CompletionSourceFuse {
"getMatches",
[findings, len],
)
this.options = matches.map(m => new FindCompletionOption(m))
this.options = matches.map(
m => new FindCompletionOption(m, reverse),
)
this.updateChain(exstr, this.options)
} else {
}
}

View file

@ -98,11 +98,10 @@ const DEFAULTS = o({
":": "fillcmdline",
s: "fillcmdline open search",
S: "fillcmdline tabopen search",
// find mode not suitable for general consumption yet.
// "/": "find",
// "?": "find -1",
// "n": "findnext 1",
// "N": "findnext -1",
"/": "fillcmdline find",
"?": "fillcmdline find -?",
n: "findnext 1",
N: "findnext -1",
M: "gobble 1 quickmark",
B: "fillcmdline bufferall",
b: "fillcmdline buffer",
@ -161,6 +160,7 @@ const DEFAULTS = o({
alias: "command",
au: "autocmd",
b: "buffer",
clsh: "clearsearchhighlight",
o: "open",
w: "winopen",
t: "tabopen",

View file

@ -751,14 +751,27 @@ export function scrollpage(n = 1) {
scrollpx(0, window.innerHeight * n)
}
//#content_helper
import * as finding from "./finding_content"
/** Start find mode. Work in progress.
*
* @param direction - the direction to search in: 1 is forwards, -1 is backwards.
*
*/
//#background
export function find(str: string) {
console.log("find ", str)
//#content
export function find(...args: string[]) {
let flagpos = args.indexOf("-?")
let reverse = flagpos >= 0
if (reverse) args.splice(flagpos, 1)
flagpos = args.indexOf("-:")
let startingFrom = 0
if (flagpos >= 0) {
startingFrom = parseInt(args[flagpos + 1]) || 0
args.splice(flagpos, 2)
}
finding.jumpToMatch(args.join(" "), reverse, startingFrom)
}
/** Highlight the next occurence of the previously searched for word.
@ -766,9 +779,14 @@ export function find(str: string) {
* @param number - number of words to advance down the page (use 1 for next word, -1 for previous)
*
*/
//#background
//#content
export function findnext(n: number) {
console.log("findnext ", n)
finding.jumpToNextMatch(n)
}
//#background
export function clearsearchhighlight() {
browserBg.find.removeHighlighting()
}
/** @hidden */

View file

@ -1,85 +1,219 @@
// This file has various utilities used by the completion source for the find excmd
import * as Messaging from "./messaging"
import * as config from "./config"
import { browserBg, activeTabId } from "./lib/webext"
export class Match {
constructor(public rangeData, public rectData, public precontext, public postcontext){}
constructor(
public index,
public rangeData,
public rectData,
public precontext,
public postcontext,
public firstNode,
) {}
}
function isCommandLineNode(n) {
let url = n.ownerDocument.location.href
return url.protocol == "moz-extension:" && url.pathname == "/static/commandline.html"
return (
url.protocol == "moz-extension:" &&
url.pathname == "/static/commandline.html"
)
}
// We cache these results because walking the tree can be quite expensive when there's a lot of nodes
// TODO: Implement cache invalidation using a MutationObserver to track nodes being added to or removed from the DOM
let nodes = null
/** Get all text nodes within the page.
TODO: cache the results. I tried to do it but since we need to invalidate the cache when nodes are added/removed from the page, the results are constantly being invalidated by the completion buffer.
The solution is obviously to pass `document.body` to createTreeWalker instead of just `document` but then you won't get all the text nodes in the page and this is a big problem because the results returned by browser.find.find() need absolutely all nodes existing within the page, even the ones belonging the commandline. */
function getNodes() {
// if (nodes != null)
// return nodes
nodes = []
let walker = document.createTreeWalker(document, NodeFilter.SHOW_TEXT, null, false);
let nodes = []
let walker = document.createTreeWalker(
document,
NodeFilter.SHOW_TEXT,
null,
false,
)
let node = walker.nextNode()
do {
nodes.push(node);
nodes.push(node)
node = walker.nextNode()
} while (node)
return nodes;
return nodes
}
/** Given "findings", an object matching the ones returned by browser.find.find(), will compute the context for every match in findings and prune the matches than happened in Tridactyl's command line. ContextLength is how many from before and after the match should be included into the returned Match object */
let lastMatches = []
/** Given "findings", an object matching the ones returned by browser.find.find(), will compute the context for every match in findings and prune the matches than happened in Tridactyl's command line. ContextLength is how many characters from before and after the match should be included into the returned Match object.
getMatches() will save its returned values in lastMatches. This is important for caching purposes in jumpToMatch, read its documentation to get the whole picture. */
export function getMatches(findings, contextLength = 10): Match[] {
let result = []
if (findings.count == 0)
return result
if (findings.count == 0) return result
if (!findings.rangeData)
throw new Error("Can't get matches without range data!")
// Helper function to create matches. This avoids a `if` in the loop below
let constructMatch = (findings, i, precontext, postcontext) => new Match(findings.rangeData[i], findings.rectData[i], precontext, postcontext)
let constructMatch = (findings, i, precontext, postcontext, node) =>
new Match(
i,
findings.rangeData[i],
findings.rectData[i],
precontext,
postcontext,
node,
)
if (!findings.rectData)
constructMatch = (findings, i, precontext, postcontext) => new Match(findings.rangeData[i], null, precontext, postcontext)
constructMatch = (findings, i, precontext, postcontext, node) =>
new Match(
i,
findings.rangeData[i],
null,
precontext,
postcontext,
node,
)
// Checks if a node belongs to the command line
let nodes = getNodes();
let nodes = getNodes()
for (let i = 0; i < findings.count; ++i) {
let range = findings.rangeData[i]
let firstnode = nodes[range.startTextNodePos];
let lastnode = nodes[range.endTextNodePos];
let firstnode = nodes[range.startTextNodePos]
let lastnode = nodes[range.endTextNodePos]
// We never want to match against nodes in the command line
if (!firstnode || !lastnode || isCommandLineNode(firstnode) || isCommandLineNode(lastnode)) {
console.log(i, nodes, range, firstnode, lastnode)
if (
!firstnode ||
!lastnode ||
isCommandLineNode(firstnode) ||
isCommandLineNode(lastnode)
)
continue
}
// Get the context before the match
let precontext = firstnode.textContent.substring(range.startOffset - contextLength, range.startOffset)
let precontext = firstnode.textContent.substring(
range.startOffset - contextLength,
range.startOffset,
)
if (precontext.length < contextLength) {
let missingChars = contextLength - precontext.length
let id = range.startTextNodePos - 1
while (missingChars > 0 && nodes[id]) {
let txt = nodes[id].textContent
precontext = txt.substring(txt.length - missingChars, txt.length) + precontext
precontext =
txt.substring(txt.length - missingChars, txt.length) +
precontext
missingChars = contextLength - precontext.length
id -= 1
}
}
// Get the context after the match
let postcontext = lastnode.textContent.substr(range.endOffset, contextLength)
let postcontext = lastnode.textContent.substr(
range.endOffset,
contextLength,
)
// If the last node doesn't have enough context and if there's a node after it
if (postcontext.length < contextLength && nodes[range.endTextNodePos + 1]) {
if (
postcontext.length < contextLength &&
nodes[range.endTextNodePos + 1]
) {
// Add text from the following text node to the context
postcontext += nodes[range.endTextNodePos + 1].textContent.substr(0, contextLength - postcontext.length)
postcontext += nodes[range.endTextNodePos + 1].textContent.substr(
0,
contextLength - postcontext.length,
)
}
result.push(constructMatch(findings, i, precontext, postcontext));
result.push(
constructMatch(findings, i, precontext, postcontext, firstnode),
)
}
return result;
if (cachedQuery != result[0].rangeData.text) matchesCacheIsValid = false
return result
}
let matchesCacheIsValid = false
let cachedQuery = ""
/** Performs a call to browser.find.find() with the right parameters.
If count is different from -1 and lower than the number of matches returned by browser.find.find(), will return count results. Note that when this happens, `matchesCacheIsValid ` is set to false, which will prevent `jumpToMatch` from using cached matches. */
export async function find(
query,
count = -1,
reverse = false,
): Promise<findResult> {
let findcase = await config.getAsync("findcase")
let caseSensitive =
findcase == "sensitive" ||
(findcase == "smart" && query.search(/[A-Z]/) >= 0)
let tabId = await activeTabId()
let findings = await browserBg.find.find(query, {
tabId,
caseSensitive,
includeRangeData: true,
includeRectData: true,
})
if (reverse) {
findings.rangeData = findings.rangeData.reverse()
findings.rectData = findings.rectData.reverse()
}
if (count != -1 && count < findings.count) {
findings.count = count
findings.rangeData = findings.rangeData.slice(0, findings.count)
findings.rectData = findings.rectData.slice(0, findings.count)
matchesCacheIsValid = false
} else {
matchesCacheIsValid = true
}
cachedQuery = query
return findings
}
let lastMatch = 0
let lastReverse = false
/* Jumps to the startingFromth dom node matching pattern */
export async function jumpToMatch(pattern, reverse, startingFrom) {
let match
// When we already computed all the matches, don't recompute them
if (matchesCacheIsValid && pattern == cachedQuery)
match = lastMatches[startingFrom]
if (!match) {
lastMatches = getMatches(await find(pattern, -1, reverse))
match = lastMatches[startingFrom]
}
if (!match) return
// Note: using this function can cause bugs, see
// https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/find/highlightResults
// Ideally we should reimplement our own highlighting
browserBg.find.highlightResults()
// Here, we make sure that the current match hasn't been removed from its owner document
// If it has, we try to find the next/previous node that is still in the document
let n = startingFrom
while (!match.firstNode.ownerDocument.contains(match.firstNode)) {
n += reverse ? -1 : 1
match = lastMatches[startingFrom]
if (n == startingFrom) return
}
match.firstNode.parentNode.scrollIntoView()
// Remember where we where and what actions we did. This is need for jumpToNextMatch
lastMatch = n
lastReverse = reverse
}
export function jumpToNextMatch(n: number) {
if (lastReverse) n *= -1
n = (n + lastMatch + lastMatches.length) % lastMatches.length
let match = lastMatches[n]
browserBg.find.highlightResults()
match.firstNode.parentNode.scrollIntoView()
lastMatch = n
}
import * as SELF from "./finding_content"

23
src/tridactyl.d.ts vendored
View file

@ -22,6 +22,29 @@ interface UIEvent {
pageY: number
}
// This isn't an actual firefox type but it's nice to have one for this kind of object
// https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/find/find
interface findResult {
count: number
rangeData: {
framePos: number
startTextNodePos: number
endTextNodePos: number
startOffset: number
endOffset: number
text: string
}[]
rectData: {
rectsAndTexts: {
top: number
left: number
bottom: number
right: number
}[]
textList: string[]
}
}
interface HTMLElement {
// Let's be future proof:
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus