├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── DEV_NOTES.MD ├── LICENSE.md ├── README.md ├── biome.json ├── esbuild.config.mjs ├── manifest.json ├── media └── SNW.gif ├── package.json ├── src ├── indexer.ts ├── main.ts ├── settings.ts ├── snwApi.ts ├── types.ts ├── ui │ ├── IconMoreDetails.tsx │ ├── PluginCommands.ts │ ├── SettingsTab.ts │ ├── SideBarPaneView.tsx │ ├── SortOrderDropdown.tsx │ ├── components │ │ ├── context │ │ │ ├── ContextBuilder.ts │ │ │ ├── formatting-utils.ts │ │ │ └── position-utils.ts │ │ ├── uic-ref--parent.ts │ │ ├── uic-ref-area.tsx │ │ ├── uic-ref-item.tsx │ │ └── uic-ref-title.tsx │ ├── frontmatterRefCount.ts │ └── headerRefCount.ts ├── utils.ts └── view-extensions │ ├── gutters-cm6.ts │ ├── htmlDecorations.tsx │ ├── references-cm6.ts │ └── references-preview.ts ├── styles.css ├── tsconfig.json ├── version-bump.mjs ├── version-github-action.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig: https://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [*.yml] 14 | indent_style = space 15 | indent_size = 2 16 | 17 | [*.md] 18 | max_line_length = 140 19 | indent_style = space 20 | indent_size = 2 21 | end_of_line = lf 22 | insert_final_newline = true 23 | trim_trailing_whitespace = true 24 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: '21.x' 19 | 20 | - name: Build plugin 21 | run: | 22 | npm install 23 | npm run build 24 | 25 | - name: Create release 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | run: | 29 | tag="${GITHUB_REF#refs/tags/}" 30 | 31 | gh release create "$tag" \ 32 | --title="$tag" \ 33 | build/main.js manifest.json styles.css 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | TODONOTES.md 4 | 5 | # Intellij 6 | *.iml 7 | .idea 8 | 9 | # npm 10 | node_modules 11 | package-lock.json 12 | 13 | # Don't include the compiled main.js file in the repo. 14 | # They should be uploaded to GitHub releases instead. 15 | main.js 16 | 17 | # Exclude sourcemaps 18 | *.map 19 | 20 | # obsidian 21 | data.json 22 | 23 | build 24 | 25 | # OS generated files # 26 | ###################### 27 | .DS_Store 28 | .DS_Store? 29 | ._* 30 | .Spotlight-V100 31 | .Trashes 32 | ehthumbs.db 33 | Thumbs.db 34 | 35 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.3.2 2 | - Fix: When using editing toolbar configured for showing toolbar at top, the SNW reference counter overlapped. The CSS was mofiied to prevent this. [#155](https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/155). 3 | 4 | # 2.3.1 5 | - Change: if a link is the only link in the vault to a destination (thus the reference count is 1), the reference counter will not be shown next to the link, regardless of the minimum threshold count set in settings. This makes sense. If the link is used once, its obvious without the counter, and the counter simply stating the link has one reference was redudant and annoying to many users. 6 | 7 | # 2.3.0 8 | - New: Added support for references to show up in the kanban plugin. This can be toggled on and off in settings. 9 | - Changed: SNW's internal index did not include file extensions, which in some cases led to issues. the index now uses the full path with the extension, which means every file whether it is a MD file or not is included in the index. 10 | 11 | # 2.2.1 12 | 13 | - Reference count now appears in the files property subpan 14 | 15 | # 2.2.0 16 | 17 | - This is another major update to the indexing engine to avoid detecting certain reported issues. 18 | - Additional optimization efforts to improve performance. (removing loops, keeping index optimized) 19 | - Fix: when a link or embed points to the file it is in, it will now be included in the reference count. 20 | - One challenge is case-sensitivity. In fact, Obsidian allows links to be case-insensitive, but the internal link engine is case-sensitive. This is a problem when a link is written in a different case than the file name. This update should help with this issue. The internal index ignores the case of links. [#145](https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/145), [#153](https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/153), [[#142](https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/142)] 21 | - Partial fix to [[#154](https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/154)] 22 | - Fix: Cannot distinguish the title with the same name [#143](https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/143) 23 | - Fixed issue with Alias links display reference counts 24 | - Fiex: issue with [#139](https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/139) disabling incoming headers settings 25 | 26 | 27 | # 2.1.5 28 | 29 | - Updating plugin to newest Obsidian recommendations https://docs.obsidian.md/oo24/plugin. 30 | - Transition to Biome from EsLint and Prettier. 31 | - Update to new DeferredView handling - Thank you to @baodrate for PR [151](https://github.com/TfTHacker/obsidian42-strange-new-worlds/pull/151) 32 | 33 | # 2.1.4 34 | 35 | - New: In Settings under Custom Display List, the display of references can be customized to show properties from referenced files. In Settings, provide a comma separated list of case-sensitive property names. If a file has any of these properties when displayed in the reference list, the properties will also be displayed. 36 | 37 | # 2.1.3 38 | 39 | - Fix: after sorting list of references, they would not respond to being clicked on. 40 | - Dependency updates 41 | 42 | # 2.1.2 43 | 44 | - New: Added an option to toggle SNW off and on in Source Mode. By default, it is toggled off, since source mode is intended to view the raw markdown. This can be changed in the settings. [#137](https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/137) 45 | 46 | # 2.1.1 47 | 48 | - Fix to sidepane not opening after being closed [#136](https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/136) 49 | 50 | # 2.1.0 51 | 52 | ## New 53 | 54 | - Items shown in the refreence panel can now be sorted by File Name and Date Modified, in ascending and decending order. This can be changed in the reference count hover panel or sidepane. A change in either these places will be reflected in the other. 55 | 56 | # 2.0.3 57 | 58 | ## New 59 | 60 | - Added indexing properties as part of the link counting 61 | 62 | # Updated 63 | 64 | - Major rewrite of the internal indexer - significant performance improvements 65 | - Removed the caching tuning feature - this wasn't proving to be of help in performance 66 | - Simplified the Settings screen where possible 67 | 68 | # Fixed 69 | 70 | - Fixed max items that can be displayed when reviewing references 71 | - Block reference counts will not show up in Export to PDF 72 | - Improved the handling of ghost files (files that don't exist in the vault, but are linked to) 73 | 74 | # 1.2.6 (2024-04-05) 75 | 76 | - Dependency updates 77 | - Added Github action to automate the release process 78 | 79 | # 1.2.4 (2023-11-??) 80 | 81 | Thanks to the help of @GitMurf, SNW has made some nice steps forward 82 | 83 | - New toggle to control if a modifier key is needed to hover an SNW counter when hovering over it. This can be turned off/on in settings. 84 | - Performance optimizations focused on preventing delays while typing in a document 85 | - Typescriptifying the codebase where it wasn't Typescripty 86 | 87 | # 1.2.3 (2023-11-04) 88 | 89 | - Update of dependencies and new version of esbuild 90 | - small bug fixes from when properties was added (though this doesn't really make SNW work with properties properly yet) 91 | 92 | # 1.2.0 (2023-05-13) 93 | 94 | ## New 95 | 96 | - Breadcrumbs: Add context around links inside the previews of SNW. Many thanks for PR by @@ivan-lednev (https://github.com/TfTHacker/obsidian42-strange-new-worlds/pull/90) 97 | - Respect Obsidian's global excluded files. Many thanks for PR by @sk91 (https://github.com/TfTHacker/obsidian42-strange-new-worlds/pull/88) 98 | - Ghost links now supported: This is a link to a file that doesnt exist in the vault. A counter now appears to such "ghosted" links. (https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/67)(https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/26) 99 | - Added two new frontmatter properties to control if SNW counters are shown in canvas: `snw-canvas-exclude-preview: true` and `snw-canvas-exclude-edit: true` 100 | 101 | ## Updates 102 | 103 | - All core libraries updated to Obsidian 1.3.0 104 | - BIG CHANGE: The internal link engine has been fine tuned to not be case-sensitive, but this required massive changes to the way links are resolved. Hopefully this will improve the accuracy of SNW reference counters. This should resolve iseue (https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/75)(https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/34)(https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/64) 105 | 106 | ## Bug Fixes 107 | 108 | - Fix to styling conflict with better-fn (https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/93) 109 | - Fix to block reference counters not formatting properly when in lists (https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/89) 110 | - Fix to Header reference does not render when header includes a colon (https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/94) 111 | - Fix Indicators don't appear correctly on tasks as well (https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/39) 112 | - Links or embeds pointing to their own page will now be included in reference counts. (https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/60) (https://github.com/TfTHacker/obsidian42-strange-new-worlds/issues/77) 113 | - Fix so that SNW doesnt run in the kanban plugin 114 | -------------------------------------------------------------------------------- /DEV_NOTES.MD: -------------------------------------------------------------------------------- 1 | DEV_NOTES.MD 2 | 3 | # Updating the version 4 | 5 | 1. update pacakage.json version number 6 | 2. npm run version (updates the manifest and version file) 7 | 3. commit repo 8 | 4. npm run githubaction (commits the version number tag to the repo and pushes it, which kicks of the github action to prepare the release) 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Strange New Worlds of networked thought 2 | 3 | This plugin helps you to see the connections between the different parts of your vault. 4 | 5 | The basic idea is we want to see when links, block references and embeds have associations with other files in the vault. The problem is you have to search, open backlinks, and so on to find out what is going on. But there are so many strange new worlds of networked thought to discover in our vault. This plugin attempts to resurface those connections and not be too intrusive (or not too intrusive) in doing so. 6 | 7 | ![](media/SNW.gif) 8 | 9 | Documentation for this plugin can be found at: https://tfthacker.com/SNW 10 | 11 | See videos on how to use Strange New Worlds: https://tfthacker.com/SNW-videos 12 | 13 | You might also be interested in a few products I have made for Obsidian: 14 | 15 | 16 | - [JournalCraft](https://tfthacker.com/jco) - A curated collection of 10 powerful journaling templates designed to enhance your journaling experience. Whether new to journaling or looking to step up your game, JournalCraft has something for you. 17 | - [Cornell Notes Learning Vault](https://tfthacker.com/cornell-notes) - This vault teaches you how to use the Cornell Note-Taking System in your Obsidian vault. It includes learning material, samples, and Obsidian configuration files to enable Cornell Notes in your vault. 18 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": [] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "tab", 15 | "lineWidth": 140 16 | }, 17 | "organizeImports": { 18 | "enabled": true 19 | }, 20 | "linter": { 21 | "enabled": true, 22 | "rules": { 23 | "recommended": true 24 | } 25 | }, 26 | "javascript": { 27 | "formatter": { 28 | "quoteStyle": "double" 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import process from 'process'; 3 | import builtins from 'builtin-modules'; 4 | import fs from 'fs'; 5 | import console from 'console'; 6 | 7 | fs.copyFile('manifest.json', 'build/manifest.json', (err) => { 8 | if (err) console.log(err); 9 | }); 10 | fs.copyFile('styles.css', 'build/styles.css', (err) => { 11 | if (err) console.log(err); 12 | }); 13 | 14 | 15 | const prod = process.argv[2] === 'production'; 16 | 17 | const context = await esbuild.context({ 18 | entryPoints: ['src/main.ts'], 19 | bundle: true, 20 | minify: prod, 21 | external: [ 22 | 'obsidian', 23 | 'electron', 24 | '@codemirror/autocomplete', 25 | '@codemirror/collab', 26 | '@codemirror/commands', 27 | '@codemirror/language', 28 | '@codemirror/lint', 29 | '@codemirror/search', 30 | '@codemirror/state', 31 | '@codemirror/view', 32 | '@lezer/common', 33 | '@lezer/highlight', 34 | '@lezer/lr', 35 | ...builtins, 36 | ], 37 | format: 'cjs', 38 | target: 'es2018', 39 | logLevel: 'info', 40 | loader: { '.ts': 'ts', '.tsx': 'tsx' }, 41 | jsxFactory: 'h', 42 | jsxFragment: 'Fragment', 43 | sourcemap: prod ? false : 'inline', 44 | treeShaking: true, 45 | outfile: 'build/main.js', 46 | }); 47 | 48 | if (prod) { 49 | console.log('Building for production'); 50 | await context.rebuild(); 51 | process.exit(0); 52 | } else { 53 | await context.watch(); 54 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian42-strange-new-worlds", 3 | "name": "Strange New Worlds", 4 | "version": "2.3.2", 5 | "minAppVersion": "1.7.2", 6 | "description": "Help see how your vault is interconnected with visual indicators.", 7 | "author": "TfTHacker", 8 | "authorUrl": "https://twitter.com/TfTHacker", 9 | "helpUrl": "https://tfthacker.com/SNW", 10 | "isDesktopOnly": false, 11 | "fundingUrl": { 12 | "Sponsor my work": "https://tfthacker.com/sponsor" 13 | } 14 | } -------------------------------------------------------------------------------- /media/SNW.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TfTHacker/obsidian42-strange-new-worlds/68cc7528f2e1e229dcf4d2b6d78fe687c3af2fd1/media/SNW.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian42-strange-new-worlds", 3 | "version": "2.3.2", 4 | "description": "Revealing networked thought and the strange new worlds created by your vault", 5 | "scripts": { 6 | "dev": "node --no-warnings esbuild.config.mjs", 7 | "build": "node --no-warnings esbuild.config.mjs production", 8 | "lint": "biome check ./src", 9 | "version": "node version-bump.mjs", 10 | "githubaction": "node version-github-action.mjs" 11 | }, 12 | "author": "TfTHacker", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@biomejs/biome": "1.9.4", 16 | "@codemirror/commands": "^6.7.1", 17 | "@codemirror/language": "^6.10.6", 18 | "@codemirror/search": "^6.5.8", 19 | "@codemirror/state": "^6.4.1", 20 | "@codemirror/view": "^6.35.0", 21 | "@types/node": "^22.10.1", 22 | "builtin-modules": "4.0.0", 23 | "esbuild": "0.24.0", 24 | "obsidian": "^1.7.2", 25 | "obsidian-typings": "^2.3.4", 26 | "tslib": "^2.8.1", 27 | "typescript": "5.7.2" 28 | }, 29 | "dependencies": { 30 | "preact": "^10.25.1", 31 | "tippy.js": "^6.3.7" 32 | } 33 | } -------------------------------------------------------------------------------- /src/indexer.ts: -------------------------------------------------------------------------------- 1 | // This module builds on Obsidians cache to provide more specific link information 2 | 3 | import { type CachedMetadata, type HeadingCache, type Pos, type TFile, parseLinktext, stripHeading } from "obsidian"; 4 | import type SNWPlugin from "./main"; 5 | import type { TransformedCache } from "./types"; 6 | 7 | let indexedReferences = new Map(); 8 | let lastUpdateToReferences = 0; 9 | let plugin: SNWPlugin; 10 | 11 | export function setPluginVariableForIndexer(snwPlugin: SNWPlugin) { 12 | plugin = snwPlugin; 13 | } 14 | 15 | export function getIndexedReferences() { 16 | return indexedReferences; 17 | } 18 | 19 | // Primary Indexing function. Adss to the indexedReferences map all outgoing links from a given file 20 | // The Database is primarily a key which is the link, and the value is an array of references that use that link 21 | export const getLinkReferencesForFile = (file: TFile, cache: CachedMetadata) => { 22 | if (plugin.settings.enableIgnoreObsExcludeFoldersLinksFrom && file?.path && plugin.app.metadataCache.isUserIgnored(file?.path)) { 23 | return; 24 | } 25 | for (const item of [cache?.links, cache?.embeds, cache?.frontmatterLinks]) { 26 | if (!item) continue; 27 | for (const ref of item) { 28 | const { path, subpath } = ref.link.startsWith("#") // if link is pointing to itself, create a full path 29 | ? parseLinktext(file.path.replace(`.${file.extension}`, "") + ref.link) 30 | : parseLinktext(ref.link); 31 | const tfileDestination = plugin.app.metadataCache.getFirstLinkpathDest(path, "/"); 32 | if (tfileDestination) { 33 | if ( 34 | plugin.settings.enableIgnoreObsExcludeFoldersLinksTo && 35 | tfileDestination?.path && 36 | plugin.app.metadataCache.isUserIgnored(tfileDestination.path) 37 | ) { 38 | continue; 39 | } 40 | // if the file has a property snw-index-exclude set to true, exclude it from the index 41 | if (plugin.app.metadataCache.getFileCache(tfileDestination)?.frontmatter?.["snw-index-exclude"] === true) continue; 42 | 43 | const linkWithFullPath = (tfileDestination ? tfileDestination.path + subpath : path).toLocaleUpperCase(); 44 | indexedReferences.set(linkWithFullPath, [ 45 | ...(indexedReferences.get(linkWithFullPath) || []), 46 | { 47 | realLink: ref.link, 48 | reference: ref, 49 | resolvedFile: tfileDestination, 50 | sourceFile: file, 51 | }, 52 | ]); 53 | } else { 54 | // Null if it is a ghost file link, Create Ghost link 55 | const link = ref.link.toLocaleUpperCase(); 56 | indexedReferences.set(link, [ 57 | ...(indexedReferences.get(link) || []), 58 | { 59 | realLink: ref.link, 60 | reference: ref, 61 | // mock up ghost file for linking 62 | resolvedFile: { 63 | path: `${path}.md`, 64 | name: `${path}.md`, 65 | basename: path, 66 | extension: "md", 67 | } as TFile, 68 | sourceFile: file, 69 | }, 70 | ]); 71 | } 72 | } 73 | } 74 | }; 75 | 76 | // removes existing references from the map, used with getLinkReferencesForFile to rebuild the refeences 77 | export const removeLinkReferencesForFile = async (file: TFile) => { 78 | for (const [key, items] of indexedReferences.entries()) { 79 | const filtered = items.filter((item: { sourceFile?: TFile }) => item?.sourceFile?.path !== file.path); 80 | filtered.length === 0 ? indexedReferences.delete(key) : indexedReferences.set(key, filtered); 81 | } 82 | }; 83 | 84 | /** 85 | * Buildings a optimized list of cache references for resolving the block count. 86 | * It is only updated when there are data changes to the vault. This is hooked to an event 87 | * trigger in main.ts 88 | */ 89 | export function buildLinksAndReferences(): void { 90 | if (plugin.showCountsActive !== true) return; 91 | 92 | indexedReferences = new Map(); 93 | for (const file of plugin.app.vault.getMarkdownFiles()) { 94 | const fileCache = plugin.app.metadataCache.getFileCache(file); 95 | if (fileCache) getLinkReferencesForFile(file, fileCache); 96 | } 97 | 98 | if (window.snwAPI) window.snwAPI.references = indexedReferences; 99 | lastUpdateToReferences = Date.now(); 100 | } 101 | 102 | // following MAP works as a cache for the getCurrentPage call. Based on time elapsed since last update, it just returns a cached transformedCache object 103 | const cacheCurrentPages = new Map(); 104 | 105 | // Provides an optimized view of the cache for determining the block count for references in a given page 106 | export function getSNWCacheByFile(file: TFile): TransformedCache { 107 | if (plugin.showCountsActive !== true) return {}; 108 | 109 | // Check if references have been updated since last cache update, and if cache is old 110 | const cachedPage = cacheCurrentPages.get(file.path.toLocaleUpperCase()); 111 | if (cachedPage) { 112 | const cachedPageCreateDate = cachedPage.createDate ?? 0; 113 | if (lastUpdateToReferences < cachedPageCreateDate && cachedPageCreateDate + 1000 > Date.now()) { 114 | return cachedPage; 115 | } 116 | } 117 | 118 | const transformedCache: TransformedCache = {}; 119 | const cachedMetaData = plugin.app.metadataCache.getFileCache(file); 120 | if (!cachedMetaData) return transformedCache; 121 | const filePathInUppercase = file.path.toLocaleUpperCase(); 122 | 123 | if (!indexedReferences) buildLinksAndReferences(); 124 | 125 | if (cachedMetaData?.headings) { 126 | // filter - fFirst confirm there are references 127 | // map - map to the transformed cache 128 | const baseFilePath = `${filePathInUppercase}#`; 129 | const tempCacheHeadings = cachedMetaData.headings 130 | .filter((header) => { 131 | return indexedReferences.has(baseFilePath + stripHeading(header.heading).toLocaleUpperCase()); 132 | }) 133 | .map((header) => { 134 | const key = baseFilePath + stripHeading(header.heading).toLocaleUpperCase(); 135 | return { 136 | original: "#".repeat(header.level) + header.heading, 137 | key, 138 | headerMatch: header.heading.replace(/\[|\]/g, ""), 139 | pos: header.position, 140 | page: file.basename, 141 | type: "heading" as const, 142 | references: indexedReferences.get(key), 143 | }; 144 | }); 145 | if (tempCacheHeadings.length > 0) transformedCache.headings = tempCacheHeadings; 146 | } 147 | if (cachedMetaData?.blocks) { 148 | // First confirm there are references to the block 149 | // then map the block to the transformed cache 150 | const tempCacheBlocks = Object.values(cachedMetaData.blocks) 151 | .filter((block) => (indexedReferences.get(`${filePathInUppercase}#^${block.id.toUpperCase()}`)?.length || 0) > 0) 152 | .map((block) => { 153 | const key = `${filePathInUppercase}#^${block.id.toLocaleUpperCase()}`; 154 | return { 155 | key, 156 | pos: block.position, 157 | page: file.basename, 158 | type: "block" as const, 159 | references: indexedReferences.get(key), 160 | }; 161 | }); 162 | if (tempCacheBlocks.length > 0) transformedCache.blocks = tempCacheBlocks; 163 | } 164 | 165 | if (cachedMetaData?.links) { 166 | const tempCacheLinks = cachedMetaData.links 167 | .filter((link) => { 168 | const linkPath = 169 | parseLinkTextToFullPath(link.link.startsWith("#") ? filePathInUppercase + link.link : link.link).toLocaleUpperCase() || 170 | link.link.toLocaleUpperCase(); 171 | const refs = indexedReferences.get(linkPath); 172 | return refs?.length > 0; 173 | }) 174 | .map((link) => { 175 | const linkPath = 176 | parseLinkTextToFullPath(link.link.startsWith("#") ? filePathInUppercase + link.link : link.link).toLocaleUpperCase() || 177 | link.link.toLocaleUpperCase(); 178 | 179 | const result = { 180 | key: linkPath, 181 | original: link.original, 182 | type: "link" as const, 183 | pos: link.position, 184 | page: file.basename, 185 | references: indexedReferences.get(linkPath) || [], 186 | }; 187 | 188 | // Handle heading references in one pass 189 | if (linkPath.includes("#") && !linkPath.includes("#^")) { 190 | result.original = linkPath.split("#")[1]; 191 | } 192 | 193 | return result; 194 | }); 195 | if (tempCacheLinks.length > 0) transformedCache.links = tempCacheLinks; 196 | } 197 | 198 | if (cachedMetaData?.embeds) { 199 | const tempCacheEmbeds = cachedMetaData.embeds 200 | .filter((embed) => { 201 | const embedPath = 202 | (embed.link.startsWith("#") 203 | ? parseLinkTextToFullPath(filePathInUppercase + embed.link) 204 | : parseLinkTextToFullPath(embed.link) 205 | ).toLocaleUpperCase() || embed.link.toLocaleUpperCase(); 206 | const key = embedPath.startsWith("#") ? `${file.basename}${embedPath}` : embedPath; 207 | return indexedReferences.get(key)?.length > 0; 208 | }) 209 | .map((embed) => { 210 | const getEmbedPath = () => { 211 | const rawPath = embed.link.startsWith("#") ? filePathInUppercase + embed.link : embed.link; 212 | return parseLinkTextToFullPath(rawPath).toLocaleUpperCase() || embed.link.toLocaleUpperCase(); 213 | }; 214 | 215 | const embedPath = getEmbedPath(); 216 | const key = embedPath.startsWith("#") ? `${file.basename}${embedPath}` : embedPath; 217 | const [_, original] = key.includes("#") && !key.includes("#^") ? key.split("#") : []; 218 | 219 | return { 220 | key, 221 | page: file.basename, 222 | type: "embed" as const, 223 | pos: embed.position, 224 | references: indexedReferences.get(key) ?? [], 225 | ...(original && { original }), 226 | }; 227 | }); 228 | if (tempCacheEmbeds.length > 0) transformedCache.embeds = tempCacheEmbeds; 229 | } 230 | 231 | if (cachedMetaData?.frontmatterLinks) { 232 | // filter - fFirst confirm there are references 233 | // map - map to the transformed cache 234 | const tempCacheFrontmatter = cachedMetaData.frontmatterLinks 235 | .filter((link) => indexedReferences.has(parseLinkTextToFullPath(link.link).toLocaleUpperCase() || link.link.toLocaleUpperCase())) 236 | .map((link) => { 237 | const linkPath = parseLinkTextToFullPath(link.link).toLocaleUpperCase() || link.link.toLocaleUpperCase(); 238 | return { 239 | key: linkPath, 240 | original: link.original, 241 | type: "frontmatterLink" as const, 242 | pos: { start: { line: -1, col: -1, offset: -1 }, end: { line: -1, col: -1, offset: -1 } }, 243 | displayText: link.displayText, 244 | page: file.basename, 245 | references: indexedReferences.get(linkPath) || [], 246 | }; 247 | }); 248 | if (tempCacheFrontmatter.length > 0) transformedCache.frontmatterLinks = tempCacheFrontmatter; 249 | } 250 | 251 | transformedCache.cacheMetaData = cachedMetaData; 252 | transformedCache.createDate = Date.now(); 253 | cacheCurrentPages.set(file.path, transformedCache); 254 | 255 | return transformedCache; 256 | } 257 | 258 | export function parseLinkTextToFullPath(link: string): string { 259 | const resolvedFilePath = parseLinktext(link); 260 | const resolvedTFile = plugin.app.metadataCache.getFirstLinkpathDest(resolvedFilePath.path, "/"); 261 | if (resolvedTFile === null) return ""; 262 | return resolvedTFile.path + resolvedFilePath.subpath; 263 | } 264 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import type { Extension } from "@codemirror/state"; 2 | import { 3 | type CachedMetadata, 4 | type MarkdownPostProcessor, 5 | MarkdownPreviewRenderer, 6 | Platform, 7 | Plugin, 8 | type TFile, 9 | type WorkspaceLeaf, 10 | debounce, 11 | } from "obsidian"; 12 | import { buildLinksAndReferences, getLinkReferencesForFile, removeLinkReferencesForFile, setPluginVariableForIndexer } from "./indexer"; 13 | import { DEFAULT_SETTINGS, type Settings } from "./settings"; 14 | import SnwAPI from "./snwApi"; 15 | import PluginCommands from "./ui/PluginCommands"; 16 | import { SettingsTab } from "./ui/SettingsTab"; 17 | import { SideBarPaneView, VIEW_TYPE_SNW } from "./ui/SideBarPaneView"; 18 | import { setPluginVariableForUIC } from "./ui/components/uic-ref--parent"; 19 | import { setPluginVariableUIC_RefArea } from "./ui/components/uic-ref-area"; 20 | import { setPluginVariableForFrontmatterLinksRefCount, updatePropertiesDebounce } from "./ui/frontmatterRefCount"; 21 | import { setPluginVariableForHeaderRefCount, updateHeadersDebounce } from "./ui/headerRefCount"; 22 | import ReferenceGutterExtension, { setPluginVariableForCM6Gutter } from "./view-extensions/gutters-cm6"; 23 | import { setPluginVariableForHtmlDecorations, updateAllSnwLiveUpdateReferencesDebounce } from "./view-extensions/htmlDecorations"; 24 | import { InlineReferenceExtension, setPluginVariableForCM6InlineReferences } from "./view-extensions/references-cm6"; 25 | import markdownPreviewProcessor, { setPluginVariableForMarkdownPreviewProcessor } from "./view-extensions/references-preview"; 26 | 27 | export const UPDATE_DEBOUNCE = 200; 28 | 29 | export default class SNWPlugin extends Plugin { 30 | appName = this.manifest.name; 31 | appID = this.manifest.id; 32 | APP_ABBREVIARTION = "SNW"; 33 | settings: Settings = DEFAULT_SETTINGS; 34 | //controls global state if the plugin is showing counters 35 | showCountsActive: boolean = DEFAULT_SETTINGS.enableOnStartupDesktop; 36 | lastSelectedReferenceType = ""; 37 | lastSelectedReferenceRealLink = ""; 38 | lastSelectedReferenceKey = ""; 39 | lastSelectedReferenceFilePath = ""; 40 | lastSelectedLineNumber = 0; 41 | snwAPI: SnwAPI = new SnwAPI(this); 42 | markdownPostProcessor: MarkdownPostProcessor | null = null; 43 | editorExtensions: Extension[] = []; 44 | commands: PluginCommands = new PluginCommands(this); 45 | 46 | async onload(): Promise { 47 | console.log(`loading ${this.appName}`); 48 | 49 | setPluginVariableForIndexer(this); 50 | setPluginVariableUIC_RefArea(this); 51 | setPluginVariableForHtmlDecorations(this); 52 | setPluginVariableForCM6Gutter(this); 53 | setPluginVariableForHeaderRefCount(this); 54 | setPluginVariableForFrontmatterLinksRefCount(this); 55 | setPluginVariableForMarkdownPreviewProcessor(this); 56 | setPluginVariableForCM6InlineReferences(this); 57 | setPluginVariableForUIC(this); 58 | 59 | window.snwAPI = this.snwAPI; // API access to SNW for Templater, Dataviewjs and the console debugger 60 | 61 | await this.loadSettings(); 62 | this.addSettingTab(new SettingsTab(this.app, this)); 63 | 64 | // set current state based on startup parameters 65 | if (Platform.isMobile || Platform.isMobileApp) this.showCountsActive = this.settings.enableOnStartupMobile; 66 | else this.showCountsActive = this.settings.enableOnStartupDesktop; 67 | 68 | this.registerView(VIEW_TYPE_SNW, (leaf) => new SideBarPaneView(leaf, this)); 69 | 70 | //Build the full index of the vault of references 71 | const indexFullUpdateDebounce = debounce( 72 | () => { 73 | buildLinksAndReferences(); 74 | updateHeadersDebounce(); 75 | updatePropertiesDebounce(); 76 | updateAllSnwLiveUpdateReferencesDebounce(); 77 | }, 78 | 3000, 79 | true, 80 | ); 81 | 82 | // Updates reference index for a single file by removing and re-adding the references 83 | const indexFileUpdateDebounce = debounce( 84 | async (file: TFile, data: string, cache: CachedMetadata) => { 85 | await removeLinkReferencesForFile(file); 86 | getLinkReferencesForFile(file, cache); 87 | updateHeadersDebounce(); 88 | updatePropertiesDebounce(); 89 | updateAllSnwLiveUpdateReferencesDebounce(); 90 | }, 91 | 1000, 92 | true, 93 | ); 94 | 95 | this.registerEvent(this.app.vault.on("rename", indexFullUpdateDebounce)); 96 | this.registerEvent(this.app.vault.on("delete", indexFullUpdateDebounce)); 97 | this.registerEvent(this.app.metadataCache.on("changed", indexFileUpdateDebounce)); 98 | 99 | this.app.workspace.registerHoverLinkSource(this.appID, { 100 | display: this.appName, 101 | defaultMod: true, 102 | }); 103 | 104 | this.snwAPI.settings = this.settings; 105 | 106 | this.registerEditorExtension(this.editorExtensions); 107 | 108 | this.app.workspace.on("layout-change", () => { 109 | updateHeadersDebounce(); 110 | updatePropertiesDebounce(); 111 | }); 112 | 113 | this.toggleStateSNWMarkdownPreview(); 114 | this.toggleStateSNWLivePreview(); 115 | this.toggleStateSNWGutters(); 116 | 117 | this.app.workspace.onLayoutReady(async () => { 118 | if (!this.app.workspace.getLeavesOfType(VIEW_TYPE_SNW)?.length) { 119 | await this.app.workspace.getRightLeaf(false)?.setViewState({ type: VIEW_TYPE_SNW, active: false }); 120 | } 121 | buildLinksAndReferences(); 122 | }); 123 | } 124 | 125 | // Displays the sidebar SNW pane 126 | async activateView(refType: string, realLink: string, key: string, filePath: string, lineNu: number) { 127 | this.lastSelectedReferenceType = refType; 128 | this.lastSelectedReferenceRealLink = realLink; 129 | this.lastSelectedReferenceKey = key; 130 | this.lastSelectedReferenceFilePath = filePath; 131 | this.lastSelectedLineNumber = lineNu; 132 | 133 | const { workspace } = this.app; 134 | let leaf: WorkspaceLeaf | null = null; 135 | const leaves = workspace.getLeavesOfType(VIEW_TYPE_SNW); 136 | 137 | if (leaves.length > 0) { 138 | // A leaf with our view already exists, use that 139 | leaf = leaves[0]; 140 | } else { 141 | // Our view could not be found in the workspace, create a new leaf 142 | const leaf = workspace.getRightLeaf(false); 143 | await leaf?.setViewState({ type: VIEW_TYPE_SNW, active: true }); 144 | } 145 | 146 | // "Reveal" the leaf in case it is in a collapsed sidebar 147 | if (leaf) workspace.revealLeaf(leaf); 148 | await (this.app.workspace.getLeavesOfType(VIEW_TYPE_SNW)[0].view as SideBarPaneView).updateView(); 149 | } 150 | 151 | // Turns on and off the SNW reference counters in Reading mode 152 | toggleStateSNWMarkdownPreview(): void { 153 | if (this.settings.displayInlineReferencesMarkdown && this.showCountsActive && this.markdownPostProcessor === null) { 154 | this.markdownPostProcessor = this.registerMarkdownPostProcessor((el, ctx) => markdownPreviewProcessor(el, ctx), 100); 155 | } else { 156 | if (!this.markdownPostProcessor) { 157 | console.log("Markdown post processor is not registered"); 158 | } else { 159 | MarkdownPreviewRenderer.unregisterPostProcessor(this.markdownPostProcessor); 160 | } 161 | this.markdownPostProcessor = null; 162 | } 163 | } 164 | 165 | // Turns on and off the SNW reference counters in CM editor 166 | toggleStateSNWLivePreview(): void { 167 | let state = this.settings.displayInlineReferencesLivePreview; 168 | 169 | if (state === true) state = this.showCountsActive; 170 | 171 | this.updateCMExtensionState("inline-ref", state, InlineReferenceExtension); 172 | } 173 | 174 | // Turns on and off the SNW reference counters in CM editor gutter 175 | toggleStateSNWGutters(): void { 176 | let state = 177 | Platform.isMobile || Platform.isMobileApp 178 | ? this.settings.displayEmbedReferencesInGutterMobile 179 | : this.settings.displayEmbedReferencesInGutter; 180 | 181 | if (state === true) state = this.showCountsActive; 182 | 183 | this.updateCMExtensionState("gutter", state, ReferenceGutterExtension); 184 | } 185 | 186 | // Manages which CM extensions are loaded into Obsidian 187 | updateCMExtensionState(extensionIdentifier: string, extensionState: boolean, extension: Extension) { 188 | if (extensionState === true) { 189 | this.editorExtensions.push(extension); 190 | // @ts-ignore 191 | this.editorExtensions[this.editorExtensions.length - 1].snwID = extensionIdentifier; 192 | } else { 193 | for (let i = 0; i < this.editorExtensions.length; i++) { 194 | const ext = this.editorExtensions[i]; 195 | // @ts-ignore 196 | if (ext.snwID === extensionIdentifier) { 197 | this.editorExtensions.splice(i, 1); 198 | break; 199 | } 200 | } 201 | } 202 | this.app.workspace.updateOptions(); 203 | } 204 | 205 | async loadSettings(): Promise { 206 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 207 | } 208 | 209 | async saveSettings(): Promise { 210 | await this.saveData(this.settings); 211 | } 212 | 213 | onunload(): void { 214 | console.log(`unloading ${this.appName}`); 215 | try { 216 | if (!this.markdownPostProcessor) { 217 | console.log("Markdown post processor is not registered"); 218 | } else { 219 | MarkdownPreviewRenderer.unregisterPostProcessor(this.markdownPostProcessor); 220 | } 221 | this.app.workspace.unregisterHoverLinkSource(this.appID); 222 | } catch (error) { 223 | /* don't do anything */ 224 | } 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | export type SortOption = "name-asc" | "name-desc" | "mtime-asc" | "mtime-desc"; 2 | 3 | export interface Settings { 4 | enableOnStartupDesktop: boolean; 5 | enableOnStartupMobile: boolean; 6 | minimumRefCountThreshold: number; //minimum required to display a count 7 | maxFileCountToDisplay: number; // maximum number of items to display in popup or sidepane 8 | displayIncomingFilesheader: boolean; 9 | displayInlineReferencesLivePreview: boolean; 10 | displayInlineReferencesMarkdown: boolean; 11 | displayInlineReferencesInSourceMode: boolean; 12 | displayEmbedReferencesInGutter: boolean; 13 | displayEmbedReferencesInGutterMobile: boolean; 14 | displayPropertyReferences: boolean; 15 | displayPropertyReferencesMobile: boolean; 16 | enableRenderingBlockIdInMarkdown: boolean; 17 | enableRenderingLinksInMarkdown: boolean; 18 | enableRenderingHeadersInMarkdown: boolean; 19 | enableRenderingEmbedsInMarkdown: boolean; 20 | enableRenderingBlockIdInLivePreview: boolean; 21 | enableRenderingLinksInLivePreview: boolean; 22 | enableRenderingHeadersInLivePreview: boolean; 23 | enableRenderingEmbedsInLivePreview: boolean; 24 | enableIgnoreObsExcludeFoldersLinksFrom: boolean; //Use Obsidians Exclude Files from folder - links from those files outgoing to other files 25 | enableIgnoreObsExcludeFoldersLinksTo: boolean; //Use Obsidians Exclude Files from folder - links to those "excluded" files 26 | requireModifierKeyToActivateSNWView: boolean; //require CTRL hover to activate SNW view 27 | sortOptionDefault: SortOption; 28 | displayCustomPropertyList: string; //list of custom properties to display when showing references 29 | pluginSupportKanban: boolean; 30 | } 31 | 32 | export const DEFAULT_SETTINGS: Settings = { 33 | enableOnStartupDesktop: true, 34 | enableOnStartupMobile: true, 35 | minimumRefCountThreshold: 1, 36 | maxFileCountToDisplay: 100, 37 | displayIncomingFilesheader: true, 38 | displayInlineReferencesLivePreview: true, 39 | displayInlineReferencesMarkdown: true, 40 | displayInlineReferencesInSourceMode: false, 41 | displayEmbedReferencesInGutter: false, 42 | displayEmbedReferencesInGutterMobile: false, 43 | displayPropertyReferences: true, 44 | displayPropertyReferencesMobile: false, 45 | enableRenderingBlockIdInMarkdown: true, 46 | enableRenderingLinksInMarkdown: true, 47 | enableRenderingHeadersInMarkdown: true, 48 | enableRenderingEmbedsInMarkdown: true, 49 | enableRenderingBlockIdInLivePreview: true, 50 | enableRenderingLinksInLivePreview: true, 51 | enableRenderingHeadersInLivePreview: true, 52 | enableRenderingEmbedsInLivePreview: true, 53 | enableIgnoreObsExcludeFoldersLinksFrom: false, 54 | enableIgnoreObsExcludeFoldersLinksTo: false, 55 | requireModifierKeyToActivateSNWView: false, 56 | sortOptionDefault: "name-asc", 57 | displayCustomPropertyList: "", 58 | pluginSupportKanban: false, 59 | }; 60 | -------------------------------------------------------------------------------- /src/snwApi.ts: -------------------------------------------------------------------------------- 1 | import { getIndexedReferences, getSNWCacheByFile, parseLinkTextToFullPath } from "./indexer"; 2 | import type SNWPlugin from "./main"; 3 | import type { TFile, CachedMetadata } from "obsidian"; 4 | import type { TransformedCache } from "./types"; 5 | 6 | /** 7 | * Provide a simple API for use with Templater, Dataview and debugging the complexities of various pages. 8 | * main.ts will attach this to window.snwAPI 9 | */ 10 | export default class SnwAPI { 11 | plugin: SNWPlugin; 12 | references = new Map(); 13 | 14 | constructor(snwPlugin: SNWPlugin) { 15 | this.plugin = snwPlugin; 16 | } 17 | 18 | console = (logDescription: string, ...outputs: unknown[]): void => { 19 | console.log(`SNW: ${logDescription}`, outputs); 20 | }; 21 | 22 | getMetaInfoByCurrentFile = async (): Promise<{ 23 | TFile: TFile | null; 24 | metadataCache: CachedMetadata | null; 25 | SnwTransformedCache: TransformedCache | null; 26 | } | null> => { 27 | return this.getMetaInfoByFileName(this.plugin.app.workspace.getActiveFile()?.path || ""); 28 | }; 29 | 30 | searchReferencesStartingWith = async (searchString: string) => { 31 | for (const [key, value] of getIndexedReferences()) { 32 | if (key.startsWith(searchString)) { 33 | console.log(key, value); 34 | } 35 | } 36 | }; 37 | 38 | searchReferencesContains = async (searchString: string) => { 39 | for (const [key, value] of getIndexedReferences()) { 40 | if (key.contains(searchString)) { 41 | console.log(key, value); 42 | } 43 | } 44 | }; 45 | 46 | // For given file name passed into the function, get the meta info for that file 47 | getMetaInfoByFileName = async (fileName: string) => { 48 | const currentFile = this.plugin.app.metadataCache.getFirstLinkpathDest(fileName, "/"); 49 | return { 50 | TFile: currentFile, 51 | metadataCache: currentFile ? this.plugin.app.metadataCache.getFileCache(currentFile) : null, 52 | SnwTransformedCache: currentFile ? getSNWCacheByFile(currentFile) : null, 53 | }; 54 | }; 55 | 56 | parseLinkTextToFullPath(linkText: string) { 57 | return parseLinkTextToFullPath(linkText); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { CachedMetadata, ListItemCache, Pos, TFile } from "obsidian"; 2 | import type SnwAPI from "./snwApi"; 3 | 4 | declare global { 5 | interface Window { 6 | snwAPI?: SnwAPI; 7 | } 8 | } 9 | 10 | declare module "obsidian" { 11 | interface Workspace {} 12 | 13 | interface MetadataCache { 14 | metadataCache: { 15 | [x: string]: CachedMetadata; 16 | }; 17 | isUserIgnored(path: string): boolean; 18 | } 19 | 20 | interface Vault { 21 | fileMap: { 22 | [x: string]: TFile; 23 | }; 24 | } 25 | } 26 | 27 | export interface ReferenceLocation { 28 | type: "block" | "heading" | "embed" | "link" | string; 29 | pos: number; 30 | count: number; 31 | key: string; //identifier for the reference 32 | link: string; // full link to reference 33 | attachClass: string; // allows a custom class to be attached when processing cm6 references 34 | } 35 | 36 | export interface Link { 37 | reference: { 38 | link: string; 39 | key: string; 40 | displayText: string; 41 | position: Pos; 42 | }; 43 | resolvedFile: TFile | null; 44 | realLink: string; //the real link in the markdown 45 | sourceFile: TFile | null; 46 | } 47 | 48 | export interface TransformedCachedItem { 49 | key: string; 50 | pos: Pos; 51 | page: string; 52 | type: string; 53 | references: Link[]; 54 | original?: string; 55 | headerMatch?: string; //used for matching headers 56 | displayText?: string; 57 | } 58 | 59 | export interface TransformedCache { 60 | blocks?: TransformedCachedItem[]; 61 | links?: TransformedCachedItem[]; 62 | headings?: TransformedCachedItem[]; 63 | embeds?: TransformedCachedItem[]; 64 | frontmatterLinks?: TransformedCachedItem[]; 65 | createDate?: number; //date when cache was generated with Date.now() 66 | cacheMetaData?: CachedMetadata; 67 | } 68 | 69 | export interface ListItem extends ListItemCache { 70 | pos: number; 71 | key: string; 72 | } 73 | 74 | export interface Section { 75 | id?: string; 76 | items?: ListItem[]; 77 | position: Pos; 78 | pos?: number; 79 | type: string; 80 | } 81 | -------------------------------------------------------------------------------- /src/ui/IconMoreDetails.tsx: -------------------------------------------------------------------------------- 1 | import type { FunctionComponent } from "preact"; 2 | 3 | export const IconMoreDetails: FunctionComponent = () => { 4 | return ( 5 | 17 | More details icon 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/ui/PluginCommands.ts: -------------------------------------------------------------------------------- 1 | import { Notice } from "obsidian"; 2 | import type SNWPlugin from "../main"; 3 | 4 | export default class PluginCommands { 5 | plugin: SNWPlugin; 6 | snwCommands = [ 7 | { 8 | id: "SNW-ToggleActiveState", 9 | icon: "dot-network", 10 | name: "Toggle active state of SNW plugin on/off", 11 | showInRibbon: true, 12 | callback: async () => { 13 | this.plugin.showCountsActive = !this.plugin.showCountsActive; 14 | let msg = `SNW toggled ${this.plugin.showCountsActive ? "ON\n\n" : "OFF\n\n"}`; 15 | msg += "Tabs may require reloading for this change to take effect."; 16 | new Notice(msg); 17 | this.plugin.toggleStateSNWMarkdownPreview(); 18 | this.plugin.toggleStateSNWLivePreview(); 19 | this.plugin.toggleStateSNWGutters(); 20 | }, 21 | }, 22 | ]; 23 | 24 | constructor(plugin: SNWPlugin) { 25 | this.plugin = plugin; 26 | 27 | for (const item of this.snwCommands) { 28 | this.plugin.addCommand({ 29 | id: item.id, 30 | name: item.name, 31 | icon: item.icon, 32 | callback: async () => { 33 | await item.callback(); 34 | }, 35 | }); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/ui/SettingsTab.ts: -------------------------------------------------------------------------------- 1 | import { type App, PluginSettingTab, Setting, type ToggleComponent } from "obsidian"; 2 | import type SNWPlugin from "../main"; 3 | 4 | export class SettingsTab extends PluginSettingTab { 5 | plugin: SNWPlugin; 6 | 7 | constructor(app: App, plugin: SNWPlugin) { 8 | super(app, plugin); 9 | this.plugin = plugin; 10 | } 11 | 12 | display(): void { 13 | const { containerEl } = this; 14 | containerEl.empty(); 15 | 16 | new Setting(containerEl).setHeading().setName("Enable on startup"); 17 | new Setting(containerEl).setName("On the desktop enable SNW at startup").addToggle((cb: ToggleComponent) => { 18 | cb.setValue(this.plugin.settings.enableOnStartupDesktop); 19 | cb.onChange(async (value: boolean) => { 20 | this.plugin.settings.enableOnStartupDesktop = value; 21 | await this.plugin.saveSettings(); 22 | }); 23 | }); 24 | 25 | new Setting(containerEl).setName("On mobile devices enable SNW at startup").addToggle((cb: ToggleComponent) => { 26 | cb.setValue(this.plugin.settings.enableOnStartupMobile); 27 | cb.onChange(async (value: boolean) => { 28 | this.plugin.settings.enableOnStartupMobile = value; 29 | await this.plugin.saveSettings(); 30 | }); 31 | }); 32 | 33 | new Setting(containerEl).setHeading().setName("SNW Activation"); 34 | new Setting(containerEl) 35 | .setName("Require modifier key to activate SNW") 36 | .setDesc( 37 | `If enabled, SNW will only activate when the modifier key is pressed when hovering the mouse over an SNW counter. 38 | Otherwise, SNW will activate on a mouse hover. May require reopening open files to take effect.`, 39 | ) 40 | .addToggle((cb: ToggleComponent) => { 41 | cb.setValue(this.plugin.settings.requireModifierKeyToActivateSNWView); 42 | cb.onChange(async (value: boolean) => { 43 | this.plugin.settings.requireModifierKeyToActivateSNWView = value; 44 | await this.plugin.saveSettings(); 45 | }); 46 | }); 47 | 48 | new Setting(containerEl).setHeading().setName("Thresholds"); 49 | new Setting(containerEl) 50 | .setName("Minimal required count to show counter") 51 | .setDesc( 52 | `This setting defines how many references there needs to be for the reference count box to appear. May require reloading open files. 53 | Currently set to: ${this.plugin.settings.minimumRefCountThreshold} references.`, 54 | ) 55 | .addSlider((slider) => 56 | slider 57 | .setLimits(1, 1000, 1) 58 | .setValue(this.plugin.settings.minimumRefCountThreshold) 59 | .onChange(async (value) => { 60 | this.plugin.settings.minimumRefCountThreshold = value; 61 | await this.plugin.saveSettings(); 62 | }) 63 | .setDynamicTooltip(), 64 | ); 65 | 66 | new Setting(containerEl) 67 | .setName("Maximum file references to show") 68 | .setDesc( 69 | `This setting defines the max amount of files with their references are displayed in the popup or sidebar. Set to 1000 for no maximum. 70 | Currently set to: ${this.plugin.settings.maxFileCountToDisplay} references. Keep in mind higher numbers can affect performance on larger vaults.`, 71 | ) 72 | .addSlider((slider) => 73 | slider 74 | .setLimits(1, 1000, 1) 75 | .setValue(this.plugin.settings.maxFileCountToDisplay) 76 | .onChange(async (value) => { 77 | this.plugin.settings.maxFileCountToDisplay = value; 78 | await this.plugin.saveSettings(); 79 | }) 80 | .setDynamicTooltip(), 81 | ); 82 | 83 | new Setting(containerEl).setHeading().setName(`Use Obsidian's Excluded Files list (Settings > Files & Links)`); 84 | 85 | new Setting(containerEl) 86 | .setName("Outgoing links") 87 | .setDesc( 88 | "If enabled, links FROM files in the excluded folder will not be included in SNW's reference counters. May require restarting Obsidian.", 89 | ) 90 | .addToggle((cb: ToggleComponent) => { 91 | cb.setValue(this.plugin.settings.enableIgnoreObsExcludeFoldersLinksFrom); 92 | cb.onChange(async (value: boolean) => { 93 | this.plugin.settings.enableIgnoreObsExcludeFoldersLinksFrom = value; 94 | await this.plugin.saveSettings(); 95 | }); 96 | }); 97 | 98 | new Setting(containerEl) 99 | .setName("Incoming links") 100 | .setDesc( 101 | "If enabled, links TO files in the excluded folder will not be included in SNW's reference counters. May require restarting Obsidian.", 102 | ) 103 | .addToggle((cb: ToggleComponent) => { 104 | cb.setValue(this.plugin.settings.enableIgnoreObsExcludeFoldersLinksTo); 105 | cb.onChange(async (value: boolean) => { 106 | this.plugin.settings.enableIgnoreObsExcludeFoldersLinksTo = value; 107 | await this.plugin.saveSettings(); 108 | }); 109 | }); 110 | 111 | new Setting(containerEl).setHeading().setName("Properties"); 112 | 113 | new Setting(containerEl).setName("Show references in properties on Desktop").addToggle((cb: ToggleComponent) => { 114 | cb.setValue(this.plugin.settings.displayPropertyReferences); 115 | cb.onChange(async (value: boolean) => { 116 | this.plugin.settings.displayPropertyReferences = value; 117 | await this.plugin.saveSettings(); 118 | }); 119 | }); 120 | 121 | new Setting(containerEl).setName("Show references in properties on mobile").addToggle((cb: ToggleComponent) => { 122 | cb.setValue(this.plugin.settings.displayPropertyReferencesMobile); 123 | cb.onChange(async (value: boolean) => { 124 | this.plugin.settings.displayPropertyReferencesMobile = value; 125 | await this.plugin.saveSettings(); 126 | }); 127 | }); 128 | 129 | new Setting(containerEl).setHeading().setName("View Modes"); 130 | 131 | new Setting(containerEl) 132 | .setName("Incoming Links Header Count") 133 | .setDesc("In header of a document, show number of incoming link to that file.") 134 | .addToggle((cb: ToggleComponent) => { 135 | cb.setValue(this.plugin.settings.displayIncomingFilesheader); 136 | cb.onChange(async (value: boolean) => { 137 | this.plugin.settings.displayIncomingFilesheader = value; 138 | await this.plugin.saveSettings(); 139 | }); 140 | }); 141 | 142 | new Setting(containerEl) 143 | .setName("Show SNW indicators in Live Preview Editor") 144 | .setDesc( 145 | "While using Live Preview, Display inline of the text of documents all reference counts for links, blocks and embeds." + 146 | "Note: files may need to be closed and reopened for this setting to take effect.", 147 | ) 148 | .addToggle((cb: ToggleComponent) => { 149 | cb.setValue(this.plugin.settings.displayInlineReferencesLivePreview); 150 | cb.onChange(async (value: boolean) => { 151 | this.plugin.settings.displayInlineReferencesLivePreview = value; 152 | this.plugin.toggleStateSNWLivePreview(); 153 | await this.plugin.saveSettings(); 154 | }); 155 | }); 156 | 157 | new Setting(containerEl) 158 | .setName("Show SNW indicators in Reading view ") 159 | .setDesc( 160 | "While in Reading View of a document, display inline of the text of documents all reference counts for links, blocks and embeds." + 161 | "Note: files may need to be closed and reopened for this setting to take effect.", 162 | ) 163 | .addToggle((cb: ToggleComponent) => { 164 | cb.setValue(this.plugin.settings.displayInlineReferencesMarkdown); 165 | cb.onChange(async (value: boolean) => { 166 | this.plugin.settings.displayInlineReferencesMarkdown = value; 167 | this.plugin.toggleStateSNWMarkdownPreview(); 168 | await this.plugin.saveSettings(); 169 | }); 170 | }); 171 | 172 | new Setting(containerEl) 173 | .setName("Show SNW indicators in Source Mode ") 174 | .setDesc( 175 | "While in Source Mode of a document, display inline of the text of documents all reference counts for links, blocks and embeds." + 176 | "By default, this is turned off since the goal of Source Mode is to see the raw markdown." + 177 | "Note: files may need to be closed and reopened for this setting to take effect.", 178 | ) 179 | .addToggle((cb: ToggleComponent) => { 180 | cb.setValue(this.plugin.settings.displayInlineReferencesInSourceMode); 181 | cb.onChange(async (value: boolean) => { 182 | this.plugin.settings.displayInlineReferencesInSourceMode = value; 183 | await this.plugin.saveSettings(); 184 | }); 185 | }); 186 | 187 | new Setting(containerEl) 188 | .setName("Embed references in Gutter in Live Preview Mode (Desktop)") 189 | .setDesc( 190 | `Displays a count of references in the gutter while in live preview. This is done only in a 191 | special scenario. It has to do with the way Obsidian renders embeds, example: ![[link]] when 192 | they are on its own line. Strange New Worlds cannot embed the count in this scenario, so a hint is 193 | displayed in the gutter. It is a hack, but at least we get some information.`, 194 | ) 195 | .addToggle((cb: ToggleComponent) => { 196 | cb.setValue(this.plugin.settings.displayEmbedReferencesInGutter); 197 | cb.onChange(async (value: boolean) => { 198 | this.plugin.settings.displayEmbedReferencesInGutter = value; 199 | this.plugin.toggleStateSNWGutters(); 200 | await this.plugin.saveSettings(); 201 | }); 202 | }); 203 | 204 | new Setting(containerEl) 205 | .setName("Embed references in Gutter in Live Preview Mode (Mobile)") 206 | .setDesc("This is off by default on mobile since the gutter takes up some space in the left margin.") 207 | .addToggle((cb: ToggleComponent) => { 208 | cb.setValue(this.plugin.settings.displayEmbedReferencesInGutterMobile); 209 | cb.onChange(async (value: boolean) => { 210 | this.plugin.settings.displayEmbedReferencesInGutterMobile = value; 211 | this.plugin.toggleStateSNWGutters(); 212 | await this.plugin.saveSettings(); 213 | }); 214 | }); 215 | 216 | new Setting(containerEl).setHeading().setName("Enable reference types in Reading Mode"); 217 | containerEl.createEl("sup", { 218 | text: "(requires reopening documents to take effect)", 219 | }); 220 | 221 | new Setting(containerEl) 222 | .setName("Block ID") 223 | .setDesc("Identifies block ID's, for example text blocks that end with a ^ and unique ID for that text block.") 224 | .addToggle((cb: ToggleComponent) => { 225 | cb.setValue(this.plugin.settings.enableRenderingBlockIdInMarkdown); 226 | cb.onChange(async (value: boolean) => { 227 | this.plugin.settings.enableRenderingBlockIdInMarkdown = value; 228 | await this.plugin.saveSettings(); 229 | }); 230 | }); 231 | 232 | new Setting(containerEl) 233 | .setName("Embeds") 234 | .setDesc("Identifies embedded links, that is links that start with an explanation mark. For example: ![[PageName]].") 235 | .addToggle((cb: ToggleComponent) => { 236 | cb.setValue(this.plugin.settings.enableRenderingEmbedsInMarkdown); 237 | cb.onChange(async (value: boolean) => { 238 | this.plugin.settings.enableRenderingEmbedsInMarkdown = value; 239 | await this.plugin.saveSettings(); 240 | }); 241 | }); 242 | 243 | new Setting(containerEl) 244 | .setName("Links") 245 | .setDesc("Identifies links in a document. For example: [[PageName]].") 246 | .addToggle((cb: ToggleComponent) => { 247 | cb.setValue(this.plugin.settings.enableRenderingLinksInMarkdown); 248 | cb.onChange(async (value: boolean) => { 249 | this.plugin.settings.enableRenderingLinksInMarkdown = value; 250 | await this.plugin.saveSettings(); 251 | }); 252 | }); 253 | 254 | new Setting(containerEl) 255 | .setName("Headers") 256 | .setDesc("Identifies headers, that is lines of text that start with a hash mark or multiple hash marks. For example: # Heading 1.") 257 | .addToggle((cb: ToggleComponent) => { 258 | cb.setValue(this.plugin.settings.enableRenderingHeadersInMarkdown); 259 | cb.onChange(async (value: boolean) => { 260 | this.plugin.settings.enableRenderingHeadersInMarkdown = value; 261 | await this.plugin.saveSettings(); 262 | }); 263 | }); 264 | 265 | new Setting(containerEl).setHeading().setName("Enable reference types in Live Preview Mode"); 266 | containerEl.createEl("sup", { 267 | text: "(requires reopening documents to take effect)", 268 | }); 269 | 270 | new Setting(containerEl) 271 | .setName("Block ID") 272 | .setDesc("Identifies block ID's, for example text blocks that end with a ^ and unique ID for that text block.") 273 | .addToggle((cb: ToggleComponent) => { 274 | cb.setValue(this.plugin.settings.enableRenderingBlockIdInLivePreview); 275 | cb.onChange(async (value: boolean) => { 276 | this.plugin.settings.enableRenderingBlockIdInLivePreview = value; 277 | await this.plugin.saveSettings(); 278 | }); 279 | }); 280 | 281 | new Setting(containerEl) 282 | .setName("Embeds") 283 | .setDesc("Identifies embedded links, that is links that start with an explanation mark. For example: ![[PageName]].") 284 | .addToggle((cb: ToggleComponent) => { 285 | cb.setValue(this.plugin.settings.enableRenderingEmbedsInLivePreview); 286 | cb.onChange(async (value: boolean) => { 287 | this.plugin.settings.enableRenderingEmbedsInLivePreview = value; 288 | await this.plugin.saveSettings(); 289 | }); 290 | }); 291 | 292 | new Setting(containerEl) 293 | .setName("Links") 294 | .setDesc("Identifies links in a document. For example: [[PageName]].") 295 | .addToggle((cb: ToggleComponent) => { 296 | cb.setValue(this.plugin.settings.enableRenderingLinksInLivePreview); 297 | cb.onChange(async (value: boolean) => { 298 | this.plugin.settings.enableRenderingLinksInLivePreview = value; 299 | await this.plugin.saveSettings(); 300 | }); 301 | }); 302 | 303 | new Setting(containerEl) 304 | .setName("Headers") 305 | .setDesc("Identifies headers, that is lines of text that start with a hash mark or multiple hash marks. For example: # Heading 1.") 306 | .addToggle((cb: ToggleComponent) => { 307 | cb.setValue(this.plugin.settings.enableRenderingHeadersInLivePreview); 308 | cb.onChange(async (value: boolean) => { 309 | this.plugin.settings.enableRenderingHeadersInLivePreview = value; 310 | await this.plugin.saveSettings(); 311 | }); 312 | }); 313 | 314 | new Setting(containerEl).setHeading().setName("Custom Display Settings"); 315 | 316 | new Setting(this.containerEl) 317 | .setName("Custom Property List") 318 | .setDesc( 319 | "Displays properties from referenced files in the references list. The list is comma separated list of case-sensitive property names.", 320 | ) 321 | .addText((cb) => { 322 | cb.setPlaceholder("Ex: Project, Summary") 323 | .setValue(this.plugin.settings.displayCustomPropertyList) 324 | .onChange(async (list) => { 325 | this.plugin.settings.displayCustomPropertyList = list; 326 | await this.plugin.saveSettings(); 327 | }); 328 | }); 329 | 330 | new Setting(containerEl).setHeading().setName("Support for Other Plugins"); 331 | 332 | new Setting(containerEl) 333 | .setName("Kanban by mgmeyers") 334 | .setDesc( 335 | `Enables SNW support with in the preview mode of the Kanban plugin by mgmeyers at https://github.com/mgmeyers/obsidian-kanban. 336 | SNW references will always show when editing a card. Changing this setting may require reopening files.`, 337 | ) 338 | .addToggle((cb: ToggleComponent) => { 339 | cb.setValue(this.plugin.settings.pluginSupportKanban); 340 | cb.onChange(async (value: boolean) => { 341 | this.plugin.settings.pluginSupportKanban = value; 342 | await this.plugin.saveSettings(); 343 | }); 344 | }); 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /src/ui/SideBarPaneView.tsx: -------------------------------------------------------------------------------- 1 | // Sidepane used by SNW for displaying references 2 | 3 | import { ItemView, type WorkspaceLeaf } from "obsidian"; 4 | import { render } from "preact"; 5 | import { scrollResultsIntoView } from "src/utils"; 6 | import type SNWPlugin from "../main"; 7 | import { getUIC_SidePane } from "./components/uic-ref--parent"; 8 | 9 | export const VIEW_TYPE_SNW = "Strange New Worlds"; 10 | 11 | export class SideBarPaneView extends ItemView { 12 | plugin: SNWPlugin; 13 | 14 | constructor(leaf: WorkspaceLeaf, snnwPlugin: SNWPlugin) { 15 | super(leaf); 16 | this.plugin = snnwPlugin; 17 | } 18 | 19 | getViewType() { 20 | return VIEW_TYPE_SNW; 21 | } 22 | 23 | getDisplayText() { 24 | return VIEW_TYPE_SNW; 25 | } 26 | 27 | getIcon() { 28 | return "file-digit"; 29 | } 30 | 31 | async onOpen() { 32 | render( 33 |
34 |
Discovering Strange New Worlds...
35 |
Click a reference counter in the main document for information to appear here.
36 |
, 37 | this.containerEl.querySelector(".view-content") as HTMLElement, 38 | ); 39 | } 40 | 41 | async updateView() { 42 | const plugin = this.plugin; 43 | 44 | this.containerEl.replaceChildren( 45 | await getUIC_SidePane( 46 | plugin.lastSelectedReferenceType, 47 | plugin.lastSelectedReferenceRealLink, 48 | plugin.lastSelectedReferenceKey, 49 | plugin.lastSelectedReferenceFilePath, 50 | plugin.lastSelectedLineNumber, 51 | ), 52 | ); 53 | 54 | scrollResultsIntoView(this.containerEl); 55 | } 56 | 57 | async onClose() {} 58 | } 59 | -------------------------------------------------------------------------------- /src/ui/SortOrderDropdown.tsx: -------------------------------------------------------------------------------- 1 | import type { FunctionComponent } from "preact"; 2 | import { useEffect, useRef, useState } from "preact/hooks"; 3 | import type SNWPlugin from "src/main"; 4 | import type { SortOption } from "../settings"; 5 | 6 | interface SortOptionUI { 7 | label: string; 8 | icon: string; 9 | } 10 | 11 | const sortOptions: Record = { 12 | "name-asc": { 13 | label: "Name", 14 | icon: '', 15 | }, 16 | "name-desc": { 17 | label: "Name", 18 | icon: '', 19 | }, 20 | "mtime-asc": { 21 | label: "Date", 22 | icon: '', 23 | }, 24 | "mtime-desc": { 25 | label: "Date", 26 | icon: '', 27 | }, 28 | }; 29 | 30 | interface HelpSourceButtonProps { 31 | plugin: SNWPlugin; 32 | onChange: () => void; 33 | } 34 | 35 | export const SortOrderDropdown: FunctionComponent = ({ plugin, onChange }) => { 36 | const [isOpen, setIsOpen] = useState(false); 37 | const menuRef = useRef(null); 38 | 39 | const handleButtonClick = () => { 40 | setIsOpen(!isOpen); 41 | }; 42 | 43 | const handleOptionClick = async (value: SortOption) => { 44 | setIsOpen(false); 45 | plugin.settings.sortOptionDefault = value; 46 | await plugin.saveSettings(); 47 | onChange(); 48 | }; 49 | 50 | useEffect(() => { 51 | const handleClickOutside = (event: MouseEvent) => { 52 | if (menuRef.current && !menuRef.current.contains(event.target as Node)) { 53 | setIsOpen(false); 54 | } 55 | }; 56 | 57 | document.addEventListener("mousedown", handleClickOutside); 58 | return () => { 59 | document.removeEventListener("mousedown", handleClickOutside); 60 | }; 61 | }, []); 62 | 63 | return ( 64 |
65 | 73 | {isOpen && ( 74 |
    75 | {Object.entries(sortOptions).map(([value, { label, icon }]) => ( 76 | // biome-ignore lint/correctness/useJsxKeyInIterable: 77 | // biome-ignore lint/a11y/useKeyWithClickEvents: 78 |
  • { 81 | e.stopPropagation(); 82 | await handleOptionClick(value as SortOption); 83 | }} 84 | class="snw-sort-dropdown-list-item" 85 | > 86 | {/* biome-ignore lint/security/noDangerouslySetInnerHtml: */} 87 | 88 | {label} 89 |
  • 90 | ))} 91 |
92 | )} 93 |
94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /src/ui/components/context/ContextBuilder.ts: -------------------------------------------------------------------------------- 1 | import type { CachedMetadata, HeadingCache, ListItemCache, Pos, SectionCache } from "obsidian"; 2 | import { doesPositionIncludeAnother } from "./position-utils"; 3 | 4 | export class ContextBuilder { 5 | private readonly listItems: ListItemCache[]; 6 | private readonly headings: HeadingCache[]; 7 | private readonly sections: SectionCache[]; 8 | 9 | constructor( 10 | private readonly fileContents: string, 11 | { listItems = [], headings = [], sections = [] }: CachedMetadata, 12 | ) { 13 | this.listItems = listItems; 14 | this.headings = headings; 15 | this.sections = sections; 16 | } 17 | 18 | getListItemIndexContaining = (searchedForPosition: Pos) => { 19 | return this.listItems.findIndex(({ position }) => doesPositionIncludeAnother(position, searchedForPosition)); 20 | }; 21 | 22 | getSectionContaining = (searchedForPosition: Pos) => { 23 | return this.sections.find(({ position }) => doesPositionIncludeAnother(position, searchedForPosition)); 24 | }; 25 | 26 | getListItemWithDescendants = (listItemIndex: number) => { 27 | const rootListItem = this.listItems[listItemIndex]; 28 | const listItemWithDescendants = [rootListItem]; 29 | 30 | for (let i = listItemIndex + 1; i < this.listItems.length; i++) { 31 | const nextItem = this.listItems[i]; 32 | if (nextItem.parent < rootListItem.position.start.line) { 33 | return listItemWithDescendants; 34 | } 35 | listItemWithDescendants.push(nextItem); 36 | } 37 | 38 | return listItemWithDescendants; 39 | }; 40 | 41 | getListBreadcrumbs(position: Pos) { 42 | const listBreadcrumbs: ListItemCache[] = []; 43 | 44 | if (this.listItems.length === 0) { 45 | return listBreadcrumbs; 46 | } 47 | 48 | const thisItemIndex = this.getListItemIndexContaining(position); 49 | const isPositionOutsideListItem = thisItemIndex < 0; 50 | 51 | if (isPositionOutsideListItem) { 52 | return listBreadcrumbs; 53 | } 54 | 55 | const thisItem = this.listItems[thisItemIndex]; 56 | let currentParent = thisItem.parent; 57 | 58 | if (this.isTopLevelListItem(thisItem)) { 59 | return listBreadcrumbs; 60 | } 61 | 62 | for (let i = thisItemIndex - 1; i >= 0; i--) { 63 | const currentItem = this.listItems[i]; 64 | 65 | const currentItemIsHigherUp = currentItem.parent < currentParent; 66 | if (currentItemIsHigherUp) { 67 | listBreadcrumbs.unshift(currentItem); 68 | currentParent = currentItem.parent; 69 | } 70 | 71 | if (this.isTopLevelListItem(currentItem)) { 72 | return listBreadcrumbs; 73 | } 74 | } 75 | 76 | return listBreadcrumbs; 77 | } 78 | 79 | getFirstSectionUnder(position: Pos) { 80 | return this.sections.find((section) => section.position.start.line > position.start.line); 81 | } 82 | 83 | getHeadingContaining(position: Pos) { 84 | const index = this.getHeadingIndexContaining(position); 85 | return this.headings[index]; 86 | } 87 | 88 | getHeadingBreadcrumbs(position: Pos) { 89 | const headingBreadcrumbs: HeadingCache[] = []; 90 | if (this.headings.length === 0) { 91 | return headingBreadcrumbs; 92 | } 93 | 94 | const collectAncestorHeadingsForHeadingAtIndex = (startIndex: number) => { 95 | let currentLevel = this.headings[startIndex].level; 96 | const previousHeadingIndex = startIndex - 1; 97 | 98 | for (let i = previousHeadingIndex; i >= 0; i--) { 99 | const lookingAtHeading = this.headings[i]; 100 | 101 | if (lookingAtHeading.level < currentLevel) { 102 | currentLevel = lookingAtHeading.level; 103 | headingBreadcrumbs.unshift(lookingAtHeading); 104 | } 105 | } 106 | }; 107 | 108 | const headingIndexAtPosition = this.getHeadingIndexContaining(position); 109 | const positionIsInsideHeading = headingIndexAtPosition >= 0; 110 | 111 | if (positionIsInsideHeading) { 112 | collectAncestorHeadingsForHeadingAtIndex(headingIndexAtPosition); 113 | return headingBreadcrumbs; 114 | } 115 | 116 | const headingIndexAbovePosition = this.getIndexOfHeadingAbove(position); 117 | const positionIsBelowHeading = headingIndexAbovePosition >= 0; 118 | 119 | if (positionIsBelowHeading) { 120 | const headingAbovePosition = this.headings[headingIndexAbovePosition]; 121 | headingBreadcrumbs.unshift(headingAbovePosition); 122 | collectAncestorHeadingsForHeadingAtIndex(headingIndexAbovePosition); 123 | return headingBreadcrumbs; 124 | } 125 | 126 | return headingBreadcrumbs; 127 | } 128 | 129 | private isTopLevelListItem(listItem: ListItemCache) { 130 | return listItem.parent <= 0; 131 | } 132 | 133 | private getIndexOfHeadingAbove(position: Pos) { 134 | if (position === undefined) return -1; //added because of properties - need to fix later 135 | return this.headings.reduce( 136 | (previousIndex, lookingAtHeading, index) => (lookingAtHeading.position.start.line < position.start.line ? index : previousIndex), 137 | -1, 138 | ); 139 | } 140 | 141 | private getHeadingIndexContaining(position: Pos) { 142 | if (position === undefined) return -1; //added because of properties - need to fix later 143 | return this.headings.findIndex((heading) => heading.position.start.line === position.start.line); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/ui/components/context/formatting-utils.ts: -------------------------------------------------------------------------------- 1 | import type { HeadingCache, ListItemCache } from "obsidian"; 2 | import { getTextAtPosition, getTextFromLineStartToPositionEnd } from "./position-utils"; 3 | 4 | export const chainBreadcrumbs = (lines: string[]) => 5 | lines 6 | .map((line) => line.trim()) 7 | .filter((line) => line.length > 0) 8 | .join(" ➤ "); 9 | 10 | export const formatListBreadcrumbs = (fileContents: string, breadcrumbs: ListItemCache[]) => 11 | chainBreadcrumbs( 12 | breadcrumbs 13 | .map((listCache) => getTextAtPosition(fileContents, listCache.position)) 14 | .map((listText) => listText.trim().replace(/^-\s+/, "")), 15 | ); 16 | 17 | export const formatListWithDescendants = (textInput: string, listItems: ListItemCache[]) => { 18 | const root = listItems[0]; 19 | const leadingSpacesCount = root.position.start.col; 20 | return listItems 21 | .map((itemCache) => getTextFromLineStartToPositionEnd(textInput, itemCache.position).slice(leadingSpacesCount)) 22 | .join("\n"); 23 | }; 24 | 25 | export const formatHeadingBreadCrumbs = (breadcrumbs: HeadingCache[]) => 26 | chainBreadcrumbs(breadcrumbs.map((headingCache) => headingCache.heading)); 27 | -------------------------------------------------------------------------------- /src/ui/components/context/position-utils.ts: -------------------------------------------------------------------------------- 1 | import type { Pos } from "obsidian"; 2 | 3 | export const getTextAtPosition = (textInput: string, pos: Pos) => textInput.substring(pos.start.offset, pos.end.offset); 4 | 5 | export const getTextFromLineStartToPositionEnd = (textInput: string, pos: Pos) => 6 | textInput.substring(pos.start.offset - pos.start.col, pos.end.offset); 7 | 8 | export const doesPositionIncludeAnother = (container: Pos, child: Pos) => { 9 | try { 10 | //added because of properties - need to fix later 11 | return container.start.offset <= child.start.offset && container.end.offset >= child.end.offset; 12 | } catch (error) { 13 | return false; 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/ui/components/uic-ref--parent.ts: -------------------------------------------------------------------------------- 1 | import { Keymap, MarkdownView, Notice } from "obsidian"; 2 | import type SNWPlugin from "src/main"; 3 | import { scrollResultsIntoView } from "src/utils"; 4 | import type { Instance, ReferenceElement } from "tippy.js"; 5 | import { getUIC_Ref_Area } from "./uic-ref-area"; 6 | import { setPluginVariableUIC_RefItem } from "./uic-ref-item"; 7 | 8 | let plugin: SNWPlugin; 9 | 10 | export function setPluginVariableForUIC(snwPlugin: SNWPlugin) { 11 | plugin = snwPlugin; 12 | setPluginVariableUIC_RefItem(plugin); 13 | } 14 | 15 | /** 16 | * Starting point for the hover popup control. Calls into uic-ref-area, then uic-ref-title and uic-ref-item 17 | * 18 | * @param {Instance} instance the Tippy instance. Tippy provides the floating container. 19 | */ 20 | export const getUIC_Hoverview = async (instance: Instance) => { 21 | const { refType, realLink, key, filePath, lineNu } = await getDataElements(instance); 22 | const popoverEl = createDiv(); 23 | popoverEl.addClass("snw-popover-container"); 24 | popoverEl.addClass("search-result-container"); 25 | popoverEl.appendChild(await getUIC_Ref_Area(refType, realLink, key, filePath, lineNu, true)); 26 | instance.setContent(popoverEl); 27 | setTimeout(async () => { 28 | await setFileLinkHandlers(false, popoverEl); 29 | }, 500); 30 | scrollResultsIntoView(popoverEl); 31 | }; 32 | 33 | // Loads the references into the side pane, using the same logic as the HoverView 34 | export const getUIC_SidePane = async ( 35 | refType: string, 36 | realLink: string, 37 | key: string, 38 | filePath: string, 39 | lineNu: number, 40 | ): Promise => { 41 | const sidepaneEL = createDiv(); 42 | sidepaneEL.addClass("snw-sidepane-container"); 43 | sidepaneEL.addClass("search-result-container"); 44 | sidepaneEL.append(await getUIC_Ref_Area(refType, realLink, key, filePath, lineNu, false)); 45 | 46 | setTimeout(async () => { 47 | await setFileLinkHandlers(false, sidepaneEL); 48 | }, 500); 49 | 50 | return sidepaneEL; 51 | }; 52 | 53 | // Creates event handlers for components of the HoverView and sidepane 54 | export const setFileLinkHandlers = async (isHoverView: boolean, rootElementForViewEl: HTMLElement) => { 55 | const linksToFiles: NodeList = rootElementForViewEl.querySelectorAll( 56 | ".snw-ref-item-file, .snw-ref-item-info, .snw-ref-title-popover-label", 57 | ); 58 | // biome-ignore lint/complexity/noForEach: 59 | linksToFiles.forEach((node: Element) => { 60 | if (!node.getAttribute("snw-has-handler")) { 61 | node.setAttribute("snw-has-handler", "true"); //prevent the event from being added twice 62 | // CLICK event 63 | node.addEventListener("click", async (e: MouseEvent) => { 64 | e.preventDefault(); 65 | const handlerElement = (e.target as HTMLElement).closest(".snw-ref-item-file, .snw-ref-item-info, .snw-ref-title-popover-label"); 66 | if (!handlerElement) return; 67 | let lineNu = Number(handlerElement.getAttribute("snw-data-line-number")); 68 | const filePath = handlerElement.getAttribute("snw-data-file-name"); 69 | const fileT = app.metadataCache.getFirstLinkpathDest(filePath, filePath); 70 | 71 | if (!fileT) { 72 | new Notice(`File not found: ${filePath}. It may be a broken link.`); 73 | return; 74 | } 75 | 76 | plugin.app.workspace.getLeaf(Keymap.isModEvent(e)).openFile(fileT); 77 | 78 | // for file titles, the embed handling for titles related to block id's and headers is hard to calculate, so its more efficient to do it here 79 | const titleKey = handlerElement.getAttribute("snw-ref-title-key"); 80 | if (titleKey) { 81 | if (titleKey.contains("#^")) { 82 | // links to a block id 83 | const destinationBlocks = Object.entries(plugin.app.metadataCache.getFileCache(fileT)?.blocks); 84 | if (destinationBlocks) { 85 | // @ts-ignore 86 | const blockID = titleKey 87 | .match(/#\^(.+)$/g)[0] 88 | .replace("#^", "") 89 | .toLowerCase(); 90 | const l = destinationBlocks.find((b) => b[0] === blockID); 91 | lineNu = l[1].position.start.line; 92 | } 93 | } else if (titleKey.contains("#")) { 94 | // possibly links to a header 95 | const destinationHeadings = plugin.app.metadataCache.getFileCache(fileT)?.headings; 96 | if (destinationHeadings) { 97 | // @ts-ignore 98 | const headingKey = titleKey.match(/#(.+)/g)[0].replace("#", ""); 99 | const l = destinationHeadings.find((h) => h.heading.toLocaleUpperCase() === headingKey); 100 | // @ts-ignore 101 | lineNu = l.position.start.line; 102 | } 103 | } 104 | } 105 | 106 | if (lineNu > 0) { 107 | setTimeout(() => { 108 | // jumps to the line of the file where the reference is located 109 | try { 110 | const activeView = plugin.app.workspace.getActiveViewOfType(MarkdownView); 111 | if (activeView) activeView.setEphemeralState({ line: lineNu }); 112 | } catch (error) {} 113 | }, 400); 114 | } 115 | }); 116 | // mouseover event 117 | if (plugin.app.internalPlugins.plugins["page-preview"].enabled === true) { 118 | // @ts-ignore 119 | node.addEventListener("mouseover", (e: PointerEvent) => { 120 | e.preventDefault(); 121 | const hoverMetaKeyRequired = 122 | // @ts-ignore 123 | app.internalPlugins.plugins["page-preview"].instance.overrides["obsidian42-strange-new-worlds"] !== false; 124 | if (hoverMetaKeyRequired === false || (hoverMetaKeyRequired === true && Keymap.isModifier(e, "Mod"))) { 125 | const target = e.target as HTMLElement; 126 | const previewLocation = { 127 | scroll: Number(target.getAttribute("snw-data-line-number")), 128 | }; 129 | const filePath = target.getAttribute("snw-data-file-name"); 130 | if (filePath) { 131 | // parameter signature for link-hover parent: HoverParent, targetEl: HTMLElement, linkText: string, sourcePath: string, eState: EphemeralState 132 | app.workspace.trigger("link-hover", {}, target, filePath, "", previewLocation); 133 | } 134 | } 135 | }); 136 | } 137 | } 138 | }); 139 | }; 140 | 141 | // Utility function to extact key data points from the Tippy instance 142 | const getDataElements = async ( 143 | instance: Instance, 144 | ): Promise<{ 145 | refType: string; 146 | realLink: string; 147 | key: string; 148 | filePath: string; 149 | lineNu: number; 150 | }> => { 151 | const parentElement: ReferenceElement = instance.reference; 152 | const refType = parentElement.getAttribute("data-snw-type") || ""; 153 | const realLink = parentElement.getAttribute("data-snw-reallink") || ""; 154 | const key = parentElement.getAttribute("data-snw-key") || ""; 155 | const path = parentElement.getAttribute("data-snw-filepath") || ""; 156 | const lineNum = Number(parentElement.getAttribute("snw-data-line-number")) || 0; 157 | return { 158 | refType: refType, 159 | realLink: realLink, 160 | key: key, 161 | filePath: path, 162 | lineNu: lineNum, 163 | }; 164 | }; 165 | -------------------------------------------------------------------------------- /src/ui/components/uic-ref-area.tsx: -------------------------------------------------------------------------------- 1 | //wrapper element for references area. shared between popover and sidepane 2 | 3 | import { setIcon } from "obsidian"; 4 | import { render } from "preact"; 5 | import { getIndexedReferences } from "src/indexer"; 6 | import type SNWPlugin from "src/main"; 7 | import type { Link } from "src/types"; 8 | import type { SortOption } from "../../settings"; 9 | import { setFileLinkHandlers } from "./uic-ref--parent"; 10 | import { getUIC_Ref_Item } from "./uic-ref-item"; 11 | import { getUIC_Ref_Title_Div } from "./uic-ref-title"; 12 | 13 | let plugin: SNWPlugin; 14 | 15 | export function setPluginVariableUIC_RefArea(snwPlugin: SNWPlugin) { 16 | plugin = snwPlugin; 17 | } 18 | 19 | //Creates the primarhy "AREA" body for displaying refrences. This is the overall wrapper for the title and individaul references 20 | export const getUIC_Ref_Area = async ( 21 | refType: string, 22 | realLink: string, 23 | key: string, 24 | filePath: string, 25 | lineNu: number, 26 | isHoverView: boolean, 27 | ): Promise => { 28 | const refAreaItems = await getRefAreaItems(refType, key, filePath); 29 | const refAreaContainerEl = createDiv(); 30 | 31 | //get title header for this reference area 32 | refAreaContainerEl.append( 33 | getUIC_Ref_Title_Div(refType, realLink, key, filePath, refAreaItems.refCount, lineNu, isHoverView, plugin, async () => { 34 | // Callback to re-render the references area when the sort option is changed 35 | const refAreaEl: HTMLElement | null = refAreaContainerEl.querySelector(".snw-ref-area"); 36 | if (refAreaEl) { 37 | refAreaEl.style.visibility = "hidden"; 38 | while (refAreaEl.firstChild) { 39 | refAreaEl.removeChild(refAreaEl.firstChild); 40 | } 41 | refAreaEl.style.visibility = "visible"; 42 | const refAreaItems = await getRefAreaItems(refType, key, filePath); 43 | refAreaEl.prepend(refAreaItems.response); 44 | 45 | setTimeout(async () => { 46 | await setFileLinkHandlers(false, refAreaEl); 47 | }, 500); 48 | } 49 | }), 50 | ); 51 | 52 | const refAreaEl = createDiv({ cls: "snw-ref-area" }); 53 | refAreaEl.append(refAreaItems.response); 54 | refAreaContainerEl.append(refAreaEl); 55 | 56 | return refAreaContainerEl; 57 | }; 58 | 59 | const sortLinks = (links: Link[], option: SortOption): Link[] => { 60 | return links.sort((a, b) => { 61 | const fileA = a.sourceFile; 62 | const fileB = b.sourceFile; 63 | switch (option) { 64 | case "name-asc": 65 | return fileA?.basename.localeCompare(fileB?.basename); 66 | case "name-desc": 67 | return fileB?.basename.localeCompare(fileA?.basename); 68 | case "mtime-asc": 69 | return fileA?.stat.mtime - fileB?.stat.mtime; 70 | case "mtime-desc": 71 | return fileB?.stat.mtime - fileA?.stat.mtime; 72 | default: 73 | return 0; 74 | } 75 | }); 76 | }; 77 | 78 | // Creates a DIV for a collection of reference blocks to be displayed 79 | const getRefAreaItems = async (refType: string, key: string, filePath: string): Promise<{ response: HTMLElement; refCount: number }> => { 80 | let countOfRefs = 0; 81 | let linksToLoop: Link[] = null; 82 | 83 | if (refType === "File") { 84 | const allLinks: Link[] = getIndexedReferences(); 85 | const incomingLinks = []; 86 | for (const items of allLinks.values()) { 87 | for (const item of items) { 88 | if (item?.resolvedFile && item?.resolvedFile?.path === filePath) incomingLinks.push(item); 89 | } 90 | } 91 | 92 | countOfRefs = incomingLinks.length; 93 | linksToLoop = incomingLinks; 94 | } else { 95 | let refCache: Link[] = getIndexedReferences().get(key); 96 | if (refCache === undefined) refCache = getIndexedReferences().get(key); 97 | const sortedCache = await sortRefCache(refCache); 98 | countOfRefs = sortedCache.length; 99 | linksToLoop = sortedCache; 100 | } 101 | 102 | // get the unique file names for files in thie refeernces 103 | const uniqueFileKeys: Link[] = Array.from(new Set(linksToLoop.map((a: Link) => a.sourceFile?.path))).map((file_path) => { 104 | return linksToLoop.find((a) => a.sourceFile?.path === file_path); 105 | }); 106 | 107 | const sortedFileKeys = sortLinks(uniqueFileKeys, plugin.settings.sortOptionDefault); 108 | 109 | const wrapperEl = createDiv(); 110 | 111 | let maxItemsToShow = plugin.settings.maxFileCountToDisplay; 112 | 113 | if (countOfRefs < maxItemsToShow) { 114 | maxItemsToShow = countOfRefs; 115 | } 116 | 117 | let itemsDisplayedCounter = 0; 118 | 119 | let customProperties = null; 120 | if (plugin.settings.displayCustomPropertyList.trim() !== "") 121 | customProperties = plugin.settings.displayCustomPropertyList.split(",").map((x) => x.trim()); 122 | 123 | for (let index = 0; index < sortedFileKeys.length; index++) { 124 | if (itemsDisplayedCounter > maxItemsToShow) continue; 125 | const file_path = sortedFileKeys[index]; 126 | const responseItemContainerEl = createDiv(); 127 | responseItemContainerEl.addClass("snw-ref-item-container"); 128 | responseItemContainerEl.addClass("tree-item"); 129 | 130 | wrapperEl.appendChild(responseItemContainerEl); 131 | 132 | const refItemFileEl = createDiv(); 133 | refItemFileEl.addClass("snw-ref-item-file"); 134 | refItemFileEl.addClass("tree-item-self"); 135 | refItemFileEl.addClass("search-result-file-title"); 136 | refItemFileEl.addClass("is-clickable"); 137 | refItemFileEl.setAttribute("snw-data-line-number", "-1"); 138 | refItemFileEl.setAttribute("snw-data-file-name", file_path.sourceFile.path); 139 | refItemFileEl.setAttribute("data-href", file_path.sourceFile.path); 140 | refItemFileEl.setAttribute("href", file_path.sourceFile.path); 141 | 142 | const refItemFileIconEl = createDiv(); 143 | refItemFileIconEl.addClass("snw-ref-item-file-icon"); 144 | refItemFileIconEl.addClass("tree-item-icon"); 145 | refItemFileIconEl.addClass("collapse-icon"); 146 | setIcon(refItemFileIconEl, "file-box"); 147 | 148 | const refItemFileLabelEl = createDiv(); 149 | refItemFileLabelEl.addClass("snw-ref-item-file-label"); 150 | refItemFileLabelEl.addClass("tree-item-inner"); 151 | refItemFileLabelEl.innerText = file_path.sourceFile.basename; 152 | 153 | refItemFileEl.append(refItemFileIconEl); 154 | refItemFileEl.append(refItemFileLabelEl); 155 | 156 | responseItemContainerEl.appendChild(refItemFileEl); 157 | 158 | // Add custom property field to display 159 | if (customProperties != null) { 160 | const fileCache = file_path.sourceFile ? plugin.app.metadataCache.getFileCache(file_path.sourceFile) : null; 161 | 162 | // biome-ignore lint/complexity/noForEach: 163 | customProperties.forEach((propName) => { 164 | const propValue = fileCache?.frontmatter?.[propName]; 165 | if (propValue) { 166 | const customPropertyElement = ( 167 |
168 | {propName} 169 | : {propValue} 170 |
171 | ); 172 | const fieldEl = createDiv(); 173 | render(customPropertyElement, fieldEl); 174 | refItemFileLabelEl.append(fieldEl); 175 | } 176 | }); 177 | } 178 | 179 | const refItemsCollectionE = createDiv(); 180 | refItemsCollectionE.addClass("snw-ref-item-collection-items"); 181 | refItemsCollectionE.addClass("search-result-file-matches"); 182 | responseItemContainerEl.appendChild(refItemsCollectionE); 183 | 184 | for (const ref of linksToLoop) { 185 | if (file_path.sourceFile?.path === ref.sourceFile?.path && itemsDisplayedCounter < maxItemsToShow) { 186 | itemsDisplayedCounter += 1; 187 | refItemsCollectionE.appendChild(await getUIC_Ref_Item(ref)); 188 | } 189 | } 190 | } 191 | 192 | return { response: wrapperEl, refCount: countOfRefs }; 193 | }; 194 | 195 | const sortRefCache = async (refCache: Link[]): Promise => { 196 | return refCache.sort((a, b) => { 197 | let positionA = 0; //added because of properties - need to fix later 198 | if (a.reference.position !== undefined) positionA = Number(a.reference.position.start.line); 199 | 200 | let positionB = 0; //added because of properties - need to fix later 201 | if (b.reference.position !== undefined) positionB = Number(b.reference.position.start.line); 202 | 203 | return a.sourceFile?.basename.localeCompare(b.sourceFile.basename) || Number(positionA) - Number(positionB); 204 | }); 205 | }; 206 | -------------------------------------------------------------------------------- /src/ui/components/uic-ref-item.tsx: -------------------------------------------------------------------------------- 1 | // Component creates an individual reference item 2 | 3 | import { MarkdownRenderer } from "obsidian"; 4 | import { render } from "preact"; 5 | import type SNWPlugin from "src/main"; 6 | import type { Link } from "../../types"; 7 | import { ContextBuilder } from "./context/ContextBuilder"; 8 | import { formatHeadingBreadCrumbs, formatListBreadcrumbs, formatListWithDescendants } from "./context/formatting-utils"; 9 | import { getTextAtPosition } from "./context/position-utils"; 10 | 11 | let plugin: SNWPlugin; 12 | 13 | export function setPluginVariableUIC_RefItem(snwPlugin: SNWPlugin) { 14 | plugin = snwPlugin; 15 | } 16 | 17 | export const getUIC_Ref_Item = async (ref: Link): Promise => { 18 | const startLine = ref.reference.position !== undefined ? ref.reference.position.start.line.toString() : "0"; 19 | 20 | const itemElJsx = ( 21 |
27 | dangerouslySetInnerHTML={{ 28 | __html: (await grabChunkOfFile(ref)).innerHTML, 29 | }} 30 | /> 31 | ); 32 | 33 | const itemEl = createDiv(); 34 | render(itemElJsx, itemEl); 35 | 36 | return itemEl; 37 | }; 38 | 39 | /** 40 | * Grabs a block from a file, then runs it through a markdown render 41 | * 42 | * @param {Link} ref 43 | * @return {*} {Promise} 44 | */ 45 | const grabChunkOfFile = async (ref: Link): Promise => { 46 | const fileContents = await plugin.app.vault.cachedRead(ref.sourceFile); 47 | const fileCache = plugin.app.metadataCache.getFileCache(ref.sourceFile); 48 | const linkPosition = ref.reference.position; 49 | 50 | const container = createDiv(); 51 | container.setAttribute("uic", "uic"); //used to track if this is UIC element. 52 | 53 | if (ref.reference?.key) { 54 | container.innerText = `Used in property: ${ref.reference.key}`; 55 | return container; 56 | } 57 | const contextBuilder = new ContextBuilder(fileContents, fileCache); 58 | 59 | const headingBreadcrumbs = contextBuilder.getHeadingBreadcrumbs(linkPosition); 60 | if (headingBreadcrumbs.length > 0) { 61 | const headingBreadcrumbsEl = container.createDiv(); 62 | headingBreadcrumbsEl.addClass("snw-breadcrumbs"); 63 | 64 | headingBreadcrumbsEl.createEl("span", { text: "H" }); 65 | 66 | await MarkdownRenderer.render( 67 | plugin.app, 68 | formatHeadingBreadCrumbs(headingBreadcrumbs), 69 | headingBreadcrumbsEl, 70 | ref.sourceFile.path, 71 | plugin, 72 | ); 73 | } 74 | 75 | const indexOfListItemContainingLink = contextBuilder.getListItemIndexContaining(linkPosition); 76 | const isLinkInListItem = indexOfListItemContainingLink >= 0; 77 | 78 | if (isLinkInListItem) { 79 | const listBreadcrumbs = contextBuilder.getListBreadcrumbs(linkPosition); 80 | 81 | if (listBreadcrumbs.length > 0) { 82 | const contextEl = container.createDiv(); 83 | contextEl.addClass("snw-breadcrumbs"); 84 | 85 | contextEl.createEl("span", { text: "L" }); 86 | 87 | await MarkdownRenderer.render( 88 | plugin.app, 89 | formatListBreadcrumbs(fileContents, listBreadcrumbs), 90 | contextEl, 91 | ref.sourceFile.path, 92 | plugin, 93 | ); 94 | } 95 | 96 | const listItemWithDescendants = contextBuilder.getListItemWithDescendants(indexOfListItemContainingLink); 97 | 98 | const contextEl = container.createDiv(); 99 | await MarkdownRenderer.render( 100 | plugin.app, 101 | formatListWithDescendants(fileContents, listItemWithDescendants), 102 | contextEl, 103 | ref.sourceFile.path, 104 | plugin, 105 | ); 106 | } else { 107 | const sectionContainingLink = contextBuilder.getSectionContaining(linkPosition); 108 | 109 | let blockContents = ""; 110 | 111 | if (sectionContainingLink?.position !== undefined) blockContents = getTextAtPosition(fileContents, sectionContainingLink.position); 112 | 113 | const regex = /^\[\^([\w]+)\]:(.*)$/; 114 | if (regex.test(blockContents)) blockContents = blockContents.replace("[", "").replace("]:", ""); 115 | 116 | await MarkdownRenderer.render(plugin.app, blockContents, container, ref.sourceFile.path, plugin); 117 | } 118 | 119 | const headingThatContainsLink = contextBuilder.getHeadingContaining(linkPosition); 120 | if (headingThatContainsLink) { 121 | const firstSectionPosition = contextBuilder.getFirstSectionUnder(headingThatContainsLink.position); 122 | if (firstSectionPosition) { 123 | const contextEl = container.createDiv(); 124 | await MarkdownRenderer.render( 125 | plugin.app, 126 | getTextAtPosition(fileContents, firstSectionPosition.position), 127 | contextEl, 128 | ref.sourceFile.path, 129 | plugin, 130 | ); 131 | } 132 | } 133 | 134 | // add highlight to the link 135 | const elems = container.querySelectorAll("*"); 136 | const res = Array.from(elems).find((v) => v.textContent === ref.reference.displayText); 137 | try { 138 | // this fails in some edge cases, so in that case, just ignore 139 | res.addClass("search-result-file-matched-text"); 140 | } catch (error) { 141 | //@ts-ignore 142 | } 143 | 144 | return container; 145 | }; 146 | -------------------------------------------------------------------------------- /src/ui/components/uic-ref-title.tsx: -------------------------------------------------------------------------------- 1 | // Component to display the title at the top of a uic-ref-area 2 | 3 | import { render } from "preact"; 4 | import type SNWPlugin from "src/main"; 5 | import { hideAll } from "tippy.js"; 6 | import { IconMoreDetails } from "../IconMoreDetails"; 7 | import { SortOrderDropdown } from "../SortOrderDropdown"; 8 | 9 | export const getUIC_Ref_Title_Div = ( 10 | refType: string, 11 | realLink: string, 12 | key: string, 13 | filePath: string, 14 | refCount: number, 15 | lineNu: number, 16 | isPopover: boolean, 17 | plugin: SNWPlugin, 18 | handleSortOptionChangeCallback: () => void, 19 | ): HTMLElement => { 20 | const titleElJsx = ( 21 |
22 |
30 | {realLink} 31 |
32 | 33 | {isPopover && ( 34 | 42 | {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} 43 | { 46 | e.stopPropagation(); 47 | hideAll({ duration: 0 }); // hide popup 48 | plugin.activateView(refType, realLink, key, filePath, Number(lineNu)); 49 | }} 50 | > 51 | 52 | 53 | 54 | )} 55 |
56 | ); 57 | 58 | const titleEl = createDiv(); 59 | render(titleElJsx, titleEl); 60 | 61 | return titleEl; 62 | }; 63 | -------------------------------------------------------------------------------- /src/ui/frontmatterRefCount.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownView, Platform, View, type WorkspaceLeaf, debounce } from "obsidian"; 2 | import { getSNWCacheByFile } from "src/indexer"; 3 | import { htmlDecorationForReferencesElement } from "src/view-extensions/htmlDecorations"; 4 | import type SNWPlugin from "../main"; 5 | import { UPDATE_DEBOUNCE } from "../main"; 6 | import type { TransformedCachedItem } from "../types"; 7 | 8 | let plugin: SNWPlugin; 9 | 10 | export function setPluginVariableForFrontmatterLinksRefCount(snwPlugin: SNWPlugin) { 11 | plugin = snwPlugin; 12 | } 13 | 14 | // Iterates all open documents to see if they are markdown file, and if so called processHeader 15 | function setFrontmatterLinksReferenceCounts() { 16 | plugin.app.workspace.iterateAllLeaves((leaf: WorkspaceLeaf) => { 17 | if (leaf.view.getViewType() === "markdown" || leaf.view.getViewType() === "file-properties") processFrontmatterLinks(leaf.view); 18 | }); 19 | } 20 | 21 | export const updatePropertiesDebounce = debounce( 22 | () => { 23 | setFrontmatterLinksReferenceCounts(); 24 | }, 25 | UPDATE_DEBOUNCE, 26 | true, 27 | ); 28 | 29 | function processFrontmatterLinks(mdView: View) { 30 | if (!plugin.showCountsActive) return; 31 | const state = 32 | Platform.isMobile || Platform.isMobileApp ? plugin.settings.displayPropertyReferencesMobile : plugin.settings.displayPropertyReferences; 33 | 34 | const markdownView = mdView as MarkdownView; 35 | if (!state || !markdownView?.rawFrontmatter) return; 36 | 37 | const transformedCache = markdownView.file ? getSNWCacheByFile(markdownView.file) : {}; 38 | if (!transformedCache.frontmatterLinks?.length) return; 39 | 40 | for (const item of markdownView.metadataEditor.rendered) { 41 | const innerLink = item.valueEl.querySelector(".metadata-link-inner.internal-link") as HTMLElement; 42 | if (innerLink) { 43 | const innerLinkText = innerLink.textContent; 44 | const fmMatch = transformedCache.frontmatterLinks?.find((item) => item.displayText === innerLinkText); 45 | if (fmMatch) appendRefCounter(innerLink as HTMLElement, fmMatch); 46 | } 47 | 48 | const pillLinks = item.valueEl.querySelectorAll(".multi-select-pill.internal-link .multi-select-pill-content span"); 49 | if (!pillLinks.length) continue; 50 | for (const pill of Array.from(pillLinks) as HTMLElement[]) { 51 | const pillText = pill.textContent; 52 | const fmMatch = transformedCache.frontmatterLinks?.find((item) => item.displayText === pillText); 53 | const parent = pill.parentElement; 54 | if (fmMatch && parent) appendRefCounter(parent, fmMatch); 55 | } 56 | } 57 | } 58 | 59 | function appendRefCounter(parentLink: HTMLElement, cacheItem: TransformedCachedItem) { 60 | let wrapperEl = parentLink.parentElement?.querySelector(".snw-frontmatter-wrapper"); 61 | const refCount = cacheItem.references.length; 62 | 63 | if (!wrapperEl && refCount >= plugin.settings.minimumRefCountThreshold) { 64 | wrapperEl = createSpan({ cls: "snw-frontmatter-wrapper" }); 65 | const htmlCounter = htmlDecorationForReferencesElement( 66 | refCount, 67 | "link", 68 | cacheItem.references[0].realLink, 69 | cacheItem.key, 70 | cacheItem.references[0]?.resolvedFile?.path ?? "", 71 | "snw-frontmatter-count", 72 | cacheItem.pos.start.line, 73 | ); 74 | wrapperEl.appendChild(htmlCounter); 75 | parentLink.insertAdjacentElement("afterend", wrapperEl); 76 | } else { 77 | try { 78 | //update the existing wrapper with current count, otherwise if the count fell below the threshold, remove it 79 | if (refCount >= plugin.settings.minimumRefCountThreshold) { 80 | const countElement = wrapperEl?.querySelector(".snw-frontmatter-count") as HTMLElement | null; 81 | if (countElement) { 82 | countElement.innerText = ` ${refCount} `; 83 | } 84 | } else { 85 | wrapperEl?.remove(); 86 | } 87 | } catch (error) {} 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/ui/headerRefCount.ts: -------------------------------------------------------------------------------- 1 | // Displays in the header of open documents the count of incoming links 2 | 3 | import { type MarkdownView, Platform, type WorkspaceLeaf, debounce } from "obsidian"; 4 | import tippy from "tippy.js"; 5 | import { getIndexedReferences, getSNWCacheByFile } from "../indexer"; 6 | import type SNWPlugin from "../main"; 7 | import { UPDATE_DEBOUNCE } from "../main"; 8 | import { processHtmlDecorationReferenceEvent } from "../view-extensions/htmlDecorations"; 9 | import "tippy.js/dist/tippy.css"; 10 | import { getUIC_Hoverview } from "./components/uic-ref--parent"; 11 | 12 | let plugin: SNWPlugin; 13 | 14 | export function setPluginVariableForHeaderRefCount(snwPlugin: SNWPlugin) { 15 | plugin = snwPlugin; 16 | } 17 | 18 | // Iterates all open documents to see if they are markdown file, and if so called processHeader 19 | function setHeaderWithReferenceCounts() { 20 | if (!plugin.settings.displayIncomingFilesheader || !plugin.showCountsActive) return; 21 | plugin.app.workspace.iterateAllLeaves((leaf: WorkspaceLeaf) => { 22 | if (leaf.view.getViewType() === "markdown") processHeader(leaf.view as MarkdownView); 23 | }); 24 | } 25 | 26 | export const updateHeadersDebounce = debounce( 27 | () => { 28 | setHeaderWithReferenceCounts(); 29 | }, 30 | UPDATE_DEBOUNCE, 31 | true, 32 | ); 33 | 34 | // Analyzes the page and if there is incoming links displays a header message 35 | function processHeader(mdView: MarkdownView) { 36 | const mdViewFile = mdView.file; 37 | if (!mdViewFile) return; 38 | const allLinks = getIndexedReferences(); 39 | 40 | let incomingLinksCount = 0; 41 | for (const items of allLinks.values()) { 42 | for (const item of items) { 43 | if (item?.resolvedFile && item?.resolvedFile?.path === mdViewFile.path) { 44 | incomingLinksCount++; 45 | } 46 | } 47 | } 48 | 49 | // check if the page is to be ignored 50 | const transformedCache = getSNWCacheByFile(mdViewFile); 51 | if (transformedCache?.cacheMetaData?.frontmatter?.["snw-file-exclude"] === true) incomingLinksCount = 0; 52 | 53 | // if no incoming links, check if there is a header and remove it. In all cases, exit roturin 54 | if (incomingLinksCount < 1) { 55 | const headerCountWrapper = mdView.contentEl.querySelector(".snw-header-count-wrapper"); 56 | if (headerCountWrapper) headerCountWrapper.remove(); 57 | return; 58 | } 59 | 60 | let snwTitleRefCountDisplayCountEl: HTMLElement | null = mdView.contentEl.querySelector(".snw-header-count"); 61 | 62 | // header count is already displayed, just update information. 63 | if (snwTitleRefCountDisplayCountEl && snwTitleRefCountDisplayCountEl.dataset.snwKey === mdViewFile.basename) { 64 | snwTitleRefCountDisplayCountEl.innerText = ` ${incomingLinksCount.toString()} `; 65 | return; 66 | } 67 | 68 | // ad new header count 69 | const containerViewContent: HTMLElement = mdView.contentEl; 70 | 71 | let wrapper: HTMLElement | null = containerViewContent.querySelector(".snw-header-count-wrapper"); 72 | 73 | if (!wrapper) { 74 | wrapper = createDiv({ cls: "snw-reference snw-header-count-wrapper" }); 75 | snwTitleRefCountDisplayCountEl = createDiv({ cls: "snw-header-count" }); 76 | wrapper.appendChild(snwTitleRefCountDisplayCountEl); 77 | containerViewContent.prepend(wrapper); 78 | } else { 79 | snwTitleRefCountDisplayCountEl = containerViewContent.querySelector(".snw-header-count"); 80 | } 81 | 82 | if (snwTitleRefCountDisplayCountEl) snwTitleRefCountDisplayCountEl.innerText = ` ${incomingLinksCount.toString()} `; 83 | if ((Platform.isDesktop || Platform.isDesktopApp) && snwTitleRefCountDisplayCountEl) { 84 | snwTitleRefCountDisplayCountEl.onclick = (e: MouseEvent) => { 85 | e.stopPropagation(); 86 | if (wrapper) processHtmlDecorationReferenceEvent(wrapper); 87 | }; 88 | } 89 | wrapper.setAttribute("data-snw-reallink", mdViewFile.basename); 90 | wrapper.setAttribute("data-snw-key", mdViewFile.basename); 91 | wrapper.setAttribute("data-snw-type", "File"); 92 | wrapper.setAttribute("data-snw-filepath", mdViewFile.path); 93 | 94 | wrapper.onclick = (e: MouseEvent) => { 95 | e.stopPropagation(); 96 | processHtmlDecorationReferenceEvent(e.target as HTMLElement); 97 | }; 98 | 99 | // defaults to showing tippy on hover, but if plugin.settings.requireModifierKeyToActivateSNWView is true, then only show on ctrl/meta key 100 | let showTippy = true; 101 | const tippyObject = tippy(wrapper, { 102 | interactive: true, 103 | appendTo: () => document.body, 104 | allowHTML: true, 105 | zIndex: 9999, 106 | placement: "auto-end", 107 | onTrigger(instance, event) { 108 | const mouseEvent = event as MouseEvent; 109 | if (plugin.settings.requireModifierKeyToActivateSNWView === false) return; 110 | if (mouseEvent.ctrlKey || mouseEvent.metaKey) { 111 | showTippy = true; 112 | } else { 113 | showTippy = false; 114 | } 115 | }, 116 | onShow(instance) { 117 | // returning false will cancel the show (coming from onTrigger) 118 | if (!showTippy) return false; 119 | 120 | setTimeout(async () => { 121 | await getUIC_Hoverview(instance); 122 | }, 1); 123 | }, 124 | }); 125 | 126 | tippyObject.popper.classList.add("snw-tippy"); 127 | } 128 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | // pulled from here: https://github.com/slindberg/jquery-scrollparent/blob/master/jquery.scrollparent.js 2 | const getScrollParent = (element: HTMLElement, includeHidden: boolean): HTMLElement => { 3 | let style = getComputedStyle(element); 4 | const excludeStaticParent = style.position === "absolute"; 5 | const overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/; 6 | 7 | if (style.position === "fixed") return document.body; 8 | for (let parent: HTMLElement | null = element.parentElement; parent; parent = parent.parentElement) { 9 | style = getComputedStyle(parent); 10 | if (!excludeStaticParent || style.position !== "static") { 11 | if (overflowRegex.test(style.overflow + style.overflowY + style.overflowX)) { 12 | return parent; 13 | } 14 | } 15 | } 16 | 17 | return document.body; 18 | }; 19 | 20 | const scrollResultsIntoView = (resultContainerEl: HTMLElement): void => { 21 | const searchResults = resultContainerEl.querySelectorAll(".search-result-file-matched-text"); 22 | for (const searchResult of Array.from(searchResults)) { 23 | if (searchResult instanceof HTMLElement) { 24 | const scrollParent = getScrollParent(searchResult, true) as HTMLElement; 25 | if (scrollParent) { 26 | scrollParent.scrollTop = searchResult.offsetTop - scrollParent.offsetTop - scrollParent.offsetHeight / 2; 27 | } 28 | } 29 | } 30 | }; 31 | 32 | export { getScrollParent, scrollResultsIntoView }; 33 | -------------------------------------------------------------------------------- /src/view-extensions/gutters-cm6.ts: -------------------------------------------------------------------------------- 1 | import { GutterMarker, gutter } from "@codemirror/view"; 2 | import type { BlockInfo, EditorView } from "@codemirror/view"; 3 | import { editorInfoField } from "obsidian"; 4 | import { getSNWCacheByFile, parseLinkTextToFullPath } from "src/indexer"; 5 | import type SNWPlugin from "src/main"; 6 | import { htmlDecorationForReferencesElement } from "src/view-extensions/htmlDecorations"; 7 | 8 | let plugin: SNWPlugin; 9 | 10 | export function setPluginVariableForCM6Gutter(snwPlugin: SNWPlugin) { 11 | plugin = snwPlugin; 12 | } 13 | 14 | const referenceGutterMarker = class extends GutterMarker { 15 | referenceCount: number; 16 | referenceType: string; 17 | key: string; //a unique identifier for the reference 18 | realLink: string; 19 | filePath: string; 20 | addCssClass: string; //if a reference need special treatment, this class can be assigned 21 | 22 | constructor(refCount: number, cssclass: string, realLink: string, key: string, filePath: string, addCSSClass: string) { 23 | super(); 24 | this.referenceCount = refCount; 25 | this.referenceType = cssclass; 26 | this.realLink = realLink; 27 | this.key = key; 28 | this.filePath = filePath; 29 | this.addCssClass = addCSSClass; 30 | } 31 | 32 | toDOM() { 33 | return htmlDecorationForReferencesElement( 34 | this.referenceCount, 35 | this.referenceType, 36 | this.realLink, 37 | this.key, 38 | this.filePath, 39 | this.addCssClass, 40 | 0, 41 | ); 42 | } 43 | }; 44 | 45 | const emptyMarker = new (class extends GutterMarker { 46 | toDOM() { 47 | return document.createTextNode("øøø"); 48 | } 49 | })(); 50 | 51 | const ReferenceGutterExtension = gutter({ 52 | class: "snw-gutter-ref", 53 | lineMarker(editorView: EditorView, line: BlockInfo) { 54 | const mdView = editorView.state.field(editorInfoField); 55 | 56 | // @ts-ignore - Check if should show in source mode 57 | if (mdView.currentMode?.sourceMode === true && plugin.settings.displayInlineReferencesInSourceMode === false) return null; 58 | 59 | if (!mdView.file) return null; 60 | const transformedCache = getSNWCacheByFile(mdView.file); 61 | 62 | // check if the page is to be ignored 63 | if (transformedCache?.cacheMetaData?.frontmatter?.["snw-file-exclude"] === true) return null; 64 | if (transformedCache?.cacheMetaData?.frontmatter?.["snw-canvas-exclude-edit"] === true) return null; 65 | 66 | const embedsFromMetaDataCache = mdView.app.metadataCache.getFileCache(mdView.file)?.embeds; 67 | if (!embedsFromMetaDataCache || !embedsFromMetaDataCache.length) return null; 68 | 69 | const lineNumberInFile = editorView.state.doc.lineAt(line.from).number; 70 | for (const embed of embedsFromMetaDataCache) { 71 | if (embed.position.start.line + 1 === lineNumberInFile) { 72 | for (const ref of transformedCache?.embeds ?? []) { 73 | if ( 74 | ref?.references.length >= Math.max(2, plugin.settings.minimumRefCountThreshold) && 75 | ref?.pos.start.line + 1 === lineNumberInFile 76 | ) { 77 | const lineToAnalyze = editorView.state.doc.lineAt(line.from).text.trim(); 78 | if (lineToAnalyze.startsWith("!")) { 79 | // Remove [[ and ]] and split by | to get the link text if aliased 80 | const strippedLineToAnalyze = lineToAnalyze.replace("![[", "").replace("]]", "").split("|")[0]; 81 | const lineFromFile = ( 82 | strippedLineToAnalyze.startsWith("#") 83 | ? mdView.file + strippedLineToAnalyze 84 | : parseLinkTextToFullPath(strippedLineToAnalyze) || strippedLineToAnalyze 85 | ).toLocaleUpperCase(); 86 | if (lineFromFile === ref.key) { 87 | return new referenceGutterMarker( 88 | ref.references.length, 89 | "embed", 90 | ref.references[0].realLink, 91 | ref.key, 92 | ref.references[0].resolvedFile?.path ?? "", 93 | "snw-embed-special snw-liveupdate", 94 | ); 95 | } 96 | } 97 | } 98 | } 99 | } 100 | } 101 | 102 | return null; 103 | }, 104 | initialSpacer: () => emptyMarker, 105 | }); 106 | 107 | export default ReferenceGutterExtension; 108 | -------------------------------------------------------------------------------- /src/view-extensions/htmlDecorations.tsx: -------------------------------------------------------------------------------- 1 | import tippy from "tippy.js"; 2 | import type SNWPlugin from "../main"; 3 | import { UPDATE_DEBOUNCE } from "../main"; 4 | import "tippy.js/dist/tippy.css"; 5 | import { Platform, debounce } from "obsidian"; 6 | import { render } from "preact"; 7 | import { getUIC_Hoverview } from "src/ui/components/uic-ref--parent"; 8 | 9 | let plugin: SNWPlugin; 10 | 11 | export function setPluginVariableForHtmlDecorations(snwPlugin: SNWPlugin) { 12 | plugin = snwPlugin; 13 | } 14 | 15 | /** 16 | * Shared function between references-cm6.ts and references-preview. 17 | * This decoration is just the html box drawn into the document with the count of references. 18 | * It is used in the header as well as inline in the document. If a user clicks on this element, 19 | * the function processHtmlDecorationReferenceEvent is called 20 | * 21 | * @export 22 | * @param {number} count Number to show in the box 23 | * @param {string} referenceType The type of references (block, embed, link, header) 24 | * @param {string} realLink The real link to the reference contained in the document 25 | * @param {string} key Unique key used to identify this reference based on its type 26 | * @param {string} filePath File path in file in vault 27 | * @param {string} attachCSSClass if special class is need for the element 28 | * @return {*} {HTMLElement} 29 | */ 30 | export function htmlDecorationForReferencesElement( 31 | count: number, 32 | referenceType: string, 33 | realLink: string, 34 | key: string, 35 | filePath: string, 36 | attachCSSClass: string, 37 | lineNu: number, 38 | ): HTMLElement { 39 | const referenceElementJsx = ( 40 |
48 | {count.toString()} 49 |
50 | ); 51 | 52 | const refenceElement = createDiv(); 53 | render(referenceElementJsx, refenceElement); 54 | const refCountBox = refenceElement.firstElementChild as HTMLElement; 55 | 56 | if (Platform.isDesktop || Platform.isDesktopApp) 57 | //click is default to desktop, otherwise mobile behaves differently 58 | refCountBox.onclick = async (e: MouseEvent) => processHtmlDecorationReferenceEvent(e.target as HTMLElement); 59 | 60 | const requireModifierKey = plugin.settings.requireModifierKeyToActivateSNWView; 61 | // defaults to showing tippy on hover, but if requireModifierKey is true, then only show on ctrl/meta key 62 | let showTippy = true; 63 | const tippyObject = tippy(refCountBox, { 64 | interactive: true, 65 | appendTo: () => document.body, 66 | allowHTML: true, 67 | zIndex: 9999, 68 | placement: "auto-end", 69 | // trigger: "click", // on click is another option instead of hovering at all 70 | onTrigger(instance, event) { 71 | const mouseEvent = event as MouseEvent; 72 | if (requireModifierKey === false) return; 73 | if (mouseEvent.ctrlKey || mouseEvent.metaKey) { 74 | showTippy = true; 75 | } else { 76 | showTippy = false; 77 | } 78 | }, 79 | onShow(instance) { 80 | // returning false will cancel the show (coming from onTrigger) 81 | if (!showTippy) return false; 82 | 83 | setTimeout(async () => { 84 | await getUIC_Hoverview(instance); 85 | }, 1); 86 | }, 87 | }); 88 | 89 | tippyObject.popper.classList.add("snw-tippy"); 90 | 91 | return refenceElement; 92 | } 93 | 94 | // Opens the sidebar SNW pane by calling activateView on main.ts 95 | export const processHtmlDecorationReferenceEvent = async (target: HTMLElement) => { 96 | const refType = target.getAttribute("data-snw-type") ?? ""; 97 | const realLink = target.getAttribute("data-snw-realLink") ?? ""; 98 | const key = target.getAttribute("data-snw-key") ?? ""; 99 | const filePath = target.getAttribute("data-snw-filepath") ?? ""; 100 | const lineNu = target.getAttribute("snw-data-line-number") ?? ""; 101 | plugin.activateView(refType, realLink, key, filePath, Number(lineNu)); 102 | }; 103 | 104 | // loops all visble references marked with the class snw-liveupdate and updates the count if needed 105 | // or removes the element if the reference is no longer in the document 106 | export const updateAllSnwLiveUpdateReferencesDebounce = debounce( 107 | () => { 108 | const elements = document.querySelectorAll(".snw-liveupdate"); 109 | for (const el of Array.from(elements) as HTMLElement[]) { 110 | const newCount = plugin.snwAPI.references.get(el.dataset.snwKey)?.length ?? 0; 111 | if (newCount < plugin.settings.minimumRefCountThreshold) { 112 | el.remove(); 113 | continue; 114 | } 115 | const newCountStr = String(newCount); 116 | if (el.textContent !== newCountStr) { 117 | el.textContent = newCountStr; 118 | } 119 | } 120 | }, 121 | UPDATE_DEBOUNCE, 122 | true, 123 | ); 124 | -------------------------------------------------------------------------------- /src/view-extensions/references-cm6.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Codemirror extension - hook into the CM editor 3 | * CM will call update as the doc updates. 4 | */ 5 | import { Decoration, type DecorationSet, type EditorView, MatchDecorator, ViewPlugin, type ViewUpdate, WidgetType } from "@codemirror/view"; 6 | import { editorInfoField, parseLinktext, stripHeading, TFile } from "obsidian"; 7 | import { getSNWCacheByFile, parseLinkTextToFullPath } from "src/indexer"; 8 | import type SNWPlugin from "src/main"; 9 | import type { TransformedCachedItem } from "../types"; 10 | import { htmlDecorationForReferencesElement } from "./htmlDecorations"; 11 | import SnwAPI from "src/snwApi"; 12 | 13 | let plugin: SNWPlugin; 14 | 15 | export function setPluginVariableForCM6InlineReferences(snwPlugin: SNWPlugin) { 16 | plugin = snwPlugin; 17 | } 18 | 19 | /** 20 | * CM widget for renderinged matched ranges of references. This allows us to provide our UX for matches. 21 | */ 22 | export const InlineReferenceExtension = ViewPlugin.fromClass( 23 | class { 24 | decorator: MatchDecorator | undefined; 25 | decorations: DecorationSet = Decoration.none; 26 | regxPattern = ""; 27 | 28 | constructor(public view: EditorView) { 29 | // The constructor seems to be called only once when a file is viewed. The decorator is called multipe times. 30 | if (plugin.settings.enableRenderingBlockIdInLivePreview) this.regxPattern = "(\\s\\^)(\\S+)$"; 31 | if (plugin.settings.enableRenderingEmbedsInLivePreview) this.regxPattern += `${this.regxPattern !== "" ? "|" : ""}!\\[\\[(.*?)\\]\\]`; 32 | if (plugin.settings.enableRenderingLinksInLivePreview) this.regxPattern += `${this.regxPattern !== "" ? "|" : ""}\\[\\[(.*?)\\]\\]`; 33 | if (plugin.settings.enableRenderingHeadersInLivePreview) this.regxPattern += `${this.regxPattern !== "" ? "|" : ""}^#+\\s.+`; 34 | 35 | //if there is no regex pattern, then don't go further 36 | if (this.regxPattern === "") return; 37 | 38 | this.decorator = new MatchDecorator({ 39 | regexp: new RegExp(this.regxPattern, "g"), 40 | decorate: (add, from, to, match, view) => { 41 | const mdView = view.state.field(editorInfoField); 42 | 43 | const widgetsToAdd: { 44 | key: string; 45 | transformedCachedItem: TransformedCachedItem[] | null; 46 | refType: string; 47 | from: number; 48 | to: number; 49 | }[] = []; 50 | 51 | let mdViewFile: TFile | null = null; 52 | 53 | // there is no file, likely a canvas file, look for links and embeds, process it with snwApi.references 54 | if (!mdView.file && (plugin.settings.enableRenderingEmbedsInLivePreview || plugin.settings.enableRenderingLinksInLivePreview)) { 55 | const ref = match[0].replace(/^\[\[|\]\]$|^!\[\[|\]\]$/g, ""); 56 | const key = parseLinkTextToFullPath(ref).toLocaleUpperCase(); 57 | if (key) { 58 | const refType = match.input.startsWith("!") ? "embed" : "link"; 59 | mdViewFile = plugin.app.metadataCache.getFirstLinkpathDest(parseLinktext(ref).path, "/") as TFile; 60 | const references = plugin.snwAPI.references.get(key); 61 | 62 | const newTransformedCachedItem = [ 63 | { 64 | key: key, 65 | page: mdViewFile.path, 66 | type: refType, 67 | pos: { start: { line: 0, ch: 0, col: 0, offset: 0 }, end: { line: 0, ch: 0, col: 0, offset: 0 } }, 68 | references: references ?? [], 69 | }, 70 | ]; 71 | 72 | widgetsToAdd.push({ 73 | key: key, 74 | transformedCachedItem: newTransformedCachedItem ?? null, 75 | refType: refType, 76 | from: to, 77 | to: to, 78 | }); 79 | } 80 | } else { 81 | // If we get this far, then it is a file, and process it using getSNWCacheByFile 82 | 83 | // @ts-ignore && Check if should show in source mode 84 | if (plugin.settings.displayInlineReferencesInSourceMode === false && mdView.currentMode?.sourceMode === true) return null; 85 | 86 | mdViewFile = mdView.file as TFile; 87 | 88 | const transformedCache = getSNWCacheByFile(mdViewFile); 89 | 90 | if ( 91 | (transformedCache.links || transformedCache.headings || transformedCache.embeds || transformedCache.blocks) && 92 | transformedCache?.cacheMetaData?.frontmatter?.["snw-file-exclude"] !== true && 93 | transformedCache?.cacheMetaData?.frontmatter?.["snw-canvas-exclude-edit"] !== true 94 | ) { 95 | const firstCharacterMatch = match[0].charAt(0); 96 | 97 | if (firstCharacterMatch === "[" && (transformedCache?.links?.length ?? 0) > 0) { 98 | let newLink = match[0].replace("[[", "").replace("]]", ""); 99 | //link to an internal page link, add page name 100 | if (newLink.startsWith("#")) newLink = mdViewFile.path + newLink; 101 | newLink = newLink.toLocaleUpperCase(); 102 | widgetsToAdd.push({ 103 | key: newLink, 104 | transformedCachedItem: transformedCache.links ?? null, 105 | refType: "link", 106 | from: to, 107 | to: to, 108 | }); 109 | } else if ( 110 | firstCharacterMatch === "#" && 111 | ((transformedCache?.headings?.length || transformedCache?.links?.length) ?? 0) > 0 112 | ) { 113 | //heading 114 | widgetsToAdd.push({ 115 | key: stripHeading(match[0].replace(/^#+/, "").substring(1)), 116 | transformedCachedItem: transformedCache.headings ?? null, 117 | refType: "heading", 118 | from: to, 119 | to: to, 120 | }); 121 | if (plugin.settings.enableRenderingLinksInLivePreview) { 122 | // this was not working with mobile from 0.16.4 so had to convert it to a string 123 | const linksinHeader = match[0].match(/\[\[(.*?)\]\]|!\[\[(.*?)\]\]/g); 124 | if (linksinHeader) 125 | for (const l of linksinHeader) { 126 | widgetsToAdd.push({ 127 | key: l.replace("![[", "").replace("[[", "").replace("]]", "").toLocaleUpperCase(), //change this to match the references cache 128 | transformedCachedItem: l.startsWith("!") ? (transformedCache.embeds ?? null) : (transformedCache.links ?? null), 129 | refType: "link", 130 | from: to - match[0].length + (match[0].indexOf(l) + l.length), 131 | to: to - match[0].length + (match[0].indexOf(l) + l.length), 132 | }); 133 | } 134 | } 135 | } else if (firstCharacterMatch === "!" && (transformedCache?.embeds?.length ?? 0) > 0) { 136 | //embeds 137 | let newEmbed = match[0].replace("![[", "").replace("]]", ""); 138 | //link to an internal page link, add page name 139 | if (newEmbed.startsWith("#")) newEmbed = mdViewFile.path + stripHeading(newEmbed); 140 | widgetsToAdd.push({ 141 | key: newEmbed.toLocaleUpperCase(), 142 | transformedCachedItem: transformedCache.embeds ?? null, 143 | refType: "embed", 144 | from: to, 145 | to: to, 146 | }); 147 | } else if (firstCharacterMatch === " " && (transformedCache?.blocks?.length ?? 0) > 0) { 148 | widgetsToAdd.push({ 149 | //blocks 150 | key: (mdViewFile.path + match[0].replace(" ^", "#^")).toLocaleUpperCase(), //change this to match the references cache 151 | transformedCachedItem: transformedCache.blocks ?? null, 152 | refType: "block", 153 | from: to, 154 | to: to, 155 | }); 156 | } 157 | } // end for 158 | } 159 | 160 | if (widgetsToAdd.length === 0 || !mdViewFile) return; 161 | 162 | // first see if it is a heading, as it should be sorted to the end, then sort by position 163 | const sortWidgets = widgetsToAdd.sort((a, b) => (a.to === b.to ? (a.refType === "heading" ? 1 : -1) : a.to - b.to)); 164 | 165 | for (const ref of widgetsToAdd) { 166 | if (ref.key !== "") { 167 | const wdgt = constructWidgetForInlineReference( 168 | ref.refType, 169 | ref.key, 170 | ref.transformedCachedItem ?? [], 171 | mdViewFile.path, 172 | mdViewFile.extension, 173 | ); 174 | if (wdgt != null) { 175 | add(ref.from, ref.to, Decoration.widget({ widget: wdgt, side: 1 })); 176 | } 177 | } 178 | } 179 | }, 180 | }); 181 | 182 | this.decorations = this.decorator.createDeco(view); 183 | } 184 | 185 | update(update: ViewUpdate) { 186 | if (this.regxPattern !== "" && (update.docChanged || update.viewportChanged)) { 187 | this.decorations = this.decorator ? this.decorator.updateDeco(update, this.decorations) : this.decorations; 188 | // this.decorations = this.decorator?.updateDeco(update, this.decorations); 189 | } 190 | } 191 | }, 192 | { 193 | decorations: (v) => v.decorations, 194 | }, 195 | ); 196 | 197 | // Helper function for preparing the Widget for displaying the reference count 198 | const constructWidgetForInlineReference = ( 199 | refType: string, 200 | key: string, 201 | references: TransformedCachedItem[], 202 | filePath: string, 203 | fileExtension: string, 204 | ): InlineReferenceWidget | null => { 205 | let modifyKey = key; 206 | 207 | for (let i = 0; i < references.length; i++) { 208 | const ref = references[i]; 209 | let matchKey = ref.key; 210 | 211 | if (refType === "heading") { 212 | matchKey = stripHeading(ref.headerMatch ?? ""); // headers require special comparison 213 | modifyKey = modifyKey.replace(/^\s+|\s+$/g, ""); // should be not leading spaces 214 | } 215 | 216 | if (refType === "link" && ref.references.length === 1) continue; // if this is a link and there is only one reference, don't show the widget 217 | 218 | if (refType === "embed" || refType === "link") { 219 | // check for aliased references 220 | if (modifyKey.contains("|")) modifyKey = modifyKey.substring(0, key.search(/\|/)); 221 | const parsedKey = parseLinkTextToFullPath(modifyKey).toLocaleUpperCase(); 222 | modifyKey = parsedKey === "" ? modifyKey : parsedKey; //if no results, likely a ghost link 223 | 224 | if (matchKey.startsWith("#")) { 225 | // internal page link 226 | matchKey = filePath + stripHeading(matchKey); 227 | } 228 | } 229 | 230 | if (matchKey === modifyKey) { 231 | const filePath = ref?.references[0]?.resolvedFile 232 | ? ref.references[0].resolvedFile.path.replace(`.${ref.references[0].resolvedFile}`, "") 233 | : modifyKey; 234 | if (ref?.references.length >= plugin.settings.minimumRefCountThreshold) 235 | return new InlineReferenceWidget( 236 | ref.references.length, 237 | ref.type, 238 | ref.references[0].realLink, 239 | ref.key, 240 | filePath, 241 | "snw-liveupdate", 242 | ref.pos.start.line, 243 | ); 244 | return null; 245 | } 246 | } 247 | return null; 248 | }; 249 | 250 | // CM widget for renderinged matched ranges of references. This allows us to provide our UX for matches. 251 | export class InlineReferenceWidget extends WidgetType { 252 | referenceCount: number; 253 | referenceType: string; 254 | realLink: string; 255 | key: string; //a unique identifier for the reference 256 | filePath: string; 257 | addCssClass: string; //if a reference need special treatment, this class can be assigned 258 | lineNu: number; //number of line within the file 259 | 260 | constructor(refCount: number, cssclass: string, realLink: string, key: string, filePath: string, addCSSClass: string, lineNu: number) { 261 | super(); 262 | this.referenceCount = refCount; 263 | this.referenceType = cssclass; 264 | this.realLink = realLink; 265 | this.key = key; 266 | this.filePath = filePath; 267 | this.addCssClass = addCSSClass; 268 | this.lineNu = lineNu; 269 | } 270 | 271 | // eq(other: InlineReferenceWidget) { 272 | // return other.referenceCount == this.referenceCount; 273 | // } 274 | 275 | toDOM() { 276 | return htmlDecorationForReferencesElement( 277 | this.referenceCount, 278 | this.referenceType, 279 | this.realLink, 280 | this.key, 281 | this.filePath, 282 | this.addCssClass, 283 | this.lineNu, 284 | ); 285 | } 286 | 287 | destroy() {} 288 | 289 | ignoreEvent() { 290 | return false; 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /src/view-extensions/references-preview.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type MarkdownPostProcessorContext, 3 | MarkdownRenderChild, 4 | type MarkdownSectionInformation, 5 | parseLinktext, 6 | type TFile, 7 | } from "obsidian"; 8 | import { getSNWCacheByFile, parseLinkTextToFullPath } from "../indexer"; 9 | import type SNWPlugin from "../main"; 10 | import { htmlDecorationForReferencesElement } from "./htmlDecorations"; 11 | 12 | let plugin: SNWPlugin; 13 | 14 | export function setPluginVariableForMarkdownPreviewProcessor(snwPlugin: SNWPlugin) { 15 | plugin = snwPlugin; 16 | } 17 | 18 | /** 19 | * Function called by main.registerMarkdownPostProcessor - this function renders the html when in preview mode 20 | * This function receives a section of the document for processsing. So this function is called many times for a document. 21 | */ 22 | export default function markdownPreviewProcessor(el: HTMLElement, ctx: MarkdownPostProcessorContext) { 23 | // @ts-ignore 24 | if (ctx.remainingNestLevel === 4) return; // This is an attempt to prevent processing of embed files 25 | 26 | if (el.hasAttribute("uic")) return; // this is a custom component, don't render SNW inside it. 27 | 28 | // The following line addresses a conflict with the popular Tasks plugin. 29 | if (el.querySelectorAll(".contains-task-list").length > 0) return; 30 | 31 | const currentFile = plugin.app.vault.fileMap[ctx.sourcePath]; 32 | if (currentFile === undefined) { 33 | //this is run if the processor is not run within a markdown file, rather a card on a canvas 34 | ctx.addChild(new snwChildComponentMardkownWithoutFile(el)); 35 | } else { 36 | //this is run if the processor is run within a markdown file 37 | if (plugin.settings.pluginSupportKanban === false) { 38 | const fileCache = plugin.app.metadataCache.getFileCache(currentFile); 39 | if (fileCache?.frontmatter?.["kanban-plugin"]) return; 40 | } 41 | ctx.addChild(new snwChildComponentForMarkdownFile(el, ctx.getSectionInfo(el), currentFile)); 42 | } 43 | } 44 | 45 | // Processes pure markdown not coming from a document, like a card on a canvas that is not based on a file 46 | class snwChildComponentMardkownWithoutFile extends MarkdownRenderChild { 47 | containerEl: HTMLElement; 48 | 49 | constructor(containerEl: HTMLElement) { 50 | super(containerEl); 51 | this.containerEl = containerEl; 52 | } 53 | 54 | onload(): void { 55 | for (const link of Array.from(this.containerEl.querySelectorAll("a.internal-link, span.internal-embed"))) { 56 | const ref = ((link as HTMLElement).dataset.href || link.getAttribute("src")) as string; 57 | const key = parseLinkTextToFullPath(ref).toLocaleUpperCase(); 58 | const resolvedTFile = plugin.app.metadataCache.getFirstLinkpathDest(parseLinktext(ref).path, "/"); 59 | const references = plugin.snwAPI.references.get(key); 60 | 61 | const refCount = references?.length || 0; 62 | if (refCount <= 0 || refCount < plugin.settings.minimumRefCountThreshold) continue; 63 | 64 | const refType = link.classList.contains("internal-link") ? "link" : "embed"; 65 | if (!resolvedTFile) continue; 66 | 67 | const referenceElement = htmlDecorationForReferencesElement( 68 | refCount, 69 | refType, 70 | ref, 71 | key, 72 | resolvedTFile.path, 73 | `snw-liveupdate snw-${refType}-preview`, 74 | 1, 75 | ); 76 | link.after(referenceElement); 77 | } 78 | } 79 | } 80 | 81 | // Processes markdown coming from a markdown file 82 | class snwChildComponentForMarkdownFile extends MarkdownRenderChild { 83 | containerEl: HTMLElement; 84 | sectionInfo: MarkdownSectionInformation | null; 85 | currentFile: TFile; 86 | 87 | constructor(containerEl: HTMLElement, sectionInfo: MarkdownSectionInformation | null, currentFile: TFile) { 88 | super(containerEl); 89 | this.containerEl = containerEl; 90 | this.sectionInfo = sectionInfo; 91 | this.currentFile = currentFile; 92 | } 93 | 94 | onload(): void { 95 | const minRefCountThreshold = plugin.settings.minimumRefCountThreshold; 96 | const transformedCache = getSNWCacheByFile(this.currentFile); 97 | 98 | if (transformedCache?.cacheMetaData?.frontmatter?.["snw-file-exclude"] === true) return; 99 | if (transformedCache?.cacheMetaData?.frontmatter?.["snw-canvas-exclude-preview"] === true) return; 100 | 101 | if (transformedCache?.blocks || transformedCache.embeds || transformedCache.headings || transformedCache.links) { 102 | if (plugin.settings.enableRenderingBlockIdInMarkdown && transformedCache?.blocks && this.sectionInfo) { 103 | for (const value of transformedCache.blocks) { 104 | if ( 105 | value.references.length >= minRefCountThreshold && 106 | value.pos.start.line >= this.sectionInfo?.lineStart && 107 | value.pos.end.line <= this.sectionInfo?.lineEnd 108 | ) { 109 | const referenceElement = htmlDecorationForReferencesElement( 110 | value.references.length, 111 | "block", 112 | value.references[0].realLink, 113 | value.key, 114 | value.references[0]?.resolvedFile?.path ?? "", 115 | "snw-liveupdate", 116 | value.pos.start.line, 117 | ); 118 | let blockElement: HTMLElement | null = this.containerEl.querySelector("p"); 119 | const valueLineInSection: number = value.pos.start.line - this.sectionInfo.lineStart; 120 | if (!blockElement) { 121 | blockElement = this.containerEl.querySelector(`li[data-line="${valueLineInSection}"]`); 122 | if (!blockElement) continue; 123 | const ulElement = blockElement.querySelector("ul"); 124 | if (ulElement) ulElement.before(referenceElement); 125 | else blockElement.append(referenceElement); 126 | } else { 127 | // if (!blockElement) { 128 | // blockElement = this.containerEl.querySelector(`ol[data-line="${valueLineInSection}"]`); 129 | // blockElement.append(referenceElement); 130 | // } else { 131 | blockElement.append(referenceElement); 132 | // } 133 | } 134 | if (blockElement && !blockElement.hasClass("snw-block-preview")) referenceElement.addClass("snw-block-preview"); 135 | } 136 | } 137 | } 138 | 139 | if (plugin.settings.enableRenderingEmbedsInMarkdown && transformedCache?.embeds) { 140 | // biome-ignore lint/complexity/noForEach: 141 | this.containerEl.querySelectorAll(".internal-embed:not(.snw-embed-preview)").forEach((element) => { 142 | const src = element.getAttribute("src"); 143 | if (!src) return; 144 | 145 | // Testing for normal links, links within same page starting with # and for ghost links 146 | const embedKey = 147 | parseLinkTextToFullPath( 148 | src[0] === "#" ? this.currentFile.path.slice(0, -(this.currentFile.extension.length + 1)) + src : src, 149 | ) || src; 150 | 151 | for (const value of transformedCache.embeds ?? []) { 152 | if (value.references.length >= minRefCountThreshold && embedKey.toLocaleUpperCase() === value.key.toLocaleUpperCase()) { 153 | const referenceElement = htmlDecorationForReferencesElement( 154 | value.references.length, 155 | "embed", 156 | value.references[0].realLink, 157 | value.key.toLocaleUpperCase(), 158 | value.references[0]?.resolvedFile?.path ?? "", 159 | "snw-liveupdate", 160 | value.pos.start.line, 161 | ); 162 | referenceElement.addClass("snw-embed-preview"); 163 | element.after(referenceElement); 164 | break; 165 | } 166 | } 167 | }); 168 | } 169 | 170 | if (plugin.settings.enableRenderingLinksInMarkdown && transformedCache?.links) { 171 | // biome-ignore lint/complexity/noForEach: 172 | this.containerEl.querySelectorAll("a.internal-link").forEach((element) => { 173 | const dataHref = element.getAttribute("data-href"); 174 | if (!dataHref) return; 175 | // Testing for normal links, links within same page starting with # and for ghost links 176 | const link = 177 | parseLinkTextToFullPath( 178 | dataHref[0] === "#" ? this.currentFile.path.slice(0, -(this.currentFile.extension.length + 1)) + dataHref : dataHref, 179 | ) || dataHref; 180 | 181 | for (const value of transformedCache.links ?? []) { 182 | if ( 183 | value.references.length >= Math.max(2, minRefCountThreshold) && 184 | value.key.toLocaleUpperCase() === link.toLocaleUpperCase() 185 | ) { 186 | const referenceElement = htmlDecorationForReferencesElement( 187 | value.references.length, 188 | "link", 189 | value.references[0].realLink, 190 | value.key.toLocaleUpperCase(), 191 | value.references[0]?.resolvedFile?.path ?? "", 192 | "snw-liveupdate", 193 | value.pos.start.line, 194 | ); 195 | referenceElement.addClass("snw-link-preview"); 196 | element.after(referenceElement); 197 | break; 198 | } 199 | } 200 | }); 201 | } 202 | 203 | if (plugin.settings.enableRenderingHeadersInMarkdown) { 204 | const headerKey = this.containerEl.querySelector("[data-heading]"); 205 | if (transformedCache?.headings && headerKey) { 206 | const textContext = headerKey.getAttribute("data-heading"); 207 | 208 | for (const value of transformedCache.headings) { 209 | if (value.references.length >= minRefCountThreshold && value.headerMatch === textContext?.replace(/\[|\]/g, "")) { 210 | const referenceElement = htmlDecorationForReferencesElement( 211 | value.references.length, 212 | "heading", 213 | value.references[0].realLink, 214 | value.key, 215 | value.references[0]?.resolvedFile?.path ?? "", 216 | "snw-liveupdate", 217 | value.pos.start.line, 218 | ); 219 | referenceElement.addClass("snw-heading-preview"); 220 | const headerElement = this.containerEl.querySelector("h1,h2,h3,h4,h5,h6"); 221 | if (headerElement) { 222 | headerElement.insertAdjacentElement("beforeend", referenceElement); 223 | } 224 | break; 225 | } 226 | } 227 | } 228 | } 229 | } 230 | } // end of processMarkdown() 231 | } 232 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | --snw-counter-opacity: 0.4; 3 | --snw-icon-opacity: 0.4; 4 | } 5 | 6 | 7 | div:has(> .snw-reference), 8 | .snw-link-preview { 9 | display: inline; 10 | } 11 | 12 | .snw-reference { 13 | display: inline; 14 | font-size: var(--font-ui-smaller); 15 | border-radius: var(--radius-s); 16 | border: var(--border-width) dotted; 17 | vertical-align: top; 18 | opacity: var(--snw-counter-opacity); 19 | min-width: 10px; 20 | padding-left: 3px; 21 | padding-right: 3px !important; 22 | margin-left: 3px !important; 23 | margin-right: 2px; 24 | } 25 | 26 | .is-mobile .snw-reference { 27 | font-size: var(--font-ui-smaller); 28 | } 29 | 30 | /* Properties */ 31 | /* Have to remove the underline since text-decoration-line isn't inheritted, but then re-add ito the link */ 32 | .metadata-container .internal-link:has(.snw-frontmatter-wrapper) { 33 | text-decoration-line: none !important; 34 | color: unset; 35 | cursor: unset; 36 | 37 | .multi-select-pill-content { 38 | text-decoration-line: underline !important; 39 | color: var(--link-color); 40 | cursor: var(--cursor-link); 41 | } 42 | } 43 | 44 | /* custom properties display */ 45 | .snw-custom-property-name { 46 | font-style: italic; 47 | } 48 | 49 | /* CM Live Preview */ 50 | .cm-content .snw-reference { 51 | padding-right: 2px; 52 | } 53 | 54 | /* Preview mode */ 55 | .markdown-preview-view .snw-reference { 56 | padding-right: 3px; 57 | } 58 | 59 | .snw-block { 60 | padding-left: 2px; 61 | margin-left: 2px; 62 | } 63 | 64 | .snw-heading { 65 | margin-left: 2px; 66 | font-size: var(--font-ui-smaller); 67 | } 68 | 69 | .snw-embed { 70 | margin-top: 1px; 71 | } 72 | 73 | .snw-embed-preview { 74 | top: 2px; 75 | } 76 | 77 | .snw-link { 78 | margin-left: 3px; 79 | } 80 | 81 | /* Hover container */ 82 | .snw-popover-container { 83 | border-radius: var(--radius-l); 84 | border: var(--border-width) solid var(--modal-border-color); 85 | box-shadow: var(--shadow-s); 86 | background-color: var(--background-primary) !important; 87 | min-height: 100px; 88 | width: 300px; 89 | overflow: hidden; 90 | z-index: 1000; 91 | } 92 | 93 | /* Shared reference area CSS */ 94 | 95 | .snw-ref-area { 96 | display: flex; 97 | flex-direction: column; 98 | overflow-y: scroll; 99 | max-height: 300px !important; 100 | color: var(--text-normal); 101 | } 102 | 103 | .snw-sidepane-container>div>.snw-ref-area { 104 | max-height: 100% !important; 105 | margin-bottom: 10px; 106 | } 107 | 108 | .snw-ref-area::-webkit-scrollbar { 109 | display: none; 110 | } 111 | 112 | .snw-ref-title-popover { 113 | color: var(--text-normal); 114 | padding-top: 5px; 115 | padding-left: 10px; 116 | padding-right: 5px; 117 | padding-bottom: 10px; 118 | margin-bottom: 10px; 119 | border-bottom: var(--border-width) solid var(--modal-border-color); 120 | display: flex; 121 | flex-direction: row; 122 | align-items: center; 123 | } 124 | 125 | .snw-ref-title-popover-open-sidepane-icon { 126 | width: 25px; 127 | opacity: var(--snw-icon-opacity); 128 | } 129 | 130 | .snw-ref-title-popover-label { 131 | width: 100%; 132 | } 133 | 134 | .snw-ref-title-side-pane { 135 | color: var(--tab-text-color-active); 136 | padding-top: 5px; 137 | padding-left: 10px; 138 | padding-right: 5px; 139 | padding-bottom: 5px; 140 | margin-bottom: 5px; 141 | border-radius: var(--radius-s); 142 | background-color: var(--titlebar-background-focused); 143 | border: var(--border-width) solid var(--modal-border-color); 144 | align-items: center; 145 | } 146 | 147 | .snw-ref-item-container { 148 | margin-bottom: 10px; 149 | } 150 | 151 | .snw-ref-item-file { 152 | display: flex; 153 | flex-direction: row; 154 | } 155 | 156 | .snw-ref-item-file.tree-item-self { 157 | align-items: flex-start !important; 158 | } 159 | 160 | .snw-ref-item-collection-items { 161 | margin-left: 1px; 162 | margin-right: 1px; 163 | margin-bottom: 1px !important; 164 | } 165 | 166 | [uic='uic']>* { 167 | margin-top: 0px !important; 168 | margin-bottom: 0px !important; 169 | max-height: 230px !important; 170 | overflow: hidden; 171 | white-space: normal; 172 | } 173 | 174 | .snw-ref-item-info a { 175 | text-decoration: none; 176 | color: var(--text-normal) !important; 177 | cursor: default; 178 | } 179 | 180 | /* Sidepane */ 181 | 182 | .snw-sidepane-container { 183 | height: 100%; 184 | } 185 | 186 | .snw-header-count-wrapper { 187 | position: absolute; 188 | border: var(--border-width) dotted; 189 | font-size: var(--font-ui-smallest); 190 | border-radius: var(--radius-s); 191 | min-width: 18px; 192 | min-height: 18px; 193 | right: 20px; 194 | opacity: var(--snw-counter-opacity); 195 | padding: 3px; 196 | z-index: 1000; 197 | } 198 | 199 | /* Modification for when Edting Toolbar plugin is used */ 200 | .view-content:has(.top.cMenuToolbarDefaultAesthetic), 201 | .view-content:has(.top.cMenuToolbarTinyAesthetic) { 202 | .snw-header-count-wrapper { 203 | top: 80px; 204 | } 205 | } 206 | 207 | .view-content:has(.top.cMenuToolbarGlassAesthetic) { 208 | .snw-header-count-wrapper { 209 | top: 70px; 210 | } 211 | } 212 | 213 | .snw-header-count { 214 | text-align: center; 215 | } 216 | 217 | .snw-sidepane-container { 218 | padding: 10px; 219 | overflow: scroll; 220 | } 221 | 222 | .snw-sidepane-header { 223 | font-weight: bold; 224 | padding-top: 5px; 225 | padding-bottom: 5px; 226 | } 227 | 228 | .snw-sidepane-header-references-header { 229 | font-weight: bold; 230 | padding-top: 15px; 231 | padding-bottom: 5px; 232 | } 233 | 234 | .snw-sidepane-references { 235 | margin-top: 0px; 236 | padding-left: 20px; 237 | } 238 | 239 | .snw-sidepane-reference-item { 240 | padding-bottom: 5px; 241 | line-height: normal; 242 | } 243 | 244 | .snw-sidepane-loading { 245 | color: var(--text-muted); 246 | margin-top: 15px; 247 | margin-left: 20px; 248 | } 249 | 250 | .snw-sidepane-loading-subtext { 251 | color: var(--text-faint); 252 | font-style: italic; 253 | margin-top: 10px; 254 | font-size: small; 255 | } 256 | 257 | .snw-gutter-ref { 258 | /* margin-right: 6px; */ 259 | } 260 | 261 | .cm-gutters { 262 | z-index: 0 !important; 263 | } 264 | 265 | .snw-breadcrumbs { 266 | display: flex; 267 | align-items: baseline; 268 | border-bottom: 1px solid var(--background-modifier-border); 269 | } 270 | 271 | .snw-breadcrumbs>span { 272 | margin-right: 1ch; 273 | border: 1px solid var(--background-modifier-border); 274 | border-radius: var(--radius-s); 275 | font-weight: bold; 276 | padding-left: 3px; 277 | padding-right: 3px; 278 | } 279 | 280 | /* .tippy-box settings to avoid conflicts with other plugins */ 281 | .snw-tippy .tippy-box { 282 | background-color: transparent; 283 | } 284 | 285 | .snw-tippy .tippy-content { 286 | padding: 0px; 287 | } 288 | 289 | .snw-tippy .tippy-arrow { 290 | width: 0px; 291 | height: 0px; 292 | color: transparent; 293 | } 294 | 295 | /* Dropdown options */ 296 | .snw-sort-dropdown-wrapper { 297 | position: relative; 298 | } 299 | 300 | .snw-sort-dropdown-button { 301 | border: none !important; 302 | box-shadow: none !important; 303 | width: 35px; 304 | background-color: unset !important; 305 | opacity: var(--snw-icon-opacity); 306 | 307 | >svg { 308 | width: 25px; 309 | color: var(--tab-text-color-focused) !important; 310 | } 311 | } 312 | 313 | .snw-sort-dropdown-list { 314 | position: absolute; 315 | right: 0; 316 | top: 15px; 317 | width: 90px; 318 | padding: 5px; 319 | z-index: 1; 320 | border: 3px solid var(--divider-color); 321 | border-radius: 2px; 322 | background-color: var(--background-primary); 323 | overflow-y: auto; 324 | list-style: none; 325 | color: var(--nav-item-color); 326 | font-size: var(--nav-item-size); 327 | font-weight: var(--nav-item-weight); 328 | } 329 | 330 | .snw-sort-dropdown-list-item { 331 | padding-left: 5px; 332 | } 333 | 334 | .snw-sort-dropdown-list-item:hover { 335 | background-color: var(--background-modifier-hover); 336 | } 337 | 338 | .snw-sort-dropdown-list-item-label { 339 | padding-left: 5px; 340 | position: relative; 341 | bottom: 5px; 342 | } 343 | 344 | 345 | /* prevent printing of reference numbers */ 346 | @media print { 347 | .snw-reference { 348 | display: none; 349 | } 350 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "baseUrl": ".", 5 | "inlineSourceMap": true, 6 | "inlineSources": true, 7 | "module": "ESNext", 8 | "target": "ES6", 9 | "allowJs": true, 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "isolatedModules": true, 14 | "strictNullChecks": true, 15 | "lib": ["DOM", "ES5", "ES6", "ES7", "ES2021"], 16 | "types": [ 17 | "obsidian-typings" 18 | ], 19 | "jsx": "react-jsx", 20 | "jsxImportSource": "preact", 21 | "moduleDetection": "auto", 22 | "skipLibCheck": true, 23 | "skipDefaultLibCheck": true, 24 | "esModuleInterop": true, 25 | "resolveJsonModule": true, 26 | "forceConsistentCasingInFileNames": true 27 | }, 28 | "include": ["**/*.ts", "**/*.tsx", "src/types.d.ts"] 29 | } 30 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /version-github-action.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { exec } from 'child_process'; 3 | 4 | // Read the manifest.json file 5 | fs.readFile('manifest.json', 'utf8', (err, data) => { 6 | if (err) { 7 | console.error(`Error reading file from disk: ${err}`); 8 | } else { 9 | // Parse the file content to a JavaScript object 10 | const manifest = JSON.parse(data); 11 | 12 | // Extract the version 13 | const version = manifest.version; 14 | 15 | // Execute the git commands 16 | exec(`git tag -a ${version} -m "${version}" && git push origin ${version}`, (error, stdout, stderr) => { 17 | if (error) { 18 | console.error(`exec error: ${error}`); 19 | return; 20 | } 21 | console.log(`stdout: ${stdout}`); 22 | console.error(`stderr: ${stderr}`); 23 | }); 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.2.5": "1.4.16", 3 | "1.2.6": "1.5.11", 4 | "2.0.0": "1.5.11", 5 | "2.0.1": "1.5.11", 6 | "2.0.2": "1.5.11", 7 | "2.0.3": "1.5.11", 8 | "2.1.0": "1.5.11", 9 | "2.1.1": "1.5.11", 10 | "2.1.2": "1.5.11", 11 | "2.1.3": "1.5.11", 12 | "2.1.4": "1.5.11", 13 | "2.1.5": "1.7.2", 14 | "2.2.0": "1.7.2", 15 | "2.2.1": "1.7.2", 16 | "2.3.0": "1.7.2", 17 | "2.3.1": "1.7.2", 18 | "2.3.2": "1.7.2" 19 | } --------------------------------------------------------------------------------