OLD | NEW |
(Empty) | |
| 1 /// Query selector implementation for our DOM. |
| 2 library html.src.query; |
| 3 |
| 4 import 'package:csslib/parser.dart' as css; |
| 5 import 'package:csslib/parser.dart' show TokenKind; |
| 6 import 'package:csslib/visitor.dart'; // the CSSOM |
| 7 import 'package:html/dom.dart'; |
| 8 import 'package:html/src/constants.dart' show isWhitespaceCC; |
| 9 |
| 10 bool matches(Node node, String selector) => |
| 11 new SelectorEvaluator().matches(node, _parseSelectorList(selector)); |
| 12 |
| 13 Element querySelector(Node node, String selector) => |
| 14 new SelectorEvaluator().querySelector(node, _parseSelectorList(selector)); |
| 15 |
| 16 List<Element> querySelectorAll(Node node, String selector) { |
| 17 var results = []; |
| 18 new SelectorEvaluator().querySelectorAll( |
| 19 node, _parseSelectorList(selector), results); |
| 20 return results; |
| 21 } |
| 22 |
| 23 // http://dev.w3.org/csswg/selectors-4/#grouping |
| 24 SelectorGroup _parseSelectorList(String selector) { |
| 25 var errors = []; |
| 26 var group = css.parseSelectorGroup(selector, errors: errors); |
| 27 if (group == null || errors.isNotEmpty) { |
| 28 throw new FormatException("'$selector' is not a valid selector: $errors"); |
| 29 } |
| 30 return group; |
| 31 } |
| 32 |
| 33 class SelectorEvaluator extends Visitor { |
| 34 /// The current HTML element to match against. |
| 35 Element _element; |
| 36 |
| 37 bool matches(Element element, SelectorGroup selector) { |
| 38 _element = element; |
| 39 return visitSelectorGroup(selector); |
| 40 } |
| 41 |
| 42 Element querySelector(Node root, SelectorGroup selector) { |
| 43 for (var node in root.nodes) { |
| 44 if (node is! Element) continue; |
| 45 if (matches(node, selector)) return node; |
| 46 var result = querySelector(node, selector); |
| 47 if (result != null) return result; |
| 48 } |
| 49 return null; |
| 50 } |
| 51 |
| 52 void querySelectorAll( |
| 53 Node root, SelectorGroup selector, List<Element> results) { |
| 54 for (var node in root.nodes) { |
| 55 if (node is! Element) continue; |
| 56 if (matches(node, selector)) results.add(node); |
| 57 querySelectorAll(node, selector, results); |
| 58 } |
| 59 } |
| 60 |
| 61 bool visitSelectorGroup(SelectorGroup group) => |
| 62 group.selectors.any(visitSelector); |
| 63 |
| 64 bool visitSelector(Selector selector) { |
| 65 var old = _element; |
| 66 var result = true; |
| 67 |
| 68 // Note: evaluate selectors right-to-left as it's more efficient. |
| 69 int combinator = null; |
| 70 for (var s in selector.simpleSelectorSequences.reversed) { |
| 71 if (combinator == null) { |
| 72 result = s.simpleSelector.visit(this); |
| 73 } else if (combinator == TokenKind.COMBINATOR_DESCENDANT) { |
| 74 // descendant combinator |
| 75 // http://dev.w3.org/csswg/selectors-4/#descendant-combinators |
| 76 do { |
| 77 _element = _element.parent; |
| 78 } while (_element != null && !s.simpleSelector.visit(this)); |
| 79 |
| 80 if (_element == null) result = false; |
| 81 } else if (combinator == TokenKind.COMBINATOR_TILDE) { |
| 82 // Following-sibling combinator |
| 83 // http://dev.w3.org/csswg/selectors-4/#general-sibling-combinators |
| 84 do { |
| 85 _element = _element.previousElementSibling; |
| 86 } while (_element != null && !s.simpleSelector.visit(this)); |
| 87 |
| 88 if (_element == null) result = false; |
| 89 } |
| 90 |
| 91 if (!result) break; |
| 92 |
| 93 switch (s.combinator) { |
| 94 case TokenKind.COMBINATOR_PLUS: |
| 95 // Next-sibling combinator |
| 96 // http://dev.w3.org/csswg/selectors-4/#adjacent-sibling-combinators |
| 97 _element = _element.previousElementSibling; |
| 98 break; |
| 99 case TokenKind.COMBINATOR_GREATER: |
| 100 // Child combinator |
| 101 // http://dev.w3.org/csswg/selectors-4/#child-combinators |
| 102 _element = _element.parent; |
| 103 break; |
| 104 case TokenKind.COMBINATOR_DESCENDANT: |
| 105 case TokenKind.COMBINATOR_TILDE: |
| 106 // We need to iterate through all siblings or parents. |
| 107 // For now, just remember what the combinator was. |
| 108 combinator = s.combinator; |
| 109 break; |
| 110 case TokenKind.COMBINATOR_NONE: |
| 111 break; |
| 112 default: |
| 113 throw _unsupported(selector); |
| 114 } |
| 115 |
| 116 if (_element == null) { |
| 117 result = false; |
| 118 break; |
| 119 } |
| 120 } |
| 121 |
| 122 _element = old; |
| 123 return result; |
| 124 } |
| 125 |
| 126 _unimplemented(SimpleSelector selector) => new UnimplementedError( |
| 127 "'$selector' selector of type " |
| 128 "${selector.runtimeType} is not implemented"); |
| 129 |
| 130 _unsupported(selector) => |
| 131 new FormatException("'$selector' is not a valid selector"); |
| 132 |
| 133 bool visitPseudoClassSelector(PseudoClassSelector selector) { |
| 134 switch (selector.name) { |
| 135 // http://dev.w3.org/csswg/selectors-4/#structural-pseudos |
| 136 |
| 137 // http://dev.w3.org/csswg/selectors-4/#the-root-pseudo |
| 138 case 'root': |
| 139 // TODO(jmesserly): fix when we have a .ownerDocument pointer |
| 140 // return _element == _element.ownerDocument.rootElement; |
| 141 return _element.localName == 'html' && _element.parentNode == null; |
| 142 |
| 143 // http://dev.w3.org/csswg/selectors-4/#the-empty-pseudo |
| 144 case 'empty': |
| 145 return _element.nodes |
| 146 .any((n) => !(n is Element || n is Text && n.text.isNotEmpty)); |
| 147 |
| 148 // http://dev.w3.org/csswg/selectors-4/#the-blank-pseudo |
| 149 case 'blank': |
| 150 return _element.nodes.any((n) => !(n is Element || |
| 151 n is Text && n.text.runes.any((r) => !isWhitespaceCC(r)))); |
| 152 |
| 153 // http://dev.w3.org/csswg/selectors-4/#the-first-child-pseudo |
| 154 case 'first-child': |
| 155 return _element.previousElementSibling == null; |
| 156 |
| 157 // http://dev.w3.org/csswg/selectors-4/#the-last-child-pseudo |
| 158 case 'last-child': |
| 159 return _element.nextElementSibling == null; |
| 160 |
| 161 // http://dev.w3.org/csswg/selectors-4/#the-only-child-pseudo |
| 162 case 'only-child': |
| 163 return _element.previousElementSibling == null && |
| 164 _element.nextElementSibling == null; |
| 165 |
| 166 // http://dev.w3.org/csswg/selectors-4/#link |
| 167 case 'link': |
| 168 return _element.attributes['href'] != null; |
| 169 |
| 170 case 'visited': |
| 171 // Always return false since we aren't a browser. This is allowed per: |
| 172 // http://dev.w3.org/csswg/selectors-4/#visited-pseudo |
| 173 return false; |
| 174 } |
| 175 |
| 176 // :before, :after, :first-letter/line can't match DOM elements. |
| 177 if (_isLegacyPsuedoClass(selector.name)) return false; |
| 178 |
| 179 throw _unimplemented(selector); |
| 180 } |
| 181 |
| 182 bool visitPseudoElementSelector(PseudoElementSelector selector) { |
| 183 // :before, :after, :first-letter/line can't match DOM elements. |
| 184 if (_isLegacyPsuedoClass(selector.name)) return false; |
| 185 |
| 186 throw _unimplemented(selector); |
| 187 } |
| 188 |
| 189 static bool _isLegacyPsuedoClass(String name) { |
| 190 switch (name) { |
| 191 case 'before': |
| 192 case 'after': |
| 193 case 'first-line': |
| 194 case 'first-letter': |
| 195 return true; |
| 196 default: |
| 197 return false; |
| 198 } |
| 199 } |
| 200 |
| 201 bool visitPseudoElementFunctionSelector(PseudoElementFunctionSelector s) => |
| 202 throw _unimplemented(s); |
| 203 |
| 204 bool visitPseudoClassFunctionSelector(PseudoClassFunctionSelector selector) { |
| 205 switch (selector.name) { |
| 206 // http://dev.w3.org/csswg/selectors-4/#child-index |
| 207 |
| 208 // http://dev.w3.org/csswg/selectors-4/#the-nth-child-pseudo |
| 209 case 'nth-child': |
| 210 // TODO(jmesserly): support An+B syntax too. |
| 211 var exprs = selector.expression.expressions; |
| 212 if (exprs.length == 1 && exprs[0] is LiteralTerm) { |
| 213 LiteralTerm literal = exprs[0]; |
| 214 var parent = _element.parentNode; |
| 215 return parent != null && |
| 216 literal.value > 0 && |
| 217 parent.nodes.indexOf(_element) == literal.value; |
| 218 } |
| 219 break; |
| 220 |
| 221 // http://dev.w3.org/csswg/selectors-4/#the-lang-pseudo |
| 222 case 'lang': |
| 223 // TODO(jmesserly): shouldn't need to get the raw text here, but csslib |
| 224 // gets confused by the "-" in the expression, such as in "es-AR". |
| 225 var toMatch = selector.expression.span.text; |
| 226 var lang = _getInheritedLanguage(_element); |
| 227 // TODO(jmesserly): implement wildcards in level 4 |
| 228 return lang != null && lang.startsWith(toMatch); |
| 229 } |
| 230 throw _unimplemented(selector); |
| 231 } |
| 232 |
| 233 static String _getInheritedLanguage(Node node) { |
| 234 while (node != null) { |
| 235 var lang = node.attributes['lang']; |
| 236 if (lang != null) return lang; |
| 237 node = node.parent; |
| 238 } |
| 239 return null; |
| 240 } |
| 241 |
| 242 bool visitNamespaceSelector(NamespaceSelector selector) { |
| 243 // Match element tag name |
| 244 if (!selector.nameAsSimpleSelector.visit(this)) return false; |
| 245 |
| 246 if (selector.isNamespaceWildcard) return true; |
| 247 |
| 248 if (selector.namespace == '') return _element.namespaceUri == null; |
| 249 |
| 250 throw _unimplemented(selector); |
| 251 } |
| 252 |
| 253 bool visitElementSelector(ElementSelector selector) => |
| 254 selector.isWildcard || _element.localName == selector.name.toLowerCase(); |
| 255 |
| 256 bool visitIdSelector(IdSelector selector) => _element.id == selector.name; |
| 257 |
| 258 bool visitClassSelector(ClassSelector selector) => |
| 259 _element.classes.contains(selector.name); |
| 260 |
| 261 // TODO(jmesserly): negation should support any selectors in level 4, |
| 262 // not just simple selectors. |
| 263 // http://dev.w3.org/csswg/selectors-4/#negation |
| 264 bool visitNegationSelector(NegationSelector selector) => |
| 265 !selector.negationArg.visit(this); |
| 266 |
| 267 bool visitAttributeSelector(AttributeSelector selector) { |
| 268 // Match name first |
| 269 var value = _element.attributes[selector.name.toLowerCase()]; |
| 270 if (value == null) return false; |
| 271 |
| 272 if (selector.operatorKind == TokenKind.NO_MATCH) return true; |
| 273 |
| 274 var select = '${selector.value}'; |
| 275 switch (selector.operatorKind) { |
| 276 case TokenKind.EQUALS: |
| 277 return value == select; |
| 278 case TokenKind.INCLUDES: |
| 279 return value.split(' ').any((v) => v.isNotEmpty && v == select); |
| 280 case TokenKind.DASH_MATCH: |
| 281 return value.startsWith(select) && |
| 282 (value.length == select.length || value[select.length] == '-'); |
| 283 case TokenKind.PREFIX_MATCH: |
| 284 return value.startsWith(select); |
| 285 case TokenKind.SUFFIX_MATCH: |
| 286 return value.endsWith(select); |
| 287 case TokenKind.SUBSTRING_MATCH: |
| 288 return value.contains(select); |
| 289 default: |
| 290 throw _unsupported(selector); |
| 291 } |
| 292 } |
| 293 } |
OLD | NEW |