API reference
Everything a plugin is written against, from the SDK’s index.d.ts. A plugin is a class that extends Plugin; it reaches the rest of Bläck Studio through this.app.
what a Bläck Studio plugin is written against.
import { Plugin } from '@black/plugin'
export default class WordOfTheDay extends Plugin { onload() { this.addCommand({ id: 'hello', name: 'Say hello', run: () => this.app.ui.notify('Hello') }) } }
A plugin runs in Bläck Studio's window, where it can draw its own interface with React (Bläck Studio's own copy: import React from 'react'), or — with "sandboxed": true in its manifest — in a worker of its own, where it has everything here but what draws (views, inspector tabs, settings panels, editor extensions). The same code works in either, as long as it doesn't use those.
Everything a plugin adds through these methods is taken away when it's turned off or reloaded. It reaches the book only through this.app, and the internet only through this.app.net.fetch, to the hosts its manifest names and the writer allowed.
Plugin class
readonly app: Appreadonly manifest: Manifestsettings: Record<string, unknown>The values of the settings declared with addSettings.
onload(): void | Promise<void>Set up: add commands, views and the rest.
onunload(): voidAnything to undo as it's turned off, beyond what Bläck Studio takes away itself.
Every add… returns what takes it away again: for when a setting changes what a plugin offers. Whatever's still there goes when the plugin is turned off.
register(dispose: () => void): voidSomething to undo when the plugin goes: a timer, a listener.
addCommand(command: {
id: string
name: string
hotkey?: string
menu?: boolean
when?: () => boolean
run: () => void | Promise<void>
}): () => voidIn the command palette (Ctrl+K), and the Plugins menu unless menu: false. hotkey: "Mod+Shift+F" (Mod is Ctrl, or ⌘).
addView(view: { id: string; name: string; icon?: ComponentType<{ size?: number }>; render: () => ReactNode }): () => voidA view beside the corkboard and outliner. Window only.
addInspectorTab(tab: {
id: string
name: string | ((nodeId: string) => string)
when?: (nodeId: string, node: { type: NodeType; title: string }) => boolean
render: (nodeId: string) => ReactNode
}): () => voidA tab in the inspector, for the items when says (given the item's type and title). Window only.
addStatusBarItem(item: { id: string; render: () => ReactNode }): () => voidIn the status bar. In the window, it draws itself; sandboxed, it's text, changed with setText.
addStatusBarItem(item: { id: string; text: string; title?: string; onClick?: () => unknown }): {
setText(text: string, title?: string): void
}addEditorMenuItem(item: {
id: string
label: string | ((ctx: EditorContext) => string)
show?: 'selection' | 'no-selection' | 'always'
when?: (ctx: EditorContext) => boolean
run: (ctx: EditorContext) => void | Promise<void>
}): () => voidIn the editor's right-click menu. Sandboxed, the label is a string and when isn't available: use show.
addBinderMenuItem(item: {
id: string
label: string | ((nodeId: string) => string)
when?: (nodeId: string) => boolean
run: (nodeId: string) => void | Promise<void>
}): () => voidIn the binder's right-click menu.
addExporter(exporter: {
id: string
name: string
description?: string
extension: string
export: (book: ExportInput) => string | Uint8Array | Promise<string | Uint8Array>
}): () => voidA format in Compile. Return text, or bytes for a binary format.
addHighlighter(highlighter: {
id: string
color?: string
find: (text: string) => HighlightRange[] | Promise<HighlightRange[]>
}): () => voidMarks in the text, found a paragraph at a time; drawn by Bläck Studio, never saved.
refreshHighlights(): voidAsk every highlighter again, e.g. after a setting that decides them changed.
registerEditorExtension(extension: AnyExtension | AnyExtension[]): () => voidTipTap extensions for every editor. Window only.
addSidebarPanel(panel: { id: string; name: string; render: () => ReactNode }): () => voidA panel in the sidebar, beside the binder and collections. Window only.
addToolbarButton(button: {
id: string
title: string
icon?: ComponentType<{ size?: number }> | string
active?: () => boolean
run: () => void | Promise<void>
}): () => voidA button in the editor's toolbar. Sandboxed, its icon is text (an emoji).
addSuggester(suggester: {
id: string
trigger: string
find: (query: string, ctx: { docId: string }) => Suggestion[] | Promise<Suggestion[]>
}): () => voidSuggestions under the caret as the writer types after trigger ("@", "#"…) at the start of a word.
addImporter(importer: {
id: string
name: string
extensions: string[]
import: (file: ImportFile) => { title: string; html: string }[] | Promise<{ title: string; html: string }[]>
}): () => voidFiles of a kind Bläck Studio doesn't read, for File → Import Documents: each made into documents.
addDashboardWidget(widget: {
id: string
name: string
size?: 'half' | 'full'
order?: number
render: () => ReactNode
}): () => voidA card on the welcome screen's dashboard, shown while no book is open: half its width or all of it, placed by order (lower first, 100 if not given). That's where it starts; the writer can move it and change its width. Window only. API 2.
addSettings(fields: SettingField[]): () => voidSettings Bläck Studio draws in Settings → Plugins; read them from this.settings.
addSettingsPanel(render: () => ReactNode): () => voidSettings of its own drawing. Window only.
onSettingsChange(listener: (settings: Record<string, unknown>) => void): voidupdateSettings(patch: Record<string, unknown>): Promise<void>on(event: PluginEvent, handler: (arg: string | null) => void): voidloadData(): Promise<unknown>Data of its own, on this computer (for data in the book, see app.project.getData).
saveData(data: unknown): Promise<void>App interface
readonly apiVersion: numberreadonly language: stringThe interface's language: 'en', 'sv'…
app.project
The book that's open. Changing it needs "project:write"; every change can be undone, and a document is snapshotted before a plugin first rewrites it.
isOpen(): Promise<boolean>info(): Promise<{ title: string; author: string; language: string; series: string | null } | null>nodes(): Promise<NodeInfo[]>node(id: string): Promise<NodeInfo | null>manuscript(): Promise<string[]>The manuscript's documents, in reading order.
selected(): Promise<string | null>select(id: string): Promise<void>read(id: string): Promise<string>A document's text, as HTML.
readText(id: string): Promise<string>A document's text, plain.
write(id: string, html: string): Promise<void>create(type: 'text' | 'folder' | 'character' | 'place' | 'research', title: string, parentId?: string): Promise<string>meta(id: string): Promise<MetaInfo | null>setMeta(id: string, patch: Partial<Pick<MetaInfo, 'synopsis' | 'notes' | 'keywords' | 'fields' | 'when'>>): Promise<void>trash(id: string): Promise<void>Into the trash, where the writer can get it back.
rename(id: string, title: string): Promise<void>move(id: string, parentId: string, index?: number): Promise<void>Into a folder, at a place among its children (the end, if not given).
labels(): Promise<Label[]>statuses(): Promise<Status[]>setLabel(id: string, labelId: string): Promise<void>By the label's (or status's) id; '' for none.
setStatus(id: string, statusId: string): Promise<void>comments(id: string): Promise<{ id: string; text: string; created: string; resolved: boolean }[]>resolveComment(id: string, commentId: string, resolved?: boolean): Promise<void>snapshots(id: string): Promise<{ label: string; taken: string; html: string }[]>snapshot(id: string, label: string): Promise<void>Keep a document as it is now; no permission needed.
collections(): Promise<{ id: string; name: string; kind: 'manual' | 'search'; ids: string[] }[]>createCollection(name: string, ids?: string[]): Promise<string>Returns the new collection's id.
addToCollection(collectionId: string, ids: string[]): Promise<void>search(
query: string,
opts?: { wholeWord?: boolean; caseSensitive?: boolean; regex?: boolean },
): Promise<{ id: string; title: string; line: number; excerpt: string }[]>As Find in project does.
series(): Promise<{ name: string; books: { id: string; title: string; current: boolean }[] } | null>The series the book is in, and its books in order.
getData(): Promise<unknown>This plugin's own data in this book, saved with it.
setData(value: unknown): Promise<void>app.editor
active(): Editor | nullThe TipTap editor the writer is in. Window only; null in a sandboxed plugin.
selection(): Promise<{ text: string; from: number; to: number } | null>insertText(text: string): Promise<boolean>Type text at the caret (over the selection). Needs "project:write".
comment(text: string): Promise<boolean>A margin comment on the selection. Needs "project:write".
anchor(): Promise<EditorAnchor | null>A place to write at as text comes, as dictation does: the writer's cursor (or where it was before a button took it), else the end of the document they're in. Null if none is open. Needs "project:write". Window only. API 4.
app.ui
notify(message: string, action?: { label: string; run: () => void }): voidconfirm(title: string, detail?: string, confirmLabel?: string): Promise<boolean>Asks yes or no, in Bläck Studio's own dialog: true for yes.
openView(viewId: string): voidShow one of this plugin's views. Window only.
openSettings(): voidShow this plugin's settings.
openModal(modal: {
title: string
render: (close: () => void) => ReactNode
wide?: boolean
/** It's gone: closed by the writer (Escape, ✕) or by `close`; or replaced, by another dialog opened over it. */
onClose?: (how: 'closed' | 'replaced') => void
}): () => voidA dialog of the plugin's own drawing; returns what closes it. Window only.
prompt(opts: { title: string; label?: string; value?: string; placeholder?: string }): Promise<string | null>Ask for a line of text; null if the writer cancels.
choose(opts: { title: string; options: { value: string; label: string; detail?: string }[] }): Promise<string | null>Ask the writer to choose one; null if they cancel.
startSprint(minutes: number): voidStart a timed writing sprint in the open book, as the status bar's does (1 to 600 minutes). API 2.
app.library
The writer's books: those on the recent list, and every book of their series. Needs "library:read". API 2.
books(): Promise<LibraryBook[]>activity(): Promise<LibraryActivity>open(path: string, opts?: { select?: string }): Promise<boolean>Open one of them (closing the one that's open), at one of its documents if given; false if it couldn't be.
app.net
fetch(
url: string,
init?: { method?: string; headers?: Record<string, string>; body?: string | Uint8Array },
): Promise<FetchResult>Over https, to the hosts in the manifest that the writer allowed.
stream(
url: string,
init: { method?: string; headers?: Record<string, string>; body?: string | Uint8Array } | undefined,
onChunk: (text: string) => void,
): Promise<{ ok: boolean; status: number; statusText: string; headers: Record<string, string> }>The answer as it comes, each piece to onChunk as text (an AI's reply, say). Up to ten minutes. A plain-text answer without "X-Content-Type-Options: nosniff" may come whole, up to its first 512 bytes, as Chromium reads that much to guess its kind; event streams and JSON don't.
app.files
Files on the computer: only those the writer chose for the plugin (with pickFolder and pickFile), and a temporary folder of its own. Needs "files:read" or "files:write". Paths are full paths.
pickFolder(opts?: { write?: boolean; title?: string }): Promise<string | null>Ask for a folder; the plugin may then read what's in it (or, with write, change it).
pickFile(opts?: { save?: boolean; name?: string; extensions?: string[]; title?: string }): Promise<string | null>Ask for a file to open, or (save) where to save one.
granted(): Promise<{ path: string; write: boolean; file?: boolean }[]>temp(): Promise<string>A folder of its own, for passing files to programs.
read(path: string): Promise<Uint8Array>readText(path: string): Promise<string>write(path: string, data: string | Uint8Array): Promise<void>list(path: string): Promise<FileEntry[]>stat(path: string): Promise<{ folder: boolean; size: number; modified: string } | null>mkdir(path: string): Promise<void>trash(path: string): Promise<void>To the system's trash.
app.exec
Programs on the computer: those named "exec:<program>" in the manifest, by name, with no shell.
run(
program: string,
args?: string[],
opts?: { input?: string | Uint8Array; cwd?: string; timeout?: number },
): Promise<ExecOutput>app.speech
Speech written down on the device itself, never sent anywhere: Whisper on a computer (its model downloaded once, when the writer agrees), the system's own recogniser on a phone or tablet. Needs "microphone", and a plugin in the window. API 4.
language is a code like "sv" or "en"; '' lets a computer tell by ear. model is the computer's, downloaded once: "tiny", "base", "small" (the default), "medium", "large-v3-turbo" or "large-v3" for every language; "tiny.en" to "medium.en" for English only; "kb-tiny" to "kb-large", KB-Whisper, for Swedish only. A phone has its own.
status(language: string, opts?: SpeechChoice): Promise<SpeechStatus>Ready to listen, or what it needs first: a download (size bytes, if known), or that it can't here.
prepare(language: string, opts?: SpeechChoice & { onProgress?: (fraction: number) => void }): Promise<boolean>What it needs, got: Bläck Studio asks the writer first. False if they'd rather not.
listen(opts: SpeechChoice & {
/** '' to hear which language is spoken, among `languages`, Bläck Studio's, the system's and English. */
language: string
languages?: string[]
/** The words of the phrase being spoken, as best heard so far. */
onPartial?: (text: string) => void
/** Each phrase, written down, as the writer pauses; then stop() gives only what's left. */
onSegment?: (text: string) => void
/** It went wrong: nothing more will be written down. */
onError?: (message: string) => void
/** How loud it is, from 0 to 1, several times a second: to show it's hearing. Not called by an older Bläck Studio. */
onLevel?: (level: number) => void
}): Promise<SpeechSession>Listening, until stopped: then what was said. A phone tells onPartial the words as they're heard.
Manifest interface
id: stringLower-case letters, digits and dashes; also its folder's name.
name: stringversion: stringdescription: stringauthor: stringapiVersion: numberThe plugin API it's written for: 1; 2 for the dashboard and the library; 3 for the microphone; 4 for app.speech.
main: stringIts script, in its folder: main.js.
sandboxed: booleanRun in a worker of its own, apart from the window.
locales?: Record<string, { name?: string; description?: string }>Its name and description in other languages: { "sv": { "name": "…", "description": "…" } }.
permissions: string[]What it needs beyond reading the book, which the writer is asked to allow:
- "project:write": change the book;
- "library:read": see the writer's recent books and their series (app.library), for the dashboard;
- "microphone": record through the microphone (navigator.mediaDevices.getUserMedia, audio only), in the window. API 3;
- "network:<host>" for each host it talks to ("network:*.<host>" with its subdomains, "network:*" for anywhere);
- "files:read", "files:write": files and folders the writer chooses for it;
- "exec:<program>": run that program (by name, on the PATH, without a shell).
NodeType type
type NodeType = 'text' | 'folder' | 'character' | 'place' | 'research' | 'file'
NodeInfo interface
id: stringtitle: stringtype: NodeTypeparentId: string | nullchildren: string[]inTrash: booleanMetaInfo interface
synopsis: stringnotes: stringkeywords: string[]fields: Record<string, string>A character's or place's sheet: "Age", "Born"…
when: stringWhen the scene happens, as written on its card: "Day 3", "1888-05-01"…
words: numberLabel interface
id: stringname: stringcolor: stringStatus interface
id: stringname: stringcolor?: stringIts colour on the corkboard (#rrggbb), when cards are coloured by status.
FileEntry interface
name: stringpath: stringfolder: booleansize: numbermodified: stringExecOutput interface
code: number | nullThe exit code; null if it was stopped.
stdout: Uint8Arraystderr: stringtimedOut: booleantext(): stringstdout, as text.
FetchResult interface
ok: booleanstatus: numberstatusText: stringheaders: Record<string, string>text(): stringjson(): unknownbytes(): Uint8ArrayLibraryBook interface
One of the writer's books, summed up from its files: for the welcome screen's dashboard.
path: stringIts folder: what app.library.open takes.
title: stringauthor: stringopened: stringWhen it was last opened in Bläck Studio (ISO); '' for a series' book never opened.
modified: stringWhen anything in it last changed (ISO).
series: { name: string; path: string; index: number; books: number } | nullThe series it's in: its name, its folder, the book's place in it (from 0), and how many books it has.
words: numberThe manuscript's words, and the scenes they're in.
scenes: numbertarget: numberIts word goal (0 for none), and the day it's due (YYYY-MM-DD, '' for none).
deadline: stringsessionTarget: numberA day's writing goal (0 for none).
history: Record<string, number>Words added each day, by day (YYYY-MM-DD, the writer's own time).
statuses: { name: string; count: number }[]How many scenes have each status, in the book's order of statuses.
last: { id: string; title: string; modified: string; excerpt: string } | nullThe scene written in last, and how it ends now (plain text, a few hundred characters).
LibraryActivity interface
What's happened to the writer's books lately.
versions: { book: string; title: string; message: string; date: string }[]The latest saved versions (git commits), newest first.
backups: { file: string; date: string; size: number }[]The latest zip backups, newest first.
pushes: { book: string; title: string; ok: boolean; at: string; message: string }[]Each book's last push of its history, newest first.
SpeechChoice interface
How speech is written down: the same for status, prepare and listen.
model?: stringWhisper's model (see app.speech).
processor?: booleanWhisper on the processor, even with a graphics card that could.
engine?: 'auto' | 'system' | 'whisper'On a phone: its own recogniser, Whisper, or (auto) its own where it knows the language.
EditorAnchor interface
A place in the text to write at as text comes: see app.editor.anchor.
docId: stringbefore(): stringThe character just before it: whether what's written wants a space first.
insert(text: string): booleanPlain text, written there, after what was written before; false if the document has gone from the screen.
release(): voidDone with it.
SpeechStatus interface
ready: booleansize: numberBytes still to download, if known.
SpeechSession interface
stop(): Promise<string>Stop listening: what was said, written down.
cancel(): voidStop, and let go of what was heard.
pause?(): voidHold listening, and let go of the microphone, until resume: what was said so far is written down. Missing in an older Bläck Studio: check it's there.
resume?(): voidEditorContext interface
What a right-click in the text is about.
docId: stringselection: stringThe selected text, or ''.
word: stringThe word the caret is in, when nothing is selected.
HighlightRange interface
A stretch of a paragraph's text to mark, by character offsets in that text.
from: numberto: numbermessage?: stringShown when the pointer rests on it.
ExportInput interface
The book, compiled, for an export format.
title: stringauthor: stringlanguage: stringhtml: stringThe whole manuscript as one HTML document, as Bläck Studio's own HTML export makes it.
items: { id: string; title: string; depth: number; isFolder: boolean; section: 'front' | 'body' | 'back'; html: string }[]Each part in reading order: a folder's title, or a scene's title and text (HTML).
SettingField interface
key: stringlabel: stringtype: 'text' | 'secret' | 'textarea' | 'number' | 'toggle' | 'select''secret' is text shown as dots, for an API key or a password (API 3).
description?: stringdefault?: unknownoptions?: { value: string; label: string }[]PluginEvent type
type PluginEvent = 'project-open' | 'project-close' | 'selection-change' | 'doc-change' | 'nodes-change'
Suggestion interface
Offered as the writer types after a trigger.
label: stringinsert?: stringTyped in place of the trigger and what followed it; the label, if not given.
detail?: stringImportFile interface
A file to import, as picked.
name: stringext: stringLower-case, without the dot.
data: Uint8Arraytext: stringIts contents as text (UTF-8).