~glacambre/firenvim

ref: 076c1a4f04db2173afb395d0af8c63a5bbcab6a8 firenvim/src/FirenvimElement.ts -rw-r--r-- 18.1 KiB
076c1a4f — Brian Ryall fixes installing fire nvim on brave 11 months ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
import { isChrome } from "./utils/utils";
import { AbstractEditor } from "./editors/AbstractEditor";
import { getEditor } from "./editors/editors";
import { computeSelector } from "./utils/CSSUtils";

export class FirenvimElement {

    // editor is an object that provides an interface to interact (e.g.
    // retrieve/set content, retrieve/set cursor position) consistently with
    // underlying elements (be they simple textareas, CodeMirror elements or
    // other).
    private editor: AbstractEditor;
    // frameId is the webextension id of the neovim frame. We use it to send
    // commands to the frame.
    private frameId: number;
    // frameIdPromise is a promise that will resolve to the frameId. The
    // frameId can't be retrieved synchronously as it needs to be sent by the
    // background script.
    private frameIdPromise: Promise<number>;
    // iframe is the Neovim frame. This is the element that receives all inputs
    // and displays the editor.
    private iframe: HTMLIFrameElement;
    // We use an intersectionObserver to detect when the element the
    // FirenvimElement is tied becomes invisible. When this happens,
    // we hide the FirenvimElement from the page.
    private intersectionObserver: IntersectionObserver;
    // We use a mutation observer to detect whether the element is removed from
    // the page. When this happens, the FirenvimElement is removed from the
    // page.
    private mutationObserver: MutationObserver;
    // nvimify is the function that listens for focus events and creates
    // firenvim elements. We need it in order to be able to remove it as an
    // event listener from the element the user selected when the user wants to
    // select that element again.
    private nvimify: (evt: { target: EventTarget }) => Promise<void>;
    // originalElement is the element a focus event has been triggered on. We
    // use it to retrieve the element the editor should appear over (e.g., if
    // elem is an element inside a CodeMirror editor, elem will be a small
    // invisible textarea and what we really want to put the Firenvim element
    // over is the parent div that contains it) and to give focus back to the
    // page when the user asks for that.
    private originalElement: HTMLElement;
    // resizeObserver is used in order to detect when the size of the element
    // being edited changed. When this happens, we resize the neovim frame.
    // TODO: periodically check if MS implemented a ResizeObserver type
    private resizeObserver: any;
    // span is the span element we use in order to insert the neovim frame in
    // the page. The neovim frame is attached to its shadow dom. Using a span
    // is much less disruptive to the page and enables a modicum of privacy
    // (the page won't be able to check what's in it). In firefox, pages will
    // still be able to detect the neovim frame by using window.frames though.
    private span: HTMLSpanElement;
    // resizeReqId keeps track of the number of resizing requests that are sent
    // to the iframe. We send and increment it for every resize requests, this
    // lets the iframe know what the most recently sent resize request is and
    // thus avoids reacting to an older resize request if a more recent has
    // already been processed.
    private resizeReqId = 0;
    // relativeX/Y is the position the iframe should have relative to the input
    // element in order to be both as close as possible to the input element
    // and fit in the window without overflowing out of the viewport.
    private relativeX = 0;
    private relativeY = 0;
    // firstPutEditorCloseToInputOrigin keeps track of whether this is the
    // first time the putEditorCloseToInputOrigin function is called from the
    // iframe. See putEditorCloseToInputOriginAfterResizeFromFrame() for more
    // information.
    private firstPutEditorCloseToInputOrigin = true;
    // onDetach is a callback provided by the content script when it creates
    // the FirenvimElement. It is called when the detach() function is called,
    // after all Firenvim elements have been removed from the page.
    private onDetach: (id: number) => any;
    // bufferInfo: a [url, selector, cursor, lang] tuple indicating the page
    // the last iframe was created on, the selector of the corresponding
    // textarea and the column/line number of the cursor.
    // Note that these are __default__ values. Real values must be created with
    // prepareBufferInfo(). The reason we're not doing this from the
    // constructor is that it's expensive and disruptive - getting this
    // information requires evaluating code in the page's context.
    private bufferInfo = (Promise.resolve(["", "", [1, 1], undefined]) as
                          Promise<[string, string, [number, number], string]>);


    // elem is the element that received the focusEvent.
    // Nvimify is the function that listens for focus events. We need to know
    // about it in order to remove it before focusing elem (otherwise we'll
    // just grab focus again).
    constructor (elem: HTMLElement,
                 listener: (evt: { target: EventTarget }) => Promise<void>,
                 onDetach: (id: number) => any) {
        this.originalElement = elem;
        this.nvimify = listener;
        this.onDetach = onDetach;
        this.editor = getEditor(elem);

        this.span = elem
            .ownerDocument
            .createElementNS("http://www.w3.org/1999/xhtml", "span");
        this.iframe = elem
            .ownerDocument
            .createElementNS("http://www.w3.org/1999/xhtml", "iframe") as HTMLIFrameElement;
        // Make sure there isn't any extra width/height
        this.iframe.style.padding = "0px";
        this.iframe.style.margin = "0px";
        this.iframe.style.border = "0px";
        // We still need a border, use a shadow for that
        this.iframe.style.boxShadow = "0px 0px 1px 1px black";
    }

    attachToPage (fip: Promise<number>) {
        this.frameIdPromise = fip.then((f: number) => this.frameId = f);

        // We don't need the iframe to be appended to the page in order to
        // resize it because we're just using the corresponding
        // input/textarea's size
        let rect = this.getElement().getBoundingClientRect();
        this.resizeTo(rect.width, rect.height, false);
        this.relativeX = 0;
        this.relativeY = 0;
        this.putEditorCloseToInputOrigin();

        // Use a ResizeObserver to detect when the underlying input element's
        // size changes and change the size of the FirenvimElement
        // accordingly
        this.resizeObserver = new ((window as any).ResizeObserver)(((self) => async (entries: any[]) => {
            const entry = entries.find((ent: any) => ent.target === self.getElement());
            if (self.frameId === undefined) {
                await this.frameIdPromise;
            }
            if (entry) {
                const newRect = this.getElement().getBoundingClientRect();
                if (rect.width === newRect.width && rect.height === newRect.height) {
                    return;
                }
                rect = newRect;
                self.resizeTo(rect.width, rect.height, false);
                self.putEditorCloseToInputOrigin();
                self.resizeReqId += 1;
                browser.runtime.sendMessage({
                    args: {
                        frameId: self.frameId,
                        message: {
                            args: [self.resizeReqId, rect.width, rect.height],
                            funcName: ["resize"],
                        }
                    },
                    funcName: ["messageFrame"],
                });
            }
        })(this));
        this.resizeObserver.observe(this.getElement(), { box: "border-box" });

        this.iframe.src = (browser as any).extension.getURL("/NeovimFrame.html");
        this.span.attachShadow({ mode: "closed" }).appendChild(this.iframe);
        this.getElement().ownerDocument.body.appendChild(this.span);

        this.focus();

        // It is pretty hard to tell when an element disappears from the page
        // (either by being removed or by being hidden by other elements), so
        // we use an intersection observer, which is triggered every time the
        // element becomes more or less visible.
        this.intersectionObserver = new IntersectionObserver((self => () => {
            const elem = self.getElement();
            // If elem doesn't have a rect anymore, it's hidden
            if (elem.getClientRects().length === 0) {
                self.hide();
            } else {
                self.show();
            }
        })(this), { root: null, threshold: 0.1 });
        this.intersectionObserver.observe(this.getElement());

        // We want to remove the FirenvimElement from the page when the
        // corresponding element is removed. We do this by adding a
        // mutationObserver to its parent.
        this.mutationObserver = new MutationObserver((self => (mutations: MutationRecord[]) => {
            const elem = self.getElement();
            mutations.forEach(mutation => mutation.removedNodes.forEach(node => {
                const walker = document.createTreeWalker(node, NodeFilter.SHOW_ALL);
                while (walker.nextNode()) {
                    if (walker.currentNode === elem) {
                        setTimeout(() => self.detachFromPage());
                    }
                }
            }));
        })(this));
        this.mutationObserver.observe(document.documentElement, {
            subtree: true,
            childList: true
        });
    }

    detachFromPage () {
        const elem = this.getElement();
        this.resizeObserver.unobserve(elem);
        this.intersectionObserver.unobserve(elem);
        this.mutationObserver.disconnect();
        this.span.parentNode.removeChild(this.span);
        this.onDetach(this.frameId);
    }

    focus () {
        // Some inputs try to grab the focus again after we appended the iframe
        // to the page, so we need to refocus it each time it loses focus. But
        // the user might want to stop focusing the iframe at some point, so we
        // actually stop refocusing the iframe a second after it is created.
        const self = this;
        function refocus() {
            setTimeout(() => {
                // First, destroy current selection. Some websites use the
                // selection to force-focus an element.
                const sel = document.getSelection();
                sel.removeAllRanges();
                const range = document.createRange();
                range.setStart(self.span, 0);
                range.collapse(true);
                sel.addRange(range);
                // Then, attempt to "release" the focus from whatever element
                // is currently focused. This doesn't work on Chrome.
                if (!isChrome()) {
                    window.focus();
                    document.documentElement.focus();
                    document.body.focus();
                }
                self.iframe.focus();
            }, 0);
        }
        this.iframe.addEventListener("blur", refocus);
        this.getElement().addEventListener("focus", refocus);
        setTimeout(() => {
            refocus();
            this.iframe.removeEventListener("blur", refocus);
            this.getElement().removeEventListener("focus", refocus);
        }, 100);
        refocus();
    }

    focusOriginalElement (addListener: boolean) {
        (document.activeElement as any).blur();
        this.originalElement.removeEventListener("focus", this.nvimify);
        this.originalElement.focus();
        if (addListener) {
            this.originalElement.addEventListener("focus", this.nvimify);
        }
    }

    getBufferInfo () {
        return this.bufferInfo;
    }

    getEditor () {
        return this.editor;
    }

    getElement () {
        return this.editor.getElement();
    }

    getIframe () {
        return this.iframe;
    }

    getPageElementContent () {
        return this.getEditor().getContent();
    }

    getSelector () {
        return computeSelector(this.getElement());
    }

    getSpan () {
        return this.span;
    }

    hide () {
        this.iframe.style.display = "none";
    }

    isFocused () {
        return document.activeElement === this.span
            || document.activeElement === this.iframe;
    }

    prepareBufferInfo () {
        this.bufferInfo = new Promise(async r => r([
            document.location.href,
            this.getSelector(),
            await (this.editor.getCursor().catch(() => [1, 1])),
            await (this.editor.getLanguage().catch(() => undefined))
        ]));
    }

    pressKeys (keys: KeyboardEvent[]) {
        keys.forEach(ev => this.originalElement.dispatchEvent(ev));
        this.focus();
    }

    putEditorCloseToInputOrigin () {
        const rect = this.editor.getElement().getBoundingClientRect();

        // Save attributes
        const posAttrs = ["left", "position", "top", "zIndex"];
        const oldPosAttrs = posAttrs.map((attr: any) => this.iframe.style[attr]);

        // Assign new values
        this.iframe.style.left = `${rect.left + window.scrollX + this.relativeX}px`;
        this.iframe.style.position = "absolute";
        this.iframe.style.top = `${rect.top + window.scrollY + this.relativeY}px`;
        // 2139999995 is hopefully higher than everything else on the page but
        // lower than Vimium's elements
        this.iframe.style.zIndex = "2139999995";

        // Compare, to know whether the element moved or not
        const posChanged = !!posAttrs.find((attr: any, index) =>
                                           this.iframe.style[attr] !== oldPosAttrs[index]);
        return { posChanged, newRect: rect };
    }

    putEditorCloseToInputOriginAfterResizeFromFrame () {
        // This is a very weird, complicated and bad piece of code. All calls
        // to `resizeEditor()` have to result in a call to `resizeTo()` and
        // then `putEditorCloseToInputOrigin()` in order to make sure the
        // iframe doesn't overflow from the viewport.
        // However, when we create the iframe, we don't want it to fit in the
        // viewport at all cost. Instead, we want it to cover the underlying
        // input as much as possible. The problem is that when it is created,
        // the iframe will ask for a resize (because Neovim asks for one) and
        // will thus also accidentally call putEditorCloseToInputOrigin, which
        // we don't want to call.
        // So we have to track the calls to putEditorCloseToInputOrigin that
        // are made from the iframe (i.e. from `resizeEditor()`) and ignore the
        // first one.
        if (this.firstPutEditorCloseToInputOrigin) {
            this.relativeX = 0;
            this.relativeY = 0;
            this.firstPutEditorCloseToInputOrigin = false;
            return;
        }
        return this.putEditorCloseToInputOrigin();
    }

    // Resize the iframe, making sure it doesn't get larger than the window
    resizeTo (width: number, height: number, warnIframe: boolean) {
        // If the dimensions that are asked for are too big, make them as big
        // as the window
        let cantFullyResize = false;
        let availableWidth = window.innerWidth;
        if (availableWidth > document.documentElement.clientWidth) {
            availableWidth = document.documentElement.clientWidth;
        }
        if (width >= availableWidth) {
            width = availableWidth - 1;
            cantFullyResize = true;
        }
        let availableHeight = window.innerHeight;
        if (availableHeight > document.documentElement.clientHeight) {
            availableHeight = document.documentElement.clientHeight;
        }
        if (height >= availableHeight) {
            height = availableHeight - 1;
            cantFullyResize = true;
        }

        // The dimensions that were asked for might make the iframe overflow.
        // In this case, we need to compute how much we need to move the iframe
        // to the left/top in order to have it bottom-right corner sit right in
        // the window's bottom-right corner.
        const rect = this.editor.getElement().getBoundingClientRect();
        const rightOverflow = availableWidth - (rect.left + width);
        this.relativeX = rightOverflow < 0 ? rightOverflow : 0;
        const bottomOverflow = availableHeight - (rect.top + height);
        this.relativeY = bottomOverflow < 0 ? bottomOverflow : 0;

        // Now actually set the width/height, move the editor where it is
        // supposed to be and if the new iframe can't be as big as requested,
        // warn the iframe script.
        this.iframe.style.width = `${width}px`;
        this.iframe.style.height = `${height}px`;
        if (cantFullyResize && warnIframe) {
            this.resizeReqId += 1;
            browser.runtime.sendMessage({
                args: {
                    frameId: this.frameId,
                    message: {
                        args: [this.resizeReqId, width, height],
                        funcName: ["resize"],
                    }
                },
                funcName: ["messageFrame"],
            });
        }
    }

    sendKey (key: string) {
        return browser.runtime.sendMessage({
            args: {
                frameId: this.frameId,
                message: {
                    args: [key],
                    funcName: ["sendKey"],
                }
            },
            funcName: ["messageFrame"],
        });
    }

    setPageElementContent (text: string) {
        const focused = this.isFocused();
        this.editor.setContent(text);
        [
            new Event("keydown",     { bubbles: true }),
            new Event("keyup",       { bubbles: true }),
            new Event("keypress",    { bubbles: true }),
            new Event("beforeinput", { bubbles: true }),
            new Event("input",       { bubbles: true }),
            new Event("change",      { bubbles: true })
        ].forEach(ev => this.originalElement.dispatchEvent(ev));
        if (focused) {
            this.focus();
        }
    }

    setPageElementCursor (line: number, column: number) {
        return this.editor.setCursor(line, column);
    }

    show () {
        this.iframe.style.display = "initial";
    }

}