Main page Rss feed

How to add new commands to Tridactyl

I've contributed a lot to Tridactyl between the end of 2017 and mid-2019. A few days ago, someone asked for a very simple feature on Tridactyl's matrix room and it sounded so trivial that I thought I'd be able to implement it in a few minutes. I was amused to discover that I had forgotten a lot of things about how Tridactyl worked and that this trivial feature would actually require more work than I thought. I'm writing this article in the hope that it'll help me and possibly you implement new features in Tridactyl should we need to do that again some day.

The feature

Quite simply: "a way to bring up completion candidates for all the headings in a document so you can jump to them". As completions can only be invoked once a command exists for them, we'll have to implement the command first. There is no reason to limit it to headings, so what we'll implement is a command that can jump to any CSS selector.

Implementing a new command

First, we need to come up with a name. I decided to use :goto. All commands are implemented as a Typescript function in src/excmds.ts. I believe the functions in there were once intended to be grouped in terms of what they operated on (e.g. tabnew and tabclose wouldn't be too far from each other) but this idea has long been abandonned, so we can just pick whatever spot we want without thinking too much. I decided to put :goto right after :gobble, because I like to sort things in alphabetical order. The command itself is fairly straightforward:

/**
 * Jump to selector.
 */
//#content
export async function goto(...selector: string[]) {
    const element = document.querySelector(selector.join(" "))
    if (element) {
        element.scrollIntoView()
    }
}

However, a lot of magic happens behind the scenes. First, the multi-line comment. This comment will be re-used as documentation for :goto's :help section. It will also be extracted by Tridactyl's custom typescript backend (which I wrote, and am quite proud of despite its profound ugliness) and re-injected in Tridactyl's source code in order to be displayed in command completions:

A screenshot of Tridactyl's command-line, showing command completion results for :g

The second comment, //#content, labels the function as a "content ex command". Browser extensions are split between multiple contexts, the most important ones being the background process and the content processes. Background processes execute in Firefox's main process, while content processes execute in tabs. Background processes have access to a lot of powerful APIs, content processes can read and modify the page. These processes communicate by sending messages to eachother.

As :goto modifies the page (by scrolling to an element), we need to make sure it executes in the content script. Labelling it with a //#content comment instructs Tridactyl's macro-processor to put it in the content script and to generate shims in the background script that let us easily call content-script commands from the background script.

The third element is the function signature: export async function goto(...selector: string[]) {. Once again, there is a lot of magic here. Indeed, ...selector: string[] is picked up by Tridactyl's macro-processor and will be used in Tridactyl's command-line parsing code in order to pick the right types to instantiate and pass to :goto. For example, if we had used ...selector: number[] instead, Tridactyl's parser would attempt to convert the arguments given to :goto to numbers.

The rest of the function is basic javascript code which searches for an element in the document and scrolls to it if it found it. And that's all we need to create a new :goto command! Users can now type :goto #id > .class to jump to the first element matching this selector. I hadn't realized it before writing this article, but despite all my gripes about Tridactyl's code, I have to admit that it makes adding new commands trivial!

Implementing :goto completions

Implementing completions is slightly more involved. You first need to create a new file in the src/completions directory. It's probably easiest to get started by copy/pasting one of the existing and simpler completion files, like Rss.ts. This file must define two classes, one extending the CompletionOptionHTML class and the other extending the CompletionSourceFuse class. CompletionOption classes are used to represent a single completion entry, and they usually do not need to do much more than holding some values. CompletionSource classes hold the actual logic to handle input, create completion options and sort and filter them. Let's take a look at the GotoCompletionSource first:

export class GotoCompletionSource extends Completions.CompletionSourceFuse {
    public options: GotoCompletionOption[] = []
    private shouldSetStateFromScore = true

    constructor(private _parent) {
        super(["goto"], "GotoCompletionSource", "Headings")

        this.updateOptions()
        this.shouldSetStateFromScore =
            config.get("completions", "Goto", "autoselect") === "true"
        this._parent.appendChild(this.node)
    }

    setStateFromScore(scoredOpts: Completions.ScoredOption[]) {
        super.setStateFromScore(scoredOpts, this.shouldSetStateFromScore)
    }

    onInput(...whatever) {
        return this.updateOptions(...whatever)
    }

    private async updateOptions(exstr = "") {
        this.lastExstr = exstr
        const [prefix] = this.splitOnPrefix(exstr)

        // Hide self and stop if prefixes don't match
        if (prefix) {
            // Show self if prefix and currently hidden
            if (this.state === "hidden") {
                this.state = "normal"
            }
        } else {
            this.state = "hidden"
            return
        }

        if (this.options.length < 1) {
            this.options = (
                await Messaging.messageOwnTab(
                    "excmd_content",
                    "getGotoSelectors",
                    [],
                )
            )
                .sort((a, b) => a.y - b.y)
                .map(heading => {
                    const opt = new GotoCompletionOption(
                        heading.level,
                        heading.y,
                        heading.title,
                        heading.selector,
                    )
                    opt.state = "normal"
                    return opt
                })
        }
        return this.updateChain()
    }
}

The first thing the constructor does is call super(["goto"], "GotoCompletionSource", "Headings"). The first argument is an array of Tridactyl commands or aliases that the completion should be triggered on, the second argument is the name of the completion source, and the third one is the title of the completion source, which will be displayed in the command line. The rest of the function, calling updateOptions, shouldSetStateFromScore and this._parent.appendChild is just uninteresting bookkeeping required for most completion sources.

The same goes for the next two functions, setStateFromScore and onInput. Note that while they aren't very interesting, they are required in most completion sources: for example, most of the time, you want to update the completion source's options every time the user inputs something in the command line.

The third function, updateOptions, is where all of the magic happens. The first part, checking if splitOnPrefix returned a prefix, is very important: it controls whether completions should be shown for the current content of the command line. Indeed, prefix will only be set if the command line's content begins with :goto. We do not want to display any :goto completion options for commands that aren't :goto and that's why we return early.

Once we've decided that we want to display completion options, we check if we already have some options with this.options.length < 1. If we don't have any options, we need to fetch some from the page. Something very important happens here: we use Messaging.messageOwnTab to get a list of CSS selectors to give to :goto.

The reason we can't just use document.querySelectorAll("h1, h2, h3") to get a list of headings, compute their unique selector and then transform them into completion options is that the command line completions aren't executing in the same context as the page! Indeed, completion code is executing in the command line's iframe, and thus doesn't have access to the page the user is seeing. This separation of the command line is done for security purposes and thus requires us to use webextension APIs to communicate with the page.

So we use Messaging.messageOwnTab("excmd_content", "getGotoSelectors", []) to fetch our completion options. excmd_content is the name of the handler that needs to handle our message, getGotoSelectors is the name of the function we want to execute and the last [] is a list of arguments that we want to pass to getGotoSelectors. So what does getGotoSelectors do, how does it do it and where does it do it? As getGotoSelectors executes in the same context as the :goto command, I decided to make it live in the same file, src/excmds.ts right before the function implementing :goto:

/** @hidden
 * This function is used by goto completions.
 */
//#content
export async function getGotoSelectors(): Promise<Array<{ level: number; y: number; title: string; selector: string }>> {
    const result = []
    let level = 1
    for (const selector of config.get("gotoselector").split(",")) {
        result.push(
            ...(Array.from(document.querySelectorAll(selector)) as HTMLElement[])
                .filter(e => e.innerText)
                .map(e => ({ level, y: e.getClientRects()[0]?.y, title: e.innerText, selector: DOM.getSelector(e) }))
                .filter(e => e.y !== undefined),
        )
        level += 1
    }
    return result
}

As with the function implementing :goto, there is a bit of magic here. In order to prevent the function from showing up in Tridactyl's :help pages and command line completions, we put @hidden in its documentation. We mark it //#content to make it live in the content script (= the page). The rest is more or less regular TypeScript: we fetch the user's gotoselector setting, which contains a CSS selector representing the elements that should be treated as headings on the current page.

We use a gotoselector setting, rather than hardcoding h1, h2, h3, h4, h5, h6 because some webdevs have the nasty habit of not using semantic tags in their HTML, meaning that they could very well chose to make all of their headings a regular <span> which they would then differentiate through custom CSS classes and attributes. By using a setting, we allow users to choose what should be treated as a heading on a specific page thanks to the :seturl command.

But the gotoselector setting doesn't exist yet. We have to implement it ourselves. Doing so is deceptively simple, we just need to add the following lines to the Config class in src/lib/config.ts:

    /**
     * Default selector for :goto command.
     */
    gotoselector = "h1, h2, h3, h4, h5, h6"

And here we are again, with tons of magic! First, TypeDoc will grab the documentation and put it in gotoselector's :help page. Then, Tridactyl's custom typescript backend will grab the documentation and put it in Tridactyl's :set command line completions, along with its type and default value. But it'll also generate type information so that Tridactyl's :set command will be able to typecheck the values set by users when they try to set settings. New settings, like commands, are pretty easy to add to Tridactyl (probably the reason why there's close to a hundred setting)!

With our small detour through settings done, we can go back to getGotoSelectors(). Iterating over each css selector in gotoselector, we gather matching elements, remove the ones that do not contain any text and then turn them into the data we need to build our completions. level keeps track of how nested the current css selector should be and increases every time a new css selector is processed. y is the position of the matched element in the page, which will let us sort elements in the order they visually appear in the page, which is important to get headings that visually make sense to the user. title will be the content of each completion option and selector is a CSS selector that uniquely identifies the current element. All of this information is storred in a flat list and returned to the GotoCompletionSource.

The GotoCompletionSource then takes care of sorting the results accoring to their position in the document and to turn them into the last piece of the puzzle, GotoCompletionOption instances. Here's the GotoCompletionOption class:

class GotoCompletionOption
    extends Completions.CompletionOptionHTML
    implements Completions.CompletionOptionFuse {
    public fuseKeys = []

    constructor(public level, public y, public title, public value) {
        super()
        this.fuseKeys.push(title)

        this.html = html`<tr class="GotoCompletionOption option">
            <td class="title" style="padding-left: ${level * 4}ch">${title}</td>
        </tr>`
    }
}

It doesn't do much besides storing a bit of data and choosing how to display it! Indeed, all the logic that does the displaying, filtering, selecting and dispatching of commands is handled by the parent classes of GotoCompletionSource. The one, very important thing CompletionOptions should do is have a value attribute, which is the value that will be given to the command if they are selected.

The very last thing we need to do is hooking the GotoCompletionSource into the command line. In order to do that, we just need to import it in src/commandline_frame.ts:

diff --git a/src/commandline_frame.ts b/src/commandline_frame.ts
index 68ced756..010e7878 100644
--- a/src/commandline_frame.ts
+++ b/src/commandline_frame.ts
@@ -26,6 +26,7 @@ import { CompositeCompletionSource } from "@src/completions/Composite"
 import { ExcmdCompletionSource } from "@src/completions/Excmd"
 import { ExtensionsCompletionSource } from "@src/completions/Extensions"
 import { FileSystemCompletionSource } from "@src/completions/FileSystem"
+import { GotoCompletionSource } from "@src/completions/Goto"
 import { GuisetCompletionSource } from "@src/completions/Guiset"
 import { HelpCompletionSource } from "@src/completions/Help"
 import { HistoryCompletionSource } from "@src/completions/History"
@@ -119,6 +120,7 @@ export function enableCompletions() {
             ThemeCompletionSource,
             CompositeCompletionSource,
             FileSystemCompletionSource,
+            GotoCompletionSource,
             GuisetCompletionSource,
             HelpCompletionSource,
             AproposCompletionSource,

And that's it! The :goto command and its completions are implemented and working!