Bläck StudioPlugin SDK v1.0.0 · API 4
Contents

Start

Building

Beyond Bläck Studio

How it runs

Sharing

Look up

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: App
readonly manifest: Manifest
settings: Record<string, unknown>

The values of the settings declared with addSettings.

onload(): void | Promise<void>

Set up: add commands, views and the rest.

onunload(): void

Anything 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): void

Something 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>
}): () => void

In 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 }): () => void

A 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
}): () => void

A tab in the inspector, for the items when says (given the item's type and title). Window only.

addStatusBarItem(item: { id: string; render: () => ReactNode }): () => void

In 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>
}): () => void

In 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>
}): () => void

In the binder's right-click menu.

addExporter(exporter: {
  id: string
  name: string
  description?: string
  extension: string
  export: (book: ExportInput) => string | Uint8Array | Promise<string | Uint8Array>
}): () => void

A format in Compile. Return text, or bytes for a binary format.

addHighlighter(highlighter: {
  id: string
  color?: string
  find: (text: string) => HighlightRange[] | Promise<HighlightRange[]>
}): () => void

Marks in the text, found a paragraph at a time; drawn by Bläck Studio, never saved.

refreshHighlights(): void

Ask every highlighter again, e.g. after a setting that decides them changed.

registerEditorExtension(extension: AnyExtension | AnyExtension[]): () => void

TipTap extensions for every editor. Window only.

addSidebarPanel(panel: { id: string; name: string; render: () => ReactNode }): () => void

A 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>
}): () => void

A 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[]>
}): () => void

Suggestions 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 }[]>
}): () => void

Files 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
}): () => void

A 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[]): () => void

Settings Bläck Studio draws in Settings → Plugins; read them from this.settings.

addSettingsPanel(render: () => ReactNode): () => void

Settings of its own drawing. Window only.

onSettingsChange(listener: (settings: Record<string, unknown>) => void): void
updateSettings(patch: Record<string, unknown>): Promise<void>
on(event: PluginEvent, handler: (arg: string | null) => void): void
loadData(): 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: number
readonly language: string

The 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 | null

The 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 }): void
confirm(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): void

Show one of this plugin's views. Window only.

openSettings(): void

Show 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
}): () => void

A 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): void

Start 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: string

Lower-case letters, digits and dashes; also its folder's name.

name: string
version: string
description: string
author: string
apiVersion: number

The plugin API it's written for: 1; 2 for the dashboard and the library; 3 for the microphone; 4 for app.speech.

main: string

Its script, in its folder: main.js.

sandboxed: boolean

Run 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: string
title: string
type: NodeType
parentId: string | null
children: string[]
inTrash: boolean

MetaInfo interface

synopsis: string
notes: string
keywords: string[]
fields: Record<string, string>

A character's or place's sheet: "Age", "Born"…

when: string

When the scene happens, as written on its card: "Day 3", "1888-05-01"…

words: number

Label interface

id: string
name: string
color: string

Status interface

id: string
name: string
color?: string

Its colour on the corkboard (#rrggbb), when cards are coloured by status.

FileEntry interface

name: string
path: string
folder: boolean
size: number
modified: string

ExecOutput interface

code: number | null

The exit code; null if it was stopped.

stdout: Uint8Array
stderr: string
timedOut: boolean
text(): string

stdout, as text.

FetchResult interface

ok: boolean
status: number
statusText: string
headers: Record<string, string>
text(): string
json(): unknown
bytes(): Uint8Array

LibraryBook interface

One of the writer's books, summed up from its files: for the welcome screen's dashboard.

path: string

Its folder: what app.library.open takes.

title: string
author: string
opened: string

When it was last opened in Bläck Studio (ISO); '' for a series' book never opened.

modified: string

When anything in it last changed (ISO).

series: { name: string; path: string; index: number; books: number } | null

The series it's in: its name, its folder, the book's place in it (from 0), and how many books it has.

words: number

The manuscript's words, and the scenes they're in.

scenes: number
target: number

Its word goal (0 for none), and the day it's due (YYYY-MM-DD, '' for none).

deadline: string
sessionTarget: number

A 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 } | null

The 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?: string

Whisper's model (see app.speech).

processor?: boolean

Whisper 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: string
before(): string

The character just before it: whether what's written wants a space first.

insert(text: string): boolean

Plain text, written there, after what was written before; false if the document has gone from the screen.

release(): void

Done with it.

SpeechStatus interface

ready: boolean
size: number

Bytes still to download, if known.

unavailable?: 'device' | 'language'

Why it can't be done on this device at all: no recogniser, or not in this language.

SpeechSession interface

stop(): Promise<string>

Stop listening: what was said, written down.

cancel(): void

Stop, and let go of what was heard.

pause?(): void

Hold 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?(): void

EditorContext interface

What a right-click in the text is about.

docId: string
selection: string

The selected text, or ''.

word: string

The 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: number
to: number
message?: string

Shown when the pointer rests on it.

ExportInput interface

The book, compiled, for an export format.

title: string
author: string
language: string
html: string

The 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: string
label: string
type: 'text' | 'secret' | 'textarea' | 'number' | 'toggle' | 'select'

'secret' is text shown as dots, for an API key or a password (API 3).

description?: string
default?: unknown
options?: { 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: string
insert?: string

Typed in place of the trigger and what followed it; the label, if not given.

detail?: string

ImportFile interface

A file to import, as picked.

name: string
ext: string

Lower-case, without the dot.

data: Uint8Array
text: string

Its contents as text (UTF-8).