Skip to main content

Lesson 08 — Dialog UI

Scripts become dramatically more useful when they can ask the user a question before running. This lesson covers Affinity's built-in dialog system — how to build modal dialogs with columns, groups, and controls, how to read the values the user enters, and how to use those values to drive the rest of your script.

A dialog is the difference between a script that always does the same thing and one that adapts to what the user needs. Instead of hardcoding a colour, you present a colour picker. Instead of a fixed export format, you offer a dropdown. The dialog API is self-contained — no HTML, no CSS, no web views — and produces native dialogs that look and feel like the rest of the application.

The dialog system has a lifecycle that matters: controls are configured before the dialog is shown, and values are read after it closes. Attempting to read a control value before calling runModal(), or configuring a control after it, produces either incorrect results or silent failures. Understanding the build-then-show-then-read model before writing your first dialog will save a significant amount of debugging.

The layout system is column-based. Controls live inside groups, groups live inside columns, and columns live inside the dialog. This hierarchy is stricter than a web layout, but it means dialogs render consistently without any CSS or measurement code on your part. Reactive behaviour — enabling one control based on the state of another — is handled through per-control or per-dialog value changed callbacks.

By the end of this lesson you will be able to build dialogs that collect text, numbers, colours, fills, strokes, fonts, and dropdown selections from the user, handle OK and Cancel correctly, and use the resulting values to parameterise the rest of your script.

What this lesson covers​

  • Creating modal dialogs with Dialog.create()
  • The column → group → control hierarchy
  • All available control types including FillEditor, StrokeEditor, FontPicker, and SpatialAnchor
  • Reading values after the user clicks OK
  • Reactive controls — onValueChangedHandler (per-control) and onControlValueChangedHandler (dialog-level)
  • Live preview dialogs — the while (runModal() == Ok) loop, preview flag, and clearPreviews()
  • Mixed units in UnitValueEditor — store in pixels, display in mm
  • UnitType.Number for unitless integer editors
  • Resizable dialogs, findControl(), and attaching controls to the dialog object
  • DialogResult — handling OK vs Cancel
  • alert() for simple user messages

The Dialog hierarchy​

Dialog
└── Column (one or more)
└── Group (one or more, with optional label)
├── Controls (CheckBox, ComboBox, TextBox, FillEditor, etc.)
└── ColumnStack (sub-layout inside a group)
└── Column
└── Group
└── Controls

// All items inherit from DialogItem base
// DialogItem exposes: .itemID (number), .itemType (string)
// Legacy aliases retained: .controlID, .controlType

Minimal dialog example​

'use strict';
const { Dialog, DialogResult } = require('/dialog.js');

const dlg = Dialog.create('My Script');
const col = dlg.addColumn();
const grp = col.addGroup('Options');

const nameBox = grp.addTextBox('Name', 'Default text');
const enableCb = grp.addCheckBox('Enable feature', true);

const result = dlg.runModal();

if (result == DialogResult.Ok) {
console.log('Name: ', nameBox.text);
console.log('Enabled: ', enableCb.value);
} else {
console.log('User cancelled.');
}

All control types​

TextBox​

// Snippet
const tb = grp.addTextBox('Label', 'initial text');
// Read: tb.text
tb.isMultiLine = true; // allow multiple lines
tb.rowSpan = 3; // height in rows
tb.textHorizontalAlignment = 1; // 0=Left, 1=Centre, 2=Right

CheckBox​

// Snippet
const cb = grp.addCheckBox('Enable option', false);
// Read: cb.value (boolean)
cb.isFullWidth = true;

Switch (toggle)​

// Snippet
const sw = grp.addSwitch('Dark mode', false);
// Read: sw.value (boolean)

ComboBox (dropdown)​

// Snippet
const FORMATS = ['PNG', 'JPEG', 'PDF', 'SVG'];
const combo = grp.addComboBox('Format', FORMATS, 0);
// Read: FORMATS[combo.selectedIndex]
// Note: combo.value returns undefined — always use selectedIndex
combo.isFullWidth = true;

RadioGroup​

// Snippet
const OPTIONS = ['Low', 'Medium', 'High'];
const radio = grp.addRadioGroup('Quality', OPTIONS, 1);
// Read: OPTIONS[radio.selectedIndex]
radio.isFullWidth = true;

ButtonSet (segmented control)​

// Snippet
const ALIGN = ['Left', 'Centre', 'Right'];
const btns = grp.addButtonSet('Align', ALIGN, 0);
btns.isFullWidth = true;
// Read: ALIGN[btns.selectedIndex]

ColourPicker​

// Snippet
const { Colour } = require('/colours.js');
const picker = grp.addColourPicker('Colour'); // no initial colour
// Or with an initial colour:
// const picker = grp.addColourPicker('Colour', initialColour);
picker.allowPickNone = true; // allow "no colour" selection
picker.allowNoise = true; // allow noise fills
// Read: picker.value (Colour object or null)

FillEditor​

// Snippet
const { SolidFill } = require('/fills.js');
const { Colour } = require('/colours.js');
const initialFill = SolidFill.create(new Colour(/* ... */));
const fillEditor = grp.addFillEditor('Fill', initialFill);
fillEditor.isFullWidth = true;
fillEditor.isStrokeFill = false; // true = stroke fill mode
// Read: fillEditor.fill (returns a fill descriptor)

StrokeEditor​

// Snippet
const strokeEditor = grp.addStrokeEditor('Stroke');
strokeEditor.isFullWidth = true;
// Read: strokeEditor.stroke

SpatialAnchor​

A spatial anchor picker lets the user choose a reference point (top-left, centre, bottom-right, etc.) for alignment, resize, or transform operations. It renders as a 3x3 grid of position buttons.

// Snippet — assumes grp is defined
const anchor = grp.addSpatialAnchor('Anchor Point');
// Read: anchor.value returns a SpatialAnchor enum value (Centre, TopLeft, etc.)
// Import from: const { SpatialAnchor } = require('/dialog.js');

SpatialAnchor is re-exported from /dialog — no need to import from affinity:dom directly. The initial value defaults to SpatialAnchor.None — pass a different value as the second argument.

Button​

A standalone push button with an onClickHandler. Unlike other controls it doesn't hold a value — it fires an action.

// Snippet
const btn = grp.addButton('Apply');
btn.isFullWidth = true;
btn.alignment = HorizontalAlignment.Centre; // Centre, Left, Right

btn.onClickHandler = () => {
console.log('Button clicked — running action');
};

// Fluent chaining — all setters return the item
grp.addCheckBox('Dark mode', false)
.setIsFullWidth(true)
.setIsEnabled(false);

Control quick reference​

ControlFactoryRead valueKey properties
TextBoxgrp.addTextBox(label, text).text.isMultiLine, .rowSpan, .isFullWidth, .isEnabled, .description, .textHorizontalAlignment
CheckBoxgrp.addCheckBox(label, checked).value (bool).isFullWidth, .isEnabled
Switchgrp.addSwitch(label, checked).value (bool).isEnabled
ComboBoxgrp.addComboBox(label, items[], index)items[.selectedIndex] (.value returns undefined).selectedIndex, .isFullWidth, .isEnabled
RadioGroupgrp.addRadioGroup(label, items[], index)items[.selectedIndex].selectedIndex, .isFullWidth, .isEnabled
ButtonSetgrp.addButtonSet(label, items[], index)items[.selectedIndex].selectedIndex, .isFullWidth, .isEnabled
ColourPickergrp.addColourPicker(label, initialColour?).value (Colour or null).allowPickNone, .allowNoise, .isEnabled
FillEditorgrp.addFillEditor(label, initialFill?).fill (FillDescriptor).isFullWidth, .isStrokeFill, .isEnabled
StrokeEditorgrp.addStrokeEditor(label, initialStroke?).stroke.isFullWidth, .isEnabled
Buttongrp.addButton(label)— (fires .onClickHandler).isFullWidth, .alignment (Left/Centre/Right)
UnitValueEditorgrp.addUnitValueEditor(label, storeUnit, dispUnit, value, min?, max?)
grp.addUserUnitValueEditor(label, storeUnit, dispUnit, value, min?, max?)
.value (in store unit).showPopupSlider, .precision, .isEnabled, .isFullWidth, .noMinValue, .noMaxValue
FontPickergrp.addFontPicker(label).text (name), .font, .fontFamily, .fontCollection.isEnabled
SpatialAnchorgrp.addSpatialAnchor(label).value (SpatialAnchor enum).isEnabled
StaticTextgrp.addStaticText(label, text).text.isFullWidth (read-only display)

Common to all controls: .isEnabled, .isVisible, .itemID, .itemType, .description, .onValueChangedHandler, .setIsVisible(v), .setIsEnabled(v), .setIsFullWidth(v)

Common to groups: .addColumnStack(), .label, .column, .controls, .isVisible, .setIsVisible(v), .enableSeparator, .setEnableSeparator(v)

Common to columns: .addGroup(name), .groups, .controls, .columnIndex, .widthProportion, .paddingFactor, .setPaddingFactor(v), .isVisible, .setIsVisible(v)

Common to dialog: .columns, .groups, .controls, .isResizable, .setIsResizable(v), .initialWidth, .isRunningModal, .findControl(id), .setItemsVisibility(showIDs, hideIDs), .onControlValueChangedHandler, .setOnControlValueChangedHandler(fn)

ColumnStack​

Columns added directly to the dialog (dlg.addColumn()) sit at the top level and affect the whole dialog width. ColumnStack lets you create a multi-column sub-layout inside a single group — useful when one section of your dialog needs a side-by-side layout while the rest uses a single column:

// Snippet
const stack = grp.addColumnStack();
const leftCol = stack.addColumn();
const rightCol = stack.addColumn();

leftCol.addGroup('Left').addCheckBox('Option A', true);
rightCol.addGroup('Right').addCheckBox('Option B', false);

UnitValueEditor (numeric with units)​

// Snippet
const { UnitType } = require('/units.js');

// Basic — store and display in the same unit
const editor = grp.addUnitValueEditor(
'Size', // label
UnitType.Pixel, // storage unit — values read/written in this unit
UnitType.Pixel, // display unit — what the user sees
100, // initial value (in storage units)
1, // min (null = no minimum)
2000 // max (null = no maximum)
);
editor.showPopupSlider = true; // show a slider popup on the field
editor.precision = 1; // decimal places
editor.noMinValue = false; // allow values below the original min
editor.noMaxValue = false; // allow values above the original max
// Read: editor.value (always in storage units)

// Mixed units — store in pixels, display in millimetres
// The user sees and types mm; .value returns pixels
const mmEditor = grp.addUnitValueEditor('Offset', UnitType.Pixel, UnitType.Millimetre, 0);

// Unitless integer — for counts, indices, sides, etc.
const sidesEditor = grp.addUnitValueEditor('Sides', UnitType.Number, UnitType.Number, 6, 3, 20);

// UserUnitType variant — accepts UserUnitType enums instead of UnitType
// const userEditor = grp.addUserUnitValueEditor(label, storeUnit, displayUnit, value, min?, max?);

FontPicker​

DialogFontPicker lets the user choose a font interactively. It exposes three read properties after the dialog closes: text (a plain name string), font (a Font object), and fontFamily / fontCollection for family- or collection-level access.

'use strict';
const { Dialog, DialogResult } = require('/dialog.js');
const { FontFamily } = require('/fonts.js');
const { Document } = require('/document.js');

const doc = Document.current;
if (doc) {
const dlg = Dialog.create('Choose font');
const col = dlg.addColumn();
const grp = col.addGroup('');

const fp = grp.addFontPicker('Font');
fp.text = 'Arial'; // optional: set initial font by name

if (dlg.runModal() == DialogResult.Ok) {
console.log('Name: ', fp.text);
console.log('Font: ', fp.font?.postscriptName);
console.log('Family: ', fp.fontFamily?.name);
console.log('Collection: ', fp.fontCollection?.displayName ?? '(none)');

// Walk all variants in the chosen family
const fam = fp.fontFamily;
if (fam) {
for (const f of fam.fonts) {
console.log(' Variant:', f.postscriptName, 'bold:', f.isBold, 'italic:', f.isItalic);
}
}
}
}

fontCollection is null when the font is not part of a named collection (e.g. system fonts). Always null-guard before using it.

StaticText (label)​

// Snippet
const label = grp.addStaticText('Info', 'This is a read-only label.');
label.isFullWidth = true;
// Update text reactively:
label.text = 'Updated text';

Per-item visibility​

Every dialog item exposes isVisible (read/write) and a fluent setIsVisible():

// Snippet
const showAdvanced = grp.addCheckBox('Show advanced', false);
const advanced = grp.addGroup('Advanced').setIsVisible(false);

showAdvanced.onValueChangedHandler = () => {
advanced.isVisible = showAdvanced.value;
};

For bulk changes, use setItemsVisibility(showIDs, hideIDs) — pass arrays of itemID values:

// Snippet
const dlg = Dialog.create('My Dialog');
const col = dlg.addColumn();
const grp = col.addGroup('Options');
const rgbIds = [redEd.itemID, greenEd.itemID, blueEd.itemID];
const cmykIds = [cyanEd.itemID, magentaEd.itemID, yellowEd.itemID, keyEd.itemID];

modeSelector.onValueChangedHandler = () => {
if (modeSelector.selectedIndex === 0)
dlg.setItemsVisibility(rgbIds, cmykIds); // show RGB, hide CMYK
else
dlg.setItemsVisibility(cmykIds, rgbIds); // show CMYK, hide RGB
};

Use itemID (not controlID) with setItemsVisibility. The controlID property is an alias for itemID but itemID is the canonical name.


Dialog-level properties​

// Snippet
const dlg = Dialog.create('My Script');

// Make dialog resizable
dlg.isResizable = true;
dlg.initialWidth = 400; // pixels

// Find a control by ID after building the dialog
const ctrl = dlg.findControl(someControl.controlID);

// findControl returns an untyped control — narrow it with DialogItemType
// (DialogSwitch, DialogCheckBox, DialogComboBox, DialogRadioGroup,
// DialogButtonSet, DialogStaticText, DialogGroup, DialogColumn all inherit
// from DialogItem / DialogControl, which expose itemID/itemType/description)
// if (ctrl?.itemType === DialogItemType.CheckBox) { console.log(ctrl.value); }
// Full DialogItemType member list: SDK-EXPOSURE-MAP.md enum reference.

// Iterate all controls across all groups
for (const ctrl of dlg.controls) {
console.log(ctrl.label);
}

// Check if dialog is currently showing
console.log('Is modal open?', dlg.isRunningModal);

// Column padding
col.paddingFactor = 0.5; // finer spacing control
col.setPaddingFactor(0.8); // fluent alternative

// Control description — now readable
tb.description = 'Enter the filename';
console.log(tb.description); // readable description

Multi-column dialogs​

'use strict';
const { Dialog, DialogResult } = require('/dialog.js');
const { UnitType } = require('/units.js');

const dlg = Dialog.create('Export Options');

const col1 = dlg.addColumn();
col1.widthProportion = 1;
const grp1 = col1.addGroup('Format');
const FORMATS = ['PNG', 'JPEG', 'PDF'];
const formatCombo = grp1.addComboBox('Type', FORMATS, 0);

const col2 = dlg.addColumn();
col2.widthProportion = 1;
const grp2 = col2.addGroup('Quality');
const qualityEdit = grp2.addUnitValueEditor('DPI', UnitType.Pixel, UnitType.Pixel, 300, 72, 600);

const result = dlg.runModal();

if (result == DialogResult.Ok) {
console.log('Format:', FORMATS[formatCombo.selectedIndex]);
console.log('DPI: ', qualityEdit.value);
} else {
console.log('Cancelled.');
}

Reactive controls​

Two approaches are available. Per-control handlers use onValueChangedHandler set directly on each control — no argument, just read the control's current value. The dialog-level handler uses onControlValueChangedHandler on the dialog and receives the changed control as its argument.

Handlers fire on user interaction only. Setting a control value from script code (ctrl.value = x) does not trigger onValueChangedHandler — only genuine user interaction does. This prevents accidental handler recursion.

setIsEnabled() updates isEnabled correctly. It correctly updates isEnabled.

Values modified inside a handler are reflected immediately. Changing a control's value inside an onValueChangedHandler updates the UI correctly.

Per-control (simpler for most cases):

'use strict';
const { Dialog, DialogResult } = require('/dialog.js');
const { UnitType } = require('/units.js');

const dlg = Dialog.create('Export Size');
const col = dlg.addColumn();
const grp = col.addGroup('Options');

const useCustom = grp.addCheckBox('Use custom size', false);
const widthEdit = grp.addUnitValueEditor('Width', UnitType.Pixel, UnitType.Pixel, 800, 1, 4096);
const heightEdit = grp.addUnitValueEditor('Height', UnitType.Pixel, UnitType.Pixel, 600, 1, 4096);

widthEdit.isEnabled = false;
heightEdit.isEnabled = false;

// onValueChangedHandler on the control — no argument, read control value directly
useCustom.onValueChangedHandler = () => {
widthEdit.isEnabled = useCustom.value;
heightEdit.isEnabled = useCustom.value;
};

const result = dlg.runModal();
if (result == DialogResult.Ok) {
if (useCustom.value) {
console.log('Width:', widthEdit.value, 'Height:', heightEdit.value);
} else {
console.log('Using default size.');
}
}

Dialog-level (useful when many controls share logic):

// Snippet
// onControlValueChangedHandler on the dialog — receives changed control as argument
dlg.onControlValueChangedHandler = (ctrl) => {
if (ctrl === useCustom) {
widthEdit.isEnabled = useCustom.value;
heightEdit.isEnabled = useCustom.value;
}
};

Full practical example — export dialog​

'use strict';
const { Document, FileExportOptions } = require('/document.js');
const { Dialog, DialogResult } = require('/dialog.js');
const { app } = require('/application.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const presets = FileExportOptions.allPresetNames;

const dlg = Dialog.create('Export Document');
const col = dlg.addColumn();
const grp = col.addGroup('Settings');

const presetCombo = grp.addComboBox('Format', presets, 0);
const suffixBox = grp.addTextBox('Filename suffix', '');

const result = dlg.runModal();

if (result == DialogResult.Ok) {
const presetName = presets[presetCombo.selectedIndex];
const suffix = suffixBox.text || '';
const desktop = app.userDesktopPath;
const outPath = `${desktop}/${doc.title}${suffix}.${presetName.toLowerCase()}`;

const opts = FileExportOptions.createWithPresetName(presetName);
const records = doc.export(outPath, opts, null, null);

for (const rec of records.all) {
console.log(rec.isSuccess ? 'Exported: ' + rec.path : 'Failed: ' + rec.errorMessage);
}
}
}

Reactive dialog example — shape creator​

08d-reactive-dialog.js shows a complete reactive dialog in practice. It builds a shape creation tool where:

  • A ComboBox selects the shape type — changing it enables the Sides field only when Polygon is selected
  • Width and Height fields each have their own onControlValueChangedHandler — when "Lock aspect ratio" is ticked, changing one syncs the other
  • A static text label updates in real time as values change, showing a preview of what will be created before the user clicks OK

The key pattern is that each control has its own onValueChangedHandler set before runModal(). The function takes no argument — read the control's value directly. The function runs inside the modal without closing it.

// Snippet
// Per-control handler — no argument needed
useCustom.onValueChangedHandler = () => {
widthEdit.isEnabled = useCustom.value;
heightEdit.isEnabled = useCustom.value;
};

See 08d-reactive-dialog.js for the full working implementation.


Live preview dialogs​

The most powerful dialog pattern runs preview commands while the dialog is open, so the user sees the effect of their settings in real time before committing. This uses three elements working together:

  • doc.executeCommand(cmd, true) — the second argument true marks this as a preview. It renders immediately but is not added to the undo history.
  • doc.clearPreviews() — removes all active preview commands. Must always be called when the dialog loop ends, whether the user clicks OK or Cancel.
  • while (dlg.runModal() == DialogResult.Ok) — keeps the dialog open if the user clicks OK but validation fails, allowing another attempt.
'use strict';
const { Document } = require('/document.js');
const { DocumentCommand } = require('/commands.js');
const { Dialog, DialogResult } = require('/dialog.js');
const { UnitType } = require('/units.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const dlg = Dialog.create('Live Preview Example');
const col = dlg.addColumn();
const grp = col.addGroup('Settings');

// Attach controls directly to dlg for convenient access in helper functions
dlg.opacity = grp.addUnitValueEditor('Opacity %', UnitType.Number, UnitType.Number, 100, 0, 100);

function update() {
// Build and execute a preview command — rendered but not committed
const opacity = dlg.opacity.value / 100;
const cmd = DocumentCommand.createSetOpacity(doc.selection, opacity);
doc.executeCommand(cmd, true); // true = preview
}

// Update preview every time a control changes
dlg.onControlValueChangedHandler = update;

// Loop keeps dialog open if validation fails
while (dlg.runModal() == DialogResult.Ok) {
if (dlg.opacity.value < 0 || dlg.opacity.value > 100) {
alert('Opacity must be between 0 and 100.');
continue; // re-show dialog
}
// Commit — run without preview flag so it goes into undo history
const opacity = dlg.opacity.value / 100;
doc.executeCommand(DocumentCommand.createSetOpacity(doc.selection, opacity));
break;
}

// Always clear previews — whether OK or Cancel
doc.clearPreviews();
}

doc.clearPreviews() must always be called after the loop. If the user cancels, previews would otherwise remain visible on the canvas permanently.

Previews don't stack. Only one preview command is active at a time. Rebuild everything in a single command (or compound command) on each change rather than layering preview commands on top of each other. The SDK replaces the previous preview with the new one automatically.


Attaching controls to the dialog object​

Rather than storing all controls in separate variables, you can attach them as properties directly on the dialog object. This keeps related controls together and makes helper functions cleaner — they only need the dlg argument:

// Snippet
const dlg = Dialog.create('Add Guides');
const col = dlg.addColumn();
const grp = col.addGroup('Position');

// Attach controls as properties on dlg itself
dlg.left = grp.addSwitch('Left');
dlg.right = grp.addSwitch('Right');
dlg.top = grp.addSwitch('Top');
dlg.bottom = grp.addSwitch('Bottom');

// You can also attach helper methods
dlg.anySelected = () => dlg.left.value || dlg.right.value || dlg.top.value || dlg.bottom.value;

while (dlg.runModal() == DialogResult.Ok) {
if (!dlg.anySelected()) {
alert('Please select at least one side.');
continue;
}
// use dlg.left.value, dlg.right.value etc.
break;
}
doc.clearPreviews();

alert()​

For simple validation messages, alert() is available as a global:

// Snippet
if (!doc) {
alert('This script requires an open document.');
}

It blocks execution and shows a native system alert. Use it for error conditions only — not as a substitute for console.log.

app.confirm(message, title) and app.prompt(message, title, initialText) show native OK/Cancel and text-input prompts without building a dialog (app.alert() is the method form of the global). Async variants (alertAsync, confirmAsync, promptAsync, chooseFileAsync) are available for non-blocking flows.


The canonical example — addGuides.js​

The SDK ships a built-in example at examples/addGuides.js (see source) that demonstrates the full production-quality dialog pattern: live preview with the while loop, per-control onValueChangedHandler, mixed units (UnitType.Pixel stored / UnitType.Millimetre displayed), controls attached to the dialog object, and clearPreviews() always called after the loop. Reading that script alongside this lesson is the most direct way to see all the patterns working together.


Key gotchas​

combo.selectedIndex not combo.value. combo.value returns undefined. Always read the selection as combo.selectedIndex and look up the value in your options array.

Per-control handler is onValueChangedHandler, dialog handler is onControlValueChangedHandler. These are different properties — onValueChangedHandler lives on each control and takes no argument. onControlValueChangedHandler lives on the dialog and receives the changed control.

UnitType.Pixel not UnitType.Pixels, UnitType.Number for unitless. All enum values use singular spelling. Use UnitType.Number when you want a plain integer with no unit — e.g. a count of sides or a percentage value.

Storage unit vs display unit in addUnitValueEditor. The second argument is the storage unit (what .value returns), the third is the display unit (what the user sees). They can differ — e.g. (UnitType.Pixel, UnitType.Millimetre, 0) stores pixels, displays mm.

Previews don't stack — rebuild everything each time. doc.executeCommand(cmd, true) replaces the previous preview, not adds to it. Build a single compound command covering all changes on each update.

Always call doc.clearPreviews() after the dialog loop. If the user cancels, previews remain on the canvas permanently without this call.

Read control values only after runModal() returns. Values are set by user interaction — reading them before the dialog is shown returns initial values only.

Fill/Stroke editors and buttons return proper typed wrappers. addFillEditor, addStrokeEditor and addButton return their correct types — use them directly without casting. createTypedDialogControl is not needed.

result == DialogResult.Ok — title-case Ok not OK. The simpler == comparison works directly. Wrong case returns undefined and your check silently fails.

There is no addSlider. The SDK dialog has no slider control. Use addComboBox for a fixed set of options, addUnitValueEditor for a free numeric input, or set showPopupSlider = true on a UnitValueEditor.


Exercises​

Exercise 8.1 — Colour picker dialog​

Build a dialog with a ColourPicker and a Switch for "apply to stroke". Apply the chosen colour to all selected nodes when confirmed.

Exercise 8.2 — Fill editor dialog​

Build a dialog with a FillEditor. Apply the chosen fill to all selected nodes when confirmed.

Exercise 8.3 — Export preset picker​

Show a ComboBox with all available export preset names. Export the current document using the chosen preset on OK.

Exercise 8.4 — Reactive form​

Build a dialog where a CheckBox controls whether two UnitValueEditor fields (width and height) are enabled. Use onValueChangedHandler on the checkbox. Use UnitType.Pixel for storage and UnitType.Millimetre for display.

Exercise 8.5 — Reactive shape creator​

See 08d-reactive-dialog.js for a full implementation. Extend it by adding a ColourPicker control and applying the chosen fill colour to the newly created shape after it is added to the document.

Exercise 8.6 — Live preview opacity​

See 08e-live-preview-dialog.js for a full working implementation. Extend it by adding a second UnitValueEditor for blur radius, and applying both opacity and a Gaussian blur as a combined preview command using CompoundCommandBuilder.


SDK modules used in this lesson​

  • /dialog — Dialog, DialogResult, all control classes
  • /colours — Colour
  • /fills — SolidFill
  • /units — UnitType
  • /document — FileExportOptions
  • /shapes — ShapeRectangle, ShapeEllipse, etc. (used in 08d)
  • /nodes — ShapeNodeDefinition (used in 08d)

Further examples​

ExampleWhat it shows
addGuides.jsLive-preview dialog pattern: runModal, preview flag, clearPreviews; CompoundCommandBuilder for multi-guide batching
flexibleLayout.jsColumn stacks, DialogColourPicker, dynamic visibility via setItemsVisibility

See also​


Example scripts​

08a-colour-picker-dialog.js​

Download 08a-colour-picker-dialog.js

08a-colour-picker-dialog.js
'use strict';

// Example 08a — Colour picker dialog
// Lesson 08: Dialog UI
//
// Shows a dialog with a colour picker and stroke toggle.
// Applies the chosen colour to selected nodes on OK.

const { Document } = require('/document.js');
const { DocumentCommand, CompoundCommandBuilder } = require('/commands.js');
const { Dialog, DialogResult } = require('/dialog.js');
const { FillDescriptor } = require('/fills.js');
const { Colour } = require('/colours.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else if (doc.selection.nodes.isEmpty) {
console.log('Select some objects and run again.');
} else {
const dlg = Dialog.create('Apply Colour');
const col = dlg.addColumn();
const grp = col.addGroup('Colour');

// addColourPicker accepts a Colour object or null (no initial colour)
const picker = grp.addColourPicker('Fill colour');
const strokeSw = grp.addSwitch('Also apply to stroke', false);

// Capture the selection BEFORE runModal() — runModal clears it
const sel = doc.selection;

const result = dlg.runModal();

if (result == DialogResult.Ok) {
const colour = picker.value;
if (!colour) { console.log('No colour selected.'); }
else {
const fill = FillDescriptor.createSolid(colour);
const compound = CompoundCommandBuilder.create();

compound.addCommand(DocumentCommand.createSetBrushFill(sel, fill));
if (strokeSw.value) {
compound.addCommand(DocumentCommand.createSetPenFill(sel, fill));
}

doc.executeCommand(compound.createCommand());
console.log('Colour applied' + (strokeSw.value ? ' to fill and stroke.' : ' to fill.'));
}
} else {
console.log('Cancelled.');
}
}

08b-batch-rename-dialog.js​

Download 08b-batch-rename-dialog.js

08b-batch-rename-dialog.js
'use strict';

// Example 08b — Batch rename dialog
// Lesson 08: Dialog UI
//
// Renames all selected layers by wrapping their current names
// with a prefix and/or suffix entered by the user.

const { Document } = require('/document.js');
const { DocumentCommand, CompoundCommandBuilder } = require('/commands.js');
const { Dialog, DialogResult } = require('/dialog.js');
const { Selection } = require('/selections.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else if (doc.selection.nodes.isEmpty) {
console.log('Select some layers and run again.');
} else {
const dlg = Dialog.create('Batch Rename Layers');
const col = dlg.addColumn();
const grp = col.addGroup('Name pattern');

const prefixBox = grp.addTextBox('Prefix', '');
const suffixBox = grp.addTextBox('Suffix', '');

// Capture the selection BEFORE runModal() — runModal clears it
const nodes = doc.selection.nodes.toArray();

const result = dlg.runModal();

if (result == DialogResult.Ok) {
const prefix = prefixBox.text;
const suffix = suffixBox.text;
const compound = CompoundCommandBuilder.create();
let count = 0;

for (const node of nodes) {
const currentName = node.descriptionInterface?.description ?? '';
const newName = `${prefix}${currentName}${suffix}`;
const nodeSel = Selection.create(doc, node);
compound.addCommand(DocumentCommand.createSetDescription(nodeSel, newName));
count++;
}

doc.executeCommand(compound.createCommand());
console.log(`Renamed ${count} layer(s).`);
} else {
console.log('Cancelled.');
}
}

08c-fill-stroke-editor-dialog.js​

Download 08c-fill-stroke-editor-dialog.js

08c-fill-stroke-editor-dialog.js
'use strict';

// Example 08c — Fill and stroke editor dialog
// Lesson 08: Dialog UI
//
// Shows a dialog with a FillEditor and StrokeEditor — the most capable
// fill/stroke controls available in the dialog API. Applies the chosen
// fill and stroke to all selected nodes on OK.
//
// FillEditor supports solid, gradient, and pattern fills.
// StrokeEditor supports full stroke style configuration.

const { Document } = require('/document.js');
const { DocumentCommand, CompoundCommandBuilder } = require('/commands.js');
const { Dialog, DialogResult } = require('/dialog.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else if (doc.selection.nodes.isEmpty) {
console.log('Select some objects and run again.');
} else {
const dlg = Dialog.create('Apply Fill & Stroke');
dlg.isResizable = true;

const col = dlg.addColumn();

const fillGrp = col.addGroup('Fill');
const fillEditor = fillGrp.addFillEditor('Fill');
fillEditor.isFullWidth = true;

const strokeGrp = col.addGroup('Stroke');
const strokeEditor = strokeGrp.addStrokeEditor('Stroke');
strokeEditor.isFullWidth = true;

// Capture the selection BEFORE runModal() — runModal clears it
const sel = doc.selection;

const result = dlg.runModal();

if (result == DialogResult.Ok) {
const fill = fillEditor.fill;
const stroke = strokeEditor.stroke;

const compound = CompoundCommandBuilder.create();
if (fill) compound.addCommand(DocumentCommand.createSetBrushFill(sel, fill));
if (stroke) {
// StrokeEditor returns a LineStyleDescriptor with weight, colour, etc.
// Apply it via createSetLineStyleDescriptor
compound.addCommand(DocumentCommand.createSetLineStyleDescriptor(sel, stroke));
}
doc.executeCommand(compound.createCommand());

console.log('Fill and stroke applied.');
} else {
console.log('Cancelled.');
}
}

08d-reactive-dialog.js​

Download 08d-reactive-dialog.js

08d-reactive-dialog.js
'use strict';

// Example 08d — Reactive dialog with value changed callbacks
// Lesson 08: Dialog UI
//
// Demonstrates onValueChangedHandler on individual controls.
// Each control has its own handler — no need to branch on which control fired.
//
// This example builds a shape creation dialog where:
// - A ComboBox selects the shape type
// - Sides field enables only when Polygon is selected
// - A "lock aspect ratio" checkbox keeps width and height in sync
// - A live preview label updates as values change

const { Document } = require('/document.js');
const { ShapeNodeDefinition } = require('/nodes.js');
const { AddChildNodesCommandBuilder, DocumentCommand } = require('/commands.js');
const { Dialog, DialogResult } = require('/dialog.js');
const { UnitType } = require('/units.js');
const {
ShapeRectangle,
ShapeEllipse,
ShapePolygon,
ShapeStar,
ShapeTriangle
} = require('/shapes.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const SHAPES = ['Rectangle', 'Ellipse', 'Polygon', 'Star', 'Triangle'];
const SHAPE_CTORS = [ShapeRectangle, ShapeEllipse, ShapePolygon, ShapeStar, ShapeTriangle];

// ── Build dialog ───────────────────────────────────────────────────────────

const dlg = Dialog.create('Add Shape');
dlg.isResizable = true;

const col = dlg.addColumn();

const typeGrp = col.addGroup('Shape');
const typeCombo = typeGrp.addComboBox('Type', SHAPES, 0);

const sizeGrp = col.addGroup('Size');
const widthEdit = sizeGrp.addUnitValueEditor('Width', UnitType.Pixel, UnitType.Pixel, 200, 10, 2000);
const heightEdit = sizeGrp.addUnitValueEditor('Height', UnitType.Pixel, UnitType.Pixel, 200, 10, 2000);
widthEdit.showPopupSlider = true;
heightEdit.showPopupSlider = true;

const optGrp = col.addGroup('Options');
const lockAR = optGrp.addCheckBox('Lock aspect ratio', false);
const sidesEdit = optGrp.addUnitValueEditor('Sides', UnitType.Number, UnitType.Number, 6, 3, 20); // unitless integer
sidesEdit.isEnabled = false;

const infoGrp = col.addGroup('');
const infoLabel = infoGrp.addStaticText(' ', `${SHAPES[0]} 200 × 200 px`);
infoLabel.isFullWidth = true;

// ── Helper ─────────────────────────────────────────────────────────────────

function updateLabel() {
const name = SHAPES[typeCombo.selectedIndex];
const w = Math.round(widthEdit.value);
const h = Math.round(heightEdit.value);
infoLabel.text = `${name} ${w} × ${h} px`;
}

// ── Per-control handlers ────────────────────────────────────────────────────
// onValueChangedHandler is set on each control individually.
// The dialog-level onControlValueChangedHandler is also available
// if you prefer a single entry point.

typeCombo.onValueChangedHandler = () => {
sidesEdit.isEnabled = (SHAPES[typeCombo.selectedIndex] === 'Polygon');
updateLabel();
};

let updatingFromLock = false;

widthEdit.onValueChangedHandler = () => {
if (lockAR.value && !updatingFromLock) {
updatingFromLock = true;
heightEdit.value = widthEdit.value;
updatingFromLock = false;
}
updateLabel();
};

heightEdit.onValueChangedHandler = () => {
if (lockAR.value && !updatingFromLock) {
updatingFromLock = true;
widthEdit.value = heightEdit.value;
updatingFromLock = false;
}
updateLabel();
};

lockAR.onValueChangedHandler = () => {
if (lockAR.value) {
heightEdit.value = widthEdit.value;
updateLabel();
}
};

// ── Run ────────────────────────────────────────────────────────────────────

const result = dlg.runModal();

if (result != DialogResult.Ok) {
console.log('Cancelled.');
} else {
const idx = typeCombo.selectedIndex;
const shape = SHAPE_CTORS[idx].create();
const w = widthEdit.value;
const h = heightEdit.value;

const def = ShapeNodeDefinition.createDefault();
def.shape = shape;
def.setBoundingRectangle({ x: 100, y: 100, width: w, height: h });

const builder = AddChildNodesCommandBuilder.create();
builder.addShapeNode(def);
const cmd = builder.createCommand(true);
doc.executeCommand(cmd);

if (SHAPES[idx] === 'Polygon') {
const sides = Math.round(sidesEdit.value);
doc.executeCommand(DocumentCommand.createSetShapeIntParam(doc.selection, 'NumberOfPoints', sides));
}

console.log(`${SHAPES[idx]} added — ${Math.round(w)} × ${Math.round(h)} px`);
}
}

08e-live-preview-dialog.js​

Download 08e-live-preview-dialog.js

08e-live-preview-dialog.js
'use strict';

// Example 08e — Live preview dialog
// Lesson 08: Dialog UI
//
// Demonstrates the live preview pattern:
// - doc.executeCommand(cmd, true) — preview flag: renders but not in undo history
// - doc.clearPreviews() — removes all previews (always call after loop)
// - while (runModal() == Ok) — loop for validation, re-showing dialog on failure
// - onControlValueChangedHandler — dialog-level reactive callback
//
// Select one or more objects and run.
// The opacity changes live as you move the slider — cancel restores the original.

const { Document } = require('/document.js');
const { DocumentCommand, CompoundCommandBuilder } = require('/commands.js');
const { Dialog, DialogResult } = require('/dialog.js');
const { UnitType } = require('/units.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else if (doc.selection.nodes.isEmpty) {
console.log('Select some objects and run again.');
} else {
const sel = doc.selection;

// ── Build dialog ─────────────────────────────────────────────────────────

const dlg = Dialog.create('Set Opacity');
const col = dlg.addColumn();
const grp = col.addGroup('Settings');

// UnitType.Number = unitless integer, shown without unit suffix
dlg.opacity = grp.addUnitValueEditor('Opacity %', UnitType.Number, UnitType.Number, 100, 0, 100);
dlg.opacity.showPopupSlider = true;
dlg.opacity.precision = 0;

dlg.infoLabel = grp.addStaticText('', 'Opacity: 100%');
dlg.infoLabel.isFullWidth = true;

// ── Live preview ─────────────────────────────────────────────────────────
// Dialog-level handler fires on every control change.
// executeCommand with preview=true renders immediately but is not committed.

function update() {
const pct = Math.round(dlg.opacity.value);
dlg.infoLabel.text = `Opacity: ${pct}%`;
const cmd = DocumentCommand.createSetOpacity(sel, pct / 100);
doc.executeCommand(cmd, true); // true = preview
}

dlg.onControlValueChangedHandler = update;

// ── Run loop ─────────────────────────────────────────────────────────────
// while loop allows re-showing the dialog with an alert if validation fails.

while (dlg.runModal() == DialogResult.Ok) {
const pct = dlg.opacity.value;
if (pct < 0 || pct > 100) {
alert('Opacity must be between 0 and 100.');
continue;
}
// Commit — run without preview flag so it goes into the undo history
doc.executeCommand(DocumentCommand.createSetOpacity(sel, pct / 100));
console.log(`Opacity set to ${Math.round(pct)}%.`);
break;
}

// ALWAYS clear previews — if user cancels, previews stay on canvas without this
doc.clearPreviews();
}