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
**Install QuickAdd.** Open Settings → Community plugins → Browse → search for "QuickAdd" → Install → Enable.
**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.
**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`.
**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`).
**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.
**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.
**(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 -> QuickAdd -> set "User Scripts Folder" to that folder.
* 3. Settings -> QuickAdd -> Manage Macros -> create a Macro (e.g. "Callout Generator").
* 4. Inside the Macro: "User Scripts" -> add `callout-generator.js`.
* 5. Back on QuickAdd settings, add a Choice of type "Macro" that runs that Macro.
* 6. (Optional) Settings -> Hotkeys -> search "QuickAdd: <your choice name>" 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 "> " 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) => {
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) => l.trim())
.filter((l) => l.length > 0);
let header = `> [!${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) => `> ${l}`).join("\n");
}
onOpen() {
injectStyles();
const { contentEl, modalEl } = this;
modalEl.classList.add("cf-modal-wrapper");
contentEl.empty();
contentEl.innerHTML = `
<h2 class="cf-title">Callout Generator</h2>
<p class="cf-subtitle">Configure, paste your content, and choose how to use it.</p>
<label class="cf-label">Callout type</label>
<select id="cf-type" class="cf-field">
${TYPES.map((t) => `<option value="${t}">${t}</option>`).join("")}
</select>
<label class="cf-label">Behavior</label>
<select id="cf-foldable" class="cf-field">
<option value="">Non-foldable</option>
<option value="+">Foldable (open)</option>
<option value="-">Foldable (collapsed)</option>
</select>
<label class="cf-label">Title <span class="cf-hint">(optional)</span></label>
<input id="cf-title" class="cf-field" type="text" placeholder="e.g., Important reminder">
<label class="cf-label">Content <span class="cf-hint">— paste raw text</span></label>
<textarea id="cf-content" rows="8" class="cf-field"
placeholder="Paste here. Blank lines are removed automatically."></textarea>
<label class="cf-label">Preview</label>
<pre id="cf-preview" class="cf-preview"></pre>
<div class="cf-actions">
<span id="cf-status" class="cf-status"></span>
<button id="cf-cancel" class="cf-btn cf-btn-secondary">Cancel</button>
<button id="cf-copy" class="cf-btn cf-btn-secondary">Copy</button>
<button id="cf-insert" class="cf-btn cf-btn-primary">Insert into note</button>
</div>
`;
const $ = (sel) => 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 = () => {
this.state.result = this.buildCallout();
$preview.textContent = this.state.result;
};
$type.addEventListener("change", (e) => {
this.state.type = e.target.value;
refresh();
});
$foldable.addEventListener("change", (e) => {
this.state.foldable = e.target.value;
refresh();
});
$title.addEventListener("input", (e) => {
this.state.title = e.target.value;
refresh();
});
$content.addEventListener("input", (e) => {
this.state.content = e.target.value;
refresh();
});
$copy.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(this.state.result);
$status.textContent = "Copied to clipboard";
new Notice("Callout copied");
setTimeout(() => ($status.textContent = ""), 2500);
} catch (err) {
$status.textContent = "Error: " + err.message;
}
});
$insert.addEventListener("click", () => {
this.submitted = true;
this.close();
});
$cancel.addEventListener("click", () => {
this.submitted = false;
this.close();
});
// Shortcut: Ctrl/Cmd + Enter = Insert
contentEl.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
this.submitted = true;
this.close();
}
});
refresh();
// Initial focus on title
setTimeout(() => $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) => {
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 && 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.