agentsclimarketplace

Dragble custom tools

Skill Dragble/dragble-skills/dragble-custom-tools

Build custom drag-and-drop tools and property editor widgets for the Dragble editor. Covers tool registration, declarative templates, JS renderers, property definitions, widgets, property states, transformers, validators, canvas scripts, and email-safe HTML patterns.From its SKILL.md

Install
npx -y skills add Dragble/dragble-skills --skill dragble-custom-tools

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its file declares

Copied from the file, not written here

The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.

SKILL.md

37.2 KB, ~10.0k tokens by cl100k_base, as published. Nobody here has run it

Dragble Custom Tools

Overview

Custom tools are drag-and-drop content blocks that users can place on the editor canvas. They are registered via dragble.registerTool() through the SDK or window.dragble.registerTool() inside custom JS scripts loaded by the editor.

Two rendering modes (auto-detected — no flag needed):

ModeTriggerBest for
Declarative (Template)Config has template (string)~60% of tools — HTML + mustache-like syntax
JS RendererConfig has renderer (function)Dynamic tools — QR codes, charts, runtime JS

Having both template AND renderer is an error. Having neither defaults to declarative with an empty template.

Rules:

  • Tool IDs (name) must be camelCase: coupon, productCard, qrCode
  • No custom# prefix in config — the editor adds custom# internally
  • Properties use editor (widget type) and default (not defaultValue)
  • Flat properties auto-group under "Settings"; grouped properties use explicit { title, position, properties }
  • Functions (renderer, script, validator) are serialized as __DRAGBLE_FN__-prefixed strings when crossing the postMessage boundary — closures don't survive

Complete Example: Declarative Template Mode

A coupon code tool with template, emailTemplate, and flat properties:

dragble.registerTool({
  name: 'couponCode',
  label: 'Coupon Code',
  icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="12" x2="22" y2="12"/></svg>',
  category: 'interactive',
  description: 'Displays a styled coupon code with a copy button',

  // Declarative template — uses {{value}} syntax
  template: `
    <div style="text-align:center; padding:{{padding}}; background:{{bgColor}}; border:2px dashed {{borderColor}}; border-radius:8px;">
      {{#if showLabel}}
        <div style="font-size:14px; color:{{textColor}}; margin-bottom:8px;">
          {{label || 'Your Coupon Code'}}
        </div>
      {{/if}}
      <div style="font-size:{{fontSize}}; font-weight:bold; color:{{codeColor}}; letter-spacing:2px;">
        {{code}}
      </div>
      {{#if showExpiry}}
        <div style="font-size:12px; color:#999; margin-top:8px;">
          Expires: {{expiryDate}}
        </div>
      {{/if}}
    </div>
  `,

  // Email-specific template (table-based for Outlook compatibility)
  emailTemplate: `
    <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:2px dashed {{borderColor}}; border-radius:8px; background:{{bgColor}};">
      <tr>
        <td align="center" style="padding:{{padding}};">
          {{#if showLabel}}
            <div style="font-size:14px; color:{{textColor}}; margin-bottom:8px;">
              {{label || 'Your Coupon Code'}}
            </div>
          {{/if}}
          <div style="font-size:{{fontSize}}; font-weight:bold; color:{{codeColor}}; letter-spacing:2px;">
            {{code}}
          </div>
          {{#if showExpiry}}
            <div style="font-size:12px; color:#999999; margin-top:8px;">
              Expires: {{expiryDate}}
            </div>
          {{/if}}
        </td>
      </tr>
    </table>
  `,

  // Flat properties — auto-grouped under "Settings"
  properties: {
    code:        { editor: 'text',         default: 'SAVE20',    label: 'Coupon Code' },
    bgColor:     { editor: 'color_picker', default: '#fff8e1' },
    borderColor: { editor: 'color_picker', default: '#f59e0b' },
    textColor:   { editor: 'color_picker', default: '#333333' },
    codeColor:   { editor: 'color_picker', default: '#d97706' },
    fontSize:    { editor: 'font_size',    default: '28px' },
    padding:     { editor: 'text',         default: '24px' },
    showLabel:   { editor: 'toggle',       default: true },
    label:       { editor: 'text',         default: 'Your Coupon Code' },
    showExpiry:  { editor: 'toggle',       default: false },
    expiryDate:  { editor: 'text',         default: '2025-12-31' },
  },

  // Hide expiry fields when showExpiry is off
  propertyStates: {
    expiryDate: { show: { property: 'showExpiry', equals: true } },
    label:      { show: { property: 'showLabel',  equals: true } },
  },
});

Complete Example: JS Renderer Mode

A product card with renderer, emailRenderer, and grouped properties:

dragble.registerTool({
  name: 'productCard',
  label: 'Product Card',
  icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/></svg>',
  category: 'interactive',

  // JS renderer — receives values, returns HTML string
  renderer: (values) => {
    const price = Number(values.price) || 0;
    const salePrice = values.onSale ? Number(values.salePrice) || 0 : null;
    return `
      <div style="border:1px solid ${values.borderColor}; border-radius:8px; overflow:hidden; font-family:sans-serif;">
        <img src="${values.imageUrl}" alt="${values.title}" style="width:100%; height:200px; object-fit:cover;" />
        <div style="padding:16px;">
          <h3 style="margin:0 0 8px; color:${values.titleColor};">${values.title}</h3>
          <p style="margin:0 0 12px; color:#666; font-size:14px;">${values.description}</p>
          <div style="font-size:18px; font-weight:bold; color:${values.priceColor};">
            ${salePrice !== null ? `<span style="text-decoration:line-through; color:#999; margin-right:8px;">$${price.toFixed(2)}</span>` : ''}
            $${(salePrice ?? price).toFixed(2)}
          </div>
        </div>
      </div>
    `;
  },

  // Email renderer — table-based layout for email clients
  emailRenderer: (values) => {
    const price = Number(values.price) || 0;
    const salePrice = values.onSale ? Number(values.salePrice) || 0 : null;
    return `
      <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid ${values.borderColor}; border-radius:8px;">
        <tr>
          <td>
            <img src="${values.imageUrl}" alt="${values.title}" width="600" style="width:100%; display:block;" />
          </td>
        </tr>
        <tr>
          <td style="padding:16px;">
            <h3 style="margin:0 0 8px; color:${values.titleColor};">${values.title}</h3>
            <p style="margin:0 0 12px; color:#666666; font-size:14px;">${values.description}</p>
            <div style="font-size:18px; font-weight:bold; color:${values.priceColor};">
              ${salePrice !== null ? `<span style="text-decoration:line-through; color:#999999; margin-right:8px;">$${price.toFixed(2)}</span>` : ''}
              $${(salePrice ?? price).toFixed(2)}
            </div>
          </td>
        </tr>
      </table>
    `;
  },

  // Grouped properties — explicit sections with titles and ordering
  properties: {
    content: {
      title: 'Content',
      position: 1,
      properties: {
        title:       { editor: 'text',     default: 'Product Name' },
        description: { editor: 'textarea', default: 'A short description of the product.' },
        imageUrl:    { editor: 'image',    default: 'https://placehold.co/600x400' },
      },
    },
    pricing: {
      title: 'Pricing',
      position: 2,
      properties: {
        price:     { editor: 'text',   default: '49.99', label: 'Regular Price' },
        onSale:    { editor: 'toggle', default: false },
        salePrice: { editor: 'text',   default: '29.99' },
      },
    },
    style: {
      title: 'Style',
      position: 3,
      properties: {
        titleColor:  { editor: 'color_picker', default: '#1a1a1a' },
        priceColor:  { editor: 'color_picker', default: '#16a34a' },
        borderColor: { editor: 'color_picker', default: '#e5e7eb' },
      },
    },
  },

  propertyStates: {
    salePrice: { show: { property: 'onSale', equals: true } },
  },

  transformer: {
    onSale: {
      false: { salePrice: '' },
    },
  },
});

DragbleToolConfig Reference

All fields on the tool configuration object:

FieldTypeRequiredDefaultDescription
namestringYescamelCase tool ID: coupon, productCard
labelstringYesDisplay label in the side panel
iconstringNodefault iconSVG string for the tool icon
category'text'|'media'|'interactive'|'advanced'|'layout'No'advanced'Panel category
descriptionstringNoTooltip text
ordernumberNoSort order in panel (lower = higher)
templatestringNo*HTML template (declarative mode)
emailTemplatestringNoEmail-specific template override
renderer(values) => stringNo*HTML renderer function (JS mode)
emailRenderer(values) => stringNoEmail-specific renderer function
propertiesDragblePropertiesNoProperty definitions (flat or grouped)
baseToolTypestringNo'html'Base tool: paragraph|heading|button|image|divider|menu|html|social|video|table
includeBasePropertiesbooleanNofalseInclude base tool's existing properties
supportedDisplayModes('email'|'web'|'popup')[]Noall modesWhich editor modes show this tool
usageLimitnumberNo0 (unlimited)Max instances in a design
head{ css?: string, js?: string }NoCSS/JS injected into <head> when tool is present
propertyStatesPropertyStatesConfigNoDynamic property show/hide rules
transformerTransformerConfigNoCross-property auto-updates
validatorfunction | stringNoContent audit rules
openOnDropbooleanNofalseAuto-open content dialog on drop
dataRecord<string, unknown>NoArbitrary data from SDK config
script(container, values) => cleanup?NoCanvas lifecycle script
urlstringNoURL to a JS file exporting { tool, widgets? }

*One of template or renderer is expected (but neither is technically required — empty template is valid).


Property Definitions

Flat Properties

All properties at the top level, auto-grouped under a "Settings" section:

properties: {
  bgColor:  { editor: 'color_picker', default: '#ffffff' },
  title:    { editor: 'text',         default: 'Hello' },
  fontSize: { editor: 'font_size',    default: '16px' },
}

Grouped Properties

Explicit sections with title, position, and nested properties:

properties: {
  content: {
    title: 'Content',
    position: 1,
    properties: {
      title:       { editor: 'text',     default: 'Heading' },
      description: { editor: 'textarea', default: '' },
    },
  },
  style: {
    title: 'Style',
    position: 2,
    properties: {
      bgColor: { editor: 'color_picker', default: '#ffffff' },
    },
  },
}

Mixing flat and grouped is not allowed — the normalizer rejects it.

DragblePropertyDef

Each property definition:

FieldTypeRequiredDescription
editorstringYesWidget type name (see Widget Types below)
labelstringNoDisplay label. Auto-generated from key if omitted (bgColor -> "Bg Color")
defaultunknownNoDefault value. Extracted into tool defaults automatically
hideOnMobilebooleanNoHide this property on mobile view
messagestringNoHelp text shown below the property
dataRecord<string, unknown>NoEditor-specific config (dropdown options, slider range, etc.)

Widget Types

Built-In Generic Widgets

30+ widget types available for property editors. Use the generic name (left column) in property definitions:

Generic NameDefault Value TypeWhat It RendersAccess Pattern
textstringSingle-line text inputvalues.propName (string)
textareastringMulti-line text input / code editorvalues.propName (string)
rich_textstringRich text editor (falls back to code editor)values.propName (HTML string)
color_pickerstringColor picker with labelvalues.propName ('#rrggbb')
alignmentstringText alignment (left/center/right/justify)values.propName ('left'|'center'|'right'|'justify')
text_alignmentstringText alignment (4-way)Same as alignment
vertical_alignmentstringVertical alignment (start/center/end)values.propName ('flex-start'|'center'|'flex-end')
font_familystringFont family dropdownvalues.propName (font name string)
font_weightstring|numberFont weight pickervalues.propName ('400'|'700' etc.)
font_sizestringFont size counter (px)values.propName ('16px')
borderobjectBorder editor (width + color + style)values.propName (border object)
single_borderobjectSingle-side bordervalues.propName (border object)
counterstring|numberNumeric increment/decrementvalues.propName (number or string)
imagestringImage URL input with dimensionsvalues.propName (URL string)
image_uploadstringImage upload with file manager + previewvalues.propName (URL string)
togglebooleanOn/off switchvalues.propName (true|false)
linkobjectLink/action URL editorvalues.propName (link object)
dropdownstringDropdown selectvalues.propName (selected value)
datetimestringDate/time input (falls back to numeric)values.propName (string)
slidernumber|stringRange slidervalues.propName (number)
spacingstring4-side padding/margin editorvalues.propName (CSS shorthand)
border_radiusstring4-corner border radius editorvalues.propName (CSS shorthand)
line_heightstringLine height (%)values.propName ('140%')
letter_spacingstringLetter spacing (px)values.propName ('0.5px')
htmlstringHTML code editorvalues.propName (HTML string)
code_editorstringCode editor with syntax highlightingvalues.propName (code string)
heading_typestringHeading level selector (h1-h4)values.propName ('h1'|'h2'|'h3'|'h4')
visibilityobjectDesktop/mobile show/hide togglesvalues.propName (visibility object)
auto_widthboolean|stringAuto-width toggle + percentagevalues.propName
video_urlstringVideo URL input with auto-detectionvalues.propName (URL string)

Aliases

These aliases resolve to the same widget. Use whichever reads best:

AliasResolves To
colorcolor_picker
input, text_inputtext
number, numericcounter
padding, marginspacing
radius, borderradiusborder_radius
code, html_code, raw_htmlcode_editor
text_align, textaligntext_alignment
vertical_align, verticalalign, valignvertical_alignment
heading, heading_levelheading_type
hide_show, displayvisibility
width_auto, autowidthauto_width
border_top, border_sidesingle_border
file, uploadimage_upload
videovideo_url

Dropdown Configuration

Pass options via the data field:

layout: {
  editor: 'dropdown',
  default: 'horizontal',
  data: {
    options: [
      { label: 'Horizontal', value: 'horizontal' },
      { label: 'Vertical',   value: 'vertical' },
      { label: 'Grid',       value: 'grid' },
    ],
  },
}

Slider Configuration

Pass min/max/step via the data field:

opacity: {
  editor: 'slider',
  default: 100,
  data: {
    min: 0,
    max: 100,
    step: 1,
  },
}

Resolution Order

When the editor encounters a property type string, it resolves in this order:

  1. Custom renderer — registered via dragble.registerPropertyEditor() or createWidget()
  2. Generic widget — resolved from the table above (e.g., color_picker -> internal TEXT_COLOR)
  3. Internal renderer — direct SCREAMING_CASE lookup (e.g., FONT_SIZE, ALIGNMENT)
  4. Fallback — dev warning in development, null in production

SCREAMING_CASE types (e.g., IMAGE_UPLOAD, FONT_SIZE) always bypass generic widget resolution and go directly to the internal renderer.


Template Engine Syntax

Used in template and emailTemplate fields. Processing order: #each -> #if -> #unless -> {{{raw}}} -> {{escaped}}.

Value Interpolation

{{value}}                    HTML-escaped output
{{{rawHtml}}}                Unescaped HTML (triple braces)
{{nested.key.path}}          Dot-notation access into nested objects
{{value || 'fallback'}}      Fallback when value is empty/undefined/null

Conditional Blocks

{{#if showImage}}
  <img src="{{imageUrl}}" />
{{/if}}

{{#if hasTitle}}
  <h2>{{title}}</h2>
{{else}}
  <h2>Untitled</h2>
{{/if}}

{{#unless hideFooter}}
  <footer>{{footerText}}</footer>
{{/unless}}

{{#unless hideFooter}}
  <footer>{{footerText}}</footer>
{{else}}
  <!-- footer hidden -->
{{/unless}}

Truthiness rules: null, undefined, false, empty string, 0, empty array -> falsy. Everything else -> truthy.

Iteration

{{#each items}}
  <div>
    {{name}} - ${{price}}
    {{#if @first}}<span class="badge">First!</span>{{/if}}
    {{#if @last}}<span class="badge">Last!</span>{{/if}}
    <small>Item #{{@index}}</small>
  </div>
{{/each}}

Inside {{#each}}:

  • {{this}} — current item (for primitive arrays like strings/numbers)
  • {{@index}} — 0-based index
  • {{@first}}true if first item
  • {{@last}}true if last item
  • Object properties are merged into scope: if item is { name: 'Widget' }, use {{name}} directly

Safety

  • {{value}} escapes HTML (< -> &lt;). Use {{{raw}}} only when you trust the content.
  • Template errors are logged but never thrown. Bad expressions return the raw placeholder in dev, empty string in production.
  • No eval() or new Function(). Only property access and simple truthiness.
  • Nesting limit: 10 levels of recursion per block type (safety counter).

Property States

Control which properties are visible based on other property values.

Simple Declarative

propertyStates: {
  borderColor: {
    show: { property: 'style', equals: 'outlined' }
  },
  borderWidth: {
    show: { property: 'style', in: ['outlined', 'bordered'] }
  },
  subtitle: {
    show: { property: 'showSubtitle', truthy: true }
  },
}

Available operators on a single condition (AND-ed if multiple):

  • equals — strict equality
  • notEquals — strict inequality
  • in — value is in array
  • notIn — value is not in array
  • truthy — value is truthy (set to true)
  • falsy — value is falsy (set to true)

Functional

propertyStates: {
  gradient: {
    show: (values) => values.bgType === 'gradient' && values.enableBg === true
  },
}

Functions cannot cross postMessage (not serializable). Use declarative for SDK-registered tools.

Compound Conditions (all / any / not)

propertyStates: {
  borderRadius: {
    show: {
      all: [
        { property: 'style', equals: 'outlined' },
        { property: 'enableRoundedCorners', equals: true },
      ]
    }
  },
  altLayout: {
    show: {
      any: [
        { property: 'mode', equals: 'advanced' },
        { property: 'forceAlt', equals: true },
      ]
    }
  },
  simpleBorder: {
    show: {
      not: { property: 'style', equals: 'none' }
    }
  },
}

On evaluation errors, the property defaults to visible (never silently hidden).


Transformers

Auto-update properties when another property changes. Runs once per change (no cascading, prevents loops). The changed property itself is never overwritten by the transformer (user's explicit change always wins).

Declarative Transformer

transformer: {
  // When 'style' changes to 'minimal', set these values
  style: {
    minimal:  { borderColor: 'transparent', borderWidth: '0' },
    outlined: { borderColor: '#000000', borderWidth: '1px' },
  },
  // When 'enableBg' changes to 'false', clear bg properties
  enableBg: {
    false: { bgColor: 'transparent', bgImage: '' },
  },
}

Format: { [changedProperty]: { [matchValue]: { [targetProp]: newValue } } }

Match values are compared via String(value) — booleans become 'true'/'false', numbers become their string representation.

Functional Transformer

transformer: (values, changedProperty) => {
  if (changedProperty === 'imageSize') {
    const size = Number(values.imageSize) || 200;
    return { imageWidth: `${size}px`, imageHeight: `${size}px` };
  }
  if (changedProperty === 'style' && values.style === 'minimal') {
    return { borderColor: 'transparent', borderWidth: '0', shadow: 'none' };
  }
  return {};
}

Functions cannot cross postMessage. Use declarative for SDK-registered tools.


Canvas Scripts

The script field attaches interactive behavior to the tool's rendered HTML on the canvas. Receives the container DOM element and current property values. Can return a cleanup function.

dragble.registerTool({
  name: 'countdownTimer',
  label: 'Countdown Timer',
  template: `
    <div style="text-align:center; padding:20px; background:{{bgColor}}; font-family:monospace;">
      <div style="font-size:12px; color:{{labelColor}}; margin-bottom:8px;">{{label}}</div>
      <div class="countdown-display" style="font-size:{{fontSize}}; color:{{digitColor}}; font-weight:bold;">
        00:00:00:00
      </div>
    </div>
  `,
  properties: {
    targetDate:  { editor: 'text',         default: '2025-12-31T00:00:00', label: 'Target Date (ISO)' },
    label:       { editor: 'text',         default: 'Time Remaining' },
    bgColor:     { editor: 'color_picker', default: '#1a1a2e' },
    labelColor:  { editor: 'color_picker', default: '#aaaaaa' },
    digitColor:  { editor: 'color_picker', default: '#ffffff' },
    fontSize:    { editor: 'font_size',    default: '36px' },
  },

  // Canvas script — runs inside the editor workspace
  script: (container, values) => {
    const display = container.querySelector('.countdown-display');
    if (!display) return;

    const target = new Date(values.targetDate).getTime();

    function tick() {
      const now = Date.now();
      const diff = Math.max(0, target - now);
      const days = Math.floor(diff / 86400000);
      const hrs  = Math.floor((diff % 86400000) / 3600000);
      const mins = Math.floor((diff % 3600000) / 60000);
      const secs = Math.floor((diff % 60000) / 1000);
      display.textContent =
        `${String(days).padStart(2,'0')}:${String(hrs).padStart(2,'0')}:${String(mins).padStart(2,'0')}:${String(secs).padStart(2,'0')}`;
    }

    tick();
    const interval = setInterval(tick, 1000);

    // Return cleanup function — called before re-render or tool removal
    return () => clearInterval(interval);
  },
});

Rules:

  • The script runs after the tool's HTML is inserted into the canvas DOM
  • The cleanup function is called before re-render (property change) or when the tool is removed
  • Scripts are serialized as __DRAGBLE_FN__-prefixed strings when crossing postMessage — closures over external variables will NOT work
  • For email export, scripts are ignored (email HTML is static)

Content Dialog

Auto-open a host-app dialog when a tool is dropped. The dialog runs in the host app context (outside the editor iframe), giving full access to the host app's UI framework.

// Tool config
dragble.registerTool({
  name: 'productPicker',
  label: 'Product',
  template: '<div style="padding:16px;"><h3>{{productName}}</h3><p>{{productDescription}}</p></div>',
  openOnDrop: true,   // <-- triggers dialog on drop
  properties: {
    productName:        { editor: 'text', default: '' },
    productDescription: { editor: 'text', default: '' },
    productImage:       { editor: 'text', default: '' },
  },
});

// SDK init — register the dialog callback
dragble.init({
  // ... other config ...
  callbacks: {
    onContentDialog: (info) => {
      // info.toolType   = "custom#productPicker"
      // info.action     = "insert" | "edit"
      // info.currentValues = { ... } (for edit action)
      return new Promise((resolve) => {
        showProductPickerModal({
          onSelect: (product) => resolve({
            productName: product.name,
            productDescription: product.description,
            productImage: product.imageUrl,
          }),
          onCancel: () => resolve({ cancelled: true }),
        });
      });
    },
  },
});

Flow:

  1. User drops tool -> editor sends DRAGBLE_CONTENT_DIALOG_REQUEST to SDK
  2. SDK calls onContentDialog(info) -> host app shows modal
  3. Host app resolves with values or { cancelled: true }
  4. Editor merges values into tool content; sidebar properties remain available for fine-tuning
  5. If cancelled on insert: tool is removed. If cancelled on edit: no change.

Dialog timeout: 60 seconds.


Custom Widgets (createWidget)

When none of the 30+ built-in widgets fit, create a custom property editor widget.

Mode A: Render/Mount/Update/Unmount

Lightweight, reactive lifecycle:

dragble.createWidget({
  name: 'star_rating',

  // render() returns HTML string — called on initial render
  render(value, updateValue, context) {
    const stars = Number(value) || 0;
    return `<div class="star-rating">${
      [1,2,3,4,5].map(i =>
        `<span data-star="${i}" style="cursor:pointer; font-size:24px; color:${i <= stars ? '#f59e0b' : '#ddd'};">&#9733;</span>`
      ).join('')
    }</div>`;
  },

  // mount() — called after render HTML is inserted into DOM
  mount(node, value, updateValue, context) {
    node.addEventListener('click', (e) => {
      const star = (e.target as HTMLElement).dataset.star;
      if (star) updateValue(Number(star));
    });
  },

  // update() — called when value changes externally (undo, transformer, load)
  // If omitted, the widget is fully re-rendered (render + mount) on change
  update(node, value, context) {
    const stars = Number(value) || 0;
    node.querySelectorAll('[data-star]').forEach((el) => {
      const i = Number((el as HTMLElement).dataset.star);
      (el as HTMLElement).style.color = i <= stars ? '#f59e0b' : '#ddd';
    });
  },

  // unmount() — cleanup when widget is removed from DOM
  unmount(node) {
    // Remove event listeners if needed
  },
});

// Use in tool properties:
dragble.registerTool({
  name: 'reviewBlock',
  label: 'Review',
  template: '<div>Rating: {{rating}}/5</div>',
  properties: {
    rating: { editor: 'star_rating', default: 3 },
  },
});

Mode B: URL/Iframe

For widgets built with any framework. The iframe communicates via postMessage:

dragble.createWidget({
  name: 'map_picker',
  url: 'https://my-app.com/widgets/map-picker.html',
  height: 300,  // iframe height in pixels (default: 200)
});

The iframe page receives messages:

  • { type: "init", value, data, theme } — on load
  • { type: "update", value } — when value changes externally (undo, transformer, load)

The iframe sends:

  • { type: "setValue", value } — to update the property value

WidgetContext

All Mode A lifecycle functions receive a WidgetContext:

interface WidgetContext {
  values: Record<string, unknown>;  // ALL tool property values
  data: Record<string, unknown>;    // Application data from init config
  theme: { accentColor: string; mode: 'light' | 'dark' };
  toolName: string;                 // Tool name this widget belongs to
}

Widgets receive ALL tool values (not just their own), enabling cross-property logic without a transformer.

Widget Registration Order

Widgets flush before tools. When the editor initializes:

  1. Pending widgets are sent via CREATE_WIDGET messages
  2. Then pending tools are sent via ADD_TOOL messages

This ensures widget types are registered in the PropertyEditorRegistry before tools reference them via editor: "widget_name".

Serialization

Functions in widget configs (render, mount, update, unmount) are serialized as __DRAGBLE_FN__-prefixed strings when crossing postMessage:

"__DRAGBLE_FN__function render(value, updateValue, context) { ... }"

Closures don't survive serialization. The function body is serialized via .toString() and reconstructed on the other side. Variables captured from outer scope will be undefined. Keep widget functions self-contained.

Layout

Widgets can specify layout positioning:

dragble.createWidget({
  name: 'my_widget',
  render: (value, updateValue) => `<div>...</div>`,
  layout: 'bottom',  // 'inline' (default) | 'bottom' | 'full-width'
});

Validators

Custom content audit rules. Called during audit() to check tool content for issues. Returns AuditRule[].

Function Validator

dragble.registerTool({
  name: 'imageBlock',
  label: 'Image Block',
  template: '<img src="{{imageUrl}}" alt="{{altText}}" />',
  properties: {
    imageUrl: { editor: 'image', default: '' },
    altText:  { editor: 'text',  default: '' },
  },

  validator: (info) => {
    const errors = [];
    const { design, defaultErrors } = info;
    // info.design = full design data
    // info.defaultErrors = built-in audit errors
    // info.tool = "custom#imageBlock"

    // Check all instances of this tool in the design
    // Return AuditRule[] array:
    if (!info.design) return errors;

    errors.push({
      id: 'imageBlock-missing-alt',
      severity: 'WARNING',    // 'ERROR' | 'WARNING' | 'INFO'
      title: 'Missing Alt Text',
      description: 'Image blocks should have alt text for accessibility.',
    });

    return errors;
  },
});

String Validator (postMessage-safe)

For SDK-registered tools, provide the validator as a function body string:

validator: `
  var errors = [];
  // 'info' is the argument name
  if (!info.design) return errors;
  errors.push({
    id: 'my-check',
    severity: 'WARNING',
    title: 'Check Failed',
    description: 'Something needs attention.',
  });
  return errors;
`

String validators are auto-registered with the Redux validator system. Function validators are executed directly from metadata (10-second timeout).

AuditRule Shape

interface AuditRule {
  id: string;              // Unique rule ID
  severity: 'ERROR' | 'WARNING' | 'INFO';
  title: string;           // Short description
  description: string;     // Detailed explanation
  tool?: { type: string }; // Auto-tagged with tool type
}

Email-Safe HTML Patterns

Email clients (especially Outlook) have severe CSS limitations. Use these patterns in emailTemplate and emailRenderer.

Table-Based Button

<table role="presentation" cellpadding="0" cellspacing="0" border="0">
  <tr>
    <td align="center" bgcolor="{{buttonColor}}" style="border-radius:4px;">
      <a href="{{buttonUrl}}" target="_blank"
         style="display:inline-block; padding:12px 24px; color:{{buttonTextColor}};
                text-decoration:none; font-family:Arial,sans-serif; font-size:16px;
                font-weight:bold; border-radius:4px;">
        {{buttonText}}
      </a>
    </td>
  </tr>
</table>

Two-Column Layout

<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
  <tr>
    <td width="50%" valign="top" style="padding-right:10px;">
      <!-- Left column content -->
    </td>
    <td width="50%" valign="top" style="padding-left:10px;">
      <!-- Right column content -->
    </td>
  </tr>
</table>

Safe CSS Properties

These work across all major email clients:

SafeNotes
colorUse hex codes, not rgb()
background-color, bgcolorUse bgcolor on <td> for Outlook
font-familyStick to web-safe stacks with Arial,sans-serif fallback
font-size, font-weight, font-styleUse px units
text-align, vertical-alignUse on <td> elements
paddingOn <td> elements. Avoid padding on <div>
borderSimple borders work. Use border-collapse: collapse on tables
width, heightUse HTML attributes on <table>, <td>, <img>
line-heightUse px values
text-decorationnone, underline
display: block/inlineOn <img> to prevent gaps
marginUnreliable — use padding on <td> or spacer rows instead
border-radiusWorks in modern clients, ignored in Outlook

Unsafe CSS (Avoid in Email Templates)

UnsafeWhy
flexbox, gridNo support in Outlook
position, floatInconsistent across clients
box-shadow, text-shadowNo Outlook support
opacityIgnored in many clients
max-width, min-widthUnreliable — use fixed width
background-imageRequires VML fallback for Outlook
calc()Not supported in email
@media queriesLimited — only some clients support
CSS custom properties (--var)No support
transform, animation, transitionNot supported

Common Mistakes

1. Tool name not camelCase

// WRONG — will be rejected
{ name: 'my-tool',     ... }  // kebab-case
{ name: 'MyTool',      ... }  // PascalCase
{ name: 'my_tool',     ... }  // snake_case
{ name: 'TOOL',        ... }  // uppercase

// CORRECT
{ name: 'myTool',      ... }
{ name: 'coupon',      ... }
{ name: 'productCard', ... }

Name must match /^[a-z][a-zA-Z0-9]*$/.

2. Both template and renderer

// WRONG — error: cannot have both
{
  name: 'myTool',
  template: '<div>{{value}}</div>',
  renderer: (v) => `<div>${v.value}</div>`,
}

// CORRECT — pick one
{ name: 'myTool', template: '<div>{{value}}</div>', ... }
// OR
{ name: 'myTool', renderer: (v) => `<div>${v.value}</div>`, ... }

3. Missing emailTemplate for email tools

If your tool is used in email mode and the web template uses non-email-safe CSS (flexbox, grid), provide a table-based emailTemplate. Without it, the web template is used for email export too, which may break in Outlook.

4. Mixing flat and grouped properties

// WRONG — cannot mix flat and grouped
properties: {
  bgColor: { editor: 'color_picker', default: '#fff' },  // flat
  content: {                                               // grouped
    title: 'Content',
    properties: { title: { editor: 'text', default: '' } },
  },
}

// CORRECT — pick one style
// All flat:
properties: {
  bgColor: { editor: 'color_picker', default: '#fff' },
  title:   { editor: 'text',         default: '' },
}

// All grouped:
properties: {
  style:   { title: 'Style',   position: 1, properties: { bgColor: { editor: 'color_picker', default: '#fff' } } },
  content: { title: 'Content', position: 2, properties: { title: { editor: 'text', default: '' } } },
}

5. Closures in serialized functions

// WRONG — `API_BASE` is captured from outer scope, will be undefined after serialization
const API_BASE = 'https://api.example.com';
dragble.registerTool({
  name: 'myTool',
  renderer: (values) => `<img src="${API_BASE}/images/${values.id}" />`,  // API_BASE is gone!
});

// CORRECT — inline all values
dragble.registerTool({
  name: 'myTool',
  renderer: (values) => `<img src="https://api.example.com/images/${values.id}" />`,
});

6. Using default instead of defaultValue

// WRONG — this is the old Unlayer convention
properties: {
  color: { editor: 'color_picker', defaultValue: '#fff' },
}

// CORRECT — Dragble uses 'default'
properties: {
  color: { editor: 'color_picker', default: '#fff' },
}

7. Property state references unknown property

// WRONG — 'theme' doesn't exist in properties
propertyStates: {
  borderColor: { show: { property: 'theme', equals: 'dark' } },
}

// CORRECT — reference a property that exists in the tool's properties
properties: {
  style: { editor: 'dropdown', default: 'solid', data: { options: [...] } },
  borderColor: { editor: 'color_picker', default: '#000' },
},
propertyStates: {
  borderColor: { show: { property: 'style', equals: 'outlined' } },
}

8. Expecting transformer cascading

Transformers run once per property change. If transformer A sets property B, and property B has a transformer rule, that rule does not fire. This prevents infinite loops.

9. Using custom# prefix in tool name

// WRONG — the editor adds custom# internally
{ name: 'custom#coupon', ... }

// CORRECT — just the camelCase name
{ name: 'coupon', ... }

10. createWidget before init

// WRONG — throws "Editor not initialized"
dragble.createWidget({ name: 'myWidget', render: ... });
dragble.init({ ... });

// CORRECT — init first, then create widget (it queues automatically)
dragble.init({ ... });
dragble.createWidget({ name: 'myWidget', render: ... });
// Widget is queued and flushed after INIT handshake completes

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 326,629. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.