1 /*
2  * PanmirrorEditImageDialog.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 
16 
17 package org.rstudio.studio.client.panmirror.dialogs;
18 
19 import org.rstudio.core.client.ElementIds;
20 import org.rstudio.core.client.StringUtil;
21 import org.rstudio.core.client.dom.DomUtils;
22 import org.rstudio.core.client.theme.DialogTabLayoutPanel;
23 import org.rstudio.core.client.theme.VerticalTabPanel;
24 import org.rstudio.core.client.widget.FormLabel;
25 import org.rstudio.core.client.widget.ModalDialog;
26 import org.rstudio.core.client.widget.NumericTextBox;
27 import org.rstudio.core.client.widget.OperationWithInput;
28 import org.rstudio.studio.client.RStudioGinjector;
29 import org.rstudio.studio.client.common.GlobalDisplay;
30 import org.rstudio.studio.client.panmirror.dialogs.model.PanmirrorAttrProps;
31 import org.rstudio.studio.client.panmirror.dialogs.model.PanmirrorImageDimensions;
32 import org.rstudio.studio.client.panmirror.dialogs.model.PanmirrorImageProps;
33 import org.rstudio.studio.client.panmirror.ui.PanmirrorUIContext;
34 import org.rstudio.studio.client.panmirror.uitools.PanmirrorUITools;
35 import org.rstudio.studio.client.panmirror.uitools.PanmirrorUIToolsImage;
36 import org.rstudio.studio.client.rmarkdown.model.RMarkdownServerOperations;
37 
38 import com.google.gwt.aria.client.Roles;
39 import com.google.gwt.dom.client.Document;
40 import com.google.gwt.event.dom.client.DomEvent;
41 import com.google.gwt.user.client.ui.CheckBox;
42 import com.google.gwt.user.client.ui.HasVerticalAlignment;
43 import com.google.gwt.user.client.ui.HorizontalPanel;
44 import com.google.gwt.user.client.ui.ListBox;
45 import com.google.gwt.user.client.ui.Panel;
46 import com.google.gwt.user.client.ui.TextBox;
47 import com.google.gwt.user.client.ui.Widget;
48 import com.google.inject.Inject;
49 
50 
51 public class PanmirrorEditImageDialog extends ModalDialog<PanmirrorImageProps>
52 {
PanmirrorEditImageDialog(PanmirrorImageProps props, PanmirrorImageDimensions dims, boolean editAttributes, PanmirrorUIContext uiContext, OperationWithInput<PanmirrorImageProps> operation)53    public PanmirrorEditImageDialog(PanmirrorImageProps props,
54                                    PanmirrorImageDimensions dims,
55                                    boolean editAttributes,
56                                    PanmirrorUIContext uiContext,
57                                    OperationWithInput<PanmirrorImageProps> operation)
58    {
59       super("Image", Roles.getDialogRole(), operation, () -> {
60          // cancel returns null
61          operation.execute(null);
62       });
63 
64       RStudioGinjector.INSTANCE.injectMembers(this);
65 
66       // natural width, height, and containerWidth (will be null if this
67       // is an insert image dialog)
68       dims_ = dims;
69 
70       // size props that we are going to reflect back to the caller. the idea is that
71       // if the user makes no explicit edits of size props then we just return
72       // exactly what we were passed. this allows us to show a width and height
73       // for images that are 'unsized' (i.e. just use natural height and width). the
74       // in-editor resizing shelf implements the same behavior.
75       widthProp_ = props.width;
76       heightProp_ = props.height;
77       unitsProp_ = props.units;
78 
79       // image tab
80       VerticalTabPanel imageTab = new VerticalTabPanel(ElementIds.VISUAL_MD_IMAGE_TAB_IMAGE);
81       imageTab.addStyleName(RES.styles().dialog());
82 
83       // panel for size controls (won't be added if this is an insert or !editAttributes)
84       HorizontalPanel sizePanel = new HorizontalPanel();
85       sizePanel.addStyleName(RES.styles().spaced());
86       sizePanel.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE);
87 
88       // image url picker
89       imageTab.add(url_ = new PanmirrorImageChooser(uiContext, server_));
90       url_.addStyleName(RES.styles().spaced());
91       if (!StringUtil.isNullOrEmpty(props.src))
92          url_.setText(props.src);
93       // when the url is changed we no longer know the image dimensions. in this case
94       // just wipe out those props and remove the image sizing ui. note that immediately
95       // after insert the size controls will appear at the bottom of the image.
96       url_.addValueChangeHandler(value -> {
97          widthProp_ = null;
98          heightProp_ = null;
99          unitsProp_ = null;
100          dims_ = null;
101          imageTab.remove(sizePanel);
102       });
103 
104       // width, height, units
105       width_ = addSizeInput(sizePanel, ElementIds.VISUAL_MD_IMAGE_WIDTH, "Width:");
106       height_ = addSizeInput(sizePanel, ElementIds.VISUAL_MD_IMAGE_HEIGHT, "Height:");
107       heightAuto_ = createHorizontalLabel("(Auto)");
108       heightAuto_.addStyleName(RES.styles().heightAuto());
109       sizePanel.add(heightAuto_);
110       units_ = addUnitsSelect(sizePanel);
111       initSizeInputs();
112 
113       // lock ratio
114       lockRatio_ = new CheckBox("Lock ratio");
115       lockRatio_.addStyleName(RES.styles().lockRatioCheckbox());
116       lockRatio_.getElement().setId(ElementIds.VISUAL_MD_IMAGE_LOCK_RATIO);
117       lockRatio_.setValue(props.lockRatio);
118       sizePanel.add(lockRatio_);
119 
120       // update widthProp_ and height (if lockRatio) when width text box changes
121       width_.addChangeHandler(event -> {
122          String width = width_.getText();
123          widthProp_ = StringUtil.isNullOrEmpty(width) ? null : Double.parseDouble(width);
124          if (widthProp_ != null && lockRatio_.getValue()) {
125             double height = widthProp_ * (dims_.naturalHeight/dims_.naturalWidth);
126             height_.setValue(uiTools_.roundUnit(height, units_.getSelectedValue()));
127             heightProp_ = Double.parseDouble(height_.getValue());
128          }
129          unitsProp_ = units_.getSelectedValue();
130       });
131 
132       // update heightProp_ and width (if lockRatio) when height text box changes
133       height_.addChangeHandler(event -> {
134          String height = height_.getText();
135          heightProp_ = StringUtil.isNullOrEmpty(height) ? null : Double.parseDouble(height);
136          if (heightProp_ != null && lockRatio_.getValue()) {
137             double width = heightProp_ * (dims_.naturalWidth/dims_.naturalHeight);
138             width_.setValue(uiTools_.roundUnit(width, units_.getSelectedValue()));
139             widthProp_ = Double.parseDouble(width_.getValue());
140          }
141          unitsProp_ = units_.getSelectedValue();
142       });
143 
144       // do applicable unit conversion when units change
145       units_.addChangeHandler(event -> {
146 
147          String width = width_.getText();
148          if (!StringUtil.isNullOrEmpty(width))
149          {
150             double widthPixels = uiTools_.unitToPixels(Double.parseDouble(width), prevUnits_, dims_.containerWidth);
151             double widthUnit = uiTools_.pixelsToUnit(widthPixels, units_.getSelectedValue(), dims_.containerWidth);
152             width_.setText(uiTools_.roundUnit(widthUnit, units_.getSelectedValue()));
153             widthProp_ = Double.parseDouble(width_.getValue());
154          }
155 
156          String height = height_.getText();
157          if (!StringUtil.isNullOrEmpty(height))
158          {
159             double heightPixels = uiTools_.unitToPixels(Double.parseDouble(height), prevUnits_, dims_.containerWidth);
160             double heightUnit = uiTools_.pixelsToUnit(heightPixels, units_.getSelectedValue(), dims_.containerWidth);
161             height_.setText(uiTools_.roundUnit(heightUnit, units_.getSelectedValue()));
162             heightProp_ = Double.parseDouble(height_.getValue());
163          }
164 
165          // track previous units for subsequent conversions
166          prevUnits_ = units_.getSelectedValue();
167 
168          // save units prop
169          unitsProp_ = units_.getSelectedValue();
170 
171          manageUnitsUI();
172       });
173       manageUnitsUI();
174 
175 
176       // only add sizing controls if we support editAttributes, dims have been provided
177       // (i.e. not an insert operation) and there aren't width or height attributes
178       // within props.keyvalue (which is an indicator that they use units unsupported
179       // by our sizing UI (e.g. ch, em, etc.)
180       if (editAttributes && dims_ != null && hasNaturalSizes(dims) && !hasSizeKeyvalue(props.keyvalue))
181       {
182          imageTab.add(sizePanel);
183       }
184 
185       // title and alt
186       title_ = PanmirrorDialogsUtil.addTextBox(imageTab, ElementIds.VISUAL_MD_IMAGE_TITLE, "Title/Tooltip:", props.title);
187       alt_ = PanmirrorDialogsUtil.addTextBox(imageTab, ElementIds.VISUAL_MD_IMAGE_ALT, "Caption/Alt:", props.alt);
188 
189       // linkto
190       linkTo_ = PanmirrorDialogsUtil.addTextBox(imageTab,  ElementIds.VISUAL_MD_IMAGE_LINK_TO, "Link To:", props.linkTo);
191 
192       // standard pandoc attributes
193       editAttr_ =  new PanmirrorEditAttrWidget();
194       editAttr_.setAttr(props, null);
195       if (editAttributes)
196       {
197          VerticalTabPanel attributesTab = new VerticalTabPanel(ElementIds.VISUAL_MD_IMAGE_TAB_ATTRIBUTES);
198          attributesTab.addStyleName(RES.styles().dialog());
199          attributesTab.add(editAttr_);
200 
201          DialogTabLayoutPanel tabPanel = new DialogTabLayoutPanel("Image");
202          tabPanel.addStyleName(RES.styles().imageDialogTabs());
203          tabPanel.add(imageTab, "Image", imageTab.getBasePanelId());
204          tabPanel.add(attributesTab, "Attributes", attributesTab.getBasePanelId());
205          tabPanel.selectTab(0);
206 
207          mainWidget_ = tabPanel;
208       }
209       else
210       {
211          mainWidget_ = imageTab;
212       }
213    }
214 
215    @Inject
initialize(RMarkdownServerOperations server)216    void initialize(RMarkdownServerOperations server)
217    {
218       server_ = server;
219    }
220 
221    @Override
createMainWidget()222    protected Widget createMainWidget()
223    {
224       return mainWidget_;
225    }
226 
227    @Override
focusInitialControl()228    public void focusInitialControl()
229    {
230       url_.getTextBox().setFocus(true);
231       url_.getTextBox().setSelectionRange(0, 0);
232    }
233 
234    @Override
collectInput()235    protected PanmirrorImageProps collectInput()
236    {
237       // process change event for focused size controls (typically these changes
238       // only occur on the change event, which won't occur if the dialog is
239       // dismissed while they are focused
240       fireChangedIfFocused(width_);
241       fireChangedIfFocused(height_);
242 
243       // collect and return result
244       PanmirrorImageProps result = new PanmirrorImageProps();
245       result.src = url_.getTextBox().getValue().trim();
246       result.title = title_.getValue().trim();
247       result.alt = alt_.getValue().trim();
248       result.linkTo = linkTo_.getValue().trim();
249       result.width = widthProp_;
250       result.height = heightProp_;
251       result.units = unitsProp_;
252       result.lockRatio = lockRatio_.getValue();
253       PanmirrorAttrProps attr = editAttr_.getAttr();
254       result.id = attr.id;
255       result.classes = attr.classes;
256       result.keyvalue = attr.keyvalue;
257       return result;
258    }
259 
260    @Override
validate(PanmirrorImageProps result)261    protected boolean validate(PanmirrorImageProps result)
262    {
263       // width is required if height is specified
264       if (height_.getText().trim().length() > 0)
265       {
266          GlobalDisplay globalDisplay = RStudioGinjector.INSTANCE.getGlobalDisplay();
267          String width = width_.getText().trim();
268          if (width.length() == 0)
269          {
270             globalDisplay.showErrorMessage(
271                "Error", "You must provide a value for image width."
272             );
273             width_.setFocus(true);
274             return false;
275          }
276          else
277          {
278             return true;
279          }
280       }
281       else
282       {
283          return true;
284       }
285    }
286 
287 
288    // set sizing UI based on passed width, height, and unit props. note that
289    // these can be null (default/natural sizing) and in that case we still
290    // want to dispaly pixel sizing in the UI as an FYI to the user
initSizeInputs()291    private void initSizeInputs()
292    {
293       // only init for existing images (i.e. dims passed)
294       if (dims_ == null)
295          return;
296 
297       String width = null, height = null, units = "px";
298 
299       // if we have both width and height then use them
300       if (widthProp_ != null && heightProp_ != null)
301       {
302          width = widthProp_.toString();
303          height = heightProp_.toString();
304          units = unitsProp_;
305       }
306       else if (dims_.naturalHeight != null && dims_.naturalWidth != null)
307       {
308          units = unitsProp_;
309 
310          // if there is width only then show computed height
311          if (widthProp_ == null && heightProp_ == null)
312          {
313             width = dims_.naturalWidth.toString();
314             height = dims_.naturalHeight.toString();
315             units = "px";
316          }
317          else if (widthProp_ != null)
318          {
319             width = widthProp_.toString();
320             height = uiTools_.roundUnit(widthProp_ * (dims_.naturalHeight/dims_.naturalWidth), units);
321          }
322          else if (heightProp_ != null)
323          {
324             height = heightProp_.toString();
325             width = uiTools_.roundUnit(heightProp_ * (dims_.naturalWidth/dims_.naturalHeight), units);
326          }
327       }
328 
329       // set values into inputs
330       width_.setValue(width);
331       height_.setValue(height);
332       for (int i = 0; i<units_.getItemCount(); i++)
333       {
334          if (units_.getItemText(i) == units)
335          {
336             units_.setSelectedIndex(i);
337             // track previous units for conversions
338             prevUnits_ = units;
339             break;
340          }
341       }
342    }
343 
344    // show/hide controls and enable/disable lockUnits depending on
345    // whether we are using percent sizing
manageUnitsUI()346    private void manageUnitsUI()
347    {
348       boolean percentUnits = units_.getSelectedValue() == uiTools_.percentUnit();
349 
350       if (percentUnits)
351       {
352          lockRatio_.setValue(true);
353          lockRatio_.setEnabled(false);
354       }
355       else
356       {
357          lockRatio_.setEnabled(true);
358       }
359 
360       height_.setVisible(!percentUnits);
361       heightAuto_.setVisible(percentUnits);
362    }
363 
364 
365    // create a numeric input
addSizeInput(Panel panel, String id, String labelText)366    private static NumericTextBox addSizeInput(Panel panel, String id, String labelText)
367    {
368       FormLabel label = createHorizontalLabel(labelText);
369       NumericTextBox input = new NumericTextBox();
370       input.setMin(1);
371       input.setMax(10000);
372       input.addStyleName(RES.styles().horizontalInput());
373       input.addStyleName(RES.styles().numericSizeInput());
374       input.getElement().setId(id);
375       label.setFor(input);
376       panel.add(label);
377       panel.add(input);
378       return input;
379    }
380 
381    // create units select list box
addUnitsSelect(Panel panel)382    private ListBox addUnitsSelect(Panel panel)
383    {
384       String[] options = uiTools_.validUnits();
385       ListBox units = new ListBox();
386       units.addStyleName(RES.styles().horizontalInput());
387       units.addStyleName(RES.styles().unitsSelectInput());
388       for (int i = 0; i < options.length; i++)
389          units.addItem(options[i], options[i]);
390       units.getElement().setId(ElementIds.VISUAL_MD_IMAGE_UNITS);
391       Roles.getListboxRole().setAriaLabelProperty(units.getElement(), "Units");
392       panel.add(units);
393       return units;
394    }
395 
396    // create a horizontal label
createHorizontalLabel(String text)397    private static FormLabel createHorizontalLabel(String text)
398    {
399       FormLabel label = new FormLabel(text);
400       label.addStyleName(RES.styles().horizontalLabel());
401       return label;
402    }
403 
404    // fire a change event if the widget is currently focused
fireChangedIfFocused(Widget widget)405    private static void fireChangedIfFocused(Widget widget)
406    {
407       if (widget.getElement() == DomUtils.getActiveElement())
408          DomEvent.fireNativeEvent(Document.get().createChangeEvent(), widget);
409    }
410 
411    // check whether the passed keyvalue attributes has a size (width or height)
hasSizeKeyvalue(String[][] keyvalue)412    private static boolean hasSizeKeyvalue(String[][] keyvalue)
413    {
414       for (int i=0; i<keyvalue.length; i++)
415       {
416          String key = keyvalue[i][0];
417          if (key.equalsIgnoreCase(WIDTH) || key.equalsIgnoreCase(HEIGHT))
418             return true;
419       }
420       return false;
421    }
422 
hasNaturalSizes(PanmirrorImageDimensions dims)423    private static boolean hasNaturalSizes(PanmirrorImageDimensions dims)
424    {
425       return dims.naturalWidth != null && dims.naturalHeight != null;
426    }
427 
428    // resources
429    private static PanmirrorDialogsResources RES = PanmirrorDialogsResources.INSTANCE;
430 
431    // UI utility functions from panmirror
432    private final PanmirrorUIToolsImage uiTools_ = new PanmirrorUITools().image;
433 
434    private RMarkdownServerOperations server_;
435 
436    // original image/container dimensions
437    private PanmirrorImageDimensions dims_;
438 
439    // current 'edited' values for size props
440    private Double widthProp_ = null;
441    private Double heightProp_ = null;
442    private String unitsProp_ = null;
443 
444    // track previous units for conversions
445    private String prevUnits_;
446 
447    // widgets
448    private final Widget mainWidget_;
449    private final PanmirrorImageChooser url_;
450    private final NumericTextBox width_;
451    private final NumericTextBox height_;
452    private final FormLabel heightAuto_;
453    private final ListBox units_;
454    private final CheckBox lockRatio_;
455    private final TextBox title_;
456    private final TextBox alt_;
457    private final TextBox linkTo_;
458    private final PanmirrorEditAttrWidget editAttr_;
459 
460    private static final String WIDTH = "width";
461    private static final String HEIGHT = "height";
462 
463 
464 }
465