summaryrefslogtreecommitdiff
path: root/qutebrowser/javascript/webelem.js
blob: f2a0cc0a2a3f9e8dfed3ac7b62bdc0bc4837b90b (plain)
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
// Copyright 2016-2021 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
//
// SPDX-License-Identifier: GPL-3.0-or-later

/**
 * The connection for web elements between Python and Javascript works like
 * this:
 *
 * - Python calls into Javascript and invokes a function to find elements (one
 *   of the find_* functions).
 * - Javascript gets the requested element, and calls serialize_elem on it.
 * - serialize_elem saves the javascript element object in "elements", gets some
 *   attributes from the element, and assigns an ID (index into 'elements') to
 *   it.
 * - Python gets this information and constructs a Python wrapper object with
 *   the information it got right away, and the ID.
 * - When Python wants to modify an element, it calls javascript again with the
 *   element ID.
 * - Javascript gets the element from the elements array, and modifies it.
 */

"use strict";

window._qutebrowser.webelem = (function() {
    const funcs = {};
    const elements = [];

    function get_frame_offset(frame) {
        if (frame === null) {
            // Dummy object with zero offset
            return {
                "top": 0,
                "right": 0,
                "bottom": 0,
                "left": 0,
                "height": 0,
                "width": 0,
            };
        }
        return frame.frameElement.getBoundingClientRect();
    }

    // Add an offset rect to a base rect, for use with frames
    function add_offset_rect(base, offset) {
        return {
            "top": base.top + offset.top,
            "left": base.left + offset.left,
            "bottom": base.bottom + offset.top,
            "right": base.right + offset.left,
            "height": base.height,
            "width": base.width,
        };
    }

    function serialize_elem(elem, frame = null) {
        if (!elem) {
            return null;
        }

        const id = elements.length;
        elements[id] = elem;

        const caret_position = elem.selectionStart;

        // isContentEditable occasionally returns undefined.
        const is_content_editable = elem.isContentEditable || false;

        const out = {
            "id": id,
            "rects": [],  // Gets filled up later
            "caret_position": caret_position,
            "is_content_editable": is_content_editable,
        };

        // Deal with various fun things which can happen in form elements
        // https://github.com/qutebrowser/qutebrowser/issues/2569
        // https://github.com/qutebrowser/qutebrowser/issues/2877
        // https://stackoverflow.com/q/22942689/2085149
        if (typeof elem.tagName === "string") {
            out.tag_name = elem.tagName;
        } else if (typeof elem.nodeName === "string") {
            out.tag_name = elem.nodeName;
        } else {
            out.tag_name = "";
        }

        if (typeof elem.className === "string") {
            out.class_name = elem.className;
        } else {
            // e.g. SVG elements
            out.class_name = "";
        }

        if (typeof elem.value === "string" || typeof elem.value === "number") {
            out.value = elem.value;
        } else {
            out.value = "";
        }

        if (typeof elem.outerHTML === "string") {
            out.outer_xml = elem.outerHTML;
        } else {
            out.outer_xml = "";
        }

        if (typeof elem.textContent === "string") {
            out.text = elem.textContent;
        } else if (typeof elem.text === "string") {
            out.text = elem.text;
        }  // else: don't add the text at all

        const attributes = {};
        for (let i = 0; i < elem.attributes.length; ++i) {
            const attr = elem.attributes[i];
            attributes[attr.name] = attr.value;
        }
        out.attributes = attributes;

        const client_rects = elem.getClientRects();
        const frame_offset_rect = get_frame_offset(frame);

        for (let k = 0; k < client_rects.length; ++k) {
            const rect = client_rects[k];
            out.rects.push(
                add_offset_rect(rect, frame_offset_rect)
            );
        }

        // console.log(JSON.stringify(out));

        return out;
    }

    function is_hidden_css(elem) {
        // Check if the element is hidden via CSS
        const win = elem.ownerDocument.defaultView;
        const style = win.getComputedStyle(elem, null);

        const invisible = style.getPropertyValue("visibility") !== "visible";
        const none_display = style.getPropertyValue("display") === "none";
        const zero_opacity = style.getPropertyValue("opacity") === "0";

        const is_framework = (
            // ACE editor
            elem.classList.contains("ace_text-input") ||
            // bootstrap CSS
            elem.classList.contains("custom-control-input")
        );

        return (invisible || none_display || (zero_opacity && !is_framework));
    }

    function is_visible(elem, frame = null) {
        // Adopted from vimperator:
        // https://github.com/vimperator/vimperator-labs/blob/vimperator-3.14.0/common/content/hints.js#L259-L285
        // FIXME:qtwebengine we might need something more sophisticated like
        // the cVim implementation here?
        // https://github.com/1995eaton/chromium-vim/blob/1.2.85/content_scripts/dom.js#L74-L134

        if (is_hidden_css(elem)) {
            return false;
        }

        const offset_rect = get_frame_offset(frame);
        let rect = add_offset_rect(elem.getBoundingClientRect(), offset_rect);

        if (!rect ||
                rect.top > window.innerHeight ||
                rect.bottom < 0 ||
                rect.left > window.innerWidth ||
                rect.right < 0) {
            return false;
        }

        rect = elem.getClientRects()[0];
        return Boolean(rect);
    }

    // Returns true if the iframe is accessible without
    // cross domain errors, else false.
    function iframe_same_domain(frame) {
        try {
            frame.document; // eslint-disable-line no-unused-expressions
            return true;
        } catch (exc) {
            if (exc instanceof DOMException && exc.name === "SecurityError") {
                // FIXME:qtwebengine This does not work for cross-origin frames.
                return false;
            }
            throw exc;
        }
    }

    funcs.find_css = (selector, only_visible) => {
        let elems;

        try {
            elems = document.querySelectorAll(selector);
        } catch (ex) {
            return {"success": false, "error": ex.toString()};
        }

        const subelem_frames = window.frames;
        const out = [];

        for (let i = 0; i < elems.length; ++i) {
            if (!only_visible || is_visible(elems[i])) {
                out.push(serialize_elem(elems[i]));
            }
        }

        // Recurse into frames and add them
        for (let i = 0; i < subelem_frames.length; i++) {
            if (iframe_same_domain(subelem_frames[i])) {
                const frame = subelem_frames[i];
                const subelems = frame.document.
                    querySelectorAll(selector);
                for (let elem_num = 0; elem_num < subelems.length; ++elem_num) {
                    if (!only_visible ||
                        is_visible(subelems[elem_num], frame)) {
                        out.push(serialize_elem(subelems[elem_num], frame));
                    }
                }
            }
        }

        return {"success": true, "result": out};
    };

    // Runs a function in a frame until the result is not null, then return
    // If no frame succeeds, return null
    function run_frames(func) {
        for (let i = 0; i < window.frames.length; ++i) {
            const frame = window.frames[i];
            if (iframe_same_domain(frame)) {
                const result = func(frame);
                if (result) {
                    return result;
                }
            }
        }
        return null;
    }

    funcs.find_id = (id) => {
        const elem = document.getElementById(id);
        if (elem) {
            return serialize_elem(elem);
        }

        const serialized_elem = run_frames((frame) => {
            const element = frame.window.document.getElementById(id);
            return serialize_elem(element, frame);
        });

        if (serialized_elem) {
            return serialized_elem;
        }

        return null;
    };

    // Check if elem is an iframe, and if so, return the result of func on it.
    // If no iframes match, return null
    function call_if_frame(elem, func) {
        // Check if elem is a frame, and if so, call func on the window
        if ("contentWindow" in elem) {
            const frame = elem.contentWindow;
            if (iframe_same_domain(frame) &&
                "frameElement" in elem.contentWindow) {
                return func(frame);
            }
        }
        return null;
    }

    funcs.find_focused = () => {
        const elem = document.activeElement;

        if (!elem || elem === document.body) {
            // "When there is no selection, the active element is the page's
            // <body> or null."
            return null;
        }

        // Check if we got an iframe, and if so, recurse inside of it
        const frame_elem = call_if_frame(elem,
            (frame) => serialize_elem(frame.document.activeElement, frame));

        if (frame_elem !== null) {
            return frame_elem;
        }
        return serialize_elem(elem);
    };

    funcs.find_at_pos = (x, y) => {
        const elem = document.elementFromPoint(x, y);

        if (!elem) {
            return null;
        }

        // Check if we got an iframe, and if so, recurse inside of it
        const frame_elem = call_if_frame(elem,
            (frame) => {
                // Subtract offsets due to being in an iframe
                const frame_offset_rect =
                      frame.frameElement.getBoundingClientRect();
                return serialize_elem(frame.document.
                    elementFromPoint(x - frame_offset_rect.left,
                        y - frame_offset_rect.top), frame);
            });

        if (frame_elem !== null) {
            return frame_elem;
        }
        return serialize_elem(elem);
    };

    // Function for returning a selection or focus to python (so we can click
    // it). If nothing is selected but there is something focused, returns
    // "focused"
    funcs.find_selected_focused_link = () => {
        const elem = window.getSelection().anchorNode;
        if (elem) {
            return serialize_elem(elem.parentNode);
        }

        const serialized_frame_elem = run_frames((frame) => {
            const node = frame.window.getSelection().anchorNode;
            if (node) {
                return serialize_elem(node.parentNode, frame);
            }
            return null;
        });

        if (serialized_frame_elem) {
            return serialized_frame_elem;
        }
        return funcs.find_focused() && "focused";
    };

    funcs.set_value = (id, value) => {
        elements[id].value = value;
    };

    funcs.insert_text = (id, text) => {
        const elem = elements[id];
        elem.focus();
        document.execCommand("insertText", false, text);
    };

    funcs.dispatch_event = (id, event, bubbles = false,
        cancelable = false, composed = false) => {
        const elem = elements[id];
        elem.dispatchEvent(
            new Event(event, {"bubbles": bubbles,
                "cancelable": cancelable,
                "composed": composed}));
    };

    funcs.set_attribute = (id, name, value) => {
        elements[id].setAttribute(name, value);
    };

    funcs.remove_blank_target = (id) => {
        let elem = elements[id];
        while (elem !== null) {
            const tag = elem.tagName.toLowerCase();
            if (tag === "a" || tag === "area") {
                if (elem.getAttribute("target") === "_blank") {
                    elem.setAttribute("target", "_top");
                }
                break;
            }
            elem = elem.parentElement;
        }
    };

    funcs.click = (id) => {
        const elem = elements[id];
        elem.click();
    };

    funcs.focus = (id) => {
        const elem = elements[id];
        elem.focus();
    };

    funcs.move_cursor_to_end = (id) => {
        const elem = elements[id];
        elem.selectionStart = elem.value.length;
        elem.selectionEnd = elem.value.length;
    };

    funcs.delete = (id) => {
        const elem = elements[id];
        elem.remove();
    };

    return funcs;
})();