Coverage Report

Created: 2025-11-16 07:46

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/src/serenity/Userland/Libraries/LibWeb/Selection/Selection.cpp
Line
Count
Source
1
/*
2
 * Copyright (c) 2021-2022, Andreas Kling <kling@serenityos.org>
3
 *
4
 * SPDX-License-Identifier: BSD-2-Clause
5
 */
6
7
#include <LibWeb/Bindings/Intrinsics.h>
8
#include <LibWeb/Bindings/SelectionPrototype.h>
9
#include <LibWeb/DOM/Document.h>
10
#include <LibWeb/DOM/Range.h>
11
#include <LibWeb/Selection/Selection.h>
12
13
namespace Web::Selection {
14
15
JS_DEFINE_ALLOCATOR(Selection);
16
17
JS::NonnullGCPtr<Selection> Selection::create(JS::NonnullGCPtr<JS::Realm> realm, JS::NonnullGCPtr<DOM::Document> document)
18
0
{
19
0
    return realm->heap().allocate<Selection>(realm, realm, document);
20
0
}
21
22
Selection::Selection(JS::NonnullGCPtr<JS::Realm> realm, JS::NonnullGCPtr<DOM::Document> document)
23
0
    : PlatformObject(realm)
24
0
    , m_document(document)
25
0
{
26
0
}
27
28
0
Selection::~Selection() = default;
29
30
void Selection::initialize(JS::Realm& realm)
31
0
{
32
0
    Base::initialize(realm);
33
0
    WEB_SET_PROTOTYPE_FOR_INTERFACE(Selection);
34
0
}
35
36
// https://w3c.github.io/selection-api/#dfn-empty
37
bool Selection::is_empty() const
38
0
{
39
    // Each selection can be associated with a single range.
40
    // When there is no range associated with the selection, the selection is empty.
41
    // The selection must be initially empty.
42
43
    // NOTE: This function should not be confused with Selection.empty() which empties the selection.
44
0
    return !m_range;
45
0
}
46
47
void Selection::visit_edges(Cell::Visitor& visitor)
48
0
{
49
0
    Base::visit_edges(visitor);
50
0
    visitor.visit(m_range);
51
0
    visitor.visit(m_document);
52
0
}
53
54
// https://w3c.github.io/selection-api/#dfn-anchor
55
JS::GCPtr<DOM::Node> Selection::anchor_node()
56
0
{
57
0
    if (!m_range)
58
0
        return nullptr;
59
0
    if (m_direction == Direction::Forwards)
60
0
        return m_range->start_container();
61
0
    return m_range->end_container();
62
0
}
63
64
// https://w3c.github.io/selection-api/#dfn-anchor
65
unsigned Selection::anchor_offset()
66
0
{
67
0
    if (!m_range)
68
0
        return 0;
69
0
    if (m_direction == Direction::Forwards)
70
0
        return m_range->start_offset();
71
0
    return m_range->end_offset();
72
0
}
73
74
// https://w3c.github.io/selection-api/#dfn-focus
75
JS::GCPtr<DOM::Node> Selection::focus_node()
76
0
{
77
0
    if (!m_range)
78
0
        return nullptr;
79
0
    if (m_direction == Direction::Forwards)
80
0
        return m_range->end_container();
81
0
    return m_range->start_container();
82
0
}
83
84
// https://w3c.github.io/selection-api/#dfn-focus
85
unsigned Selection::focus_offset() const
86
0
{
87
0
    if (!m_range)
88
0
        return 0;
89
0
    if (m_direction == Direction::Forwards)
90
0
        return m_range->end_offset();
91
0
    return m_range->start_offset();
92
0
}
93
94
// https://w3c.github.io/selection-api/#dom-selection-iscollapsed
95
bool Selection::is_collapsed() const
96
0
{
97
    // The attribute must return true if and only if the anchor and focus are the same
98
    // (including if both are null). Otherwise it must return false.
99
0
    if (!m_range)
100
0
        return true;
101
0
    return const_cast<Selection*>(this)->anchor_node() == const_cast<Selection*>(this)->focus_node()
102
0
        && m_range->start_offset() == m_range->end_offset();
103
0
}
104
105
// https://w3c.github.io/selection-api/#dom-selection-rangecount
106
unsigned Selection::range_count() const
107
0
{
108
0
    if (m_range)
109
0
        return 1;
110
0
    return 0;
111
0
}
112
113
String Selection::type() const
114
0
{
115
0
    if (!m_range)
116
0
        return "None"_string;
117
0
    if (m_range->collapsed())
118
0
        return "Caret"_string;
119
0
    return "Range"_string;
120
0
}
121
122
String Selection::direction() const
123
0
{
124
0
    if (!m_range || m_direction == Direction::Directionless)
125
0
        return "none"_string;
126
0
    if (m_direction == Direction::Forwards)
127
0
        return "forward"_string;
128
0
    return "backward"_string;
129
0
}
130
131
// https://w3c.github.io/selection-api/#dom-selection-getrangeat
132
WebIDL::ExceptionOr<JS::GCPtr<DOM::Range>> Selection::get_range_at(unsigned index)
133
0
{
134
    // The method must throw an IndexSizeError exception if index is not 0, or if this is empty.
135
0
    if (index != 0 || is_empty())
136
0
        return WebIDL::IndexSizeError::create(realm(), "Selection.getRangeAt() on empty Selection or with invalid argument"_string);
137
138
    // Otherwise, it must return a reference to (not a copy of) this's range.
139
0
    return m_range;
140
0
}
141
142
// https://w3c.github.io/selection-api/#dom-selection-addrange
143
void Selection::add_range(JS::NonnullGCPtr<DOM::Range> range)
144
0
{
145
    // 1. If the root of the range's boundary points are not the document associated with this, abort these steps.
146
0
    if (&range->start_container()->root() != m_document.ptr())
147
0
        return;
148
149
    // 2. If rangeCount is not 0, abort these steps.
150
0
    if (range_count() != 0)
151
0
        return;
152
153
    // 3. Set this's range to range by a strong reference (not by making a copy).
154
0
    set_range(range);
155
156
    // AD-HOC: WPT selection/removeAllRanges.html and selection/addRange.htm expect this
157
0
    m_direction = Direction::Forwards;
158
0
}
159
160
// https://w3c.github.io/selection-api/#dom-selection-removerange
161
WebIDL::ExceptionOr<void> Selection::remove_range(JS::NonnullGCPtr<DOM::Range> range)
162
0
{
163
    // The method must make this empty by disassociating its range if this's range is range.
164
0
    if (m_range == range) {
165
0
        set_range(nullptr);
166
0
        return {};
167
0
    }
168
169
    // Otherwise, it must throw a NotFoundError.
170
0
    return WebIDL::NotFoundError::create(realm(), "Selection.removeRange() with invalid argument"_string);
171
0
}
172
173
// https://w3c.github.io/selection-api/#dom-selection-removeallranges
174
void Selection::remove_all_ranges()
175
0
{
176
    // The method must make this empty by disassociating its range if this has an associated range.
177
0
    set_range(nullptr);
178
0
}
179
180
// https://w3c.github.io/selection-api/#dom-selection-empty
181
void Selection::empty()
182
0
{
183
    // The method must be an alias, and behave identically, to removeAllRanges().
184
0
    remove_all_ranges();
185
0
}
186
187
// https://w3c.github.io/selection-api/#dom-selection-collapse
188
WebIDL::ExceptionOr<void> Selection::collapse(JS::GCPtr<DOM::Node> node, unsigned offset)
189
0
{
190
    // 1. If node is null, this method must behave identically as removeAllRanges() and abort these steps.
191
0
    if (!node) {
192
0
        remove_all_ranges();
193
0
        return {};
194
0
    }
195
196
    // 2. If node is a DocumentType, throw an InvalidNodeTypeError exception and abort these steps.
197
0
    if (node->is_document_type())
198
0
        return WebIDL::InvalidNodeTypeError::create(realm(), "Selection.collapse() with DocumentType node"_string);
199
200
    // 3. The method must throw an IndexSizeError exception if offset is longer than node's length and abort these steps.
201
0
    if (offset > node->length())
202
0
        return WebIDL::IndexSizeError::create(realm(), "Selection.collapse() with offset longer than node's length"_string);
203
204
    // 4. If document associated with this is not a shadow-including inclusive ancestor of node, abort these steps.
205
0
    if (!m_document->is_shadow_including_inclusive_ancestor_of(*node))
206
0
        return {};
207
208
    // 5. Otherwise, let newRange be a new range.
209
0
    auto new_range = DOM::Range::create(*m_document);
210
211
    // 6. Set the start the start and the end of newRange to (node, offset).
212
0
    TRY(new_range->set_start(*node, offset));
213
214
    // 7. Set this's range to newRange.
215
0
    set_range(new_range);
216
217
0
    return {};
218
0
}
219
220
// https://w3c.github.io/selection-api/#dom-selection-setposition
221
WebIDL::ExceptionOr<void> Selection::set_position(JS::GCPtr<DOM::Node> node, unsigned offset)
222
0
{
223
    // The method must be an alias, and behave identically, to collapse().
224
0
    return collapse(node, offset);
225
0
}
226
227
// https://w3c.github.io/selection-api/#dom-selection-collapsetostart
228
WebIDL::ExceptionOr<void> Selection::collapse_to_start()
229
0
{
230
    // 1. The method must throw InvalidStateError exception if the this is empty.
231
0
    if (!m_range) {
232
0
        return WebIDL::InvalidStateError::create(realm(), "Selection.collapse_to_start() on empty range"_string);
233
0
    }
234
235
    // 2. Otherwise, it must create a new range
236
0
    auto new_range = DOM::Range::create(*m_document);
237
238
    // 3. Set the start both its start and end to the start of this's range
239
0
    TRY(new_range->set_start(*anchor_node(), m_range->start_offset()));
240
0
    TRY(new_range->set_end(*anchor_node(), m_range->start_offset()));
241
242
    // 4. Then set this's range to the newly-created range.
243
0
    set_range(new_range);
244
0
    return {};
245
0
}
246
247
// https://w3c.github.io/selection-api/#dom-selection-collapsetoend
248
WebIDL::ExceptionOr<void> Selection::collapse_to_end()
249
0
{
250
    // 1. The method must throw InvalidStateError exception if the this is empty.
251
0
    if (!m_range) {
252
0
        return WebIDL::InvalidStateError::create(realm(), "Selection.collapse_to_end() on empty range"_string);
253
0
    }
254
255
    // 2. Otherwise, it must create a new range
256
0
    auto new_range = DOM::Range::create(*m_document);
257
258
    // 3. Set the start both its start and end to the start of this's range
259
0
    TRY(new_range->set_start(*anchor_node(), m_range->end_offset()));
260
0
    TRY(new_range->set_end(*anchor_node(), m_range->end_offset()));
261
262
    // 4. Then set this's range to the newly-created range.
263
0
    set_range(new_range);
264
265
0
    return {};
266
0
}
267
268
// https://w3c.github.io/selection-api/#dom-selection-extend
269
WebIDL::ExceptionOr<void> Selection::extend(JS::NonnullGCPtr<DOM::Node> node, unsigned offset)
270
0
{
271
    // 1. If the document associated with this is not a shadow-including inclusive ancestor of node, abort these steps.
272
0
    if (!m_document->is_shadow_including_inclusive_ancestor_of(node))
273
0
        return {};
274
275
    // 2. If this is empty, throw an InvalidStateError exception and abort these steps.
276
0
    if (!m_range) {
277
0
        return WebIDL::InvalidStateError::create(realm(), "Selection.extend() on empty range"_string);
278
0
    }
279
280
    // 3. Let oldAnchor and oldFocus be the this's anchor and focus, and let newFocus be the boundary point (node, offset).
281
0
    auto& old_anchor_node = *anchor_node();
282
0
    auto old_anchor_offset = anchor_offset();
283
284
0
    auto& new_focus_node = node;
285
0
    auto new_focus_offset = offset;
286
287
    // 4. Let newRange be a new range.
288
0
    auto new_range = DOM::Range::create(*m_document);
289
290
    // 5. If node's root is not the same as the this's range's root, set the start newRange's start and end to newFocus.
291
0
    if (&node->root() != &m_range->start_container()->root()) {
292
0
        TRY(new_range->set_start(new_focus_node, new_focus_offset));
293
0
        TRY(new_range->set_end(new_focus_node, new_focus_offset));
294
0
    }
295
    // 6. Otherwise, if oldAnchor is before or equal to newFocus, set the start newRange's start to oldAnchor, then set its end to newFocus.
296
0
    else if (position_of_boundary_point_relative_to_other_boundary_point(old_anchor_node, old_anchor_offset, new_focus_node, new_focus_offset) != DOM::RelativeBoundaryPointPosition::After) {
297
0
        TRY(new_range->set_start(old_anchor_node, old_anchor_offset));
298
0
        TRY(new_range->set_end(new_focus_node, new_focus_offset));
299
0
    }
300
    // 7. Otherwise, set the start newRange's start to newFocus, then set its end to oldAnchor.
301
0
    else {
302
0
        TRY(new_range->set_start(new_focus_node, new_focus_offset));
303
0
        TRY(new_range->set_end(old_anchor_node, old_anchor_offset));
304
0
    }
305
306
    // 8. Set this's range to newRange.
307
0
    set_range(new_range);
308
309
    // 9. If newFocus is before oldAnchor, set this's direction to backwards. Otherwise, set it to forwards.
310
0
    if (position_of_boundary_point_relative_to_other_boundary_point(new_focus_node, new_focus_offset, old_anchor_node, old_anchor_offset) == DOM::RelativeBoundaryPointPosition::Before) {
311
0
        m_direction = Direction::Backwards;
312
0
    } else {
313
0
        m_direction = Direction::Forwards;
314
0
    }
315
316
0
    return {};
317
0
}
318
319
// https://w3c.github.io/selection-api/#dom-selection-setbaseandextent
320
WebIDL::ExceptionOr<void> Selection::set_base_and_extent(JS::NonnullGCPtr<DOM::Node> anchor_node, unsigned anchor_offset, JS::NonnullGCPtr<DOM::Node> focus_node, unsigned focus_offset)
321
0
{
322
    // 1. If anchorOffset is longer than anchorNode's length or if focusOffset is longer than focusNode's length, throw an IndexSizeError exception and abort these steps.
323
0
    if (anchor_offset > anchor_node->length())
324
0
        return WebIDL::IndexSizeError::create(realm(), "Anchor offset points outside of the anchor node"_string);
325
326
0
    if (focus_offset > focus_node->length())
327
0
        return WebIDL::IndexSizeError::create(realm(), "Focus offset points outside of the focus node"_string);
328
329
    // 2. If document associated with this is not a shadow-including inclusive ancestor of anchorNode or focusNode, abort these steps.
330
0
    if (!m_document->is_shadow_including_inclusive_ancestor_of(anchor_node) || !m_document->is_shadow_including_inclusive_ancestor_of(focus_node))
331
0
        return {};
332
333
    // 3. Let anchor be the boundary point (anchorNode, anchorOffset) and let focus be the boundary point (focusNode, focusOffset).
334
335
    // 4. Let newRange be a new range.
336
0
    auto new_range = DOM::Range::create(*m_document);
337
338
    // 5. If anchor is before focus, set the start the newRange's start to anchor and its end to focus. Otherwise, set the start them to focus and anchor respectively.
339
0
    auto position_of_anchor_relative_to_focus = DOM::position_of_boundary_point_relative_to_other_boundary_point(anchor_node, anchor_offset, focus_node, focus_offset);
340
0
    if (position_of_anchor_relative_to_focus == DOM::RelativeBoundaryPointPosition::Before) {
341
0
        TRY(new_range->set_start(anchor_node, anchor_offset));
342
0
        TRY(new_range->set_end(focus_node, focus_offset));
343
0
    } else {
344
0
        TRY(new_range->set_start(focus_node, focus_offset));
345
0
        TRY(new_range->set_end(anchor_node, anchor_offset));
346
0
    }
347
348
    // 6. Set this's range to newRange.
349
0
    set_range(new_range);
350
351
    // 7. If focus is before anchor, set this's direction to backwards. Otherwise, set it to forwards
352
    // NOTE: "Otherwise" can be seen as "focus is equal to or after anchor".
353
0
    if (position_of_anchor_relative_to_focus == DOM::RelativeBoundaryPointPosition::After)
354
0
        m_direction = Direction::Backwards;
355
0
    else
356
0
        m_direction = Direction::Forwards;
357
358
0
    return {};
359
0
}
360
361
// https://w3c.github.io/selection-api/#dom-selection-selectallchildren
362
WebIDL::ExceptionOr<void> Selection::select_all_children(JS::NonnullGCPtr<DOM::Node> node)
363
0
{
364
    // 1. If node is a DocumentType, throw an InvalidNodeTypeError exception and abort these steps.
365
0
    if (node->is_document_type())
366
0
        return WebIDL::InvalidNodeTypeError::create(realm(), "Selection.selectAllChildren() with DocumentType node"_string);
367
368
    // 2. If node's root is not the document associated with this, abort these steps.
369
0
    if (&node->root() != m_document.ptr())
370
0
        return {};
371
372
    // 3. Let newRange be a new range and childCount be the number of children of node.
373
0
    auto new_range = DOM::Range::create(*m_document);
374
0
    auto child_count = node->child_count();
375
376
    // 4. Set newRange's start to (node, 0).
377
0
    TRY(new_range->set_start(node, 0));
378
379
    // 5. Set newRange's end to (node, childCount).
380
0
    TRY(new_range->set_end(node, child_count));
381
382
    // 6. Set this's range to newRange.
383
0
    set_range(new_range);
384
385
    // 7. Set this's direction to forwards.
386
0
    m_direction = Direction::Forwards;
387
388
0
    return {};
389
0
}
390
391
// https://w3c.github.io/selection-api/#dom-selection-deletefromdocument
392
WebIDL::ExceptionOr<void> Selection::delete_from_document()
393
0
{
394
    // The method must invoke deleteContents() on this's range if this is not empty.
395
    // Otherwise the method must do nothing.
396
0
    if (!is_empty())
397
0
        return m_range->delete_contents();
398
0
    return {};
399
0
}
400
401
// https://w3c.github.io/selection-api/#dom-selection-containsnode
402
bool Selection::contains_node(JS::NonnullGCPtr<DOM::Node> node, bool allow_partial_containment) const
403
0
{
404
    // The method must return false if this is empty or if node's root is not the document associated with this.
405
0
    if (!m_range)
406
0
        return false;
407
0
    if (&node->root() != m_document.ptr())
408
0
        return false;
409
410
    // Otherwise, if allowPartialContainment is false, the method must return true if and only if
411
    // start of its range is before or visually equivalent to the first boundary point in the node
412
    // and end of its range is after or visually equivalent to the last boundary point in the node.
413
0
    if (!allow_partial_containment) {
414
0
        auto start_relative_position = DOM::position_of_boundary_point_relative_to_other_boundary_point(
415
0
            *m_range->start_container(),
416
0
            m_range->start_offset(),
417
0
            node,
418
0
            0);
419
0
        auto end_relative_position = DOM::position_of_boundary_point_relative_to_other_boundary_point(
420
0
            *m_range->end_container(),
421
0
            m_range->end_offset(),
422
0
            node,
423
0
            node->length());
424
425
0
        return (start_relative_position == DOM::RelativeBoundaryPointPosition::Before || start_relative_position == DOM::RelativeBoundaryPointPosition::Equal)
426
0
            && (end_relative_position == DOM::RelativeBoundaryPointPosition::Equal || end_relative_position == DOM::RelativeBoundaryPointPosition::After);
427
0
    }
428
429
    // If allowPartialContainment is true, the method must return true if and only if
430
    // start of its range is before or visually equivalent to the last boundary point in the node
431
    // and end of its range is after or visually equivalent to the first boundary point in the node.
432
433
0
    auto start_relative_position = DOM::position_of_boundary_point_relative_to_other_boundary_point(
434
0
        *m_range->start_container(),
435
0
        m_range->start_offset(),
436
0
        node,
437
0
        node->length());
438
0
    auto end_relative_position = DOM::position_of_boundary_point_relative_to_other_boundary_point(
439
0
        *m_range->end_container(),
440
0
        m_range->end_offset(),
441
0
        node,
442
0
        0);
443
444
0
    return (start_relative_position == DOM::RelativeBoundaryPointPosition::Before || start_relative_position == DOM::RelativeBoundaryPointPosition::Equal)
445
0
        && (end_relative_position == DOM::RelativeBoundaryPointPosition::Equal || end_relative_position == DOM::RelativeBoundaryPointPosition::After);
446
0
}
447
448
String Selection::to_string() const
449
0
{
450
    // FIXME: This needs more work to be compatible with other engines.
451
    //        See https://www.w3.org/Bugs/Public/show_bug.cgi?id=10583
452
0
    if (!m_range)
453
0
        return String {};
454
0
    return m_range->to_string();
455
0
}
456
457
JS::NonnullGCPtr<DOM::Document> Selection::document() const
458
0
{
459
0
    return m_document;
460
0
}
461
462
JS::GCPtr<DOM::Range> Selection::range() const
463
0
{
464
0
    return m_range;
465
0
}
466
467
void Selection::set_range(JS::GCPtr<DOM::Range> range)
468
0
{
469
0
    if (m_range == range)
470
0
        return;
471
472
0
    if (m_range)
473
0
        m_range->set_associated_selection({}, nullptr);
474
475
0
    m_range = range;
476
477
0
    if (m_range)
478
0
        m_range->set_associated_selection({}, this);
479
0
}
480
481
}