From 3b9f8d954937ee81865ade099a415adbc21f2fed Mon Sep 17 00:00:00 2001 From: PJ Date: Tue, 3 Oct 2023 23:54:13 -0400 Subject: [PATCH] New Features - Use the Tag Wrangler context menu for tags in the body of a note (editor AND preview mode) - Open or create a tag page by alt/opt clicking a tag in any note (or the tag pane) - Drag-and-drop tags to rename/reorganize them - Drag tags from the tag pane or a note preview to an editor pane to insert them as text. - A donation link is now available --- README.md | 13 ++++- manifest.json | 3 +- src/Tag.js | 4 ++ src/plugin.js | 155 +++++++++++++++++++++++++++++++++++++------------- versions.json | 3 + 5 files changed, 133 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index bedfcf1..954c1df 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # Obsidian Tag Wrangler Plugin - -> NEW in 0.5.5 - you can now drag tags from the tag pane to an editor pane to insert them as text. +> NEW in 0.6.0 +> - Open or create a tag page by alt/opt clicking a tag in any note (or the tag pane) +> - Use the Tag Wrangler context menu for tags in the body of a note (editor or preview mode) +> - Drag-and-drop tags to rename/reorganize them +> - Drag tags from the tag pane or a note preview to an editor pane to insert them as text. This plugin adds a context menu for tags in the [Obsidian.md](https://obsidian.md) tag pane, with the following actions available: @@ -35,7 +38,7 @@ People often debate the merits of using tags vs. page links to organize your not To create a tag page, just right click any tag in the tag pane, then select "Create Tag Page". A new note will be created with an alias of the selected tag. You can rename the note or move it anywhere you like in the vault, as long as it retains the alias linking it to the tag. (Renaming a tag associated with a tag page (see "Renaming Tags", below) will automatically update the alias.) -To open an *existing* tag page, you can Alt-click any tag in the tag pane or any note, whether in editing or reading view. Ctrl/Cmd-click or middle click will open the tag page in a new pane. (Note: if no tag page exists, the normal click behavior of globally searching for the tag will apply.) +To open or create a tag page, you can Alt-click (Option-click on Mac) any tag in the tag pane or any note, whether in editing or reading view. Ctrl/Cmd-click or middle click plus Alt/Option will open the tag page in a new pane. (Note: if no tag page exists, you'll be prompted for whether you want to create it. If you cancel, the normal click behavior of globally searching for the tag will apply.) Or, you can enter the tag's name in the Obsidian "quick switcher" (default hotkey: Ctrl/Cmd-O) to open the page from the keyboard. You can also hover-preview any tag in the tag pane or any markdown views to pop up a preview of the tag page. @@ -119,6 +122,10 @@ Rather, this kind of thing will happen if the `#Bar/baz` tag is the first tag be This is just how Obsidian tags work, and not something that Tag Wrangler can work around. But you can easily fix the problem by renaming anything that's in the "wrong" case to the "right" case. It just means that (as is already the case in Obsidian) you can't have more than one casing of the same tag name displayed in the tag pane, and that now you can easily rename tags to a consistent casing, if desired. +### Canvas Support + +Please note that tag renaming is not supported for tags in Obsidian Canvas files yet, as Obsidian itself doesn't fully support such tags yet either. (That is, tags in canvas text do *not* appear in the tag pane counts or in Obsidian's internal indexes, so from Tag Wrangler's perspective they aren't findable and don't exist.) If some future version of Obsidian addresses this, this limitation *may* be removable then, depending on how the issue is addressed. + ## Developer Notes diff --git a/manifest.json b/manifest.json index af77857..fb4c268 100644 --- a/manifest.json +++ b/manifest.json @@ -3,8 +3,9 @@ "name": "Tag Wrangler", "author": "PJ Eby", "authorUrl": "https://github.com/pjeby", - "version": "0.5.13", + "version": "0.6.0", "minAppVersion": "1.2.8", "description": "Rename, merge, toggle, and search tags from the tag pane", + "fundingUrl": "https://dirtsimple.org/tips/tag-wrangler", "isDesktopOnly": false } diff --git a/src/Tag.js b/src/Tag.js index ee78b2b..fcda181 100644 --- a/src/Tag.js +++ b/src/Tag.js @@ -21,6 +21,10 @@ export class Tag { return name.startsWith("#") ? name : "#"+name; } + static toName(tag) { + return this.toTag(tag).slice(1); + } + static canonical(name) { return Tag.toTag(name).toLowerCase(); } diff --git a/src/plugin.js b/src/plugin.js index b6b15b9..61dd273 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -1,7 +1,8 @@ -import {Component, Keymap, Menu, Notice, parseFrontMatterAliases, Plugin, Scope} from "obsidian"; +import {Component, Keymap, Menu, Notice, parseFrontMatterAliases, Plugin} from "obsidian"; import {renameTag, findTargets} from "./renaming"; import {Tag} from "./Tag"; import {around} from "monkey-around"; +import {Confirm} from "@ophidian/core"; const tagHoverMain = "tag-wrangler:tag-pane"; @@ -47,7 +48,14 @@ export default class TagWrangler extends Plugin { app.workspace.trigger("tag-page:did-create", tp_evt); } - async onload(){ + onload(){ + this.registerEvent( + app.workspace.on("editor-menu", (menu, editor) => { + const token = editor.getClickableTokenAt(editor.getCursor()); + if (token?.type === "tag") this.setupMenu(menu, token.text); + }) + ) + this.register( onElement(document, "contextmenu", ".tag-pane-tag", this.onMenu.bind(this), {capture: true}) ); @@ -117,6 +125,31 @@ export default class TagWrangler extends Plugin { }, {capture: false}) ); + const dropHandler = (e, targetEl, info = app.dragManager.draggable, drop) => { + if (info?.source !== "tag-wrangler" || e.defaultPrevented ) return; + const tag = targetEl.find(".tag-pane-tag-text, tag-pane-tag-text, .tag-pane-tag .tree-item-inner-text")?.textContent; + const dest = tag+"/"+Tag.toName(info.title).split("/").pop(); + if (Tag.canonical(tag) === Tag.canonical(info.title)) return; + e.dataTransfer.dropEffect = "move"; + e.preventDefault(); + if (drop) { + this.rename(Tag.toName(info.title), dest); + } else { + app.dragManager.updateHover(targetEl, "is-being-dragged-over"); + app.dragManager.setAction(`Rename to ${dest}`); + } + } + + this.register(onElement(document.body, "dragover", ".tag-pane-tag.tree-item-self", dropHandler, {capture: true})); + this.register(onElement(document.body, "dragenter", ".tag-pane-tag.tree-item-self", dropHandler, {capture: true})); + // This has to be registered on the window so that it will still get the .draggable + this.registerDomEvent(window, "drop", e => { + const targetEl = e.target?.matchParent(".tag-pane-tag.tree-item-self", e.currentTarget); + if (!targetEl) return; + const info = app.dragManager.draggable; + if (info && !e.defaultPrevented) dropHandler(e, targetEl, info, true); + }, {capture: true}); + // Track Tag Pages const metaCache = this.app.metadataCache; const plugin = this; @@ -177,49 +210,69 @@ export default class TagWrangler extends Plugin { } onMenu(e, tagEl) { - if (!e.obsidian_contextmenu) { - e.obsidian_contextmenu = new Menu(this.app); + let menu = e.obsidian_contextmenu; + if (!menu) { + menu = e.obsidian_contextmenu = new Menu(); setTimeout(() => menu.showAtPosition({x: e.pageX, y: e.pageY}), 0); } const tagName = tagEl.find(".tag-pane-tag-text, .tag-pane-tag .tree-item-inner-text").textContent, + isHierarchy = tagEl.parentElement.parentElement.find(".collapse-icon") + ; + this.setupMenu(menu, tagName, isHierarchy); + if (isHierarchy) { + const + tagParent = tagName.split("/").slice(0, -1).join("/"), + tagView = this.leafView(tagEl.matchParent(".workspace-leaf")), + tagContainer = tagParent ? tagView.tagDoms["#" + tagParent.toLowerCase()]: tagView.root + ; + function toggle(collapse) { + for(const tag of tagContainer.children ?? tagContainer.vChildren.children) tag.setCollapsed(collapse); + } + menu.addItem(item("tag-hierarchy", "vertical-three-dots", "Collapse tags at this level", () => toggle(true ))) + .addItem(item("tag-hierarchy", "expand-vertically" , "Expand tags at this level" , () => toggle(false))) + } + } + + setupMenu(menu, tagName, isHierarchy=false) { + tagName = Tag.toTag(tagName).slice(1); + const tagPage = this.tagPage(tagName), - isHierarchy = tagEl.parentElement.parentElement.find(".collapse-icon"), searchPlugin = this.app.internalPlugins.getPluginById("global-search"), search = searchPlugin && searchPlugin.instance, query = search && search.getGlobalSearchQuery(), - random = this.app.plugins.plugins["smart-random-note"], - menu = e.obsidian_contextmenu.addItem(item("pencil", "Rename #"+tagName, () => this.rename(tagName))); + random = this.app.plugins.plugins["smart-random-note"] + ; + menu.addItem(item("tag-rename", "pencil", "Rename #"+tagName, () => this.rename(tagName))) - menu.addSeparator(); if (tagPage) { menu.addItem( - item("popup-open", "Open tag page", (e) => this.openTagPage(tagPage, false, Keymap.isModEvent(e))) + item("tag-page", "popup-open", "Open tag page", (e) => this.openTagPage(tagPage, false, Keymap.isModEvent(e))) ) } else { menu.addItem( - item("create-new", "Create tag page", (e) => this.createTagPage(tagName, Keymap.isModEvent(e))) + item("tag-page", "create-new", "Create tag page", (e) => this.createTagPage(tagName, Keymap.isModEvent(e))) ) } if (search) { - menu.addSeparator().addItem( - item("magnifying-glass", "New search for #"+tagName, () => search.openGlobalSearch("tag:" + tagName)) + menu.addItem( + item("tag-search", "magnifying-glass", "New search for #"+tagName, () => search.openGlobalSearch("tag:#" + tagName)) ); if (query) { menu.addItem( - item("sheets-in-box", "Require #"+tagName+" in search" , () => search.openGlobalSearch(query+" tag:" + tagName)) + item("tag-search", "sheets-in-box", "Require #"+tagName+" in search" , () => search.openGlobalSearch(query+" tag:#" + tagName)) ); } menu.addItem( - item("crossed-star" , "Exclude #"+tagName+" from search", () => search.openGlobalSearch(query+" -tag:" + tagName)) + item("tag-search", "crossed-star" , "Exclude #"+tagName+" from search", () => search.openGlobalSearch(query+" -tag:#" + tagName)) ); } if (random) { - menu.addSeparator().addItem( - item("dice", "Open random note", async () => { + menu.addItem( + item("tag-random", "dice", "Open random note", async () => { const targets = await findTargets(this.app, new Tag(tagName)); random.openRandomNote(targets.map(f=> this.app.vault.getAbstractFileByPath(f.filename))); }) @@ -227,20 +280,6 @@ export default class TagWrangler extends Plugin { } this.app.workspace.trigger("tag-wrangler:contextmenu", menu, tagName, {search, query, isHierarchy, tagPage}); - - if (isHierarchy) { - const - tagParent = tagName.split("/").slice(0, -1).join("/"), - tagView = this.leafView(tagEl.matchParent(".workspace-leaf")), - tagContainer = tagParent ? tagView.tagDoms["#" + tagParent.toLowerCase()]: tagView.root - ; - function toggle(collapse) { - for(const tag of tagContainer.children ?? tagContainer.vChildren.children) tag.setCollapsed(collapse); - } - menu.addSeparator() - .addItem(item("vertical-three-dots", "Collapse tags at this level", () => toggle(true ))) - .addItem(item("expand-vertically" , "Expand tags at this level" , () => toggle(false))) - } } leafView(containerEl) { @@ -252,18 +291,15 @@ export default class TagWrangler extends Plugin { } - async rename(tagName) { - const scope = new Scope; - this.app.keymap.pushScope(scope); - try { await renameTag(this.app, tagName); } + async rename(tagName, toName=tagName) { + try { await renameTag(this.app, tagName, toName); } catch (e) { console.error(e); new Notice("error: " + e); } - this.app.keymap.popScope(scope); } } -function item(icon, title, click) { - return i => i.setIcon(icon).setTitle(title).onClick(click); +function item(section, icon, title, click) { + return i => { i.setIcon(icon).setTitle(title).onClick(click); if (section) i.setSection(section); } } @@ -288,18 +324,55 @@ class TagPageUIHandler extends Component { }); }, {capture: false}) ); + + if (hoverSource === "preview") { + this.register( + onElement(document, "contextmenu", selector, (e, targetEl) => { + let menu = e.obsidian_contextmenu; + if (!menu) { + menu = e.obsidian_contextmenu = new Menu(); + setTimeout(() => menu.showAtPosition({x: e.pageX, y: e.pageY}), 0); + } + this.plugin.setupMenu(menu, toTag(targetEl)); + }) + ); + this.register( + onElement(document, "dragstart", selector, (event, targetEl) => { + const tagName = toTag(targetEl); + event.dataTransfer.setData("text/plain", Tag.toTag(tagName)); + app.dragManager.onDragStart(event, { + source: "tag-wrangler", + type: "text", + title: tagName, + icon: "hashtag", + }) + }, {capture: false}) + ); + } + this.register( // Open tag page w/alt click (current pane) or ctrl/cmd/middle click (new pane) - onElement(document, "click", selector, (event, targetEl) => { + onElement(document, hoverSource === "editor" ? "mousedown" : "click", selector, (event, targetEl) => { const {altKey} = event; if (!Keymap.isModEvent(event) && !altKey) return; const tagName = toTag(targetEl), tp = tagName && this.plugin.tagPage(tagName); if (tp) { this.plugin.openTagPage(tp, false, Keymap.isModEvent(event)); - event.preventDefault(); - event.stopPropagation(); - return false; + } else { + new Confirm() + .setTitle("Create Tag Page") + .setContent(`A tag page for ${tagName} does not exist. Create it?`) + .confirm() + .then(v => { + if (v) return this.plugin.createTagPage(tagName, Keymap.isModEvent(event)); + const search = app.internalPlugins.getPluginById("global-search")?.instance; + search?.openGlobalSearch("tag:#" + tagName) + }) + ; } + event.preventDefault(); + event.stopImmediatePropagation(); + return false; }, {capture: true}) ); } diff --git a/versions.json b/versions.json index 7883d3a..0b92cc5 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,8 @@ { + "0.6.0": "1.2.8", "0.5.13": "1.2.8", + "0.5.12": "1.2.8", + "0.5.10": "0.15.9", "0.5.5": "0.15.9", "0.5.3": "0.14.5", "0.5.2": "0.13.19",