/* * ImagePreviewer.java * * Copyright (C) 2009-16 by RStudio, Inc. * * Unless you have received this program directly from RStudio pursuant * to the terms of a commercial license agreement with RStudio, then * this program is licensed to you under the terms of version 3 of the * GNU Affero General Public License. This program is distributed WITHOUT * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. * */ package org.rstudio.studio.client.workbench.views.source.editors.text; import java.util.Map; import org.rstudio.core.client.CommandWithArg; import org.rstudio.core.client.Mutable; import org.rstudio.core.client.StringUtil; import org.rstudio.core.client.dom.ImageElementEx; import org.rstudio.core.client.html.HTMLAttributesParser; import org.rstudio.core.client.js.JsUtil; import org.rstudio.core.client.layout.FadeOutAnimation; import org.rstudio.core.client.regex.Pattern; import org.rstudio.studio.client.common.FilePathUtils; import org.rstudio.studio.client.rmarkdown.model.RmdChunkOptions; import org.rstudio.studio.client.workbench.prefs.model.UIPrefs; import org.rstudio.studio.client.workbench.prefs.model.UIPrefsAccessor; import org.rstudio.studio.client.workbench.views.source.editors.text.ace.LineWidget; import org.rstudio.studio.client.workbench.views.source.editors.text.ace.Position; import org.rstudio.studio.client.workbench.views.source.editors.text.ace.Range; import org.rstudio.studio.client.workbench.views.source.editors.text.ace.Renderer.ScreenCoordinates; import org.rstudio.studio.client.workbench.views.source.editors.text.ace.Token; import org.rstudio.studio.client.workbench.views.source.editors.text.events.DocumentChangedEvent; import org.rstudio.studio.client.workbench.views.source.editors.text.events.RenderFinishedEvent; import org.rstudio.studio.client.workbench.views.source.editors.text.rmd.ChunkOutputHost; import org.rstudio.studio.client.workbench.views.source.editors.text.rmd.TextEditingTargetNotebook; import org.rstudio.studio.client.workbench.views.source.model.DocUpdateSentinel; import com.google.gwt.core.client.GWT; import com.google.gwt.core.client.JsArray; import com.google.gwt.core.client.Scheduler; import com.google.gwt.core.client.Scheduler.ScheduledCommand; import com.google.gwt.dom.client.Document; import com.google.gwt.dom.client.Element; import com.google.gwt.dom.client.Style; import com.google.gwt.dom.client.Style.Unit; import com.google.gwt.event.shared.HandlerRegistration; import com.google.gwt.resources.client.ClientBundle; import com.google.gwt.resources.client.CssResource; import com.google.gwt.user.client.Command; import com.google.gwt.user.client.DOM; import com.google.gwt.user.client.Event; import com.google.gwt.user.client.EventListener; import com.google.gwt.user.client.Timer; import com.google.gwt.user.client.ui.Image; import com.google.gwt.user.client.ui.Label; import com.google.gwt.user.client.ui.SimplePanel; public class ImagePreviewer { public ImagePreviewer(DocDisplay display, DocUpdateSentinel sentinel, UIPrefs prefs) { display_ = display; prefs_ = prefs; sentinel_ = sentinel; } public void previewAllLinks() { for (int i = 0, n = display_.getRowCount(); i < n; i++) { String line = display_.getLine(i); if (isStandaloneMarkdownLink(line)) { // perform a preview with the cursor on the href (past the opening // parenthetical) onPreviewLink(display_, sentinel_, prefs_, Position.create(i, line.indexOf('(') + 2)); } } } public void removeAllPreviews() { JsArray<LineWidget> widgets = display_.getLineWidgets(); for (int i = 0; i < widgets.length(); i++) { LineWidget widget = widgets.get(i); if (widget.getType() == LINE_WIDGET_TYPE) display_.removeLineWidget(widget); } } public static void onPreviewLink(DocDisplay display, DocUpdateSentinel sentinel, UIPrefs prefs, Position position) { Token token = display.getTokenAt(position); if (token == null) return; if (!token.hasType("href")) return; Range tokenRange = Range.fromPoints( Position.create(position.getRow(), token.getColumn()), Position.create(position.getRow(), token.getColumn() + token.getValue().length())); String href = token.getValue(); if (ImagePreviewer.isImageHref(href)) { // extract HTML attributes from line for markdown links, e.g. // // ![](plot.png){width=400 height=400} // String attributes = null; String line = display.getLine(position.getRow()); if (isStandaloneMarkdownLink(line)) { int startBraceIdx = line.indexOf("){"); int endBraceIdx = line.lastIndexOf("}"); if (startBraceIdx != -1 && endBraceIdx != -1 && endBraceIdx > startBraceIdx) { attributes = line.substring(startBraceIdx + 2, endBraceIdx).trim(); } } onPreviewImage(display, sentinel, prefs, href, attributes, position, tokenRange); } } private static void onPreviewImageLineWidget(final DocDisplay display, final DocUpdateSentinel sentinel, final String href, final String attributes, final Position position, final Range tokenRange) { // if we already have a line widget for this row, bail LineWidget lineWidget = display.getLineWidgetForRow(position.getRow()); if (lineWidget != null) return; // shared mutable state that we hide in this closure final Mutable<PinnedLineWidget> plw = new Mutable<PinnedLineWidget>(); final Mutable<ChunkOutputWidget> cow = new Mutable<ChunkOutputWidget>(); final Mutable<HandlerRegistration> docChangedHandler = new Mutable<HandlerRegistration>(); final Mutable<HandlerRegistration> renderHandler = new Mutable<HandlerRegistration>(); // command that ensures state is cleaned up when widget hidden final Command onDetach = new Command() { private void detach() { // detach chunk output widget cow.set(null); // detach pinned line widget if (plw.get() != null) plw.get().detach(); plw.set(null); // detach render handler if (renderHandler.get() != null) renderHandler.get().removeHandler(); renderHandler.set(null); // detach doc changed handler if (docChangedHandler.get() != null) docChangedHandler.get().removeHandler(); docChangedHandler.set(null); } @Override public void execute() { // if the associated chunk output widget has been cleaned up, // make a last-ditch detach effort anyhow ChunkOutputWidget widget = cow.get(); if (widget == null) { detach(); return; } // fade out and then detach FadeOutAnimation anim = new FadeOutAnimation(widget, new Command() { @Override public void execute() { detach(); } }); anim.run(400); } }; // construct placeholder for image final SimplePanel container = new SimplePanel(); container.addStyleName(RES.styles().container()); final Label noImageLabel = new Label("(No image at path " + href + ")"); // resize command (used by various routines that need to respond // to width / height change events) final CommandWithArg<Integer> onResize = new CommandWithArg<Integer>() { private int state_ = -1; @Override public void execute(Integer height) { // defend against missing chunk output widget (can happen if a widget // is closed / dismissed before image finishes loading) ChunkOutputWidget widget = cow.get(); if (widget == null) return; // don't resize if the chunk widget if we were already collapsed int state = widget.getExpansionState(); if (state == state_ && state == ChunkOutputWidget.COLLAPSED) return; state_ = state; widget.getFrame().setHeight(height + "px"); LineWidget lw = plw.get().getLineWidget(); lw.setPixelHeight(height); display.onLineWidgetChanged(lw); } }; // construct our image String srcPath = imgSrcPathFromHref(sentinel, href); final Image image = new Image(srcPath); image.addStyleName(RES.styles().image()); // parse and inject attributes Map<String, String> parsedAttributes = HTMLAttributesParser.parseAttributes(attributes); final Element imgEl = image.getElement(); for (Map.Entry<String, String> entry : parsedAttributes.entrySet()) { String key = entry.getKey(); String val = entry.getValue(); if (StringUtil.isNullOrEmpty(key) || StringUtil.isNullOrEmpty(val)) continue; imgEl.setAttribute(key, val); } // add load handlers to image DOM.sinkEvents(imgEl, Event.ONLOAD | Event.ONERROR); DOM.setEventListener(imgEl, new EventListener() { @Override public void onBrowserEvent(Event event) { if (DOM.eventGetType(event) == Event.ONLOAD) { final ImageElementEx imgEl = image.getElement().cast(); int minWidth = Math.min(imgEl.naturalWidth(), 100); int maxWidth = Math.min(imgEl.naturalWidth(), 650); Style style = imgEl.getStyle(); boolean hasWidth = imgEl.hasAttribute("width") || style.getProperty("width") != null; if (!hasWidth) { style.setProperty("width", "100%"); style.setProperty("minWidth", minWidth + "px"); style.setProperty("maxWidth", maxWidth + "px"); } // attach to container container.setWidget(image); // update widget int height = image.getOffsetHeight() + 10; onResize.execute(height); } else if (DOM.eventGetType(event) == Event.ONERROR) { container.setWidget(noImageLabel); onResize.execute(50); } } }); // handle editor resize events final Timer renderTimer = new Timer() { @Override public void run() { int height = image.getOffsetHeight() + 30; onResize.execute(height); } }; // initialize render handler renderHandler.set(display.addRenderFinishedHandler( new RenderFinishedEvent.Handler() { private int width_; @Override public void onRenderFinished(RenderFinishedEvent event) { int width = display.getBounds().getWidth(); if (width == width_) return; width_ = width; renderTimer.schedule(100); } })); // initialize doc changed handler docChangedHandler.set(display.addDocumentChangedHandler( new DocumentChangedEvent.Handler() { private String href_ = href; private String attributes_ = StringUtil.notNull(attributes); private final Timer refreshImageTimer = new Timer() { @Override public void run() { // if the discovered href isn't an image link, just bail if (!ImagePreviewer.isImageHref(href_)) return; // set new src location (load handler will replace label as needed) container.setWidget(new SimplePanel()); noImageLabel.setText("(No image at path " + href_ + ")"); image.getElement().setAttribute("src", imgSrcPathFromHref( sentinel, href_)); // parse and inject attributes Map<String, String> parsedAttributes = HTMLAttributesParser.parseAttributes(attributes_); final Element imgEl = image.getElement(); for (Map.Entry<String, String> entry : parsedAttributes.entrySet()) { String key = entry.getKey(); String val = entry.getValue(); if (StringUtil.isNullOrEmpty(key) || StringUtil.isNullOrEmpty(val)) continue; imgEl.setAttribute(key, val); } } }; private void onDocumentChangedImpl(DocumentChangedEvent event) { int row = plw.get().getRow(); Range range = event.getEvent().getRange(); if (range.getStart().getRow() <= row && row <= range.getEnd().getRow()) { String line = display.getLine(row); if (ImagePreviewer.isStandaloneMarkdownLink(line)) { // check to see if the URL text has been updated Token hrefToken = null; JsArray<Token> tokens = display.getTokens(row); for (Token token : JsUtil.asIterable(tokens)) { if (token.hasType("href")) { hrefToken = token; break; } } if (hrefToken == null) return; String attributes = ""; int startBraceIdx = line.indexOf("){"); int endBraceIdx = line.lastIndexOf("}"); if (startBraceIdx != -1 && endBraceIdx != -1 && endBraceIdx > startBraceIdx) { attributes = line.substring(startBraceIdx + 2, endBraceIdx).trim(); } // if we have the same href as before, don't update // (avoid flickering + re-requests of same URL) if (hrefToken.getValue().equals(href_) && attributes.equals(attributes_)) return; // cache href and schedule refresh of image href_ = hrefToken.getValue(); attributes_ = attributes; refreshImageTimer.schedule(700); } else { onDetach.execute(); } } } @Override public void onDocumentChanged(final DocumentChangedEvent event) { // ignore 'removeLines' events as they won't mutate the actual // line containing the markdown link String action = event.getEvent().getAction(); if (action.equals("removeLines")) return; Scheduler.get().scheduleDeferred(new ScheduledCommand() { @Override public void execute() { onDocumentChangedImpl(event); } }); } })); ChunkOutputHost host = new ChunkOutputHost() { @Override public void onOutputRemoved(final ChunkOutputWidget widget) { onDetach.execute(); } @Override public void onOutputHeightChanged(ChunkOutputWidget widget, int height, boolean ensureVisible) { onResize.execute(height); } }; cow.set(new ChunkOutputWidget( sentinel.getId(), "md-image-preview-" + StringUtil.makeRandomId(8), RmdChunkOptions.create(), ChunkOutputWidget.EXPANDED, false, // can close host, ChunkOutputSize.Bare)); ChunkOutputWidget outputWidget = cow.get(); outputWidget.setRootWidget(container); outputWidget.hideSatellitePopup(); outputWidget.getElement().getStyle().setMarginTop(4, Unit.PX); plw.set(new PinnedLineWidget(LINE_WIDGET_TYPE, display, outputWidget, position.getRow(), null, null)); } private static boolean isStandaloneMarkdownLink(String line) { return line.matches("^\\s*!\\s*\\[[^\\]]*\\]\\s*\\([^)]*\\)\\s*(?:{.*)?$"); } private static boolean isImageHref(String href) { return href.endsWith(".png") || href.endsWith(".jpg") || href.endsWith(".jpeg") || href.endsWith(".gif") || href.endsWith(".svg"); } private static String imgSrcPathFromHref(DocUpdateSentinel sentinel, String href) { // return paths that have a custom / external protocol as-is Pattern reProtocol = Pattern.create("^\\w+://"); if (reProtocol.test(href)) return href; // make relative paths absolute String absPath = href; if (FilePathUtils.pathIsRelative(href)) { String docPath = sentinel.getPath(); absPath = FilePathUtils.dirFromFile(docPath) + "/" + absPath; } return "file_show?path=" + StringUtil.encodeURIComponent(absPath) + "&id=" + IMAGE_ID++; } private static void onPreviewImage(DocDisplay display, DocUpdateSentinel sentinel, UIPrefs prefs, String href, String attributes, Position position, Range tokenRange) { // check to see if we already have a popup showing for this image; // if we do then bail early String encoded = StringUtil.encodeURIComponent(href); Element el = Document.get().getElementById(encoded); if (el != null) return; String pref = prefs.showLatexPreviewOnCursorIdle().getValue(); // skip if disabled entirely if (!sentinel.getBoolProperty( TextEditingTargetNotebook.CONTENT_PREVIEW_ENABLED, pref != UIPrefsAccessor.LATEX_PREVIEW_SHOW_NEVER)) return; // display stand-alone links as line widgets (if enabled) String line = display.getLine(position.getRow()); if (isStandaloneMarkdownLink(line) && sentinel.getBoolProperty( TextEditingTargetNotebook.CONTENT_PREVIEW_INLINE, prefs.showLatexPreviewOnCursorIdle().getValue() == UIPrefsAccessor.LATEX_PREVIEW_SHOW_ALWAYS)) { onPreviewImageLineWidget(display, sentinel, href, attributes, position, tokenRange); return; } // construct image el, place in popup, and show ImagePreviewPopup panel = new ImagePreviewPopup(display, tokenRange, href, imgSrcPathFromHref(sentinel, href)); panel.getElement().setId(encoded); ScreenCoordinates coordinates = display.documentPositionToScreenCoordinates(position); panel.setPopupPosition(coordinates.getPageX(), coordinates.getPageY() + 20); panel.show(); } private final DocDisplay display_; private final DocUpdateSentinel sentinel_; private final UIPrefs prefs_; private static final String LINE_WIDGET_TYPE = "image-preview" ; private static int IMAGE_ID = 0; interface Styles extends CssResource { String container(); String image(); } interface Resources extends ClientBundle { @Source("ImagePreviewer.css") Styles styles(); } private static final Resources RES = GWT.create(Resources.class); static { RES.styles().ensureInjected(); } }