Rendering viewer in a new controlled window

Which product are you using?

PDF.js Express Viewer

PDF.js Express Version

8.7.0

Detailed description of issue
Hi! I’m trying to implement a use-case for opening a new controlled window, and rendering the PDFJS Express Viewer within it. Here is a (very simplified) example of what I’m trying to achieve:

const popupWindow = window.open("about:blank", "_blank")
const element = document.createElement("div")

popupWindow.document.body.appendChild(element)

WebViewer(config, element).then(...)

The iframe renders and the assets are successfully loaded, but no UI is rendered / the iframe contents seem to contain the scaffold / DOM structure for the viewer’s index.html, but no actual UI is rendered.

I’ve tried configuring the viewer for CORS (using config.js) but that made no difference (and it shouldn’t, given that “about:blank” targets inherit the origin of the opener).

I’ve also tried more advanced techniques such as reverse-portals (rendering the viewer in the parent window, then moving the container element into a said new window), and even though the viewer renders properly in the parent before being moved to the new window, same rendering issue occurs after moving it to the child window.

Note that we currently do use the viewer in our application (albeit not in a separate window), so I can confirm that this is not related to a misconfiguration of the viewer itself.

Expected behaviour

It should still be able to render even if the opener window isn’t the same window that it was opened in.

Does your issue happen with every document, or just one?
Any document

Please let me know if I can provide you with more information :slight_smile: Thanks in advance!

Hi there,

Thank you for contacting pdf.js express forums,

Could you please provide any screenshots/console error messages and your WebViewer instantiation?

Does WebViewer successfully load if it is instantiated in the new window rather than the initial window?

Thank you in advance.

Best regards,
Kevin Kim

Hi, thanks for the reply!

Here are some additional details attached:

  • Console of the new window, showing instantiation information, and the exception thrown
  • Screenshot of said new window, with the iframe element highlighted
  • Screenshot of the inner contents of the iframe, which shows that the webviewer UI index.html file is properly loaded (and all assets / scripts it requests do resolve, so this isn’t a path resolution issue)

Does WebViewer successfully load if it is instantiated in the new window rather than the initial window?

I’m sure it would, but I don’t believe this is feasible in the use-case I’m describing: you can only control a new window’s document when you open it as “about:blank”, which prevents me having the new window do the loading itself: Same-origin policy - Web security | MDN

(Will have to do 3 replies, as I’m only allowed by forum to post one embed / post)

Thank you for the reply,

You mentioned before that iframe resources are loaded but no UI components are rendered. Could you check if the WebViewer instance is created in the new window?

Are you able to get to the documentLoaded event?

Does WebViewer have an element to mount to in the new window?
In the above guide, it is mounted to document.getElementById(‘viewer’)

Best regards,
Kevin Kim

Hi again,

  • I can confirm the element does exist in the new (child) window, this is how I was able to highlight the iframe in my second screenshot. The fact the iframe renders and the scripts/assets required within the iframe are loaded does also imply that it does render within the new window
  • I don’t get the documentLoaded event, in fact, the promise returned by Webviewer(config, el).then(...) never resolves with an instance, so it seems to fail earlier than that. I’ve also been trying to load viewer without a doc, just to see if UI (toolbar etc) renders, to no avail. As you can see in my console screenshot there is however a log showing webviewer info, which further implies that it does start loading but fails during bootstrap.
  • The Webviewer instance is created within the opener (parent) window, but is passed an element which belongs to / is in the new (child) window

Trying to make a repro, I can confirm that doing the minimal example I gave in my initial post does reproduce the bug:

const openViewer = () => {
  const popout = window.open("about:blank", "_blank")
  const element = document.createElement("div")
  popout.document.body.appendChild(element)
  WebViewer({ path: WEBVIEWER_PATH, licenseKey: LICENSE_KEY }, element)
    .then(console.log, console.error) // neither get called
}

openViewer()

If that helps, let me know if you guys have a codesandbox/codepen I can use to give you a version with above repro.

Hi there,

Thank you for your reply, I was able to reproduce your functionality.

I am not entirely certain if WebViewer can handle multi-window functionality, but I when I changed the path in the WebViewer constructor to my domain, I had access to the instance in the console:

WebViewer(
    { 
      path: 'http://localhost:3000/lib',
      // path: '../../../lib',
      // initialDoc: 'https://pdftron.s3.amazonaws.com/downloads/pl/demo-annotated.pdf',
      // licenseKey: LICENSE_KEY 
    }, element)

I would presume the issue could be that not all assets are loaded in the new window. For example, in our sample project, the index.html contains the assets:

Best regards,
Kevin Kim

Hi again, thank you for your reponse.

I forgot to mention in my initial post that I did have to include the origin within the config in order for it to partially render, so all my examples were based off of that.

I did further digging, and believe I found the root cause of the issue, and it basically relates to how the viewer does iframe <-> window IPC:

  • the iframe uses window.postMessage to send messages from the iframe → the app-level viewer package
  • from the context of the iframe, window refers to the popout window, not the parent window. messages don’t get bubbled up.
  • the webviewer lib which runs in our app code (i.e. not the viewer iframe, in the parent window) listens to the parent window’s message events, which doesn’t receive events from posted to the popup (child) window
  • therefore there is a disconnect between the two: iframe sends messages to child window, app-level webviewer library listens for messages on the parent window

Now on your end, this should be a relatively simple fix, and an issue I’ve experienced / had to fix with a few other libraries when opening in a new controlled window:

The app/parent level library (npm module we import) should be using the element.ownerDocument.defaultView of the element which is provided in the WebViewer(..., element) constructor as a target to subscribe to messages sent by the iframe, as opposed to the current behaviour of using the global window variable, which would refer to the parent window in this context.

Doing one of those fixes would basically fix the whole issue and would be non-breaking for the regular use-case of rendering in the window that instantiates the viewer (since in that context, element.ownerDocument.defaultView === window).

(Note: you could fix it the other way around: from iframe, doing parentWindow = window.parent?.opener || window, but that’s less flexible as it wouldn’t enable an arbitrary level of window / iframe nesting, unlike the proposed fix).

I obviously don’t have access to your code, so I can’t easily propose detailed changes, but I was able to prove the above by crafting a small workaround which does make the viewer (and documents) successfully render in a popup window! It’s obviously very suboptimal and hackish, but proves that this is the issue:

const openViewer = () => {
  const popout = window.open("about:blank", "_blank")
  const element = document.createElement("div")
  popout.document.body.appendChild(element)

  let iframeRef = null

  popout.addEventListener("message", event => {
    if(!!e.source.instance) { // hack to check if it came from the webviewer iframe
      iframeRef = e.source
      data = event.data
      if(typeof data === "object") {
        // _source allows deduping and not sending msg back from parent -> iframe
        data = {...data, _source: "pdfjs-express"}
      }
      // forward message to parent window
      window.postMessage(event.data, event.origin)
    }
  })

   // since webviewer replies to the message sent using event source, 
  // which is forwarded above in ctx of parent window,
  // webviewer replies will be posted to parent window instead of iframe
  window.addEventListener("message", event => {
    // don't send back events that came from the iframe
    if(iframeRef && e.data._source !== "pdfjs-express") {
      // forward to iframe
      iframeRef.postMessage(event.data, event.origin)
    }
  })

  
  WebViewer({ path: WEBVIEWER_PATH, licenseKey: LICENSE_KEY }, element)
    .then(instance => {...})
}

openViewer()

What would you recommend going forward? Would it be able to get this logged as a bug for your team to address?

I hope this helps, please let me know if you have any further questions or if I can provide any further information!

Hi there,

We don’t currently have this on our roadmap but we could add it to the backlog for our product team to review. If you could provide some further information around the context of why you want the feature, this may help prioritize its development. We would be interested to know:

What is the workflow that you would use the requested feature in?
Why is the feature valuable to you and your users?
How are you coping without it right now?

Best regards,
Kevin Kim

Hi Kevin, sure I’ll give you some additional context.

First, I would personally categorize this as a “bug”, since there’s no new features to add to support this and this is supported with a workaround (and the fix I proposed would not require any additional work to support from the PoV of a library consumer)

What is the workflow that you would use the requested feature in?

The usecase is the ability to open and instantiate a WebViewer in a controlled popup window, i.e. a window opened using (window.open("about:blank")).

This allows developers to create a popup/window-based alternative to a modal, while still allowing the viewer to communicate back to the main application, and allows the main application to control the instance opened in the new window (unlike an “uncontrolled” window, which would be opened to a canonical URL instead of about:blank).

Why is the feature valuable to you and your users?

Being able to open a viewer in a new window is essential to productivity applications which implement any kind of “file-browsing” capabilities. Notably, being able to use a window instead of a modal/virtual window means that the user can better leverage multi-screen setups and window organization tools provided by their OS to manage viewing multiple documents at once.

How are you coping without it right now?

We are coping by using the workaround that I posted in the above comment, which works but is relatively hack-ish given how we have to intercept the communication channel that exists between your iframe and the parent document in order to “trick” you library into communicating with the parent window, when opened within a child window.

Please let me know if I can provide any more information.
Best

Going back to this, please let me know if you’d like me to create a new thread for it in your “bug reports” topic with aggregate information on repro, root-cause, and workaround :slight_smile: