scmJS docs

api.ui

UiApi

Everything a plugin puts on the screen: the status line, toasts, dialogs, floating panels, confirm / alert / prompt / progress, a map tool that owns the pointer, a passive overlay drawn over the map, and picking an area or a tile.

Members

openDialogs

openDialogs(): string[];

The dialogs open now, bottom to top, by id: the built-in ones by their DialogId, a plugin's as "pluginDialog". What a shared map shows others ("in Player Settings").

status

status(text: string): void;

Set the status bar line.

statusText

statusText(): string;

The status bar line as it stands.

toast

toast(toast: { kind?: Toast["kind"]; title: string; detail?: string; ttl?: number; }): void;

A short notice over the map that leaves by itself — how Save reports; ttl 0 keeps it until dismissed.

api.ui.toast({ kind: "ok", title: "Export finished", detail: "12 triggers written." });
Try it

saveFile

saveFile(data: Blob | Uint8Array, fileName: string): Promise<{ route: "picker" | "download"; fileName: string; } | null>;

Write a file to disk the way the editor's own exports do: into the file the browser's save dialog picks, or as a download where there is no dialog. Resolves with where it went, or null when the user dismissed the dialog.

dialog

dialog(spec: DialogSpec): DialogHandle;

panel

panel(spec: PanelSpec): PanelHandle;

Open a panel: floating over the map, docked at the right with the built-in panels, or in the strip under the map (spec.dock). As many as you like; each closes with the plugin.

fileType

fileType(spec: FileTypeSpec): Disposable;

Claim a kind of file: dropped on the editor or picked in File ▸ Open, a file with one of spec.extensions is handed to spec.open instead of being opened as a map. A plugin that claims what another already has is not given it (the first stays); none can claim the editor's own .scm, .scx or .chk. Ends with the plugin, or with dispose.

api.ui.fileType({ name: "StarCraft replay", extensions: [".rep"], open: (files) => loadReplays(files) });

mapView

mapView(spec: MapViewSpec): MapViewHandle;

Another way to look at the maps you choose, in the map's place, with a switch over the map between it and the map itself (see MapViewSpec). A map shows one view at a time; each map keeps the one it was left on. Ends with the plugin, or with dispose.

const game = api.ui.mapView({ title: "Game", mount: (el) => { el.append(frame); } });
// A map just opened is the one in front.
if (await api.document.open(map, "replay.scx", { viewOnly: { label: "Replay" } })) game.show(true);

statusItem

statusItem(spec: StatusItemSpec): StatusItemHandle;

A cell of your own in the status bar. One line of text beside the plugin's icon, a spinner while busy, a click. Keep the handle and set it as things move; it leaves with remove() or the plugin.

mapButton

mapButton(spec: MapButtonSpec): MapButtonHandle;

A button on the map, in the row at its bottom-right corner beside the tileset, size and zoom — for something that belongs to the map being worked on, such as a shared map's chat. Keep it to what matters while the map is in view; everything else goes in a menu or the status bar. It leaves with remove() or the plugin.

dialogSlot

dialogSlot(dialog: DialogSlotId, spec: DialogSlotSpec): Disposable;

Add to a built-in dialog. mount runs in a row at the left of the dialog's footer each time that dialog opens, with the dialog's working-copy fields to read and fill (SlottedDialogId lists which dialogs and fields) — or to another plugin's dialog, by the id its DialogSpec.slot offers. Returns the registration; it leaves with dispose() or the plugin.

// A button in Map Properties that tidies the name in the form. The user still presses OK.
api.ui.dialogSlot("mapProperties", {
  mount(body, dlg) {
    body.append(api.ui.widgets.button("Tidy name", { onClick: () => {
      dlg.fields.name.set(dlg.fields.name.get().trim().replace(/\s+/g, " "));
    } }));
  },
});
Try it

preferencesPage

preferencesPage(spec: PreferencesPageSpec): Disposable;

A page of the plugin's own in Edit ▸ Preferences, under Plugins — see PreferencesPageSpec. ui.open("preferences", { page: "plugin:<id>" }) opens it. Returns the registration; it leaves with dispose() or the plugin.

mapTool

mapTool(spec: MapToolSpec): MapToolHandle;

Take over the pointer on the map until stop(), Esc, a right-click, a map change or another tool. One tool runs at a time — starting one stops the previous — and a pickArea / pickTile in progress is served first.

overlay

overlay(spec: OverlaySpec): OverlayHandle;

A picture over the map the user can switch on and off (View ▸ Overlays, the Layers panel) and that stays while they work on any layer. It draws at every repaint and hears the pointer, but never takes it. As many as you like; they go with the plugin.

pickFiles

pickFiles(options?: PickFilesOptions): Promise<File[]>;

The browser's file picker; resolves with an empty list on cancel.

pickArea

pickArea(options?: PickOptions): Promise<Rect | null>;

Let the user drag a rectangle on the map. The viewport switches to a crosshair, draws the marquee and takes the gesture ahead of the active layer's tools; resolves with the tile rect (exclusive x1 / y1), or null when the user pressed Esc or right-clicked, no map is open, the map was replaced, or the plugin was disabled. Only one pick runs at a time — a new one cancels the previous. A modal dialog covers the map, so close yours first and reopen it with the result.

pickTile

pickTile(options?: PickOptions): Promise<{ x: number; y: number; } | null>;

As pickArea, for a single click: the tile under it.

pickObject

pickObject(options?: PickObjectOptions): Promise<PickedObject | null>;

As pickTile, for an object: the user clicks a unit or a location. The viewport outlines and names what is under the pointer as it moves; a click on nothing keeps picking. Resolves with the kind and the record's index, or null as pickArea does.

const picked = await api.ui.pickObject({ prompt: "Click a unit", kinds: ["unit"] });
const unit = picked && api.document.scenario()?.units[picked.index];
if (unit) await api.ui.alert(`${api.names.unit(unit.unitId)}, owned by ${api.names.player(unit.owner)}`);
Try it

loadImage

loadImage(source: Blob | string): Promise<ImageBitmap>;

Decode a picture: a File / Blob, a data: URL, or an http(s) URL (fetched, and when the site refuses cross-origin reads, loaded through an <img> with crossOrigin — a site that allows neither rejects with a message saying so).

readClipboardImage

readClipboardImage(): Promise<Blob | null>;

The image on the system clipboard, if the browser lets the page read it (a permission prompt may appear); null otherwise.

confirm

confirm(message: string, options?: ConfirmOptions): Promise<boolean>;

Ask a yes/no question; resolves false on Cancel, Escape or the ×.

alert

alert(message: string, options?: ConfirmOptions): Promise<void>;

Say something with a single OK.

prompt

prompt(message: string, options?: PromptOptions): Promise<string | null>;

Ask for a line of text; null when the user cancelled.

const name = await api.ui.prompt("Name the map", { value: api.document.info()?.name, confirmLabel: "Rename" });
if (name) api.document.update("Rename", (tx) => tx.properties({ name }));
Try it

progress

progress(label: string, options?: ProgressOptions): ProgressHandle;

A progress bar over the map for long work, with an optional Cancel.

const triggers = api.triggers.list();
const job = api.ui.progress("Reading triggers", { cancellable: true });
try {
  for (let i = 0; i < triggers.length && !job.cancelled(); i++) {
    job.report(i / triggers.length, `Trigger ${i + 1} of ${triggers.length}`);
    await new Promise((next) => setTimeout(next, 20)); // the slow part of your work goes here
  }
} finally {
  job.done();
}
Try it

tooltip

tooltip(target: HTMLElement, content: string | ((at: TooltipPointer) => HTMLElement | string | null), options?: TooltipOptions): TooltipHandle;

A tooltip in the editor's own style over an element of yours, in place of the browser's title. content is a line of text, or a function asked for what to show at the pointer — rows, bars, pictures, built with el and the widgets — which is how a chart says what is under the pointer: it is asked again as the pointer moves, and answering null hides the tip there. The editor places it beside the pointer, keeps it on screen and takes it away when the pointer leaves, the element leaves the page, or the plugin stops. refresh() asks again where the pointer stands (a picture arrived, the data moved). Remove the element's title, or both show.

// A chart that reads out the value under the pointer.
api.ui.tooltip(canvas, (at) => {
  const i = Math.floor((at.offsetX / canvas.clientWidth) * values.length);
  return values[i] === undefined ? null : api.ui.el("div", {}, api.ui.el("b", {}, `${values[i]}`), " minerals");
});

el

el<K extends keyof HTMLElementTagNameMap>(tag: K, props?: Record<string, unknown>, ...children: WidgetChild[]): HTMLElementTagNameMap[K];

el("div", { className: "row" }, …): the DOM helper the widgets are built from.

widgets

readonly widgets: WidgetsApi;

Buttons, fields, forms and lists in the editor's own styles.

open

open(dialog: DialogId, payload?: Record<string, unknown>): void;

Open a built-in dialog (fire and forget; ask waits for one that answers).

ask

ask(dialog: DialogId, payload?: Record<string, unknown>): Promise<boolean>;

Open a built-in dialog that answers — "saveAs", "confirmClose", "newMap" — and resolve true when it went through, false when it was dismissed.

repaint

repaint(): void;

Ask the viewport to repaint (a transaction does this by itself). Raises no event.

Types

Declarations only this group names.

Toastinterface

interface Toast

A short notice over the map that leaves by itself — what a save says when it is done, since the status bar line is easy to miss and the menubar dot only stops glowing.

id: number
kind: "ok" | "info" | "warn" | "error"
title: string
detail?: string
ttl: number

Milliseconds before it leaves on its own; 0 keeps it until dismissed.

action?: { label: string; run: () => void; }

One button beside the text, for a notice that is really a question — the update check's "Download". Pressing it dismisses the toast as well as running this.

PluginInfointerface

interface PluginInfo
id: string

Manifest id (or slug of the name).

name: string
source: string

The spec the plugin was installed from (builtin:…, github:…, a URL).

version?: string
icon?: PluginIcon

The manifest's icon, resolved; absent when it declared none (or an unusable one).

DialogSizetype

type DialogSize = "sm" | "md" | "lg" | "xl" | "full";

DialogButtoninterface

interface DialogButton
label: string
primary?: boolean
run?: (dialog: DialogHandle) => void | boolean | Promise<void | boolean>

Return false (or a promise of it) to keep the dialog open.

closes?: boolean

Close after run; default true.

DialogTransferinterface

interface DialogTransfer

What a paste or a drop brought into a dialog.

files: File[]
text: string

DialogSpecinterface

interface DialogSpec
title: string
size?: DialogSize
tall?: boolean
mount(body: HTMLElement, dialog: DialogHandle): void | (() => void)

Fill body (an empty <div> inside the dialog); return a cleanup if you need one.

buttons?: DialogButton[]

Footer buttons, left to right; a single Close when omitted. An empty list leaves the dialog without a footer — for a dialog that is a workspace of its own, with its own controls, closed by the title strip's × (setBusy and a slot then have nowhere to show).

flush?: boolean

The body runs edge to edge, without the dialog's padding: for content that draws its own frame (an editor with its own bars).

onPaste?: (transfer: DialogTransfer, dialog: DialogHandle) => void

Ctrl+V anywhere in the dialog (while it is the topmost one). Files come from the clipboard's items — a screenshot pastes as one image/png file — and text is the plain-text part, so a copied image URL arrives here too.

onDrop?: (transfer: DialogTransfer, dialog: DialogHandle) => void

Something dropped onto the dialog body.

keepOpenOnEscape?: (target: EventTarget | null) => boolean

Escape normally closes the dialog. Answer true to keep it open for this press — when the key was meant for something inside the dialog that handles Escape itself (a code editor dismissing its own popups), judged by the element it landed on.

slot?: DialogSlotOffer

Offer a slot of this dialog's own, so other plugins can add to it with ui.dialogSlot the way they add to a built-in dialog: id is what they name ("<plugin>.<name>"), fields the working copy lent to them, read live, and payload what the dialog was opened with. The Text Trigger Editor plugin offers "trigedit.text" with a text field.

const name = api.ui.widgets.text({ value: api.document.info()?.name ?? "" });
api.ui.dialog({
  title: "Rename the map",
  size: "sm",
  mount: (body) => body.append(api.ui.widgets.form([{ label: "Name", field: name }])),
  buttons: [
    { label: "Cancel" },
    { label: "Rename", primary: true, run: () => { api.document.update("Rename", (tx) => tx.properties({ name: name.value })); } },
  ],
});
Try it

DialogSlotOfferinterface

interface DialogSlotOffer

A slot a plugin dialog offers to other plugins; see DialogSpec.slot.

id: string
payload?: Record<string, unknown>
fields?: Record<string, DialogField>

DialogHandleinterface

interface DialogHandle
close(): void
isOpen(): boolean

Whether the dialog is still on screen.

setTitle(title: string): void

Change the title strip's text.

setBusy(label: string | false): void

Say the dialog is working: a ring and label at the left of the footer, and every footer button disabled until setBusy(false). A button's own run already does this for as long as its promise is pending — this is for work the buttons did not start, such as the first load, or a search that runs as the user types.

PanelSpecinterface

interface PanelSpec

A panel floats over the map and does not block it: the user keeps drawing, scrolling and using hotkeys while it is open (except while typing in one of its fields). It is dragged by its title bar and closed with the × or close().

title: string
width?: number

In CSS pixels; 260 by default. The panel is as tall as its content, up to the map's height. Ignored when docked.

height?: number

Floating: a height in CSS pixels instead of the content's own; the body then scrolls, or fills, as its content asks. Bottom dock: the strip's starting height (160 by default), which the user can then drag.

resizable?: boolean

Floating only: a grip at the bottom-right corner lets the user resize the panel, and the size is remembered with the position for the session. The body is a column flex box, so a root element with flex: 1; min-height: 0 fills whatever size it has — what a code editor or a long list beside the map wants.

dock?: "float" | "right" | "bottom"

Where the panel lives. "float" (the default) is a frame over the map the user drags about. "right" puts it in the right dock as a panel of its own, under Minimap, Layers and Properties, with the same head and hide button the built-in panels have — the choice for anything the user keeps open while working, such as an assistant or a readout. A docked panel shows the dock even when the user has hidden every built-in panel in it. "bottom" puts it in a strip under the map, as wide as the map, which the user makes taller or shorter by dragging its top edge — the place for a timeline, a console or anything read along the width of the map. Several bottom panels stack.

grow?: boolean

A docked panel that takes the dock's spare height (a transcript, a long list); off by default.

flush?: boolean

Floating only: the body runs edge to edge, without the panel's padding — for content that draws its own frame.

maximizable?: boolean

Floating only: a button in the title strip (and a double-click on it) makes the panel fill the map's area, and puts it back. For a window of charts or tables read at length beside a map that is still there underneath. PanelHandle.maximize does the same.

mount(body: HTMLElement, panel: PanelHandle): void | (() => void)

Fill body (an empty <div> inside the panel); return a cleanup if you need one.

onClose?: () => void

The panel closed, whichever way.

// A unit count that follows the map, docked under the built-in panels.
api.ui.panel({
  title: "Unit count",
  dock: "right",
  mount(body) {
    const show = () => { body.textContent = `${api.document.scenario()?.units.length ?? 0} units`; };
    show();
    const sub = api.events.on("units", show);
    return () => sub.dispose();
  },
});
Try it

FileTypeSpecinterface

interface FileTypeSpec

A kind of file a plugin opens (ui.fileType): replays, a mod's own data. A file whose name ends in one of extensions, dropped on the editor or picked in File ▸ Open, goes to open instead of being read as a map. Several dropped at once arrive together.

name: string

What the files are, for File ▸ Open's list of types ("StarCraft replay").

extensions: string[]

With the dot, any case: [".rep"].

open(files: File[]): void | Promise<void>

MapViewSpecinterface

interface MapViewSpec

Another way to look at a map, shown where the map is drawn (ui.mapView): a replay's game played out, a 3D view, a picture of something else entirely. The editor gives the view one element for its whole life and never moves it — an iframe in it is not loaded again when the view is hidden and shown — and lays it over the map, rulers and all.

title: string

What the switch over the map calls it ("Game"). The map itself is "Map".

mount(element: HTMLElement): void | (() => void)

Called once, with the element the view lives in. Fill it as you like; it is as large as the map's area and in the page from the first time the view is shown. Return a function to be called when the view goes (the plugin stops, or dispose).

onShow?(look: MapViewCamera): void

The view is shown in the map's place, where the map was looking: look is the map's view at that moment, or the other view's (setCamera) when the switch came from one. Turn to look at the same place, as near the same scale as you can, so the switch keeps the person where they were. Not called when another map, already showing the view, comes to the front.

minimap?: MapViewMinimapSpec

What the minimap shows while the view is shown. Without it the minimap shows the map, and its box and presses are the map's own view's (hidden behind this one).

MapViewCamerainterface

interface MapViewCamera

Where a view looks, in map pixels (32 to a tile): the point in the middle of what it shows, and how much of the map that is.

x: number
y: number
width: number
height: number

MapViewMinimapSpecinterface

interface MapViewMinimapSpec

A map view's own minimap (MapViewSpec.minimap): the minimap draws the map's ground, then draw over it in place of the map's units, start locations and fog, and its box where the view looks (MapViewHandle.setCamera).

draw?(ctx: CanvasRenderingContext2D, minimap: MinimapView): void

Draw over the map's ground, each time the minimap's picture is made while the view is shown.

press?(x: number, y: number): void

A press on the minimap, and each move of a drag from it, at a map pixel: look there. Without it the minimap does not move anything while the view is shown.

// A replay's units as dots, and the minimap moving the game's camera.
const view = api.ui.mapView({
  title: "Replay",
  mount: (el) => el.append(frame),
  minimap: {
    draw(ctx, m) {
      for (const u of units) {
        ctx.fillStyle = u.colour;
        ctx.fillRect(m.x(u.x) - 1, m.y(u.y) - 1, Math.max(2, u.width * m.scale), Math.max(2, u.height * m.scale));
      }
    },
    press: (x, y) => game.lookAt(x, y),
  },
});
game.onUnits(() => view.redrawMinimap());
game.onCamera((c) => view.setCamera(c));

MinimapViewinterface

interface MinimapView

Map pixels to minimap canvas pixels, for MapViewMinimapSpec.draw.

scale: number

Minimap canvas pixels per map pixel.

x(px: number): number

A map pixel's x on the minimap's canvas.

y(py: number): number
size: number

The canvas's size in pixels, each side (the map is centred in it, letterboxed).

MapViewHandleinterface

interface MapViewHandle
offer(on: boolean, documentId?: number): void

Offer the view on a map (by OpenDocumentInfo.id; the one in front when left out), or stop offering it there. A map that offers views has a switch over it, the map's own view first. Nothing is offered until you say so.

show(on: boolean, documentId?: number): void

Show the view in that map's place (offering it there if it was not), or the map again.

isShown(): boolean

Whether the view is what the map in front shows now.

onChange(listener: (shown: boolean) => void): Disposable

Whether the view is shown changed for the map in front: the person used the switch, show was called, or another map came to the front.

setCamera(camera: MapViewCamera): void

Where the view looks now, in map pixels; call it whenever that changes. The minimap draws its box there while the view is shown, and the map, shown again, opens on the same place at the same scale.

redrawMinimap(): void

Make the minimap's picture again (minimap.draw), at most once a frame. Cheap: call it as the view's contents move.

PanelHandleinterface

interface PanelHandle
close(): void
isOpen(): boolean
setTitle(title: string): void
maximize(on?: boolean): void

A maximizable panel: fill the map's area (true), go back (false), or toggle. Nothing for another panel.

isMaximized(): boolean

Whether it fills the map's area now.

StatusItemSpecinterface

interface StatusItemSpec

One cell in the status bar (ui.statusItem): a line of text with the plugin's icon, a spinner while busy, and a click. It is how a plugin that works in the background stays visible without a panel — "AI · working 12 s", "3 problems", "Synced".

text: string
title?: string

The tooltip.

busy?: boolean

Show a spinner in the cell.

warn?: boolean

Paint the cell as a warning.

onClick?: () => void
const count = () => api.query.validate().filter((i) => i.level !== "info").length;
const cell = api.ui.statusItem({ text: `${count()} problems`, onClick: () => api.ui.open("validateMap") });
api.events.on("commit", () => {
  const n = count();
  cell.set({ text: `${n} problems`, warn: n > 0 });
});
Try it

StatusItemHandleinterface

interface StatusItemHandle
set(patch: Partial<StatusItemSpec>): void

Change any of the fields; the cell redraws.

remove(): void
isShown(): boolean

MapButtonSpecinterface

interface MapButtonSpec

A button on the map itself (ui.mapButton), in the row at its bottom-right corner.

label: string

A word or two; the button is as wide as it.

title?: string

The tooltip.

badge?: number | string | null

A count or a short mark beside the label (unread messages); nothing when absent, null, 0 or "".

active?: boolean

Drawn pressed: what the button opens is open.

onClick: () => void
let notes = 3;
const button = api.ui.mapButton({
  label: "Notes",
  badge: notes,
  onClick: () => { notes = 0; button.set({ badge: null }); },
});
Try it

MapButtonHandleinterface

interface MapButtonHandle
set(patch: Partial<MapButtonSpec>): void

Change any of the fields; the button redraws.

remove(): void
isShown(): boolean

SlottedDialogIdtype

type SlottedDialogId = "mapProperties" | "triggerEditor" | "stringEditor" | "playerSettings" | "missionBriefing";

The dialogs a plugin may add to (ui.dialogSlot), and the fields each one exposes to the slot. A field is the dialog's working copy — what the person sees in the form, not yet applied to the map — so a slot's button can fill it in and leave OK to them.

  • mapProperties: name, description.
  • triggerEditor, missionBriefing: selected, the selected row's index in the list as text ("" for none). Setting it selects that row. The index is into the dialog's working copy, which matches triggers.list() until the person inserts or removes rows. And modified, "1" while that working copy has changes not yet applied (read only): close() discards them, so ask before closing when it is set.
  • stringEditor, playerSettings: no fields, a slot only.

A plugin's own dialog can offer a slot too (DialogSpec.slot), under an id of its choosing — "trigedit.text" is the Text Trigger Editor's, lending text with payload.briefing — and another plugin adds to it with the same dialogSlot call.

DialogSlotIdtype

type DialogSlotId = SlottedDialogId | (string & {});

A slot id: one of the editor's dialogs, or the id a plugin dialog offers ("<plugin>.<name>" by convention).

DialogFieldinterface

interface DialogField

A form field a dialog lends to a slot: read it, set it, hear it change.

get(): string
set(value: string): void

DialogSlotHostinterface

interface DialogSlotHost

What a slot's mount is handed: which dialog, its payload, its fields, and a way to close it.

readonly dialog: DialogSlotId
readonly payload: Readonly<Record<string, unknown>>
readonly fields: Readonly<Record<string, DialogField>>
close(): void

DialogSlotSpecinterface

interface DialogSlotSpec

Something a plugin mounts inside a built-in dialog — a button in Map Properties that suggests a name, one in the Text Trigger Editor that explains the text. The slot is a row at the left of the dialog's footer; fill it with the widgets, and it looks like part of the dialog. mount runs every time the dialog opens, with a cleanup on close.

mount(body: HTMLElement, host: DialogSlotHost): void | (() => void)

PreferencesPageSpecinterface

interface PreferencesPageSpec

A page of the plugin's own in Edit ▸ Preferences, listed under Plugins by the plugin's name — where a user looks for a setting, instead of a menu item per plugin. One page per plugin; registering again replaces it. mount runs the first time the page is shown while the dialog is open and the page then stays mounted until the dialog closes, so what the user changed is still there when OK or Apply calls apply; the cleanup runs on close. A page with no apply writes its settings as they change (api.storage), which is the simpler shape when nothing needs undoing on Cancel.

mount(body: HTMLElement, page: PreferencesPageHost): void | (() => void)

Fill body with the page's controls; return a cleanup if you need one.

apply?(): void

Called on OK and Apply while the dialog is open and the page has been shown.

reset?(): void

Called by Reset to defaults while the page is the one showing.

const settings = api.storage.get("settings", { dock: "float" });
let pending: (() => void) | null = null;
api.ui.preferencesPage({
  mount(body) {
    const w = api.ui.widgets;
    const dock = w.select([{ value: "float", label: "Floating" }, { value: "right", label: "Docked" }], { value: settings.dock });
    body.append(w.form([{ label: "Panel", field: dock }]), w.hint("Where the panel opens."));
    pending = () => api.storage.set("settings", { dock: dock.value });
  },
  // OK and Apply write it; Cancel leaves the stored value alone.
  apply: () => pending?.(),
});
Try it

PreferencesPageHostinterface

interface PreferencesPageHost

What a preferences page's mount is handed.

readonly plugin: PluginInfo
close(): void

Close the Preferences dialog.

MapPointerinterface

interface MapPointer

The pointer over the map, in the map's own units. A tile is 32 × 32 pixels.

px: number

Map pixels. Kept inside the map while a button is held, as the built-in brushes do.

py: number
tx: number

The tile under the pointer.

ty: number
inMap: boolean

False once the pointer has left the map with no button held.

down: boolean

Whether the primary button is held.

shift: boolean
ctrl: boolean
alt: boolean

MapViewinterface

interface MapView

Map pixels to canvas pixels, for a tool's draw.

zoom: number
tilePx: number

Canvas pixels per tile.

x(px: number): number

A map pixel's x on the canvas.

y(py: number): number
visible: Rect

The tiles on screen.

MapToolStopReasontype

type MapToolStopReason = 
/** `handle.stop()`. */
"stopped"
/** Esc or a right-click, and `onCancel` did not keep the tool. */
 | "cancelled"
/** The map was closed or replaced. */
 | "document"
/** Another tool started. */
 | "replaced"
/** The plugin was disabled. */
 | "disabled";

MapToolSpecinterface

interface MapToolSpec

A tool owns the pointer over the map: the viewport hands it every press, move and release ahead of the active layer's own tools, hides the layer's brush ghost, and lets it draw an overlay. The map stays visible and scrollable, and a panel can stay open beside it — which is how a plugin gets a drawing mode of its own.

name: string

Shown in the viewport's HUD and the status bar while the tool runs.

hint?: string

After the name: "drag to draw a line".

cursor?: string

CSS cursor over the map; "crosshair" by default.

onDown?(p: MapPointer): void
onMove?(p: MapPointer): void

Every pointer move over the map, button held or not, and once with inMap: false when it leaves.

onUp?(p: MapPointer): void
onCancel?(): boolean | void

Esc or a right-click. Return true to keep the tool running (you cancelled a gesture of your own); otherwise it stops.

draw?(ctx: CanvasRenderingContext2D, view: MapView): void

Draw over the map, after everything else, each time the viewport repaints.

onStop?(reason: MapToolStopReason): void

The tool is no longer running, for whatever reason (once).

// Click to drop a marine, with a ring under the pointer. Esc or a right-click ends the tool.
let at: { px: number; py: number } | null = null;
const tool = api.ui.mapTool({
  name: "Drop marines",
  hint: "click to place, Esc to stop",
  onMove(p) { at = p.inMap ? p : null; tool.redraw(); },
  onDown(p) { api.document.edit("Drop a marine", (tx) => { tx.placeUnit(0, 0, p.px, p.py); }); },
  draw(ctx, view) {
    if (!at) return;
    ctx.strokeStyle = "#ffd24a";
    ctx.beginPath();
    ctx.arc(view.x(at.px), view.y(at.py), view.tilePx / 2, 0, Math.PI * 2);
    ctx.stroke();
  },
});
Try it

MapToolHandleinterface

interface MapToolHandle
stop(): void
isActive(): boolean
redraw(): void

Repaint the viewport — and so call draw — now. Cheap; call it from onMove.

OverlayAbovetype

type OverlayAbove = 
/** Over the terrain and grid, under doodad footprints, units, sprites and locations (the default). */
"terrain"
/** Over units, sprites and locations, under fog of war. */
 | "objects"
/** Over everything but a running map tool's own drawing. */
 | "everything";

Where in the viewport's paint pass an overlay draws.

OverlaySpecinterface

interface OverlaySpec

An overlay is a picture drawn over the map that the user can turn on and off: it is listed under View ▸ Overlays and in the Layers panel, stays while the user works on any layer, and never takes the pointer — clicks go to the active layer's tools as usual. It hears the pointer through onHover, which is how a readout follows the mouse while the user places units. Register one at activation and keep the handle; it goes away with the plugin.

name: string

Shown in View ▸ Overlays and the Layers panel. Unique per plugin. In English, like a menu label: shown through the plugin's registered catalogues.

visible?: boolean

Start visible; true by default. What the user last set for this name wins for the session.

above?: OverlayAbove

Where the picture goes in the paint pass; "terrain" by default.

draw(ctx: CanvasRenderingContext2D, view: MapView): void

Draw in canvas pixels through view, each time the viewport repaints while visible.

onHover?(p: MapPointer | null): void

The pointer over the map while the overlay is visible, on every layer and while a map tool runs; null once when it leaves. Call handle.redraw() here to move a hover mark. A press sets down but is never captured for you.

onToggle?(visible: boolean): void

The overlay was shown or hidden, by the user in the chrome or by your handle.

// A ring twelve tiles wide round every start location, listed under View ▸ Overlays.
const overlay = api.ui.overlay({
  name: "Start rings",
  above: "objects",
  draw(ctx, view) {
    ctx.strokeStyle = "#ffd24a";
    ctx.lineWidth = 2;
    for (const start of api.query.startLocations()) {
      ctx.beginPath();
      ctx.arc(view.x(start.x), view.y(start.y), 12 * view.tilePx, 0, Math.PI * 2);
      ctx.stroke();
    }
  },
});
api.events.on("units", () => overlay.redraw());
Try it

OverlayHandleinterface

interface OverlayHandle
show(): void
hide(): void
toggle(): void
isVisible(): boolean
redraw(): void

Repaint the viewport — and so call draw — now.

remove(): void

Take the overlay out of the chrome for good; isVisible is false from then on.

PickOptionsinterface

interface PickOptions
prompt?: string

Shown in the viewport's HUD while the user picks; also the status line.

PickObjectKindtype

type PickObjectKind = "unit" | "location";

What ui.pickObject can pick.

PickObjectOptionsinterface

interface PickObjectOptions
kinds?: PickObjectKind[]

Which kinds count; every kind when omitted. Under the pointer, a unit wins over a location.

PickedObjectinterface

interface PickedObject

What the user picked: the record's index in document.scenario().units or .locations.

kind: PickObjectKind
index: number

PickFilesOptionsinterface

interface PickFilesOptions
accept?: string

accept for the file input, e.g. "image/*" or ".png,.jpg".

multiple?: boolean

TooltipPointerinterface

interface TooltipPointer

Where the pointer is, for a tooltip's content.

x: number

In the page, CSS pixels (clientX / clientY).

y: number
offsetX: number

From the target's top-left corner, CSS pixels.

offsetY: number
pressed: boolean

Whether a button is held — a drag across a chart.

TooltipOptionsinterface

interface TooltipOptions
delay?: number

Milliseconds the pointer rests before the tip shows. 500 for text, as the editor's own tooltips; 0 for a function, which reads out what is under the pointer and should keep up with it.

maxWidth?: number

A width the tip does not grow past, CSS pixels; 320 by default.

TooltipHandleinterface

interface TooltipHandle
refresh(): void

Ask content again where the pointer stands; nothing when the tip is not showing.

hide(): void

Take the tip away until the pointer next moves over the target.

ConfirmOptionsinterface

interface ConfirmOptions
title?: string
confirmLabel?: string

The primary button; OK by default.

cancelLabel?: string
danger?: boolean

Paint the primary button as a destructive action.

PromptOptionsinterface

interface PromptOptions
value?: string
placeholder?: string
multiline?: boolean

A multi-line field.

ProgressOptionsinterface

interface ProgressOptions
title?: string
cancellable?: boolean

Show a Cancel button; cancelled() answers true once it is pressed.

ProgressHandleinterface

interface ProgressHandle

A progress panel over the map. It does not block the editor — the user can still scroll and look — so a plugin doing long work should report often and check cancelled() in its loop.

report(fraction: number, text?: string): void

How far along (0…1), and optionally a line under the bar.

cancelled(): boolean

True once the user pressed Cancel or closed the panel. Poll it inside a loop.

readonly signal: AbortSignal

The same answer as a signal, for work that takes one: aborted when the user cancels, so fetch(url, { signal }) and anything built on AbortSignal stop with the panel. Never aborted by done().

done(): void
isOpen(): boolean

WidgetChildtype

type WidgetChild = Node | string | number | null | undefined | false;

Anything the widget builders accept as a child: a node, text, or nothing.

ButtonOptionsinterface

interface ButtonOptions
primary?: boolean
danger?: boolean
ghost?: boolean
busy?: boolean

Start off waiting: a ring in front of the label, and the button disabled.

onClick?: (event: MouseEvent) => void

ButtonElementtype

type ButtonElement = HTMLButtonElement & {
	/**
	 * A ring in front of the label while `busy`, and the button disabled — so the press
	 * that started the work cannot be repeated, and says why it cannot.
	 */
	setBusy(busy: boolean): void;
};

The <button>, with the one call that puts it in and out of its waiting state.

CheckboxOptionsinterface

interface CheckboxOptions
value?: boolean
radio?: boolean

A radio button instead; give the group a name.

name?: string
onChange?: (value: boolean) => void

CheckboxElementtype

type CheckboxElement = HTMLLabelElement & {
	input: HTMLInputElement;
};

The <label> a checkbox is, with its <input> on it so you can read or set the value.

TextFieldOptionsinterface

interface TextFieldOptions
value?: string
placeholder?: string
password?: boolean
onChange?: (value: string) => void

NumberFieldOptionsinterface

interface NumberFieldOptions
value?: number
min?: number
max?: number
step?: number
onChange?: (value: number) => void

SelectOptioninterface

interface SelectOption
value: string | number
label: string
disabled?: boolean

SegmentedElementtype

type SegmentedElement = HTMLElement & {
	get(): string;
	/** Pick `value` without calling `onChange`. */
	set(value: string | number): void;
};

widgets.segmented: the value picked, and a way to pick another.

TabIteminterface

interface TabItem
value: string
label: string
disabled?: boolean
mount(body: HTMLElement): void

Fill the tab's panel, the first time it is shown. What it starts ends with the panel or dialog the tabs are in.

TabsOptionsinterface

interface TabsOptions
value?: string

The tab shown first; the first that is not disabled otherwise.

onChange?: (value: string) => void
compact?: boolean

Smaller tabs, for a narrow panel.

className?: string

TabsElementtype

type TabsElement = HTMLElement & {
	get(): string;
	/** Show the tab `value` (mounting it the first time) without calling `onChange`. */
	set(value: string): void;
	/** The panel of a tab that has been shown, or null. */
	panel(value: string): HTMLElement | null;
};

widgets.tabs: the tab shown, and a way to show another.

SelectOptionsinterface

interface SelectOptions
value?: string | number
onChange?: (value: string) => void

FormRowinterface

interface FormRow
label: string
field: HTMLElement

ListIteminterface

interface ListItem
label: string
value: T
hint?: string

Dimmed text at the end of the row.

index?: number

A number in the row's gutter, as the editor's own lists show indices.

title?: string

ListOptionsinterface

interface ListOptions
selected?: number

Which row starts selected.

height?: number

Maximum height in pixels; the list scrolls past it.

className?: string
onPick?: (value: T, index: number) => void

SpinnerOptionsinterface

interface SpinnerOptions
size?: "sm" | "md" | "lg"

"sm" 10px, "md" 14px (the default), "lg" 20px.

label?: string

Text beside the ring. Without it you get the bare ring, to put beside your own.

ProgressBarOptionsinterface

interface ProgressBarOptions
value?: number | null

How far along to start: 0…1, or null (the default) for the sliding bar that means "no idea how long".

label?: string

A line under the bar — what is being waited for, or how much of it is done.

percent?: boolean

The percentage at the end of the bar; on for a bar that knows how far along it is.

width?: number

Width in CSS pixels; it fills the width it is given by default.

ProgressBarElementtype

type ProgressBarElement = HTMLElement & {
	/**
	 * How far along: 0…1, or null for the sliding bar. `label` replaces the line under it.
	 * Cheap to call often — it repaints only when the bar actually moves.
	 */
	set(value: number | null, label?: string): void;
};

StatusLineOptionsinterface

interface StatusLineOptions
text?: string

What it says before anything has happened.

StatusLineElementtype

type StatusLineElement = HTMLElement & {
	/**
	 * A finished line. `kind` colours it; nothing means plain. A `Node` in place of the
	 * text is for a line that carries more than words — a failure with a link to the
	 * settings that would fix it.
	 */
	set(text: string | Node, kind?: "ok" | "warn" | "error"): void;
	/** Waiting, with no idea how long: a ring and the text. */
	busy(text: string): void;
	/** Waiting with a share done (0…1), or null for the sliding bar. */
	progress(text: string, value: number | null): void;
	/**
	 * A Cancel beside the line that calls `stop`; null takes it away. `label` is the
	 * button's word — "Stop" for a request that is running, since Cancel in a dialog
	 * means leaving it.
	 */
	cancel(stop: (() => void) | null, label?: string): void;
	/** Back to an empty line. */
	clear(): void;
};

The line along the bottom of a dialog: what happened, what is happening, how far along it is, and the Cancel that stops it — in one place, so a dialog has one voice. It is a live region, so a screen reader hears the outcome (but not every byte of a download: the bar's own movement is not announced).

SkeletonOptionsinterface

interface SkeletonOptions
width?: string

CSS width, "100%" by default.

height?: number

Height in CSS pixels: 10 for a line, 44 for a block.

lines?: number

How many lines, stacked; 1 by default. The last is drawn short, as text ends short.

block?: boolean

A block — the place a thumbnail or a picture will take — rather than a line of text.

BusyOptionsinterface

interface BusyOptions
label?: string

What is being waited for, over the covered box.

dim?: boolean

Dim what is already there rather than leaving it at full strength; true by default.

BusyHandleinterface

interface BusyHandle
set(label: string): void

Change the label without lifting the cover.

done(): void

Uncover the box. Safe to call twice.

StepsOptionsinterface

interface StepsOptions
tail?: number

While the list is running, show only its last this many rows; the rest come back when it stops. For a list that grows past what a panel can hold while it is watched. 0, the default, shows every row.

StepOptionsinterface

interface StepOptions
icon?: string

A character in front of the label, in the list's own vocabulary: ✎ for a change, ▸ for a read.

title?: string

The row's tooltip.

running?: boolean

Begin running (a ring) rather than pending (a hollow mark).

StepHandletype

type StepHandle = {
	readonly element: HTMLElement;
	/** Rename the row (a step named before its arguments were known), and its tooltip. */
	set(label: string, title?: string): void;
	start(detail?: string): void;
	done(detail?: string): void;
	fail(detail?: string): void;
	skip(detail?: string): void;
	/** Change the detail and nothing else — a clock ticking beside a running step. */
	detail(text: string): void;
	/** Something under the label — a thumbnail, a line of output. */
	append(node: Node): void;
};

One row of a steps list. Each state takes the detail shown dim after the label.

StepsElementtype

type StepsElement = HTMLElement & {
	add(label: string, options?: StepOptions): StepHandle;
	/**
	 * A line between rows that is not a step — an assistant's words between its tool
	 * calls. `at` puts it before the row at that index; without it, at the end.
	 */
	note(content: string | Node, at?: number): HTMLElement;
	/** Rows and notes so far, for `note`'s `at`. */
	size(): number;
	/** Steps so far (rows, not notes). */
	count(): number;
	/** Whether work is running: while it is, the `tail` window applies. */
	running(on: boolean): void;
};

A list of steps: one row per unit of work, each pending, running, done, failed or skipped, with a detail after its label and a mark at its end. What a builder shows while it builds, and what a tool-using assistant shows while it works.

FoldOptionsinterface

interface FoldOptions
text?: string

The line to begin with.

open?: boolean

Open to begin with.

busy?: boolean

Start with a ring for a mark.

FoldElementtype

type FoldElement = HTMLDetailsElement & {
	/** Where the content goes. */
	readonly body: HTMLElement;
	/** The line: text, or text and nodes. */
	set(...content: (string | Node)[]): void;
	/** The mark in front of the line: `"busy"` for a ring, a character (✓, ✗) in a colour, or null for none. */
	mark(sym: string | null, kind?: "ok" | "warn" | "error"): void;
	/** A control at the end of the line — an Undo — whose click does not fold the block; null takes it away. */
	action(node: HTMLElement | null): void;
};

A block that folds to one line: a mark, the line, a control at its end, and a body. For work worth a line when it is done and a list while it runs — one turn of an assistant, a pass over the map — and for anything long that most readers skip, such as a model's reasoning.

WidgetsApiinterface

interface WidgetsApi

Buttons, fields, forms and lists in the editor's own styles, as plain DOM. A plugin dialog built from these looks like a built-in one; el is the escape hatch for anything they do not cover.

button(label: string, options?: ButtonOptions): ButtonElement
checkbox(label: string, options?: CheckboxOptions): CheckboxElement
text(options?: TextFieldOptions): HTMLInputElement
number(options?: NumberFieldOptions): HTMLInputElement
select(items: SelectOption[], options?: SelectOptions): HTMLSelectElement
segmented(items: SelectOption[], options?: SelectOptions): SegmentedElement

Buttons side by side of which one is on: a choice of a few short options seen at once ("Both", "Minerals", "Gas"), where a select would hide them. onChange hears the value picked; set moves the choice without it.

tabs(items: TabItem[], options?: TabsOptions): TabsElement

Tabs in the editor's style: a row of tabs over a panel a tab. A tab's mount runs the first time it is shown and its panel is kept after, so what the user did there stays when they come back. Arrow keys move between the tabs.

form(rows: (FormRow | null | undefined | false)[]): HTMLElement

A two-column grid of labelled fields.

group(title: string, ...children: WidgetChild[]): HTMLElement

A titled box (a fieldset with a legend, as the dialogs use).

row(...children: WidgetChild[]): HTMLDivElement
column(...children: WidgetChild[]): HTMLDivElement
hint(text: string): HTMLElement

Small dimmed explanatory text.

separator(): HTMLElement
list<T>(items: ListItem<T>[], options?: ListOptions<T>): HTMLElement
spinner(options?: SpinnerOptions): HTMLElement

A turning ring: on its own to put beside your own text, or with a label beside it.

progressBar(options?: ProgressBarOptions): ProgressBarElement

A bar, with the percentage and a line under it. set(0…1) moves it; set(null) gives the sliding bar for work whose length is not known. Use it where the wait has a size — a download, a pass over every trigger — and a spinner where it does not.

statusLine(options?: StatusLineOptions): StatusLineElement

The dialog's bottom line: set, busy, progress and a cancel button.

skeleton(options?: SkeletonOptions): HTMLElement

A grey stand-in for content that has not arrived, in the shape it will take. Rows of these in the list you are about to fill say more than an empty box does, and the list does not jump when the answer lands.

busy(target: HTMLElement, options?: BusyOptions | string): BusyHandle

Cover a box while what is in it is being replaced: dimmed, deaf to clicks, with a ring and a label over it. done() uncovers it. Give it the box, not the page — the search field that starts the work should stay live so the user can change their mind. Calling it again on a covered box only changes the label.

steps(options?: StepsOptions): StepsElement

A list of steps, each pending → running → done / failed / skipped, with a detail.

fold(options?: FoldOptions): FoldElement

A block folded to one line — a mark, a line that can change, a control at its end — over a body.

Seen in

scmJS 0.7.3 + main@e2d8ba5 · Generated from the repository. The source is under the MIT license, which does not cover what ATTRIBUTION.md lists. StarCraft and Brood War are trademarks of Blizzard Entertainment; this project ships none of their data.