diff --git a/components/Toolbar/EditorToolbarButton.tsx b/components/Toolbar/EditorToolbarButton.tsx new file mode 100644 index 0000000..504cd53 --- /dev/null +++ b/components/Toolbar/EditorToolbarButton.tsx @@ -0,0 +1,99 @@ +import React, { FC, memo, useEffect, useState } from "react"; +import { GroupedStatuses } from "@/types/noteStatus"; +interface EditorToolbarButtonProps { + statuses: GroupedStatuses; + onClick: () => void; + unknownStatusConfig: { + icon: string; + color: string; + }; +} +export const EditorToolbarButton: FC = memo( + ({ statuses, onClick, unknownStatusConfig }) => { + const [statusChanged, setStatusChanged] = useState(false); + const statusEntries = Object.entries(statuses); + const allStatuses = statusEntries.flatMap( + ([_, statusList]) => statusList, + ); + const totalStatuses = allStatuses.length; + // Animate when status changes + useEffect(() => { + setStatusChanged(true); + const timer = setTimeout(() => setStatusChanged(false), 300); + return () => clearTimeout(timer); + }, [statusEntries.length, allStatuses.map((s) => s.name).join(",")]); + + const getTooltipText = () => { + if (totalStatuses === 0) { + return "No status assigned - click to set status"; + } + if (totalStatuses === 1) { + return `Status: ${allStatuses[0].name} - click to change`; + } + const statusNames = allStatuses.map((s) => s.name).join(", "); + return `Statuses: ${statusNames} - click to change`; + }; + + const buttonClasses = [ + "editor-toolbar-btn", + totalStatuses === 0 + ? "editor-toolbar-btn--no-status" + : "editor-toolbar-btn--has-status", + statusChanged ? "editor-toolbar-btn--status-changed" : "", + ] + .filter(Boolean) + .join(" "); + + if (totalStatuses === 0) { + return ( + + ); + } + + const primaryStatus = allStatuses[0]; + + return ( + + ); + }, +); diff --git a/integrations/toolbar/editorToolbarIntegration.tsx b/integrations/toolbar/editorToolbarIntegration.tsx new file mode 100644 index 0000000..b2e0245 --- /dev/null +++ b/integrations/toolbar/editorToolbarIntegration.tsx @@ -0,0 +1,218 @@ +import { createRoot, Root } from "react-dom/client"; +import eventBus from "core/eventBus"; +import { MarkdownView, Plugin, WorkspaceLeaf } from "obsidian"; +import settingsService from "@/core/settingsService"; +import { EditorToolbarButton } from "@/components/Toolbar/EditorToolbarButton"; +import { NoteStatusService } from "@/core/noteStatusService"; + +export class EditorToolbarIntegration { + private static instance: EditorToolbarIntegration | null = null; + private root: Root | null = null; + private plugin: Plugin; + private buttonElement: HTMLElement | null = null; + private noteStatusService: NoteStatusService | null = null; + private currentLeaf: WorkspaceLeaf | null = null; + private readonly BUTTON_CLASS = "note-status-editor-toolbar-button"; + + constructor(plugin: Plugin) { + if (EditorToolbarIntegration.instance) { + throw new Error("The editor toolbar instance is already created"); + } + this.plugin = plugin; + EditorToolbarIntegration.instance = this; + } + + async integrate() { + eventBus.subscribe( + "active-file-change", + ({ leaf }) => { + if (this.isValidMarkdownLeaf(leaf)) { + this.currentLeaf = leaf; + this.handleActiveFileChange().catch(console.error); + } else { + this.removeButton(); + } + }, + "editorToolbarIntegrationSubscription1", + ); + + eventBus.subscribe( + "plugin-settings-changed", + ({ key }) => { + if (key === "enabledTemplates" || key === "templates") { + this.handleActiveFileChange().catch(console.error); + } + if (key === "useMultipleStatuses") { + this.handleActiveFileChange().catch(console.error); + } + if (key === "tagPrefix") { + this.handleActiveFileChange().catch(console.error); + } + if (key === "useCustomStatusesOnly") { + this.handleActiveFileChange().catch(console.error); + } + if (key === "customStatuses") { + this.handleActiveFileChange().catch(console.error); + } + if ( + key === "unknownStatusIcon" || + key === "unknownStatusColor" + ) { + this.render(); + } + }, + "editorToolbarIntegrationSubscription2", + ); + + eventBus.subscribe( + "frontmatter-manually-changed", + () => { + this.handleActiveFileChange().catch(console.error); + }, + "editorToolbarIntegrationSubscription3", + ); + } + + private async handleActiveFileChange() { + this.extractStatusesFromLeaf(this.currentLeaf); + this.createButton(); + this.render(); + } + + private extractStatusesFromLeaf(leaf: WorkspaceLeaf | null) { + if (!this.isValidMarkdownLeaf(leaf)) { + return {}; + } + + const markdownView = leaf!.view as MarkdownView; + if (!markdownView.file) { + return {}; + } + + this.noteStatusService = new NoteStatusService(markdownView.file); + this.noteStatusService.populateStatuses(); + } + + private isValidMarkdownLeaf(leaf: WorkspaceLeaf | null): boolean { + return leaf !== null && leaf.view.getViewType() === "markdown"; + } + + private createButton(): void { + if (!this.isValidMarkdownLeaf(this.currentLeaf)) { + return; + } + const markdownView = this.currentLeaf!.view as MarkdownView; + + // Try multiple selectors for different Obsidian versions + const possibleSelectors = [ + ".view-header-nav-buttons", + ".view-actions", + ".view-header .clickable-icon:last-child", + ]; + + let actionsContainer: Element | null = null; + + for (const selector of possibleSelectors) { + actionsContainer = markdownView.containerEl.querySelector(selector); + if (actionsContainer) break; + } + + // Fallback: create container if none exists + if (!actionsContainer) { + const viewHeader = + markdownView.containerEl.querySelector(".view-header"); + if (viewHeader) { + actionsContainer = document.createElement("div"); + actionsContainer.className = "view-header-nav-buttons"; + viewHeader.appendChild(actionsContainer); + } + } + + if (!actionsContainer) { + console.warn( + "Could not find or create actions container for editor toolbar button", + ); + return; + } + + // Remove existing button if it exists + if (this.buttonElement) { + this.removeButton(); + } + + this.buttonElement = document.createElement("div"); + this.buttonElement.className = this.BUTTON_CLASS; + + // Insert button at the end of actions container (right side) + if (actionsContainer) { + actionsContainer.appendChild(this.buttonElement); + } + this.root = createRoot(this.buttonElement); + } + + private removeButton(): void { + if (this.root) { + this.root.unmount(); + this.root = null; + } + if (this.buttonElement) { + this.buttonElement.remove(); + this.buttonElement = null; + } + } + + private openStatusModal() { + if (!this.noteStatusService) { + throw new Error( + "open status modal failed because there is no noteStatusService available", + ); + } + eventBus.publish("triggered-open-modal", { + statusService: this.noteStatusService, + }); + } + + private getUnknownStatusConfig() { + const settings = settingsService.settings; + return { + icon: settings.unknownStatusIcon || "❓", + color: settings.unknownStatusColor || "#8b949e", + }; + } + + private render() { + if (!this.root || !this.buttonElement) { + return; + } + + this.root.render( + this.openStatusModal()} + unknownStatusConfig={this.getUnknownStatusConfig()} + />, + ); + } + + destroy() { + eventBus.unsubscribe( + "active-file-change", + "editorToolbarIntegrationSubscription1", + ); + eventBus.unsubscribe( + "plugin-settings-changed", + "editorToolbarIntegrationSubscription2", + ); + eventBus.unsubscribe( + "frontmatter-manually-changed", + "editorToolbarIntegrationSubscription3", + ); + + this.removeButton(); + this.currentLeaf = null; + this.noteStatusService = null; + EditorToolbarIntegration.instance = null; + } +} + +export default EditorToolbarIntegration; diff --git a/main.tsx b/main.tsx index 6c2df10..45696bf 100644 --- a/main.tsx +++ b/main.tsx @@ -8,6 +8,7 @@ import { StatusModalIntegration } from "./integrations/modals/statusModalIntegra import ContextMenuIntegration from "./integrations/context-menu/contextMenuIntegration"; import { FileExplorerIntegration } from "./integrations/file-explorer/file-explorer-integration"; import { CommandsIntegration } from "./integrations/commands/commandsIntegration"; +import EditorToolbarIntegration from "./integrations/toolbar/editorToolbarIntegration"; import { GroupedStatusView, VIEW_TYPE_EXAMPLE, @@ -23,6 +24,7 @@ export default class NoteStatusPlugin extends Plugin { private contextMenuIntegration: ContextMenuIntegration; private fileExplorerIntegration: FileExplorerIntegration; private commandsIntegration: CommandsIntegration; + private editorToolbarIntegration: EditorToolbarIntegration; async onload() { BaseNoteStatusService.initialize(this.app); @@ -34,6 +36,7 @@ export default class NoteStatusPlugin extends Plugin { this.loadStatusBar(), this.loadFileExplorer(), this.loadCommands(), + this.loadEditorToolbar(), this.loadEventBus(), ]); @@ -62,6 +65,7 @@ export default class NoteStatusPlugin extends Plugin { this.contextMenuIntegration?.destroy(); this.fileExplorerIntegration?.destroy(); this.commandsIntegration?.destroy(); + this.editorToolbarIntegration?.destroy(); this.pluginSettingsIntegration?.destroy(); // Clean up event subscriptions @@ -188,4 +192,9 @@ export default class NoteStatusPlugin extends Plugin { this.commandsIntegration = new CommandsIntegration(this); await this.commandsIntegration.integrate(); } + + private async loadEditorToolbar() { + this.editorToolbarIntegration = new EditorToolbarIntegration(this); + await this.editorToolbarIntegration.integrate(); + } } diff --git a/styles/components/toolbar-button.css b/styles/components/toolbar-button.css new file mode 100644 index 0000000..b0c02b7 --- /dev/null +++ b/styles/components/toolbar-button.css @@ -0,0 +1,84 @@ +/* Editor Toolbar Button - Status indicator for notes */ +.editor-toolbar-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--header-height); + height: var(--header-height); + padding: 0; + border: none; + border-radius: var(--clickable-icon-radius); + background: transparent; + color: var(--icon-color); + cursor: pointer; + opacity: var(--icon-opacity); + position: relative; + transition: var(--anim-duration-fast) ease; +} + +.editor-toolbar-btn:hover { + background: var(--background-modifier-hover); + opacity: var(--icon-opacity-hover); +} + +.editor-toolbar-btn:active { + background: var(--background-modifier-active); +} + +.editor-toolbar-btn:focus-visible { + outline: 2px solid var(--background-modifier-border-focus); + outline-offset: -2px; +} + +.editor-toolbar-btn__icon { + font-size: var(--icon-size-sm); + line-height: 1; + transition: inherit; +} + +.editor-toolbar-btn__count { + position: absolute; + top: -2px; + right: -2px; + min-width: var(--size-2-3); + height: var(--size-2-3); + padding: 0 var(--size-2-1); + background: var(--status-color, var(--interactive-accent)); + color: var(--text-on-accent); + font-size: var(--font-ui-smaller); + font-weight: var(--font-weight-medium); + border-radius: var(--radius-xl); + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--background-primary); +} + +/* Status variants */ +.editor-toolbar-btn--no-status .editor-toolbar-btn__icon { + color: var(--status-color, var(--text-muted)); + opacity: 0.7; +} + +.editor-toolbar-btn--has-status .editor-toolbar-btn__icon { + color: var(--status-color, var(--interactive-accent)); +} + +.editor-toolbar-btn--has-status:hover .editor-toolbar-btn__icon { + transform: scale(1.05); +} + +/* Status change animation */ +@keyframes status-pulse { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.1); + } +} + +.editor-toolbar-btn--status-changed .editor-toolbar-btn__icon { + animation: status-pulse var(--anim-duration-moderate) ease-out; +} diff --git a/styles/index.css b/styles/index.css index c3dbc5f..a43eb59 100644 --- a/styles/index.css +++ b/styles/index.css @@ -16,3 +16,4 @@ @import "components/collapsible-counter.css"; @import "components/status-display.css"; @import "components/obsidian-icon.css"; +@import "components/toolbar-button.css";