├── .npmrc ├── versions.json ├── .eslintignore ├── CHANGELOG.md.d.ts ├── api ├── tsconfig.json ├── events.ts ├── callout.ts ├── functions.ts ├── index.ts └── README.md ├── index.d.ts ├── docs └── images │ ├── screenshot_manage_callout.png │ ├── screenshot_manage_pane_dark.png │ ├── screenshot_manage_pane_light.png │ └── screenshot_manage_pane_darklight.png ├── babel.config.cjs ├── .gitignore ├── .editorconfig ├── src ├── api-common.ts ├── util │ ├── type-helpers.ts │ ├── validity-set.ts │ ├── color.test.ts │ └── color.ts ├── default_colors.json ├── search │ ├── effect.ts │ ├── normalize.test.ts │ ├── search.test.ts │ ├── normalize.ts │ ├── condition.ts │ ├── bitfield.test.ts │ ├── bitfield.ts │ ├── search-index.test.ts │ ├── condition.test.ts │ ├── factory.ts │ ├── search-index.ts │ └── search.ts ├── ui │ ├── component │ │ ├── reset-button.ts │ │ └── icon-preview.ts │ ├── setting │ │ ├── callout-icon.ts │ │ └── callout-color.ts │ ├── pane.ts │ ├── pane-layers.ts │ └── paned-setting-tab.ts ├── panes │ ├── edit-callout-pane │ │ ├── appearance-editor.ts │ │ ├── editor-complex.ts │ │ ├── editor-unified.ts │ │ ├── editor-per-scheme.ts │ │ ├── misc-editor.ts │ │ ├── section-info.ts │ │ ├── appearance-type.ts │ │ ├── section-preview.ts │ │ └── index.ts │ ├── changelog-pane.ts │ ├── create-callout-pane.ts │ ├── select-icon-pane.ts │ ├── manage-plugin-pane.ts │ └── manage-callouts-pane.ts ├── callout-util.ts ├── settings.ts ├── css-parser.ts ├── css-parser.test.ts ├── api-v1.ts ├── apis.ts ├── sort.test.ts ├── callout-resolver.ts ├── sort.ts ├── changelog.ts ├── callout-search.ts └── callout-settings.ts ├── manifest.json ├── .prettierrc ├── jest.config.mjs ├── __mocks__ └── obsidian.ts ├── .npmignore ├── .eslintrc ├── README.md ├── tsconfig.json ├── LICENSE ├── CHANGELOG.md ├── rollup.config.mjs ├── package.json └── esbuild.config.mjs /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.1.0": "1.0.0" 3 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /CHANGELOG.md.d.ts: -------------------------------------------------------------------------------- 1 | declare const raw: string; 2 | export default raw; 3 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | // This is needed for compatibility with node and node16 module resolution. 2 | export * from "./dist/api"; 3 | -------------------------------------------------------------------------------- /docs/images/screenshot_manage_callout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eth-p/obsidian-callout-manager/HEAD/docs/images/screenshot_manage_callout.png -------------------------------------------------------------------------------- /docs/images/screenshot_manage_pane_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eth-p/obsidian-callout-manager/HEAD/docs/images/screenshot_manage_pane_dark.png -------------------------------------------------------------------------------- /docs/images/screenshot_manage_pane_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eth-p/obsidian-callout-manager/HEAD/docs/images/screenshot_manage_pane_light.png -------------------------------------------------------------------------------- /docs/images/screenshot_manage_pane_darklight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eth-p/obsidian-callout-manager/HEAD/docs/images/screenshot_manage_pane_darklight.png -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | '@babel/preset-typescript', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Development 2 | node_modules 3 | 4 | # Artifacts 5 | /dist 6 | *.tsbuildinfo 7 | 8 | # System 9 | .DS_Store 10 | ._* 11 | 12 | # IDEs 13 | .vscode 14 | .idea 15 | *.iml 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /src/api-common.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A symbol for the event emitter of a handle. 3 | */ 4 | export const emitter = Symbol("emitter"); 5 | 6 | /** 7 | * A symbol that refers to the function used to destroy a handle. 8 | */ 9 | export const destroy = Symbol("destroy"); 10 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "callout-manager", 3 | "version": "1.1.0", 4 | "description": "Easily create and customize callouts.", 5 | "author": "eth-p", 6 | "authorUrl": "https://github.com/eth-p", 7 | "name": "Callout Manager", 8 | "minAppVersion": "1.0.0", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 4, 4 | "useTabs": true, 5 | "trailingComma": "all", 6 | "singleQuote": true, 7 | "semi": true, 8 | 9 | "importOrder": ["^(?!obsidian|&|\\.{1,2}/).+", "^obsidian", "^&(?!ui)", "^&ui", "^\\.\\./", "^\\./"], 10 | "importOrderSeparation": true, 11 | "importOrderSortSpecifiers": true 12 | } 13 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | import { pathsToModuleNameMapper } from 'ts-jest'; 2 | import { readFileSync } from 'fs'; 3 | 4 | const {compilerOptions} = JSON.parse(readFileSync("./tsconfig.json", 'utf8')); 5 | 6 | const options = { 7 | moduleNameMapper: { 8 | ...pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/' }), 9 | } 10 | } 11 | 12 | export default options; 13 | -------------------------------------------------------------------------------- /__mocks__/obsidian.ts: -------------------------------------------------------------------------------- 1 | import type { SearchResult } from 'obsidian'; 2 | 3 | export function prepareFuzzySearch(query: string): (text: string) => SearchResult | null { 4 | return (text) => { 5 | // TODO: A real fuzzy search mock. 6 | if (text.includes(query)) { 7 | return { score: text.length / query.length, matches: [] }; 8 | } 9 | 10 | return null; 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/util/type-helpers.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | /** 4 | * Converts a type union to a type intersection. 5 | */ 6 | export type Intersection = (Ts extends any ? (k: Ts) => void : never) extends (k: infer I) => void ? I : never; 7 | 8 | /** 9 | * Extracts values out of an array. 10 | */ 11 | export type ArrayValues> = Ts[number]; 12 | -------------------------------------------------------------------------------- /src/default_colors.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultColors": { 3 | "82, 139, 212": "blue", 4 | "83, 223, 221": "cyan", 5 | "68, 207, 110": "green", 6 | "233, 151, 63": "orange", 7 | "251, 70, 76": "red", 8 | "168, 130, 255": "purple", 9 | "166, 189, 197": "gray", 10 | "158, 158, 158": "light gray", 11 | "208, 181, 48": "yellow", 12 | "227, 107, 167": "pink", 13 | "161, 106, 73": "brown", 14 | "0, 0, 0": "black" 15 | } 16 | } -------------------------------------------------------------------------------- /api/events.ts: -------------------------------------------------------------------------------- 1 | 2 | interface CalloutManagerEventMap { 3 | 4 | /** 5 | * Called whenever one or more callouts have changed. 6 | */ 7 | change(): void; 8 | 9 | } 10 | 11 | /** 12 | * A Callout Manager event that can be listened for. 13 | */ 14 | export type CalloutManagerEvent = keyof CalloutManagerEventMap; 15 | 16 | /** 17 | * A type which maps event names to their associated listener functions. 18 | */ 19 | export type CalloutManagerEventListener = CalloutManagerEventMap[Event]; 20 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Sources 2 | /api 3 | /src 4 | 5 | # Obsidian 6 | /versions.json 7 | /dist/main.js 8 | /dist/styles.css 9 | /manifest.json 10 | 11 | # Documentation 12 | /docs/images/* 13 | !/docs/images/screenshot_manage_pane_darklight.png 14 | 15 | # Development 16 | node_modules 17 | /tsconfig.json 18 | /build 19 | /*.config.cjs 20 | /*.config.mjs 21 | /.* 22 | 23 | # Deployment 24 | .github 25 | 26 | # Artifacts 27 | *.tsbuildinfo 28 | 29 | # System 30 | .DS_Store 31 | ._* 32 | 33 | # IDEs 34 | .vscode 35 | .idea 36 | *.iml 37 | -------------------------------------------------------------------------------- /src/search/effect.ts: -------------------------------------------------------------------------------- 1 | import { BitField } from './bitfield'; 2 | 3 | /** 4 | * How the matching items should affect the search results. 5 | */ 6 | export type SearchEffect = (a: BitField, b: BitField) => BitField; 7 | 8 | /** 9 | * Adds the matching items to the results. 10 | * (Set Union) 11 | */ 12 | export function add(a: BitField, b: BitField): BitField { 13 | return BitField.or(a, b); 14 | } 15 | 16 | /** 17 | * Removes the matching items from the results. 18 | * (Set Difference) 19 | */ 20 | export function remove(a: BitField, b: BitField): BitField { 21 | return BitField.andNot(a, b); 22 | } 23 | 24 | /** 25 | * Filters the existing results to only include those that also match these. 26 | * (Set Intersection) 27 | */ 28 | export function filter(a: BitField, b: BitField): BitField { 29 | return BitField.and(a, b); 30 | } 31 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "no-mixed-spaces-and-tabs": "off", 19 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none", "varsIgnorePattern": "STYLES" }], 20 | "@typescript-eslint/ban-ts-comment": "off", 21 | "no-prototype-builtins": "off", 22 | "@typescript-eslint/no-empty-function": "off", 23 | "@typescript-eslint/no-namespace": "off" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Callout Manager 2 | An [Obsidian](https://obsidian.md) plugin that makes creating and configuring callouts easy. 3 | 4 | ![Screenshot](docs/images/screenshot_manage_pane_darklight.png) 5 | 6 | ## Features 7 | 8 | - **Browse a list of available callouts.** 9 | Learn about all the callouts that you can use! 10 | 11 | - **Change the colors and icon of callouts.** 12 | Make callouts your own by changing their colors and icons. 13 | 14 | - **Create custom callouts.** 15 | No callout to suit your needs? Make it yourself! 16 | 17 | - **Automatically detects callouts created by snippets and themes.** 18 | Callout Manager keeps track of callouts for you. 19 | 20 | - **Supports Mobile Obsidian** 21 | Take your callouts on the go! 22 | 23 | - **Plugin API** 24 | We have a [Plugin API](./api/README.md) for integration with other plugins. 25 | -------------------------------------------------------------------------------- /src/ui/component/reset-button.ts: -------------------------------------------------------------------------------- 1 | import { ExtraButtonComponent } from 'obsidian'; 2 | 3 | /** 4 | * A reset button. 5 | */ 6 | export class ResetButtonComponent extends ExtraButtonComponent { 7 | public constructor(containerEl: HTMLElement) { 8 | super(containerEl); 9 | this.setIcon('lucide-undo'); 10 | this.extraSettingsEl.classList.add('calloutmanager-reset-button'); 11 | } 12 | } 13 | 14 | declare const STYLES: ` 15 | :root { 16 | --calloutmanager-reset-button-disabled-opacity: 0.3; 17 | } 18 | 19 | // The "undo" button when the setting has not been changed from the default. 20 | .calloutmanager-reset-button:is(.is-disabled, [disabled]) { 21 | opacity: var(--calloutmanager-reset-button-disabled-opacity); 22 | 23 | &:hover { 24 | background-color: transparent; 25 | } 26 | 27 | &:active { 28 | color: var(--icon-color); 29 | } 30 | } 31 | `; 32 | -------------------------------------------------------------------------------- /src/search/normalize.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from '@jest/globals'; 2 | 3 | import { casefold, combinedNormalization, trimmed, unaccented, unicode } from './normalize'; 4 | 5 | describe('normalize', () => { 6 | test('casefold', () => { 7 | expect(casefold('A')).toBe(casefold('a')); 8 | expect(casefold('abCd')).toBe(casefold('ABcD')); 9 | }); 10 | 11 | test('trimmed', () => { 12 | expect(trimmed(' a')).toBe(trimmed('a')); 13 | expect(trimmed(' a ')).toBe(trimmed('a')); 14 | }); 15 | 16 | test('unicode', () => { 17 | expect(unicode('\u{00E0}')).toBe(unicode('a\u{0300}')); // "a" with grave. 18 | }); 19 | 20 | test('unaccented', () => { 21 | expect(unaccented('\u{00E0}')).toBe('a'); // "a" with grave. 22 | }); 23 | 24 | test('combinedNormalizaion', () => { 25 | expect(combinedNormalization([trimmed, casefold])(' Fo ')).toBe(casefold('Fo')); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/panes/edit-callout-pane/appearance-editor.ts: -------------------------------------------------------------------------------- 1 | import { Callout } from '&callout'; 2 | import { CalloutSettings } from '&callout-settings'; 3 | import CalloutManagerPlugin from '&plugin'; 4 | 5 | import { UIPaneNavigation } from '&ui/pane'; 6 | 7 | import { Appearance } from './appearance-type'; 8 | 9 | /** 10 | * An editor UI to change a callout's appearance settings. 11 | */ 12 | export abstract class AppearanceEditor { 13 | public plugin!: CalloutManagerPlugin; 14 | 15 | public nav!: UIPaneNavigation; 16 | public callout!: Callout; 17 | public appearance!: T; 18 | public containerEl!: HTMLElement; 19 | 20 | /** 21 | * Changes the appearance. 22 | */ 23 | public setAppearance!: (appearance: Appearance) => void; 24 | 25 | /** 26 | * Converts the current appearance into {@link CalloutSettings}. 27 | * @param appearance The appearance to convert. 28 | */ 29 | public abstract toSettings(): CalloutSettings; 30 | 31 | /** 32 | * Renders the appearance. 33 | */ 34 | public abstract render(): void; 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["dist/**/*", "src/**/*.test.ts", "src/test-util.ts"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "ES5", "ES6", "ES7", "ES2017", "ES2018", "ES2019"], 5 | "allowSyntheticDefaultImports": true, 6 | "resolveJsonModule": true, 7 | "inlineSourceMap": true, 8 | "inlineSources": true, 9 | "module": "ESNext", 10 | "target": "ES2020", 11 | "allowJs": true, 12 | "noImplicitAny": true, 13 | "moduleResolution": "node", 14 | "importHelpers": true, 15 | "isolatedModules": true, 16 | "strictNullChecks": true, 17 | "strict": true, 18 | "stripInternal": true, 19 | "noEmit": true, 20 | "paths": { 21 | "obsidian-callout-manager": ["./api"], 22 | "&callout": ["./api/callout"], 23 | "&callout-util": ["./src/callout-util"], 24 | "&callout-resolver": ["./src/callout-resolver"], 25 | "&callout-settings": ["./src/callout-settings"], 26 | "&plugin": ["./src/main"], 27 | "&plugin-settings": ["./src/settings"], 28 | "&ui/*": ["./src/ui/*"], 29 | "&color": ["./src/util/color"] 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/callout-util.ts: -------------------------------------------------------------------------------- 1 | import Callout from '&callout'; 2 | import { RGB, parseColorRGB } from '&color'; 3 | 4 | /** 5 | * Gets the color (as a {@link RGB}) from a {@link Callout}. 6 | * This will try to do basic parsing on the color field. 7 | * 8 | * @param callout The callout. 9 | * @returns The callout's color, or null if not valid. 10 | */ 11 | export function getColorFromCallout(callout: Callout): RGB | null { 12 | return parseColorRGB(`rgb(${callout.color})`); 13 | } 14 | 15 | /** 16 | * Gets the title of a callout. 17 | * 18 | * This should be the same as what Obsidian displays when a callout block does not have a user-specified title. 19 | * 20 | * @param callout The callout. 21 | * @returns The callout's title. 22 | */ 23 | export function getTitleFromCallout(callout: Callout): string { 24 | const matches = /^(.)(.*)/u.exec(callout.id); 25 | if (matches == null) return callout.id; 26 | 27 | const firstChar = matches[1].toLocaleUpperCase(); 28 | const remainingChars = matches[2].toLocaleLowerCase().replace(/-+/g, ' '); 29 | 30 | return `${firstChar}${remainingChars}`; 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 eth-p 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { CalloutID } from '&callout'; 2 | import { CalloutSettings } from './callout-settings'; 3 | 4 | 5 | /** 6 | * The Callout Manager plugin settings. 7 | */ 8 | export default interface Settings { 9 | callouts: { 10 | custom: string[]; 11 | settings: Record; 12 | }; 13 | 14 | calloutDetection: { 15 | obsidian: boolean; 16 | theme: boolean; 17 | snippet: boolean; 18 | 19 | /** @deprecated */ 20 | obsidianFallbackForced?: boolean; 21 | }; 22 | } 23 | 24 | /** 25 | * Creates default settings for the plugin. 26 | */ 27 | export function defaultSettings(): Settings { 28 | return { 29 | callouts: { 30 | custom: [], 31 | settings: {}, 32 | }, 33 | calloutDetection: { 34 | obsidian: true, 35 | theme: true, 36 | snippet: true, 37 | }, 38 | }; 39 | } 40 | 41 | /** 42 | * Migrates settings. 43 | * 44 | * @param into The object to merge into. 45 | * @param from The settings to add. 46 | * @returns The merged settings. 47 | */ 48 | export function migrateSettings(into: Settings, from: Settings | undefined) { 49 | const merged = Object.assign(into, { 50 | ...from, 51 | calloutDetection: { 52 | ...into.calloutDetection, 53 | ...(from?.calloutDetection ?? {}), 54 | }, 55 | }); 56 | 57 | delete merged.calloutDetection.obsidianFallbackForced; 58 | return merged; 59 | } 60 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Version 1.1.1 2 | 3 | > [!new] Export Callouts as CSS 4 | > There's now a button to copy your callout changes. 5 | 6 | # Version 1.1.0 7 | 8 | > [!new] In-App Changelogs 9 | > Learn about plugin changes and new features straight from the horse's mouth. 10 | 11 | > [!new] Insert Callouts 12 | > 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! 13 | > 14 | > Thank you, [**@decheine**](https://github.com/decheine)! 15 | 16 | > [!new] Color Dropdown 17 | > Pick from a nifty dropdown instead of memorizing color values. 18 | > 19 | > Thank you, [**@decheine**](https://github.com/decheine)! 20 | 21 | > [!new] Rename Callouts 22 | > You can now rename your custom callouts. 23 | 24 | > [!fix] More Robust Callout Detection 25 | > 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. 26 | 27 | > [!fix] Integration with Completr 28 | > You can now use [Completr](obsidian://show-plugin?id=obsidian-completr) to autocomplete callouts! 29 | 30 | # Version 1.0.1 31 | The first release available on Obsidian's community plugin browser! 32 | 33 | # Version 1.0.0 34 | 35 | > [!new] Callout Customization 36 | > Change callouts to your heart's content! 37 | 38 | > [!new] Automatic Detection 39 | > Browse and search through your one and only list of available callouts. 40 | -------------------------------------------------------------------------------- /src/css-parser.ts: -------------------------------------------------------------------------------- 1 | import { CalloutID } from '../api'; 2 | 3 | /** 4 | * Extracts a list of callout IDs from a stylesheet. 5 | * 6 | * @param css The CSS to extract from. 7 | * @returns The callout IDs found. 8 | */ 9 | export function getCalloutsFromCSS(css: string): CalloutID[] { 10 | const REGEX_CALLOUT_SELECTOR = /\[data-callout([^\]]*)\]/gmi; 11 | const REGEX_MATCH_QUOTED_STRING: {[key: string]: RegExp} = { 12 | "'": /^'([^']+)'( i)?$/, 13 | '"': /^"([^"]+)"( i)?$/, 14 | '': /^([^\]]+)$/, 15 | }; 16 | 17 | // Get a list of attribute selectors. 18 | const attributeSelectors = []; 19 | let matches; 20 | while ((matches = REGEX_CALLOUT_SELECTOR.exec(css)) != null) { 21 | attributeSelectors.push(matches[1]); 22 | REGEX_CALLOUT_SELECTOR.lastIndex = matches.index + matches[0].length; 23 | } 24 | 25 | // Try to find exact matches within the list. 26 | const ids = []; 27 | for (const attributeSelector of attributeSelectors) { 28 | let selectorString: null | string; 29 | if (attributeSelector.startsWith('=')) { 30 | selectorString = attributeSelector.substring(1); 31 | } else if (attributeSelector.startsWith('^=')){ 32 | selectorString = attributeSelector.substring(2); 33 | } else { 34 | continue; 35 | } 36 | 37 | // Try to extract the string from the attribute selector. 38 | const quoteChar = selectorString.charAt(0); 39 | const stringRegex = REGEX_MATCH_QUOTED_STRING[quoteChar] ?? REGEX_MATCH_QUOTED_STRING['']; 40 | const matches = stringRegex.exec(selectorString); 41 | if (matches != null && matches[1] != null) { 42 | ids.push(matches[1]); 43 | } 44 | } 45 | 46 | return ids; 47 | } 48 | -------------------------------------------------------------------------------- /api/callout.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A type representing the ID of a callout. 3 | */ 4 | export type CalloutID = string; 5 | 6 | /** 7 | * A description of a markdown callout. 8 | */ 9 | export type Callout = CalloutProperties & { 10 | /** 11 | * The list of known sources for the callout. 12 | * A source is a stylesheet that provides styles for a callout with this ID. 13 | */ 14 | sources: CalloutSource[]; 15 | }; 16 | 17 | export interface CalloutProperties { 18 | /** 19 | * The ID of the callout. 20 | * This is the part that goes in the callout header. 21 | */ 22 | id: CalloutID; 23 | 24 | /** 25 | * The current color of the callout. 26 | */ 27 | color: string; 28 | 29 | /** 30 | * The icon associated with the callout. 31 | */ 32 | icon: string; 33 | } 34 | 35 | /** 36 | * The source of a callout. 37 | * This is what declares the style information for the callout with the given ID. 38 | */ 39 | export type CalloutSource = CalloutSourceObsidian | CalloutSourceSnippet | CalloutSourceTheme | CalloutSourceCustom; 40 | 41 | /** 42 | * The callout is a built-in Obsidian callout. 43 | */ 44 | export interface CalloutSourceObsidian { 45 | type: 'builtin'; 46 | } 47 | 48 | /** 49 | * The callout is from a snippet. 50 | */ 51 | export interface CalloutSourceSnippet { 52 | type: 'snippet'; 53 | snippet: string; 54 | } 55 | 56 | /** 57 | * The callout is from a theme. 58 | */ 59 | export interface CalloutSourceTheme { 60 | type: 'theme'; 61 | theme: string; 62 | } 63 | 64 | /** 65 | * The callout was added by the user. 66 | */ 67 | export interface CalloutSourceCustom { 68 | type: 'custom'; 69 | } 70 | 71 | export default Callout; 72 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | // --------------------------------------------------------------------------------------------------------------------- 2 | // This is the configuration file for building the API package. 3 | // --------------------------------------------------------------------------------------------------------------------- 4 | import typescript from '@rollup/plugin-typescript'; 5 | import dts from 'rollup-plugin-dts'; 6 | import prettier from 'rollup-plugin-prettier'; 7 | 8 | import { external, outdir } from './build.config.mjs'; 9 | 10 | const entry = './api/index.ts'; 11 | const config = [ 12 | // API Type Declarations 13 | { 14 | input: entry, 15 | output: [{ file: `${outdir}/api.d.ts`, format: 'es' }], 16 | external, 17 | plugins: [ 18 | typescript({ 19 | compilerOptions: { 20 | outDir: 'dist', 21 | sourceMap: false, 22 | inlineSources: false, 23 | inlineSourceMap: false, 24 | }, 25 | }), 26 | dts(), 27 | prettier({ 28 | parser: 'typescript', 29 | }), 30 | ], 31 | }, 32 | 33 | // API Runtime 34 | buildApi('commonjs', 'api-cjs.js'), 35 | buildApi('es', 'api-esm.mjs'), 36 | buildApi('es', 'api-esm-esnext.mjs', { 37 | typescript: { 38 | target: 'esnext', 39 | }, 40 | }), 41 | ]; 42 | 43 | export default config; 44 | 45 | function buildApi(format, filename, options) { 46 | return { 47 | input: entry, 48 | output: [{ file: `${outdir}/${filename}`, format }], 49 | external, 50 | plugins: [ 51 | prettier({ parser: 'babel' }), 52 | typescript({ 53 | tsconfig: 'api/tsconfig.json', 54 | inlineSources: false, 55 | inlineSourceMap: false, 56 | ...(options?.typescript ?? {}), 57 | }), 58 | ], 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /src/panes/edit-callout-pane/editor-complex.ts: -------------------------------------------------------------------------------- 1 | import { ButtonComponent } from 'obsidian'; 2 | 3 | import { CalloutSettings } from '&callout-settings'; 4 | 5 | import { AppearanceEditor } from './appearance-editor'; 6 | import { ComplexAppearance } from './appearance-type'; 7 | 8 | export default class ComplexAppearanceEditor extends AppearanceEditor { 9 | /** @override */ 10 | public toSettings(): CalloutSettings { 11 | return this.appearance.settings; 12 | } 13 | 14 | /** @override */ 15 | public render() { 16 | const { containerEl } = this; 17 | const { settings } = this.appearance; 18 | 19 | const complexJson = JSON.stringify(settings, undefined, ' '); 20 | containerEl.createEl('p', { 21 | text: 22 | "This callout has been configured using the plugin's data.json file. " + 23 | 'To prevent unintentional changes to the configuration, you need to edit it manually.', 24 | }); 25 | 26 | containerEl.createEl('code', { cls: 'calloutmanager-edit-callout-appearance-json' }, (el) => { 27 | el.createEl('pre', { text: complexJson }); 28 | }); 29 | 30 | containerEl.createEl('p', { 31 | text: 'Alternatively, you can reset the callout by clicking the button below twice.', 32 | }); 33 | 34 | let resetButtonClicked = false; 35 | const resetButton = new ButtonComponent(containerEl) 36 | .setButtonText('Reset Callout') 37 | .setClass('calloutmanager-edit-callout-appearance-reset') 38 | .setWarning() 39 | .onClick(() => { 40 | if (!resetButtonClicked) { 41 | resetButtonClicked = true; 42 | resetButton.setButtonText('Are you sure?'); 43 | return; 44 | } 45 | 46 | this.setAppearance({ type: 'unified', color: undefined, otherChanges: {} }); 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-callout-manager", 3 | "version": "1.1.0", 4 | "description": "An Obsidian.md plugin that makes creating and configuring callouts easy.", 5 | "type": "module", 6 | "exports": { 7 | "import": "./dist/api-esm.mjs", 8 | "require": "./dist/api-cjs.js" 9 | }, 10 | "scripts": { 11 | "dev": "node esbuild.config.mjs", 12 | "test": "jest", 13 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production && rollup -c rollup.config.mjs", 14 | "format": "prettier -w src" 15 | }, 16 | "keywords": [], 17 | "author": { 18 | "name": "eth-p", 19 | "url": "https://github.com/eth-p" 20 | }, 21 | "license": "MIT", 22 | "obsidianPlugin": { 23 | "name": "Callout Manager", 24 | "minAppVersion": "1.0.0", 25 | "description": "Easily create and customize callouts.", 26 | "isDesktopOnly": false 27 | }, 28 | "dependencies": { 29 | "obsidian-extra": "^0.1.5" 30 | }, 31 | "devDependencies": { 32 | "@babel/preset-env": "^7.20.2", 33 | "@babel/preset-typescript": "^7.18.6", 34 | "@coderspirit/nominal": "^3.2.2", 35 | "@rollup/plugin-typescript": "^11.0.0", 36 | "@trivago/prettier-plugin-sort-imports": "^4.0.0", 37 | "@types/node": "^16.11.6", 38 | "@typescript-eslint/eslint-plugin": "5.29.0", 39 | "@typescript-eslint/parser": "5.29.0", 40 | "builtin-modules": "3.3.0", 41 | "esbuild": "0.17.3", 42 | "esbuild-plugin-alias": "^0.2.1", 43 | "jest": "^29.4.2", 44 | "obsidian": "latest", 45 | "obsidian-undocumented": "^0.1.2", 46 | "rollup": "^3.14.0", 47 | "rollup-plugin-dts": "^5.1.1", 48 | "rollup-plugin-prettier": "^3.0.0", 49 | "sass": "^1.58.3", 50 | "sorcery": "^0.11.0", 51 | "source-map": "^0.7.4", 52 | "ts-jest": "^29.0.5", 53 | "typescript": "4.7.4" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | // --------------------------------------------------------------------------------------------------------------------- 2 | // This is the configuration file for building the Obsidian plugin. 3 | // --------------------------------------------------------------------------------------------------------------------- 4 | import esbuild from 'esbuild'; 5 | import process from 'process'; 6 | 7 | import { external, outdir } from './build.config.mjs'; 8 | import esbuildAlias from 'esbuild-plugin-alias'; 9 | import esbuildCssInJs from './build/esbuild-plugin-cssints/esbuild-plugin-cssints.mjs'; 10 | import esbuildObsidian from './build/esbuild-plugin-obsidian/esbuild-plugin-obsidian.mjs'; 11 | import ts from 'typescript'; 12 | 13 | const banner = `/* 14 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 15 | if you want to view the source, please visit the github repository of this plugin 16 | */ 17 | `; 18 | 19 | const prod = process.argv[2] === 'production'; 20 | const tsconfig = ts.getParsedCommandLineOfConfigFile("tsconfig.json", undefined, ts.createCompilerHost({})); 21 | const aliases = Object.keys(tsconfig.options.paths).map(([module, paths]) => paths[0]); 22 | 23 | const context = await esbuild.context({ 24 | banner: { 25 | js: banner, 26 | }, 27 | entryPoints: ['src/main.ts'], 28 | bundle: true, 29 | plugins: [ 30 | esbuildAlias(aliases), 31 | esbuildObsidian({ outdir: '.' }), 32 | esbuildCssInJs({ compressed: prod }) 33 | ], 34 | loader: { 35 | '.md': 'text', 36 | }, 37 | format: 'cjs', 38 | target: 'es2020', 39 | logLevel: 'info', 40 | sourcemap: prod ? false : 'inline', 41 | treeShaking: true, 42 | external, 43 | outdir, 44 | }); 45 | 46 | if (prod) { 47 | await context.rebuild(); 48 | process.exit(0); 49 | } else { 50 | await context.watch(); 51 | } 52 | -------------------------------------------------------------------------------- /src/css-parser.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from '@jest/globals'; 2 | 3 | import { getCalloutsFromCSS } from './css-parser'; 4 | 5 | describe('getCalloutsFromCSS', () => { 6 | test('no quotes', () => { 7 | expect(getCalloutsFromCSS('[data-callout=foo]')).toStrictEqual(['foo']); 8 | expect(getCalloutsFromCSS('[data-callout=foo-bar-baz]')).toStrictEqual(['foo-bar-baz']); 9 | }); 10 | 11 | test('single quotes', () => { 12 | expect(getCalloutsFromCSS('[data-callout=\'foo\']')).toStrictEqual(['foo']); 13 | expect(getCalloutsFromCSS('[data-callout=\'foo-bar-baz\']')).toStrictEqual(['foo-bar-baz']); 14 | }); 15 | 16 | test('double quotes', () => { 17 | expect(getCalloutsFromCSS('[data-callout="foo"]')).toStrictEqual(['foo']); 18 | expect(getCalloutsFromCSS('[data-callout="foo-bar-baz"]')).toStrictEqual(['foo-bar-baz']); 19 | }); 20 | 21 | test('allows matching start-of-attribute', () => { 22 | expect(getCalloutsFromCSS('[data-callout^=foo]')).toStrictEqual(['foo']); 23 | }); 24 | 25 | test('ignores partial matches', () => { 26 | expect(getCalloutsFromCSS('[data-callout*=foo]')).toStrictEqual([]); // contains 27 | expect(getCalloutsFromCSS('[data-callout~=foo]')).toStrictEqual([]); // within space-delimited list 28 | expect(getCalloutsFromCSS('[data-callout|=foo]')).toStrictEqual([]); // within dash-delimited list 29 | expect(getCalloutsFromCSS('[data-callout$=foo]')).toStrictEqual([]); // ends-with 30 | }); 31 | 32 | test('styles are complex', () => { 33 | expect(getCalloutsFromCSS('div.foo[data-callout=foo]{background-color: red !important}')).toStrictEqual(['foo']); 34 | expect(getCalloutsFromCSS('div:is([data-callout=foo])')).toStrictEqual(['foo']); 35 | }); 36 | 37 | test('styles have multiple selectors', () => { 38 | expect(getCalloutsFromCSS('div.foo[data-callout=foo], [data-callout=bar]')).toStrictEqual(['foo', 'bar']); 39 | }); 40 | 41 | test('styles have newline', () => { 42 | expect(getCalloutsFromCSS('[data-callout=foo]\n[data-callout=bar]')).toStrictEqual(['foo', 'bar']); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/search/search.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from '@jest/globals'; 2 | 3 | import { BitField } from './bitfield'; 4 | import { equals, includes, startsWith } from './condition'; 5 | import { add, filter, remove } from './effect'; 6 | import { Search, SearchOptions } from './search'; 7 | 8 | function createSearch(items: string[], options?: SearchOptions): Search { 9 | const search = new Search({ 10 | ...(options ?? {}), 11 | columns: { 12 | test: {}, 13 | }, 14 | indexItem(item, index) { 15 | return BitField.fromPosition(index.column('test').add(item)); 16 | }, 17 | }); 18 | 19 | search.addItems(items); 20 | search.reset(); 21 | return search; 22 | } 23 | 24 | describe('Search', () => { 25 | test('resetToAll', () => { 26 | const s = createSearch(['foo'], { resetToAll: true, resultRanking: false }); 27 | expect(s.results).toStrictEqual(['foo']); 28 | }); 29 | 30 | test('works', () => { 31 | const s = createSearch(['foo', 'bar', 'baz'], { resetToAll: true, resultRanking: false }); 32 | 33 | s.reset(); 34 | s.search('test', startsWith, 'ba', filter); 35 | expect(s.results.slice(0).sort()).toStrictEqual(['bar', 'baz'].sort()); 36 | }); 37 | 38 | test('combinations work', () => { 39 | const s = createSearch(['foo', 'bar', 'baz'], { resetToAll: true, resultRanking: false }); 40 | 41 | s.reset(); 42 | s.search('test', startsWith, 'ba', filter); 43 | s.search('test', includes, 'ar', filter); 44 | s.search('test', equals, 'foo', add); 45 | expect(s.results.slice(0).sort()).toStrictEqual(['foo', 'bar'].sort()); 46 | 47 | s.reset(); 48 | s.search('test', startsWith, 'ba', filter); 49 | s.search('test', equals, 'bar', remove); 50 | expect(s.results.slice(0).sort()).toStrictEqual(['baz'].sort()); 51 | }); 52 | 53 | test('ranking works', () => { 54 | const s = createSearch(['doge', 'dog'], { resetToAll: true, resultRanking: true }); 55 | 56 | s.reset(); 57 | s.search('test', startsWith, 'dog', filter); 58 | expect(s.results).toStrictEqual(['dog', 'doge']); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/search/normalize.ts: -------------------------------------------------------------------------------- 1 | import { WithBrand } from '@coderspirit/nominal'; 2 | 3 | /** 4 | * A normalized property value. 5 | */ 6 | export type NormalizedValue = WithBrand; 7 | 8 | /** 9 | * A function that normalizes a property through some arbitrary but consistent means. 10 | * @nosideeffects 11 | */ 12 | export type NormalizationFunction = (this: void, text: string) => string; 13 | 14 | /** 15 | * Case-folding {@link NormalizationFunction normalization}. 16 | * @param text The input text. 17 | * @returns The input text, entirely in lower-case form. 18 | */ 19 | export function casefold(text: string): string { 20 | return text.toLowerCase(); 21 | } 22 | 23 | /** 24 | * Unicode {@link NormalizationFunction normalization}. 25 | * This uses Normalization Form C. 26 | * 27 | * @param text The input text. 28 | * @returns The input text, normalized according to Unicode NFC rules. 29 | */ 30 | export function unicode(text: string): string { 31 | return text.normalize('NFC'); 32 | } 33 | 34 | /** 35 | * Whitespace trimming {@link NormalizationFunction normalization}. 36 | * This removes leading and trailing whitespace. 37 | * 38 | * @param text The input text. 39 | * @returns The input text, without leading and trailing whitespace. 40 | */ 41 | export function trimmed(text: string): string { 42 | return text.trim(); 43 | } 44 | 45 | /** 46 | * {@link NormalizationFunction Normalization} that strips accents and combining characters from glyphs. 47 | * 48 | * @param text The input text. 49 | * @returns The input text, without accents. 50 | */ 51 | export function unaccented(text: string): string { 52 | return text.normalize('NFD').replace(/\p{M}/gu, ''); 53 | } 54 | 55 | /** 56 | * Combines multiple {@link NormalizationFunction}s into a single one. 57 | * 58 | * @param fns The functions to combine. 59 | * @returns The combined function. 60 | */ 61 | export function combinedNormalization(fns: NormalizationFunction[]): NormalizationFunction { 62 | return (text) => { 63 | for (const fn of fns) { 64 | text = fn(text); 65 | } 66 | 67 | return text; 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /api/functions.ts: -------------------------------------------------------------------------------- 1 | import { RGB } from 'obsidian'; 2 | 3 | import type Callout from './callout'; 4 | import { CalloutManagerEvent, CalloutManagerEventListener } from './events'; 5 | 6 | /** 7 | * A handle for the Callout Manager API. 8 | */ 9 | export type CalloutManager = 10 | (WithPluginReference extends true ? CalloutManagerOwnedHandle : CalloutManagerUnownedHandle); 11 | 12 | /** 13 | * An unowned handle for the Callout Manager API. 14 | */ 15 | interface CalloutManagerUnownedHandle { 16 | 17 | /** 18 | * Gets the list of available callouts. 19 | */ 20 | getCallouts(): ReadonlyArray; 21 | 22 | /** 23 | * Tries to parse the color of a {@link Callout callout} into an Obsidian {@link RGB} object. 24 | * If the color is not a valid callout color, you can access the invalid color string through the `invalid` property. 25 | * 26 | * @param callout The callout. 27 | */ 28 | getColor(callout: Callout): RGB | { invalid: string }; 29 | 30 | /** 31 | * Gets the title text of a {@link Callout callout}. 32 | * 33 | * @param callout The callout. 34 | */ 35 | getTitle(callout: Callout): string; 36 | } 37 | 38 | /** 39 | * An owned handle for the Callout Manager API. 40 | */ 41 | interface CalloutManagerOwnedHandle extends CalloutManagerUnownedHandle { 42 | 43 | /** 44 | * Registers an event listener. 45 | * If Callout Manager or the handle owner plugin are unloaded, all events will be unregistered automatically. 46 | * 47 | * @param event The event to listen for. 48 | * @param listener The listener function. 49 | */ 50 | on(event: E, listener: CalloutManagerEventListener): void; 51 | 52 | /** 53 | * Unregisters an event listener. 54 | * 55 | * In order to unregister a listener successfully, the exact reference of the listener function provided to 56 | * {@link on} must be provided as the listener parameter to this function. 57 | * 58 | * @param event The event which the listener was bound to. 59 | * @param listener The listener function to unregister. 60 | */ 61 | off(event: E, listener: CalloutManagerEventListener): void; 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/panes/changelog-pane.ts: -------------------------------------------------------------------------------- 1 | import CalloutManagerPlugin from '&plugin'; 2 | 3 | import { UIPane } from '&ui/pane'; 4 | 5 | import { getSections } from '../changelog'; 6 | 7 | /** 8 | * A pane that shows the plugin changelog. 9 | */ 10 | export class ChangelogPane extends UIPane { 11 | public readonly title = 'Changelog'; 12 | private readonly plugin: CalloutManagerPlugin; 13 | private changelogEl: HTMLElement; 14 | 15 | public constructor(plugin: CalloutManagerPlugin) { 16 | super(); 17 | this.plugin = plugin; 18 | 19 | // Create the changelog element. 20 | const sections = getSections(plugin); 21 | const frag = document.createDocumentFragment(); 22 | this.changelogEl = frag.createDiv({ cls: 'calloutmanager-changelog' }); 23 | 24 | Array.from(sections.values()).forEach(({ version, containerEl: el }) => { 25 | this.changelogEl.appendChild(el); 26 | if (version === this.plugin.manifest.version) { 27 | el.setAttribute('open', ''); 28 | el.setAttribute('data-current-version', 'true'); 29 | } 30 | }); 31 | } 32 | 33 | /** @override */ 34 | public display(): void { 35 | const { containerEl } = this; 36 | 37 | containerEl.appendChild(this.changelogEl); 38 | } 39 | } 40 | 41 | // --------------------------------------------------------------------------------------------------------------------- 42 | // Styles: 43 | // --------------------------------------------------------------------------------------------------------------------- 44 | 45 | declare const STYLES: ` 46 | .calloutmanager-changelog > *:not(:first-child) { 47 | margin-top: 1em; 48 | } 49 | 50 | .calloutmanager-changelog { 51 | --callout-blend-mode: normal; 52 | 53 | details { 54 | > summary::marker { 55 | color: var(--text-faint); 56 | } 57 | 58 | &[open] > summary::marker { 59 | color: var(--text-muted); 60 | } 61 | 62 | .calloutmanager-changelog-section { 63 | border-bottom: 1px solid var(--background-modifier-border); 64 | margin-bottom: 1.25em; 65 | padding-bottom: 1.25em; 66 | 67 | > :last-child { 68 | margin-bottom: 0; 69 | } 70 | } 71 | } 72 | 73 | details:not([data-current-version=true]) { 74 | .calloutmanager-changelog-heading { 75 | color: var(--text-muted); 76 | } 77 | } 78 | } 79 | `; 80 | -------------------------------------------------------------------------------- /src/api-v1.ts: -------------------------------------------------------------------------------- 1 | import { Events, Plugin, RGB } from 'obsidian'; 2 | 3 | import { getColorFromCallout, getTitleFromCallout } from '&callout-util'; 4 | import CalloutManagerPlugin from '&plugin'; 5 | 6 | import { Callout, CalloutManager } from '../api'; 7 | import { CalloutManagerEvent, CalloutManagerEventListener } from '../api/events'; 8 | import { destroy, emitter } from './api-common'; 9 | 10 | export class CalloutManagerAPI_V1 implements CalloutManager { 11 | private readonly plugin: CalloutManagerPlugin; 12 | private readonly consumer: Plugin | undefined; 13 | 14 | public readonly [emitter]: Events; 15 | 16 | public constructor(plugin: CalloutManagerPlugin, consumer: Plugin | undefined) { 17 | this.plugin = plugin; 18 | this.consumer = consumer; 19 | this[emitter] = new Events(); 20 | 21 | if (consumer != null) { 22 | console.debug('Created API V1 Handle:', { plugin: consumer.manifest.id }); 23 | } 24 | } 25 | 26 | /** 27 | * Called to destroy an API handle bound to a consumer. 28 | */ 29 | public [destroy]() { 30 | const consumer = this.consumer as Plugin; 31 | console.debug('Destroyed API V1 Handle:', { plugin: consumer.manifest.id }); 32 | } 33 | 34 | /** @override */ 35 | public getCallouts(): Readonly[] { 36 | return this.plugin.callouts.values().map((callout) => Object.freeze({ ...callout })); 37 | } 38 | 39 | /** @override */ 40 | public getColor(callout: Callout): RGB | { invalid: string } { 41 | const color = getColorFromCallout(callout); 42 | return color ?? { invalid: callout.color }; 43 | } 44 | 45 | /** @override */ 46 | public getTitle(callout: Callout): string { 47 | return getTitleFromCallout(callout); 48 | } 49 | 50 | /** @override */ 51 | public on(event: E, listener: CalloutManagerEventListener): void { 52 | if (this.consumer == null) { 53 | throw new Error('Cannot listen for events without an API consumer.'); 54 | } 55 | 56 | this[emitter].on(event, listener); 57 | } 58 | 59 | /** @override */ 60 | public off(event: E, listener: CalloutManagerEventListener): void { 61 | if (this.consumer == null) { 62 | throw new Error('Cannot listen for events without an API consumer.'); 63 | } 64 | 65 | this[emitter].off(event, listener); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/search/condition.ts: -------------------------------------------------------------------------------- 1 | import { prepareFuzzySearch } from 'obsidian'; 2 | 3 | import { BitField } from './bitfield'; 4 | import { NormalizedValue } from './normalize'; 5 | import { ReadonlySearchIndexColumn } from './search-index'; 6 | 7 | export type SearchCondition = ( 8 | index: ReadonlySearchIndexColumn, 9 | using: NormalizedValue, 10 | scores: Float32Array, 11 | ) => BitField; 12 | 13 | /** 14 | * The query fuzzily matches a property. 15 | */ 16 | export function matches( 17 | index: ReadonlySearchIndexColumn, 18 | textToMatch: NormalizedValue, 19 | scores: Float32Array, 20 | ): BitField { 21 | const matchesQuery = prepareFuzzySearch(textToMatch.trim()); 22 | let mask = 0n; 23 | 24 | for (const [text, bit] of index) { 25 | const res = matchesQuery(text); 26 | if (res != null) { 27 | mask |= 1n << BigInt(bit); 28 | scores[bit] += res.score; 29 | } 30 | } 31 | 32 | return mask as BitField; 33 | } 34 | 35 | /** 36 | * The query is a substring of the property. 37 | */ 38 | export function includes( 39 | index: ReadonlySearchIndexColumn, 40 | textToInclude: NormalizedValue, 41 | scores: Float32Array, 42 | ): BitField { 43 | let mask = 0n; 44 | 45 | for (const [text, bit] of index) { 46 | if (text.includes(textToInclude)) { 47 | mask |= 1n << BigInt(bit); 48 | scores[bit] += textToInclude.length / text.length; 49 | } 50 | } 51 | 52 | return mask as BitField; 53 | } 54 | 55 | /** 56 | * The query exactly matches the property. 57 | */ 58 | export function equals(index: ReadonlySearchIndexColumn, textToHave: NormalizedValue, scores: Float32Array): BitField { 59 | let mask = 0n; 60 | 61 | for (const [text, bit] of index) { 62 | if (text.includes(textToHave)) { 63 | mask |= 1n << BigInt(bit); 64 | scores[bit] += textToHave.length / text.length; 65 | } 66 | } 67 | 68 | return mask as BitField; 69 | } 70 | 71 | /** 72 | * The query starts with the property. 73 | */ 74 | export function startsWith( 75 | index: ReadonlySearchIndexColumn, 76 | textToStart: NormalizedValue, 77 | scores: Float32Array, 78 | ): BitField { 79 | let mask = 0n; 80 | 81 | for (const [text, bit] of index) { 82 | if (text.startsWith(textToStart)) { 83 | mask |= 1n << BigInt(bit); 84 | scores[bit] += textToStart.length / text.length; 85 | } 86 | } 87 | 88 | return mask as BitField; 89 | } 90 | -------------------------------------------------------------------------------- /src/panes/edit-callout-pane/editor-unified.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from 'obsidian'; 2 | import { getCurrentColorScheme } from 'obsidian-extra'; 3 | 4 | import { CalloutSettings } from '&callout-settings'; 5 | 6 | import { CalloutColorSetting } from '&ui/setting/callout-color'; 7 | import { CalloutIconSetting } from '&ui/setting/callout-icon'; 8 | 9 | import { AppearanceEditor } from './appearance-editor'; 10 | import { PerSchemeAppearance, UnifiedAppearance } from './appearance-type'; 11 | 12 | export default class UnifiedAppearanceEditor extends AppearanceEditor { 13 | /** @override */ 14 | public toSettings(): CalloutSettings { 15 | const { otherChanges, color } = this.appearance; 16 | const changes = { 17 | ...otherChanges, 18 | color: color, 19 | }; 20 | 21 | if (color === undefined) { 22 | delete changes.color; 23 | } 24 | 25 | return Object.keys(changes).length === 0 ? [] : [{ changes }]; 26 | } 27 | 28 | public render() { 29 | const { plugin, containerEl, callout, setAppearance } = this; 30 | const { color, otherChanges } = this.appearance; 31 | 32 | const colorScheme = getCurrentColorScheme(plugin.app); 33 | const otherColorScheme = colorScheme === 'dark' ? 'light' : 'dark'; 34 | 35 | new CalloutColorSetting(containerEl, callout) 36 | .setName('Color') 37 | .setDesc('Change the color of the callout.') 38 | .setColorString(color) 39 | .onChange((color) => setAppearance({ type: 'unified', otherChanges, color })); 40 | 41 | new Setting(containerEl) 42 | .setName(`Color Scheme`) 43 | .setDesc(`Change the color of the callout for the ${otherColorScheme} color scheme.`) 44 | .addButton((btn) => 45 | btn 46 | .setClass('clickable-icon') 47 | .setIcon('lucide-sun-moon') 48 | .onClick(() => { 49 | const currentColor = color ?? callout.color; 50 | setAppearance({ 51 | type: 'per-scheme', 52 | colorDark: currentColor, 53 | colorLight: currentColor, 54 | otherChanges, 55 | } as PerSchemeAppearance); 56 | }), 57 | ); 58 | 59 | new CalloutIconSetting(containerEl, callout, plugin, () => this.nav) 60 | .setName('Icon') 61 | .setDesc('Change the callout icon.') 62 | .setIcon(otherChanges.icon) 63 | .onChange((icon) => setAppearance({ type: 'unified', color, otherChanges: { ...otherChanges, icon } })); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/ui/component/icon-preview.ts: -------------------------------------------------------------------------------- 1 | import { Component, getIcon } from 'obsidian'; 2 | 3 | /** 4 | * A preview of an icon. 5 | * 6 | * This is a button that shows the icon graphic and its name. 7 | */ 8 | export class IconPreviewComponent extends Component { 9 | public readonly componentEl: HTMLElement; 10 | public readonly iconEl: HTMLElement; 11 | public readonly idEl: HTMLElement; 12 | 13 | public constructor(containerEl: HTMLElement) { 14 | super(); 15 | this.componentEl = containerEl.createEl('button', { cls: 'calloutmanager-icon-preview' }); 16 | this.iconEl = this.componentEl.createDiv({ cls: 'calloutmanager-icon-preview--icon' }); 17 | this.idEl = this.componentEl.createDiv({ cls: 'calloutmanager-icon-preview--id' }); 18 | } 19 | 20 | /** 21 | * Sets the icon of the icon preview component. 22 | * This will update the label and the icon SVG. 23 | * 24 | * @param icon The icon name. 25 | * @returns This, for chaining. 26 | */ 27 | public setIcon(icon: string): typeof this { 28 | const iconSvg = getIcon(icon); 29 | 30 | this.componentEl.setAttribute('data-icon-id', icon); 31 | this.idEl.textContent = icon; 32 | this.iconEl.empty(); 33 | if (iconSvg != null) { 34 | this.iconEl.appendChild(iconSvg); 35 | } 36 | 37 | return this; 38 | } 39 | 40 | /** 41 | * Sets the `click` event listener for the component. 42 | * 43 | * @param listener The listener. 44 | * @returns This, for chaining. 45 | */ 46 | public onClick(listener: (evt: MouseEvent) => void): typeof this { 47 | this.componentEl.onclick = listener; 48 | return this; 49 | } 50 | } 51 | 52 | declare const STYLES: ` 53 | :root { 54 | --calloutmanager-icon-preview-icon-size: 1em; 55 | --calloutmanager-icon-preview-id-size: 0.8em; 56 | } 57 | 58 | .calloutmanager-icon-preview { 59 | position: relative; 60 | height: unset; 61 | min-height: 3em; 62 | 63 | display: flex; 64 | flex-direction: column; 65 | } 66 | 67 | .calloutmanager-icon-preview--icon { 68 | position: absolute; 69 | top: 50%; 70 | left: 50%; 71 | transform: translate(-50%, calc(-50% - 0.5em)); 72 | 73 | --icon-size: var(--calloutmanager-icon-picker-icon-size); 74 | } 75 | 76 | .calloutmanager-icon-preview--id { 77 | width: 100%; 78 | margin-top: auto; 79 | 80 | // Break words. 81 | white-space: normal; 82 | word-break: break-word; 83 | hyphens: manual; 84 | 85 | font-size: var(--calloutmanager-icon-picker-id-size); 86 | } 87 | `; 88 | -------------------------------------------------------------------------------- /src/panes/create-callout-pane.ts: -------------------------------------------------------------------------------- 1 | import { Setting, TextComponent } from 'obsidian'; 2 | 3 | import CalloutManagerPlugin from '&plugin'; 4 | 5 | import { UIPane } from '&ui/pane'; 6 | 7 | import { ValiditySet } from '../util/validity-set'; 8 | 9 | import { EditCalloutPane } from './edit-callout-pane'; 10 | 11 | export class CreateCalloutPane extends UIPane { 12 | public readonly title = { title: 'Callouts', subtitle: 'New Callout' }; 13 | private readonly plugin: CalloutManagerPlugin; 14 | 15 | private btnCreate: HTMLButtonElement; 16 | private fieldId: Setting; 17 | private fieldIdComponent!: TextComponent; 18 | private validity: ValiditySet; 19 | 20 | public constructor(plugin: CalloutManagerPlugin) { 21 | super(); 22 | this.plugin = plugin; 23 | this.validity = new ValiditySet(ValiditySet.AllValid); 24 | 25 | const btnCreate = (this.btnCreate = document.createElement('button')); 26 | btnCreate.textContent = 'Create'; 27 | btnCreate.addEventListener('click', (evt) => { 28 | if (!this.validity.valid) { 29 | return; 30 | } 31 | 32 | const id = this.fieldIdComponent.getValue(); 33 | this.plugin.createCustomCallout(id); 34 | this.nav.replace(new EditCalloutPane(this.plugin, id, false)); 35 | }); 36 | 37 | this.fieldId = new Setting(document.createElement('div')) 38 | .setHeading() 39 | .setName('Callout Name') 40 | .setDesc('This is how you will refer to your callout in Markdown.') 41 | .addText((cmp) => { 42 | this.fieldIdComponent = cmp; 43 | cmp.setPlaceholder('my-awesome-callout'); 44 | 45 | makeTextComponentValidateCalloutID(cmp, 'id', this.validity); 46 | }); 47 | 48 | this.validity.onChange((valid) => { 49 | this.btnCreate.disabled = !valid; 50 | }); 51 | } 52 | 53 | /** @override */ 54 | public display(): void { 55 | const { containerEl } = this; 56 | 57 | containerEl.appendChild(this.fieldId.settingEl); 58 | containerEl.createDiv().appendChild(this.btnCreate); 59 | } 60 | } 61 | 62 | export function makeTextComponentValidateCalloutID(cmp: TextComponent, id: string, vs: ValiditySet): void { 63 | cmp.then(({ inputEl }) => { 64 | const update = vs.addSource(id); 65 | 66 | inputEl.setAttribute('pattern', '^[a-z\\-]{1,}$'); 67 | inputEl.setAttribute('required', 'required'); 68 | inputEl.addEventListener('change', onChange); 69 | inputEl.addEventListener('input', onChange); 70 | 71 | update(inputEl.validity.valid); 72 | function onChange() { 73 | update(inputEl.validity.valid); 74 | } 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /src/apis.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from 'obsidian'; 2 | 3 | import CalloutManagerPlugin from '&plugin'; 4 | 5 | import { CalloutID, CalloutManager } from '../api'; 6 | 7 | import { destroy, emitter } from './api-common'; 8 | import { CalloutManagerAPI_V1 } from './api-v1'; 9 | 10 | export class CalloutManagerAPIs { 11 | private readonly handles: Map; 12 | private readonly plugin: CalloutManagerPlugin; 13 | 14 | public constructor(plugin: CalloutManagerPlugin) { 15 | this.plugin = plugin; 16 | this.handles = new Map(); 17 | } 18 | 19 | /** 20 | * Creates (or gets) an instance of the Callout Manager API for a plugin. 21 | * If the plugin is undefined, only trivial functions are available. 22 | * 23 | * @param version The API version. 24 | * @param consumerPlugin The plugin using the API. 25 | * 26 | * @internal 27 | */ 28 | public async newHandle( 29 | version: 'v1', 30 | consumerPlugin: Plugin | undefined, 31 | cleanupFunc: () => void, 32 | ): Promise { 33 | if (version !== 'v1') throw new Error(`Unsupported Callout Manager API: ${version}`); 34 | 35 | // If we aren't trying to create an owned handle, create and return an unowned one. 36 | if (consumerPlugin == null) { 37 | return new CalloutManagerAPI_V1(this.plugin, undefined); 38 | } 39 | 40 | // Otherwise, give back the owned handle for the plugin if we already have one. 41 | const existing = this.handles.get(consumerPlugin); 42 | if (existing != null) { 43 | return existing; 44 | } 45 | 46 | // Register the provided clean-up function on the consumer plugin. 47 | // When the consumer plugin unloads, the cleanup function will call `destroyApiHandle`. 48 | consumerPlugin.register(cleanupFunc); 49 | 50 | // Create a new handle. 51 | const handle = new CalloutManagerAPI_V1(this.plugin, consumerPlugin); 52 | this.handles.set(consumerPlugin, handle); 53 | return handle; 54 | } 55 | 56 | /** 57 | * Destroys an API handle created by {@link newHandle}. 58 | * 59 | * @param version The API version. 60 | * @param consumerPlugin The plugin using the API. 61 | * 62 | * @internal 63 | */ 64 | public destroyHandle(version: 'v1', consumerPlugin: Plugin) { 65 | if (version !== 'v1') throw new Error(`Unsupported Callout Manager API: ${version}`); 66 | 67 | const handle = this.handles.get(consumerPlugin); 68 | if (handle == null) return; 69 | 70 | handle[destroy](); 71 | this.handles.delete(consumerPlugin); 72 | } 73 | 74 | public emitEventForCalloutChange(id?: CalloutID) { 75 | for (const handle of this.handles.values()) { 76 | handle[emitter].trigger('change'); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/panes/edit-callout-pane/editor-per-scheme.ts: -------------------------------------------------------------------------------- 1 | 2 | import { CalloutSetting, CalloutSettings } from '&callout-settings'; 3 | 4 | import { CalloutColorSetting } from '&ui/setting/callout-color'; 5 | import { CalloutIconSetting } from '&ui/setting/callout-icon'; 6 | 7 | import { AppearanceEditor } from './appearance-editor'; 8 | import { PerSchemeAppearance } from './appearance-type'; 9 | 10 | export default class PerSchemeAppearanceEditor extends AppearanceEditor { 11 | /** @override */ 12 | public toSettings(): CalloutSettings { 13 | const { otherChanges, colorDark, colorLight } = this.appearance; 14 | 15 | const forLight: CalloutSetting = { 16 | condition: { colorScheme: 'light' }, 17 | changes: { 18 | color: colorLight, 19 | }, 20 | }; 21 | 22 | const forDark: CalloutSetting = { 23 | condition: { colorScheme: 'dark' }, 24 | changes: { 25 | color: colorDark, 26 | }, 27 | }; 28 | 29 | if (forLight.changes.color === undefined) delete forLight.changes.color; 30 | if (forDark.changes.color === undefined) delete forDark.changes.color; 31 | 32 | return [{ changes: otherChanges }, forLight, forDark]; 33 | } 34 | 35 | protected setAppearanceOrChangeToUnified(appearance: PerSchemeAppearance): void { 36 | const { colorDark, colorLight, otherChanges } = appearance; 37 | 38 | // If both the light and dark colors are default, reset the appearance to the unified type. 39 | if (colorDark === undefined && colorLight === undefined) { 40 | this.setAppearance({ type: 'unified', color: undefined, otherChanges }); 41 | return; 42 | } 43 | 44 | this.setAppearance(appearance); 45 | } 46 | 47 | public render() { 48 | const { callout, containerEl, appearance, plugin, nav } = this; 49 | const { colorDark, colorLight, otherChanges } = this.appearance; 50 | 51 | new CalloutColorSetting(containerEl, callout) 52 | .setName('Dark Color') 53 | .setDesc('Change the color of the callout for the dark color scheme.') 54 | .setColorString(colorDark) 55 | .onChange((color) => this.setAppearanceOrChangeToUnified({ ...appearance, colorDark: color })); 56 | 57 | new CalloutColorSetting(containerEl, callout) 58 | .setName(`Light Color`) 59 | .setDesc(`Change the color of the callout for the light color scheme.`) 60 | .setColorString(colorLight) 61 | .onChange((color) => this.setAppearanceOrChangeToUnified({ ...appearance, colorLight: color })); 62 | 63 | new CalloutIconSetting(containerEl, callout, plugin, () => nav) 64 | .setName('Icon') 65 | .setDesc('Change the callout icon.') 66 | .setIcon(otherChanges.icon) 67 | .onChange((icon) => 68 | this.setAppearanceOrChangeToUnified({ ...appearance, otherChanges: { ...otherChanges, icon } }), 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/ui/setting/callout-icon.ts: -------------------------------------------------------------------------------- 1 | import { ButtonComponent, ExtraButtonComponent, Setting, getIcon } from 'obsidian'; 2 | 3 | import { Callout } from '../../../api'; 4 | import CalloutManagerPlugin from '../../main'; 5 | import { ResetButtonComponent } from '../component/reset-button'; 6 | import { SelectIconPane } from '../../panes/select-icon-pane'; 7 | import { UIPaneNavigation } from '&ui/pane'; 8 | 9 | /** 10 | * An Obsidian {@link Setting} for picking the icon of a callout. 11 | */ 12 | export class CalloutIconSetting extends Setting { 13 | private readonly callout: Callout; 14 | private buttonComponent!: ButtonComponent; 15 | private resetComponent!: ExtraButtonComponent; 16 | 17 | private isDefault: boolean; 18 | private iconName: string | undefined; 19 | private onChanged: ((value: string | undefined) => void) | undefined; 20 | 21 | public constructor( 22 | containerEl: HTMLElement, 23 | callout: Callout, 24 | plugin: CalloutManagerPlugin, 25 | getNav: () => UIPaneNavigation, 26 | ) { 27 | super(containerEl); 28 | this.onChanged = undefined; 29 | this.callout = callout; 30 | this.isDefault = true; 31 | this.iconName = undefined; 32 | 33 | // Create the setting archetype. 34 | this.addButton((btn) => { 35 | this.buttonComponent = btn; 36 | btn.onClick(() => { 37 | getNav().open( 38 | new SelectIconPane(plugin, 'Select Icon', { onChoose: (icon) => this.onChanged?.(icon) }), 39 | ); 40 | }); 41 | }); 42 | 43 | this.components.push( 44 | new ResetButtonComponent(this.controlEl).then((btn) => { 45 | this.resetComponent = btn; 46 | btn.onClick(() => this.onChanged?.(undefined)); 47 | }), 48 | ); 49 | 50 | this.setIcon(undefined); 51 | } 52 | 53 | /** 54 | * Sets the icon. 55 | * 56 | * @param icon The icon name or undefined to reset the color to default. 57 | * @returns `this`, for chaining. 58 | */ 59 | public setIcon(icon: string | undefined): typeof this { 60 | const isDefault = (this.isDefault = icon == null); 61 | const iconName = (this.iconName = icon ?? this.callout.icon); 62 | const iconExists = getIcon(iconName) != null; 63 | 64 | // Update components. 65 | if (iconExists) { 66 | this.buttonComponent.setIcon(iconName); 67 | } else { 68 | this.buttonComponent.setButtonText(iconExists ? '' : `(missing icon: ${iconName})`); 69 | } 70 | 71 | this.resetComponent.setDisabled(isDefault).setTooltip(isDefault ? '' : 'Reset Icon'); 72 | return this; 73 | } 74 | 75 | public getIcon(): string { 76 | return this.iconName ?? this.callout.icon; 77 | } 78 | 79 | public isDefaultIcon(): boolean { 80 | return this.isDefault; 81 | } 82 | 83 | public onChange(cb: (value: string | undefined) => void): typeof this { 84 | this.onChanged = cb; 85 | return this; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/util/validity-set.ts: -------------------------------------------------------------------------------- 1 | import { ButtonComponent, EventRef, Events } from 'obsidian'; 2 | 3 | /** 4 | * A set of validity states that can be reduced down to a single "valid" or "invalid" result. 5 | */ 6 | export class ValiditySet { 7 | private _emitter: Events; 8 | private _reducer: (states: Record) => boolean; 9 | private _lastReducedValidity: boolean | null; 10 | private _cachedValidity: Record; 11 | 12 | public constructor(reducer: (states: Record) => boolean) { 13 | this._emitter = new Events(); 14 | this._reducer = reducer; 15 | this._lastReducedValidity = null; 16 | this._cachedValidity = {}; 17 | } 18 | 19 | /** 20 | * The current validity. 21 | */ 22 | public get valid(): boolean { 23 | const { _lastReducedValidity } = this; 24 | if (_lastReducedValidity == null) throw new Error('No validity available.'); 25 | return _lastReducedValidity; 26 | } 27 | 28 | /** 29 | * Runs the provided function when the reduced validity changes. 30 | * 31 | * @param callback The callback to run. 32 | * @returns An event ref. 33 | */ 34 | public onChange(callback: (valid: boolean) => void): EventRef { 35 | if (this._lastReducedValidity != null) { 36 | callback(this._lastReducedValidity); 37 | } 38 | 39 | return this._emitter.on('change', callback); 40 | } 41 | 42 | /** 43 | * Updates the provided component's disabled state when the reduced validity changes. 44 | * 45 | * @param component The component to update. 46 | * @returns An event ref. 47 | */ 48 | public onChangeUpdateDisabled(component: ButtonComponent): EventRef { 49 | return this.onChange((valid) => { 50 | component.setDisabled(!valid); 51 | }); 52 | } 53 | 54 | /** 55 | * Adds a validity source. 56 | * 57 | * @param id The source's unique ID. 58 | * @returns A function for updating the validity. 59 | */ 60 | public addSource(id: string): (valid: boolean) => void { 61 | return (valid: boolean) => { 62 | // Do nothing if the validity hasn't changed. 63 | const cachedValidity = this._cachedValidity[id]; 64 | if (cachedValidity === valid) return; 65 | 66 | // Update the cached state and re-run the reducer. 67 | this._cachedValidity[id] = valid; 68 | const newValidity = this._reducer({ ...this._cachedValidity }); 69 | 70 | // Run the callbacks if the reduced validity has changed. 71 | if (newValidity !== this._lastReducedValidity) { 72 | this._lastReducedValidity = newValidity; 73 | this._emitter.trigger('change', newValidity); 74 | } 75 | }; 76 | } 77 | } 78 | 79 | export namespace ValiditySet { 80 | /** 81 | * A reducer that only reduces to true if all constitutent parts are true. 82 | */ 83 | export function AllValid(states: Record) { 84 | return !Object.values(states).includes(false); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/sort.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from '@jest/globals'; 2 | 3 | import {Callout} from "&callout"; 4 | import { Precomputed, combinedComparison, compareColor, compareId } from './sort'; 5 | 6 | /** 7 | * Creates something for compareColor. 8 | */ 9 | function C(rgb: string): Precomputed { 10 | const callout = { 11 | id: '', 12 | color: rgb, 13 | }; 14 | 15 | return { 16 | value: callout, 17 | computed: compareColor.precompute(callout), 18 | }; 19 | } 20 | 21 | describe('compareColor', () => { 22 | test('shades', () => { 23 | expect(compareColor(C('255, 255, 255'), C('0, 0, 0'))).toBeGreaterThan(0); 24 | expect(compareColor(C('255, 255, 255'), C('127, 127, 127'))).toBeGreaterThan(0); 25 | expect(compareColor(C('255, 255, 255'), C('255, 255, 255'))).toBe(0); 26 | }); 27 | 28 | test('shades and colors', () => { 29 | expect(compareColor(C('255, 0, 0'), C('255, 255, 255'))).toBeGreaterThan(0); 30 | expect(compareColor(C('255, 255, 254'), C('0, 0, 0'))).toBeGreaterThan(0); 31 | }); 32 | 33 | test('hues', () => { 34 | expect(compareColor(C('168, 50, 50'), C('70, 168, 50'))).toBeLessThan(0); 35 | expect(compareColor(C('50, 54, 168'), C('70, 168, 50'))).toBeGreaterThan(0); 36 | expect(compareColor(C('255, 0, 0'), C('255, 0, 0'))).toBe(0); 37 | }); 38 | 39 | test('equal hues', () => { 40 | expect(compareColor(C('0, 49, 255'), C('82, 116, 255'))).toBeGreaterThan(0); // higher saturation > lower saturation 41 | expect(compareColor(C('0, 49, 255'), C('1, 30, 145'))).toBeGreaterThan(0); // higher value > lower value 42 | expect(compareColor(C('7, 48, 232'), C('45, 68, 173'))).toBeGreaterThan(0); // higher both > lower both 43 | }); 44 | 45 | test('validity', () => { 46 | expect(compareColor(C('255, 255, 255'), C('invalid'))).toBeGreaterThan(0); 47 | expect(compareColor(C('invalid'), C('127, 0, 0'))).toBeLessThan(0); 48 | }); 49 | }); 50 | 51 | /** 52 | * Creates something for compareId. 53 | */ 54 | function I(id: string): Precomputed> { 55 | const callout = { 56 | id, 57 | color: '', 58 | }; 59 | 60 | return { 61 | value: callout, 62 | computed: {}, 63 | }; 64 | } 65 | 66 | describe('compareId', () => { 67 | test('basic', () => { 68 | expect(compareId(I('a'), I('b'))).toBeGreaterThan(0); 69 | expect(compareId(I('A'), I('B'))).toBeGreaterThan(0); 70 | }); 71 | }); 72 | 73 | describe('combinedComparison', () => { 74 | test('color then id', () => { 75 | const compare = combinedComparison([compareColor, compareId]); 76 | const P = (value) => ({ value, computed: compare.precompute(value) }); 77 | 78 | expect(compare(P({ id: 'b', color: '255, 255, 255' }), P({ id: 'a', color: '0, 0, 0' }))).toBeGreaterThan(0); 79 | expect(compare(P({ id: 'b', color: '255, 255, 255' }), P({ id: 'a', color: '255, 255, 255' }))).toBeLessThan(0); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/panes/edit-callout-pane/misc-editor.ts: -------------------------------------------------------------------------------- 1 | import { EditCalloutPane } from '.'; 2 | 3 | import { Setting, TextComponent } from 'obsidian'; 4 | 5 | import { Callout } from '&callout'; 6 | import CalloutManagerPlugin from '&plugin'; 7 | 8 | import { UIPaneNavigation } from '&ui/pane'; 9 | 10 | import { ValiditySet } from '../../util/validity-set'; 11 | import { makeTextComponentValidateCalloutID } from '../create-callout-pane'; 12 | 13 | /** 14 | * An editor UI to change a callout's misc settings. 15 | */ 16 | export class MiscEditor { 17 | public plugin: CalloutManagerPlugin; 18 | 19 | public nav!: UIPaneNavigation; 20 | public viewOnly: boolean; 21 | public callout: Callout; 22 | public containerEl: HTMLElement; 23 | 24 | private renameSetting: Setting | null; 25 | 26 | constructor(plugin: CalloutManagerPlugin, callout: Callout, containerEl: HTMLElement, viewOnly: boolean) { 27 | this.plugin = plugin; 28 | this.callout = callout; 29 | this.containerEl = containerEl; 30 | this.viewOnly = viewOnly; 31 | 32 | this.renameSetting = this.createRenameSetting(); 33 | } 34 | 35 | /** 36 | * Renders the editors. 37 | */ 38 | public render(): void { 39 | this.containerEl.empty(); 40 | if (this.viewOnly) return; 41 | 42 | if (this.renameSetting != null) this.containerEl.appendChild(this.renameSetting.settingEl); 43 | } 44 | 45 | protected createRenameSetting(): Setting | null { 46 | const { plugin, containerEl, callout } = this; 47 | if (callout.sources.length !== 1 || callout.sources[0].type !== 'custom') return null; 48 | 49 | const validity = new ValiditySet(ValiditySet.AllValid); 50 | const desc = document.createDocumentFragment(); 51 | desc.createEl('p', { text: 'Change the name of this callout.' }); 52 | desc.createEl('p', { text: 'This will not update any references in your notes!', cls: 'mod-warning' }); 53 | 54 | let newIdComponent!: TextComponent; 55 | return new Setting(containerEl) 56 | .setName(`Rename`) 57 | .setDesc(desc) 58 | .addText((cmp) => { 59 | newIdComponent = cmp; 60 | cmp.setValue(callout.id).setPlaceholder(callout.id); 61 | 62 | // Ensure the ID is not already in use. 63 | const isUnusedId = validity.addSource('unused'); 64 | cmp.onChange((value) => { 65 | const alreadyExists = plugin.callouts.has(value); 66 | isUnusedId(!alreadyExists); 67 | cmp.inputEl.classList.toggle('invalid', alreadyExists); 68 | }); 69 | 70 | // Ensure the ID is valid. 71 | makeTextComponentValidateCalloutID(cmp, 'id', validity); 72 | }) 73 | .addButton((btn) => { 74 | validity.onChangeUpdateDisabled(btn); 75 | btn.setIcon('lucide-clipboard-signature') 76 | .setTooltip('Rename') 77 | .then(({ buttonEl }) => buttonEl.classList.add('clickable-icon', 'mod-warning')) 78 | .onClick(() => { 79 | if (!validity.valid) return; 80 | const newId = newIdComponent.getValue(); 81 | plugin.renameCustomCallout(callout.id, newId); 82 | this.nav.replace(new EditCalloutPane(plugin, newId, this.viewOnly)); 83 | }); 84 | }); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/callout-resolver.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentColorScheme } from 'obsidian-extra'; 2 | 3 | import { CalloutID } from '&callout'; 4 | 5 | import { IsolatedCalloutPreviewComponent } from '&ui/component/callout-preview'; 6 | 7 | /** 8 | * A class that fetches style information for callouts. 9 | * This keeps a Shadow DOM within the page document and uses getComputedStyles to get CSS variables. 10 | */ 11 | export class CalloutResolver { 12 | private readonly hostElement: HTMLElement; 13 | private readonly calloutPreview: IsolatedCalloutPreviewComponent; 14 | 15 | public constructor() { 16 | this.hostElement = document.body.createDiv({ 17 | cls: 'calloutmanager-callout-resolver', 18 | }); 19 | 20 | this.hostElement.style.setProperty('display', 'none', 'important'); 21 | this.calloutPreview = new IsolatedCalloutPreviewComponent(this.hostElement, { 22 | id: '', 23 | icon: '', 24 | colorScheme: 'dark', 25 | }); 26 | 27 | this.calloutPreview.resetStylePropertyOverrides(); 28 | } 29 | 30 | /** 31 | * Reloads the styles of the callout resolver. 32 | * This is necessary to get up-to-date styles when the application CSS changes. 33 | * 34 | * Note: This will not reload the Obsidian app.css stylesheet. 35 | * @param styles The new style elements to use. 36 | */ 37 | public reloadStyles(): void { 38 | this.calloutPreview.setColorScheme(getCurrentColorScheme(app)); 39 | this.calloutPreview.updateStyles(); 40 | this.calloutPreview.removeStyles((el) => el.getAttribute('data-callout-manager') === 'style-overrides'); 41 | } 42 | 43 | /** 44 | * Removes the host element. 45 | * This should be called when the plugin is unloading. 46 | */ 47 | public unload() { 48 | this.hostElement.remove(); 49 | } 50 | 51 | /** 52 | * Gets the computed styles for a given type of callout. 53 | * This uses the current Obsidian styles, themes, and snippets. 54 | * 55 | * @param id The callout ID. 56 | * @param callback A callback function to run. The styles may only be accessed through this. 57 | * @returns Whatever the callback function returned. 58 | */ 59 | public getCalloutStyles(id: CalloutID, callback: (styles: CSSStyleDeclaration) => T): T { 60 | const { calloutEl } = this.calloutPreview; 61 | calloutEl.setAttribute('data-callout', id); 62 | 63 | // Run the callback. 64 | // We need to use the callback to create the full set of desired return properties because 65 | // window.getComputedStyle returns an object that will update itself automatically. The moment we 66 | // change the host element, all the styles we want from it will be removed. 67 | return callback(window.getComputedStyle(calloutEl)); 68 | } 69 | 70 | /** 71 | * Gets the icon and color for a given type of callout. 72 | * This uses the current Obsidian styles, themes, and snippets. 73 | * 74 | * @param id The callout ID. 75 | * @returns The callout icon and color. 76 | */ 77 | public getCalloutProperties(id: CalloutID): { icon: string; color: string } { 78 | return this.getCalloutStyles(id, (styles) => ({ 79 | icon: styles.getPropertyValue('--callout-icon').trim(), 80 | color: styles.getPropertyValue('--callout-color').trim(), 81 | })); 82 | } 83 | 84 | public get customStyleEl(): HTMLStyleElement { 85 | return this.calloutPreview.customStyleEl as HTMLStyleElement; 86 | } 87 | } 88 | 89 | declare const STYLES: ` 90 | .calloutmanager-callout-resolver { 91 | display: none !important; 92 | } 93 | `; 94 | -------------------------------------------------------------------------------- /src/search/bitfield.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from '@jest/globals'; 2 | 3 | import {BitField, BitPosition, BitPositionRegistry} from "./bitfield"; 4 | 5 | describe('BitField', () => { 6 | test('fromPosition', () => { 7 | expect(BitField.fromPosition(0 as BitPosition)).toBe(1n); 8 | expect(BitField.fromPosition(1 as BitPosition)).toBe(2n); 9 | expect(BitField.fromPosition(8 as BitPosition)).toBe(256n); 10 | }); 11 | 12 | test('fromPositionWithTrailing', () => { 13 | expect(BitField.fromPositionWithTrailing(-1 as BitPosition)).toBe(0b0n); 14 | expect(BitField.fromPositionWithTrailing(0 as BitPosition)).toBe(0b1n); 15 | expect(BitField.fromPositionWithTrailing(1 as BitPosition)).toBe(0b11n); 16 | expect(BitField.fromPositionWithTrailing(2 as BitPosition)).toBe(0b111n); 17 | }); 18 | 19 | 20 | test('scanMostSignificant', () => { 21 | expect(BitField.scanMostSignificant(0b0n as BitField)).toBe(-1); 22 | expect(BitField.scanMostSignificant(0b1n as BitField)).toBe(0); 23 | expect(BitField.scanMostSignificant(0b10n as BitField)).toBe(1); 24 | expect(BitField.scanMostSignificant(0b101n as BitField)).toBe(2); 25 | }); 26 | 27 | 28 | test('and', () => { 29 | expect(BitField.and(0n as BitField, 0n as BitField)).toBe(0n); 30 | expect(BitField.and(0n as BitField, 1n as BitField)).toBe(0n); 31 | expect(BitField.and(1n as BitField, 0n as BitField)).toBe(0n); 32 | expect(BitField.and(1n as BitField, 1n as BitField)).toBe(1n); 33 | expect(BitField.and(0b111n as BitField, 0b111n as BitField)).toBe(0b111n); 34 | }); 35 | 36 | test('or', () => { 37 | expect(BitField.or(0n as BitField, 0n as BitField)).toBe(0n); 38 | expect(BitField.or(0n as BitField, 1n as BitField)).toBe(1n); 39 | expect(BitField.or(1n as BitField, 0n as BitField)).toBe(1n); 40 | expect(BitField.or(1n as BitField, 1n as BitField)).toBe(1n); 41 | expect(BitField.or(0b0110n as BitField, 0b1100n as BitField)).toBe(0b1110n); 42 | }); 43 | 44 | test('not', () => { 45 | expect(BitField.not(0n as BitField, 0)).toBe(0n); // Special case. 46 | expect(BitField.not(0n as BitField, 1)).toBe(1n); 47 | expect(BitField.not(1n as BitField, 1)).toBe(0n); 48 | expect(BitField.not(0b10n as BitField, 2)).toBe(0b01n); 49 | }); 50 | 51 | test('andNot', () => { 52 | expect(BitField.andNot(0n as BitField, 0n as BitField)).toBe(0n); 53 | expect(BitField.andNot(0n as BitField, 1n as BitField)).toBe(0n); 54 | expect(BitField.andNot(1n as BitField, 0n as BitField)).toBe(1n); 55 | expect(BitField.andNot(1n as BitField, 1n as BitField)).toBe(0n); 56 | }); 57 | }); 58 | 59 | describe('BitPositionRegistry', () => { 60 | test('claim', () => { 61 | const r = new BitPositionRegistry(); 62 | expect(r.claim()).toBe(0); 63 | expect(r.claim()).toBe(1); 64 | expect(r.claim()).toBe(2); 65 | 66 | // 3 claimed. 67 | expect(r.size).toBe(3); 68 | expect(r.field).toBe(0b111n); 69 | }); 70 | 71 | test('relinquish', () => { 72 | const r = new BitPositionRegistry(); 73 | for (let i = 0; i < 10; i++) r.claim(); 74 | 75 | r.relinquish(0 as BitPosition); 76 | expect(r.field).toBe(0b1111111110n); 77 | expect(r.claim()).toBe(0); 78 | expect(r.field).toBe(0b1111111111n); 79 | 80 | r.relinquish(2 as BitPosition); 81 | expect(r.field).toBe(0b1111111011n); 82 | expect(r.claim()).toBe(2); 83 | expect(r.field).toBe(0b1111111111n); 84 | 85 | expect(r.claim()).toBe(10); 86 | 87 | // 10 claimed -> 2 reclaimed -> 1 claimed = 11. 88 | expect(r.size).toBe(11); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /api/index.ts: -------------------------------------------------------------------------------- 1 | import type { App, Plugin } from 'obsidian'; 2 | 3 | import type { CalloutManager } from './functions'; 4 | 5 | export * from './functions'; 6 | export * from './callout'; 7 | export * from './events'; 8 | 9 | type ObsidianAppWithPlugins = App & { 10 | plugins: { 11 | enabledPlugins: Set; 12 | manifests: { [key: string]: unknown }; 13 | plugins: { [key: string]: Plugin }; 14 | }; 15 | }; 16 | 17 | export const PLUGIN_ID = 'callout-manager'; 18 | export const PLUGIN_API_VERSION = 'v1'; 19 | 20 | /** 21 | * Gets an owned handle to the Callout Manager plugin API. 22 | * The provided plugin will be used as the owner. 23 | */ 24 | export async function getApi(plugin: Plugin): Promise | undefined>; 25 | 26 | /** 27 | * Gets an unowned handle to the Callout Manager plugin API. 28 | * This handle cannot be used to register events. 29 | */ 30 | export async function getApi(): Promise | undefined>; 31 | 32 | /** 33 | * @internal 34 | */ 35 | export async function getApi(plugin?: Plugin): Promise { 36 | type CalloutManagerPlugin = Plugin & { 37 | newApiHandle( 38 | version: string, 39 | plugin: Plugin | undefined, 40 | cleanupFunc: () => void, 41 | ): Promise>; 42 | 43 | destroyApiHandle(version: string, plugin: Plugin | undefined): CalloutManager; 44 | }; 45 | 46 | // Check if the plugin is installed and enabled. 47 | const app = (plugin?.app ?? globalThis.app) as ObsidianAppWithPlugins; 48 | if (!isInstalled(app)) { 49 | return undefined; 50 | } 51 | 52 | // Get the plugin instance. 53 | // We may need to wait until it's loaded. 54 | const { plugins } = app; 55 | const calloutManagerInstance = await waitFor((resolve) => { 56 | const instance = plugins.plugins[PLUGIN_ID] as CalloutManagerPlugin; 57 | if (instance != null) resolve(instance); 58 | }); 59 | 60 | // Create a new API handle. 61 | return calloutManagerInstance.newApiHandle(PLUGIN_API_VERSION, plugin, () => { 62 | calloutManagerInstance.destroyApiHandle(PLUGIN_API_VERSION, plugin); 63 | }); 64 | } 65 | 66 | /** 67 | * Checks if Callout Manager is installed. 68 | */ 69 | export function isInstalled(app?: App) { 70 | // Check if the plugin is available and loaded. 71 | const plugins = ((app ?? globalThis.app) as ObsidianAppWithPlugins).plugins; 72 | return PLUGIN_ID in plugins.manifests && plugins.enabledPlugins.has(PLUGIN_ID); 73 | } 74 | 75 | /** 76 | * Runs a function every 10 milliseconds, returning a promise that resolves when the function resolves. 77 | * 78 | * @param fn A function that runs periodically, waiting for something to happen. 79 | * @returns A promise that resolves to whatever the function wants to return. 80 | */ 81 | function waitFor(fn: (resolve: (value: T) => void) => void | PromiseLike): Promise { 82 | return new Promise((doResolve, reject) => { 83 | let queueAttempt = () => { 84 | setTimeout(attempt, 10); 85 | }; 86 | 87 | const resolve = (value: T) => { 88 | queueAttempt = () => {}; 89 | doResolve(value); 90 | }; 91 | 92 | function attempt() { 93 | try { 94 | const promise = fn(resolve); 95 | if (promise === undefined) { 96 | queueAttempt(); 97 | return; 98 | } 99 | 100 | promise.then(queueAttempt, (ex) => reject(ex)); 101 | } catch (ex) { 102 | reject(ex); 103 | } 104 | } 105 | 106 | attempt(); 107 | }); 108 | } 109 | -------------------------------------------------------------------------------- /src/ui/setting/callout-color.ts: -------------------------------------------------------------------------------- 1 | import { ColorComponent, DropdownComponent, ExtraButtonComponent, Setting } from 'obsidian'; 2 | 3 | import { Callout } from '&callout'; 4 | import { getColorFromCallout } from '&callout-util'; 5 | import { RGB, parseColorRGB } from '&color'; 6 | 7 | import { ResetButtonComponent } from '&ui/component/reset-button'; 8 | 9 | import { defaultColors } from '../../default_colors.json'; 10 | 11 | /** 12 | * An Obsidian {@link Setting} for picking the color of a callout. 13 | */ 14 | export class CalloutColorSetting extends Setting { 15 | private readonly callout: Callout; 16 | private colorComponent!: ColorComponent; 17 | private dropdownComponent!: DropdownComponent; 18 | private resetComponent!: ExtraButtonComponent; 19 | 20 | private isDefault: boolean; 21 | private onChanged: ((value: string | undefined) => void) | undefined; 22 | 23 | public constructor(containerEl: HTMLElement, callout: Callout) { 24 | super(containerEl); 25 | this.onChanged = undefined; 26 | this.callout = callout; 27 | this.isDefault = true; 28 | 29 | // Create the setting archetype. 30 | this.addColorPicker((picker) => { 31 | this.colorComponent = picker; 32 | picker.onChange(() => { 33 | const { r, g, b } = this.getColor(); 34 | this.onChanged?.(`${r}, ${g}, ${b}`); 35 | }); 36 | }); 37 | 38 | this.dropdownComponent = new DropdownComponent(this.controlEl).then((dropdown) => { 39 | // If the rgb string is in the default_colors keys, then change dropdown. 40 | dropdown.addOptions(defaultColors); 41 | dropdown.onChange((value: string) => { 42 | this.setColorString(value); 43 | }); 44 | }); 45 | 46 | this.components.push(this.dropdownComponent); 47 | 48 | this.components.push( 49 | new ResetButtonComponent(this.controlEl).then((btn) => { 50 | this.resetComponent = btn; 51 | btn.onClick(() => this.onChanged?.(undefined)); 52 | }), 53 | ); 54 | 55 | this.setColor(undefined); 56 | } 57 | 58 | /** 59 | * Sets the color string. 60 | * This only accepts comma-delimited RGB values. 61 | * 62 | * @param color The color (e.g. `255, 10, 25`) or undefined to reset the color to default. 63 | * @returns `this`, for chaining. 64 | */ 65 | public setColorString(color: string | undefined): typeof this { 66 | if (color == null) { 67 | return this.setColor(undefined); 68 | } 69 | 70 | return this.setColor(parseColorRGB(`rgb(${color})`) ?? { r: 0, g: 0, b: 0 }); 71 | } 72 | 73 | /** 74 | * Sets the color. 75 | * 76 | * @param color The color or undefined to reset the color to default. 77 | * @returns `this`, for chaining. 78 | */ 79 | public setColor(color: RGB | undefined): typeof this { 80 | const isDefault = (this.isDefault = color == null); 81 | if (color == null) { 82 | color = getColorFromCallout(this.callout) ?? { r: 0, g: 0, b: 0 }; 83 | } 84 | 85 | // Convert color to Obsidian RGB format. 86 | if (color instanceof Array) { 87 | color = { r: color[0], g: color[1], b: color[2] }; 88 | } 89 | 90 | // Update components. 91 | this.colorComponent.setValueRgb(color); 92 | 93 | // Update dropdown menu if it matches current color 94 | if (`${color.r}, ${color.g}, ${color.b}` in defaultColors) { 95 | this.dropdownComponent.setValue(`${color.r}, ${color.g}, ${color.b}`); 96 | } else { 97 | this.dropdownComponent.setValue(''); 98 | } 99 | 100 | this.resetComponent.setDisabled(isDefault).setTooltip(isDefault ? '' : 'Reset Color'); 101 | 102 | return this; 103 | } 104 | 105 | public getColor(): RGB { 106 | return this.colorComponent.getValueRgb(); 107 | } 108 | 109 | public isDefaultColor(): boolean { 110 | return this.isDefault; 111 | } 112 | 113 | public onChange(cb: (value: string | undefined) => void): typeof this { 114 | this.onChanged = cb; 115 | return this; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/sort.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any */ 2 | import Callout from '&callout'; 3 | import { getColorFromCallout } from '&callout-util'; 4 | import { HSV, toHSV } from '&color'; 5 | 6 | import { ArrayValues, Intersection } from './util/type-helpers'; 7 | 8 | /** 9 | * An object that associates some precomputed values to an object. 10 | * Given that sorting is not O(1), it's important that we compute values we are sorting on ahead of time. 11 | */ 12 | export type Precomputed = { 13 | value: T; 14 | computed: C extends void ? {} : C; 15 | }; 16 | 17 | /** 18 | * Extracts the computed data type out of a {@link Precomputed} object. 19 | */ 20 | export type PrecomputedValue = F extends Comparator ? C : never; 21 | 22 | /** 23 | * A comparator function. 24 | * 25 | * This may contain a `precompute` property which will compute data necessary for the comparisons to work. 26 | */ 27 | export type Comparator = ((a: Precomputed, b: Precomputed) => number) & { precompute?(value: T): C }; 28 | 29 | type CombinedPrecomputedOf = Intersection< 30 | ArrayValues<{ 31 | readonly [Index in keyof Ts]: PrecomputedValue; 32 | }> 33 | >; 34 | 35 | type CombinedComparator>> = Comparator> & { 36 | precompute(value: T): CombinedPrecomputedOf; 37 | }; 38 | 39 | /** 40 | * Combines different sorting comparisons into a single comparison function where items are sorted on multiple criteria. 41 | * 42 | * @param fns The comparison functions. 43 | * 44 | * @returns A new comparison function that encompasses all the provided ones. 45 | */ 46 | export function combinedComparison> = Array>>(fns: Fs): CombinedComparator { 47 | const compare: CombinedComparator = (a, b) => { 48 | let delta = 0; 49 | for (const compare of fns) { 50 | delta = compare(a, b); 51 | if (delta !== 0) break; 52 | } 53 | return delta; 54 | }; 55 | 56 | compare.precompute = (value) => { 57 | const obj: Partial> = {}; 58 | for (const fn of fns) { 59 | if ('precompute' in fn) { 60 | Object.assign(obj, (fn.precompute as (value: T) => CombinedPrecomputedOf)(value)); 61 | } 62 | } 63 | return obj as CombinedPrecomputedOf; 64 | }; 65 | 66 | return compare; 67 | } 68 | 69 | /** 70 | * Sort by color. 71 | */ 72 | export function compareColor( 73 | { computed: { colorValid: aValid, colorHSV: aHSV } }: Precomputed, 74 | { computed: { colorValid: bValid, colorHSV: bHSV } }: Precomputed, 75 | ): number { 76 | const validityDelta = (aValid ? 1 : 0) - (bValid ? 1 : 0); 77 | if (validityDelta !== 0) return validityDelta; 78 | 79 | // Next: colors before shades. 80 | const saturatedDelta = (aHSV.s > 0 ? 1 : 0) - (bHSV.s > 0 ? 1 : 0); 81 | if (saturatedDelta !== 0) return saturatedDelta; 82 | 83 | // Next: hue. 84 | const hueDelta = aHSV.h - bHSV.h; 85 | if (Math.abs(hueDelta) > 2) return hueDelta; 86 | 87 | // Next: saturation + value; 88 | const svDelta = aHSV.s + aHSV.v - (bHSV.s + bHSV.v); 89 | if (svDelta !== 0) return svDelta; 90 | 91 | return 0; 92 | } 93 | 94 | export namespace compareColor { 95 | export type T = { colorValid: boolean; colorHSV: HSV }; 96 | export function precompute(v: Callout): T { 97 | const color = getColorFromCallout(v); 98 | return { 99 | colorValid: color != null, 100 | colorHSV: color == null ? { h: 0, s: 0, v: 0 } : toHSV(color), 101 | }; 102 | } 103 | } 104 | 105 | /** 106 | * Sort by ID. 107 | */ 108 | export function compareId( 109 | { value: { id: aId } }: Precomputed, 110 | { value: { id: bId } }: Precomputed, 111 | ) { 112 | return bId.localeCompare(aId); 113 | } 114 | -------------------------------------------------------------------------------- /src/changelog.ts: -------------------------------------------------------------------------------- 1 | import { Component, MarkdownRenderer } from 'obsidian'; 2 | 3 | import Changelog from '../CHANGELOG.md'; 4 | 5 | interface ChangelogSection { 6 | version: string | undefined; 7 | containerEl: HTMLDetailsElement; 8 | titleEl: HTMLElement; 9 | contentsEl: HTMLElement; 10 | } 11 | 12 | export function getSections(parent: Component): Map { 13 | const frag = document.createDocumentFragment(); 14 | const renderedEl = frag.createDiv(); 15 | 16 | // Render the markdown. 17 | MarkdownRenderer.renderMarkdown(Changelog, renderedEl, '', parent); 18 | 19 | // Extract the sections into details elements. 20 | const sections = new Map(); 21 | let heading: HTMLHeadingElement | null = null; 22 | let sectionContainer = frag.createEl('details'); 23 | let sectionSummary = sectionContainer.createEl('summary'); 24 | let sectionContents: Node[] = []; 25 | 26 | const addPreviousSection = () => { 27 | if (heading != null && heading.textContent !== null) { 28 | const headingText = heading.textContent; 29 | 30 | // Create the details summary / title. 31 | const titleEl = sectionSummary.createEl('h2', { 32 | cls: 'calloutmanager-changelog-heading', 33 | text: headingText, 34 | }); 35 | 36 | // Create the details body / content. 37 | const contentsEl = sectionContainer.createDiv( 38 | { 39 | cls: 'calloutmanager-changelog-section', 40 | }, 41 | (el) => { 42 | sectionContents.forEach((node) => el.appendChild(node)); 43 | }, 44 | ); 45 | 46 | // Rewrite `data-callout` attribute to `data-calloutmanager-changelog-callout`. 47 | Array.from(contentsEl.querySelectorAll('.callout[data-callout]')).forEach((el) => { 48 | el.setAttribute('data-calloutmanager-changelog-callout', el.getAttribute('data-callout') as string); 49 | el.removeAttribute('data-callout'); 50 | }); 51 | 52 | const version = /^\s*Version ([0-9.]+)\s*$/.exec(headingText)?.[1]; 53 | sections.set(version ?? heading.textContent, { 54 | version: version, 55 | contentsEl, 56 | containerEl: sectionContainer, 57 | titleEl, 58 | }); 59 | } 60 | 61 | // Reset variables. 62 | heading = null; 63 | sectionContainer = frag.createEl('details'); 64 | sectionSummary = sectionContainer.createEl('summary'); 65 | sectionContents = []; 66 | }; 67 | 68 | for (let node = renderedEl.firstChild; node != null; node = node?.nextSibling) { 69 | if (node instanceof HTMLHeadingElement && node.tagName === 'H1') { 70 | addPreviousSection(); 71 | 72 | heading = node; 73 | continue; 74 | } 75 | 76 | sectionContents.push(node); 77 | } 78 | 79 | addPreviousSection(); 80 | return sections; 81 | } 82 | 83 | declare const STYLES: ` 84 | // Special callouts used in the changelog. 85 | 86 | // The data attribute is rewritten from 'data-callout' to 'data-calloutmanager-changelog-callout' to prevent 87 | // any possible conflicts with user-defined or builtin callouts. 88 | 89 | .calloutmanager-changelog-section .callout { 90 | --callout-padding: 0.5em; 91 | 92 | > .callout-content { 93 | margin-left: calc(18px + 0.25em); 94 | } 95 | 96 | > .callout-content > :first-child { 97 | margin-top: 0; 98 | } 99 | 100 | > .callout-content > :last-child { 101 | margin-bottom: 0; 102 | } 103 | } 104 | 105 | .callout[data-calloutmanager-changelog-callout="new"] { 106 | --callout-icon: lucide-plus; 107 | --callout-color: 30, 160, 30; 108 | .theme-dark & { 109 | --callout-color: 60, 250, 60; 110 | } 111 | } 112 | 113 | .callout[data-calloutmanager-changelog-callout="fix"] { 114 | --callout-icon: lucide-wrench; 115 | --callout-color: 128, 128, 128; 116 | .theme-dark & { 117 | --callout-color: 180, 180, 180; 118 | } 119 | } 120 | 121 | .callout[data-calloutmanager-changelog-callout="change"] { 122 | --callout-icon: lucide-edit-3; 123 | --callout-color: 10, 170, 210; 124 | .theme-dark & { 125 | --callout-color: 60, 157, 210; 126 | } 127 | } 128 | 129 | .calloutmanager-changelog-heading { 130 | display: inline; 131 | font-weight: bold; 132 | } 133 | `; 134 | -------------------------------------------------------------------------------- /src/search/bitfield.ts: -------------------------------------------------------------------------------- 1 | import { WithBrand } from '@coderspirit/nominal'; 2 | 3 | /** 4 | * A field of {@link SearchIndex} bits. 5 | * Performing bitwise operations on this is a low-cost way of doing set operations. 6 | */ 7 | export type BitField = WithBrand; 8 | export namespace BitField { 9 | /** 10 | * Gets a {@link BitField} containing a single enabled bit at the given position. 11 | * @param position The position of the bit. 12 | * @returns The field. 13 | */ 14 | export function fromPosition(position: BitPosition): BitField { 15 | return (1n << BigInt(position)) as BitField; 16 | } 17 | 18 | /** 19 | * Gets a {@link BitField} containing a all bits enabled until and including the given position. 20 | * @param position The position of the bit. 21 | * @returns The field. 22 | */ 23 | export function fromPositionWithTrailing(position: BitPosition): BitField { 24 | return ((1n << BigInt(position + 1)) - 1n) as BitField; 25 | } 26 | 27 | /** 28 | * Scans for the most significant bit in the bit field. 29 | * 30 | * @param field The field to scan. 31 | * @returns The position of the most significant bit, or `-1` if the input is zero. 32 | * @benchmark https://jsperf.app/gepiye 33 | */ 34 | export function scanMostSignificant(field: BitField): BitPosition { 35 | const MASK = ~0xFFFFFFFFn; 36 | 37 | let offset = 0; 38 | for (let a = field as bigint; (a & MASK) > 0; a >>= 32n) { 39 | offset += 32; 40 | } 41 | 42 | return (offset + (31 - Math.clz32(Number(field)))) as BitPosition; 43 | } 44 | 45 | /** 46 | * Truth table: 47 | * 48 | * |`\|`| `0` | `1` | 49 | * |:--:|:---:|:---:| 50 | * |`0` | 0 | 1 | 51 | * |`1` | 1 | 1 | 52 | */ 53 | export function or(a: BitField, b: BitField): BitField { 54 | return (a | b) as BitField; 55 | } 56 | 57 | /** 58 | * Truth table: 59 | * 60 | * |`&` | `0` | `1` | 61 | * |---:|:---:|:---:| 62 | * |`0` | 0 | 0 | 63 | * |`1` | 0 | 1 | 64 | */ 65 | export function and(a: BitField, b: BitField): BitField { 66 | return (a & b) as BitField; 67 | } 68 | 69 | /** 70 | * Truth table: 71 | * 72 | * |`&~`| `0` | `1` | 73 | * |---:|:---:|:---:| 74 | * |`0` | 0 | 1 | 75 | * |`1` | 0 | 0 | 76 | */ 77 | export function andNot(a: BitField, b: BitField): BitField { 78 | return (a & ~b) as BitField; 79 | } 80 | 81 | /** 82 | * Truth table: 83 | * 84 | * |`~` | `0` | `1` | 85 | * |---:|:---:|:---:| 86 | * |`0` | 1 | 0 | 87 | * 88 | * @param width The width of the bitfield. 89 | */ 90 | export function not(a: BitField, width: number): BitField { 91 | return (fromPositionWithTrailing((width - 1) as BitPosition) ^ a) as BitField; 92 | } 93 | } 94 | 95 | /** 96 | * A position of a bit inside the {@link SearchIndex}. 97 | */ 98 | export type BitPosition = WithBrand; 99 | 100 | /** 101 | * A registry of owned bit positions within an infinitely-sized integer. 102 | */ 103 | export class BitPositionRegistry { 104 | private recycled: Array = []; 105 | private next: BitPosition = 0 as BitPosition; 106 | private asField: BitField = 0n as BitField; 107 | 108 | /** 109 | * A field of all owned bits. 110 | */ 111 | public get field(): BitField { 112 | return this.asField; 113 | } 114 | 115 | /** 116 | * The number of bits that are needed to represent a bitfield. 117 | */ 118 | public get size(): number { 119 | return this.next as number; 120 | } 121 | 122 | /** 123 | * Claims a bit from the registry. 124 | */ 125 | public claim(): BitPosition { 126 | const { recycled } = this; 127 | 128 | // Claim the next bit. 129 | const claimed = (recycled.length > 0) ? recycled.pop() : (this.next++); 130 | 131 | // Update the field. 132 | this.asField = BitField.or(this.asField, BitField.fromPosition(claimed as BitPosition)); 133 | return claimed as BitPosition; 134 | } 135 | 136 | /** 137 | * Relinquishes a bit back to the registry. 138 | * @param position The position to relinquish. 139 | */ 140 | public relinquish(position: BitPosition): void { 141 | const { recycled } = this; 142 | 143 | // Recycle the bit. 144 | recycled.push(position); 145 | this.asField = BitField.andNot(this.asField, BitField.fromPosition(position)); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/search/search-index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, jest, test } from '@jest/globals'; 2 | import { Spied } from 'jest-mock'; 3 | 4 | import { BitPositionRegistry } from './bitfield'; 5 | import { ColumnDescription, SearchIndexColumn } from './search-index'; 6 | 7 | /** 8 | * Creates a new column for testing. 9 | * 10 | * @param opts The column options. 11 | * @returns The column, an associated registry, and some spied functions. 12 | */ 13 | function newColumn( 14 | opts?: ColumnDescription, 15 | setup?: (col: SearchIndexColumn) => void, 16 | ): [ 17 | SearchIndexColumn, 18 | { 19 | reg: BitPositionRegistry; 20 | _claim: Spied; 21 | _relinquish: Spied; 22 | }, 23 | ] { 24 | const reg = new BitPositionRegistry(); 25 | const col = new SearchIndexColumn(reg, opts); 26 | 27 | setup?.(col); 28 | 29 | const _claim = jest.spyOn(reg, 'claim'); 30 | const _relinquish = jest.spyOn(reg, 'relinquish'); 31 | return [col, { reg, _claim, _relinquish }]; 32 | } 33 | 34 | describe('SearchIndexColumn', () => { 35 | test('add', () => { 36 | const [col, { _claim, _relinquish }] = newColumn(); 37 | 38 | // Initial assumptions. 39 | expect(col.size).toBe(0); // Empty 40 | expect(Array.from(col)).toHaveLength(0); // Empty 41 | 42 | // We add a unique entry. 43 | col.add('abc'); 44 | expect(col.size).toBe(1); 45 | expect(Array.from(col)).toStrictEqual([['abc', 0]]); 46 | expect(_claim).toBeCalledTimes(1); 47 | expect(col.get('abc')).not.toBeUndefined(); 48 | 49 | // We add another unique entry. 50 | col.add('def'); 51 | expect(col.size).toBe(2); 52 | expect(col.get('def')).not.toBeUndefined(); 53 | expect(_claim).toBeCalledTimes(2); 54 | expect(Array.from(col)).toStrictEqual([ 55 | ['abc', 0], 56 | ['def', 1], 57 | ]); 58 | 59 | // We add a duplicate entry. 60 | // This should *not* claim anything. 61 | col.add('abc'); 62 | expect(col.size).toBe(2); 63 | expect(col.get('abc')).not.toBeUndefined(); 64 | expect(_claim).toBeCalledTimes(2); 65 | expect(Array.from(col)).toStrictEqual([ 66 | ['abc', 0], 67 | ['def', 1], 68 | ]); 69 | 70 | // Final assertions. 71 | expect(_relinquish).not.toBeCalled(); 72 | }); 73 | 74 | test('delete', () => { 75 | const [col, { _claim, _relinquish }] = newColumn(undefined, (col) => { 76 | col.add('abc'); 77 | col.add('def'); 78 | col.add('ghi'); 79 | }); 80 | 81 | // Initial assumptions. 82 | const initSize = col.size; 83 | const initValues = Array.from(col); 84 | expect(Array.from(col)).toHaveLength(initSize); 85 | expect(_claim).not.toBeCalled(); 86 | expect(_relinquish).not.toBeCalled(); 87 | 88 | // Remove an entry that does not exist. 89 | // -> nothing changes. 90 | col.delete('no-exist'); 91 | expect(col.size).toBe(initSize); 92 | expect(Array.from(col)).toStrictEqual(initValues); 93 | expect(_claim).not.toBeCalled(); 94 | expect(_relinquish).not.toBeCalled(); 95 | 96 | // Remove an entry that does exist. 97 | // -> relinquishes bit, removes it from the list. 98 | col.delete(initValues[0][0]); 99 | expect(col.size).toBe(initSize - 1); 100 | expect(Array.from(col)).not.toContain(initValues[0]); 101 | expect(col.get(initValues[0][0])).toBeUndefined(); 102 | expect(_claim).not.toBeCalled(); 103 | expect(_relinquish).toBeCalledTimes(1); 104 | }); 105 | 106 | test('normalize', () => { 107 | const [col] = newColumn({ 108 | normalize: (a) => a.toLowerCase(), 109 | }); 110 | 111 | // Initial assumptions. 112 | expect(col.size).toBe(0); // Empty 113 | expect(Array.from(col)).toHaveLength(0); // Empty 114 | 115 | // Check that normalization works. 116 | expect(col.normalize("Abc")).toBe("abc"); 117 | 118 | // Check that the normalization function is used for adding. 119 | const _normalize = jest.spyOn(col, 'normalize'); 120 | 121 | col.add("Abc"); 122 | expect(_normalize).toBeCalledTimes(1); 123 | expect(col.size).toBe(1); 124 | 125 | col.add("ABC"); 126 | expect(_normalize).toBeCalledTimes(2); 127 | expect(col.size).toBe(1); 128 | 129 | // Check that the normalization function is used for getting. 130 | expect(col.get("AbC")).not.toBeUndefined(); 131 | expect(_normalize).toBeCalledTimes(3); 132 | 133 | // Check that the normalization function is used for deleting. 134 | col.delete("abC") 135 | expect(_normalize).toBeCalledTimes(4); 136 | expect(col.size).toBe(0); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # Callout Manager Plugin API 2 | 3 | Table of Contents: 4 | 5 | - [Installation](#installation) 6 | - [Setup](#setup) 7 | - Types 8 | - [`Callout`](#callout) 9 | - [`CalloutID`](#calloutid) 10 | - [`CalloutSource`](#calloutsource) 11 | - Functions 12 | - [`getApi`](#getapi) (package import) 13 | - [`getCallouts`](#getcallouts) 14 | - [`getColor`](#getcolor) 15 | - [`getTitle`](#gettitle) 16 | - Events 17 | - [`on("change")`](#onchange-listener) / [`off("change")`](#offchange-listener) 18 | 19 | ## Installation 20 | You can install Callout Manager's plugin API by adding the package through `npm` or `yarn`. 21 | 22 | ```bash 23 | npm install obsidian-callout-manager 24 | ``` 25 | 26 | ## Setup 27 | 28 | To use the API, you need to get an API handle. Since we can't guarantee plugin load order, it is recommended you do this under an `onLayoutReady` callback: 29 | 30 | ```ts 31 | import {CalloutManager, getApi} from "obsidian-callout-manager"; 32 | 33 | class MyPlugin extends Plugin { 34 | private calloutManager?: CalloutManager; 35 | 36 | public async onload() { 37 | this.app.workspace.onLayoutReady(() => { 38 | this.calloutManager = getApi(this); 39 | } 40 | } 41 | } 42 | ``` 43 | 44 |   45 | 46 | ## Types 47 | 48 | ### `Callout` 49 | A callout and its properties. 50 | > type **Callout** = { 51 | >     id: [CalloutID](#calloutid), 52 | >     color: string, 53 | >     icon: string, 54 | >     sources: Array<[CalloutSource](#calloutsource)>, 55 | > } 56 | 57 | **id**: [CalloutID](#calloutid) 58 | The ID of the callout. 59 | This is the part that goes in the callout header. 60 | 61 | **color**: string 62 | The current color of the callout. 63 | This is going to be a comma-delimited RGB tuple. 64 | If you need to parse this, use [getColor](#getcolor). 65 | 66 | **icon**: string 67 | The icon associated with the callout. 68 | 69 | **sources**: Array<[CalloutSource](#calloutsource)> 70 | The list of known sources for the callout. 71 | A source is a stylesheet that provides styles for a callout with this ID. 72 | 73 | ### `CalloutID` 74 | > type **CalloutID** = string; 75 | 76 | A type representing the ID of a callout. 77 | 78 | ### `CalloutSource` 79 | > type **CalloutSource** = 80 | >     { type: "builtin"; } | 81 | >     { type: "custom"; } | 82 | >     { type: "snippet"; snippet: string } | 83 | >     { type: "theme"; theme: string } 84 | 85 | The source of a callout. 86 | 87 | - `builtin` callouts come from Obsidian. 88 | - `custom` callouts were added by Callout Manager. 89 | - `snippet` callouts were added by a user's CSS snippet. 90 | - `theme` callouts were added by the user's current theme. 91 | 92 | 93 |   94 | 95 | ## Functions 96 | 97 | ### `getApi` 98 | > **getApi(owner: Plugin)**: CalloutManager<true> 99 | 100 | Gets an API handle owned by the provided plugin. 101 | 102 | > **getApi()**: CalloutManager 103 | 104 | Gets an unowned API handle. 105 | This only has access to a subset of API functions. 106 | 107 | ### `getCallouts` 108 | > **(handle).getCallouts()**: ReadonlyArray<[Callout](#callout)> 109 | 110 | Gets the list of available callouts. 111 | 112 | ### `getColor` 113 | > **(handle).getColor(callout: [Callout](#callout))**: RGB | { invalid: string } 114 | 115 | Parses the color of a callout into an Obsidian RGB object, or an object containing the property "invalid" if the color is not valid. 116 | 117 | If the parsing was successful, you can extract the red, green, and blue channels through `color.r`, `color.g`, and `color.b` respectively. 118 | 119 | ### `getTitle` 120 | > **(handle).getTitle(callout: [Callout](#callout))**: string 121 | 122 | Gets the default title string for the provided callout. 123 | 124 | ### `on("change", listener)` 125 | > **(owned handle).on("change", listener: () => void)** 126 | 127 | Adds an event listener that is triggered whenever one or more callouts are changed. 128 | 129 | This event is intended to be used as a signal for your plugin to refresh any caches or computed data that relied on the callouts as a source. If you need to determine which callouts have changed, that should be done manually. 130 | 131 | ### `off("change", listener)` 132 | > **(owned handle).off("change", listener: () => void)** 133 | 134 | Removes a previously-registered [change](#onchange-listener) event listener. 135 | -------------------------------------------------------------------------------- /src/callout-search.ts: -------------------------------------------------------------------------------- 1 | import Callout from '&callout'; 2 | 3 | import { SearchCondition, matches } from './search/condition'; 4 | import { filter } from './search/effect'; 5 | import { SearchFactory } from './search/factory'; 6 | import { casefold, combinedNormalization, trimmed, unicode } from './search/normalize'; 7 | import { parseQuery } from './search/query'; 8 | import { SearchColumns } from './search/search'; 9 | import { combinedComparison, compareColor, compareId } from './sort'; 10 | 11 | type MaybePreview = Preview extends HTMLElement 12 | ? { readonly preview: Preview } 13 | : { readonly preview?: never }; 14 | 15 | export type CalloutSearchResult = MaybePreview & { 16 | readonly callout: Callout; 17 | }; 18 | 19 | /** 20 | * A function that will search a predefined list of callouts for the given query. 21 | */ 22 | export type CalloutSearch = ( 23 | query: string, 24 | ) => ReadonlyArray>; 25 | 26 | /** 27 | * Search options. 28 | */ 29 | interface CalloutSearchOptions { 30 | /** 31 | * The type of searching to use for query operations. 32 | * If not provided, the default will be `matches`. 33 | */ 34 | defaultCondition?: SearchCondition; 35 | } 36 | 37 | /** 38 | * Search options with a preview generator. 39 | */ 40 | interface CalloutSearchWithPreviewOptions extends CalloutSearchOptions { 41 | /** 42 | * A function that generates a preview for each callout. 43 | * 44 | * @param callout The callout. 45 | * @returns Its associated preview. 46 | */ 47 | preview: (callout: Callout) => Preview; 48 | } 49 | 50 | export function calloutSearch< 51 | Options extends CalloutSearchOptions | CalloutSearchWithPreviewOptions, 52 | Preview extends HTMLElement | never = Options extends CalloutSearchWithPreviewOptions ? P : never, 53 | >(callouts: ReadonlyArray, options?: Options): CalloutSearch { 54 | const preview = (options as Partial>)?.preview; 55 | const defaultCondition = options?.defaultCondition ?? matches; 56 | 57 | const standardNormalization = combinedNormalization([ 58 | casefold, 59 | unicode, 60 | trimmed, 61 | (v) => v.replace(/[ -_.]+/g, '-'), 62 | ]); 63 | 64 | const standardSorting = combinedComparison([compareColor, compareId]); 65 | const search = new SearchFactory(callouts) 66 | .withColumn('id', 'id', standardNormalization) 67 | .withColumn('icon', 'icon', standardNormalization) 68 | .withColumn('from', sourceGetter, standardNormalization) 69 | .withColumn('snippet', snippetGetter, standardNormalization) 70 | .withMetadata((callout) => (preview == null ? {} : { preview: preview(callout) })) 71 | .withInclusiveDefaults(true) 72 | .withSorting(standardSorting) 73 | .build(); 74 | 75 | type Columns = SearchColumns; 76 | return ((query: string) => { 77 | const ops = parseQuery(query); 78 | search.reset(); 79 | for (const op of ops) { 80 | let field = op.field; 81 | if (field === '' || field == null) field = 'id'; 82 | 83 | // Skip operations that don't have text. 84 | if (op.text === '' || op.text == null) continue; 85 | 86 | // Perform search. 87 | search.search(field as Columns, op.condition ?? defaultCondition, op.text, op.effect ?? filter); 88 | } 89 | 90 | return search.results as unknown as ReadonlyArray>; 91 | }) as CalloutSearch; 92 | } 93 | 94 | function snippetGetter(callout: Callout): string[] { 95 | const values = [] as string[]; 96 | 97 | for (const source of callout.sources) { 98 | if (source.type !== 'snippet') continue; 99 | values.push(source.snippet); 100 | } 101 | 102 | return values; 103 | } 104 | 105 | function sourceGetter(callout: Callout): string[] { 106 | const sources = [] as string[]; 107 | 108 | for (const source of callout.sources) { 109 | switch (source.type) { 110 | case 'builtin': 111 | sources.push('obsidian'); 112 | sources.push('builtin'); 113 | sources.push('built-in'); 114 | break; 115 | 116 | case 'custom': 117 | sources.push('custom'); 118 | sources.push('user'); 119 | sources.push('callout-manager'); 120 | break; 121 | 122 | default: 123 | sources.push(source.type); 124 | } 125 | } 126 | 127 | return sources; 128 | } 129 | -------------------------------------------------------------------------------- /src/panes/edit-callout-pane/section-info.ts: -------------------------------------------------------------------------------- 1 | import { App } from 'obsidian'; 2 | import { getThemeManifest } from 'obsidian-extra'; 3 | 4 | import { Callout, CalloutSource } from '&callout'; 5 | import { getColorFromCallout } from '&callout-util'; 6 | import { RGB, toHexRGB } from '&color'; 7 | 8 | import DefaultColors from "../../default_colors.json"; 9 | 10 | export function renderInfo(app: App, callout: Callout, containerEl: HTMLElement): void { 11 | const frag = document.createDocumentFragment(); 12 | const contentEl = frag.createDiv({ cls: 'calloutmanager-edit-callout-section' }); 13 | 14 | contentEl.createEl('h2', { text: 'About this Callout' }); 15 | contentEl.createEl('div', { cls: 'calloutmanager-edit-callout-info' }, (el) => { 16 | el.appendText('The '); 17 | el.createSpan({ cls: 'calloutmanager-edit-callout--callout-id', text: callout.id }); 18 | el.appendText(' callout'); 19 | 20 | // Color information. 21 | el.appendText(' has '); 22 | appendColorInfo(el, callout); 23 | 24 | // Icon information. 25 | el.appendText(' and '); 26 | appendIconInfo(el, callout); 27 | 28 | // Source information. 29 | if (callout.sources.length === 1) { 30 | if (callout.sources[0].type === 'builtin') { 31 | el.appendText('. It is one of the built-in callouts.'); 32 | return; 33 | } 34 | 35 | el.appendText('. It was added to Obsidian by the '); 36 | appendSourceInfo(app, el, callout.sources[0]); 37 | el.appendText('.'); 38 | return; 39 | } 40 | 41 | el.appendText('. The callout comes from:'); 42 | const sources = el.createEl('ul', { cls: 'calloutmanager-edit-callout--callout-source-list' }); 43 | for (const source of callout.sources) { 44 | const itemEl = sources.createEl('li'); 45 | itemEl.appendText('The '); 46 | appendSourceInfo(app, itemEl, source); 47 | itemEl.appendText('.'); 48 | } 49 | }); 50 | 51 | // Render the fragment. 52 | containerEl.appendChild(frag); 53 | } 54 | 55 | function appendIconInfo(el: HTMLElement, callout: Callout): void { 56 | el.appendText('is using the icon '); 57 | el.createEl('code', { cls: 'calloutmanager-edit-callout--callout-icon', text: callout.icon }); 58 | } 59 | 60 | function appendColorInfo(el: HTMLElement, callout: Callout): void { 61 | const calloutColor = getColorFromCallout(callout); 62 | 63 | // Invalid color. 64 | if (calloutColor == null) { 65 | el.appendText('an invalid color ('); 66 | el.createEl('code', { 67 | cls: 'calloutmanager-edit-callout--color-invalid', 68 | text: callout.color.trim(), 69 | }); 70 | el.appendText(')'); 71 | return; 72 | } 73 | 74 | // Valid color. 75 | el.appendText('the color '); 76 | el.createEl( 77 | 'code', 78 | { cls: 'calloutmanager-edit-callout--callout-color', text: describeColor(calloutColor) }, 79 | (colorEl) => colorEl.style.setProperty('--resolved-callout-color', callout.color), 80 | ); 81 | } 82 | 83 | function appendSourceInfo(app: App, el: HTMLElement, source: CalloutSource): boolean { 84 | switch (source.type) { 85 | case 'builtin': 86 | el.appendText('built-in callouts'); 87 | return true; 88 | case 'custom': 89 | el.appendText('custom callouts you created'); 90 | return true; 91 | case 'snippet': 92 | el.appendText('CSS snippet '); 93 | el.createEl('code', { 94 | cls: 'calloutmanager-edit-callout--callout-source', 95 | text: `${source.snippet}.css`, 96 | }); 97 | return true; 98 | case 'theme': { 99 | el.appendText('theme '); 100 | const themeName = getThemeManifest(app, source.theme)?.name ?? source.theme; 101 | el.createSpan({ cls: 'calloutmanager-edit-callout--callout-source', text: themeName }); 102 | return true; 103 | } 104 | } 105 | } 106 | 107 | function describeColor(color: RGB): string { 108 | const hexString = toHexRGB(color); 109 | const rgbString = `${color.r}, ${color.g}, ${color.b}`; 110 | 111 | const namedColor = DefaultColors.defaultColors[rgbString as keyof typeof DefaultColors.defaultColors]; 112 | if (namedColor != null) { 113 | return namedColor; 114 | } 115 | 116 | return hexString; 117 | } 118 | 119 | declare const STYLES: ` 120 | // The info paragraph and list. 121 | .calloutmanager-edit-callout-info { 122 | color: var(--text-muted); 123 | } 124 | 125 | .calloutmanager-edit-callout--invalid-color { 126 | color: var(--text-error); 127 | } 128 | 129 | .calloutmanager-edit-callout--callout-color { 130 | color: rgb(var(--resolved-callout-color)); 131 | } 132 | 133 | .calloutmanager-edit-callout--callout-id, 134 | .calloutmanager-edit-callout--callout-icon, 135 | .calloutmanager-edit-callout--callout-source { 136 | color: var(--text-normal); 137 | } 138 | 139 | .calloutmanager-edit-callout--callout-source-list { 140 | } 141 | `; 142 | -------------------------------------------------------------------------------- /src/ui/pane.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "obsidian"; 2 | 3 | export interface UIPaneNavigation { 4 | close(): void; 5 | open(pane: UIPane): void; 6 | replace(pane: UIPane): void; 7 | } 8 | 9 | export type UIPaneTitle = string | { title: string; subtitle: string }; 10 | 11 | /** 12 | * A setting pane that exists within the setting tab. 13 | * 14 | * This has its own navigation and sticky header! 15 | */ 16 | export abstract class UIPane { 17 | protected readonly nav!: UIPaneNavigation; 18 | protected readonly containerEl!: HTMLElement; 19 | protected readonly controlsEl!: HTMLElement; 20 | protected readonly root!: Component; 21 | 22 | /** 23 | * The title of the pane. 24 | */ 25 | public abstract get title(): UIPaneTitle; 26 | 27 | /** 28 | * Called to render the pane to its container element. 29 | */ 30 | public abstract display(): void; 31 | 32 | /** 33 | * Called to display the controls for the pane. 34 | */ 35 | public displayControls(): void {} 36 | 37 | /** 38 | * Called when the pane is created and attached to the setting tab, but before {@link display} is called. 39 | */ 40 | protected onReady(): void {} 41 | 42 | /** 43 | * Called when the pane is removed and ready to be destroyed. 44 | * Any important settings should be saved here. 45 | * 46 | * @param cancelled If true, the user closed the pane with the escape key. 47 | */ 48 | protected onClose(cancelled: boolean): void {} 49 | 50 | /** 51 | * Called to save the state of the setting pane. 52 | * This is used for suspending a pane when another pane covers it up. 53 | * 54 | * @returns The saved state. 55 | */ 56 | protected suspendState(): S { 57 | return undefined as unknown as S; 58 | } 59 | 60 | /** 61 | * Called to load the state of the setting pane. 62 | * This is called before {@link display}. 63 | * 64 | * @param state The state to restore. 65 | */ 66 | protected restoreState(state: S): void {} 67 | } 68 | 69 | /** 70 | * A type for a {@link UIPane}, but with all properties exposed and writable. 71 | * @internal 72 | */ 73 | export type UIPane_FRIEND = { 74 | -readonly [key in keyof UIPane]: UIPane[key]; 75 | } & { 76 | nav: UIPane['nav'] | undefined; 77 | containerEl: UIPane['containerEl'] | undefined; 78 | controlsEl: UIPane['controlsEl'] | undefined; 79 | root: UIPane['root'] | undefined; 80 | onReady: UIPane['onReady']; 81 | onClose: UIPane['onClose']; 82 | suspendState: UIPane['suspendState']; 83 | restoreState: UIPane['restoreState']; 84 | }; 85 | 86 | // --------------------------------------------------------------------------------------------------------------------- 87 | // Styles: 88 | // --------------------------------------------------------------------------------------------------------------------- 89 | 90 | declare const STYLES: ` 91 | // A centered box to help display help messages for empty searches. 92 | .calloutmanager-centerbox { 93 | width: 100%; 94 | height: 100%; 95 | 96 | display: flex; 97 | flex-direction: column; 98 | align-items: center; 99 | justify-content: center; 100 | } 101 | 102 | // Improve form UX. 103 | .calloutmanager-pane { 104 | // Make disabled buttons look disabled. 105 | button[disabled] { 106 | box-shadow: none; 107 | background-color: var(--interactive-normal); 108 | 109 | &:hover { 110 | background-color: var(--interactive-normal); 111 | cursor: not-allowed; 112 | } 113 | } 114 | 115 | input[type='color'][disabled] { 116 | cursor: not-allowed; 117 | } 118 | 119 | // Make invalid text boxes look invalid. 120 | input:invalid:not(:placeholder-shown), 121 | input.invalid { 122 | border-color: var(--text-error); 123 | } 124 | 125 | // Improve color picker UX on mobile. 126 | body.is-phone & input[type='color']::-webkit-color-swatch { 127 | border-radius: var(--button-radius); 128 | border: #f00 2px solid; 129 | border: 1px solid var(--checkbox-border-color); 130 | } 131 | 132 | // Make clickable icons with 'mod-warning' not solid. 133 | .clickable-icon.mod-warning { 134 | color: var(--text-error); 135 | background: transparent; 136 | &:hover { 137 | color: var(--text-error); 138 | background: transparent; 139 | } 140 | } 141 | 142 | // Add mod-error to text field. 143 | input[type='text'].mod-error { 144 | border-color: var(--text-error); 145 | } 146 | } 147 | 148 | // Make clickable icons not too large on mobile. 149 | .calloutmanager-setting-tab-content .setting-item-control, 150 | .calloutmanager-setting-tab-controls { 151 | body.is-phone & button.clickable-icon { 152 | width: var(--button-height); 153 | } 154 | } 155 | 156 | // Make clickable icons in setting panes more visible on mobile. 157 | body.is-phone .calloutmanager-setting-tab-content .setting-item-control button.clickable-icon { 158 | border: 1px solid var(--checkbox-border-color); 159 | 160 | &.calloutmanager-setting-set { 161 | // background-color: var(--background-modifier-border); 162 | } 163 | } 164 | `; 165 | -------------------------------------------------------------------------------- /src/search/condition.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from '@jest/globals'; 2 | 3 | import { BitField, BitPosition, BitPositionRegistry } from './bitfield'; 4 | import { SearchCondition, equals as _equals, includes as _includes, startsWith as _startsWith } from './condition'; 5 | import { SearchIndexColumn } from './search-index'; 6 | 7 | function createColumn>( 8 | ...values: T 9 | ): SearchIndexColumn & { scoreBuffer: Float32Array; fieldWhere(...values: Array): BitField } { 10 | const reg = new BitPositionRegistry(); 11 | const col = new SearchIndexColumn(reg); 12 | values.forEach(col.add.bind(col)); 13 | 14 | return Object.assign(col, { 15 | scoreBuffer: new Float32Array(col.size), 16 | fieldWhere(...values: Array): BitField { 17 | return values 18 | .map((v) => col.normalize(v as string)) 19 | .map((n) => col.get(n) as BitPosition) 20 | .map((p) => BitField.fromPosition(p)) 21 | .reduce(BitField.or, 0n as BitField); 22 | }, 23 | }); 24 | } 25 | 26 | /** 27 | * Wraps a condition so we can check what values are matched from it. 28 | */ 29 | function wrapCondition(col: ReturnType, cond: SearchCondition): (text: string) => BitField { 30 | return (v: string) => cond(col, col.normalize(v), col.scoreBuffer); 31 | } 32 | 33 | /** 34 | * Wraps a condition so we can check the score delta between two values. 35 | */ 36 | function wrapConditionForScoreDelta, Column extends ReturnType>>( 37 | col: Column, 38 | cond: SearchCondition, 39 | ): (v: T[keyof T]) => (a: string, b: string) => number { 40 | return (v: T[keyof T]) => { 41 | const valuePosition = col.get(col.normalize(v as string)) as number; 42 | return (a: string, b: string) => { 43 | const aScore = col.scoreBuffer.map(() => 0); 44 | const bScore = aScore.slice(); 45 | 46 | cond(col, col.normalize(a), aScore); 47 | cond(col, col.normalize(b), bScore); 48 | 49 | return aScore[valuePosition] - bScore[valuePosition]; 50 | }; 51 | }; 52 | } 53 | 54 | describe('includes', () => { 55 | test('should', () => { 56 | const col = createColumn('foo', 'bar', 'baz'); 57 | const includes = wrapCondition(col, _includes); 58 | 59 | expect(includes('ba')).toBe(col.fieldWhere('bar', 'baz')); 60 | expect(includes('z')).toBe(col.fieldWhere('baz')); 61 | expect(includes('')).toBe(col.fieldWhere('foo', 'bar', 'baz')); 62 | }); 63 | 64 | test('should not', () => { 65 | const col = createColumn('foo', 'bar', 'baz'); 66 | const includes = wrapCondition(col, _includes); 67 | 68 | expect(includes('d')).toBe(col.fieldWhere()); 69 | expect(includes('fooz')).toBe(col.fieldWhere()); 70 | }); 71 | 72 | test('scores', () => { 73 | const col = createColumn('foo', 'bar', 'baz'); 74 | const includes = wrapConditionForScoreDelta(col, _includes); 75 | 76 | expect(includes('bar')("ba", "r")).toBeGreaterThan(0); 77 | expect(includes('bar')("ba", "ba")).toBe(0); 78 | expect(includes('bar')("b", "ba")).toBeLessThan(0); 79 | }); 80 | }); 81 | 82 | describe('equals', () => { 83 | test('should', () => { 84 | const col = createColumn('foo', 'bar', 'baz'); 85 | const equals = wrapCondition(col, _equals); 86 | 87 | expect(equals('foo')).toBe(col.fieldWhere('foo')); 88 | expect(equals('food')).toBe(col.fieldWhere()); 89 | }); 90 | 91 | test('should not', () => { 92 | const col = createColumn('foo', 'bar', 'baz'); 93 | const equals = wrapCondition(col, _equals); 94 | 95 | expect(equals('d')).toBe(col.fieldWhere()); 96 | expect(equals('fooz')).toBe(col.fieldWhere()); 97 | }); 98 | 99 | test('scores', () => { 100 | const col = createColumn('foo', 'bar', 'baz'); 101 | const equals = wrapConditionForScoreDelta(col, _equals); 102 | 103 | expect(equals('bar')("bar", "ba")).toBeGreaterThan(0); 104 | expect(equals('bar')("bar", "bart")).toBe(1); 105 | expect(equals('bar')("xxx", "yyy")).toBe(0); 106 | }); 107 | }); 108 | 109 | describe('startsWith', () => { 110 | test('should', () => { 111 | const col = createColumn('foo', 'far', 'bar', 'baz'); 112 | const startsWith = wrapCondition(col, _startsWith); 113 | 114 | expect(startsWith('f')).toBe(col.fieldWhere('foo', 'far')); 115 | expect(startsWith('fo')).toBe(col.fieldWhere('foo')); 116 | expect(startsWith('')).toBe(col.fieldWhere('foo', 'far', 'bar', 'baz')); 117 | }); 118 | 119 | test('should not', () => { 120 | const col = createColumn('foo', 'bar', 'baz'); 121 | const startsWith = wrapCondition(col, _startsWith); 122 | 123 | expect(startsWith('a')).toBe(col.fieldWhere()); 124 | expect(startsWith('bax')).toBe(col.fieldWhere()); 125 | }); 126 | 127 | test('scores', () => { 128 | const col = createColumn('foo', 'bar', 'baz'); 129 | const startsWith = wrapConditionForScoreDelta(col, _startsWith); 130 | 131 | expect(startsWith('bar')("b", "ba")).toBeLessThan(0); 132 | expect(startsWith('bar')("ba", "bar")).toBeLessThan(0); 133 | expect(startsWith('bar')("bar", "b")).toBeGreaterThan(0); 134 | expect(startsWith('bar')("bar", "")).toBe(1); 135 | expect(startsWith('bar')("z", "yyy")).toBe(0); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /src/panes/edit-callout-pane/appearance-type.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CalloutSetting, 3 | CalloutSettings, 4 | CalloutSettingsChanges, 5 | CalloutSettingsColorSchemeCondition, 6 | typeofCondition, 7 | } from '&callout-settings'; 8 | 9 | /** 10 | * A complex appearance. 11 | * 12 | * This cannot be represented with a UI, and must be changed manually in the 13 | * plugin's `data.json` settings. 14 | */ 15 | export type ComplexAppearance = { 16 | type: 'complex'; 17 | settings: CalloutSettings; 18 | }; 19 | 20 | /** 21 | * Unified appearance. 22 | * 23 | * The color is changed to a single value no matter what the color scheme is. 24 | */ 25 | export type UnifiedAppearance = { 26 | type: 'unified'; 27 | color: string | undefined; 28 | otherChanges: Exclude; 29 | }; 30 | 31 | /** 32 | * Per-color scheme appearance. 33 | * 34 | * The color is different for both dark and light modes. 35 | */ 36 | export type PerSchemeAppearance = { 37 | type: 'per-scheme'; 38 | colorDark: string | undefined; 39 | colorLight: string | undefined; 40 | otherChanges: Exclude; 41 | }; 42 | 43 | export type Appearance = UnifiedAppearance | PerSchemeAppearance | ComplexAppearance; 44 | 45 | /** 46 | * Determines the {@link Appearance} for the provided callout settings. 47 | * @param settings The settings to determine the appearance type for. 48 | */ 49 | export function determineAppearanceType(settings: CalloutSettings): Appearance { 50 | return ( 51 | determineNonComplexAppearanceType(settings) ?? { 52 | type: 'complex', 53 | settings, 54 | } 55 | ); 56 | } 57 | 58 | function determineNonComplexAppearanceType( 59 | settings: CalloutSettings, 60 | ): Exclude | null { 61 | // Ensure all the conditions are only "appearance". 62 | const settingsWithColorSchemeCondition: CalloutSettings = []; 63 | const settingsWithNoCondition: CalloutSettings = []; 64 | for (const setting of settings) { 65 | const type = typeofCondition(setting.condition); 66 | switch (type) { 67 | case 'colorScheme': 68 | settingsWithColorSchemeCondition.push(setting as CalloutSetting); 69 | break; 70 | 71 | case undefined: 72 | settingsWithNoCondition.push(setting as CalloutSetting); 73 | break; 74 | 75 | case 'and': 76 | case 'or': 77 | case 'theme': { 78 | console.debug('Cannot represent callout settings with UI.', { 79 | reason: `Has condition of type '${type}'`, 80 | settings, 81 | }); 82 | return null; 83 | } 84 | } 85 | } 86 | 87 | // Check to see that the colorScheme conditions only change the color. 88 | const colorSchemeColor = { dark: undefined as undefined | string, light: undefined as undefined | string }; 89 | for (const setting of settingsWithColorSchemeCondition) { 90 | const changed = Object.keys(setting.changes); 91 | if (changed.length === 0) { 92 | continue; 93 | } 94 | 95 | if (changed.find((key) => key !== 'color') !== undefined) { 96 | console.debug('Cannot represent callout settings with UI.', { 97 | reason: `Has 'colorScheme' condition with non-color change.`, 98 | settings, 99 | }); 100 | return null; 101 | } 102 | 103 | // Keep track of the changed color. 104 | const appearanceCond = (setting.condition as CalloutSettingsColorSchemeCondition).colorScheme; 105 | if (colorSchemeColor[appearanceCond] !== undefined) { 106 | console.debug('Cannot represent callout settings with UI.', { 107 | reason: `Has multiple 'colorScheme' conditions that change ${appearanceCond} color.`, 108 | settings, 109 | }); 110 | return null; 111 | } 112 | 113 | colorSchemeColor[appearanceCond] = setting.changes.color; 114 | } 115 | 116 | // Collect the remaining changes. 117 | const otherChanges: CalloutSettingsChanges = {}; 118 | for (const [change, value] of settingsWithNoCondition.flatMap((s) => Object.entries(s.changes))) { 119 | if (value === undefined) continue; 120 | if (change in otherChanges) { 121 | console.debug('Cannot represent callout settings with UI.', { 122 | reason: `Has multiple changes to '${change}'.`, 123 | settings, 124 | }); 125 | return null; 126 | } 127 | 128 | (otherChanges as Record)[change] = value; 129 | } 130 | 131 | // Remove color from otherChanges. 132 | const otherChangesColor = otherChanges.color; 133 | delete otherChanges.color; 134 | 135 | // If there aren't any dark or light color scheme colors defined, it's a unified color. 136 | if (colorSchemeColor.dark === undefined && colorSchemeColor.light === undefined) { 137 | if (otherChangesColor === undefined) { 138 | return { type: 'unified', color: undefined, otherChanges }; 139 | } 140 | 141 | return { type: 'unified', color: otherChangesColor, otherChanges }; 142 | } 143 | 144 | // Split color. 145 | const colorDark = colorSchemeColor.dark ?? (colorSchemeColor.light as string); 146 | const colorLight = colorSchemeColor.light ?? (colorSchemeColor.dark as string); 147 | return { type: 'per-scheme', colorDark, colorLight, otherChanges }; 148 | } 149 | -------------------------------------------------------------------------------- /src/search/factory.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import { Comparator, Precomputed, combinedComparison } from '../sort'; 3 | 4 | import { BitField } from './bitfield'; 5 | import { type NormalizationFunction } from './normalize'; 6 | import { SealedSearch, Search, SearchOptions } from './search'; 7 | import { type ColumnDescription, SearchIndex } from './search-index'; 8 | 9 | // eslint-disable-next-line @typescript-eslint/ban-types 10 | type NonStringValues = number | boolean | bigint | symbol | null | undefined | Function | void; 11 | type RecordWhereKeys = { [K in keyof T as K extends KeyIs ? K : never]: T[K] }; 12 | type RecordWhereValues = { [K in keyof T as T[K] extends ValueIs ? K : never]: T[K] }; 13 | type RecordWhereValuesNot = { [K in keyof T as T[K] extends ValueIsNot ? never : K]: T[K] }; 14 | 15 | /** 16 | * Filters an object to only return properties where both the key and the value are *only* a `string`. 17 | */ 18 | type StringRecord = { 19 | [K in keyof RecordWhereValuesNot, string>, NonStringValues>]: T[K]; 20 | }; 21 | 22 | /** 23 | * A function that can get a property and return it as a string. 24 | */ 25 | type PropertyGetter = (target: T) => string[]; 26 | 27 | /** 28 | * The name of a string property, or a function that can return a stringified property of `T`. 29 | */ 30 | export type StringPropertyOrGetter = keyof StringRecord | PropertyGetter; 31 | 32 | /** 33 | * Creates {@link Search} instances using the factory pattern. 34 | */ 35 | export class SearchFactory { 36 | private columns: Array<{ name: Columns; getter: PropertyGetter; desc: ColumnDescription }> = []; 37 | 38 | private metadataGenerators: Array<(obj: T) => Partial> = []; 39 | private sortFunctions: Array> = []; 40 | 41 | private items: ReadonlyArray; 42 | private options: SearchOptions = {}; 43 | 44 | /** 45 | * @param objects The objects to search through. 46 | */ 47 | public constructor(objects: ReadonlyArray) { 48 | this.items = objects; 49 | } 50 | 51 | /** 52 | * Adds a column that can be searched. 53 | * 54 | * @param name The column name. 55 | * @param property The property name of the string property to index, or a getter function to return some string. 56 | * @param normalize A function to normalize the value. 57 | */ 58 | public withColumn>( 59 | name: ColumnName, 60 | property: StringPropertyOrGetter, 61 | normalize?: NormalizationFunction, 62 | ): SearchFactory { 63 | const getter = (typeof property === 'function' ? property : (obj: T) => [obj[property]]) as PropertyGetter; 64 | 65 | this.columns.push({ 66 | name: name as string as Columns, 67 | getter: getter, 68 | desc: { 69 | normalize, 70 | }, 71 | }); 72 | 73 | return this as SearchFactory; 74 | } 75 | 76 | /** 77 | * Adds metadata to the search result items. 78 | * This is useful for attaching cached data such as search previews. 79 | * 80 | * @param generator A function to generate the metadata. 81 | */ 82 | public withMetadata(generator: (obj: T) => R): SearchFactory { 83 | this.metadataGenerators.push(generator); 84 | return this as unknown as SearchFactory; 85 | } 86 | 87 | /** 88 | * Adds a sorting rule to the search result items. 89 | * @param comparator The comparator for the sorting rule. 90 | */ 91 | public withSorting(comparator: Comparator): SearchFactory { 92 | this.sortFunctions.push(comparator); 93 | return this; 94 | } 95 | 96 | /** 97 | * Changes if empty queries will be inclusive. 98 | * @param enabled Whether enabled. 99 | */ 100 | public withInclusiveDefaults(enabled: boolean): SearchFactory { 101 | this.options.resetToAll = enabled; 102 | return this; 103 | } 104 | 105 | /** 106 | * Builds the index and returns a search class. 107 | */ 108 | public build(): SealedSearch<{ value: T } & Extra, Columns> { 109 | const { metadataGenerators, sortFunctions, columns: columnGenerators } = this; 110 | 111 | // Generate the comparison function. 112 | const compareFn = 113 | sortFunctions.length === 0 114 | ? undefined 115 | : sortFunctions.length === 1 116 | ? sortFunctions[0] 117 | : combinedComparison(this.sortFunctions); 118 | 119 | // Generate the items. 120 | const items: Array<{ value: T } & Extra & Precomputed> = this.items.map((item) => 121 | Object.assign({}, ...metadataGenerators.map((fn) => fn(item)), { 122 | value: item, 123 | computed: compareFn?.precompute?.(item), 124 | }), 125 | ); 126 | 127 | // Generate the column record and index function. 128 | const columns = Object.fromEntries(columnGenerators.map(({ name, desc }) => [name, desc])) as Record< 129 | Columns, 130 | ColumnDescription 131 | >; 132 | 133 | const indexFn = (item: { value: T } & Extra, index: SearchIndex) => { 134 | let field = 0n as BitField; 135 | 136 | for (const { name, getter } of columnGenerators) { 137 | const column = index.column(name); 138 | for (const value of getter(item.value)) { 139 | field = BitField.or(field, BitField.fromPosition(column.add(value))); 140 | } 141 | } 142 | 143 | return field; 144 | }; 145 | 146 | // Create the search instance and add the items. 147 | const search = new Search<{ value: T } & Extra & Precomputed, Columns>({ 148 | ...this.options, 149 | columns, 150 | indexItem: indexFn, 151 | compareItem: compareFn, 152 | }); 153 | 154 | search.addItems(items); 155 | 156 | // Return the search instance as a sealed search. 157 | return search as SealedSearch<{ value: T } & Extra, Columns>; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/search/search-index.ts: -------------------------------------------------------------------------------- 1 | import { BitField, type BitPosition, BitPositionRegistry } from './bitfield'; 2 | import { type NormalizationFunction, NormalizedValue } from './normalize'; 3 | 4 | /** 5 | * An optimized search index. 6 | * 7 | * This serves to collect a finite, unique list of `m_1, m_2, ... m_k` normalized properties and bijectively associate 8 | * them to a specific bit in a bitmask. Matching against a specific property can then be done in `O(m_k)` time to 9 | * generate a bitmask where each `1` in the mask represents a successful match for the associated `(property, value)` tuple. 10 | * Under average circumstances where many tuples are common between `n` items, this will be significantly less expensive 11 | * than performing a search against each individual item in `O(n)` time. 12 | * 13 | * Further, performing matches against multiple criteria may be done by performing bitwise operations on the unique masks 14 | * created by the individual operations. 15 | * 16 | * All in all, this search index is designed to: 17 | * 18 | * 1. Require normalization of property values as up-front work to be done when building the index, thereby 19 | * preventing redundant computations inside hot loops; 20 | * 21 | * 2. Promote the use of set operations (union, intersection, difference) without falling back to `O(n^k)` time 22 | * (where `k` is the number of operations). 23 | */ 24 | export class SearchIndex implements ReadonlySearchIndex { 25 | private readonly columns: Map; 26 | private readonly registry: BitPositionRegistry; 27 | 28 | public constructor(columns: Record) { 29 | this.columns = new Map(); 30 | this.registry = new BitPositionRegistry(); 31 | 32 | for (const [col, description] of Object.entries(columns)) { 33 | this.columns.set(col as Columns, new SearchIndexColumn(this.registry, description)); 34 | } 35 | } 36 | 37 | public get bitfield(): BitField { 38 | return this.registry.field; 39 | } 40 | 41 | public get size(): number { 42 | return this.registry.size; 43 | } 44 | 45 | public column(name: Columns): SearchIndexColumn { 46 | const col = this.columns.get(name); 47 | if (col === undefined) throw new NoSuchColumnError(name); 48 | return col; 49 | } 50 | } 51 | 52 | /** 53 | * An immutable {@link SearchIndex}. 54 | */ 55 | export interface ReadonlySearchIndex { 56 | /** 57 | * A bitfield for all possible indices. 58 | */ 59 | get bitfield(): BitField; 60 | 61 | /** 62 | * The number of indices in all columns. 63 | */ 64 | get size(): number; 65 | 66 | /** 67 | * Gets a column from the index. 68 | * 69 | * @param name The column name. 70 | * @throws {NoSuchColumnError} If the column does not exist. 71 | */ 72 | column(name: Columns): ReadonlySearchIndexColumn; 73 | } 74 | 75 | export class NoSuchColumnError extends Error { 76 | constructor(column: string) { 77 | super(`No such column in index: ${column}`); 78 | } 79 | } 80 | 81 | /** 82 | * Options for creating an {@link SearchIndexColumn}. 83 | */ 84 | export interface ColumnDescription { 85 | normalize?: NormalizationFunction; 86 | } 87 | 88 | /** 89 | * An indexed property (i.e. a column). 90 | * This stores a bijective (perfect one-to-one) mapping between property values and their associated bit mask. 91 | * 92 | * @internal 93 | */ 94 | export class SearchIndexColumn { 95 | private readonly bitReg: BitPositionRegistry; 96 | protected readonly entries: Map; 97 | 98 | public constructor(registry: BitPositionRegistry, options?: ColumnDescription) { 99 | this.entries = new Map(); 100 | this.bitReg = registry; 101 | this.normalize = (options?.normalize ?? ((n) => n)) as (value: string) => NormalizedValue; 102 | } 103 | 104 | /** 105 | * Using the normalization rules for this indexed property, surjectively normalize the provided string. 106 | * 107 | * @param value The input string. 108 | * @return The normalized version of the input string. 109 | */ 110 | public readonly normalize: (value: string) => NormalizedValue; 111 | 112 | /** 113 | * Adds a value to the column. 114 | * 115 | * @param value The value to add. 116 | * @returns The associated bit position of the value. 117 | */ 118 | public add(value: string): BitPosition { 119 | const { entries } = this; 120 | const normalized = this.normalize(value); 121 | 122 | // If we already have this value, reuse it. 123 | const existingBit = entries.get(normalized); 124 | if (existingBit !== undefined) return existingBit; 125 | 126 | // If we don't, we need to make it. 127 | const newBit = this.bitReg.claim(); 128 | entries.set(normalized, newBit); 129 | return newBit; 130 | } 131 | 132 | /** 133 | * Removes a value from the column. 134 | * @param value The value to remove. 135 | */ 136 | public delete(value: string): void { 137 | const { entries } = this; 138 | const normalized = this.normalize(value); 139 | 140 | // Get the position. 141 | const existingBit = entries.get(normalized); 142 | if (existingBit === undefined) return; 143 | 144 | // Remove it. 145 | this.bitReg.relinquish(existingBit); 146 | entries.delete(normalized); 147 | } 148 | 149 | /** 150 | * Gets a value from the column. 151 | * 152 | * @param value The value to get. 153 | * @returns The associated bit position, or undefined if it is not in the column. 154 | */ 155 | public get(value: string): BitPosition | undefined { 156 | return this.entries.get(this.normalize(value)); 157 | } 158 | 159 | /** 160 | * The number of indices in this column. 161 | */ 162 | public get size(): number { 163 | return this.entries.size; 164 | } 165 | 166 | public [Symbol.iterator](): Iterator<[NormalizedValue, BitPosition]> { 167 | return this.entries.entries(); 168 | } 169 | } 170 | 171 | /** 172 | * A read-only {@link SearchIndexColumn}. 173 | */ 174 | export type ReadonlySearchIndexColumn = Pick< 175 | Readonly, 176 | 'get' | 'normalize' | 'size' | (typeof Symbol)['iterator'] 177 | >; 178 | -------------------------------------------------------------------------------- /src/panes/edit-callout-pane/section-preview.ts: -------------------------------------------------------------------------------- 1 | import { Component, MarkdownRenderer, TextAreaComponent, getIcon } from 'obsidian'; 2 | import { getCurrentColorScheme } from 'obsidian-extra'; 3 | 4 | import { Callout } from '&callout'; 5 | import { CalloutSettings, calloutSettingsToCSS, currentCalloutEnvironment } from '&callout-settings'; 6 | import { getTitleFromCallout } from '&callout-util'; 7 | import CalloutManagerPlugin from '&plugin'; 8 | 9 | import { IsolatedCalloutPreviewComponent } from '&ui/component/callout-preview'; 10 | 11 | /** 12 | * A callout preview for the edit callout pane. 13 | * 14 | * This allows the preview text to be edited. 15 | */ 16 | export class EditCalloutPanePreview { 17 | public readonly preview: IsolatedCalloutPreviewComponent; 18 | 19 | private readonly plugin: CalloutManagerPlugin; 20 | private readonly sectionEl: HTMLElement; 21 | private readonly calloutId: string; 22 | 23 | private previewMarkdown = 'Lorem ipsum dolor sit amet.'; 24 | private previewEditorEl: HTMLTextAreaElement | null = null; 25 | 26 | private calloutHasIconReady: boolean; 27 | 28 | public constructor(plugin: CalloutManagerPlugin, callout: Callout, viewOnly: boolean) { 29 | this.calloutHasIconReady = false; 30 | this.calloutId = callout.id; 31 | this.plugin = plugin; 32 | 33 | // Create the callout preview. 34 | const frag = document.createDocumentFragment(); 35 | this.sectionEl = frag.createDiv({ 36 | cls: ['calloutmanager-preview-container', 'calloutmanager-edit-callout-preview'], 37 | }); 38 | 39 | this.preview = new IsolatedCalloutPreviewComponent(this.sectionEl, { 40 | id: callout.id, 41 | title: getTitleFromCallout(callout), 42 | icon: callout.icon, 43 | colorScheme: getCurrentColorScheme(plugin.app), 44 | content: (containerEl) => { 45 | containerEl.createEl('p', { text: this.previewMarkdown }); 46 | }, 47 | }); 48 | 49 | // Make the preview editable. 50 | if (!viewOnly) { 51 | this.makeEditable(); 52 | } 53 | } 54 | 55 | private makeEditable(): void { 56 | const contentEl = this.preview.contentEl as HTMLElement; 57 | 58 | // Add a click handler to change the preview. 59 | this.previewEditorEl = null; 60 | this.preview.calloutEl.addEventListener('click', () => { 61 | if (this.previewEditorEl != null) { 62 | return; 63 | } 64 | 65 | const height = contentEl.getBoundingClientRect().height; 66 | contentEl.empty(); 67 | new TextAreaComponent(contentEl) 68 | .setValue(this.previewMarkdown) 69 | .setPlaceholder('Preview Markdown...') 70 | .then((c) => { 71 | const inputEl = (this.previewEditorEl = c.inputEl); 72 | inputEl.style.setProperty('height', `${height}px`); 73 | inputEl.classList.add('calloutmanager-preview-editor'); 74 | inputEl.focus(); 75 | inputEl.addEventListener('blur', () => { 76 | const value = c.getValue(); 77 | this.previewEditorEl = null; 78 | this.previewMarkdown = value; 79 | this.changeContent(value); 80 | }); 81 | }); 82 | }); 83 | } 84 | 85 | /** 86 | * Refreshes the callout preview's icon. 87 | * We need to do this after the preview is attached to DOM, as we can't get the correct icon until that happens. 88 | */ 89 | protected refreshPreviewIcon(): void { 90 | const { iconEl, calloutEl } = this.preview; 91 | 92 | if (window.document.contains(this.sectionEl)) { 93 | const icon = window.getComputedStyle(calloutEl).getPropertyValue('--callout-icon').trim(); 94 | const iconSvg = getIcon(icon) ?? document.createElement('svg'); 95 | 96 | iconEl.empty(); 97 | iconEl.appendChild(iconSvg); 98 | 99 | this.calloutHasIconReady = true; 100 | } 101 | } 102 | 103 | /** 104 | * Changes the preview that is displayed inside the callout. 105 | * 106 | * @param markdown The markdown to render. 107 | */ 108 | public async changeContent(markdown: string): Promise { 109 | const contentEl = this.preview.contentEl as HTMLElement; 110 | contentEl.empty(); 111 | 112 | try { 113 | await MarkdownRenderer.renderMarkdown(markdown, contentEl, '', undefined as unknown as Component); 114 | } catch (ex) { 115 | contentEl.createEl('code').createEl('pre', { text: markdown }); 116 | } 117 | } 118 | 119 | /** 120 | * Changes the settings for the callout. 121 | * This can be used to show the customized callout. 122 | * 123 | * @param settings The settings to use. 124 | */ 125 | public async changeSettings(settings: CalloutSettings): Promise { 126 | const { preview } = this; 127 | const styles = calloutSettingsToCSS(this.calloutId, settings, currentCalloutEnvironment(this.plugin.app)); 128 | 129 | // Update the custom stylesheet of the callout preview. 130 | preview.customStyleEl.textContent = styles; 131 | preview.resetStylePropertyOverrides(); 132 | preview.removeStyles((el) => el.getAttribute('data-callout-manager') === 'style-overrides'); 133 | 134 | this.calloutHasIconReady = false; 135 | 136 | // Remove the preview styles added by callout manager. 137 | // Now that we changed the settings, having the old styles would lead to inconsistency. 138 | preview.removeStyles((el) => el.getAttribute('data-inject-id') === 'callout-settings'); 139 | } 140 | 141 | /** 142 | * Attaches the preview to a container. 143 | * @param containerEl The container element. 144 | */ 145 | public attach(containerEl: HTMLElement) { 146 | containerEl.appendChild(this.sectionEl); 147 | 148 | if (!this.calloutHasIconReady) { 149 | this.refreshPreviewIcon(); 150 | } 151 | } 152 | } 153 | 154 | declare const STYLES: ` 155 | // Ensure the preview takes a certain height. 156 | .calloutmanager-edit-callout-preview { 157 | padding-bottom: var(--size-4-8); 158 | min-height: 14em; 159 | 160 | body.is-mobile & { 161 | min-height: 35vh; 162 | } 163 | } 164 | 165 | // The text box that allows the preview to be changed. 166 | .calloutmanager-preview-editor { 167 | resize: vertical; 168 | width: 100%; 169 | min-height: 6em; 170 | 171 | margin-top: var(--size-4-3); 172 | 173 | // Try to be as transparent as possible. 174 | background: transparent; 175 | font-size: var(--font-text-size); 176 | font-family: var(--font-text); 177 | line-height: var(--line-height-normal); 178 | } 179 | `; 180 | -------------------------------------------------------------------------------- /src/util/color.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from '@jest/globals'; 2 | 3 | import { parseColorHex, parseColorRGB, parseColorRGBA } from './color'; 4 | 5 | describe('parseColorRGB', () => { 6 | test('rgb(i, i, i)', () => { 7 | expect(parseColorRGB(`rgb( 10, 10, 10 )`)).toStrictEqual({ r: 10, g: 10, b: 10 }); 8 | expect(parseColorRGB(`rgb(1,1,1)`)).toStrictEqual({ r: 1, g: 1, b: 1 }); 9 | expect(parseColorRGB(`rgb(2, 2, 2)`)).toStrictEqual({ r: 2, g: 2, b: 2 }); 10 | 11 | // Weird formatting. 12 | expect(parseColorRGB(` rgb(3, 3, 3)`)).toStrictEqual({ r: 3, g: 3, b: 3 }); 13 | expect(parseColorRGB(`rgb(9, 9, 9) `)).toStrictEqual({ r: 9, g: 9, b: 9 }); 14 | expect(parseColorRGB(`rgb(10, 10 , 10 ) `)).toStrictEqual({ r: 10, g: 10, b: 10 }); 15 | expect(parseColorRGB(`rgb(4, 4,4)`)).toStrictEqual({ r: 4, g: 4, b: 4 }); 16 | expect(parseColorRGB(`rgb(5,5 ,5)`)).toStrictEqual({ r: 5, g: 5, b: 5 }); 17 | 18 | // Inconsistent commas. 19 | expect(parseColorRGB(`rgb(6 6 6)`)).toStrictEqual({ r: 6, g: 6, b: 6 }); 20 | expect(parseColorRGB(`rgb(7 7, 7)`)).toStrictEqual({ r: 7, g: 7, b: 7 }); 21 | expect(parseColorRGB(`rgb(8 8 ,8)`)).toStrictEqual({ r: 8, g: 8, b: 8 }); 22 | 23 | // Boundary cases. 24 | expect(parseColorRGB(`rgb( 0, 0, 0 )`)).toStrictEqual({ r: 0, g: 0, b: 0 }); 25 | expect(parseColorRGB(`rgb( 255, 255, 255 )`)).toStrictEqual({ r: 255, g: 255, b: 255 }); 26 | }); 27 | 28 | test('rgb(%, %, %)', () => { 29 | expect(parseColorRGB(`rgb( 50% 50% 50% )`)).toStrictEqual({ r: 127, g: 127, b: 127 }); 30 | expect(parseColorRGB(`rgb( 33.4%, 33.4%, 33.4% )`)).toStrictEqual({ r: 85, g: 85, b: 85 }); 31 | 32 | // Boundary cases. 33 | expect(parseColorRGB(`rgb( 0%, 0%, 0% )`)).toStrictEqual({ r: 0, g: 0, b: 0 }); 34 | expect(parseColorRGB(`rgb( 100%, 100%, 100% )`)).toStrictEqual({ r: 255, g: 255, b: 255 }); 35 | }); 36 | 37 | test('rgb(invalid)', () => { 38 | expect(parseColorRGB(`rgb( 10, 10 )`)).toBeNull(); 39 | expect(parseColorRGB(`rgb( 10 )`)).toBeNull(); 40 | expect(parseColorRGB(`rgb( 10 10 )`)).toBeNull(); 41 | expect(parseColorRGB(`rgbn( 10, 10 )`)).toBeNull(); 42 | 43 | // Mixed percentages and values. 44 | expect(parseColorRGB(`rgb( 10, 10, 10% )`)).toBeNull(); 45 | expect(parseColorRGB(`rgb( 10%, 10%, 10 )`)).toBeNull(); 46 | }); 47 | 48 | test('invalid(i, i, i)', () => { 49 | expect(parseColorRGB(`not( 10, 10, 10 )`)).toBeNull(); 50 | }); 51 | }); 52 | 53 | describe('parseColorRGBA', () => { 54 | test('rgba(i, i, i, i)', () => { 55 | expect(parseColorRGBA(`rgba( 10, 10, 10, 0.2 )`)).toStrictEqual({ r: 10, g: 10, b: 10, a: 51 }); 56 | 57 | // Weird formatting. 58 | expect(parseColorRGBA(` rgba(3, 3, 3,1)`)).toStrictEqual({ r: 3, g: 3, b: 3, a: 255 }); 59 | expect(parseColorRGBA(`rgba(9, 9, 9,1) `)).toStrictEqual({ r: 9, g: 9, b: 9, a: 255 }); 60 | expect(parseColorRGBA(`rgba(10, 10 , 10,1 ) `)).toStrictEqual({ r: 10, g: 10, b: 10, a: 255 }); 61 | expect(parseColorRGBA(`rgba(4, 4,4 ,1)`)).toStrictEqual({ r: 4, g: 4, b: 4, a: 255 }); 62 | expect(parseColorRGBA(`rgba(5,5 ,5,1)`)).toStrictEqual({ r: 5, g: 5, b: 5, a: 255 }); 63 | 64 | // Not accepted. 65 | expect(parseColorRGBA(`rgba( 10, 10, 10 1 )`)).toBeNull(); 66 | expect(parseColorRGBA(`rgba( 10 10 10 1 )`)).toBeNull(); 67 | 68 | // Boundary cases. 69 | expect(parseColorRGBA(`rgba( 0, 0, 0, 0 )`)).toStrictEqual({ r: 0, g: 0, b: 0, a: 0 }); 70 | expect(parseColorRGBA(`rgba( 255, 255, 255, 1 )`)).toStrictEqual({ r: 255, g: 255, b: 255, a: 255 }); 71 | }); 72 | 73 | test('rgba(i, i, i, %)', () => { 74 | expect(parseColorRGBA(`rgba( 10, 10, 10, 100% )`)).toStrictEqual({ r: 10, g: 10, b: 10, a: 255 }); 75 | }); 76 | 77 | test('rgba(%, %, %, %)', () => { 78 | expect(parseColorRGBA(`rgba( 100%, 0%, 100%, 0% )`)).toStrictEqual({ r: 255, g: 0, b: 255, a: 0 }); 79 | }); 80 | 81 | test('rgba(%, %, %, i)', () => { 82 | expect(parseColorRGBA(`rgba( 100%, 0%, 100%, 0.5 )`)).toStrictEqual({ r: 255, g: 0, b: 255, a: 127 }); 83 | }); 84 | 85 | test('rgba(i, i, i)', () => { 86 | expect(parseColorRGBA(`rgba( 10, 10, 10 )`)).toStrictEqual({ r: 10, g: 10, b: 10, a: 255 }); 87 | 88 | // Inconsistent commas. 89 | expect(parseColorRGBA(`rgba( 10 10 10 )`)).toStrictEqual({ r: 10, g: 10, b: 10, a: 255 }); 90 | 91 | // Boundary cases. 92 | expect(parseColorRGBA(`rgba( 0, 0, 0 )`)).toStrictEqual({ r: 0, g: 0, b: 0, a: 255 }); 93 | expect(parseColorRGBA(`rgba( 255, 255, 255 )`)).toStrictEqual({ r: 255, g: 255, b: 255, a: 255 }); 94 | }); 95 | }); 96 | 97 | describe('parseColorHex', () => { 98 | test('#rgb', () => { 99 | expect(parseColorHex(`#f80`)).toStrictEqual({ r: 255, g: 136, b: 0 }); 100 | expect(parseColorHex(` #f80`)).toStrictEqual({ r: 255, g: 136, b: 0 }); 101 | expect(parseColorHex(`#f80 `)).toStrictEqual({ r: 255, g: 136, b: 0 }); 102 | 103 | // Not accepted. 104 | expect(parseColorHex(`#g00`)).toBeNull(); 105 | expect(parseColorHex(`#0g0`)).toBeNull(); 106 | expect(parseColorHex(`#00g`)).toBeNull(); 107 | expect(parseColorHex(`#00-`)).toBeNull(); 108 | }); 109 | 110 | test('#rgba', () => { 111 | expect(parseColorHex(`#f808`)).toStrictEqual({ r: 255, g: 136, b: 0, a: 136 }); 112 | expect(parseColorHex(` #f808`)).toStrictEqual({ r: 255, g: 136, b: 0, a: 136 }); 113 | expect(parseColorHex(`#f808 `)).toStrictEqual({ r: 255, g: 136, b: 0, a: 136 }); 114 | 115 | // Not accepted. 116 | expect(parseColorHex(`#000g`)).toBeNull(); 117 | }); 118 | 119 | test('#rrggbb', () => { 120 | expect(parseColorHex(`#010203`)).toStrictEqual({ r: 1, g: 2, b: 3 }); 121 | 122 | // Not accepted. 123 | expect(parseColorHex(`#gg0000`)).toBeNull(); 124 | }); 125 | 126 | test('#rrggbbaa', () => { 127 | expect(parseColorHex(`#01020304`)).toStrictEqual({ r: 1, g: 2, b: 3, a: 4 }); 128 | expect(parseColorHex(`#01020304`)).toStrictEqual({ r: 1, g: 2, b: 3, a: 4 }); 129 | 130 | // Not accepted. 131 | expect(parseColorHex(`#g00gg00g`)).toBeNull(); 132 | }); 133 | 134 | test('invalid', () => { 135 | // Has spaces. 136 | expect(parseColorHex(`#f 80`)).toBeNull(); 137 | expect(parseColorHex(`#f8 0`)).toBeNull(); 138 | expect(parseColorHex(`# f80`)).toBeNull(); 139 | expect(parseColorHex(`#f 80`)).toBeNull(); 140 | 141 | // Too few chars. 142 | expect(parseColorHex(`#f`)).toBeNull(); 143 | expect(parseColorHex(`#ff`)).toBeNull(); 144 | 145 | // Too many chars. 146 | expect(parseColorHex(`#fffff`)).toBeNull(); 147 | expect(parseColorHex(`#fffffff`)).toBeNull(); 148 | expect(parseColorHex(`#fffffffff`)).toBeNull(); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /src/ui/pane-layers.ts: -------------------------------------------------------------------------------- 1 | import { ButtonComponent, Component } from 'obsidian'; 2 | 3 | import { UIPane, UIPaneNavigation, UIPane_FRIEND } from './pane'; 4 | 5 | /** 6 | * Layered navigation for the Callout Manager setting tab. 7 | * This allows panes to be stacked on top of each other, allowing for a hierarchical view of the plugin settings. 8 | */ 9 | export class UIPaneLayers { 10 | protected readonly navInstance: UIPaneNavigation; 11 | protected readonly closeParent: () => void; 12 | protected readonly root: Component; 13 | protected activePane: UIPane_FRIEND | undefined; 14 | 15 | public titleEl!: HTMLElement; 16 | public containerEl!: HTMLElement; 17 | public controlsEl!: HTMLElement; 18 | public navEl!: HTMLElement; 19 | public scrollEl!: HTMLElement; 20 | 21 | public readonly layers: Array<{ 22 | state: unknown; 23 | scroll: { top: number; left: number }; 24 | pane: UIPane_FRIEND; 25 | title: string; 26 | }> = []; 27 | 28 | public constructor(root: Component, options: { close: () => void }) { 29 | this.closeParent = options.close; 30 | this.root = root; 31 | this.navInstance = { 32 | open: (pane) => this.push(pane), 33 | close: () => this.pop(), 34 | replace: (pane) => (this.top = pane), 35 | }; 36 | } 37 | 38 | /** 39 | * Pushes a new pane on top of the stack. 40 | * The active pane will be suspended. 41 | * 42 | * @param pane The pane to push. 43 | */ 44 | public push(pane: UIPane) { 45 | const { activePane: oldPane } = this; 46 | 47 | // Suspend the active layer. 48 | if (oldPane !== undefined) { 49 | const title = oldPane.title; 50 | this.layers.push({ 51 | scroll: { top: this.scrollEl.scrollTop, left: this.scrollEl.scrollLeft }, 52 | state: oldPane.suspendState(), 53 | pane: oldPane, 54 | title: typeof title === 'string' ? title : title.subtitle, 55 | }); 56 | 57 | this.setPaneVariables(oldPane, false); 58 | this.containerEl.empty(); 59 | } 60 | 61 | // Attach the new layer. 62 | const newPane = (this.activePane = pane as unknown as UIPane_FRIEND); 63 | this.setPaneVariables(newPane, true); 64 | newPane.onReady(); 65 | this.doDisplay(true); 66 | this.scrollEl.scrollTo({ top: 0, left: 0 }); 67 | } 68 | 69 | /** 70 | * Pops the active pane off the stack. 71 | * The active pane will be destroyed, and the one underneath it will be restored. 72 | * 73 | * @param pane The pane to push. 74 | */ 75 | public pop(options?: { cancelled?: boolean; noDisplay?: boolean }): UIPane | undefined { 76 | if (this.activePane === undefined) { 77 | this.closeParent(); 78 | return undefined; 79 | } 80 | 81 | const noDisplay = options?.noDisplay ?? false; 82 | const oldPane = this.activePane; 83 | const newPane = this.layers.pop(); 84 | 85 | // Destroy the old top layer. 86 | this.activePane = undefined; 87 | this.setPaneVariables(oldPane, false); 88 | oldPane.onClose(options?.cancelled ?? false); 89 | if (!noDisplay) { 90 | this.containerEl.empty(); 91 | } 92 | 93 | // Prepare the new top layer. 94 | if (newPane !== undefined) { 95 | this.activePane = newPane.pane; 96 | this.setPaneVariables(newPane.pane, true); 97 | newPane.pane.restoreState(newPane.state); 98 | if (!noDisplay) { 99 | this.doDisplay(true); 100 | this.scrollEl.scrollTo(newPane.scroll); 101 | } 102 | } 103 | 104 | return oldPane as unknown as UIPane; 105 | } 106 | 107 | /** 108 | * Removes all panes off the stack. 109 | * All panes will be destroyed. 110 | * 111 | * @param pane The pane to push. 112 | */ 113 | public clear(options?: Parameters[0]): UIPane[] { 114 | const removed: UIPane[] = []; 115 | const opts = { 116 | noDisplay: true, 117 | ...(options ?? {}), 118 | }; 119 | 120 | while (this.activePane !== undefined) { 121 | removed.push(this.pop(opts) as UIPane); 122 | } 123 | 124 | return removed; 125 | } 126 | 127 | /** 128 | * The top-most (i.e. currently active) pane in the layers. 129 | */ 130 | public get top(): UIPane | undefined { 131 | return this.activePane as unknown as UIPane; 132 | } 133 | 134 | public set top(pane: UIPane | undefined) { 135 | const { activePane: oldTop } = this; 136 | 137 | // Destroy the old top layer. 138 | if (oldTop !== undefined) { 139 | this.setPaneVariables(oldTop, false); 140 | oldTop.onClose(false); 141 | } 142 | 143 | // Prepare the new top layer. 144 | const newPane = (this.activePane = pane as unknown as UIPane_FRIEND); 145 | this.setPaneVariables(newPane, true); 146 | newPane.onReady(); 147 | this.doDisplay(true); 148 | } 149 | 150 | protected doDisplay(renderControls: boolean): void { 151 | const { activePane, titleEl, navEl, containerEl } = this; 152 | if (activePane === undefined) { 153 | return; 154 | } 155 | 156 | // Display the nav. 157 | navEl.empty(); 158 | if (this.layers.length > 0) { 159 | new ButtonComponent(this.navEl) 160 | .setIcon('lucide-arrow-left-circle') 161 | .setClass('clickable-icon') 162 | .setTooltip(`Back to ${this.layers[this.layers.length - 1].title}`) 163 | .onClick(() => this.navInstance.close()); 164 | } 165 | 166 | // Display the title. 167 | titleEl.empty(); 168 | const { title } = activePane; 169 | if (typeof title === 'string') { 170 | titleEl.createEl('h2', { text: title }); 171 | } else { 172 | titleEl.createEl('h2', { text: title.title }); 173 | titleEl.createEl('h3', { text: title.subtitle }); 174 | } 175 | 176 | // Display the controls. 177 | // Ideally, this should only be done once. 178 | if (renderControls) { 179 | this.controlsEl.empty(); 180 | activePane.displayControls(); 181 | } 182 | 183 | // Display the contents. 184 | containerEl.empty(); 185 | activePane.display(); 186 | } 187 | 188 | private setPaneVariables(pane: UIPane_FRIEND, attached: boolean) { 189 | const notAttachedError = () => { 190 | throw new Error('Not attached'); 191 | }; 192 | 193 | Object.defineProperties(pane, { 194 | nav: { 195 | configurable: true, 196 | enumerable: true, 197 | get: attached ? () => this.navInstance : notAttachedError, 198 | }, 199 | 200 | containerEl: { 201 | configurable: true, 202 | enumerable: true, 203 | get: attached ? () => this.containerEl : notAttachedError, 204 | }, 205 | 206 | controlsEl: { 207 | configurable: true, 208 | enumerable: true, 209 | get: attached ? () => this.controlsEl : notAttachedError, 210 | }, 211 | 212 | root: { 213 | configurable: true, 214 | enumerable: true, 215 | get: attached ? () => this.root : notAttachedError, 216 | }, 217 | }); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/ui/paned-setting-tab.ts: -------------------------------------------------------------------------------- 1 | import { PluginSettingTab } from 'obsidian'; 2 | import { openPluginSettings } from 'obsidian-extra'; 3 | import { closeSettings } from 'obsidian-extra/unsafe'; 4 | 5 | import CalloutManagerPlugin from '&plugin'; 6 | 7 | import { UIPane } from './pane'; 8 | import { UIPaneLayers } from './pane-layers'; 9 | 10 | /** 11 | * The settings tab (UI) that will show up under Obsidian's settings. 12 | * 13 | * This implements stacked navigation, where {@link UIPane}s may be stacked on top of eachother. 14 | */ 15 | export class UISettingTab extends PluginSettingTab { 16 | private readonly plugin: CalloutManagerPlugin; 17 | private readonly layers: UIPaneLayers; 18 | private readonly createDefault: () => UIPane; 19 | 20 | private initLayer: UIPane | null; 21 | 22 | public constructor(plugin: CalloutManagerPlugin, createDefault: () => UIPane) { 23 | super(plugin.app, plugin); 24 | this.plugin = plugin; 25 | this.createDefault = createDefault; 26 | 27 | this.initLayer = null; 28 | this.layers = new UIPaneLayers(plugin, { 29 | close: () => closeSettings(this.app), 30 | }); 31 | } 32 | 33 | public openWithPane(pane: UIPane) { 34 | this.initLayer = pane; 35 | openPluginSettings(this.plugin.app, this.plugin); 36 | } 37 | 38 | /** @override */ 39 | public hide(): void { 40 | this.initLayer = null; 41 | this.layers.clear(); 42 | super.hide(); 43 | } 44 | 45 | public display(): void { 46 | const { containerEl, layers } = this; 47 | 48 | // Clear the container and create the elements. 49 | containerEl.empty(); 50 | containerEl.classList.add('calloutmanager-setting-tab', 'calloutmanager-pane'); 51 | 52 | const headerEl = containerEl.createDiv({ cls: 'calloutmanager-setting-tab-header' }); 53 | layers.navEl = headerEl.createDiv({ cls: 'calloutmanager-setting-tab-nav' }); 54 | layers.titleEl = headerEl.createDiv({ cls: 'calloutmanager-setting-tab-title' }); 55 | 56 | const controlsEl = headerEl.createDiv({ cls: 'calloutmanager-setting-tab-controls' }); 57 | layers.controlsEl = controlsEl.createDiv(); 58 | layers.scrollEl = containerEl.createDiv({ 59 | cls: 'calloutmanager-setting-tab-viewport vertical-tab-content', 60 | }); 61 | layers.containerEl = layers.scrollEl.createDiv({ cls: 'calloutmanager-setting-tab-content' }); 62 | 63 | // Create a close button, since the native one is covered. 64 | controlsEl.createDiv({ cls: 'modal-close-button' }, (closeButtonEl) => { 65 | closeButtonEl.addEventListener('click', (ev) => { 66 | if (!ev.isTrusted) return; 67 | closeSettings(this.app); 68 | }); 69 | }); 70 | 71 | // Clear the layers. 72 | layers.clear(); 73 | 74 | // Render the top layer (or the default). 75 | const initLayer = this.initLayer ?? this.createDefault(); 76 | this.initLayer = null; 77 | layers.top = initLayer; 78 | } 79 | } 80 | 81 | // --------------------------------------------------------------------------------------------------------------------- 82 | // Styles: 83 | // --------------------------------------------------------------------------------------------------------------------- 84 | 85 | declare const STYLES: ` 86 | // The setting tab container. 87 | .mod-sidebar-layout .calloutmanager-setting-tab.vertical-tab-content { 88 | position: relative; 89 | padding: 0 !important; 90 | display: flex; 91 | flex-direction: column; 92 | 93 | // Prevent scrolling on the parent container so we can have a sticky header. 94 | overflow-y: initial; 95 | 96 | // Variables. 97 | & { 98 | // Set a margin that allows the controls to become padding if there's nothing inside them. 99 | --cm-setting-tab-controls-margin: calc(var(--size-4-12) - (var(--size-4-2) - var(--size-4-1))); 100 | body.is-phone & { 101 | --cm-setting-tab-controls-margin: var(--size-4-2); 102 | } 103 | } 104 | } 105 | 106 | // The setting tab header. 107 | .calloutmanager-setting-tab-header { 108 | display: flex; 109 | align-items: center; 110 | 111 | // Padding to mimic the sizing of the '.vertical-tab-content' 112 | padding-top: var(--size-4-1); 113 | padding-bottom: var(--size-4-1); 114 | padding-right: var(--size-4-2); 115 | 116 | // Bottom border to separate the header from the content. 117 | border-bottom: 1px solid var(--background-modifier-border); 118 | 119 | // Use the background of the sidebar. 120 | background-color: var(--background-secondary); 121 | 122 | body.is-phone & { 123 | background-color: var(--background-primary); 124 | } 125 | } 126 | 127 | // The setting tab nav within the header. 128 | .calloutmanager-setting-tab-nav { 129 | display: flex; 130 | align-items: center; 131 | justify-content: center; 132 | 133 | // Ensure the nav is at least as big as a button. 134 | min-width: var(--size-4-12); 135 | min-height: calc(var(--size-4-2) + var(--input-height)); 136 | 137 | // Override the button padding. 138 | button { 139 | padding: var(--size-4-1) var(--size-4-2); 140 | box-shadow: none; 141 | } 142 | 143 | // Reduce padding for mobile. 144 | body.is-mobile & { 145 | padding: var(--size-4-2); 146 | } 147 | 148 | body.is-phone &, 149 | body.is-phone & button { 150 | height: 100%; 151 | min-width: unset; 152 | } 153 | } 154 | 155 | // The setting tab nav within the header. 156 | .calloutmanager-setting-tab-controls { 157 | flex: 3 3; 158 | 159 | display: flex; 160 | align-items: center; 161 | justify-content: end; 162 | gap: var(--size-4-2); 163 | 164 | padding-left: var(--cm-setting-tab-controls-margin); 165 | 166 | // Make the real control elements transparent to the container. 167 | > *:not(.modal-close-button) { 168 | display: contents; 169 | 170 | > input[type='text'] { 171 | flex: 1 1 auto; 172 | } 173 | } 174 | } 175 | 176 | .calloutmanager-setting-tab-controls .modal-close-button { 177 | flex: 0 0 auto; 178 | 179 | position: static; 180 | left: unset; 181 | top: 0; 182 | right: 0; 183 | bottom: 0; 184 | 185 | body.is-phone & { 186 | display: none; 187 | } 188 | } 189 | 190 | // The setting tab title within the header. 191 | .calloutmanager-setting-tab-title { 192 | flex: 1 1 auto; 193 | flex-wrap: nowrap; 194 | 195 | h2, 196 | h3 { 197 | margin: 0; 198 | word-break: keep-all; 199 | } 200 | 201 | h3 { 202 | font-size: var(--font-ui-small); 203 | } 204 | 205 | body:not(.is-phone) & h3 { 206 | font-size: 0.8em; 207 | } 208 | 209 | body.is-phone & h2:has(+ h3) { 210 | display: none; 211 | } 212 | } 213 | 214 | // The scroll container for the setting tab. 215 | .calloutmanager-setting-tab-viewport { 216 | flex: 1 2 auto; 217 | 218 | // Enable scrolling. 219 | overflow-y: auto; 220 | -webkit-overflow-scrolling: touch; 221 | } 222 | 223 | .calloutmanager-setting-tab-content { 224 | flex: 1 1 auto; 225 | 226 | body:not(.is-phone) { 227 | min-height: 100%; 228 | } 229 | } 230 | `; 231 | -------------------------------------------------------------------------------- /src/callout-settings.ts: -------------------------------------------------------------------------------- 1 | import { App } from 'obsidian'; 2 | import { getCurrentColorScheme, getCurrentThemeID } from 'obsidian-extra'; 3 | import { ThemeID } from 'obsidian-undocumented'; 4 | 5 | import { CalloutID } from '&callout'; 6 | 7 | /** 8 | * Gets the current environment that callouts are under. 9 | * This can be passed to {@link calloutSettingsToCSS}. 10 | * 11 | * @param app The app instance. 12 | * @returns The callout environment. 13 | */ 14 | export function currentCalloutEnvironment(app: App): Parameters[1] { 15 | const theme = getCurrentThemeID(app) ?? ''; 16 | return { 17 | theme, 18 | colorScheme: getCurrentColorScheme(app), 19 | }; 20 | } 21 | 22 | /** 23 | * Converts callout settings to CSS that applies the setting. 24 | * 25 | * @param id The callout ID. 26 | * @param settings The settings for the callout. 27 | * @param environment The environment to resolve conditions under. 28 | */ 29 | export function calloutSettingsToCSS( 30 | id: CalloutID, 31 | settings: CalloutSettings, 32 | environment: Parameters[1], 33 | ): string { 34 | const styles = calloutSettingsToStyles(settings, environment).join(';\n\t'); 35 | if (styles.length === 0) { 36 | return ''; 37 | } 38 | 39 | return `.callout[data-callout="${id}"] {\n\t` + styles + '\n}'; 40 | } 41 | 42 | /** 43 | * Converts callout settings to a list of styles that apply the setting. 44 | * 45 | * @param condition The active conditions. 46 | */ 47 | export function calloutSettingsToStyles( 48 | settings: CalloutSettings, 49 | environment: Parameters[1], 50 | ): string[] { 51 | const styles: string[] = []; 52 | 53 | for (const setting of settings) { 54 | if (!checkCondition(setting.condition, environment)) { 55 | continue; 56 | } 57 | 58 | // Build the styles. 59 | const { changes } = setting; 60 | if (changes.color != null) styles.push(`--callout-color: ${changes.color}`); 61 | if (changes.icon != null) styles.push(`--callout-icon: ${changes.icon}`); 62 | if (changes.customStyles != null) styles.push(changes.customStyles); 63 | } 64 | 65 | return styles; 66 | } 67 | 68 | /** 69 | * Recursively checks a {@link CalloutSettingsCondition}. 70 | * 71 | * @param condition The condition to check. 72 | * @param environment The environment to check the condition against. 73 | * 74 | * @returns True if the condition holds for the given environment. 75 | */ 76 | function checkCondition( 77 | condition: CalloutSettingsCondition, 78 | environment: { theme: string; colorScheme: 'dark' | 'light' }, 79 | ): boolean { 80 | if (condition == null) { 81 | return true; 82 | } 83 | 84 | // "or" combinator. 85 | if ('or' in condition && condition.or !== undefined) { 86 | return condition.or.findIndex((p) => checkCondition(p, environment) === true) !== undefined; 87 | } 88 | 89 | // "and" combinator. 90 | if ('and' in condition && condition.and !== undefined) { 91 | return condition.and.findIndex((p) => checkCondition(p, environment) === false) === undefined; 92 | } 93 | 94 | // Theme condition. 95 | if ('theme' in condition && condition.theme === environment.theme) { 96 | return true; 97 | } 98 | 99 | // Dark mode condition. 100 | if ('colorScheme' in condition && condition.colorScheme === environment.colorScheme) { 101 | return true; 102 | } 103 | 104 | return false; 105 | } 106 | 107 | /** 108 | * Returns true if the condition is not an elementary condition. 109 | * 110 | * @param condition The condition to check. 111 | * @returns True if the condition is not elementary. 112 | */ 113 | export function isComplexCondition(condition: CalloutSettingsCondition): boolean { 114 | const type = typeofCondition(condition); 115 | return type === 'and' || type === 'or'; 116 | } 117 | 118 | /** 119 | * Returns the type of condition of the provided condition. 120 | * 121 | * @param condition The condition. 122 | * @returns The condition type. 123 | */ 124 | export function typeofCondition(condition: CalloutSettingsCondition): CalloutSettingsConditionType | undefined { 125 | if (condition === undefined) return undefined; 126 | const hasOwnProperty = Object.prototype.hasOwnProperty.bind(condition) as ( 127 | type: CalloutSettingsConditionType, 128 | ) => boolean; 129 | 130 | if (hasOwnProperty('colorScheme')) return 'colorScheme'; 131 | if (hasOwnProperty('theme')) return 'theme'; 132 | if (hasOwnProperty('and')) return 'and'; 133 | if (hasOwnProperty('or')) return 'or'; 134 | 135 | throw new Error(`Unsupported condition: ${JSON.stringify(condition)}`); 136 | } 137 | 138 | // --------------------------------------------------------------------------------------------------------------------- 139 | // DSL: 140 | // --------------------------------------------------------------------------------------------------------------------- 141 | 142 | /** 143 | * A type of {@link CalloutSettingsCondition callout setting condition}. 144 | */ 145 | export type CalloutSettingsConditionType = 'theme' | 'colorScheme' | 'and' | 'or'; 146 | 147 | /** A condition that checks the current Obsidian theme. */ 148 | export type CalloutSettingsThemeCondition = { theme: ThemeID | '' }; 149 | 150 | /** A condition that checks the current color scheme of Obsidian */ 151 | export type CalloutSettingsColorSchemeCondition = { colorScheme: 'dark' | 'light' }; 152 | 153 | /** Conditions that can either be true or false by themselves. */ 154 | export type CalloutSettingsElementaryConditions = CalloutSettingsThemeCondition | CalloutSettingsColorSchemeCondition; 155 | 156 | /** Conditions that combine other conditions based on binary logic operations. */ 157 | export type CalloutSettingsCombinatoryConditions = 158 | | { and: CalloutSettingsCondition[] } 159 | | { or: CalloutSettingsCondition[] }; 160 | 161 | /** 162 | * Changes that can be applied to a callout. 163 | */ 164 | export type CalloutSettingsChanges = { 165 | /** 166 | * Changes the callout color. 167 | */ 168 | color?: string; 169 | 170 | /** 171 | * Changes the callout icon. 172 | */ 173 | icon?: string; 174 | 175 | /** 176 | * Applies custom styles to the callout. 177 | */ 178 | customStyles?: string; 179 | }; 180 | 181 | /** 182 | * Conditions that affect when callout changes are applied. 183 | */ 184 | export type CalloutSettingsCondition = 185 | | undefined 186 | | CalloutSettingsElementaryConditions 187 | | CalloutSettingsCombinatoryConditions; 188 | 189 | /** 190 | * A setting that changes a callout's appearance when the given condition holds true. 191 | * If no condition is provided (or it is undefined), the changes will always be applied. 192 | */ 193 | export type CalloutSetting = { 194 | condition?: C; 195 | changes: CalloutSettingsChanges; 196 | }; 197 | 198 | /** 199 | * An array of {@link CalloutSetting} objects. 200 | */ 201 | export type CalloutSettings = Array>; 202 | -------------------------------------------------------------------------------- /src/panes/select-icon-pane.ts: -------------------------------------------------------------------------------- 1 | import { SearchResult, TextComponent, getIconIds, prepareFuzzySearch } from 'obsidian'; 2 | 3 | import CalloutManagerPlugin from '&plugin'; 4 | 5 | import { IconPreviewComponent } from '&ui/component/icon-preview'; 6 | import { UIPane, UIPaneTitle } from '&ui/pane'; 7 | 8 | const recentIcons: Set = new Set(); 9 | 10 | /** 11 | * A user interface pane for selecting an icon. 12 | * 13 | * This provides a searchable grid of icons that the user can scroll through. 14 | */ 15 | export class SelectIconPane extends UIPane { 16 | public readonly title: UIPaneTitle; 17 | private plugin: CalloutManagerPlugin; 18 | 19 | private searchQuery: string; 20 | private searchResults: IconForSearch[]; 21 | 22 | private usedIcons: Map; 23 | private allIcons: IconForSearch[]; 24 | private previewLimit: number; 25 | private previewLimitOverage: number; 26 | private onChoose: (icon: string) => void; 27 | 28 | private compareIcons: (a: IconForSearch, b: IconForSearch) => number; 29 | 30 | public constructor( 31 | plugin: CalloutManagerPlugin, 32 | title: UIPaneTitle, 33 | options: { limit?: number; onChoose: (icon: string) => void }, 34 | ) { 35 | super(); 36 | this.title = title; 37 | this.plugin = plugin; 38 | this.onChoose = options.onChoose; 39 | 40 | this.previewLimit = options.limit ?? 250; 41 | this.previewLimitOverage = 0; 42 | 43 | this.searchQuery = ''; 44 | this.searchResults = []; 45 | 46 | // Generate suggestions based on what other callouts are using. 47 | const usedIconIds = new Set(plugin.callouts.values().map((c) => c.icon)); 48 | const usedIcons = (this.usedIcons = new Map()); 49 | 50 | // Create an easily-searchable list of icons. 51 | this.allIcons = getIconIds().map((id) => { 52 | const icon: IconForSearch = { 53 | id, 54 | searchId: id.trim().toLowerCase(), 55 | component: null, 56 | searchResult: null, 57 | }; 58 | 59 | if (usedIconIds.has(id)) { 60 | this.usedIcons.set(id, icon); 61 | } 62 | 63 | return icon; 64 | }); 65 | 66 | // Create comparator function. 67 | this.compareIcons = ( 68 | { id: a, searchId: aLC, searchResult: aSR }, 69 | { id: b, searchId: bLC, searchResult: bSR }, 70 | ) => { 71 | const recency = (recentIcons.has(b) ? 1 : 0) - (recentIcons.has(a) ? 1 : 0); 72 | const suggested = (usedIcons.has(b) ? 1 : 0) - (usedIcons.has(a) ? 1 : 0); 73 | const searchRank = (bSR?.score ?? 0) - (aSR?.score ?? 0); 74 | 75 | // Ranked. 76 | const sum = recency + suggested + searchRank; 77 | if (sum !== 0) return sum; 78 | 79 | // Locale compare. 80 | return bLC.localeCompare(aLC); 81 | }; 82 | } 83 | 84 | /** 85 | * Changes the search query. 86 | * @param query The search query. 87 | */ 88 | public search(query: string): void { 89 | this.searchQuery = query; 90 | 91 | if (query === '') { 92 | this.resetSearchResults(); 93 | } else { 94 | const search = prepareFuzzySearch(query.trim().toLowerCase()); 95 | this.calculateSearchResults((icon) => search(icon.searchId)); 96 | } 97 | 98 | this.display(); 99 | } 100 | 101 | /** 102 | * Updatse the search results list. 103 | * @param search The search function. 104 | */ 105 | protected calculateSearchResults(search: (icon: IconForSearch) => SearchResult | null): void { 106 | this.searchResults = this.allIcons.filter((icon) => { 107 | icon.searchResult = search(icon); 108 | return icon.searchResult != null; 109 | }); 110 | 111 | this.searchResults.sort(this.compareIcons); 112 | this.previewLimitOverage = this.searchResults.splice(this.previewLimit).length; 113 | } 114 | 115 | /** 116 | * Resets the search results list to show a default list of suggested icons. 117 | */ 118 | protected resetSearchResults(): void { 119 | const { allIcons, previewLimit, usedIcons } = this; 120 | this.searchResults = Array.from( 121 | new Set([ 122 | ...allIcons.slice(0, previewLimit), 123 | ...Array.from(usedIcons.values()).slice(0, previewLimit), 124 | ]).values(), 125 | ); 126 | 127 | // Sort. 128 | this.searchResults.sort(this.compareIcons).slice(0, this.previewLimit); 129 | this.previewLimitOverage = this.allIcons.length - this.searchResults.length; 130 | } 131 | 132 | /** @override */ 133 | public display(): void { 134 | const { containerEl } = this; 135 | 136 | // Add the icons. 137 | const gridEl = document.createDocumentFragment().createDiv({ cls: 'calloutmanager-icon-picker' }); 138 | for (const icon of this.searchResults) { 139 | if (icon.component == null) { 140 | icon.component = new IconPreviewComponent(gridEl).setIcon(icon.id).componentEl; 141 | } else { 142 | gridEl.appendChild(icon.component); 143 | } 144 | } 145 | 146 | // Add a delegated click listener. 147 | gridEl.addEventListener('click', ({ targetNode }) => { 148 | for (; targetNode != null && targetNode !== gridEl; targetNode = targetNode.parentElement) { 149 | if (!(targetNode instanceof HTMLElement)) continue; 150 | const iconId = targetNode.getAttribute('data-icon-id'); 151 | if (iconId != null) { 152 | recentIcons.add(iconId); 153 | this.nav.close(); 154 | this.onChoose(iconId); 155 | return; 156 | } 157 | } 158 | }); 159 | 160 | // Clear the container and re-render. 161 | containerEl.empty(); 162 | containerEl.appendChild(gridEl); 163 | 164 | // Add a message if there are too many icons to display. 165 | const { previewLimitOverage } = this; 166 | if (previewLimitOverage > 0) { 167 | const { pluralIs, pluralIcon } = 168 | previewLimitOverage === 1 169 | ? { pluralIs: 'is', pluralIcon: 'icon' } 170 | : { pluralIs: 'are', pluralIcon: 'icons' }; 171 | containerEl.createEl('p', { 172 | text: 173 | `There ${pluralIs} ${previewLimitOverage} more ${pluralIcon} to show. ` + 174 | `Refine your search to see more.`, 175 | }); 176 | } 177 | } 178 | 179 | /** @override */ 180 | public displayControls(): void { 181 | const { controlsEl } = this; 182 | 183 | new TextComponent(controlsEl) 184 | .setValue(this.searchQuery) 185 | .setPlaceholder('Search icons...') 186 | .onChange(this.search.bind(this)); 187 | } 188 | 189 | /** @override */ 190 | protected onReady(): void { 191 | this.resetSearchResults(); 192 | } 193 | } 194 | 195 | interface IconForSearch { 196 | id: string; 197 | searchId: string; 198 | component: HTMLElement | null; 199 | searchResult: SearchResult | null; 200 | } 201 | 202 | // --------------------------------------------------------------------------------------------------------------------- 203 | // Styles: 204 | // --------------------------------------------------------------------------------------------------------------------- 205 | 206 | declare const STYLES: ` 207 | :root { 208 | --calloutmanager-icon-picker-size: 100px; 209 | --calloutmanager-icon-picker-gap: 8px; 210 | --calloutmanager-icon-picker-icon-size: 2.5em; 211 | --calloutmanager-icon-picker-id-size: 0.75em; 212 | } 213 | 214 | .calloutmanager-icon-picker { 215 | display: grid; 216 | 217 | grid-template-columns: repeat(auto-fill, var(--calloutmanager-icon-picker-size)); 218 | grid-auto-rows: var(--calloutmanager-icon-picker-size); 219 | gap: var(--calloutmanager-icon-picker-gap); 220 | 221 | justify-content: center; 222 | 223 | .calloutmanager-icon-preview { 224 | height: 100%; 225 | 226 | --calloutmanager-icon-preview-icon-size: var(--calloutmanager-icon-picker-icon-size); 227 | --calloutmanager-icon-preview-id-size: var(--calloutmanager-icon-picker-id-size); 228 | } 229 | } 230 | `; 231 | -------------------------------------------------------------------------------- /src/search/search.ts: -------------------------------------------------------------------------------- 1 | import { BitField } from './bitfield'; 2 | import { SearchCondition } from './condition'; 3 | import { SearchEffect } from './effect'; 4 | import { ColumnDescription, ReadonlySearchIndex, SearchIndex } from './search-index'; 5 | 6 | /** 7 | * An item that exists within a search. 8 | * This contains the item itself, along with associated data. 9 | */ 10 | type SearchItem = { 11 | readonly value: Readonly; 12 | readonly mask: BitField; 13 | score: number; 14 | }; 15 | 16 | export interface SearchOptions { 17 | /** 18 | * If `true`, resetting the search will reinitialize the search results to include every item. 19 | * This is useful when you want to treat the search like a filter operation. 20 | */ 21 | resetToAll?: boolean; 22 | 23 | /** 24 | * If `false`, search score will not influence the order in which results are returned. 25 | */ 26 | resultRanking?: boolean; 27 | 28 | /** 29 | * A comparison function that will be used to sort results with equal score. 30 | * If not provided, results with equal score will not be sorted. 31 | */ 32 | compareItem?: (a: T, b: T) => number; 33 | } 34 | 35 | interface ConstructorOptions extends SearchOptions { 36 | /** 37 | * A list of columns that will be indexed. 38 | */ 39 | columns: Record; 40 | 41 | /** 42 | * A function that adds an item to the index. 43 | * 44 | * @param item The item to be indexed. 45 | * @param index The index to which the item's properties will be added. 46 | * @returns The mask for the item. 47 | */ 48 | indexItem: (item: Readonly, index: SearchIndex) => BitField; 49 | } 50 | 51 | /** 52 | * A wrapper around a {@link SearchIndex} that provides a simple search-oriented API with result sorting and 53 | * support for executing multiple conditions. 54 | */ 55 | export class Search implements SealedSearch { 56 | private readonly indexedItems: SearchItem[]; 57 | public readonly index: SearchIndex; 58 | 59 | private currentMask: BitField; 60 | private currentResults: null | ReadonlyArray; 61 | private currentScores: Float32Array; 62 | 63 | private reusableCurrentScoresBuffer: Float32Array; 64 | private resetToAll: boolean; 65 | 66 | private fnIndex: (item: T, index: SearchIndex) => BitField; 67 | private fnCompare: (a: SearchItem, b: SearchItem) => number; 68 | 69 | public constructor(options: ConstructorOptions) { 70 | this.resetToAll = options?.resetToAll ?? false; 71 | this.index = new SearchIndex(options.columns); 72 | this.indexedItems = []; 73 | 74 | // Save functions. 75 | const suppliedFnCompare = options.compareItem ?? ((a, b) => 0); 76 | this.fnIndex = options.indexItem; 77 | 78 | // Initial values. 79 | this.currentMask = 0n as BitField; 80 | this.currentResults = null; 81 | this.currentScores = new Float32Array(0); 82 | this.reusableCurrentScoresBuffer = new Float32Array(0); 83 | 84 | // Create the compare function. 85 | this.fnCompare = 86 | (options.resultRanking ?? true) === false 87 | ? (a, b) => suppliedFnCompare(a.value, b.value) 88 | : (a, b) => { 89 | const delta = b.score - a.score; // Sort higher to the top. 90 | if (delta !== 0) return delta; 91 | return suppliedFnCompare(b.value, a.value); 92 | }; 93 | } 94 | 95 | /** 96 | * Adds items to the search. 97 | * This will index the items. 98 | * 99 | * @param items The items to add. 100 | */ 101 | public addItems(items: ReadonlyArray>): void { 102 | const { index, indexedItems } = this; 103 | 104 | // Index the items. 105 | for (const item of items) { 106 | const mask = this.fnIndex(item, index); 107 | indexedItems.push({ 108 | value: item, 109 | mask, 110 | score: 0, 111 | }); 112 | } 113 | 114 | // Resize the score arrays if needed. 115 | const { size: newLength } = index; 116 | if (newLength > this.currentScores.length) { 117 | const { currentScores: oldScore } = this; 118 | this.reusableCurrentScoresBuffer = new Float32Array(newLength); 119 | this.currentScores = new Float32Array(newLength); 120 | this.currentScores.set(oldScore, 0); 121 | } 122 | } 123 | 124 | public reset(): void { 125 | const { index } = this; 126 | this.currentMask = this.resetToAll ? index.bitfield : (0n as BitField); 127 | this.currentResults = null; 128 | this.currentScores = new Float32Array(index.size); 129 | } 130 | 131 | public search( 132 | property: Columns, 133 | condition: SearchCondition, 134 | text: string, 135 | effect: SearchEffect, 136 | weight?: number, 137 | ): void { 138 | const newScores = this.reusableCurrentScoresBuffer.fill(0); 139 | this.currentResults = null; 140 | 141 | // Run the search. 142 | const column = this.index.column(property); 143 | const searchResult = condition(column, column.normalize(text), newScores); 144 | 145 | // Adjust the search mask. 146 | this.currentMask = effect(this.currentMask, searchResult); 147 | 148 | // Adjust the search scores. 149 | const scoreWeight = weight ?? 1; 150 | const scores = this.currentScores; 151 | for (let i = 0, end = scores.length; i < end; i++) { 152 | scores[i] += newScores[i] * scoreWeight; 153 | } 154 | } 155 | 156 | public get results(): ReadonlyArray> { 157 | const cached = this.currentResults; 158 | if (cached != null) return cached; 159 | 160 | // Filter all the items to only include the ones that were selected. 161 | const { currentMask, currentScores, fnCompare } = this; 162 | const results = this.indexedItems.filter(({ mask }) => (currentMask & mask) > 0n); 163 | 164 | // Add the scores to the items. 165 | results.forEach((item) => { 166 | let mask = item.mask as bigint; 167 | let score = 0; 168 | 169 | for (let index = 0; mask > 0n; index++, mask >>= 1n) { 170 | if ((mask & 1n) !== 0n) score += currentScores[index]; 171 | } 172 | 173 | item.score = score; 174 | }); 175 | 176 | // Sort the results. 177 | results.sort(fnCompare); 178 | 179 | // Return the results. 180 | return (this.currentResults = results.map(({ value }) => value)); 181 | } 182 | } 183 | 184 | /** 185 | * A {@link Search} class where no more items can be added. 186 | */ 187 | export interface SealedSearch { 188 | /** 189 | * The search index. 190 | */ 191 | readonly index: ReadonlySearchIndex; 192 | 193 | /** 194 | * Resets the current search. 195 | */ 196 | reset(): void; 197 | 198 | /** 199 | * Runs a search operation. 200 | * 201 | * @param property The indexed property to search against. 202 | * @param condition The search condition. 203 | * @param text The text to search with. 204 | * @param effect How the search should affect the search mask. 205 | * @param weight How heavily the results should weigh on the result sorting. 206 | */ 207 | search(property: Columns, condition: SearchCondition, text: string, effect: SearchEffect, weight?: number): void; 208 | 209 | /** 210 | * Gets the combined results from all the search operations made since the last {@link reset}. 211 | */ 212 | get results(): ReadonlyArray>; 213 | } 214 | 215 | /** 216 | * Extracts the columns from a {@link Search} or {@link SealedSearch}. 217 | */ 218 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 219 | export type SearchColumns> = T extends SealedSearch ? C : never; 220 | 221 | /** 222 | * Extracts the values from a {@link Search} or {@link SealedSearch}. 223 | */ 224 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 225 | export type SearchValues> = T extends SealedSearch ? V : never; 226 | -------------------------------------------------------------------------------- /src/panes/manage-plugin-pane.ts: -------------------------------------------------------------------------------- 1 | import { ButtonComponent, Setting } from 'obsidian'; 2 | 3 | import CalloutManagerPlugin from '&plugin'; 4 | 5 | import { UIPane } from '&ui/pane'; 6 | 7 | import { getSections } from '../changelog'; 8 | 9 | import { ChangelogPane } from './changelog-pane'; 10 | import { ManageCalloutsPane } from './manage-callouts-pane'; 11 | 12 | export class ManagePluginPane extends UIPane { 13 | public readonly title = 'Callout Manager Settings'; 14 | private plugin: CalloutManagerPlugin; 15 | 16 | public constructor(plugin: CalloutManagerPlugin) { 17 | super(); 18 | this.plugin = plugin; 19 | } 20 | 21 | /** @override */ 22 | public display(): void { 23 | const { containerEl, plugin } = this; 24 | 25 | // ----------------------------------------------------------------------------------------------------- 26 | // Navigation. 27 | // ----------------------------------------------------------------------------------------------------- 28 | new Setting(containerEl) 29 | .setName('Manage Callouts') 30 | .setDesc('Create or edit Markdown callouts.') 31 | .addButton((btn) => { 32 | btn.setButtonText('Manage Callouts'); 33 | btn.onClick(() => this.nav.open(new ManageCalloutsPane(plugin))); 34 | }); 35 | 36 | // ----------------------------------------------------------------------------------------------------- 37 | // Section: Callout Detection 38 | // ----------------------------------------------------------------------------------------------------- 39 | new Setting(containerEl).setHeading().setName('Callout Detection'); 40 | 41 | new Setting(containerEl) 42 | .setName('Obsidian') 43 | .setDesc( 44 | (() => { 45 | const desc = document.createDocumentFragment(); 46 | const container = desc.createDiv(); 47 | const method = plugin.cssWatcher.describeObsidianFetchMethod(); 48 | 49 | container.createDiv({ 50 | text: `Find built-in Obsidian callouts${method === '' ? '' : ' '}${method}.`, 51 | }); 52 | 53 | return desc; 54 | })(), 55 | ) 56 | .addToggle((setting) => { 57 | setting.setValue(plugin.settings.calloutDetection.obsidian).onChange((v) => { 58 | plugin.settings.calloutDetection.obsidian = v; 59 | plugin.saveSettings(); 60 | plugin.refreshCalloutSources(); 61 | }); 62 | }); 63 | 64 | new Setting(containerEl) 65 | .setName('Theme') 66 | .setDesc('Find theme-provided callouts.') 67 | .addToggle((setting) => { 68 | setting.setValue(plugin.settings.calloutDetection.theme).onChange((v) => { 69 | plugin.settings.calloutDetection.theme = v; 70 | plugin.saveSettings(); 71 | plugin.refreshCalloutSources(); 72 | }); 73 | }); 74 | 75 | new Setting(containerEl) 76 | .setName('Snippet') 77 | .setDesc('Find callouts in custom CSS snippets.') 78 | .addToggle((setting) => { 79 | setting.setValue(plugin.settings.calloutDetection.snippet).onChange((v) => { 80 | plugin.settings.calloutDetection.snippet = v; 81 | plugin.saveSettings(); 82 | plugin.refreshCalloutSources(); 83 | }); 84 | }); 85 | 86 | // ----------------------------------------------------------------------------------------------------- 87 | // Section: Changelog 88 | // ----------------------------------------------------------------------------------------------------- 89 | new Setting(containerEl) 90 | .setHeading() 91 | .setName("What's New") 92 | .setDesc(`Version ${this.plugin.manifest.version}`) 93 | .addExtraButton((btn) => { 94 | btn.setIcon('lucide-more-horizontal') 95 | .setTooltip('More Changelogs') 96 | .onClick(() => this.nav.open(new ChangelogPane(plugin))); 97 | }); 98 | 99 | const latestChanges = getSections(this.root).get(this.plugin.manifest.version); 100 | if (latestChanges != null) { 101 | const desc = document.createDocumentFragment(); 102 | desc.appendChild(latestChanges.contentsEl); 103 | 104 | new Setting(containerEl) 105 | .setDesc(desc) 106 | .then((setting) => setting.controlEl.remove()) 107 | .then((setting) => setting.settingEl.classList.add('calloutmanager-latest-changes')); 108 | } 109 | 110 | // ----------------------------------------------------------------------------------------------------- 111 | // Section: Export 112 | // ----------------------------------------------------------------------------------------------------- 113 | new Setting(containerEl).setHeading().setName('Export'); 114 | 115 | new Setting(containerEl) 116 | .setName('Callout Styles') 117 | .setDesc('Export your custom callouts and changes as CSS.') 118 | .addButton((btn) => { 119 | btn.setButtonText('Copy'); 120 | btn.onClick(async () => { 121 | btn.setDisabled(true); 122 | 123 | try { 124 | await navigator.clipboard.writeText('/* Exported Styles from Obsidian Callout Manager */\n' + this.plugin.cssApplier.css) 125 | btn.setButtonText("Copied!"); 126 | } catch (ex) { 127 | btn.setButtonText("Error"); 128 | } 129 | }); 130 | }); 131 | 132 | // ----------------------------------------------------------------------------------------------------- 133 | // Section: Reset 134 | // ----------------------------------------------------------------------------------------------------- 135 | new Setting(containerEl).setHeading().setName('Reset'); 136 | 137 | new Setting(containerEl) 138 | .setName('Reset Callout Settings') 139 | .setDesc('Reset all the changes you made to callouts.') 140 | .addButton( 141 | withConfirm((btn) => { 142 | btn.setButtonText('Reset').onClick(() => { 143 | this.plugin.settings.callouts.settings = {}; 144 | this.plugin.saveSettings(); 145 | 146 | // Regenerate the callout styles. 147 | this.plugin.applyStyles(); 148 | btn.setButtonText('Reset').setDisabled(true); 149 | }); 150 | }), 151 | ); 152 | 153 | new Setting(containerEl) 154 | .setName('Reset Custom Callouts') 155 | .setDesc('Removes all the custom callouts you created.') 156 | .addButton( 157 | withConfirm((btn) => { 158 | btn.setButtonText('Reset').onClick(() => { 159 | // Remove the stylings for the custom callouts. 160 | const { settings } = this.plugin; 161 | for (const custom of settings.callouts.custom) { 162 | delete settings.callouts.settings[custom]; 163 | } 164 | 165 | // Remove the custom callouts. 166 | settings.callouts.custom = []; 167 | this.plugin.saveSettings(); 168 | 169 | // Regenerate the callout styles. 170 | this.plugin.callouts.custom.clear(); 171 | this.plugin.applyStyles(); 172 | 173 | // Regenerate the cache. 174 | this.plugin.refreshCalloutSources(); 175 | btn.setButtonText('Reset').setDisabled(true); 176 | }); 177 | }), 178 | ); 179 | } 180 | } 181 | 182 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 183 | function withConfirm(callback: (btn: ButtonComponent) => any): (btn: ButtonComponent) => any { 184 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 185 | let onClickHandler: undefined | ((...args: any[]) => any) = undefined; 186 | let resetButtonClicked = false; 187 | 188 | return (btn) => { 189 | btn.setWarning().onClick(() => { 190 | if (!resetButtonClicked) { 191 | resetButtonClicked = true; 192 | btn.setButtonText('Confirm'); 193 | return; 194 | } 195 | 196 | if (onClickHandler != undefined) { 197 | onClickHandler(); 198 | } 199 | }); 200 | 201 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 202 | btn.onClick = (handler: (...args: any[]) => any) => { 203 | onClickHandler = handler; 204 | return btn; 205 | }; 206 | 207 | // Call the callback. 208 | callback(btn); 209 | }; 210 | } 211 | 212 | declare const STYLES: ` 213 | .calloutmanager-latest-changes { 214 | padding: 0.75em 0; 215 | border-top: 1px solid var(--background-modifier-border); 216 | 217 | .calloutmanager-changelog-section { 218 | > :first-child { margin-top: 0; } 219 | > :last-child { margin-bottom: 0; } 220 | } 221 | 222 | .callout { 223 | background: none; 224 | } 225 | } 226 | `; 227 | -------------------------------------------------------------------------------- /src/panes/edit-callout-pane/index.ts: -------------------------------------------------------------------------------- 1 | import { ButtonComponent } from 'obsidian'; 2 | 3 | import { Callout, CalloutID } from '&callout'; 4 | import { CalloutSettings } from '&callout-settings'; 5 | import CalloutManagerPlugin from '&plugin'; 6 | 7 | import { UIPane } from '&ui/pane'; 8 | 9 | import { AppearanceEditor } from './appearance-editor'; 10 | import { Appearance, determineAppearanceType } from './appearance-type'; 11 | import ComplexAppearanceEditor from './editor-complex'; 12 | import PerSchemeAppearanceEditor from './editor-per-scheme'; 13 | import UnifiedAppearanceEditor from './editor-unified'; 14 | import { MiscEditor } from './misc-editor'; 15 | import { renderInfo } from './section-info'; 16 | import { EditCalloutPanePreview } from './section-preview'; 17 | 18 | const IMPOSSIBLE_CALLOUT_ID = '[not a real callout]'; 19 | 20 | export class EditCalloutPane extends UIPane { 21 | public readonly title; 22 | private readonly viewOnly: boolean; 23 | private readonly plugin: CalloutManagerPlugin; 24 | 25 | private callout: Callout; 26 | 27 | private previewSection: EditCalloutPanePreview; 28 | private appearanceEditorContainerEl: HTMLElement; 29 | private appearanceEditorEl: HTMLElement; 30 | private appearanceEditor!: AppearanceEditor; 31 | private miscEditor: MiscEditor; 32 | private miscEditorContainerEl: HTMLElement; 33 | private appearance!: Appearance; 34 | 35 | public constructor(plugin: CalloutManagerPlugin, id: CalloutID, viewOnly: boolean) { 36 | super(); 37 | this.plugin = plugin; 38 | this.viewOnly = viewOnly; 39 | this.title = { title: 'Callout', subtitle: id }; 40 | 41 | // Get the callout information. 42 | this.callout = plugin.callouts.get(id) ?? { 43 | sources: [{ type: 'custom' }], 44 | ...plugin.calloutResolver.getCalloutProperties(IMPOSSIBLE_CALLOUT_ID), 45 | id, 46 | }; 47 | 48 | // Create the preview. 49 | this.previewSection = new EditCalloutPanePreview(plugin, this.callout, false); 50 | 51 | // Create the misc editor. 52 | this.miscEditorContainerEl = document.createElement('div'); 53 | this.miscEditorContainerEl.classList.add( 54 | 'calloutmanager-edit-callout-section', 55 | 'calloutmanager-edit-callout-section--noborder', 56 | 'calloutmanager-edit-callout-misc', 57 | ); 58 | 59 | this.miscEditor = new MiscEditor(plugin, this.callout, this.miscEditorContainerEl, viewOnly); 60 | Object.defineProperty(this.miscEditor, 'nav', { 61 | get: () => this.nav, 62 | }); 63 | 64 | // Create the appearance editor. 65 | this.appearanceEditorContainerEl = document.createElement('div'); 66 | this.appearanceEditorContainerEl.classList.add( 67 | 'calloutmanager-edit-callout-section', 68 | 'calloutmanager-edit-callout-appearance', 69 | ); 70 | 71 | this.appearanceEditorContainerEl.createEl('h2', { text: 'Appearance' }); 72 | this.appearanceEditorEl = this.appearanceEditorContainerEl.createDiv(); 73 | 74 | this.changeSettings(plugin.getCalloutSettings(id) ?? []); 75 | } 76 | 77 | protected changeAppearanceEditor(newAppearance: Appearance) { 78 | const oldAppearance = this.appearance; 79 | this.appearance = newAppearance; 80 | 81 | if (newAppearance.type !== oldAppearance?.type) { 82 | this.appearanceEditor = new APPEARANCE_EDITORS[newAppearance.type](); 83 | 84 | Object.defineProperties(this.appearanceEditor, { 85 | nav: { get: () => this.nav }, 86 | plugin: { value: this.plugin }, 87 | containerEl: { value: this.appearanceEditorEl }, 88 | setAppearance: { value: this.onSetAppearance.bind(this) }, 89 | }); 90 | } 91 | 92 | const { appearanceEditor } = this; 93 | appearanceEditor.appearance = newAppearance; 94 | appearanceEditor.callout = this.callout; 95 | } 96 | 97 | protected onSetAppearance(appearance: Appearance) { 98 | this.changeAppearanceEditor(appearance); 99 | const newSettings = this.appearanceEditor.toSettings(); 100 | const { callout } = this; 101 | const { calloutResolver } = this.plugin; 102 | 103 | // Update the plugin settings. 104 | this.plugin.setCalloutSettings(callout.id, newSettings); 105 | 106 | // Update the callout properties. 107 | const { color, icon } = calloutResolver.getCalloutProperties(callout.id); 108 | callout.color = color; 109 | callout.icon = icon; 110 | 111 | // Rerender to show what changed. 112 | this.previewSection.changeSettings(newSettings); 113 | 114 | this.appearanceEditor.callout = callout; 115 | this.appearanceEditorEl.empty(); 116 | this.appearanceEditor.render(); 117 | 118 | this.containerEl.empty(); 119 | this.display(); 120 | } 121 | 122 | /** @override */ 123 | public display(): void { 124 | const { containerEl, previewSection, appearanceEditorContainerEl, miscEditorContainerEl } = this; 125 | 126 | containerEl.empty(); 127 | previewSection.attach(containerEl); 128 | renderInfo(this.plugin.app, this.callout, containerEl); 129 | containerEl.appendChild(miscEditorContainerEl); 130 | containerEl.appendChild(appearanceEditorContainerEl); 131 | } 132 | 133 | /** @override */ 134 | public displayControls(): void { 135 | const { callout, controlsEl } = this; 136 | 137 | // Delete button. 138 | if (!this.viewOnly && callout.sources.length === 1 && callout.sources[0].type === 'custom') { 139 | new ButtonComponent(controlsEl) 140 | .setIcon('lucide-trash') 141 | .setTooltip('Delete Callout') 142 | .onClick(() => { 143 | this.plugin.removeCustomCallout(callout.id); 144 | this.nav.close(); 145 | }) 146 | .then(({ buttonEl }) => buttonEl.classList.add('clickable-icon', 'mod-warning')); 147 | } 148 | } 149 | 150 | /** 151 | * Changes the preview that is displayed inside the callout. 152 | * 153 | * @param markdown The markdown to render. 154 | */ 155 | public async changePreview(markdown: string): Promise { 156 | return this.previewSection.changeContent(markdown); 157 | } 158 | 159 | /** 160 | * Changes the styles of the preview that is displayed inside the callout. 161 | * 162 | * @param markdown The markdown to render. 163 | */ 164 | public async changeSettings(settings: CalloutSettings): Promise { 165 | this.changeAppearanceEditor(determineAppearanceType(settings)); 166 | this.appearanceEditorEl.empty(); 167 | this.appearanceEditor.render(); 168 | this.miscEditor.render(); 169 | 170 | await this.previewSection.changeSettings(settings); 171 | } 172 | } 173 | 174 | const APPEARANCE_EDITORS: Record }> = { 175 | complex: ComplexAppearanceEditor, 176 | unified: UnifiedAppearanceEditor, 177 | 'per-scheme': PerSchemeAppearanceEditor, 178 | }; 179 | 180 | declare const STYLES: ` 181 | // Sections of the pane. 182 | .calloutmanager-edit-callout-section { 183 | border-top: 1px solid var(--background-modifier-border); 184 | padding-top: var(--size-4-3); 185 | padding-bottom: var(--size-4-6); 186 | } 187 | 188 | .calloutmanager-edit-callout-section:empty { 189 | display: none; 190 | } 191 | 192 | .calloutmanager-edit-callout-section--noborder { 193 | border-top: none; 194 | } 195 | 196 | .calloutmanager-edit-callout-section .setting-item { 197 | .setting-item-description p { 198 | margin: 0; 199 | } 200 | } 201 | 202 | .calloutmanager-edit-callout-section h2 { 203 | margin-bottom: 0.3em; 204 | & + p { 205 | margin-top: 0; 206 | } 207 | } 208 | 209 | .calloutmanager-edit-callout-appearance { 210 | .setting-item { 211 | border-top: none; 212 | padding-top: 0.375em; 213 | } 214 | 215 | .setting-item:has(+ .setting-item) { 216 | padding-bottom: 0.375em; 217 | 218 | body.is-phone & { 219 | margin-bottom: 0.7em; 220 | } 221 | } 222 | 223 | .setting-item + .setting-item { 224 | } 225 | } 226 | 227 | // The preview showing the complex callout setting JSON. 228 | .calloutmanager-edit-callout-appearance-json pre { 229 | border: rgba(var(--background-modifier-border)) 1px solid; 230 | border-radius: var(--callout-radius); 231 | padding: var(--size-4-2); 232 | background: var(--background-primary-alt); 233 | overflow-x: auto; 234 | 235 | margin: 0; 236 | } 237 | 238 | // The reset button. 239 | .calloutmanager-edit-callout-appearance-reset { 240 | width: 100%; 241 | } 242 | `; 243 | -------------------------------------------------------------------------------- /src/util/color.ts: -------------------------------------------------------------------------------- 1 | // --------------------------------------------------------------------------------------------------------------------- 2 | // Color Types: 3 | // --------------------------------------------------------------------------------------------------------------------- 4 | 5 | /** 6 | * A color in 8-bit RGB color space. 7 | * Each color component is between 0 and 255. 8 | */ 9 | export interface RGB { 10 | r: number; 11 | g: number; 12 | b: number; 13 | } 14 | 15 | /** 16 | * A color in 8-bit RGB color space with an alpha channel. 17 | * The alpha component is between 0 and 255. 18 | * 19 | * @see RGB 20 | */ 21 | export interface RGBA extends RGB { 22 | a: number; 23 | } 24 | 25 | /** 26 | * A color in hue-saturation-value color space. 27 | */ 28 | export interface HSV { 29 | /** 30 | * Hue. 31 | * Range: `0-359` 32 | */ 33 | h: number; 34 | 35 | /** 36 | * Saturation. 37 | * Range: `0-100` 38 | */ 39 | s: number; 40 | 41 | /** 42 | * Value. 43 | * Range: `0-100` 44 | */ 45 | v: number; 46 | } 47 | 48 | /** 49 | * A color in hue-saturation-value color space with an alpha channel. 50 | * 51 | * @see HSV 52 | */ 53 | export interface HSVA extends HSV { 54 | a: number; 55 | } 56 | 57 | // --------------------------------------------------------------------------------------------------------------------- 58 | // Color Conversion: 59 | // --------------------------------------------------------------------------------------------------------------------- 60 | 61 | /** 62 | * Converts a color to HSV(A). 63 | * 64 | * @param color The color to convert. 65 | * @returns The color in HSV color space. 66 | */ 67 | export function toHSV(color: RGB | RGBA | HSV | HSVA): HSV | HSVA { 68 | if ('h' in color && 's' in color && 'v' in color) return color; 69 | 70 | const rFloat = color.r / 255; 71 | const gFloat = color.g / 255; 72 | const bFloat = color.b / 255; 73 | 74 | const cmax = Math.max(rFloat, gFloat, bFloat); 75 | const cmin = Math.min(rFloat, gFloat, bFloat); 76 | const delta = cmax - cmin; 77 | 78 | let h = 0; 79 | if (cmax !== cmin) { 80 | switch (cmax) { 81 | case rFloat: 82 | h = (60 * ((gFloat - bFloat) / delta) + 360) % 360; 83 | break; 84 | case gFloat: 85 | h = (60 * ((bFloat - rFloat) / delta) + 120) % 360; 86 | break; 87 | case bFloat: 88 | h = (60 * ((rFloat - gFloat) / delta) + 240) % 360; 89 | break; 90 | } 91 | } 92 | 93 | const s = cmax === 0 ? 0 : (delta / cmax) * 100; 94 | const v = cmax * 100; 95 | 96 | const hsv: HSV | HSVA = { h, s, v }; 97 | if ('a' in color) { 98 | (hsv as HSVA).a = (((color as RGBA | HSVA).a as number) / 255) * 100; 99 | } 100 | 101 | return hsv; 102 | } 103 | 104 | export function toHexRGB(color: RGB | RGBA): string { 105 | const parts = [color.r, color.g, color.b, ...('a' in color ? [color.a] : [])]; 106 | return parts.map((c) => c.toString(16).padStart(2, '0')).join(''); 107 | } 108 | 109 | // --------------------------------------------------------------------------------------------------------------------- 110 | // Color Parsing: 111 | // --------------------------------------------------------------------------------------------------------------------- 112 | const REGEX_RGB = /^\s*rgba?\(\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?\s*)\)\s*$/i; 113 | const REGEX_RGBA = /^\s*rgba\(\s*([\d.]+%?)\s*,\s*([\d.]+%?)\s*,\s*([\d.]+%?)\s*,\s*([\d.]+%?)\s*\)\s*$/i; 114 | const REGEX_HEX = /^\s*#([\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})\s*$/i; 115 | 116 | /** 117 | * Parses a CSS color into RGB(A) components. 118 | * This does not support other color formats than RGB (e.g. HSV). 119 | * 120 | * @param color The color string. 121 | * @returns The color RGB(A), or null if not valid. 122 | */ 123 | export function parseColor(color: string): RGB | RGBA | null { 124 | const trimmed = color.trim(); 125 | if (trimmed.startsWith('#')) { 126 | return parseColorHex(color); 127 | } 128 | 129 | return parseColorRGBA(color); 130 | } 131 | 132 | /** 133 | * Parses a `rgb()` CSS color into RGB components. 134 | * 135 | * @param color The color string. 136 | * @returns The color RGB, or null if not valid. 137 | */ 138 | export function parseColorRGB(rgb: string): RGB | null { 139 | const matches = REGEX_RGB.exec(rgb); 140 | if (matches === null) return null; 141 | 142 | const components = matches.slice(1).map((v) => v.trim()) as [string, string, string]; 143 | const rgbComponents = rgbComponentStringsToNumber(components); 144 | if (rgbComponents === null) { 145 | return null; 146 | } 147 | 148 | // Validate. 149 | if (undefined !== rgbComponents.find((v) => isNaN(v) || v < 0 || v > 0xff)) { 150 | return null; 151 | } 152 | 153 | // Parsed. 154 | return { 155 | r: rgbComponents[0], 156 | g: rgbComponents[1], 157 | b: rgbComponents[2], 158 | }; 159 | } 160 | 161 | /** 162 | * Parses a `rgba()` CSS color into RGBA components. 163 | * 164 | * @param color The color string. 165 | * @returns The color RGBA, or null if not valid. 166 | */ 167 | export function parseColorRGBA(rgba: string): RGBA | null { 168 | const asRGB = parseColorRGB(rgba) as RGBA | null; 169 | if (asRGB != null) { 170 | asRGB.a = 255; 171 | return asRGB; 172 | } 173 | 174 | // As RGBA. 175 | const matches = REGEX_RGBA.exec(rgba); 176 | if (matches === null) return null; 177 | 178 | const components = matches.slice(1).map((v) => v.trim()) as [string, string, string, string]; 179 | const rgbComponents = rgbComponentStringsToNumber(components.slice(0, 3) as [string, string, string]); 180 | if (rgbComponents === null) { 181 | return null; 182 | } 183 | 184 | // Parse the alpha channel. 185 | let alphaComponent = 255; 186 | const alphaString = components[3]; 187 | if (alphaString != null) { 188 | if (alphaString.endsWith('%')) { 189 | alphaComponent = Math.floor((parseFloat(alphaString.substring(0, alphaString.length - 1)) * 255) / 100); 190 | } else { 191 | alphaComponent = Math.floor(parseFloat(alphaString) * 255); 192 | } 193 | } 194 | 195 | // Validate. 196 | const allComponents = [...rgbComponents, alphaComponent]; 197 | if (undefined !== allComponents.find((v) => isNaN(v) || v < 0 || v > 0xff)) { 198 | return null; 199 | } 200 | 201 | // Parsed. 202 | return { 203 | r: allComponents[0], 204 | g: allComponents[1], 205 | b: allComponents[2], 206 | a: allComponents[3], 207 | }; 208 | } 209 | 210 | /** 211 | * Parses a `#hex` CSS color into RGB(A) components. 212 | * 213 | * @param color The color string. 214 | * @returns The color RGB(A), or null if not valid. 215 | */ 216 | export function parseColorHex(hex: string): RGB | RGBA | null { 217 | const matches = REGEX_HEX.exec(hex); 218 | if (matches === null) return null; 219 | 220 | const hexString = matches[1]; 221 | let hexDigits; 222 | if (hexString.length < 6) hexDigits = hexString.split('').map((c) => `${c}${c}`); 223 | else { 224 | hexDigits = [hexString.slice(0, 2), hexString.slice(2, 4), hexString.slice(4, 6), hexString.slice(6, 8)].filter( 225 | (v) => v != '', 226 | ); 227 | } 228 | 229 | const hexComponents = hexDigits.map((v) => parseInt(v, 16)); 230 | 231 | // Validate. 232 | if (undefined !== hexComponents.find((v) => isNaN(v) || v < 0 || v > 0xff)) { 233 | return null; 234 | } 235 | 236 | // Return RGB object. 237 | const hexRGB: RGB | RGBA = { 238 | r: hexComponents[0], 239 | g: hexComponents[1], 240 | b: hexComponents[2], 241 | }; 242 | 243 | if (hexComponents.length > 3) { 244 | (hexRGB as RGBA).a = hexComponents[3]; 245 | } 246 | 247 | return hexRGB; 248 | } 249 | 250 | function rgbComponentStringsToNumber(components: [string, string, string]): [number, number, number] | null { 251 | // Percentage. 252 | if (components[0].endsWith('%')) { 253 | if (undefined !== components.slice(1, 3).find((c) => !c.endsWith('%'))) { 254 | return null; 255 | } 256 | 257 | return components 258 | .map((v) => parseFloat(v.substring(0, v.length - 1))) 259 | .map((v) => Math.floor((v * 255) / 100)) as [number, number, number]; 260 | } 261 | 262 | // Integer. 263 | if (undefined !== components.slice(1, 3).find((c) => c.endsWith('%'))) { 264 | return null; 265 | } 266 | 267 | return components.map((v) => parseInt(v, 10)) as [number, number, number]; 268 | } 269 | -------------------------------------------------------------------------------- /src/panes/manage-callouts-pane.ts: -------------------------------------------------------------------------------- 1 | import { ButtonComponent, MarkdownView, TextComponent, getIcon } from 'obsidian'; 2 | 3 | import { Callout } from '&callout'; 4 | import { getColorFromCallout, getTitleFromCallout } from '&callout-util'; 5 | import CalloutManagerPlugin from '&plugin'; 6 | 7 | import { CalloutPreviewComponent } from '&ui/component/callout-preview'; 8 | import { UIPane } from '&ui/pane'; 9 | 10 | import { CalloutSearch, CalloutSearchResult, calloutSearch } from '../callout-search'; 11 | 12 | import { CreateCalloutPane } from './create-callout-pane'; 13 | import { EditCalloutPane } from './edit-callout-pane'; 14 | import { closeSettings } from 'obsidian-extra/unsafe'; 15 | 16 | /** 17 | * The user interface pane for changing Callout Manager settings. 18 | */ 19 | export class ManageCalloutsPane extends UIPane { 20 | public readonly title = { title: 'Callouts', subtitle: 'Manage' }; 21 | private readonly viewOnly: boolean; 22 | private plugin: CalloutManagerPlugin; 23 | 24 | private searchQuery: string; 25 | private searchFn!: CalloutSearch; 26 | private callouts!: ReadonlyArray>; 27 | 28 | private setSearchError: undefined | ((message: string | false) => void); 29 | private searchErrorDiv: HTMLElement; 30 | private searchErrorQuery!: HTMLElement; 31 | 32 | public constructor(plugin: CalloutManagerPlugin) { 33 | super(); 34 | this.plugin = plugin; 35 | this.viewOnly = false; 36 | this.searchQuery = ''; 37 | 38 | const { searchErrorDiv, searchErrorQuery } = createEmptySearchResultDiv(); 39 | this.searchErrorDiv = searchErrorDiv; 40 | this.searchErrorQuery = searchErrorQuery; 41 | } 42 | 43 | /** 44 | * Change the search query and re-render the panel. 45 | * @param query The search query. 46 | */ 47 | public search(query: string): void { 48 | this.doSearch(query); 49 | this.display(); 50 | } 51 | 52 | protected doSearch(query: string): void { 53 | try { 54 | this.callouts = this.searchFn(query); 55 | this.setSearchError?.(false); 56 | } catch (ex) { 57 | this.setSearchError?.((ex as Error).message); 58 | } 59 | } 60 | 61 | /** 62 | * Refresh the callout previews. 63 | * This regenerates the previews and their metadata from the list of callouts known to the plugin. 64 | */ 65 | protected invalidate(): void { 66 | const { plugin, viewOnly } = this; 67 | 68 | this.searchFn = calloutSearch(plugin.callouts.values(), { 69 | preview: createPreviewFactory(viewOnly), 70 | }); 71 | 72 | // Refresh the callout list. 73 | this.doSearch(this.searchQuery); 74 | } 75 | 76 | protected onCalloutButtonClick(evt: MouseEvent) { 77 | let id = null; 78 | let action = null; 79 | for (let target = evt.targetNode; target != null && (id == null || action == null); target = target?.parentElement) { 80 | if (!(target instanceof Element)) continue; 81 | 82 | // Find the callout ID. 83 | if (id == null) { 84 | id = target.getAttribute('data-callout-manager-callout'); 85 | } 86 | 87 | // Find the button action. 88 | if (action == null) { 89 | action = target.getAttribute('data-callout-manager-action'); 90 | } 91 | } 92 | 93 | // Do nothing if neither the callout nor action was found. 94 | if (id == null || action == null) { 95 | return; 96 | } 97 | 98 | // View/edit the selected callout. 99 | if (action === 'edit') { 100 | this.nav.open(new EditCalloutPane(this.plugin, id, this.viewOnly)); 101 | } 102 | 103 | // Insert the selected callout. 104 | else if (action === 'insert') { 105 | const view = app.workspace.getActiveViewOfType(MarkdownView); 106 | 107 | // Make sure the user is editing a Markdown file. 108 | if (view) { 109 | const cursor = view.editor.getCursor(); 110 | view.editor.replaceRange( 111 | `> [!${id}]\n> Contents`, 112 | cursor 113 | ) 114 | view.editor.setCursor(cursor.line + 1, 10) 115 | closeSettings(app) 116 | } 117 | } 118 | } 119 | 120 | /** @override */ 121 | public display(): void { 122 | // Create a content element to render into. 123 | const contentEl = document.createDocumentFragment().createDiv(); 124 | contentEl.addEventListener('click', this.onCalloutButtonClick.bind(this)); 125 | 126 | // Render the previews. 127 | const { callouts } = this; 128 | for (const callout of callouts) { 129 | contentEl.appendChild(callout.preview); 130 | } 131 | 132 | // If no previews, show help instead. 133 | if (callouts.length === 0) { 134 | contentEl.appendChild(this.searchErrorDiv); 135 | } 136 | 137 | // Clear the container. 138 | const { containerEl } = this; 139 | containerEl.empty(); 140 | containerEl.appendChild(contentEl); 141 | } 142 | 143 | /** @override */ 144 | public displayControls(): void { 145 | const { controlsEl } = this; 146 | 147 | const filter = new TextComponent(controlsEl) 148 | .setValue(this.searchQuery) 149 | .setPlaceholder('Filter callouts...') 150 | .onChange(this.search.bind(this)); 151 | 152 | this.setSearchError = (message) => { 153 | filter.inputEl.classList.toggle('mod-error', !!message); 154 | if (message) { 155 | filter.inputEl.setAttribute('aria-label', message); 156 | } else { 157 | filter.inputEl.removeAttribute('aria-label'); 158 | } 159 | }; 160 | 161 | if (!this.viewOnly) { 162 | new ButtonComponent(controlsEl) 163 | .setIcon('lucide-plus') 164 | .setTooltip('New Callout') 165 | .onClick(() => this.nav.open(new CreateCalloutPane(this.plugin))) 166 | .then(({ buttonEl }) => buttonEl.classList.add('clickable-icon')); 167 | } 168 | } 169 | 170 | /** @override */ 171 | protected restoreState(state: unknown): void { 172 | this.invalidate(); 173 | } 174 | 175 | /** @override */ 176 | protected onReady(): void { 177 | this.invalidate(); 178 | } 179 | } 180 | 181 | function createPreviewFactory(viewOnly: boolean): (callout: Callout) => HTMLElement { 182 | const editButtonContent = 183 | (viewOnly ? getIcon('lucide-view') : getIcon('lucide-edit')) ?? document.createTextNode('Edit Callout'); 184 | 185 | const insertButtonContent = 186 | (viewOnly ? getIcon('lucide-view') : getIcon('lucide-forward')) ?? 187 | document.createTextNode('Insert Callout'); 188 | 189 | return (callout) => { 190 | const frag = document.createDocumentFragment(); 191 | const calloutContainerEl = frag.createDiv({ 192 | cls: ['calloutmanager-preview-container'], 193 | attr: { 194 | ['data-callout-manager-callout']: callout.id, 195 | }, 196 | }); 197 | 198 | // Add the preview. 199 | new CalloutPreviewComponent(calloutContainerEl, { 200 | id: callout.id, 201 | icon: callout.icon, 202 | title: getTitleFromCallout(callout), 203 | color: getColorFromCallout(callout) ?? undefined, 204 | }); 205 | 206 | // Add the edit button to the container. 207 | calloutContainerEl.classList.add('calloutmanager-preview-container-with-button'); 208 | 209 | const editButton = calloutContainerEl.createEl('button'); 210 | editButton.setAttribute('data-callout-manager-action', 'edit'); 211 | editButton.appendChild(editButtonContent.cloneNode(true)); 212 | 213 | // Add the insert button to the container. 214 | const insertButton = calloutContainerEl.createEl('button'); 215 | insertButton.setAttribute('data-callout-manager-action', 'insert'); 216 | insertButton.appendChild(insertButtonContent.cloneNode(true)); 217 | 218 | // Return the preview container. 219 | return calloutContainerEl; 220 | }; 221 | } 222 | 223 | /** 224 | * Creates a div that can be used to show the user why the search query failed. 225 | */ 226 | function createEmptySearchResultDiv(): { searchErrorDiv: HTMLElement; searchErrorQuery: HTMLElement } { 227 | let searchErrorQuery!: HTMLElement; 228 | const searchErrorDiv = document.createElement('div'); 229 | searchErrorDiv.className = 'calloutmanager-centerbox'; 230 | const contentEl = searchErrorDiv.createDiv({ cls: 'calloutmanager-search-error' }); 231 | 232 | // Title. 233 | contentEl.createEl('h2', { text: 'No callouts found.' }); 234 | 235 | // Error message. 236 | contentEl.createEl('p', undefined, (el) => { 237 | el.createSpan({ text: 'Your search query ' }); 238 | searchErrorQuery = el.createEl('code', { text: '' }); 239 | el.createSpan({ text: ' did not return any results.' }); 240 | }); 241 | 242 | // Suggestions. 243 | contentEl.createDiv({ cls: 'calloutmanager-search-error-suggestions' }, (el) => { 244 | el.createDiv({ text: 'Try searching:' }); 245 | el.createEl('ul', undefined, (el) => { 246 | el.createEl('li', { text: 'By name: ' }, (el) => { 247 | el.createEl('code', { text: 'warning' }); 248 | }); 249 | el.createEl('li', { text: 'By icon: ' }, (el) => { 250 | el.createEl('code', { text: 'icon:check' }); 251 | }); 252 | el.createEl('li', { text: 'Built-in callouts: ' }, (el) => { 253 | el.createEl('code', { text: 'from:obsidian' }); 254 | }); 255 | el.createEl('li', { text: 'Theme callouts: ' }, (el) => { 256 | el.createEl('code', { text: 'from:theme' }); 257 | }); 258 | el.createEl('li', { text: 'Snippet callouts: ' }, (el) => { 259 | el.createEl('code', { text: 'from:my snippet' }); 260 | }); 261 | el.createEl('li', { text: 'Custom callouts: ' }, (el) => { 262 | el.createEl('code', { text: 'from:custom' }); 263 | }); 264 | }); 265 | }); 266 | 267 | return { searchErrorDiv, searchErrorQuery }; 268 | } 269 | 270 | // --------------------------------------------------------------------------------------------------------------------- 271 | // Styles: 272 | // --------------------------------------------------------------------------------------------------------------------- 273 | 274 | declare const STYLES: ` 275 | .calloutmanager-search-error { 276 | width: 60%; 277 | 278 | body.is-phone & { 279 | width: 100%; 280 | } 281 | 282 | code { 283 | word-break: break-all; 284 | color: var(--text-accent); 285 | } 286 | } 287 | 288 | .calloutmanager-search-error-suggestions { 289 | color: var(--text-muted); 290 | } 291 | 292 | .calloutmanager-preview-container-with-button { 293 | --calloutmanager-callout-edit-buttons-size: calc(var(--input-height) + 2 * var(--size-4-3)); 294 | body.is-phone & { 295 | --calloutmanager-callout-edit-buttons-size: var(--input-height); 296 | } 297 | 298 | // Conver the preview into a grid. 299 | display: grid; 300 | grid-template-columns: 1fr var(--calloutmanager-callout-edit-buttons-size) var(--calloutmanager-callout-edit-buttons-size); 301 | 302 | align-items: center; 303 | gap: var(--size-4-2); 304 | 305 | // Ensure the button has a small width, but can grow tall. 306 | > button { 307 | width: var(--calloutmanager-callout-edit-buttons-size); 308 | height: 100%; 309 | 310 | // Fix rendering not working on non-phone devices. 311 | body:not(.is-phone) & { 312 | display: block; 313 | padding: 0 !important; 314 | } 315 | } 316 | } 317 | `; 318 | --------------------------------------------------------------------------------