feat: integrate toolbar v1

This commit is contained in:
Aleix Soler 2025-08-06 21:08:40 +02:00
parent 6502bdcf7c
commit 75a07a6ca5
5 changed files with 411 additions and 0 deletions

View file

@ -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<EditorToolbarButtonProps> = 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 (
<button
className={buttonClasses}
onClick={onClick}
title={getTooltipText()}
aria-label={getTooltipText()}
style={
{
"--status-color": unknownStatusConfig.color,
} as React.CSSProperties
}
>
<span
className="editor-toolbar-btn__icon"
aria-hidden="true"
>
{unknownStatusConfig.icon}
</span>
</button>
);
}
const primaryStatus = allStatuses[0];
return (
<button
className={buttonClasses}
onClick={onClick}
title={getTooltipText()}
aria-label={getTooltipText()}
style={
{
"--status-color":
primaryStatus.color || "var(--interactive-accent)",
} as React.CSSProperties
}
>
<span className="editor-toolbar-btn__icon" aria-hidden="true">
{primaryStatus.icon || "📝"}
</span>
{totalStatuses > 1 && (
<span
className="editor-toolbar-btn__count"
aria-label={`${totalStatuses} statuses`}
>
{totalStatuses}
</span>
)}
</button>
);
},
);

View file

@ -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(
<EditorToolbarButton
statuses={this.noteStatusService?.statuses || {}}
onClick={() => 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;

View file

@ -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();
}
}

View file

@ -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;
}

View file

@ -16,3 +16,4 @@
@import "components/collapsible-counter.css";
@import "components/status-display.css";
@import "components/obsidian-icon.css";
@import "components/toolbar-button.css";