Skip to content

File Viewers ​

A file viewer opens files with given extensions in a tab, using a page from your extension. The file is parsed inside the sandboxed page — a malformed or malicious file can't reach the network or other files even if it breaks your parser.

json
{
  "id": "typora.csv-viewer",
  "name": "CSV Viewer",
  "version": "1.0.0",
  "contributes": {
    "fileViewers": [
      {
        "id": "csv",
        "title": "CSV Table",
        "extensions": ["csv", "tsv"],
        "entry": "viewer/index.html",
        "editable": true
      }
    ]
  }
}

Fields ​

FieldRequiredDescription
idyesLocal id of the viewer.
titleDisplay name. Defaults to id.
extensionsyesFile extensions without the dot. md and markdown are not allowed — Markdown always opens in the editor.
entryThe viewer page. Required unless main resolves it.
priority"default" opens these files with this viewer. "option" does not take over the file type. Default "default".
editabletrue lets the page save changes back to the file. Default false.
retainWhenHiddenReserved. Pages are recreated when their tab is shown again; use getState / setState to restore UI state.

The page ​

html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="viewer.css" />
    <script src="../__typora/view.js"></script>
    <script src="viewer.js" defer></script>
  </head>
  <body><table id="grid"></table></body>
</html>

The SDK path

__typora/view.js is served from the root of every extension. From a page in a sub-folder, use a relative path that reaches the root (../__typora/view.js).

js
const view = window.typora.view;

view.onOpen(({ name, path, ext, size, readonly, bytes }) => {
  const text = new TextDecoder().decode(bytes); // bytes is an ArrayBuffer
  rows = parseCsv(text, ext === "tsv" ? "\t" : ",");
  render(rows, { readonly });
});

// After the user edits a cell:
view.setDirty(true);

// On save (e.g. a Save button, or ⌘S forwarded by your own handler):
await view.save(serialize(rows)); // string, ArrayBuffer or Uint8Array
  • onOpen gets the file's name, path, extension, size, whether it is read-only, and its bytes.
  • setDirty(true) marks the tab as modified; save(data) writes the file and clears it. Your extension needs no file permission — Typora writes the file it opened.
  • getState() / setState(value) keep scroll position or UI state across tab switches.

Resolving from main ​

With a main script, the viewer can be resolved at open time: main picks the page and can talk to it. This is useful when the page needs something only main can do, such as the clipboard:

js
// main.js
export function activate(ctx) {
  ctx.subscriptions.push(
    typora.views.registerFileViewer("csv", {
      resolve(file, panel) {
        // file: { path, name, ext, readonly } — metadata only
        panel.entry = "viewer/index.html";
        panel.onMessage(async (message) => {
          if (message?.type !== "copyMarkdown") return;
          await typora.clipboard.writeText(message.markdown);
          await typora.ui.toast(`Copied ${message.rows} rows as a Markdown table.`);
        });
      },
    }),
  );
}
js
// viewer.js
view.postMessage({ type: "copyMarkdown", markdown: toMarkdown(rows), rows: rows.length - 1 });
  • panel.entry is a path inside your extension. If main registers no provider, the manifest entry is used.
  • panel.postMessage(message) sends to the page (view.onMessage); panel.onMessage(fn) receives from it (view.postMessage).
  • File bytes go straight to the page; they never pass through the Worker.
  • A viewer without entry activates main when a file opens (onFileViewer:<id>).

See the CSV Viewer sample.

Theme adapted from Nimbus (MIT).