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