├── src ├── @types │ ├── global.d.ts │ ├── internal-plugin-injector.ts │ └── obsidian.d.ts ├── editor │ ├── live-preview │ │ ├── index.ts │ │ ├── plugins │ │ │ ├── index.ts │ │ │ ├── icon-in-links.ts │ │ │ └── icon-in-text.ts │ │ ├── widgets │ │ │ ├── index.ts │ │ │ ├── icon-in-link.ts │ │ │ └── icon-in-text.ts │ │ └── decorations │ │ │ ├── index.ts │ │ │ ├── build-text-decorations.ts │ │ │ └── build-link-decorations.ts │ ├── markdown-processors │ │ ├── index.ts │ │ └── icon-in-link.ts │ ├── icons-suggestion.ts │ └── icons-suggestion.test.ts ├── index.ts ├── lib │ ├── event │ │ ├── events.ts │ │ ├── event.ts │ │ └── event.test.ts │ ├── icon-cache.ts │ ├── icon-cache.test.ts │ ├── api.ts │ ├── logger.ts │ ├── util │ │ ├── text.test.ts │ │ ├── text.ts │ │ ├── svg.ts │ │ ├── style.ts │ │ ├── style.test.ts │ │ └── svg.test.ts │ ├── logger.test.ts │ ├── icon-title.ts │ ├── icon.test.ts │ └── icon-tabs.ts ├── migrations │ ├── 0004-remove-none-emoji-option.ts │ ├── 0001-change-migrated-true-to-1.ts │ ├── 0005-remove-downloaded-lucide-icon-pack.ts │ ├── 0002-order-custom-rules.ts │ ├── index.ts │ └── 0003-inheritance-to-custom-rule.ts ├── settings │ ├── ui │ │ ├── iconFolderSetting.ts │ │ ├── debugMode.ts │ │ ├── toggleIconsInLinks.ts │ │ ├── useInternalPlugins.ts │ │ ├── toggleIconsInNotes.ts │ │ ├── recentlyUsedIcons.ts │ │ ├── iconPacksBackgroundChecker.ts │ │ ├── iconIdentifier.ts │ │ ├── predefinedIconPacks.ts │ │ ├── iconColor.ts │ │ ├── iconFontSize.ts │ │ ├── iconPacksPath.ts │ │ ├── toggleIconInTabs.ts │ │ ├── extraMargin.ts │ │ ├── index.ts │ │ ├── emojiStyle.ts │ │ └── toggleIconInTitle.ts │ ├── ResetButtonComponent.ts │ └── helper.ts ├── config │ └── index.ts ├── icon-packs.test.ts ├── test-setup.ts ├── zip-util.ts ├── emoji.test.ts ├── ui │ ├── icon-pack-browser-modal.ts │ └── change-color-modal.ts ├── icon-pack-manager │ ├── lucide.ts │ ├── icon-pack.ts │ ├── util.ts │ ├── util.test.ts │ └── file-manager.ts ├── styles.css ├── zip-util.test.ts ├── internal-plugins │ ├── starred.ts │ └── outline.ts ├── icon-packs.ts └── util.test.ts ├── .prettierignore ├── versions.json ├── .eslintignore ├── docs ├── guide │ ├── settings.md │ ├── getting-started.md │ ├── syncing.md │ └── icon-packs.md ├── preview-image.png ├── icon-pack-preview.png ├── assets │ ├── emoji-style.png │ ├── icon-of-file.png │ ├── add-inheritance.png │ ├── icons-in-notes.png │ ├── browse-icon-packs.png │ ├── icon-above-title.png │ ├── add-custom-icon-pack.png │ ├── syncing-icon-packs.png │ ├── icons-in-notes-setting.png │ ├── individual-icon-color.png │ ├── enable-frontmatter-option.png │ ├── icon-above-title-option.png │ ├── lucide-native-icon-pack.png │ ├── icon-in-tabs-settings-option.png │ └── default-icon-through-custom-rules.png ├── .vitepress │ ├── theme │ │ ├── index.js │ │ └── custom.css │ └── config.mts ├── api │ └── getting-started.md ├── good-to-know │ ├── transform-png-to-svg.md │ ├── unicode-issue.md │ └── see-icon-name.md ├── files-and-folders │ ├── icon-before-file-or-folder.md │ ├── icon-tabs.md │ ├── individual-icon-color.md │ ├── use-frontmatter.md │ └── custom-rules.md ├── deprecated │ └── inheritance.md ├── notes │ ├── title-icon.md │ └── icons-in-notes.md ├── compatibility-plugins │ └── metadatamenu.md └── index.md ├── .commitlintrc.json ├── .husky ├── pre-commit └── commit-msg ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── unit-test.yml │ └── deploy.yml ├── iconPacks ├── boxicons.zip ├── icon-brew.zip └── rpg-awesome.zip ├── .prettierrc ├── tsconfig.lib.json ├── manifest.json ├── .gitignore ├── tsconfig.json ├── copy-file.js ├── vitest.config.ts ├── .eslintrc ├── LICENSE ├── rollup.config.js ├── package.json └── README.md /src/@types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*'; 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | docs/.vitepress/cache 3 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.9.12" 3 | } 4 | -------------------------------------------------------------------------------- /src/editor/live-preview/index.ts: -------------------------------------------------------------------------------- 1 | export * from './plugins'; 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | docs/.vitepress/cache 4 | -------------------------------------------------------------------------------- /docs/guide/settings.md: -------------------------------------------------------------------------------- 1 | # Settings 2 | 3 | *Documentation Coming Soon* 4 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: florianwoelki 2 | custom: ['https://www.paypal.me/FlorianWoelki'] 3 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /docs/preview-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/docs/preview-image.png -------------------------------------------------------------------------------- /iconPacks/boxicons.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/iconPacks/boxicons.zip -------------------------------------------------------------------------------- /iconPacks/icon-brew.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/iconPacks/icon-brew.zip -------------------------------------------------------------------------------- /src/editor/live-preview/plugins/index.ts: -------------------------------------------------------------------------------- 1 | export * from './icon-in-text'; 2 | export * from './icon-in-links'; 3 | -------------------------------------------------------------------------------- /src/editor/live-preview/widgets/index.ts: -------------------------------------------------------------------------------- 1 | export * from './icon-in-text'; 2 | export * from './icon-in-link'; 3 | -------------------------------------------------------------------------------- /src/editor/markdown-processors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './icon-in-text'; 2 | export * from './icon-in-link'; 3 | -------------------------------------------------------------------------------- /docs/icon-pack-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/docs/icon-pack-preview.png -------------------------------------------------------------------------------- /iconPacks/rpg-awesome.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/iconPacks/rpg-awesome.zip -------------------------------------------------------------------------------- /docs/assets/emoji-style.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/docs/assets/emoji-style.png -------------------------------------------------------------------------------- /docs/assets/icon-of-file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/docs/assets/icon-of-file.png -------------------------------------------------------------------------------- /docs/assets/add-inheritance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/docs/assets/add-inheritance.png -------------------------------------------------------------------------------- /docs/assets/icons-in-notes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/docs/assets/icons-in-notes.png -------------------------------------------------------------------------------- /docs/assets/browse-icon-packs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/docs/assets/browse-icon-packs.png -------------------------------------------------------------------------------- /docs/assets/icon-above-title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/docs/assets/icon-above-title.png -------------------------------------------------------------------------------- /docs/assets/add-custom-icon-pack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/docs/assets/add-custom-icon-pack.png -------------------------------------------------------------------------------- /docs/assets/syncing-icon-packs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/docs/assets/syncing-icon-packs.png -------------------------------------------------------------------------------- /src/editor/live-preview/decorations/index.ts: -------------------------------------------------------------------------------- 1 | export * from './build-link-decorations'; 2 | export * from './build-text-decorations'; 3 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.js: -------------------------------------------------------------------------------- 1 | import DefaultTheme from 'vitepress/theme'; 2 | import './custom.css'; 3 | 4 | export default DefaultTheme; 5 | -------------------------------------------------------------------------------- /docs/assets/icons-in-notes-setting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/docs/assets/icons-in-notes-setting.png -------------------------------------------------------------------------------- /docs/assets/individual-icon-color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/docs/assets/individual-icon-color.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 80, 6 | "tabWidth": 2, 7 | } 8 | -------------------------------------------------------------------------------- /docs/assets/enable-frontmatter-option.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/docs/assets/enable-frontmatter-option.png -------------------------------------------------------------------------------- /docs/assets/icon-above-title-option.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/docs/assets/icon-above-title-option.png -------------------------------------------------------------------------------- /docs/assets/lucide-native-icon-pack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/docs/assets/lucide-native-icon-pack.png -------------------------------------------------------------------------------- /docs/assets/icon-in-tabs-settings-option.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/docs/assets/icon-in-tabs-settings-option.png -------------------------------------------------------------------------------- /docs/assets/default-icon-through-custom-rules.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlorianWoelki/obsidian-iconize/HEAD/docs/assets/default-icon-through-custom-rules.png -------------------------------------------------------------------------------- /docs/api/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: API Getting Started | Obsidian Iconize 3 | --- 4 | 5 | # Getting Started 6 | 7 | *Coming Soon* 8 | 9 | ## What is possible? 10 | 11 | ## Installation 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import IconizeAPI from './lib/api'; 2 | import IconizePlugin from './main'; 3 | 4 | export function getApi(plugin: IconizePlugin): IconizeAPI | undefined { 5 | return plugin.app.plugins.plugins['obsidian-icon-folder']?.api; 6 | } 7 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/custom.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --vp-c-brand-1: rgba(168, 85, 247, 1); 3 | --vp-c-brand-2: #9333ea; 4 | --vp-button-brand-bg: rgba(168, 85, 247, 1); 5 | } 6 | 7 | .dark { 8 | --vp-c-brand-1: rgba(192, 132, 252, 1); 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "ESNext", 5 | "outDir": "dist/lib", 6 | "declaration": true, 7 | "inlineSourceMap": true 8 | }, 9 | "include": ["src"], 10 | "exclude": ["**/*.test.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/event/events.ts: -------------------------------------------------------------------------------- 1 | import { Event } from './event'; 2 | 3 | export type AllIconsLoadedEvent = Event; 4 | 5 | export type EventMap = { 6 | allIconsLoaded: AllIconsLoadedEvent; 7 | }; 8 | 9 | export type EventType = keyof EventMap; 10 | export type AnyEvent = EventMap[EventType]; 11 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-icon-folder", 3 | "name": "Iconize", 4 | "version": "2.14.7", 5 | "minAppVersion": "0.9.12", 6 | "description": "Add icons to anything you desire in Obsidian, including files, folders, and text.", 7 | "author": "Florian Woelki", 8 | "authorUrl": "https://florianwoelki.com/", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /docs/good-to-know/transform-png-to-svg.md: -------------------------------------------------------------------------------- 1 | # Transform PNG to SVG 2 | 3 | At the moment, it is necessary to only use SVG files in Iconize. That means, 4 | that this plugin currently does not support other files than SVG. 5 | 6 | To transform and convert a PNG to a SVG, you could use 7 | [Adobe](https://www.adobe.com/express/feature/image/convert/png-to-svg) which is 8 | free of charge. 9 | -------------------------------------------------------------------------------- /docs/good-to-know/unicode-issue.md: -------------------------------------------------------------------------------- 1 | # Unicode issue 2 | 3 | If you found some weird characters in your text, it's probably because of the 4 | encoding. The reason for this issue is most likely that Emojis are turned off 5 | which just removes all the Emojis in your text. 6 | 7 | You can enable emojis by following [this guide](../guide/icon-packs.html#using-emojis). This should most likely resolve 8 | the issue. 9 | -------------------------------------------------------------------------------- /src/migrations/0004-remove-none-emoji-option.ts: -------------------------------------------------------------------------------- 1 | import IconizePlugin from '@app/main'; 2 | 3 | export default async function migrate(plugin: IconizePlugin): Promise { 4 | if (plugin.getSettings().migrated === 4) { 5 | if ((plugin.getSettings().emojiStyle as string) === 'none') { 6 | plugin.getSettings().emojiStyle = 'native'; 7 | } 8 | plugin.getSettings().migrated++; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Intellij 4 | *.iml 5 | .idea 6 | 7 | # npm 8 | node_modules 9 | 10 | # build 11 | main.js 12 | *.js.map 13 | dist 14 | 15 | # icons generation 16 | build 17 | remixicons 18 | fontawesome 19 | devicon 20 | 21 | # obsidian 22 | data.json 23 | env.js 24 | 25 | # Vitepress 26 | docs/.vitepress/dist 27 | docs/.vitepress/cache 28 | 29 | # Vitest 30 | coverage 31 | -------------------------------------------------------------------------------- /src/settings/ui/iconFolderSetting.ts: -------------------------------------------------------------------------------- 1 | import IconizePlugin from '@app/main'; 2 | 3 | export default abstract class IconFolderSetting { 4 | protected plugin: IconizePlugin; 5 | protected containerEl: HTMLElement; 6 | 7 | constructor(plugin: IconizePlugin, containerEl: HTMLElement) { 8 | this.plugin = plugin; 9 | this.containerEl = containerEl; 10 | } 11 | 12 | public abstract display(): void; 13 | } 14 | -------------------------------------------------------------------------------- /docs/files-and-folders/icon-before-file-or-folder.md: -------------------------------------------------------------------------------- 1 | # Icon before file or folder name 2 | 3 | It is pretty easy to set a icon before a file or folder name. 4 | 5 | You simply need to `Right Click a file or folder > Change Icon` (keyboard 6 | shortcut: `Cmd`/`Ctrl`-`Shift`-`j`) and then select the icon you would like to 7 | set for this specific file or folder. Be aware that this has the highest 8 | priority and overrides every other set icon for this file or folder. 9 | -------------------------------------------------------------------------------- /docs/good-to-know/see-icon-name.md: -------------------------------------------------------------------------------- 1 | # See icon name of file or folder 2 | 3 | If you've ever wondered which icon you've used for this specific file or folder 4 | and it does not exist in your most recently used icons, you can simply hover 5 | over the icon (and wait some time) to see the icon name. It will be in the 6 | following format: `` (e.g., `IbCalendar`). 7 | 8 | It can look like the following: 9 | 10 | ![See icon name of file or folder](../assets/icon-of-file.png) 11 | 12 | -------------------------------------------------------------------------------- /src/settings/ResetButtonComponent.ts: -------------------------------------------------------------------------------- 1 | import { ButtonComponent } from 'obsidian'; 2 | 3 | export class ResetButtonComponent extends ButtonComponent { 4 | constructor(protected contentEl: HTMLElement) { 5 | super(contentEl); 6 | this.setTooltip('Restore default'); 7 | this.setIcon('rotate-ccw'); 8 | this.render(); 9 | } 10 | 11 | private render(): void { 12 | this.buttonEl.classList.add('clickable-icon'); 13 | this.buttonEl.classList.add('extra-setting-button'); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/migrations/0001-change-migrated-true-to-1.ts: -------------------------------------------------------------------------------- 1 | import { Notice } from 'obsidian'; 2 | import IconizePlugin from '@app/main'; 3 | 4 | export default async function migrate(plugin: IconizePlugin): Promise { 5 | // Migration for new syncing mechanism. 6 | if (plugin.getSettings().migrated === 1) { 7 | new Notice( 8 | 'Please delete your old icon packs and redownload your icon packs to use the new syncing mechanism.', 9 | 20000, 10 | ); 11 | plugin.getSettings().migrated++; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /docs/files-and-folders/icon-tabs.md: -------------------------------------------------------------------------------- 1 | # Icon in Tabs 2 | 3 | Make sure, that you've enabled the setting which is related to activating icons in tabs. 4 | Please refer to the [Settings](../guide/settings.md) documentation for more information 5 | about this and other options. 6 | 7 | ![Icon in tabs settings option](../assets/icon-in-tabs-settings-option.png) 8 | 9 | Tabs can have icons next to the title of the opened file. To add an icon to a tab, you 10 | just need to add an icon to a file and the tab will automatically show it. 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@app/*": ["src/*"], 6 | "@lib/*": ["src/lib/*"] 7 | }, 8 | "inlineSourceMap": true, 9 | "inlineSources": true, 10 | "allowSyntheticDefaultImports": true, 11 | "module": "ESNext", 12 | "target": "es6", 13 | "noImplicitAny": true, 14 | "moduleResolution": "node", 15 | "importHelpers": true, 16 | "lib": ["dom", "es5", "scripthost", "es2015"] 17 | }, 18 | "include": ["**/*.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /src/migrations/0005-remove-downloaded-lucide-icon-pack.ts: -------------------------------------------------------------------------------- 1 | import { LUCIDE_ICON_PACK_NAME } from '@app/icon-pack-manager/lucide'; 2 | import IconizePlugin from '@app/main'; 3 | 4 | export default async function migrate(plugin: IconizePlugin): Promise { 5 | if (plugin.getSettings().migrated === 5) { 6 | const iconPack = plugin 7 | .getIconPackManager() 8 | .getIconPackByName(LUCIDE_ICON_PACK_NAME); 9 | await plugin.getIconPackManager().removeIconPack(iconPack); 10 | plugin.getSettings().migrated++; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /docs/files-and-folders/individual-icon-color.md: -------------------------------------------------------------------------------- 1 | # Change individual icon color 2 | 3 | If you would like, you can also set an individual icon color for your icon, 4 | that is set to a file or folder. 5 | 6 | For that you simply need to `Right click on your file/folder which contains an 7 | icon > Change color of icon`. Within the next modal, you can set or reset the 8 | color of the icon. Resetting means, setting the icon color to be the default 9 | editor color. 10 | 11 | ![Individual icon color change](../assets/individual-icon-color.png) 12 | 13 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | const PLUGIN_NAME = 'iconize'; 2 | 3 | const TITLE_ICON_CLASS = 'iconize-title-icon'; 4 | 5 | const INLINE_TITLE_WRAPPER_CLASS = 'iconize-inline-title-wrapper'; 6 | 7 | /** 8 | * The name of the attribute that is used to store the icon name in the node. 9 | * The value of this attributes contains the prefix and the name of the icon. 10 | */ 11 | const ICON_ATTRIBUTE_NAME = 'data-icon'; 12 | 13 | export default { 14 | PLUGIN_NAME, 15 | TITLE_ICON_CLASS, 16 | INLINE_TITLE_WRAPPER_CLASS, 17 | ICON_ATTRIBUTE_NAME, 18 | }; 19 | -------------------------------------------------------------------------------- /src/migrations/0002-order-custom-rules.ts: -------------------------------------------------------------------------------- 1 | import IconizePlugin from '@app/main'; 2 | 3 | export default async function migrate(plugin: IconizePlugin): Promise { 4 | // Migration for new order functionality of custom rules. 5 | if (plugin.getSettings().migrated === 2) { 6 | // Sorting alphabetically was the default behavior before. 7 | plugin 8 | .getSettings() 9 | .rules.sort((a, b) => a.rule.localeCompare(b.rule)) 10 | .forEach((rule, i) => { 11 | rule.order = i; 12 | }); 13 | plugin.getSettings().migrated++; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /docs/deprecated/inheritance.md: -------------------------------------------------------------------------------- 1 | # Inheritance 2 | 3 | 4 | 5 | Inheritance allows to add icons for a specific folder to all the files that are 6 | only at the root of the folder. That means that that all root files in a folder 7 | will have an icon, if inheritance is applied. 8 | 9 | After clicking the `Inherit icon` menu item, a icon modal will pop up where 10 | you can select the icon you want for the inheritance. After that, all the files 11 | have the icon. 12 | 13 | ![Inheritance function](../assets/add-inheritance.png) 14 | 15 | -------------------------------------------------------------------------------- /src/icon-packs.test.ts: -------------------------------------------------------------------------------- 1 | import { it, expect } from 'vitest'; 2 | import { getExtraPath } from './icon-packs'; 3 | 4 | it('should return the correct extra path for an icon pack', () => { 5 | const iconPackName = 'simple-icons'; 6 | const expectedPath = 'simple-icons-11.10.0/icons/'; 7 | 8 | const path = getExtraPath(iconPackName); 9 | 10 | expect(path).toEqual(expectedPath); 11 | }); 12 | 13 | it('should return `undefined` for an icon pack that does not exist', () => { 14 | const iconPackName = 'non-existent-icon-pack'; 15 | 16 | const path = getExtraPath(iconPackName); 17 | 18 | expect(path).toBeUndefined(); 19 | }); 20 | -------------------------------------------------------------------------------- /docs/files-and-folders/use-frontmatter.md: -------------------------------------------------------------------------------- 1 | # Use Frontmatter 2 | 3 | If you want to use a frontmatter property to set the icon, you can follow this 4 | guide. 5 | 6 | First of, you need to enable the properties option, so that Iconize can read 7 | your frontmatter values. 8 | 9 | ![Enable frontmatter option](../assets/enable-frontmatter-option.png) 10 | 11 | After that you can feel free to use the frontmatter property `icon` on any file 12 | to customize the icon for this file. 13 | 14 | For example, you can use the following frontmatter to set the icon for a 15 | specific file: 16 | 17 | ```markdown 18 | --- 19 | icon: IbBell 20 | --- 21 | ``` 22 | 23 | -------------------------------------------------------------------------------- /copy-file.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import path from 'path'; 3 | 4 | const copyFile = (options = {}) => { 5 | const { targets = [], hook = 'writeBundle' } = options; 6 | return { 7 | name: 'copy-files', 8 | [hook]: async () => { 9 | targets.forEach(async (target) => { 10 | try { 11 | console.log(`copying ${target.src}...`); 12 | const destPath = path.join(target.dest, path.basename(target.src)); 13 | await fs.copyFile(target.src, destPath); 14 | } catch (error) { 15 | console.log(error); 16 | } 17 | }); 18 | }, 19 | }; 20 | }; 21 | 22 | export default copyFile; 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | setupFiles: ['./src/test-setup.ts'], 7 | environment: 'happy-dom', 8 | coverage: { 9 | all: false, 10 | reporter: ['text', 'json-summary', 'json'], 11 | provider: 'istanbul', 12 | thresholds: { 13 | lines: 60, 14 | branches: 50, 15 | functions: 60, 16 | statements: 60, 17 | }, 18 | }, 19 | }, 20 | resolve: { 21 | alias: [ 22 | { find: '@app', replacement: resolve(__dirname, './src') }, 23 | { find: '@lib', replacement: resolve(__dirname, './src/lib') }, 24 | ], 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /docs/notes/title-icon.md: -------------------------------------------------------------------------------- 1 | # Icon above Title 2 | 3 | If you would like to have a notion-like icon above your title like the following: 4 | 5 | ![Icon above title](../assets/icon-above-title.png) 6 | 7 | You can enable the following setting in the plugin settings: 8 | 9 | ![Icon above title option](../assets/icon-above-title-option.png) 10 | 11 | This will enable the icons above the titles. The icon is taken from the icon of the file. 12 | This feature works with normal icons and custom rules. 13 | The size of the icon is aligned to the size of the title. If you want to change the size 14 | of the icon, you can do so by adding some custom css: 15 | 16 | ```css 17 | .iconize-title-icon { 18 | width: 1.5em; 19 | height: 1.5em; 20 | } 21 | ``` 22 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "ecmaVersion": 2020, 5 | "sourceType": "module", 6 | }, 7 | "extends": ["plugin:@typescript-eslint/recommended", "prettier"], 8 | "rules": { 9 | "@typescript-eslint/no-explicit-any": "warn", 10 | "@typescript-eslint/no-unused-vars": [ 11 | "error", 12 | { 13 | "argsIgnorePattern": "^_", 14 | "varsIgnorePattern": "^_", 15 | }, 16 | ], 17 | "no-restricted-imports": [ 18 | "warn", 19 | { 20 | "patterns": [ 21 | { 22 | "group": ["../*"], 23 | "message": "Usage of relative parent imports is not allowed.", 24 | } 25 | ] 26 | } 27 | ] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/settings/ui/debugMode.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from 'obsidian'; 2 | import IconFolderSetting from './iconFolderSetting'; 3 | 4 | export default class DebugMode extends IconFolderSetting { 5 | public display(): void { 6 | new Setting(this.containerEl) 7 | .setName('Toggle Debug Mode') 8 | .setDesc( 9 | 'Toggle debug mode to see more detailed logs in the console. Do not touch this unless you know what you are doing.', 10 | ) 11 | .addToggle((toggle) => { 12 | toggle 13 | .setValue(this.plugin.getSettings().debugMode) 14 | .onChange(async (enabled) => { 15 | this.plugin.getSettings().debugMode = enabled; 16 | await this.plugin.saveIconFolderData(); 17 | }); 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /docs/files-and-folders/custom-rules.md: -------------------------------------------------------------------------------- 1 | # Custom Rules 2 | 3 | *Documentation Coming Soon* 4 | 5 | ## Use Cases 6 | 7 | This section of the documentation will show you some use cases for custom rules. 8 | 9 | ### Having a default icon 10 | 11 | You can use custom rules to have a default icon for all files and folders. This is useful 12 | if you want to have a default icon for all files and folders and only change the icon for 13 | some of them. 14 | 15 | You can easily set a default icon by applying a custom rule to your vault. You simply need 16 | to add a custom rule with the input `.`, so that it looks like this: 17 | 18 | ![Default icon through custom rule](../assets/default-icon-through-custom-rules.png) 19 | 20 | After that, you can select the icon you want to use as a default icon. This icon will be 21 | used for all files and folders that don't have a custom icon. 22 | -------------------------------------------------------------------------------- /src/@types/internal-plugin-injector.ts: -------------------------------------------------------------------------------- 1 | import { TAbstractFile, View, WorkspaceLeaf } from 'obsidian'; 2 | import IconizePlugin from '@app/main'; 3 | 4 | interface FileExplorerWorkspaceLeaf extends WorkspaceLeaf { 5 | containerEl: HTMLElement; 6 | view: FileExplorerView; 7 | } 8 | 9 | interface FileExplorerView extends View { 10 | fileItems: { [path: string]: TAbstractFile }; 11 | } 12 | 13 | export default abstract class InternalPluginInjector { 14 | protected plugin: IconizePlugin; 15 | 16 | constructor(plugin: IconizePlugin) { 17 | this.plugin = plugin; 18 | } 19 | 20 | get fileExplorers(): FileExplorerWorkspaceLeaf[] { 21 | return this.plugin.app.workspace.getLeavesOfType( 22 | 'file-explorer', 23 | ) as unknown as FileExplorerWorkspaceLeaf[]; 24 | } 25 | 26 | onMount(): void {} 27 | 28 | abstract get enabled(): boolean; 29 | 30 | abstract register(): void; 31 | } 32 | -------------------------------------------------------------------------------- /src/test-setup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This test setup script is used to patch the `obsidian` module to make it work with 3 | * `vitest`. It is a workaround and only adds the `main.js` file and updates the 4 | * `package.json` to point to it. 5 | */ 6 | import { writeFileSync } from 'fs'; 7 | import { join } from 'path'; 8 | 9 | (async () => { 10 | const obsidianModuleDir = join(__dirname, '../node_modules/obsidian'); 11 | const mainFilePath = join(obsidianModuleDir, 'main.js'); 12 | 13 | // Creates an empty `main.js` file. 14 | writeFileSync(mainFilePath, ''); 15 | 16 | const packageJsonPath = join(obsidianModuleDir, 'package.json'); 17 | const packageJson = (await import(packageJsonPath)).default; 18 | delete packageJson.main; 19 | packageJson.main = 'main.js'; 20 | 21 | // Modifies `package.json` file to add `main.js` as the main entry point. 22 | writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); 23 | })(); 24 | -------------------------------------------------------------------------------- /src/migrations/index.ts: -------------------------------------------------------------------------------- 1 | import IconizePlugin from '@app/main'; 2 | import migrate0001 from './0001-change-migrated-true-to-1'; 3 | import migrate0002 from './0002-order-custom-rules'; 4 | import migrate0003 from './0003-inheritance-to-custom-rule'; 5 | import migrate0004 from './0004-remove-none-emoji-option'; 6 | import migrate0005 from './0005-remove-downloaded-lucide-icon-pack'; 7 | 8 | export const migrate = async (plugin: IconizePlugin): Promise => { 9 | // eslint-disable-next-line 10 | // @ts-ignore - Required because an older version of the plugin saved the `migrated` 11 | // property as a boolean instead of a number. 12 | if (plugin.getSettings().migrated === true) { 13 | plugin.getSettings().migrated = 1; 14 | } 15 | 16 | await migrate0001(plugin); 17 | await migrate0002(plugin); 18 | await migrate0003(plugin); 19 | await migrate0004(plugin); 20 | await migrate0005(plugin); 21 | 22 | await plugin.saveIconFolderData(); 23 | }; 24 | -------------------------------------------------------------------------------- /src/settings/ui/toggleIconsInLinks.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Setting } from 'obsidian'; 2 | import IconFolderSetting from './iconFolderSetting'; 3 | import config from '@app/config'; 4 | 5 | export default class ToggleIconsInLinks extends IconFolderSetting { 6 | public display(): void { 7 | new Setting(this.containerEl) 8 | .setName('Toggle icons in links') 9 | .setDesc( 10 | 'Toggles whether you are able to see icons in the links to other notes', 11 | ) 12 | .addToggle((toggle) => { 13 | toggle 14 | .setValue(this.plugin.getSettings().iconsInLinksEnabled) 15 | .onChange(async (enabled) => { 16 | this.plugin.getSettings().iconsInLinksEnabled = enabled; 17 | await this.plugin.saveIconFolderData(); 18 | new Notice( 19 | `[${config.PLUGIN_NAME}] Obsidian has to be restarted for this change to take effect.`, 20 | ); 21 | }); 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/editor/live-preview/plugins/icon-in-links.ts: -------------------------------------------------------------------------------- 1 | import IconizePlugin from '@app/main'; 2 | import { 3 | DecorationSet, 4 | EditorView, 5 | ViewPlugin, 6 | ViewUpdate, 7 | } from '@codemirror/view'; 8 | import { buildLinkDecorations } from '@app/editor/live-preview/decorations'; 9 | 10 | export const buildIconInLinksPlugin = (plugin: IconizePlugin) => { 11 | return ViewPlugin.fromClass( 12 | class { 13 | decorations: DecorationSet; 14 | plugin: IconizePlugin; 15 | 16 | constructor(view: EditorView) { 17 | this.plugin = plugin; 18 | this.decorations = buildLinkDecorations(view, plugin); 19 | } 20 | 21 | destroy() {} 22 | 23 | update(update: ViewUpdate) { 24 | if (update.docChanged || update.viewportChanged) { 25 | this.decorations = buildLinkDecorations(update.view, this.plugin); 26 | } 27 | } 28 | }, 29 | { 30 | decorations: (v) => v.decorations, 31 | }, 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /src/settings/ui/useInternalPlugins.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Setting } from 'obsidian'; 2 | import IconFolderSetting from './iconFolderSetting'; 3 | import config from '@app/config'; 4 | 5 | export default class UseInternalPlugins extends IconFolderSetting { 6 | public display(): void { 7 | new Setting(this.containerEl) 8 | .setName('EXPERIMENTAL: Use internal plugins') 9 | .setDesc( 10 | 'Toggles whether to try to add icons to the bookmark and outline internal plugins.', 11 | ) 12 | .addToggle((toggle) => { 13 | toggle 14 | .setValue(this.plugin.getSettings().useInternalPlugins) 15 | .onChange(async (enabled) => { 16 | this.plugin.getSettings().useInternalPlugins = enabled; 17 | await this.plugin.saveIconFolderData(); 18 | new Notice( 19 | `[${config.PLUGIN_NAME}] Obsidian has to be restarted for this change to take effect.`, 20 | ); 21 | }); 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /docs/notes/icons-in-notes.md: -------------------------------------------------------------------------------- 1 | # Icons in Notes 2 | 3 | Using icons or emojis in your notes is a great way to make them more visually appealing and 4 | to make them easier to navigate. Obsidian Iconize allows you to do that by just typing 5 | and selecting the icon, without interrupting your workflow. 6 | 7 | You can do so, by just typing a `:` and then the name of the icon you want to use. 8 | For example, if you want to use the `:smile:` emoji, you can just type `:smile:` and 9 | then select the emoji from the suggestion list. This also works with all your installed 10 | icon packs in your vault. 11 | 12 | By default, the icons will always appear in preview mode but also in the live- 13 | preview when you edit your notes: 14 | 15 | ![Icons in notes](../assets/icons-in-notes.png) 16 | 17 | Furthermore, if you would like to completely disable icons in notes, you can do 18 | so in the options by toggling the following setting: 19 | 20 | ![Icons in notes setting](../assets/icons-in-notes-setting.png) 21 | 22 | -------------------------------------------------------------------------------- /src/settings/ui/toggleIconsInNotes.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Setting } from 'obsidian'; 2 | import IconFolderSetting from './iconFolderSetting'; 3 | import config from '@app/config'; 4 | 5 | export default class ToggleIconsInEditor extends IconFolderSetting { 6 | public display(): void { 7 | new Setting(this.containerEl) 8 | .setName('Toggle icons while editing notes') 9 | .setDesc( 10 | 'Toggles whether you are able to add and see icons in your notes and editor (e.g., ability to have :LiSofa: as an icon in your notes).', 11 | ) 12 | .addToggle((toggle) => { 13 | toggle 14 | .setValue(this.plugin.getSettings().iconsInNotesEnabled) 15 | .onChange(async (enabled) => { 16 | this.plugin.getSettings().iconsInNotesEnabled = enabled; 17 | await this.plugin.saveIconFolderData(); 18 | new Notice( 19 | `[${config.PLUGIN_NAME}] Obsidian has to be restarted for this change to take effect.`, 20 | ); 21 | }); 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/settings/ui/recentlyUsedIcons.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from 'obsidian'; 2 | import IconFolderSetting from './iconFolderSetting'; 3 | import { DEFAULT_SETTINGS } from '../data'; 4 | 5 | export default class RecentlyUsedIconsSetting extends IconFolderSetting { 6 | public display(): void { 7 | new Setting(this.containerEl) 8 | .setName('Recently used icons limit') 9 | .setDesc( 10 | 'Change the limit for the recently used icons displayed in the icon selection modal.', 11 | ) 12 | .addSlider((slider) => { 13 | slider 14 | .setLimits(1, 25, 1) 15 | .setDynamicTooltip() 16 | .setValue( 17 | this.plugin.getSettings().recentlyUsedIconsSize ?? 18 | DEFAULT_SETTINGS.recentlyUsedIconsSize, 19 | ) 20 | .onChange(async (val) => { 21 | this.plugin.getSettings().recentlyUsedIconsSize = val; 22 | await this.plugin.checkRecentlyUsedIcons(); 23 | await this.plugin.saveIconFolderData(); 24 | }); 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/settings/ui/iconPacksBackgroundChecker.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Setting } from 'obsidian'; 2 | import IconFolderSetting from './iconFolderSetting'; 3 | 4 | export default class IconPacksBackgroundChecker extends IconFolderSetting { 5 | public display(): void { 6 | new Setting(this.containerEl) 7 | .setName('Icons background check') 8 | .setDesc( 9 | 'Check in the background on every load of Obsidian, if icons are missing and it will try to add them to the specific icon pack.', 10 | ) 11 | .addToggle((toggle) => { 12 | toggle 13 | .setValue(this.plugin.getSettings().iconsBackgroundCheckEnabled) 14 | .onChange(async (enabled) => { 15 | this.plugin.getSettings().iconsBackgroundCheckEnabled = enabled; 16 | await this.plugin.saveIconFolderData(); 17 | 18 | if (enabled) { 19 | new Notice( 20 | 'You need to reload Obsidian for this to take effect.', 21 | 10000, 22 | ); 23 | } 24 | }); 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /docs/compatibility-plugins/metadatamenu.md: -------------------------------------------------------------------------------- 1 | # Metadatamenu 2 | 3 | _Link to plugin: [click here](https://github.com/mdelobelle/metadatamenu)_ 4 | 5 | --- 6 | 7 | Iconize and Metadatamenu work together, but you need to tweak a few options, 8 | if you want to use [frontmatter](../files-and-folders/use-frontmatter) and 9 | want to [see recommendations](../notes/icons-in-notes) when you want to 10 | insert a new icon in your markdown. 11 | 12 | First, according to this 13 | [GitHub issue](https://github.com/FlorianWoelki/obsidian-iconize/issues/389) 14 | you need to adjust the frontmatter field name to something else like 15 | `iconizeIcon`. 16 | 17 | _Add screenshot_ 18 | 19 | This will allow you to use frontmatter in your markdown files. 20 | 21 | To [see recommendations](../notes/icons-in-notes) when you want to insert a new 22 | icon in your markdown, you also need to adjust the icon identifier which 23 | normally starts with `:` to something else than `:`. 24 | 25 | _Add screenshot_ 26 | 27 | This will allow you to see recommendations when you want to insert a new icon 28 | in your markdown. 29 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "Obsidian Iconize" 7 | text: "Add icons everywhere" 8 | tagline: Add icons to anything you desire in Obsidian, including files, folders, and text. 9 | actions: 10 | - theme: brand 11 | text: Get Started 12 | link: /guide/getting-started 13 | - theme: brand 14 | text: API 15 | link: /api/getting-started 16 | - theme: alt 17 | text: Files and Folders 18 | link: /files-and-folders/icon-before-file-or-folder 19 | - theme: alt 20 | text: Notes 21 | link: /notes/icons-in-notes 22 | 23 | features: 24 | - title: Simplicity 25 | details: Install the plugin, download your most favorite icon pack and you are ready to go. 26 | - title: Iconize your Vault 27 | details: Set an icon almost everywhere you want. For example on a folder, file or even in a text or in a title. 28 | - title: Community Driven 29 | details: The plugin is open source and everyone can contribute bug reports, features, or ideas to it. 30 | --- 31 | -------------------------------------------------------------------------------- /src/editor/live-preview/plugins/icon-in-text.ts: -------------------------------------------------------------------------------- 1 | import IconizePlugin from '@app/main'; 2 | import { 3 | Decoration, 4 | DecorationSet, 5 | EditorView, 6 | ViewPlugin, 7 | ViewUpdate, 8 | } from '@codemirror/view'; 9 | import { buildTextDecorations } from '@app/editor/live-preview/decorations'; 10 | 11 | export const buildIconInTextPlugin = (plugin: IconizePlugin) => { 12 | return ViewPlugin.fromClass( 13 | class IconPlugin { 14 | decorations: DecorationSet; 15 | plugin: IconizePlugin; 16 | 17 | constructor(view: EditorView) { 18 | this.plugin = plugin; 19 | this.decorations = buildTextDecorations(view, plugin); 20 | } 21 | 22 | update(update: ViewUpdate) { 23 | this.decorations = buildTextDecorations(update.view, this.plugin); 24 | } 25 | }, 26 | { 27 | decorations: (v) => v.decorations, 28 | provide: (plugin) => 29 | EditorView.atomicRanges.of((view) => { 30 | const value = view.plugin(plugin); 31 | return value ? value.decorations : Decoration.none; 32 | }), 33 | }, 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Florian Woelki 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/lib/icon-cache.ts: -------------------------------------------------------------------------------- 1 | interface CacheResult { 2 | iconNameWithPrefix: string; 3 | iconColor?: string; 4 | inCustomRule?: boolean; 5 | } 6 | 7 | export class IconCache { 8 | private static instance: IconCache = new IconCache(); 9 | private cache: Map = new Map(); 10 | 11 | constructor() { 12 | if (IconCache.instance) { 13 | throw new Error( 14 | 'Error: Instantiation failed: Use `IconCache.getInstance()` instead of new.', 15 | ); 16 | } 17 | 18 | IconCache.instance = this; 19 | } 20 | 21 | public set = (path: string, result: CacheResult): void => { 22 | this.cache.set(path, result); 23 | }; 24 | 25 | public invalidate = (path: string): void => { 26 | this.cache.delete(path); 27 | }; 28 | 29 | public clear = (): void => { 30 | this.cache.clear(); 31 | }; 32 | 33 | public get = (path: string): CacheResult | null => { 34 | return this.cache.get(path) ?? null; 35 | }; 36 | 37 | public doesRecordExist = (path: string): boolean => { 38 | return this.get(path) !== null; 39 | }; 40 | 41 | public static getInstance = (): IconCache => { 42 | return IconCache.instance; 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/settings/ui/iconIdentifier.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Setting, TextComponent } from 'obsidian'; 2 | import IconFolderSetting from './iconFolderSetting'; 3 | 4 | export default class IconIdentifierSetting extends IconFolderSetting { 5 | private textComp: TextComponent; 6 | 7 | public display(): void { 8 | const setting = new Setting(this.containerEl) 9 | .setName('Icon identifier') 10 | .setDesc('Change the icon identifier used in notes.') 11 | .setClass('iconize-setting'); 12 | 13 | setting.addText((text) => { 14 | this.textComp = text; 15 | text.setValue(this.plugin.getSettings().iconIdentifier); 16 | }); 17 | 18 | setting.addButton((btn) => { 19 | btn.setButtonText('Save'); 20 | btn.onClick(async () => { 21 | const newIdentifier = this.textComp.getValue(); 22 | const oldIdentifier = this.plugin.getSettings().iconIdentifier; 23 | 24 | if (newIdentifier === oldIdentifier) { 25 | return; 26 | } 27 | 28 | this.plugin.getSettings().iconIdentifier = newIdentifier; 29 | await this.plugin.saveIconFolderData(); 30 | new Notice('...saved successfully'); 31 | }); 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/editor/live-preview/decorations/build-text-decorations.ts: -------------------------------------------------------------------------------- 1 | import IconizePlugin from '@app/main'; 2 | import { Decoration, EditorView } from '@codemirror/view'; 3 | import { editorLivePreviewField } from 'obsidian'; 4 | import { IconInTextWidget } from '@app/editor/live-preview/widgets'; 5 | 6 | export const buildTextDecorations = ( 7 | view: EditorView, 8 | plugin: IconizePlugin, 9 | ) => { 10 | const ranges: [iconId: string, from: number, to: number][] = []; 11 | const iconInfo = view.state.field(plugin.positionField); 12 | for (const { from, to } of view.visibleRanges) { 13 | iconInfo.between(from - 1, to + 1, (from, to, { iconId }) => { 14 | ranges.push([iconId, from, to]); 15 | }); 16 | } 17 | return Decoration.set( 18 | ranges.map(([code, from, to]) => { 19 | const widget = new IconInTextWidget(plugin, code); 20 | widget.setPosition(from, to); 21 | if (view.state.field(editorLivePreviewField)) { 22 | return Decoration.replace({ 23 | widget, 24 | side: -1, 25 | }).range(from, to); 26 | } 27 | 28 | return Decoration.widget({ 29 | widget, 30 | side: -1, 31 | }).range(to); 32 | }), 33 | true, 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/settings/ui/predefinedIconPacks.ts: -------------------------------------------------------------------------------- 1 | import { App, Setting } from 'obsidian'; 2 | import IconFolderSetting from './iconFolderSetting'; 3 | import IconPackBrowserModal from '@app/ui/icon-pack-browser-modal'; 4 | import IconizePlugin from '@app/main'; 5 | 6 | export default class PredefinedIconPacksSetting extends IconFolderSetting { 7 | private app: App; 8 | private refreshDisplay: () => void; 9 | 10 | constructor( 11 | plugin: IconizePlugin, 12 | containerEl: HTMLElement, 13 | app: App, 14 | refreshDisplay: () => void, 15 | ) { 16 | super(plugin, containerEl); 17 | this.app = app; 18 | this.refreshDisplay = refreshDisplay; 19 | } 20 | 21 | public display(): void { 22 | new Setting(this.containerEl) 23 | .setName('Add predefined icon pack') 24 | .setDesc('Add a predefined icon pack that is officially supported.') 25 | .addButton((btn) => { 26 | btn.setButtonText('Browse icon packs'); 27 | btn.onClick(() => { 28 | const modal = new IconPackBrowserModal(this.app, this.plugin); 29 | modal.onAddedIconPack = () => { 30 | this.refreshDisplay(); 31 | }; 32 | modal.open(); 33 | }); 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/settings/ui/iconColor.ts: -------------------------------------------------------------------------------- 1 | import { Setting, ColorComponent } from 'obsidian'; 2 | import IconFolderSetting from './iconFolderSetting'; 3 | import helper from '../helper'; 4 | import { ResetButtonComponent } from '../ResetButtonComponent'; 5 | import { DEFAULT_SETTINGS } from '../data'; 6 | 7 | const DEFAULT_VALUE = DEFAULT_SETTINGS.iconColor; 8 | 9 | export default class IconColorSetting extends IconFolderSetting { 10 | public display(): void { 11 | const setting = new Setting(this.containerEl) 12 | .setName('Icon color') 13 | .setDesc('Change the color of the displayed icons.'); 14 | 15 | new ResetButtonComponent(setting.controlEl).onClick(async () => { 16 | colorPicker.setValue(DEFAULT_VALUE); 17 | this.plugin.getSettings().iconColor = null; 18 | // Custom saving to not save the color black in the data. 19 | await this.plugin.saveIconFolderData(); 20 | helper.refreshStyleOfIcons(this.plugin); 21 | }); 22 | 23 | const colorPicker = new ColorComponent(setting.controlEl) 24 | .setValue(this.plugin.getSettings().iconColor ?? DEFAULT_VALUE) 25 | .onChange(async (value) => { 26 | this.plugin.getSettings().iconColor = value; 27 | await this.plugin.saveIconFolderData(); 28 | 29 | helper.refreshStyleOfIcons(this.plugin); 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/unit-test.yml: -------------------------------------------------------------------------------- 1 | name: 'Unit Tests' 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | cache-and-install: 11 | runs-on: ubuntu-latest 12 | 13 | permissions: 14 | contents: read 15 | pull-requests: write 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Install Node.js 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: 16 24 | 25 | - name: Install pnpm 26 | uses: pnpm/action-setup@v2 27 | with: 28 | version: 8 29 | run_install: false 30 | 31 | - name: Get pnpm store directory 32 | shell: bash 33 | run: | 34 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 35 | 36 | - name: Setup pnpm cache 37 | uses: actions/cache@v3 38 | with: 39 | path: ${{ env.STORE_PATH }} 40 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 41 | restore-keys: | 42 | ${{ runner.os }}-pnpm-store- 43 | 44 | - name: Install dependencies 45 | run: pnpm install 46 | 47 | - name: Run unit tests 48 | run: pnpm test:coverage 49 | 50 | - name: Report Coverage 51 | if: always() 52 | uses: davelosert/vitest-coverage-report-action@v2 53 | -------------------------------------------------------------------------------- /src/settings/ui/iconFontSize.ts: -------------------------------------------------------------------------------- 1 | import { Setting, SliderComponent } from 'obsidian'; 2 | import IconFolderSetting from './iconFolderSetting'; 3 | import { DEFAULT_SETTINGS } from '../data'; 4 | import helper from '../helper'; 5 | import { ResetButtonComponent } from '../ResetButtonComponent'; 6 | 7 | const values = { 8 | min: 10, 9 | max: 64, 10 | default: DEFAULT_SETTINGS.fontSize, 11 | step: 1, 12 | }; 13 | 14 | export default class IconFontSizeSetting extends IconFolderSetting { 15 | private slider: SliderComponent; 16 | 17 | public display(): void { 18 | const setting = new Setting(this.containerEl) 19 | .setName('Icon font size (in pixels)') 20 | .setDesc('Change the font size of the displayed icons.'); 21 | 22 | new ResetButtonComponent(setting.controlEl).onClick(() => { 23 | this.slider.setValue(values.default); 24 | }); 25 | 26 | setting.addSlider((slider) => { 27 | this.slider = slider; 28 | slider 29 | .setLimits(values.min, values.max, values.step) 30 | .setDynamicTooltip() 31 | .setValue( 32 | this.plugin.getSettings().fontSize ?? DEFAULT_SETTINGS.fontSize, 33 | ) 34 | .onChange(async (val) => { 35 | this.plugin.getSettings().fontSize = val; 36 | await this.plugin.saveIconFolderData(); 37 | 38 | helper.refreshStyleOfIcons(this.plugin); 39 | }); 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/settings/helper.ts: -------------------------------------------------------------------------------- 1 | import customRule from '@lib/custom-rule'; 2 | import style from '@lib/util/style'; 3 | import IconizePlugin from '@app/main'; 4 | import { getFileItemTitleEl } from '@app/util'; 5 | import svg from '@app/lib/util/svg'; 6 | 7 | /** 8 | * Helper function that refreshes the style of all the icons that are defined 9 | * or in a custom rule involved. 10 | * @param plugin Instance of the IconizePlugin. 11 | */ 12 | const refreshStyleOfIcons = async (plugin: IconizePlugin): Promise => { 13 | // Refreshes the icon style for all normally added icons. 14 | style.refreshIconNodes(plugin); 15 | 16 | // Refreshes the icon style for all custom icon rules, when the color of the rule is 17 | // not defined. 18 | for (const rule of customRule.getSortedRules(plugin)) { 19 | const fileItems = await customRule.getFileItems(plugin, rule); 20 | for (const fileItem of fileItems) { 21 | const titleEl = getFileItemTitleEl(fileItem); 22 | const iconNode = titleEl.querySelector('.iconize-icon') as HTMLElement; 23 | let iconContent = iconNode.innerHTML; 24 | 25 | iconContent = style.applyAll(plugin, iconContent, iconNode); 26 | 27 | if (rule.color) { 28 | iconContent = svg.colorize(iconContent, rule.color); 29 | iconNode.style.color = rule.color; 30 | } 31 | 32 | iconNode.innerHTML = iconContent; 33 | } 34 | } 35 | }; 36 | 37 | export default { 38 | refreshStyleOfIcons, 39 | }; 40 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy VitePress site to Pages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | # Allows you to run this workflow manually from the Actions tab. 8 | workflow_dispatch: 9 | 10 | permissions: 11 | contents: read 12 | pages: write 13 | id-token: write 14 | 15 | concurrency: 16 | group: pages 17 | cancel-in-progress: false 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v3 25 | with: 26 | fetch-depth: 0 27 | - uses: pnpm/action-setup@v2 28 | with: 29 | version: 8 30 | - name: Setup Node 31 | uses: actions/setup-node@v3 32 | with: 33 | node-version: 18 34 | cache: pnpm 35 | - name: Setup Pages 36 | uses: actions/configure-pages@v3 37 | - name: Install dependencies 38 | run: pnpm install 39 | - name: Build with VitePress 40 | run: | 41 | pnpm docs:build 42 | touch docs/.vitepress/dist/.nojekyll 43 | - name: Upload artifact 44 | uses: actions/upload-pages-artifact@v3 45 | with: 46 | path: docs/.vitepress/dist 47 | 48 | deploy: 49 | environment: 50 | name: github-pages 51 | url: ${{ steps.deployment.outputs.page_url }} 52 | needs: build 53 | runs-on: ubuntu-latest 54 | name: Deploy 55 | steps: 56 | - name: Deploy to GitHub Pages 57 | id: deployment 58 | uses: actions/deploy-pages@v4 59 | -------------------------------------------------------------------------------- /src/settings/ui/iconPacksPath.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Setting, TextComponent } from 'obsidian'; 2 | import IconFolderSetting from './iconFolderSetting'; 3 | 4 | export default class IconPacksPathSetting extends IconFolderSetting { 5 | private iconPacksSettingTextComp: TextComponent; 6 | 7 | public display(): void { 8 | const iconPacksPathSetting = new Setting(this.containerEl) 9 | .setName('Icon packs folder path') 10 | .setDesc('Change the default icon packs folder path.'); 11 | 12 | iconPacksPathSetting.addText((text) => { 13 | this.iconPacksSettingTextComp = text; 14 | text.setValue(this.plugin.getSettings().iconPacksPath); 15 | }); 16 | 17 | iconPacksPathSetting.addButton((btn) => { 18 | btn.setButtonText('Save'); 19 | btn.onClick(async () => { 20 | const newPath = this.iconPacksSettingTextComp.getValue(); 21 | const oldPath = this.plugin.getSettings().iconPacksPath; 22 | 23 | if (oldPath === this.iconPacksSettingTextComp.getValue()) { 24 | return; 25 | } 26 | 27 | new Notice('Saving in progress...'); 28 | this.plugin.getIconPackManager().setPath(newPath); 29 | await this.plugin.getIconPackManager().createDefaultDirectory(); 30 | await this.plugin 31 | .getIconPackManager() 32 | .moveIconPackDirectories(oldPath, newPath); 33 | 34 | this.plugin.getSettings().iconPacksPath = newPath; 35 | await this.plugin.saveIconFolderData(); 36 | new Notice('...saved successfully'); 37 | }); 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/settings/ui/toggleIconInTabs.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from 'obsidian'; 2 | import iconTabs from '@lib/icon-tabs'; 3 | import { TabHeaderLeaf } from '@app/@types/obsidian'; 4 | import IconFolderSetting from './iconFolderSetting'; 5 | 6 | export default class ToggleIconInTabs extends IconFolderSetting { 7 | public display(): void { 8 | new Setting(this.containerEl) 9 | .setName('Toggle icon in tabs') 10 | .setDesc('Toggles the visibility of an icon for a file in the tab bar.') 11 | .addToggle((toggle) => { 12 | toggle 13 | .setValue(this.plugin.getSettings().iconInTabsEnabled) 14 | .onChange(async (enabled) => { 15 | this.plugin.getSettings().iconInTabsEnabled = enabled; 16 | await this.plugin.saveIconFolderData(); 17 | 18 | // Updates the already opened files. 19 | this.plugin.app.workspace 20 | .getLeavesOfType('markdown') 21 | .forEach((leaf) => { 22 | const file = leaf.view.file; 23 | if (file) { 24 | const tabHeaderLeaf = leaf as TabHeaderLeaf; 25 | if (enabled) { 26 | // Adds the icons to already opened files. 27 | iconTabs.add( 28 | this.plugin, 29 | file.path, 30 | tabHeaderLeaf.tabHeaderInnerIconEl, 31 | ); 32 | } else { 33 | // Removes the icons from already opened files. 34 | iconTabs.remove(tabHeaderLeaf.tabHeaderInnerIconEl); 35 | } 36 | } 37 | }); 38 | }); 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/editor/live-preview/widgets/icon-in-link.ts: -------------------------------------------------------------------------------- 1 | import emoji from '@app/emoji'; 2 | import { Icon } from '@app/icon-pack-manager'; 3 | import { 4 | calculateFontTextSize, 5 | calculateHeaderSize, 6 | HeaderToken, 7 | } from '@app/lib/util/text'; 8 | import svg from '@app/lib/util/svg'; 9 | import IconizePlugin from '@app/main'; 10 | import { WidgetType } from '@codemirror/view'; 11 | 12 | export class IconInLinkWidget extends WidgetType { 13 | constructor( 14 | private plugin: IconizePlugin, 15 | private iconData: Icon | string, 16 | private path: string, 17 | private headerType: HeaderToken | null, 18 | ) { 19 | super(); 20 | } 21 | 22 | toDOM() { 23 | const iconNode = document.createElement('span'); 24 | const iconName = 25 | typeof this.iconData === 'string' 26 | ? this.iconData 27 | : this.iconData.prefix + this.iconData.name; 28 | iconNode.style.color = 29 | this.plugin.getIconColor(this.path) ?? 30 | this.plugin.getSettings().iconColor; 31 | iconNode.setAttribute('title', iconName); 32 | iconNode.classList.add('iconize-icon-in-link'); 33 | 34 | if (typeof this.iconData === 'string') { 35 | iconNode.style.transform = 'translateY(0)'; 36 | } 37 | 38 | let innerHTML = 39 | typeof this.iconData === 'string' 40 | ? this.iconData 41 | : this.iconData.svgElement; 42 | 43 | let fontSize = calculateFontTextSize(); 44 | if (this.headerType) { 45 | fontSize = calculateHeaderSize(this.headerType); 46 | } 47 | 48 | if (emoji.isEmoji(innerHTML)) { 49 | innerHTML = emoji.parseEmoji( 50 | this.plugin.getSettings().emojiStyle, 51 | innerHTML, 52 | fontSize, 53 | ); 54 | } else { 55 | innerHTML = svg.setFontSize(innerHTML, fontSize); 56 | } 57 | 58 | iconNode.innerHTML = innerHTML; 59 | return iconNode; 60 | } 61 | 62 | ignoreEvent(): boolean { 63 | return true; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/event/event.ts: -------------------------------------------------------------------------------- 1 | import type { EventType, EventMap, AnyEvent } from './events'; 2 | 3 | export interface Event

{ 4 | payload?: P; 5 | } 6 | 7 | type EventListener = (event: E) => void; 8 | 9 | interface ListenerEntry { 10 | listener: EventListener; 11 | once: boolean; 12 | priority: number; 13 | } 14 | 15 | export class EventEmitter { 16 | private listeners: { [K in EventType]?: ListenerEntry[] } = {}; 17 | 18 | on( 19 | type: K, 20 | listener: EventListener, 21 | priority = 0, 22 | ): void { 23 | this.listeners[type] ??= []; 24 | this.listeners[type]?.push({ listener, once: false, priority }); 25 | this.sortListeners(type); 26 | } 27 | 28 | once( 29 | type: K, 30 | listener: EventListener, 31 | priority = 0, 32 | ): void { 33 | this.listeners[type] ??= []; 34 | this.listeners[type]?.push({ listener, once: true, priority }); 35 | this.sortListeners(type); 36 | } 37 | 38 | off( 39 | type: K, 40 | listener: EventListener, 41 | ): void { 42 | if (!this.listeners[type]) { 43 | return; 44 | } 45 | 46 | this.listeners[type] = this.listeners[type]?.filter( 47 | (entry) => entry.listener !== listener, 48 | ); 49 | } 50 | 51 | emit(type: K, payload?: EventMap[K]['payload']): void { 52 | const listeners = this.listeners[type]; 53 | if (!listeners) { 54 | return; 55 | } 56 | 57 | const event = { payload } as EventMap[K]; 58 | listeners.slice().forEach((entry) => { 59 | entry.listener(event); 60 | if (entry.once) { 61 | this.off(type, entry.listener); 62 | } 63 | }); 64 | } 65 | 66 | private sortListeners(type: EventType): void { 67 | if (this.listeners[type]) { 68 | this.listeners[type]?.sort((a, b) => b.priority - a.priority); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /docs/guide/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting Started | Obsidian Iconize 3 | --- 4 | 5 | # Getting Started 6 | 7 | This obsidian plugin allows you to add **any** custom icon (of type `.svg`) or from an 8 | icon pack to anything you want in your Vault. This can be a file, a folder, in a title, or 9 | even in a paragraph of your notes. 10 | 11 | ## Installation 12 | 13 | For installing the plugin, you can either install it from the community plugins or download 14 | the latest release from the 15 | [GitHub releases](https://github.com/FlorianWoelki/obsidian-iconize/releases/). 16 | 17 | This was all you need to do to install the plugin. Now you can start using it. 18 | 19 | ## Usage 20 | 21 | Using the plugin is really straightforward. Obviously, Iconize has a lot of other features, 22 | but the most important one is to add icons to your files or folders. 23 | 24 | First of all, you need an icon pack. You can either use one of the predefined icon packs or 25 | add your own. For adding your own, please read the documentation about 26 | custom icon packs. 27 | 28 | For adding a predefined icon pack, you can go to the settings of the plugin and select 29 | `Browse icon packs` and then select the icon pack you want to use. 30 | 31 | After installing the icon pack, you just need to *right-click* on a file or folder and 32 | then select the option `Change Icon`. This will open a modal where you can select the 33 | icon you want to use. 34 | 35 | ## Support the Project 36 | 37 | Our vision is to let you add Icons to your Obsidian Vault whereever you want. We want to make it as easy as possible to add Icons to your notes, files, or folders. 38 | 39 | This project is **open source** and **free to use**. If you like it, please consider supporting us 40 | 41 | Buy Me A Coffee 42 | -------------------------------------------------------------------------------- /src/settings/ui/extraMargin.ts: -------------------------------------------------------------------------------- 1 | import { DropdownComponent, Setting, SliderComponent } from 'obsidian'; 2 | import IconFolderSetting from './iconFolderSetting'; 3 | import { ExtraMarginSettings } from '../data'; 4 | import helper from '../helper'; 5 | 6 | export default class ExtraMarginSetting extends IconFolderSetting { 7 | public display(): void { 8 | const extraMarginSetting = new Setting(this.containerEl) 9 | .setName('Extra margin (in pixels)') 10 | .setDesc('Change the margin of the icons.') 11 | .setClass('iconize-setting'); 12 | 13 | const extraMarginDropdown = new DropdownComponent( 14 | extraMarginSetting.controlEl, 15 | ).addOptions({ 16 | top: 'Top', 17 | right: 'Right', 18 | bottom: 'Bottom', 19 | left: 'Left', 20 | } as Record); 21 | 22 | const extraMarginSlider = new SliderComponent(extraMarginSetting.controlEl) 23 | .setLimits(-24, 24, 1) 24 | .setDynamicTooltip() 25 | .setValue(this.plugin.getSettings().extraMargin?.top ?? 2) 26 | .onChange(async (val) => { 27 | const dropdownValue = 28 | extraMarginDropdown.getValue() as keyof ExtraMarginSettings; 29 | if (this.plugin.getSettings().extraMargin) { 30 | this.plugin.getSettings().extraMargin[dropdownValue] = val; 31 | } else { 32 | this.plugin.getSettings().extraMargin = { 33 | [dropdownValue]: val, 34 | }; 35 | } 36 | await this.plugin.saveIconFolderData(); 37 | helper.refreshStyleOfIcons(this.plugin); 38 | }); 39 | 40 | extraMarginDropdown.onChange((val: keyof ExtraMarginSettings) => { 41 | if (this.plugin.getSettings().extraMargin) { 42 | extraMarginSlider.setValue( 43 | this.plugin.getSettings().extraMargin[val] ?? 2, 44 | ); 45 | } else { 46 | extraMarginSlider.setValue(2); 47 | } 48 | }); 49 | 50 | extraMarginSetting.components.push(extraMarginDropdown, extraMarginSlider); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /docs/guide/syncing.md: -------------------------------------------------------------------------------- 1 | # Syncing 2 | 3 | Normally, syncing should work across all devices with established cloud providers. 4 | However, if you want to sync your data across devices with Obsidian Sync and 5 | possibly have thousands of icons, you need to try the following configuration 6 | (detailed discussions can be found 7 | [here](https://github.com/obsidianmd/obsidian-api/issues/134)) for a successful 8 | syncing process: 9 | 10 | Try setting the Iconize icon packs folder path to `.obsidian/icons`. Setting the 11 | icon packs path to this specific path **does not** sync the icon packs and you have 12 | to sync them manually. This won't clog up the synchronization process of Obsidian Sync. 13 | 14 | You also need to enable the [background checker](#background-checker). Your settings 15 | should look like this: 16 | 17 | ![syncing-icon-packs](../assets/syncing-icon-packs.png) 18 | 19 | ## Lucide Icons 20 | 21 | Obsidian supports Lucide icons by default. 22 | This native integration brings several advantages, such as there is no additional download required and sync seamlessly across all your devices. 23 | That's why I highly advise, if you are experiencing syncing issues, exclusively use the Lucide icons for now, which are installed by default, and remove all other icon packs from your Obsidian vault. 24 | 25 | When the the native Lucide icon pack is disabled, it will look something like this: 26 | 27 | ![syncing-icon-packs](../assets/lucide-native-icon-pack.png) 28 | 29 | ## Background Checker 30 | 31 | ::: tip NOTE 32 | 33 | The background checker is not only useful for syncing. It is also useful for when you 34 | want to download icons that are set in your vault but are not yet available in the 35 | icon packs. 36 | 37 | ::: 38 | 39 | Next to setting the icon packs path to `.obsidian/icons`, you should also enable the 40 | background checker. This will check if icons are missing and will download them in the 41 | background and extract them to the correct icon pack folder. In addition, it will also 42 | remove unused icon files from the icon packs folder (the icons will still be available). 43 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import alias from '@rollup/plugin-alias'; 5 | import copyFile from './copy-file.js'; 6 | import { obsidianExportPath } from './env.js'; 7 | 8 | const isProd = process.env.BUILD === 'production'; 9 | 10 | const banner = `/* 11 | THIS IS A GENERATED/BUNDLED FILE BY ROLLUP 12 | if you want to view the source visit the plugins github repository 13 | */ 14 | `; 15 | 16 | const cmModules = [ 17 | '@codemirror/autocomplete', 18 | '@codemirror/closebrackets', 19 | '@codemirror/collab', 20 | '@codemirror/comment', 21 | '@codemirror/commands', 22 | '@codemirror/fold', 23 | '@codemirror/highlight', 24 | '@codemirror/gutter', 25 | '@codemirror/history', 26 | '@codemirror/language', 27 | '@codemirror/lint', 28 | '@codemirror/matchbrackets', 29 | '@codemirror/search', 30 | '@codemirror/state', 31 | '@codemirror/stream-parser', 32 | '@codemirror/rangeset', 33 | '@codemirror/panel', 34 | '@codemirror/matchbrackets', 35 | '@codemirror/text', 36 | '@codemirror/tooltip', 37 | '@codemirror/view', 38 | ]; 39 | 40 | export default { 41 | input: './src/main.ts', 42 | output: { 43 | dir: '.', 44 | sourcemap: 'inline', 45 | sourcemapExcludeSources: isProd, 46 | format: 'cjs', 47 | exports: 'default', 48 | banner, 49 | }, 50 | external: ['obsidian', ...cmModules], 51 | plugins: [ 52 | alias({ 53 | entries: { 54 | '@app': 'src', 55 | '@lib': 'src/lib', 56 | }, 57 | }), 58 | typescript(), 59 | nodeResolve({ browser: true }), 60 | commonjs(), 61 | copyFile({ 62 | targets: [ 63 | { src: './main.js', dest: obsidianExportPath }, 64 | { src: './manifest.json', dest: obsidianExportPath }, 65 | { src: './src/styles.css', dest: obsidianExportPath }, 66 | ], 67 | }), 68 | ], 69 | onwarn: (warning) => { 70 | if (warning.code === 'THIS_IS_UNDEFINED') return; 71 | 72 | console.warn(warning.message); 73 | }, 74 | }; 75 | -------------------------------------------------------------------------------- /src/zip-util.ts: -------------------------------------------------------------------------------- 1 | import { loadAsync, JSZipObject } from 'jszip'; 2 | import { requestUrl } from 'obsidian'; 3 | 4 | /** 5 | * Download a zip file from a url and return the bytes of the file as an ArrayBuffer. 6 | * @param url String url of the zip file to download. 7 | * @returns ArrayBuffer of the zip file. 8 | */ 9 | export const downloadZipFile = async (url: string): Promise => { 10 | const fetched = await requestUrl({ url }); 11 | const bytes = fetched.arrayBuffer; 12 | return bytes; 13 | }; 14 | 15 | /** 16 | * Transforms a JSZip file into a File object. 17 | * @param file JSZip file to transform. 18 | * @returns File object of the JSZip file. 19 | */ 20 | export const getFileFromJSZipFile = async ( 21 | file: JSZipObject, 22 | ): Promise => { 23 | const fileData = await file.async('blob'); 24 | const filename = file.name.split('/').pop(); 25 | return new File([fileData], filename); 26 | }; 27 | 28 | /** 29 | * Read a zip file and return the files inside it. 30 | * @param bytes ArrayBuffer of the zip file. 31 | * @param extraPath String path to filter the files inside the zip file. This can be used 32 | * to set an extra path (like a directory inside the zip file) to filter the files. 33 | * @returns Array of loaded files inside the zip file. 34 | */ 35 | export const readZipFile = async ( 36 | bytes: ArrayBuffer, 37 | extraPath = '', 38 | ): Promise => { 39 | const unzippedFiles = await loadAsync(bytes); 40 | return Promise.resolve(unzippedFiles).then((unzipped) => { 41 | if (!Object.keys(unzipped.files).length) { 42 | return Promise.reject('No file was found'); 43 | } 44 | 45 | const files: JSZipObject[] = []; 46 | // Regex for retrieving the files inside the zip file or inside the directory of a 47 | // zip file. 48 | const regex = new RegExp(extraPath + '(.+)\\.svg', 'g'); 49 | Object.entries(unzippedFiles.files).forEach( 50 | ([_, v]: [string, JSZipObject]) => { 51 | const matched = v.name.match(regex); 52 | if (!v.dir && matched && matched.length > 0) { 53 | files.push(v); 54 | } 55 | }, 56 | ); 57 | 58 | return files; 59 | }); 60 | }; 61 | -------------------------------------------------------------------------------- /src/lib/event/event.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, vi, expect, beforeEach } from 'vitest'; 2 | import { EventEmitter } from './event'; 3 | 4 | describe('EventEmitter', () => { 5 | let emitter: EventEmitter; 6 | 7 | beforeEach(() => { 8 | emitter = new EventEmitter(); 9 | }); 10 | 11 | it('should register and emit event listeners', () => { 12 | const listener = vi.fn(); 13 | emitter.on('allIconsLoaded', listener); 14 | 15 | emitter.emit('allIconsLoaded'); 16 | 17 | expect(listener).toHaveBeenCalledWith({ payload: undefined }); 18 | expect(listener).toHaveBeenCalledTimes(1); 19 | }); 20 | 21 | it('should unregister event listeners', () => { 22 | const listener = vi.fn(); 23 | emitter.on('allIconsLoaded', listener); 24 | emitter.off('allIconsLoaded', listener); 25 | 26 | emitter.emit('allIconsLoaded'); 27 | 28 | expect(listener).not.toHaveBeenCalled(); 29 | }); 30 | 31 | it('should support once-only event listeners', () => { 32 | const listener = vi.fn(); 33 | emitter.once('allIconsLoaded', listener); 34 | 35 | emitter.emit('allIconsLoaded'); 36 | emitter.emit('allIconsLoaded'); 37 | 38 | expect(listener).toHaveBeenCalledWith({ 39 | payload: undefined, 40 | }); 41 | expect(listener).toHaveBeenCalledTimes(1); 42 | }); 43 | 44 | it('should support priority listeners', () => { 45 | const listener1 = vi.fn(); 46 | const listener2 = vi.fn(); 47 | const listener3 = vi.fn(); 48 | 49 | emitter.on('allIconsLoaded', listener1, 1); 50 | emitter.on('allIconsLoaded', listener2, 2); 51 | emitter.on('allIconsLoaded', listener3, 3); 52 | 53 | emitter.emit('allIconsLoaded'); 54 | 55 | expect(listener3).toHaveBeenCalled(); 56 | expect(listener2).toHaveBeenCalled(); 57 | expect(listener1).toHaveBeenCalled(); 58 | 59 | const listener3CallOrder = listener3.mock.invocationCallOrder[0]; 60 | const listener2CallOrder = listener2.mock.invocationCallOrder[0]; 61 | const listener1CallOrder = listener1.mock.invocationCallOrder[0]; 62 | 63 | expect(listener3CallOrder).toBeLessThan(listener2CallOrder); 64 | expect(listener2CallOrder).toBeLessThan(listener1CallOrder); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/migrations/0003-inheritance-to-custom-rule.ts: -------------------------------------------------------------------------------- 1 | import config from '@app/config'; 2 | import customRule from '@app/lib/custom-rule'; 3 | import IconizePlugin from '@app/main'; 4 | import { CustomRule } from '@app/settings/data'; 5 | import { Notice } from 'obsidian'; 6 | 7 | interface FolderIconObject { 8 | iconName: string | null; 9 | inheritanceIcon?: string; 10 | iconColor?: string; 11 | } 12 | 13 | export default async function migrate(plugin: IconizePlugin): Promise { 14 | // Migration for inheritance to custom rule. 15 | if (plugin.getSettings().migrated === 3) { 16 | let hasRemovedInheritance = false; 17 | for (const [key, value] of Object.entries(plugin.getData())) { 18 | if (key === 'settings' || typeof value !== 'object') { 19 | continue; 20 | } 21 | 22 | const folderData = value as FolderIconObject; 23 | const inheritanceIcon = folderData.inheritanceIcon; 24 | if (!inheritanceIcon) { 25 | continue; 26 | } 27 | 28 | const folderIconName = folderData.iconName; 29 | 30 | // Clean up old data. 31 | if (folderData.iconColor && folderIconName) { 32 | delete folderData.inheritanceIcon; 33 | } else if (folderIconName) { 34 | delete plugin.getData()[key]; 35 | plugin.getData()[key] = folderIconName; 36 | } else if (!folderIconName) { 37 | delete plugin.getData()[key]; 38 | } 39 | 40 | const folderPath = key + '\\/[\\w\\d\\s]+'; 41 | const newRule = { 42 | icon: inheritanceIcon, 43 | rule: `${folderPath}\\.(?:\\w+\\.)*\\w+`, 44 | for: 'files', 45 | order: 0, 46 | useFilePath: true, 47 | } as CustomRule; 48 | 49 | // Reorder existing custom rules so that the new inheritance custom rule 50 | // is at the top. 51 | plugin.getSettings().rules.map((rule) => { 52 | rule.order++; 53 | }); 54 | plugin.getSettings().rules.unshift(newRule); 55 | 56 | // Apply the custom rule. 57 | await customRule.addToAllFiles(plugin, newRule); 58 | hasRemovedInheritance = true; 59 | } 60 | 61 | if (hasRemovedInheritance) { 62 | new Notice( 63 | `[${config.PLUGIN_NAME}] Inheritance has been removed and replaced with custom rules.`, 64 | ); 65 | } 66 | 67 | plugin.getSettings().migrated++; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/emoji.test.ts: -------------------------------------------------------------------------------- 1 | import { Mock, describe, expect, it, vi } from 'vitest'; 2 | import twemoji from '@twemoji/api'; 3 | import emoji from './emoji'; 4 | 5 | vi.mock('@twemoji/api'); 6 | 7 | describe('isEmoji', () => { 8 | it('should return `true` for valid emojis', () => { 9 | expect(emoji.isEmoji('👍')).toBe(true); 10 | expect(emoji.isEmoji('🇺🇸')).toBe(true); 11 | expect(emoji.isEmoji('🤔')).toBe(true); 12 | expect(emoji.isEmoji('😂')).toBe(true); 13 | expect(emoji.isEmoji('👍🏼')).toBe(true); 14 | expect(emoji.isEmoji('😂😂')).toBe(true); 15 | }); 16 | 17 | it('should return `false` for invalid emojis', () => { 18 | expect(emoji.isEmoji('hello')).toBe(false); 19 | expect(emoji.isEmoji('123')).toBe(false); 20 | expect(emoji.isEmoji('*')).toBe(false); 21 | expect(emoji.isEmoji('-')).toBe(false); 22 | expect(emoji.isEmoji('#')).toBe(false); 23 | expect(emoji.isEmoji('+')).toBe(false); 24 | }); 25 | }); 26 | 27 | describe('getShortcode', () => { 28 | it('should replace whitespaces with underscores', () => { 29 | expect(emoji.getShortcode('👍')).toBe('thumbs_up'); 30 | expect(emoji.getShortcode('🤔')).toBe('thinking_face'); 31 | expect(emoji.getShortcode('😂')).toBe('face_with_tears_of_joy'); 32 | }); 33 | 34 | it('should replace colons with an empty string', () => { 35 | expect(emoji.getShortcode('🇺🇸')).toBe('flag_united_states'); 36 | expect(emoji.getShortcode('🏴󠁧󠁢󠁥󠁮󠁧󠁿')).toBe('flag_england'); 37 | }); 38 | 39 | it('should return `undefined` for invalid emojis', () => { 40 | expect(emoji.getShortcode('hello')).toBe(undefined); 41 | expect(emoji.getShortcode('123')).toBe(undefined); 42 | expect(emoji.getShortcode('🤗 hello')).toBe(undefined); 43 | expect(emoji.getShortcode('hello 🤗')).toBe(undefined); 44 | }); 45 | }); 46 | 47 | describe('parseEmoji', () => { 48 | it('should return emoji when emojiStyle is `native`', () => { 49 | expect(emoji.parseEmoji('native', '👍')).toBe('👍'); 50 | expect(emoji.parseEmoji('native', '🤔')).toBe('🤔'); 51 | expect(emoji.parseEmoji('native', '😂')).toBe('😂'); 52 | }); 53 | 54 | it('should call twemoji.parse when emojiStyle is `twemoji`', () => { 55 | (twemoji.parse as Mock).mockImplementation(() => '👍'); 56 | emoji.parseEmoji('twemoji', '👍'); 57 | expect(twemoji.parse).toHaveBeenCalledTimes(1); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/lib/icon-cache.test.ts: -------------------------------------------------------------------------------- 1 | import { IconCache } from './icon-cache'; 2 | import { describe, expect, it } from 'vitest'; 3 | 4 | describe('set', () => { 5 | it('should add a record', () => { 6 | const path = 'path'; 7 | const record = { iconNameWithPrefix: 'IbTest' }; 8 | 9 | IconCache.getInstance().set(path, record); 10 | const result = IconCache.getInstance().get(path); 11 | expect(result).toEqual(record); 12 | }); 13 | }); 14 | 15 | describe('get', () => { 16 | it('should return `null` for a non-existent record', () => { 17 | const path = 'non-existent-path'; 18 | const result = IconCache.getInstance().get(path); 19 | expect(result).toBeNull(); 20 | }); 21 | 22 | it('should return a record that was added', () => { 23 | const path = 'path'; 24 | const record = { iconNameWithPrefix: 'IbTest' }; 25 | 26 | IconCache.getInstance().set(path, record); 27 | const result = IconCache.getInstance().get(path); 28 | expect(result).toEqual(record); 29 | }); 30 | }); 31 | 32 | describe('doesRecordExist', () => { 33 | it('should return `false` for a non-existent record', () => { 34 | const path = 'non-existent-path'; 35 | const result = IconCache.getInstance().doesRecordExist(path); 36 | expect(result).toBe(false); 37 | }); 38 | 39 | it('should return `true` for a record that was added', () => { 40 | const path = 'path'; 41 | const record = { iconNameWithPrefix: 'IbTest' }; 42 | 43 | IconCache.getInstance().set(path, record); 44 | const result = IconCache.getInstance().doesRecordExist(path); 45 | expect(result).toBe(true); 46 | }); 47 | }); 48 | 49 | describe('invalidate', () => { 50 | it('should invalidate a record', () => { 51 | const path = 'path'; 52 | const record = { iconNameWithPrefix: 'IbTest' }; 53 | 54 | IconCache.getInstance().set(path, record); 55 | IconCache.getInstance().invalidate(path); 56 | const result = IconCache.getInstance().get(path); 57 | expect(result).toBeNull(); 58 | }); 59 | }); 60 | 61 | describe('clear', () => { 62 | it('should clear all records', () => { 63 | const path = 'path'; 64 | const record = { iconNameWithPrefix: 'IbTest' }; 65 | 66 | IconCache.getInstance().set(path, record); 67 | IconCache.getInstance().clear(); 68 | const result = IconCache.getInstance().get(path); 69 | expect(result).toBeNull(); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/editor/live-preview/decorations/build-link-decorations.ts: -------------------------------------------------------------------------------- 1 | import IconizePlugin from '@app/main'; 2 | import { Decoration, EditorView } from '@codemirror/view'; 3 | import { MarkdownView, editorInfoField } from 'obsidian'; 4 | import { RangeSetBuilder } from '@codemirror/state'; 5 | import { syntaxTree, tokenClassNodeProp } from '@codemirror/language'; 6 | import icon from '@lib/icon'; 7 | import { IconInLinkWidget } from '@app/editor/live-preview/widgets'; 8 | import { HeaderToken } from '@app/lib/util/text'; 9 | 10 | export const buildLinkDecorations = ( 11 | view: EditorView, 12 | plugin: IconizePlugin, 13 | ) => { 14 | const builder = new RangeSetBuilder(); 15 | const mdView = view.state.field(editorInfoField) as MarkdownView; 16 | 17 | for (const { from, to } of view.visibleRanges) { 18 | syntaxTree(view.state).iterate({ 19 | from, 20 | to, 21 | enter: (node) => { 22 | const tokenProps = node.type.prop(tokenClassNodeProp); 23 | if (tokenProps) { 24 | const props = new Set(tokenProps.split(' ')); 25 | const isLink = props.has('hmd-internal-link'); 26 | const headerType = [ 27 | 'header-1', 28 | 'header-2', 29 | 'header-3', 30 | 'header-4', 31 | 'header-5', 32 | 'header-6', 33 | ].find((header) => props.has(header)) as HeaderToken | null; 34 | 35 | if (isLink) { 36 | let linkText = view.state.doc.sliceString(node.from, node.to); 37 | linkText = linkText.split('#')[0]; 38 | const file = plugin.app.metadataCache.getFirstLinkpathDest( 39 | linkText, 40 | mdView.file.basename, 41 | ); 42 | 43 | if (file) { 44 | const possibleIcon = icon.getIconByPath(plugin, file.path); 45 | 46 | if (possibleIcon) { 47 | const iconDecoration = Decoration.widget({ 48 | widget: new IconInLinkWidget( 49 | plugin, 50 | possibleIcon, 51 | file.path, 52 | headerType, 53 | ), 54 | }); 55 | 56 | builder.add(node.from, node.from, iconDecoration); 57 | } 58 | } 59 | } 60 | } 61 | }, 62 | }); 63 | } 64 | 65 | return builder.finish(); 66 | }; 67 | -------------------------------------------------------------------------------- /src/ui/icon-pack-browser-modal.ts: -------------------------------------------------------------------------------- 1 | import { App, FuzzyMatch, FuzzySuggestModal, Notice } from 'obsidian'; 2 | import predefinedIconPacks, { PredefinedIconPack } from '@app/icon-packs'; 3 | import IconizePlugin from '@app/main'; 4 | import { downloadZipFile } from '@app/zip-util'; 5 | import { IconPack } from '@app/icon-pack-manager/icon-pack'; 6 | 7 | export default class IconPackBrowserModal extends FuzzySuggestModal { 8 | private plugin: IconizePlugin; 9 | 10 | constructor(app: App, plugin: IconizePlugin) { 11 | super(app); 12 | this.plugin = plugin; 13 | 14 | this.resultContainerEl.classList.add('iconize-browse-modal'); 15 | this.inputEl.placeholder = 'Select to download icon pack'; 16 | } 17 | 18 | // eslint-disable-next-line 19 | onAddedIconPack(): void {} 20 | 21 | onOpen(): void { 22 | super.onOpen(); 23 | } 24 | 25 | onClose(): void { 26 | this.contentEl.empty(); 27 | } 28 | 29 | getItemText(item: PredefinedIconPack): string { 30 | // TODO: refactor this 31 | const tempIconPack = new IconPack(this.plugin, item.name, false); 32 | return `${item.displayName} (${tempIconPack.getPrefix()})`; 33 | } 34 | 35 | getItems(): PredefinedIconPack[] { 36 | const iconPacks = Object.values(predefinedIconPacks); 37 | const allIconPacks = this.plugin.getIconPackManager().getIconPacks(); 38 | 39 | return iconPacks.filter( 40 | (iconPack) => 41 | allIconPacks.find((ip) => iconPack.name === ip.getName()) === undefined, 42 | ); 43 | } 44 | 45 | async onChooseItem( 46 | item: PredefinedIconPack, 47 | _event: MouseEvent | KeyboardEvent, 48 | ): Promise { 49 | new Notice(`Adding ${item.displayName}...`); 50 | 51 | const arrayBuffer = await downloadZipFile(item.downloadLink); 52 | await this.plugin 53 | .getIconPackManager() 54 | .getFileManager() 55 | .createZipFile( 56 | this.plugin.getIconPackManager().getPath(), 57 | `${item.name}.zip`, 58 | arrayBuffer, 59 | ); 60 | await this.plugin 61 | .getIconPackManager() 62 | .registerIconPack(item.name, arrayBuffer); 63 | 64 | new Notice(`...${item.displayName} added`); 65 | this.onAddedIconPack(); 66 | } 67 | 68 | renderSuggestion( 69 | item: FuzzyMatch, 70 | el: HTMLElement, 71 | ): void { 72 | super.renderSuggestion(item, el); 73 | 74 | el.innerHTML = `

${el.innerHTML}
`; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@florianwoelki/obsidian-iconize", 3 | "version": "0.0.1", 4 | "description": "API for adding icons to obsidian.", 5 | "main": "lib/index.js", 6 | "type": "module", 7 | "types": "lib/index.d.ts", 8 | "scripts": { 9 | "prepare": "husky install", 10 | "dev": "rollup --config rollup.config.js -w", 11 | "docs:dev": "vitepress dev docs --port 3000", 12 | "docs:build": "vitepress build docs", 13 | "docs:preview": "vitepress preview docs", 14 | "build": "rollup --config rollup.config.js --environment BUILD:production", 15 | "build:lib": "tsc --project tsconfig.lib.json", 16 | "release": "mkdir -p dist && mv main.js dist/ && cp src/styles.css dist/ && cp manifest.json dist/", 17 | "test": "vitest", 18 | "test:coverage": "vitest run --coverage", 19 | "lint": "eslint '*/**/*.{js,ts,tsx}' --quiet --fix", 20 | "prettify": "prettier --write '*/**/*.{js,ts,tsx}'" 21 | }, 22 | "keywords": [], 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/FlorianWoelki/obsidian-iconize.git" 26 | }, 27 | "author": "FlorianWoelki", 28 | "license": "MIT", 29 | "lint-staged": { 30 | "*.{json,js,ts,jsx,tsx,html}": [ 31 | "pnpm lint", 32 | "pnpm prettify" 33 | ] 34 | }, 35 | "devDependencies": { 36 | "@codemirror/language": "https://github.com/lishid/cm-language", 37 | "@codemirror/state": "^6.4.1", 38 | "@codemirror/view": "^6.26.0", 39 | "@commitlint/cli": "^17.8.1", 40 | "@commitlint/config-conventional": "^17.8.1", 41 | "@rollup/plugin-alias": "^5.1.1", 42 | "@rollup/plugin-babel": "^6.0.4", 43 | "@rollup/plugin-commonjs": "^28.0.1", 44 | "@rollup/plugin-node-resolve": "^15.3.0", 45 | "@rollup/plugin-typescript": "^12.1.1", 46 | "@twemoji/api": "^15.1.0", 47 | "@types/node": "^20.17.9", 48 | "@typescript-eslint/eslint-plugin": "^7.18.0", 49 | "@typescript-eslint/parser": "^7.18.0", 50 | "@vitest/coverage-istanbul": "^2.1.6", 51 | "eslint": "^8.57.1", 52 | "eslint-config-prettier": "^9.1.0", 53 | "eslint-plugin-prettier": "^5.2.1", 54 | "happy-dom": "^12.10.3", 55 | "husky": "^8.0.3", 56 | "lint-staged": "^15.2.10", 57 | "mkdirp": "^3.0.1", 58 | "monkey-around": "^2.3.0", 59 | "obsidian": "^1.7.2", 60 | "prettier": "^3.4.1", 61 | "rollup": "^4.27.4", 62 | "tslib": "^2.8.1", 63 | "typescript": "^5.7.2", 64 | "vitepress": "1.2.3", 65 | "vitest": "^2.1.6" 66 | }, 67 | "dependencies": { 68 | "jszip": "^3.10.1" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/icon-pack-manager/lucide.ts: -------------------------------------------------------------------------------- 1 | import { getIcon, getIconIds } from 'obsidian'; 2 | import IconizePlugin from '@app/main'; 3 | import { IconPackManager } from '.'; 4 | import { getNormalizedName } from './util'; 5 | import { IconPack } from './icon-pack'; 6 | import { downloadZipFile } from '@app/zip-util'; 7 | import predefinedIconPacks from '@app/icon-packs'; 8 | 9 | export const LUCIDE_ICON_PACK_NAME = 'lucide-icons'; 10 | 11 | export class LucideIconPack { 12 | private iconPack: IconPack; 13 | 14 | constructor( 15 | private plugin: IconizePlugin, 16 | private iconPackManager: IconPackManager, 17 | ) {} 18 | 19 | public init(iconPack?: IconPack): IconPack { 20 | // Do not initialize Lucide icon pack when setting is `None`. 21 | if ( 22 | !this.plugin.doesUseCustomLucideIconPack() && 23 | !this.plugin.doesUseNativeLucideIconPack() 24 | ) { 25 | return; 26 | } 27 | 28 | this.iconPack = 29 | iconPack ?? new IconPack(this.plugin, LUCIDE_ICON_PACK_NAME, false); 30 | const icons = this.plugin.doesUseNativeLucideIconPack() 31 | ? getIconIds() 32 | .map((iconId) => iconId.replace(/^lucide-/, '')) 33 | .map((iconId) => { 34 | const iconEl = getIcon(iconId); 35 | iconEl.removeClass('svg-icon'); // Removes native `svg-icon` class. 36 | return { 37 | name: getNormalizedName(iconId), 38 | filename: iconId, 39 | prefix: 'Li', 40 | displayName: iconId, 41 | svgElement: iconEl?.outerHTML, 42 | svgContent: iconEl?.innerHTML, 43 | svgViewbox: '', 44 | iconPackName: LUCIDE_ICON_PACK_NAME, 45 | }; 46 | }) 47 | : []; 48 | this.iconPack.setIcons(icons); 49 | return this.iconPack; 50 | } 51 | 52 | public async addCustom(): Promise { 53 | await this.iconPackManager.removeIconPack(this.iconPack); 54 | 55 | this.iconPack = new IconPack(this.plugin, LUCIDE_ICON_PACK_NAME, true); 56 | const arrayBuffer = await downloadZipFile( 57 | predefinedIconPacks['lucide'].downloadLink, 58 | ); 59 | await this.iconPackManager 60 | .getFileManager() 61 | .createZipFile( 62 | this.iconPackManager.getPath(), 63 | `${this.iconPack.getName()}.zip`, 64 | arrayBuffer, 65 | ); 66 | await this.iconPackManager.registerIconPack( 67 | this.iconPack.getName(), 68 | arrayBuffer, 69 | ); 70 | } 71 | 72 | public async removeCustom(): Promise { 73 | await this.iconPackManager.removeIconPack(this.iconPack); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/editor/live-preview/widgets/icon-in-text.ts: -------------------------------------------------------------------------------- 1 | import emoji from '@app/emoji'; 2 | import icon from '@app/lib/icon'; 3 | import svg from '@app/lib/util/svg'; 4 | import { 5 | HTMLHeader, 6 | calculateFontTextSize, 7 | calculateHeaderSize, 8 | } from '@app/lib/util/text'; 9 | import IconizePlugin from '@app/main'; 10 | import { EditorView, WidgetType } from '@codemirror/view'; 11 | 12 | export class IconInTextWidget extends WidgetType { 13 | private start = -1; 14 | private end = -1; 15 | 16 | constructor( 17 | public plugin: IconizePlugin, 18 | public id: string, 19 | ) { 20 | super(); 21 | } 22 | 23 | setPosition(start: number, end: number): void { 24 | this.start = start; 25 | this.end = end; 26 | } 27 | 28 | eq(other: IconInTextWidget) { 29 | return other instanceof IconInTextWidget && other.id === this.id; 30 | } 31 | 32 | private getSize(view: EditorView): number { 33 | let fontSize = calculateFontTextSize(); 34 | 35 | const line = view.state.doc.lineAt(this.end); 36 | const headerMatch = line.text.match(/^#{1,6}\s/); 37 | if (headerMatch && headerMatch[0].trim()) { 38 | const mapping: Record = { 39 | '#': 'h1', 40 | '##': 'h2', 41 | '###': 'h3', 42 | '####': 'h4', 43 | '#####': 'h5', 44 | '######': 'h6', 45 | }; 46 | 47 | const header = mapping[headerMatch[0].trim()]; 48 | fontSize = calculateHeaderSize(header); 49 | } 50 | 51 | return fontSize; 52 | } 53 | 54 | toDOM(view: EditorView) { 55 | const wrap = createSpan({ 56 | cls: 'cm-iconize-icon', 57 | attr: { 58 | 'aria-label': this.id, 59 | 'data-icon': this.id, 60 | 'aria-hidden': 'true', 61 | }, 62 | }); 63 | 64 | const foundIcon = icon.getIconByName(this.plugin, this.id); 65 | const fontSize = this.getSize(view); 66 | 67 | if (foundIcon) { 68 | const svgElement = svg.setFontSize(foundIcon.svgElement, fontSize); 69 | wrap.style.display = 'inline-flex'; 70 | wrap.style.transform = 'translateY(13%)'; 71 | wrap.innerHTML = svgElement; 72 | } else if (emoji.isEmoji(this.id)) { 73 | wrap.innerHTML = emoji.parseEmoji( 74 | this.plugin.getSettings().emojiStyle, 75 | this.id, 76 | fontSize, 77 | ); 78 | } else { 79 | wrap.append( 80 | `${this.plugin.getSettings().iconIdentifier}${this.id}${ 81 | this.plugin.getSettings().iconIdentifier 82 | }`, 83 | ); 84 | } 85 | 86 | return wrap; 87 | } 88 | 89 | ignoreEvent(): boolean { 90 | return false; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/ui/change-color-modal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, ColorComponent, ButtonComponent, Notice } from 'obsidian'; 2 | import IconizePlugin from '@app/main'; 3 | import svg from '@app/lib/util/svg'; 4 | import dom from '@app/lib/util/dom'; 5 | 6 | export default class ChangeColorModal extends Modal { 7 | private plugin: IconizePlugin; 8 | private path: string; 9 | 10 | private usedColor?: string; 11 | 12 | constructor(app: App, plugin: IconizePlugin, path: string) { 13 | super(app); 14 | this.plugin = plugin; 15 | this.path = path; 16 | 17 | this.usedColor = this.plugin.getIconColor(this.path); 18 | 19 | this.contentEl.style.display = 'block'; 20 | this.modalEl.classList.add('iconize-custom-modal'); 21 | this.titleEl.setText('Change color'); 22 | 23 | const description = this.contentEl.createEl('p', { 24 | text: 'Select a color for this icon', 25 | cls: 'setting-item-description', 26 | }); 27 | description.style.marginBottom = 'var(--size-2-2)'; 28 | const colorContainer = this.contentEl.createDiv(); 29 | colorContainer.style.display = 'flex'; 30 | colorContainer.style.alignItems = 'center'; 31 | colorContainer.style.justifyContent = 'space-between'; 32 | const colorPicker = new ColorComponent(colorContainer) 33 | .setValue(this.usedColor ?? '#000000') 34 | .onChange((value) => { 35 | this.usedColor = value; 36 | }); 37 | const defaultColorButton = new ButtonComponent(colorContainer); 38 | defaultColorButton.setTooltip('Set color to the default one'); 39 | defaultColorButton.setButtonText('Reset'); 40 | defaultColorButton.onClick(() => { 41 | colorPicker.setValue('#000000'); 42 | this.usedColor = undefined; 43 | }); 44 | 45 | // Save button. 46 | const button = new ButtonComponent(this.contentEl); 47 | button.buttonEl.style.marginTop = 'var(--size-4-4)'; 48 | button.buttonEl.style.float = 'right'; 49 | button.setButtonText('Save Changes'); 50 | button.onClick(async () => { 51 | new Notice('Color of icon changed.'); 52 | 53 | if (this.usedColor) { 54 | this.plugin.addIconColor(this.path, this.usedColor); 55 | } else { 56 | this.plugin.removeIconColor(this.path); 57 | } 58 | 59 | // Refresh the DOM. 60 | const iconNode = dom.getIconNodeFromPath(this.path); 61 | iconNode.style.color = this.usedColor ?? null; 62 | const colorizedInnerHtml = svg.colorize( 63 | iconNode.innerHTML, 64 | this.usedColor, 65 | ); 66 | iconNode.innerHTML = colorizedInnerHtml; 67 | 68 | this.close(); 69 | }); 70 | } 71 | 72 | onOpen() { 73 | super.onOpen(); 74 | } 75 | 76 | onClose() { 77 | const { contentEl } = this; 78 | contentEl.empty(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | .iconize-inline-title-wrapper { 2 | width: var(--line-width); 3 | max-width: var(--max-width); 4 | margin-inline: var(--content-margin); 5 | } 6 | 7 | .iconize-title-icon { 8 | max-width: var(--max-width); 9 | margin-right: var(--size-4-2); 10 | } 11 | 12 | .iconize-icon-in-link { 13 | transform: translateY(20%); 14 | margin-right: var(--size-2-2); 15 | display: inline-flex; 16 | } 17 | 18 | .iconize-icon { 19 | border: 1px solid transparent; 20 | margin: 0px 4px 0px 0px; 21 | display: flex; 22 | align-self: center; 23 | margin: auto 0; 24 | } 25 | 26 | .nav-folder-title, 27 | .nav-file-title { 28 | align-items: center; 29 | } 30 | 31 | .iconize-setting input[type='color'] { 32 | margin: 0 6px; 33 | } 34 | 35 | .iconize-modal.prompt-results { 36 | margin: 0; 37 | overflow-y: auto; 38 | display: grid; 39 | grid-template-columns: repeat(5, minmax(0, 1fr)); 40 | } 41 | 42 | .prompt .iconize-subheadline { 43 | margin-top: 12px; 44 | font-size: 12px; 45 | color: gray; 46 | grid-column-start: 1; 47 | grid-column-end: 6; 48 | } 49 | 50 | @media (max-width: 640px) { 51 | .iconize-modal.prompt-results { 52 | grid-template-columns: repeat(3, minmax(0, 1fr)); 53 | } 54 | .prompt .iconize-subheadline { 55 | grid-column-end: 4; 56 | } 57 | } 58 | 59 | .iconize-modal.prompt-results .suggestion-item { 60 | cursor: pointer; 61 | white-space: pre-wrap; 62 | display: flex; 63 | justify-content: flex-end; 64 | align-items: center; 65 | flex-direction: column-reverse; 66 | text-align: center; 67 | font-size: 13px; 68 | color: var(--text-muted); 69 | padding: 16px 8px; 70 | line-break: auto; 71 | word-break: break-word; 72 | line-height: 1.3; 73 | } 74 | 75 | .iconize-modal.prompt-results .suggestion-item.suggestion-item__center { 76 | justify-content: center; 77 | } 78 | 79 | .iconize-icon-preview { 80 | font-size: 22px; 81 | } 82 | 83 | .iconize-icon-preview img { 84 | width: 16px; 85 | height: 16px; 86 | } 87 | 88 | .iconize-icon-preview svg { 89 | width: 24px; 90 | height: 24px; 91 | color: currentColor; 92 | margin-bottom: 4px; 93 | } 94 | 95 | .iconize-dragover { 96 | position: relative; 97 | } 98 | 99 | .iconize-dragover-el { 100 | position: absolute; 101 | width: 100%; 102 | height: 100%; 103 | color: var(--text-normal); 104 | background-color: var(--background-secondary-alt); 105 | display: flex; 106 | align-items: center; 107 | justify-content: center; 108 | } 109 | 110 | /* Custom rule modal. */ 111 | .iconize-custom-modal .modal-content { 112 | display: flex; 113 | align-items: center; 114 | justify-content: center; 115 | } 116 | 117 | .iconize-custom-modal .modal-content input { 118 | width: 100%; 119 | margin-right: 0.5rem; 120 | } 121 | -------------------------------------------------------------------------------- /src/icon-pack-manager/icon-pack.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '@app/lib/logger'; 2 | import { generateIcon, getNormalizedName } from './util'; 3 | import IconizePlugin from '@app/main'; 4 | import { Icon } from '.'; 5 | 6 | export class IconPack { 7 | private icons: Icon[]; 8 | private prefix: string; 9 | 10 | constructor( 11 | private plugin: IconizePlugin, 12 | private name: string, 13 | private isCustom: boolean, 14 | ) { 15 | this.icons = []; 16 | this.prefix = this.generatePrefix(); 17 | } 18 | 19 | private generatePrefix(): string { 20 | if (this.name.includes('-')) { 21 | const splitted = this.name.split('-'); 22 | let result = splitted[0].charAt(0).toUpperCase(); 23 | for (let i = 1; i < splitted.length; i++) { 24 | result += splitted[i].charAt(0).toLowerCase(); 25 | } 26 | 27 | return result; 28 | } 29 | 30 | return ( 31 | this.name.charAt(0).toUpperCase() + this.name.charAt(1).toLowerCase() 32 | ); 33 | } 34 | 35 | public async delete(): Promise { 36 | const path = this.plugin.getIconPackManager().getPath(); 37 | // Check for the icon pack directory and delete it. 38 | if (await this.plugin.app.vault.adapter.exists(`${path}/${this.name}`)) { 39 | await this.plugin.app.vault.adapter.rmdir(`${path}/${this.name}`, true); 40 | } 41 | // Check for the icon pack zip file and delete it. 42 | if ( 43 | await this.plugin.app.vault.adapter.exists(`${path}/${this.name}.zip`) 44 | ) { 45 | await this.plugin.app.vault.adapter.remove(`${path}/${this.name}.zip`); 46 | } 47 | } 48 | 49 | public addIcon(iconName: string, iconContent: string): Icon | undefined { 50 | // Normalize the icon name to remove `-` or `_` in the name. 51 | iconName = getNormalizedName(iconName); 52 | const icon = generateIcon(this, iconName, iconContent); 53 | if (!icon) { 54 | logger.warn( 55 | `Icon could not be generated (icon: ${iconName}, content: ${iconContent})`, 56 | ); 57 | return undefined; 58 | } 59 | 60 | this.icons.push(icon); 61 | 62 | return icon; 63 | } 64 | 65 | public removeIcon(path: string, iconName: string): Promise { 66 | if (this.isCustom) { 67 | return; 68 | } 69 | 70 | return this.plugin.app.vault.adapter.rmdir( 71 | `${path}/${this.name}/${iconName}.svg`, 72 | true, 73 | ); 74 | } 75 | 76 | public getIcon(iconName: string): Icon | undefined { 77 | return this.icons.find((icon) => getNormalizedName(icon.name) === iconName); 78 | } 79 | 80 | public setIcons(icons: Icon[]): void { 81 | this.icons = icons; 82 | } 83 | 84 | public getName(): string { 85 | return this.name; 86 | } 87 | 88 | public getPrefix(): string { 89 | return this.prefix; 90 | } 91 | 92 | public getIcons(): Icon[] { 93 | return this.icons; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/editor/markdown-processors/icon-in-link.ts: -------------------------------------------------------------------------------- 1 | import emoji from '@app/emoji'; 2 | import svg from '@app/lib/util/svg'; 3 | import icon from '@app/lib/icon'; 4 | import { logger } from '@app/lib/logger'; 5 | import { 6 | calculateFontTextSize, 7 | calculateHeaderSize, 8 | HTMLHeader, 9 | isHeader, 10 | } from '@app/lib/util/text'; 11 | import IconizePlugin from '@app/main'; 12 | import { MarkdownPostProcessorContext } from 'obsidian'; 13 | 14 | export const processIconInLinkMarkdown = ( 15 | plugin: IconizePlugin, 16 | element: HTMLElement, 17 | ctx: MarkdownPostProcessorContext, 18 | ) => { 19 | const linkElements = element.querySelectorAll('a'); 20 | if (!linkElements || linkElements.length === 0) { 21 | return; 22 | } 23 | 24 | linkElements.forEach((linkElement) => { 25 | // Skip if the link element e.g., is a tag. 26 | if (!linkElement.hasAttribute('data-href')) { 27 | return; 28 | } 29 | 30 | const linkHref = linkElement.getAttribute('href'); 31 | if (!linkHref) { 32 | logger.warn('Link element does not have an `href` attribute'); 33 | return; 34 | } 35 | 36 | const file = plugin.app.metadataCache.getFirstLinkpathDest( 37 | linkHref, 38 | ctx.sourcePath, 39 | ); 40 | if (!file) { 41 | logger.warn('Link element does not have a linkpath to a file'); 42 | return; 43 | } 44 | 45 | const path = file.path; 46 | const iconValue = icon.getIconByPath(plugin, path); 47 | if (!iconValue) { 48 | return; 49 | } 50 | 51 | let fontSize = calculateFontTextSize(); 52 | const tagName = linkElement.parentElement?.tagName?.toLowerCase() ?? ''; 53 | if (isHeader(tagName)) { 54 | fontSize = calculateHeaderSize(tagName as HTMLHeader); 55 | } 56 | 57 | const iconName = 58 | typeof iconValue === 'string' 59 | ? iconValue 60 | : iconValue.prefix + iconValue.name; 61 | 62 | const rootSpan = createSpan({ 63 | cls: 'iconize-icon-in-link', 64 | attr: { 65 | title: iconName, 66 | 'aria-label': iconName, 67 | 'data-icon': iconName, 68 | 'aria-hidden': 'true', 69 | }, 70 | }); 71 | rootSpan.style.color = 72 | plugin.getIconColor(path) ?? plugin.getSettings().iconColor; 73 | 74 | if (emoji.isEmoji(iconName)) { 75 | const parsedEmoji = 76 | emoji.parseEmoji(plugin.getSettings().emojiStyle, iconName, fontSize) ?? 77 | iconName; 78 | rootSpan.style.transform = 'translateY(0)'; 79 | rootSpan.innerHTML = parsedEmoji; 80 | } else { 81 | let svgEl = icon.getIconByName(plugin, iconName).svgElement; 82 | svgEl = svg.setFontSize(svgEl, fontSize); 83 | if (svgEl) { 84 | rootSpan.style.transform = 'translateY(20%)'; 85 | rootSpan.innerHTML = svgEl; 86 | } 87 | } 88 | 89 | linkElement.prepend(rootSpan); 90 | }); 91 | }; 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Iconize 2 | 3 | ![Preview Image](./docs/preview-image.png) 4 | 5 | Project Deprecation and End of Maintenance: [read more](https://github.com/FlorianWoelki/obsidian-iconize/discussions/646) 6 | 7 | ## What is it? 8 | 9 | This obsidian plugin allows you to add **any** custom icon (of type `.svg`) or from an icon pack to anything you want. 10 | 11 | Refer to the official documentation for more information: 12 | [https://florianwoelki.github.io/obsidian-iconize/](https://florianwoelki.github.io/obsidian-iconize/) about the plugin and its functionalities. 13 | 14 | If you like this plugin, feel free to support the development by buying a coffee: 15 | 16 | Buy Me A Coffee 17 | 18 | ## Key Highlights 19 | 20 | [Icons before file/folder name](https://florianwoelki.github.io/obsidian-iconize/files-and-folders/icon-before-file-or-folder.html), 21 | [Icons in notes](https://florianwoelki.github.io/obsidian-iconize/notes/icons-in-notes.html), 22 | [Icon above title](https://florianwoelki.github.io/obsidian-iconize/notes/title-icon.html), 23 | [Predefined icon packs](https://florianwoelki.github.io/obsidian-iconize/guide/icon-packs.html), 24 | [Icons in tabs](https://florianwoelki.github.io/obsidian-iconize/files-and-folders/icon-tabs.html), 25 | [Customizable settings](https://florianwoelki.github.io/obsidian-iconize/guide/settings.html), 26 | [Custom rules](https://florianwoelki.github.io/obsidian-iconize/files-and-folders/custom-rules.html), 27 | [Frontmatter integration](https://florianwoelki.github.io/obsidian-iconize/files-and-folders/use-frontmatter.html), 28 | [Change color of an individual icon](https://florianwoelki.github.io/obsidian-iconize/files-and-folders/individual-icon-color.html), 29 | 30 | ## Development 31 | 32 | To customize this project for your needs, you can clone it and then install all dependencies: 33 | ```sh 34 | $ git clone https://github.com/FlorianWoelki/obsidian-iconize 35 | $ cd obsidian-iconize 36 | $ pnpm i 37 | ``` 38 | 39 | After the installation, you need to create a `env.js` file in the root directory. Fill the file with the following content: 40 | 41 | ```js 42 | export const obsidianExportPath = 43 | '/.obsidian/plugins/obsidian-iconize/'; 44 | ``` 45 | 46 | Make sure you create the directory specified in that variable if it does not exist yet. 47 | 48 | Afterwards, you can start the rollup dev server by using: 49 | 50 | ```sh 51 | $ pnpm dev 52 | ``` 53 | 54 | This command will automatically build the neccesary files for testing and developing on every change. Furthermore, it does copy all the necessary files to the plugin directory you specified. 55 | 56 | Finally, you can customize the plugin and add it to your plugins. 57 | -------------------------------------------------------------------------------- /src/lib/api.ts: -------------------------------------------------------------------------------- 1 | import IconizePlugin from '@app/main'; 2 | import dom from '@lib/util/dom'; 3 | import svg from '@lib/util/svg'; 4 | import icon from '@lib/icon'; 5 | import { EventEmitter } from './event/event'; 6 | import { removeIconFromIconPack, saveIconToIconPack } from '@app/util'; 7 | import { Icon } from '@app/icon-pack-manager'; 8 | import { IconPack } from '@app/icon-pack-manager/icon-pack'; 9 | 10 | export { AllIconsLoadedEvent } from '@lib/event/events'; 11 | 12 | export default interface IconizeAPI { 13 | getEventEmitter(): EventEmitter; 14 | getIconByName(iconNameWithPrefix: string): Icon | null; 15 | /** 16 | * Sets an icon or emoji for an HTMLElement based on the specified icon name and color. 17 | * The function manipulates the specified node inline. 18 | * @param iconName Name of the icon or emoji to add. 19 | * @param node HTMLElement to which the icon or emoji will be added. 20 | * @param color Optional color of the icon to add. 21 | */ 22 | setIconForNode(iconName: string, node: HTMLElement, color?: string): void; 23 | doesElementHasIconNode: typeof dom.doesElementHasIconNode; 24 | getIconFromElement: typeof dom.getIconFromElement; 25 | removeIconInNode: typeof dom.removeIconInNode; 26 | removeIconInPath: typeof dom.removeIconInPath; 27 | /** 28 | * Will add the icon to the icon pack and then extract the icon to the icon pack. 29 | * @param iconNameWithPrefix String that will be used to add the icon to the icon pack. 30 | */ 31 | saveIconToIconPack(iconNameWithPrefix: string): void; 32 | /** 33 | * Will remove the icon from the icon pack by removing the icon file from the icon pack directory. 34 | * @param iconNameWithPrefix String that will be used to remove the icon from the icon pack. 35 | */ 36 | removeIconFromIconPack(iconNameWithPrefix: string): void; 37 | getIconPacks(): IconPack[]; 38 | util: { 39 | dom: typeof dom; 40 | svg: typeof svg; 41 | }; 42 | version: { 43 | current: string; 44 | }; 45 | } 46 | 47 | export function getApi(plugin: IconizePlugin): IconizeAPI { 48 | return { 49 | getEventEmitter: () => plugin.getEventEmitter(), 50 | getIconByName: (iconNameWithPrefix: string) => 51 | icon.getIconByName(plugin, iconNameWithPrefix), 52 | setIconForNode: (iconName: string, node: HTMLElement, color?: string) => 53 | dom.setIconForNode(plugin, iconName, node, { color }), 54 | saveIconToIconPack: (iconNameWithPrefix) => 55 | saveIconToIconPack(plugin, iconNameWithPrefix), 56 | removeIconFromIconPack: (iconNameWithPrefix) => 57 | removeIconFromIconPack(plugin, iconNameWithPrefix), 58 | getIconPacks: plugin.getIconPackManager().getIconPacks, 59 | doesElementHasIconNode: dom.doesElementHasIconNode, 60 | getIconFromElement: dom.getIconFromElement, 61 | removeIconInNode: dom.removeIconInNode, 62 | removeIconInPath: dom.removeIconInPath, 63 | util: { 64 | dom, 65 | svg, 66 | }, 67 | version: { 68 | get current() { 69 | return plugin.manifest.version; 70 | }, 71 | }, 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /src/settings/ui/index.ts: -------------------------------------------------------------------------------- 1 | import { App, PluginSettingTab } from 'obsidian'; 2 | import IconizePlugin from '@app/main'; 3 | import CustomIconPackSetting from './customIconPack'; 4 | import CustomIconRuleSetting from './customIconRule'; 5 | import EmojiStyleSetting from './emojiStyle'; 6 | import ExtraMarginSetting from './extraMargin'; 7 | import IconColorSetting from './iconColor'; 8 | import IconFontSizeSetting from './iconFontSize'; 9 | import IconPacksPathSetting from './iconPacksPath'; 10 | import IconPacksBackgroundChecker from './iconPacksBackgroundChecker'; 11 | import PredefinedIconPacksSetting from './predefinedIconPacks'; 12 | import RecentlyUsedIconsSetting from './recentlyUsedIcons'; 13 | import ToggleIconInTabs from './toggleIconInTabs'; 14 | import ToggleIconInTitle from './toggleIconInTitle'; 15 | import FrontmatterOptions from './frontmatterOptions'; 16 | import ToggleIconsInNotes from './toggleIconsInNotes'; 17 | import ToggleIconsInLinks from './toggleIconsInLinks'; 18 | import IconIdentifierSetting from './iconIdentifier'; 19 | import DebugMode from './debugMode'; 20 | import UseInternalPlugins from './useInternalPlugins'; 21 | 22 | export default class IconFolderSettings extends PluginSettingTab { 23 | private plugin: IconizePlugin; 24 | 25 | constructor(app: App, plugin: IconizePlugin) { 26 | super(app, plugin); 27 | 28 | this.plugin = plugin; 29 | } 30 | 31 | display(): void { 32 | const { plugin, containerEl, app } = this; 33 | containerEl.empty(); 34 | 35 | containerEl.createEl('h1', { text: 'General' }); 36 | new RecentlyUsedIconsSetting(plugin, containerEl).display(); 37 | new IconPacksPathSetting(plugin, containerEl).display(); 38 | new IconPacksBackgroundChecker(plugin, containerEl).display(); 39 | new EmojiStyleSetting(plugin, containerEl).display(); 40 | new IconIdentifierSetting(plugin, containerEl).display(); 41 | new UseInternalPlugins(plugin, containerEl).display(); 42 | new DebugMode(plugin, containerEl).display(); 43 | 44 | containerEl.createEl('h3', { text: 'Visibility of icons' }); 45 | new ToggleIconInTabs(plugin, containerEl).display(); 46 | new ToggleIconInTitle(plugin, containerEl).display(); 47 | new FrontmatterOptions(plugin, containerEl).display(); 48 | new ToggleIconsInNotes(plugin, containerEl).display(); 49 | new ToggleIconsInLinks(plugin, containerEl).display(); 50 | 51 | containerEl.createEl('h1', { 52 | text: 'Icon customization for files/folders', 53 | }); 54 | new IconFontSizeSetting(plugin, containerEl).display(); 55 | new IconColorSetting(plugin, containerEl).display(); 56 | new ExtraMarginSetting(plugin, containerEl).display(); 57 | 58 | containerEl.createEl('h1', { text: 'Custom icon rules' }); 59 | new CustomIconRuleSetting(plugin, containerEl, app, () => 60 | this.display(), 61 | ).display(); 62 | 63 | containerEl.createEl('h1', { text: 'Icon packs' }); 64 | new PredefinedIconPacksSetting(plugin, containerEl, app, () => 65 | this.display(), 66 | ).display(); 67 | new CustomIconPackSetting(plugin, containerEl, () => 68 | this.display(), 69 | ).display(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | import config from '@app/config'; 2 | 3 | export enum LoggerPrefix { 4 | Outline = 'Outline', 5 | } 6 | 7 | type LogLevel = 'log' | 'info' | 'warn' | 'error'; 8 | 9 | interface LogLevelInformation { 10 | label: string; 11 | } 12 | 13 | interface Logger { 14 | log( 15 | message: string, 16 | prefix?: LoggerPrefix, 17 | ...optionalParams: unknown[] 18 | ): void; 19 | info( 20 | message: string, 21 | prefix?: LoggerPrefix, 22 | ...optionalParams: unknown[] 23 | ): void; 24 | warn( 25 | message: string, 26 | prefix?: LoggerPrefix, 27 | ...optionalParams: unknown[] 28 | ): void; 29 | error( 30 | message: string, 31 | prefix?: LoggerPrefix, 32 | ...optionalParams: unknown[] 33 | ): void; 34 | toggleLogging(enabled: boolean): void; 35 | } 36 | 37 | export class ConsoleLogger implements Logger { 38 | private projectPrefix: string; 39 | private enabled: boolean; 40 | 41 | constructor(projectPrefix: string, enabled: boolean = false) { 42 | this.projectPrefix = projectPrefix; 43 | this.enabled = enabled; 44 | } 45 | 46 | private logLevels: Record = { 47 | log: { label: 'LOG:' }, 48 | info: { label: 'INFO:' }, 49 | warn: { label: 'WARN:' }, 50 | error: { label: 'ERROR:' }, 51 | }; 52 | 53 | private formatMessage( 54 | level: LogLevel, 55 | message: string, 56 | prefix: LoggerPrefix | null, 57 | optionalParams: unknown[], 58 | ): [string, ...unknown[]] { 59 | const timestamp = new Date().toISOString(); 60 | const { label } = this.logLevels[level]; 61 | const prefixAsStr = !prefix ? '' : `/${prefix}`; 62 | return [ 63 | `${this.projectPrefix}${prefixAsStr}: [${timestamp}] ${label} ${message}`, 64 | ...optionalParams, 65 | ]; 66 | } 67 | 68 | log( 69 | message: string, 70 | prefix?: LoggerPrefix, 71 | ...optionalParams: unknown[] 72 | ): void { 73 | if (this.enabled) { 74 | console.log( 75 | ...this.formatMessage('log', message, prefix, optionalParams), 76 | ); 77 | } 78 | } 79 | 80 | info( 81 | message: string, 82 | prefix?: LoggerPrefix, 83 | ...optionalParams: unknown[] 84 | ): void { 85 | if (this.enabled) { 86 | console.info( 87 | ...this.formatMessage('info', message, prefix, optionalParams), 88 | ); 89 | } 90 | } 91 | 92 | warn( 93 | message: string, 94 | prefix?: LoggerPrefix, 95 | ...optionalParams: unknown[] 96 | ): void { 97 | if (this.enabled) { 98 | console.warn( 99 | ...this.formatMessage('warn', message, prefix, optionalParams), 100 | ); 101 | } 102 | } 103 | 104 | error( 105 | message: string, 106 | prefix?: LoggerPrefix, 107 | ...optionalParams: unknown[] 108 | ): void { 109 | if (this.enabled) { 110 | console.error( 111 | ...this.formatMessage('error', message, prefix, optionalParams), 112 | ); 113 | } 114 | } 115 | 116 | toggleLogging(enabled: boolean): void { 117 | this.enabled = enabled; 118 | } 119 | } 120 | 121 | export const logger: Logger = new ConsoleLogger(config.PLUGIN_NAME); 122 | -------------------------------------------------------------------------------- /src/icon-pack-manager/util.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '@app/lib/logger'; 2 | import { IconPack } from './icon-pack'; 3 | import svg from '@app/lib/util/svg'; 4 | import { Icon } from '.'; 5 | import IconizePlugin from '@app/main'; 6 | 7 | export function getNormalizedName(s: string): string { 8 | return s 9 | .split(/[ -]|[ _]/g) 10 | .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) 11 | .join(''); 12 | } 13 | 14 | export function nextIdentifier(iconName: string): number { 15 | return iconName.substring(1).search(/[(A-Z)|(0-9)]/) + 1; 16 | } 17 | 18 | export function getSvgFromLoadedIcon( 19 | plugin: IconizePlugin, 20 | iconPrefix: string, 21 | iconName: string, 22 | ): string { 23 | let icon = ''; 24 | let foundIcon = plugin 25 | .getIconPackManager() 26 | .getPreloadedIcons() 27 | .find( 28 | (icon) => 29 | icon.prefix.toLowerCase() === iconPrefix.toLowerCase() && 30 | icon.name.toLowerCase() === iconName.toLowerCase(), 31 | ); 32 | if (!foundIcon) { 33 | plugin 34 | .getIconPackManager() 35 | .getIconPacks() 36 | .forEach((iconPack) => { 37 | const icon = iconPack.getIcons().find((icon) => { 38 | return ( 39 | icon.prefix.toLowerCase() === iconPrefix.toLowerCase() && 40 | getNormalizedName(icon.name).toLowerCase() === 41 | iconName.toLowerCase() 42 | ); 43 | }); 44 | if (icon) { 45 | foundIcon = icon; 46 | } 47 | }); 48 | } 49 | 50 | if (foundIcon) { 51 | icon = foundIcon.svgElement; 52 | } 53 | 54 | return icon; 55 | } 56 | 57 | const validIconName = /^[(A-Z)|(0-9)]/; 58 | const svgViewboxRegex = /viewBox="([^"]*)"/g; 59 | const svgContentRegex = /(.*?)<\/svg>/g; 60 | export function generateIcon( 61 | iconPack: IconPack, 62 | iconName: string, 63 | content: string, 64 | ): Icon | null { 65 | if (content.length === 0) { 66 | return; 67 | } 68 | 69 | content = content.replace(/(\r\n|\n|\r)/gm, ''); 70 | content = content.replace(/>\s+<'); 71 | const normalizedName = 72 | iconName.charAt(0).toUpperCase() + iconName.substring(1); 73 | 74 | if (!validIconName.exec(normalizedName)) { 75 | logger.info(`Skipping icon with invalid name: ${iconName}`); 76 | return null; 77 | } 78 | 79 | const svgViewboxMatch = content.match(svgViewboxRegex); 80 | let svgViewbox = ''; 81 | if (svgViewboxMatch && svgViewboxMatch.length !== 0) { 82 | svgViewbox = svgViewboxMatch[0]; 83 | } 84 | 85 | const svgContentMatch = content.match(svgContentRegex); 86 | if (!svgContentMatch) { 87 | logger.info(`Skipping icon with invalid svg content: ${iconName}`); 88 | return null; 89 | } 90 | 91 | const svgContent = svgContentMatch.map((val) => 92 | val.replace(/<\/?svg>/g, '').replace(//g, ''), 93 | )[0]; 94 | 95 | const icon: Icon = { 96 | name: normalizedName.split('.svg')[0], 97 | prefix: iconPack.getPrefix(), 98 | iconPackName: iconPack.getName(), 99 | displayName: iconName, 100 | filename: iconName, 101 | svgContent, 102 | svgViewbox, 103 | svgElement: svg.extract(content), 104 | }; 105 | 106 | return icon; 107 | } 108 | -------------------------------------------------------------------------------- /src/settings/ui/emojiStyle.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownView, Setting } from 'obsidian'; 2 | import emoji from '@app/emoji'; 3 | import customRule from '@lib/custom-rule'; 4 | import dom from '@lib/util/dom'; 5 | import { FolderIconObject } from '@app/main'; 6 | import iconTabs from '@app/lib/icon-tabs'; 7 | import IconFolderSetting from './iconFolderSetting'; 8 | import titleIcon from '@app/lib/icon-title'; 9 | import { getAllOpenedFiles } from '@app/util'; 10 | import { InlineTitleView } from '@app/@types/obsidian'; 11 | import { calculateInlineTitleSize } from '@app/lib/util/text'; 12 | 13 | export default class EmojiStyleSetting extends IconFolderSetting { 14 | public display(): void { 15 | const emojiStyle = new Setting(this.containerEl) 16 | .setName('Emoji style') 17 | .setDesc('Change the style of your emojis.'); 18 | emojiStyle.addDropdown((dropdown) => { 19 | dropdown.addOption('native', 'Native'); 20 | dropdown.addOption('twemoji', 'Twemoji'); 21 | dropdown.setValue(this.plugin.getSettings().emojiStyle); 22 | dropdown.onChange(async (value: 'native' | 'twemoji') => { 23 | this.plugin.getSettings().emojiStyle = value; 24 | this.updateDOM(); 25 | await this.plugin.saveIconFolderData(); 26 | }); 27 | }); 28 | } 29 | 30 | private updateDOM(): void { 31 | for (const fileExplorer of this.plugin.getRegisteredFileExplorers()) { 32 | const fileItems = Object.entries(fileExplorer.fileItems || {}); 33 | for (const [path, _] of fileItems) { 34 | let iconName = this.plugin.getData()[path] as string | undefined | null; 35 | if (!iconName) { 36 | continue; 37 | } 38 | 39 | const data = this.plugin.getData()[path]; 40 | if (typeof data === 'object') { 41 | const data = this.plugin.getData()[path] as FolderIconObject; 42 | 43 | if (data.iconName) { 44 | iconName = data.iconName; 45 | } 46 | } 47 | 48 | if (emoji.isEmoji(iconName)) { 49 | dom.createIconNode(this.plugin, path, iconName); 50 | if (this.plugin.getSettings().iconInTabsEnabled) { 51 | const tabLeaves = iconTabs.getTabLeavesOfFilePath( 52 | this.plugin, 53 | path, 54 | ); 55 | for (const tabLeaf of tabLeaves) { 56 | iconTabs.update( 57 | this.plugin, 58 | iconName, 59 | tabLeaf.tabHeaderInnerIconEl, 60 | ); 61 | } 62 | } 63 | 64 | if (this.plugin.getSettings().iconInTitleEnabled) { 65 | for (const openedFile of getAllOpenedFiles(this.plugin)) { 66 | const activeView = openedFile.leaf.view as InlineTitleView; 67 | if ( 68 | activeView instanceof MarkdownView && 69 | openedFile.path === path 70 | ) { 71 | titleIcon.add(this.plugin, activeView.inlineTitleEl, iconName, { 72 | fontSize: calculateInlineTitleSize(), 73 | }); 74 | } 75 | } 76 | } 77 | } 78 | } 79 | } 80 | 81 | for (const rule of customRule.getSortedRules(this.plugin)) { 82 | customRule.addToAllFiles(this.plugin, rule); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/zip-util.test.ts: -------------------------------------------------------------------------------- 1 | import { vi, it, expect, describe, afterEach } from 'vitest'; 2 | import { downloadZipFile, getFileFromJSZipFile, readZipFile } from './zip-util'; 3 | import JSZip from 'jszip'; 4 | 5 | const zipUrl = 'http://example.com/zip-file.zip'; 6 | 7 | afterEach(() => { 8 | vi.restoreAllMocks(); 9 | }); 10 | 11 | describe('downloadZipFile', () => { 12 | it('should download a zip file and return an ArrayBuffer', async () => { 13 | vi.mock('obsidian', () => ({ 14 | requestUrl: () => ({ 15 | arrayBuffer: new ArrayBuffer(8), 16 | }), 17 | })); 18 | 19 | const result = await downloadZipFile(zipUrl); 20 | expect(result).toBeInstanceOf(ArrayBuffer); 21 | expect(result.byteLength).toBe(8); 22 | }); 23 | }); 24 | 25 | describe('readZipFile', () => { 26 | it('should read a zip file and return its files', async () => { 27 | const spy = vi.spyOn(JSZip, 'loadAsync'); 28 | spy.mockImplementationOnce( 29 | () => 30 | ({ 31 | files: { 32 | 'file1.svg': { 33 | name: 'file1.svg', 34 | dir: false, 35 | }, 36 | 'file2.svg': { 37 | name: 'file2.svg', 38 | dir: false, 39 | }, 40 | 'file3.svg': { 41 | name: 'file3.svg', 42 | dir: false, 43 | }, 44 | }, 45 | }) as any, 46 | ); 47 | 48 | const arrayBuffer = new ArrayBuffer(8); 49 | const files = await readZipFile(arrayBuffer); 50 | expect(files).toBeInstanceOf(Array); 51 | }); 52 | 53 | it('should filter files by `extraPath`', async () => { 54 | const spy = vi.spyOn(JSZip, 'loadAsync'); 55 | spy.mockImplementationOnce( 56 | () => 57 | ({ 58 | files: { 59 | 'extra-path/file1.svg': { 60 | name: 'extra-path/file1.svg', 61 | dir: false, 62 | }, 63 | 'extra-path/file2.svg': { 64 | name: 'extra-path/file2.svg', 65 | dir: false, 66 | }, 67 | 'extra-path/file3.svg': { 68 | name: 'extra-path/file3.svg', 69 | dir: false, 70 | }, 71 | }, 72 | }) as any, 73 | ); 74 | 75 | const arrayBuffer = new ArrayBuffer(8); 76 | const files = await readZipFile(arrayBuffer, 'extra-path/'); 77 | expect(files).toBeInstanceOf(Array); 78 | }); 79 | 80 | it('should reject if no file are found', async () => { 81 | const spy = vi.spyOn(JSZip, 'loadAsync'); 82 | spy.mockImplementationOnce( 83 | () => 84 | ({ 85 | files: {}, 86 | }) as any, 87 | ); 88 | 89 | const bytes = new ArrayBuffer(0); 90 | await expect(readZipFile(bytes)).rejects.toBe('No file was found'); 91 | }); 92 | }); 93 | 94 | describe('getFileFromJSZipFile', () => { 95 | it('should transform a JSZip file into a File object', async () => { 96 | const file: any = { 97 | name: 'file.svg', 98 | // eslint-disable-next-line @typescript-eslint/no-empty-function 99 | async: () => {}, 100 | }; 101 | 102 | const result = await getFileFromJSZipFile(file); 103 | expect(result).toBeInstanceOf(File); 104 | expect(result.name).toBe('file.svg'); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/settings/ui/toggleIconInTitle.ts: -------------------------------------------------------------------------------- 1 | import { DropdownComponent, MarkdownView, Setting } from 'obsidian'; 2 | import { calculateInlineTitleSize } from '@app/lib/util/text'; 3 | import IconFolderSetting from './iconFolderSetting'; 4 | import icon from '@lib/icon'; 5 | import titleIcon from '@lib/icon-title'; 6 | import { InlineTitleView } from '@app/@types/obsidian'; 7 | import { IconInTitlePosition } from '@app/settings/data'; 8 | 9 | interface UpdateLeavesOptions { 10 | /** 11 | * Decides whether to enable or disable the icon in the title. 12 | */ 13 | enabled: boolean; 14 | /** 15 | * If true, removes the icon of the title before re-adding it. 16 | * Enabling this option is useful when the icon position is updated and therefore 17 | * the DOM needs to be updated. 18 | * @default false 19 | */ 20 | removeBeforeReAdd?: boolean; 21 | } 22 | 23 | export default class ToggleIconInTitle extends IconFolderSetting { 24 | private dropdown: DropdownComponent; 25 | 26 | private updateLeaves(options: UpdateLeavesOptions): void { 27 | this.plugin.app.workspace.getLeavesOfType('markdown').forEach((leaf) => { 28 | const view = leaf.view as InlineTitleView; 29 | if (view instanceof MarkdownView) { 30 | const foundIcon = icon.getIconByPath(this.plugin, view.file.path); 31 | 32 | if (foundIcon && options.enabled) { 33 | if (options.removeBeforeReAdd) { 34 | // Remove the icon before re-adding it. This is needed to update the DOM because 35 | // the icon node will be inserted in the beginning inline title node. 36 | titleIcon.remove(view.contentEl); 37 | } 38 | 39 | const content = 40 | typeof foundIcon === 'string' ? foundIcon : foundIcon.svgElement; 41 | titleIcon.add(this.plugin, view.inlineTitleEl, content, { 42 | fontSize: calculateInlineTitleSize(), 43 | }); 44 | } else { 45 | titleIcon.remove(view.contentEl); 46 | } 47 | } 48 | }); 49 | } 50 | 51 | public display(): void { 52 | new Setting(this.containerEl) 53 | .setName('Toggle icon in title') 54 | .setDesc('Toggles the visibility of an icon above the title of a file.') 55 | .addDropdown((dropdown) => { 56 | this.dropdown = dropdown; 57 | dropdown.setDisabled(!this.plugin.getSettings().iconInTitleEnabled); 58 | dropdown.addOptions({ 59 | above: 'Above title', 60 | inline: 'Next to title', 61 | }); 62 | dropdown.setValue(this.plugin.getSettings().iconInTitlePosition); 63 | dropdown.onChange(async (value) => { 64 | this.plugin.getSettings().iconInTitlePosition = 65 | value as IconInTitlePosition; 66 | await this.plugin.saveIconFolderData(); 67 | this.updateLeaves({ enabled: true, removeBeforeReAdd: true }); 68 | }); 69 | }) 70 | .addToggle((toggle) => { 71 | toggle 72 | .setValue(this.plugin.getSettings().iconInTitleEnabled) 73 | .onChange(async (enabled) => { 74 | if (this.dropdown) { 75 | this.dropdown.setDisabled(!enabled); 76 | } 77 | 78 | this.plugin.getSettings().iconInTitleEnabled = enabled; 79 | await this.plugin.saveIconFolderData(); 80 | this.updateLeaves({ enabled }); 81 | }); 82 | }); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/lib/util/text.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { 3 | calculateFontTextSize, 4 | calculateHeaderSize, 5 | calculateInlineTitleSize, 6 | isHeader, 7 | isPx, 8 | pxToRem, 9 | } from './text'; 10 | 11 | describe('calculateFontTextSize', () => { 12 | it('should return the font text size from document body', () => { 13 | document.body.style.setProperty('--font-text-size', '16px'); 14 | expect(calculateFontTextSize()).toBe(16); 15 | }); 16 | 17 | it('should return the font text size from document element if not found in body', () => { 18 | document.body.style.removeProperty('--font-text-size'); 19 | document.documentElement.style.fontSize = '16px'; 20 | expect(calculateFontTextSize()).toBe(16); 21 | }); 22 | }); 23 | 24 | describe('calculateInlineTitleSize', () => { 25 | it('should calculate the inline title size correctly', () => { 26 | document.body.style.setProperty('--font-text-size', '16px'); 27 | document.body.style.setProperty('--inline-title-size', '2'); 28 | expect(calculateInlineTitleSize()).toBe(32); 29 | }); 30 | 31 | it('should transform `px` to `em` values', () => { 32 | document.body.style.setProperty('--font-text-size', '16px'); 33 | document.body.style.setProperty('--inline-title-size', '32px'); 34 | expect(calculateInlineTitleSize()).toBe(32); 35 | }); 36 | }); 37 | 38 | describe('isHeader', () => { 39 | it.each(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])( 40 | 'should return `true` for %s', 41 | (value) => { 42 | expect(isHeader(value)).toBe(true); 43 | }, 44 | ); 45 | 46 | it('should return `false` for invalid header values', () => { 47 | expect(isHeader('h7')).toBe(false); 48 | expect(isHeader('h0')).toBe(false); 49 | expect(isHeader('h')).toBe(false); 50 | expect(isHeader('')).toBe(false); 51 | }); 52 | }); 53 | 54 | describe('calculateHeaderSize', () => { 55 | it('should calculate the header size correctly', () => { 56 | document.body.style.setProperty('--font-text-size', '16px'); 57 | document.body.style.setProperty('--h1-size', '2'); 58 | expect(calculateHeaderSize('h1')).toBe(32); 59 | }); 60 | 61 | it('should calculate the header size correctly based on header token', () => { 62 | document.body.style.setProperty('--font-text-size', '16px'); 63 | document.body.style.setProperty('--h1-size', '2'); 64 | expect(calculateHeaderSize('header-1')).toBe(32); 65 | }); 66 | }); 67 | 68 | describe('pxToRem', () => { 69 | it('should convert px to rem', () => { 70 | expect(pxToRem(16)).toBe(1); 71 | expect(pxToRem(24)).toBe(1.5); 72 | expect(pxToRem(32)).toBe(2); 73 | }); 74 | 75 | it('should convert px to rem with different base value', () => { 76 | expect(pxToRem(16, 20)).toBe(0.8); 77 | expect(pxToRem(24, 20)).toBe(1.2); 78 | expect(pxToRem(32, 20)).toBe(1.6); 79 | }); 80 | }); 81 | 82 | describe('isPx', () => { 83 | it('should return `true` when value is in px format', () => { 84 | expect(isPx('16px')).toBe(true); 85 | expect(isPx('0px')).toBe(true); 86 | expect(isPx('5903px')).toBe(true); 87 | }); 88 | 89 | it('should return `false` when value is in not px format', () => { 90 | expect(isPx('px')).toBe(false); 91 | expect(isPx('')).toBe(false); 92 | expect(isPx('16rem')).toBe(false); 93 | expect(isPx('16em')).toBe(false); 94 | expect(isPx('16ch')).toBe(false); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /docs/guide/icon-packs.md: -------------------------------------------------------------------------------- 1 | # Icon Packs 2 | 3 | Iconize comes with some predefined icon packs. However, you can also add your own icon 4 | packs. This section of the documentation will show you how to do that, but also how to use 5 | the predefined icon packs and emojis. 6 | 7 | ## Predefined Icon Packs 8 | 9 | To use a predefined icon pack, you can go to the settings of the plugin and select 10 | `Browse icon packs` and then select the icon pack you want to use. So that the following 11 | modal will open: 12 | 13 | ![Browse icon packs](../assets/browse-icon-packs.png) 14 | 15 | After you have selected the icon pack you want to use, it will download the icon pack and 16 | then you can use it in your vault. 17 | 18 | Currently, Iconize supports the following predefined icon packs: 19 | 20 | - [Font Awesome](https://fontawesome.com/) 21 | - [Remix Icons](https://remixicon.com/) 22 | - [Icon Brew](https://iconbrew.com/) 23 | - [Simple Icons](https://simpleicons.org/) 24 | - [Lucide Icons](https://lucide.dev/) 25 | - [Tabler Icons](https://tabler-icons.io/) 26 | - [BoxIcons](https://boxicons.com/) 27 | - [RPG Awesome](http://nagoshiashumari.github.io/Rpg-Awesome/) 28 | - [coolicons](https://coolicons.cool/) 29 | - [Feather Icons](https://feathericons.com/) 30 | 31 | If you want to add a predefined icon pack or you would like to update an existing one, 32 | feel free to open a pull request on 33 | [GitHub](https://github.com/FlorianWoelki/obsidian-iconize/compare). 34 | 35 | ## Custom Icon Packs 36 | 37 | ::: tip NOTE 38 | 39 | This feature is currently not 100% available and stable. If you want to use it, you can 40 | do that, but it might be that some things are not working as expected. Furthermore, there 41 | might be some breaking changes in the future. 42 | 43 | ::: 44 | 45 | If you want to add your own icon pack, you can do that by using the option `Add icon pack` 46 | in the plugin settings of Iconize. You just need to enter the name of the icon pack. 47 | After that, you can add the icons you want to use in your vault by using the plus icon (`+`) 48 | next to the custom icon pack. 49 | 50 | ![Add icon pack](../assets/add-custom-icon-pack.png) 51 | 52 | After you have added the icon pack, you need to zip your custom icon pack by going to the 53 | plugins folder of Obsidian. You can find the plugins folder by going to the settings of 54 | Obsidian and then clicking on `Open plugins folder`. After that, you need to go to the 55 | folder `obsidian-iconize` and then to the folder `icons`. In this folder, you can zip your 56 | custom icon pack. The zip file needs to have the same name as the icon pack you have 57 | entered in the settings of Iconize. 58 | 59 | ::: tip NOTE 60 | 61 | The creation of a zip file needs to be currently done manually. In the future, this will be 62 | automatically done by Iconize. See 63 | [this issue](https://github.com/FlorianWoelki/obsidian-iconize/issues/224) for more 64 | information. 65 | 66 | ::: 67 | 68 | ## Using Emojis 69 | 70 | If you want to use emojis in your vault, you can do that by using the built-in functionality 71 | of Iconize. You can directly use emojis in the icon picker by searching for them. You can 72 | search for emojis by using the name of the emoji or by using the emoji itself. 73 | 74 | Furthermore, you can also adapt the style of the emoji by choosing the emoji style in the 75 | settings of Iconize. You can choose between `Native` and `Twemoji`. 76 | 77 | ![Emoji style](../assets/emoji-style.png) 78 | -------------------------------------------------------------------------------- /src/lib/util/text.ts: -------------------------------------------------------------------------------- 1 | // Cache for font size 2 | let cachedFontSize: number | null = null; 3 | let fontSizeCacheTime: number = 0; 4 | 5 | const calculateFontTextSize = () => { 6 | // get cached font size if available 7 | const now = Date.now(); 8 | if (cachedFontSize !== null && now - fontSizeCacheTime < 2000) { 9 | return cachedFontSize; 10 | } 11 | 12 | let fontSize = parseFloat( 13 | getComputedStyle(document.body).getPropertyValue('--font-text-size') ?? '0', 14 | ); 15 | if (!fontSize) { 16 | fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize); 17 | } 18 | // set font size cache 19 | cachedFontSize = fontSize; 20 | fontSizeCacheTime = now; 21 | return fontSize; 22 | }; 23 | 24 | const calculateInlineTitleSize = (): number => { 25 | const fontSize = calculateFontTextSize(); 26 | const inlineTitleSizeValue = getComputedStyle(document.body).getPropertyValue( 27 | '--inline-title-size', 28 | ); 29 | const unit = inlineTitleSizeValue.replace(/[\d.]/g, ''); 30 | let inlineTitleSize = parseFloat(inlineTitleSizeValue); 31 | if (unit === 'px') { 32 | inlineTitleSize /= 16; 33 | } 34 | 35 | return fontSize * inlineTitleSize; 36 | }; 37 | 38 | // Type is being used for the HTML header tags. 39 | export type HTMLHeader = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; 40 | // Type is being used for the header token types in codemirror. 41 | export type HeaderToken = 42 | | 'header-1' 43 | | 'header-2' 44 | | 'header-3' 45 | | 'header-4' 46 | | 'header-5' 47 | | 'header-6'; 48 | 49 | const isHeader = (value: string): boolean => { 50 | return /^h[1-6]$/.test(value); 51 | }; 52 | 53 | const getHTMLHeaderByToken = (header: HeaderToken): HTMLHeader | null => { 54 | for (let i = 1; i <= 6; i++) { 55 | if (header === `header-${i}`) { 56 | return `h${i}` as HTMLHeader; 57 | } 58 | } 59 | return null; 60 | }; 61 | 62 | const calculateHeaderSize = (header: HTMLHeader | HeaderToken): number => { 63 | const fontSize = calculateFontTextSize(); 64 | const htmlHeader = getHTMLHeaderByToken(header as HeaderToken) ?? header; 65 | const headerComputedStyle = getComputedStyle(document.body).getPropertyValue( 66 | `--${htmlHeader}-size`, 67 | ); 68 | let headerSize = parseFloat(headerComputedStyle); 69 | if (isPx(headerComputedStyle)) { 70 | headerSize = pxToRem(headerSize, fontSize); 71 | } 72 | 73 | // If there is some `calc` operation going on, it has to be evaluated. 74 | if (headerComputedStyle.includes('calc')) { 75 | const temp = document.createElement('div'); 76 | 77 | temp.style.setProperty('font-size', `var(--${htmlHeader}-size)`); 78 | document.body.appendChild(temp); 79 | 80 | const computedStyle = window.getComputedStyle(temp); 81 | const computedValue = computedStyle.getPropertyValue('font-size'); 82 | headerSize = parseFloat(computedValue); 83 | if (isPx(computedValue)) { 84 | headerSize = pxToRem(headerSize, fontSize); 85 | } 86 | 87 | document.body.removeChild(temp); 88 | } 89 | 90 | return fontSize * headerSize; 91 | }; 92 | 93 | const pxToRem = (px: number, baseSize = 16): number => { 94 | return px / baseSize; 95 | }; 96 | 97 | const isPx = (value: string): boolean => { 98 | return /^-?\d+(\.\d+)?px$/.test(value); 99 | }; 100 | 101 | export { 102 | calculateInlineTitleSize, 103 | calculateHeaderSize, 104 | calculateFontTextSize, 105 | isHeader, 106 | isPx, 107 | pxToRem, 108 | }; 109 | -------------------------------------------------------------------------------- /docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress'; 2 | 3 | // https://vitepress.dev/reference/site-config 4 | export default defineConfig({ 5 | title: 'Obsidian Iconize', 6 | description: 7 | 'Add icons to anything you desire in Obsidian, including files, folders, and text.', 8 | base: '/obsidian-iconize/', 9 | themeConfig: { 10 | // https://vitepress.dev/reference/default-theme-config 11 | nav: [ 12 | { text: 'Home', link: '/' }, 13 | { text: 'Get Started', link: '/guide/getting-started' }, 14 | { text: 'API', link: '/api/getting-started' }, 15 | ], 16 | 17 | search: { 18 | provider: 'local', 19 | }, 20 | 21 | sidebar: [ 22 | { 23 | text: 'Guide', 24 | collapsed: false, 25 | items: [ 26 | { text: 'Getting Started', link: '/guide/getting-started' }, 27 | { text: 'Settings', link: '/guide/settings' }, 28 | { text: 'Icon Packs', link: '/guide/icon-packs' }, 29 | { text: 'Syncing', link: '/guide/syncing' }, 30 | ], 31 | }, 32 | { 33 | text: 'Files and Folders', 34 | collapsed: false, 35 | items: [ 36 | { 37 | text: 'Icon before file/folder name', 38 | link: '/files-and-folders/icon-before-file-or-folder', 39 | }, 40 | { text: 'Icon in Tabs', link: '/files-and-folders/icon-tabs' }, 41 | { text: 'Custom Rules', link: '/files-and-folders/custom-rules' }, 42 | { 43 | text: 'Use Frontmatter', 44 | link: '/files-and-folders/use-frontmatter', 45 | }, 46 | { 47 | text: 'Change individual icon color', 48 | link: '/files-and-folders/individual-icon-color', 49 | }, 50 | ], 51 | }, 52 | { 53 | text: 'Notes', 54 | collapsed: false, 55 | items: [ 56 | { text: 'Icons in Notes', link: '/notes/icons-in-notes' }, 57 | { text: 'Icon above Title', link: '/notes/title-icon' }, 58 | ], 59 | }, 60 | { 61 | text: 'API', 62 | collapsed: false, 63 | items: [{ text: 'Getting Started', link: '/api/getting-started' }], 64 | }, 65 | { 66 | text: 'Compatibility with Plugins', 67 | collapsed: true, 68 | items: [ 69 | { text: 'Metadatamenu', link: '/compatibility-plugins/metadatamenu' }, 70 | ], 71 | }, 72 | { 73 | text: 'Good to know', 74 | collapsed: true, 75 | items: [ 76 | { text: 'See icon name', link: '/good-to-know/see-icon-name' }, 77 | { 78 | text: 'Use png in icon pack', 79 | link: '/good-to-know/transform-png-to-svg', 80 | }, 81 | { text: 'Unicode issue', link: '/good-to-know/unicode-issue' }, 82 | ], 83 | }, 84 | { 85 | text: 'Deprecated', 86 | collapsed: true, 87 | items: [{ text: 'Inheritance', link: '/deprecated/inheritance' }], 88 | }, 89 | ], 90 | 91 | socialLinks: [ 92 | { 93 | icon: 'github', 94 | link: 'https://github.com/FlorianWoelki/obsidian-iconize', 95 | }, 96 | ], 97 | 98 | footer: { 99 | message: 100 | 'Released under the MIT License.', 101 | copyright: 102 | 'Copyright © 2021-present Florian Woelki', 103 | }, 104 | }, 105 | }); 106 | -------------------------------------------------------------------------------- /src/internal-plugins/starred.ts: -------------------------------------------------------------------------------- 1 | import { around } from 'monkey-around'; 2 | import { View } from 'obsidian'; 3 | import InternalPluginInjector from '@app/@types/internal-plugin-injector'; 4 | import { StarredFile } from '@app/@types/obsidian'; 5 | import dom from '@lib/util/dom'; 6 | import icon from '@lib/icon'; 7 | import config from '@app/config'; 8 | import IconizePlugin from '@app/main'; 9 | 10 | interface StarredView extends View { 11 | itemLookup: WeakMap; 12 | } 13 | 14 | /** 15 | * @deprecated After obsidian 1.2.6 in favor of the bookmarks plugin. 16 | */ 17 | export default class StarredInternalPlugin extends InternalPluginInjector { 18 | constructor(plugin: IconizePlugin) { 19 | super(plugin); 20 | } 21 | 22 | get starred() { 23 | return this.plugin.app.internalPlugins.getPluginById('starred'); 24 | } 25 | 26 | get enabled() { 27 | return this.plugin.app.internalPlugins.getPluginById('starred').enabled; 28 | } 29 | 30 | get leaf(): StarredView | undefined { 31 | const leaf = this.plugin.app.workspace.getLeavesOfType('starred'); 32 | if (!leaf) { 33 | return undefined; 34 | } 35 | 36 | if (leaf.length === 1) { 37 | return leaf[0].view as StarredView; 38 | } 39 | 40 | return undefined; 41 | } 42 | 43 | private setIcon(filePath: string, node: Element | undefined): void { 44 | const iconName = icon.getByPath(this.plugin, filePath); 45 | const iconNode = node.querySelector('.nav-file-icon'); 46 | if (!iconNode || !iconName) { 47 | return; 48 | } 49 | 50 | dom.setIconForNode(this.plugin, iconName, iconNode as HTMLElement); 51 | } 52 | 53 | private computeNodesWithPath( 54 | callback: (node: Element, filePath: string) => void, 55 | ): void { 56 | const { itemLookup, containerEl } = this.leaf; 57 | const navFileEls = containerEl.querySelectorAll('.nav-file'); 58 | navFileEls.forEach((navFileEl) => { 59 | const lookupFile = itemLookup.get(navFileEl); 60 | if (!lookupFile) { 61 | return; 62 | } 63 | 64 | callback(navFileEl, lookupFile.path); 65 | }); 66 | } 67 | 68 | onMount(): void { 69 | const nodesWithPath: { [key: string]: Element } = {}; 70 | this.computeNodesWithPath((node, filePath) => { 71 | nodesWithPath[filePath] = node; 72 | }); 73 | 74 | Object.entries(nodesWithPath).forEach(([filePath, node]) => 75 | this.setIcon(filePath, node as HTMLElement), 76 | ); 77 | } 78 | 79 | register(): void { 80 | if ( 81 | !this.plugin.app.internalPlugins.getPluginById('file-explorer').enabled 82 | ) { 83 | console.info( 84 | `[${config.PLUGIN_NAME}/Starred] Skipping starred internal plugin registration because file-explorer is not enabled.`, 85 | ); 86 | return; 87 | } 88 | 89 | if (!this.enabled) { 90 | console.info( 91 | `[${config.PLUGIN_NAME}/Starred] Skipping starred internal plugin registration because it's not enabled.`, 92 | ); 93 | return; 94 | } 95 | 96 | // eslint-disable-next-line 97 | const self = this; 98 | this.plugin.register( 99 | around(this.starred.instance, { 100 | addItem: function (next) { 101 | return function (file) { 102 | next.call(this, file); 103 | self.onMount(); 104 | }; 105 | }, 106 | removeItem: function (next) { 107 | return function (file) { 108 | next.call(this, file); 109 | self.onMount(); 110 | }; 111 | }, 112 | }), 113 | ); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/lib/util/svg.ts: -------------------------------------------------------------------------------- 1 | // This library file does not include any other dependency and is a standalone file that 2 | // only include utility functions for manipulating or extracting svg information. 3 | 4 | /** 5 | * Extracts an SVG string from a given input string and returns a cleaned up and 6 | * formatted SVG string. 7 | * @param svgString SVG string to extract from. 8 | * @returns Cleaned up and formatted SVG string. 9 | */ 10 | const extract = (svgString: string): string => { 11 | // Removes unnecessary spaces and newlines. 12 | svgString = svgString.replace(/(\r\n|\n|\r)/gm, ''); 13 | svgString = svgString.replace(/>\s+<'); 14 | 15 | // Create a parser for better parsing of HTML. 16 | const parser = new DOMParser(); 17 | const svg = parser 18 | .parseFromString(svgString, 'text/html') 19 | .querySelector('svg'); 20 | 21 | // Removes `width` and `height` from the `style` attribute. 22 | if (svg.hasAttribute('style')) { 23 | svg.style.width = ''; 24 | svg.style.height = ''; 25 | } 26 | 27 | // Add `viewbox`, if it is not already a attribute. 28 | if (svg.viewBox.baseVal.width === 0 && svg.viewBox.baseVal.height === 0) { 29 | const width = svg.width.baseVal.value ?? 16; 30 | const height = svg.height.baseVal.value ?? 16; 31 | svg.viewBox.baseVal.width = width; 32 | svg.viewBox.baseVal.height = height; 33 | } 34 | 35 | if (!svg.hasAttribute('fill')) { 36 | svg.setAttribute('fill', 'currentColor'); 37 | } 38 | 39 | const possibleTitle = svg.querySelector('title'); 40 | if (possibleTitle) { 41 | possibleTitle.remove(); 42 | } 43 | 44 | svg.setAttribute('width', '16px'); 45 | svg.setAttribute('height', '16px'); 46 | 47 | return svg.outerHTML; 48 | }; 49 | 50 | /** 51 | * Sets the font size of an SVG string by modifying its width and/or height attributes. 52 | * The font size will be always set in pixels. 53 | * @param svgString SVG string to modify. 54 | * @param fontSize Font size in pixels to set. 55 | * @returns Modified SVG string. 56 | */ 57 | const setFontSize = (svgString: string, fontSize: number): string => { 58 | const widthRe = new RegExp(/width="[\d.]+(px)?"/); 59 | const heightRe = new RegExp(/height="[\d.]+(px)?"/); 60 | if (svgString.match(widthRe)) { 61 | svgString = svgString.replace(widthRe, `width="${fontSize}px"`); 62 | } 63 | if (svgString.match(heightRe)) { 64 | svgString = svgString.replace(heightRe, `height="${fontSize}px"`); 65 | } 66 | return svgString; 67 | }; 68 | 69 | /** 70 | * Replaces the fill or stroke color of an SVG string with a given color. 71 | * @param svgString SVG string to modify. 72 | * @param color Color to set. Defaults to 'currentColor'. 73 | * @returns The modified SVG string. 74 | */ 75 | const colorize = ( 76 | svgString: string, 77 | color: string | undefined | null, 78 | ): string => { 79 | if (!color) { 80 | color = 'currentColor'; 81 | } 82 | 83 | const parser = new DOMParser(); 84 | // Tries to parse the string into a HTML node. 85 | const parsedNode = parser.parseFromString(svgString, 'text/html'); 86 | const svg = parsedNode.querySelector('svg'); 87 | 88 | if (svg) { 89 | if (svg.hasAttribute('fill') && svg.getAttribute('fill') !== 'none') { 90 | svg.setAttribute('fill', color); 91 | } else if ( 92 | svg.hasAttribute('stroke') && 93 | svg.getAttribute('stroke') !== 'none' 94 | ) { 95 | svg.setAttribute('stroke', color); 96 | } 97 | 98 | return svg.outerHTML; 99 | } 100 | 101 | return svgString; 102 | }; 103 | 104 | export default { 105 | extract, 106 | colorize, 107 | setFontSize, 108 | }; 109 | -------------------------------------------------------------------------------- /src/@types/obsidian.d.ts: -------------------------------------------------------------------------------- 1 | import IconizeAPI from '@app/lib/api'; 2 | import { 3 | Editor, 4 | TAbstractFile, 5 | TFile, 6 | View, 7 | ViewState, 8 | WorkspaceLeaf, 9 | } from 'obsidian'; 10 | 11 | interface InternalPlugin { 12 | enabled: boolean; 13 | enable: (b: boolean) => void; 14 | disable: (b: boolean) => void; 15 | } 16 | 17 | interface StarredFile { 18 | type: 'file'; 19 | title: string; 20 | path: string; 21 | } 22 | 23 | interface StarredInternalPlugin extends InternalPlugin { 24 | instance: { 25 | addItem: (file: StarredFile) => void; 26 | removeItem: (file: StarredFile) => void; 27 | items: StarredFile[]; 28 | }; 29 | } 30 | 31 | interface BookmarkItem { 32 | ctime: number; 33 | type: 'file' | 'folder' | 'group'; 34 | path: string; 35 | title: string; 36 | items?: BookmarkItem[]; 37 | } 38 | 39 | interface BookmarkItemValue { 40 | el: HTMLElement; 41 | item: BookmarkItem; 42 | } 43 | 44 | interface BookmarkInternalPlugin extends InternalPlugin { 45 | instance: { 46 | addItem: (file: BookmarkItem) => void; 47 | removeItem: (file: BookmarkItem) => void; 48 | items: BookmarkItem[]; 49 | }; 50 | } 51 | 52 | interface OutlineInternalPlugin extends InternalPlugin {} 53 | 54 | type FileExplorerInternalPlugin = InternalPlugin; 55 | 56 | interface InternalPlugins { 57 | starred: StarredInternalPlugin; 58 | bookmarks: BookmarkInternalPlugin; 59 | 'file-explorer': FileExplorerInternalPlugin; 60 | outline: OutlineInternalPlugin; 61 | } 62 | 63 | declare module 'obsidian' { 64 | interface Workspace { 65 | getLeavesOfType( 66 | viewType: 'markdown' | 'search' | 'file-explorer', 67 | ): ExplorerLeaf[]; 68 | } 69 | 70 | interface App { 71 | plugins: { 72 | enabledPlugins: Set; 73 | plugins: { 74 | ['obsidian-icon-folder']?: { 75 | api: IconizeAPI; 76 | }; 77 | }; 78 | }; 79 | internalPlugins: { 80 | plugins: InternalPlugins; 81 | getPluginById(id: T): InternalPlugins[T]; 82 | loadPlugin(...args: any[]): any; 83 | }; 84 | } 85 | } 86 | 87 | type FileWithLeaf = TFile & { leaf: ExplorerLeaf; pinned: boolean }; 88 | 89 | interface ExplorerLeaf extends WorkspaceLeaf { 90 | view: ExplorerView; 91 | } 92 | 93 | interface TabHeaderLeaf extends ExplorerLeaf { 94 | tabHeaderEl: HTMLElement; 95 | tabHeaderInnerIconEl: HTMLElement; 96 | } 97 | 98 | interface DomChild { 99 | file: TFile; 100 | collapseEl: HTMLElement; 101 | containerEl: HTMLElement; 102 | } 103 | 104 | interface ExplorerViewState extends ViewState { 105 | state: { 106 | source: boolean; // true if source view is active 107 | }; 108 | } 109 | 110 | interface ExplorerView extends View { 111 | fileItems: Record; // keyed by path 112 | ready: boolean; // true if fileItems is populated 113 | file?: TFile; 114 | getViewState(): ExplorerViewState; 115 | getMode(): 'source' | 'preview'; 116 | dom: { children: DomChild[]; changed: () => void }; 117 | } 118 | 119 | interface InlineTitleView extends ExplorerView { 120 | inlineTitleEl: HTMLElement; 121 | } 122 | 123 | interface FileItem { 124 | /** 125 | * @deprecated After Obsidian 1.2.0, use `selfEl` instead. 126 | */ 127 | titleEl?: HTMLDivElement; 128 | /** 129 | * @deprecated After Obsidian 1.2.0, use `innerEl` instead. 130 | */ 131 | titleInnerEl?: HTMLDivElement; 132 | selfEl: HTMLDivElement; 133 | innerEl: HTMLDivElement; 134 | file: TAbstractFile; 135 | } 136 | 137 | interface EditorWithEditorComponent extends Editor { 138 | editorComponent?: { 139 | file?: TFile; 140 | }; 141 | } 142 | -------------------------------------------------------------------------------- /src/lib/logger.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, it, expect, vi, afterEach, Mock } from 'vitest'; 2 | import { ConsoleLogger, LoggerPrefix } from './logger'; 3 | 4 | describe('ConsoleLogger', () => { 5 | let mockConsole: Record; 6 | const now = new Date(); 7 | 8 | beforeEach(() => { 9 | mockConsole = { 10 | log: vi.fn(), 11 | info: vi.fn(), 12 | warn: vi.fn(), 13 | error: vi.fn(), 14 | }; 15 | 16 | console = mockConsole as any; 17 | 18 | vi.useFakeTimers(); 19 | vi.setSystemTime(now); 20 | }); 21 | 22 | afterEach(() => { 23 | vi.restoreAllMocks(); 24 | vi.useRealTimers(); 25 | }); 26 | 27 | it('should log a basic message', () => { 28 | const logger = new ConsoleLogger('TestPrefix', true); 29 | logger.log('Test message', null); 30 | 31 | expect(mockConsole.log).toHaveBeenCalledWith( 32 | `TestPrefix: [${now.toISOString()}] LOG: Test message`, 33 | ); 34 | }); 35 | 36 | it('should log a info message', () => { 37 | const logger = new ConsoleLogger('TestPrefix', true); 38 | logger.info('Test message', null); 39 | 40 | expect(mockConsole.info).toHaveBeenCalledWith( 41 | `TestPrefix: [${now.toISOString()}] INFO: Test message`, 42 | ); 43 | }); 44 | 45 | it('should log a warn message', () => { 46 | const logger = new ConsoleLogger('TestPrefix', true); 47 | logger.warn('Test message', null); 48 | 49 | expect(mockConsole.warn).toHaveBeenCalledWith( 50 | `TestPrefix: [${now.toISOString()}] WARN: Test message`, 51 | ); 52 | }); 53 | 54 | it('should log an error message', () => { 55 | const logger = new ConsoleLogger('TestPrefix', true); 56 | logger.error('Test message', null); 57 | 58 | expect(mockConsole.error).toHaveBeenCalledWith( 59 | `TestPrefix: [${now.toISOString()}] ERROR: Test message`, 60 | ); 61 | }); 62 | 63 | it('should log with optional parameters', () => { 64 | const logger = new ConsoleLogger('TestPrefix', true); 65 | logger.warn('Test message', null, { data: 123 }); 66 | 67 | expect(mockConsole.warn).toHaveBeenCalledWith( 68 | `TestPrefix: [${now.toISOString()}] WARN: Test message`, 69 | { data: 123 }, 70 | ); 71 | }); 72 | 73 | it('should log additional prefix if it is not null', () => { 74 | const logger = new ConsoleLogger('TestPrefix', true); 75 | logger.warn('Test message', LoggerPrefix.Outline, { data: 123 }); 76 | 77 | expect(mockConsole.warn).toHaveBeenCalledWith( 78 | `TestPrefix/Outline: [${now.toISOString()}] WARN: Test message`, 79 | { data: 123 }, 80 | ); 81 | }); 82 | 83 | it('should not log when logging is disabled', () => { 84 | const logger = new ConsoleLogger('TestPrefix', false); 85 | logger.log('Test message', null); 86 | 87 | expect(mockConsole.log).not.toHaveBeenCalled(); 88 | expect(mockConsole.info).not.toHaveBeenCalled(); 89 | expect(mockConsole.warn).not.toHaveBeenCalled(); 90 | expect(mockConsole.error).not.toHaveBeenCalled(); 91 | }); 92 | 93 | it('should log when logging is enabled after being disabled', () => { 94 | const logger = new ConsoleLogger('TestPrefix', false); 95 | logger.log('Test message', null); 96 | expect(mockConsole.log).not.toHaveBeenCalled(); 97 | 98 | logger.toggleLogging(true); 99 | logger.log('Test message', null); 100 | 101 | expect(mockConsole.log).toHaveBeenCalledWith(expect.any(String)); 102 | }); 103 | 104 | it('should log when logging is disabled after being enabled', () => { 105 | const logger = new ConsoleLogger('TestPrefix', true); 106 | logger.log('Test message', null); 107 | expect(mockConsole.log).toHaveBeenCalledWith(expect.any(String)); 108 | 109 | logger.toggleLogging(false); 110 | logger.log('Test message', null); 111 | 112 | expect(mockConsole.log).toHaveBeenCalledTimes(1); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /src/icon-pack-manager/util.test.ts: -------------------------------------------------------------------------------- 1 | import { it, describe, expect, vi } from 'vitest'; 2 | import { 3 | generateIcon, 4 | getNormalizedName, 5 | getSvgFromLoadedIcon, 6 | nextIdentifier, 7 | } from './util'; 8 | import IconizePlugin from '@app/main'; 9 | import { IconPack } from './icon-pack'; 10 | 11 | describe('getNormalizedName', () => { 12 | it('should return a string with all words capitalized and no spaces or underscores', () => { 13 | const input = 'this is a test_name'; 14 | const expectedOutput = 'ThisIsATestName'; 15 | expect(getNormalizedName(input)).toEqual(expectedOutput); 16 | }); 17 | 18 | it('should handle input with only one word', () => { 19 | const input = 'test'; 20 | const expectedOutput = 'Test'; 21 | expect(getNormalizedName(input)).toEqual(expectedOutput); 22 | }); 23 | 24 | it('should handle input with spaces and underscores', () => { 25 | const input = 'this_is a_test name'; 26 | const expectedOutput = 'ThisIsATestName'; 27 | expect(getNormalizedName(input)).toEqual(expectedOutput); 28 | }); 29 | 30 | it('should handle input with spaces and hyphens', () => { 31 | const input = 'this-is a-test-name'; 32 | const expectedOutput = 'ThisIsATestName'; 33 | expect(getNormalizedName(input)).toEqual(expectedOutput); 34 | }); 35 | }); 36 | 37 | describe('nextIdentifier', () => { 38 | it('should find first uppercase letter or number', () => { 39 | expect(nextIdentifier('aBcDef')).toBe(1); 40 | expect(nextIdentifier('a1bcDef')).toBe(1); 41 | expect(nextIdentifier('aBc123')).toBe(1); 42 | }); 43 | 44 | it('should return 0 when no match found', () => { 45 | expect(nextIdentifier('abcdef')).toBe(0); 46 | }); 47 | 48 | it('should handle empty string', () => { 49 | expect(nextIdentifier('')).toBe(0); 50 | }); 51 | }); 52 | 53 | describe('getSvgFromLoadedIcon', () => { 54 | const mockPlugin = { 55 | getIconPackManager: vi.fn(() => ({ 56 | getPreloadedIcons: vi.fn(() => [ 57 | { prefix: 'fa', name: 'user', svgElement: 'preloaded' }, 58 | ]), 59 | getIconPacks: vi.fn(() => [ 60 | { 61 | getIcons: vi.fn(() => [ 62 | { 63 | prefix: 'md', 64 | name: 'settings', 65 | svgElement: 'material', 66 | }, 67 | ]), 68 | }, 69 | ]), 70 | })), 71 | } as unknown as IconizePlugin; 72 | 73 | it('should find preloaded icon', () => { 74 | const result = getSvgFromLoadedIcon(mockPlugin, 'fa', 'user'); 75 | expect(result).toBe('preloaded'); 76 | }); 77 | 78 | it('should search icon packs when not preloaded', () => { 79 | const result = getSvgFromLoadedIcon(mockPlugin, 'md', 'settings'); 80 | expect(result).toBe('material'); 81 | }); 82 | 83 | it('should return empty string when not found', () => { 84 | const result = getSvgFromLoadedIcon(mockPlugin, 'none', 'missing'); 85 | expect(result).toBe(''); 86 | }); 87 | }); 88 | 89 | describe('generateIcon', () => { 90 | const mockIconPack = { 91 | getPrefix: () => 'fa', 92 | getName: () => 'font-awesome', 93 | } as IconPack; 94 | 95 | it('should create valid icon structure', () => { 96 | const result = generateIcon( 97 | mockIconPack, 98 | 'test', 99 | '', 100 | ); 101 | 102 | expect(result).toEqual({ 103 | name: 'Test', 104 | prefix: 'fa', 105 | iconPackName: 'font-awesome', 106 | displayName: 'test', 107 | filename: 'test', 108 | svgContent: '', 109 | svgViewbox: 'viewBox="0 0 24 24"', 110 | svgElement: expect.any(String), 111 | }); 112 | }); 113 | 114 | it('should handle SVG normalization', () => { 115 | const result = generateIcon( 116 | mockIconPack, 117 | 'test', 118 | ` 119 | 123 | 124 | 125 | `, 126 | ); 127 | 128 | expect(result?.svgContent).toBe(''); 129 | expect(result?.svgViewbox).toBe('viewBox="0 0 24 24"'); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /src/editor/icons-suggestion.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | Editor, 4 | EditorPosition, 5 | EditorSuggest, 6 | EditorSuggestContext, 7 | EditorSuggestTriggerInfo, 8 | } from 'obsidian'; 9 | import icon from '@app/lib/icon'; 10 | import emoji from '@app/emoji'; 11 | import { saveIconToIconPack } from '@app/util'; 12 | import IconizePlugin from '@app/main'; 13 | 14 | export default class SuggestionIcon extends EditorSuggest { 15 | constructor( 16 | app: App, 17 | public plugin: IconizePlugin, 18 | ) { 19 | super(app); 20 | } 21 | 22 | onTrigger(cursor: EditorPosition, editor: Editor): EditorSuggestTriggerInfo { 23 | // Isolate shortcode starting position closest to the cursor. 24 | const shortcodeStart = editor 25 | .getLine(cursor.line) 26 | .substring(0, cursor.ch) 27 | .lastIndexOf(this.plugin.getSettings().iconIdentifier); 28 | 29 | // `onTrigger` needs to return `null` as soon as possible to save processing performance. 30 | if (shortcodeStart === -1) { 31 | return null; 32 | } 33 | 34 | // Regex for checking if the shortcode is not done yet. 35 | const regex = new RegExp( 36 | `^(${this.plugin.getSettings().iconIdentifier})\\w+$`, 37 | 'g', 38 | ); 39 | const regexOngoingShortcode = editor 40 | .getLine(cursor.line) 41 | .substring(shortcodeStart, cursor.ch) 42 | .match(regex); 43 | 44 | if (regexOngoingShortcode === null) { 45 | return null; 46 | } 47 | 48 | const startingIndex = editor 49 | .getLine(cursor.line) 50 | .indexOf(regexOngoingShortcode[0]); 51 | 52 | return { 53 | start: { 54 | line: cursor.line, 55 | ch: startingIndex, 56 | }, 57 | end: { 58 | line: cursor.line, 59 | ch: startingIndex + regexOngoingShortcode[0].length, 60 | }, 61 | query: regexOngoingShortcode[0], 62 | }; 63 | } 64 | 65 | getSuggestions(context: EditorSuggestContext): string[] { 66 | const queryLowerCase = context.query 67 | .substring(this.plugin.getSettings().iconIdentifier.length) 68 | .toLowerCase(); 69 | 70 | // Store all icons corresponding to the current query. 71 | const iconsNameArray = this.plugin 72 | .getIconPackManager() 73 | .allLoadedIconNames.filter((iconObject) => { 74 | const name = 75 | iconObject.prefix.toLowerCase() + iconObject.name.toLowerCase(); 76 | return name.toLowerCase().includes(queryLowerCase); 77 | }) 78 | .map((iconObject) => iconObject.prefix + iconObject.name); 79 | 80 | // Store all emojis correspoding to the current query - parsing whitespaces and 81 | // colons for shortcodes compatibility. 82 | const emojisNameArray = Object.keys(emoji.shortNames).filter((e) => 83 | emoji.getShortcode(e)?.includes(queryLowerCase), 84 | ); 85 | 86 | return [...iconsNameArray, ...emojisNameArray]; 87 | } 88 | 89 | renderSuggestion(value: string, el: HTMLElement): void { 90 | const iconObject = icon.getIconByName(this.plugin, value); 91 | el.style.display = 'flex'; 92 | el.style.alignItems = 'center'; 93 | el.style.gap = '0.25rem'; 94 | if (iconObject) { 95 | // Suggest an icon. 96 | el.innerHTML = `${iconObject.svgElement} ${value}`; 97 | } else { 98 | // Suggest an emoji - display its shortcode version. 99 | const shortcode = emoji.getShortcode(value); 100 | if (shortcode) { 101 | el.innerHTML = `${value} ${shortcode}`; 102 | } 103 | } 104 | } 105 | 106 | selectSuggestion(value: string): void { 107 | const isEmoji = emoji.isEmoji(value.replace(/_/g, ' ')); 108 | if (!isEmoji) { 109 | saveIconToIconPack(this.plugin, value); 110 | } 111 | 112 | // Replace query with iconNameWithPrefix or emoji unicode directly. 113 | const updatedValue = isEmoji 114 | ? value 115 | : `${this.plugin.getSettings().iconIdentifier}${value}${ 116 | this.plugin.getSettings().iconIdentifier 117 | }`; 118 | this.context.editor.replaceRange( 119 | updatedValue, 120 | this.context.start, 121 | this.context.end, 122 | ); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/lib/util/style.ts: -------------------------------------------------------------------------------- 1 | // This library file does not include any other dependency and is a standalone file that 2 | // only include utility functions for setting styles for nodes or icons. The only 3 | // dependency is the `svg` library. 4 | 5 | import emoji from '@app/emoji'; 6 | import IconizePlugin from '@app/main'; 7 | import { getFileItemTitleEl } from '@app/util'; 8 | import svg from './svg'; 9 | 10 | interface Margin { 11 | top: number; 12 | right: number; 13 | left: number; 14 | bottom: number; 15 | } 16 | 17 | /** 18 | * Sets the margin for a specific node. 19 | * @param el Node where the margin will be set. 20 | * @param margin Margin that will be applied to the node. 21 | * @returns The modified node with the applied margin. 22 | */ 23 | const setMargin = (el: HTMLElement, margin: Margin): HTMLElement => { 24 | el.style.margin = `${margin.top}px ${margin.right}px ${margin.bottom}px ${margin.left}px`; 25 | return el; 26 | }; 27 | 28 | /** 29 | * Applies all stylings to the specified svg icon string and applies styling to the node 30 | * (container). The styling to the specified element is only modified when it is an emoji 31 | * or extra margin is defined in the settings. 32 | * @param plugin Instance of the IconizePlugin. 33 | * @param iconString SVG that will be used to apply the svg styles to. 34 | * @param el Node for manipulating the style. 35 | * @returns Icon svg string with the manipulate style attributes. 36 | */ 37 | const applyAll = ( 38 | plugin: IconizePlugin, 39 | iconString: string, 40 | container: HTMLElement, 41 | ): string => { 42 | iconString = svg.setFontSize(iconString, plugin.getSettings().fontSize); 43 | container.style.color = plugin.getSettings().iconColor; 44 | iconString = svg.colorize(iconString, plugin.getSettings().iconColor); 45 | 46 | // Sets the margin of an element. 47 | const margin = plugin.getSettings().extraMargin; 48 | const normalizedMargin = { 49 | top: margin.top !== undefined ? margin.top : 4, 50 | right: margin.right !== undefined ? margin.right : 4, 51 | left: margin.left !== undefined ? margin.left : 4, 52 | bottom: margin.bottom !== undefined ? margin.bottom : 4, 53 | }; 54 | if (plugin.getSettings().extraMargin) { 55 | setMargin(container, normalizedMargin); 56 | } 57 | 58 | if (emoji.isEmoji(iconString)) { 59 | container.style.fontSize = `${plugin.getSettings().fontSize}px`; 60 | container.style.lineHeight = `${plugin.getSettings().fontSize}px`; 61 | } 62 | 63 | return iconString; 64 | }; 65 | 66 | /** 67 | * Refreshes all the styles of all the applied icons where a `.iconize-icon` 68 | * class is defined. This function only modifies the styling of the node. 69 | * @param plugin Instance of the IconizePlugin. 70 | * @param applyStyles Function that is getting called when the icon node is found and 71 | * typically applies all the styles to the icon. 72 | */ 73 | const refreshIconNodes = ( 74 | plugin: IconizePlugin, 75 | applyStyles = applyAll, 76 | ): void => { 77 | const fileExplorers = plugin.app.workspace.getLeavesOfType('file-explorer'); 78 | for (const fileExplorer of fileExplorers) { 79 | Object.keys(plugin.getData()).forEach((path) => { 80 | const fileItem = fileExplorer.view.fileItems[path]; 81 | if (fileItem) { 82 | const titleEl = getFileItemTitleEl(fileItem); 83 | const iconNode = titleEl.querySelector( 84 | '.iconize-icon', 85 | ) as HTMLElement | null; 86 | if (iconNode) { 87 | const pathValue = plugin.getData()[path]; 88 | const hasIndividualColor = 89 | typeof pathValue === 'object' && pathValue.iconColor; 90 | 91 | iconNode.innerHTML = applyStyles( 92 | plugin, 93 | iconNode.innerHTML, 94 | iconNode, 95 | ); 96 | if (hasIndividualColor) { 97 | iconNode.style.color = pathValue.iconColor; 98 | const colorizedInnerHtml = svg.colorize( 99 | iconNode.innerHTML, 100 | pathValue.iconColor, 101 | ); 102 | iconNode.innerHTML = colorizedInnerHtml; 103 | } 104 | } 105 | } 106 | }); 107 | } 108 | }; 109 | 110 | export default { 111 | applyAll, 112 | setMargin, 113 | refreshIconNodes, 114 | }; 115 | -------------------------------------------------------------------------------- /src/icon-pack-manager/file-manager.ts: -------------------------------------------------------------------------------- 1 | import config from '@app/config'; 2 | import { logger } from '@app/lib/logger'; 3 | import IconizePlugin from '@app/main'; 4 | import { Notice } from 'obsidian'; 5 | import { generateIcon, getNormalizedName } from './util'; 6 | import JSZip from 'jszip'; 7 | import { getExtraPath } from '@app/icon-packs'; 8 | import { getFileFromJSZipFile } from '@app/zip-util'; 9 | import { IconPack } from './icon-pack'; 10 | import { Icon } from '.'; 11 | 12 | export class FileManager { 13 | constructor(private plugin: IconizePlugin) {} 14 | 15 | // TODO: Maybe remove `path` and combine with `dir` param. 16 | public async createFile( 17 | iconPackName: string, 18 | path: string, 19 | filename: string, 20 | content: string, 21 | absoluteFilename?: string, 22 | ): Promise { 23 | const normalizedFilename = getNormalizedName(filename); 24 | const exists = await this.plugin.app.vault.adapter.exists( 25 | `${path}/${iconPackName}/${normalizedFilename}`, 26 | ); 27 | if (exists) { 28 | const folderSplit = absoluteFilename.split('/'); 29 | if (folderSplit.length >= 2) { 30 | const folderName = folderSplit[folderSplit.length - 2]; 31 | const newFilename = folderName + normalizedFilename; 32 | await this.plugin.app.vault.adapter.write( 33 | `${path}/${iconPackName}/${newFilename}`, 34 | content, 35 | ); 36 | logger.info( 37 | `Renamed old file ${normalizedFilename} to ${newFilename} due to duplication`, 38 | ); 39 | new Notice( 40 | `[${config.PLUGIN_NAME}] Renamed ${normalizedFilename} to ${newFilename} to avoid duplication.`, 41 | 8000, 42 | ); 43 | } else { 44 | logger.warn( 45 | `Could not create icons with duplicated file names (file name: ${normalizedFilename})`, 46 | ); 47 | new Notice( 48 | `[${config.PLUGIN_NAME}] Could not create duplicated icon name (${normalizedFilename})`, 49 | 8000, 50 | ); 51 | } 52 | } else { 53 | await this.plugin.app.vault.adapter.write( 54 | `${path}/${iconPackName}/${normalizedFilename}`, 55 | content, 56 | ); 57 | } 58 | } 59 | 60 | // TODO: Maybe remove `path` and combine with `dir` param. 61 | public async createDirectory(path: string, dir: string): Promise { 62 | const doesDirExist = await this.plugin.app.vault.adapter.exists( 63 | `${path}/${dir}`, 64 | ); 65 | if (!doesDirExist) { 66 | await this.plugin.app.vault.adapter.mkdir(`${path}/${dir}`); 67 | } 68 | 69 | return doesDirExist; 70 | } 71 | 72 | public async deleteFile(filePath: string): Promise { 73 | await this.plugin.app.vault.adapter.remove(filePath); 74 | } 75 | 76 | public async getFilesInDirectory(dir: string): Promise { 77 | if (!(await this.plugin.app.vault.adapter.exists(dir))) { 78 | return []; 79 | } 80 | 81 | return (await this.plugin.app.vault.adapter.list(dir)).files; 82 | } 83 | 84 | // TODO: Maybe remove `path` and combine with `dir` param. 85 | public async createZipFile( 86 | path: string, 87 | filename: string, 88 | buffer: ArrayBuffer, 89 | ): Promise { 90 | await this.plugin.app.vault.adapter.writeBinary( 91 | `${path}/${filename}`, 92 | buffer, 93 | ); 94 | } 95 | 96 | public async getIconsFromZipFile( 97 | iconPack: IconPack, 98 | files: JSZip.JSZipObject[], 99 | ): Promise { 100 | const loadedIcons: Icon[] = []; 101 | const extraPath = getExtraPath(iconPack.getName()); 102 | 103 | for (let j = 0; j < files.length; j++) { 104 | // Checks if the icon pack has an extra path. Also ignores files which do not start 105 | // with the extra path. 106 | if (extraPath && !files[j].name.startsWith(extraPath)) { 107 | continue; 108 | } 109 | 110 | const file = await getFileFromJSZipFile(files[j]); 111 | const iconContent = await file.text(); 112 | const iconName = getNormalizedName(file.name); 113 | const icon = generateIcon(iconPack, iconName, iconContent); 114 | if (icon) { 115 | loadedIcons.push(icon); 116 | } 117 | } 118 | return loadedIcons; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/icon-packs.ts: -------------------------------------------------------------------------------- 1 | export interface PredefinedIconPack { 2 | name: string; 3 | displayName: string; 4 | path: string; 5 | downloadLink: string; 6 | } 7 | 8 | const predefinedIconPacks = { 9 | faBrands: { 10 | name: 'font-awesome-brands', 11 | displayName: 'FontAwesome Brands', 12 | path: 'fontawesome-free-6.5.1-web/svgs/brands/', 13 | downloadLink: 14 | 'https://github.com/FortAwesome/Font-Awesome/releases/download/6.5.1/fontawesome-free-6.5.1-web.zip', 15 | }, 16 | faRegular: { 17 | name: 'font-awesome-regular', 18 | displayName: 'FontAwesome Regular', 19 | path: 'fontawesome-free-6.5.1-web/svgs/regular/', 20 | downloadLink: 21 | 'https://github.com/FortAwesome/Font-Awesome/releases/download/6.5.1/fontawesome-free-6.5.1-web.zip', 22 | }, 23 | faSolid: { 24 | name: 'font-awesome-solid', 25 | displayName: 'FontAwesome Solid', 26 | path: 'fontawesome-free-6.5.1-web/svgs/solid/', 27 | downloadLink: 28 | 'https://github.com/FortAwesome/Font-Awesome/releases/download/6.5.1/fontawesome-free-6.5.1-web.zip', 29 | }, 30 | remixIcons: { 31 | name: 'remix-icons', 32 | displayName: 'Remix Icons', 33 | path: '', 34 | downloadLink: 35 | 'https://github.com/Remix-Design/RemixIcon/releases/download/v4.2.0/RemixIcon_Svg_v4.2.0.zip', 36 | }, 37 | iconBrew: { 38 | name: 'icon-brew', 39 | displayName: 'Icon Brew', 40 | path: '', 41 | downloadLink: 42 | 'https://github.com/FlorianWoelki/obsidian-iconize/raw/main/iconPacks/icon-brew.zip', 43 | }, 44 | /** @source https://simpleicons.org/ */ 45 | simpleIcons: { 46 | name: 'simple-icons', 47 | displayName: 'Simple Icons', 48 | path: 'simple-icons-11.10.0/icons/', 49 | downloadLink: 50 | 'https://github.com/simple-icons/simple-icons/archive/refs/tags/11.10.0.zip', 51 | }, 52 | lucide: { 53 | name: 'lucide-icons', 54 | displayName: 'Lucide', 55 | path: '', 56 | downloadLink: 57 | 'https://github.com/lucide-icons/lucide/releases/download/0.363.0/lucide-icons-0.363.0.zip', 58 | }, 59 | tablerIcons: { 60 | name: 'tabler-icons', 61 | displayName: 'Tabler Icons', 62 | path: 'svg', 63 | downloadLink: 64 | 'https://github.com/tabler/tabler-icons/releases/download/v3.1.0/tabler-icons-3.1.0.zip', 65 | }, 66 | /** @source https://boxicons.com/ */ 67 | boxicons: { 68 | name: 'boxicons', 69 | displayName: 'Boxicons', 70 | path: 'svg', 71 | downloadLink: 72 | 'https://github.com/FlorianWoelki/obsidian-iconize/raw/main/iconPacks/boxicons.zip', 73 | }, 74 | /** @source http://nagoshiashumari.github.io/Rpg-Awesome/ */ 75 | rpgAwesome: { 76 | name: 'rpg-awesome', 77 | displayName: 'RPG Awesome', 78 | path: '', 79 | downloadLink: 80 | 'https://github.com/FlorianWoelki/obsidian-iconize/raw/main/iconPacks/rpg-awesome.zip', 81 | }, 82 | /** @source https://coolicons.cool/ */ 83 | coolicons: { 84 | name: 'coolicons', 85 | displayName: 'Coolicons', 86 | path: 'cooliocns SVG', 87 | downloadLink: 88 | 'https://github.com/krystonschwarze/coolicons/releases/download/v4.1/coolicons.v4.1.zip', 89 | }, 90 | /** @source https://feathericons.com/ */ 91 | feathericons: { 92 | name: 'feather-icons', 93 | displayName: 'Feather Icons', 94 | path: 'feather-4.29.1/icons/', 95 | downloadLink: 96 | 'https://github.com/feathericons/feather/archive/refs/tags/v4.29.1.zip', 97 | }, 98 | /** @source https://github.com/primer/octicons */ 99 | octicons: { 100 | name: 'octicons', 101 | displayName: 'Octicons', 102 | path: 'octicons-19.8.0/icons/', 103 | downloadLink: 104 | 'https://github.com/primer/octicons/archive/refs/tags/v19.8.0.zip', 105 | }, 106 | } as { [key: string]: PredefinedIconPack }; 107 | 108 | /** 109 | * Returns a possible path to the icon pack. 110 | * @param name String of the icon pack name. 111 | * @returns String of the path to the icon pack or undefined if the icon pack does not 112 | * exist. 113 | */ 114 | export const getExtraPath = (iconPackName: string): string | undefined => { 115 | const path: string | undefined = Object.values(predefinedIconPacks).find( 116 | (iconPack) => iconPack.name === iconPackName, 117 | )?.path; 118 | return path?.length === 0 ? undefined : path; 119 | }; 120 | 121 | export default predefinedIconPacks; 122 | -------------------------------------------------------------------------------- /src/lib/util/style.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Mock, 3 | MockInstance, 4 | beforeEach, 5 | describe, 6 | expect, 7 | it, 8 | vi, 9 | } from 'vitest'; 10 | import * as util from '@app/util'; 11 | import svg from './svg'; 12 | import style from './style'; 13 | 14 | describe('setMargin', () => { 15 | it('should set the margin of an element', () => { 16 | const el = document.createElement('div'); 17 | const margin = { top: 10, right: 15, bottom: 20, left: 25 }; 18 | const modifiedEl = style.setMargin(el, margin); 19 | expect(modifiedEl.style.margin).toBe('10px 15px 20px 25px'); 20 | }); 21 | }); 22 | 23 | describe('applyAll', () => { 24 | let plugin: any; 25 | let iconString: string; 26 | let container: HTMLElement; 27 | 28 | beforeEach(() => { 29 | vi.restoreAllMocks(); 30 | iconString = ''; 31 | container = document.createElement('div'); 32 | plugin = { 33 | getSettings: () => ({ 34 | fontSize: 16, 35 | iconColor: '#000000', 36 | extraMargin: {}, 37 | }), 38 | }; 39 | }); 40 | 41 | it('should call `setFontSize` function with defined font size', () => { 42 | const setFontSizeSpy = vi.spyOn(svg, 'setFontSize'); 43 | setFontSizeSpy.mockImplementationOnce(() => iconString); 44 | style.applyAll(plugin as any, iconString, container); 45 | expect(setFontSizeSpy).toHaveBeenCalledWith(iconString, 16); 46 | }); 47 | 48 | it('should call `colorize` function with defined color', () => { 49 | const colorize = vi.spyOn(svg, 'colorize'); 50 | colorize.mockImplementationOnce(() => iconString); 51 | style.applyAll(plugin as any, iconString, container); 52 | expect(colorize).toHaveBeenCalledWith(iconString, '#000000'); 53 | }); 54 | 55 | it('should set icon color of container', () => { 56 | style.applyAll(plugin as any, iconString, container); 57 | expect(container.style.color).toBe('#000000'); 58 | }); 59 | 60 | it('should set extra margin of container', () => { 61 | plugin = { 62 | getSettings: () => ({ 63 | extraMargin: { top: 2, right: 3, bottom: 4, left: 5 }, 64 | }), 65 | }; 66 | style.applyAll(plugin as any, iconString, container); 67 | expect(container.style.margin).toBe('2px 3px 4px 5px'); 68 | }); 69 | 70 | it('should set default extra margin of container when not specified', () => { 71 | style.applyAll(plugin as any, iconString, container); 72 | expect(container.style.margin).toBe('4px'); 73 | }); 74 | 75 | it('should apply emoji styles when specified icon is an emoji', () => { 76 | iconString = '😀'; 77 | style.applyAll(plugin as any, iconString, container); 78 | expect(container.style.fontSize).toBe('16px'); 79 | expect(container.style.lineHeight).toBe('16px'); 80 | }); 81 | }); 82 | 83 | describe('refreshIconNodes', () => { 84 | let applyStyles: Mock; 85 | let getFileItemTitleElSpy: MockInstance; 86 | let plugin: any; 87 | let titleEl: HTMLElement; 88 | let iconNode: HTMLElement; 89 | 90 | beforeEach(() => { 91 | vi.restoreAllMocks(); 92 | applyStyles = vi.fn(); 93 | getFileItemTitleElSpy = vi.spyOn(util, 'getFileItemTitleEl'); 94 | plugin = { 95 | app: { 96 | workspace: { 97 | getLeavesOfType: () => [ 98 | { view: { fileItems: { path1: {}, path2: {} } } }, 99 | ], 100 | }, 101 | }, 102 | getData() { 103 | return { path1: {}, path2: {} }; 104 | }, 105 | getSettings: () => ({ 106 | fontSize: 16, 107 | iconColor: '#000000', 108 | extraMargin: { top: 4, right: 4, bottom: 4, left: 4 }, 109 | }), 110 | }; 111 | titleEl = document.createElement('div'); 112 | iconNode = document.createElement('div'); 113 | titleEl.appendChild(iconNode); 114 | }); 115 | 116 | it('should refresh icon nodes', () => { 117 | iconNode.classList.add('iconize-icon'); 118 | 119 | getFileItemTitleElSpy.mockReturnValue(titleEl); 120 | 121 | style.refreshIconNodes(plugin as any, applyStyles); 122 | expect(applyStyles).toHaveBeenCalledTimes(2); 123 | }); 124 | 125 | it('should not refresh icon nodes if none are found', () => { 126 | getFileItemTitleElSpy.mockReturnValue(titleEl); 127 | 128 | style.refreshIconNodes(plugin as any, applyStyles); 129 | expect(applyStyles).toHaveBeenCalledTimes(0); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /src/lib/icon-title.ts: -------------------------------------------------------------------------------- 1 | import IconizePlugin from '@app/main'; 2 | import config from '@app/config'; 3 | import emoji from '@app/emoji'; 4 | import svg from './util/svg'; 5 | import { IconInTitlePosition } from '@app/settings/data'; 6 | 7 | const getTitleIcon = (leaf: HTMLElement): HTMLElement | null => { 8 | return leaf.querySelector(`.${config.TITLE_ICON_CLASS}`); 9 | }; 10 | 11 | interface Options { 12 | fontSize?: number; 13 | } 14 | 15 | const add = ( 16 | plugin: IconizePlugin, 17 | inlineTitleEl: HTMLElement, 18 | svgElement: string, 19 | options?: Options, 20 | ): void => { 21 | if (!inlineTitleEl.parentElement) { 22 | return; 23 | } 24 | 25 | if (options?.fontSize) { 26 | svgElement = svg.setFontSize(svgElement, options.fontSize); 27 | } 28 | 29 | let titleIcon = getTitleIcon(inlineTitleEl.parentElement); 30 | if (!titleIcon) { 31 | titleIcon = document.createElement('div'); 32 | } 33 | 34 | const isInline = 35 | plugin.getSettings().iconInTitlePosition === IconInTitlePosition.Inline; 36 | 37 | if (isInline) { 38 | titleIcon.style.display = 'inline-block'; 39 | titleIcon.style.removeProperty('margin-inline'); 40 | titleIcon.style.removeProperty('width'); 41 | } else { 42 | titleIcon.style.display = 'block'; 43 | titleIcon.style.width = 'var(--line-width)'; 44 | titleIcon.style.marginInline = '0'; 45 | } 46 | 47 | titleIcon.classList.add(config.TITLE_ICON_CLASS); 48 | // Checks if the passed element is an emoji. 49 | if (emoji.isEmoji(svgElement) && options.fontSize) { 50 | svgElement = 51 | emoji.parseEmoji( 52 | plugin.getSettings().emojiStyle, 53 | svgElement, 54 | options.fontSize, 55 | ) ?? svgElement; 56 | titleIcon.style.fontSize = `${options.fontSize}px`; 57 | } 58 | titleIcon.innerHTML = svgElement; 59 | 60 | let wrapperElement = inlineTitleEl.parentElement; 61 | // Checks the parent and selects the correct wrapper element. 62 | // This should only happen in the beginning. 63 | if ( 64 | wrapperElement && 65 | !wrapperElement.classList.contains(config.INLINE_TITLE_WRAPPER_CLASS) 66 | ) { 67 | wrapperElement = wrapperElement.querySelector( 68 | `.${config.INLINE_TITLE_WRAPPER_CLASS}`, 69 | ); 70 | } 71 | 72 | // Whenever there is no correct wrapper element, we create one. 73 | if (!wrapperElement) { 74 | wrapperElement = inlineTitleEl.parentElement.createDiv(); 75 | wrapperElement.classList.add(config.INLINE_TITLE_WRAPPER_CLASS); 76 | } 77 | 78 | // Avoiding adding the same nodes together when changing the title. 79 | if (wrapperElement !== inlineTitleEl.parentElement) { 80 | inlineTitleEl.parentElement.prepend(wrapperElement); 81 | } 82 | 83 | if (isInline) { 84 | wrapperElement.style.display = 'flex'; 85 | wrapperElement.style.alignItems = 'flex-start'; 86 | const inlineTitlePaddingTop = getComputedStyle( 87 | inlineTitleEl, 88 | null, 89 | ).getPropertyValue('padding-top'); 90 | titleIcon.style.paddingTop = inlineTitlePaddingTop; 91 | 92 | if (emoji.isEmoji(svgElement)) { 93 | titleIcon.style.transform = 'translateY(-9%)'; 94 | } else { 95 | titleIcon.style.transform = 'translateY(9%)'; 96 | } 97 | } else { 98 | wrapperElement.style.display = 'block'; 99 | titleIcon.style.transform = 'translateY(9%)'; 100 | } 101 | 102 | wrapperElement.append(titleIcon); 103 | wrapperElement.append(inlineTitleEl); 104 | }; 105 | 106 | const updateStyle = (inlineTitleEl: HTMLElement, options: Options): void => { 107 | if (!inlineTitleEl.parentElement) { 108 | return; 109 | } 110 | 111 | const titleIcon = getTitleIcon(inlineTitleEl.parentElement); 112 | if (!titleIcon) { 113 | return; 114 | } 115 | 116 | if (options.fontSize) { 117 | if (!emoji.isEmoji(titleIcon.innerHTML)) { 118 | titleIcon.innerHTML = svg.setFontSize( 119 | titleIcon.innerHTML, 120 | options.fontSize, 121 | ); 122 | } else { 123 | titleIcon.style.fontSize = `${options.fontSize}px`; 124 | } 125 | } 126 | }; 127 | 128 | /** 129 | * Hides the title icon from the provided HTMLElement. 130 | * @param contentEl HTMLElement to hide the title icon from. 131 | */ 132 | const hide = (inlineTitleEl: HTMLElement): void => { 133 | if (!inlineTitleEl.parentElement) { 134 | return; 135 | } 136 | 137 | const titleIconContainer = getTitleIcon(inlineTitleEl.parentElement); 138 | if (!titleIconContainer) { 139 | return; 140 | } 141 | 142 | titleIconContainer.style.display = 'none'; 143 | }; 144 | 145 | const remove = (inlineTitleEl: HTMLElement): void => { 146 | if (!inlineTitleEl.parentElement) { 147 | return; 148 | } 149 | 150 | const titleIconContainer = getTitleIcon(inlineTitleEl.parentElement); 151 | if (!titleIconContainer) { 152 | return; 153 | } 154 | 155 | titleIconContainer.remove(); 156 | }; 157 | 158 | export default { 159 | add, 160 | updateStyle, 161 | hide, 162 | remove, 163 | }; 164 | -------------------------------------------------------------------------------- /src/lib/util/svg.test.ts: -------------------------------------------------------------------------------- 1 | import { vi, describe, expect, it, afterEach } from 'vitest'; 2 | import svg from './svg'; 3 | 4 | describe('extract', () => { 5 | afterEach(() => { 6 | vi.restoreAllMocks(); 7 | }); 8 | 9 | it('should remove unnecessary spaces and newlines', () => { 10 | const input = 11 | '\n\n'; 12 | const output = svg.extract(input); 13 | expect(output).toBe( 14 | '', 15 | ); 16 | }); 17 | 18 | it('should remove `width` and `height` from style attribute', () => { 19 | const input = 20 | ''; 21 | const output = svg.extract(input); 22 | expect(output).not.toMatch(/style=""/); 23 | }); 24 | 25 | const mockParseFromString = (width: number | null, height: number | null) => { 26 | return { 27 | parseFromString: vi.fn( 28 | () => 29 | ({ 30 | querySelector: vi.fn(() => ({ 31 | querySelector: vi.fn(), 32 | hasAttribute: vi.fn(() => true), 33 | setAttribute: vi.fn(), 34 | style: { width: '', height: '' }, 35 | viewBox: { baseVal: { width: 0, height: 0 } }, 36 | width: { baseVal: { value: width } }, 37 | height: { baseVal: { value: height } }, 38 | })), 39 | }) as unknown as Document, 40 | ), 41 | }; 42 | }; 43 | 44 | it('should set `viewbox` width and height if they are `0` to the SVG width and height', () => { 45 | const s = vi 46 | .spyOn(window, 'DOMParser') 47 | .mockImplementationOnce(() => mockParseFromString(12, 12)); 48 | 49 | const input = 50 | ''; 51 | svg.extract(input); 52 | 53 | // TODO: Maybe find better alternative to this. 54 | const extractedSVG = 55 | s.mock.results[0].value.parseFromString.mock.results[0].value 56 | .querySelector.mock.results[0].value; 57 | expect(extractedSVG.viewBox.baseVal.width).toBe(12); 58 | expect(extractedSVG.viewBox.baseVal.height).toBe(12); 59 | }); 60 | 61 | it('should set `viewbox` width and height if they are `0` to the SVG width and height', () => { 62 | const s = vi 63 | .spyOn(window, 'DOMParser') 64 | .mockImplementationOnce(() => mockParseFromString(null, null)); 65 | 66 | const input = 67 | ''; 68 | svg.extract(input); 69 | 70 | // TODO: Maybe find better alternative to this. 71 | const extractedSVG = 72 | s.mock.results[0].value.parseFromString.mock.results[0].value 73 | .querySelector.mock.results[0].value; 74 | expect(extractedSVG.viewBox.baseVal.width).toBe(16); 75 | expect(extractedSVG.viewBox.baseVal.height).toBe(16); 76 | }); 77 | 78 | it('should set `fill` attribute to `currentColor` if not present', () => { 79 | const input = 80 | ''; 81 | const output = svg.extract(input); 82 | expect(output).toMatch(/fill="currentColor"/); 83 | }); 84 | }); 85 | 86 | describe('setFontSize', () => { 87 | it('should set width and height according to the defined integer font size', () => { 88 | const input = ``; 89 | const output = svg.setFontSize(input, 50); 90 | expect(output).toMatch(/width="50px"/); 91 | expect(output).toMatch(/height="50px"/); 92 | }); 93 | 94 | it('should set width and height according to the defined floating point number font size', () => { 95 | const input = ``; 96 | const output = svg.setFontSize(input, 95.5); 97 | expect(output).toMatch(/width="95.5px"/); 98 | expect(output).toMatch(/height="95.5px"/); 99 | }); 100 | }); 101 | 102 | describe('colorize', () => { 103 | it('should set fill color if fill attribute is present and not `null`', () => { 104 | const input = ``; 105 | const output = svg.colorize(input, 'blue'); 106 | expect(output).toMatch(/fill="blue"/); 107 | }); 108 | 109 | it('should set stroke color if stroke attribute is present and not `null`', () => { 110 | const input = ``; 111 | const output = svg.colorize(input, 'blue'); 112 | expect(output).toMatch(/stroke="blue"/); 113 | }); 114 | 115 | it('should default to `currentColor` if color is `null`', () => { 116 | const input = ``; 117 | const output = svg.colorize(input, null); 118 | expect(output).toMatch(/fill="currentColor"/); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /src/util.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 | import { 3 | getAllOpenedFiles, 4 | isHexadecimal, 5 | readFileSync, 6 | removeIconFromIconPack, 7 | saveIconToIconPack, 8 | stringToHex, 9 | } from './util'; 10 | 11 | describe('readFileSync', () => { 12 | it('should read file content', async () => { 13 | const mockFileContent = 'Hello World!'; 14 | const mockFile = new Blob([mockFileContent], { type: 'text/plain' }); 15 | const result = await readFileSync(mockFile as any); 16 | expect(result).toBe(mockFileContent); 17 | }); 18 | }); 19 | 20 | describe('getAllOpenedFiles', () => { 21 | it('should return all opened files', () => { 22 | const plugin: any = { 23 | app: { 24 | workspace: { 25 | getLeavesOfType: () => [ 26 | { 27 | view: { 28 | file: { 29 | path: 'file/path', 30 | }, 31 | }, 32 | }, 33 | ], 34 | }, 35 | }, 36 | }; 37 | 38 | const openedFiles = getAllOpenedFiles(plugin); 39 | expect(openedFiles).toHaveLength(1); 40 | expect(openedFiles[0]).toEqual({ 41 | path: 'file/path', 42 | pinned: false, 43 | leaf: { 44 | view: { 45 | file: { 46 | path: 'file/path', 47 | }, 48 | }, 49 | }, 50 | }); 51 | }); 52 | }); 53 | 54 | describe('saveIconToIconPack', () => { 55 | const plugin: any = { 56 | getIconPackManager: () => ({ 57 | getSvgFromLoadedIcon: vi.fn(() => ''), 58 | getIconPackNameByPrefix: vi.fn(() => ''), 59 | addIconToIconPack: vi.fn(() => ({ name: 'IbTest' })), 60 | extractIcon: vi.fn(() => {}), 61 | }), 62 | }; 63 | 64 | beforeEach(() => { 65 | vi.restoreAllMocks(); 66 | }); 67 | 68 | it('should not save icon to icon pack when svg was not found', () => { 69 | expect.assertions(2); 70 | try { 71 | saveIconToIconPack({} as any, 'IbTest'); 72 | } catch (e) { 73 | expect(e).not.toBeNull(); 74 | } 75 | expect(plugin.getIconPackManager().extractIcon).toBeCalledTimes(0); 76 | }); 77 | 78 | it('should save icon to icon pack', () => { 79 | saveIconToIconPack({} as any, 'IbTest'); 80 | expect(plugin.getIconPackManager().extractIcon).toBeCalledTimes(1); 81 | }); 82 | }); 83 | 84 | describe.skip('removeIconFromIconPack', () => { 85 | const plugin: any = { 86 | getDataPathByValue: () => 'folder/path', 87 | getIconPackManager: () => ({ 88 | getPath: () => '', 89 | getIconPackByPrefix: vi.fn(() => ({ 90 | removeIcon: vi.fn(), 91 | })), 92 | }), 93 | }; 94 | 95 | beforeEach(() => { 96 | vi.restoreAllMocks(); 97 | }); 98 | 99 | it('should not remove icon from icon pack if there is a duplicated icon', () => { 100 | removeIconFromIconPack(plugin, 'IbTest'); 101 | expect( 102 | plugin.getIconPackManager().getIconPackByPrefix().removeIcon, 103 | ).toBeCalledTimes(0); 104 | }); 105 | 106 | it.only('should remove icon from icon pack if there is no duplicated icon', () => { 107 | plugin.getDataPathByValue = () => ''; 108 | removeIconFromIconPack(plugin, 'IbTest'); 109 | expect( 110 | plugin.getIconPackManager().getIconPackByPrefix().removeIcon, 111 | ).toBeCalledTimes(1); 112 | expect( 113 | plugin.getIconPackManager().getIconPackByPrefix().removeIcon, 114 | ).toBeCalledWith('IconBrew', 'Test'); 115 | }); 116 | }); 117 | 118 | describe('stringToHex', () => { 119 | it('should handle strings with leading zeros', () => { 120 | expect(stringToHex('000000')).toBe('#000000'); 121 | expect(stringToHex('00f')).toBe('#00000f'); 122 | }); 123 | 124 | it('should handle strings without leading zeros', () => { 125 | expect(stringToHex('11c0a1')).toBe('#11c0a1'); 126 | expect(stringToHex('f0f0f0')).toBe('#f0f0f0'); 127 | }); 128 | 129 | it('should handle mixed-case hexadecimal strings', () => { 130 | expect(stringToHex('aBc123')).toBe('#aBc123'); 131 | expect(stringToHex('AbCdEf')).toBe('#AbCdEf'); 132 | }); 133 | 134 | it('should return original string if it already starts with #', () => { 135 | expect(stringToHex('#123456')).toBe('#123456'); 136 | }); 137 | 138 | it('should handle empty strings', () => { 139 | expect(stringToHex('')).toBe('#000000'); 140 | }); 141 | }); 142 | 143 | describe('isHexadecimal', () => { 144 | it('should return true for valid hexadecimal strings', () => { 145 | expect(isHexadecimal('000000')).toBe(true); 146 | expect(isHexadecimal('#000000', true)).toBe(true); 147 | expect(isHexadecimal('00f')).toBe(true); 148 | expect(isHexadecimal('#00f', true)).toBe(true); 149 | }); 150 | 151 | it('should return false for invalid hexadecimal strings', () => { 152 | expect(isHexadecimal('0000000')).toBe(false); 153 | expect(isHexadecimal('#0000000', true)).toBe(false); 154 | expect(isHexadecimal('00g')).toBe(false); 155 | expect(isHexadecimal('#00g', true)).toBe(false); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /src/lib/icon.test.ts: -------------------------------------------------------------------------------- 1 | import { it, describe, beforeEach, expect, vi } from 'vitest'; 2 | import icon from './icon'; 3 | import customRule from './custom-rule'; 4 | 5 | describe('getAllWithPath', () => { 6 | let plugin: any; 7 | beforeEach(() => { 8 | vi.restoreAllMocks(); 9 | plugin = { 10 | getSettings: () => ({ 11 | rules: [ 12 | { 13 | icon: 'IbRuleTest', 14 | rule: 'folder', 15 | }, 16 | ], 17 | }), 18 | getData: () => ({ 19 | folder: 'IbTest', 20 | }), 21 | }; 22 | }); 23 | 24 | it('should return empty array when no icons are found', () => { 25 | plugin.getData = () => ({}); 26 | plugin.getSettings = () => ({ rules: [] }) as any; 27 | const result = icon.getAllWithPath(plugin); 28 | expect(result).toEqual([]); 29 | }); 30 | 31 | it('should return normal without custom rules', () => { 32 | plugin.getSettings = () => ({ rules: [] }) as any; 33 | const result = icon.getAllWithPath(plugin); 34 | expect(result).toEqual([ 35 | { 36 | icon: 'IbTest', 37 | path: 'folder', 38 | }, 39 | ]); 40 | }); 41 | 42 | it('should return custom rule icon if icon was found in custom rules', () => { 43 | plugin.getData = () => ({}); 44 | const result = icon.getAllWithPath(plugin); 45 | expect(result).toEqual([ 46 | { 47 | icon: 'IbRuleTest', 48 | path: 'folder', 49 | }, 50 | ]); 51 | }); 52 | }); 53 | 54 | describe('getByPath', () => { 55 | let plugin: any; 56 | beforeEach(() => { 57 | vi.restoreAllMocks(); 58 | plugin = { 59 | getData: () => ({ 60 | folder: 'IbTest', 61 | folderObj: { 62 | iconName: 'IbTest', 63 | }, 64 | }), 65 | }; 66 | vi.spyOn(customRule, 'getSortedRules').mockImplementationOnce( 67 | () => [] as any, 68 | ); 69 | }); 70 | 71 | it('should return `undefined` when path is `settings` or `migrated', () => { 72 | expect(icon.getByPath({} as any, 'settings')).toBeUndefined(); 73 | expect(icon.getByPath({} as any, 'migrated')).toBeUndefined(); 74 | }); 75 | 76 | it('should return the value if value in data of path is a string', () => { 77 | const result = icon.getByPath(plugin, 'folder'); 78 | expect(result).toBe('IbTest'); 79 | }); 80 | 81 | it('should return the `iconName` property if value in data of path is an object', () => { 82 | const result = icon.getByPath(plugin, 'folderObj'); 83 | expect(result).toBe('IbTest'); 84 | }); 85 | 86 | it('should return custom rule icon if icon was found in custom rules', () => { 87 | vi.spyOn(customRule, 'getSortedRules').mockImplementationOnce( 88 | () => 89 | [ 90 | { 91 | icon: 'IbTest', 92 | }, 93 | ] as any, 94 | ); 95 | 96 | const result = icon.getByPath(plugin, 'foo'); 97 | expect(result).toBe('IbTest'); 98 | }); 99 | 100 | it('should return `undefined` when no icon is found', () => { 101 | const result = icon.getByPath(plugin, 'foo'); 102 | expect(result).toBe(undefined); 103 | }); 104 | }); 105 | 106 | describe('getIconByPath', () => { 107 | let plugin: any; 108 | beforeEach(() => { 109 | plugin = { 110 | getData: () => ({}), 111 | getSettings: () => 112 | ({ 113 | rules: [], 114 | }) as any, 115 | }; 116 | }); 117 | 118 | it('should return the correct icon for a given path', () => { 119 | const getIconPackByPrefix = vi.fn().mockImplementationOnce(() => ({ 120 | getIcon: vi.fn(() => 'IbTest'), 121 | })); 122 | 123 | const newPlugin = { 124 | ...plugin, 125 | getIconPackManager: () => ({ 126 | getIconPackByPrefix, 127 | }), 128 | getData: () => ({ 129 | folder: 'IbTest', 130 | }), 131 | }; 132 | const result = icon.getIconByPath(newPlugin, 'folder'); 133 | expect(result).toBe('IbTest'); 134 | }); 135 | 136 | it('should return emoji for a given path', () => { 137 | plugin.getData = () => ({ 138 | folder: '😁', 139 | }); 140 | const result = icon.getIconByPath(plugin, 'folder'); 141 | expect(result).toBe('😁'); 142 | }); 143 | 144 | it('should return `null` when no icon was found', () => { 145 | const result = icon.getIconByPath(plugin, 'foo'); 146 | expect(result).toBeNull(); 147 | }); 148 | }); 149 | 150 | describe('getIconByName', () => { 151 | const getIcon = vi.fn(); 152 | let plugin: any = { 153 | getIconPackManager: () => ({ 154 | getIconPackByPrefix: () => ({}), 155 | }), 156 | }; 157 | 158 | beforeEach(() => { 159 | vi.restoreAllMocks(); 160 | 161 | plugin = { 162 | ...plugin, 163 | getIconPackManager: () => ({ 164 | getIconPackByPrefix: () => ({ 165 | getIcon, 166 | }), 167 | }), 168 | }; 169 | }); 170 | 171 | it('should return the correct icon for a given name', () => { 172 | getIcon.mockImplementation(() => 'IbTest'); 173 | const result = icon.getIconByName(plugin, 'IbTest'); 174 | expect(result).toBe('IbTest'); 175 | }); 176 | 177 | it('should return `null` when no icon was found', () => { 178 | plugin.getIconPackManager().getIconPackByPrefix().getIcon = (): 179 | | string 180 | | null => null; 181 | const result = icon.getIconByName(plugin, 'IbFoo'); 182 | expect(result).toBe(null); 183 | }); 184 | }); 185 | -------------------------------------------------------------------------------- /src/lib/icon-tabs.ts: -------------------------------------------------------------------------------- 1 | import IconizePlugin, { FolderIconObject } from '@app/main'; 2 | import { DEFAULT_FILE_ICON, getAllOpenedFiles } from '@app/util'; 3 | import { TabHeaderLeaf } from '@app/@types/obsidian'; 4 | import customRule from './custom-rule'; 5 | import dom from './util/dom'; 6 | 7 | /** 8 | * Gets the tab leaves of a specific file path by looping through all opened files and 9 | * checking if the file path matches. 10 | * @param plugin IconizePlugin instance. 11 | * @param path String of the file path to get the tab leaf of. 12 | * @returns TabHeaderLeaf array that includes all tab leaves of the file path. 13 | */ 14 | const getTabLeavesOfFilePath = ( 15 | plugin: IconizePlugin, 16 | path: string, 17 | ): TabHeaderLeaf[] => { 18 | const openedFiles = getAllOpenedFiles(plugin); 19 | const openedFile = openedFiles.filter( 20 | (openedFile) => openedFile.path === path, 21 | ); 22 | const leaves = openedFile.map((openedFile) => openedFile.leaf); 23 | return leaves as TabHeaderLeaf[]; 24 | }; 25 | 26 | interface AddOptions { 27 | /** 28 | * Name of the icon to add to the tab. 29 | * @default undefined 30 | */ 31 | iconName?: string; 32 | /** 33 | * Color of the icon to add to the tab. 34 | * @default undefined 35 | */ 36 | iconColor?: string; 37 | } 38 | 39 | /** 40 | * Adds an icon to the tab and its container. This function respects the 41 | * custom rules and individually icon set. 42 | * @param plugin IconizePlugin instance. 43 | * @param filePath String file path to add the icon to. 44 | * @param iconContainer HTMLElement where the icon will be added to. 45 | * @param options AddOptions for the add function which can optionally be used. 46 | */ 47 | const add = async ( 48 | plugin: IconizePlugin, 49 | filePath: string, 50 | iconContainer: HTMLElement, 51 | options?: AddOptions, 52 | ): Promise => { 53 | const iconColor = options?.iconColor ?? plugin.getSettings().iconColor; 54 | const data = Object.entries(plugin.getData()); 55 | 56 | // Removes the `display: none` from the obsidian styling. 57 | iconContainer.style.display = 'flex'; 58 | 59 | // Only add the icon name manually when it is defined in the options. 60 | if (options?.iconName) { 61 | dom.setIconForNode(plugin, options.iconName, iconContainer, { 62 | color: iconColor, 63 | }); 64 | // TODO: Refactor to include option to `insertIconToNode` function. 65 | iconContainer.style.margin = null; 66 | return; 67 | } 68 | 69 | // Add icons to tabs if a custom rule is applicable. 70 | for (const rule of customRule.getSortedRules(plugin)) { 71 | const isApplicable = await customRule.isApplicable(plugin, rule, filePath); 72 | if (isApplicable) { 73 | dom.setIconForNode(plugin, rule.icon, iconContainer, { 74 | color: rule.color, 75 | }); 76 | // TODO: Refactor to include option to `insertIconToNode` function. 77 | iconContainer.style.margin = null; 78 | break; 79 | } 80 | } 81 | 82 | // Add icons to tabs if there is an icon set. 83 | const iconData = data.find(([dataPath]) => dataPath === filePath); 84 | if (!iconData) { 85 | return; 86 | } 87 | 88 | const value = iconData[1]; 89 | if (typeof value !== 'string' && typeof value !== 'object') { 90 | return; 91 | } 92 | 93 | let iconName; 94 | if (typeof value === 'object') { 95 | const v = value as FolderIconObject; 96 | if (v.iconName === null) { 97 | return; 98 | } 99 | iconName = v.iconName; 100 | } else { 101 | iconName = value; 102 | } 103 | 104 | dom.setIconForNode(plugin, iconName, iconContainer, { 105 | color: iconColor, 106 | shouldApplyAllStyles: true, 107 | }); 108 | // TODO: Refactor to include option to `insertIconToNode` function. 109 | iconContainer.style.margin = null; 110 | }; 111 | 112 | /** 113 | * Updates the icon in the tab and container by setting calling the `setIconForNode` 114 | * function and removing the margin from the icon container. 115 | * @param plugin IconizePlugin instance. 116 | * @param iconName String of the icon name to update to. 117 | * @param iconContainer HTMLElement where the icon is located and will be updated. 118 | */ 119 | const update = ( 120 | plugin: IconizePlugin, 121 | iconName: string, 122 | iconContainer: HTMLElement, 123 | ) => { 124 | dom.setIconForNode(plugin, iconName, iconContainer); 125 | // TODO: Refactor to include option to `insertIconToNode` function. 126 | iconContainer.style.margin = null; 127 | }; 128 | 129 | interface RemoveOptions { 130 | /** 131 | * Replaces the icon in the tab with the default obsidian icon. 132 | * @default false 133 | */ 134 | replaceWithDefaultIcon?: boolean; 135 | } 136 | 137 | /** 138 | * Removes the icon from the tab and container by setting the `display` style property 139 | * to `none`. Optionally, the icon can be replaced with the default obsidian icon. 140 | * @param iconContainer HTMLElement where the icon is located and will be removed from. 141 | * @param options RemoveOptions for the remove function which can optionally be used. 142 | */ 143 | const remove = (iconContainer: HTMLElement, options?: RemoveOptions) => { 144 | if (!options?.replaceWithDefaultIcon) { 145 | // Removes the display of the icon container to remove the icons from the tabs. 146 | iconContainer.style.display = 'none'; 147 | } else { 148 | iconContainer.innerHTML = DEFAULT_FILE_ICON; 149 | } 150 | }; 151 | 152 | export default { 153 | add, 154 | update, 155 | remove, 156 | getTabLeavesOfFilePath, 157 | }; 158 | -------------------------------------------------------------------------------- /src/internal-plugins/outline.ts: -------------------------------------------------------------------------------- 1 | import InternalPluginInjector from '@app/@types/internal-plugin-injector'; 2 | import { createIconShortcodeRegex } from '@app/editor/markdown-processors'; 3 | import svg from '@app/lib/util/svg'; 4 | import icon from '@app/lib/icon'; 5 | import { LoggerPrefix, logger } from '@app/lib/logger'; 6 | import IconizePlugin from '@app/main'; 7 | import { requireApiVersion, View, WorkspaceLeaf } from 'obsidian'; 8 | 9 | const TREE_ITEM_CLASS = 'tree-item-self'; 10 | const TREE_ITEM_INNER = 'tree-item-inner'; 11 | 12 | interface OutlineLeaf extends WorkspaceLeaf { 13 | view: OutlineView; 14 | } 15 | 16 | interface OutlineView extends View { 17 | tree: { 18 | containerEl: HTMLDivElement; 19 | }; 20 | } 21 | 22 | export default class OutlineInternalPlugin extends InternalPluginInjector { 23 | constructor(plugin: IconizePlugin) { 24 | super(plugin); 25 | } 26 | 27 | onMount(): void { 28 | // TODO: Might improve the performance here. 29 | } 30 | 31 | register(): void { 32 | if (!this.enabled) { 33 | logger.info( 34 | 'Skipping internal plugin registration because it is not enabled.', 35 | LoggerPrefix.Outline, 36 | ); 37 | return; 38 | } 39 | 40 | const updateTreeItems = () => { 41 | if (!this.leaf?.view?.tree) { 42 | return; 43 | } 44 | 45 | const treeItems = Array.from( 46 | this.leaf.view.tree.containerEl.querySelectorAll(`.${TREE_ITEM_CLASS}`), 47 | ); 48 | for (const treeItem of treeItems) { 49 | const treeItemInner = treeItem.querySelector(`.${TREE_ITEM_INNER}`); 50 | let text = treeItemInner?.getText(); 51 | if (!text) { 52 | continue; 53 | } 54 | 55 | const iconShortcodeRegex = createIconShortcodeRegex(this.plugin); 56 | const iconIdentifierLength = 57 | this.plugin.getSettings().iconIdentifier.length; 58 | 59 | let trimmedLength = 0; 60 | for (const code of [...text.matchAll(iconShortcodeRegex)] 61 | .sort((a, b) => a.index - b.index) 62 | .map((arr) => ({ text: arr[0], index: arr.index! }))) { 63 | const shortcode = code.text; 64 | const iconName = shortcode.slice( 65 | iconIdentifierLength, 66 | shortcode.length - iconIdentifierLength, 67 | ); 68 | const iconObject = icon.getIconByName(this.plugin, iconName); 69 | if (iconObject) { 70 | const startIndex = code.index - trimmedLength; 71 | const endIndex = code.index + code.text.length - trimmedLength; 72 | 73 | const str = 74 | text.substring(0, startIndex) + text.substring(endIndex); 75 | 76 | const iconSpan = createSpan({ 77 | cls: 'cm-iconize-icon', 78 | attr: { 79 | 'aria-label': iconName, 80 | 'data-icon': iconName, 81 | 'aria-hidden': 'true', 82 | }, 83 | }); 84 | const fontSize = parseFloat( 85 | getComputedStyle(document.body).getPropertyValue( 86 | '--nav-item-size', 87 | ) ?? '16', 88 | ); 89 | const svgElement = svg.setFontSize(iconObject.svgElement, fontSize); 90 | iconSpan.style.display = 'inline-flex'; 91 | iconSpan.style.transform = 'translateY(13%)'; 92 | iconSpan.innerHTML = svgElement; 93 | treeItemInner.innerHTML = treeItemInner.innerHTML.replace( 94 | shortcode, 95 | iconSpan.outerHTML, 96 | ); 97 | 98 | text = str; 99 | trimmedLength += code.text.length; 100 | } 101 | } 102 | } 103 | }; 104 | 105 | const setOutlineIcons = () => { 106 | this.plugin.getEventEmitter().once('allIconsLoaded', () => { 107 | updateTreeItems(); 108 | 109 | const callback = (mutations: MutationRecord[]) => { 110 | mutations.forEach((mutation) => { 111 | if (mutation.type !== 'childList') { 112 | return; 113 | } 114 | 115 | const addedNodes = mutation.addedNodes; 116 | if (addedNodes.length === 0) { 117 | return; 118 | } 119 | 120 | updateTreeItems(); 121 | }); 122 | 123 | if (!this.enabled) { 124 | observer.disconnect(); 125 | } 126 | }; 127 | 128 | const observer = new MutationObserver(callback); 129 | 130 | observer.observe(this.leaf.view.tree.containerEl, { 131 | childList: true, 132 | subtree: true, 133 | }); 134 | }); 135 | }; 136 | 137 | if (requireApiVersion('1.7.2')) { 138 | // TODO: Might improve the performance here. 139 | this.leaf.loadIfDeferred().then(setOutlineIcons); 140 | } else { 141 | setOutlineIcons(); 142 | } 143 | } 144 | 145 | get leaf(): OutlineLeaf | undefined { 146 | const leaf = this.plugin.app.workspace.getLeavesOfType('outline'); 147 | if (!leaf) { 148 | logger.log('`leaf` in outline is undefined', LoggerPrefix.Outline); 149 | return undefined; 150 | } 151 | 152 | if (leaf.length === 0) { 153 | logger.log('`leaf` length in outline is 0', LoggerPrefix.Outline); 154 | return undefined; 155 | } 156 | 157 | return leaf[0] as OutlineLeaf; 158 | } 159 | 160 | get outline() { 161 | return this.plugin.app.internalPlugins.getPluginById('outline'); 162 | } 163 | 164 | get enabled(): boolean { 165 | return this.plugin.app.internalPlugins.getPluginById('outline').enabled; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/editor/icons-suggestion.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Mock, 3 | MockInstance, 4 | beforeEach, 5 | describe, 6 | expect, 7 | it, 8 | vi, 9 | } from 'vitest'; 10 | import icon from '@lib/icon'; 11 | import * as iconPackManager from '@app/icon-pack-manager'; 12 | import * as util from '@app/util'; 13 | import SuggestionIcon from './icons-suggestion'; 14 | 15 | vi.mock('obsidian', () => ({ 16 | App: class {}, 17 | Editor: class {}, 18 | EditorPosition: class {}, 19 | EditorSuggest: class {}, 20 | EditorSuggestContext: class {}, 21 | EditorSuggestTriggerInfo: class {}, 22 | })); 23 | 24 | let app: any; 25 | let plugin: any; 26 | let suggestionIcon: SuggestionIcon; 27 | let replaceRangeMock: Mock; 28 | 29 | beforeEach(() => { 30 | vi.restoreAllMocks(); 31 | app = {}; 32 | plugin = { 33 | getSettings: () => ({ 34 | iconIdentifier: ':', 35 | }), 36 | }; 37 | suggestionIcon = new SuggestionIcon(app, plugin); 38 | replaceRangeMock = vi.fn(); 39 | suggestionIcon.context = { 40 | start: { 41 | line: 0, 42 | ch: 0, 43 | }, 44 | end: { 45 | line: 0, 46 | ch: 0, 47 | }, 48 | editor: { 49 | replaceRange: replaceRangeMock, 50 | }, 51 | } as any; 52 | vi.spyOn(util, 'saveIconToIconPack').mockImplementation(() => {}); 53 | }); 54 | 55 | describe('selectSuggestion', () => { 56 | it('should replace the range with the emoji when the value is an emoji', () => { 57 | const emojiValue = 'smiley_face'; 58 | suggestionIcon.selectSuggestion(emojiValue); 59 | expect(replaceRangeMock).toHaveBeenCalledTimes(1); 60 | expect(replaceRangeMock).toHaveBeenCalledWith( 61 | `:${emojiValue}:`, 62 | { 63 | line: 0, 64 | ch: 0, 65 | }, 66 | { 67 | line: 0, 68 | ch: 0, 69 | }, 70 | ); 71 | }); 72 | 73 | it('should replace the range with the icon when the value is an icon', () => { 74 | const iconValue = 'heart_fill'; 75 | suggestionIcon.selectSuggestion(iconValue); 76 | expect(replaceRangeMock).toHaveBeenCalledTimes(1); 77 | expect(replaceRangeMock).toHaveBeenCalledWith( 78 | `:${iconValue}:`, 79 | { 80 | line: 0, 81 | ch: 0, 82 | }, 83 | { 84 | line: 0, 85 | ch: 0, 86 | }, 87 | ); 88 | }); 89 | }); 90 | 91 | describe('renderSuggestion', () => { 92 | it('should render a icon suggestion when the value is an icon', () => { 93 | const getIconByName = vi.spyOn(icon, 'getIconByName'); 94 | getIconByName.mockImplementationOnce( 95 | () => 96 | ({ 97 | svgElement: '', 98 | }) as any, 99 | ); 100 | 101 | const el = document.createElement('div'); 102 | suggestionIcon.renderSuggestion('heart_fill', el); 103 | 104 | expect(el.innerHTML).toBe(' heart_fill'); 105 | 106 | getIconByName.mockRestore(); 107 | }); 108 | 109 | it('should render a emoji suggestion when the value is an icon', () => { 110 | const getIconByName = vi.spyOn(icon, 'getIconByName'); 111 | getIconByName.mockImplementationOnce(() => null as any); 112 | 113 | const el = document.createElement('div'); 114 | suggestionIcon.renderSuggestion('😁', el); 115 | 116 | expect(el.innerHTML).toBe( 117 | '😁 beaming_face_with_smiling_eyes', 118 | ); 119 | 120 | getIconByName.mockRestore(); 121 | }); 122 | 123 | it('should not render emoji shortcode if the emoji has no shortcode', () => { 124 | const getIconByName = vi.spyOn(icon, 'getIconByName'); 125 | getIconByName.mockImplementationOnce(() => null as any); 126 | 127 | const el = document.createElement('div'); 128 | suggestionIcon.renderSuggestion('hehe', el); 129 | 130 | expect(el.innerHTML).toBe(''); 131 | 132 | getIconByName.mockRestore(); 133 | }); 134 | }); 135 | 136 | describe.skip('getSuggestions', () => { 137 | let getAllLoadedIconNamesSpy: MockInstance; 138 | beforeEach(() => { 139 | vi.restoreAllMocks(); 140 | getAllLoadedIconNamesSpy = vi.spyOn(iconPackManager, 'allLoadedIconNames'); 141 | getAllLoadedIconNamesSpy.mockImplementationOnce(() => [ 142 | { 143 | name: 'winking_face', 144 | prefix: 'Ib', 145 | }, 146 | { 147 | name: 'heart', 148 | prefix: 'Ib', 149 | }, 150 | ]); 151 | }); 152 | 153 | it('should return an array of icon names and emoji shortcodes', () => { 154 | suggestionIcon.context = { 155 | ...suggestionIcon.context, 156 | query: 'winking_face', 157 | }; 158 | const suggestions = suggestionIcon.getSuggestions(suggestionIcon.context); 159 | expect(suggestions).toEqual(['Ibwinking_face', '😉', '😜', '🤔']); 160 | }); 161 | }); 162 | 163 | describe('onTrigger', () => { 164 | let editor: any; 165 | let cursor: any; 166 | beforeEach(() => { 167 | vi.restoreAllMocks(); 168 | cursor = { 169 | line: 0, 170 | ch: 0, 171 | }; 172 | editor = { 173 | getLine: () => '', 174 | }; 175 | }); 176 | 177 | it('should return `null` when the cursor is not on a shortcode', () => { 178 | const result = suggestionIcon.onTrigger(cursor, editor); 179 | expect(result).toBeNull(); 180 | }); 181 | 182 | it('should return `null` when the shortcode is done', () => { 183 | cursor.ch = 6; 184 | editor.getLine = () => ':wink:'; 185 | const result = suggestionIcon.onTrigger(cursor, editor); 186 | expect(result).toBeNull(); 187 | }); 188 | 189 | it('should return the shortcode when the shortcode is not done yet', () => { 190 | cursor.ch = 5; 191 | editor.getLine = () => ':wink'; 192 | const result = suggestionIcon.onTrigger(cursor, editor); 193 | expect(result).toEqual({ 194 | start: { 195 | line: 0, 196 | ch: 0, 197 | }, 198 | end: { 199 | line: 0, 200 | ch: 5, 201 | }, 202 | query: ':wink', 203 | }); 204 | }); 205 | }); 206 | --------------------------------------------------------------------------------