Appearance
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
| Field | Required | Description |
|---|---|---|
id | yes | Local id of the viewer. |
title | Display name. Defaults to id. | |
extensions | yes | File extensions without the dot. md and markdown are not allowed — Markdown always opens in the editor. |
entry | The 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". | |
editable | true lets the page save changes back to the file. Default false. | |
retainWhenHidden | Reserved. 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 Uint8ArrayonOpengets 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.entryis a path inside your extension. Ifmainregisters no provider, the manifestentryis 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
entryactivatesmainwhen a file opens (onFileViewer:<id>).
See the CSV Viewer sample.