1 /*
2  * ExportPlotSizeEditor.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.exportplot;
16 
17 import org.rstudio.core.client.Size;
18 import org.rstudio.core.client.dom.IFrameElementEx;
19 
20 import com.google.gwt.dom.client.Style.Overflow;
21 import com.google.gwt.dom.client.Style.Unit;
22 import com.google.gwt.event.dom.client.ChangeEvent;
23 import com.google.gwt.event.dom.client.ChangeHandler;
24 import com.google.gwt.event.dom.client.ClickEvent;
25 import com.google.gwt.event.dom.client.ClickHandler;
26 import com.google.gwt.user.client.Command;
27 import com.google.gwt.user.client.Timer;
28 import com.google.gwt.user.client.Window;
29 import com.google.gwt.user.client.ui.CellPanel;
30 import com.google.gwt.user.client.ui.CheckBox;
31 import com.google.gwt.user.client.ui.Composite;
32 import com.google.gwt.user.client.ui.Focusable;
33 import com.google.gwt.user.client.ui.HTML;
34 import com.google.gwt.user.client.ui.HasHorizontalAlignment;
35 import com.google.gwt.user.client.ui.HasVerticalAlignment;
36 import com.google.gwt.user.client.ui.HorizontalPanel;
37 import com.google.gwt.user.client.ui.LayoutPanel;
38 import com.google.gwt.user.client.ui.TextBox;
39 import com.google.gwt.user.client.ui.VerticalPanel;
40 import com.google.gwt.user.client.ui.Widget;
41 import org.rstudio.core.client.widget.FocusHelper;
42 import org.rstudio.core.client.widget.FormLabel;
43 import org.rstudio.core.client.widget.GlassPanel;
44 import org.rstudio.core.client.widget.ResizeGripper;
45 import org.rstudio.core.client.widget.ThemedButton;
46 
47 public class ExportPlotSizeEditor extends Composite
48 {
49    public interface Observer
50    {
onResized(boolean withMouse)51       void onResized(boolean withMouse);
52    }
53 
ExportPlotSizeEditor(int initialWidth, int initialHeight, boolean keepRatio, ExportPlotPreviewer previewer, Observer observer)54    public ExportPlotSizeEditor(int initialWidth,
55                                int initialHeight,
56                                boolean keepRatio,
57                                ExportPlotPreviewer previewer,
58                                Observer observer)
59    {
60       this(initialWidth, initialHeight, keepRatio, null, previewer, observer);
61    }
62 
ExportPlotSizeEditor(int initialWidth, int initialHeight, boolean keepRatio, Widget extraWidget, ExportPlotPreviewer previewer, final Observer observer)63    public ExportPlotSizeEditor(int initialWidth,
64                                int initialHeight,
65                                boolean keepRatio,
66                                Widget extraWidget,
67                                ExportPlotPreviewer previewer,
68                                final Observer observer)
69    {
70       // alias objects and resources
71       previewer_ = previewer;
72       observer_ = observer;
73       ExportPlotResources resources = ExportPlotResources.INSTANCE;
74 
75       // main widget
76       VerticalPanel verticalPanel = new VerticalPanel();
77 
78       // if we have an extra widget then enclose it within a horizontal
79       // panel with it on the left and the options on the right
80       HorizontalPanel topPanel = new HorizontalPanel();
81       CellPanel optionsPanel = null;
82       HorizontalPanel widthAndHeightPanel = null;
83       if (extraWidget != null)
84       {
85          topPanel.setWidth("100%");
86 
87          topPanel.add(extraWidget);
88          topPanel.setCellHorizontalAlignment(extraWidget,
89                                              HasHorizontalAlignment.ALIGN_LEFT);
90 
91          optionsPanel = new VerticalPanel();
92          optionsPanel.setStylePrimaryName(
93                                     resources.styles().verticalSizeOptions());
94          optionsPanel.setSpacing(0);
95          topPanel.add(optionsPanel);
96          topPanel.setCellHorizontalAlignment(
97                                        optionsPanel,
98                                        HasHorizontalAlignment.ALIGN_RIGHT);
99 
100          widthAndHeightPanel = new HorizontalPanel();
101          widthAndHeightPanel.setStylePrimaryName(
102                                     resources.styles().widthAndHeightEntry());
103          configureHorizontalOptionsPanel(widthAndHeightPanel);
104          optionsPanel.add(widthAndHeightPanel);
105       }
106       else
107       {
108          optionsPanel = topPanel;
109          optionsPanel.setStylePrimaryName(
110                                  resources.styles().horizontalSizeOptions());
111          widthAndHeightPanel = topPanel;
112          configureHorizontalOptionsPanel(topPanel);
113       }
114 
115       // image width
116       widthTextBox_ = createImageSizeTextBox();
117       FormLabel widthLabel = createImageOptionLabel("Width:", widthTextBox_);
118       widthAndHeightPanel.add(widthLabel);
119       widthTextBox_.addChangeHandler(new ChangeHandler() {
120          @Override
121          public void onChange(ChangeEvent event)
122          {
123             // screen out programmatic sets
124             if (settingDimenensionInProgress_)
125                return;
126 
127             // enforce min size
128             int width = constrainWidth(getImageWidth());
129 
130             // preserve aspect ratio if requested
131             if (getKeepRatio())
132             {
133                double ratio = (double)lastHeight_ / (double)lastWidth_;
134                int height = constrainHeight((int) (ratio * (double)width));
135                setHeightTextBox(height);
136             }
137 
138             // set width
139             setWidthTextBox(width);
140          }
141 
142       });
143       widthAndHeightPanel.add(widthTextBox_);
144 
145       // image height
146       widthAndHeightPanel.add(new HTML("  "));
147       heightTextBox_ = createImageSizeTextBox();
148       FormLabel heightLabel = createImageOptionLabel("Height:", heightTextBox_);
149       widthAndHeightPanel.add(heightLabel);
150       heightTextBox_.addChangeHandler(new ChangeHandler() {
151          @Override
152          public void onChange(ChangeEvent event)
153          {
154             // screen out programmatic sets
155             if (settingDimenensionInProgress_)
156                return;
157 
158             // enforce min size
159             int height = constrainHeight(getImageHeight());
160 
161             // preserve aspect ratio if requested
162             if (getKeepRatio())
163             {
164                double ratio = (double)lastWidth_ / (double)lastHeight_;
165                int width = constrainWidth((int) (ratio * (double)height));
166                setWidthTextBox(width);
167             }
168 
169             // always set height
170             setHeightTextBox(height);
171          }
172 
173       });
174       widthAndHeightPanel.add(heightTextBox_);
175 
176       // add width and height panel to options panel container if necessary
177       if (widthAndHeightPanel != optionsPanel)
178          optionsPanel.add(widthAndHeightPanel);
179 
180       // lock ratio check box
181       keepRatioCheckBox_ = new CheckBox();
182       keepRatioCheckBox_.addStyleName(resources.styles().maintainAspectRatioCheckBox());
183       keepRatioCheckBox_.setValue(keepRatio);
184       keepRatioCheckBox_.setText("Maintain aspect ratio");
185       optionsPanel.add(keepRatioCheckBox_);
186 
187       // image and sizer in layout panel (create now so we can call
188       // setSize in update button click handler)
189       previewPanel_ = new LayoutPanel();
190 
191 
192       // update button
193       ThemedButton updateButton = new ThemedButton("Update Preview",
194                                                     new ClickHandler(){
195          public void onClick(ClickEvent event)
196          {
197             updatePreview();
198          }
199       });
200       updateButton.setStylePrimaryName(
201                                  resources.styles().updateImageSizeButton());
202       optionsPanel.add(updateButton);
203 
204       // add top panel
205       verticalPanel.add(topPanel);
206 
207       // previewer
208       Widget previewWidget = previewer_.getWidget();
209 
210       // Stops mouse events from being routed to the iframe, which would
211       // interfere with resizing
212       final GlassPanel glassPanel = new GlassPanel(previewWidget);
213       glassPanel.getChildContainerElement().getStyle().setOverflow(
214                                                             Overflow.VISIBLE);
215       glassPanel.setSize("100%", "100%");
216 
217 
218 
219       previewPanel_.add(glassPanel);
220       previewPanel_.setWidgetLeftRight(glassPanel,
221                                       0, Unit.PX,
222                                       IMAGE_INSET, Unit.PX);
223       previewPanel_.setWidgetTopBottom(glassPanel,
224                                       0, Unit.PX,
225                                       IMAGE_INSET, Unit.PX);
226       previewPanel_.getWidgetContainerElement(
227                      glassPanel).getStyle().setOverflow(Overflow.VISIBLE);
228 
229       // resize gripper
230       gripper_ = new ResizeGripper(new ResizeGripper.Observer()
231       {
232          @Override
233          public void onResizingStarted()
234          {
235             int startWidth = getImageWidth();
236             int startHeight = getImageHeight();
237 
238             widthAspectRatio_ = (double)startWidth / (double)startHeight;
239             heightAspectRatio_ = (double)startHeight / (double)startWidth;
240 
241             glassPanel.setGlass(true);
242          }
243 
244          @Override
245          public void onResizing(int xDelta, int yDelta)
246          {
247             // get start width and height
248             int startWidth = getImageWidth();
249             int startHeight = getImageHeight();
250 
251             // calculate new height and width
252             int newWidth = constrainWidth(startWidth + xDelta);
253             int newHeight = constrainHeight(startHeight + yDelta);
254 
255             // preserve aspect ratio if requested
256             if (getKeepRatio())
257             {
258                if (Math.abs(xDelta) > Math.abs(yDelta))
259                   newHeight = (int) (heightAspectRatio_ * (double)newWidth);
260                else
261                   newWidth = (int) (widthAspectRatio_ * (double)newHeight);
262             }
263 
264             // set text boxes
265             setWidthTextBox(newWidth);
266             setHeightTextBox(newHeight);
267 
268             // set image preview size
269             setPreviewPanelSize(newWidth,  newHeight);
270          }
271 
272          @Override
273          public void onResizingCompleted()
274          {
275             glassPanel.setGlass(false);
276             previewer_.updatePreview(getImageWidth(), getImageHeight());
277             observer.onResized(true);
278          }
279 
280          private double widthAspectRatio_ = 1.0;
281          private double heightAspectRatio_ = 1.0;
282       });
283 
284       // layout gripper
285       previewPanel_.add(gripper_);
286       previewPanel_.setWidgetRightWidth(gripper_,
287                                       0, Unit.PX,
288                                       gripper_.getImageWidth(), Unit.PX);
289       previewPanel_.setWidgetBottomHeight(gripper_,
290                                         0, Unit.PX,
291                                         gripper_.getImageHeight(), Unit.PX);
292 
293       // constrain dimensions
294       initialWidth = constrainWidth(initialWidth);
295       initialHeight = constrainHeight(initialHeight);
296 
297       // initialize text boxes
298       setWidthTextBox(initialWidth);
299       setHeightTextBox(initialHeight);
300 
301       // initialize preview
302       setPreviewPanelSize(initialWidth, initialHeight);
303 
304       verticalPanel.add(previewPanel_);
305 
306       // set initial focus widget
307       if (extraWidget == null)
308          initialFocusWidget_ = widthTextBox_;
309       else
310          initialFocusWidget_ = null;
311 
312       initWidget(verticalPanel);
313 
314    }
315 
onSizerShown()316    public void onSizerShown()
317    {
318       previewer_.updatePreview(getImageWidth(), getImageHeight());
319 
320       if (initialFocusWidget_ != null)
321          FocusHelper.setFocusDeferred(initialFocusWidget_);
322    }
323 
324 
getImageWidth()325    public int getImageWidth()
326    {
327       try
328       {
329          return Integer.parseInt(widthTextBox_.getText().trim());
330       }
331       catch(NumberFormatException ex)
332       {
333          setWidthTextBox(lastWidth_);
334          return lastWidth_;
335       }
336    }
337 
getImageHeight()338    public int getImageHeight()
339    {
340       try
341       {
342          return Integer.parseInt(heightTextBox_.getText().trim());
343       }
344       catch(NumberFormatException ex)
345       {
346          setHeightTextBox(lastHeight_);
347          return lastHeight_;
348       }
349    }
350 
getKeepRatio()351    public boolean getKeepRatio()
352    {
353       return keepRatioCheckBox_.getValue();
354    }
355 
prepareForExport(final Command onReady)356    public void prepareForExport(final Command onReady)
357    {
358       if (getPreviewRequiresUpdate())
359       {
360          updatePreview();
361          new Timer() {
362             @Override
363             public void run()
364             {
365                onReady.execute();
366             }
367          }.schedule(1000);
368       }
369       else
370       {
371          onReady.execute();
372       }
373    }
374 
getPreviewIFrame()375    public IFrameElementEx getPreviewIFrame()
376    {
377       return previewer_.getPreviewIFrame();
378    }
379 
setGripperVisible(boolean visible)380    public void setGripperVisible(boolean visible)
381    {
382       gripper_.setVisible(visible);
383    }
384 
updatePreview()385    private void updatePreview()
386    {
387       setPreviewPanelSize(getImageWidth(), getImageHeight());
388       previewer_.updatePreview(getImageWidth(), getImageHeight());
389       observer_.onResized(false);
390    }
391 
getPreviewRequiresUpdate()392    private boolean getPreviewRequiresUpdate()
393    {
394       IFrameElementEx iframe = previewer_.getPreviewIFrame();
395       return (getImageWidth() != iframe.getClientWidth() ||
396               getImageHeight() != iframe.getClientHeight());
397    }
398 
setWidthTextBox(int width)399    private void setWidthTextBox(int width)
400    {
401       settingDimenensionInProgress_ = true;
402       lastWidth_ = width;
403       widthTextBox_.setText(Integer.toString(width));
404       settingDimenensionInProgress_ = false;
405    }
406 
407 
setHeightTextBox(int height)408    private void setHeightTextBox(int height)
409    {
410       settingDimenensionInProgress_ = true;
411       lastHeight_ = height;
412       heightTextBox_.setText(Integer.toString(height));
413       settingDimenensionInProgress_ = false;
414    }
415 
constrainWidth(int width)416    private int constrainWidth(int width)
417    {
418       if (width < MIN_SIZE)
419       {
420          keepRatioCheckBox_.setValue(false);
421          return MIN_SIZE;
422       }
423       else if (previewer_.getLimitToScreen() && (width > getMaxSize().width))
424       {
425          keepRatioCheckBox_.setValue(false);
426          return getMaxSize().width;
427       }
428       else
429       {
430          return width;
431       }
432    }
433 
constrainHeight(int height)434    private int constrainHeight(int height)
435    {
436       if (height < MIN_SIZE)
437       {
438          keepRatioCheckBox_.setValue(false);
439          return MIN_SIZE;
440       }
441       else if (previewer_.getLimitToScreen() && (height > getMaxSize().height))
442       {
443          keepRatioCheckBox_.setValue(false);
444          return getMaxSize().height;
445       }
446       else
447       {
448          return height;
449       }
450    }
451 
setPreviewPanelSize(int width, int height)452    private void setPreviewPanelSize(int width, int height)
453    {
454       Size maxSize = getMaxSize();
455 
456       if (width <= maxSize.width && height <= maxSize.height)
457       {
458          previewPanel_.setVisible(true);
459          previewPanel_.setSize((width + IMAGE_INSET) + "px",
460                                (height + IMAGE_INSET) + "px");
461       }
462       else
463       {
464          previewPanel_.setVisible(false);
465       }
466    }
467 
getMaxSize()468    private Size getMaxSize()
469    {
470       return new Size(Window.getClientWidth() - 100,
471                       Window.getClientHeight() - 200);
472    }
473 
createImageOptionLabel(String text, Widget w)474    private FormLabel createImageOptionLabel(String text, Widget w)
475    {
476       FormLabel label = new FormLabel(text, w);
477       label.setStylePrimaryName(
478             ExportPlotResources.INSTANCE.styles().imageOptionLabel());
479       return label;
480    }
481 
createImageSizeTextBox()482    private TextBox createImageSizeTextBox()
483    {
484       TextBox textBox = new TextBox();
485       textBox.setStylePrimaryName(
486             ExportPlotResources.INSTANCE.styles().imageSizeTextBox());
487       return textBox;
488    }
489 
490 
configureHorizontalOptionsPanel(HorizontalPanel panel)491    private void configureHorizontalOptionsPanel(HorizontalPanel panel)
492    {
493       panel.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE);
494       panel.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_LEFT);
495    }
496 
497    @Override
onAttach()498    protected void onAttach()
499    {
500       super.onAttach();
501 
502       // ensure image preview is updated after dialog is shown
503       updatePreview();
504    }
505 
506    private static final int IMAGE_INSET = 6;
507 
508    private final Observer observer_;
509 
510    private final ExportPlotPreviewer previewer_;
511    private final ResizeGripper gripper_;
512    private final TextBox widthTextBox_;
513    private final TextBox heightTextBox_;
514    private final CheckBox keepRatioCheckBox_;
515 
516    private final Focusable initialFocusWidget_;
517 
518    private int lastWidth_;
519    private int lastHeight_;
520 
521    private boolean settingDimenensionInProgress_ = false;
522 
523    private final int MIN_SIZE = 100;
524    private LayoutPanel previewPanel_;
525 }
526