├── .prettierrc.cjs ├── .release-it.cjs ├── .release-it.beta.cjs ├── .release-it.lib.cjs ├── .release-it.ob.cjs ├── assets ├── dev.zip ├── ra.zip ├── fa-brands.zip ├── fa-solid.zip ├── ri-fill.zip ├── ri-line.zip ├── fa-regular.zip └── manifest.json ├── src ├── typings │ ├── icon-list.d.ts │ ├── emoji-list.d.ts │ ├── svg.d.ts │ ├── obsidian-ex.d.ts │ └── api.ts ├── icons │ ├── index.ts │ ├── obsidian-v0.13.27.txt │ └── lucide-v0.17.2.txt ├── invalid.less ├── icon-in-editor │ ├── index.ts │ ├── get-menu.ts │ ├── widget.ts │ ├── deco.ts │ ├── sample.md │ ├── view-plugin.ts │ └── state.ts ├── post-ps │ ├── index.ts │ ├── callout-icon.ts │ └── text.ts ├── settings.less ├── index.ts ├── icon-packs │ ├── emoji.ts │ ├── icon.less │ ├── types.ts │ ├── icon-cache.ts │ ├── file-icon.ts │ ├── built-ins.ts │ ├── utils.ts │ └── pack-manager.ts ├── modules │ ├── union.ts │ ├── dialog.ts │ ├── json-to-svg.ts │ ├── icon-packs.ts │ └── suggester.ts ├── component │ ├── loading.tsx │ ├── icon-manager.less │ ├── browser-packs.less │ ├── icon-manager.tsx │ ├── icon-preview.tsx │ └── browser-packs.tsx ├── isc-main.ts └── settings.ts ├── .vscode └── settings.json ├── tsconfig-lib.json ├── tsconfig.json ├── manifest.json ├── manifest-beta.json ├── scripts ├── cp-dts.mjs ├── icon-list.js └── generate-icon.js ├── .eslintrc ├── .gitignore ├── versions.json ├── LICENSE ├── esbuild.config.mjs ├── package.json ├── README.md └── CHANGELOG.md /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@aidenlx/prettier-config"); 2 | -------------------------------------------------------------------------------- /.release-it.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@aidenlx/release-it-config").full; 2 | -------------------------------------------------------------------------------- /.release-it.beta.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@aidenlx/release-it-config").beta; 2 | -------------------------------------------------------------------------------- /.release-it.lib.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@aidenlx/release-it-config").libOnly; 2 | -------------------------------------------------------------------------------- /.release-it.ob.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@aidenlx/release-it-config").obOnly; 2 | -------------------------------------------------------------------------------- /assets/dev.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aidenlx/obsidian-icon-shortcodes/HEAD/assets/dev.zip -------------------------------------------------------------------------------- /assets/ra.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aidenlx/obsidian-icon-shortcodes/HEAD/assets/ra.zip -------------------------------------------------------------------------------- /assets/fa-brands.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aidenlx/obsidian-icon-shortcodes/HEAD/assets/fa-brands.zip -------------------------------------------------------------------------------- /assets/fa-solid.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aidenlx/obsidian-icon-shortcodes/HEAD/assets/fa-solid.zip -------------------------------------------------------------------------------- /assets/ri-fill.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aidenlx/obsidian-icon-shortcodes/HEAD/assets/ri-fill.zip -------------------------------------------------------------------------------- /assets/ri-line.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aidenlx/obsidian-icon-shortcodes/HEAD/assets/ri-line.zip -------------------------------------------------------------------------------- /assets/fa-regular.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aidenlx/obsidian-icon-shortcodes/HEAD/assets/fa-regular.zip -------------------------------------------------------------------------------- /src/typings/icon-list.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.txt" { 2 | const src: string[]; 3 | export default src; 4 | } 5 | -------------------------------------------------------------------------------- /src/typings/emoji-list.d.ts: -------------------------------------------------------------------------------- 1 | declare module "node-emoji/lib/emoji.json" { 2 | const emojiByName: Record; 3 | export default emojiByName; 4 | } 5 | -------------------------------------------------------------------------------- /src/icons/index.ts: -------------------------------------------------------------------------------- 1 | import LucideIcon from "./lucide-v0.17.2.txt"; 2 | import ObsidianIcon from "./obsidian-v0.13.27.txt"; 3 | 4 | export { LucideIcon, ObsidianIcon }; 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "conventionalCommits.scopes": [ 3 | "browser-packs", 4 | "icon-in-editor", 5 | "icon-packs", 6 | "suggester", 7 | "post-ps" 8 | ] 9 | } -------------------------------------------------------------------------------- /src/invalid.less: -------------------------------------------------------------------------------- 1 | @id: .isc; 2 | 3 | .mod-settings @{id}-add-pack-input.invalid, 4 | @{id}-icon-manager .icons .name textarea.invalid { 5 | color: var(--text-error); 6 | background: var(--background-primary-alt); 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig-lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "ESNext", 5 | "outDir": "lib", 6 | "declaration": true, 7 | "inlineSourceMap": true 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@aidenlx/ts-config/tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["dom", "es5", "scripthost", "es2015", "DOM.Iterable"], 5 | "jsx": "react" 6 | }, 7 | "include": ["src/**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-icon-shortcodes", 3 | "name": "Icon Shortcodes", 4 | "version": "0.9.7", 5 | "minAppVersion": "1.0.0", 6 | "description": "Insert emoji and custom icons with shortcodes", 7 | "author": "AidenLx", 8 | "authorUrl": "https://github.com/aidenlx", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /manifest-beta.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-icon-shortcodes", 3 | "name": "Icon Shortcodes", 4 | "version": "0.9.7", 5 | "minAppVersion": "1.0.0", 6 | "description": "Insert emoji and custom icons with shortcodes", 7 | "author": "AidenLx", 8 | "authorUrl": "https://github.com/aidenlx", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /src/icon-in-editor/index.ts: -------------------------------------------------------------------------------- 1 | import type IconSC from "../isc-main"; 2 | import getIconLivePreviewPlugin from "./view-plugin"; 3 | 4 | const setupIconPlugin = (plugin: IconSC) => { 5 | plugin.registerEditorExtension([ 6 | plugin.shortcodePosField, 7 | getIconLivePreviewPlugin(plugin), 8 | ]); 9 | }; 10 | 11 | export default setupIconPlugin; 12 | -------------------------------------------------------------------------------- /scripts/cp-dts.mjs: -------------------------------------------------------------------------------- 1 | import glob from "fast-glob"; 2 | import { promises as fs } from "fs"; 3 | import { dirname } from "path"; 4 | 5 | const copy = async (srcPath) => { 6 | const cpTo = dts.replace("src/", "lib/"); 7 | await fs.mkdir(dirname(cpTo), { recursive: true }); 8 | await fs.copyFile(dts, cpTo); 9 | }; 10 | 11 | await glob("src/**/*.d.ts").map(copy); 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@aidenlx/eslint-config", 4 | "plugin:react-hooks/recommended", 5 | "plugin:react/recommended" 6 | ], 7 | "settings": { 8 | "react": { 9 | "version": "detect" 10 | }, 11 | "import/parsers": { 12 | "@typescript-eslint/parser": [".ts", ".tsx"] 13 | } 14 | }, 15 | "ignorePatterns": ["lib/**/*"] 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # npm 2 | node_modules 3 | package-lock.json 4 | # yarn.lock 5 | 6 | # build 7 | build 8 | assets/* 9 | !assets/*.zip 10 | !assets/manifest.json 11 | 12 | # https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored 13 | .pnp.* 14 | .yarn/* 15 | !.yarn/patches 16 | !.yarn/plugins 17 | !.yarn/releases 18 | !.yarn/sdks 19 | !.yarn/versions 20 | 21 | lib -------------------------------------------------------------------------------- /src/typings/svg.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | const src: string; 3 | export default src; 4 | } 5 | declare module "react-loading/lib/svg" { 6 | export const blank: string; 7 | export const balls: string; 8 | export const bars: string; 9 | export const bubbles: string; 10 | export const cubes: string; 11 | export const cylon: string; 12 | export const spin: string; 13 | export const spinningBubbles: string; 14 | export const spokes: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/post-ps/index.ts: -------------------------------------------------------------------------------- 1 | import type IconSC from "../isc-main"; 2 | import getCalloutIconPostProcessor from "./callout-icon"; 3 | import { getMDPostProcessor, getNodePostProcessor } from "./text"; 4 | 5 | const setupPostProcessors = (plugin: IconSC) => { 6 | plugin.registerMarkdownPostProcessor(plugin._nodeProcessor); 7 | plugin.registerMarkdownPostProcessor(getCalloutIconPostProcessor(plugin)); 8 | }; 9 | 10 | export { getMDPostProcessor, getNodePostProcessor, setupPostProcessors }; 11 | -------------------------------------------------------------------------------- /scripts/icon-list.js: -------------------------------------------------------------------------------- 1 | import { promises } from "fs"; 2 | const { readFile } = promises; 3 | 4 | /** 5 | * @type {import("esbuild").Plugin} 6 | */ 7 | const iconList = { 8 | name: "obsidian-plugin", 9 | setup: (build) => { 10 | build.onLoad( 11 | { filter: /src\/icons\/[^\/]+?\.txt$/, namespace: "file" }, 12 | async ({ path }) => { 13 | const lines = (await readFile(path, "utf8")).split(/\r?\n/); 14 | return { 15 | contents: JSON.stringify(lines), 16 | loader: "json", 17 | }; 18 | }, 19 | ); 20 | }, 21 | }; 22 | export default iconList; 23 | -------------------------------------------------------------------------------- /src/typings/obsidian-ex.d.ts: -------------------------------------------------------------------------------- 1 | import "obsidian"; 2 | 3 | import IconSCAPI from "./api"; 4 | 5 | declare module "obsidian" { 6 | interface EditorSuggest { 7 | suggestEl: HTMLElement; 8 | } 9 | interface Vault { 10 | readJson(path: string): Promise; 11 | writeJson(path: string, data: any): Promise; 12 | configDir: string; 13 | } 14 | interface App { 15 | openWithDefaultApp(path: string): Promise; 16 | plugins: { 17 | enabledPlugins: Set; 18 | plugins: { 19 | ["obsidian-icon-shortcodes"]?: { 20 | api: IconSCAPI; 21 | }; 22 | }; 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/settings.less: -------------------------------------------------------------------------------- 1 | @id: .isc; 2 | 3 | .mod-settings { 4 | @{id}-add-pack-input { 5 | margin-right: 5px; 6 | } 7 | 8 | @{id}-settings-custom-icon .dragover { 9 | position: relative; 10 | 11 | &:before { 12 | content: "Drop SVG icon(s) here"; 13 | font-size: 16px; 14 | position: absolute; 15 | top: 50%; 16 | left: 50%; 17 | transform: translate(-50%, -50%); 18 | color: var(--text-normal); 19 | } 20 | 21 | background-color: var(--shade-10) ; 22 | .theme-dark & { 23 | background-color: var(--shade-40) 24 | } 25 | border-radius: 5px; 26 | & > * { 27 | filter: blur(50px); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.0": "0.12.17", 3 | "0.1.1": "0.12.17", 4 | "0.2.0": "0.12.17", 5 | "0.3.0": "0.12.17", 6 | "0.3.1": "0.12.17", 7 | "0.3.2": "0.12.17", 8 | "0.3.3": "0.12.17", 9 | "0.4.0": "0.12.17", 10 | "0.4.1": "0.12.17", 11 | "0.4.2": "0.12.17", 12 | "0.4.3": "0.12.17", 13 | "0.5.0": "0.12.17", 14 | "0.5.1": "0.12.17", 15 | "0.6.0": "0.13.4", 16 | "0.6.1": "0.13.4", 17 | "0.6.2": "0.13.4", 18 | "0.7.0": "0.13.27", 19 | "0.8.0": "0.13.27", 20 | "0.8.1": "0.13.27", 21 | "0.8.2": "0.13.4", 22 | "0.8.3": "0.13.27", 23 | "0.8.4": "0.13.27", 24 | "0.9.0": "0.13.27", 25 | "0.9.1": "0.13.27", 26 | "0.9.2": "0.13.27", 27 | "0.9.3": "0.13.27", 28 | "0.9.4": "0.13.27", 29 | "0.9.5": "0.13.27", 30 | "0.9.6": "0.15.0", 31 | "0.9.7": "1.0.0" 32 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import "obsidian"; 2 | 3 | import { Plugin } from "obsidian"; 4 | 5 | import IconSCAPI, { evtPrefix, PMEvents } from "./typings/api"; 6 | 7 | // EVENTS 8 | 9 | type OnArgs = T extends [infer A, ...infer B] 10 | ? A extends string 11 | ? [name: `${typeof evtPrefix}${A}`, callback: (...args: B) => any] 12 | : never 13 | : never; 14 | declare module "obsidian" { 15 | interface Vault { 16 | on(...args: OnArgs): EventRef; 17 | } 18 | } 19 | 20 | // UTIL FUNCTIONS 21 | 22 | export const getApi = (plugin?: Plugin): IconSCAPI | undefined => { 23 | if (plugin) 24 | return plugin.app.plugins.plugins["obsidian-icon-shortcodes"]?.api; 25 | else return window["IconSCAPIv0"]; 26 | }; 27 | 28 | /** @deprecated you can check if getApi return undefined directly */ 29 | export const isPluginEnabled = (plugin?: Plugin) => 30 | getApi(plugin) !== undefined; 31 | -------------------------------------------------------------------------------- /src/icon-packs/emoji.ts: -------------------------------------------------------------------------------- 1 | import emoji from "node-emoji"; 2 | 3 | import type { EmojiIconData as EmojiIconDataType } from "./types"; 4 | import { getClsForIcon } from "./utils"; 5 | 6 | export default class EmojiIconData implements EmojiIconDataType { 7 | constructor(public name: string) {} 8 | public get id() { 9 | return this.name; 10 | } 11 | public get pack() { 12 | return "emoji" as const; 13 | } 14 | public get type() { 15 | return "emoji" as const; 16 | } 17 | public get char() { 18 | return emoji.get(this.name); 19 | } 20 | 21 | static getData(name: string) { 22 | if (emoji.hasEmoji(name)) { 23 | return new EmojiIconData(name); 24 | } else { 25 | return null; 26 | } 27 | } 28 | 29 | public getDOM(svg = true) { 30 | return createSpan({ 31 | cls: [getClsForIcon(this), "isc-char-icon"], 32 | text: this.char, 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/union.ts: -------------------------------------------------------------------------------- 1 | type PosRange = [start: number, end: number]; 2 | 3 | const isRangeOverlap = (a: PosRange, b: PosRange) => 4 | isWithinRange(a, b[0]) || isWithinRange(a, b[1]); 5 | const isWithinRange = (r: PosRange, pos: number) => r[0] < pos && r[1] > pos; 6 | 7 | const mergeRanges = (a: PosRange, b: PosRange) => { 8 | const start = isWithinRange(a, b[0]) ? a[0] : b[0], 9 | end = isWithinRange(a, b[1]) ? a[1] : b[1]; 10 | return [start, end] as PosRange; 11 | }; 12 | 13 | const UnionRanges = (ranges: PosRange[]) => 14 | ranges 15 | .sort((a, b) => a[0] - b[0]) 16 | .reduce((arr, range) => { 17 | let index = arr.findIndex((rangeToCheck) => 18 | isRangeOverlap(rangeToCheck, range), 19 | ); 20 | if (index !== -1) { 21 | arr[index] = mergeRanges(arr[index], range); 22 | } else { 23 | arr.push(range); 24 | } 25 | return arr; 26 | }, [] as PosRange[]); 27 | 28 | export default UnionRanges; 29 | -------------------------------------------------------------------------------- /src/icon-in-editor/get-menu.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "@codemirror/view"; 2 | import { Menu } from "obsidian"; 3 | 4 | import IconSC from "../isc-main"; 5 | 6 | const getMenu = ( 7 | start: number, 8 | end: number, 9 | plugin: IconSC, 10 | view: EditorView, 11 | ) => { 12 | return new Menu() 13 | .addItem((item) => 14 | item 15 | .setIcon("image-glyph") 16 | .setTitle("Change Icon") 17 | .onClick(async () => { 18 | const icon = await plugin.api.getIconFromUser(); 19 | if (!icon) return; 20 | view.dispatch({ 21 | changes: { from: start, to: end, insert: `:${icon.id}:` }, 22 | }); 23 | }), 24 | ) 25 | .addItem((item) => 26 | item 27 | .setIcon("trash") 28 | .setTitle("Delete Icon") 29 | .onClick(() => { 30 | view.dispatch({ 31 | changes: { from: start, to: end, insert: "" }, 32 | }); 33 | }), 34 | ); 35 | }; 36 | export default getMenu; 37 | -------------------------------------------------------------------------------- /src/icon-in-editor/widget.ts: -------------------------------------------------------------------------------- 1 | import type { EditorView } from "@codemirror/view"; 2 | import { WidgetType } from "@codemirror/view"; 3 | import cls from "classnames"; 4 | 5 | import type IconSC from "../isc-main"; 6 | export default class IconWidget extends WidgetType { 7 | constructor(public id: string, public plugin: IconSC) { 8 | super(); 9 | } 10 | 11 | eq(other: IconWidget) { 12 | return other instanceof IconWidget && other.id === this.id; 13 | } 14 | 15 | toDOM(view: EditorView) { 16 | let wrap = createSpan({ 17 | cls: "cm-isc-icon", 18 | attr: { "aria-label": this.id.replace(/_/g, " ") }, 19 | }); 20 | 21 | this.plugin.packManager.getSVGIcon(this.id).then((span) => { 22 | if (!span) { 23 | wrap.append(`:${this.id}:`); 24 | } else { 25 | span.classList.forEach((cls) => wrap.addClass(cls)); 26 | wrap.replaceChildren(...span.childNodes); 27 | } 28 | }); 29 | return wrap; 30 | } 31 | 32 | ignoreEvent() { 33 | return false; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2021-present AidenLx 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/icon-in-editor/deco.ts: -------------------------------------------------------------------------------- 1 | import type { EditorView } from "@codemirror/view"; 2 | import { Decoration } from "@codemirror/view"; 3 | import { editorLivePreviewField } from "obsidian"; 4 | 5 | import type IconSC from "../isc-main"; 6 | import IconWidget from "./widget"; 7 | 8 | const icons = (view: EditorView, plugin: IconSC) => { 9 | let ranges: [iconId: string, from: number, to: number][] = []; 10 | const SCInfo = view.state.field(plugin.shortcodePosField); 11 | for (let { from, to } of view.visibleRanges) { 12 | SCInfo.between(from, to, (from, to, { iconId }) => { 13 | ranges.push([iconId, from, to]); 14 | }); 15 | } 16 | return Decoration.set( 17 | ranges.map(([iconId, from, to]) => { 18 | const widget = new IconWidget(iconId, plugin); 19 | const spec = { widget, side: -1, from, to }; 20 | if (view.state.field(editorLivePreviewField)) { 21 | return Decoration.replace(spec).range(from, to); 22 | } else { 23 | return Decoration.widget(spec).range(to); 24 | } 25 | }), 26 | true, 27 | ); 28 | }; 29 | 30 | export default icons; 31 | -------------------------------------------------------------------------------- /src/icon-packs/icon.less: -------------------------------------------------------------------------------- 1 | @font-size: 1em; 2 | 3 | .isc-icon { 4 | &:not(.isc-char-icon) { 5 | display: inline-flex; 6 | vertical-align: text-top; 7 | padding-top: calc(var(--font-text-size) / 8); 8 | } 9 | & > img, 10 | & > svg { 11 | cursor: default !important; 12 | .view-content .mod-cm6 .cm-isc > & { 13 | cursor: pointer; 14 | } 15 | .dimension(@font-size); 16 | .markdown-source-view &, 17 | .markdown-preview-view & { 18 | .dimension(var(--font-text-size, var(--editor-font-size, @font-size))); 19 | } 20 | each(range(6), #(@level) { 21 | .markdown-source-view .HyperMD-header-@{level} &, 22 | .markdown-preview-view h@{level} & { 23 | .dimension(~"var(--h@{level}-size)"); 24 | } 25 | }); 26 | } 27 | .callout .callout-icon& { 28 | & > img, 29 | & > svg { 30 | height: 16px; 31 | width: 16px; 32 | } 33 | } 34 | } 35 | 36 | .suggestion-container.isc .suggestion-flair { 37 | opacity: 1; 38 | & > .isc-icon { 39 | color: var(--text-normal); 40 | } 41 | } 42 | 43 | .dimension(@size) { 44 | height: @size; 45 | width: @size; 46 | } 47 | -------------------------------------------------------------------------------- /src/post-ps/callout-icon.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownPostProcessor } from "obsidian"; 2 | 3 | import type IconSC from "../isc-main"; 4 | 5 | const getCalloutIconPostProcessor = 6 | (plugin: IconSC): MarkdownPostProcessor => 7 | (el, ctx) => { 8 | for (const calloutEl of el.querySelectorAll(".callout")) { 9 | const iconEl = calloutEl.querySelector( 10 | ".callout-title > .callout-icon", 11 | )! as HTMLElement; 12 | const observer = new MutationObserver(async (m) => { 13 | if (iconEl.childElementCount > 0) return; 14 | const id = getCssPropertyValue(iconEl, "--callout-icon"), 15 | icon = await plugin.api.getSVGIcon(id); 16 | if (!icon) return; 17 | observer.disconnect(); 18 | iconEl.className += " " + icon.className; 19 | iconEl.replaceChildren(...icon.childNodes); 20 | }); 21 | observer.observe(iconEl, { childList: true }); 22 | } 23 | }; 24 | export default getCalloutIconPostProcessor; 25 | 26 | const getCssPropertyValue = ( 27 | el: HTMLElement, 28 | prop: string, 29 | pseudoEl?: string | null, 30 | ) => getComputedStyle(el, pseudoEl).getPropertyValue(prop).trim(); 31 | -------------------------------------------------------------------------------- /src/component/loading.tsx: -------------------------------------------------------------------------------- 1 | import type { HTMLProps } from "react"; 2 | import React, { useEffect, useState } from "react"; 3 | import * as svgSources from "react-loading/lib/svg"; 4 | 5 | type LoadingType = 6 | | "blank" 7 | | "balls" 8 | | "bars" 9 | | "bubbles" 10 | | "cubes" 11 | | "cylon" 12 | | "spin" 13 | | "spinningBubbles" 14 | | "spokes"; 15 | 16 | export interface LoadingProps extends HTMLProps { 17 | color?: string; 18 | /** in milisecond */ 19 | delay?: number; 20 | type?: LoadingType; 21 | height?: string | number; 22 | width?: string | number; 23 | } 24 | 25 | const Loading = ({ 26 | color = "var(--interactive-accent)", 27 | delay = 0, 28 | type = "balls", 29 | height = 64, 30 | width = 64, 31 | ...restProps 32 | }: LoadingProps) => { 33 | const [delayed, setDelayed] = useState(delay > 0); 34 | useEffect(() => { 35 | let timeout = -1; 36 | if (delayed) { 37 | timeout = window.setTimeout(() => setDelayed(false), delay); 38 | } 39 | return () => clearTimeout(timeout); 40 | }, []); 41 | const selectedType = delayed ? "blank" : type; 42 | return ( 43 |
52 | ); 53 | }; 54 | export default Loading; 55 | -------------------------------------------------------------------------------- /src/modules/dialog.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal } from "obsidian"; 2 | 3 | type PromiseVal = T | PromiseLike; 4 | 5 | class ConfirmModal extends Modal { 6 | buttonContainerEl = this.modalEl.createDiv("modal-button-container"); 7 | private resolve: ((value: PromiseVal) => void) | null = null; 8 | constructor(app: App) { 9 | super(app); 10 | this.containerEl.addClass("mod-confirmation"); 11 | this.addButton("mod-cta", "OK", () => this.resolve && this.resolve(true)); 12 | this.addCancelButton(); 13 | } 14 | open(): Promise { 15 | super.open(); 16 | return new Promise((resolve) => (this.resolve = resolve)); 17 | } 18 | addButton( 19 | cls: string | string[], 20 | text: string, 21 | callback?: (evt: MouseEvent) => any, 22 | ) { 23 | this.buttonContainerEl 24 | .createEl("button", { cls, text }) 25 | .addEventListener("click", async (evt) => { 26 | callback && (await callback(evt)); 27 | this.close(); 28 | }); 29 | return this; 30 | } 31 | onClose() { 32 | this.resolve && this.resolve(false); 33 | } 34 | 35 | addCancelButton() { 36 | return this.addButton("", "Cancel", this.close.bind(this)); 37 | } 38 | } 39 | 40 | export const confirm = (message: string | DocumentFragment, app: App) => { 41 | const modal = new ConfirmModal(app); 42 | modal.contentEl.setText(message); 43 | return modal.open(); 44 | }; 45 | -------------------------------------------------------------------------------- /src/icon-packs/types.ts: -------------------------------------------------------------------------------- 1 | import Fuse from "fuse.js"; 2 | 3 | interface IconBasicInfo { 4 | pack: string; 5 | name: string; 6 | } 7 | 8 | export type IconInfo = FileIconInfo | EmojiIconInfo | BultiInIconInfo; 9 | export type IconData = FileIconData | EmojiIconData | BultiInIconData; 10 | 11 | type withId = { id: string }; 12 | 13 | type EmojiIconInfo = IconBasicInfo & { pack: "emoji" } & withId; 14 | export type EmojiIconData = EmojiIconInfo & 15 | IconBasicData & { char: string; type: "emoji" }; 16 | 17 | type FileBasicInfo = IconBasicInfo & { 18 | /** path relative to vault */ 19 | path: string; 20 | /** with dot */ 21 | ext: string; 22 | }; 23 | 24 | type IconBasicData = { 25 | getDOM(svg: true): HTMLSpanElement | Promise; 26 | getDOM(svg?: false): HTMLSpanElement; 27 | }; 28 | 29 | export type FileIconInfo = FileBasicInfo & withId; 30 | export type FileIconData = FileBasicInfo & 31 | IconBasicData & { 32 | /** real path in file system, null if not in Desktop */ 33 | fsPath: string | null; 34 | /** resource path to icon file */ 35 | resourcePath: string; 36 | type: "file"; 37 | isSVG: boolean; 38 | }; 39 | export const isFileIconInfo = (id: IconInfo): id is FileIconInfo => 40 | !!(id as FileIconInfo).ext; 41 | 42 | export type BultiInIconInfo = IconBasicInfo & withId; 43 | export type BultiInIconData = IconBasicInfo & 44 | IconBasicData & { 45 | /** data uri of svg icon */ 46 | dataUri: string; 47 | /** svg icon raw content */ 48 | data: string; 49 | type: "bulti-in"; 50 | }; 51 | 52 | export type FuzzyMatch = Fuse.FuseResult; 53 | -------------------------------------------------------------------------------- /src/icon-in-editor/sample.md: -------------------------------------------------------------------------------- 1 | --- 2 | some: "hello:tada:world" 3 | --- 4 | 5 | hello:tada:world 6 | 7 | [[hello:tada:world\|hello:tada:world]] 8 | 9 | %%hello:tada:world%% 10 | 11 | $$hello:tada:world$$ 12 | 13 | $hello :tada: world$ 14 | 15 | # h1 hello:tada:world 16 | ## h2 hello:tada:world 17 | ### h3 hello:tada:world 18 | #### h4 hello:tada:world 19 | ##### h5 hello:tada:world 20 | ###### h6 hello:tada:world 21 | 22 | **hello:tada:world** 23 | 24 | __hello:tada:world__ 25 | 26 | *hello:tada:world* 27 | 28 | _hello:tada:world_ 29 | 30 | ~~hello:tada:world~~ 31 | 32 | > hello:tada:world 33 | >> hello:tada:world 34 | > > > hello:tada:world 35 | 36 | + hello:tada:world 37 | + hello:tada:world 38 | 1. hello:tada:world 39 | 1. hello:tada:world 40 | 41 | `hello:tada:world` 42 | 43 | hello:tada:world 44 | line 2 of code 45 | line 3 of code 46 | 47 | ``` 48 | hello:tada:world 49 | ``` 50 | 51 | | hello:tada:world | hello:tada:world | 52 | | ------ | ----------- | 53 | | hello:tada:world | hello:tada:world | 54 | 55 | [hello:tada:world]( "title text! hello:tada:world") 56 | 57 | 58 | ![hello:tada:world](https://octodex.github.com/images/stormtroopocat.jpg "hello:tada:world") 59 | 60 | ![hello:tada:world][id] 61 | 62 | [id]: https://octodex.github.com/images/dojocat.jpg "hello:tada:world" 63 | 64 | ==hello:tada:world== 65 | 66 | Footnote 1 link[^hello:tada:world]. 67 | 68 | Inline footnote^[hello:tada:world] definition. 69 | 70 | [^hello:tada:world]: Footnote hello:tada:world **can have hello:tada:world** 71 | 72 | and multiple paragraphs. hello:tada:world 73 | -------------------------------------------------------------------------------- /src/modules/json-to-svg.ts: -------------------------------------------------------------------------------- 1 | import { Notice, TFile } from "obsidian"; 2 | import { join } from "path"; 3 | 4 | import IconSC from "../isc-main"; 5 | import { confirm } from "./dialog"; 6 | 7 | const jsonToSvg = async (plugin: IconSC) => { 8 | const { vault } = plugin.app; 9 | const data = (await vault.readJson( 10 | plugin.packManager.customIconsFilePath, 11 | )) as Record; 12 | let path = plugin.packManager.customIconsDir; 13 | if (!(await vault.adapter.exists(path))) { 14 | await vault.adapter.mkdir(path); 15 | } 16 | await Promise.allSettled( 17 | Object.entries(data).reduce((arr, [id, svg]) => { 18 | if (typeof id === "string" && typeof svg === "string") { 19 | const filePath = join(path, `${id}.svg`); 20 | arr.push(vault.create(filePath, svg)); 21 | } 22 | return arr; 23 | }, [] as Promise[]), 24 | ); 25 | }; 26 | 27 | const tryUpdateIcons = async (plugin: IconSC) => { 28 | if ( 29 | (await plugin.app.vault.adapter.exists( 30 | plugin.packManager.customIconsFilePath, 31 | )) && 32 | !plugin.settings.isMigrated 33 | ) { 34 | const message = 35 | "Found custom icons that have not been upgraded, update icons now?"; 36 | if (await confirm(message, plugin.app)) { 37 | try { 38 | await jsonToSvg(plugin); 39 | plugin.settings.isMigrated = true; 40 | await plugin.saveSettings(); 41 | new Notice( 42 | "Icon update complete, you can now find icon files in " + 43 | plugin.packManager.customIconsDir, 44 | ); 45 | } catch (error) { 46 | new Notice("Failed to update icons, check console for more details"); 47 | console.error(error); 48 | } 49 | } 50 | } 51 | }; 52 | 53 | export default tryUpdateIcons; 54 | -------------------------------------------------------------------------------- /src/component/icon-manager.less: -------------------------------------------------------------------------------- 1 | .isc-icon-manager .icons { 2 | margin-top: 10px; 3 | display: grid; 4 | grid-auto-rows: auto; 5 | grid-auto-columns: -webkit-max-content; 6 | grid-auto-columns: max-content; 7 | grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); 8 | grid-column-gap: 1rem; 9 | grid-row-gap: 1rem; 10 | text-align: center; 11 | 12 | .item { 13 | outline: none; 14 | .icon { 15 | min-height: 64px; 16 | display: flex; 17 | align-items: center; 18 | justify-content: center; 19 | background: white; 20 | border-radius: 6px; 21 | box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 22 | 0 1px 2px 0 rgba(0, 0, 0, 0.06); 23 | border: 2px solid transparent; 24 | font-size: 1.6em; 25 | 26 | & > img { 27 | width: 1em; 28 | height: 1em; 29 | } 30 | } 31 | .name { 32 | height: 49px; 33 | display: flex; 34 | justify-content: center; 35 | align-content: center; 36 | flex-direction: column; 37 | span { 38 | font-size: 0.7em; 39 | overflow: hidden; 40 | word-wrap: break-word; 41 | } 42 | textarea { 43 | margin-top: 5px; 44 | width: 100%; 45 | padding: 0 0.5em; 46 | font-size: 14px; 47 | &:disabled { 48 | padding: 0; 49 | font-size: 16px; 50 | border: hidden; 51 | text-align: center; 52 | background: transparent; 53 | } 54 | } 55 | } 56 | .buttons button { 57 | padding: 4px 6px; 58 | margin: 0px 1px; 59 | } 60 | // &:focus { 61 | // .icon { 62 | // border-color: rgba(@color-brand-rgb), 0.5; 63 | // background: rgba(@color-brand-rgb), 0.05; 64 | // } 65 | // .name { 66 | // color: @color-brand; 67 | // } 68 | // } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import obPlugin from "@aidenlx/esbuild-plugin-obsidian"; 2 | import { build } from "esbuild"; 3 | import { lessLoader } from "esbuild-plugin-less"; 4 | 5 | import iconList from "./scripts/icon-list.js"; 6 | 7 | const banner = `/* 8 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 9 | if you want to view the source visit the plugins github repository 10 | */ 11 | `; 12 | 13 | const cmModules = [ 14 | "@codemirror/autocomplete", 15 | "@codemirror/closebrackets", 16 | "@codemirror/collab", 17 | "@codemirror/commands", 18 | "@codemirror/comment", 19 | "@codemirror/fold", 20 | "@codemirror/gutter", 21 | "@codemirror/highlight", 22 | "@codemirror/history", 23 | "@codemirror/language", 24 | "@codemirror/lint", 25 | "@codemirror/matchbrackets", 26 | "@codemirror/panel", 27 | "@codemirror/rangeset", 28 | "@codemirror/rectangular-selection", 29 | "@codemirror/search", 30 | "@codemirror/state", 31 | "@codemirror/stream-parser", 32 | "@codemirror/text", 33 | "@codemirror/tooltip", 34 | "@codemirror/view", 35 | ]; 36 | 37 | const isBeta = process.env.BETA === "1"; 38 | const isProd = process.env.BUILD === "production"; 39 | 40 | try { 41 | await build({ 42 | entryPoints: ["src/isc-main.ts"], 43 | bundle: true, 44 | watch: !isProd, 45 | platform: "browser", 46 | external: ["obsidian", ...cmModules], 47 | format: "cjs", 48 | mainFields: ["browser", "module", "main"], 49 | banner: { js: banner }, 50 | sourcemap: "inline", 51 | minify: isProd, 52 | define: { 53 | "process.env.NODE_ENV": JSON.stringify(process.env.BUILD), 54 | }, 55 | loader: { ".svg": "text" }, 56 | outfile: "build/main.js", 57 | plugins: [ 58 | lessLoader({ 59 | javascriptEnabled: true, 60 | }), 61 | obPlugin({ beta: isBeta }), 62 | iconList, 63 | ], 64 | }); 65 | } catch (err) { 66 | console.error(err); 67 | process.exit(1); 68 | } 69 | -------------------------------------------------------------------------------- /src/modules/icon-packs.ts: -------------------------------------------------------------------------------- 1 | import { decode } from "js-base64"; 2 | import { requestUrl } from "obsidian"; 3 | 4 | export const getIconPackBundleUrl = ( 5 | path: string, 6 | branch = "master", 7 | alt = false, 8 | ) => 9 | `https://${ 10 | alt ? "raw.staticdn.net" : "raw.githubusercontent.com" 11 | }/aidenlx/obsidian-icon-shortcodes/${branch}/${path}`; 12 | 13 | export interface IconPackManifestRaw { 14 | path: string; 15 | count: number; 16 | series: string; 17 | description: string; 18 | license: string; 19 | bundleName: string; 20 | packId: string; 21 | homepage: string; 22 | style: string; 23 | } 24 | 25 | export class GitHubError extends Error { 26 | constructor(public response: { message: string }) { 27 | super("GitHub: " + response.message); 28 | } 29 | } 30 | 31 | export const getManifestViaAPI = async (branch = "master") => { 32 | const url = `https://api.github.com/repos/aidenlx/obsidian-icon-shortcodes/git/trees/${branch}?recursive=1&${Date.now()}`; 33 | const response = (await requestUrl({ url })).json; 34 | if (Array.isArray(response.tree)) { 35 | const manifestUrl = response.tree.find( 36 | (item: any) => item.path === "assets/manifest.json", 37 | )?.url; 38 | if (!manifestUrl) { 39 | console.error(response); 40 | throw new Error("No manifest.json for icon pack found"); 41 | } else { 42 | return await getJSONfromBlobUrl(manifestUrl); 43 | } 44 | } else { 45 | throw new GitHubError(response); 46 | } 47 | }; 48 | 49 | const getJSONfromBlobUrl = async ( 50 | manifestUrl: string, 51 | ): Promise => { 52 | const response = (await requestUrl({ url: manifestUrl })).json; 53 | if (response.encoding && response.content) { 54 | if (response.encoding === "base64") { 55 | return JSON.parse(decode(response.content)) as IconPackManifestRaw[]; 56 | } else { 57 | console.error(response); 58 | throw new TypeError("Unsupported encoding"); 59 | } 60 | } else { 61 | throw new GitHubError(response); 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /src/icon-packs/icon-cache.ts: -------------------------------------------------------------------------------- 1 | import { Stat } from "obsidian"; 2 | 3 | import type IconSC from "../isc-main"; 4 | 5 | declare global { 6 | const DOMPurify: typeof import("dompurify"); 7 | } 8 | 9 | interface IconCache { 10 | ctime: number; 11 | mtime: number; 12 | size: number; 13 | svg: SVGElement; 14 | } 15 | 16 | export default class FileIconCache { 17 | constructor(public plugin: IconSC) {} 18 | private get vault() { 19 | return this.plugin.app.vault; 20 | } 21 | private cache = new Map(); 22 | async getIcon(normalizedPath: string): Promise { 23 | const stat = await this.vault.adapter.stat(normalizedPath); 24 | if (!stat || stat.type !== "file") return null; 25 | if (this.cache.has(normalizedPath)) { 26 | const cache = this.cache.get(normalizedPath)!; 27 | if ( 28 | cache.ctime === stat.ctime && 29 | cache.mtime === stat.mtime && 30 | cache.size === stat.size 31 | ) { 32 | return cache.svg.cloneNode(true) as SVGElement; 33 | } 34 | } 35 | const svg = await this.readIntoCache(normalizedPath, stat); 36 | return svg.cloneNode(true) as SVGElement; 37 | } 38 | private async readIntoCache( 39 | normalizedPath: string, 40 | stat: Stat, 41 | ): Promise { 42 | const data = DOMPurify.sanitize( 43 | await this.vault.adapter.read(normalizedPath), 44 | ), 45 | svg = new DOMParser().parseFromString(data, "image/svg+xml") 46 | .documentElement as unknown as SVGElement; 47 | this.cache.set(normalizedPath, { ...stat, svg }); 48 | return svg; 49 | } 50 | refresh() { 51 | const refresh = async (path: string) => { 52 | const stat = await this.vault.adapter.stat(path); 53 | if (!stat || stat.type !== "file") { 54 | this.cache.delete(path); 55 | } else { 56 | await this.readIntoCache(path, stat); 57 | } 58 | return path; 59 | }; 60 | return Promise.allSettled([...this.cache.keys()].map(refresh)); 61 | } 62 | clear() { 63 | this.cache.clear(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/icons/obsidian-v0.13.27.txt: -------------------------------------------------------------------------------- 1 | logo-crystal 2 | create-new 3 | trash 4 | search 5 | right-triangle 6 | document 7 | folder 8 | pencil 9 | left-arrow 10 | right-arrow 11 | three-horizontal-bars 12 | dot-network 13 | audio-file 14 | image-file 15 | pdf-file 16 | gear 17 | documents 18 | blocks 19 | go-to-file 20 | presentation 21 | cross-in-box 22 | microphone 23 | microphone-filled 24 | link 25 | popup-open 26 | checkmark 27 | hashtag 28 | left-arrow-with-tail 29 | right-arrow-with-tail 30 | up-arrow-with-tail 31 | down-arrow-with-tail 32 | lines-of-text 33 | vertical-three-dots 34 | pin 35 | magnifying-glass 36 | info 37 | horizontal-split 38 | vertical-split 39 | calendar-with-checkmark 40 | sheets-in-box 41 | up-and-down-arrows 42 | broken-link 43 | cross 44 | any-key 45 | reset 46 | star 47 | crossed-star 48 | dice 49 | filled-pin 50 | enter 51 | help 52 | vault 53 | open-vault 54 | paper-plane 55 | bullet-list 56 | uppercase-lowercase-a 57 | star-list 58 | expand-vertically 59 | languages 60 | switch 61 | pane-layout 62 | install 63 | sync 64 | check-in-circle 65 | sync-small 66 | check-small 67 | paused 68 | forward-arrow 69 | stacked-levels 70 | bracket-glyph 71 | note-glyph 72 | tag-glyph 73 | price-tag-glyph 74 | heading-glyph 75 | bold-glyph 76 | italic-glyph 77 | strikethrough-glyph 78 | highlight-glyph 79 | code-glyph 80 | quote-glyph 81 | link-glyph 82 | bullet-list-glyph 83 | number-list-glyph 84 | checkbox-glyph 85 | undo-glyph 86 | redo-glyph 87 | up-chevron-glyph 88 | down-chevron-glyph 89 | left-chevron-glyph 90 | right-chevron-glyph 91 | percent-sign-glyph 92 | keyboard-glyph 93 | double-up-arrow-glyph 94 | double-down-arrow-glyph 95 | image-glyph 96 | wrench-screwdriver-glyph 97 | clock 98 | plus-with-circle 99 | minus-with-circle 100 | indent-glyph 101 | unindent-glyph 102 | fullscreen 103 | exit-fullscreen 104 | cloud 105 | run-command 106 | compress-glyph 107 | enlarge-glyph 108 | scissors-glyph 109 | up-curly-arrow-glyph 110 | down-curly-arrow-glyph 111 | plus-minus-glyph 112 | links-going-out 113 | links-coming-in 114 | add-note-glyph 115 | duplicate-glyph 116 | clock-glyph 117 | calendar-glyph 118 | command-glyph 119 | dice-glyph 120 | file-explorer-glyph 121 | graph-glyph 122 | import-glyph 123 | navigate-glyph 124 | open-elsewhere-glyph 125 | bullet-list-glyph 126 | presentation-glyph 127 | paper-plane-glyph 128 | question-mark-glyph 129 | restore-file-glyph 130 | search-glyph 131 | star-glyph 132 | play-audio-glyph 133 | stop-audio-glyph 134 | tomorrow-glyph 135 | wand-glyph 136 | workspace-glyph 137 | yesterday-glyph 138 | box-glyph 139 | merge-files-glyph 140 | merge-files 141 | two-blank-pages 142 | scissors 143 | paste 144 | paste-text 145 | split 146 | select-all-text 147 | wand 148 | github-glyph 149 | reading-glasses -------------------------------------------------------------------------------- /assets/manifest.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "bundleName": "dev", 4 | "count": 674, 5 | "description": "Set of icons representing programming languages, designing & development tools", 6 | "homepage": "https://devicon.dev", 7 | "license": "MIT License", 8 | "packId": "dev", 9 | "path": "assets/dev.zip", 10 | "series": "Devicon", 11 | "style": "" 12 | }, 13 | { 14 | "bundleName": "fa-brands", 15 | "count": 459, 16 | "description": "the Internet's icon library and toolkit, used by millions of designers, developers, and content creators.", 17 | "homepage": "https://fontawesome.com", 18 | "license": "CC BY 4.0 License", 19 | "packId": "fab", 20 | "path": "assets/fa-brands.zip", 21 | "series": "Font Awesome (Free)", 22 | "style": "brands" 23 | }, 24 | { 25 | "bundleName": "fa-regular", 26 | "count": 161, 27 | "description": "the Internet's icon library and toolkit, used by millions of designers, developers, and content creators.", 28 | "homepage": "https://fontawesome.com", 29 | "license": "CC BY 4.0 License", 30 | "packId": "far", 31 | "path": "assets/fa-regular.zip", 32 | "series": "Font Awesome (Free)", 33 | "style": "regular" 34 | }, 35 | { 36 | "bundleName": "fa-solid", 37 | "count": 1128, 38 | "description": "the Internet's icon library and toolkit, used by millions of designers, developers, and content creators.", 39 | "homepage": "https://fontawesome.com", 40 | "license": "CC BY 4.0 License", 41 | "packId": "fas", 42 | "path": "assets/fa-solid.zip", 43 | "series": "Font Awesome (Free)", 44 | "style": "solid" 45 | }, 46 | { 47 | "bundleName": "ra", 48 | "count": 495, 49 | "description": "a suite of 495 pictographic, rpg and fantasy themes icons for easy scalable vector graphics on websites", 50 | "homepage": "http://nagoshiashumari.github.io/Rpg-Awesome/", 51 | "license": "BSD-2-Clause License", 52 | "packId": "ra", 53 | "path": "assets/ra.zip", 54 | "series": "RPG Awesome", 55 | "style": "" 56 | }, 57 | { 58 | "bundleName": "ri-fill", 59 | "count": 1086, 60 | "description": "a set of open-source neutral-style system symbols elaborately crafted for designers and developers.", 61 | "homepage": "http://remixicon.com", 62 | "license": "Apache-2.0 License", 63 | "packId": "rif", 64 | "path": "assets/ri-fill.zip", 65 | "series": "Remix Icon", 66 | "style": "fill" 67 | }, 68 | { 69 | "bundleName": "ri-line", 70 | "count": 1185, 71 | "description": "a set of open-source neutral-style system symbols elaborately crafted for designers and developers.", 72 | "homepage": "http://remixicon.com", 73 | "license": "Apache-2.0 License", 74 | "packId": "ril", 75 | "path": "assets/ri-line.zip", 76 | "series": "Remix Icon", 77 | "style": "line" 78 | } 79 | ] -------------------------------------------------------------------------------- /src/icon-packs/file-icon.ts: -------------------------------------------------------------------------------- 1 | import { FileSystemAdapter } from "obsidian"; 2 | import { extname } from "path"; 3 | 4 | import IconSC from "../isc-main"; 5 | import { FileIconData as FileIconDataType } from "./types"; 6 | import { getClsForIcon, getPacknNameFromId } from "./utils"; 7 | 8 | export default class FileIconData implements FileIconDataType { 9 | static getData( 10 | id: string, 11 | path: string, 12 | plugin: IconSC, 13 | ): FileIconData | null { 14 | const result = getPacknNameFromId(id); 15 | if (!result || result.pack === "emoji") return null; 16 | return new FileIconData(id, result.name, result.pack, path, plugin); 17 | } 18 | 19 | public get type() { 20 | return "file" as const; 21 | } 22 | public path: string; 23 | constructor( 24 | private _id: string, 25 | private _name: string, 26 | private _pack: string, 27 | path: string, 28 | private plugin: IconSC, 29 | ) { 30 | this.path = path.trim(); 31 | } 32 | private get vault() { 33 | return this.plugin.app.vault; 34 | } 35 | 36 | public get id() { 37 | return this._id; 38 | } 39 | public get pack() { 40 | return this._pack; 41 | } 42 | public get name() { 43 | return this._name; 44 | } 45 | public get ext() { 46 | return extname(this.path); 47 | } 48 | public get fsPath() { 49 | if (this.vault.adapter instanceof FileSystemAdapter) { 50 | return this.vault.adapter.getFullPath(this.path); 51 | } else return null; 52 | } 53 | public get resourcePath() { 54 | return this.vault.adapter.getResourcePath(this.path); 55 | } 56 | 57 | public get isSVG() { 58 | return this.ext === ".svg"; 59 | } 60 | public getDOM(svg: true): Promise; 61 | public getDOM(svg: false): HTMLSpanElement; 62 | public getDOM(svg = true): Promise | HTMLSpanElement { 63 | const el = createSpan({ cls: getClsForIcon(this) }); 64 | if (svg && this.isSVG) { 65 | el.addClass("isc-svg-icon"); 66 | return (async () => { 67 | const svgEl = await this.plugin.fileIconCache.getIcon(this.path); 68 | if (svgEl) { 69 | this.fixFillColor(svgEl); 70 | el.append(svgEl); 71 | } else { 72 | console.error("failed to get icon data for", this.path); 73 | } 74 | return el; 75 | })(); 76 | } else { 77 | el.addClass("isc-img-icon"); 78 | el.createEl("img", { attr: { src: this.resourcePath } }); 79 | return el; 80 | } 81 | } 82 | 83 | fixFillColor(svg: SVGElement): void { 84 | if (!packToPatch.includes(this.pack)) return; 85 | for (const pathEl of svg.getElementsByTagName("path")) { 86 | if (!pathEl.hasAttribute("fill")) { 87 | pathEl.setAttribute("fill", "currentColor"); 88 | } 89 | } 90 | } 91 | } 92 | const packToPatch = ["fab", "far", "fas", "rif", "ril"]; 93 | -------------------------------------------------------------------------------- /src/icon-in-editor/view-plugin.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Decoration, 3 | DecorationSet, 4 | EditorView, 5 | ViewPlugin, 6 | ViewUpdate, 7 | } from "@codemirror/view"; 8 | import { editorLivePreviewField } from "obsidian"; 9 | 10 | import type IconSC from "../isc-main"; 11 | import icons from "./deco"; 12 | import getMenu from "./get-menu"; 13 | 14 | interface IconPlugin { 15 | constructor(view: EditorView): IconPlugin; 16 | update(update: ViewUpdate): void; 17 | } 18 | 19 | const getIconLivePreviewPlugin = (plugin: IconSC): ViewPlugin => { 20 | class IconPlugin { 21 | decorations: DecorationSet; 22 | plugin: IconSC; 23 | 24 | constructor(view: EditorView) { 25 | this.plugin = plugin; 26 | this.decorations = icons(view, this.plugin); 27 | } 28 | 29 | update(update: ViewUpdate) { 30 | const prevMode = update.startState.field(editorLivePreviewField), 31 | currMode = update.state.field(editorLivePreviewField); 32 | if ( 33 | update.docChanged || 34 | update.viewportChanged || 35 | prevMode !== currMode 36 | ) { 37 | this.decorations = icons(update.view, plugin); 38 | } 39 | } 40 | } 41 | 42 | return ViewPlugin.fromClass(IconPlugin, { 43 | eventHandlers: { 44 | mousedown: IconClickHandler, 45 | }, 46 | decorations: (v) => v.decorations, 47 | provide: (plugin) => 48 | EditorView.atomicRanges.of((view) => { 49 | let value = view.plugin(plugin); 50 | return value ? value.decorations : Decoration.none; 51 | }), 52 | }); 53 | /* eslint-disable prefer-arrow/prefer-arrow-functions */ 54 | function IconClickHandler( 55 | this: IconPlugin, 56 | evt: MouseEvent, 57 | view: EditorView, 58 | ) { 59 | let target = evt.target as HTMLElement; 60 | if (target.matchParent(".cm-isc-icon", view.contentDOM)) { 61 | const elFrom = view.posAtDOM(target); 62 | let anchor: number = -1, 63 | head: number = -1; 64 | this.decorations.between(elFrom - 1, elFrom + 1, (from, to, value) => { 65 | if (elFrom >= from && elFrom <= to) { 66 | if (from === to) { 67 | anchor = value.spec.from; 68 | head = value.spec.to; 69 | } else (anchor = from), (head = to); 70 | return; 71 | } 72 | }); 73 | if (anchor < 0 || head < 0) { 74 | console.error("no range found for", target); 75 | return; 76 | } 77 | wait(0).then(() => view.dispatch({ selection: { anchor, head } })); 78 | if (evt.button === 0 || evt.button === 1) { 79 | const menu = getMenu(anchor, head, plugin, view); 80 | wait(200).then(() => menu.showAtMouseEvent(evt)); 81 | } 82 | } 83 | } 84 | }; 85 | 86 | const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 87 | export default getIconLivePreviewPlugin; 88 | -------------------------------------------------------------------------------- /src/icon-packs/built-ins.ts: -------------------------------------------------------------------------------- 1 | import svg2uri from "mini-svg-data-uri"; 2 | import emojiByName from "node-emoji/lib/emoji.json"; 3 | import { setIcon } from "obsidian"; 4 | 5 | import { LucideIcon, ObsidianIcon } from "../icons"; 6 | import { BultiInIconData as BultiInIconDataType, IconInfo } from "./types"; 7 | import { getClsForIcon } from "./utils"; 8 | 9 | const kabobToSnake = (name: string) => name.replace(/-/g, "_"); 10 | 11 | const LucidePackName = "luc", 12 | ObsidianPackName = "obs"; 13 | 14 | export type SVGPacknames = typeof LucidePackName | typeof ObsidianPackName; 15 | 16 | const removeBultiInIconAttrs = (el: HTMLElement) => 17 | ["class", "height", "width"].forEach((k) => 18 | el.firstElementChild?.removeAttribute(k), 19 | ); 20 | class BultiInIconData implements BultiInIconDataType { 21 | public type = "bulti-in" as const; 22 | public name: string; 23 | /** icon shortcode */ 24 | public id: string; 25 | constructor(public pack: string, private obsidianId: string) { 26 | this.name = kabobToSnake(obsidianId); 27 | this.id = `${pack}_${this.name}`; 28 | } 29 | public get data() { 30 | const el = createDiv(); 31 | setIcon( 32 | el, 33 | (this.pack === LucidePackName ? "lucide-" : "") + this.obsidianId, 34 | ); 35 | removeBultiInIconAttrs(el); 36 | el.firstElementChild?.setAttribute("xmlns", "http://www.w3.org/2000/svg"); 37 | return el.innerHTML; 38 | } 39 | public get dataUri() { 40 | return svg2uri(this.data); 41 | } 42 | public getDOM(svg = true): HTMLSpanElement { 43 | const el = createSpan({ cls: getClsForIcon(this) }); 44 | if (svg) { 45 | el.addClass("isc-svg-icon"); 46 | setIcon( 47 | el, 48 | (this.pack === LucidePackName ? "lucide-" : "") + this.obsidianId, 49 | ); 50 | removeBultiInIconAttrs(el); 51 | } else { 52 | el.addClass("isc-img-icon"); 53 | el.createEl("img", { attr: { src: this.dataUri } }); 54 | } 55 | return el; 56 | } 57 | } 58 | 59 | const EMOJI_PACK_NAME = "emoji"; 60 | const getBuiltIns = (): { 61 | packs: ReadonlyMap; 62 | ids: ReadonlyArray; 63 | packnames: ReadonlyArray; 64 | } => { 65 | let packs = new Map(), 66 | ids = [] as IconInfo[], 67 | packnames = [] as string[]; 68 | 69 | for (const [pack, icons] of [ 70 | [ObsidianPackName, ObsidianIcon], 71 | [LucidePackName, LucideIcon], 72 | ] as const) { 73 | packnames.push(pack); 74 | for (const obsidianId of icons) { 75 | const icon = new BultiInIconData(pack, obsidianId); 76 | packs.set(icon.id, icon); 77 | ids.push(icon); 78 | } 79 | } 80 | packnames.push(EMOJI_PACK_NAME); 81 | for (const key of Object.keys(emojiByName)) { 82 | ids.push({ pack: EMOJI_PACK_NAME, id: key, name: key }); 83 | } 84 | return { packs, ids, packnames }; 85 | }; 86 | 87 | const result = getBuiltIns(); 88 | export const BuiltInSVGIconPacks = result.packs; 89 | export const BuiltInIconIds = result.ids; 90 | export const BuiltInIconPacknames = result.packnames; 91 | -------------------------------------------------------------------------------- /src/isc-main.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, setIcon } from "obsidian"; 2 | 3 | import buildIconPlugin from "./icon-in-editor"; 4 | import type { ShortcodePosField } from "./icon-in-editor/state"; 5 | import getShortcodePosField from "./icon-in-editor/state"; 6 | import FileIconCache from "./icon-packs/icon-cache"; 7 | import PackManager from "./icon-packs/pack-manager"; 8 | import tryUpdateIcons from "./modules/json-to-svg"; 9 | import { EmojiSuggester } from "./modules/suggester"; 10 | import { getMDPostProcessor, getNodePostProcessor } from "./post-ps"; 11 | import { setupPostProcessors } from "./post-ps"; 12 | import { DEFAULT_SETTINGS, IconSCSettings, IconSCSettingTab } from "./settings"; 13 | import { getApi } from "./typings/api"; 14 | import API, { API_NAME } from "./typings/api"; 15 | 16 | const API_NAME: API_NAME extends keyof typeof window ? API_NAME : never = 17 | "IconSCAPIv0" as const; // this line will throw error if name out of sync 18 | 19 | export default class IconSC extends Plugin { 20 | settings: IconSCSettings = DEFAULT_SETTINGS; 21 | 22 | packManager = new PackManager(this); 23 | 24 | _nodeProcessor = getNodePostProcessor(this); 25 | _mdProcessor = getMDPostProcessor(this); 26 | 27 | shortcodePosField: ShortcodePosField = getShortcodePosField(this); 28 | 29 | postProcessor(input: string, replacer: (shortcode: string) => string): string; 30 | postProcessor(input: HTMLElement): void; 31 | postProcessor( 32 | input: HTMLElement | string, 33 | replacer?: (shortcode: string) => string, 34 | ): string | void { 35 | if (typeof input === "string" && replacer) { 36 | return this._mdProcessor(input, replacer); 37 | } else if (input instanceof HTMLElement) { 38 | return this._nodeProcessor(input); 39 | } else { 40 | throw new TypeError("Invalid args given to postProcessor"); 41 | } 42 | } 43 | 44 | api = getApi(this.packManager, this); 45 | fileIconCache = new FileIconCache(this); 46 | 47 | async onload() { 48 | console.log("loading Icon Shortcodes"); 49 | 50 | await this.loadSettings(); 51 | await tryUpdateIcons(this); 52 | await this.packManager.loadIcons(); 53 | 54 | (window[API_NAME] = this.api) && 55 | this.register(() => (window[API_NAME] = undefined)); 56 | 57 | this.registerEditorSuggest(new EmojiSuggester(this)); 58 | setupPostProcessors(this); 59 | buildIconPlugin(this); 60 | 61 | this.addSettingTab(new IconSCSettingTab(this.app, this)); 62 | } 63 | 64 | // onunload() { 65 | // console.log("unloading Icon Shortcodes"); 66 | // } 67 | 68 | async loadSettings() { 69 | let loaded = (await this.loadData()) as IconSCSettings | undefined; 70 | if (loaded) { 71 | if ((loaded as any).iconpack) { 72 | delete (loaded as any)["iconpack"]; 73 | } 74 | this.settings = { 75 | ...this.settings, 76 | ...loaded, 77 | disabledPacks: loaded.disabledPacks 78 | ? new Set(loaded.disabledPacks) 79 | : this.settings.disabledPacks, 80 | }; 81 | } 82 | } 83 | 84 | async saveSettings() { 85 | await this.saveData({ 86 | ...this.settings, 87 | disabledPacks: [...this.settings.disabledPacks], 88 | }); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/post-ps/text.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getGlobalRegexp, 3 | RE_SHORTCODE, 4 | stripColons, 5 | } from "../icon-packs/utils"; 6 | import IconSC from "../isc-main"; 7 | 8 | const acceptNode = (node: Node): number => { 9 | switch (node.nodeName) { 10 | case "CODE": 11 | case "MJX-CONTAINER": 12 | return NodeFilter.FILTER_REJECT; 13 | case "#text": { 14 | if (node.nodeValue && RE_SHORTCODE.test(node.nodeValue)) { 15 | return NodeFilter.FILTER_ACCEPT; 16 | } else return NodeFilter.FILTER_REJECT; 17 | } 18 | default: 19 | return NodeFilter.FILTER_SKIP; 20 | } 21 | }; 22 | 23 | export const getNodePostProcessor = ( 24 | plugin: IconSC, 25 | ): ((el: HTMLElement) => void) => { 26 | const scReplace = async (text: Text) => { 27 | for (const code of [ 28 | ...text.wholeText.matchAll(getGlobalRegexp(RE_SHORTCODE)), 29 | ] 30 | .sort((a, b) => (b.index as number) - (a.index as number)) 31 | .map((arr) => ({ text: arr[0], index: arr.index! }))) { 32 | await insertElToText(text, code); 33 | } 34 | }; 35 | const insertElToText = async ( 36 | text: Text, 37 | { text: pattern, index }: { text: string; index: number }, 38 | ) => { 39 | const icon = await plugin.packManager.getSVGIcon(stripColons(pattern)); 40 | if (!icon) return text; 41 | if (typeof icon === "string") { 42 | text.textContent && 43 | (text.textContent = text.textContent?.replace(pattern, icon)); 44 | } else { 45 | const toReplace = text.splitText(index); 46 | toReplace.parentElement?.insertBefore(icon, toReplace); 47 | toReplace.textContent = toReplace.wholeText.substring(pattern.length); 48 | } 49 | }; 50 | 51 | return (el: HTMLElement) => { 52 | const walker = document.createTreeWalker(el, NodeFilter.SHOW_ALL, { 53 | acceptNode, 54 | }); 55 | let currentNode: Node | null = walker.currentNode; 56 | while (currentNode) { 57 | if (currentNode.nodeType === 3) { 58 | const text = currentNode as Text & { __PENDING__?: Promise }; 59 | // don't wait for new node to be inserted 60 | (async () => { 61 | let textNodes = [text]; 62 | if (text.__PENDING__) { 63 | // wait for prevous post processor to finish 64 | await text.__PENDING__; 65 | // rescan for new text nodes 66 | textNodes = [...text.parentElement!.childNodes].filter( 67 | (n): n is Text => n instanceof Text, 68 | ); 69 | } 70 | const pending = Promise.all(textNodes.map(scReplace)); 71 | // save promise to __PENDING__ to notify other async post processor 72 | text.__PENDING__ = pending; 73 | await pending; 74 | delete text.__PENDING__; 75 | })(); 76 | } 77 | currentNode = walker.nextNode(); 78 | } 79 | }; 80 | }; 81 | 82 | export const getMDPostProcessor = 83 | (plugin: IconSC) => (str: string, replacer: (shortcode: string) => string) => 84 | str.replace(getGlobalRegexp(RE_SHORTCODE), (code) => { 85 | if (plugin.packManager.hasIcon(stripColons(code))) { 86 | return replacer(code); 87 | } else { 88 | return code; 89 | } 90 | }); 91 | -------------------------------------------------------------------------------- /src/component/browser-packs.less: -------------------------------------------------------------------------------- 1 | .modal.mod-browser-packs { 2 | width: 80vw; 3 | max-width: 80vw; 4 | .modal-content { 5 | justify-content: center; 6 | .icon-text { 7 | margin-left: 4px; 8 | position: relative; 9 | top: -1px; 10 | } 11 | & > .loading { 12 | display: flex; 13 | flex: auto; 14 | flex-direction: row; 15 | align-items: center; 16 | justify-content: center; 17 | & > .loading-indicator + div { 18 | margin: 0 20px; 19 | font-size: medium; 20 | } 21 | } 22 | 23 | & > .icon-pack-list { 24 | display: flex; 25 | flex-direction: row; 26 | flex-wrap: wrap; 27 | justify-content: center; 28 | .pack-manifest { 29 | display: flex; 30 | flex-direction: column; 31 | align-items: center; 32 | width: 260px; 33 | padding: 15px 10px; 34 | background: var(--background-secondary-alt); 35 | margin: 15px 10px; 36 | .round-edge(); 37 | .pack-manifest-title { 38 | .title(); 39 | } 40 | .pack-manifest-details { 41 | .space-between-horizontal(); 42 | font-size: 0.9em; 43 | color: var(--text-muted); 44 | display: flex; 45 | text-align: center; 46 | } 47 | .pack-manifest-desc { 48 | max-width: 200px; 49 | height: auto; 50 | } 51 | } 52 | .styles-list { 53 | .space-between-vertical(); 54 | .round-edge(); 55 | padding: 5px; 56 | background-color: var(--background-primary); 57 | display: flex; 58 | align-self: stretch; 59 | flex-direction: column; 60 | flex-wrap: nowrap; 61 | margin: 10px 10px 0; 62 | text-align: center; 63 | } 64 | .style-info { 65 | .round-edge(); 66 | background-color: var(--background-primary-alt); 67 | font-size: small; 68 | display: flex; 69 | flex-direction: row; 70 | flex-wrap: nowrap; 71 | & > * { 72 | .flexbox(); 73 | } 74 | .style-info-title { 75 | flex-grow: 1; 76 | max-width: 5em; 77 | line-height: 1.2em; 78 | margin-left: 10px; 79 | text-transform: capitalize; 80 | .style-info-pack-id { 81 | text-transform: none; 82 | .parentheses(); 83 | } 84 | } 85 | 86 | .style-info-details { 87 | flex-grow: 1; 88 | } 89 | .style-info-button-container { 90 | flex-shrink: 0; 91 | button { 92 | padding: 6px; 93 | margin: 5px 5px 5px 0; 94 | } 95 | } 96 | } 97 | } 98 | } 99 | } 100 | 101 | .flexbox() { 102 | display: flex; 103 | flex-wrap: wrap; 104 | align-content: center; 105 | justify-content: space-evenly; 106 | } 107 | 108 | .parentheses() { 109 | &::before { 110 | content: "("; 111 | } 112 | &::after { 113 | content: ")"; 114 | } 115 | } 116 | .title() { 117 | text-align: center; 118 | font-weight: bold; 119 | } 120 | .space-between-horizontal() { 121 | & > :not(:last-child) { 122 | margin-right: 5px; 123 | } 124 | } 125 | .space-between-vertical() { 126 | & > :not(:last-child) { 127 | margin-bottom: 5px; 128 | } 129 | } 130 | .round-edge() { 131 | border-radius: 10px; 132 | } 133 | -------------------------------------------------------------------------------- /src/icon-packs/utils.ts: -------------------------------------------------------------------------------- 1 | import { extension, lookup } from "mime-types"; 2 | import emoji from "node-emoji"; 3 | import { extname } from "path"; 4 | 5 | import { FileIconInfo, IconInfo } from "./types"; 6 | 7 | export type EntriesFromRecord = [key: keyof T, value: T[keyof T]][]; 8 | 9 | export const ObjtoEntries = (obj: T) => 10 | Object.entries(obj) as EntriesFromRecord; 11 | 12 | /** 13 | * Removes colons on either side 14 | * of the string if present 15 | * @param {string} str 16 | * @return {string} 17 | */ 18 | 19 | export const stripColons = (str: string): string => { 20 | var colonIndex = str.indexOf(":"); 21 | if (colonIndex > -1) { 22 | // :emoji: (http://www.emoji-cheat-sheet.com/) 23 | if (colonIndex === str.length - 1) { 24 | str = str.substring(0, colonIndex); 25 | return stripColons(str); 26 | } else { 27 | str = str.substring(colonIndex + 1); 28 | return stripColons(str); 29 | } 30 | } 31 | 32 | return str; 33 | }; 34 | 35 | export const PackPrefixPattern = /^([A-Za-z0-9]+?)_/; 36 | 37 | export const getPacknNameFromId = ( 38 | id: string, 39 | ): { pack: string; name: string } | null => { 40 | if (emoji.hasEmoji(id)) return { pack: "emoji", name: id }; 41 | const match = id.match(PackPrefixPattern); 42 | if (!match) { 43 | console.error("No vaild pack id found in: ", id); 44 | return null; 45 | } 46 | const [str, packname] = match; 47 | return { pack: packname, name: id.substring(str.length) }; 48 | }; 49 | 50 | export const sanitizeId = (id: string): string | null => { 51 | const result = getPacknNameFromId(id); 52 | if (!result) { 53 | console.log("failed to rename icon: id %s invalid", id); 54 | return null; 55 | } 56 | return `${result.pack}_${sanitizeName(result.name)}`; 57 | }; 58 | export const sanitizeName = (name: string): string => 59 | name.trim().replace(/[ -]+/g, "_").replace(/\s+/g, "").toLocaleLowerCase(); 60 | 61 | export const SupportedIconExt = [ 62 | ".bmp", 63 | ".png", 64 | ".jpg", 65 | ".jpeg", 66 | ".gif", 67 | ".svg", 68 | ".webp", 69 | ] as const; 70 | export const iconFilePattern = /^[\w-]+\.(?:bmp|png|jpg|jpeg|gif|svg|webp)$/; 71 | export const extPattern = /\.(?:bmp|png|jpg|jpeg|gif|svg|webp)$/; 72 | const mimes = SupportedIconExt.map((ext) => lookup(ext)); 73 | export const getIconsFromFileList = async ( 74 | list: FileList | null | undefined, 75 | ): Promise<{ name: string; ext: string; data: ArrayBuffer }[] | null> => { 76 | if (!list || list.length <= 0) return null; 77 | const getIcon = async (file: File) => ({ 78 | name: file.name.replace(extPattern, ""), 79 | ext: "." + (extension(file.type) as string), 80 | data: await file.arrayBuffer(), 81 | }); 82 | let promises = [] as ReturnType[]; 83 | for (let i = 0; i < list.length; i++) { 84 | const file = list[i]; 85 | if (mimes.includes(file.type)) { 86 | promises.push(getIcon(file)); 87 | } 88 | } 89 | const result = await Promise.all(promises); 90 | return result.length > 0 ? result : null; 91 | }; 92 | 93 | import classNames from "classnames"; 94 | import cloneRegexp from "clone-regexp"; 95 | 96 | import IconSC from "../isc-main"; 97 | 98 | export const RE_SHORTCODE = /:\+1:|:-1:|:[\w-]+:/; 99 | export const getGlobalRegexp = (pattern: RegExp) => 100 | cloneRegexp(pattern, { global: true }); 101 | 102 | export const getClsForIcon = (icon: IconInfo) => 103 | classNames(["isc-icon", `isc-${icon.pack}`]); 104 | -------------------------------------------------------------------------------- /src/component/icon-manager.tsx: -------------------------------------------------------------------------------- 1 | import "./icon-manager.less"; 2 | 3 | import { enableMapSet } from "immer"; 4 | import { Modal, setIcon } from "obsidian"; 5 | import React, { 6 | createContext, 7 | useContext, 8 | useEffect, 9 | useMemo, 10 | useState, 11 | } from "react"; 12 | import ReactDOM from "react-dom"; 13 | import { useImmer } from "use-immer"; 14 | 15 | import PackManager from "../icon-packs/pack-manager"; 16 | import { FileIconInfo, IconInfo } from "../icon-packs/types"; 17 | import IconSC from "../isc-main"; 18 | import IconPreview from "./icon-preview"; 19 | 20 | enableMapSet(); 21 | 22 | type icons = Record<"trash" | "pencil" | "star" | "checkmark", string>; 23 | const getIcons = (): icons => { 24 | const tempEl = createDiv(), 25 | returns: Partial = {}; 26 | for (const icon of ["trash", "pencil", "star", "checkmark"] as const) { 27 | tempEl.empty(); 28 | setIcon(tempEl, icon, 14); 29 | returns[icon] = tempEl.innerHTML; 30 | } 31 | return returns as icons; 32 | }; 33 | 34 | export const Context = createContext<{ packs: PackManager; icons: icons }>( 35 | null as any, 36 | ); 37 | 38 | const ALL_UPDATE_KEY = "%ALL%"; 39 | 40 | export default class IconManager extends Modal { 41 | constructor(public plugin: IconSC, public pack: string) { 42 | super(plugin.app); 43 | this.titleEl.setText(`${pack} Icons`); 44 | this.modalEl.addClasses(["isc-icon-manager", "mod-community-theme"]); 45 | } 46 | 47 | async onOpen() { 48 | this.contentEl.empty(); 49 | ReactDOM.render( 50 | 53 | 54 | , 55 | this.contentEl, 56 | ); 57 | } 58 | onClose() { 59 | ReactDOM.unmountComponentAtNode(this.contentEl); 60 | } 61 | } 62 | const compareIconId = (a: IconInfo, b: IconInfo): number => 63 | a.name.localeCompare(b.name); 64 | const Icons = ({ pack }: { pack: string }) => { 65 | if (pack === "emoji") throw new TypeError("Emoji not supported"); 66 | 67 | const { packs } = useContext(Context); 68 | const [filter, setFilter] = useState(""); 69 | const [affected, setAffected] = useImmer(new Map()); 70 | const ids = useMemo( 71 | () => { 72 | let arr = packs 73 | .search(filter ? filter.trim().split(" ") : [], [pack], Infinity) 74 | // add an updated property to force an icon update internally 75 | .map(({ item }) => item as FileIconInfo); 76 | if (!filter) arr.sort(compareIconId); 77 | return arr; 78 | }, 79 | // eslint-disable-next-line react-hooks/exhaustive-deps 80 | [filter, pack, affected], 81 | ); 82 | useEffect(() => { 83 | const eventRef = packs.on("changed", (_api, affected) => 84 | setAffected((draft) => { 85 | if (affected) 86 | affected.forEach((id: string) => 87 | draft.set(id, (draft.get(id) || 0) + 1), 88 | ); 89 | else draft.set(ALL_UPDATE_KEY, (draft.get(ALL_UPDATE_KEY) || 0) + 1); 90 | }), 91 | ); 92 | return () => packs.offref(eventRef); 93 | // eslint-disable-next-line react-hooks/exhaustive-deps 94 | }, [packs]); 95 | 96 | return ( 97 | <> 98 |
99 | setFilter(evt.target.value)} 104 | /> 105 |
106 |
107 | {ids.map((item) => { 108 | const updated = 109 | (affected.get(item.id) ?? 0) + (affected.get(ALL_UPDATE_KEY) ?? 0); 110 | return ( 111 | 116 | ); 117 | })} 118 |
119 | 120 | ); 121 | }; 122 | -------------------------------------------------------------------------------- /src/component/icon-preview.tsx: -------------------------------------------------------------------------------- 1 | import "../invalid.less"; 2 | 3 | import cls from "classnames"; 4 | import { Notice } from "obsidian"; 5 | import React, { 6 | HTMLAttributes, 7 | TextareaHTMLAttributes, 8 | useContext, 9 | useMemo, 10 | useState, 11 | } from "react"; 12 | 13 | import { FileIconInfo } from "../icon-packs/types"; 14 | import { sanitizeName } from "../icon-packs/utils"; 15 | import { Context } from "./icon-manager"; 16 | 17 | interface IconPreviewProps { 18 | iconInfo: FileIconInfo; 19 | updated: number; 20 | } 21 | 22 | const IconPreview = ({ iconInfo, updated }: IconPreviewProps) => { 23 | const { packs, icons } = useContext(Context), 24 | { trash, pencil, star, checkmark } = icons; 25 | 26 | const [input, setInput] = useState(iconInfo.name.replace(/[-_]/g, " ")), 27 | [isEditing, setIsEditing] = useState(false); 28 | 29 | const inputId = `${iconInfo.pack}_${sanitizeName(input)}`, 30 | isInputVaild = inputId === iconInfo.id || !packs.hasIcon(inputId); 31 | 32 | // eslint-disable-next-line react-hooks/exhaustive-deps 33 | const iconSrc = useMemo( 34 | () => packs.getIcon(iconInfo.id, true), 35 | // eslint-disable-next-line react-hooks/exhaustive-deps 36 | [iconInfo.id, updated], 37 | ); 38 | const renameIcon = async (renameTo: string) => { 39 | const newName = await packs.rename(iconInfo.id, renameTo); 40 | if (!newName) 41 | new Notice(`Failed to rename to ${input}, check log for details`); 42 | else { 43 | new Notice(`The icon is renamed to ${newName}`); 44 | setIsEditing(false); 45 | } 46 | }; 47 | 48 | return ( 49 |
50 |
51 | 52 |
53 |
54 | {isEditing ? ( 55 | setInput(evt.target.value)} 58 | value={input} 59 | /> 60 | ) : ( 61 | {input} 62 | )} 63 |
64 |
65 | { 69 | let newName; 70 | if ((newName = await packs.star(iconInfo.id))) { 71 | new Notice(`${iconInfo.id} is now ${newName}`); 72 | } 73 | }} 74 | /> 75 | { 79 | if (isEditing) { 80 | if (isInputVaild) { 81 | if (inputId !== iconInfo.id) { 82 | await renameIcon(inputId); 83 | } else { 84 | setIsEditing(false); 85 | } 86 | } else { 87 | new Notice(`Unable to rename to ${input}, given id invalid`); 88 | } 89 | } else { 90 | setIsEditing(true); 91 | } 92 | }} 93 | /> 94 | { 98 | if (await packs.delete(iconInfo.id)) { 99 | new Notice(`${iconInfo.id} is removed from the pack`); 100 | } 101 | }} 102 | /> 103 |
104 |
105 | ); 106 | }; 107 | 108 | export default IconPreview; 109 | 110 | const ObButton = ( 111 | props: HTMLAttributes & { 112 | btnType?: "warning" | "cta"; 113 | invalid?: boolean; 114 | icon: string; 115 | }, 116 | ) => { 117 | const { btnType, icon, ...rest } = props; 118 | return ( 119 |