u/Awkward-Secretary726

Callout Generator — a small QuickAdd script to build callouts faster

Hey everyone,

**Disclaimer:** I'm using AI to help me communicate with you more clearly. Spanish is my native language, and AI helps me translate my ideas into proper English.

I wanted to share a small utility script I built to speed up callout creation in Obsidian. If you find yourself manually adding `> ` to every line and remembering the syntax for `> [!warning] Title`, this might save you some time.

---

## What it does

- Opens a modal with a dropdown for callout type (all 27 native types: note, info, tip, warning, danger, quote, and the rest), a selector for foldable behavior (open / collapsed / non-foldable), a title field, and a content textarea.

- You paste **raw, unformatted text** into the content area. The script automatically strips blank lines and prefixes every line with `> ` to produce a valid callout.

- Live preview while you type.

- Three actions: **Copy** to clipboard, **Insert** at the cursor in the active note, or **Cancel**.

- Keyboard shortcut inside the modal: `Ctrl/Cmd + Enter` inserts directly.

---

## Setup

The script runs as a User Script through the QuickAdd plugin. If you already use QuickAdd you can skip to step 3.

### For those without QuickAdd experience

  1. **Install QuickAdd.** Open Settings → Community plugins → Browse → search for "QuickAdd" → Install → Enable.

  2. **Create a folder for scripts inside your vault.** Any name works. A common convention is `_scripts`. You can do this from your file manager or from Obsidian's file explorer.

  3. **Save the script.** Save the code below as `callout-generator.js` inside that folder. You can rename the file to whatever you prefer, as long as it ends in `.js`.

  4. **Point QuickAdd at the scripts folder.** Open Settings → QuickAdd. In the field labeled **User Scripts Folder**, enter the path to the folder you created (for example: `_scripts`).

  5. **Create the Macro.**

    - Still in Settings → QuickAdd, click **Manage Macros**.

    - Type a name for the macro (for example `Callout Generator`) and click **Add Macro**.

    - Click **Configure** next to the macro you just created.

    - In the **User Scripts** dropdown, find `callout-generator.js` and click **Add**.

    - Close the macros window.

  6. **Create the Choice.**

    - Back on the QuickAdd settings screen, type a name for a new Choice (for example `Callout`).

    - Select **Macro** as the type, then click **Add Choice**.

    - Click the lightning bolt icon next to the new Choice and select the macro you created in step 5.

  7. **(Optional) Assign a shortcut.** Open Settings → Hotkeys, search for `QuickAdd: <your choice name>`, and bind the keys you prefer.

That's it. Trigger the hotkey (or open the QuickAdd command palette and run the Choice), the modal opens, paste your text, pick a type, insert.

---

## The script

```js

/**
 * Callout Generator — QuickAdd User Script for Obsidian
 *
 * INSTALLATION
 *   1. Save this file inside a folder of your vault (e.g. `_scripts/`).
 *   2. Obsidian: Settings -&gt; QuickAdd -&gt; set "User Scripts Folder" to that folder.
 *   3. Settings -&gt; QuickAdd -&gt; Manage Macros -&gt; create a Macro (e.g. "Callout Generator").
 *   4. Inside the Macro: "User Scripts" -&gt; add `callout-generator.js`.
 *   5. Back on QuickAdd settings, add a Choice of type "Macro" that runs that Macro.
 *   6. (Optional) Settings -&gt; Hotkeys -&gt; search "QuickAdd: &lt;your choice name&gt;" and assign a shortcut.
 *
 * USAGE
 *   - Pick a callout type (note, info, tip, warning, etc.) and the foldable behavior.
 *   - Type an optional title.
 *   - Paste raw text into the content area: blank lines are stripped and every line
 *     is prefixed with "&gt; " automatically.
 *   - Live preview updates as you type.
 *   - Buttons: Copy to clipboard, Insert into the active note, or Cancel.
 *   - Keyboard shortcut inside the modal: Ctrl/Cmd + Enter inserts directly.
 *
 * Author: Pablo (@kapela2017)
 */


module.exports = async (params) =&gt; {
  const obsidian = params.obsidian || require("obsidian");
  const { Modal, Notice, MarkdownView } = obsidian;
  const app = params.app;


  const TYPES = [
    "note", "abstract", "summary", "tldr", "info", "todo",
    "tip", "hint", "important", "success", "check", "done",
    "question", "help", "faq", "warning", "caution", "attention",
    "failure", "fail", "missing", "danger", "error", "bug",
    "example", "quote", "cite",
  ];


  const STYLE_ID = "cf-styles";


  const CSS = `
    .cf-modal-wrapper {
      width: 680px;
      max-width: 92vw;
    }


    .cf-field {
      box-sizing: border-box;
      width: 100%;
      padding: 10px 14px;
      background: var(--background-secondary);
      color: var(--text-normal);
      border: 1.5px solid var(--background-modifier-border);
      border-radius: 10px;
      font-size: 15px;
      font-family: inherit;
      transition: border-color .15s ease, box-shadow .15s ease;
    }
    .cf-field:focus {
      outline: none;
      border-color: var(--interactive-accent);
      box-shadow: 0 0 0 3px color-mix(in srgb, var(--interactive-accent) 25%, transparent);
    }


    select.cf-field {
      height: 44px;
      line-height: 1.6;
      padding: 0 36px 0 14px;
      appearance: none;
      -webkit-appearance: none;
      background-image:
        linear-gradient(45deg,  transparent 50%, var(--text-muted) 50%),
        linear-gradient(135deg, var(--text-muted) 50%, transparent 50%);
      background-position: calc(100% - 18px) 52%, calc(100% - 13px) 52%;
      background-size: 5px 5px, 5px 5px;
      background-repeat: no-repeat;
      cursor: pointer;
    }
    input.cf-field { height: 44px; line-height: 1.5; }
    textarea.cf-field {
      line-height: 1.55;
      resize: vertical;
      min-height: 140px;
      font-family: var(--font-monospace, monospace);
    }


    .cf-label {
      display: block;
      margin: 18px 0 6px;
      font-size: 12px;
      font-weight: 600;
      color: var(--text-muted);
      letter-spacing: .5px;
      text-transform: uppercase;
    }
    .cf-label .cf-hint {
      text-transform: none;
      font-weight: 400;
      color: var(--text-faint);
    }


    .cf-preview {
      background: var(--background-secondary);
      padding: 14px 16px;
      border-radius: 12px;
      max-height: 220px;
      overflow: auto;
      white-space: pre-wrap;
      font-family: var(--font-monospace, monospace);
      font-size: 14px;
      line-height: 1.55;
      margin: 0;
      border: 1.5px solid var(--background-modifier-border);
    }


    .cf-title { margin: 0 0 4px; font-size: 22px; font-weight: 700; }
    .cf-subtitle { margin: 0 0 8px; color: var(--text-muted); font-size: 14px; }


    .cf-actions {
      display: flex;
      gap: 10px;
      margin-top: 22px;
      justify-content: flex-end;
      align-items: center;
    }
    .cf-status {
      margin-right: auto;
      font-size: 13px;
      color: var(--text-muted);
      min-height: 18px;
    }
    .cf-btn {
      padding: 10px 20px;
      font-size: 14px;
      font-weight: 600;
      border: none;
      border-radius: 10px;
      cursor: pointer;
      font-family: inherit;
      transition: transform .08s ease, background .15s ease, box-shadow .15s ease, filter .15s ease;
    }
    .cf-btn:hover { transform: translateY(-1px); }
    .cf-btn:active { transform: translateY(0); }
    .cf-btn-primary {
      background: var(--interactive-accent);
      color: var(--text-on-accent);
    }
    .cf-btn-primary:hover {
      background: var(--interactive-accent-hover);
      box-shadow: 0 4px 12px color-mix(in srgb, var(--interactive-accent) 35%, transparent);
    }
    .cf-btn-secondary {
      background: var(--background-modifier-border);
      color: var(--text-normal);
    }
    .cf-btn-secondary:hover { filter: brightness(1.15); }
  `;


  function injectStyles() {
    if (document.getElementById(STYLE_ID)) return;
    const style = document.createElement("style");
    style.id = STYLE_ID;
    style.textContent = CSS;
    document.head.appendChild(style);
  }


  function removeStyles() {
    document.getElementById(STYLE_ID)?.remove();
  }


  class CalloutGeneratorModal extends Modal {
    constructor(app, onSubmit) {
      super(app);
      this.state = {
        type: "note",
        foldable: "",
        title: "",
        content: "",
        result: "",
      };
      this.onSubmit = onSubmit;
      this.submitted = false;
    }


    buildCallout() {
      const lines = this.state.content
        .split("\n")
        .map((l) =&gt; l.trim())
        .filter((l) =&gt; l.length &gt; 0);


      let header = `&gt; [!${this.state.type}]${this.state.foldable}`;
      if (this.state.title.trim()) header += ` ${this.state.title.trim()}`;


      if (lines.length === 0) return header;
      return header + "\n" + lines.map((l) =&gt; `&gt; ${l}`).join("\n");
    }


    onOpen() {
      injectStyles();


      const { contentEl, modalEl } = this;
      modalEl.classList.add("cf-modal-wrapper");
      contentEl.empty();


      contentEl.innerHTML = `
        &lt;h2 class="cf-title"&gt;Callout Generator&lt;/h2&gt;
        &lt;p class="cf-subtitle"&gt;Configure, paste your content, and choose how to use it.&lt;/p&gt;


        &lt;label class="cf-label"&gt;Callout type&lt;/label&gt;
        &lt;select id="cf-type" class="cf-field"&gt;
          ${TYPES.map((t) =&gt; `&lt;option value="${t}"&gt;${t}&lt;/option&gt;`).join("")}
        &lt;/select&gt;


        &lt;label class="cf-label"&gt;Behavior&lt;/label&gt;
        &lt;select id="cf-foldable" class="cf-field"&gt;
          &lt;option value=""&gt;Non-foldable&lt;/option&gt;
          &lt;option value="+"&gt;Foldable (open)&lt;/option&gt;
          &lt;option value="-"&gt;Foldable (collapsed)&lt;/option&gt;
        &lt;/select&gt;


        &lt;label class="cf-label"&gt;Title &lt;span class="cf-hint"&gt;(optional)&lt;/span&gt;&lt;/label&gt;
        &lt;input id="cf-title" class="cf-field" type="text" placeholder="e.g., Important reminder"&gt;


        &lt;label class="cf-label"&gt;Content &lt;span class="cf-hint"&gt;— paste raw text&lt;/span&gt;&lt;/label&gt;
        &lt;textarea id="cf-content" rows="8" class="cf-field"
          placeholder="Paste here. Blank lines are removed automatically."&gt;&lt;/textarea&gt;


        &lt;label class="cf-label"&gt;Preview&lt;/label&gt;
        &lt;pre id="cf-preview" class="cf-preview"&gt;&lt;/pre&gt;


        &lt;div class="cf-actions"&gt;
          &lt;span id="cf-status" class="cf-status"&gt;&lt;/span&gt;
          &lt;button id="cf-cancel" class="cf-btn cf-btn-secondary"&gt;Cancel&lt;/button&gt;
          &lt;button id="cf-copy"   class="cf-btn cf-btn-secondary"&gt;Copy&lt;/button&gt;
          &lt;button id="cf-insert" class="cf-btn cf-btn-primary"&gt;Insert into note&lt;/button&gt;
        &lt;/div&gt;
      `;


      const $ = (sel) =&gt; contentEl.querySelector(sel);
      const $type = $("#cf-type");
      const $foldable = $("#cf-foldable");
      const $title = $("#cf-title");
      const $content = $("#cf-content");
      const $preview = $("#cf-preview");
      const $copy = $("#cf-copy");
      const $insert = $("#cf-insert");
      const $cancel = $("#cf-cancel");
      const $status = $("#cf-status");


      const refresh = () =&gt; {
        this.state.result = this.buildCallout();
        $preview.textContent = this.state.result;
      };


      $type.addEventListener("change", (e) =&gt; {
        this.state.type = e.target.value;
        refresh();
      });
      $foldable.addEventListener("change", (e) =&gt; {
        this.state.foldable = e.target.value;
        refresh();
      });
      $title.addEventListener("input", (e) =&gt; {
        this.state.title = e.target.value;
        refresh();
      });
      $content.addEventListener("input", (e) =&gt; {
        this.state.content = e.target.value;
        refresh();
      });


      $copy.addEventListener("click", async () =&gt; {
        try {
          await navigator.clipboard.writeText(this.state.result);
          $status.textContent = "Copied to clipboard";
          new Notice("Callout copied");
          setTimeout(() =&gt; ($status.textContent = ""), 2500);
        } catch (err) {
          $status.textContent = "Error: " + err.message;
        }
      });


      $insert.addEventListener("click", () =&gt; {
        this.submitted = true;
        this.close();
      });


      $cancel.addEventListener("click", () =&gt; {
        this.submitted = false;
        this.close();
      });


      // Shortcut: Ctrl/Cmd + Enter = Insert
      contentEl.addEventListener("keydown", (e) =&gt; {
        if ((e.ctrlKey || e.metaKey) &amp;&amp; e.key === "Enter") {
          e.preventDefault();
          this.submitted = true;
          this.close();
        }
      });


      refresh();


      // Initial focus on title
      setTimeout(() =&gt; $title.focus(), 50);
    }


    onClose() {
      this.contentEl.empty();
      removeStyles();
      if (this.onSubmit) {
        this.onSubmit(this.submitted ? this.state.result : null);
      }
    }
  }


  // Open the modal and wait for it to close
  const result = await new Promise((resolve) =&gt; {
    new CalloutGeneratorModal(app, resolve).open();
  });


  // If the user clicked "Insert", place the callout into the active note
  if (result) {
    const view = app.workspace.getActiveViewOfType(MarkdownView);
    if (view &amp;&amp; view.editor) {
      view.editor.replaceSelection(result + "\n");
      new Notice("Callout inserted");
    } else {
      // Fallback when no editor is active
      await navigator.clipboard.writeText(result);
      new Notice("No active editor — copied to clipboard");
    }
    return result; // available as {{value}} if chained with Capture/Format
  }


  return "";
};

```

Hope it's useful to someone.

u/Awkward-Secretary726 — 7 days ago