/src/serenity/Userland/Libraries/LibWeb/CSS/CSSStyleSheet.cpp
Line | Count | Source |
1 | | /* |
2 | | * Copyright (c) 2019-2022, Andreas Kling <kling@serenityos.org> |
3 | | * Copyright (c) 2022-2024, Sam Atkins <sam@ladybird.org> |
4 | | * Copyright (c) 2024, Tim Ledbetter <timledbetter@gmail.com> |
5 | | * |
6 | | * SPDX-License-Identifier: BSD-2-Clause |
7 | | */ |
8 | | |
9 | | #include <LibWeb/Bindings/CSSStyleSheetPrototype.h> |
10 | | #include <LibWeb/Bindings/Intrinsics.h> |
11 | | #include <LibWeb/CSS/CSSImportRule.h> |
12 | | #include <LibWeb/CSS/CSSStyleSheet.h> |
13 | | #include <LibWeb/CSS/Parser/Parser.h> |
14 | | #include <LibWeb/CSS/StyleComputer.h> |
15 | | #include <LibWeb/CSS/StyleSheetList.h> |
16 | | #include <LibWeb/DOM/Document.h> |
17 | | #include <LibWeb/HTML/Window.h> |
18 | | #include <LibWeb/Platform/EventLoopPlugin.h> |
19 | | #include <LibWeb/WebIDL/ExceptionOr.h> |
20 | | |
21 | | namespace Web::CSS { |
22 | | |
23 | | JS_DEFINE_ALLOCATOR(CSSStyleSheet); |
24 | | |
25 | | JS::NonnullGCPtr<CSSStyleSheet> CSSStyleSheet::create(JS::Realm& realm, CSSRuleList& rules, MediaList& media, Optional<URL::URL> location) |
26 | 0 | { |
27 | 0 | return realm.heap().allocate<CSSStyleSheet>(realm, realm, rules, media, move(location)); |
28 | 0 | } |
29 | | |
30 | | // https://drafts.csswg.org/cssom/#dom-cssstylesheet-cssstylesheet |
31 | | WebIDL::ExceptionOr<JS::NonnullGCPtr<CSSStyleSheet>> CSSStyleSheet::construct_impl(JS::Realm& realm, Optional<CSSStyleSheetInit> const& options) |
32 | 0 | { |
33 | | // 1. Construct a new CSSStyleSheet object sheet. |
34 | 0 | auto sheet = create(realm, CSSRuleList::create_empty(realm), CSS::MediaList::create(realm, {}), {}); |
35 | | |
36 | | // 2. Set sheet’s location to the base URL of the associated Document for the current global object. |
37 | 0 | auto associated_document = verify_cast<HTML::Window>(HTML::current_global_object()).document(); |
38 | 0 | sheet->set_location(MUST(associated_document->base_url().to_string())); |
39 | | |
40 | | // 3. Set sheet’s stylesheet base URL to the baseURL attribute value from options. |
41 | 0 | if (options.has_value() && options->base_url.has_value()) { |
42 | 0 | Optional<URL::URL> sheet_location_url; |
43 | 0 | if (sheet->location().has_value()) |
44 | 0 | sheet_location_url = sheet->location().release_value(); |
45 | | |
46 | | // AD-HOC: This isn't explicitly mentioned in the specification, but multiple modern browsers do this. |
47 | 0 | URL::URL url = sheet->location().has_value() ? sheet_location_url->complete_url(options->base_url.value()) : options->base_url.value(); |
48 | 0 | if (!url.is_valid()) |
49 | 0 | return WebIDL::NotAllowedError::create(realm, "Constructed style sheets must have a valid base URL"_string); |
50 | | |
51 | 0 | sheet->set_base_url(url); |
52 | 0 | } |
53 | | |
54 | | // 4. Set sheet’s parent CSS style sheet to null. |
55 | 0 | sheet->set_parent_css_style_sheet(nullptr); |
56 | | |
57 | | // 5. Set sheet’s owner node to null. |
58 | 0 | sheet->set_owner_node(nullptr); |
59 | | |
60 | | // 6. Set sheet’s owner CSS rule to null. |
61 | 0 | sheet->set_owner_css_rule(nullptr); |
62 | | |
63 | | // 7. Set sheet’s title to the the empty string. |
64 | 0 | sheet->set_title(String {}); |
65 | | |
66 | | // 8. Unset sheet’s alternate flag. |
67 | 0 | sheet->set_alternate(false); |
68 | | |
69 | | // 9. Set sheet’s origin-clean flag. |
70 | 0 | sheet->set_origin_clean(true); |
71 | | |
72 | | // 10. Set sheet’s constructed flag. |
73 | 0 | sheet->set_constructed(true); |
74 | | |
75 | | // 11. Set sheet’s Constructor document to the associated Document for the current global object. |
76 | 0 | sheet->set_constructor_document(associated_document); |
77 | | |
78 | | // 12. If the media attribute of options is a string, create a MediaList object from the string and assign it as sheet’s media. |
79 | | // Otherwise, serialize a media query list from the attribute and then create a MediaList object from the resulting string and set it as sheet’s media. |
80 | 0 | if (options.has_value()) { |
81 | 0 | if (options->media.has<String>()) { |
82 | 0 | sheet->set_media(options->media.get<String>()); |
83 | 0 | } else { |
84 | 0 | sheet->m_media = *options->media.get<JS::Handle<MediaList>>(); |
85 | 0 | } |
86 | 0 | } |
87 | | |
88 | | // 13. If the disabled attribute of options is true, set sheet’s disabled flag. |
89 | 0 | if (options.has_value() && options->disabled) |
90 | 0 | sheet->set_disabled(true); |
91 | | |
92 | | // 14. Return sheet |
93 | 0 | return sheet; |
94 | 0 | } |
95 | | |
96 | | CSSStyleSheet::CSSStyleSheet(JS::Realm& realm, CSSRuleList& rules, MediaList& media, Optional<URL::URL> location) |
97 | 0 | : StyleSheet(realm, media) |
98 | 0 | , m_rules(&rules) |
99 | 0 | { |
100 | 0 | if (location.has_value()) |
101 | 0 | set_location(MUST(location->to_string())); |
102 | |
|
103 | 0 | for (auto& rule : *m_rules) |
104 | 0 | rule->set_parent_style_sheet(this); |
105 | |
|
106 | 0 | recalculate_rule_caches(); |
107 | |
|
108 | 0 | m_rules->on_change = [this]() { |
109 | 0 | recalculate_rule_caches(); |
110 | 0 | }; |
111 | 0 | } |
112 | | |
113 | | void CSSStyleSheet::initialize(JS::Realm& realm) |
114 | 0 | { |
115 | 0 | Base::initialize(realm); |
116 | 0 | WEB_SET_PROTOTYPE_FOR_INTERFACE(CSSStyleSheet); |
117 | 0 | } |
118 | | |
119 | | void CSSStyleSheet::visit_edges(Cell::Visitor& visitor) |
120 | 0 | { |
121 | 0 | Base::visit_edges(visitor); |
122 | 0 | visitor.visit(m_style_sheet_list); |
123 | 0 | visitor.visit(m_rules); |
124 | 0 | visitor.visit(m_owner_css_rule); |
125 | 0 | visitor.visit(m_default_namespace_rule); |
126 | 0 | visitor.visit(m_constructor_document); |
127 | 0 | visitor.visit(m_namespace_rules); |
128 | 0 | visitor.visit(m_import_rules); |
129 | 0 | } |
130 | | |
131 | | // https://www.w3.org/TR/cssom/#dom-cssstylesheet-insertrule |
132 | | WebIDL::ExceptionOr<unsigned> CSSStyleSheet::insert_rule(StringView rule, unsigned index) |
133 | 0 | { |
134 | | // FIXME: 1. If the origin-clean flag is unset, throw a SecurityError exception. |
135 | | |
136 | | // If the disallow modification flag is set, throw a NotAllowedError DOMException. |
137 | 0 | if (disallow_modification()) |
138 | 0 | return WebIDL::NotAllowedError::create(realm(), "Can't call insert_rule() on non-modifiable stylesheets."_string); |
139 | | |
140 | | // 3. Let parsed rule be the return value of invoking parse a rule with rule. |
141 | 0 | auto context = m_style_sheet_list ? CSS::Parser::ParsingContext { m_style_sheet_list->document() } : CSS::Parser::ParsingContext { realm() }; |
142 | 0 | auto parsed_rule = parse_css_rule(context, rule); |
143 | | |
144 | | // 4. If parsed rule is a syntax error, return parsed rule. |
145 | 0 | if (!parsed_rule) |
146 | 0 | return WebIDL::SyntaxError::create(realm(), "Unable to parse CSS rule."_string); |
147 | | |
148 | | // 5. If parsed rule is an @import rule, and the constructed flag is set, throw a SyntaxError DOMException. |
149 | 0 | if (constructed() && parsed_rule->type() == CSSRule::Type::Import) |
150 | 0 | return WebIDL::SyntaxError::create(realm(), "Can't insert @import rules into a constructed stylesheet."_string); |
151 | | |
152 | | // 6. Return the result of invoking insert a CSS rule rule in the CSS rules at index. |
153 | 0 | auto result = m_rules->insert_a_css_rule(parsed_rule, index); |
154 | |
|
155 | 0 | if (!result.is_exception()) { |
156 | | // NOTE: The spec doesn't say where to set the parent style sheet, so we'll do it here. |
157 | 0 | parsed_rule->set_parent_style_sheet(this); |
158 | |
|
159 | 0 | if (m_style_sheet_list) { |
160 | 0 | m_style_sheet_list->document().style_computer().invalidate_rule_cache(); |
161 | 0 | m_style_sheet_list->document_or_shadow_root().invalidate_style(DOM::StyleInvalidationReason::StyleSheetInsertRule); |
162 | 0 | } |
163 | 0 | } |
164 | |
|
165 | 0 | return result; |
166 | 0 | } |
167 | | |
168 | | // https://www.w3.org/TR/cssom/#dom-cssstylesheet-deleterule |
169 | | WebIDL::ExceptionOr<void> CSSStyleSheet::delete_rule(unsigned index) |
170 | 0 | { |
171 | | // FIXME: 1. If the origin-clean flag is unset, throw a SecurityError exception. |
172 | | |
173 | | // 2. If the disallow modification flag is set, throw a NotAllowedError DOMException. |
174 | 0 | if (disallow_modification()) |
175 | 0 | return WebIDL::NotAllowedError::create(realm(), "Can't call delete_rule() on non-modifiable stylesheets."_string); |
176 | | |
177 | | // 3. Remove a CSS rule in the CSS rules at index. |
178 | 0 | auto result = m_rules->remove_a_css_rule(index); |
179 | 0 | if (!result.is_exception()) { |
180 | 0 | if (m_style_sheet_list) { |
181 | 0 | m_style_sheet_list->document().style_computer().invalidate_rule_cache(); |
182 | 0 | m_style_sheet_list->document_or_shadow_root().invalidate_style(DOM::StyleInvalidationReason::StyleSheetDeleteRule); |
183 | 0 | } |
184 | 0 | } |
185 | 0 | return result; |
186 | 0 | } |
187 | | |
188 | | // https://drafts.csswg.org/cssom/#dom-cssstylesheet-replace |
189 | | JS::NonnullGCPtr<JS::Promise> CSSStyleSheet::replace(String text) |
190 | 0 | { |
191 | | // 1. Let promise be a promise |
192 | 0 | auto promise = JS::Promise::create(realm()); |
193 | | |
194 | | // 2. If the constructed flag is not set, or the disallow modification flag is set, reject promise with a NotAllowedError DOMException and return promise. |
195 | 0 | if (!constructed()) { |
196 | 0 | promise->reject(WebIDL::NotAllowedError::create(realm(), "Can't call replace() on non-constructed stylesheets"_string)); |
197 | 0 | return promise; |
198 | 0 | } |
199 | | |
200 | 0 | if (disallow_modification()) { |
201 | 0 | promise->reject(WebIDL::NotAllowedError::create(realm(), "Can't call replace() on non-modifiable stylesheets"_string)); |
202 | 0 | return promise; |
203 | 0 | } |
204 | | |
205 | | // 3. Set the disallow modification flag. |
206 | 0 | set_disallow_modification(true); |
207 | | |
208 | | // 4. In parallel, do these steps: |
209 | 0 | Platform::EventLoopPlugin::the().deferred_invoke([this, text = move(text), promise] { |
210 | | // 1. Let rules be the result of running parse a stylesheet’s contents from text. |
211 | 0 | auto context = m_style_sheet_list ? CSS::Parser::ParsingContext { m_style_sheet_list->document() } : CSS::Parser::ParsingContext { realm() }; |
212 | 0 | auto* parsed_stylesheet = parse_css_stylesheet(context, text); |
213 | 0 | auto& rules = parsed_stylesheet->rules(); |
214 | | |
215 | | // 2. If rules contains one or more @import rules, remove those rules from rules. |
216 | 0 | JS::MarkedVector<JS::NonnullGCPtr<CSSRule>> rules_without_import(realm().heap()); |
217 | 0 | for (auto rule : rules) { |
218 | 0 | if (rule->type() != CSSRule::Type::Import) |
219 | 0 | rules_without_import.append(rule); |
220 | 0 | } |
221 | | |
222 | | // 3. Set sheet’s CSS rules to rules. |
223 | 0 | m_rules->set_rules({}, rules_without_import); |
224 | | |
225 | | // 4. Unset sheet’s disallow modification flag. |
226 | 0 | set_disallow_modification(false); |
227 | | |
228 | | // 5. Resolve promise with sheet. |
229 | 0 | promise->fulfill(this); |
230 | 0 | }); |
231 | |
|
232 | 0 | return promise; |
233 | 0 | } |
234 | | |
235 | | // https://drafts.csswg.org/cssom/#dom-cssstylesheet-replacesync |
236 | | WebIDL::ExceptionOr<void> CSSStyleSheet::replace_sync(StringView text) |
237 | 0 | { |
238 | | // 1. If the constructed flag is not set, or the disallow modification flag is set, throw a NotAllowedError DOMException. |
239 | 0 | if (!constructed()) |
240 | 0 | return WebIDL::NotAllowedError::create(realm(), "Can't call replaceSync() on non-constructed stylesheets"_string); |
241 | 0 | if (disallow_modification()) |
242 | 0 | return WebIDL::NotAllowedError::create(realm(), "Can't call replaceSync() on non-modifiable stylesheets"_string); |
243 | | |
244 | | // 2. Let rules be the result of running parse a stylesheet’s contents from text. |
245 | 0 | auto context = m_style_sheet_list ? CSS::Parser::ParsingContext { m_style_sheet_list->document() } : CSS::Parser::ParsingContext { realm() }; |
246 | 0 | auto* parsed_stylesheet = parse_css_stylesheet(context, text); |
247 | 0 | auto& rules = parsed_stylesheet->rules(); |
248 | | |
249 | | // 3. If rules contains one or more @import rules, remove those rules from rules. |
250 | 0 | JS::MarkedVector<JS::NonnullGCPtr<CSSRule>> rules_without_import(realm().heap()); |
251 | 0 | for (auto rule : rules) { |
252 | 0 | if (rule->type() != CSSRule::Type::Import) |
253 | 0 | rules_without_import.append(rule); |
254 | 0 | } |
255 | | |
256 | | // 4.Set sheet’s CSS rules to rules. |
257 | 0 | m_rules->set_rules({}, rules_without_import); |
258 | |
|
259 | 0 | return {}; |
260 | 0 | } |
261 | | |
262 | | // https://drafts.csswg.org/cssom/#dom-cssstylesheet-addrule |
263 | | WebIDL::ExceptionOr<WebIDL::Long> CSSStyleSheet::add_rule(Optional<String> selector, Optional<String> style, Optional<WebIDL::UnsignedLong> index) |
264 | 0 | { |
265 | | // 1. Let rule be an empty string. |
266 | 0 | StringBuilder rule; |
267 | | |
268 | | // 2. Append selector to rule. |
269 | 0 | if (selector.has_value()) |
270 | 0 | rule.append(selector.release_value()); |
271 | | |
272 | | // 3. Append " { " to rule. |
273 | 0 | rule.append('{'); |
274 | | |
275 | | // 4. If block is not empty, append block, followed by a space, to rule. |
276 | 0 | if (style.has_value() && !style->is_empty()) |
277 | 0 | rule.appendff("{} ", style.release_value()); |
278 | | |
279 | | // 5. Append "}" to rule. |
280 | 0 | rule.append('}'); |
281 | | |
282 | | // 6. Let index be optionalIndex if provided, or the number of CSS rules in the stylesheet otherwise. |
283 | | // 7. Call insertRule(), with rule and index as arguments. |
284 | 0 | TRY(insert_rule(rule.string_view(), index.value_or(rules().length()))); |
285 | | |
286 | | // 8. Return -1. |
287 | 0 | return -1; |
288 | 0 | } |
289 | | |
290 | | // https://www.w3.org/TR/cssom/#dom-cssstylesheet-removerule |
291 | | WebIDL::ExceptionOr<void> CSSStyleSheet::remove_rule(Optional<WebIDL::UnsignedLong> index) |
292 | 0 | { |
293 | | // The removeRule(index) method must run the same steps as deleteRule(). |
294 | 0 | return delete_rule(index.value_or(0)); |
295 | 0 | } |
296 | | |
297 | | void CSSStyleSheet::for_each_effective_rule(TraversalOrder order, Function<void(Web::CSS::CSSRule const&)> const& callback) const |
298 | 0 | { |
299 | 0 | if (m_media->matches()) |
300 | 0 | m_rules->for_each_effective_rule(order, callback); |
301 | 0 | } |
302 | | |
303 | | void CSSStyleSheet::for_each_effective_style_producing_rule(Function<void(CSSRule const&)> const& callback) const |
304 | 0 | { |
305 | 0 | for_each_effective_rule(TraversalOrder::Preorder, [&](CSSRule const& rule) { |
306 | 0 | if (rule.type() == CSSRule::Type::Style || rule.type() == CSSRule::Type::NestedDeclarations) |
307 | 0 | callback(rule); |
308 | 0 | }); |
309 | 0 | } |
310 | | |
311 | | void CSSStyleSheet::for_each_effective_keyframes_at_rule(Function<void(CSSKeyframesRule const&)> const& callback) const |
312 | 0 | { |
313 | 0 | for_each_effective_rule(TraversalOrder::Preorder, [&](CSSRule const& rule) { |
314 | 0 | if (rule.type() == CSSRule::Type::Keyframes) |
315 | 0 | callback(static_cast<CSSKeyframesRule const&>(rule)); |
316 | 0 | }); |
317 | 0 | } |
318 | | |
319 | | bool CSSStyleSheet::evaluate_media_queries(HTML::Window const& window) |
320 | 0 | { |
321 | 0 | bool any_media_queries_changed_match_state = false; |
322 | |
|
323 | 0 | bool now_matches = m_media->evaluate(window); |
324 | 0 | if (!m_did_match.has_value() || m_did_match.value() != now_matches) |
325 | 0 | any_media_queries_changed_match_state = true; |
326 | 0 | if (now_matches && m_rules->evaluate_media_queries(window)) |
327 | 0 | any_media_queries_changed_match_state = true; |
328 | |
|
329 | 0 | m_did_match = now_matches; |
330 | |
|
331 | 0 | return any_media_queries_changed_match_state; |
332 | 0 | } |
333 | | |
334 | | void CSSStyleSheet::set_style_sheet_list(Badge<StyleSheetList>, StyleSheetList* list) |
335 | 0 | { |
336 | 0 | m_style_sheet_list = list; |
337 | 0 | } |
338 | | |
339 | | Optional<FlyString> CSSStyleSheet::default_namespace() const |
340 | 0 | { |
341 | 0 | if (m_default_namespace_rule) |
342 | 0 | return m_default_namespace_rule->namespace_uri(); |
343 | | |
344 | 0 | return {}; |
345 | 0 | } |
346 | | |
347 | | Optional<FlyString> CSSStyleSheet::namespace_uri(StringView namespace_prefix) const |
348 | 0 | { |
349 | 0 | return m_namespace_rules.get(namespace_prefix) |
350 | 0 | .map([](JS::GCPtr<CSSNamespaceRule> namespace_) { |
351 | 0 | return namespace_->namespace_uri(); |
352 | 0 | }); |
353 | 0 | } |
354 | | |
355 | | void CSSStyleSheet::recalculate_rule_caches() |
356 | 0 | { |
357 | 0 | m_default_namespace_rule = nullptr; |
358 | 0 | m_namespace_rules.clear(); |
359 | 0 | m_import_rules.clear(); |
360 | |
|
361 | 0 | for (auto const& rule : *m_rules) { |
362 | | // "Any @import rules must precede all other valid at-rules and style rules in a style sheet |
363 | | // (ignoring @charset and @layer statement rules) and must not have any other valid at-rules |
364 | | // or style rules between it and previous @import rules, or else the @import rule is invalid." |
365 | | // https://drafts.csswg.org/css-cascade-5/#at-import |
366 | | // |
367 | | // "Any @namespace rules must follow all @charset and @import rules and precede all other |
368 | | // non-ignored at-rules and style rules in a style sheet. |
369 | | // ... |
370 | | // A syntactically invalid @namespace rule (whether malformed or misplaced) must be ignored." |
371 | | // https://drafts.csswg.org/css-namespaces/#syntax |
372 | 0 | switch (rule->type()) { |
373 | 0 | case CSSRule::Type::Import: { |
374 | | // @import rules must appear before @namespace rules, so skip this if we've seen @namespace. |
375 | 0 | if (!m_namespace_rules.is_empty()) |
376 | 0 | continue; |
377 | 0 | m_import_rules.append(verify_cast<CSSImportRule>(*rule)); |
378 | 0 | break; |
379 | 0 | } |
380 | 0 | case CSSRule::Type::Namespace: { |
381 | 0 | auto& namespace_rule = verify_cast<CSSNamespaceRule>(*rule); |
382 | 0 | if (!namespace_rule.namespace_uri().is_empty() && namespace_rule.prefix().is_empty()) |
383 | 0 | m_default_namespace_rule = namespace_rule; |
384 | |
|
385 | 0 | m_namespace_rules.set(namespace_rule.prefix(), namespace_rule); |
386 | 0 | break; |
387 | 0 | } |
388 | 0 | default: |
389 | | // Any other types mean that further @namespace rules are invalid, so we can stop here. |
390 | 0 | return; |
391 | 0 | } |
392 | 0 | } |
393 | 0 | } |
394 | | |
395 | | void CSSStyleSheet::set_source_text(String source) |
396 | 0 | { |
397 | 0 | m_source_text = move(source); |
398 | 0 | } |
399 | | |
400 | | Optional<String> CSSStyleSheet::source_text(Badge<DOM::Document>) const |
401 | 0 | { |
402 | 0 | return m_source_text; |
403 | 0 | } |
404 | | |
405 | | bool CSSStyleSheet::has_associated_font_loader(FontLoader& font_loader) const |
406 | 0 | { |
407 | 0 | for (auto& loader : m_associated_font_loaders) { |
408 | 0 | if (loader.ptr() == &font_loader) |
409 | 0 | return true; |
410 | 0 | } |
411 | 0 | return false; |
412 | 0 | } |
413 | | |
414 | | } |