1 /* 2 * Presentation.java 3 4 * 5 * Copyright (C) 2021 by RStudio, PBC 6 * 7 * Unless you have received this program directly from RStudio pursuant 8 * to the terms of a commercial license agreement with RStudio, then 9 * this program is licensed to you under the terms of version 3 of the 10 * GNU Affero General Public License. This program is distributed WITHOUT 11 * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, 12 * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the 13 * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. 14 * 15 */ 16 package org.rstudio.studio.client.workbench.views.presentation; 17 18 import java.util.Iterator; 19 20 import com.google.gwt.core.client.JavaScriptObject; 21 import com.google.gwt.core.client.JsArray; 22 import com.google.gwt.event.shared.HandlerManager; 23 import com.google.gwt.event.shared.HandlerRegistration; 24 import com.google.gwt.json.client.JSONString; 25 import com.google.gwt.user.client.Command; 26 import com.google.gwt.user.client.Event; 27 import com.google.gwt.user.client.Timer; 28 import com.google.inject.Inject; 29 30 import org.rstudio.core.client.StringUtil; 31 import org.rstudio.core.client.TimeBufferedCommand; 32 import org.rstudio.core.client.command.CommandBinder; 33 import org.rstudio.core.client.command.Handler; 34 import org.rstudio.core.client.files.FileSystemItem; 35 import org.rstudio.core.client.widget.MessageDialog; 36 import org.rstudio.core.client.widget.ProgressIndicator; 37 import org.rstudio.core.client.widget.ProgressOperation; 38 import org.rstudio.core.client.widget.ProgressOperationWithInput; 39 import org.rstudio.studio.client.application.Desktop; 40 import org.rstudio.studio.client.application.events.EventBus; 41 import org.rstudio.studio.client.application.events.ReloadWithLastChanceSaveEvent; 42 import org.rstudio.studio.client.common.FileDialogs; 43 import org.rstudio.studio.client.common.GlobalDisplay; 44 import org.rstudio.studio.client.common.GlobalProgressDelayer; 45 import org.rstudio.studio.client.common.SimpleRequestCallback; 46 import org.rstudio.studio.client.common.filetypes.FileTypeRegistry; 47 import org.rstudio.studio.client.common.presentation.SlideNavigationMenu; 48 import org.rstudio.studio.client.common.presentation.SlideNavigationPresenter; 49 import org.rstudio.studio.client.common.presentation.events.SlideIndexChangedEvent; 50 import org.rstudio.studio.client.common.presentation.events.SlideNavigationChangedEvent; 51 import org.rstudio.studio.client.common.presentation.model.SlideNavigation; 52 import org.rstudio.studio.client.server.Void; 53 import org.rstudio.studio.client.server.ServerError; 54 import org.rstudio.studio.client.server.ServerRequestCallback; 55 import org.rstudio.studio.client.server.VoidServerRequestCallback; 56 import org.rstudio.studio.client.workbench.WorkbenchView; 57 import org.rstudio.studio.client.workbench.commands.Commands; 58 import org.rstudio.studio.client.workbench.model.RemoteFileSystemContext; 59 import org.rstudio.studio.client.workbench.model.Session; 60 import org.rstudio.studio.client.workbench.views.BasePresenter; 61 import org.rstudio.studio.client.workbench.views.presentation.events.PresentationPaneRequestCompletedEvent; 62 import org.rstudio.studio.client.workbench.views.presentation.events.ShowPresentationPaneEvent; 63 import org.rstudio.studio.client.workbench.views.presentation.events.SourceFileSaveCompletedEvent; 64 import org.rstudio.studio.client.workbench.views.presentation.model.PresentationServerOperations; 65 import org.rstudio.studio.client.workbench.views.presentation.model.PresentationState; 66 import org.rstudio.studio.client.workbench.views.source.events.EditPresentationSourceEvent; 67 68 public class Presentation extends BasePresenter 69 implements SlideNavigationPresenter.Display 70 { 71 public interface Binder extends CommandBinder<Commands, Presentation> {} 72 73 public interface Display extends WorkbenchView 74 { load(String url, String sourceFile)75 void load(String url, String sourceFile); zoom(String title, String url, Command onClosed)76 void zoom(String title, String url, Command onClosed); clear()77 void clear(); hasSlides()78 boolean hasSlides(); 79 home()80 void home(); navigate(int index)81 void navigate(int index); next()82 void next(); prev()83 void prev(); 84 getNavigationMenu()85 SlideNavigationMenu getNavigationMenu(); 86 pauseMedia()87 void pauseMedia(); 88 getPresentationTitle()89 String getPresentationTitle(); 90 showBusy()91 void showBusy(); hideBusy()92 void hideBusy(); 93 } 94 95 @Inject Presentation(Display display, PresentationServerOperations server, GlobalDisplay globalDisplay, FileDialogs fileDialogs, RemoteFileSystemContext fileSystemContext, EventBus eventBus, FileTypeRegistry fileTypeRegistry, Session session, Binder binder, Commands commands, PresentationDispatcher dispatcher)96 public Presentation(Display display, 97 PresentationServerOperations server, 98 GlobalDisplay globalDisplay, 99 FileDialogs fileDialogs, 100 RemoteFileSystemContext fileSystemContext, 101 EventBus eventBus, 102 FileTypeRegistry fileTypeRegistry, 103 Session session, 104 Binder binder, 105 Commands commands, 106 PresentationDispatcher dispatcher) 107 { 108 super(display); 109 view_ = display; 110 server_ = server; 111 globalDisplay_ = globalDisplay; 112 fileDialogs_ = fileDialogs; 113 fileSystemContext_ = fileSystemContext; 114 eventBus_ = eventBus; 115 commands_ = commands; 116 fileTypeRegistry_ = fileTypeRegistry; 117 session_ = session; 118 dispatcher_ = dispatcher; 119 dispatcher_.setContext(new PresentationDispatcher.Context() 120 { 121 @Override 122 public void pauseMedia() 123 { 124 view_.pauseMedia(); 125 } 126 127 @Override 128 public String getPresentationFilePath() 129 { 130 return currentState_.getFilePath(); 131 } 132 }); 133 navigationPresenter_ = new SlideNavigationPresenter(this); 134 135 binder.bind(commands, this); 136 137 // auto-refresh for presentation files saved 138 eventBus.addHandler(SourceFileSaveCompletedEvent.TYPE, 139 new SourceFileSaveCompletedEvent.Handler() { 140 @Override 141 public void onSourceFileSaveCompleted(SourceFileSaveCompletedEvent event) 142 { 143 if (currentState_ != null) 144 { 145 FileSystemItem file = event.getSourceFile(); 146 if (file.getPath() == currentState_.getFilePath()) 147 { 148 int index = detectSlideIndex(event.getContents(), 149 event.getCursorPos().getRow()); 150 if (index != -1) 151 currentState_.setSlideIndex(index); 152 153 refreshPresentation(); 154 } 155 else if (file.getParentPathString() == getCurrentPresDir() 156 && 157 file.getExtension().toLowerCase().equals(".css")) 158 { 159 refreshPresentation(); 160 } 161 } 162 } 163 }); 164 165 eventBus.addHandler(PresentationPaneRequestCompletedEvent.TYPE, 166 new PresentationPaneRequestCompletedEvent.Handler() 167 { 168 @Override 169 public void onPresentationRequestCompleted( 170 PresentationPaneRequestCompletedEvent event) 171 { 172 view_.hideBusy(); 173 } 174 }); 175 176 initPresentationCallbacks(); 177 } 178 initialize(PresentationState state)179 public void initialize(PresentationState state) 180 { 181 if ((state.getSlideIndex() == 0)) 182 view_.bringToFront(); 183 184 init(state); 185 } 186 onShowPresentationPane(ShowPresentationPaneEvent event)187 public void onShowPresentationPane(ShowPresentationPaneEvent event) 188 { 189 globalDisplay_.showProgress("Opening Presentation..."); 190 reloadWorkbench(); 191 } 192 193 @Override editCurrentSlide()194 public void editCurrentSlide() 195 { 196 eventBus_.fireEvent(new EditPresentationSourceEvent( 197 FileSystemItem.createFile(currentState_.getFilePath()), 198 currentState_.getSlideIndex())); 199 } 200 201 @Handler onPresentationNext()202 void onPresentationNext() 203 { 204 view_.next(); 205 } 206 207 @Handler onPresentationPrev()208 void onPresentationPrev() 209 { 210 view_.prev(); 211 } 212 213 @Handler onPresentationFullscreen()214 void onPresentationFullscreen() 215 { 216 // clear the internal iframe so there is no conflict over handling 217 // presentation events (we'll restore it on zoom close) 218 view_.clear(); 219 220 // show the zoomed version of the presentation. after it closes 221 // restore the inline version 222 view_.zoom(session_.getSessionInfo().getPresentationName(), 223 buildPresentationUrl("zoom"), 224 new Command() { 225 @Override 226 public void execute() 227 { 228 view_.load(buildPresentationUrl(), currentState_.getFilePath()); 229 } 230 }); 231 } 232 233 @Handler onPresentationViewInBrowser()234 void onPresentationViewInBrowser() 235 { 236 if (Desktop.isDesktop()) 237 { 238 server_.createDesktopViewInBrowserPresentation( 239 new SimpleRequestCallback<String>() { 240 @Override 241 public void onResponseReceived(String path) 242 { 243 Desktop.getFrame().showFile(StringUtil.notNull(path)); 244 } 245 }); 246 } 247 else 248 { 249 globalDisplay_.openWindow( 250 server_.getApplicationURL("presentation/view")); 251 } 252 } 253 254 @Handler onPresentationSaveAsStandalone()255 void onPresentationSaveAsStandalone() 256 { 257 // determine the default file name 258 if (saveAsStandaloneDefaultPath_ == null) 259 { 260 FileSystemItem presFilePath = FileSystemItem.createFile( 261 currentState_.getFilePath()); 262 saveAsStandaloneDefaultPath_ = FileSystemItem.createFile( 263 presFilePath.getParentPath().completePath(presFilePath.getStem() 264 + ".html")); 265 } 266 267 fileDialogs_.saveFile( 268 "Save Presentation As", 269 fileSystemContext_, 270 saveAsStandaloneDefaultPath_, 271 ".html", 272 false, 273 new ProgressOperationWithInput<FileSystemItem>(){ 274 275 @Override 276 public void execute(final FileSystemItem targetFile, 277 ProgressIndicator indicator) 278 { 279 if (targetFile == null) 280 { 281 indicator.onCompleted(); 282 return; 283 } 284 285 indicator.onProgress("Saving Presentation..."); 286 287 server_.createStandalonePresentation( 288 targetFile.getPath(), 289 new VoidServerRequestCallback(indicator) { 290 @Override 291 public void onSuccess() 292 { 293 saveAsStandaloneDefaultPath_ = targetFile; 294 } 295 }); 296 } 297 }); 298 } 299 saveAsStandalone(String targetFile, final ProgressIndicator indicator, final Command onSuccess)300 private void saveAsStandalone(String targetFile, 301 final ProgressIndicator indicator, 302 final Command onSuccess) 303 { 304 server_.createStandalonePresentation( 305 targetFile, new VoidServerRequestCallback(indicator) { 306 @Override 307 public void onSuccess() 308 { 309 onSuccess.execute(); 310 } 311 }); 312 } 313 314 @Handler onClearPresentationCache()315 void onClearPresentationCache() 316 { 317 globalDisplay_.showYesNoMessage( 318 MessageDialog.INFO, 319 "Clear Knitr Cache", 320 "Clearing the Knitr cache will discard previously cached " + 321 "output and re-run all of the R code chunks within the " + 322 "presentation.\n\n" + 323 "Are you sure you want to clear the cache now?", 324 false, 325 new ProgressOperation() { 326 327 @Override 328 public void execute(final ProgressIndicator indicator) 329 { 330 indicator.onProgress("Clearing Knitr Cache..."); 331 server_.clearPresentationCache( 332 new ServerRequestCallback<Void>() { 333 @Override 334 public void onResponseReceived(Void response) 335 { 336 indicator.onCompleted(); 337 refreshPresentation(); 338 } 339 340 @Override 341 public void onError(ServerError error) 342 { 343 indicator.onCompleted(); 344 globalDisplay_.showErrorMessage( 345 "Error Clearing Cache", 346 getErrorMessage(error)); 347 } 348 }); 349 } 350 351 }, 352 new ProgressOperation() { 353 354 @Override 355 public void execute(ProgressIndicator indicator) 356 { 357 indicator.onCompleted(); 358 } 359 }, 360 true); 361 } 362 363 364 @Handler onRefreshPresentation()365 void onRefreshPresentation() 366 { 367 if (Event.getCurrentEvent().getShiftKey()) 368 currentState_.setSlideIndex(0); 369 370 refreshPresentation(); 371 } 372 refreshPresentation()373 private void refreshPresentation() 374 { 375 view_.showBusy(); 376 view_.load(buildPresentationUrl(), currentState_.getFilePath()); 377 } 378 379 @Override onSelected()380 public void onSelected() 381 { 382 super.onSelected(); 383 384 // after doing a pane reconfig the frame gets wiped (no idea why) 385 // workaround this by doing a check for an active state with 386 // no slides currently displayed 387 if (currentState_ != null && 388 currentState_.isActive() && 389 !view_.hasSlides()) 390 { 391 init(currentState_); 392 } 393 } 394 395 confirmClose(Command onConfirmed)396 public void confirmClose(Command onConfirmed) 397 { 398 final ProgressIndicator progress = new GlobalProgressDelayer( 399 globalDisplay_, 400 0, 401 "Closing Presentation...").getIndicator(); 402 403 server_.closePresentationPane(new ServerRequestCallback<Void>(){ 404 @Override 405 public void onResponseReceived(Void resp) 406 { 407 reloadWorkbench(); 408 } 409 410 @Override 411 public void onError(ServerError error) 412 { 413 progress.onError(error.getUserMessage()); 414 415 } 416 }); 417 } 418 419 420 @Override navigate(int index)421 public void navigate(int index) 422 { 423 view_.navigate(index); 424 425 } 426 427 @Override getNavigationMenu()428 public SlideNavigationMenu getNavigationMenu() 429 { 430 return view_.getNavigationMenu(); 431 } 432 433 @Override addSlideNavigationChangedHandler( SlideNavigationChangedEvent.Handler handler)434 public HandlerRegistration addSlideNavigationChangedHandler( 435 SlideNavigationChangedEvent.Handler handler) 436 { 437 return handlerManager_.addHandler(SlideNavigationChangedEvent.TYPE, 438 handler); 439 } 440 441 @Override addSlideIndexChangedHandler( SlideIndexChangedEvent.Handler handler)442 public HandlerRegistration addSlideIndexChangedHandler( 443 SlideIndexChangedEvent.Handler handler) 444 { 445 return handlerManager_.addHandler(SlideIndexChangedEvent.TYPE, handler); 446 } 447 getErrorMessage(ServerError error)448 public static String getErrorMessage(ServerError error) 449 { 450 String message = error.getUserMessage(); 451 JSONString userMessage = error.getClientInfo().isString(); 452 if (userMessage != null) 453 message = userMessage.stringValue(); 454 return message; 455 } 456 reloadWorkbench()457 private void reloadWorkbench() 458 { 459 eventBus_.fireEvent(new ReloadWithLastChanceSaveEvent()); 460 } 461 462 init(PresentationState state)463 private void init(PresentationState state) 464 { 465 currentState_ = state; 466 view_.load(buildPresentationUrl(), currentState_.getFilePath()); 467 } 468 buildPresentationUrl()469 private String buildPresentationUrl() 470 { 471 return buildPresentationUrl(null); 472 } 473 buildPresentationUrl(String extraPath)474 private String buildPresentationUrl(String extraPath) 475 { 476 String url = server_.getApplicationURL("presentation/"); 477 if (extraPath != null) 478 url = url + extraPath; 479 url = url + "#/" + currentState_.getSlideIndex(); 480 return url; 481 } 482 isPresentationActive()483 private boolean isPresentationActive() 484 { 485 return (currentState_ != null) && 486 (currentState_.isActive())&& 487 view_.hasSlides(); 488 } 489 getCurrentPresDir()490 private String getCurrentPresDir() 491 { 492 if (currentState_ == null) 493 return ""; 494 495 FileSystemItem presFilePath = FileSystemItem.createFile( 496 currentState_.getFilePath()); 497 return presFilePath.getParentPathString(); 498 } 499 onPresentationSlideChanged(final int index, final JavaScriptObject jsCmds)500 private void onPresentationSlideChanged(final int index, 501 final JavaScriptObject jsCmds) 502 { 503 // note the slide index and save it 504 currentState_.setSlideIndex(index); 505 indexPersister_.setIndex(index); 506 507 handlerManager_.fireEvent(new SlideIndexChangedEvent(index)); 508 509 // execute commands if we stay on the slide for > 500ms 510 new Timer() { 511 @Override 512 public void run() 513 { 514 // execute commands if we're still on the same slide 515 if (index == currentState_.getSlideIndex()) 516 { 517 JsArray<JavaScriptObject> cmds = jsCmds.cast(); 518 for (int i=0; i<cmds.length(); i++) 519 dispatchCommand(cmds.get(i)); 520 } 521 } 522 }.schedule(500); 523 } 524 dispatchCommand(JavaScriptObject jsCommand)525 private void dispatchCommand(JavaScriptObject jsCommand) 526 { 527 dispatcher_.dispatchCommand(jsCommand); 528 } 529 initPresentationNavigator(JavaScriptObject jsNavigator)530 private void initPresentationNavigator(JavaScriptObject jsNavigator) 531 { 532 // record current slides 533 SlideNavigation navigation = jsNavigator.cast(); 534 handlerManager_.fireEvent( 535 new SlideNavigationChangedEvent(navigation)); 536 } 537 recordPresentationQuizAnswer(int slideIndex, int answer, boolean correct)538 private void recordPresentationQuizAnswer(int slideIndex, 539 int answer, 540 boolean correct) 541 { 542 server_.tutorialQuizResponse(slideIndex, 543 answer, 544 correct, 545 new VoidServerRequestCallback()); 546 } 547 initPresentationCallbacks()548 private final native void initPresentationCallbacks() /*-{ 549 var thiz = this; 550 $wnd.presentationSlideChanged = $entry(function(index, cmds) { 551 thiz.@org.rstudio.studio.client.workbench.views.presentation.Presentation::onPresentationSlideChanged(ILcom/google/gwt/core/client/JavaScriptObject;)(index, cmds); 552 }); 553 $wnd.dispatchPresentationCommand = $entry(function(cmd) { 554 thiz.@org.rstudio.studio.client.workbench.views.presentation.Presentation::dispatchCommand(Lcom/google/gwt/core/client/JavaScriptObject;)(cmd); 555 }); 556 $wnd.initPresentationNavigator = $entry(function(slides) { 557 thiz.@org.rstudio.studio.client.workbench.views.presentation.Presentation::initPresentationNavigator(Lcom/google/gwt/core/client/JavaScriptObject;)(slides); 558 }); 559 $wnd.recordPresentationQuizAnswer = $entry(function(index, answer, correct) { 560 thiz.@org.rstudio.studio.client.workbench.views.presentation.Presentation::recordPresentationQuizAnswer(IIZ)(index, answer, correct); 561 }); 562 }-*/; 563 564 private class IndexPersister extends TimeBufferedCommand 565 { IndexPersister()566 public IndexPersister() 567 { 568 super(500); 569 } 570 setIndex(int index)571 public void setIndex(int index) 572 { 573 index_ = index; 574 nudge(); 575 } 576 577 @Override performAction(boolean shouldSchedulePassive)578 protected void performAction(boolean shouldSchedulePassive) 579 { 580 server_.setPresentationSlideIndex(index_, 581 new VoidServerRequestCallback()); 582 } 583 584 private int index_ = 0; 585 } 586 587 private IndexPersister indexPersister_ = new IndexPersister(); 588 589 590 591 592 detectSlideIndex(String contents, int cursorLine)593 private static int detectSlideIndex(String contents, int cursorLine) 594 { 595 int currentLine = 0; 596 int slideIndex = -1; 597 String slideRegex = "^\\={3,}\\s*$"; 598 599 Iterator<String> it = StringUtil.getLineIterator(contents).iterator(); 600 while (it.hasNext()) 601 { 602 String line = it.next(); 603 if (line.matches(slideRegex)) 604 slideIndex++; 605 606 if (currentLine++ >= cursorLine) 607 { 608 // bump the slide index if the next line is a header 609 if (it.hasNext() && it.next().matches(slideRegex)) 610 slideIndex++; 611 612 return slideIndex; 613 } 614 } 615 616 617 return -1; 618 } 619 620 private final Display view_; 621 private final PresentationServerOperations server_; 622 private final GlobalDisplay globalDisplay_; 623 private final EventBus eventBus_; 624 private final Commands commands_; 625 private final FileTypeRegistry fileTypeRegistry_; 626 private final FileDialogs fileDialogs_; 627 private final RemoteFileSystemContext fileSystemContext_; 628 private final Session session_; 629 private final PresentationDispatcher dispatcher_; 630 private final SlideNavigationPresenter navigationPresenter_; 631 private PresentationState currentState_ = null; 632 633 private FileSystemItem saveAsStandaloneDefaultPath_ = null; 634 635 private HandlerManager handlerManager_ = new HandlerManager(this); 636 } 637