├── versions.json ├── .gitignore ├── Another-dynamic-highlights-plugin.png ├── src ├── types │ └── regexp-match-indices.d.ts ├── settings │ ├── ignoredWords.ts │ ├── settings.ts │ ├── export.ts │ ├── import.ts │ ├── modals.ts │ └── ui.ts ├── schema │ └── queries.ts └── highlighters │ ├── selection.ts │ ├── regexp-cursor.ts │ └── static.ts ├── manifest.json ├── tsconfig.json ├── version-bump.mjs ├── CHANGELOG.md ├── esbuild.config.mjs ├── LICENSE.txt ├── package.json ├── README.md ├── main.ts └── styles.css /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /yarn.lock 3 | /data.json 4 | .gitignore 5 | /zeuch 6 | main.js 7 | -------------------------------------------------------------------------------- /Another-dynamic-highlights-plugin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tine-schreibt/aDHL/HEAD/Another-dynamic-highlights-plugin.png -------------------------------------------------------------------------------- /src/types/regexp-match-indices.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'regexp-match-indices' { 2 | function execWithIndices(regexp: RegExp, str: string): (RegExpExecArray & { 3 | indices: Array<[number, number]>; 4 | }) | null; 5 | export default execWithIndices; 6 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "another-dynamic-highlights", 3 | "name": "aDHL", 4 | "version": "1.2.2", 5 | "minAppVersion": "0.15.0", 6 | "description": "Create pretty static highlighters from search or regEx. Group by tag and set commands for toggling. Based on Dynamic Highlights.", 7 | "author": "tine-schreibt", 8 | "authorUrl": "https://github.com/tine-schreibt", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /src/settings/ignoredWords.ts: -------------------------------------------------------------------------------- 1 | export const ignoredWords = 2 | "about, after, all, also, and, any, as, at, back, be, because, before, but, by, can, come, could, day, do, down, each, even, find, first, for, from, get, give, go, good, great, have, he, I, if, in, into, it, just, know, like, long, look, make, man, many, may, more, most, much, must, new, no, not, now, of, on, one, only, or, other, out, over, own, people, say, see, she, should, so, some, state, such, take, than, that, the, then, there, these, they, think, this, through, time, to, up, use, way, we, well, what, when, where, which, who, will, with, work, would, year, you"; 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": ["DOM", "ES5", "ES6", "ES7"], 15 | "allowSyntheticDefaultImports": true, 16 | "outDir": "dist", 17 | "rootDir": ".", 18 | "esModuleInterop": true, 19 | "skipLibCheck": true, 20 | "typeRoots": ["./node_modules/@types", "./types"] 21 | }, 22 | "include": ["main.ts", "src/**/*"] 23 | } 24 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.2.2] 2 | - main.js was corrupted in [1.2.1] 3 | - I am sorry for that inconvenience, too 4 | - what a day 5 | 6 | 7 | ## [1.2.1] 8 | - I accidentally released main.ts in v1.2.0, which broke the plugin for everyone. 9 | - I'm very sorry for the inconvenience -.- 10 | - I have now included the correct file 11 | 12 | ## [1.2.0] 13 | ## New 14 | - Now you can also use capture groups. Thanks to @leonrjg for this contribution! 15 | 16 | ## [1.1.0] 17 | ## Fixes 18 | - reading mode was missing support for /case insensitive/i searches 19 | - correct version bump for new features 20 | 21 | ## [1.0.7] 22 | ### Fixes: 23 | - missing x button in settings panel 24 | 25 | ### New: 26 | - works now in reading mode, but semantic versioning is hard 27 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import { createRequire } from 'module'; 4 | const require = createRequire(import.meta.url); 5 | import builtins from 'builtin-modules'; 6 | 7 | const banner = 8 | `/* 9 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 10 | if you want to view the source, please visit the github repository of this plugin 11 | */ 12 | `; 13 | 14 | const prod = (process.argv[2] === "production"); 15 | 16 | const context = await esbuild.context({ 17 | banner: { 18 | js: banner, 19 | }, 20 | entryPoints: ["main.ts"], 21 | bundle: true, 22 | external: ['obsidian', 23 | '@codemirror/state', 24 | '@codemirror/view', 25 | '@codemirror/language', 26 | ...Object.values(builtins) 27 | ], 28 | format: "cjs", 29 | target: "es2018", 30 | logLevel: "info", 31 | sourcemap: prod ? false : "inline", 32 | treeShaking: true, 33 | outfile: "main.js", 34 | minify: prod, 35 | inject: ["./node_modules/@codemirror/state/dist/index.js"], 36 | }); 37 | 38 | if (prod) { 39 | await context.rebuild(); 40 | process.exit(0); 41 | } else { 42 | await context.watch(); 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 mathieu 4 | Copyright (c) 2024 CS 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/schema/queries.ts: -------------------------------------------------------------------------------- 1 | export const queriesSchema = { 2 | $schema: "http://json-schema.org/draft-07/schema#", 3 | additionalProperties: { 4 | $ref: "#/definitions/SearchQuery", 5 | }, 6 | definitions: { 7 | SearchQuery: { 8 | properties: { 9 | class: { 10 | type: "string", 11 | }, 12 | staticColor: { 13 | type: ["null", "string"], 14 | }, 15 | staticDecoration: { 16 | type: "string", 17 | }, 18 | staticCss: { 19 | type: "StyleSpec", 20 | }, 21 | colorIconSnippet: { 22 | type: "string", 23 | }, 24 | regex: { 25 | type: "boolean", 26 | }, 27 | query: { 28 | type: "string", 29 | }, 30 | mark: { 31 | items: { 32 | enum: ["line", "match"], 33 | type: "string", 34 | }, 35 | type: "array", 36 | }, 37 | enabled: { 38 | type: "boolean", 39 | }, 40 | tag: { 41 | type: "string", 42 | }, 43 | tagEnabled: { 44 | type: "boolean", 45 | }, 46 | }, 47 | required: [ 48 | "class", 49 | "staticColor", 50 | "staticDecoration", 51 | "staticCss", 52 | "colorIconSnippet", 53 | "regex", 54 | "query", 55 | "enabled", 56 | "tag", 57 | "tagEnabled", 58 | ], 59 | }, 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /src/settings/settings.ts: -------------------------------------------------------------------------------- 1 | import { StaticHighlightOptions } from "src/highlighters/static"; 2 | import { SelectionHighlightOptions } from "../highlighters/selection"; 3 | import { ignoredWords } from "./ignoredWords"; 4 | import { StyleSpec } from "style-mod"; 5 | 6 | export type markTypes = "line" | "match" | "groups"; 7 | 8 | export type SettingValue = number | string | boolean; 9 | export interface CSSSettings { 10 | [key: string]: SettingValue; 11 | } 12 | 13 | interface SearchQuery { 14 | query: string; 15 | class: string; 16 | staticColor: string; 17 | staticDecoration: string; 18 | staticCss: StyleSpec; 19 | colorIconSnippet: string; 20 | regex: boolean; 21 | mark?: markTypes[]; 22 | highlighterEnabled: boolean; 23 | tag: string; 24 | tagEnabled: boolean; 25 | } 26 | export interface SearchQueries { 27 | [key: string]: SearchQuery; 28 | } 29 | 30 | export type HighlighterOptions = 31 | | SelectionHighlightOptions 32 | | StaticHighlightOptions; 33 | 34 | export interface AnotherDynamicHighlightsSettings { 35 | selectionHighlighter: SelectionHighlightOptions; 36 | staticHighlighter: StaticHighlightOptions; 37 | } 38 | 39 | export const DEFAULT_SETTINGS: AnotherDynamicHighlightsSettings = { 40 | selectionHighlighter: { 41 | highlightWordAroundCursor: true, 42 | highlightSelectedText: true, 43 | maxMatches: 100, 44 | minSelectionLength: 3, 45 | highlightDelay: 200, 46 | ignoredWords: ignoredWords, 47 | selectionColor: "default", 48 | selectionDecoration: "default", 49 | css: "text-decoration: underline dotted var(--text-accent)", 50 | }, 51 | staticHighlighter: { 52 | showInReadingMode: false, 53 | queries: {}, 54 | queryOrder: [], 55 | tagOrder: [], 56 | expandedTags: [], 57 | toggleable: ["#unsorted"], 58 | onOffSwitch: true, 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "another-dynamic-highlights-plugin", 3 | "version": "1.2.2", 4 | "description": "Another Dynamic Highlights Plugin that highlights RegEx and word under cursor.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production --exclude=_Zeuch", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [ 12 | "RegEx", 13 | "highlights", 14 | "dynamic highlights", 15 | "writing tool", 16 | "avoid repetition" 17 | ], 18 | "author": "tine-schreibt", 19 | "license": "MIT", 20 | "dependencies": { 21 | "@codemirror/autocomplete": "6.18.4", 22 | "@codemirror/commands": "6.7.1", 23 | "@codemirror/lang-css": "6.3.1", 24 | "@codemirror/lang-html": "6.4.9", 25 | "@codemirror/language": "6.10.7", 26 | "@codemirror/language-data": "6.5.1", 27 | "@codemirror/lint": "6.8.4", 28 | "@codemirror/search": "6.5.8", 29 | "@codemirror/state": "6.5.0", 30 | "@codemirror/view": "6.36.1", 31 | "@lezer/highlight": "1.2.1", 32 | "@simonwep/pickr": "1.9.1", 33 | "@types/lodash": "4.17.13", 34 | "@types/node": "22.10.2", 35 | "@types/sortablejs": "1.15.8", 36 | "@typescript-eslint/eslint-plugin": "8.18.1", 37 | "@typescript-eslint/parser": "8.18.1", 38 | "ajv": "8.17.1", 39 | "builtin-modules": "4.0.0", 40 | "codemirror": "6.0.1", 41 | "esbuild": "0.24.2", 42 | "eslint": "9.17.0", 43 | "glob": "10.3.10", 44 | "lodash": "4.17.21", 45 | "lru-cache": "10.2.0", 46 | "lucide": "0.469.0", 47 | "monkey-around": "3.0.0", 48 | "obsidian": "1.7.2", 49 | "regexp-match-indices": "1.0.2", 50 | "sortablejs": "1.15.6", 51 | "stylelint": "16.12.0", 52 | "stylelint-config-recommended": "14.0.1", 53 | "stylelint-config-standard": "36.0.1", 54 | "tslib": "2.8.1", 55 | "typescript": "5.7.2", 56 | "typescript-json-schema": "0.65.1" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/settings/export.ts: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/mgmeyers/obsidian-style-setting 2 | 3 | import { App, Modal, Setting, TextAreaComponent } from "obsidian"; 4 | import AnotherDynamicHighlightsPlugin from "../../main"; 5 | import { SearchQueries } from "./settings"; 6 | 7 | 8 | export class ExportModal extends Modal { 9 | plugin: AnotherDynamicHighlightsPlugin; 10 | section: string; 11 | config: SearchQueries; 12 | 13 | constructor(app: App, plugin: AnotherDynamicHighlightsPlugin, section: string, config: SearchQueries) { 14 | super(app); 15 | this.plugin = plugin; 16 | this.section = section; 17 | this.config = config; 18 | } 19 | 20 | onOpen() { 21 | let { contentEl, modalEl } = this; 22 | 23 | modalEl.addClass("modal-style-settings"); 24 | modalEl.addClass("modal-dynamic-highlights"); 25 | 26 | 27 | new Setting(contentEl).setName(`Export settings for: ${this.section}`).then(setting => { 28 | const output = JSON.stringify(this.config, null, 2); 29 | 30 | // Build a copy to clipboard link 31 | setting.controlEl.createEl( 32 | "a", 33 | { 34 | cls: "style-settings-copy", 35 | text: "Copy to clipboard", 36 | href: "#", 37 | }, 38 | copyButton => { 39 | new TextAreaComponent(contentEl).setValue(output).then(textarea => { 40 | copyButton.addEventListener("click", async e => { 41 | e.preventDefault(); 42 | 43 | try { 44 | // Use the Clipboard API to copy the value directly 45 | await navigator.clipboard.writeText(textarea.inputEl.value); 46 | 47 | // Add a success class to the button for feedback 48 | copyButton.addClass("success"); 49 | } catch (err) { 50 | console.error("Failed to copy to clipboard", err); 51 | } 52 | }); 53 | }); 54 | setTimeout(() => { 55 | // If the button is still in the dom, remove the success class 56 | if (copyButton.parentNode) { 57 | copyButton.removeClass("success"); 58 | } 59 | }, 2000); 60 | }); 61 | 62 | // Build a download link 63 | setting.controlEl.createEl("a", { 64 | cls: "style-settings-download", 65 | text: "Download", 66 | attr: { 67 | download: "dynamic-highlights.json", 68 | href: `data:application/json;charset=utf-8,${encodeURIComponent(output)}`, 69 | }, 70 | }); 71 | }); 72 | } 73 | 74 | onClose() { 75 | let { contentEl } = this; 76 | contentEl.empty(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/settings/import.ts: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/mgmeyers/obsidian-style-setting 2 | 3 | import Ajv from "ajv"; 4 | import { 5 | App, 6 | ButtonComponent, 7 | Modal, 8 | Setting, 9 | TextAreaComponent, 10 | } from "obsidian"; 11 | import { queriesSchema } from "src/schema/queries"; 12 | import AnotherDynamicHighlightsPlugin from "../../main"; 13 | import { SearchQueries } from "./settings"; 14 | 15 | export class ImportModal extends Modal { 16 | plugin: AnotherDynamicHighlightsPlugin; 17 | 18 | constructor(app: App, plugin: AnotherDynamicHighlightsPlugin) { 19 | super(app); 20 | this.plugin = plugin; 21 | } 22 | 23 | onOpen() { 24 | let { contentEl, modalEl } = this; 25 | 26 | modalEl.addClass("modal-style-settings"); 27 | modalEl.addClass("modal-dynamic-highlights"); 28 | 29 | new Setting(contentEl) 30 | .setName("Import highlighters") 31 | .setDesc( 32 | "Import an entire or partial configuration. Warning: this may override existing highlighters" 33 | ); 34 | 35 | new Setting(contentEl).then((setting) => { 36 | // Build an error message container 37 | const errorSpan = createSpan({ 38 | cls: "style-settings-import-error", 39 | text: "Error importing config", 40 | }); 41 | 42 | setting.nameEl.appendChild(errorSpan); 43 | 44 | // Attempt to parse the imported data and close if successful 45 | const importAndClose = async (str: string) => { 46 | if (str) { 47 | try { 48 | let { queryOrder } = this.plugin.settings.staticHighlighter; 49 | const importedSettings = JSON.parse(str) as SearchQueries; 50 | const ajv = new Ajv(); 51 | Object.assign( 52 | this.plugin.settings.staticHighlighter.queries, 53 | importedSettings 54 | ); 55 | Object.keys(importedSettings).forEach( 56 | (key) => queryOrder.includes(key) || queryOrder.push(key) 57 | ); 58 | await this.plugin.saveSettings(); 59 | this.plugin.updateStaticHighlighter(); 60 | this.plugin.updateStyles(); 61 | //this.plugin.updateCustomCSS(); 62 | this.plugin.settingsTab.display(); 63 | this.close(); 64 | } catch (e) { 65 | errorSpan.addClass("active"); 66 | errorSpan.setText(`Error importing highlighters: ${e}`); 67 | } 68 | } else { 69 | errorSpan.addClass("active"); 70 | errorSpan.setText(`Error importing highlighters: config is empty`); 71 | } 72 | }; 73 | 74 | // Build a file input 75 | setting.controlEl.createEl( 76 | "input", 77 | { 78 | cls: "style-settings-import-input", 79 | attr: { 80 | id: "style-settings-import-input", 81 | name: "style-settings-import-input", 82 | type: "file", 83 | accept: ".json", 84 | }, 85 | }, 86 | (importInput) => { 87 | // Set up a FileReader so we can parse the file contents 88 | importInput.addEventListener("change", (e) => { 89 | const reader = new FileReader(); 90 | reader.onload = async (e: ProgressEvent) => { 91 | if (e.target?.result) { 92 | await importAndClose( 93 | e.target && e.target.result.toString().trim() 94 | ); 95 | } 96 | }; 97 | let files = (e.target as HTMLInputElement).files; 98 | if (files?.length) reader.readAsText(files[0]); 99 | }); 100 | } 101 | ); 102 | 103 | // Build a label we will style as a link 104 | setting.controlEl.createEl("label", { 105 | cls: "style-settings-import-label", 106 | text: "Import from file", 107 | attr: { 108 | for: "style-settings-import-input", 109 | }, 110 | }); 111 | 112 | new TextAreaComponent(contentEl) 113 | .setPlaceholder("Paste config here...") 114 | .then((ta) => { 115 | new ButtonComponent(contentEl) 116 | .setButtonText("Save") 117 | .onClick(async () => { 118 | await importAndClose(ta.getValue().trim()); 119 | }); 120 | }); 121 | }); 122 | } 123 | 124 | onClose() { 125 | let { contentEl } = this; 126 | contentEl.empty(); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/highlighters/selection.ts: -------------------------------------------------------------------------------- 1 | // originally from: https://github.com/codemirror/search/blob/main/src/selection-match.ts 2 | import { SearchCursor } from "@codemirror/search"; 3 | import { 4 | CharCategory, 5 | combineConfig, 6 | Compartment, 7 | Extension, 8 | Facet, 9 | } from "@codemirror/state"; 10 | import { 11 | Decoration, 12 | DecorationSet, 13 | EditorView, 14 | ViewPlugin, 15 | ViewUpdate, 16 | } from "@codemirror/view"; 17 | import { cloneDeep } from "lodash"; 18 | import { debounce, Debouncer } from "obsidian"; 19 | import { ignoredWords } from "src/settings/ignoredWords"; 20 | 21 | export type SelectionHighlightOptions = { 22 | highlightWordAroundCursor: boolean; 23 | highlightSelectedText: boolean; 24 | minSelectionLength: number; 25 | maxMatches: number; 26 | ignoredWords: string; 27 | highlightDelay: number; 28 | selectionColor: string; 29 | selectionDecoration: string; 30 | css?: string 31 | }; 32 | 33 | const defaultHighlightOptions: SelectionHighlightOptions = { 34 | highlightWordAroundCursor: true, 35 | highlightSelectedText: true, 36 | minSelectionLength: 3, 37 | maxMatches: 100, 38 | ignoredWords: ignoredWords, 39 | highlightDelay: 0, 40 | selectionColor: "default", 41 | selectionDecoration: "default", 42 | css: "text-decoration: dashed var(--text-accent)", 43 | }; 44 | 45 | export const highlightConfig = Facet.define< 46 | SelectionHighlightOptions, 47 | Required 48 | >({ 49 | combine(options: readonly SelectionHighlightOptions[]) { 50 | return combineConfig(options, defaultHighlightOptions, { 51 | highlightWordAroundCursor: (a, b) => a || b, 52 | highlightSelectedText: (a, b) => a || b, 53 | minSelectionLength: Math.min, 54 | maxMatches: Math.min, 55 | highlightDelay: Math.min, 56 | ignoredWords: (a, b) => a || b, 57 | selectionColor: (a, b) => b || a, 58 | selectionDecoration: (a, b) => b || a, 59 | css: (a, b) => b || a, // Use the custom css if available, otherwise fallback to default 60 | }); 61 | }, 62 | }); 63 | 64 | export const highlightCompartment = new Compartment(); 65 | 66 | export function highlightSelectionMatches( 67 | options?: SelectionHighlightOptions 68 | ): Extension { 69 | let ext: Extension[] = [matchHighlighter]; 70 | if (options) { 71 | ext.push(highlightConfig.of(cloneDeep(options))); 72 | } 73 | return ext; 74 | } 75 | 76 | export function reconfigureSelectionHighlighter( 77 | options: SelectionHighlightOptions 78 | ) { 79 | return highlightCompartment.reconfigure( 80 | highlightConfig.of(cloneDeep(options)) 81 | ); 82 | } 83 | 84 | const matchHighlighter = ViewPlugin.fromClass( 85 | class { 86 | decorations: DecorationSet; 87 | highlightDelay: number; 88 | delayedGetDeco: Debouncer<[view: EditorView], void>; 89 | 90 | constructor(view: EditorView) { 91 | this.updateDebouncer(view); 92 | this.decorations = this.getDeco(view); 93 | } 94 | 95 | update(update: ViewUpdate) { 96 | if (update.selectionSet || update.docChanged || update.viewportChanged) { 97 | // don't immediately remove decorations to prevent issues with things like link clicking 98 | // https://github.com/nothingislost/obsidian-dynamic-highlights/issues/58 99 | setTimeout(() => { 100 | this.decorations = Decoration.none; 101 | update.view.update([]); 102 | }, 150); 103 | // this.decorations = Decoration.none; 104 | this.delayedGetDeco(update.view); 105 | } 106 | } 107 | 108 | updateDebouncer(view: EditorView) { 109 | this.highlightDelay = view.state.facet(highlightConfig).highlightDelay; 110 | this.delayedGetDeco = debounce( 111 | (view: EditorView) => { 112 | this.decorations = this.getDeco(view); 113 | view.update([]); // force a view update so that the decorations we just set get applied 114 | }, 115 | this.highlightDelay, 116 | true 117 | ); 118 | } 119 | 120 | getDeco(view: EditorView): DecorationSet { 121 | let conf = view.state.facet(highlightConfig); 122 | if (this.highlightDelay != conf.highlightDelay) 123 | this.updateDebouncer(view); 124 | let selectionDecoration = conf.css; 125 | let { state } = view, 126 | sel = state.selection; 127 | if (sel.ranges.length > 1) return Decoration.none; 128 | let range = sel.main, 129 | query, 130 | check = null, 131 | matchType: string; 132 | if (range.empty) { 133 | matchType = "word"; 134 | if (!conf.highlightWordAroundCursor) return Decoration.none; 135 | let word = state.wordAt(range.head); 136 | if (!word) return Decoration.none; 137 | if (word) check = state.charCategorizer(range.head); 138 | query = state.sliceDoc(word.from, word.to); 139 | let ignoredWords = new Set( 140 | conf.ignoredWords.split(",").map((w) => w.toLowerCase().trim()) 141 | ); 142 | if ( 143 | ignoredWords.has(query.toLowerCase()) || 144 | query.length < conf.minSelectionLength 145 | ) 146 | return Decoration.none; 147 | } else { 148 | matchType = "string"; 149 | if (!conf.highlightSelectedText) return Decoration.none; 150 | let len = range.to - range.from; 151 | if (len < conf.minSelectionLength || len > 200) return Decoration.none; 152 | query = state.sliceDoc(range.from, range.to).trim(); 153 | if (!query) return Decoration.none; 154 | } 155 | let deco = []; 156 | for (let part of view.visibleRanges) { 157 | let caseInsensitive = (s: string) => s.toLowerCase(); 158 | let cursor = new SearchCursor( 159 | state.doc, 160 | query, 161 | part.from, 162 | part.to, 163 | caseInsensitive 164 | ); 165 | while (!cursor.next().done) { 166 | let { from, to } = cursor.value; 167 | if ( 168 | !check || 169 | ((from == 0 || 170 | check(state.sliceDoc(from - 1, from)) != CharCategory.Word) && 171 | (to == state.doc.length || 172 | check(state.sliceDoc(to, to + 1)) != CharCategory.Word)) 173 | ) { 174 | let string = state.sliceDoc(from, to).trim(); 175 | if (check && from <= range.from && to >= range.to) { 176 | const mainMatchDeco = Decoration.mark({ 177 | attributes: { "data-contents": string, style: selectionDecoration }, 178 | }); 179 | deco.push(mainMatchDeco.range(from, to)); 180 | } else if (from >= range.to || to <= range.from) { 181 | const matchDeco = Decoration.mark({ 182 | attributes: { "data-contents": string, style: selectionDecoration }, 183 | }); 184 | deco.push(matchDeco.range(from, to)); 185 | } 186 | if (deco.length > conf.maxMatches) return Decoration.none; 187 | } 188 | } 189 | } 190 | if (deco.length < (range.empty ? 2 : 1)) { 191 | return Decoration.none; 192 | } 193 | return Decoration.set(deco); 194 | } 195 | }, 196 | { 197 | decorations: (v) => v.decorations, 198 | } 199 | ); 200 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This plugin is based on 'Dynamic Highlights' by @nothingislost. I fixed a bug in 2 | the regEx, but otherwise I left the basic mechanics untouched. I just added a 3 | lot more customisability and some usability features, which I hope you find 4 | worthwhile. 5 | 6 | UPDATES 7 | 8 | - 2025-08-16: @leonrjg added capture group functionality. Thank you! 9 | 10 | Some of the highlight styles were inspired by those available in 'Highlightr' by 11 | @chetachiezikeuzor, so if you're using that, aDHL should fit right in, style 12 | wise. 13 | 14 | Here's a picture of the settings panel as it looks on desktop, including some 15 | use cases and highlighter examples. Scroll down to find all elements explained, 16 | left to right, top to bottom. The example queries can be found all the way down. 17 | 18 | 19 | 20 | **Persistent highlights** 21 | 22 | - **Import:** Imports settings. Importing highlighters from the original plugin 23 | won't work, sorry. But in order to get the benefits of the new plugin, you'll 24 | have to touch the settings of each highlighter anyway, so... 25 | - **Export:** Exports settings. 26 | - **Toggle:** This is The Switch that starts/stops all persistent highlights 27 | being rendered; find it in the Command Palette/Hotkey panel. 28 | 29 | **Define persistent highlighters** 30 | 31 | - **First Row:** 32 | - **First input field:** Here you input your highlighter's name. 33 | - **Checkerboard circle:** This is the color picker. Click on it to get a... 34 | well, picker, and the option to input a hexa or hsla. By default it's set to 35 | your chosen accent color with an opacity of 0.25. 36 | - **Second input field:** Here you input your search query. It also shows what 37 | the highlight will look like. If a highlight seems to not be rendered, try 38 | upping the opacity of your color or (mostly if you're using a Dark Theme) 39 | changing it altogether. 40 | - **Dropdown:** All your tags. Choose one or make a new one. Intended to group 41 | your highlighters together and make them easier to manage. Find all your 42 | tags in the Command Palette/Hotkey panel. 43 | - **Save button:** Save your highlighters. 44 | - **Discard button**: Discard changes; useful when you start to edit a 45 | highlighter and then think better of it. 46 | - **Second row** 47 | - **Dropdown**: So many decoration styles! Choose one that fits your vibe 48 | and/or purpose. 49 | - **RegEx toggle:** Turn regEx on/off. This obviously uses JavaScript 50 | flavoured regEx. Find info here: https://www.regular-expressions.info. As I 51 | said, I fixed one bug, but there might be some left. 52 | - **Groups toggle:** Toggle capture groups on/off. 53 | - **Matches toggle:** Toggle on/off if matches will be highlighted. 54 | - **Parent line toggle:** Toggle on/off if the parent line of a match will be 55 | highlighted. 56 | 57 | **Your highlighters and tags** 58 | 59 | - **Sort button:** By default newly created tags appear at the top of the list, 60 | and newly created highlighters appear at the top inside their tag, its tag 61 | also being moved to the top. Once you're done creating, sort it all 62 | alphabetically (or don't, I'm not your boss). 63 | - **Caret**: UI iconography thingy that tells you that this element can be 64 | expanded/collapsed. 65 | - **Tag name:** The name of your tag(s). **#unsorted** is the default tag. Your 66 | tags don't need a #, though. 67 | - **Toggle:** Starts/stops rendering of all highlighters associated with this 68 | tag. Individual highlight settings remain intact. 69 | - **Edit button:** Edit the tag name. If you choose a name that already exists, 70 | both tags will be merged. 71 | - **Delete button:** Delete the tag. _This will also delete all highlighters 72 | associated with it_, but there's a modal and a hurdle before anything is 73 | actually deleted, so you should be safe. 74 | - **abc icon**: This is a little preview of what your highlighter will look 75 | like. I think it's cute. 76 | - **Highlighter name and query/regEx:** The name of your highlighter and the 77 | stuff that it will highlight. Be sure to check if you have regEx enabled, if 78 | your regEx highlight doesn't seem to work. 79 | - **Toggle:** Toggle this highlighter on/off. 80 | - **Edit button**: Edit your highlighter. 81 | - **Delete button:** Delete your highlighter. 82 | 83 | **Show highlights in reading mode** 84 | 85 | - Toggle on or off if you want your highlights to be rendered in reading mode, 86 | too. 87 | 88 | **Hotkeys and command palette** 89 | 90 | - All your tags are automatically added to the command palette/hotkeys for 91 | toggling. If you want to toggle individual highlighters via palette/hotkey, 92 | you can input a comma separated list of their names (case sensitive). 93 | Highlighters with the tag '#unsorted' are added automatically. 94 | - **Input field:** For the names of the tags whose highlighters you want to 95 | toggle individually. Be aware of your spelling, there's no check. Just type or 96 | delete away; saving happens automatically, deletions take effect when you 97 | reload Obsidian. 98 | 99 | **Selection highlights** 100 | 101 | - **Choose a color:** Self explanatory, really. 102 | - **Choose a decoration:** All the deco available for static highlighters you 103 | also can choose for your dynamic highlights. 104 | - **Save button:** Save the style you made. I would give you a preview but 105 | couldn't figure out how. 106 | - **Cancel button:** For when you regret your choices. 107 | - **Highlight all occurrences of the word under the cursor:** Is very useful to 108 | avoid repetition on the fly. Find a toggle for this in the Control 109 | Palette/Hotkey panel. You can also set a delay. 110 | - **Highlight all occurrences of the actively selected text:** As the 111 | description says. Find a toggle for this also in the Control Palette/Hotkey 112 | panel. 113 | - **Highlight delay:** For when you want the word around your cursor to be 114 | highlighted, but only when you stop typing for a moment. 115 | - **Ignored words:** A list of words you don't want to be highlighted, even when 116 | they are under the cursor. By default this field contains the 100 most 117 | commonly used words of the English language. Empty it if you like; saving 118 | happens automatically. 119 | 120 | **Example queries that use regEx** 121 | 122 | - **Highlight all dialogue:** 123 | - make sure to NOT copy the two backticks \`\`. 124 | - `"(.*?)" ` <- Highlights all between two "" 125 | - `'(.*?)'` <- Highlights all between two '' 126 | - **Highlight several words:** 127 | - same here; don't copy the two backticks \`\`. 128 | - `untoward |henceforth |betwixt` <- the pipe - | - means 'or', so this 129 | highlights all these words; spaces are there to make it more readable, _they 130 | are also part of the search term, though, so instances where the word is 131 | followed by a `,` won't be highlighted_. Keep that in mind when phrasing 132 | your search. 133 | - `/untoward |henceforth |betwixt/i` <- This highlights all these words, case 134 | INsensitive; due to the way regEx is implemented in this plugin, mixing case 135 | sensitive and insensitive in a single regEx doesn't work. 136 | 137 | And that's it. Let me know if I forgot anything. I might add a modal for on/off 138 | toggling, but only if many people would find that useful. 139 | 140 | If you want to express your joy at finding this neat little piece of code, you 141 | can throw me some coin: https://ko-fi.com/tine_schreibt 142 | -------------------------------------------------------------------------------- /src/highlighters/regexp-cursor.ts: -------------------------------------------------------------------------------- 1 | // from https://github.com/codemirror/search/blob/main/src/regexp.ts 2 | 3 | import { Text, TextIterator } from "@codemirror/state"; 4 | 5 | import execWithIndices from "regexp-match-indices"; 6 | 7 | const match = /.*/.exec(""); 8 | if (!match) 9 | throw new Error( 10 | "Unexpected null value for regex match (see regexp-cursor.ts)" 11 | ); 12 | const empty = { from: -1, to: -1, match }; 13 | 14 | const baseFlags = "gm" + (/x/.unicode == null ? "" : "u"); 15 | 16 | /// This class is similar to [`SearchCursor`](#search.SearchCursor) 17 | /// but searches for a regular expression pattern instead of a plain 18 | /// string. 19 | export class RegExpCursor 20 | implements Iterator<{ from: number; to: number; match: RegExpExecArray }> 21 | { 22 | private iter!: TextIterator; 23 | private re!: RegExp; 24 | private curLine = ""; 25 | private curLineStart!: number; 26 | private matchPos!: number; 27 | 28 | /// Set to `true` when the cursor has reached the end of the search 29 | /// range. 30 | done = false; 31 | 32 | /// Will contain an object with the extent of the match and the 33 | /// match object when [`next`](#search.RegExpCursor.next) 34 | /// sucessfully finds a match. 35 | value = empty; 36 | 37 | /// Create a cursor that will search the given range in the given 38 | /// document. `query` should be the raw pattern (as you'd pass it to 39 | /// `new RegExp`). 40 | constructor( 41 | text: Text, 42 | query: string, 43 | options?: { ignoreCase?: boolean }, 44 | from = 0, 45 | private to: number = text.length 46 | ) { 47 | if (/\\[sWDnr]|\n|\r|\[\^/.test(query)) 48 | return new MultilineRegExpCursor( 49 | text, 50 | query, 51 | options, 52 | from, 53 | to 54 | ) as unknown as RegExpCursor; 55 | let pattern = query; 56 | let flags = baseFlags; 57 | 58 | // Ensure query does not include / delimiters 59 | if (pattern.startsWith("/") && pattern.endsWith("/i")) { 60 | flags += "i"; 61 | pattern = pattern.slice(1, -2); 62 | } else if (pattern.startsWith("/") && pattern.endsWith("/g")) { 63 | flags += "g"; 64 | pattern = pattern.slice(1, -2); 65 | } 66 | this.re = new RegExp(pattern, flags); 67 | 68 | this.iter = text.iter(); 69 | let startLine = text.lineAt(from); 70 | this.curLineStart = startLine.from; 71 | this.matchPos = from; 72 | this.getLine(this.curLineStart); 73 | } 74 | 75 | private getLine(skip: number) { 76 | this.iter.next(skip); 77 | if (this.iter.lineBreak) { 78 | this.curLine = ""; 79 | } else { 80 | this.curLine = this.iter.value; 81 | if (this.curLineStart + this.curLine.length > this.to) 82 | this.curLine = this.curLine.slice(0, this.to - this.curLineStart); 83 | this.iter.next(); 84 | } 85 | } 86 | 87 | private nextLine() { 88 | this.curLineStart = this.curLineStart + this.curLine.length + 1; 89 | if (this.curLineStart > this.to) this.curLine = ""; 90 | else this.getLine(0); 91 | } 92 | 93 | /// Move to the next match, if there is one. 94 | next() { 95 | for (let off = this.matchPos - this.curLineStart; ; ) { 96 | this.re.lastIndex = off; 97 | let match = 98 | this.matchPos <= this.to && execWithIndices(this.re, this.curLine); 99 | if (match) { 100 | let from = this.curLineStart + match.index, 101 | to = from + match[0].length; 102 | this.matchPos = to + (from == to ? 1 : 0); 103 | if (from == this.curLine.length) this.nextLine(); 104 | if (from < to || from > this.value.to) { 105 | this.value = { from, to, match }; 106 | return this; 107 | } 108 | off = this.matchPos - this.curLineStart; 109 | } else if (this.curLineStart + this.curLine.length < this.to) { 110 | this.nextLine(); 111 | off = 0; 112 | } else { 113 | this.done = true; 114 | return this; 115 | } 116 | } 117 | } 118 | 119 | [Symbol.iterator]!: () => Iterator<{ 120 | from: number; 121 | to: number; 122 | match: RegExpExecArray; 123 | }>; 124 | } 125 | 126 | const flattened = new WeakMap(); 127 | 128 | // Reusable (partially) flattened document strings 129 | class FlattenedDoc { 130 | constructor(readonly from: number, readonly text: string) {} 131 | get to() { 132 | return this.from + this.text.length; 133 | } 134 | 135 | static get(doc: Text, from: number, to: number) { 136 | let cached = flattened.get(doc); 137 | if (!cached || cached.from >= to || cached.to <= from) { 138 | let flat = new FlattenedDoc(from, doc.sliceString(from, to)); 139 | flattened.set(doc, flat); 140 | return flat; 141 | } 142 | if (cached.from == from && cached.to == to) return cached; 143 | let { text, from: cachedFrom } = cached; 144 | if (cachedFrom > from) { 145 | text = doc.sliceString(from, cachedFrom) + text; 146 | cachedFrom = from; 147 | } 148 | if (cached.to < to) text += doc.sliceString(cached.to, to); 149 | flattened.set(doc, new FlattenedDoc(cachedFrom, text)); 150 | return new FlattenedDoc( 151 | from, 152 | text.slice(from - cachedFrom, to - cachedFrom) 153 | ); 154 | } 155 | } 156 | 157 | const enum Chunk { 158 | Base = 5000, 159 | } 160 | 161 | class MultilineRegExpCursor 162 | implements Iterator<{ from: number; to: number; match: RegExpExecArray }> 163 | { 164 | private flat: FlattenedDoc; 165 | private matchPos; 166 | private re: RegExp; 167 | 168 | done = false; 169 | value = empty; 170 | 171 | constructor( 172 | private text: Text, 173 | query: string, 174 | options: { ignoreCase?: boolean } | undefined, 175 | from: number, 176 | private to: number 177 | ) { 178 | this.matchPos = from; 179 | this.re = new RegExp(query, baseFlags + (options?.ignoreCase ? "i" : "")); 180 | this.flat = FlattenedDoc.get(text, from, this.chunkEnd(from + Chunk.Base)); 181 | } 182 | 183 | private chunkEnd(pos: number) { 184 | return pos >= this.to ? this.to : this.text.lineAt(pos).to; 185 | } 186 | 187 | next() { 188 | for (;;) { 189 | let off = (this.re.lastIndex = this.matchPos - this.flat.from); 190 | let match = execWithIndices(this.re, this.flat.text); 191 | // Skip empty matches directly after the last match 192 | if (match && !match[0] && match.index == off) { 193 | this.re.lastIndex = off + 1; 194 | match = execWithIndices(this.re, this.flat.text); 195 | } 196 | // If a match goes almost to the end of a noncomplete chunk, try 197 | // again, since it'll likely be able to match more 198 | if ( 199 | match && 200 | this.flat.to < this.to && 201 | match.index + match[0].length > this.flat.text.length - 10 202 | ) 203 | match = null; 204 | if (match) { 205 | let from = this.flat.from + match.index, 206 | to = from + match[0].length; 207 | this.value = { from, to, match }; 208 | this.matchPos = to + (from == to ? 1 : 0); 209 | return this; 210 | } else { 211 | if (this.flat.to == this.to) { 212 | this.done = true; 213 | return this; 214 | } 215 | // Grow the flattened doc 216 | this.flat = FlattenedDoc.get( 217 | this.text, 218 | this.flat.from, 219 | this.chunkEnd(this.flat.from + this.flat.text.length * 2) 220 | ); 221 | } 222 | } 223 | } 224 | 225 | [Symbol.iterator]!: () => Iterator<{ 226 | from: number; 227 | to: number; 228 | match: RegExpExecArray; 229 | }>; 230 | } 231 | 232 | if (typeof Symbol != "undefined") { 233 | RegExpCursor.prototype[Symbol.iterator] = MultilineRegExpCursor.prototype[ 234 | Symbol.iterator 235 | ] = function (this: RegExpCursor) { 236 | return this; 237 | }; 238 | } 239 | 240 | export function validRegExp(source: string) { 241 | try { 242 | new RegExp(source, baseFlags); 243 | return true; 244 | } catch { 245 | return false; 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/highlighters/static.ts: -------------------------------------------------------------------------------- 1 | // originally from: https://github.com/codemirror/search/blob/main/src/selection-match.ts 2 | import { SearchCursor } from "@codemirror/search"; 3 | import { 4 | combineConfig, 5 | // Compartment, 6 | Extension, 7 | Facet, 8 | Range, 9 | } from "@codemirror/state"; 10 | import { syntaxTree } from "@codemirror/language"; 11 | import { 12 | Decoration, 13 | DecorationSet, 14 | EditorView, 15 | ViewPlugin, 16 | ViewUpdate, 17 | WidgetType, 18 | } from "@codemirror/view"; 19 | import { cloneDeep } from "lodash"; 20 | import AnotherDynamicHighlightsPlugin from "main"; 21 | import { SearchQueries } from "src/settings/settings"; 22 | import { StyleSpec } from "style-mod"; 23 | import { RegExpCursor } from "./regexp-cursor"; 24 | import { NodeProp } from "@lezer/common"; 25 | 26 | export type StaticHighlightOptions = { 27 | showInReadingMode: boolean; 28 | queries: SearchQueries; 29 | queryOrder: string[]; 30 | tagOrder: string[]; 31 | expandedTags: string[]; 32 | toggleable: string[]; 33 | onOffSwitch: boolean; 34 | }; 35 | 36 | const tokenClassNodeProp = new NodeProp(); 37 | 38 | const defaultOptions: StaticHighlightOptions = { 39 | showInReadingMode: false, 40 | queries: {}, 41 | queryOrder: [], 42 | tagOrder: [], 43 | expandedTags: [], 44 | toggleable: [], 45 | onOffSwitch: true, 46 | }; 47 | 48 | export const staticHighlightConfig = Facet.define< 49 | StaticHighlightOptions, 50 | Required 51 | >({ 52 | combine(options: readonly StaticHighlightOptions[]) { 53 | return combineConfig(options, defaultOptions, { 54 | queries: (a, b) => a || b, 55 | queryOrder: (a, b) => a || b, 56 | tagOrder: (a, b) => a || b, 57 | toggleable: (a, b) => a || b, 58 | expandedTags: (a, b) => a || b, 59 | }); 60 | }, 61 | }); 62 | 63 | export function staticHighlighterExtension( 64 | plugin: AnotherDynamicHighlightsPlugin 65 | ): Extension { 66 | let ext: Extension[] = [staticHighlighter]; 67 | let options = plugin.settings.staticHighlighter; 68 | ext.push(staticHighlightConfig.of(cloneDeep(options))); 69 | return ext; 70 | } 71 | 72 | export interface Styles { 73 | [selector: string]: StyleSpec; 74 | } 75 | 76 | export function buildStyles(plugin: AnotherDynamicHighlightsPlugin) { 77 | let queries = Object.values(plugin.settings.staticHighlighter.queries); 78 | let styles: Styles = {}; 79 | for (let query of queries) { 80 | if (query.staticCss) { 81 | let css = query.staticCss; 82 | } 83 | let className = "." + query.class; 84 | if (!query.staticColor) continue; 85 | styles[className] = query.staticCss; 86 | } 87 | let theme = EditorView.theme(styles); 88 | return theme; 89 | } 90 | 91 | class IconWidget extends WidgetType { 92 | className: string | undefined; 93 | 94 | constructor(className?: string) { 95 | super(); 96 | this.className = className; 97 | } 98 | 99 | toDOM() { 100 | let headerEl = document.createElement("span"); 101 | this.className && headerEl.addClass(this.className); 102 | return headerEl; 103 | } 104 | 105 | ignoreEvent() { 106 | return true; 107 | } 108 | } 109 | 110 | const staticHighlighter = ViewPlugin.fromClass( 111 | class { 112 | decorations: DecorationSet; 113 | lineDecorations: DecorationSet; 114 | widgetDecorations: DecorationSet; 115 | 116 | constructor(view: EditorView) { 117 | let { token, line, widget } = this.getDeco(view); 118 | this.decorations = token; 119 | this.lineDecorations = line; 120 | this.widgetDecorations = widget; 121 | } 122 | 123 | update(update: ViewUpdate) { 124 | let reconfigured = 125 | update.startState.facet(staticHighlightConfig) !== 126 | update.state.facet(staticHighlightConfig); 127 | if (update.docChanged || update.viewportChanged || reconfigured) { 128 | let { token, line, widget } = this.getDeco(update.view); 129 | this.decorations = token; 130 | this.lineDecorations = line; 131 | this.widgetDecorations = widget; 132 | } 133 | } 134 | 135 | getDeco(view: EditorView): { 136 | line: DecorationSet; 137 | token: DecorationSet; 138 | widget: DecorationSet; 139 | } { 140 | let { state } = view, 141 | tokenDecos: Range[] = [], 142 | lineDecos: Range[] = [], 143 | widgetDecos: Range[] = [], 144 | lineClasses: { [key: number]: string[] } = {}, 145 | queries = Object.values( 146 | view.state.facet(staticHighlightConfig).queries 147 | ), 148 | onOffSwitchState: boolean = view.state.facet( 149 | staticHighlightConfig 150 | ).onOffSwitch; 151 | 152 | for (let part of view.visibleRanges) { 153 | for (let query of queries) { 154 | if ( 155 | query.highlighterEnabled && 156 | query.tagEnabled && 157 | onOffSwitchState 158 | ) { 159 | let cursor: RegExpCursor | SearchCursor; 160 | try { 161 | if (query.regex) 162 | cursor = new RegExpCursor( 163 | state.doc, 164 | query.query, 165 | {}, 166 | part.from, 167 | part.to 168 | ); 169 | else 170 | cursor = new SearchCursor( 171 | state.doc, 172 | query.query, 173 | part.from, 174 | part.to 175 | ); 176 | } catch (err) { 177 | console.debug(err); 178 | continue; 179 | } 180 | while (!cursor.next().done) { 181 | let { from, to } = cursor.value; 182 | let string = state.sliceDoc(from, to).trim(); 183 | const linePos = view.state.doc.lineAt(from)?.from; 184 | let syntaxNode = syntaxTree(view.state).resolveInner(linePos + 1), 185 | nodeProps = syntaxNode.type.prop(tokenClassNodeProp), 186 | excludedSection = ["hmd-codeblock", "hmd-frontmatter"].find( 187 | (token) => nodeProps?.toString().split(" ").includes(token) 188 | ); 189 | if (excludedSection) continue; 190 | if (query.mark?.contains("line")) { 191 | if (!lineClasses[linePos]) lineClasses[linePos] = []; 192 | lineClasses[linePos].push(query.class); 193 | } 194 | 195 | const ranges: Array<{ content: string, start: number, end: number }> = []; 196 | 197 | if (!query.mark || query.mark?.contains("match")) { 198 | ranges.push({content: string, start: from, end: to}); 199 | } 200 | 201 | if (query.mark?.contains("groups") && 'match' in cursor.value) { 202 | const groups = cursor.value.match; 203 | const fullMatch = groups[0]; 204 | let searchStart = 0; 205 | for (let i = 1; i < groups.length; i++) { 206 | const match = groups[i]; 207 | if (!match) continue; 208 | const groupIndex = fullMatch.indexOf(match, searchStart); 209 | const groupStart = from + groupIndex; 210 | searchStart = groupIndex + match.length; 211 | ranges.push({content: match, start: groupStart, end: groupStart + match.length}); 212 | } 213 | } 214 | 215 | for (const {content, start, end} of ranges) { 216 | tokenDecos.push(Decoration.mark({ 217 | class: query.class, 218 | attributes: {"data-contents": content.trim()}, 219 | }).range(start, end)); 220 | } 221 | } 222 | } 223 | } 224 | } 225 | Object.entries(lineClasses).forEach(([pos, classes]) => { 226 | const parsedPos = parseInt(pos, 10); // Parse the `pos` key into a number 227 | if (isNaN(parsedPos)) return; // Ensure it’s valid 228 | const lineDeco = Decoration.line({ 229 | attributes: { class: classes.join(" ") }, // Join the class names 230 | }); 231 | lineDecos.push(lineDeco.range(parsedPos)); // Use the parsed number here 232 | }); 233 | 234 | return { 235 | line: Decoration.set(lineDecos.sort((a, b) => a.from - b.from)), 236 | token: Decoration.set(tokenDecos.sort((a, b) => a.from - b.from)), 237 | widget: Decoration.set(widgetDecos.sort((a, b) => a.from - b.from)), 238 | }; 239 | } 240 | }, 241 | { 242 | provide: (plugin) => [ 243 | // these are separated out so that we can set decoration priority 244 | // it's also much easier to sort the decorations when they're tagged 245 | EditorView.decorations.of( 246 | (v) => v.plugin(plugin)?.lineDecorations || Decoration.none 247 | ), 248 | EditorView.decorations.of( 249 | (v) => v.plugin(plugin)?.decorations || Decoration.none 250 | ), 251 | EditorView.decorations.of( 252 | (v) => v.plugin(plugin)?.widgetDecorations || Decoration.none 253 | ), 254 | ], 255 | } 256 | ); 257 | -------------------------------------------------------------------------------- /src/settings/modals.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | Notice, 4 | setIcon, 5 | DropdownComponent, 6 | Modal, 7 | TextComponent, 8 | ButtonComponent, 9 | } from "obsidian"; 10 | import { StaticHighlightOptions } from "src/highlighters/static"; 11 | 12 | /* 13 | - newTagModal 14 | - renameTagModal 15 | - deleteTagModal 16 | - deleteHighlighterModal 17 | */ 18 | 19 | export class NewTagModal extends Modal { 20 | private dropdown: DropdownComponent; 21 | private nameHolder: string; 22 | private expandedTags: string[]; 23 | 24 | constructor( 25 | app: App, 26 | dropdown: DropdownComponent, 27 | nameHolder: string, 28 | expandedTags: string[] 29 | ) { 30 | super(app); 31 | this.dropdown = dropdown; 32 | this.nameHolder = nameHolder; 33 | this.expandedTags = expandedTags; 34 | } 35 | onOpen() { 36 | const { contentEl } = this; 37 | contentEl.createEl("h2", { text: "Create new tag" }); 38 | const helperText = contentEl.createEl("p", { 39 | text: "Enter a name for your new tag", 40 | cls: "tag-modal-helper", 41 | }); 42 | const newTagNameInput = new TextComponent(contentEl); 43 | newTagNameInput.setPlaceholder("Tag name"); 44 | newTagNameInput.inputEl.ariaLabel = "Tag name"; 45 | newTagNameInput.inputEl.addClass("tag-modal-text"); 46 | 47 | const saveButton = new ButtonComponent(contentEl); 48 | saveButton.setClass("action-button"); 49 | saveButton.setClass("action-button-save"); 50 | saveButton.setCta(); 51 | saveButton.setTooltip("Save new tag."); 52 | saveButton.setIcon("save"); 53 | saveButton.onClick(async () => { 54 | const newTagName = newTagNameInput.inputEl.value.trim(); 55 | // if a tag name is entered, hand over name and set status to enabled 56 | if (newTagName) { 57 | this.nameHolder = newTagName; 58 | this.dropdown.addOption(newTagName, newTagName); 59 | this.dropdown.setValue(newTagName); 60 | this.expandedTags.unshift(newTagName); 61 | } else { 62 | new Notice(`Please enter a tag name (case sensitive).`); 63 | } 64 | this.close(); 65 | }); 66 | } 67 | } 68 | 69 | export class RenameTagModal extends Modal { 70 | private oldTagName: string; 71 | private dropdown: DropdownComponent; 72 | private expandedTags: string[]; 73 | private staticHighlighter: StaticHighlightOptions; 74 | private modalSaveAndReload: () => Promise; 75 | constructor( 76 | app: App, 77 | oldTagName: string, 78 | dropdown: DropdownComponent, 79 | expandedTags: string[], 80 | staticHighlighter: StaticHighlightOptions, 81 | modalSaveAndReload: () => Promise 82 | ) { 83 | super(app); 84 | this.oldTagName = oldTagName; 85 | this.dropdown = dropdown; 86 | this.expandedTags = expandedTags; 87 | this.staticHighlighter = staticHighlighter; 88 | this.modalSaveAndReload = modalSaveAndReload; 89 | } 90 | onOpen() { 91 | const { contentEl } = this; 92 | contentEl.createEl("h2", { text: `Rename ${this.oldTagName}` }); 93 | const helperText = contentEl.createEl("p", { 94 | text: "Enter a new tag name (case sensitive).", 95 | cls: "tag-modal-helper", 96 | }); 97 | const newTagNameInput = new TextComponent(contentEl); 98 | newTagNameInput.setPlaceholder("Tag name"); 99 | newTagNameInput.inputEl.ariaLabel = "Tag name"; 100 | newTagNameInput.inputEl.addClass("tag-modal-text"); 101 | 102 | const saveButton = new ButtonComponent(contentEl); 103 | saveButton.setClass("action-button"); 104 | saveButton.setClass("action-button-save"); 105 | saveButton.setCta(); 106 | saveButton.setTooltip("Save new tag name."); 107 | saveButton.setIcon("save"); 108 | saveButton.onClick(async () => { 109 | const newTagName = newTagNameInput.inputEl.value.trim(); 110 | // if a Tag name is entered, hand over name and set status to enabled 111 | if (newTagName) { 112 | let tagAdded = false; 113 | Object.keys(this.staticHighlighter.queries).forEach((highlighter) => { 114 | if ( 115 | this.staticHighlighter.queries[highlighter].tag === this.oldTagName 116 | ) { 117 | this.staticHighlighter.queries[highlighter].tag = newTagName; 118 | if (!tagAdded) { 119 | this.dropdown.addOption(newTagName, newTagName); 120 | this.expandedTags.unshift(newTagName); 121 | tagAdded = true; 122 | } 123 | } 124 | }); 125 | new Notice(`Tag "${this.oldTagName}" renamed to "${newTagName}"!`); 126 | } else { 127 | new Notice(`Please enter a tag name.`); 128 | } 129 | await this.modalSaveAndReload(); 130 | this.close(); 131 | }); 132 | } 133 | } 134 | 135 | export class DeleteTagModal extends Modal { 136 | private oldTagName: string; 137 | private staticHighlighter: StaticHighlightOptions; 138 | private expandedTags: string[]; 139 | private modalSaveAndReload: () => Promise; 140 | constructor( 141 | app: App, 142 | oldTagName: string, 143 | staticHighlighter: StaticHighlightOptions, 144 | expandedTags: string[], 145 | modalSaveAndReload: () => Promise 146 | ) { 147 | super(app); 148 | this.oldTagName = oldTagName; 149 | this.staticHighlighter = staticHighlighter; 150 | this.expandedTags = expandedTags; 151 | this.modalSaveAndReload = modalSaveAndReload; 152 | } 153 | onOpen() { 154 | const { contentEl } = this; 155 | contentEl.createEl("h2", { 156 | text: `Delete ${this.oldTagName}`, 157 | cls: "modal-content-grid", 158 | }); 159 | 160 | // Create warning text with proper styling 161 | const warningSpan = contentEl.createEl("span", { 162 | text: "WARNING:", 163 | cls: "modal-warning-text", // We'll define this in CSS 164 | }); 165 | 166 | const helperText = contentEl.createEl("p", { 167 | cls: "modal-helper-text", 168 | }); 169 | 170 | helperText.appendChild(warningSpan); 171 | helperText.appendChild( 172 | document.createTextNode( 173 | ` This will also permanently delete all highlighters carrying this tag. 174 | 175 | Input "Delete ${this.oldTagName}!" to proceed.` 176 | ) 177 | ); 178 | 179 | let tagDeleteDecision = contentEl.createEl("input", { 180 | type: "text", 181 | cls: "modal-inputEl", 182 | }); 183 | 184 | tagDeleteDecision.placeholder = `Delete ${this.oldTagName}!`; 185 | 186 | const deleteButton = new ButtonComponent(contentEl); 187 | deleteButton.setClass("action-button"); 188 | deleteButton.setClass("action-button-modal"); 189 | deleteButton.setClass("action-button-delete"); 190 | deleteButton.setWarning(); 191 | deleteButton.setTooltip(`Delete ${this.oldTagName}.`); 192 | deleteButton.setIcon("trash"); 193 | deleteButton.onClick(async () => { 194 | const decision = tagDeleteDecision.value.trim(); 195 | // if a tag name is entered, hand over name and set status to enabled 196 | if (decision === `Delete ${this.oldTagName}!`) { 197 | try { 198 | this.expandedTags = this.expandedTags.filter( 199 | (item) => item !== this.oldTagName 200 | ); 201 | Object.keys(this.staticHighlighter.queries).forEach((highlighter) => { 202 | if ( 203 | this.staticHighlighter.queries[highlighter].tag === 204 | this.oldTagName 205 | ) { 206 | delete this.staticHighlighter.queries[highlighter]; 207 | this.staticHighlighter.queryOrder = 208 | this.staticHighlighter.queryOrder.filter( 209 | (item) => item !== highlighter 210 | ); 211 | } 212 | }); 213 | await this.modalSaveAndReload(); 214 | new Notice(`Tag "${this.oldTagName}" was deleted!`); 215 | this.close(); 216 | } catch (error) { 217 | new Notice("Failed to delete tag: " + error); 218 | } 219 | } 220 | }); 221 | 222 | const cancelButton = new ButtonComponent(contentEl); 223 | cancelButton.setClass("action-button"); 224 | cancelButton.setClass("action-button-cancel"); 225 | cancelButton.setCta(); 226 | cancelButton.setTooltip("Cancel."); 227 | cancelButton.setIcon("x-circle"); 228 | cancelButton.onClick(async () => { 229 | this.close(); 230 | }); 231 | } 232 | } 233 | 234 | export class DeleteHighlighterModal extends Modal { 235 | private highlighterName: string; 236 | private staticHighlighter: StaticHighlightOptions; 237 | private queryOrder: string[]; 238 | //private removeEmptyTag: () => Promise; 239 | private modalSaveAndReload: () => Promise; 240 | constructor( 241 | app: App, 242 | highlighterName: string, 243 | staticHighlighter: StaticHighlightOptions, 244 | queryOrder: string[], 245 | //removeEmptyTag: () => Promise, 246 | modalSaveAndReload: () => Promise 247 | ) { 248 | super(app); 249 | this.highlighterName = highlighterName; 250 | this.staticHighlighter = staticHighlighter; 251 | this.queryOrder = queryOrder; 252 | //this.removeEmptyTag = removeEmptyTag; 253 | this.modalSaveAndReload = modalSaveAndReload; 254 | } 255 | onOpen() { 256 | const { contentEl } = this; 257 | contentEl.createEl("h2", { text: `Delete ${this.highlighterName}` }); 258 | const helperText = contentEl.createEl("p", { 259 | text: `Delete ${this.highlighterName}?\nIt highlights ${ 260 | this.staticHighlighter.queries[this.highlighterName].query 261 | }.`, 262 | cls: "Tag-modal-helper", 263 | }); 264 | 265 | const deleteButton = new ButtonComponent(contentEl); 266 | deleteButton.setClass("action-button"); 267 | deleteButton.setClass("action-button-delete-modal"); 268 | deleteButton.setWarning(); 269 | deleteButton.setTooltip(`Delete ${this.highlighterName}.`); 270 | deleteButton.setIcon("trash"); 271 | deleteButton.onClick(async () => { 272 | try { 273 | delete this.staticHighlighter.queries[this.highlighterName]; 274 | this.queryOrder = this.queryOrder.filter( 275 | (h) => h !== this.highlighterName 276 | ); 277 | await this.modalSaveAndReload(); 278 | new Notice(`Highlighter "${this.highlighterName}" was deleted!`); 279 | this.close(); 280 | } catch (error) { 281 | new Notice("Failed to delete highlighter: " + error); 282 | } 283 | }); 284 | 285 | const cancelButton = new ButtonComponent(contentEl); 286 | cancelButton.setClass("action-button"); 287 | cancelButton.setClass("action-button-cancel"); 288 | cancelButton.setCta(); 289 | cancelButton.setTooltip("Cancel."); 290 | cancelButton.setIcon("x-circle"); 291 | cancelButton.onClick(async () => { 292 | this.close(); 293 | }); 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { Extension, StateEffect } from "@codemirror/state"; 2 | import { EditorView } from "@codemirror/view"; 3 | import { 4 | debounce, 5 | MarkdownView, 6 | MarkdownPostProcessor, 7 | Plugin, 8 | Notice, 9 | } from "obsidian"; 10 | import { 11 | highlightSelectionMatches, 12 | reconfigureSelectionHighlighter, 13 | } from "./src/highlighters/selection"; 14 | import { 15 | buildStyles, 16 | staticHighlighterExtension, 17 | } from "./src/highlighters/static"; 18 | import { 19 | DEFAULT_SETTINGS, 20 | AnotherDynamicHighlightsSettings, 21 | HighlighterOptions, 22 | } from "./src/settings/settings"; 23 | import { SettingTab } from "./src/settings/ui"; 24 | 25 | declare module "obsidian" { 26 | interface Editor { 27 | cm?: EditorView; 28 | } 29 | } 30 | // ignore this; this is just here so I can do a new commit because the 31 | // fucking verification bot will take another look at this. 32 | 33 | export default class AnotherDynamicHighlightsPlugin extends Plugin { 34 | settings: AnotherDynamicHighlightsSettings; 35 | extensions: Extension[]; 36 | styles: Extension; 37 | staticHighlighter: Extension; 38 | selectionHighlighter: Extension; 39 | // customCSS: Record; 40 | styleEl: HTMLElement; 41 | settingsTab: SettingTab; 42 | 43 | async onload() { 44 | try { 45 | await this.loadSettings(); 46 | 47 | this.settingsTab = new SettingTab(this.app, this); 48 | this.addSettingTab(this.settingsTab); 49 | this.staticHighlighter = staticHighlighterExtension(this); 50 | this.extensions = []; 51 | this.updateSelectionHighlighter(); 52 | this.updateStaticHighlighter(); 53 | this.updateStyles(); 54 | this.registerEditorExtension(this.extensions); 55 | this.initCSS(); 56 | 57 | // Register reading mode highlighter if enabled 58 | this.registerReadingModeHighlighter(); 59 | 60 | this.registerCommands(); 61 | } catch (error) { 62 | console.error("Error loading settings:", error); 63 | } 64 | } 65 | 66 | async loadSettings() { 67 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 68 | if (this.settings.selectionHighlighter.highlightDelay < 200) { 69 | this.settings.selectionHighlighter.highlightDelay = 200; 70 | await this.saveSettings(); 71 | } 72 | } 73 | 74 | async saveSettings() { 75 | await this.saveData(this.settings); 76 | 77 | // Re-register the reading mode processor when settings change 78 | // This ensures reading mode reflects the current state 79 | this.registerReadingModeHighlighter(); 80 | 81 | // Force re-render of all markdown views in reading mode 82 | this.app.workspace.iterateAllLeaves((leaf) => { 83 | if (leaf.view instanceof MarkdownView) { 84 | // Check if the view is in preview mode 85 | if (leaf.view.getMode() === "preview") { 86 | // This triggers a full re-render of the preview 87 | leaf.view.previewMode.rerender(true); 88 | } 89 | } 90 | }); 91 | 92 | // The editor mode is already handled by these: 93 | this.updateStaticHighlighter(); 94 | this.updateStyles(); 95 | } 96 | 97 | initCSS() { 98 | let styleEl = (this.styleEl = document.createElement("style")); 99 | styleEl.setAttribute("type", "text/css"); 100 | document.head.appendChild(styleEl); 101 | this.register(() => styleEl.detach()); 102 | } 103 | 104 | updateStyles() { 105 | this.extensions.remove(this.styles); 106 | this.styles = buildStyles(this); 107 | this.extensions.push(this.styles); 108 | this.app.workspace.updateOptions(); 109 | } 110 | 111 | updateStaticHighlighter() { 112 | this.extensions.remove(this.staticHighlighter); 113 | this.staticHighlighter = staticHighlighterExtension(this); 114 | this.extensions.push(this.staticHighlighter); 115 | this.app.workspace.updateOptions(); 116 | } 117 | 118 | updateSelectionHighlighter() { 119 | this.extensions.remove(this.selectionHighlighter); 120 | this.selectionHighlighter = highlightSelectionMatches( 121 | this.settings.selectionHighlighter 122 | ); 123 | this.extensions.push(this.selectionHighlighter); 124 | this.app.workspace.updateOptions(); 125 | } 126 | 127 | iterateCM6(callback: (editor: EditorView) => unknown) { 128 | this.app.workspace.iterateAllLeaves((leaf) => { 129 | if ( 130 | leaf?.view instanceof MarkdownView && 131 | leaf.view.editor.cm instanceof EditorView 132 | ) { 133 | callback(leaf.view.editor.cm); 134 | } 135 | }); 136 | } 137 | 138 | // Command Palette and hotkeys 139 | registerCommands() { 140 | const staticHighlighters = this.settings.staticHighlighter; 141 | const selectionHighlight = this.settings.selectionHighlighter; 142 | // Command for onOffSwitch 143 | this.addCommand({ 144 | id: `toggle-adhl`, 145 | name: `The switch - start/stop all static highlighting`, 146 | callback: () => { 147 | // toggle 148 | let toggleState: string = ""; 149 | if (staticHighlighters.onOffSwitch) { 150 | staticHighlighters.onOffSwitch = false; 151 | new Notice(`Static highlighting is now OFF.`); 152 | } else if (!staticHighlighters.onOffSwitch) { 153 | staticHighlighters.onOffSwitch = true; 154 | new Notice(`Static highlighting is now ON.`); 155 | } 156 | this.saveSettings(); 157 | this.updateStaticHighlighter(); 158 | }, 159 | }); 160 | 161 | this.addCommand({ 162 | id: `toggle-cursor`, 163 | name: `Start/stop highlighting the word around the cursor`, 164 | callback: () => { 165 | // toggle 166 | let toggleState: string = ""; 167 | if (selectionHighlight.highlightWordAroundCursor) { 168 | selectionHighlight.highlightWordAroundCursor = false; 169 | new Notice(`Highlighting the word around the cursor is now OFF.`); 170 | } else if (!selectionHighlight.highlightWordAroundCursor) { 171 | selectionHighlight.highlightWordAroundCursor = true; 172 | new Notice(`Highlighting the word around the cursor is now ON.`); 173 | } 174 | this.saveSettings(); 175 | this.updateSelectionHighlighter(); 176 | }, 177 | }); 178 | 179 | this.addCommand({ 180 | id: `toggle-selected`, 181 | name: `Start/stop highlighting actively selected text.`, 182 | callback: () => { 183 | // toggle 184 | let toggleState: string = ""; 185 | if (selectionHighlight.highlightSelectedText) { 186 | selectionHighlight.highlightSelectedText = false; 187 | new Notice(`Highlighting selected text is now OFF.`); 188 | } else if (!selectionHighlight.highlightSelectedText) { 189 | selectionHighlight.highlightSelectedText = true; 190 | new Notice(`Highlighting selected text is now ON.`); 191 | } 192 | this.saveSettings(); 193 | this.updateSelectionHighlighter(); 194 | }, 195 | }); 196 | 197 | // Commands for highlighters 198 | let sortedQueryOrder: string[] = [ 199 | ...this.settings.staticHighlighter.queryOrder, 200 | ]; 201 | sortedQueryOrder.sort(); 202 | sortedQueryOrder.forEach((highlighter) => { 203 | if (staticHighlighters.queries[highlighter]) { 204 | if (staticHighlighters.queries[highlighter].tag === "#unsorted") { 205 | this.addCommand({ 206 | id: `toggle-${highlighter}`, 207 | name: `Toggle highlighter "${highlighter} (tag: ${staticHighlighters.queries[highlighter].tag})"`, 208 | callback: () => { 209 | // toggle 210 | let toggleState: string = ""; 211 | if (staticHighlighters.queries[highlighter].highlighterEnabled) { 212 | staticHighlighters.queries[highlighter].highlighterEnabled = 213 | false; 214 | toggleState = "OFF"; 215 | } else if ( 216 | !staticHighlighters.queries[highlighter].highlighterEnabled 217 | ) { 218 | staticHighlighters.queries[highlighter].highlighterEnabled = 219 | true; 220 | toggleState = "ON"; 221 | } 222 | // notify of states 223 | staticHighlighters.queries[highlighter].tagEnabled 224 | ? new Notice( 225 | `Toggled "${highlighter}" ${toggleState}; its tag "${staticHighlighters.queries[highlighter].tag}" is ON.` 226 | ) 227 | : new Notice( 228 | `Toggled "${highlighter}" ${toggleState}; its tag "${staticHighlighters.queries[highlighter].tag}" is OFF.` 229 | ); 230 | this.saveSettings(); 231 | this.updateStaticHighlighter(); 232 | }, 233 | }); 234 | } 235 | } 236 | }); 237 | 238 | // Commands for tags 239 | sortedQueryOrder.forEach((highlighter) => { 240 | if (staticHighlighters.queries[highlighter]) { 241 | let tag = staticHighlighters.queries[highlighter].tag; 242 | this.addCommand({ 243 | id: `toggle-${tag}`, 244 | name: `Toggle tag "${tag}"`, 245 | callback: () => { 246 | let currentState = 247 | staticHighlighters.queries[highlighter].tagEnabled; 248 | 249 | // Toggle the state for all highlighters with the same tag 250 | Object.keys(staticHighlighters.queries).forEach((key) => { 251 | if (staticHighlighters.queries[key].tag === tag) { 252 | staticHighlighters.queries[key].tagEnabled = !currentState; 253 | } 254 | }); 255 | 256 | if (staticHighlighters.queries[highlighter].tagEnabled) { 257 | new Notice(`Toggled "${tag}" ON.`); 258 | } else if (staticHighlighters.queries[highlighter].tagEnabled) { 259 | new Notice( 260 | `Toggled "${tag}" OFF. All highlighters carrying this tag are now OFF, too.` 261 | ); 262 | } 263 | 264 | this.saveSettings(); 265 | this.updateStaticHighlighter(); 266 | }, 267 | }); 268 | } 269 | }); 270 | 271 | const sortedToggleables = staticHighlighters.toggleable.sort(); 272 | sortedToggleables.forEach((highlighter) => { 273 | if (staticHighlighters.queries[highlighter]) { 274 | if (staticHighlighters.queries[highlighter].tag != "#unsorted") { 275 | this.addCommand({ 276 | id: `toggle-${highlighter}`, 277 | name: `Toggle highlighter "${highlighter} (tag: ${staticHighlighters.queries[highlighter].tag})"`, 278 | callback: () => { 279 | // toggle 280 | let toggleState: string = ""; 281 | if (staticHighlighters.queries[highlighter].highlighterEnabled) { 282 | staticHighlighters.queries[highlighter].highlighterEnabled = 283 | false; 284 | toggleState = "OFF"; 285 | } else if ( 286 | !staticHighlighters.queries[highlighter].highlighterEnabled 287 | ) { 288 | staticHighlighters.queries[highlighter].highlighterEnabled = 289 | true; 290 | toggleState = "ON"; 291 | } 292 | // notify of states 293 | staticHighlighters.queries[highlighter].tagEnabled 294 | ? new Notice( 295 | `Toggled "${highlighter}" ${toggleState}; its tag "${staticHighlighters.queries[highlighter].tag}" is ON.` 296 | ) 297 | : new Notice( 298 | `Toggled "${highlighter}" ${toggleState}; its tag "${staticHighlighters.queries[highlighter].tag}" is OFF.` 299 | ); 300 | this.saveSettings(); 301 | this.updateStaticHighlighter(); 302 | }, 303 | }); 304 | } 305 | } 306 | }); 307 | } 308 | 309 | updateConfig = debounce( 310 | (type: string, config: HighlighterOptions) => { 311 | let reconfigure: (config: HighlighterOptions) => StateEffect; 312 | if (type === "selection") { 313 | reconfigure = reconfigureSelectionHighlighter; 314 | } else { 315 | return; 316 | } 317 | this.iterateCM6((view) => { 318 | view.dispatch({ 319 | effects: reconfigure(config), 320 | }); 321 | }); 322 | }, 323 | 1000, 324 | true 325 | ); 326 | 327 | private processNodeForHighlights(node: Node, pattern: RegExp, query: any) { 328 | // Skip already highlighted nodes 329 | if ( 330 | node.nodeType === Node.ELEMENT_NODE && 331 | (node as Element).classList.contains("adhl-highlighted") 332 | ) { 333 | return; 334 | } 335 | 336 | // Process text nodes 337 | if (node.nodeType === Node.TEXT_NODE && node.nodeValue) { 338 | const nodeText: string = node.nodeValue; 339 | 340 | // Skip pure whitespace nodes 341 | if (!nodeText.trim()) { 342 | return; 343 | } 344 | 345 | const matches = Array.from(nodeText.matchAll(pattern)); 346 | 347 | // Only log if matches are found 348 | if (matches.length > 0) { 349 | console.log("Found matches in text:", { 350 | text: nodeText, 351 | pattern: pattern, 352 | matches: matches.map((m) => ({ 353 | text: m[0], 354 | index: m.index, 355 | })), 356 | }); 357 | 358 | const fragment = document.createDocumentFragment(); 359 | let lastIndex = 0; 360 | 361 | for (const match of matches) { 362 | const matchIndex = match.index; 363 | if (matchIndex === undefined) continue; 364 | 365 | // Add text before match 366 | if (matchIndex > lastIndex) { 367 | fragment.appendChild( 368 | document.createTextNode(nodeText.slice(lastIndex, matchIndex)) 369 | ); 370 | } 371 | 372 | // Create highlight span 373 | const highlight = document.createElement("span"); 374 | highlight.classList.add("adhl-highlighted", query.class); 375 | highlight.textContent = match[0]; 376 | 377 | // Apply styles 378 | if (query.staticCss) { 379 | Object.entries(query.staticCss).forEach(([prop, value]) => { 380 | const cssProperty = prop.replace(/([A-Z])/g, "-$1").toLowerCase(); 381 | highlight.style.setProperty(cssProperty, value as string); 382 | }); 383 | } 384 | 385 | fragment.appendChild(highlight); 386 | lastIndex = matchIndex + match[0].length; 387 | } 388 | 389 | // Add remaining text 390 | if (lastIndex < nodeText.length) { 391 | fragment.appendChild( 392 | document.createTextNode(nodeText.slice(lastIndex)) 393 | ); 394 | } 395 | 396 | const parent = node.parentNode; 397 | if (parent) { 398 | parent.replaceChild(fragment, node); 399 | } 400 | } 401 | } 402 | 403 | // Process child nodes 404 | Array.from(node.childNodes).forEach((child) => 405 | this.processNodeForHighlights(child, pattern, query) 406 | ); 407 | } 408 | 409 | private registerReadingModeHighlighter() { 410 | // Force re-render of all markdown views in reading mode 411 | this.app.workspace.iterateAllLeaves((leaf) => { 412 | if ( 413 | leaf.view instanceof MarkdownView && 414 | leaf.view.getMode() === "preview" 415 | ) { 416 | // If reading mode is disabled, we need to remove existing highlights first 417 | if (!this.settings.staticHighlighter.showInReadingMode) { 418 | const container = leaf.view.previewMode.containerEl; 419 | const highlights = container.querySelectorAll(".adhl-highlighted"); 420 | highlights.forEach((highlight) => { 421 | // Replace the highlight span with its text content 422 | highlight.replaceWith(highlight.textContent || ""); 423 | }); 424 | } 425 | leaf.view.previewMode.rerender(true); 426 | } 427 | }); 428 | 429 | // Only register if the setting is enabled 430 | if (this.settings.staticHighlighter.showInReadingMode) { 431 | this.registerMarkdownPostProcessor( 432 | (element: HTMLElement, context: any) => { 433 | // Only process if the main switch is on AND reading mode highlights are enabled 434 | if ( 435 | !this.settings.staticHighlighter.onOffSwitch || 436 | !this.settings.staticHighlighter.showInReadingMode 437 | ) { 438 | return; 439 | } 440 | 441 | // Get active queries 442 | const activeQueries = Object.entries( 443 | this.settings.staticHighlighter.queries 444 | ).filter( 445 | ([_, query]) => query.highlighterEnabled && query.tagEnabled 446 | ); 447 | 448 | activeQueries.forEach(([highlighterName, query]) => { 449 | try { 450 | let patternString = query.query; 451 | let flags = "gm"; // Default flags 452 | 453 | if (query.regex) { 454 | // Check if the query string contains flags like /pattern/flags 455 | const match = query.query.match(/^\/(.*)\/([gimyus]*)$/); 456 | if (match) { 457 | patternString = match[1]; 458 | // Combine extracted flags with default, ensuring no duplicates 459 | flags = Array.from( 460 | new Set(flags.split("").concat(match[2].split(""))) 461 | ).join(""); 462 | } 463 | // If no flags in query string, patternString remains query.query and flags remain "gm" 464 | } else { 465 | patternString = query.query.replace( 466 | /[-\\/\\\\^$*+?.()|[\\]{}]/g, 467 | "\\$&" 468 | ); 469 | // For non-regex, flags remain "gm" (though 'g' is most relevant for replacement, 'm' doesn't hurt) 470 | } 471 | 472 | const pattern = new RegExp(patternString, flags); 473 | 474 | this.processNodeForHighlights(element, pattern, query); 475 | } catch (error) { 476 | console.error( 477 | `Error processing highlighter ${highlighterName}:`, 478 | error 479 | ); 480 | } 481 | }); 482 | } 483 | ); 484 | } 485 | } 486 | } 487 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .modal-close-button { 2 | display: block !important; 3 | position: absolute; 4 | top: 12px; 5 | right: 12px; 6 | z-index: 1000; 7 | } 8 | 9 | .modal.mod-settings .modal-close-button { 10 | display: block !important; 11 | } 12 | 13 | .adhl-reading-highlights { 14 | contain: strict; 15 | pointer-events: none; 16 | } 17 | 18 | .adhl-reading-highlights > div { 19 | pointer-events: none; 20 | z-index: 1; 21 | } 22 | 23 | /* ##################### CONTAINER & HEADER #####################*/ 24 | 25 | /* Base container styles */ 26 | .dynamic-highlights-settings { 27 | position: relative; 28 | } 29 | 30 | .dynamic-highlights-settings h3.persistent-highlights { 31 | padding: 5px; 32 | } 33 | 34 | .dynamic-highlights-settings .import-export-wrapper { 35 | right: 0; 36 | position: absolute; 37 | padding: 0 3em; 38 | margin-bottom: 8px; 39 | } 40 | 41 | .dynamic-highlighter-import-export.import-link { 42 | margin-right: 0.3em; /* Creates space between the links */ 43 | } 44 | 45 | /* Header section */ 46 | .headline-and-toggle { 47 | border-top: "none"; 48 | margin-top: 20px; 49 | } 50 | 51 | .headline-toggle-container .setting-item { 52 | border-top: none; 53 | } 54 | 55 | /* ##################### PERSISTENT HIGHLIGHTS #####################*/ 56 | /* ##################### DEFINE PERSISTENT HIGHLIGTS #####################*/ 57 | 58 | .dynamic-highlights-settings .highlighter-setting-icon { 59 | display: flex; 60 | height: 20px; 61 | width: 20px; 62 | } 63 | 64 | .dynamic-highlights-settings .define-query-ui { 65 | display: block; 66 | } 67 | 68 | .dynamic-highlights-settings .define-query-ui .setting-item-name { 69 | margin-bottom: 8px; 70 | } 71 | 72 | .dynamic-highlights-settings .define-query-ui .setting-item-control { 73 | align-content: center; 74 | align-items: center; 75 | flex-wrap: wrap; 76 | justify-content: flex-start; 77 | border-top: none; 78 | } 79 | 80 | /* FLEXBOX TOP ROW */ 81 | .define-query-ui-top-container { 82 | display: flex; 83 | gap: 10px; 84 | align-items: center; 85 | height: 30px; 86 | line-height: 30px; 87 | justify-content: center; 88 | border-bottom: none; 89 | padding-bottom: 0px; 90 | } 91 | 92 | .define-query-ui-top-container .setting-item { 93 | padding: 0px 0px 0px; 94 | } 95 | 96 | /* Reorder elements in the flexbox */ 97 | .query-name-input { 98 | order: -3; 99 | margin: 0; 100 | } 101 | 102 | .color-button-wrapper { 103 | order: -2; 104 | } 105 | 106 | .query-input { 107 | order: -1; 108 | margin: 0; 109 | padding: 0; 110 | } 111 | 112 | .tag-dropdown { 113 | order: 0; 114 | margin: 0; 115 | margin-right: 12px; 116 | padding: 4px; 117 | max-width: 140px; 118 | white-space: nowrap; 119 | overflow: hidden; 120 | text-overflow: ellipsis; 121 | display: inline-block; 122 | background-image: none; 123 | } 124 | 125 | .tag-dropdown select { 126 | width: 100%; 127 | max-width: 140px; 128 | white-space: nowrap; 129 | overflow: hidden; 130 | text-overflow: ellipsis; 131 | } 132 | 133 | .save-button { 134 | order: 2; 135 | } 136 | 137 | .discard-button { 138 | order: 3; 139 | } 140 | 141 | .dynamic-highlights-settings .action-button.action-button-save { 142 | align-self: center; 143 | margin-left: auto; 144 | } 145 | 146 | .dynamic-highlights-settings .action-button.action-button-discard { 147 | align-self: center; 148 | margin-left: auto; 149 | } 150 | 151 | /* Contains the dropdown, regEx and marks */ 152 | .define-query-ui-bottom-container { 153 | display: flex; 154 | align-items: center; 155 | gap: 10px; 156 | height: 30px; 157 | justify-content: center; 158 | margin-top: -3px; 159 | border-top: none; 160 | margin-left: -26px; 161 | margin-bottom: 10px; 162 | } 163 | 164 | /* Reorder elements in the flexbox */ 165 | .deco-dropdown { 166 | order: 0; 167 | margin-top: 3px; 168 | margin-right: 5px; 169 | } 170 | 171 | .regEx-text { 172 | order: 1; 173 | text-align: start; 174 | margin-right: -3px; 175 | } 176 | 177 | .regEx-toggle { 178 | order: 2; 179 | margin-right: 5px; 180 | } 181 | 182 | .matches-text { 183 | order: 3; 184 | text-align: start; 185 | margin-left: 10px; 186 | } 187 | 188 | .matches-toggle { 189 | order: 4; 190 | } 191 | 192 | .line-text { 193 | order: 5; 194 | margin-left: 10px; 195 | } 196 | 197 | .line-toggle { 198 | order: 6; 199 | } 200 | 201 | /* ##################### YOUR HIGHLIGHTERS AND TAGS #####################*/ 202 | /* This is for the display of the highlighters themselves */ 203 | 204 | .your-highlighters { 205 | display: flex; 206 | justify-content: space-between; 207 | align-items: center; 208 | width: 100%; 209 | margin-top: 0px; 210 | /* border-top: 1px solid #ccc;*/ 211 | padding-top: 10px; 212 | font-size: 1.2em; 213 | font-weight: 550; 214 | font-kerning: 0.05em; 215 | padding-bottom: 10px; 216 | margin-bottom: 0px; 217 | border-bottom: none; 218 | } 219 | 220 | .tag-container { 221 | border: 1px solid #ccc; 222 | /*border-radius: 10px;*/ 223 | padding: 15px; 224 | /*background-color: #bbb3c638;*/ 225 | } 226 | 227 | .tag-container .tag-header { 228 | display: flex; 229 | align-items: center; 230 | cursor: pointer; 231 | } 232 | 233 | .tag-header-buttons { 234 | display: flex; 235 | align-items: center; 236 | border: none; 237 | } 238 | 239 | .tag-header .toggle-icon { 240 | margin-right: 5px; 241 | cursor: pointer; 242 | border-top: none; 243 | } 244 | 245 | .tag-header .tag-name { 246 | flex-grow: 1; 247 | border-top: 1px; 248 | font-weight: 600; 249 | font-size: medium; 250 | border-bottom: 1px; 251 | background-color: #ffffff; 252 | cursor: pointer; 253 | } 254 | 255 | .highlighters-list-expanded { 256 | display: block; 257 | } 258 | 259 | .highlighters-list-collapsed { 260 | display: none; 261 | } 262 | 263 | .tag-header .tag-toggle { 264 | margin-left: auto; 265 | border-top: 1px; 266 | border-bottom: 1px; 267 | } 268 | 269 | .dynamic-highlights-settings .tag-wrapper { 270 | display: grid; 271 | align-items: center; 272 | grid-template-columns: repeat(2, auto); 273 | border-bottom: none; 274 | } 275 | 276 | .dynamic-highlights-settings .highlighter-container { 277 | /* allow for copy and paste */ 278 | user-select: text; 279 | border-bottom: 1px solid var(--background-modifier-border); 280 | margin-top: 10px; 281 | margin-bottom: 10px; 282 | } 283 | 284 | .highlighter-style-icon { 285 | position: relative; 286 | display: inline-block; 287 | padding: 0.1em 0.2em; 288 | } 289 | 290 | .highlighter-style-icon-abc { 291 | font-size: medium; 292 | position: relative; 293 | z-index: 1; 294 | display: inline-block; 295 | line-height: normal; 296 | padding: 0.1em 0; 297 | } 298 | 299 | .dynamic-highlights-settings .highlighter-container .highlighter-details { 300 | display: flex; 301 | padding: 18px 0 18px 0; 302 | border-top: none; 303 | } 304 | 305 | .dynamic-highlights-settings 306 | .highlighter-container 307 | .highlighter-details 308 | .setting-item-control { 309 | flex-wrap: nowrap; 310 | flex-grow: 0; 311 | border-top: none; 312 | } 313 | 314 | /* ##################### BUTTON, TOGGLE AND TEXT AREA STYLES #####################*/ 315 | .dynamic-highlights-settings .ignored-words-input { 316 | width: 15rem; 317 | height: 8em; 318 | resize: vertical; 319 | } 320 | 321 | .enabled-highlighter-toggle { 322 | opacity: 0.7; 323 | } 324 | 325 | .disabled-highlighter-toggle { 326 | opacity: 0.3; 327 | pointer-events: none; 328 | cursor: not-allowed; 329 | } 330 | 331 | .disabled-toggle { 332 | opacity: 0.5; 333 | pointer-events: none; 334 | cursor: not-allowed; 335 | } 336 | 337 | .mod-cta-inverted { 338 | background-color: var(--text-on-accent); 339 | --text-color: var(--interactive-accent); 340 | } 341 | 342 | .mod-warning-inverted { 343 | background-color: var(--text-on-accent); 344 | --text-color: var(--background-modifier-error); 345 | } 346 | 347 | .highlighterToggleOpacity { 348 | opacity: 0.5; 349 | } 350 | 351 | /* ##################### SELECTED HIGHLIGHTS #####################*/ 352 | .selectedHighlightsStylingsContainerHeader { 353 | padding-top: 10px; 354 | } 355 | 356 | .selectedHighlightsStylingsContainer { 357 | display: grid; 358 | grid-template-columns: 359 | [example] auto 360 | [color-picker] auto 361 | [spacer1] 5px 362 | [drop-down] auto 363 | [spacer2] 10px 364 | [save] auto 365 | [selected-discard-button] auto 366 | [spacer3] 10px [end]; 367 | grid-template-rows: [row] auto; 368 | gap: 10px; 369 | width: 100%; 370 | align-items: center; 371 | } 372 | 373 | .selectedHighlightsStylingsContainer .example { 374 | grid-column: example; 375 | text-align: left; 376 | width: 100%; 377 | } 378 | 379 | .selectedHighlightsStylingsContainer .selection-color-picker { 380 | grid-column: color-picker; 381 | } 382 | 383 | .selectedHighlightsStylingsContainer .choose-decoration-text { 384 | grid-column: choose_deco; 385 | text-align: left; 386 | } 387 | 388 | .selectedHighlightsStylingsContainer .decoration-dropdown { 389 | grid-column: drop-down; 390 | border: none; 391 | } 392 | 393 | .selectedHighlightsStylingsContainer .selected-save-button { 394 | grid-column: save; 395 | } 396 | 397 | .selectedHighlightsStylingsContainer .selected-discard-button { 398 | grid-column: selected-discard-button; 399 | } 400 | 401 | .selectedHighlightsStylingsContainer .selected-spacer { 402 | grid-column: spacer3; 403 | border: none; 404 | } 405 | 406 | /* ##################### BUTTONS AND STUFF I DON'T UNDERSTAND #####################*/ 407 | .dynamic-highlights-settings { 408 | position: relative; 409 | } 410 | 411 | .dynamic-highlights-settings button.action-button { 412 | display: grid; 413 | place-content: center; 414 | padding: 8px 18px; 415 | /* set this to the obsidian default to address theme devs messing with it 👀 */ 416 | margin-right: 12px; 417 | } 418 | 419 | .modal-warning-text { 420 | color: var(--text-error); /* Using Obsidian's built-in error color variable */ 421 | font-weight: bold; 422 | } 423 | 424 | .modal.mod-settings .dynamic-highlights-settings .pcr-button, 425 | .modal.mod-settings .dynamic-highlights-settings .color-wrapper { 426 | margin: 0; 427 | padding: 0; 428 | /* fixing a weird primary style that caused the picker to be oblong */ 429 | --scale-2-3: 0; 430 | --scale-2-8: 0; 431 | --scale-2-4: 0; 432 | } 433 | 434 | .modal.mod-settings 435 | .dynamic-highlights-settings 436 | .setting-item 437 | .pickr 438 | button.pcr-button { 439 | border-radius: 50px; 440 | overflow: hidden; 441 | border: 2px solid var(--background-modifier-border); 442 | height: 30px; 443 | width: 30px; 444 | } 445 | 446 | /* #region search term & save */ 447 | 448 | .title-container { 449 | margin-bottom: 1em; /* Adjust spacing below the title */ 450 | } 451 | 452 | /* #region mobile specific styles */ 453 | 454 | .is-mobile 455 | .dynamic-highlights-settings 456 | .setting-item-control 457 | button.action-button-save, 458 | .is-mobile .dynamic-highlights-settings .query-wrapper input { 459 | width: unset; 460 | } 461 | 462 | .is-mobile 463 | .dynamic-highlights-settings 464 | .setting-item-control 465 | button.action-button-discard, 466 | .is-mobile .dynamic-highlights-settings .query-wrapper input { 467 | width: unset; 468 | } 469 | 470 | .is-mobile 471 | .dynamic-highlights-settings 472 | .setting-item-control 473 | input.class-input { 474 | width: fit-content; 475 | } 476 | 477 | .is-mobile .dynamic-highlights-settings .query-wrapper { 478 | display: grid; 479 | grid-template-columns: 1fr auto auto; 480 | place-items: center; 481 | } 482 | 483 | .is-mobile .dynamic-highlights-settings .setting-item-control select, 484 | .is-mobile .dynamic-highlights-settings .setting-item-control input, 485 | .is-mobile .dynamic-highlights-settings .setting-item-control textarea, 486 | .is-mobile .dynamic-highlights-settings .setting-item-control button { 487 | width: 100%; 488 | margin: 0 4px; 489 | } 490 | 491 | /* #region pickr vendored styles */ 492 | .pickr { 493 | position: relative; 494 | overflow: visible; 495 | transform: translateY(0); 496 | } 497 | 498 | .pickr * { 499 | box-sizing: border-box; 500 | outline: none; 501 | border: none; 502 | -webkit-appearance: none; 503 | appearance: none; 504 | } 505 | 506 | .pickr .pcr-button { 507 | position: relative; 508 | height: 2em; 509 | width: 2em; 510 | padding: 0.5em; 511 | cursor: pointer; 512 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", 513 | "Helvetica Neue", Arial, sans-serif; 514 | border-radius: 0.15em; 515 | background: url('data:image/svg+xml;utf8, ') 516 | no-repeat center; 517 | background-size: 0; 518 | transition: all 0.3s; 519 | } 520 | 521 | .pickr .pcr-button::before { 522 | position: absolute; 523 | content: ""; 524 | top: 0; 525 | left: 0; 526 | width: 100%; 527 | height: 100%; 528 | background: url('data:image/svg+xml;utf8, '); 529 | background-size: 0.5em; 530 | border-radius: 0.15em; 531 | z-index: -1; 532 | } 533 | 534 | .pickr .pcr-button::before { 535 | z-index: initial; 536 | } 537 | 538 | .pickr .pcr-button::after { 539 | position: absolute; 540 | content: ""; 541 | top: 0; 542 | left: 0; 543 | height: 100%; 544 | width: 100%; 545 | transition: background 0.3s; 546 | background: var(--pcr-color); 547 | border-radius: 0.15em; 548 | } 549 | 550 | .pickr .pcr-button.clear { 551 | background-size: 70%; 552 | } 553 | 554 | .pickr .pcr-button.clear::before { 555 | opacity: 0; 556 | } 557 | 558 | .pickr .pcr-button.clear:focus { 559 | box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.85), 0 0 0 3px var(--pcr-color); 560 | } 561 | 562 | .pickr .pcr-button.disabled { 563 | cursor: not-allowed; 564 | } 565 | 566 | .pickr *, 567 | .pcr-app * { 568 | box-sizing: border-box; 569 | outline: none; 570 | border: none; 571 | -webkit-appearance: none; 572 | appearance: none; 573 | } 574 | 575 | .pickr input:focus, 576 | .pickr input.pcr-active, 577 | .pickr button:focus, 578 | .pickr button.pcr-active, 579 | .pcr-app input:focus, 580 | .pcr-app input.pcr-active, 581 | .pcr-app button:focus, 582 | .pcr-app button.pcr-active { 583 | box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.85), 0 0 0 3px var(--pcr-color); 584 | } 585 | 586 | .pickr .pcr-palette, 587 | .pickr .pcr-slider, 588 | .pcr-app .pcr-palette, 589 | .pcr-app .pcr-slider { 590 | transition: box-shadow 0.3s; 591 | } 592 | 593 | .pickr .pcr-palette:focus, 594 | .pickr .pcr-slider:focus, 595 | .pcr-app .pcr-palette:focus, 596 | .pcr-app .pcr-slider:focus { 597 | box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.85), 0 0 0 3px rgba(0, 0, 0, 0.25); 598 | } 599 | 600 | .pcr-app { 601 | position: fixed; 602 | display: flex; 603 | flex-direction: column; 604 | z-index: 10000; 605 | border-radius: 0.1em; 606 | background: #fff; 607 | opacity: 0; 608 | visibility: hidden; 609 | transition: opacity 0.3s, visibility 0s 0.3s; 610 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", 611 | "Helvetica Neue", Arial, sans-serif; 612 | box-shadow: 0 0.15em 1.5em 0 rgba(0, 0, 0, 0.1), 0 0 1em 0 rgba(0, 0, 0, 0.03); 613 | left: 0; 614 | top: 0; 615 | } 616 | 617 | .pcr-app.visible { 618 | transition: opacity 0.3s; 619 | visibility: visible; 620 | opacity: 1; 621 | } 622 | 623 | .pcr-app .pcr-swatches { 624 | display: flex; 625 | flex-wrap: wrap; 626 | margin-top: 0.75em; 627 | } 628 | 629 | .pcr-app .pcr-swatches.pcr-last { 630 | margin: 0; 631 | } 632 | 633 | @supports (display: grid) { 634 | .pcr-app .pcr-swatches { 635 | display: grid; 636 | align-items: center; 637 | grid-template-columns: repeat(auto-fit, 1.75em); 638 | } 639 | } 640 | 641 | .pcr-app .pcr-swatches > button { 642 | font-size: 1em; 643 | position: relative; 644 | width: calc(1.75em - 5px); 645 | height: calc(1.75em - 5px); 646 | border-radius: 0.15em; 647 | cursor: pointer; 648 | margin: 2.5px; 649 | flex-shrink: 0; 650 | justify-self: center; 651 | transition: all 0.15s; 652 | overflow: hidden; 653 | background: transparent; 654 | z-index: 1; 655 | } 656 | 657 | .pcr-app .pcr-swatches > button::before { 658 | position: absolute; 659 | content: ""; 660 | top: 0; 661 | left: 0; 662 | width: 100%; 663 | height: 100%; 664 | background: url('data:image/svg+xml;utf8, '); 665 | background-size: 6px; 666 | border-radius: 0.15em; 667 | z-index: -1; 668 | } 669 | 670 | .pcr-app .pcr-swatches > button::after { 671 | content: ""; 672 | position: absolute; 673 | top: 0; 674 | left: 0; 675 | width: 100%; 676 | height: 100%; 677 | background: var(--pcr-color); 678 | border: 1px solid rgba(0, 0, 0, 0.05); 679 | border-radius: 0.15em; 680 | box-sizing: border-box; 681 | } 682 | 683 | .pcr-app .pcr-swatches > button:hover { 684 | filter: brightness(1.05); 685 | } 686 | 687 | .pcr-app .pcr-swatches > button:not(.pcr-active) { 688 | box-shadow: none; 689 | } 690 | 691 | .pcr-app .pcr-interaction { 692 | display: flex; 693 | flex-wrap: wrap; 694 | align-items: center; 695 | margin: 0 -0.2em 0 -0.2em; 696 | } 697 | 698 | .pcr-app .pcr-interaction > * { 699 | margin: 0 0.2em; 700 | } 701 | 702 | .pcr-app .pcr-interaction input { 703 | letter-spacing: 0.07em; 704 | font-size: 0.75em; 705 | text-align: center; 706 | cursor: pointer; 707 | color: #75797e; 708 | background: #f1f3f4; 709 | border-radius: 0.15em; 710 | transition: all 0.15s; 711 | padding: 0.45em 0.5em; 712 | margin-top: 0.75em; 713 | } 714 | 715 | .pcr-app .pcr-interaction input:hover { 716 | filter: brightness(0.975); 717 | } 718 | 719 | .pcr-app .pcr-interaction input:focus { 720 | box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.85), 721 | 0 0 0 3px rgba(66, 133, 244, 0.75); 722 | } 723 | 724 | .pcr-app .pcr-interaction .pcr-result { 725 | color: #75797e; 726 | text-align: left; 727 | flex: 1 1 8em; 728 | min-width: 8em; 729 | transition: all 0.2s; 730 | border-radius: 0.15em; 731 | background: #f1f3f4; 732 | cursor: text; 733 | } 734 | 735 | .pcr-app .pcr-interaction .pcr-result::-moz-selection { 736 | background: #4285f4; 737 | color: #fff; 738 | } 739 | 740 | .pcr-app .pcr-interaction .pcr-result::selection { 741 | background: #4285f4; 742 | color: #fff; 743 | } 744 | 745 | .pcr-app .pcr-interaction .pcr-type.active { 746 | color: #fff; 747 | background: #4285f4; 748 | } 749 | 750 | .pcr-app .pcr-interaction .pcr-save, 751 | .pcr-app .pcr-interaction .pcr-cancel, 752 | .pcr-app .pcr-interaction .pcr-clear { 753 | color: #fff; 754 | width: auto; 755 | } 756 | 757 | .pcr-app .pcr-interaction .pcr-save, 758 | .pcr-app .pcr-interaction .pcr-cancel, 759 | .pcr-app .pcr-interaction .pcr-clear { 760 | color: #fff; 761 | } 762 | 763 | .pcr-app .pcr-interaction .pcr-save:hover, 764 | .pcr-app .pcr-interaction .pcr-cancel:hover, 765 | .pcr-app .pcr-interaction .pcr-clear:hover { 766 | filter: brightness(0.925); 767 | } 768 | 769 | .pcr-app .pcr-interaction .pcr-save { 770 | background: #4285f4; 771 | } 772 | 773 | .pcr-app .pcr-interaction .pcr-clear, 774 | .pcr-app .pcr-interaction .pcr-cancel { 775 | background: #f44250; 776 | } 777 | 778 | .pcr-app .pcr-interaction .pcr-clear:focus, 779 | .pcr-app .pcr-interaction .pcr-cancel:focus { 780 | box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.85), 781 | 0 0 0 3px rgba(244, 66, 80, 0.75); 782 | } 783 | 784 | .pcr-app .pcr-selection .pcr-picker { 785 | position: absolute; 786 | height: 18px; 787 | width: 18px; 788 | border: 2px solid #fff; 789 | border-radius: 100%; 790 | -webkit-user-select: none; 791 | -moz-user-select: none; 792 | -ms-user-select: none; 793 | user-select: none; 794 | } 795 | 796 | .pcr-app .pcr-selection .pcr-color-palette, 797 | .pcr-app .pcr-selection .pcr-color-chooser, 798 | .pcr-app .pcr-selection .pcr-color-opacity { 799 | position: relative; 800 | -webkit-user-select: none; 801 | -moz-user-select: none; 802 | -ms-user-select: none; 803 | user-select: none; 804 | display: flex; 805 | flex-direction: column; 806 | cursor: grab; 807 | cursor: -webkit-grab; 808 | } 809 | 810 | .pcr-app .pcr-selection .pcr-color-palette:active, 811 | .pcr-app .pcr-selection .pcr-color-chooser:active, 812 | .pcr-app .pcr-selection .pcr-color-opacity:active { 813 | cursor: grabbing; 814 | cursor: -webkit-grabbing; 815 | } 816 | 817 | .pcr-app[data-theme="nano"] { 818 | width: 14.25em; 819 | max-width: 95vw; 820 | } 821 | 822 | .pcr-app[data-theme="nano"] .pcr-swatches { 823 | margin-top: 0.6em; 824 | padding: 0 0.6em; 825 | } 826 | 827 | .pcr-app[data-theme="nano"] .pcr-interaction { 828 | padding: 0 0.6em 0.6em 0.6em; 829 | } 830 | 831 | .pcr-app[data-theme="nano"] .pcr-selection { 832 | display: grid; 833 | grid-gap: 0.6em; 834 | grid-template-columns: 1fr 4fr; 835 | grid-template-rows: 5fr auto auto; 836 | align-items: center; 837 | height: 10.5em; 838 | width: 100%; 839 | align-self: flex-start; 840 | } 841 | 842 | .pcr-app[data-theme="nano"] .pcr-selection .pcr-color-preview { 843 | grid-area: 2 / 1 / 4 / 1; 844 | height: 100%; 845 | width: 100%; 846 | display: flex; 847 | flex-direction: row; 848 | justify-content: center; 849 | margin-left: 0.6em; 850 | } 851 | 852 | .pcr-app[data-theme="nano"] .pcr-selection .pcr-color-preview .pcr-last-color { 853 | display: none; 854 | } 855 | 856 | .pcr-app[data-theme="nano"] 857 | .pcr-selection 858 | .pcr-color-preview 859 | .pcr-current-color { 860 | position: relative; 861 | background: var(--pcr-color); 862 | width: 2em; 863 | height: 2em; 864 | border-radius: 50em; 865 | overflow: hidden; 866 | } 867 | 868 | .pcr-app[data-theme="nano"] 869 | .pcr-selection 870 | .pcr-color-preview 871 | .pcr-current-color::before { 872 | position: absolute; 873 | content: ""; 874 | top: 0; 875 | left: 0; 876 | width: 100%; 877 | height: 100%; 878 | background: url('data:image/svg+xml;utf8, '); 879 | background-size: 0.5em; 880 | border-radius: 0.15em; 881 | z-index: -1; 882 | } 883 | 884 | .pcr-app[data-theme="nano"] .pcr-selection .pcr-color-palette { 885 | grid-area: 1 / 1 / 2 / 3; 886 | width: 100%; 887 | height: 100%; 888 | z-index: 1; 889 | } 890 | 891 | .pcr-app[data-theme="nano"] .pcr-selection .pcr-color-palette .pcr-palette { 892 | border-radius: 0.15em; 893 | width: 100%; 894 | height: 100%; 895 | } 896 | 897 | .pcr-app[data-theme="nano"] 898 | .pcr-selection 899 | .pcr-color-palette 900 | .pcr-palette::before { 901 | position: absolute; 902 | content: ""; 903 | top: 0; 904 | left: 0; 905 | width: 100%; 906 | height: 100%; 907 | background: url('data:image/svg+xml;utf8, '); 908 | background-size: 0.5em; 909 | border-radius: 0.15em; 910 | z-index: -1; 911 | } 912 | 913 | .pcr-app[data-theme="nano"] .pcr-selection .pcr-color-chooser { 914 | grid-area: 2 / 2 / 2 / 2; 915 | } 916 | 917 | .pcr-app[data-theme="nano"] .pcr-selection .pcr-color-opacity { 918 | grid-area: 3 / 2 / 3 / 2; 919 | } 920 | 921 | .pcr-app[data-theme="nano"] .pcr-selection .pcr-color-chooser, 922 | .pcr-app[data-theme="nano"] .pcr-selection .pcr-color-opacity { 923 | height: 0.5em; 924 | margin: 0 0.6em; 925 | } 926 | 927 | .pcr-app[data-theme="nano"] .pcr-selection .pcr-color-chooser .pcr-picker, 928 | .pcr-app[data-theme="nano"] .pcr-selection .pcr-color-opacity .pcr-picker { 929 | top: 50%; 930 | transform: translateY(-50%); 931 | } 932 | 933 | .pcr-app[data-theme="nano"] .pcr-selection .pcr-color-chooser .pcr-slider, 934 | .pcr-app[data-theme="nano"] .pcr-selection .pcr-color-opacity .pcr-slider { 935 | flex-grow: 1; 936 | border-radius: 50em; 937 | } 938 | 939 | .pcr-app[data-theme="nano"] .pcr-selection .pcr-color-chooser .pcr-slider { 940 | background: linear-gradient(to right, red, #ff0, lime, cyan, blue, #f0f, red); 941 | } 942 | 943 | .pcr-app[data-theme="nano"] .pcr-selection .pcr-color-opacity .pcr-slider { 944 | background: linear-gradient(to right, transparent, black), 945 | url('data:image/svg+xml;utf8, '); 946 | background-size: 100%, 0.25em; 947 | } 948 | 949 | /* #endregion pickr vendored styles */ 950 | 951 | /* ##################### MODALS #####################*/ 952 | .tag-modal-text { 953 | width: 400px; 954 | margin-top: -10px; 955 | margin-right: 10px; 956 | } 957 | 958 | .modal-content-grid { 959 | display: grid; 960 | grid: 961 | [row1-start] [modal-helper-text] 1fr [row1-end] 962 | [row2-start][inputEl] auto [spacer0] 5px [button1] auto [spacer1] 5px [button2] auto [spacer2] 10px [button3] auto [spacer3] 10px [selected-discard-button] auto [row2-end] 963 | / auto 50px auto; 964 | gap: 10px; 965 | width: 100%; 966 | align-items: center; 967 | } 968 | 969 | .modal-content-grid .helper-text { 970 | grid-column: modal-helper-text; 971 | margin-right: 10px; 972 | } 973 | 974 | .modal-content-grid .modal-inputEl { 975 | grid-column: inputEl; 976 | align-self: center; 977 | margin-left: 10px; 978 | } 979 | 980 | .modal-content-grid .modal-button1 { 981 | grid-column: button1; 982 | align-self: center; 983 | margin-right: 10px; 984 | margin-left: 10px; 985 | } 986 | 987 | .modal-content-grid .modal-button2 { 988 | grid-column: button2; 989 | align-self: center; 990 | margin-right: 10px; 991 | } 992 | 993 | .modal-content-grid .modal-button3 { 994 | grid-column: button2; 995 | align-self: center; 996 | margin-right: 10px; 997 | } 998 | 999 | /* #region mobile specific styles */ 1000 | .is-mobile 1001 | .dynamic-highlights-settings 1002 | .setting-item-control 1003 | button.action-button-save, 1004 | .is-mobile .dynamic-highlights-settings .query-wrapper input { 1005 | width: unset; 1006 | } 1007 | 1008 | .is-mobile 1009 | .dynamic-highlights-settings 1010 | .setting-item-control 1011 | button.action-button-discard, 1012 | .is-mobile .dynamic-highlights-settings .query-wrapper input { 1013 | width: unset; 1014 | } 1015 | 1016 | .is-mobile 1017 | .dynamic-highlights-settings 1018 | .setting-item-control 1019 | input.class-input { 1020 | width: fit-content; 1021 | } 1022 | 1023 | .is-mobile .dynamic-highlights-settings .query-wrapper { 1024 | display: grid; 1025 | grid-template-columns: 1fr auto auto; 1026 | place-items: center; 1027 | } 1028 | 1029 | .is-mobile .dynamic-highlights-settings .setting-item-control select, 1030 | .is-mobile .dynamic-highlights-settings .setting-item-control input, 1031 | .is-mobile .dynamic-highlights-settings .setting-item-control textarea, 1032 | .is-mobile .dynamic-highlights-settings .setting-item-control button { 1033 | width: 100%; 1034 | margin: 0 4px; 1035 | } 1036 | 1037 | /* #endregion mobile specific styles */ 1038 | 1039 | /* #region style settings import/export */ 1040 | 1041 | .modal-dynamic-highlights { 1042 | height: 70vh; 1043 | display: flex; 1044 | flex-direction: column; 1045 | } 1046 | 1047 | .modal-dynamic-highlights .modal-content { 1048 | flex-grow: 1; 1049 | margin: 0; 1050 | display: flex; 1051 | flex-direction: column; 1052 | } 1053 | 1054 | .modal-dynamic-highlights textarea { 1055 | display: block; 1056 | width: 100%; 1057 | height: 100%; 1058 | font-family: var(--font-monospace) !important; 1059 | font-size: 12px; 1060 | white-space: pre; 1061 | overflow-wrap: normal; 1062 | overflow-x: scroll; 1063 | margin-bottom: 5px; 1064 | } 1065 | 1066 | .modal-dynamic-highlights .setting-item { 1067 | align-items: flex-start; 1068 | } 1069 | 1070 | .modal-dynamic-highlights .setting-item .setting-item-control a { 1071 | margin-left: 4px; 1072 | } 1073 | 1074 | .modal-dynamic-highlights button { 1075 | margin: 10px 0 0; 1076 | } 1077 | 1078 | .modal-dynamic-highlights .style-settings-import-error { 1079 | display: none; 1080 | color: var(--text-error); 1081 | } 1082 | 1083 | .modal-dynamic-highlights .style-settings-import-error.active { 1084 | display: block; 1085 | } 1086 | 1087 | .dynamic-highlights-settings .highlighter-item-draggable { 1088 | cursor: initial; 1089 | display: grid; 1090 | grid-gap: 8px; 1091 | grid-template-columns: 0.2fr 0.5fr 7fr; 1092 | align-items: center; 1093 | border-top: 1px solid var(--background-modifier-border); 1094 | } 1095 | 1096 | .dynamic-highlights-settings .highlighter-setting-icon-drag { 1097 | cursor: grab; 1098 | } 1099 | 1100 | .dynamic-highlights-settings .highlighter-sortable-fallback { 1101 | cursor: grabbing; 1102 | box-shadow: 0px 3px 32px rgb(31 38 135 / 15%); 1103 | } 1104 | 1105 | .dynamic-highlights-settings .highlighter-sortable-grab { 1106 | cursor: grabbing; 1107 | } 1108 | 1109 | .dynamic-highlights-settings .highlighter-sortable-ghost { 1110 | opacity: 0.4; 1111 | cursor: grabbing; 1112 | } 1113 | 1114 | .dynamic-highlights-settings .highlighter-sortable-chosen { 1115 | cursor: grabbing; 1116 | padding: 0 0 0 18px; 1117 | background-color: var(--background-primary); 1118 | } 1119 | 1120 | .dynamic-highlights-settings .highlighter-sortable-drag { 1121 | cursor: grabbing; 1122 | box-shadow: 0px 3px 32px rgb(31 38 135 / 15%); 1123 | } 1124 | 1125 | /* ##################### ADDITIONAL STYLES #####################*/ 1126 | 1127 | /* Title and search related */ 1128 | .title-container { 1129 | margin-bottom: 1em; /* Adjust spacing below the title */ 1130 | } 1131 | 1132 | /* General button styles */ 1133 | .dynamic-highlights-settings button.action-button { 1134 | display: grid; 1135 | place-content: center; 1136 | padding: 8px 18px; 1137 | margin-right: 12px; 1138 | } 1139 | 1140 | /* Search term & save region */ 1141 | /* Any specific styles for search and save functionality */ 1142 | 1143 | /* General utility classes */ 1144 | .highlighterToggleOpacity { 1145 | opacity: 0.5; 1146 | } 1147 | 1148 | /* Drag and drop related */ 1149 | .highlighter-item-draggable { 1150 | cursor: grab; 1151 | } 1152 | 1153 | .highlighter-sortable-fallback { 1154 | opacity: 0; 1155 | } 1156 | 1157 | .highlighter-sortable-ghost { 1158 | opacity: 0.5; 1159 | } 1160 | 1161 | .highlighter-sortable-chosen { 1162 | background: var(--background-modifier-hover); 1163 | } 1164 | 1165 | .highlighter-sortable-drag { 1166 | opacity: 0.5; 1167 | } 1168 | -------------------------------------------------------------------------------- /src/settings/ui.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "@codemirror/view"; 2 | import { StyleSpec } from "style-mod"; 3 | import Pickr from "@simonwep/pickr"; 4 | import { createIcons, icons } from "lucide"; 5 | 6 | import { 7 | App, 8 | ButtonComponent, 9 | Notice, 10 | PluginSettingTab, 11 | Scope, 12 | setIcon, 13 | Setting, 14 | DropdownComponent, 15 | TextComponent, 16 | ToggleComponent, 17 | } from "obsidian"; 18 | import AnotherDynamicHighlightsPlugin from "../../main"; 19 | import * as Modals from "./modals"; 20 | import { ExportModal } from "./export"; 21 | import { ImportModal } from "./import"; 22 | import { markTypes } from "./settings"; 23 | 24 | export class SettingTab extends PluginSettingTab { 25 | plugin: AnotherDynamicHighlightsPlugin; 26 | editor: EditorView; 27 | scope: Scope; 28 | pickrInstance: Pickr; 29 | 30 | constructor(app: App, plugin: AnotherDynamicHighlightsPlugin) { 31 | super(app, plugin); 32 | this.plugin = plugin; 33 | this.scope = new Scope(app.scope); 34 | } 35 | 36 | hide() { 37 | this.editor?.destroy(); 38 | this.pickrInstance && this.pickrInstance.destroyAndRemove(); 39 | this.app.keymap.popScope(this.scope); 40 | } 41 | // Display the settings tab 42 | display(): void { 43 | this.app.keymap.pushScope(this.scope); 44 | const { containerEl } = this; 45 | containerEl.empty(); 46 | const config = this.plugin.settings.staticHighlighter; 47 | 48 | //############################################################## 49 | //######################### FUNCTIONS ###################### 50 | //############################################################## 51 | 52 | // Enable modals save and redraw the display 53 | const modalSaveAndReload = async () => { 54 | await this.plugin.saveSettings(); 55 | this.plugin.updateStaticHighlighter(); 56 | 57 | this.display(); // Refresh the UI after saving 58 | }; 59 | 60 | // Make color and dropdown choices into standard css snippets 61 | const snippetMaker = (deco: string, color: string) => { 62 | let cssSnippet; 63 | if (color == undefined) { 64 | color = "default"; 65 | } 66 | const resolveColor = () => 67 | color === "default" ? "var(--text-accent)" : color; 68 | if (deco == "background") { 69 | cssSnippet = `background-color: ${resolveColor()}`; 70 | } else if (deco == "background rounded") { 71 | cssSnippet = `position: relative; background-color: ${resolveColor()}; padding: 0.15em 0.25em; margin: 0; border-radius: 0.25em; box-decoration-break: clone; -webkit-box-decoration-break: clone; background-clip: padding-box; background-image: linear-gradient(to right, ${resolveColor()}dd, ${resolveColor()} 50%, ${resolveColor()}dd); box-shadow: inset 0 0 0 1px ${resolveColor()}22; display: inline;`; 72 | } else if (deco == "background realistic") { 73 | cssSnippet = `position: relative; background-color: ${resolveColor()}; padding: 0.15em 0.45em; margin: 0; border-radius: 0.7em 0.25em; box-decoration-break: clone; -webkit-box-decoration-break: clone; background-image: linear-gradient(135deg, ${resolveColor()} 0%, ${resolveColor()}ee 100%); background-clip: padding-box; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); text-shadow: 0 1px 2px var(--background-primary-alt); display: inline`; 74 | } else if (deco == "underline lowlight") { 75 | cssSnippet = `position: relative; background-color: transparent; padding: 0.125em 0.125em; border-radius: 0; background-image: linear-gradient(360deg, ${resolveColor()} 40%, transparent 40%)`; 76 | } else if (deco == "underline floating thick") { 77 | cssSnippet = `position: relative; background-color: transparent; padding: 0.125em; padding-bottom: 5px; border-radius: 0; background-image: linear-gradient(360deg, ${resolveColor()} 25%, transparent 25%)`; 78 | } else if (deco == "underline floating thin") { 79 | cssSnippet = `position: relative; background-color: transparent; padding: 0.125em; padding-bottom: 5px; border-radius: 0; background-image: linear-gradient(360deg, ${resolveColor()} 15%, transparent 15%)`; 80 | } else if (deco == "color") { 81 | cssSnippet = `font-weight: 400; color: ${resolveColor()}`; 82 | } else if (deco == "bold") { 83 | cssSnippet = `font-weight: 600; color: ${resolveColor()}`; 84 | } else if (deco == "underline wavy") { 85 | cssSnippet = `text-decoration: underline; -webkit-text-decoration-color: ${resolveColor()}; text-decoration-color: ${resolveColor()}; -webkit-text-decoration-style: wavy; text-decoration-style: wavy;`; 86 | } else if (deco === "border solid") { 87 | cssSnippet = `border: 1px solid ${resolveColor()}; border-radius: 5px; padding: 1px; padding-bottom: 2px`; 88 | } else if (deco === "border dashed") { 89 | cssSnippet = `border: 1px dashed ${resolveColor()}; border-radius: 5px; padding: 1px; padding-bottom: 2px`; 90 | } else if (deco === "line-through") { 91 | cssSnippet = `text-decoration: line-through; text-decoration-thickness: 2px; text-decoration-color: ${resolveColor()}`; 92 | } else { 93 | cssSnippet = `text-decoration: ${deco}; text-decoration-color: ${resolveColor()}`; 94 | } 95 | return cssSnippet; 96 | }; 97 | 98 | // sort highlighters and tags alphabetically 99 | const sortAlphabetically = async () => { 100 | const { queryOrder, queries } = this.plugin.settings.staticHighlighter; 101 | 102 | // 1. Get unique tags and sort them 103 | const tagList = [ 104 | ...new Set(queryOrder.map((h) => queries[h].tag)), 105 | ].sort(); 106 | 107 | // 2. Create a map of tags to their highlighters 108 | const tagToHighlighters = tagList.reduce((acc, tag) => { 109 | // Get all highlighters for this tag and sort them alphabetically 110 | acc[tag] = queryOrder 111 | .filter((highlighter) => queries[highlighter].tag === tag) 112 | .sort(); 113 | return acc; 114 | }, {} as Record); 115 | 116 | // 3. Create the final sorted array by concatenating sorted highlighters from each sorted tag 117 | const sortedQueryOrder = tagList.flatMap((tag) => tagToHighlighters[tag]); 118 | 119 | // 4. Update the settings 120 | this.plugin.settings.staticHighlighter.queryOrder = sortedQueryOrder; 121 | await this.plugin.saveSettings(); 122 | this.display(); 123 | }; 124 | 125 | const getAccentColor = (alpha = 0.25): string => { 126 | const hsl = getComputedStyle(document.body) 127 | .getPropertyValue("--text-accent") 128 | .trim(); 129 | return hsl.replace("hsl", "hsla").replace(")", `, ${alpha})`); 130 | }; 131 | 132 | // Usage 133 | const hslaColor = getAccentColor(0.25); 134 | 135 | //############################################################## 136 | //######################### UI ############################# 137 | //############################################################## 138 | 139 | containerEl.addClass("persistent-highlights"); 140 | containerEl.addClass("dynamic-highlights-settings"); 141 | 142 | // Import/Export buttons 143 | const importExportEl = containerEl.createDiv("import-export-wrapper"); 144 | importExportEl.createEl( 145 | "a", 146 | { 147 | cls: "dynamic-highlighter-import-export import-link", 148 | text: "Import ", 149 | href: "#", 150 | }, 151 | (el) => { 152 | el.addEventListener("click", (e) => { 153 | e.preventDefault(); 154 | new ImportModal(this.plugin.app, this.plugin).open(); 155 | }); 156 | } 157 | ); 158 | importExportEl.createEl( 159 | "a", 160 | { 161 | cls: "dynamic-highlighter-import-export export-link", 162 | text: " Export", 163 | href: "#", 164 | }, 165 | (el) => { 166 | el.addEventListener("click", (e) => { 167 | e.preventDefault(); 168 | new ExportModal( 169 | this.plugin.app, 170 | this.plugin, 171 | "All", 172 | config.queries 173 | ).open(); 174 | }); 175 | } 176 | ); 177 | 178 | const headlineAndToggle = new Setting(containerEl) 179 | .setHeading() 180 | .setName("Persistent highlights") 181 | // On/Off-Switch that starts/stops all highlighting 182 | .addToggle((headlineToggle) => { 183 | headlineToggle.setTooltip( 184 | config.onOffSwitch 185 | ? `Deactivate highlighing` 186 | : `Activate highlighting` 187 | ); 188 | headlineToggle.toggleEl.addClass("headline-toggle"); 189 | headlineToggle.setValue(config.onOffSwitch).onChange((value) => { 190 | config.onOffSwitch = value; 191 | this.plugin.saveSettings(); 192 | this.plugin.updateStaticHighlighter(); 193 | this.display(); 194 | headlineToggle.setTooltip( 195 | value ? `Highlighting active` : `Highlighting inactive` 196 | ); 197 | }); 198 | }); 199 | headlineAndToggle.settingEl.addClass("headline-and-toggle"); 200 | 201 | //############################################################## 202 | //############## Persistent highlighters def ############## 203 | //############################################################## 204 | 205 | // Setting up variables vor the Persistent Highlighters Scope 206 | let staticDecorationValue: string = "background"; 207 | let staticDecorationDropdown: Setting; 208 | let staticDecorationDropdownComponent: DropdownComponent; 209 | let tagDropdownComponent: DropdownComponent; 210 | let tagName: string; 211 | let tagStatus: boolean; 212 | let selectionDecorationDropdownComponent: DropdownComponent; 213 | 214 | const defineQueryUI = new Setting(containerEl); 215 | defineQueryUI 216 | .setName("Define persistent highlighters") 217 | .setClass("define-query-ui"); 218 | 219 | const defineQueryUITop = new Setting(defineQueryUI.controlEl); 220 | 221 | // Input field for the highlighter name 222 | const queryNameInput = new TextComponent(defineQueryUITop.controlEl); 223 | queryNameInput.inputEl.addClass("query-name-input"); 224 | queryNameInput.setPlaceholder("Highlighter name"); 225 | queryNameInput.inputEl.setAttribute("aria-label", "Highlighter name"); 226 | if (!queryNameInput.inputEl.classList.contains("has-tooltip")) { 227 | queryNameInput.inputEl.classList.add("has-tooltip"); 228 | } 229 | 230 | // Color picker 231 | const colorButtonWrapper = defineQueryUITop.controlEl.createDiv( 232 | "color-button-wrapper" 233 | ); 234 | let staticPickrInstance: Pickr; 235 | const colorPicker = new ButtonComponent(colorButtonWrapper); 236 | colorPicker.setClass("color-button-wrapper").then(() => { 237 | this.pickrInstance = staticPickrInstance = new Pickr({ 238 | el: colorPicker.buttonEl, 239 | container: colorButtonWrapper, 240 | theme: "nano", 241 | defaultRepresentation: "HEXA", 242 | default: hslaColor, 243 | comparison: false, 244 | components: { 245 | preview: true, 246 | opacity: true, 247 | hue: true, 248 | interaction: { 249 | hex: true, 250 | rgba: false, 251 | hsla: true, 252 | hsva: false, 253 | cmyk: false, 254 | input: true, 255 | clear: true, 256 | cancel: true, 257 | save: true, 258 | }, 259 | }, 260 | }); 261 | // Make the button 262 | const button = colorButtonWrapper.querySelector(".pcr-button"); 263 | if (!button) { 264 | throw new Error("Button is null (see ui.ts)"); 265 | } 266 | button.ariaLabel = "Decoration color picker"; 267 | 268 | // Picker functionality 269 | staticPickrInstance 270 | .on("clear", (instance: Pickr) => { 271 | instance.hide(); 272 | queryInput.inputEl.setAttribute( 273 | "style", 274 | snippetMaker(staticDecorationValue, "default") 275 | ); 276 | }) 277 | .on("cancel", (instance: Pickr) => { 278 | instance.hide(); 279 | }) 280 | .on("change", (color: Pickr.HSVaColor) => { 281 | const colorHex = color?.toHEXA().toString() || ""; 282 | let newColor; 283 | colorHex && colorHex.length == 6 284 | ? (newColor = `${colorHex}A6`) 285 | : (newColor = colorHex); 286 | queryInput.inputEl.setAttribute( 287 | "style", 288 | snippetMaker(staticDecorationValue, newColor) 289 | ); 290 | }) 291 | .on("save", (color: Pickr.HSVaColor, instance: Pickr) => { 292 | instance.hide(); 293 | }); 294 | }); 295 | 296 | // Query input field (query = highlighter) 297 | const queryInput = new TextComponent(defineQueryUITop.controlEl); 298 | queryInput.setPlaceholder("Search term"); 299 | queryInput.inputEl.setAttribute("aria-label", "Search term"); 300 | if (!queryNameInput.inputEl.classList.contains("has-tooltip")) { 301 | queryNameInput.inputEl.classList.add("has-tooltip"); 302 | } 303 | queryInput.inputEl.addClass("query-input"); 304 | 305 | // Tag dropdown 306 | const tagDropdownWrapper = defineQueryUITop.controlEl.createDiv( 307 | "tag-dropdown-wrapper" 308 | ); 309 | const tagDropdown = new Setting(tagDropdownWrapper); 310 | tagDropdown.setClass("tag-dropdown").addDropdown((dropdown) => { 311 | tagDropdownComponent = dropdown; 312 | dropdown.addOption("#unsorted", "#unsorted"); 313 | dropdown.selectEl.setAttribute( 314 | "aria-label", 315 | "Select a tag for your highlighter" 316 | ); 317 | if (!dropdown.selectEl.classList.contains("has-tooltip")) { 318 | dropdown.selectEl.classList.add("has-tooltip"); 319 | } 320 | // Make a set to add each tag only once 321 | const uniqueTags = new Set(); 322 | Object.keys(config.queries).forEach((highlighter) => { 323 | const tagging = config.queries[highlighter].tag; 324 | // get tagStatus as well... 325 | const taggingStatus = config.queries[highlighter].tagEnabled; 326 | if (tagging && !uniqueTags.has(tagging)) { 327 | uniqueTags.add(tagging); 328 | // Here's the .addOption part 329 | tagDropdownComponent.addOption(tagging, tagging); 330 | } 331 | tagDropdownComponent.onChange((value) => { 332 | tagName = value; 333 | // ... to keep it consistent between old and new highlighters 334 | tagStatus = taggingStatus; 335 | }); 336 | }); 337 | // Access to the Create new tag modal and handing over of arguments 338 | tagDropdownComponent.addOption("create-new", "Create new tag"); 339 | tagDropdownComponent.onChange(async (value) => { 340 | if (value === "create-new") { 341 | const createNewTag = new Modals.NewTagModal( 342 | this.app, 343 | tagDropdownComponent, 344 | tagName, 345 | expandedTags 346 | ); 347 | createNewTag.open(); 348 | } 349 | }); 350 | }); 351 | 352 | // Array that holds all expanded Tags to persist the states 353 | // This has to be her because of the scope 354 | let expandedTags: string[]; 355 | // Initialise array without wiping saved data 356 | if (this.plugin.settings.staticHighlighter.expandedTags) { 357 | expandedTags = this.plugin.settings.staticHighlighter.expandedTags; 358 | } else { 359 | expandedTags = []; 360 | } 361 | //############## BOTTOM ROW #################################### 362 | const defineQueryUIBottom = new Setting(defineQueryUI.controlEl); 363 | defineQueryUIBottom.setClass("define-query-ui-bottom-container"); 364 | 365 | // Input field for the highlighter name 366 | 367 | // Tag dropdown 368 | const staticDecorationDropdownWrapper = 369 | defineQueryUIBottom.controlEl.createDiv("deco-dropdown"); 370 | 371 | staticDecorationDropdown = new Setting(staticDecorationDropdownWrapper); 372 | staticDecorationDropdown 373 | .setClass("deco-dropdown") 374 | .addDropdown((dropdown) => { 375 | staticDecorationDropdownComponent = dropdown; 376 | dropdown.selectEl.setAttribute( 377 | "aria-label", 378 | "Select a decoration style for your highlighter" 379 | ); 380 | if (!dropdown.selectEl.classList.contains("has-tooltip")) { 381 | dropdown.selectEl.classList.add("has-tooltip"); 382 | } 383 | dropdown 384 | .addOption("background", "Background square") 385 | .addOption("background rounded", "--- rounded") 386 | .addOption("background realistic", "--- realistic") 387 | .addOption("underline", "Underline solid") 388 | .addOption("underline lowlight", "--- lowlight") 389 | .addOption("underline floating thick", "--- floating thick") 390 | .addOption("underline floating thin", "--- floating thin") 391 | .addOption("underline dotted", "--- dotted") 392 | .addOption("underline dashed", "--- dashed") 393 | .addOption("underline wavy", "--- wavy") 394 | .addOption("border solid", "Border solid") 395 | .addOption("border dashed", "--- dashed") 396 | .addOption("color", "Colored text normal") 397 | .addOption("bold", "--- bold") 398 | .addOption("line-through", "Strikethrough") 399 | .setValue("background") 400 | .onChange((value) => { 401 | staticDecorationValue = value; 402 | let color = staticPickrInstance 403 | .getSelectedColor() 404 | ?.toHEXA() 405 | .toString(); 406 | queryInput.inputEl.setAttribute( 407 | "style", 408 | snippetMaker(staticDecorationValue, color) 409 | ); 410 | }); 411 | }); 412 | 413 | // RegEx toggle 414 | defineQueryUIBottom.controlEl.createSpan("regex-text").setText("regEx"); 415 | const regexToggle = new ToggleComponent( 416 | defineQueryUIBottom.controlEl 417 | ).setValue(false); 418 | regexToggle.setTooltip("Activate RegEx"); 419 | regexToggle.toggleEl.addClass("regex-toggle"); 420 | regexToggle.onChange((value) => { 421 | if (value) { 422 | queryInput.setPlaceholder("Search expression"); 423 | regexToggle.setTooltip("Deactivate RegEx"); 424 | } else { 425 | queryInput.setPlaceholder("Search term"); 426 | regexToggle.setTooltip("Activate RegEx"); 427 | } 428 | }); 429 | 430 | // "match" toggle, to decorate matched characters 431 | defineQueryUIBottom.controlEl.createSpan("matches-text").setText("matches"); 432 | const matchToggle = new ToggleComponent( 433 | defineQueryUIBottom.controlEl 434 | ).setValue(true); 435 | matchToggle.setTooltip("Deactivate highlighting matches"); 436 | matchToggle.toggleEl.addClass("matches-toggle"); 437 | 438 | matchToggle.onChange((value) => { 439 | let matchBool: boolean = value; 440 | matchToggle.setTooltip( 441 | value 442 | ? "Deactivate highlighting matches" 443 | : "Activate highlighting matches" 444 | ); 445 | }); 446 | 447 | // "line" toggle, to decorate the entire parent line 448 | defineQueryUIBottom.controlEl.createSpan("line-text").setText("lines"); 449 | const lineToggle = new ToggleComponent( 450 | defineQueryUIBottom.controlEl 451 | ).setValue(false); 452 | lineToggle.setTooltip("Activate higlighting of parent line"); 453 | lineToggle.toggleEl.addClass("line-toggle"); 454 | lineToggle.onChange((value) => { 455 | let lineBool: boolean = value; 456 | lineToggle.setTooltip( 457 | value 458 | ? "Deactivate higlighting of parent line" 459 | : "Activate higlighting of parent line" 460 | ); 461 | }); 462 | 463 | // "groups" toggle, to highlight regex capture groups instead of whole match 464 | defineQueryUIBottom.controlEl.createSpan("groups-text").setText("groups"); 465 | const groupsToggle = new ToggleComponent( 466 | defineQueryUIBottom.controlEl 467 | ).setValue(false); 468 | groupsToggle.setTooltip("Highlight regex capture groups"); 469 | groupsToggle.toggleEl.addClass("groups-toggle"); 470 | groupsToggle.onChange((active) => { 471 | regexToggle.setValue(active || regexToggle.getValue()); 472 | groupsToggle.setTooltip(`${active ? "Deactivate" : "Activate"} regex capture group highlighting`); 473 | if (active && !queryInput.getValue().contains("(")) { 474 | new Notice("Your regex does not contain any capture groups"); 475 | } 476 | }); 477 | 478 | // The save Button 479 | // helper variable stores highlighter to enable changing its other settings 480 | let currentHighlighterName: string | null = null; 481 | const saveButton = new ButtonComponent(defineQueryUITop.controlEl); 482 | saveButton.buttonEl.setAttribute("state", "creating"); 483 | saveButton 484 | .setClass("save-button") 485 | .setClass("action-button") 486 | .setClass("action-button-save") 487 | .setCta() 488 | .setIcon("save") 489 | .setTooltip("Save") 490 | .onClick(async (buttonEl: MouseEvent) => { 491 | // Get state (creating/editing) to circumvent duplication block when editing 492 | const state = saveButton.buttonEl.getAttribute("state"); 493 | const previousHighlighterName = queryNameInput.inputEl.dataset.original; 494 | currentHighlighterName = queryNameInput.inputEl.value.trim(); 495 | 496 | // Delete old highlighter when editing 497 | if ( 498 | state === "editing" && 499 | previousHighlighterName && 500 | previousHighlighterName !== currentHighlighterName 501 | ) { 502 | // Remove the entry for the original highlighter name 503 | delete config.queries[previousHighlighterName]; 504 | // Update queryOrder 505 | const index = config.queryOrder.indexOf(previousHighlighterName); 506 | if (index !== -1) { 507 | config.queryOrder[index] = currentHighlighterName; 508 | } 509 | } 510 | 511 | // Get all the values and do the variable name dance 512 | // so that stuff acutally gets saved 513 | let enabledMarksMaker = () => { 514 | let enabledMarks: markTypes[] = []; 515 | if (matchToggle.getValue() && !enabledMarks.includes("match")) { 516 | enabledMarks.push("match"); 517 | } else { 518 | enabledMarks = enabledMarks.filter((value) => value != "match"); 519 | } 520 | if (lineToggle.getValue() && !enabledMarks.includes("line")) { 521 | enabledMarks.push("line"); 522 | } else { 523 | enabledMarks = enabledMarks.filter((value) => value != "line"); 524 | } 525 | if (groupsToggle.getValue() && !enabledMarks.includes("groups")) { 526 | enabledMarks.push("groups"); 527 | } else { 528 | enabledMarks = enabledMarks.filter((value) => value != "groups"); 529 | } 530 | return enabledMarks; 531 | }; 532 | const staticHexValue = staticPickrInstance 533 | .getSelectedColor() 534 | ?.toHEXA() 535 | .toString(); 536 | const queryValue = queryInput.inputEl.value; 537 | const queryTypeValue = regexToggle.getValue(); 538 | const tagNameValue = tagDropdownComponent.getValue(); 539 | let tagStatusValue = tagStatus; 540 | if (tagStatusValue == undefined) { 541 | tagStatusValue = true; 542 | } 543 | if (!expandedTags.includes(tagNameValue)) { 544 | expandedTags.push(tagNameValue); 545 | } 546 | 547 | // If creating, check if the class name already exists 548 | if (currentHighlighterName) { 549 | if (state == "creating") { 550 | if (!config.queryOrder.includes(currentHighlighterName)) { 551 | config.queryOrder.unshift(currentHighlighterName); 552 | } else { 553 | new Notice("Highlighter name already exists"); 554 | return; 555 | } 556 | } 557 | // Logic for the static css snippet, which can't be a standard css snippet 558 | // because it's implemented as StyleSpec in static.css 559 | let staticCssSnippet: StyleSpec = {}; 560 | const resolveColor02 = () => 561 | staticHexValue === "default" 562 | ? "var(--text-accent)" 563 | : staticHexValue; 564 | if (staticDecorationValue === "background") { 565 | staticCssSnippet = { 566 | backgroundColor: resolveColor02(), 567 | }; 568 | } else if (staticDecorationValue === "background rounded") { 569 | staticCssSnippet = { 570 | position: "relative", 571 | backgroundColor: `${resolveColor02()}`, 572 | padding: "0.15em 0.25em", 573 | margin: "0", 574 | borderRadius: "0.25em", 575 | boxDecorationBreak: "clone", 576 | WebkitBoxDecorationBreak: "clone", 577 | backgroundClip: "padding-box", 578 | backgroundImage: `linear-gradient(to right, ${resolveColor02()}dd, ${resolveColor02()} 50%, ${resolveColor02()}dd)`, 579 | boxShadow: `inset 0 0 0 1px ${resolveColor02()}22`, 580 | display: "inline", 581 | }; 582 | } else if (staticDecorationValue === "background realistic") { 583 | staticCssSnippet = { 584 | position: "relative", 585 | backgroundColor: `${resolveColor02()}`, // set base color 586 | padding: "0.15em 0.45em", 587 | margin: "0", 588 | borderRadius: "0.7em 0.25em", 589 | boxDecorationBreak: "clone", 590 | WebkitBoxDecorationBreak: "clone", 591 | backgroundImage: `linear-gradient(135deg, ${resolveColor02()} 0%, ${resolveColor02()}ee 100%)`, 592 | backgroundClip: "padding-box", 593 | boxShadow: "0 1px 3px rgba(0, 0, 0, 0.1)", 594 | textShadow: "0 1px 2px var(--background-primary-alt)", 595 | display: "inline", 596 | }; 597 | } else if (staticDecorationValue === "underline lowlight") { 598 | staticCssSnippet = { 599 | position: "relative", 600 | backgroundColor: "transparent", 601 | padding: ".125em .125em", 602 | borderRadius: "0", 603 | backgroundImage: `linear-gradient( 604 | 360deg, 605 | ${resolveColor02()} 40%, 606 | transparent 40% 607 | )`, 608 | }; 609 | } else if (staticDecorationValue === "underline floating thick") { 610 | staticCssSnippet = { 611 | position: "relative", 612 | backgroundColor: "transparent", 613 | padding: ".125em", 614 | paddingBottom: "5px", 615 | borderRadius: "0", 616 | backgroundImage: `linear-gradient( 617 | 360deg, 618 | ${resolveColor02()} 25%, 619 | transparent 25% 620 | )`, 621 | }; 622 | } else if (staticDecorationValue === "underline floating thin") { 623 | staticCssSnippet = { 624 | position: "relative", 625 | backgroundColor: "transparent", 626 | padding: ".125em", 627 | paddingBottom: "5px", 628 | borderRadius: "0", 629 | backgroundImage: `linear-gradient( 630 | 360deg, 631 | ${resolveColor02()} 15%, 632 | transparent 15% 633 | )`, 634 | }; 635 | } else if (staticDecorationValue === "color") { 636 | staticCssSnippet = { 637 | fontWeight: "400", 638 | color: resolveColor02(), 639 | }; 640 | } else if (staticDecorationValue === "bold") { 641 | staticCssSnippet = { 642 | fontWeight: "600", 643 | color: resolveColor02(), 644 | }; 645 | } else if (staticDecorationValue === "underline wavy") { 646 | staticCssSnippet = { 647 | textDecoration: "underline", 648 | webkitTextDecorationColor: resolveColor02(), 649 | textDecorationColor: resolveColor02(), 650 | webkitTextDecorationStyle: "wavy", 651 | textDecorationStyle: "wavy", 652 | }; 653 | } else if (staticDecorationValue === "border solid") { 654 | staticCssSnippet = { 655 | border: `1px solid ${resolveColor02()}`, 656 | borderRadius: "5px", 657 | padding: "1px", 658 | paddingBottom: "2px", 659 | }; 660 | } else if (staticDecorationValue === "border dashed") { 661 | staticCssSnippet = { 662 | border: `1px dashed ${resolveColor02()}`, 663 | borderRadius: "5px", 664 | padding: "1px", 665 | paddingBottom: "2px", 666 | }; 667 | } else if (staticDecorationValue === "line-through") { 668 | staticCssSnippet = { 669 | textDecoration: "line-through", 670 | textDecorationThickness: "2px", 671 | textDecorationColor: resolveColor02(), 672 | }; 673 | } else { 674 | staticCssSnippet = { 675 | textDecoration: staticDecorationValue, 676 | textDecorationColor: resolveColor02(), 677 | }; 678 | } 679 | 680 | // Make a standard snippet for the cool icon next to the highlighters 681 | let makecolorIconSnippet: string = snippetMaker( 682 | staticDecorationValue, 683 | staticHexValue 684 | ); 685 | // Gather all the stuff we need to make our highlighter 686 | config.queries[currentHighlighterName] = { 687 | class: currentHighlighterName, // the name 688 | staticColor: staticHexValue || hslaColor, // the color 689 | staticDecoration: staticDecorationValue, // the deco 690 | staticCss: staticCssSnippet, // the deco css snippet 691 | colorIconSnippet: makecolorIconSnippet, // the icon snippet 692 | regex: queryTypeValue, // the regex 693 | query: queryValue, // the search term/expression 694 | mark: enabledMarksMaker(), // the marks 695 | highlighterEnabled: true, // the enabled state of the highlighter 696 | tag: tagNameValue, // the tag name 697 | tagEnabled: tagStatusValue, // if the tag is enabled 698 | }; 699 | // Save and redraw the display 700 | await this.plugin.saveSettings(); 701 | this.plugin.registerCommands(); 702 | this.plugin.updateStaticHighlighter(); 703 | this.plugin.updateStyles(); 704 | this.display(); 705 | // and put the saveButton in creating mode 706 | saveButton.buttonEl.setAttribute("state", "creating"); 707 | // or complain if something isn't right 708 | } else if (!currentHighlighterName && staticHexValue) { 709 | new Notice("Highlighter name missing"); 710 | } else if ( 711 | !/^-?[_a-zA-Z]+[_a-zA-Z0-9-]*$/.test(currentHighlighterName) 712 | ) { 713 | new Notice("Highlighter name missing"); 714 | } else { 715 | new Notice("Highlighter values missing"); 716 | } 717 | }); 718 | 719 | // The discard button 720 | const discardButton = new ButtonComponent(defineQueryUITop.controlEl); 721 | discardButton 722 | .setClass("discard-button") 723 | .setClass("action-button") 724 | .setClass("action-button-discard") 725 | .setCta() 726 | .setIcon("x-circle") 727 | .setTooltip("Discard Changes") 728 | .onClick(() => { 729 | const state = saveButton.buttonEl.getAttribute("state"); 730 | 731 | if (state === "editing") { 732 | // Reset to original values 733 | if (currentHighlighterName != null) { 734 | const options = config.queries[currentHighlighterName]; 735 | queryNameInput.inputEl.value = currentHighlighterName; 736 | staticPickrInstance.setColor(options.staticColor); 737 | queryInput.inputEl.value = options.query; 738 | tagDropdownComponent.setValue(options.tag); 739 | regexToggle.setValue(options.regex); 740 | new Notice("Changes discarded"); 741 | } else { 742 | // Clear all fields in "creating" mode 743 | queryNameInput.inputEl.value = ""; 744 | queryInput.inputEl.value = ""; 745 | // Keep things in this order is so that queryNameInput field ends up clear 746 | staticPickrInstance.setColor(hslaColor); 747 | queryNameInput.inputEl.setAttribute( 748 | "style", 749 | `background-color: none; color: var(--text-normal);` 750 | ); 751 | staticPickrInstance.hide(); 752 | regexToggle.setValue(false); 753 | new Notice("Form cleared"); 754 | } 755 | 756 | // Reset saveButton state to creating 757 | saveButton.buttonEl.setAttribute("state", "creating"); 758 | } 759 | }); 760 | 761 | // ############################################################################################# 762 | // ################################ HIGHTLIGHERS DISPLAY ####################################### 763 | // ############################################################################################# 764 | const highlightersContainer = containerEl.createEl("div", { 765 | cls: "highlighter-container", 766 | }); 767 | 768 | const highlightersSetting = new Setting(highlightersContainer) 769 | .setName("Your highlighters and tags") 770 | .setHeading() 771 | .addButton((button) => { 772 | button 773 | .setClass("sort-button") 774 | .setTooltip("Sort a to z") 775 | .onClick(() => { 776 | sortAlphabetically(); 777 | }); 778 | 779 | // Create a container for the icon inside the button 780 | const iconContainer = button.buttonEl.createEl("span"); 781 | iconContainer.setAttribute("data-lucide", "arrow-down-a-z"); 782 | createIcons({ icons }); 783 | }); 784 | 785 | // ##################### Here come the tags with their buttons ################################################ 786 | const tagContainers: { [key: string]: HTMLElement } = {}; 787 | // This makes the highlighter display 788 | this.plugin.settings.staticHighlighter.queryOrder.forEach((highlighter) => { 789 | const queryConfig = config.queries[highlighter]; 790 | if (queryConfig) { 791 | const { query, regex, tag } = queryConfig; 792 | 793 | if (!tagContainers[tag]) { 794 | const tagContainer = highlightersContainer.createEl("div", { 795 | cls: "tag-container", 796 | }); 797 | const tagHeader = tagContainer.createEl("div", { 798 | cls: "tag-header", 799 | }); 800 | 801 | // expand/collapse functionality and UX 802 | // make the clickable icon 803 | const toggleIcon = tagHeader.createEl("div", { 804 | cls: "toggle-icon", 805 | }); 806 | toggleIcon.addClass("tag-icon"); 807 | // Make the tag name clickable as well 808 | let tagName = tagHeader.createSpan("tag-name"); 809 | tagName.setText(tag); 810 | 811 | // make a container for the highlighters 812 | let highlightersList = tagContainer.createEl("div", { 813 | cls: "highlighters-list", 814 | }); 815 | // Do this, which I don't quite get, but it works, so... 816 | tagContainers[tag] = highlightersList; 817 | 818 | // set the appropriate expand/collapse icon 819 | if (expandedTags.includes(tag)) { 820 | setIcon(toggleIcon, "chevron-down"); 821 | highlightersList.removeClass("highlighters-list-collapsed"); 822 | highlightersList.addClass("highlighters-list-expanded"); 823 | } else { 824 | setIcon(toggleIcon, "chevron-right"); 825 | highlightersList.removeClass("highlighters-list-expanded"); 826 | highlightersList.addClass("highlighters-list-collapsed"); 827 | } 828 | 829 | const tagExpandToggle = () => { 830 | // toggle to collapsed 831 | if (expandedTags.includes(tag)) { 832 | setIcon(toggleIcon, "chevron-right"); 833 | highlightersList.removeClass("highlighters-list-expanded"); 834 | highlightersList.addClass("highlighters-list-collapsed"); 835 | expandedTags = expandedTags.filter((entry) => entry != tag); 836 | } else { 837 | // toggle to expanded 838 | setIcon(toggleIcon, "chevron-down"); 839 | highlightersList.removeClass("highlighters-list-collapsed"); 840 | highlightersList.addClass("highlighters-list-expanded"); 841 | expandedTags.unshift(tag); 842 | } 843 | // Save settings after the toggle 844 | this.plugin.settings.staticHighlighter.expandedTags = expandedTags; 845 | this.plugin.saveSettings(); 846 | }; 847 | tagName.onclick = () => { 848 | tagExpandToggle(); 849 | }; 850 | toggleIcon.onclick = () => { 851 | tagExpandToggle(); 852 | }; 853 | // Create the toggle for enabling/disabling the tag 854 | const tagButtons = new Setting(tagHeader).setClass( 855 | "tag-header-buttons" 856 | ); 857 | tagButtons.addToggle((tagToggle) => { 858 | tagToggle.setTooltip( 859 | config.queries[highlighter].tagEnabled 860 | ? `Deactivate ${tag}` 861 | : `Activate ${tag}` 862 | ); 863 | // logic to grey out the toggle when appropriate 864 | let tagIsDisabled: boolean = 865 | !this.plugin.settings.staticHighlighter.onOffSwitch; 866 | tagToggle.setValue(config.queries[highlighter].tagEnabled ?? true); 867 | if (tagIsDisabled) { 868 | tagToggle.setDisabled(tagIsDisabled); 869 | tagToggle.toggleEl.classList.add("disabled-toggle"); 870 | } 871 | tagToggle.onChange((value) => { 872 | if (tagIsDisabled) { 873 | return; 874 | } 875 | // Update the tagEnabled status for the specific tag 876 | this.plugin.settings.staticHighlighter.queryOrder.forEach( 877 | (highlighter) => { 878 | if ( 879 | this.plugin.settings.staticHighlighter.queries[highlighter] 880 | .tag === queryConfig.tag && 881 | this.plugin.settings.staticHighlighter.queries[highlighter] 882 | .tagEnabled != value 883 | ) 884 | this.plugin.settings.staticHighlighter.queries[ 885 | highlighter 886 | ].tagEnabled = value; 887 | }, 888 | (async () => { 889 | // Call the save function to persist the changes 890 | await this.plugin.saveSettings(); 891 | // Refresh the highlighter decorations and display 892 | this.plugin.updateStaticHighlighter(); 893 | this.display(); 894 | })() 895 | ); 896 | }); 897 | }); 898 | tagButtons 899 | .addButton((button) => { 900 | button.setTooltip(`Rename this tag`); 901 | button 902 | .setClass("action-button") 903 | .setClass("action-button-edit") 904 | .setCta() 905 | .setIcon("pencil") 906 | .onClick(async () => { 907 | const renameTag = new Modals.RenameTagModal( 908 | this.app, 909 | tag, 910 | tagDropdownComponent, 911 | expandedTags, 912 | this.plugin.settings.staticHighlighter, 913 | modalSaveAndReload 914 | ); 915 | renameTag.open(); 916 | }); 917 | }) 918 | .addButton((button) => { 919 | button.setTooltip(`Delete ${tag}`); 920 | button 921 | .setClass("action-button") 922 | .setClass("action-button-delete") 923 | .setIcon("trash") 924 | .setWarning() 925 | .onClick(async () => { 926 | // delete modal and arguments 927 | const deleteTag = new Modals.DeleteTagModal( 928 | this.app, 929 | tag, 930 | this.plugin.settings.staticHighlighter, 931 | expandedTags, 932 | modalSaveAndReload 933 | ); 934 | deleteTag.open(); 935 | }); 936 | }); 937 | // container stuff 938 | tagContainer.appendChild(tagHeader); 939 | tagContainer.appendChild(highlightersList); 940 | highlightersContainer.appendChild(tagContainer); 941 | } 942 | 943 | // ################# Here come the highlighters with their buttons ####################################### 944 | const settingItem = tagContainers[tag].createEl("div"); 945 | settingItem.id = "dh-" + highlighter; 946 | // Most of this isn't even being displayed, but if any part is removed 947 | // the whole layout of this part crashes and burns 948 | settingItem.addClass("highlighter-item-draggable"); 949 | const dragIcon = settingItem.createEl("span"); 950 | dragIcon.addClass( 951 | "highlighter-setting-icon", 952 | "highlighter-setting-icon-drag" 953 | ); 954 | const styleIcon = settingItem.createEl("span"); 955 | styleIcon.addClass("highlighter-style-icon"); 956 | const abc = styleIcon.createEl("span", { 957 | text: "abc", 958 | cls: "highlighter-setting-icon-abc", 959 | }); 960 | Object.assign( 961 | abc.style, 962 | this.parseCssString(config.queries[highlighter].colorIconSnippet) 963 | ); 964 | dragIcon.addClass( 965 | "highlighter-setting-icon", 966 | "highlighter-setting-icon-drag" 967 | ); 968 | 969 | const highlighterButtons = new Setting(settingItem) 970 | .setClass("highlighter-details") 971 | .setName(highlighter) 972 | .setDesc( 973 | config.queries[highlighter].regex 974 | ? `regEx: ` + query 975 | : `query: ` + query 976 | ); 977 | 978 | // The toggle to enable/disable the highlighter 979 | highlighterButtons.addToggle((highlighterToggle) => { 980 | highlighterToggle.setTooltip( 981 | config.queries[highlighter].highlighterEnabled 982 | ? `Deactivate ${highlighter}` 983 | : `Activate ${highlighter}` 984 | ); 985 | // logic to grey out the toggle when appropriate 986 | let highlighterIsDisabled: boolean = 987 | !this.plugin.settings.staticHighlighter.onOffSwitch; 988 | highlighterToggle.setValue( 989 | config.queries[highlighter].highlighterEnabled ?? true 990 | ); 991 | if ( 992 | // if the highlighter is disabled for some reason 993 | highlighterIsDisabled || 994 | !config.queries[highlighter].tagEnabled 995 | ) { 996 | highlighterToggle.setDisabled(highlighterIsDisabled); 997 | highlighterToggle.toggleEl.classList.remove( 998 | "enabled-highlighter-toggle" 999 | ); 1000 | highlighterToggle.toggleEl.classList.add( 1001 | "disabled-highlighter-toggle" 1002 | ); 1003 | } else { 1004 | // if it is enabled 1005 | highlighterToggle.toggleEl.classList.remove( 1006 | "enabled-highlighter-toggle" 1007 | ); 1008 | highlighterToggle.toggleEl.classList.add( 1009 | "enabled-highlighter-toggle" 1010 | ); 1011 | } 1012 | highlighterToggle.onChange((value) => { 1013 | if (highlighterIsDisabled) { 1014 | return; 1015 | } 1016 | // Update the 'enabled' property of the highlighter 1017 | config.queries[highlighter].highlighterEnabled = value; 1018 | highlighterToggle.setTooltip( 1019 | value ? `Deactivate ${highlighter}` : `Activate ${highlighter}` 1020 | ); 1021 | 1022 | (async () => { 1023 | await this.plugin.saveSettings(); 1024 | // Refresh the highlighter decorations 1025 | this.plugin.updateStaticHighlighter(); 1026 | })(); 1027 | }); 1028 | }); 1029 | 1030 | highlighterButtons 1031 | .addButton((button) => { 1032 | button.setTooltip(`Edit ${highlighter} highlighter`); 1033 | button 1034 | .setClass("action-button") 1035 | .setClass("action-button-highlighterslist") 1036 | .setClass("mod-cta-inverted") 1037 | .setIcon("pencil") 1038 | .onClick(async (evt) => { 1039 | // disable douplication prevention 1040 | saveButton.buttonEl.setAttribute("state", "editing"); 1041 | // Populate the input elements with the highlighter's settings 1042 | const options = config.queries[highlighter]; 1043 | queryNameInput.inputEl.value = highlighter; 1044 | queryNameInput.inputEl.dataset.original = highlighter; 1045 | 1046 | staticPickrInstance.setColor(options.staticColor); 1047 | queryInput.inputEl.value = options.query; 1048 | staticDecorationDropdownComponent.setValue( 1049 | options.staticDecoration 1050 | ); 1051 | staticDecorationValue = options.staticDecoration; 1052 | tagName = options.tag; 1053 | tagDropdownComponent.setValue(options.tag); 1054 | tagStatus = options.tagEnabled; 1055 | staticPickrInstance.setColor(options.staticColor); 1056 | regexToggle.setValue(options.regex); 1057 | // matcheToggle / lineToggle / groupsToggle 1058 | if (options?.mark) { 1059 | if (options?.mark.includes("match")) { 1060 | matchToggle.setValue(true); 1061 | } else { 1062 | matchToggle.setValue(false); 1063 | } 1064 | if (options?.mark.includes("line")) { 1065 | lineToggle.setValue(true); 1066 | } else { 1067 | lineToggle.setValue(false); 1068 | } 1069 | if (options?.mark.includes("groups")) { 1070 | groupsToggle.setValue(true); 1071 | } else { 1072 | groupsToggle.setValue(false); 1073 | } 1074 | } 1075 | containerEl.scrollTop = 0; 1076 | }); 1077 | }) 1078 | 1079 | .addButton((button) => { 1080 | button.setTooltip(`Delete ${highlighter}`); 1081 | button 1082 | .setClass("action-button") 1083 | .setClass("action-button-delete") 1084 | .setClass("mod-warning-inverted") 1085 | .setIcon("trash") 1086 | .onClick(async () => { 1087 | const deleteHighlighter = new Modals.DeleteHighlighterModal( 1088 | this.app, 1089 | highlighter, 1090 | this.plugin.settings.staticHighlighter, 1091 | config.queryOrder, 1092 | modalSaveAndReload 1093 | ); 1094 | deleteHighlighter.open(); 1095 | }); 1096 | }); 1097 | } else { 1098 | this.plugin.settings.staticHighlighter.queryOrder = 1099 | this.plugin.settings.staticHighlighter.queryOrder.filter( 1100 | (item) => item != highlighter 1101 | ); 1102 | this.plugin.saveSettings(); 1103 | } 1104 | }); 1105 | const renderInReadingMode = new Setting(this.containerEl) 1106 | .setName("Show highlights in reading mode") 1107 | .setDesc( 1108 | "If enabled, highlights will also be shown in reading mode (preview)" 1109 | ) 1110 | .addToggle((toggle) => { 1111 | toggle 1112 | .setValue(this.plugin.settings.staticHighlighter.showInReadingMode) 1113 | .onChange(async (value) => { 1114 | this.plugin.settings.staticHighlighter.showInReadingMode = value; 1115 | await this.plugin.saveSettings(); 1116 | }); 1117 | }); 1118 | const chooseCommands = new Setting(containerEl); 1119 | chooseCommands.setName("Hotkeys and command palette"); 1120 | chooseCommands.setDesc( 1121 | `All your tags are automatically added to the command palette/hotkeys for toggling. If you want to toggle individual highlighters via palette/hotkey, input a comma separated list of their names (case sensitive). Highlighters with the tag '#unsorted' are added automatically.` 1122 | ); 1123 | chooseCommands.addTextArea((text) => { 1124 | text.inputEl.addClass("ignored-words-input"); 1125 | text 1126 | .setValue(this.plugin.settings.staticHighlighter.toggleable.join(", ")) 1127 | .onChange(async (value) => { 1128 | this.plugin.settings.staticHighlighter.toggleable = value 1129 | .split(",") // Split by commas (or your chosen separator) 1130 | .map((tag) => tag.trim()) // Clean up each tag 1131 | .filter((tag) => tag.length > 0); // Remove empty strings 1132 | await this.plugin.saveSettings(); 1133 | this.plugin.registerCommands(); 1134 | this.plugin.updateSelectionHighlighter(); 1135 | }); 1136 | }); 1137 | 1138 | //############################################################## 1139 | //################ SELECTION HIGHLIGHTERS ################## 1140 | //############################################################## 1141 | new Setting(containerEl).setName("Selection highlights").setHeading(); 1142 | 1143 | const selectionHighlightUI = new Setting(containerEl); 1144 | const rowWrapper = selectionHighlightUI.controlEl.createDiv( 1145 | "selectedHighlightsStylingsContainer" 1146 | ); 1147 | 1148 | const descriptionText = rowWrapper.createDiv("choose-color-text"); 1149 | descriptionText.setText("Choose a color."); 1150 | 1151 | // define pickr for the selection highlights scope 1152 | let selectionColorPickerInstance: Pickr; 1153 | const selectionColorPicker = new ButtonComponent(rowWrapper); 1154 | let selectionColorPickerDefault: string; 1155 | if ( 1156 | this.plugin.settings.selectionHighlighter.selectionColor === "default" 1157 | ) { 1158 | selectionColorPickerDefault = hslaColor; 1159 | } else { 1160 | selectionColorPickerDefault = 1161 | this.plugin.settings.selectionHighlighter.selectionColor; 1162 | } 1163 | selectionColorPicker.setClass("color-button-wrapper").then(() => { 1164 | this.pickrInstance = selectionColorPickerInstance = new Pickr({ 1165 | el: selectionColorPicker.buttonEl, 1166 | container: rowWrapper, 1167 | theme: "nano", 1168 | defaultRepresentation: "HEXA", 1169 | default: selectionColorPickerDefault, 1170 | comparison: false, 1171 | components: { 1172 | preview: true, 1173 | opacity: true, 1174 | hue: true, 1175 | interaction: { 1176 | hex: true, 1177 | rgba: false, 1178 | hsla: true, 1179 | hsva: false, 1180 | cmyk: false, 1181 | input: true, 1182 | clear: true, 1183 | cancel: true, 1184 | save: true, 1185 | }, 1186 | }, 1187 | }); 1188 | // Make the button 1189 | const button = rowWrapper.querySelector(".pcr-button"); 1190 | if (!button) { 1191 | throw new Error("Button is null (see ui.ts)"); 1192 | } 1193 | button.ariaLabel = "Decoration color picker"; 1194 | 1195 | // Picker functionality 1196 | selectionColorPickerInstance 1197 | .on("clear", (instance: Pickr) => { 1198 | instance.hide(); 1199 | }) 1200 | .on("cancel", (instance: Pickr) => { 1201 | instance.hide(); 1202 | }) 1203 | .on("change", (color: Pickr.HSVaColor) => { 1204 | const hexValue = color?.toHEXA().toString() || ""; 1205 | 1206 | hexValue && hexValue.length == 6 1207 | ? (this.plugin.settings.selectionHighlighter.selectionColor = `${hexValue}A6`) 1208 | : (this.plugin.settings.selectionHighlighter.selectionColor = 1209 | hexValue); 1210 | this.plugin.saveSettings(); 1211 | }) 1212 | .on("save", (color: Pickr.HSVaColor, instance: Pickr) => { 1213 | const hexValue = color?.toHEXA().toString() || ""; 1214 | hexValue && hexValue.length == 6 1215 | ? (this.plugin.settings.selectionHighlighter.selectionColor = `${hexValue}A6`) 1216 | : (this.plugin.settings.selectionHighlighter.selectionColor = 1217 | hexValue); 1218 | this.plugin.saveSettings(); 1219 | instance.hide(); 1220 | }); 1221 | }); 1222 | 1223 | const selectionDecorationDropdown = new Setting(rowWrapper) 1224 | .setName("Choose a decoration.") 1225 | .setClass("choose-decoration-text"); 1226 | selectionDecorationDropdown 1227 | .setClass("decoration-dropdown") 1228 | .addDropdown((dropdown) => { 1229 | selectionDecorationDropdownComponent = dropdown; 1230 | dropdown.selectEl.setAttribute( 1231 | "aria-label", 1232 | "Select a decoration style for your highlighter" 1233 | ); 1234 | if (!dropdown.selectEl.classList.contains("has-tooltip")) { 1235 | dropdown.selectEl.classList.add("has-tooltip"); 1236 | } 1237 | dropdown 1238 | .addOption("background", "Background square") 1239 | .addOption("background rounded", "--- rounded") 1240 | .addOption("background realistic", "--- realistic") 1241 | .addOption("underline", "Underline solid") 1242 | .addOption("underline lowlight", "--- lowlight") 1243 | .addOption("underline floating thick", "--- floating thick") 1244 | .addOption("underline floating thin", "--- floating thin") 1245 | .addOption("underline dotted", "--- dotted") 1246 | .addOption("underline dashed", "--- dashed") 1247 | .addOption("underline wavy", "--- wavy") 1248 | .addOption("border solid", "Border solid") 1249 | .addOption("border dashed", "--- dashed") 1250 | .addOption("color", "Colored text normal") 1251 | .addOption("bold", "--- bold") 1252 | .addOption("line-through", "Strikethrough") 1253 | .setValue( 1254 | this.plugin.settings.selectionHighlighter.selectionDecoration === 1255 | "default" 1256 | ? "underline dotted" 1257 | : this.plugin.settings.selectionHighlighter.selectionDecoration 1258 | ) 1259 | .onChange((value) => { 1260 | this.plugin.settings.selectionHighlighter.selectionDecoration = 1261 | value; 1262 | this.plugin.saveSettings(); 1263 | }); 1264 | }); 1265 | 1266 | const cssSaveButton = new ButtonComponent(rowWrapper); 1267 | cssSaveButton.setTooltip("Save highlighter"); 1268 | cssSaveButton 1269 | .setClass("action-button") 1270 | .setCta() 1271 | .setClass("selected-save-button") 1272 | .setIcon("save") 1273 | .setTooltip("Save deco") 1274 | .onClick(async () => { 1275 | let color = this.plugin.settings.selectionHighlighter.selectionColor; 1276 | let decoration = 1277 | this.plugin.settings.selectionHighlighter.selectionDecoration; 1278 | 1279 | // Make a snippet out of the chosen values 1280 | this.plugin.settings.selectionHighlighter.css = snippetMaker( 1281 | decoration, 1282 | color 1283 | ); 1284 | // save and update 1285 | new Notice("Selection highlighter style saved."); 1286 | await this.plugin.saveSettings(); 1287 | this.plugin.updateSelectionHighlighter(); 1288 | }); 1289 | 1290 | const selectedDiscardButton = new ButtonComponent(rowWrapper); 1291 | selectedDiscardButton 1292 | .setClass("selected-discard-button") 1293 | .setClass("action-button") 1294 | .setCta() 1295 | .setIcon("x-circle") 1296 | .setTooltip("Reset to default") 1297 | .onClick(async () => { 1298 | this.plugin.settings.selectionHighlighter.selectionColor = "default"; 1299 | this.plugin.settings.selectionHighlighter.selectionDecoration = 1300 | hslaColor; 1301 | this.plugin.settings.selectionHighlighter.css = 1302 | "text-decoration: underline dotted var(--text-accent)"; 1303 | selectionDecorationDropdownComponent.setValue("underline dotted"); 1304 | await this.plugin.saveSettings(); 1305 | this.plugin.updateSelectionHighlighter(); 1306 | new Notice("Defaults reset"); 1307 | }); 1308 | // It's the only way the layout works 1309 | const dropdownSpacer = new Setting(rowWrapper) 1310 | .setName("") 1311 | .setClass("selected-spacer"); 1312 | dropdownSpacer.setClass("selected-spacer"); 1313 | 1314 | new Setting(containerEl) 1315 | .setName("Highlight all occurrences of the word under the cursor") 1316 | .addToggle((toggle) => { 1317 | toggle 1318 | .setValue( 1319 | this.plugin.settings.selectionHighlighter.highlightWordAroundCursor 1320 | ) 1321 | .onChange((value) => { 1322 | this.plugin.settings.selectionHighlighter.highlightWordAroundCursor = 1323 | value; 1324 | this.plugin.saveSettings(); 1325 | this.plugin.updateSelectionHighlighter(); 1326 | }); 1327 | }); 1328 | 1329 | new Setting(containerEl) 1330 | .setName("Highlight all occurrences of the actively selected text") 1331 | .addToggle((toggle) => { 1332 | toggle 1333 | .setValue( 1334 | this.plugin.settings.selectionHighlighter.highlightSelectedText 1335 | ) 1336 | .onChange((value) => { 1337 | this.plugin.settings.selectionHighlighter.highlightSelectedText = 1338 | value; 1339 | this.plugin.saveSettings(); 1340 | this.plugin.updateSelectionHighlighter(); 1341 | }); 1342 | }); 1343 | new Setting(containerEl) 1344 | .setName("Highlight delay") 1345 | .setDesc( 1346 | "The delay, in milliseconds, before selection highlights will appear. Must be greater than 200ms." 1347 | ) 1348 | .addText((text) => { 1349 | text.inputEl.type = "number"; 1350 | text 1351 | .setValue( 1352 | String(this.plugin.settings.selectionHighlighter.highlightDelay) 1353 | ) 1354 | .onChange((value) => { 1355 | if (parseInt(value) < 200) value = "200"; 1356 | if (parseInt(value) >= 0) 1357 | this.plugin.settings.selectionHighlighter.highlightDelay = 1358 | parseInt(value); 1359 | this.plugin.saveSettings(); 1360 | this.plugin.updateSelectionHighlighter(); 1361 | }); 1362 | }); 1363 | new Setting(containerEl) 1364 | .setName("Ignored words") 1365 | .setDesc( 1366 | "A comma separated list of words that will be ignored by selection highlights." 1367 | ) 1368 | .addTextArea((text) => { 1369 | text.inputEl.addClass("ignored-words-input"); 1370 | text 1371 | .setValue(this.plugin.settings.selectionHighlighter.ignoredWords) 1372 | .onChange(async (value) => { 1373 | this.plugin.settings.selectionHighlighter.ignoredWords = value; 1374 | await this.plugin.saveSettings(); 1375 | this.plugin.updateSelectionHighlighter(); 1376 | }); 1377 | }); 1378 | } 1379 | 1380 | private parseCssString(cssString: string): Partial { 1381 | const styleObject: { [key: string]: string } = {}; 1382 | const styles = cssString.split(";").filter((style) => style.trim()); 1383 | 1384 | styles.forEach((style) => { 1385 | const [property, value] = style.split(":").map((part) => part.trim()); 1386 | if (property && value) { 1387 | // Convert kebab-case to camelCase for style properties 1388 | const camelProperty = property.replace(/-([a-z])/g, (g) => 1389 | g[1].toUpperCase() 1390 | ); 1391 | styleObject[camelProperty] = value; 1392 | } 1393 | }); 1394 | 1395 | return styleObject as Partial; 1396 | } 1397 | } 1398 | 1399 | // FIN 1400 | --------------------------------------------------------------------------------