Merge pull request #56 from devonthesofa/feature/toolbar-button

[Feature] toolbar button
This commit is contained in:
Aleix Soler 2025-08-07 21:04:16 +02:00 committed by GitHub
commit d100b5b171
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2054 additions and 2 deletions

View file

@ -169,6 +169,71 @@ export const UISettings: React.FC<Props> = ({ settings, onChange }) => {
}
/>
</SettingItem>
<h4>Editor Toolbar Button</h4>
<SettingItem
name="Show editor toolbar button"
description="Display status button in the editor toolbar"
>
<input
type="checkbox"
checked={settings.showEditorToolbarButton ?? true}
onChange={handleChange("showEditorToolbarButton")}
/>
</SettingItem>
<SettingItem
name="Editor toolbar button position"
description="Choose where to position the status button in the editor toolbar"
>
<Select
options={[
{
value: "left",
display: "Left side of toolbar",
},
{
value: "right",
display: "Right side (after all buttons)",
},
{
value: "right-before",
display: "Right side (before action buttons)",
},
]}
defaultValue={
settings.editorToolbarButtonPosition || "right"
}
onChange={(value) =>
onChange("editorToolbarButtonPosition", value)
}
/>
</SettingItem>
<SettingItem
name="Editor toolbar button display"
description="Control which notes show the status button"
>
<Select
options={[
{
value: "all-notes",
display: "All open notes",
},
{
value: "active-only",
display: "Active note only",
},
]}
defaultValue={
settings.editorToolbarButtonDisplay || "all-notes"
}
onChange={(value) =>
onChange("editorToolbarButtonDisplay", value)
}
/>
</SettingItem>
</div>
);
};

View file

@ -0,0 +1,108 @@
import React, { FC, memo } from "react";
import { GroupedStatuses } from "@/types/noteStatus";
import { StatusesInfoPopup } from "@/integrations/popups/statusesInfoPopupIntegration";
interface EditorToolbarButtonProps {
statuses: GroupedStatuses;
onClick: () => void;
unknownStatusConfig: {
icon: string;
color: string;
};
}
export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
({ statuses, onClick, unknownStatusConfig }) => {
const statusEntries = Object.entries(statuses);
const allStatuses = statusEntries.flatMap(
([_, statusList]) => statusList,
);
const totalStatuses = allStatuses.length;
const handleMouseEnter = () => {
StatusesInfoPopup.open(statuses);
};
const handleMouseLeave = () => {
StatusesInfoPopup.close();
};
const getPrimaryStatus = () => allStatuses[0];
// No status state
if (totalStatuses === 0) {
return (
<button
type="button"
className="clickable-icon"
onClick={onClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
aria-label="Add status to note"
>
<span
className="editor-toolbar-button__icon"
style={{ color: unknownStatusConfig.color }}
>
{unknownStatusConfig.icon}
</span>
</button>
);
}
const primaryStatus = getPrimaryStatus();
// Single status
if (totalStatuses === 1) {
return (
<button
type="button"
className="clickable-icon"
onClick={onClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
aria-label={`Current status: ${primaryStatus.name}. Click to change.`}
>
<span
className="editor-toolbar-button__icon editor-toolbar-button__icon--has-status"
style={{
color:
primaryStatus.color ||
"var(--interactive-accent)",
}}
>
{primaryStatus.icon || "📝"}
</span>
</button>
);
}
// Multiple statuses - show primary + counter
return (
<button
type="button"
className="clickable-icon"
onClick={onClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
aria-label={`${totalStatuses} statuses assigned. Click to change.`}
>
<div className="editor-toolbar-button__icon-container">
<span
className="editor-toolbar-button__icon editor-toolbar-button__icon--has-status"
style={{
color:
primaryStatus.color ||
"var(--interactive-accent)",
}}
>
{primaryStatus.icon || "📝"}
</span>
<span className="editor-toolbar-button__counter">
{totalStatuses}
</span>
</div>
</button>
);
},
);

View file

@ -103,7 +103,7 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
cursor: "pointer",
}}
>
<ObsidianIcon name="thrash" size={12} />
<ObsidianIcon name="trash" size={12} />
</div>
)}
</div>

View file

@ -33,4 +33,8 @@ export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
statusBarShowNoStatusIcon: false,
statusBarShowNoStatusText: true,
vaultSizeLimit: 15000, // Disable dashboard and grouped view for vaults with more notes than this limit
// Editor toolbar button settings
showEditorToolbarButton: true, // Default to show the toolbar button
editorToolbarButtonPosition: "right", // Default position on the right
editorToolbarButtonDisplay: "all-notes", // Default to show button in all notes
};

View file

@ -0,0 +1,386 @@
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";
interface LeafButton {
leaf: WorkspaceLeaf;
root: Root;
buttonElement: HTMLElement;
noteStatusService: NoteStatusService;
}
export class EditorToolbarIntegration {
private static instance: EditorToolbarIntegration | null = null;
private plugin: Plugin;
private leafButtons: Map<WorkspaceLeaf, LeafButton> = new Map();
private currentActiveLeaf: WorkspaceLeaf | null = null;
private readonly BUTTON_CLASS = "note-status-editor-toolbar-badge";
constructor(plugin: Plugin) {
if (EditorToolbarIntegration.instance) {
throw new Error("The editor toolbar instance is already created");
}
this.plugin = plugin;
EditorToolbarIntegration.instance = this;
}
async integrate() {
// Initialize the current active leaf
const activeLeaf = this.plugin.app.workspace.activeLeaf;
if (activeLeaf && this.isValidMarkdownLeaf(activeLeaf)) {
this.currentActiveLeaf = activeLeaf;
}
// Initialize buttons based on display mode
this.initializeButtons();
// Listen for new leaves being created
this.plugin.registerEvent(
this.plugin.app.workspace.on("layout-change", () => {
this.handleLayoutChange();
}),
);
eventBus.subscribe(
"active-file-change",
({ leaf }) => {
this.currentActiveLeaf =
leaf && this.isValidMarkdownLeaf(leaf) ? leaf : null;
this.handleActiveLeafChange();
},
"editorToolbarIntegrationSubscription1",
);
eventBus.subscribe(
"plugin-settings-changed",
({ key }) => {
if (key === "enabledTemplates" || key === "templates") {
this.refreshAllButtons();
}
if (key === "useMultipleStatuses") {
this.refreshAllButtons();
}
if (key === "tagPrefix") {
this.refreshAllButtons();
}
if (key === "useCustomStatusesOnly") {
this.refreshAllButtons();
}
if (key === "customStatuses") {
this.refreshAllButtons();
}
if (
key === "unknownStatusIcon" ||
key === "unknownStatusColor"
) {
this.renderAllButtons();
}
if (key === "showEditorToolbarButton") {
this.handleShowHideButtons();
}
if (key === "editorToolbarButtonPosition") {
this.recreateAllButtons();
}
if (key === "editorToolbarButtonDisplay") {
this.handleDisplayModeChange();
}
},
"editorToolbarIntegrationSubscription2",
);
eventBus.subscribe(
"frontmatter-manually-changed",
({ file }) => {
// Find the button for the specific file and refresh only that one
for (const [leaf, leafButton] of this.leafButtons.entries()) {
const markdownView = leaf.view as MarkdownView;
if (markdownView.file === file) {
leafButton.noteStatusService = new NoteStatusService(
file,
);
leafButton.noteStatusService.populateStatuses();
this.renderButtonForLeaf(leafButton);
break;
}
}
},
"editorToolbarIntegrationSubscription3",
);
}
private initializeButtons() {
const targetLeaves = this.getTargetLeaves();
for (const leaf of targetLeaves) {
this.createButtonForLeaf(leaf);
}
}
private handleActiveLeafChange() {
this.syncButtons();
}
private handleDisplayModeChange() {
this.syncButtons();
}
private syncButtons() {
const targetLeaves = new Set(this.getTargetLeaves());
// Remove buttons that shouldn't exist
for (const leaf of this.leafButtons.keys()) {
if (!targetLeaves.has(leaf)) {
this.removeButtonForLeaf(leaf);
}
}
// Add buttons that should exist but don't
for (const leaf of targetLeaves) {
if (!this.leafButtons.has(leaf)) {
this.createButtonForLeaf(leaf);
}
}
}
private handleLayoutChange() {
this.syncButtons();
}
private createButtonForLeaf(leaf: WorkspaceLeaf) {
if (!this.isValidMarkdownLeaf(leaf)) {
return;
}
// Remove existing button if it exists
if (this.leafButtons.has(leaf)) {
this.removeButtonForLeaf(leaf);
}
// Check if toolbar button should be shown
if (!settingsService.settings.showEditorToolbarButton) {
return;
}
const markdownView = leaf.view as MarkdownView;
if (!markdownView.file) {
return;
}
// Create note status service for this leaf
const noteStatusService = new NoteStatusService(markdownView.file);
noteStatusService.populateStatuses();
// Create button element
const buttonElement = this.createButtonElement(leaf);
if (!buttonElement) {
return;
}
// Create React root
const root = createRoot(buttonElement);
// Store the button info
const leafButton: LeafButton = {
leaf,
root,
buttonElement,
noteStatusService,
};
this.leafButtons.set(leaf, leafButton);
// Render the button
this.renderButtonForLeaf(leafButton);
}
private isValidMarkdownLeaf(leaf: WorkspaceLeaf | null): boolean {
return leaf !== null && leaf.view.getViewType() === "markdown";
}
private shouldHaveButton(leaf: WorkspaceLeaf): boolean {
if (
!settingsService.settings.showEditorToolbarButton ||
!this.isValidMarkdownLeaf(leaf)
) {
return false;
}
return (
settingsService.settings.editorToolbarButtonDisplay ===
"all-notes" || leaf === this.currentActiveLeaf
);
}
private getTargetLeaves(): WorkspaceLeaf[] {
const leaves: WorkspaceLeaf[] = [];
if (
settingsService.settings.editorToolbarButtonDisplay ===
"active-only"
) {
if (
this.currentActiveLeaf &&
this.isValidMarkdownLeaf(this.currentActiveLeaf)
) {
leaves.push(this.currentActiveLeaf);
}
} else {
this.plugin.app.workspace.iterateAllLeaves((leaf) => {
if (this.isValidMarkdownLeaf(leaf)) {
leaves.push(leaf);
}
});
}
return leaves;
}
private createButtonElement(leaf: WorkspaceLeaf): HTMLElement | null {
const markdownView = leaf.view as MarkdownView;
const position = settingsService.settings.editorToolbarButtonPosition;
// Find the appropriate container based on position
let targetContainer: Element | null = null;
if (position === "left") {
// For left position, use the nav buttons area or title container
targetContainer =
markdownView.containerEl.querySelector(
".view-header-nav-buttons",
) ||
markdownView.containerEl.querySelector(
".view-header-title-container",
) ||
markdownView.containerEl.querySelector(".view-header-left");
} else {
// For right positions, use the view-actions container
targetContainer =
markdownView.containerEl.querySelector(".view-actions");
}
if (!targetContainer) {
console.warn(
"Could not find target container for editor toolbar button",
);
return null;
}
const buttonElement = document.createElement("div");
buttonElement.className = this.BUTTON_CLASS;
// Insert button based on position setting
if (position === "left") {
// For left, insert after existing nav buttons
targetContainer.appendChild(buttonElement);
} else if (position === "right-before") {
// For right-before, insert at the beginning of actions
targetContainer.insertBefore(
buttonElement,
targetContainer.firstChild,
);
} else {
// For right, insert at the end of actions
targetContainer.appendChild(buttonElement);
}
return buttonElement;
}
private removeButtonForLeaf(leaf: WorkspaceLeaf): void {
const leafButton = this.leafButtons.get(leaf);
if (leafButton) {
leafButton.root.unmount();
leafButton.buttonElement.remove();
this.leafButtons.delete(leaf);
}
}
private renderButtonForLeaf(leafButton: LeafButton): void {
leafButton.root.render(
<EditorToolbarButton
statuses={leafButton.noteStatusService?.statuses || {}}
onClick={() =>
this.openStatusModal(leafButton.noteStatusService)
}
unknownStatusConfig={this.getUnknownStatusConfig()}
/>,
);
}
private refreshAllButtons(): void {
for (const [leaf, leafButton] of this.leafButtons.entries()) {
const markdownView = leaf.view as MarkdownView;
if (markdownView.file) {
leafButton.noteStatusService = new NoteStatusService(
markdownView.file,
);
leafButton.noteStatusService.populateStatuses();
this.renderButtonForLeaf(leafButton);
}
}
}
private renderAllButtons(): void {
for (const leafButton of this.leafButtons.values()) {
this.renderButtonForLeaf(leafButton);
}
}
private handleShowHideButtons(): void {
this.syncButtons();
}
private recreateAllButtons(): void {
// Remove all existing buttons first
for (const leaf of this.leafButtons.keys()) {
this.removeButtonForLeaf(leaf);
}
this.syncButtons();
}
private openStatusModal(noteStatusService: NoteStatusService) {
if (!noteStatusService) {
throw new Error(
"open status modal failed because there is no noteStatusService available",
);
}
eventBus.publish("triggered-open-modal", {
statusService: noteStatusService,
});
}
private getUnknownStatusConfig() {
const settings = settingsService.settings;
return {
icon: settings.unknownStatusIcon || "❓",
color: settings.unknownStatusColor || "#8b949e",
};
}
destroy() {
eventBus.unsubscribe(
"active-file-change",
"editorToolbarIntegrationSubscription1",
);
eventBus.unsubscribe(
"plugin-settings-changed",
"editorToolbarIntegrationSubscription2",
);
eventBus.unsubscribe(
"frontmatter-manually-changed",
"editorToolbarIntegrationSubscription3",
);
// Remove all buttons
for (const leaf of this.leafButtons.keys()) {
this.removeButtonForLeaf(leaf);
}
this.leafButtons.clear();
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();
}
}

1420
styles.css

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,57 @@
/* Editor Toolbar Button - Using Obsidian's native clickable-icon styling */
/* Container styling for proper integration */
.note-status-editor-toolbar-badge {
display: inline-flex;
align-items: center;
margin: 0 var(--size-2-1);
}
/* Hidden state */
.note-status-editor-toolbar-badge--hidden {
display: none;
}
/* BEM-based styles for toolbar button components */
/* Status icon styling */
.editor-toolbar-button__icon {
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
/* Icon with status color */
.editor-toolbar-button__icon--has-status {
color: var(--interactive-accent);
}
/* Multiple statuses container */
.editor-toolbar-button__icon-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
/* Status counter badge */
.editor-toolbar-button__counter {
position: absolute;
top: -4px;
right: -6px;
min-width: 12px;
height: 12px;
padding: 0 2px;
background: var(--interactive-accent);
color: var(--text-on-accent);
font-size: 8px;
font-weight: var(--font-semibold);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
border: 1px solid var(--background-primary);
}

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";

View file

@ -33,5 +33,9 @@ export type PluginSettings = {
statusBarShowNoStatusIcon: boolean; // Whether to show icon in status bar for no status
statusBarShowNoStatusText: boolean; // Whether to show text in status bar for no status
vaultSizeLimit: number; // Disable dashboard and grouped view for vaults with more notes than this limit
// Editor toolbar button settings
showEditorToolbarButton: boolean; // Whether to show the toolbar button
editorToolbarButtonPosition: "left" | "right" | "right-before"; // Position of the toolbar button
editorToolbarButtonDisplay: "all-notes" | "active-only"; // Whether to show button in all notes or only active one
[key: string]: unknown;
};