Initial commit

This commit is contained in:
gapmiss 2025-04-04 18:11:01 -05:00
commit 77f3a98765
24 changed files with 1723 additions and 0 deletions

10
.editorconfig Normal file
View file

@ -0,0 +1,10 @@
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4

3
.eslintignore Normal file
View file

@ -0,0 +1,3 @@
node_modules/
main.js

23
.eslintrc Normal file
View file

@ -0,0 +1,23 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"env": { "node": true },
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"sourceType": "module"
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off"
}
}

27
.gitignore vendored Normal file
View file

@ -0,0 +1,27 @@
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
dist/*
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
.hotreload
package-lock.json
pnpm-lock.yaml
build.sh

1
.npmrc Normal file
View file

@ -0,0 +1 @@
tag-version-prefix=""

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 gapmiss
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

152
README.md Normal file
View file

@ -0,0 +1,152 @@
# Inline Callouts
## Introduction
A plugin for displaying inline "callouts" in [Obsidian.md](https://github.com/obsidianmd), a successor to the [Badges](https://github.com/gapmiss/badges) plugin by the same author. Key differences: simpler syntax, access to all Lucide icons used by Obsidian, modal interfaces for icon suggestions, new inline callout and modify inline callout.
- [Features](#features)
- [Usage](#usage)
- [Caveats](#caveats)
- [Installation](#installation)
- [CSS styles](#css)
- [Style Settings plugin](#style-settings-plugin)
- [Development](#development)
- [Credits](#credits)
- [Notes](#notes)
## Features
## Usage
### Syntax
```markdown
`[!!ICON|LABEL|COLOR]`
```
| Syntax | Details |
| ----------------- | ------------------------------ |
| `ICON` | name of the Lucide icon |
| `LABEL`(optional) | callout label/title text |
| `COLOR`(optional) | RGB values or Obsidian CSS var |
> [!IMPORTANT]
> The `LABEL` cannot contain either the `|` pipe or the <code>`</code> backtick symbols, as they are used as delimiters for the custom syntax.
## Caveats
- No extraneous `|` or <code>`</code> characters
- Consecutive callouts must be separated by at lease one space, character, or line return to be rendered correctly in reading view.
- Does not work inside a wiki-link
- Markdown and HTML code is NOT rendered in the callout label
- When using inline callouts in a table, the pipe characters must be escaped. e.g.:
```
| example one | example two |
| ---------------------------------------- | ----------------------------- |
| `[!!info\|Lorem\|var(--color-blue-rgb)]` | Lorem ipsum `[!!check-check]` |
```
## Installation
From Obsidian's settings or preferences:
1. [Open in Obsidian.md](obsidian://show-plugin?id=inline-callouts)
or:
1. Community Plugins > Browse
2. Search for "Badges"
Manually:
1. download the latest [release archive](https://github.com/gapmiss/inline-callouts/releases/download/0.1.0/inline-callouts-v0.1.0.zip)
2. uncompress the downloaded archive
3. move the `inline-callouts` folder to `/path/to/vault/.obsidian/plugins/`
4. Settings > Community plugins > reload **Installed plugins**
5. enable plugin
or:
1. download `main.js`, `manifest.json` & `styles.css`
2. create a new folder `/path/to/vault/.obsidian/plugins/inline-callouts`
3. move all 3 files to `/path/to/vault/.obsidian/plugins/inline-callouts`
4. Settings > Community plugins > reload **Installed plugins**
5. enable plugin
## CSS
Custom `CSS` styles can be applied via CSS snippets. All colors and styles can be over-written just the same.
See [CSS snippets - Obsidian Help](https://help.obsidian.md/Extending+Obsidian/CSS+snippets)
### variables
```css
body {
--inline-callout-font-size: .85em;
--inline-callout-font-weight: 400;
--inline-callout-border-radius: 4px;
--inline-callout-bg-transparency: .1;
--inline-callout-margin: 1px;
--inline-callout-padding-top: 1px;
--inline-callout-padding-right: 0px;
--inline-callout-padding-bottom: 2px;
--inline-callout-padding-left: 0px;
--inline-callout-icon-size: .85em;
--inline-callout-icon-margin-top: 3px;
--inline-callout-icon-margin-right: 3px;
--inline-callout-icon-margin-bottom: 0px;
--inline-callout-icon-margin-left: 5px;
--inline-callout-label-margin-top: 2px;
--inline-callout-label-margin-right: 5px;
--inline-callout-label-margin-bottom: 0px;
--inline-callout-label-margin-left: 2px;
}
```
## Style Settings plugin
All the above CSS variables can be modified via the [Style Settings plugin](https://github.com/mgmeyers/obsidian-style-settings)
## Development
### Clone this repo
```bash
cd /path/to/vault/.obsidian/plugins
git clone https://github.com/gapmiss/inline-callouts.git
cd inline-callouts
```
### Install packages and run
```bash
pnpm i
pnpm run dev
```
### Enable plugin
1. open `Settings``Community plugins`
2. enable the `Inline Callouts` plugin.
## Credits
Some code inspired by and derived from:
- [chrisgurney/obsidian-note-toolbar](https://github.com/chrisgurney/obsidian-note-toolbar/)
- [javalent/admonitions](https://github.com/javalent/admonitions/)
- [Moyf/easy-copy](https://github.com/Moyf/easy-copy/)
- [nhaouari/obsidian-textgenerator-plugin](https://github.com/nhaouari/obsidian-textgenerator-plugin/)
- [steven-kraft/obsidian-markdown-furigana](https://github.com/steven-kraft/obsidian-markdown-furigana)
Thank you!
## Notes
[Lucide](https://github.com/lucide-icons/lucide) Icons: https://lucide.dev
Lucide Icons LICENSE: https://lucide.dev/license

48
esbuild.config.mjs Normal file
View file

@ -0,0 +1,48 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: false,
treeShaking: true,
outfile: "dist/main.js",
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "inline-callouts",
"name": "Inline Callouts",
"version": "0.1.0",
"minAppVersion": "0.15.0",
"description": "Add inline callouts/badges/icons to notes.",
"author": "@gapmiss",
"authorUrl": "https://github.com/gapmiss",
"isDesktopOnly": false
}

28
package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "inline-callouts",
"version": "0.1.0",
"description": "Badges for Obsidian (https://obsidian.md)",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
},
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
}

88
src/callout/builder.ts Normal file
View file

@ -0,0 +1,88 @@
import {
getIconIds,
setIcon
} from 'obsidian';
export class InlineCallout {
newEl: HTMLSpanElement;
iconEl: HTMLSpanElement;
labelEl: HTMLSpanElement;
textEl: HTMLSpanElement;
constructor() {
this.newEl = document.createElement("span");
this.iconEl = document.createElement("span");
this.labelEl = document.createElement("span");
this.textEl = document.createElement("span");
}
public build(text: string) {
// text content from <code>
let part: string = text.substring(3);
let content: string = part.slice(0, -1);
// parts array split on pipe
let parts: any[] = content.split('|');
// icon
let calloutIcon: string | null = parts[0] ? parts[0].trim().replace(/\\+$/, '').toLowerCase() : null;
// label
let calloutLabel: string | null = parts[1] ? parts[1].trim().replace(/\\+$/, '') : null;
// color
let calloutColor: string | null = parts[2] ? parts[2].trim() : null;
let calloutColorStyle: string;
// no content, return <code>
if (
!content.length
|| parts.length === 0
|| !calloutIcon
|| (getIconIds().indexOf('lucide-' + calloutIcon) == -1 && getIconIds().indexOf(calloutIcon) == -1) // 404, no icon found
) {
this.newEl = document.createElement("code");
this.newEl.setText(text)
return this.newEl;
}
this.newEl.addClass("inline-callout");
this.newEl.setAttr("data-inline-callout", calloutIcon);
this.iconEl.addClass("inline-callout-icon");
this.iconEl.setAttr("data-tooltip-position", 'top');
this.labelEl.addClass("inline-callout-label");
// icon only
if (parts.length === 1) {
this.iconEl.setAttr("aria-label", calloutIcon);
setIcon(this.iconEl, calloutIcon);
this.newEl.appendChild(this.iconEl);
return this.newEl;
}
// icon and color only
if (!calloutLabel && calloutColor) {
calloutColorStyle = "color: rgba(" + calloutColor + ", 1);"
this.iconEl.setAttr("style", calloutColorStyle);
this.iconEl.setAttr("aria-label", calloutIcon);
setIcon(this.iconEl, calloutIcon);
this.newEl.appendChild(this.iconEl);
return this.newEl;
}
// icon
this.iconEl.setAttr("aria-label", calloutIcon);
setIcon(this.iconEl, calloutIcon);
this.newEl.appendChild(this.iconEl);
// label?
if (calloutLabel) {
this.labelEl.setText(calloutLabel!);
this.newEl.appendChild(this.labelEl);
}
// color?
if (calloutLabel && calloutColor) {
calloutColorStyle = "color: rgba(" + calloutColor + ", 1); background-color: rgba(" + calloutColor + ", var(--inline-callout-bg-transparency));"
this.newEl.setAttr("style", calloutColorStyle)
}
return this.newEl;
}
}

262
src/main.ts Normal file
View file

@ -0,0 +1,262 @@
import {
Plugin,
MarkdownPostProcessor,
MarkdownPostProcessorContext,
Editor,
MarkdownView,
Notice,
Menu
} from 'obsidian';
import {
InlineCalloutsSettings,
DEFAULT_SETTINGS,
InlineCalloutsSettingTab
} from './settings';
import { SearchInlineCalloutsModal } from './modal/search';
import { ModifyInlineCalloutModal } from './modal/modify';
import { NewInlineCalloutModal } from './modal/new';
import { EditorIconSuggest } from './suggest/editorIcon';
import { InlineCallout } from './callout/builder';
import { viewPlugin } from './views/editor';
enum ContextType {
NULL = 'null',
INLINECODE = 'inline-code'
}
interface ContextData {
type: ContextType;
curLine: string;
match: string | null;
range: [number, number];
}
export default class InlineCalloutsPlugin extends Plugin {
settings: InlineCalloutsSettings;
public postprocessor: MarkdownPostProcessor = (el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
const blockToReplace = el.querySelectorAll('code')
if (blockToReplace.length === 0) return
function replace(node: Node) {
const childrenToReplace: Text[] = []
node.childNodes.forEach(child => {
if (child.nodeType === 3) {
childrenToReplace.push(child as Text)
}
})
childrenToReplace.forEach((child) => {
child.replaceWith(child);
})
}
blockToReplace.forEach(block => {
replace(block)
})
}
async onload() {
await this.loadSettings();
this.registerMarkdownPostProcessor(
this.buildPostProcessor()
);
this.registerEditorExtension(viewPlugin)
if (this.settings.enableSuggester) {
this.registerEditorSuggest(new EditorIconSuggest(this));
}
this.addCommand({
id: "new-inline-callout",
name: "New inline callout",
editorCallback: (editor) => {
let modal = new NewInlineCalloutModal(this, editor);
modal.open();
}
});
if (this.settings.enableEditing) {
this.addCommand({
id: "modify-inline-callout",
name: "Modify inline callout",
editorCheckCallback: (checking, editor, view: MarkdownView) => {
let res = this.checkContextType(editor, view);
if (res) {
if (!checking) {
this.modifyInlineCallout(editor, view);
}
return true;
}
return false;
}
});
}
this.addCommand({
id: "search-inline-callouts",
name: "Search for inline callouts",
callback: () => {
let modal = new SearchInlineCalloutsModal(this);
modal.open();
}
});
this.registerEvent(
this.app.workspace.on("editor-menu", (menu: Menu, editor: Editor, view: MarkdownView) => {
if (this.settings.enableEditing) {
let res = this.checkContextType(editor, view);
if (res) {
menu.addItem(item => {
item
.setTitle('Modify inline callout')
.setIcon('quote')
.onClick(async () => {
this.modifyInlineCallout(editor, view);
});
})
return true;
}
return false;
}
})
);
this.addSettingTab(new InlineCalloutsSettingTab(this.app, this));
}
onunload() { }
private checkContextType(editor: Editor, view: MarkdownView) {
const file = view.file;
if (!file) {
new Notice("Cannot get current file");
return { type: ContextType.NULL, curLine: '', match: null, range: null };
}
const cursor = editor.getCursor();
const curLine = editor.getLine(cursor.line);
const curCh = cursor.ch;
const beforeCursor = curLine.slice(0, curCh);
const afterCursor = curLine.slice(curCh);
let matcher = { type: ContextType.INLINECODE, regex: /`(\[\!\![^`]+\])`/g, enable: true }
const matchInfo = this.getMatchInfo(beforeCursor, afterCursor, matcher.regex);
if (matchInfo) {
return true;
}
return false;
}
private getMatchInfo(beforeCursor: string, afterCursor: string, regex: RegExp): { content: string; range: [number, number] } | null {
let match;
while ((match = regex.exec(beforeCursor + afterCursor)) !== null && afterCursor !== '') {
const matchStart = match.index;
const matchEnd = match.index + match[0].length;
if (beforeCursor.length >= matchStart && beforeCursor.length <= matchEnd) {
return {
content: match[1],
range: [matchStart, matchEnd],
};
}
}
return null;
}
private determineContextType(editor: Editor, view: MarkdownView): ContextData {
const file = view.file;
if (!file) {
new Notice("Cannot get current file");
return { type: ContextType.NULL, curLine: '', match: null, range: [0, 0] };
}
const cursor = editor.getCursor();
const curLine = editor.getLine(cursor.line);
const curCh = cursor.ch;
const beforeCursor = curLine.slice(0, curCh);
const afterCursor = curLine.slice(curCh);
let matcher = { type: ContextType.INLINECODE, regex: /`(\[\!\![^`]+\])`/g, enable: true }
const matchInfo = this.getMatchInfo(beforeCursor, afterCursor, matcher.regex);
if (matchInfo) {
return {
type: matcher.type,
curLine,
match: matchInfo.content,
range: matchInfo.range,
};
}
return { type: ContextType.NULL, curLine, match: null, range: [0, 0] };
}
private modifyInlineCallout(editor: Editor, view: MarkdownView): void {
const file = view.file;
if (!file) {
new Notice("Cannot get current file");
return;
}
const filename = file.basename;
const contextType = this.determineContextType(editor, view);
if (contextType.type == ContextType.NULL) {
new Notice("No inline callout found at current cursor position");
return;
}
const cursor = editor.getCursor();
const curLine = cursor.line;
if (contextType.type == ContextType.INLINECODE) {
let modal = new ModifyInlineCalloutModal(this, editor, contextType);
modal.open();
return;
}
}
buildPostProcessor(): MarkdownPostProcessor {
return (el) => {
el.findAll("code").forEach(
(code) => {
let text: string | undefined = code.innerText.trim();
if (text !== undefined && text.startsWith('[!!') && text.endsWith(']')) {
const inlineCallout = new InlineCallout();
let newEl = inlineCallout.build(text);
if (newEl !== undefined) {
code.replaceWith(newEl);
}
}
}
)
}
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
this.reload();
}
/** Reloads the plugin */
async reload() {
// @ts-ignore
await this.app.plugins.disablePlugin("inline-callouts");
// @ts-ignore
await this.app.plugins.enablePlugin("inline-callouts");
// @ts-ignore
this.app.setting.openTabById("inline-callouts").display();
}
}

239
src/modal/modify.ts Normal file
View file

@ -0,0 +1,239 @@
import {
Modal,
Notice,
Setting,
Editor
} from 'obsidian';
import type InlineCalloutsPlugin from "../main";
import { IconSuggest } from '../suggest/icon';
import { InlineCallout } from '../callout/builder';
export class ModifyInlineCalloutModal extends Modal {
public calloutIcon: string;
public calloutColor: string | undefined;
public calloutLabel: string | undefined;
previewEl: HTMLDivElement;
constructor(
private plugin: InlineCalloutsPlugin,
private editor: Editor,
private contextType: any
) {
super(plugin.app);
this.containerEl.addClass("modify-inline-callout-modal");
this.onOpen = () => this.display(true);
}
private async display(focus?: boolean, clearColor?: boolean) {
const { contentEl } = this;
contentEl.empty();
this.titleEl.setText("Modify inline callout");
let content = this.contextType.match.replace("[!!", "").replace("]", "");
let parts: any[] = content.split('|');
// icon
if (clearColor) {
// this.calloutIcon = IconSuggest.icon
}
else {
this.calloutIcon = parts[0] ? parts[0].trim().replace(/\\+$/, '').toLowerCase() : 'info';
}
// label
if (!clearColor) {
this.calloutLabel = this.calloutLabel! ? this.calloutLabel : (parts[1] ? parts[1].trim().replace(/\\+$/, '') : undefined);
}
// color
if (clearColor) {
this.calloutColor = '';
} else {
this.calloutColor = this.calloutColor! ? this.calloutColor : (parts[2] ? parts[2].trim() : undefined);
}
new Setting(contentEl)
.setName('Icon')
.setDesc('To select an icon, click the button. Default: info')
.addButton((cb) => {
cb
.setIcon(this.calloutIcon ? this.calloutIcon : "lucide-info")
.setTooltip("Select icon")
.onClick(async (e) => {
e.preventDefault();
const modal = new IconSuggest(this.plugin, async (icon) => {
this.calloutIcon = icon;
this.buildPreview();
});
modal.open();
});
cb.buttonEl.setAttribute("data-note-toolbar-no-icon", "false");
this.plugin.registerDomEvent(
cb.buttonEl, 'keydown', (e) => {
switch (e.key) {
case "Enter":
case " ":
e.preventDefault();
const modal = new IconSuggest(this.plugin, async (icon) => {
this.calloutIcon = icon;
this.buildPreview();
});
modal.open();
}
});
});
new Setting(contentEl)
.setName("Label")
.setDesc("Default: blank")
.addText((t) => {
t.setValue(this.calloutLabel ? this.calloutLabel : '');
t.onChange((v) => {
if (v === '') {
this.calloutLabel = undefined;
} else {
this.calloutLabel = v;
}
this.buildPreview();
});
});
const hexToRgb = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `${r}, ${g}, ${b}`;
};
function rgbToHex(r: any, g: any, b: any) {
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b)
.toString(16)
.slice(1)
.toUpperCase();
}
new Setting(contentEl)
.setName('Color')
.setDesc('Pick a color using the dropdown or color picker. Default: Obsidian\'s base color')
.setClass('inline-callouts-color-dropdown')
.addDropdown((cb) => {
cb.addOptions({
"": "",
"var(--color-blue-rgb)": "Blue",
"var(--color-cyan-rgb)": "Cyan",
"var(--color-green-rgb)": "Green",
"var(--color-orange-rgb)": "Orange",
"var(--color-pink-rgb)": "Pink",
"var(--color-purple-rgb)": "Purple",
"var(--color-red-rgb)": "Red",
"var(--color-yellow-rgb)": "Yellow",
})
// .setValue(this.calloutColor!)
.setValue(this.calloutColor?.startsWith("var(--color") ? this.calloutColor : '')
.onChange((value) => {
if (value !== '') {
this.calloutColor = value;
this.buildPreview();
this.display(false, false);
setTimeout(() => {
let dropdown: HTMLSelectElement | null = window.document.querySelector(".inline-callouts-color-dropdown .dropdown");
dropdown!.focus();
}, 25);
}
})
})
.addColorPicker((cb) => {
let r: number = 0;
let g: number = 0;
let b: number = 0;
if (this.calloutColor) {
let colorArr = this.calloutColor?.split(",");
r = Number(colorArr![0]);
g = Number(colorArr![1]);
b = Number(colorArr![2]);
}
cb.setValue(rgbToHex(r, g, b))
.onChange((value) => {
// this.calloutColor = hexToRgb(value);
// this.buildPreview();
this.calloutColor = hexToRgb(value);
let dropdown: HTMLSelectElement | null = window.document.querySelector(".inline-callouts-color-dropdown .dropdown");
dropdown!.value = '';
this.buildPreview();
setTimeout(() => {
let picker: HTMLSelectElement | null = window.document.querySelector('input[type="color"]');
picker!.focus();
}, 10);
});
})
.addExtraButton((ex) => {
ex.setIcon('refresh-ccw')
.setTooltip('Reset to no color')
.onClick(() => {
this.calloutColor = '';
this.display(false, true);
})
});
new Setting(contentEl)
.setName('Preview');
this.previewEl = this.contentEl.createDiv({
cls: "inline-callouts-modal-preview"
});
this.buildPreview();
new Setting(contentEl)
.addButton((b) =>
b.setButtonText("Save")
.setCta()
.onClick(() => {
try {
let firstPipe: string = '';
let secondPipe: string = '';
if (this.calloutLabel !== undefined || this.calloutColor !== undefined) {
firstPipe = "|";
}
if (
(this.calloutLabel === undefined && this.calloutColor !== undefined) ||
(this.calloutLabel !== undefined && this.calloutColor !== undefined)
) {
secondPipe = "|";
}
let cursor = this.editor.getCursor();
this.editor.replaceRange(
`\`[!!${this.calloutIcon.replace("lucide-", "")}${firstPipe + (this.calloutLabel !== undefined ? this.calloutLabel : "")}${this.calloutColor ? secondPipe + this.calloutColor : ""}]\``,
{ line: cursor.line, ch: this.contextType.range?.[0] }, { line: cursor.line, ch: this.contextType.range?.[1] }
);
} catch (e) {
console.log(e)
new Notice(
"There was an issue saving the inline callout. Check the developer console for details."
);
}
this.close();
})
);
}
buildPreview() {
this.previewEl.empty();
this.previewEl.setAttr("style", "margin-bottom: 1em;");
const inlineCallout = new InlineCallout();
let newEl = inlineCallout.build('[!!' + this.calloutIcon + '|' + (this.calloutLabel ? this.calloutLabel : '') + '|' + this.calloutColor + ']');
this.previewEl.appendChild(newEl);
}
}

194
src/modal/new.ts Normal file
View file

@ -0,0 +1,194 @@
import {
Modal,
Notice,
Setting,
Editor
} from 'obsidian';
import type InlineCalloutsPlugin from "../main";
import { IconSuggest } from '../suggest/icon';
import { InlineCallout } from '../callout/builder';
export class NewInlineCalloutModal extends Modal {
public calloutIcon: string = 'info';
public calloutColor: string | undefined;
public calloutLabel: string | undefined;
previewEl: HTMLDivElement;
constructor(
private plugin: InlineCalloutsPlugin,
private editor: Editor
) {
super(plugin.app);
this.containerEl.addClass("new-inline-callout-modal");
this.onOpen = () => {
if (this.editor.getSelection()) {
this.calloutLabel = this.editor.getSelection();
}
this.display(true);
}
}
private async display(focus?: boolean, clearColor?: boolean) {
const { contentEl } = this;
contentEl.empty();
this.titleEl.setText("New inline callout");
new Setting(contentEl)
.setName('Icon')
.setDesc('To select an icon, click the button. Default: info')
.addButton((cb) => {
cb
.setIcon('lucide-info')
.setTooltip("Select icon")
.onClick(async (e) => {
e.preventDefault();
const modal = new IconSuggest(this.plugin, async (icon) => {
this.calloutIcon = icon;
this.buildPreview();
});
modal.open();
});
cb.buttonEl.setAttribute("data-note-toolbar-no-icon", "false");
this.plugin.registerDomEvent(
cb.buttonEl, 'keydown', (e) => {
switch (e.key) {
case "Enter":
case " ":
e.preventDefault();
const modal = new IconSuggest(this.plugin, async (icon) => {
this.calloutIcon = icon;
this.buildPreview();
});
modal.open();
}
});
});
new Setting(contentEl)
.setName("Label")
.setDesc("Default: blank")
.addText((t) => {
t.setValue(this.calloutLabel!);
t.onChange((v) => {
this.calloutLabel = v;
this.buildPreview();
});
});
const hexToRgb = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `${r}, ${g}, ${b}`;
};
new Setting(contentEl)
.setName('Color')
.setDesc('Pick a color using the dropdown or color picker. Default: Obsidian\'s base color')
.setClass('inline-callouts-color-dropdown')
.addDropdown((cb) => {
cb.addOptions({
"": "",
"var(--color-blue-rgb)": "Blue",
"var(--color-cyan-rgb)": "Cyan",
"var(--color-green-rgb)": "Green",
"var(--color-orange-rgb)": "Orange",
"var(--color-pink-rgb)": "Pink",
"var(--color-purple-rgb)": "Purple",
"var(--color-red-rgb)": "Red",
"var(--color-yellow-rgb)": "Yellow",
})
// .setValue(this.calloutColor!)
.setValue(this.calloutColor?.startsWith("var(--color") ? this.calloutColor : '')
.onChange((value) => {
if (value !== '') {
this.calloutColor = value;
this.buildPreview();
this.display(false, false);
setTimeout(() => {
let dropdown: HTMLSelectElement | null = window.document.querySelector(".inline-callouts-color-dropdown .dropdown");
dropdown!.focus();
}, 25);
}
})
})
.addColorPicker((cb) => {
cb.setValue(this.calloutColor ?? '#000000')
.onChange((value) => {
this.calloutColor = hexToRgb(value);
let dropdown: HTMLSelectElement | null = window.document.querySelector(".inline-callouts-color-dropdown .dropdown");
dropdown!.value = '';
this.buildPreview();
setTimeout(() => {
let picker: HTMLSelectElement | null = window.document.querySelector('input[type="color"]');
picker!.focus();
}, 10);
});
})
.addExtraButton((ex) => {
ex.setIcon('refresh-ccw')
.setTooltip('Reset to no color')
.onClick(() => {
this.calloutColor = '';
this.display(false, true);
})
});
new Setting(contentEl)
.setName('Preview');
this.previewEl = this.contentEl.createDiv({
cls: "inline-callouts-modal-preview"
});
this.buildPreview();
new Setting(contentEl)
.addButton((b) =>
b.setButtonText("Insert")
.setCta()
.onClick(() => {
try {
let firstPipe: string = '';
let secondPipe: string = '';
if (this.calloutLabel !== undefined || this.calloutColor !== undefined) {
firstPipe = "|";
}
if (
(this.calloutLabel === undefined && this.calloutColor !== undefined) ||
(this.calloutLabel !== undefined && this.calloutColor !== undefined)
) {
secondPipe = "|";
}
let trailingSpace: string = this.plugin.settings.enableTraiingSpace ? " " : "";
this.editor.getDoc().replaceSelection(
`\`[!!${this.calloutIcon.replace("lucide-", "")}${firstPipe + (this.calloutLabel !== undefined ? this.calloutLabel : "")}${this.calloutColor ? secondPipe + this.calloutColor : ""}]\`${trailingSpace}`,
);
// const cursor = this.editor.getCursor();
} catch (e) {
console.log(e)
new Notice(
"There was an issue inserting the inline callout. Please check the developer console for details."
);
}
IconSuggest.icon = 'info';
this.close();
})
);
}
buildPreview() {
this.previewEl.empty();
this.previewEl.setAttr("style", "margin-bottom: 1em;");
const inlineCallout = new InlineCallout();
let newEl = inlineCallout.build('[!!' + this.calloutIcon + '|' + (this.calloutLabel ? this.calloutLabel : '') + '|' + this.calloutColor + ']');
this.previewEl.appendChild(newEl);
}
}

74
src/modal/search.ts Normal file
View file

@ -0,0 +1,74 @@
import {
Modal,
Notice,
Setting,
TextComponent,
getIconIds
} from 'obsidian';
import { InputIconSuggest } from '../suggest/inputIcon';
import type InlineCalloutsPlugin from "../main";
export class SearchInlineCalloutsModal extends Modal {
public calloutIcon: string;
constructor(
private plugin: InlineCalloutsPlugin,
) {
super(plugin.app);
this.containerEl.addClass("search-inline-callouts-modal");
this.onOpen = () => this.display(true);
}
private async display(focus?: boolean) {
const { contentEl } = this;
contentEl.empty();
let input: TextComponent;
this.titleEl.setText("Search inline callouts");
new Setting(contentEl)
.setName('Icon name')
.setDesc('Enter text to find an icon to search for.')
.setClass('search-icon-input')
.addSearch((t) => {
input = t;
t.setValue('')
.onChange((v) => {
this.calloutIcon = v;
});
new InputIconSuggest(
this.app,
t.inputEl,
getIconIds().filter((icon) =>
icon.replace('lucide-', '').toLowerCase().includes(icon.replace('lucide-', '').toLowerCase())
)
);
})
new Setting(contentEl)
.addButton((b) =>
b.setButtonText("Search")
.setCta()
.onClick(() => {
try {
if (this.calloutIcon) {
window.open(
"obsidian://search?vault=" + encodeURIComponent(this.app.vault.getName()) + "&query=" + encodeURIComponent('/`\\[!![a-z-]*?' + this.calloutIcon.replace('lucide-', '') + '[a-z]*?/')
);
}
} catch (e) {
console.log(e)
new Notice(
"There was an issue with your search query. Please check the developer console for details.",
5000
);
}
this.close();
})
);
}
}

71
src/settings/index.ts Normal file
View file

@ -0,0 +1,71 @@
import {
App,
PluginSettingTab,
Setting
} from 'obsidian';
import InlineCalloutsPlugin from 'src/main';
export interface InlineCalloutsSettings {
enableSuggester: boolean;
enableTraiingSpace: boolean;
enableEditing: boolean;
}
export const DEFAULT_SETTINGS: InlineCalloutsSettings = {
enableSuggester: true,
enableTraiingSpace: true,
enableEditing: true
}
export class InlineCalloutsSettingTab extends PluginSettingTab {
plugin: InlineCalloutsPlugin;
constructor(app: App, plugin: InlineCalloutsPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('Enable icon suggestions')
.setDesc('In editing view (source & live preview modes), enable inline icon auto-complete suggestions.')
.setClass("inline-callouts-enable-icon-suggestions")
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.enableSuggester)
.onChange(async newValue => {
this.plugin.settings.enableSuggester = newValue;
await this.plugin.saveSettings();
})
});
new Setting(containerEl)
.setName('Enable editing command/menu')
.setDesc('In editing view (source & live preview modes), enable "Modify inline callout" command and context menu option.')
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.enableEditing)
.onChange(async newValue => {
this.plugin.settings.enableEditing = newValue;
await this.plugin.saveSettings();
})
})
new Setting(containerEl)
.setName('Include trailing space')
.setDesc('When inserting a new inline callout, append a trailing space immediately after the inline callout code.')
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.enableTraiingSpace)
.onChange(async newValue => {
this.plugin.settings.enableTraiingSpace = newValue;
await this.plugin.saveSettings();
})
})
}
}

61
src/suggest/editorIcon.ts Normal file
View file

@ -0,0 +1,61 @@
import {
TFile,
getIconIds,
Editor,
EditorPosition,
EditorSuggestTriggerInfo,
EditorSuggest,
EditorSuggestContext,
setIcon
} from 'obsidian';
import InlineCalloutsPlugin from '../main';
export class EditorIconSuggest extends EditorSuggest<string> {
plugin: InlineCalloutsPlugin;
constructor(plugin: InlineCalloutsPlugin) {
super(plugin.app);
this.plugin = plugin;
this.setInstructions([
{ command: '↑↓', purpose: 'navigate' },
{ command: '↵', purpose: 'select' },
{ command: 'esc', purpose: 'dismiss' },
]);
}
onTrigger(cursor: EditorPosition, editor: Editor, _: TFile): EditorSuggestTriggerInfo | null {
const sub = editor.getLine(cursor.line).substring(0, cursor.ch);
const match = sub.match(/!!\S+$/)?.first();
if (match) {
return {
end: cursor,
start: {
ch: sub.lastIndexOf(match),
line: cursor.line,
},
query: match,
}
}
return null;
}
getSuggestions(context: EditorSuggestContext): string[] {
let icon_query = context.query.replace('!!', '').toLowerCase();
let iconNames = getIconIds().map(p => p.replace("lucide-", ""));
return iconNames.filter(p => p.includes(icon_query));
}
renderSuggestion(suggestion: string, el: HTMLElement): void {
const outer = el.createDiv({ cls: "icon-suggester-container" });
setIcon(outer, suggestion);
outer.createSpan({ cls: "icon-suggester-name" }, cb => {
cb.setAttr("style", "margin-left: .75em; vertical-align:top;");
}).setText(suggestion);
}
selectSuggestion(suggestion: string): void {
if (this.context) {
(this.context.editor as Editor).replaceRange('!!' + suggestion.replace("lucide-", ""), this.context.start, this.context.end);
}
}
}

63
src/suggest/icon.ts Normal file
View file

@ -0,0 +1,63 @@
import {
getIconIds,
setIcon,
IconName,
SuggestModal
} from 'obsidian';
import type InlineCalloutsPlugin from "../main";
export class IconSuggest extends SuggestModal<IconName> {
public plugin: InlineCalloutsPlugin;
private callback: (icon: string) => void;
static icon: string = 'info';
constructor(plugin: InlineCalloutsPlugin, callback: (icon: string) => void) {
super(plugin.app);
this.modalEl.addClass("vault-name-icon-select-modal");
this.plugin = plugin;
this.callback = callback;
this.setPlaceholder("Search for an icon");
this.setInstructions([
{ command: '↑↓', purpose: 'to navigate' },
{ command: '↵', purpose: 'to use' },
{ command: 'esc', purpose: 'to dismiss' },
]);
}
getSuggestions(inputStr: string): IconName[] {
const iconIds = getIconIds();
const iconSuggestions: IconName[] = [];
const lowerCaseInputStr = inputStr.toLowerCase();
// iconSuggestions.push("No icon");
iconIds.forEach((icon: IconName) => {
if (icon.toLowerCase().contains(lowerCaseInputStr)) {
iconSuggestions.push(icon);
}
});
return iconSuggestions;
}
renderSuggestion(icon: IconName, el: HTMLElement): void {
el.addClass("vault-name-icon-suggestion");
let iconWrapper = el.createDiv();
let iconName = el.createSpan();
iconName.setAttr("style", "margin-left: .75em; vertical-align:top;");
let iconGlyph = el.createSpan();
if (icon === "No icon") {
iconName.setText(icon);
}
else {
iconName.setText((icon.startsWith("lucide-") ? icon.substring(7) : icon) + " ");
setIcon(iconGlyph, icon);
}
iconWrapper.appendChild(iconGlyph);
iconWrapper.appendChild(iconName);
}
onChooseSuggestion(item: string, evt: MouseEvent | KeyboardEvent) {
setIcon(activeDocument.querySelector('[data-note-toolbar-no-icon]')!, item);
IconSuggest.icon = item;
this.callback(item);
this.close();
}
}

42
src/suggest/inputIcon.ts Normal file
View file

@ -0,0 +1,42 @@
import {
AbstractInputSuggest,
App,
setIcon
} from "obsidian";
export class InputIconSuggest extends AbstractInputSuggest<String> {
textInputEl: HTMLInputElement;
constructor(
public app: App,
public inputEl: HTMLInputElement,
private items: string[],
) {
super(app, inputEl);
}
getSuggestions(inputStr: string): string[] {
const inputLowerCase: string = inputStr.toLowerCase();
const filtered = this.items.filter((item) => {
if (item.replace('lucide-', '').toLowerCase().contains(inputLowerCase)) return item;
});
if (!filtered) this.close();
return filtered;
}
renderSuggestion(value: string, el: HTMLElement): void {
let span: HTMLSpanElement = el.createSpan();
setIcon(span, value);
span.setAttr("style", "display: inline-block; margin-right: .5em;");
let label: HTMLSpanElement = el.createSpan({ text: " " + value.replace('lucide-', '') });
label.setAttr("style", "display: inline-block; vertical-align: top;");
}
selectSuggestion(item: string): void {
this.textInputEl.value = item.replace('lucide-', '');
this.textInputEl.trigger("input");
this.close();
}
}

86
src/views/editor.ts Normal file
View file

@ -0,0 +1,86 @@
import {
editorLivePreviewField
} from 'obsidian';
import { ViewPlugin, WidgetType, EditorView, ViewUpdate, Decoration, DecorationSet } from '@codemirror/view';
import { RangeSetBuilder } from "@codemirror/state";
import { InlineCallout } from '../callout/builder';
const REGEXP = /(`\[!!([^\]]*)\]`)/gm;
export const viewPlugin = ViewPlugin.fromClass(class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view);
}
update(update: ViewUpdate) {
this.decorations = this.buildDecorations(update.view);
}
destroy() { }
buildDecorations(view: EditorView): DecorationSet {
if (!view.state.field(editorLivePreviewField)) {
return Decoration.none;
}
let builder = new RangeSetBuilder<Decoration>();
let lines: number[] = [];
if (view.state.doc.length > 0) {
lines = Array.from(
{ length: view.state.doc.lines },
(_, i) => i + 1,
);
}
const currentSelections = [...view.state.selection.ranges];
for (let n of lines) {
const line = view.state.doc.line(n);
const startOfLine = line.from;
const endOfLine = line.to;
let currentLine = false;
currentSelections.forEach((r) => {
if (r.to >= startOfLine && r.from <= endOfLine) {
currentLine = true;
return;
}
});
let matches = Array.from(line.text.matchAll(REGEXP))
for (const match of matches) {
let add = true;
const from = match.index != undefined ? match.index + line.from : -1
const to = from + match[0].length
if ((to - from) === 6) {
add = false
}
currentSelections.forEach((r) => {
if (r.to >= from && r.from <= to) {
add = false
}
})
if (add) {
builder.add(from, to, Decoration.widget({ widget: new InlineCalloutWidget(match) }))
}
}
}
return builder.finish();
}
}, {
decorations: (v: any) => v.decorations,
})
class InlineCalloutWidget extends WidgetType {
constructor(readonly callout: string[]) {
super()
}
toDOM(view: EditorView): HTMLElement {
let text: string = this.callout[0].substring(1).substring(this.callout[0].length - 2, 0);
const inlineCallout = new InlineCallout();
let newEl = inlineCallout.build(text);
return newEl;
}
}

177
styles.css Normal file
View file

@ -0,0 +1,177 @@
body {
--inline-callout-font-size: .85em;
--inline-callout-font-weight: 400;
--inline-callout-border-radius: 4px;
--inline-callout-bg-transparency: .1;
--inline-callout-margin: 1px;
--inline-callout-padding-top: 1px;
--inline-callout-padding-right: 0px;
--inline-callout-padding-bottom: 2px;
--inline-callout-padding-left: 0px;
--inline-callout-icon-size: .85em;
--inline-callout-icon-margin-top: 3px;
--inline-callout-icon-margin-right: 3px;
--inline-callout-icon-margin-bottom: 0px;
--inline-callout-icon-margin-left: 5px;
--inline-callout-label-margin-top: 2px;
--inline-callout-label-margin-right: 5px;
--inline-callout-label-margin-bottom: 0px;
--inline-callout-label-margin-left: 2px;
}
.inline-callout {
margin: var(--inline-callout-margin);
border-radius: var(--inline-callout-border-radius);
font-weight: var(--inline-callout-font-weight);
padding-top: var(--inline-callout-padding-top);
padding-right: var(--inline-callout-padding-right);
padding-bottom: var(--inline-callout-padding-bottom);
padding-left: var(--inline-callout-padding-left);
cursor: default;
display: inline-block;
}
.inline-callout-icon {
margin-top: var(--inline-callout-icon-margin-top);
margin-right: var(--inline-callout-icon-margin-right);
margin-bottom: var(--inline-callout-icon-margin-bottom);
margin-left: var(--inline-callout-icon-margin-left);
display: inline-flex;
}
.inline-callout-icon.inline-callout-icon svg {
width: var(--inline-callout-icon-size);
height: var(--inline-callout-icon-size);
}
.inline-callout-label {
font-size: var(--inline-callout-font-size);
margin-top: var(--inline-callout-label-margin-top);
margin-right: var(--inline-callout-label-margin-right);
margin-bottom: var(--inline-callout-label-margin-bottom);
margin-left: var(--inline-callout-label-margin-left);
display: inline-flex;
vertical-align: top;
}
/* @settings
name: Inline Callouts
id: inline-callouts
settings:
-
id: callout-settings
title: Callout
type: heading
level: 4
collapsed: true
-
id: inline-callout-border-radius
title: Border radius
type: variable-text
default: 4px
-
id: inline-callout-bg-transparency
title: Background transparency
type: variable-number-slider
default: .1
min: .0
max: .9
step: .05
-
id: inline-callout-margin
title: Margin
type: variable-text
default: 1px
-
id: inline-callout-padding-top
title: Top padding
type: variable-text
default: 1px
-
id: inline-callout-padding-right
title: Right padding
type: variable-text
default: 0px
-
id: inline-callout-padding-bottom
title: Bottom padding
type: variable-text
default: 2px
-
id: inline-callout-padding-left
title: Left padding
type: variable-text
default: 0px
-
id: icon-settings
title: Icon
type: heading
level: 4
collapsed: true
-
id: inline-callout-icon-size
title: Size
description: "Both width and height"
type: variable-text
default: .85em
-
id: inline-callout-icon-margin-top
title: Top margin
type: variable-text
default: 3px
-
id: inline-callout-icon-margin-right
title: Right margin
type: variable-text
default: 3px
-
id: inline-callout-icon-margin-bottom
title: Bottom margin
type: variable-text
default: 0px
-
id: inline-callout-icon-margin-left
title: Left margin
type: variable-text
default: 5px
-
id: label-settings
title: Label
type: heading
level: 4
collapsed: true
-
id: inline-callout-font-size
title: Font size
type: variable-text
default: .85em
-
id: inline-callout-font-weight
title: Font weight
type: variable-number-slider
default: 400
min: 100
max: 900
step: 100
-
id: inline-callout-label-margin-top
title: Top margin
type: variable-text
default: 2px
-
id: inline-callout-label-margin-right
title: Right margin
type: variable-text
default: 5px
-
id: inline-callout-label-margin-bottom
title: Bottom margin
type: variable-text
default: 0px
-
id: inline-callout-label-margin-left
title: Left margin
type: variable-text
default: 2px
-
*/

25
tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"
]
}

14
version-bump.mjs Normal file
View file

@ -0,0 +1,14 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));

4
versions.json Normal file
View file

@ -0,0 +1,4 @@
{
"1.1.0": "0.15.0",
"1.0.0": "0.15.0"
}