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