This is how this VSCode plug-in with 3 million downloads was developed:

Today, I am going to look for a PDF reading plug-in in the VSCode plug-in market. I found a plug-in with the highest download volume, clicked in to view the detailed information, and found that the interface gave me a familiar feeling. I checked the source code of the plug-in, and sure enough I found that it directly embeds the pdf.js web interface. Developing such a plug-in is actually not complicated. You only need to know some knowledge about plug-in development to implement it. Next, I will share how I developed this plugin. Although it may be a little different from the source code, the basic principles are the same.

The previous article has introduced in detail how to create a plug-in project, so we go directly to the function implementation part. The focus is to enable direct browsing in the plug-in after clicking on the PDF file.

Define a custom editor

By default, VSCode does not directly support viewing of some special file types, but you can extend its functionality by using custom editors customEditors.

CustomEditors allow you to create fully customizable read/write editors that replace VSCode’s standard text editor for working with specific types of resources. For example, when editing a Markdown file, you can create a custom editor that previews the Markdown rendering in real time.

For preview of PDF files, we can also use customEditors function. First, define it in your plugin’s package.json file:

"contributes": {
  "customEditors": [
    {
      "viewType": "dodo-reader.pdfEditor",
      "displayName": "PDF Viewer",
      "selector": [
        {
          "filenamePattern": "*.pdf"
        }
      ]
    }
  ]
}

Register a custom editor

  1. Create a class that implements the vscode.CustomEditorProvider interface. This interface contains methods for managing custom editors, including opening, saving, and other operations. For example:
class PdfEditorProvider implements Partial<CustomEditorProvider> {
    constructor() {
        // Some initialization operations can be performed here
    }

    resolveCustomEditor(document: CustomDocument, webviewPanel: WebviewPanel, _token: CancellationToken) {
        // Create a custom document based on the URI and return a custom document object
    }

    openCustomDocument(uri: vscode.Uri, openContext: vscode.CustomDocumentOpenContext, token: vscode.CancellationToken) {
        //Associate the custom document with the Webview panel and handle the interaction between the editor content and Webview
    }
}
  1. After you have implemented the CustomEditorProvider interface in your class, register your provider:
const myProvider = new PdfEditorProvider();
const disposable = vscode.window.registerCustomEditorProvider('dodo-reader.pdfEditor', myProvider); //Register provider, dodo-reader.pdfEditor corresponds to the logo defined above
context.subscriptions.push(disposable); // Add the provider to the subscription

Improving the view provider

After the above-mentioned registration of the custom editor, the next focus is to improve the PdfEditorProvider interface to define the display of the view. Displaying the view will involve using webview, and you will be using the pdf.js webview program. First download the program Prebuilt (modern browsers) and extract it to the project directory. This program can actually be accessed directly from the browser. Start a service in the directory, and then open the address directly:

You can add the query parameter ?file=fileUrl to the URL to open your PDF file.

openCustomDocument

When opening a PDF file, openCustomDocument will be called first. You can create a custom document object based on the incoming URI. The document object will contain the content you want to edit. This program does not do any processing and directly returns the object containing the URI. This object will be passed to the document parameter of the resolveCustomEditor method below.

openCustomDocument(uri: vscode.Uri, openContext: vscode.CustomDocumentOpenContext, token: vscode.CancellationToken) {
  return {
    uri,
    dispose: () => { }
  }
}

resolveCustomEditor

In the resolveCustomEditor method, you can define the displayed view. The procedure is as follows:

resolveCustomEditor(document: CustomDocument, webviewPanel: WebviewPanel, _token: CancellationToken) {
  webviewPanel.webview.html = 'Hello World!';
}

Displayed as shown:

Then to display the PDF file, you only need to replace the downloaded PDF program html content with the value of the current webviewPanel.webview.html. I originally thought it could be changed to:

// Nest an iframe for PDF view browsing
webviewPanel.webview.html = `
<!doctype html>
<html>
  <head>
    <meta charset="UTF-8" />
  </head>
  <body>
    <iframe src=""></iframe>
  </body>
</html>
`;

This method is the simplest, but to my surprise, the iframe does not display the content. This may be caused by VSCode’s security policy restrictions. So I thought about another approach: by reading the HTML content of the PDF view’s first page and assigning it to webviewPanel.webview.html. However, this approach can run into a problem: how to provide the PDF viewing program with a link to the file to open.

The PDF view program mentioned above will obtain the file link to be displayed through the query parameter “file”. If this parameter is not provided, it will read the default link. Here are relevant source code examples:

file = params.get("file")  _app_options.AppOptions.get("defaultUrl");

However, direct assignment of HTML text does not provide a “file” value via a URL. This may require modification of the source code. Although modifying the source code may cause some inconvenience, at first it seems that there is no other way but to try it. One idea is to change the way of obtaining the “file” value to obtain it directly from the global variable. You can add the “file” value to the read HTML content:

window.file = 'https://...'

Then modify the above source code to:

file = window.file  _app_options.AppOptions.get("defaultUrl");

However, just when I thought it was about to succeed, another red error message appeared: An error occurred while loading the PDF, file origin does not match viewer’s, which is really frustrating. Then I found the corresponding source code and found that the source code will compare the URL origin of the current page and the URL origin of the file before opening the PDF. How can this be the same? One is the link at the beginning of vscode-webview://, and the other is the link at the beginning of https:/ /the link to. Although it may be possible to remove this check, overall it seems that this method is not feasible and modifying the content is too cumbersome. So, is there a way to solve the problem without modifying the PDF view source code? Continuing to start with the source code, I later discovered a “fileinputchange” event listening handler. The code is as follows:

var webViewerFileInputChange = function (evt) {
  if (PDFViewerApplication.pdfViewer?.isInPresentationMode) {
    return;
  }
  const file = evt.fileInput.files[0];
  PDFViewerApplication.open({
    url: URL.createObjectURL(file),
    originalUrl: file.name
  });
};

Through the code, it is easy to see that once a change in the file is detected, the open method will be called immediately to open it. It should be noted that this open method does not verify whether the URL is the same as the current origin, which is currently linked to the blob. Given this situation, can we directly call the open method to open the file after initialization is completed? After some modifications, it was finally proven that this was feasible. Here is the modified code example:

resolveCustomEditor(document: CustomDocument, webviewPanel: WebviewPanel, _token: CancellationToken) {
  webviewPanel.webview.options = {
    enableScripts: true,
    localResourceRoots: [vscode.Uri.file(path.dirname(document.uri.fsPath)), this.context.extensionUri]
  };
  const base = vscode.Uri.joinPath(this.context.extensionUri, 'dist/web/pdf/web/')
  webviewPanel.webview.html = readFileSync(path.join(base.fsPath, 'viewer.html'), 'utf8').replace('<head>', `<head>
    <base href="${webviewPanel.webview.asWebviewUri(base).toString()}">
    <script>
    window.addEventListener('load', async function () {
      PDFViewerApplication.initializedPromise.then(() => {
        setTimeout(() => {
          PDFViewerApplication.open({url: "${webviewPanel.webview.asWebviewUri(document.uri)}"})
        })
      })
    })
    </script>
    <style>
      body {
        padding: 0;
      }
    </style>
  `)
}

The above key codes are analyzed as follows:

  1. localResourceRoots parameter: The purpose of this parameter is to define the directories that are allowed to be accessed through the web URL. Here, you need to clearly define two directories: one is the current directory used to open the PDF file, and the other is the directory where the plug-in is located. Make sure both are defined correctly, otherwise you may encounter a 401 error when accessing.
  2. tag: By setting the tag, you can provide a basic path for loading resources within the web page. Make sure the resource loads correctly.
  3. setTimeout function: When calling a program to open a file, the purpose of using setTimeout is to open the currently required PDF file after opening the default PDF file. This will prevent the subsequent execution of opening the default PDF from overwriting the PDF you want to open (actually, if the default file does not exist, an error will be reported, but it will not have any real impact).

This implementation is much simpler than the PDF plug-in implementation mentioned above, but the principle is basically the same. Let’s take a look at the final result: