Repository navigation
Writing a Plugin
Build a small command that asks for a chemical name, has the host parse and lay it out, and inserts the result as one undoable action.
Use a separate developer checkout with the workspace dependencies prepared as described in Building from Source. The structure below follows examples/plugins/mass-fragment-demo: a manifest, a registration factory, a public index, and a worker entry. The chemistry flow follows the official OPSIN plugin; this tutorial does not implement a name parser or drawing engine.
Create a new tutorial directory, examples/plugins/widget, containing these files:
package.json
LICENSE
src/manifest.ts
src/register.ts
src/index.ts
src/workerEntry.ts
widget is the sample identity used in the repository's authoring guide. Choose your own identity before distributing a real plugin. These are tutorial-created files, not an additional shipped example.
Use this package.json:
{
"name": "@yourorg/plugin-widget",
"version": "0.1.0",
"private": true,
"type": "module",
"license": "MIT",
"dependencies": {
"@chemdraft/plugin-api": "workspace:*"
},
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
}
}The workspace already includes examples/plugins/*. Refresh dependency links through the normal developer setup after adding the package. Include the full MIT licence in LICENSE if you use the licence declared here, preserving applicable attribution when adapting example code. Packaging requires an explicit licence file.
Plugin runtime source may import only @chemdraft/plugin-api among ChemDraft packages. Ordinary npm dependencies and relative imports within your package are allowed. Do not import chem-core, plugin-host, or desktop implementation modules. The SDK re-exports document types when needed.
Put this in src/manifest.ts:
import { parsePluginManifest } from "@chemdraft/plugin-api";
export const widgetManifest = parsePluginManifest({
id: "org.yourorg.widget",
name: "Widget",
version: "0.1.0",
apiVersion: "^0.1.4",
entry: "src/index.ts",
license: "MIT",
permissions: [
"chemistry.compute", "native.execute", "document.read",
"document.write", "ui.menu", "ui.panel"
],
contributes: {
commands: [{
id: "plugin.widget.run",
title: "Run Widget",
requiredPermissions: [
"chemistry.compute", "native.execute",
"document.read", "document.write", "ui.panel"
]
}],
menus: [{
id: "menu.widget.run",
title: "Run Widget",
commandId: "plugin.widget.run",
location: "analyze",
requiredPermissions: ["ui.menu"]
}],
panels: [{
id: "panel.widget.result",
title: "Widget Result",
commandId: "plugin.widget.run",
requiredPermissions: ["ui.panel"]
}]
}
});parsePluginManifest fills omitted contribution arrays and defaults. The command uses the plugin.<pluginName>.<action> namespace; menus and panels have their own prefixes. Their command references point to the command contributed here.
The minimum API is ^0.1.4 because direct insertion arrived in 0.1.4. The current host provides 0.1.7. chemistry.compute gates chemistry services, native.execute additionally permits the host to start OPSIN's Java process, and document.read is needed for document-aware layout. ui.panel supplies both the host text prompt and failure reports. None of these grants an arbitrary process-launch API.
Put this complete registration in src/register.ts:
import type { PluginCommandHandler } from "@chemdraft/plugin-api";
const runWidget: PluginCommandHandler = async (context) => {
try {
const dialogs = context.dialogs;
const chemistry = context.chemistry;
const applyPatch = context.documents.applyPatch;
if (!dialogs || !chemistry?.nameToStructure ||
!chemistry.structureFromSmiles || !applyPatch) {
throw new Error("Required prompt, chemistry, or insertion capability is unavailable.");
}
const answer = await dialogs.promptText({
title: "Structure from Name",
label: "Systematic (IUPAC) name",
placeholder: "2-methylpropan-1-ol",
maxLength: 500
});
if (answer.status === "cancelled") return { cancelled: true };
const name = answer.value.trim();
if (!name) throw new Error("Enter a chemical name.");
const parsed = await chemistry.nameToStructure({ name });
if (!parsed.available) {
throw new Error(`Name parser unavailable: ${parsed.reason}`);
}
if (!parsed.parsed) {
throw new Error(`Name not parsed: ${parsed.reason}`);
}
const built = await chemistry.structureFromSmiles({
smiles: parsed.smiles,
origin: "Widget"
});
if (!built.available || !built.built) {
throw new Error(
`Name parsed, but no drawing was built: ${built.reason}
SMILES: ${parsed.smiles}`
);
}
const document = await context.documents.getActiveDocument();
const pageId = document?.pages[0]?.id;
if (!pageId) throw new Error("There is no open page to add the structure to.");
const receipt = await applyPatch({
reason: `Insert the structure for "${name}"`,
patch: { op: "addObject", pageId, object: built.object }
});
return { ok: true, objectIds: receipt.objectIds };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await context.panels?.showReport("panel.widget.result", {
title: "Widget Result",
sections: [{ kind: "text", title: "Could not insert", body: message }]
});
return { ok: false, error: message };
}
};
export function createWidgetRegistration() {
return { commandHandlers: { "plugin.widget.run": runWidget } };
}The sample targets the document's first page, following the OPSIN plugin's insertion flow. It does not claim an active-page API that the context lacks. The host supplies the molecule object with coordinates and a fresh identity; the plugin never invents molecule geometry.
Cancellation opens no report. Parsing, missing-engine, and layout outcomes are distinguished. A successful insertion also opens no report. applyPatch commits through normal history, selects inserted objects, and returns an applied receipt. It is valid only during this plugin's command and is refused if the active document has changed.
This direct-write pattern is for deterministic input supplied by the user. Existing-object edits require documents.proposePatch. Image recognition is always proposal-only and must be a separate plugin without document.write.
src/index.ts exports exactly one manifest for the packaging tool to discover:
export { widgetManifest } from "./manifest";
export { createWidgetRegistration } from "./register";src/workerEntry.ts starts the installable worker:
import { runPluginWorker } from "@chemdraft/plugin-api";
import { widgetManifest } from "./manifest";
import { createWidgetRegistration } from "./register";
runPluginWorker({
manifest: widgetManifest,
commandHandlers: createWidgetRegistration().commandHandlers
});Do not re-export the worker entry from the index: importing your public surface must not start a worker. The host discovers the manifest, completes the versioned worker handshake, and registers its command handlers. No desktop registry edit is needed for a manually installed package.
Commit the complete plugin source in your developer branch before packaging. The packaging gate refuses dirty or untracked plugin files, imports escaping the public SDK boundary, missing licences, and unlicensed bundled datasets. This example bundles no dataset.
Run from the repository root:
pnpm plugin:package -- examples/plugins/widgetThe directory name and package version produce:
dist/plugin-packages/widget-0.1.0.zip
dist/plugin-packages/widget-0.1.0.zip.sha256
The tool builds src/workerEntry.ts, writes manifest.json with provenance and entry: "entry.js", and keeps any chunks/assets together. Distribute the ZIP and checksum sidecar together. A checksum verifies bytes, not publisher identity. plugin:extract instead builds a source distribution; it is not the desktop install artifact.
Open Plugins → Add or Remove Plugins… → Add plugin from package…, select the ZIP, review its permissions, and choose Install. Both platforms use this flow: macOS has an app-wide menu; Windows uses the document window's menu. The host stages and serves packages on its own platform-specific origin, not from a plugin-provided server.
In a development app, run Analyze → Run Widget, enter 2-methylpropan-1-ol, and inspect the inserted structure. Confirm it is selected and one Undo removes the insertion. Then check cancellation, an unparseable name, unavailable runtime, and switching documents while the command is pending. Each must produce its intended outcome without inserting into another document.
Check the installed package on both macOS and Windows; a successful TypeScript build does not establish native runtime availability on either. For automated coverage, follow the mass example's separation of command flow, pure calculation, and report composition, and exercise denied permissions against the host. See Testing and CI.
ChemDraft repository · Apache-2.0 · Home
- Features overview
- Installation
- Quick Start
- Your first molecule
- FAQ
- Troubleshooting
- Glossary
- macOS and Windows differences
- Drawing basics
- Atoms and labels
- Bonds and stereochemistry
- Rings and chains
- Selection and editing
- Charges, radicals, and dative bonds
- Copy, paste, and clipboard
- Keyboard shortcuts
- Toolbars and palettes
- Documents, pages, and tabs
- Arrows and mechanisms
- Art objects and styling
- Publication figures and style presets
- Compound layouts
- Templates and abbreviations
- Chemistry guarantees
- Analysis and properties
- NMR prediction
- Name from structure
- Name to structure
- Structure recognition
- Spin 3D