1 /* 2 * UserInterfaceHighlighter.java 3 * 4 * Copyright (C) 2021 by RStudio, PBC 5 * 6 * Unless you have received this program directly from RStudio pursuant 7 * to the terms of a commercial license agreement with RStudio, then 8 * this program is licensed to you under the terms of version 3 of the 9 * GNU Affero General Public License. This program is distributed WITHOUT 10 * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, 11 * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the 12 * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. 13 * 14 */ 15 package org.rstudio.studio.client.workbench; 16 17 import java.util.ArrayList; 18 import java.util.List; 19 20 import org.rstudio.core.client.Debug; 21 import org.rstudio.core.client.JsVector; 22 import org.rstudio.core.client.StringUtil; 23 import org.rstudio.core.client.command.AppCommand; 24 import org.rstudio.core.client.command.CommandEvent; 25 import org.rstudio.core.client.command.CommandHandler; 26 import org.rstudio.core.client.dom.DOMRect; 27 import org.rstudio.core.client.dom.DomUtils; 28 import org.rstudio.core.client.dom.ElementEx; 29 import org.rstudio.core.client.dom.MutationObserver; 30 import org.rstudio.core.client.events.HighlightEvent; 31 import org.rstudio.core.client.events.HighlightEvent.HighlightQuery; 32 import org.rstudio.studio.client.RStudioGinjector; 33 import org.rstudio.studio.client.application.events.EventBus; 34 import org.rstudio.studio.client.server.ServerError; 35 import org.rstudio.studio.client.server.ServerRequestCallback; 36 import org.rstudio.studio.client.workbench.commands.Commands; 37 import org.rstudio.studio.client.workbench.views.source.model.SourceServerOperations; 38 import com.google.gwt.core.client.GWT; 39 import com.google.gwt.dom.client.Document; 40 import com.google.gwt.core.client.JavaScriptObject; 41 import com.google.gwt.dom.client.Element; 42 import com.google.gwt.dom.client.NodeList; 43 import com.google.gwt.dom.client.Style; 44 import com.google.gwt.dom.client.Style.Unit; 45 import com.google.gwt.dom.client.Style.Visibility; 46 import com.google.gwt.event.shared.HandlerRegistration; 47 import com.google.gwt.resources.client.ClientBundle; 48 import com.google.gwt.resources.client.CssResource; 49 import com.google.gwt.user.client.Command; 50 import com.google.gwt.user.client.Timer; 51 import com.google.gwt.user.client.Window; 52 import com.google.inject.Inject; 53 import com.google.inject.Singleton; 54 55 @Singleton 56 public class UserInterfaceHighlighter 57 implements CommandHandler, 58 HighlightEvent.Handler 59 60 { 61 private static class HighlightPair 62 { HighlightPair(Element monitoredElement, Element highlightedElement)63 public HighlightPair(Element monitoredElement, 64 Element highlightedElement) 65 { 66 this(monitoredElement, highlightedElement, null, null, 0); 67 } 68 HighlightPair(Element monitoredElement, Element highlightedElement, String callback, UserInterfaceHighlighter highlighter, int index)69 public HighlightPair(Element monitoredElement, 70 Element highlightedElement, 71 String callback, 72 UserInterfaceHighlighter highlighter, 73 int index) 74 { 75 monitoredElement_ = monitoredElement; 76 highlightedElement_ = highlightedElement; 77 highlighter_ = highlighter; 78 callback_ = callback; 79 index_ = index; 80 handler_ = highlighter_ != null ? addListener() : null; 81 } 82 getMonitoredElement()83 public Element getMonitoredElement() 84 { 85 return monitoredElement_; 86 } 87 getHighlightElement()88 public Element getHighlightElement() 89 { 90 return highlightedElement_; 91 } 92 clearHandler()93 public void clearHandler() 94 { 95 if (handler_ != null) 96 handler_.removeHandler(); 97 } 98 executeCallback()99 public void executeCallback() 100 { 101 // This method must be called by a single HighlightPair, but because there can be multiple 102 // pairs per query, we need to check if the callback has already executed before proceeding. 103 if(!highlighter_.getCallbackProcessed(index_)) 104 { 105 highlighter_.setCallbackProcessed(index_, true); 106 highlighter_.getServer().executeRCode(callback_, new ServerRequestCallback<String>(){ 107 108 @Override 109 public void onResponseReceived(String results) 110 { 111 // Remove listener from this element and all other elements with the same query 112 highlighter_.clearEvents(); 113 } 114 115 @Override 116 public void onError(ServerError error) 117 { 118 // Remove listener from this element and all other elements with the same query 119 highlighter_.clearEvents(); 120 Debug.logError(error); 121 } 122 }); 123 } 124 } 125 addListener()126 private HandlerRegistration addListener() 127 { 128 final JavaScriptObject function = addEventListener(callback_, monitoredElement_); 129 130 return new HandlerRegistration() 131 { 132 public void removeHandler() 133 { 134 invokeJavaScriptFunction(function); 135 } 136 }; 137 } 138 addEventListener(String code, Element el)139 private native JavaScriptObject addEventListener(String code, Element el)/*-{ 140 var thiz = this; 141 var callback = $entry(function() { 142 thiz.@org.rstudio.studio.client.workbench.UserInterfaceHighlighter.HighlightPair::executeCallback()(); 143 }); 144 el.addEventListener("click", callback, true); 145 el.addEventListener("focus", callback, true); 146 147 return function() { 148 el.removeEventListener("click", callback); 149 el.removeEventListener("focus", callback); 150 }; 151 }-*/; 152 invokeJavaScriptFunction(JavaScriptObject jsFunc)153 private static native void invokeJavaScriptFunction(JavaScriptObject jsFunc)/*-{ 154 jsFunc(); 155 }-*/; 156 157 private final int index_; 158 private final UserInterfaceHighlighter highlighter_; 159 private final Element monitoredElement_; 160 private final Element highlightedElement_; 161 private final String callback_; 162 private final HandlerRegistration handler_; 163 } 164 165 public SourceServerOperations getServer() 166 { 167 return server_; 168 } 169 170 public boolean getCallbackProcessed(int index) 171 { 172 return queryCallbackStatuses_.get(index); 173 } 174 175 public void setCallbackProcessed(int index, boolean value) 176 { 177 queryCallbackStatuses_.set(index, value); 178 } 179 180 public void clearEvents() 181 { 182 for (HighlightPair pair : highlightPairs_) 183 pair.clearHandler(); 184 } 185 186 @Inject 187 public UserInterfaceHighlighter(Commands commands, 188 EventBus events, 189 SourceServerOperations server) 190 { 191 server_ = server; 192 events.addHandler(CommandEvent.TYPE, this); 193 events.addHandler(HighlightEvent.TYPE, this); 194 195 highlightQueries_ = JsVector.createVector(); 196 queryCallbackStatuses_ = new ArrayList<>(); 197 highlightPairs_ = new ArrayList<>(); 198 199 // use a timer that aggressively re-positions the highlight elements. 200 // while we might normally want a more methodological approach here, 201 // there's simply too many things that can cause the position of an 202 // element to change in the DOM, and because we want to react to those 203 // changes as fast as possible (so that the highlight UI remains 'in sync') 204 // we ultimately use an aggressively-scheduled timer. 205 repositionTimer_ = new Timer() 206 { 207 @Override 208 public void run() 209 { 210 repositionHighlighters(); 211 repositionTimer_.schedule(REPOSITION_DELAY_MS); 212 } 213 }; 214 215 // use mutation observer to detect changes to the DOM (primarily, for GWT 216 // popups) and use that as a signal to refresh and reposition highlighters 217 Command callback = () -> refreshHighlighters(); 218 observer_ = 219 new MutationObserver.Builder(callback) 220 .childList(true) 221 .get(); 222 223 observer_.observe(Document.get().getBody()); 224 225 } 226 227 228 // Event Handlers ---- 229 230 @Override 231 public void onCommand(AppCommand command) 232 { 233 } 234 235 @Override 236 public void onHighlight(HighlightEvent event) 237 { 238 highlightQueries_ = event.getData(); 239 queryCallbackStatuses_.clear(); 240 for (int i = 0; i < highlightQueries_.size(); i++) 241 queryCallbackStatuses_.add(false); 242 refreshHighlighters(); 243 repositionTimer_.schedule(REPOSITION_DELAY_MS); 244 } 245 246 247 248 // Private methods ---- 249 250 private void refreshHighlighters() 251 { 252 try 253 { 254 // we will be mutating the DOM here explicitly, but do not 255 // want to be notified of these events (otherwise we risk 256 // getting into an infinite loop). temporarily disable and 257 // then re-enable our mutation observer 258 observer_.disconnect(); 259 removeHighlightElements(); 260 for (int i = 0, n = highlightQueries_.size(); i < n; i++) 261 { 262 HighlightQuery hq = highlightQueries_.get(i); 263 if (queryCallbackStatuses_.get(i)) 264 addHighlightElements(hq.getQuery(), hq.getParent(), new String(), 0); 265 else 266 addHighlightElements(hq.getQuery(), hq.getParent(), hq.getCallback(), i); 267 } 268 } 269 catch (Exception e) 270 { 271 Debug.logException(e); 272 } 273 finally 274 { 275 observer_.observe(Document.get().getBody()); 276 } 277 } 278 279 private void addHighlightElements(String query, int parent, String code, int index) 280 { 281 NodeList<Element> els = DomUtils.querySelectorAll(Document.get().getBody(), query); 282 283 // guard against queries that might select an excessive number 284 // of UI elements 285 int n = Math.min(HIGHLIGHT_ELEMENT_MAX, els.getLength()); 286 if (n == 0) 287 return; 288 289 for (int i = 0; i < n; i++) 290 { 291 // retrieve the requested element (selecting a parent if so requested) 292 Element el = els.getItem(i); 293 for (int j = 0; j < parent; j++) 294 el = el.getParentElement(); 295 296 // create highlight element 297 Element highlightEl = Document.get().createDivElement(); 298 if (!RStudioGinjector.INSTANCE.getUserPrefs().reducedMotion().getValue()) 299 highlightEl.addClassName(RES.styles().highlightEl()); 300 else 301 highlightEl.addClassName(RES.styles().staticHighlightEl()); 302 Document.get().getBody().appendChild(highlightEl); 303 304 // record the pair of elements 305 if (StringUtil.isNullOrEmpty(code)) 306 highlightPairs_.add(new HighlightPair(el, highlightEl)); 307 else 308 highlightPairs_.add(new HighlightPair(el, highlightEl, code, this, index)); 309 } 310 311 repositionTimer_.schedule(REPOSITION_DELAY_MS); 312 repositionHighlighters(); 313 } 314 315 private void removeHighlightElements() 316 { 317 for (HighlightPair pair : highlightPairs_) 318 pair.getHighlightElement().removeFromParent(); 319 clearEvents(); 320 321 repositionTimer_.cancel(); 322 highlightPairs_.clear(); 323 } 324 325 private void repositionHighlighters() 326 { 327 int scrollX = Window.getScrollLeft(); 328 int scrollY = Window.getScrollTop(); 329 330 for (HighlightPair pair : highlightPairs_) 331 { 332 Element monitoredEl = pair.getMonitoredElement(); 333 Element highlightEl = pair.getHighlightElement(); 334 335 if (monitoredEl == null) 336 { 337 highlightEl.getStyle().setVisibility(Visibility.HIDDEN); 338 continue; 339 } 340 341 // Ensure highlight displays above requested element. 342 if (StringUtil.isNullOrEmpty(highlightEl.getStyle().getZIndex())) 343 { 344 DomUtils.findParentElement(monitoredEl, (Element el) -> { 345 346 Style style = DomUtils.getComputedStyles(el); 347 String zIndex = style.getZIndex(); 348 if (StringUtil.isNullOrEmpty(zIndex)) 349 return false; 350 351 if (zIndex.contentEquals("-1")) 352 return false; 353 354 int value = StringUtil.parseInt(zIndex, -1); 355 if (value == -1) 356 return false; 357 358 highlightEl.getStyle().setZIndex(value); 359 return true; 360 361 }); 362 } 363 364 Style style = highlightEl.getStyle(); 365 if (style.getVisibility() != "visible") 366 style.setVisibility(Visibility.VISIBLE); 367 368 DOMRect bounds = ElementEx.getBoundingClientRect(monitoredEl); 369 370 int top = bounds.getTop(); 371 int left = scrollX + bounds.getLeft(); 372 int width = bounds.getWidth(); 373 int height = bounds.getHeight(); 374 375 // Ignore out-of-bounds elements. 376 if (width == 0 || height == 0) 377 { 378 highlightEl.getStyle().setVisibility(Visibility.HIDDEN); 379 continue; 380 } 381 382 // Ignore if monitoredEl is hidden by a scrollable parent. 383 Element el = monitoredEl.getParentElement(); 384 while (el != Document.get().getBody()) 385 { 386 bounds = ElementEx.getBoundingClientRect(el); 387 if (bounds.getHeight() > 0 && 388 ((top + height) <= bounds.getTop() || 389 top > bounds.getBottom())) 390 { 391 highlightEl.getStyle().setVisibility(Visibility.HIDDEN); 392 break; 393 } 394 el = el.getParentElement(); 395 } 396 if (StringUtil.equals(Visibility.HIDDEN.getCssName(), 397 highlightEl.getStyle().getVisibility())) 398 continue; 399 400 // Avoid using too-narrow highlights. 401 if (width < 20) 402 { 403 int rest = 20 - width; 404 left = left - (rest / 2); 405 width = 20; 406 } 407 408 top += scrollY; 409 style.setTop(top, Unit.PX); 410 style.setLeft(left, Unit.PX); 411 style.setWidth(width, Unit.PX); 412 style.setHeight(height, Unit.PX); 413 } 414 } 415 416 417 418 // Resources ---- 419 420 public interface Styles extends CssResource 421 { 422 String highlightEl(); 423 String staticHighlightEl(); 424 } 425 426 public interface Resources extends ClientBundle 427 { 428 @Source("UserInterfaceHighlighter.css") 429 Styles styles(); 430 } 431 432 private static final Resources RES = GWT.create(Resources.class); 433 static 434 { 435 RES.styles().ensureInjected(); 436 } 437 438 439 // Private members ---- 440 441 private JsVector<HighlightQuery> highlightQueries_; 442 private List<Boolean> queryCallbackStatuses_; 443 private final List<HighlightPair> highlightPairs_; 444 private final Timer repositionTimer_; 445 private final MutationObserver observer_; 446 private final SourceServerOperations server_; 447 448 private static final int HIGHLIGHT_ELEMENT_MAX = 10; 449 private static final int REPOSITION_DELAY_MS = 0; 450 } 451