docs/content/.obsidian/plugins/callout-manager/main.js
2025-03-07 23:35:24 +00:00

4529 lines
136 KiB
JavaScript

/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
"use strict"
var __defProp = Object.defineProperty
var __getOwnPropDesc = Object.getOwnPropertyDescriptor
var __getOwnPropNames = Object.getOwnPropertyNames
var __hasOwnProp = Object.prototype.hasOwnProperty
var __export = (target, all) => {
for (var name in all) __defProp(target, name, { get: all[name], enumerable: true })
}
var __copyProps = (to, from, except, desc) => {
if ((from && typeof from === "object") || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, {
get: () => from[key],
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable,
})
}
return to
}
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod)
// src/main.ts
var main_exports = {}
__export(main_exports, {
default: () => CalloutManagerPlugin,
})
module.exports = __toCommonJS(main_exports)
var import_obsidian25 = require("obsidian")
// node_modules/obsidian-extra/dist/esm/functions/getFloatingWindows.js
function getFloatingWindows(app2) {
var _a, _b, _c, _d
return (_d =
(_c =
(_b =
(_a = app2 === null || app2 === void 0 ? void 0 : app2.workspace) === null || _a === void 0
? void 0
: _a.floatingSplit) === null || _b === void 0
? void 0
: _b.children) === null || _c === void 0
? void 0
: _c.map((split) => split.win)) !== null && _d !== void 0
? _d
: []
}
// node_modules/obsidian-extra/dist/esm/functions/getCurrentThemeID.js
function getCurrentThemeID(app2) {
const theme = app2.customCss.theme
return theme === "" ? null : theme
}
// node_modules/obsidian-extra/dist/esm/functions/getCurrentColorScheme.js
function getCurrentThemeID2(app2) {
const { body } = app2.workspace.containerEl.doc
return body.classList.contains("theme-dark") ? "dark" : "light"
}
// node_modules/obsidian-extra/dist/esm/functions/getThemeManifest.js
function getThemeManifest(app2, themeID) {
const manifests = app2.customCss.themes
if (!Object.prototype.hasOwnProperty.call(manifests, themeID)) {
return null
}
return manifests[themeID]
}
// node_modules/obsidian-extra/dist/esm/functions/isThemeInstalled.js
function isThemeInstalled(app2, themeID) {
return getThemeManifest(app2, themeID) !== null
}
// node_modules/obsidian-extra/dist/esm/functions/getThemeStyleElement.js
function getThemeStyleElement(app2) {
const currentTheme = getCurrentThemeID(app2)
if (currentTheme == null || !isThemeInstalled(app2, currentTheme)) {
return null
}
return app2.customCss.styleEl
}
// node_modules/obsidian-extra/dist/esm/functions/isSnippetEnabled.js
function isSnippetEnabled(app2, snippetID) {
return app2.customCss.enabledSnippets.has(snippetID)
}
// node_modules/obsidian-extra/dist/esm/functions/fetchObsidianStyleSheet.js
var import_obsidian = require("obsidian")
// node_modules/obsidian-extra/dist/esm/internal/utils/versionCompare.js
function versionCompare(a, b) {
const aParts = a.split(".").map((n) => parseInt(n, 10))
const bParts = b.split(".").map((n) => parseInt(n, 10))
const partsSize = Math.max(aParts.length, bParts.length)
arrayPadEnd(aParts, partsSize, 0)
arrayPadEnd(bParts, partsSize, 0)
for (let i = 0; i < partsSize; i++) {
if (aParts[i] < bParts[i]) return -1
if (aParts[i] > bParts[i]) return 1
}
return 0
}
function arrayPadEnd(arr, length, fill) {
const missing = length - arr.length
if (missing > 0) {
arr.push(...new Array(missing).fill(fill))
}
}
// node_modules/obsidian-extra/dist/esm/functions/fetchObsidianStyleSheet.js
var __awaiter = function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value)
})
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value))
} catch (e) {
reject(e)
}
}
function rejected(value) {
try {
step(generator["throw"](value))
} catch (e) {
reject(e)
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected)
}
step((generator = generator.apply(thisArg, _arguments || [])).next())
})
}
function fetchObsidianStyleSheet(app2) {
return __awaiter(this, void 0, void 0, function* () {
let errors = []
const orElse = (cb) => (ex) => {
errors.push(ex)
return cb()
}
const result = yield viaElectron("app.css")
.catch(orElse(() => viaFetch("app.css")))
.catch(orElse(() => viaDom("app.css")))
result._errors = errors
return result
})
}
function viaFetch(path) {
return __awaiter(this, void 0, void 0, function* () {
if (import_obsidian.Platform.isDesktopApp) {
throw new Error("Obsidian styles via fetch() does not work under Electron.")
}
return fetch(`/${path}`)
.then((r) => r.text())
.then((t) => ({
method: "fetch",
cssText: t,
}))
})
}
function viaElectron(path) {
var _a
return __awaiter(this, void 0, void 0, function* () {
if (versionCompare(import_obsidian.apiVersion, "1.1.16") > 0) {
throw new Error(
`Obsidian ${import_obsidian.apiVersion} has not been tested with this function`,
)
}
const require2 = globalThis.require
const electron =
(_a = globalThis.electron) !== null && _a !== void 0
? _a
: require2 === null || require2 === void 0
? void 0
: require2("electron")
if (electron == null) {
throw new Error("Unable to get electron module from web renderer process")
}
const fs = require2 === null || require2 === void 0 ? void 0 : require2("fs/promises")
if ((fs === null || fs === void 0 ? void 0 : fs.readFile) == null) {
throw new Error("Unable to get fs/promises module from web renderer process")
}
const resources = electron.ipcRenderer.sendSync("resources")
return fs.readFile(`${resources}/${path}`, "utf8").then((t) => ({
method: "electron",
cssText: t,
}))
})
}
function viaDom(path) {
let found = false
const lines = []
for (const styleSheet of Array.from(document.styleSheets)) {
if (!(styleSheet.ownerNode instanceof HTMLLinkElement)) continue
const href = styleSheet.ownerNode.getAttribute("href")
if (href !== path && href !== `/${path}`) continue
found = true
for (const rule of Array.from(styleSheet.cssRules)) {
lines.push(rule.cssText)
}
}
if (!found) {
throw new Error("Unable to find <link> element for Obsidian's stylesheet")
}
return {
method: "dom",
cssText: lines.join("\n"),
}
}
// node_modules/obsidian-extra/dist/esm/functions/getInstalledSnippetIDs.js
function getInstalledSnippetIDs(app2) {
return app2.customCss.snippets
}
// node_modules/obsidian-extra/dist/esm/functions/getSnippetStyleElements.js
function getSnippetStyleElements(app2) {
const styleManager = app2.customCss
const snippets = getInstalledSnippetIDs(app2)
const map = /* @__PURE__ */ new Map()
for (let i = 0, elI = 0; i < snippets.length; i++) {
const snippetID = styleManager.snippets[i]
if (isSnippetEnabled(app2, snippetID)) {
map.set(snippetID, styleManager.extraStyleEls[elI])
elI++
}
}
return map
}
// node_modules/obsidian-extra/dist/esm/functions/openPluginSettings.js
function openPluginSettings(app2, plugin) {
var _a, _b
const settingManager = app2.setting
const pluginId = typeof plugin === "string" ? plugin : plugin.manifest.id
if (((_a = settingManager.activeTab) === null || _a === void 0 ? void 0 : _a.id) !== pluginId) {
settingManager.openTabById("")
}
settingManager.open()
if (((_b = settingManager.activeTab) === null || _b === void 0 ? void 0 : _b.id) !== pluginId) {
settingManager.openTabById(pluginId)
}
}
// node_modules/obsidian-extra/dist/esm/functions/createCustomStyleSheet.js
var Counter = Symbol("CustomStylesheet count")
function foreachWindow(app2, fn) {
fn(app2.workspace.containerEl.doc, true)
for (const float of getFloatingWindows(app2)) {
fn(float.document, false)
}
}
function createCustomStyleSheet(app2, plugin) {
var _a
let result
const pl = plugin
const plId = plugin.manifest.id
const ssId = ((_a = pl[Counter]) !== null && _a !== void 0 ? _a : (pl[Counter] = 0)).toString()
pl[Counter]++
const styleEl = app2.workspace.containerEl.doc.createElement("style")
const styleElInFloats = []
styleEl.setAttribute("data-source-plugin", plId)
styleEl.setAttribute("data-source-id", ssId)
function unapply() {
styleElInFloats.splice(0, styleElInFloats.length).forEach((el) => el.remove())
styleEl.detach()
foreachWindow(app2, (doc) => {
for (const styleEl2 of Array.from(doc.head.querySelectorAll("style"))) {
if (result.is(styleEl2)) {
styleEl2.remove()
}
}
})
}
function reapply() {
unapply()
foreachWindow(app2, (doc, isFloating) => {
let lastEl = doc.head.lastElementChild
for (let el = lastEl; el != null; el = el.previousElementSibling) {
lastEl = el
if (lastEl.tagName === "STYLE") {
break
}
}
if (!isFloating) {
lastEl === null || lastEl === void 0
? void 0
: lastEl.insertAdjacentElement("afterend", styleEl)
return
}
const styleElClone = styleEl.cloneNode(true)
styleElInFloats.push(styleElClone)
lastEl === null || lastEl === void 0
? void 0
: lastEl.insertAdjacentElement("afterend", styleElClone)
})
}
app2.workspace.on("css-change", reapply)
app2.workspace.on("layout-change", reapply)
result = Object.freeze(
Object.defineProperties(
() => {
unapply()
app2.workspace.off("css-change", reapply)
app2.workspace.off("layout-change", reapply)
},
{
css: {
enumerable: true,
configurable: false,
get() {
return styleEl.textContent
},
set(v) {
styleEl.textContent = v
for (const styleEl2 of styleElInFloats) {
styleEl2.textContent = v
}
},
},
is: {
enumerable: false,
configurable: false,
value: (el) => {
return (
el.getAttribute("data-source-plugin") === plId &&
el.getAttribute("data-source-id") === ssId
)
},
},
setAttribute: {
enumerable: false,
configurable: false,
value: (attr, value) => {
if (attr === "data-source-id" || attr === "data-source-plugin") {
throw new Error(`Cannot change attribute '${attr}' on custom style sheet.`)
}
styleEl.setAttribute(attr, value)
for (const styleEl2 of styleElInFloats) {
styleEl2.setAttribute(attr, value)
}
},
},
removeAttribute: {
enumerable: false,
configurable: false,
value: (attr) => {
if (attr === "data-source-id" || attr === "data-source-plugin") {
throw new Error(`Cannot remove attribute '${attr}' from custom style sheet.`)
}
styleEl.removeAttribute(attr)
for (const styleEl2 of styleElInFloats) {
styleEl2.removeAttribute(attr)
}
},
},
},
),
)
reapply()
return result
}
// node_modules/obsidian-extra/dist/esm/functions/detectPlatformBrowser.js
var import_obsidian2 = require("obsidian")
// node_modules/obsidian-extra/dist/esm/functions/detectPlatformRuntime.js
var import_obsidian3 = require("obsidian")
// node_modules/obsidian-extra/dist/esm/functions/detectPlatformOperatingSystem.js
var import_obsidian4 = require("obsidian")
// src/ui/paned-setting-tab.ts
var import_obsidian6 = require("obsidian")
// node_modules/obsidian-extra/dist/esm/functions/closeSettings.js
function closeSettings(app2) {
const settingManager = app2.setting
settingManager.close()
}
// src/ui/pane-layers.ts
var import_obsidian5 = require("obsidian")
var UIPaneLayers = class {
constructor(options) {
this.layers = []
this.closeParent = options.close
this.navInstance = {
open: (pane) => this.push(pane),
close: () => this.pop(),
replace: (pane) => (this.top = pane),
}
}
/**
* Pushes a new pane on top of the stack.
* The active pane will be suspended.
*
* @param pane The pane to push.
*/
push(pane) {
const { activePane: oldPane } = this
if (oldPane !== void 0) {
const title = oldPane.title
this.layers.push({
scroll: { top: this.scrollEl.scrollTop, left: this.scrollEl.scrollLeft },
state: oldPane.suspendState(),
pane: oldPane,
title: typeof title === "string" ? title : title.subtitle,
})
this.setPaneVariables(oldPane, false)
this.containerEl.empty()
}
const newPane = (this.activePane = pane)
this.setPaneVariables(newPane, true)
newPane.onReady()
this.doDisplay(true)
this.scrollEl.scrollTo({ top: 0, left: 0 })
}
/**
* Pops the active pane off the stack.
* The active pane will be destroyed, and the one underneath it will be restored.
*
* @param pane The pane to push.
*/
pop(options) {
if (this.activePane === void 0) {
this.closeParent()
return void 0
}
const noDisplay = options?.noDisplay ?? false
const oldPane = this.activePane
const newPane = this.layers.pop()
this.activePane = void 0
this.setPaneVariables(oldPane, false)
oldPane.onClose(options?.cancelled ?? false)
if (!noDisplay) {
this.containerEl.empty()
}
if (newPane !== void 0) {
this.activePane = newPane.pane
this.setPaneVariables(newPane.pane, true)
newPane.pane.restoreState(newPane.state)
if (!noDisplay) {
this.doDisplay(true)
this.scrollEl.scrollTo(newPane.scroll)
}
}
return oldPane
}
/**
* Removes all panes off the stack.
* All panes will be destroyed.
*
* @param pane The pane to push.
*/
clear(options) {
const removed = []
const opts = {
noDisplay: true,
...(options ?? {}),
}
while (this.activePane !== void 0) {
removed.push(this.pop(opts))
}
return removed
}
/**
* The top-most (i.e. currently active) pane in the layers.
*/
get top() {
return this.activePane
}
set top(pane) {
const { activePane: oldTop } = this
if (oldTop !== void 0) {
this.setPaneVariables(oldTop, false)
oldTop.onClose(false)
}
const newPane = (this.activePane = pane)
this.setPaneVariables(newPane, true)
newPane.onReady()
this.doDisplay(true)
}
doDisplay(renderControls) {
const { activePane, titleEl, navEl, containerEl } = this
if (activePane === void 0) {
return
}
navEl.empty()
if (this.layers.length > 0) {
new import_obsidian5.ButtonComponent(this.navEl)
.setIcon("lucide-arrow-left-circle")
.setClass("clickable-icon")
.setTooltip(`Back to ${this.layers[this.layers.length - 1].title}`)
.onClick(() => this.navInstance.close())
}
titleEl.empty()
const { title } = activePane
if (typeof title === "string") {
titleEl.createEl("h2", { text: title })
} else {
titleEl.createEl("h2", { text: title.title })
titleEl.createEl("h3", { text: title.subtitle })
}
if (renderControls) {
this.controlsEl.empty()
activePane.displayControls()
}
containerEl.empty()
activePane.display()
}
setPaneVariables(pane, attached) {
const notAttachedError = () => {
throw new Error("Not attached")
}
Object.defineProperties(pane, {
nav: {
configurable: true,
enumerable: true,
get: attached ? () => this.navInstance : notAttachedError,
},
containerEl: {
configurable: true,
enumerable: true,
get: attached ? () => this.containerEl : notAttachedError,
},
controlsEl: {
configurable: true,
enumerable: true,
get: attached ? () => this.controlsEl : notAttachedError,
},
})
}
}
// src/ui/paned-setting-tab.ts
var UISettingTab = class extends import_obsidian6.PluginSettingTab {
constructor(plugin, createDefault) {
super(plugin.app, plugin)
this.plugin = plugin
this.createDefault = createDefault
this.initLayer = null
this.layers = new UIPaneLayers({
close: () => closeSettings(this.app),
})
}
openWithPane(pane) {
this.initLayer = pane
openPluginSettings(this.plugin.app, this.plugin)
}
/** @override */
hide() {
this.initLayer = null
this.layers.clear()
super.hide()
}
display() {
const { containerEl, layers } = this
containerEl.empty()
containerEl.classList.add("calloutmanager-setting-tab", "calloutmanager-pane")
const headerEl = containerEl.createDiv({ cls: "calloutmanager-setting-tab-header" })
layers.navEl = headerEl.createDiv({ cls: "calloutmanager-setting-tab-nav" })
layers.titleEl = headerEl.createDiv({ cls: "calloutmanager-setting-tab-title" })
const controlsEl = headerEl.createDiv({ cls: "calloutmanager-setting-tab-controls" })
layers.controlsEl = controlsEl.createDiv()
layers.scrollEl = containerEl.createDiv({
cls: "calloutmanager-setting-tab-viewport vertical-tab-content",
})
layers.containerEl = layers.scrollEl.createDiv({ cls: "calloutmanager-setting-tab-content" })
controlsEl.createDiv({ cls: "modal-close-button" }, (closeButtonEl) => {
closeButtonEl.addEventListener("click", (ev) => {
if (!ev.isTrusted) return
closeSettings(this.app)
})
})
layers.clear()
const initLayer = this.initLayer ?? this.createDefault()
this.initLayer = null
layers.top = initLayer
}
}
// src/api-common.ts
var emitter = Symbol("emitter")
var destroy = Symbol("destroy")
// src/api-v1.ts
var import_obsidian7 = require("obsidian")
// src/util/color.ts
function toHSV(color) {
if ("h" in color && "s" in color && "v" in color) return color
const rFloat = color.r / 255
const gFloat = color.g / 255
const bFloat = color.b / 255
const cmax = Math.max(rFloat, gFloat, bFloat)
const cmin = Math.min(rFloat, gFloat, bFloat)
const delta = cmax - cmin
let h = 0
if (cmax !== cmin) {
switch (cmax) {
case rFloat:
h = (60 * ((gFloat - bFloat) / delta) + 360) % 360
break
case gFloat:
h = (60 * ((bFloat - rFloat) / delta) + 120) % 360
break
case bFloat:
h = (60 * ((rFloat - gFloat) / delta) + 240) % 360
break
}
}
const s = cmax === 0 ? 0 : (delta / cmax) * 100
const v = cmax * 100
const hsv = { h, s, v }
if ("a" in color) {
hsv.a = (color.a / 255) * 100
}
return hsv
}
function toHexRGB(color) {
const parts = [color.r, color.g, color.b, ...("a" in color ? [color.a] : [])]
return parts.map((c) => c.toString(16).padStart(2, "0")).join("")
}
var REGEX_RGB = /^\s*rgba?\(\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?\s*)\)\s*$/i
function parseColorRGB(rgb) {
const matches2 = REGEX_RGB.exec(rgb)
if (matches2 === null) return null
const components = matches2.slice(1).map((v) => v.trim())
const rgbComponents = rgbComponentStringsToNumber(components)
if (rgbComponents === null) {
return null
}
if (void 0 !== rgbComponents.find((v) => isNaN(v) || v < 0 || v > 255)) {
return null
}
return {
r: rgbComponents[0],
g: rgbComponents[1],
b: rgbComponents[2],
}
}
function rgbComponentStringsToNumber(components) {
if (components[0].endsWith("%")) {
if (void 0 !== components.slice(1, 3).find((c) => !c.endsWith("%"))) {
return null
}
return components
.map((v) => parseFloat(v.substring(0, v.length - 1)))
.map((v) => Math.floor((v * 255) / 100))
}
if (void 0 !== components.slice(1, 3).find((c) => c.endsWith("%"))) {
return null
}
return components.map((v) => parseInt(v, 10))
}
// src/callout-util.ts
function getColorFromCallout(callout) {
return parseColorRGB(`rgb(${callout.color})`)
}
function getTitleFromCallout(callout) {
const matches2 = /^(.)(.*)/u.exec(callout.id)
if (matches2 == null) return callout.id
const firstChar = matches2[1].toLocaleUpperCase()
const remainingChars = matches2[2].toLocaleLowerCase().replace(/-+/g, " ")
return `${firstChar}${remainingChars}`
}
// src/api-v1.ts
var CalloutManagerAPI_V1 = class {
constructor(plugin, consumer) {
this.plugin = plugin
this.consumer = consumer
this[emitter] = new import_obsidian7.Events()
if (consumer != null) {
console.debug("Created API V1 Handle:", { plugin: consumer.manifest.id })
}
}
/**
* Called to destroy an API handle bound to a consumer.
*/
[(emitter, destroy)]() {
const consumer = this.consumer
console.debug("Destroyed API V1 Handle:", { plugin: consumer.manifest.id })
}
/** @override */
getCallouts() {
return this.plugin.callouts.values().map((callout) => Object.freeze({ ...callout }))
}
/** @override */
getColor(callout) {
const color = getColorFromCallout(callout)
return color ?? { invalid: callout.color }
}
/** @override */
getTitle(callout) {
return getTitleFromCallout(callout)
}
/** @override */
on(event, listener) {
if (this.consumer == null) {
throw new Error("Cannot listen for events without an API consumer.")
}
this[emitter].on(event, listener)
}
/** @override */
off(event, listener) {
if (this.consumer == null) {
throw new Error("Cannot listen for events without an API consumer.")
}
this[emitter].off(event, listener)
}
}
// src/apis.ts
var CalloutManagerAPIs = class {
constructor(plugin) {
this.plugin = plugin
this.handles = /* @__PURE__ */ new Map()
}
/**
* Creates (or gets) an instance of the Callout Manager API for a plugin.
* If the plugin is undefined, only trivial functions are available.
*
* @param version The API version.
* @param consumerPlugin The plugin using the API.
*
* @internal
*/
async newHandle(version, consumerPlugin, cleanupFunc) {
if (version !== "v1") throw new Error(`Unsupported Callout Manager API: ${version}`)
if (consumerPlugin == null) {
return new CalloutManagerAPI_V1(this.plugin, void 0)
}
const existing = this.handles.get(consumerPlugin)
if (existing != null) {
return existing
}
consumerPlugin.register(cleanupFunc)
const handle = new CalloutManagerAPI_V1(this.plugin, consumerPlugin)
this.handles.set(consumerPlugin, handle)
return handle
}
/**
* Destroys an API handle created by {@link newHandle}.
*
* @param version The API version.
* @param consumerPlugin The plugin using the API.
*
* @internal
*/
destroyHandle(version, consumerPlugin) {
if (version !== "v1") throw new Error(`Unsupported Callout Manager API: ${version}`)
const handle = this.handles.get(consumerPlugin)
if (handle == null) return
handle[destroy]()
this.handles.delete(consumerPlugin)
}
emitEventForCalloutChange(id) {
for (const handle of this.handles.values()) {
handle[emitter].trigger("change")
}
}
}
// src/callout-collection.ts
var CalloutCollection = class {
constructor(resolver) {
this.resolver = resolver
this.invalidated = /* @__PURE__ */ new Set()
this.invalidationCount = 0
this.cacheById = /* @__PURE__ */ new Map()
this.cached = false
this.snippets = new CalloutCollectionSnippets(this.invalidateSource.bind(this))
this.builtin = new CalloutCollectionObsidian(this.invalidateSource.bind(this))
this.theme = new CalloutCollectionTheme(this.invalidateSource.bind(this))
this.custom = new CalloutCollectionCustom(this.invalidateSource.bind(this))
}
get(id) {
if (!this.cached) this.buildCache()
const cached = this.cacheById.get(id)
if (cached === void 0) {
return void 0
}
if (this.invalidated.has(cached)) {
this.resolveOne(cached)
}
return cached.callout
}
/**
* Checks if a callout with this ID is in the collection.
* @param id The callout ID.
* @returns True if the callout is in the collection.
*/
has(id) {
if (!this.cached) this.buildCache()
return this.cacheById.has(id)
}
/**
* Gets all the known {@link CalloutID callout IDs}.
* @returns The callout IDs.
*/
keys() {
if (!this.cached) this.buildCache()
return Array.from(this.cacheById.keys())
}
/**
* Gets all the known {@link Callout callouts}.
* @returns The callouts.
*/
values() {
if (!this.cached) this.buildCache()
this.resolveAll()
return Array.from(this.cacheById.values()).map((c) => c.callout)
}
/**
* Returns a function that will return `true` if the collection has changed since the function was created.
* @returns The function.
*/
hasChanged() {
const countSnapshot = this.invalidationCount
return () => this.invalidationCount !== countSnapshot
}
/**
* Resolves the settings of a callout.
* This removes it from the set of invalidated callout caches.
*
* @param cached The callout's cache entry.
*/
resolveOne(cached) {
this.doResolve(cached)
this.invalidated.delete(cached)
}
/**
* Resolves the settings of all callouts.
*/
resolveAll() {
for (const cached of this.invalidated.values()) {
this.doResolve(cached)
}
this.invalidated.clear()
}
doResolve(cached) {
cached.callout = this.resolver(cached.id)
cached.callout.sources = Array.from(cached.sources.values()).map(sourceFromKey)
}
/**
* Builds the initial cache of callouts.
* This creates the cache entries and associates them to a source.
*/
buildCache() {
this.invalidated.clear()
this.cacheById.clear()
{
const source = sourceToKey({ type: "builtin" })
for (const callout of this.builtin.get()) {
this.addCalloutSource(callout, source)
}
}
if (this.theme.theme != null) {
const source = sourceToKey({ type: "theme", theme: this.theme.theme })
for (const callout of this.theme.get()) {
this.addCalloutSource(callout, source)
}
}
for (const snippet of this.snippets.keys()) {
const source = sourceToKey({ type: "snippet", snippet })
for (const callout of this.snippets.get(snippet)) {
this.addCalloutSource(callout, source)
}
}
{
const source = sourceToKey({ type: "custom" })
for (const callout of this.custom.keys()) {
this.addCalloutSource(callout, source)
}
}
this.cached = true
}
/**
* Marks a callout as invalidated.
* This forces the callout to be resolved again.
*
* @param id The callout ID.
*/
invalidate(id) {
if (!this.cached) return
const callout = this.cacheById.get(id)
if (callout !== void 0) {
console.debug("Invalided Callout Cache:", id)
this.invalidated.add(callout)
}
}
addCalloutSource(id, sourceKey) {
let callout = this.cacheById.get(id)
if (callout == null) {
callout = new CachedCallout(id)
this.cacheById.set(id, callout)
}
callout.sources.add(sourceKey)
this.invalidated.add(callout)
}
removeCalloutSource(id, sourceKey) {
const callout = this.cacheById.get(id)
if (callout == null) {
return
}
callout.sources.delete(sourceKey)
if (callout.sources.size === 0) {
this.cacheById.delete(id)
this.invalidated.delete(callout)
}
}
/**
* Called whenever a callout source has any changes.
* This will add or remove callouts from the cache, or invalidate a callout to mark it as requiring re-resolving.
*
* @param src The source that changed.
* @param data A diff of changes.
*/
invalidateSource(src, data) {
const sourceKey = sourceToKey(src)
if (!this.cached) {
return
}
for (const removed of data.removed) {
this.removeCalloutSource(removed, sourceKey)
}
for (const added of data.added) {
this.addCalloutSource(added, sourceKey)
}
for (const changed of data.changed) {
const callout = this.cacheById.get(changed)
if (callout != null) {
this.invalidated.add(callout)
}
}
this.invalidationCount++
}
}
var CachedCallout = class {
constructor(id) {
this.id = id
this.sources = /* @__PURE__ */ new Set()
this.callout = null
}
}
var CalloutCollectionSnippets = class {
constructor(invalidate) {
this.data = /* @__PURE__ */ new Map()
this.invalidate = invalidate
}
get(id) {
const value = this.data.get(id)
if (value === void 0) {
return void 0
}
return Array.from(value.values())
}
set(id, callouts) {
const source = { type: "snippet", snippet: id }
const old = this.data.get(id)
const updated = new Set(callouts)
this.data.set(id, updated)
if (old === void 0) {
this.invalidate(source, { added: callouts, changed: [], removed: [] })
return
}
const diffs = diff(old, updated)
this.invalidate(source, {
added: diffs.added,
removed: diffs.removed,
changed: diffs.same,
})
}
delete(id) {
const old = this.data.get(id)
const deleted = this.data.delete(id)
if (old !== void 0) {
this.invalidate(
{ type: "snippet", snippet: id },
{
added: [],
changed: [],
removed: Array.from(old.keys()),
},
)
}
return deleted
}
clear() {
for (const id of Array.from(this.data.keys())) {
this.delete(id)
}
}
keys() {
return Array.from(this.data.keys())
}
}
var CalloutCollectionObsidian = class {
constructor(invalidate) {
this.data = /* @__PURE__ */ new Set()
this.invalidate = invalidate
}
set(callouts) {
const old = this.data
const updated = (this.data = new Set(callouts))
const diffs = diff(old, updated)
this.invalidate(
{ type: "builtin" },
{
added: diffs.added,
removed: diffs.removed,
changed: diffs.same,
},
)
}
get() {
return Array.from(this.data.values())
}
}
var CalloutCollectionTheme = class {
constructor(invalidate) {
this.data = /* @__PURE__ */ new Set()
this.invalidate = invalidate
this.oldTheme = ""
}
get theme() {
return this.oldTheme
}
set(theme, callouts) {
const old = this.data
const oldTheme = this.oldTheme
const updated = (this.data = new Set(callouts))
this.oldTheme = theme
if (this.oldTheme === theme) {
const diffs = diff(old, updated)
this.invalidate(
{ type: "theme", theme },
{
added: diffs.added,
removed: diffs.removed,
changed: diffs.same,
},
)
return
}
this.invalidate(
{ type: "theme", theme: oldTheme ?? "" },
{
added: [],
removed: Array.from(old.values()),
changed: [],
},
)
this.invalidate(
{ type: "theme", theme },
{
added: callouts,
removed: [],
changed: [],
},
)
}
delete() {
const old = this.data
const oldTheme = this.oldTheme
this.data = /* @__PURE__ */ new Set()
this.oldTheme = null
this.invalidate(
{ type: "theme", theme: oldTheme ?? "" },
{
added: [],
removed: Array.from(old.values()),
changed: [],
},
)
}
get() {
return Array.from(this.data.values())
}
}
var CalloutCollectionCustom = class {
constructor(invalidate) {
this.data = []
this.invalidate = invalidate
}
has(id) {
return void 0 !== this.data.find((existingId) => existingId === id)
}
add(...ids) {
const set = new Set(this.data)
const added = []
for (const id of ids) {
if (!set.has(id)) {
added.push(id)
set.add(id)
this.data.push(id)
}
}
if (added.length > 0) {
this.invalidate({ type: "custom" }, { added, removed: [], changed: [] })
}
}
delete(...ids) {
const { data } = this
const removed = []
for (const id of ids) {
const index = data.findIndex((existingId) => id === existingId)
if (index !== void 0) {
data.splice(index, 1)
removed.push(id)
}
}
if (removed.length > 0) {
this.invalidate({ type: "custom" }, { added: [], removed, changed: [] })
}
}
keys() {
return this.data.slice(0)
}
clear() {
const removed = this.data
this.data = []
this.invalidate({ type: "custom" }, { added: [], removed, changed: [] })
}
}
function diff(before, after) {
const added = []
const removed = []
const same = []
for (const item of before.values()) {
;(after.has(item) ? same : removed).push(item)
}
for (const item of after.values()) {
if (!before.has(item)) {
added.push(item)
}
}
return { added, removed, same }
}
function sourceToKey(source) {
switch (source.type) {
case "builtin":
return "builtin"
case "snippet":
return `snippet:${source.snippet}`
case "theme":
return `theme:${source.theme}`
case "custom":
return `custom`
}
}
function sourceFromKey(sourceKey) {
if (sourceKey === "builtin") {
return { type: "builtin" }
}
if (sourceKey === "custom") {
return { type: "custom" }
}
if (sourceKey.startsWith("snippet:")) {
return { type: "snippet", snippet: sourceKey.substring("snippet:".length) }
}
if (sourceKey.startsWith("theme:")) {
return { type: "theme", theme: sourceKey.substring("theme:".length) }
}
throw new Error("Unknown source key: " + sourceKey)
}
// src/ui/component/callout-preview.ts
var import_obsidian8 = require("obsidian")
var NO_ATTACH = Symbol()
var CalloutPreviewComponent = class extends import_obsidian8.Component {
constructor(containerEl, options) {
super()
const { color, icon, id, title, content } = options
const frag = document.createDocumentFragment()
const calloutEl = (this.calloutEl = frag.createDiv({
cls: ["callout", "calloutmanager-preview"],
}))
const titleElContainer = calloutEl.createDiv({ cls: "callout-title" })
this.iconEl = titleElContainer.createDiv({ cls: "callout-icon" })
const titleEl = (this.titleEl = titleElContainer.createDiv({ cls: "callout-title-inner" }))
const contentEl = (this.contentEl =
content === void 0 ? void 0 : calloutEl.createDiv({ cls: "callout-content" }))
this.setIcon(icon)
this.setColor(color)
this.setCalloutID(id)
if (title == null) titleEl.textContent = id
else if (typeof title === "function") title(titleEl)
else if (typeof title === "string") titleEl.textContent = title
else titleEl.appendChild(title)
if (contentEl != null) {
if (typeof content === "function") content(contentEl)
else if (typeof content === "string") contentEl.textContent = content
else contentEl.appendChild(content)
}
if (containerEl != NO_ATTACH) {
CalloutPreviewComponent.prototype.attachTo.call(this, containerEl)
}
}
/**
* Changes the callout ID.
* This will *not* change the appearance of the preview.
*
* @param id The new ID to use.
*/
setCalloutID(id) {
const { calloutEl } = this
calloutEl.setAttribute("data-callout", id)
return this
}
/**
* Changes the callout icon.
*
* @param icon The ID of the new icon to use.
*/
setIcon(icon) {
const { iconEl, calloutEl } = this
calloutEl.style.setProperty("--callout-icon", icon)
iconEl.empty()
const iconSvg = (0, import_obsidian8.getIcon)(icon)
if (iconSvg != null) {
this.iconEl.appendChild(iconSvg)
}
return this
}
/**
* Changes the callout color.
*
* @param color The color to use.
*/
setColor(color) {
const { calloutEl } = this
if (color == null) {
calloutEl.style.removeProperty("--callout-color")
return this
}
calloutEl.style.setProperty("--callout-color", `${color.r}, ${color.g}, ${color.b}`)
return this
}
/**
* Attaches the callout preview to a DOM element.
* This places it at the end of the element.
*
* @param containerEl The container to attach to.
*/
attachTo(containerEl) {
containerEl.appendChild(this.calloutEl)
return this
}
/**
* Resets the `--callout-color` and `--callout-icon` CSS properties added to the callout element.
*/
resetStylePropertyOverrides() {
const { calloutEl } = this
calloutEl.style.removeProperty("--callout-color")
calloutEl.style.removeProperty("--callout-icon")
}
}
var IsolatedCalloutPreviewComponent = class extends CalloutPreviewComponent {
constructor(containerEl, options) {
super(NO_ATTACH, options)
const frag = document.createDocumentFragment()
const focused = options.focused ?? false
const colorScheme = options.colorScheme
const readingView = (options.viewType ?? "reading") === "reading"
const cssEls = options?.cssEls ?? getCurrentStyles(containerEl?.doc)
const shadowHostEl = (this.shadowHostEl = frag.createDiv())
const shadowRoot = (this.shadowRoot = shadowHostEl.attachShadow({
delegatesFocus: false,
mode: "closed",
}))
const shadowHead = (this.shadowHead = shadowRoot.createEl("head"))
const shadowBody = (this.shadowBody = shadowRoot.createEl("body"))
const styleEls = (this.styleEls = [])
for (const cssEl of cssEls) {
const cssElClone = cssEl.cloneNode(true)
if (cssEl.tagName === "STYLE") {
styleEls.push(cssElClone)
}
shadowHead.appendChild(cssElClone)
}
shadowHead.createEl("style", { text: SHADOW_DOM_RESET_STYLES })
this.customStyleEl = shadowHead.createEl("style", { attr: { "data-custom-styles": "true" } })
shadowBody.classList.add(`theme-${colorScheme}`, "obsidian-app")
const viewContentEl = shadowBody
.createDiv({ cls: "app-container" })
.createDiv({ cls: "horizontal-main-container" })
.createDiv({ cls: "workspace" })
.createDiv({ cls: "workspace-split mod-root" })
.createDiv({ cls: `workspace-tabs ${focused ? "mod-active" : ""}` })
.createDiv({ cls: "workspace-tab-container" })
.createDiv({ cls: `workspace-leaf ${focused ? "mod-active" : ""}` })
.createDiv({ cls: "workspace-leaf-content" })
.createDiv({ cls: "view-content" })
const calloutParentEl = readingView
? createReadingViewContainer(viewContentEl)
: createLiveViewContainer(viewContentEl)
calloutParentEl.appendChild(this.calloutEl)
if (containerEl != null) {
IsolatedCalloutPreviewComponent.prototype.attachTo.call(this, containerEl)
}
}
/**
* Replaces the `<style>` elements used by the isolated callout preview with the latest ones.
*/
updateStyles() {
return this.updateStylesWith(
getCurrentStyles(this.shadowHostEl.doc)
.filter((e) => e.tagName === "STYLE")
.map((e) => e.cloneNode(true)),
)
}
/**
* Replaces the `<style>` elements used by the isolated callout preview.
* This can be used to update the preview with the latest styles.
*
* @param styleEls The new style elements to use. These will *not* be cloned.
*/
updateStylesWith(styleEls) {
const { styleEls: oldStyleEls, customStyleEl } = this
let i, end
let lastNode = customStyleEl.previousSibling
for (i = 0, end = Math.min(styleEls.length, oldStyleEls.length); i < end; i++) {
const el = styleEls[i]
oldStyleEls[i].replaceWith(el)
lastNode = el
}
for (end = styleEls.length; i < end; i++) {
const el = styleEls[i]
lastNode.insertAdjacentElement("afterend", el)
oldStyleEls.push(el)
}
const toRemove = oldStyleEls.splice(i, oldStyleEls.length - i)
for (const node of toRemove) {
node.remove()
}
return this
}
/**
* Removes matching style elements.
* @param predicate The predicate function. If it returns true, the element is removed.
*/
removeStyles(predicate) {
for (let i = 0; i < this.styleEls.length; i++) {
const el = this.styleEls[i]
if (predicate(el)) {
el.remove()
this.styleEls.splice(i, 1)
i--
}
}
}
/**
* Changes the color scheme.
* @param colorScheme The color scheme to use.
*/
setColorScheme(colorScheme) {
const { classList } = this.shadowBody
classList.toggle("theme-dark", colorScheme === "dark")
classList.toggle("theme-light", colorScheme === "light")
return this
}
/**
* Attaches the callout preview to a DOM element.
* This places it at the end of the element.
*
* @param containerEl The container to attach to.
* @override
*/
attachTo(containerEl) {
containerEl.appendChild(this.shadowHostEl)
return this
}
}
function getCurrentStyles(doc) {
const els = []
let node = (doc ?? window.document).head.firstElementChild
for (; node != null; node = node.nextElementSibling) {
const nodeTag = node.tagName
if (
nodeTag === "STYLE" ||
(nodeTag === "LINK" && node.getAttribute("rel")?.toLowerCase() === "stylesheet")
) {
els.push(node)
}
}
return els
}
function createReadingViewContainer(viewContentEl) {
return viewContentEl
.createDiv({ cls: "markdown-reading-view" })
.createDiv({ cls: "markdown-preview-view markdown-rendered" })
.createDiv({ cls: "markdown-preview-section" })
.createDiv()
}
function createLiveViewContainer(viewContentEl) {
return viewContentEl
.createDiv({ cls: "markdown-source-view cm-s-obsidian mod-cm6 is-live-preview" })
.createDiv({ cls: "cm-editor \u037C1 \u037C2 \u037Cq" })
.createDiv({ cls: "cm-scroller" })
.createDiv({ cls: "cm-sizer" })
.createDiv({ cls: "cm-contentContainer" })
.createDiv({ cls: "cm-content" })
.createDiv({ cls: "cm-embed-block markdown-rendered cm-callout" })
}
var SHADOW_DOM_RESET_STYLES = `
/* Reset layout and stylings for all properties up to the callout. */
.app-container,
.horizontal-main-container,
.workspace,
.workspace-split,
.workspace-tabs,
.workspace-tab-container,
.workspace-leaf,
.workspace-leaf-content,
.view-content,
.markdown-reading-view,
.markdown-source-view,
.cm-editor.\u037C1.\u037C2.\u037Cq,
.cm-editor.\u037C1.\u037C2.\u037Cq > .cm-scroller,
.cm-sizer,
.cm-contentContainer,
.cm-content,
.markdown-preview-view {
all: initial !important;
display: block !important;
}
/* Set the text color of the container for the callout. */
.markdown-preview-section,
.cm-callout {
color: var(--text-normal) !important;
}
/* Override margin on callout to keep the preview as small as possible. */
.markdown-preview-section > div > .callout,
.cm-callout > .callout,
.calloutmanager-preview.callout {
margin: 0 !important;
}
/* Set the font properties of the callout. */
.cm-callout,
.callout {
font-size: var(--font-text-size) !important;
font-family: var(--font-text) !important;
line-height: var(--line-height-normal) !important;
}
/* Use transparent background color. */
body {
background-color: transparent !important;
}
`
// src/callout-resolver.ts
var CalloutResolver = class {
constructor() {
this.hostElement = document.body.createDiv({
cls: "calloutmanager-callout-resolver",
})
this.hostElement.style.setProperty("display", "none", "important")
this.calloutPreview = new IsolatedCalloutPreviewComponent(this.hostElement, {
id: "",
icon: "",
colorScheme: "dark",
})
this.calloutPreview.resetStylePropertyOverrides()
}
/**
* Reloads the styles of the callout resolver.
* This is necessary to get up-to-date styles when the application CSS changes.
*
* Note: This will not reload the Obsidian app.css stylesheet.
* @param styles The new style elements to use.
*/
reloadStyles() {
this.calloutPreview.setColorScheme(getCurrentThemeID2(app))
this.calloutPreview.updateStyles()
this.calloutPreview.removeStyles(
(el) => el.getAttribute("data-callout-manager") === "style-overrides",
)
}
/**
* Removes the host element.
* This should be called when the plugin is unloading.
*/
unload() {
this.hostElement.remove()
}
/**
* Gets the computed styles for a given type of callout.
* This uses the current Obsidian styles, themes, and snippets.
*
* @param id The callout ID.
* @param callback A callback function to run. The styles may only be accessed through this.
* @returns Whatever the callback function returned.
*/
getCalloutStyles(id, callback) {
const { calloutEl } = this.calloutPreview
calloutEl.setAttribute("data-callout", id)
return callback(window.getComputedStyle(calloutEl))
}
/**
* Gets the icon and color for a given type of callout.
* This uses the current Obsidian styles, themes, and snippets.
*
* @param id The callout ID.
* @returns The callout icon and color.
*/
getCalloutProperties(id) {
return this.getCalloutStyles(id, (styles) => ({
icon: styles.getPropertyValue("--callout-icon").trim(),
color: styles.getPropertyValue("--callout-color").trim(),
}))
}
get customStyleEl() {
return this.calloutPreview.customStyleEl
}
}
// src/callout-settings.ts
function currentCalloutEnvironment(app2) {
const theme = getCurrentThemeID(app2) ?? "<default>"
return {
theme,
colorScheme: getCurrentThemeID2(app2),
}
}
function calloutSettingsToCSS(id, settings, environment) {
const styles = calloutSettingsToStyles(settings, environment).join(";\n ")
if (styles.length === 0) {
return ""
}
return (
`.callout[data-callout="${id}"] {
` +
styles +
"\n}"
)
}
function calloutSettingsToStyles(settings, environment) {
const styles = []
for (const setting of settings) {
if (!checkCondition(setting.condition, environment)) {
continue
}
const { changes } = setting
if (changes.color != null) styles.push(`--callout-color: ${changes.color}`)
if (changes.icon != null) styles.push(`--callout-icon: ${changes.icon}`)
if (changes.customStyles != null) styles.push(changes.customStyles)
}
return styles
}
function checkCondition(condition, environment) {
if (condition == null) {
return true
}
if ("or" in condition && condition.or !== void 0) {
return condition.or.findIndex((p) => checkCondition(p, environment) === true) !== void 0
}
if ("and" in condition && condition.and !== void 0) {
return condition.and.findIndex((p) => checkCondition(p, environment) === false) === void 0
}
if ("theme" in condition && condition.theme === environment.theme) {
return true
}
if ("colorScheme" in condition && condition.colorScheme === environment.colorScheme) {
return true
}
return false
}
function typeofCondition(condition) {
if (condition === void 0) return void 0
const hasOwnProperty = Object.prototype.hasOwnProperty.bind(condition)
if (hasOwnProperty("colorScheme")) return "colorScheme"
if (hasOwnProperty("theme")) return "theme"
if (hasOwnProperty("and")) return "and"
if (hasOwnProperty("or")) return "or"
throw new Error(`Unsupported condition: ${JSON.stringify(condition)}`)
}
// src/css-parser.ts
function getCalloutsFromCSS(css) {
const REGEX_CALLOUT_SELECTOR = /\[data-callout([^\]]*)\]/gim
const REGEX_MATCH_QUOTED_STRING = {
"'": /^'([^']+)'( i)?$/,
'"': /^"([^"]+)"( i)?$/,
"": /^([^\]]+)$/,
}
const attributeSelectors = []
let matches2
while ((matches2 = REGEX_CALLOUT_SELECTOR.exec(css)) != null) {
attributeSelectors.push(matches2[1])
REGEX_CALLOUT_SELECTOR.lastIndex = matches2.index + matches2[0].length
}
const ids = []
for (const attributeSelector of attributeSelectors) {
let selectorString
if (attributeSelector.startsWith("=")) {
selectorString = attributeSelector.substring(1)
} else if (attributeSelector.startsWith("^=")) {
selectorString = attributeSelector.substring(2)
} else {
continue
}
const quoteChar = selectorString.charAt(0)
const stringRegex = REGEX_MATCH_QUOTED_STRING[quoteChar] ?? REGEX_MATCH_QUOTED_STRING[""]
const matches3 = stringRegex.exec(selectorString)
if (matches3 != null && matches3[1] != null) {
ids.push(matches3[1])
}
}
return ids
}
// src/css-watcher.ts
var StylesheetWatcher = class {
constructor(app2) {
this.app = app2
this.listeners = /* @__PURE__ */ new Map()
this.cachedSnippets = /* @__PURE__ */ new Map()
this.cachedObsidian = null
this.cachedTheme = null
this.watching = false
}
/**
* Start watching for changes to stylesheets.
* @returns A callback function to pass to {@link Plugin.register}.
*/
watch() {
if (this.watching) {
throw new Error("Already watching.")
}
const events = [
{
event: "css-change",
listener: () => this.checkForChanges(false),
target: this.app.workspace,
},
]
for (const evtl of events) {
evtl.target.on(evtl.event, evtl.listener)
}
this.checkForChanges()
return () => {
if (!this.watching) {
return
}
for (const evtl of events) {
evtl.target.off(evtl.event, evtl.listener)
}
this.watching = false
}
}
/**
* Describes how the Obsidian stylesheet was fetched, for the purpose of telling the user
* within the plugin's settings pane. The string will be concatenated as `Find built-in Obsidian callouts ${text}`.
*
* This uses the extra metadata attached to the {@link ObsidianStylesAndMetadata}.
*/
describeObsidianFetchMethod() {
switch (this.cachedObsidian?.method ?? "pending") {
case "dom":
return "using browser functions"
case "electron":
return "using undocumented functions"
case "fetch":
return "by reading Obsidian's styles"
case "pending":
return ""
}
}
/**
* @internal
*/
on(event, listener) {
let listenersForEvent = this.listeners.get(event)
if (listenersForEvent === void 0) {
listenersForEvent = /* @__PURE__ */ new Set()
this.listeners.set(event, listenersForEvent)
}
listenersForEvent.add(listener)
}
/**
* Removes an event listener.
*
* @param event The event.
* @param listener The listener to remove.
*/
off(event, listener) {
const listenersForEvent = this.listeners.get(event)
if (listenersForEvent === void 0) {
return
}
listenersForEvent.delete(listener)
if (listenersForEvent.size === 0) {
this.listeners.delete(event)
}
}
emit(event, ...data) {
const listenersForEvent = this.listeners.get(event)
if (listenersForEvent === void 0) {
return
}
for (const listener of listenersForEvent) {
listener(...data)
}
}
/**
* Checks for any changes to the application stylesheets.
* If {@link watch} is being used, this will be called automatically.
*
* @param clear If set to true, the cache will be cleared.
* @returns True if there were any changes.
*/
async checkForChanges(clear) {
let changed = false
this.emit("checkStarted")
if (clear === true) {
this.cachedSnippets.clear()
this.cachedTheme = null
this.cachedObsidian = null
}
if (this.cachedObsidian == null) {
changed = (await this.checkForChangesObsidian()) || changed
}
changed = this.checkForChangesSnippets() || changed
changed = this.checkForChangesTheme() || changed
this.emit("checkComplete", changed)
return changed
}
/**
* Attempts to fetch the Obsidian built-in stylesheet.
* This will fail if the version of obsidian is newer than the version supported by `obsidian-extra`.
*
* @returns true if the fetch was successful.
*/
async checkForChangesObsidian() {
try {
this.cachedObsidian = await fetchObsidianStyleSheet(this.app)
this.emit("change", {
type: "obsidian",
styles: this.cachedObsidian.cssText,
})
return true
} catch (ex) {
console.warn("Unable to fetch Obsidian stylesheet.", ex)
return false
}
}
/**
* Checks for changes in the application's theme.
*/
checkForChangesTheme() {
const theme = getCurrentThemeID(this.app)
const themeManifest = theme == null ? null : getThemeManifest(this.app, theme)
const hasTheme = theme != null && themeManifest != null
const styleEl = getThemeStyleElement(this.app)
const styles = styleEl?.textContent ?? ""
if (this.cachedTheme != null && !hasTheme) {
this.emit("remove", {
type: "theme",
theme: this.cachedTheme.id,
styles: this.cachedTheme.contents,
})
this.cachedTheme = null
return true
}
if (this.cachedTheme == null && hasTheme) {
this.cachedTheme = { id: theme, version: themeManifest.version, contents: styles }
this.emit("add", { type: "theme", theme, styles })
return true
}
if (!hasTheme || this.cachedTheme == null) {
return false
}
const changed =
this.cachedTheme.id !== theme || // Active theme changed
this.cachedTheme.version !== themeManifest.version || // Version of active theme changed
this.cachedTheme.contents !== styles
if (changed) {
this.cachedTheme = {
id: theme,
version: themeManifest.version,
contents: styles,
}
this.emit("change", { type: "theme", theme, styles })
}
return changed
}
/**
* Checks for changes in the enabled snippets.
*/
checkForChangesSnippets() {
let anyChanges = false
const snippets = getSnippetStyleElements(this.app)
const knownSnippets = Array.from(this.cachedSnippets.entries())
for (const [id, cachedStyles] of knownSnippets) {
const styleEl = snippets.get(id)
if (styleEl == null) {
this.cachedSnippets.delete(id)
this.emit("remove", { type: "snippet", snippet: id, styles: cachedStyles })
anyChanges = true
continue
}
if (styleEl.textContent != null && styleEl.textContent !== cachedStyles) {
this.cachedSnippets.set(id, styleEl.textContent)
this.emit("change", { type: "snippet", snippet: id, styles: styleEl.textContent })
anyChanges = true
}
}
for (const [id, styleEl] of snippets.entries()) {
if (styleEl == null) continue
if (!this.cachedSnippets.has(id) && styleEl.textContent != null) {
this.cachedSnippets.set(id, styleEl.textContent)
this.emit("add", { type: "snippet", snippet: id, styles: styleEl.textContent })
anyChanges = true
}
}
return anyChanges
}
}
// src/panes/manage-callouts-pane.ts
var import_obsidian22 = require("obsidian")
// src/ui/pane.ts
var UIPane = class {
/**
* Called to display the controls for the pane.
*/
displayControls() {}
/**
* Called when the pane is created and attached to the setting tab, but before {@link display} is called.
*/
onReady() {}
/**
* Called when the pane is removed and ready to be destroyed.
* Any important settings should be saved here.
*
* @param cancelled If true, the user closed the pane with the escape key.
*/
onClose(cancelled) {}
/**
* Called to save the state of the setting pane.
* This is used for suspending a pane when another pane covers it up.
*
* @returns The saved state.
*/
suspendState() {
return void 0
}
/**
* Called to load the state of the setting pane.
* This is called before {@link display}.
*
* @param state The state to restore.
*/
restoreState(state) {}
}
// src/search/condition.ts
var import_obsidian9 = require("obsidian")
function matches(index, textToMatch, scores) {
const matchesQuery = (0, import_obsidian9.prepareFuzzySearch)(textToMatch.trim())
let mask = 0n
for (const [text, bit] of index) {
const res = matchesQuery(text)
if (res != null) {
mask |= 1n << BigInt(bit)
scores[bit] += res.score
}
}
return mask
}
function includes(index, textToInclude, scores) {
let mask = 0n
for (const [text, bit] of index) {
if (text.includes(textToInclude)) {
mask |= 1n << BigInt(bit)
scores[bit] += textToInclude.length / text.length
}
}
return mask
}
function equals(index, textToHave, scores) {
let mask = 0n
for (const [text, bit] of index) {
if (text.includes(textToHave)) {
mask |= 1n << BigInt(bit)
scores[bit] += textToHave.length / text.length
}
}
return mask
}
function startsWith(index, textToStart, scores) {
let mask = 0n
for (const [text, bit] of index) {
if (text.startsWith(textToStart)) {
mask |= 1n << BigInt(bit)
scores[bit] += textToStart.length / text.length
}
}
return mask
}
// src/search/bitfield.ts
var BitField
;((BitField3) => {
function fromPosition(position) {
return 1n << BigInt(position)
}
BitField3.fromPosition = fromPosition
function fromPositionWithTrailing(position) {
return (1n << BigInt(position + 1)) - 1n
}
BitField3.fromPositionWithTrailing = fromPositionWithTrailing
function scanMostSignificant(field) {
const MASK = ~0xffffffffn
let offset = 0
for (let a = field; (a & MASK) > 0; a >>= 32n) {
offset += 32
}
return offset + (31 - Math.clz32(Number(field)))
}
BitField3.scanMostSignificant = scanMostSignificant
function or(a, b) {
return a | b
}
BitField3.or = or
function and(a, b) {
return a & b
}
BitField3.and = and
function andNot(a, b) {
return a & ~b
}
BitField3.andNot = andNot
function not(a, width) {
return fromPositionWithTrailing(width - 1) ^ a
}
BitField3.not = not
})(BitField || (BitField = {}))
var BitPositionRegistry = class {
constructor() {
this.recycled = []
this.next = 0
this.asField = 0n
}
/**
* A field of all owned bits.
*/
get field() {
return this.asField
}
/**
* The number of bits that are needed to represent a bitfield.
*/
get size() {
return this.next
}
/**
* Claims a bit from the registry.
*/
claim() {
const { recycled } = this
const claimed = recycled.length > 0 ? recycled.pop() : this.next++
this.asField = BitField.or(this.asField, BitField.fromPosition(claimed))
return claimed
}
/**
* Relinquishes a bit back to the registry.
* @param position The position to relinquish.
*/
relinquish(position) {
const { recycled } = this
recycled.push(position)
this.asField = BitField.andNot(this.asField, BitField.fromPosition(position))
}
}
// src/search/effect.ts
function add(a, b) {
return BitField.or(a, b)
}
function remove(a, b) {
return BitField.andNot(a, b)
}
function filter(a, b) {
return BitField.and(a, b)
}
// src/sort.ts
function combinedComparison(fns) {
const compare = (a, b) => {
let delta = 0
for (const compare2 of fns) {
delta = compare2(a, b)
if (delta !== 0) break
}
return delta
}
compare.precompute = (value) => {
const obj = {}
for (const fn of fns) {
if ("precompute" in fn) {
Object.assign(obj, fn.precompute(value))
}
}
return obj
}
return compare
}
function compareColor(
{ computed: { colorValid: aValid, colorHSV: aHSV } },
{ computed: { colorValid: bValid, colorHSV: bHSV } },
) {
const validityDelta = (aValid ? 1 : 0) - (bValid ? 1 : 0)
if (validityDelta !== 0) return validityDelta
const saturatedDelta = (aHSV.s > 0 ? 1 : 0) - (bHSV.s > 0 ? 1 : 0)
if (saturatedDelta !== 0) return saturatedDelta
const hueDelta = aHSV.h - bHSV.h
if (Math.abs(hueDelta) > 2) return hueDelta
const svDelta = aHSV.s + aHSV.v - (bHSV.s + bHSV.v)
if (svDelta !== 0) return svDelta
return 0
}
;((compareColor2) => {
function precompute(v) {
const color = getColorFromCallout(v)
return {
colorValid: color != null,
colorHSV: color == null ? { h: 0, s: 0, v: 0 } : toHSV(color),
}
}
compareColor2.precompute = precompute
})(compareColor || (compareColor = {}))
function compareId({ value: { id: aId } }, { value: { id: bId } }) {
return bId.localeCompare(aId)
}
// src/search/search-index.ts
var SearchIndex = class {
constructor(columns) {
this.columns = /* @__PURE__ */ new Map()
this.registry = new BitPositionRegistry()
for (const [col, description] of Object.entries(columns)) {
this.columns.set(col, new SearchIndexColumn(this.registry, description))
}
}
get bitfield() {
return this.registry.field
}
get size() {
return this.registry.size
}
column(name) {
const col = this.columns.get(name)
if (col === void 0) throw new NoSuchColumnError(name)
return col
}
}
var NoSuchColumnError = class extends Error {
constructor(column) {
super(`No such column in index: ${column}`)
}
}
var SearchIndexColumn = class {
constructor(registry, options) {
this.entries = /* @__PURE__ */ new Map()
this.bitReg = registry
this.normalize = options?.normalize ?? ((n) => n)
}
/**
* Adds a value to the column.
*
* @param value The value to add.
* @returns The associated bit position of the value.
*/
add(value) {
const { entries } = this
const normalized = this.normalize(value)
const existingBit = entries.get(normalized)
if (existingBit !== void 0) return existingBit
const newBit = this.bitReg.claim()
entries.set(normalized, newBit)
return newBit
}
/**
* Removes a value from the column.
* @param value The value to remove.
*/
delete(value) {
const { entries } = this
const normalized = this.normalize(value)
const existingBit = entries.get(normalized)
if (existingBit === void 0) return
this.bitReg.relinquish(existingBit)
entries.delete(normalized)
}
/**
* Gets a value from the column.
*
* @param value The value to get.
* @returns The associated bit position, or undefined if it is not in the column.
*/
get(value) {
return this.entries.get(this.normalize(value))
}
/**
* The number of indices in this column.
*/
get size() {
return this.entries.size
}
[Symbol.iterator]() {
return this.entries.entries()
}
}
// src/search/search.ts
var Search = class {
constructor(options) {
this.resetToAll = options?.resetToAll ?? false
this.index = new SearchIndex(options.columns)
this.indexedItems = []
const suppliedFnCompare = options.compareItem ?? ((a, b) => 0)
this.fnIndex = options.indexItem
this.currentMask = 0n
this.currentResults = null
this.currentScores = new Float32Array(0)
this.reusableCurrentScoresBuffer = new Float32Array(0)
this.fnCompare =
(options.resultRanking ?? true) === false
? (a, b) => suppliedFnCompare(a.value, b.value)
: (a, b) => {
const delta = b.score - a.score
if (delta !== 0) return delta
return suppliedFnCompare(b.value, a.value)
}
}
/**
* Adds items to the search.
* This will index the items.
*
* @param items The items to add.
*/
addItems(items) {
const { index, indexedItems } = this
for (const item of items) {
const mask = this.fnIndex(item, index)
indexedItems.push({
value: item,
mask,
score: 0,
})
}
const { size: newLength } = index
if (newLength > this.currentScores.length) {
const { currentScores: oldScore } = this
this.reusableCurrentScoresBuffer = new Float32Array(newLength)
this.currentScores = new Float32Array(newLength)
this.currentScores.set(oldScore, 0)
}
}
reset() {
const { index } = this
this.currentMask = this.resetToAll ? index.bitfield : 0n
this.currentResults = null
this.currentScores = new Float32Array(index.size)
}
search(property, condition, text, effect, weight) {
const newScores = this.reusableCurrentScoresBuffer.fill(0)
this.currentResults = null
const column = this.index.column(property)
const searchResult = condition(column, column.normalize(text), newScores)
this.currentMask = effect(this.currentMask, searchResult)
const scoreWeight = weight ?? 1
const scores = this.currentScores
for (let i = 0, end = scores.length; i < end; i++) {
scores[i] += newScores[i] * scoreWeight
}
}
get results() {
const cached = this.currentResults
if (cached != null) return cached
const { currentMask, currentScores, fnCompare } = this
const results = this.indexedItems.filter(({ mask }) => (currentMask & mask) > 0n)
results.forEach((item) => {
let mask = item.mask
let score = 0
for (let index = 0; mask > 0n; index++, mask >>= 1n) {
if ((mask & 1n) !== 0n) score += currentScores[index]
}
item.score = score
})
results.sort(fnCompare)
return (this.currentResults = results.map(({ value }) => value))
}
}
// src/search/factory.ts
var SearchFactory = class {
/**
* @param objects The objects to search through.
*/
constructor(objects) {
this.columns = []
this.metadataGenerators = []
this.sortFunctions = []
this.options = {}
this.items = objects
}
/**
* Adds a column that can be searched.
*
* @param name The column name.
* @param property The property name of the string property to index, or a getter function to return some string.
* @param normalize A function to normalize the value.
*/
withColumn(name, property, normalize) {
const getter = typeof property === "function" ? property : (obj) => [obj[property]]
this.columns.push({
name,
getter,
desc: {
normalize,
},
})
return this
}
/**
* Adds metadata to the search result items.
* This is useful for attaching cached data such as search previews.
*
* @param generator A function to generate the metadata.
*/
withMetadata(generator) {
this.metadataGenerators.push(generator)
return this
}
/**
* Adds a sorting rule to the search result items.
* @param comparator The comparator for the sorting rule.
*/
withSorting(comparator) {
this.sortFunctions.push(comparator)
return this
}
/**
* Changes if empty queries will be inclusive.
* @param enabled Whether enabled.
*/
withInclusiveDefaults(enabled) {
this.options.resetToAll = enabled
return this
}
/**
* Builds the index and returns a search class.
*/
build() {
const { metadataGenerators, sortFunctions, columns: columnGenerators } = this
const compareFn =
sortFunctions.length === 0
? void 0
: sortFunctions.length === 1
? sortFunctions[0]
: combinedComparison(this.sortFunctions)
const items = this.items.map((item) =>
Object.assign({}, ...metadataGenerators.map((fn) => fn(item)), {
value: item,
computed: compareFn?.precompute?.(item),
}),
)
const columns = Object.fromEntries(columnGenerators.map(({ name, desc }) => [name, desc]))
const indexFn = (item, index) => {
let field = 0n
for (const { name, getter } of columnGenerators) {
const column = index.column(name)
for (const value of getter(item.value)) {
field = BitField.or(field, BitField.fromPosition(column.add(value)))
}
}
return field
}
const search = new Search({
...this.options,
columns,
indexItem: indexFn,
compareItem: compareFn,
})
search.addItems(items)
return search
}
}
// src/search/normalize.ts
function casefold(text) {
return text.toLowerCase()
}
function unicode(text) {
return text.normalize("NFC")
}
function trimmed(text) {
return text.trim()
}
function combinedNormalization(fns) {
return (text) => {
for (const fn of fns) {
text = fn(text)
}
return text
}
}
// src/search/query.ts
var REGEX_NOT_WHITESPACE = /\S/g
var REGEX_FIELD_DELIMITER = /[ \\":]/g
var REGEX_QUERYOP_EFFECT = /[-+&]+/y
var REGEX_QUERYOP_FIELD = /[\w-]+/y
var REGEX_QUERYOP_CONDITION = /[\^=~%:]+|(?:\[(?:is|equals|matches|has|includes|contains)\]:)/y
function parseQuery(query) {
const ctx = new QueryParserContext(query)
const ops = []
while (!ctx.done) {
ctx.take.trim()
ops.push(parseOperation(ctx))
}
return ops
}
function parseOperation(ctx) {
ctx.stage.push("search term")
try {
const effect = parseOperationEffect(ctx)
let field = parseOperationField(ctx)
const condition = parseOperationCondition(ctx)
let text = ctx.done || ctx.peek.prefix(" ") ? null : parseText(ctx)
if (text == null && condition == null) {
text = field
field = null
}
if (text == "" && field == "" && effect == null && condition == null) {
throw new QuerySyntaxError(ctx, "Unexpected end")
}
if (text == "") {
throw new QuerySyntaxError(ctx, "Missing query text")
}
return {
field,
text,
condition,
effect,
}
} finally {
ctx.stage.pop()
}
}
function parseOperationEffect(ctx) {
const [matches2] = ctx.take.findFirstRegex(REGEX_QUERYOP_EFFECT)
if (matches2 == null) return null
switch (matches2[0]) {
case "-":
return remove
case "+":
return add
case "&":
return filter
default:
throw new QuerySyntaxError(
ctx,
`Unexpected symbol \`${matches2[1]}\`. Did you mean \`-\`, \`+\`, or \`&\`?`,
)
}
}
function parseOperationField(ctx) {
const parts = []
while (true) {
const [matches2] = ctx.take.findFirstRegex(REGEX_QUERYOP_FIELD)
if (matches2 != null) {
parts.push(matches2[0])
continue
}
if (ctx.peek.prefix("\\")) {
parts.push(parseEscapeSequence(ctx))
continue
}
break
}
return parts.join("")
}
function parseOperationCondition(ctx) {
const [matches2] = ctx.take.findFirstRegex(REGEX_QUERYOP_CONDITION)
if (matches2 == null) return null
const chr = matches2[0]
switch (chr) {
case ":":
case "[matches]:":
return matches
case "=":
case "[equals]:":
case "[is]:":
return equals
case "%=":
case "[includes]:":
case "[contains]:":
return includes
case "^=":
return startsWith
default:
throw new QuerySyntaxError(
ctx,
`Unexpected symbol \`${chr}\`. Did you mean \`:\`, \`=\`, or \`%=\`?`,
)
}
}
function parseText(ctx) {
ctx.stage.push("text")
try {
const parts = []
let isQuoted = false
while (true) {
const [matches2] = ctx.peek.findFirstRegex(REGEX_FIELD_DELIMITER)
if (matches2 == null) {
parts.push(ctx.take.remaining())
break
}
parts.push(ctx.take.next(matches2.index - ctx.offset))
const chr = matches2[0]
if (chr === "\\") {
parts.push(parseEscapeSequence(ctx))
continue
}
if (chr === " ") {
if (!isQuoted) break
ctx.take.next(chr.length)
parts.push(" ")
continue
}
if (chr === '"') {
ctx.take.next(chr.length)
isQuoted = !isQuoted
continue
}
throw new QuerySyntaxError(ctx, `Unexpected symbol \`${chr}\``)
}
if (isQuoted) {
throw new QuerySyntaxError(ctx, 'Unexpected end of string while matching `"`')
}
return parts.join("")
} finally {
ctx.stage.pop()
}
}
function parseEscapeSequence(ctx) {
ctx.stage.push("escape sequence")
try {
if (!ctx.take.prefix("\\")) {
throw new QuerySyntaxError(ctx, "Not an escape sequence")
}
const chr = ctx.take.next(1)
switch (chr) {
case "\\":
case '"':
case "'":
case " ":
return chr
case "n":
return "\n"
case "r":
return "\r"
case "x": {
const hex = ctx.take.next(2)
return hexStringToCharacter(ctx, hex)
}
case "u": {
if (ctx.peek.next(1) !== "{") {
return hexStringToCharacter(ctx, ctx.take.next(4))
}
const [matches2] = ctx.take.findLongestRegex(/\{([^}]+)\}/)
if (matches2 == null) {
throw new QuerySyntaxError(ctx, "Unexpected end of string while matching `{`")
}
return hexStringToCharacter(ctx, matches2[1])
}
default: {
throw new QuerySyntaxError(ctx, `Unknown escape sequence (${chr})`)
}
}
} finally {
ctx.stage.pop()
}
}
function hexStringToCharacter(ctx, hex) {
if (!/^[A-Fa-f0-9]+$/.test(hex)) {
throw new QuerySyntaxError(ctx, `Invalid hex number \`${hex}\``)
}
const value = parseInt(hex, 16)
if (isNaN(value)) throw new QuerySyntaxError(ctx, `Invalid hex number \`${hex}\``)
return String.fromCharCode(value)
}
var QuerySyntaxError = class extends Error {
constructor(ctx, message) {
super(`Error parsing ${ctx.stage ?? "query"} at offset ${ctx.offset}: ${message}`)
}
}
var QueryParserContext = class {
constructor(source) {
this.source = source
this._offset = 0
this.take = new QueryParserContextFunctions(this, (n) => (this._offset = n))
this.peek = new QueryParserContextFunctions(this, () => {})
this.stage = []
}
/**
* The current offset from the source.
*/
get offset() {
return this._offset
}
/**
* True if the source is fully consumed.
*/
get done() {
return this._offset >= this.source.length
}
}
var QueryParserContextFunctions = class {
constructor(ctx, setOffset) {
this.ctx = ctx
this.setOffset = setOffset
}
/**
* Checks if the next few characters are equal to a prefix string, and consumes them if they are.
* @param prefix The prefix string.
*/
prefix(prefix) {
const { source, offset } = this.ctx
if (source.substring(offset, offset + prefix.length) === prefix) {
this.setOffset(offset + prefix.length)
return true
}
return false
}
/**
* Trims leading whitespace.
* @returns The leading whitespace.
*/
trim() {
const { source, offset } = this.ctx
REGEX_NOT_WHITESPACE.lastIndex = offset
const match = REGEX_NOT_WHITESPACE.exec(source)
if (match == null) {
this.setOffset(source.length)
return source.substring(offset)
}
this.setOffset(match.index)
return source.substring(offset, match.index)
}
/**
* Consumes the next few characters.
* @param length The number of characters to consume.
*/
maybeNext(length) {
const { source, offset } = this.ctx
const substring = source.substring(offset, offset + length)
this.setOffset(offset + length)
return substring
}
/**
* Consumes the next few characters.
* If the end of the string is reached, this will throw an error.
*
* @param length The number of characters to consume.
*/
next(length) {
const { source, offset } = this.ctx
const substring = source.substring(offset, offset + length)
if (substring.length < length) throw new QuerySyntaxError(this.ctx, "Unexpected end")
this.setOffset(offset + length)
return substring
}
/**
* Takes the remaining text.
* @returns The remaining text.
*/
remaining() {
const { source, offset } = this.ctx
this.setOffset(source.length)
return source.substring(offset)
}
/**
* Finds and consumes the longest matching regular expression.
* @param regexps The regular expressions.
*/
findLongestRegex(...regexps) {
const { source, offset } = this.ctx
let longestLength = 0
const result = [null, null]
for (const regexp of regexps) {
regexp.lastIndex = offset
const matches2 = regexp.exec(source)
if (matches2 != null && matches2[0].length > longestLength) {
longestLength = matches2[0].length
result[0] = matches2
result[1] = regexp
}
}
const resultMatches = result[0]
if (resultMatches != null) {
this.setOffset(resultMatches.index + resultMatches[0].length)
}
return result
}
/**
* Finds and consumes the first matching regular expression.
* @param length The regular expressions.
*/
findFirstRegex(...regexps) {
const { source, offset } = this.ctx
let firstMatch = source.length + 1
const result = [null, null]
for (const regexp of regexps) {
regexp.lastIndex = offset
const matches2 = regexp.exec(source)
if (matches2 != null && matches2.index < firstMatch) {
firstMatch = matches2.index
result[0] = matches2
result[1] = regexp
}
}
const resultMatches = result[0]
if (resultMatches != null) {
this.setOffset(resultMatches.index + resultMatches[0].length)
}
return result
}
}
// src/callout-search.ts
function calloutSearch(callouts, options) {
const preview = options?.preview
const defaultCondition = options?.defaultCondition ?? matches
const standardNormalization = combinedNormalization([
casefold,
unicode,
trimmed,
(v) => v.replace(/[ -_.]+/g, "-"),
])
const standardSorting = combinedComparison([compareColor, compareId])
const search = new SearchFactory(callouts)
.withColumn("id", "id", standardNormalization)
.withColumn("icon", "icon", standardNormalization)
.withColumn("from", sourceGetter, standardNormalization)
.withColumn("snippet", snippetGetter, standardNormalization)
.withMetadata((callout) => (preview == null ? {} : { preview: preview(callout) }))
.withInclusiveDefaults(true)
.withSorting(standardSorting)
.build()
return (query) => {
const ops = parseQuery(query)
search.reset()
for (const op of ops) {
let field = op.field
if (field === "" || field == null) field = "id"
if (op.text === "" || op.text == null) continue
search.search(field, op.condition ?? defaultCondition, op.text, op.effect ?? filter)
}
return search.results
}
}
function snippetGetter(callout) {
const values = []
for (const source of callout.sources) {
if (source.type !== "snippet") continue
values.push(source.snippet)
}
return values
}
function sourceGetter(callout) {
const sources = []
for (const source of callout.sources) {
switch (source.type) {
case "builtin":
sources.push("obsidian")
sources.push("builtin")
sources.push("built-in")
break
case "custom":
sources.push("custom")
sources.push("user")
sources.push("callout-manager")
break
default:
sources.push(source.type)
}
}
return sources
}
// src/panes/create-callout-pane.ts
var import_obsidian21 = require("obsidian")
// src/util/validity-set.ts
var import_obsidian10 = require("obsidian")
var ValiditySet = class {
constructor(reducer) {
this._emitter = new import_obsidian10.Events()
this._reducer = reducer
this._lastReducedValidity = null
this._cachedValidity = {}
}
/**
* The current validity.
*/
get valid() {
const { _lastReducedValidity } = this
if (_lastReducedValidity == null) throw new Error("No validity available.")
return _lastReducedValidity
}
/**
* Runs the provided function when the reduced validity changes.
*
* @param callback The callback to run.
* @returns An event ref.
*/
onChange(callback) {
if (this._lastReducedValidity != null) {
callback(this._lastReducedValidity)
}
return this._emitter.on("change", callback)
}
/**
* Updates the provided component's disabled state when the reduced validity changes.
*
* @param component The component to update.
* @returns An event ref.
*/
onChangeUpdateDisabled(component) {
return this.onChange((valid) => {
component.setDisabled(!valid)
})
}
/**
* Adds a validity source.
*
* @param id The source's unique ID.
* @returns A function for updating the validity.
*/
addSource(id) {
return (valid) => {
const cachedValidity = this._cachedValidity[id]
if (cachedValidity === valid) return
this._cachedValidity[id] = valid
const newValidity = this._reducer({ ...this._cachedValidity })
if (newValidity !== this._lastReducedValidity) {
this._lastReducedValidity = newValidity
this._emitter.trigger("change", newValidity)
}
}
}
}
;((ValiditySet2) => {
function AllValid(states) {
return !Object.values(states).includes(false)
}
ValiditySet2.AllValid = AllValid
})(ValiditySet || (ValiditySet = {}))
// src/panes/edit-callout-pane/index.ts
var import_obsidian20 = require("obsidian")
// src/panes/edit-callout-pane/appearance-type.ts
function determineAppearanceType(settings) {
return (
determineNonComplexAppearanceType(settings) ?? {
type: "complex",
settings,
}
)
}
function determineNonComplexAppearanceType(settings) {
const settingsWithColorSchemeCondition = []
const settingsWithNoCondition = []
for (const setting of settings) {
const type = typeofCondition(setting.condition)
switch (type) {
case "colorScheme":
settingsWithColorSchemeCondition.push(setting)
break
case void 0:
settingsWithNoCondition.push(setting)
break
case "and":
case "or":
case "theme": {
console.debug("Cannot represent callout settings with UI.", {
reason: `Has condition of type '${type}'`,
settings,
})
return null
}
}
}
const colorSchemeColor = { dark: void 0, light: void 0 }
for (const setting of settingsWithColorSchemeCondition) {
const changed = Object.keys(setting.changes)
if (changed.length === 0) {
continue
}
if (changed.find((key) => key !== "color") !== void 0) {
console.debug("Cannot represent callout settings with UI.", {
reason: `Has 'colorScheme' condition with non-color change.`,
settings,
})
return null
}
const appearanceCond = setting.condition.colorScheme
if (colorSchemeColor[appearanceCond] !== void 0) {
console.debug("Cannot represent callout settings with UI.", {
reason: `Has multiple 'colorScheme' conditions that change ${appearanceCond} color.`,
settings,
})
return null
}
colorSchemeColor[appearanceCond] = setting.changes.color
}
const otherChanges = {}
for (const [change, value] of settingsWithNoCondition.flatMap((s) => Object.entries(s.changes))) {
if (value === void 0) continue
if (change in otherChanges) {
console.debug("Cannot represent callout settings with UI.", {
reason: `Has multiple changes to '${change}'.`,
settings,
})
return null
}
otherChanges[change] = value
}
const otherChangesColor = otherChanges.color
delete otherChanges.color
if (colorSchemeColor.dark === void 0 && colorSchemeColor.light === void 0) {
if (otherChangesColor === void 0) {
return { type: "unified", color: void 0, otherChanges }
}
return { type: "unified", color: otherChangesColor, otherChanges }
}
const colorDark = colorSchemeColor.dark ?? colorSchemeColor.light
const colorLight = colorSchemeColor.light ?? colorSchemeColor.dark
return { type: "per-scheme", colorDark, colorLight, otherChanges }
}
// src/panes/edit-callout-pane/editor-complex.ts
var import_obsidian11 = require("obsidian")
// src/panes/edit-callout-pane/appearance-editor.ts
var AppearanceEditor = class {}
// src/panes/edit-callout-pane/editor-complex.ts
var ComplexAppearanceEditor = class extends AppearanceEditor {
/** @override */
toSettings() {
return this.appearance.settings
}
/** @override */
render() {
const { containerEl } = this
const { settings } = this.appearance
const complexJson = JSON.stringify(settings, void 0, " ")
containerEl.createEl("p", {
text: "This callout has been configured using the plugin's data.json file. To prevent unintentional changes to the configuration, you need to edit it manually.",
})
containerEl.createEl("code", { cls: "calloutmanager-edit-callout-appearance-json" }, (el) => {
el.createEl("pre", { text: complexJson })
})
containerEl.createEl("p", {
text: "Alternatively, you can reset the callout by clicking the button below twice.",
})
let resetButtonClicked = false
const resetButton = new import_obsidian11.ButtonComponent(containerEl)
.setButtonText("Reset Callout")
.setClass("calloutmanager-edit-callout-appearance-reset")
.setWarning()
.onClick(() => {
if (!resetButtonClicked) {
resetButtonClicked = true
resetButton.setButtonText("Are you sure?")
return
}
this.setAppearance({ type: "unified", color: void 0, otherChanges: {} })
})
}
}
// src/ui/setting/callout-color.ts
var import_obsidian13 = require("obsidian")
// src/ui/component/reset-button.ts
var import_obsidian12 = require("obsidian")
var ResetButtonComponent = class extends import_obsidian12.ExtraButtonComponent {
constructor(containerEl) {
super(containerEl)
this.setIcon("lucide-undo")
this.extraSettingsEl.classList.add("calloutmanager-reset-button")
}
}
// src/default_colors.json
var defaultColors = {
"82, 139, 212": "blue",
"83, 223, 221": "cyan",
"68, 207, 110": "green",
"233, 151, 63": "orange",
"251, 70, 76": "red",
"168, 130, 255": "purple",
"166, 189, 197": "gray",
"158, 158, 158": "light gray",
"208, 181, 48": "yellow",
"227, 107, 167": "pink",
"161, 106, 73": "brown",
"0, 0, 0": "black",
}
var default_colors_default = {
defaultColors,
}
// src/ui/setting/callout-color.ts
var CalloutColorSetting = class extends import_obsidian13.Setting {
constructor(containerEl, callout) {
super(containerEl)
this.onChanged = void 0
this.callout = callout
this.isDefault = true
this.addColorPicker((picker) => {
this.colorComponent = picker
picker.onChange(() => {
const { r, g, b } = this.getColor()
this.onChanged?.(`${r}, ${g}, ${b}`)
})
})
this.dropdownComponent = new import_obsidian13.DropdownComponent(this.controlEl).then(
(dropdown) => {
dropdown.addOptions(defaultColors)
dropdown.onChange((value) => {
this.setColorString(value)
})
},
)
this.components.push(this.dropdownComponent)
this.components.push(
new ResetButtonComponent(this.controlEl).then((btn) => {
this.resetComponent = btn
btn.onClick(() => this.onChanged?.(void 0))
}),
)
this.setColor(void 0)
}
/**
* Sets the color string.
* This only accepts comma-delimited RGB values.
*
* @param color The color (e.g. `255, 10, 25`) or undefined to reset the color to default.
* @returns `this`, for chaining.
*/
setColorString(color) {
if (color == null) {
return this.setColor(void 0)
}
return this.setColor(parseColorRGB(`rgb(${color})`) ?? { r: 0, g: 0, b: 0 })
}
/**
* Sets the color.
*
* @param color The color or undefined to reset the color to default.
* @returns `this`, for chaining.
*/
setColor(color) {
const isDefault = (this.isDefault = color == null)
if (color == null) {
color = getColorFromCallout(this.callout) ?? { r: 0, g: 0, b: 0 }
}
if (color instanceof Array) {
color = { r: color[0], g: color[1], b: color[2] }
}
this.colorComponent.setValueRgb(color)
if (`${color.r}, ${color.g}, ${color.b}` in defaultColors) {
this.dropdownComponent.setValue(`${color.r}, ${color.g}, ${color.b}`)
} else {
this.dropdownComponent.setValue("")
}
this.resetComponent.setDisabled(isDefault).setTooltip(isDefault ? "" : "Reset Color")
return this
}
getColor() {
return this.colorComponent.getValueRgb()
}
isDefaultColor() {
return this.isDefault
}
onChange(cb) {
this.onChanged = cb
return this
}
}
// src/ui/setting/callout-icon.ts
var import_obsidian16 = require("obsidian")
// src/panes/select-icon-pane.ts
var import_obsidian15 = require("obsidian")
// src/ui/component/icon-preview.ts
var import_obsidian14 = require("obsidian")
var IconPreviewComponent = class extends import_obsidian14.Component {
constructor(containerEl) {
super()
this.componentEl = containerEl.createEl("button", { cls: "calloutmanager-icon-preview" })
this.iconEl = this.componentEl.createDiv({ cls: "calloutmanager-icon-preview--icon" })
this.idEl = this.componentEl.createDiv({ cls: "calloutmanager-icon-preview--id" })
}
/**
* Sets the icon of the icon preview component.
* This will update the label and the icon SVG.
*
* @param icon The icon name.
* @returns This, for chaining.
*/
setIcon(icon) {
const iconSvg = (0, import_obsidian14.getIcon)(icon)
this.componentEl.setAttribute("data-icon-id", icon)
this.idEl.textContent = icon
this.iconEl.empty()
if (iconSvg != null) {
this.iconEl.appendChild(iconSvg)
}
return this
}
/**
* Sets the `click` event listener for the component.
*
* @param listener The listener.
* @returns This, for chaining.
*/
onClick(listener) {
this.componentEl.onclick = listener
return this
}
}
// src/panes/select-icon-pane.ts
var recentIcons = /* @__PURE__ */ new Set()
var SelectIconPane = class extends UIPane {
constructor(plugin, title, options) {
super()
this.title = title
this.plugin = plugin
this.onChoose = options.onChoose
this.previewLimit = options.limit ?? 250
this.previewLimitOverage = 0
this.searchQuery = ""
this.searchResults = []
const usedIconIds = new Set(plugin.callouts.values().map((c) => c.icon))
const usedIcons = (this.usedIcons = /* @__PURE__ */ new Map())
this.allIcons = (0, import_obsidian15.getIconIds)().map((id) => {
const icon = {
id,
searchId: id.trim().toLowerCase(),
component: null,
searchResult: null,
}
if (usedIconIds.has(id)) {
this.usedIcons.set(id, icon)
}
return icon
})
this.compareIcons = (
{ id: a, searchId: aLC, searchResult: aSR },
{ id: b, searchId: bLC, searchResult: bSR },
) => {
const recency = (recentIcons.has(b) ? 1 : 0) - (recentIcons.has(a) ? 1 : 0)
const suggested = (usedIcons.has(b) ? 1 : 0) - (usedIcons.has(a) ? 1 : 0)
const searchRank = (bSR?.score ?? 0) - (aSR?.score ?? 0)
const sum = recency + suggested + searchRank
if (sum !== 0) return sum
return bLC.localeCompare(aLC)
}
}
/**
* Changes the search query.
* @param query The search query.
*/
search(query) {
this.searchQuery = query
if (query === "") {
this.resetSearchResults()
} else {
const search = (0, import_obsidian15.prepareFuzzySearch)(query.trim().toLowerCase())
this.calculateSearchResults((icon) => search(icon.searchId))
}
this.display()
}
/**
* Updatse the search results list.
* @param search The search function.
*/
calculateSearchResults(search) {
this.searchResults = this.allIcons.filter((icon) => {
icon.searchResult = search(icon)
return icon.searchResult != null
})
this.searchResults.sort(this.compareIcons)
this.previewLimitOverage = this.searchResults.splice(this.previewLimit).length
}
/**
* Resets the search results list to show a default list of suggested icons.
*/
resetSearchResults() {
const { allIcons, previewLimit, usedIcons } = this
this.searchResults = Array.from(
/* @__PURE__ */ new Set([
...allIcons.slice(0, previewLimit),
...Array.from(usedIcons.values()).slice(0, previewLimit),
]).values(),
)
this.searchResults.sort(this.compareIcons).slice(0, this.previewLimit)
this.previewLimitOverage = this.allIcons.length - this.searchResults.length
}
/** @override */
display() {
const { containerEl } = this
const gridEl = document
.createDocumentFragment()
.createDiv({ cls: "calloutmanager-icon-picker" })
for (const icon of this.searchResults) {
if (icon.component == null) {
icon.component = new IconPreviewComponent(gridEl).setIcon(icon.id).componentEl
} else {
gridEl.appendChild(icon.component)
}
}
gridEl.addEventListener("click", ({ targetNode }) => {
for (; targetNode != null && targetNode !== gridEl; targetNode = targetNode.parentElement) {
if (!(targetNode instanceof HTMLElement)) continue
const iconId = targetNode.getAttribute("data-icon-id")
if (iconId != null) {
recentIcons.add(iconId)
this.nav.close()
this.onChoose(iconId)
return
}
}
})
containerEl.empty()
containerEl.appendChild(gridEl)
const { previewLimitOverage } = this
if (previewLimitOverage > 0) {
const { pluralIs, pluralIcon } =
previewLimitOverage === 1
? { pluralIs: "is", pluralIcon: "icon" }
: { pluralIs: "are", pluralIcon: "icons" }
containerEl.createEl("p", {
text: `There ${pluralIs} ${previewLimitOverage} more ${pluralIcon} to show. Refine your search to see more.`,
})
}
}
/** @override */
displayControls() {
const { controlsEl } = this
new import_obsidian15.TextComponent(controlsEl)
.setValue(this.searchQuery)
.setPlaceholder("Search icons...")
.onChange(this.search.bind(this))
}
/** @override */
onReady() {
this.resetSearchResults()
}
}
// src/ui/setting/callout-icon.ts
var CalloutIconSetting = class extends import_obsidian16.Setting {
constructor(containerEl, callout, plugin, getNav) {
super(containerEl)
this.onChanged = void 0
this.callout = callout
this.isDefault = true
this.iconName = void 0
this.addButton((btn) => {
this.buttonComponent = btn
btn.onClick(() => {
getNav().open(
new SelectIconPane(plugin, "Select Icon", { onChoose: (icon) => this.onChanged?.(icon) }),
)
})
})
this.components.push(
new ResetButtonComponent(this.controlEl).then((btn) => {
this.resetComponent = btn
btn.onClick(() => this.onChanged?.(void 0))
}),
)
this.setIcon(void 0)
}
/**
* Sets the icon.
*
* @param icon The icon name or undefined to reset the color to default.
* @returns `this`, for chaining.
*/
setIcon(icon) {
const isDefault = (this.isDefault = icon == null)
const iconName = (this.iconName = icon ?? this.callout.icon)
const iconExists = (0, import_obsidian16.getIcon)(iconName) != null
if (iconExists) {
this.buttonComponent.setIcon(iconName)
} else {
this.buttonComponent.setButtonText(iconExists ? "" : `(missing icon: ${iconName})`)
}
this.resetComponent.setDisabled(isDefault).setTooltip(isDefault ? "" : "Reset Icon")
return this
}
getIcon() {
return this.iconName ?? this.callout.icon
}
isDefaultIcon() {
return this.isDefault
}
onChange(cb) {
this.onChanged = cb
return this
}
}
// src/panes/edit-callout-pane/editor-per-scheme.ts
var PerSchemeAppearanceEditor = class extends AppearanceEditor {
/** @override */
toSettings() {
const { otherChanges, colorDark, colorLight } = this.appearance
const forLight = {
condition: { colorScheme: "light" },
changes: {
color: colorLight,
},
}
const forDark = {
condition: { colorScheme: "dark" },
changes: {
color: colorDark,
},
}
if (forLight.changes.color === void 0) delete forLight.changes.color
if (forDark.changes.color === void 0) delete forDark.changes.color
return [{ changes: otherChanges }, forLight, forDark]
}
setAppearanceOrChangeToUnified(appearance) {
const { colorDark, colorLight, otherChanges } = appearance
if (colorDark === void 0 && colorLight === void 0) {
this.setAppearance({ type: "unified", color: void 0, otherChanges })
return
}
this.setAppearance(appearance)
}
render() {
const { callout, containerEl, appearance, plugin, nav } = this
const { colorDark, colorLight, otherChanges } = this.appearance
new CalloutColorSetting(containerEl, callout)
.setName("Dark Color")
.setDesc("Change the color of the callout for the dark color scheme.")
.setColorString(colorDark)
.onChange((color) => this.setAppearanceOrChangeToUnified({ ...appearance, colorDark: color }))
new CalloutColorSetting(containerEl, callout)
.setName(`Light Color`)
.setDesc(`Change the color of the callout for the light color scheme.`)
.setColorString(colorLight)
.onChange((color) =>
this.setAppearanceOrChangeToUnified({ ...appearance, colorLight: color }),
)
new CalloutIconSetting(containerEl, callout, plugin, () => nav)
.setName("Icon")
.setDesc("Change the callout icon.")
.setIcon(otherChanges.icon)
.onChange((icon) =>
this.setAppearanceOrChangeToUnified({
...appearance,
otherChanges: { ...otherChanges, icon },
}),
)
}
}
// src/panes/edit-callout-pane/editor-unified.ts
var import_obsidian17 = require("obsidian")
var UnifiedAppearanceEditor = class extends AppearanceEditor {
/** @override */
toSettings() {
const { otherChanges, color } = this.appearance
const changes = {
...otherChanges,
color,
}
if (color === void 0) {
delete changes.color
}
return Object.keys(changes).length === 0 ? [] : [{ changes }]
}
render() {
const { plugin, containerEl, callout, setAppearance } = this
const { color, otherChanges } = this.appearance
const colorScheme = getCurrentThemeID2(plugin.app)
const otherColorScheme = colorScheme === "dark" ? "light" : "dark"
new CalloutColorSetting(containerEl, callout)
.setName("Color")
.setDesc("Change the color of the callout.")
.setColorString(color)
.onChange((color2) => setAppearance({ type: "unified", otherChanges, color: color2 }))
new import_obsidian17.Setting(containerEl)
.setName(`Color Scheme`)
.setDesc(`Change the color of the callout for the ${otherColorScheme} color scheme.`)
.addButton((btn) =>
btn
.setClass("clickable-icon")
.setIcon("lucide-sun-moon")
.onClick(() => {
const currentColor = color ?? callout.color
setAppearance({
type: "per-scheme",
colorDark: currentColor,
colorLight: currentColor,
otherChanges,
})
}),
)
new CalloutIconSetting(containerEl, callout, plugin, () => this.nav)
.setName("Icon")
.setDesc("Change the callout icon.")
.setIcon(otherChanges.icon)
.onChange((icon) =>
setAppearance({ type: "unified", color, otherChanges: { ...otherChanges, icon } }),
)
}
}
// src/panes/edit-callout-pane/misc-editor.ts
var import_obsidian18 = require("obsidian")
var MiscEditor = class {
constructor(plugin, callout, containerEl, viewOnly) {
this.plugin = plugin
this.callout = callout
this.containerEl = containerEl
this.viewOnly = viewOnly
this.renameSetting = this.createRenameSetting()
}
/**
* Renders the editors.
*/
render() {
this.containerEl.empty()
if (this.viewOnly) return
if (this.renameSetting != null) this.containerEl.appendChild(this.renameSetting.settingEl)
}
createRenameSetting() {
const { plugin, containerEl, callout } = this
if (callout.sources.length !== 1 || callout.sources[0].type !== "custom") return null
const validity = new ValiditySet(ValiditySet.AllValid)
const desc = document.createDocumentFragment()
desc.createEl("p", { text: "Change the name of this callout." })
desc.createEl("p", {
text: "This will not update any references in your notes!",
cls: "mod-warning",
})
let newIdComponent
return new import_obsidian18.Setting(containerEl)
.setName(`Rename`)
.setDesc(desc)
.addText((cmp) => {
newIdComponent = cmp
cmp.setValue(callout.id).setPlaceholder(callout.id)
const isUnusedId = validity.addSource("unused")
cmp.onChange((value) => {
const alreadyExists = plugin.callouts.has(value)
isUnusedId(!alreadyExists)
cmp.inputEl.classList.toggle("invalid", alreadyExists)
})
makeTextComponentValidateCalloutID(cmp, "id", validity)
})
.addButton((btn) => {
validity.onChangeUpdateDisabled(btn)
btn
.setIcon("lucide-clipboard-signature")
.setTooltip("Rename")
.then(({ buttonEl }) => buttonEl.classList.add("clickable-icon", "mod-warning"))
.onClick(() => {
if (!validity.valid) return
const newId = newIdComponent.getValue()
plugin.renameCustomCallout(callout.id, newId)
this.nav.replace(new EditCalloutPane(plugin, newId, this.viewOnly))
})
})
}
}
// src/panes/edit-callout-pane/section-info.ts
function renderInfo(app2, callout, containerEl) {
const frag = document.createDocumentFragment()
const contentEl = frag.createDiv({ cls: "calloutmanager-edit-callout-section" })
contentEl.createEl("h2", { text: "About this Callout" })
contentEl.createEl("div", { cls: "calloutmanager-edit-callout-info" }, (el) => {
el.appendText("The ")
el.createSpan({ cls: "calloutmanager-edit-callout--callout-id", text: callout.id })
el.appendText(" callout")
el.appendText(" has ")
appendColorInfo(el, callout)
el.appendText(" and ")
appendIconInfo(el, callout)
if (callout.sources.length === 1) {
if (callout.sources[0].type === "builtin") {
el.appendText(". It is one of the built-in callouts.")
return
}
el.appendText(". It was added to Obsidian by the ")
appendSourceInfo(app2, el, callout.sources[0])
el.appendText(".")
return
}
el.appendText(". The callout comes from:")
const sources = el.createEl("ul", { cls: "calloutmanager-edit-callout--callout-source-list" })
for (const source of callout.sources) {
const itemEl = sources.createEl("li")
itemEl.appendText("The ")
appendSourceInfo(app2, itemEl, source)
itemEl.appendText(".")
}
})
containerEl.appendChild(frag)
}
function appendIconInfo(el, callout) {
el.appendText("is using the icon ")
el.createEl("code", { cls: "calloutmanager-edit-callout--callout-icon", text: callout.icon })
}
function appendColorInfo(el, callout) {
const calloutColor = getColorFromCallout(callout)
if (calloutColor == null) {
el.appendText("an invalid color (")
el.createEl("code", {
cls: "calloutmanager-edit-callout--color-invalid",
text: callout.color.trim(),
})
el.appendText(")")
return
}
el.appendText("the color ")
el.createEl(
"code",
{ cls: "calloutmanager-edit-callout--callout-color", text: describeColor(calloutColor) },
(colorEl) => colorEl.style.setProperty("--resolved-callout-color", callout.color),
)
}
function appendSourceInfo(app2, el, source) {
switch (source.type) {
case "builtin":
el.appendText("built-in callouts")
return true
case "custom":
el.appendText("custom callouts you created")
return true
case "snippet":
el.appendText("CSS snippet ")
el.createEl("code", {
cls: "calloutmanager-edit-callout--callout-source",
text: `${source.snippet}.css`,
})
return true
case "theme": {
el.appendText("theme ")
const themeName = getThemeManifest(app2, source.theme)?.name ?? source.theme
el.createSpan({ cls: "calloutmanager-edit-callout--callout-source", text: themeName })
return true
}
}
}
function describeColor(color) {
const hexString = toHexRGB(color)
const rgbString = `${color.r}, ${color.g}, ${color.b}`
const namedColor = default_colors_default.defaultColors[rgbString]
if (namedColor != null) {
return namedColor
}
return hexString
}
// src/panes/edit-callout-pane/section-preview.ts
var import_obsidian19 = require("obsidian")
var EditCalloutPanePreview = class {
constructor(plugin, callout, viewOnly) {
this.previewMarkdown = "Lorem ipsum dolor sit amet."
this.previewEditorEl = null
this.calloutHasIconReady = false
this.calloutId = callout.id
this.plugin = plugin
const frag = document.createDocumentFragment()
this.sectionEl = frag.createDiv({
cls: ["calloutmanager-preview-container", "calloutmanager-edit-callout-preview"],
})
this.preview = new IsolatedCalloutPreviewComponent(this.sectionEl, {
id: callout.id,
title: getTitleFromCallout(callout),
icon: callout.icon,
colorScheme: getCurrentThemeID2(plugin.app),
content: (containerEl) => {
containerEl.createEl("p", { text: this.previewMarkdown })
},
})
if (!viewOnly) {
this.makeEditable()
}
}
makeEditable() {
const contentEl = this.preview.contentEl
this.previewEditorEl = null
this.preview.calloutEl.addEventListener("click", () => {
if (this.previewEditorEl != null) {
return
}
const height = contentEl.getBoundingClientRect().height
contentEl.empty()
new import_obsidian19.TextAreaComponent(contentEl)
.setValue(this.previewMarkdown)
.setPlaceholder("Preview Markdown...")
.then((c) => {
const inputEl = (this.previewEditorEl = c.inputEl)
inputEl.style.setProperty("height", `${height}px`)
inputEl.classList.add("calloutmanager-preview-editor")
inputEl.focus()
inputEl.addEventListener("blur", () => {
const value = c.getValue()
this.previewEditorEl = null
this.previewMarkdown = value
this.changeContent(value)
})
})
})
}
/**
* Refreshes the callout preview's icon.
* We need to do this after the preview is attached to DOM, as we can't get the correct icon until that happens.
*/
refreshPreviewIcon() {
const { iconEl, calloutEl } = this.preview
if (window.document.contains(this.sectionEl)) {
const icon = window.getComputedStyle(calloutEl).getPropertyValue("--callout-icon").trim()
const iconSvg = (0, import_obsidian19.getIcon)(icon) ?? document.createElement("svg")
iconEl.empty()
iconEl.appendChild(iconSvg)
this.calloutHasIconReady = true
}
}
/**
* Changes the preview that is displayed inside the callout.
*
* @param markdown The markdown to render.
*/
async changeContent(markdown) {
const contentEl = this.preview.contentEl
contentEl.empty()
try {
await import_obsidian19.MarkdownRenderer.renderMarkdown(markdown, contentEl, "", void 0)
} catch (ex) {
contentEl.createEl("code").createEl("pre", { text: markdown })
}
}
/**
* Changes the settings for the callout.
* This can be used to show the customized callout.
*
* @param settings The settings to use.
*/
async changeSettings(settings) {
const { preview } = this
const styles = calloutSettingsToCSS(
this.calloutId,
settings,
currentCalloutEnvironment(this.plugin.app),
)
preview.customStyleEl.textContent = styles
preview.resetStylePropertyOverrides()
preview.removeStyles((el) => el.getAttribute("data-callout-manager") === "style-overrides")
this.calloutHasIconReady = false
preview.removeStyles((el) => el.getAttribute("data-inject-id") === "callout-settings")
}
/**
* Attaches the preview to a container.
* @param containerEl The container element.
*/
attach(containerEl) {
containerEl.appendChild(this.sectionEl)
if (!this.calloutHasIconReady) {
this.refreshPreviewIcon()
}
}
}
// src/panes/edit-callout-pane/index.ts
var IMPOSSIBLE_CALLOUT_ID = "[not a real callout]"
var EditCalloutPane = class extends UIPane {
constructor(plugin, id, viewOnly) {
super()
this.plugin = plugin
this.viewOnly = viewOnly
this.title = { title: "Callout", subtitle: id }
this.callout = plugin.callouts.get(id) ?? {
sources: [{ type: "custom" }],
...plugin.calloutResolver.getCalloutProperties(IMPOSSIBLE_CALLOUT_ID),
id,
}
this.previewSection = new EditCalloutPanePreview(plugin, this.callout, false)
this.miscEditorContainerEl = document.createElement("div")
this.miscEditorContainerEl.classList.add(
"calloutmanager-edit-callout-section",
"calloutmanager-edit-callout-section--noborder",
"calloutmanager-edit-callout-misc",
)
this.miscEditor = new MiscEditor(plugin, this.callout, this.miscEditorContainerEl, viewOnly)
Object.defineProperty(this.miscEditor, "nav", {
get: () => this.nav,
})
this.appearanceEditorContainerEl = document.createElement("div")
this.appearanceEditorContainerEl.classList.add(
"calloutmanager-edit-callout-section",
"calloutmanager-edit-callout-appearance",
)
this.appearanceEditorContainerEl.createEl("h2", { text: "Appearance" })
this.appearanceEditorEl = this.appearanceEditorContainerEl.createDiv()
this.changeSettings(plugin.getCalloutSettings(id) ?? [])
}
changeAppearanceEditor(newAppearance) {
const oldAppearance = this.appearance
this.appearance = newAppearance
if (newAppearance.type !== oldAppearance?.type) {
this.appearanceEditor = new APPEARANCE_EDITORS[newAppearance.type]()
Object.defineProperties(this.appearanceEditor, {
nav: { get: () => this.nav },
plugin: { value: this.plugin },
containerEl: { value: this.appearanceEditorEl },
setAppearance: { value: this.onSetAppearance.bind(this) },
})
}
const { appearanceEditor } = this
appearanceEditor.appearance = newAppearance
appearanceEditor.callout = this.callout
}
onSetAppearance(appearance) {
this.changeAppearanceEditor(appearance)
const newSettings = this.appearanceEditor.toSettings()
const { callout } = this
const { calloutResolver } = this.plugin
this.plugin.setCalloutSettings(callout.id, newSettings)
const { color, icon } = calloutResolver.getCalloutProperties(callout.id)
callout.color = color
callout.icon = icon
this.previewSection.changeSettings(newSettings)
this.appearanceEditor.callout = callout
this.appearanceEditorEl.empty()
this.appearanceEditor.render()
this.containerEl.empty()
this.display()
}
/** @override */
display() {
const { containerEl, previewSection, appearanceEditorContainerEl, miscEditorContainerEl } = this
containerEl.empty()
previewSection.attach(containerEl)
renderInfo(this.plugin.app, this.callout, containerEl)
containerEl.appendChild(miscEditorContainerEl)
containerEl.appendChild(appearanceEditorContainerEl)
}
/** @override */
displayControls() {
const { callout, controlsEl } = this
if (!this.viewOnly && callout.sources.length === 1 && callout.sources[0].type === "custom") {
new import_obsidian20.ButtonComponent(controlsEl)
.setIcon("lucide-trash")
.setTooltip("Delete Callout")
.onClick(() => {
this.plugin.removeCustomCallout(callout.id)
this.nav.close()
})
.then(({ buttonEl }) => buttonEl.classList.add("clickable-icon", "mod-warning"))
}
}
/**
* Changes the preview that is displayed inside the callout.
*
* @param markdown The markdown to render.
*/
async changePreview(markdown) {
return this.previewSection.changeContent(markdown)
}
/**
* Changes the styles of the preview that is displayed inside the callout.
*
* @param markdown The markdown to render.
*/
async changeSettings(settings) {
this.changeAppearanceEditor(determineAppearanceType(settings))
this.appearanceEditorEl.empty()
this.appearanceEditor.render()
this.miscEditor.render()
await this.previewSection.changeSettings(settings)
}
}
var APPEARANCE_EDITORS = {
complex: ComplexAppearanceEditor,
unified: UnifiedAppearanceEditor,
"per-scheme": PerSchemeAppearanceEditor,
}
// src/panes/create-callout-pane.ts
var CreateCalloutPane = class extends UIPane {
constructor(plugin) {
super()
this.title = { title: "Callouts", subtitle: "New Callout" }
this.plugin = plugin
this.validity = new ValiditySet(ValiditySet.AllValid)
const btnCreate = (this.btnCreate = document.createElement("button"))
btnCreate.textContent = "Create"
btnCreate.addEventListener("click", (evt) => {
if (!this.validity.valid) {
return
}
const id = this.fieldIdComponent.getValue()
this.plugin.createCustomCallout(id)
this.nav.replace(new EditCalloutPane(this.plugin, id, false))
})
this.fieldId = new import_obsidian21.Setting(document.createElement("div"))
.setHeading()
.setName("Callout Name")
.setDesc("This is how you will refer to your callout in Markdown.")
.addText((cmp) => {
this.fieldIdComponent = cmp
cmp.setPlaceholder("my-awesome-callout")
makeTextComponentValidateCalloutID(cmp, "id", this.validity)
})
this.validity.onChange((valid) => {
this.btnCreate.disabled = !valid
})
}
/** @override */
display() {
const { containerEl } = this
containerEl.appendChild(this.fieldId.settingEl)
containerEl.createDiv().appendChild(this.btnCreate)
}
}
function makeTextComponentValidateCalloutID(cmp, id, vs) {
cmp.then(({ inputEl }) => {
const update = vs.addSource(id)
inputEl.setAttribute("pattern", "^[a-z\\-]{1,}$")
inputEl.setAttribute("required", "required")
inputEl.addEventListener("change", onChange)
inputEl.addEventListener("input", onChange)
update(inputEl.validity.valid)
function onChange() {
update(inputEl.validity.valid)
}
})
}
// src/panes/manage-callouts-pane.ts
var ManageCalloutsPane = class extends UIPane {
constructor(plugin) {
super()
this.title = { title: "Callouts", subtitle: "Manage" }
this.plugin = plugin
this.viewOnly = false
this.searchQuery = ""
const { searchErrorDiv, searchErrorQuery } = createEmptySearchResultDiv()
this.searchErrorDiv = searchErrorDiv
this.searchErrorQuery = searchErrorQuery
}
/**
* Change the search query and re-render the panel.
* @param query The search query.
*/
search(query) {
this.doSearch(query)
this.display()
}
doSearch(query) {
try {
this.callouts = this.searchFn(query)
this.setSearchError?.(false)
} catch (ex) {
this.setSearchError?.(ex.message)
}
}
/**
* Refresh the callout previews.
* This regenerates the previews and their metadata from the list of callouts known to the plugin.
*/
invalidate() {
const { plugin, viewOnly } = this
this.searchFn = calloutSearch(plugin.callouts.values(), {
preview: createPreviewFactory(viewOnly),
})
this.doSearch(this.searchQuery)
}
onCalloutButtonClick(evt) {
let id = null
let action = null
for (
let target = evt.targetNode;
target != null && (id == null || action == null);
target = target?.parentElement
) {
if (!(target instanceof Element)) continue
if (id == null) {
id = target.getAttribute("data-callout-manager-callout")
}
if (action == null) {
action = target.getAttribute("data-callout-manager-action")
}
}
if (id == null || action == null) {
return
}
if (action === "edit") {
this.nav.open(new EditCalloutPane(this.plugin, id, this.viewOnly))
} else if (action === "insert") {
const view = app.workspace.getActiveViewOfType(import_obsidian22.MarkdownView)
if (view) {
const cursor = view.editor.getCursor()
view.editor.replaceRange(
`> [!${id}]
> Contents`,
cursor,
)
view.editor.setCursor(cursor.line + 1, 10)
closeSettings(app)
}
}
}
/** @override */
display() {
const contentEl = document.createDocumentFragment().createDiv()
contentEl.addEventListener("click", this.onCalloutButtonClick.bind(this))
const { callouts } = this
for (const callout of callouts) {
contentEl.appendChild(callout.preview)
}
if (callouts.length === 0) {
contentEl.appendChild(this.searchErrorDiv)
}
const { containerEl } = this
containerEl.empty()
containerEl.appendChild(contentEl)
}
/** @override */
displayControls() {
const { controlsEl } = this
const filter2 = new import_obsidian22.TextComponent(controlsEl)
.setValue(this.searchQuery)
.setPlaceholder("Filter callouts...")
.onChange(this.search.bind(this))
this.setSearchError = (message) => {
filter2.inputEl.classList.toggle("mod-error", !!message)
if (message) {
filter2.inputEl.setAttribute("aria-label", message)
} else {
filter2.inputEl.removeAttribute("aria-label")
}
}
if (!this.viewOnly) {
new import_obsidian22.ButtonComponent(controlsEl)
.setIcon("lucide-plus")
.setTooltip("New Callout")
.onClick(() => this.nav.open(new CreateCalloutPane(this.plugin)))
.then(({ buttonEl }) => buttonEl.classList.add("clickable-icon"))
}
}
/** @override */
restoreState(state) {
this.invalidate()
}
/** @override */
onReady() {
this.invalidate()
}
}
function createPreviewFactory(viewOnly) {
const editButtonContent =
(viewOnly
? (0, import_obsidian22.getIcon)("lucide-view")
: (0, import_obsidian22.getIcon)("lucide-edit")) ?? document.createTextNode("Edit Callout")
const insertButtonContent =
(viewOnly
? (0, import_obsidian22.getIcon)("lucide-view")
: (0, import_obsidian22.getIcon)("lucide-forward")) ??
document.createTextNode("Insert Callout")
return (callout) => {
const frag = document.createDocumentFragment()
const calloutContainerEl = frag.createDiv({
cls: ["calloutmanager-preview-container"],
attr: {
["data-callout-manager-callout"]: callout.id,
},
})
new CalloutPreviewComponent(calloutContainerEl, {
id: callout.id,
icon: callout.icon,
title: getTitleFromCallout(callout),
color: getColorFromCallout(callout) ?? void 0,
})
calloutContainerEl.classList.add("calloutmanager-preview-container-with-button")
const editButton = calloutContainerEl.createEl("button")
editButton.setAttribute("data-callout-manager-action", "edit")
editButton.appendChild(editButtonContent.cloneNode(true))
const insertButton = calloutContainerEl.createEl("button")
insertButton.setAttribute("data-callout-manager-action", "insert")
insertButton.appendChild(insertButtonContent.cloneNode(true))
return calloutContainerEl
}
}
function createEmptySearchResultDiv() {
let searchErrorQuery
const searchErrorDiv = document.createElement("div")
searchErrorDiv.className = "calloutmanager-centerbox"
const contentEl = searchErrorDiv.createDiv({ cls: "calloutmanager-search-error" })
contentEl.createEl("h2", { text: "No callouts found." })
contentEl.createEl("p", void 0, (el) => {
el.createSpan({ text: "Your search query " })
searchErrorQuery = el.createEl("code", { text: "" })
el.createSpan({ text: " did not return any results." })
})
contentEl.createDiv({ cls: "calloutmanager-search-error-suggestions" }, (el) => {
el.createDiv({ text: "Try searching:" })
el.createEl("ul", void 0, (el2) => {
el2.createEl("li", { text: "By name: " }, (el3) => {
el3.createEl("code", { text: "warning" })
})
el2.createEl("li", { text: "By icon: " }, (el3) => {
el3.createEl("code", { text: "icon:check" })
})
el2.createEl("li", { text: "Built-in callouts: " }, (el3) => {
el3.createEl("code", { text: "from:obsidian" })
})
el2.createEl("li", { text: "Theme callouts: " }, (el3) => {
el3.createEl("code", { text: "from:theme" })
})
el2.createEl("li", { text: "Snippet callouts: " }, (el3) => {
el3.createEl("code", { text: "from:my snippet" })
})
el2.createEl("li", { text: "Custom callouts: " }, (el3) => {
el3.createEl("code", { text: "from:custom" })
})
})
})
return { searchErrorDiv, searchErrorQuery }
}
// src/panes/manage-plugin-pane.ts
var import_obsidian24 = require("obsidian")
// src/changelog.ts
var import_obsidian23 = require("obsidian")
// CHANGELOG.md
var CHANGELOG_default = `# Version 1.1.0
> [!new] In-App Changelogs
> Learn about plugin changes and new features straight from the horse's mouth.
> [!new] Insert Callouts
> Goodbye to the days of needing to type callouts by hand, and hello to having more ways to suit your workflow. You now have the option to insert callouts directly from the "Manage Callouts" pane!
>
> Thank you, [**@decheine**](https://github.com/decheine)!
> [!new] Color Dropdown
> Pick from a nifty dropdown instead of memorizing color values.
>
> Thank you, [**@decheine**](https://github.com/decheine)!
> [!new] Rename Callouts
> You can now rename your custom callouts.
> [!fix] More Robust Callout Detection
> The code monkey (developer) learned a couple new tricks, and now Callout Manager can detect Obsidian callouts on all platforms and versions without resorting to fallback lists.
> [!fix] Integration with Completr
> You can now use [Completr](obsidian://show-plugin?id=obsidian-completr) to autocomplete callouts!
# Version 1.0.1
The first release available on Obsidian's community plugin browser!
# Version 1.0.0
> [!new] Callout Customization
> Change callouts to your heart's content!
> [!new] Automatic Detection
> Browse and search through your one and only list of available callouts.
`
// src/changelog.ts
function getSections() {
const frag = document.createDocumentFragment()
const renderedEl = frag.createDiv()
import_obsidian23.MarkdownRenderer.renderMarkdown(CHANGELOG_default, renderedEl, "", null)
const sections = /* @__PURE__ */ new Map()
let heading = null
let sectionContainer = frag.createEl("details")
let sectionSummary = sectionContainer.createEl("summary")
let sectionContents = []
const addPreviousSection = () => {
if (heading != null && heading.textContent !== null) {
const headingText = heading.textContent
const titleEl = sectionSummary.createEl("h2", {
cls: "calloutmanager-changelog-heading",
text: headingText,
})
const contentsEl = sectionContainer.createDiv(
{
cls: "calloutmanager-changelog-section",
},
(el) => {
sectionContents.forEach((node) => el.appendChild(node))
},
)
Array.from(contentsEl.querySelectorAll(".callout[data-callout]")).forEach((el) => {
el.setAttribute("data-calloutmanager-changelog-callout", el.getAttribute("data-callout"))
el.removeAttribute("data-callout")
})
const version = /^\s*Version ([0-9.]+)\s*$/.exec(headingText)?.[1]
sections.set(version ?? heading.textContent, {
version,
contentsEl,
containerEl: sectionContainer,
titleEl,
})
}
heading = null
sectionContainer = frag.createEl("details")
sectionSummary = sectionContainer.createEl("summary")
sectionContents = []
}
for (let node = renderedEl.firstChild; node != null; node = node?.nextSibling) {
if (node instanceof HTMLHeadingElement && node.tagName === "H1") {
addPreviousSection()
heading = node
continue
}
sectionContents.push(node)
}
addPreviousSection()
return sections
}
// src/panes/changelog-pane.ts
var ChangelogPane = class extends UIPane {
constructor(plugin) {
super()
this.title = "Changelog"
this.plugin = plugin
const sections = getSections()
const frag = document.createDocumentFragment()
this.changelogEl = frag.createDiv({ cls: "calloutmanager-changelog" })
Array.from(sections.values()).forEach(({ version, containerEl: el }) => {
this.changelogEl.appendChild(el)
if (version === this.plugin.manifest.version) {
el.setAttribute("open", "")
el.setAttribute("data-current-version", "true")
}
})
}
/** @override */
display() {
const { containerEl } = this
containerEl.appendChild(this.changelogEl)
}
}
// src/panes/manage-plugin-pane.ts
var ManagePluginPane = class extends UIPane {
constructor(plugin) {
super()
this.title = "Callout Manager Settings"
this.plugin = plugin
}
/** @override */
display() {
const { containerEl, plugin } = this
new import_obsidian24.Setting(containerEl)
.setName("Manage Callouts")
.setDesc("Create or edit Markdown callouts.")
.addButton((btn) => {
btn.setButtonText("Manage Callouts")
btn.onClick(() => this.nav.open(new ManageCalloutsPane(plugin)))
})
new import_obsidian24.Setting(containerEl).setHeading().setName("Callout Detection")
new import_obsidian24.Setting(containerEl)
.setName("Obsidian")
.setDesc(
(() => {
const desc = document.createDocumentFragment()
const container = desc.createDiv()
const method = plugin.cssWatcher.describeObsidianFetchMethod()
container.createDiv({
text: `Find built-in Obsidian callouts${method === "" ? "" : " "}${method}.`,
})
return desc
})(),
)
.addToggle((setting) => {
setting.setValue(plugin.settings.calloutDetection.obsidian).onChange((v) => {
plugin.settings.calloutDetection.obsidian = v
plugin.saveSettings()
plugin.refreshCalloutSources()
})
})
new import_obsidian24.Setting(containerEl)
.setName("Theme")
.setDesc("Find theme-provided callouts.")
.addToggle((setting) => {
setting.setValue(plugin.settings.calloutDetection.theme).onChange((v) => {
plugin.settings.calloutDetection.theme = v
plugin.saveSettings()
plugin.refreshCalloutSources()
})
})
new import_obsidian24.Setting(containerEl)
.setName("Snippet")
.setDesc("Find callouts in custom CSS snippets.")
.addToggle((setting) => {
setting.setValue(plugin.settings.calloutDetection.snippet).onChange((v) => {
plugin.settings.calloutDetection.snippet = v
plugin.saveSettings()
plugin.refreshCalloutSources()
})
})
new import_obsidian24.Setting(containerEl)
.setHeading()
.setName("What's New")
.setDesc(`Version ${this.plugin.manifest.version}`)
.addExtraButton((btn) => {
btn
.setIcon("lucide-more-horizontal")
.setTooltip("More Changelogs")
.onClick(() => this.nav.open(new ChangelogPane(plugin)))
})
const latestChanges = getSections().get(this.plugin.manifest.version)
if (latestChanges != null) {
const desc = document.createDocumentFragment()
desc.appendChild(latestChanges.contentsEl)
new import_obsidian24.Setting(containerEl)
.setDesc(desc)
.then((setting) => setting.controlEl.remove())
.then((setting) => setting.settingEl.classList.add("calloutmanager-latest-changes"))
}
new import_obsidian24.Setting(containerEl).setHeading().setName("Reset")
new import_obsidian24.Setting(containerEl)
.setName("Reset Callout Settings")
.setDesc("Reset all the changes you made to callouts.")
.addButton(
withConfirm((btn) => {
btn.setButtonText("Reset").onClick(() => {
this.plugin.settings.callouts.settings = {}
this.plugin.saveSettings()
this.plugin.applyStyles()
btn.setButtonText("Reset").setDisabled(true)
})
}),
)
new import_obsidian24.Setting(containerEl)
.setName("Reset Custom Callouts")
.setDesc("Removes all the custom callouts you created.")
.addButton(
withConfirm((btn) => {
btn.setButtonText("Reset").onClick(() => {
const { settings } = this.plugin
for (const custom of settings.callouts.custom) {
delete settings.callouts.settings[custom]
}
settings.callouts.custom = []
this.plugin.saveSettings()
this.plugin.callouts.custom.clear()
this.plugin.applyStyles()
this.plugin.refreshCalloutSources()
btn.setButtonText("Reset").setDisabled(true)
})
}),
)
}
}
function withConfirm(callback) {
let onClickHandler = void 0
let resetButtonClicked = false
return (btn) => {
btn.setWarning().onClick(() => {
if (!resetButtonClicked) {
resetButtonClicked = true
btn.setButtonText("Confirm")
return
}
if (onClickHandler != void 0) {
onClickHandler()
}
})
btn.onClick = (handler) => {
onClickHandler = handler
return btn
}
callback(btn)
}
}
// src/settings.ts
function defaultSettings() {
return {
callouts: {
custom: [],
settings: {},
},
calloutDetection: {
obsidian: true,
theme: true,
snippet: true,
},
}
}
function migrateSettings(into, from) {
const merged = Object.assign(into, {
...from,
calloutDetection: {
...into.calloutDetection,
...(from?.calloutDetection ?? {}),
},
})
delete merged.calloutDetection.obsidianFallbackForced
return merged
}
// src/main.ts
var CalloutManagerPlugin = class extends import_obsidian25.Plugin {
constructor() {
super(...arguments)
this.apiReadyWait = new Promise((resolve, reject) => (this.apiReadySignal = resolve))
}
/** @override */
async onload() {
await this.loadSettings()
await this.saveSettings()
const { settings } = this
this.calloutResolver = new CalloutResolver()
this.register(() => this.calloutResolver.unload())
this.callouts = new CalloutCollection((id) => {
const { icon, color } = this.calloutResolver.getCalloutProperties(id)
console.debug("Resolved Callout:", id, { icon, color })
return {
id,
icon,
color,
}
})
this.callouts.custom.add(...settings.callouts.custom)
this.cssApplier = createCustomStyleSheet(this.app, this)
this.cssApplier.setAttribute("data-callout-manager", "style-overrides")
this.register(this.cssApplier)
this.applyStyles()
this.cssWatcher = new StylesheetWatcher(this.app)
this.cssWatcher.on("add", this.updateCalloutSource.bind(this))
this.cssWatcher.on("change", this.updateCalloutSource.bind(this))
this.cssWatcher.on("remove", this.removeCalloutSource.bind(this))
this.cssWatcher.on("checkComplete", () => {
this.ensureChangedCalloutsKnown()
})
this.cssWatcher.on("checkComplete", (anyChanges) => {
if (anyChanges) {
this.api.emitEventForCalloutChange()
}
})
this.app.workspace.onLayoutReady(() => {
this.register(this.cssWatcher.watch())
})
this.registerEvent(
this.app.workspace.on("css-change", () => {
this.calloutResolver.reloadStyles()
this.applyStyles()
}),
)
this.settingTab = new UISettingTab(this, () => new ManagePluginPane(this))
this.addSettingTab(this.settingTab)
this.addCommand({
id: "manage-callouts",
name: "Edit callouts",
callback: () => {
this.settingTab.openWithPane(new ManageCalloutsPane(this))
},
})
this.api = new CalloutManagerAPIs(this)
this.apiReadySignal()
this.addRibbonIcon("lucide-gallery-vertical", "Insert Callout", () => {
this.settingTab.openWithPane(new ManageCalloutsPane(this))
})
}
async loadSettings() {
this.settings = migrateSettings(defaultSettings(), await this.loadData())
}
async saveSettings() {
await this.saveData(this.settings)
}
/**
* Takes in a stylesheet from the watcher and updates the callout collection.
* @param ss The stylesheet.
*/
updateCalloutSource(ss) {
const callouts = getCalloutsFromCSS(ss.styles)
const { calloutDetection } = this.settings
switch (ss.type) {
case "obsidian":
if (calloutDetection.obsidian === true && !calloutDetection.obsidianFallbackForced) {
callouts.push("note")
this.callouts.builtin.set(callouts)
}
return
case "theme":
if (calloutDetection.theme) {
this.callouts.theme.set(ss.theme, callouts)
}
return
case "snippet":
if (calloutDetection.snippet) {
this.callouts.snippets.set(ss.snippet, callouts)
}
return
}
}
/**
* Forces the callout sources to be refreshed.
* This is used to re-detect the sources when settings are changed.
*/
refreshCalloutSources() {
this.callouts.snippets.clear()
this.callouts.theme.delete()
this.callouts.builtin.set([])
this.cssWatcher.checkForChanges(true).then(() => {
this.ensureChangedCalloutsKnown()
})
}
/**
* Ensures that any callouts which have changed settings are detected by the plugin.
*
* If a non-builtin callout was configured by the user and then removed, this plugin should consider
* the callout to be custom so it can be seen in the list.
*/
ensureChangedCalloutsKnown() {
let hasAddedCallout = false
const { settings, callouts } = this
for (const [id, changes] of Object.entries(settings.callouts.settings)) {
if (!callouts.has(id) && changes.length > 0) {
hasAddedCallout = true
callouts.custom.add(id)
}
}
if (hasAddedCallout) {
this.saveCustomCallouts()
this.api.emitEventForCalloutChange()
}
}
saveCustomCallouts() {
const { callouts, settings } = this
settings.callouts.custom = callouts.custom.keys()
return this.saveSettings()
}
/**
* Create a custom callout and add it to Obsidian.
* @param id The custom callout ID.
*/
createCustomCallout(id) {
const { callouts } = this
callouts.custom.add(id)
this.saveCustomCallouts()
this.api.emitEventForCalloutChange(id)
}
/**
* Rename a custom callout.
*
* @param oldId The old callout ID.
* @param newId The new callout ID.
* @throws If the callout has any other sources than "custom".
* @throws If the old ID does not exist.
* @throws If the new ID already exists.
*/
renameCustomCallout(oldId, newId) {
const { callouts, settings } = this
const callout = callouts.get(oldId)
if (callout == null) throw new Error(`Callout '${oldId}' does not exist.`)
if (callouts.get(newId) != null) throw new Error(`Callout '${newId}' already exists.`)
if (callout.sources.length !== 1 || callout.sources[0].type !== "custom") {
throw new Error(`Cannot rename non-custom callout '${oldId}'.`)
}
callouts.custom.delete(oldId)
callouts.custom.add(newId)
settings.callouts.custom = callouts.custom.keys()
settings.callouts.settings[newId] = settings.callouts.settings[oldId]
delete settings.callouts.settings[oldId]
this.applyStyles()
this.saveCustomCallouts()
this.api.emitEventForCalloutChange(oldId)
this.api.emitEventForCalloutChange(newId)
}
/**
* Delete a custom callout.
* If there are no other sources for the callout, its settings will be purged.
*
* @param id The custom callout ID.
*/
removeCustomCallout(id) {
const { callouts, settings } = this
callouts.custom.delete(id)
settings.callouts.custom = callouts.custom.keys()
const calloutInstance = callouts.get(id)
if (calloutInstance == null || calloutInstance.sources.length < 1) {
delete settings.callouts.settings[id]
this.applyStyles()
}
this.saveSettings()
this.api.emitEventForCalloutChange(id)
}
/**
* Gets the custom settings for a callout.
*
* @param id The callout ID.
* @returns The custom settings, or undefined if there are none.
*/
getCalloutSettings(id) {
const calloutSettings = this.settings.callouts.settings
if (!Object.prototype.hasOwnProperty.call(calloutSettings, id)) {
return void 0
}
return calloutSettings[id]
}
/**
* Sets the custom settings for a callout.
*
* @param id The callout ID.
* @param settings The callout settings.
*/
setCalloutSettings(id, settings) {
const calloutSettings = this.settings.callouts.settings
if (settings === void 0 || settings.length < 1) {
delete calloutSettings[id]
} else {
calloutSettings[id] = settings
}
this.saveSettings()
this.applyStyles()
this.callouts.invalidate(id)
this.api.emitEventForCalloutChange(id)
}
/**
* Generates the stylesheet for the user's custom callout settings and applies it to the page and the callout
* resolver's custom stylesheet.
*/
applyStyles() {
const env = currentCalloutEnvironment(this.app)
const css = []
for (const [id, settings] of Object.entries(this.settings.callouts.settings)) {
css.push(calloutSettingsToCSS(id, settings, env))
}
const stylesheet = css.join("\n\n")
this.cssApplier.css = stylesheet
this.calloutResolver.customStyleEl.textContent = stylesheet
}
/**
* Takes in a stylesheet from the watcher and removes its callouts from the callout collection.
* @param ss The stylesheet.
*/
removeCalloutSource(ss) {
switch (ss.type) {
case "obsidian":
this.callouts.builtin.set([])
return
case "theme":
this.callouts.theme.delete()
return
case "snippet":
this.callouts.snippets.delete(ss.snippet)
return
}
}
/**
* Creates (or gets) an instance of the Callout Manager API for a plugin.
* If the plugin is undefined, only trivial functions are available.
*
* @param version The API version.
* @param consumerPlugin The plugin using the API.
*
* @internal
*/
async newApiHandle(version, consumerPlugin, cleanupFunc) {
await this.apiReadyWait
return this.api.newHandle(version, consumerPlugin, cleanupFunc)
}
/**
* Destroys an API handle created by {@link newApiHandle}.
*
* @param version The API version.
* @param consumerPlugin The plugin using the API.
*
* @internal
*/
destroyApiHandle(version, consumerPlugin) {
if (version !== "v1") throw new Error(`Unsupported Callout Manager API: ${version}`)
return this.api.destroyHandle(version, consumerPlugin)
}
}