mirror of
https://github.com/devonthesofa/obsidian-note-status.git
synced 2026-07-22 05:45:04 +00:00
refactor: vanillajs to react
This commit is contained in:
parent
db3e6cea1a
commit
cb4fda7bf1
131 changed files with 7522 additions and 8854 deletions
46
components/FileExplorer/FileExplorerIcon.tsx
Normal file
46
components/FileExplorer/FileExplorerIcon.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { GroupedStatuses } from "@/types/noteStatus";
|
||||
import React, { FC } from "react";
|
||||
|
||||
type Props = {
|
||||
statuses: GroupedStatuses;
|
||||
onMouseEnter: (statuses: GroupedStatuses) => void;
|
||||
onMouseLeave: (statuses: GroupedStatuses) => void;
|
||||
};
|
||||
|
||||
export const FileExplorerIcon: FC<Props> = ({
|
||||
statuses,
|
||||
onMouseLeave,
|
||||
onMouseEnter,
|
||||
}) => {
|
||||
const statusEntries = Object.entries(statuses);
|
||||
const totalStatuses = statusEntries.reduce(
|
||||
(acc, [, list]) => acc + list.length,
|
||||
0,
|
||||
);
|
||||
|
||||
if (totalStatuses === 0) return null;
|
||||
|
||||
const primaryStatus = statusEntries[0]?.[1]?.[0];
|
||||
if (!primaryStatus) return null;
|
||||
|
||||
return (
|
||||
<div className="status-wrapper">
|
||||
<div
|
||||
className="status-minimal"
|
||||
onMouseEnter={() => onMouseEnter(statuses)}
|
||||
onMouseLeave={() => onMouseLeave(statuses)}
|
||||
style={
|
||||
{
|
||||
"--primary-color":
|
||||
primaryStatus.color || "var(--text-accent)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="status-icon">{primaryStatus.icon}</span>
|
||||
{totalStatuses > 1 && (
|
||||
<span className="status-count">{totalStatuses}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
259
components/GrouppedStatusView/GrouppedStatusView.tsx
Normal file
259
components/GrouppedStatusView/GrouppedStatusView.tsx
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { FilterSection } from "./components/FilterSection";
|
||||
import { TagSection } from "./components/TagSection";
|
||||
|
||||
export type FileItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type StatusItem = {
|
||||
name: string;
|
||||
color: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export type FilesByStatus = {
|
||||
[statusName: string]: FileItem[];
|
||||
};
|
||||
|
||||
export type GroupedByStatus = {
|
||||
[frontmatterTag: string]: FilesByStatus;
|
||||
};
|
||||
|
||||
export type GrouppedStatusViewProps = {
|
||||
getAllFiles: () => FileItem[];
|
||||
processFiles: (files: FileItem[]) => GroupedByStatus;
|
||||
onFileClick: (file: FileItem) => void;
|
||||
subscribeToEvents: (onDataChange: () => void) => () => void;
|
||||
getAvailableStatuses: () => StatusItem[];
|
||||
};
|
||||
|
||||
const ITEMS_PER_LOAD = 20;
|
||||
|
||||
export const GrouppedStatusView = ({
|
||||
getAllFiles,
|
||||
processFiles,
|
||||
onFileClick,
|
||||
subscribeToEvents,
|
||||
getAvailableStatuses,
|
||||
}: GrouppedStatusViewProps) => {
|
||||
const [groupedData, setGroupedData] = useState<GroupedByStatus>({});
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const [noteNameFilter, setNoteNameFilter] = useState("");
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||
const [loadedItems, setLoadedItems] = useState<Record<string, number>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const files = getAllFiles();
|
||||
const processed = processFiles(files);
|
||||
setGroupedData(processed);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [getAllFiles, processFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
const handleFileChange = () => {
|
||||
loadData();
|
||||
};
|
||||
|
||||
const unsubscribe = subscribeToEvents(handleFileChange);
|
||||
|
||||
return unsubscribe;
|
||||
}, [loadData, subscribeToEvents]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchFilter.trim() && !noteNameFilter.trim()) return groupedData;
|
||||
|
||||
const filtered: GroupedByStatus = {};
|
||||
const searchLower = searchFilter.toLowerCase();
|
||||
const noteNameLower = noteNameFilter.toLowerCase();
|
||||
|
||||
Object.entries(groupedData).forEach(([tag, statusGroups]) => {
|
||||
filtered[tag] = {};
|
||||
Object.entries(statusGroups).forEach(([statusName, files]) => {
|
||||
let matchingFiles = files;
|
||||
|
||||
// Filter by status/tag if searchFilter is provided
|
||||
if (searchFilter.trim()) {
|
||||
matchingFiles = matchingFiles.filter(
|
||||
(file) =>
|
||||
statusName.toLowerCase().includes(searchLower) ||
|
||||
tag.toLowerCase().includes(searchLower),
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by note name if noteNameFilter is provided
|
||||
if (noteNameFilter.trim()) {
|
||||
matchingFiles = matchingFiles.filter(
|
||||
(file) =>
|
||||
file.name.toLowerCase().includes(noteNameLower) ||
|
||||
file.path.toLowerCase().includes(noteNameLower),
|
||||
);
|
||||
}
|
||||
|
||||
if (matchingFiles.length > 0) {
|
||||
filtered[tag][statusName] = matchingFiles;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [groupedData, searchFilter, noteNameFilter]);
|
||||
|
||||
// Auto-expand groups when there's only one group
|
||||
useEffect(() => {
|
||||
const dataToCheck = filteredData;
|
||||
const groupKeys = Object.keys(dataToCheck);
|
||||
|
||||
if (groupKeys.length === 1) {
|
||||
const singleGroupKey = `tag-${groupKeys[0]}`;
|
||||
setExpandedGroups((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(singleGroupKey);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Also expand all status groups within the single tag group
|
||||
const statusGroups = dataToCheck[groupKeys[0]];
|
||||
const statusKeys = Object.keys(statusGroups).map(
|
||||
(statusName) => `${groupKeys[0]}-${statusName}`,
|
||||
);
|
||||
setExpandedFiles((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
statusKeys.forEach((key) => newSet.add(key));
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
}, [filteredData]);
|
||||
|
||||
const toggleGroup = useCallback((groupKey: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(groupKey)) {
|
||||
newSet.delete(groupKey);
|
||||
} else {
|
||||
newSet.add(groupKey);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleFiles = useCallback((groupKey: string) => {
|
||||
setExpandedFiles((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(groupKey)) {
|
||||
newSet.delete(groupKey);
|
||||
} else {
|
||||
newSet.add(groupKey);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getLoadedCount = useCallback(
|
||||
(groupKey: string) => {
|
||||
return loadedItems[groupKey] || ITEMS_PER_LOAD;
|
||||
},
|
||||
[loadedItems],
|
||||
);
|
||||
|
||||
const loadMoreItems = useCallback((groupKey: string) => {
|
||||
setLoadedItems((prev) => ({
|
||||
...prev,
|
||||
[groupKey]: (prev[groupKey] || ITEMS_PER_LOAD) + ITEMS_PER_LOAD,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(
|
||||
e: React.UIEvent<HTMLDivElement>,
|
||||
groupKey: string,
|
||||
totalItems: number,
|
||||
) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
const loadedCount = getLoadedCount(groupKey);
|
||||
|
||||
if (
|
||||
scrollHeight - scrollTop <= clientHeight + 100 &&
|
||||
loadedCount < totalItems
|
||||
) {
|
||||
loadMoreItems(groupKey);
|
||||
}
|
||||
},
|
||||
[getLoadedCount, loadMoreItems],
|
||||
);
|
||||
|
||||
const handleFileClickCallback = useCallback(
|
||||
(file: FileItem) => {
|
||||
onFileClick(file);
|
||||
},
|
||||
[onFileClick],
|
||||
);
|
||||
|
||||
const availableStatuses = getAvailableStatuses();
|
||||
const statusMap = new Map(availableStatuses.map((s) => [s.name, s]));
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="groupped-status-view">
|
||||
<div className="groupped-status-loading">
|
||||
Loading statuses...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="groupped-status-view">
|
||||
<FilterSection
|
||||
searchFilter={searchFilter}
|
||||
noteNameFilter={noteNameFilter}
|
||||
onSearchFilterChange={setSearchFilter}
|
||||
onNoteNameFilterChange={setNoteNameFilter}
|
||||
/>
|
||||
|
||||
<div className="groupped-status-content">
|
||||
{Object.entries(filteredData).map(([tag, statusGroups]) => {
|
||||
const tagKey = `tag-${tag}`;
|
||||
const isTagExpanded = expandedGroups.has(tagKey);
|
||||
|
||||
return (
|
||||
<TagSection
|
||||
key={tag}
|
||||
tag={tag}
|
||||
statusGroups={statusGroups}
|
||||
isExpanded={isTagExpanded}
|
||||
statusMap={statusMap}
|
||||
expandedFiles={expandedFiles}
|
||||
getLoadedCount={getLoadedCount}
|
||||
onToggle={() => toggleGroup(tagKey)}
|
||||
onToggleFiles={toggleFiles}
|
||||
onFileClick={handleFileClickCallback}
|
||||
onScroll={handleScroll}
|
||||
onLoadMore={loadMoreItems}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{Object.keys(filteredData).length === 0 && (
|
||||
<div className="groupped-status-empty">
|
||||
{searchFilter
|
||||
? "No files match your search."
|
||||
: "No statuses found."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
78
components/GrouppedStatusView/components/FileList.tsx
Normal file
78
components/GrouppedStatusView/components/FileList.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import React, { useCallback } from "react";
|
||||
import { FileItem } from "../GrouppedStatusView";
|
||||
|
||||
interface FileListProps {
|
||||
files: FileItem[];
|
||||
groupKey: string;
|
||||
loadedCount: number;
|
||||
onFileClick: (file: FileItem) => void;
|
||||
onScroll: (
|
||||
e: React.UIEvent<HTMLDivElement>,
|
||||
groupKey: string,
|
||||
totalItems: number,
|
||||
) => void;
|
||||
onLoadMore: (groupKey: string) => void;
|
||||
}
|
||||
|
||||
export const FileList = ({
|
||||
files,
|
||||
groupKey,
|
||||
loadedCount,
|
||||
onFileClick,
|
||||
onScroll,
|
||||
onLoadMore,
|
||||
}: FileListProps) => {
|
||||
const visibleFiles = files.slice(0, loadedCount);
|
||||
const hasMoreItems = files.length > loadedCount;
|
||||
|
||||
const handleFileClick = useCallback(
|
||||
(file: FileItem) => {
|
||||
onFileClick(file);
|
||||
},
|
||||
[onFileClick],
|
||||
);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
onLoadMore(groupKey);
|
||||
}, [onLoadMore, groupKey]);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(e: React.UIEvent<HTMLDivElement>) => {
|
||||
onScroll(e, groupKey, files.length);
|
||||
},
|
||||
[onScroll, groupKey, files.length],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="groupped-status-files">
|
||||
<div className="groupped-status-files-list" onScroll={handleScroll}>
|
||||
{visibleFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="groupped-status-file-item"
|
||||
onClick={() => handleFileClick(file)}
|
||||
>
|
||||
<span className="groupped-status-file-name">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="groupped-status-file-path">
|
||||
{file.path}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasMoreItems && (
|
||||
<div className="groupped-status-load-more">
|
||||
<button
|
||||
className="groupped-status-load-btn"
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
Load more... ({files.length - loadedCount}{" "}
|
||||
remaining)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
37
components/GrouppedStatusView/components/FilterSection.tsx
Normal file
37
components/GrouppedStatusView/components/FilterSection.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import React from "react";
|
||||
import { SearchFilter } from "@/components/atoms/SearchFilter";
|
||||
|
||||
interface FilterSectionProps {
|
||||
searchFilter: string;
|
||||
noteNameFilter: string;
|
||||
onSearchFilterChange: (value: string) => void;
|
||||
onNoteNameFilterChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const FilterSection = ({
|
||||
searchFilter,
|
||||
noteNameFilter,
|
||||
onSearchFilterChange,
|
||||
onNoteNameFilterChange,
|
||||
}: FilterSectionProps) => {
|
||||
return (
|
||||
<div className="groupped-status-header">
|
||||
<h3 className="groupped-status-title">Status Groups</h3>
|
||||
<div className="groupped-status-filters">
|
||||
<SearchFilter
|
||||
value={searchFilter}
|
||||
onFilterChange={onSearchFilterChange}
|
||||
/>
|
||||
<div className="groupped-status-note-filter">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by note name..."
|
||||
className="groupped-status-note-input"
|
||||
value={noteNameFilter}
|
||||
onChange={(e) => onNoteNameFilterChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
75
components/GrouppedStatusView/components/StatusGroup.tsx
Normal file
75
components/GrouppedStatusView/components/StatusGroup.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import React, { useCallback } from "react";
|
||||
import { StatusBadge } from "@/components/atoms/StatusBadge";
|
||||
import { CollapsibleCounter } from "@/components/atoms/CollapsibleCounter";
|
||||
import { FileItem, StatusItem } from "../GrouppedStatusView";
|
||||
import { FileList } from "./FileList";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
|
||||
interface StatusGroupProps {
|
||||
statusName: string;
|
||||
status: StatusItem;
|
||||
files: FileItem[];
|
||||
tag: string;
|
||||
isExpanded: boolean;
|
||||
loadedCount: number;
|
||||
onToggle: () => void;
|
||||
onFileClick: (file: FileItem) => void;
|
||||
onScroll: (
|
||||
e: React.UIEvent<HTMLDivElement>,
|
||||
groupKey: string,
|
||||
totalItems: number,
|
||||
) => void;
|
||||
onLoadMore: (groupKey: string) => void;
|
||||
}
|
||||
|
||||
export const StatusGroup = ({
|
||||
statusName,
|
||||
status,
|
||||
files,
|
||||
tag,
|
||||
isExpanded,
|
||||
loadedCount,
|
||||
onToggle,
|
||||
onFileClick,
|
||||
onScroll,
|
||||
onLoadMore,
|
||||
}: StatusGroupProps) => {
|
||||
const groupKey = `${tag}-${statusName}`;
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
onToggle();
|
||||
}, [onToggle]);
|
||||
|
||||
return (
|
||||
<div className="groupped-status-group">
|
||||
<div
|
||||
className="groupped-status-group-header"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<StatusBadge
|
||||
status={
|
||||
{ ...status, icon: status.icon || "" } as NoteStatus
|
||||
}
|
||||
/>
|
||||
<div className="groupped-status-group-info">
|
||||
<CollapsibleCounter
|
||||
count={files.length}
|
||||
isCollapsed={!isExpanded}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<FileList
|
||||
files={files}
|
||||
groupKey={groupKey}
|
||||
loadedCount={loadedCount}
|
||||
onFileClick={onFileClick}
|
||||
onScroll={onScroll}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
97
components/GrouppedStatusView/components/TagSection.tsx
Normal file
97
components/GrouppedStatusView/components/TagSection.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import React, { useCallback } from "react";
|
||||
import { GroupLabel } from "@/components/atoms/GroupLabel";
|
||||
import { CollapsibleCounter } from "@/components/atoms/CollapsibleCounter";
|
||||
import { FilesByStatus, FileItem, StatusItem } from "../GrouppedStatusView";
|
||||
import { StatusGroup } from "./StatusGroup";
|
||||
|
||||
interface TagSectionProps {
|
||||
tag: string;
|
||||
statusGroups: FilesByStatus;
|
||||
isExpanded: boolean;
|
||||
statusMap: Map<string, StatusItem>;
|
||||
expandedFiles: Set<string>;
|
||||
getLoadedCount: (groupKey: string) => number;
|
||||
onToggle: () => void;
|
||||
onToggleFiles: (groupKey: string) => void;
|
||||
onFileClick: (file: FileItem) => void;
|
||||
onScroll: (
|
||||
e: React.UIEvent<HTMLDivElement>,
|
||||
groupKey: string,
|
||||
totalItems: number,
|
||||
) => void;
|
||||
onLoadMore: (groupKey: string) => void;
|
||||
}
|
||||
|
||||
export const TagSection = ({
|
||||
tag,
|
||||
statusGroups,
|
||||
isExpanded,
|
||||
statusMap,
|
||||
expandedFiles,
|
||||
getLoadedCount,
|
||||
onToggle,
|
||||
onToggleFiles,
|
||||
onFileClick,
|
||||
onScroll,
|
||||
onLoadMore,
|
||||
}: TagSectionProps) => {
|
||||
const totalFilesInTag = Object.values(statusGroups).reduce(
|
||||
(total, files) => total + files.length,
|
||||
0,
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
onToggle();
|
||||
}, [onToggle]);
|
||||
|
||||
const handleToggleFiles = useCallback(
|
||||
(groupKey: string) => {
|
||||
onToggleFiles(groupKey);
|
||||
},
|
||||
[onToggleFiles],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="groupped-status-tag-section">
|
||||
<div className="groupped-status-tag-header" onClick={handleToggle}>
|
||||
<GroupLabel name={tag} />
|
||||
<CollapsibleCounter
|
||||
count={totalFilesInTag}
|
||||
isCollapsed={!isExpanded}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="groupped-status-tag-content">
|
||||
{Object.entries(statusGroups).map(([statusName, files]) => {
|
||||
if (files.length === 0) return null;
|
||||
|
||||
const status = statusMap.get(statusName);
|
||||
if (!status) return null;
|
||||
|
||||
const groupKey = `${tag}-${statusName}`;
|
||||
const filesExpanded = expandedFiles.has(groupKey);
|
||||
const loadedCount = getLoadedCount(groupKey);
|
||||
|
||||
return (
|
||||
<StatusGroup
|
||||
key={statusName}
|
||||
statusName={statusName}
|
||||
status={status}
|
||||
files={files}
|
||||
tag={tag}
|
||||
isExpanded={filesExpanded}
|
||||
loadedCount={loadedCount}
|
||||
onToggle={() => handleToggleFiles(groupKey)}
|
||||
onFileClick={onFileClick}
|
||||
onScroll={onScroll}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import React, { useCallback } from "react";
|
||||
import { App } from "obsidian";
|
||||
import { Status, NoteStatusSettings } from "../models/types";
|
||||
import { SettingsUI } from "../integrations/settings/SettingsUI";
|
||||
import { SettingsUICallbacks } from "../integrations/settings/types";
|
||||
|
||||
interface SettingsControllerProps {
|
||||
app: App;
|
||||
settings: NoteStatusSettings;
|
||||
onSettingsChange: (settings: NoteStatusSettings) => Promise<void>;
|
||||
}
|
||||
|
||||
export const SettingsController: React.FC<SettingsControllerProps> = ({
|
||||
app,
|
||||
settings,
|
||||
onSettingsChange,
|
||||
}) => {
|
||||
const callbacks: SettingsUICallbacks = {
|
||||
onTemplateToggle: useCallback(
|
||||
async (templateId: string, enabled: boolean) => {
|
||||
const newSettings = { ...settings };
|
||||
|
||||
if (enabled) {
|
||||
if (!newSettings.enabledTemplates.includes(templateId)) {
|
||||
newSettings.enabledTemplates.push(templateId);
|
||||
}
|
||||
} else {
|
||||
newSettings.enabledTemplates =
|
||||
newSettings.enabledTemplates.filter(
|
||||
(id: string) => id !== templateId,
|
||||
);
|
||||
}
|
||||
|
||||
await onSettingsChange(newSettings);
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
),
|
||||
|
||||
onSettingChange: useCallback(
|
||||
async (key: keyof NoteStatusSettings, value) => {
|
||||
console.log("etnra?", key, value);
|
||||
const newSettings = { ...settings };
|
||||
newSettings[key] = value;
|
||||
await onSettingsChange(newSettings);
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
),
|
||||
|
||||
onCustomStatusChange: useCallback(
|
||||
async (
|
||||
index: number,
|
||||
field: keyof Status | "color",
|
||||
value: string,
|
||||
) => {
|
||||
const newSettings = { ...settings };
|
||||
const status = newSettings.customStatuses[index];
|
||||
if (!status) return;
|
||||
|
||||
if (field === "name") {
|
||||
const oldName = status.name;
|
||||
status.name = value;
|
||||
|
||||
if (oldName !== status.name) {
|
||||
newSettings.statusColors[status.name] =
|
||||
newSettings.statusColors[oldName];
|
||||
delete newSettings.statusColors[oldName];
|
||||
}
|
||||
} else if (field === "color") {
|
||||
newSettings.statusColors[status.name] = value;
|
||||
} else {
|
||||
status[field] = value;
|
||||
}
|
||||
|
||||
await onSettingsChange(newSettings);
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
),
|
||||
|
||||
onCustomStatusRemove: useCallback(
|
||||
async (index: number) => {
|
||||
const newSettings = { ...settings };
|
||||
const status = newSettings.customStatuses[index];
|
||||
if (!status) return;
|
||||
|
||||
newSettings.customStatuses.splice(index, 1);
|
||||
delete newSettings.statusColors[status.name];
|
||||
|
||||
await onSettingsChange(newSettings);
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
),
|
||||
|
||||
onCustomStatusAdd: useCallback(async () => {
|
||||
const newSettings = { ...settings };
|
||||
const newStatus: Status = {
|
||||
name: `status${newSettings.customStatuses.length + 1}`,
|
||||
icon: "⭐",
|
||||
};
|
||||
|
||||
newSettings.customStatuses.push(newStatus);
|
||||
newSettings.statusColors[newStatus.name] = "#ffffff";
|
||||
|
||||
await onSettingsChange(newSettings);
|
||||
}, [settings, onSettingsChange]),
|
||||
};
|
||||
|
||||
return <SettingsUI settings={settings} callbacks={callbacks} />;
|
||||
};
|
||||
|
||||
export default SettingsController;
|
||||
57
components/SettingsUI.tsx/BehaviourSettings.tsx
Normal file
57
components/SettingsUI.tsx/BehaviourSettings.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import React from "react";
|
||||
import { SettingItem } from "./SettingItem";
|
||||
|
||||
export type Props = {
|
||||
settings: PluginSettings;
|
||||
onChange: (key: keyof PluginSettings, value: unknown) => void;
|
||||
};
|
||||
|
||||
export const BehaviourSettings: React.FC<Props> = ({ settings, onChange }) => {
|
||||
return (
|
||||
<div>
|
||||
<h3>Status tag</h3>
|
||||
|
||||
<SettingItem
|
||||
name="Enable multiple statuses"
|
||||
description="Allow notes to have multiple statuses at the same time"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.useMultipleStatuses}
|
||||
onChange={(e) =>
|
||||
onChange("useMultipleStatuses", e.target.checked)
|
||||
}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Status tag prefix"
|
||||
description="The YAML frontmatter tag name used for status (default: obsidian-note-status)"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.tagPrefix}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.trim()) {
|
||||
onChange("tagPrefix", e.target.value.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Strict status validation"
|
||||
description="Only show statuses that are defined in templates or custom statuses. ⚠️ WARNING: When enabled, any unknown statuses will be automatically removed when modifying file statuses."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.strictStatuses || false}
|
||||
onChange={(e) =>
|
||||
onChange("strictStatuses", e.target.checked)
|
||||
}
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
91
components/SettingsUI.tsx/CustomStatusItem.tsx
Normal file
91
components/SettingsUI.tsx/CustomStatusItem.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import React from "react";
|
||||
|
||||
export type Props = {
|
||||
status: NoteStatus;
|
||||
index: number;
|
||||
settings: PluginSettings;
|
||||
onCustomStatusChange: (
|
||||
index: number,
|
||||
column: "name" | "icon" | "color" | "description",
|
||||
value: string,
|
||||
) => void;
|
||||
onCustomStatusRemove: (index: number) => void;
|
||||
};
|
||||
|
||||
export const CustomStatusItem: React.FC<Props> = ({
|
||||
status,
|
||||
index,
|
||||
onCustomStatusChange,
|
||||
onCustomStatusRemove,
|
||||
}) => {
|
||||
return (
|
||||
<div className="custom-status-card">
|
||||
<div
|
||||
className="custom-status-preview"
|
||||
style={{ borderLeftColor: status.color || "var(--text-muted)" }}
|
||||
>
|
||||
<input
|
||||
className="custom-status-icon-input"
|
||||
type="text"
|
||||
placeholder="🔥"
|
||||
value={status.icon}
|
||||
onChange={(e) =>
|
||||
onCustomStatusChange(
|
||||
index,
|
||||
"icon",
|
||||
e.target.value || "❓",
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="custom-status-text-inputs">
|
||||
<input
|
||||
className="custom-status-name-input"
|
||||
type="text"
|
||||
placeholder="Status name"
|
||||
value={status.name}
|
||||
onChange={(e) =>
|
||||
onCustomStatusChange(
|
||||
index,
|
||||
"name",
|
||||
e.target.value || "unnamed",
|
||||
)
|
||||
}
|
||||
style={{ color: status.color || "var(--text-normal)" }}
|
||||
/>
|
||||
<input
|
||||
className="custom-status-description-input"
|
||||
type="text"
|
||||
placeholder="Description (optional)"
|
||||
value={status.description || ""}
|
||||
onChange={(e) =>
|
||||
onCustomStatusChange(
|
||||
index,
|
||||
"description",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="custom-status-controls">
|
||||
<input
|
||||
className="custom-status-color-input"
|
||||
type="color"
|
||||
value={status.color || "#ffffff"}
|
||||
onChange={(e) =>
|
||||
onCustomStatusChange(index, "color", e.target.value)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
className="custom-status-remove-btn clickable-icon mod-warning"
|
||||
onClick={() => onCustomStatusRemove(index)}
|
||||
aria-label="Remove status"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
94
components/SettingsUI.tsx/CustomStatusSettings.tsx
Normal file
94
components/SettingsUI.tsx/CustomStatusSettings.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import React from "react";
|
||||
import {
|
||||
CustomStatusItem,
|
||||
Props as CustomStatusItemProps,
|
||||
} from "./CustomStatusItem";
|
||||
import { SettingItem } from "./SettingItem";
|
||||
|
||||
export type Props = {
|
||||
settings: PluginSettings;
|
||||
onChange: (key: keyof PluginSettings, value: unknown) => void;
|
||||
};
|
||||
|
||||
export const CustomStatusSettings: React.FC<Props> = ({
|
||||
settings,
|
||||
onChange,
|
||||
}) => {
|
||||
const addNewCustomStatus = () => {
|
||||
const currentStatuses = [...settings.customStatuses];
|
||||
currentStatuses.push({
|
||||
name: "",
|
||||
icon: "",
|
||||
});
|
||||
onChange("customStatuses", currentStatuses);
|
||||
};
|
||||
const removeCustomStatus: CustomStatusItemProps["onCustomStatusRemove"] = (
|
||||
index,
|
||||
) => {
|
||||
const currentStatuses = [...settings.customStatuses];
|
||||
currentStatuses.splice(index, 1);
|
||||
onChange("customStatuses", currentStatuses);
|
||||
};
|
||||
|
||||
const updateCustomStatus: CustomStatusItemProps["onCustomStatusChange"] = (
|
||||
index,
|
||||
column,
|
||||
value,
|
||||
) => {
|
||||
const currentStatuses = [...settings.customStatuses];
|
||||
|
||||
const target = currentStatuses[index];
|
||||
if (target) {
|
||||
target[column] = value;
|
||||
onChange("customStatuses", currentStatuses);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Custom statuses</h3>
|
||||
|
||||
<SettingItem
|
||||
name="Use only custom statuses"
|
||||
description="Ignore template statuses and use only the custom statuses defined below"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.useCustomStatusesOnly || false}
|
||||
onChange={(e) =>
|
||||
onChange("useCustomStatusesOnly", e.target.checked)
|
||||
}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Custom statuses"
|
||||
description="Manage the custom statuses"
|
||||
vertical
|
||||
>
|
||||
<div>
|
||||
{settings.customStatuses.map((status, index) => (
|
||||
<CustomStatusItem
|
||||
key={index}
|
||||
status={status}
|
||||
index={index}
|
||||
settings={settings}
|
||||
onCustomStatusChange={updateCustomStatus}
|
||||
onCustomStatusRemove={removeCustomStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Add new status"
|
||||
description="Add an empty custom status to be modified"
|
||||
>
|
||||
<button className="mod-cta" onClick={addNewCustomStatus}>
|
||||
Add Status
|
||||
</button>
|
||||
</SettingItem>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
7
components/SettingsUI.tsx/NoStatusesMessage.tsx
Normal file
7
components/SettingsUI.tsx/NoStatusesMessage.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from "react";
|
||||
|
||||
export const NoStatusesMessage: React.FC = () => (
|
||||
<div className="setting-item-description">
|
||||
No statuses available. Enable templates or add custom statuses first.
|
||||
</div>
|
||||
);
|
||||
105
components/SettingsUI.tsx/QuickCommandsSettings.tsx
Normal file
105
components/SettingsUI.tsx/QuickCommandsSettings.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { PluginSettings, StatusTemplate } from "@/types/pluginSettings";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { StatusGroup } from "./StatusGroup";
|
||||
import { NoStatusesMessage } from "./NoStatusesMessage";
|
||||
|
||||
export type Props = {
|
||||
settings: PluginSettings;
|
||||
onChange: (key: keyof PluginSettings, value: unknown) => void;
|
||||
templates: StatusTemplate[];
|
||||
};
|
||||
|
||||
type GroupedStatuses = {
|
||||
customStatuses: PluginSettings["customStatuses"];
|
||||
templateGroups: Array<{
|
||||
template: StatusTemplate;
|
||||
statuses: PluginSettings["customStatuses"];
|
||||
}>;
|
||||
};
|
||||
|
||||
export const QuickCommandsSettings: React.FC<Props> = ({
|
||||
settings,
|
||||
onChange,
|
||||
templates,
|
||||
}) => {
|
||||
const groupedStatuses = useMemo((): GroupedStatuses => {
|
||||
const customStatuses = settings.customStatuses.filter(
|
||||
(s) => s.name !== "unknown",
|
||||
);
|
||||
const templateGroups = [];
|
||||
if (!settings.useCustomStatusesOnly) {
|
||||
for (const templateId of settings.enabledTemplates) {
|
||||
const template = templates.find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
const statuses = template.statuses.filter(
|
||||
(s) => s.name !== "unknown",
|
||||
);
|
||||
if (statuses.length > 0) {
|
||||
templateGroups.push({ template, statuses });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { customStatuses, templateGroups };
|
||||
}, [settings, templates]);
|
||||
|
||||
const currentQuickCommands = settings.quickStatusCommands || [];
|
||||
|
||||
const handleQuickCommandToggle = useCallback(
|
||||
(statusName: string, enabled: boolean) => {
|
||||
const updatedCommands = enabled
|
||||
? [
|
||||
...currentQuickCommands.filter(
|
||||
(cmd) => cmd !== statusName,
|
||||
),
|
||||
statusName,
|
||||
]
|
||||
: currentQuickCommands.filter((cmd) => cmd !== statusName);
|
||||
onChange("quickStatusCommands", updatedCommands);
|
||||
},
|
||||
[currentQuickCommands, onChange],
|
||||
);
|
||||
|
||||
const hasAnyStatuses =
|
||||
groupedStatuses.customStatuses.length > 0 ||
|
||||
groupedStatuses.templateGroups.length > 0;
|
||||
|
||||
return (
|
||||
<div className="quick-commands-settings">
|
||||
<h3>Quick status commands</h3>
|
||||
<p>
|
||||
Select which statuses should have dedicated commands in the
|
||||
command palette. These can be assigned hotkeys for quick access.
|
||||
</p>
|
||||
<div className="quick-commands-container">
|
||||
{!hasAnyStatuses ? (
|
||||
<NoStatusesMessage />
|
||||
) : (
|
||||
<>
|
||||
{groupedStatuses.customStatuses.length > 0 && (
|
||||
<StatusGroup
|
||||
statuses={groupedStatuses.customStatuses}
|
||||
title="Custom Statuses"
|
||||
description="Your manually defined statuses"
|
||||
currentQuickCommands={currentQuickCommands}
|
||||
onToggle={handleQuickCommandToggle}
|
||||
/>
|
||||
)}
|
||||
{groupedStatuses.templateGroups.map(
|
||||
({ template, statuses }) => (
|
||||
<StatusGroup
|
||||
key={template.id}
|
||||
statuses={statuses}
|
||||
title={template.name}
|
||||
description={template.description}
|
||||
currentQuickCommands={currentQuickCommands}
|
||||
onToggle={handleQuickCommandToggle}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
27
components/SettingsUI.tsx/SettingItem.tsx
Normal file
27
components/SettingsUI.tsx/SettingItem.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import React from "react";
|
||||
|
||||
interface SettingItemProps {
|
||||
name: string;
|
||||
description: string;
|
||||
vertical?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SettingItem: React.FC<SettingItemProps> = ({
|
||||
name,
|
||||
description,
|
||||
children,
|
||||
vertical,
|
||||
}) => (
|
||||
<div className={`setting-item ${vertical && "setting-item-vertical"}`}>
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">{name}</div>
|
||||
<div className="setting-item-description">{description}</div>
|
||||
</div>
|
||||
<div
|
||||
className={vertical ? "setting-item-full" : "setting-item-control"}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
45
components/SettingsUI.tsx/StatusGroup.tsx
Normal file
45
components/SettingsUI.tsx/StatusGroup.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import React from "react";
|
||||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import { SettingItem } from "./SettingItem";
|
||||
|
||||
export type StatusGroupProps = {
|
||||
statuses: PluginSettings["customStatuses"];
|
||||
title: string;
|
||||
description?: string;
|
||||
currentQuickCommands: string[];
|
||||
onToggle: (statusName: string, enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export const StatusGroup: React.FC<StatusGroupProps> = ({
|
||||
statuses,
|
||||
title,
|
||||
description,
|
||||
currentQuickCommands,
|
||||
onToggle,
|
||||
}) => (
|
||||
<div className="status-group">
|
||||
<div className="status-group-header">
|
||||
<div className="status-group-title">{title}</div>
|
||||
{description && (
|
||||
<div className="status-group-description">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="status-group-items">
|
||||
{statuses.map((status) => (
|
||||
<SettingItem
|
||||
key={status.name}
|
||||
name={`${status.icon} ${status.name}`}
|
||||
description={status.description || ""}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentQuickCommands.includes(status.name)}
|
||||
onChange={(e) =>
|
||||
onToggle(status.name, e.target.checked)
|
||||
}
|
||||
/>
|
||||
</SettingItem>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
36
components/SettingsUI.tsx/TemplateItem.tsx
Normal file
36
components/SettingsUI.tsx/TemplateItem.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import React from "react";
|
||||
import { StatusTemplate } from "@/types/pluginSettings";
|
||||
import { TemplateStatusChip } from "./TemplateStatusChip";
|
||||
|
||||
interface TemplateItemProps {
|
||||
template: StatusTemplate;
|
||||
isEnabled: boolean;
|
||||
onToggle: (templateId: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const TemplateItem: React.FC<TemplateItemProps> = ({
|
||||
template,
|
||||
isEnabled,
|
||||
onToggle,
|
||||
}) => (
|
||||
<div
|
||||
className={`template-item ${isEnabled ? "enabled" : ""}`}
|
||||
onClick={() => onToggle(template.id, !isEnabled)}
|
||||
>
|
||||
<div className="template-header">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="template-checkbox"
|
||||
checked={isEnabled}
|
||||
readOnly
|
||||
/>
|
||||
<span className="setting-item-name">{template.name}</span>
|
||||
</div>
|
||||
<div className="setting-item-description">{template.description}:</div>
|
||||
<div className="template-statuses">
|
||||
{template.statuses.map((status, index) => (
|
||||
<TemplateStatusChip key={index} status={status} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
54
components/SettingsUI.tsx/TemplateSettings.tsx
Normal file
54
components/SettingsUI.tsx/TemplateSettings.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React, { useCallback } from "react";
|
||||
import { PluginSettings, StatusTemplate } from "@/types/pluginSettings";
|
||||
import { TemplateItem } from "./TemplateItem";
|
||||
|
||||
interface TemplateSettingsProps {
|
||||
settings: PluginSettings;
|
||||
onChange: (key: keyof PluginSettings, value: unknown) => void;
|
||||
templates: StatusTemplate[];
|
||||
}
|
||||
|
||||
export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
|
||||
settings,
|
||||
onChange,
|
||||
templates,
|
||||
}) => {
|
||||
const handleTemplateToggle = useCallback(
|
||||
(templateId: string, enabled: boolean) => {
|
||||
let newEnabledTemplated = [...settings.enabledTemplates];
|
||||
if (enabled) {
|
||||
if (!newEnabledTemplated.includes(templateId)) {
|
||||
newEnabledTemplated.push(templateId);
|
||||
}
|
||||
} else {
|
||||
newEnabledTemplated = newEnabledTemplated.filter(
|
||||
(id: string) => id !== templateId,
|
||||
);
|
||||
}
|
||||
onChange("enabledTemplates", newEnabledTemplated);
|
||||
},
|
||||
[onChange, settings.enabledTemplates],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Status templates</h3>
|
||||
<p>
|
||||
Enable predefined templates to quickly add common status
|
||||
workflows
|
||||
</p>
|
||||
<div>
|
||||
{templates.map((template) => (
|
||||
<TemplateItem
|
||||
key={template.id}
|
||||
template={template}
|
||||
isEnabled={settings.enabledTemplates.includes(
|
||||
template.id,
|
||||
)}
|
||||
onToggle={handleTemplateToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
18
components/SettingsUI.tsx/TemplateStatusChip.tsx
Normal file
18
components/SettingsUI.tsx/TemplateStatusChip.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import React from "react";
|
||||
|
||||
interface StatusChipProps {
|
||||
status: NoteStatus;
|
||||
}
|
||||
|
||||
export const TemplateStatusChip: React.FC<StatusChipProps> = ({ status }) => (
|
||||
<div className="template-status-chip">
|
||||
<span
|
||||
className="template-status-color-dot"
|
||||
style={{ "--dot-color": status.color } as React.CSSProperties}
|
||||
/>
|
||||
<span>
|
||||
{status.icon} {status.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
88
components/SettingsUI.tsx/UISettings.tsx
Normal file
88
components/SettingsUI.tsx/UISettings.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import React from "react";
|
||||
import { SettingItem } from "./SettingItem";
|
||||
|
||||
export type Props = {
|
||||
settings: PluginSettings;
|
||||
onChange: (key: keyof PluginSettings, value: unknown) => void;
|
||||
};
|
||||
|
||||
export const UISettings: React.FC<Props> = ({ settings, onChange }) => {
|
||||
const handleChange =
|
||||
(key: keyof PluginSettings) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(key, e.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ui-settings">
|
||||
<h3>User interface</h3>
|
||||
|
||||
<SettingItem
|
||||
name="Show status bar"
|
||||
description="Display the status bar"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showStatusBar}
|
||||
onChange={handleChange("showStatusBar")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Auto-hide status bar"
|
||||
description="Hide the status bar when status is unknown"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.autoHideStatusBar}
|
||||
onChange={handleChange("autoHideStatusBar")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Show status icons in file explorer"
|
||||
description="Display status icons in the file explorer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showStatusIconsInExplorer}
|
||||
onChange={handleChange("showStatusIconsInExplorer")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Hide unknown status in file explorer"
|
||||
description="Hide status icons for files with unknown status in the file explorer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.hideUnknownStatusInExplorer || false}
|
||||
onChange={handleChange("hideUnknownStatusInExplorer")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Default to compact view"
|
||||
description="Start the status pane in compact view by default"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.compactView || false}
|
||||
onChange={handleChange("compactView")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Exclude unassigned notes from status pane"
|
||||
description="Improves performance by excluding notes with no assigned status from the status pane. Recommended for large vaults."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.excludeUnknownStatus || false}
|
||||
onChange={handleChange("excludeUnknownStatus")}
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
45
components/SettingsUI.tsx/index.tsx
Normal file
45
components/SettingsUI.tsx/index.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import { UISettings } from "./UISettings";
|
||||
import { PREDEFINED_TEMPLATES } from "@/constants/defaultSettings";
|
||||
import { TemplateSettings } from "./TemplateSettings";
|
||||
import { BehaviourSettings } from "./BehaviourSettings";
|
||||
import { CustomStatusSettings } from "./CustomStatusSettings";
|
||||
import { QuickCommandsSettings } from "./QuickCommandsSettings";
|
||||
|
||||
export type Props = {
|
||||
settings: PluginSettings;
|
||||
onChange: (key: keyof PluginSettings, value: unknown) => void;
|
||||
};
|
||||
|
||||
const SettingsUI: React.FC<Props> = ({ settings, onChange }) => {
|
||||
const [localSettings, setLocalSettings] = useState(settings);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSettings(settings);
|
||||
}, [settings]);
|
||||
|
||||
const handleChange = (key: keyof PluginSettings, value: unknown) => {
|
||||
setLocalSettings((prev) => ({ ...prev, [key]: value }));
|
||||
onChange(key, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="note-status-settings">
|
||||
<TemplateSettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
templates={PREDEFINED_TEMPLATES}
|
||||
/>
|
||||
<UISettings settings={localSettings} onChange={handleChange} />
|
||||
<BehaviourSettings settings={settings} onChange={handleChange} />
|
||||
<CustomStatusSettings settings={settings} onChange={handleChange} />
|
||||
<QuickCommandsSettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
templates={PREDEFINED_TEMPLATES}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default SettingsUI;
|
||||
47
components/StatusBar/StatusBar.tsx
Normal file
47
components/StatusBar/StatusBar.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { GroupedStatuses } from "@/types/noteStatus";
|
||||
import { FC } from "react";
|
||||
import {
|
||||
StatusBarProvider,
|
||||
Props as StatusBarContextProps,
|
||||
} from "./StatusBarContext";
|
||||
import { StatusBarGroup } from "./StatusBarGroup";
|
||||
|
||||
export type Props = {
|
||||
statuses: GroupedStatuses;
|
||||
templates?: { [key: string]: { description: string } };
|
||||
hideIfNotStatuses?: boolean;
|
||||
onStatusClick: StatusBarContextProps["onStatusClick"];
|
||||
};
|
||||
|
||||
export const StatusBar: FC<Props> = ({
|
||||
statuses,
|
||||
hideIfNotStatuses,
|
||||
onStatusClick,
|
||||
}) => {
|
||||
const statusEntries = Object.entries(statuses);
|
||||
const hasStatuses = statusEntries.flatMap((s) => s[1]).length > 0;
|
||||
|
||||
if (!hasStatuses) {
|
||||
if (hideIfNotStatuses) return null;
|
||||
return <span className="status-bar-item">❓</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusBarProvider onStatusClick={onStatusClick}>
|
||||
<div className="status-bar-group-row">
|
||||
{statusEntries.map(([frontmatterTagName, statusList]) => (
|
||||
<StatusBarGroup
|
||||
key={frontmatterTagName}
|
||||
statuses={statusList}
|
||||
template={{
|
||||
description: "Note status",
|
||||
name: "",
|
||||
statuses: statusList,
|
||||
id: "inANearFutureBeWillGroupStatusesByTemplates",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</StatusBarProvider>
|
||||
);
|
||||
};
|
||||
34
components/StatusBar/StatusBarContext.tsx
Normal file
34
components/StatusBar/StatusBarContext.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { createContext, useContext, ReactNode } from "react";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
|
||||
interface StatusBarContextValue {
|
||||
onStatusClick: (status: NoteStatus) => void;
|
||||
}
|
||||
|
||||
const StatusBarContext = createContext<StatusBarContextValue>({
|
||||
onStatusClick: () => {},
|
||||
});
|
||||
|
||||
export const useStatusBarContext = () => useContext(StatusBarContext);
|
||||
|
||||
export type Props = {
|
||||
children: ReactNode;
|
||||
onStatusClick: StatusBarContextValue["onStatusClick"];
|
||||
};
|
||||
|
||||
export const StatusBarProvider: React.FC<Props> = ({
|
||||
children,
|
||||
onStatusClick,
|
||||
}) => {
|
||||
const value = {
|
||||
onStatusClick: (status: NoteStatus) => {
|
||||
onStatusClick(status);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<StatusBarContext.Provider value={value}>
|
||||
{children}
|
||||
</StatusBarContext.Provider>
|
||||
);
|
||||
};
|
||||
17
components/StatusBar/StatusBarEnableButton.tsx
Normal file
17
components/StatusBar/StatusBarEnableButton.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { FC } from "react";
|
||||
|
||||
export type Props = {
|
||||
onEnableClick: () => void;
|
||||
};
|
||||
|
||||
export const StatusBarEnableButton: FC<Props> = ({ onEnableClick }) => {
|
||||
return (
|
||||
<span
|
||||
className="status-bar-item-icon status-bar-enable-button"
|
||||
data-tooltip-position="top"
|
||||
onClick={onEnableClick}
|
||||
>
|
||||
📊
|
||||
</span>
|
||||
);
|
||||
};
|
||||
60
components/StatusBar/StatusBarGroup.tsx
Normal file
60
components/StatusBar/StatusBarGroup.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { FC, useState } from "react";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { StatusTemplate } from "@/types/pluginSettings";
|
||||
import { GroupLabel } from "../atoms/GroupLabel";
|
||||
import { CollapsibleCounter } from "../atoms/CollapsibleCounter";
|
||||
import { StatusBadge } from "../atoms/StatusBadge";
|
||||
import { useStatusBarContext } from "./StatusBarContext";
|
||||
|
||||
export interface StatusBarGroupProps {
|
||||
statuses: NoteStatus[];
|
||||
template: StatusTemplate;
|
||||
maxVisible?: number;
|
||||
}
|
||||
|
||||
export const StatusBarGroup: FC<StatusBarGroupProps> = ({
|
||||
statuses,
|
||||
template,
|
||||
maxVisible = 3,
|
||||
}) => {
|
||||
const [isUncollapsed, setIsUncollapsed] = useState(false);
|
||||
const visibleStatuses = isUncollapsed
|
||||
? statuses
|
||||
: statuses.slice(0, maxVisible);
|
||||
const hiddenCount = Math.max(0, statuses.length - maxVisible);
|
||||
const { onStatusClick } = useStatusBarContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<GroupLabel name={template.name} isHighlighted={isUncollapsed} />
|
||||
<div className="status-bar-group-container">
|
||||
{visibleStatuses.map((status, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
animation:
|
||||
isUncollapsed && i >= maxVisible
|
||||
? `status-slide-in var(--anim-duration-fast) ease-out ${i * 0.05}s both`
|
||||
: "none",
|
||||
}}
|
||||
title={status.description}
|
||||
>
|
||||
<StatusBadge
|
||||
status={status}
|
||||
onClick={() => {
|
||||
onStatusClick(status);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{hiddenCount > 0 && (
|
||||
<CollapsibleCounter
|
||||
count={hiddenCount}
|
||||
isCollapsed={!isUncollapsed}
|
||||
onToggle={() => setIsUncollapsed(!isUncollapsed)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { App } from "obsidian";
|
||||
import { NoteStatusSettings } from "../models/types";
|
||||
import { StatusService } from "../services/status-service";
|
||||
import { StatusBarView } from "./status-bar/StatusBarView";
|
||||
|
||||
interface StatusBarControllerProps {
|
||||
app: App;
|
||||
settings: NoteStatusSettings;
|
||||
statusService: StatusService;
|
||||
initialStatuses?: string[];
|
||||
}
|
||||
|
||||
interface StatusDetails {
|
||||
name: string;
|
||||
icon: string;
|
||||
tooltipText: string;
|
||||
}
|
||||
|
||||
export const StatusBarController: React.FC<StatusBarControllerProps> = ({
|
||||
app,
|
||||
settings,
|
||||
statusService,
|
||||
initialStatuses = ["unknown"],
|
||||
}) => {
|
||||
const [currentStatuses, setCurrentStatuses] =
|
||||
useState<string[]>(initialStatuses);
|
||||
|
||||
const updateStatuses = useCallback((statuses: string[]) => {
|
||||
setCurrentStatuses(statuses);
|
||||
}, []);
|
||||
|
||||
const shouldShowStatusBar = useCallback((): boolean => {
|
||||
const onlyUnknown =
|
||||
currentStatuses.length === 1 && currentStatuses[0] === "unknown";
|
||||
|
||||
if (settings.autoHideStatusBar && onlyUnknown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [currentStatuses, settings.autoHideStatusBar]);
|
||||
|
||||
const getStatusDetails = useCallback((): StatusDetails[] => {
|
||||
const statusesToShow = settings.useMultipleStatuses
|
||||
? currentStatuses
|
||||
: [currentStatuses[0]];
|
||||
|
||||
return statusesToShow.map((status) => {
|
||||
const statusObj = statusService
|
||||
.getAllStatuses()
|
||||
.find((s) => s.name === status);
|
||||
return {
|
||||
name: status,
|
||||
icon: statusService.getStatusIcon(status),
|
||||
tooltipText: statusObj?.description
|
||||
? `${status} - ${statusObj.description}`
|
||||
: status,
|
||||
};
|
||||
});
|
||||
}, [currentStatuses, settings.useMultipleStatuses, statusService]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStatusChanged = (event: CustomEvent) => {
|
||||
if (event.detail?.statuses) {
|
||||
updateStatuses(event.detail.statuses);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
"note-status:status-changed",
|
||||
handleStatusChanged as EventListener,
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"note-status:status-changed",
|
||||
handleStatusChanged as EventListener,
|
||||
);
|
||||
};
|
||||
}, [updateStatuses]);
|
||||
|
||||
if (!settings.showStatusBar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const statusDetails = getStatusDetails();
|
||||
const isVisible = shouldShowStatusBar();
|
||||
|
||||
return <StatusBarView statuses={statusDetails} isVisible={isVisible} />;
|
||||
};
|
||||
|
||||
export default StatusBarController;
|
||||
444
components/StatusDashboard/StatusDashboard.tsx
Normal file
444
components/StatusDashboard/StatusDashboard.tsx
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { TFile } from "obsidian";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import {
|
||||
BaseNoteStatusService,
|
||||
NoteStatusService,
|
||||
} from "@/core/noteStatusService";
|
||||
import { StatusBadge } from "@/components/atoms/StatusBadge";
|
||||
import { GroupLabel } from "@/components/atoms/GroupLabel";
|
||||
import eventBus from "@/core/eventBus";
|
||||
import settingsService from "@/core/settingsService";
|
||||
|
||||
interface VaultStats {
|
||||
totalNotes: number;
|
||||
notesWithStatus: number;
|
||||
statusDistribution: Record<string, number>;
|
||||
tagDistribution: Record<string, number>;
|
||||
recentChanges: Array<{
|
||||
file: TFile;
|
||||
status: NoteStatus;
|
||||
timestamp: number;
|
||||
action: "added" | "removed";
|
||||
}>;
|
||||
}
|
||||
|
||||
interface CurrentNoteInfo {
|
||||
file: TFile | null;
|
||||
statuses: Record<string, NoteStatus[]>;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
export const StatusDashboard = () => {
|
||||
const [vaultStats, setVaultStats] = useState<VaultStats>({
|
||||
totalNotes: 0,
|
||||
notesWithStatus: 0,
|
||||
statusDistribution: {},
|
||||
tagDistribution: {},
|
||||
recentChanges: [],
|
||||
});
|
||||
|
||||
const [currentNote, setCurrentNote] = useState<CurrentNoteInfo>({
|
||||
file: null,
|
||||
statuses: {},
|
||||
lastModified: 0,
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const calculateVaultStats = useCallback((): VaultStats => {
|
||||
const files = BaseNoteStatusService.app.vault.getMarkdownFiles();
|
||||
const availableStatuses =
|
||||
BaseNoteStatusService.getAllAvailableStatuses();
|
||||
const statusMetadataKeys = [settingsService.settings.tagPrefix];
|
||||
|
||||
let notesWithStatus = 0;
|
||||
const statusDistribution: Record<string, number> = {};
|
||||
const tagDistribution: Record<string, number> = {};
|
||||
|
||||
// Initialize counters
|
||||
availableStatuses.forEach((status) => {
|
||||
statusDistribution[status.name] = 0;
|
||||
});
|
||||
|
||||
statusMetadataKeys.forEach((tag) => {
|
||||
tagDistribution[tag] = 0;
|
||||
});
|
||||
|
||||
files.forEach((file) => {
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(file);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
|
||||
if (!frontmatter) return;
|
||||
|
||||
let hasAnyStatus = false;
|
||||
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
const value = frontmatter[key];
|
||||
if (value) {
|
||||
hasAnyStatus = true;
|
||||
tagDistribution[key]++;
|
||||
|
||||
const statusNames = Array.isArray(value) ? value : [value];
|
||||
statusNames.forEach((statusName) => {
|
||||
const statusStr = statusName.toString();
|
||||
if (statusDistribution.hasOwnProperty(statusStr)) {
|
||||
statusDistribution[statusStr]++;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (hasAnyStatus) {
|
||||
notesWithStatus++;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
totalNotes: files.length,
|
||||
notesWithStatus,
|
||||
statusDistribution,
|
||||
tagDistribution,
|
||||
recentChanges: [], // TODO: Implement recent changes tracking
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateCurrentNote = useCallback(() => {
|
||||
const activeFile = BaseNoteStatusService.app.workspace.getActiveFile();
|
||||
|
||||
if (!activeFile) {
|
||||
setCurrentNote({ file: null, statuses: {}, lastModified: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const noteStatusService = new NoteStatusService(activeFile);
|
||||
noteStatusService.populateStatuses();
|
||||
|
||||
setCurrentNote({
|
||||
file: activeFile,
|
||||
statuses: noteStatusService.statuses,
|
||||
lastModified: activeFile.stat.mtime,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const stats = calculateVaultStats();
|
||||
setVaultStats(stats);
|
||||
updateCurrentNote();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [calculateVaultStats, updateCurrentNote]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
const handleFileChange = () => {
|
||||
updateCurrentNote();
|
||||
};
|
||||
|
||||
const handleVaultChange = () => {
|
||||
loadData();
|
||||
};
|
||||
|
||||
eventBus.subscribe(
|
||||
"frontmatter-manually-changed",
|
||||
handleVaultChange,
|
||||
"status-dashboard-vault-subscription",
|
||||
);
|
||||
eventBus.subscribe(
|
||||
"active-file-change",
|
||||
handleFileChange,
|
||||
"status-dashboard-file-subscription",
|
||||
);
|
||||
|
||||
const unsubscribeActiveFile = BaseNoteStatusService.app.workspace.on(
|
||||
"active-leaf-change",
|
||||
handleFileChange,
|
||||
);
|
||||
|
||||
return () => {
|
||||
eventBus.unsubscribe(
|
||||
"frontmatter-manually-changed",
|
||||
"status-dashboard-vault-subscription",
|
||||
);
|
||||
eventBus.unsubscribe(
|
||||
"active-file-change",
|
||||
"status-dashboard-file-subscription",
|
||||
);
|
||||
BaseNoteStatusService.app.workspace.offref(unsubscribeActiveFile);
|
||||
};
|
||||
}, [loadData, updateCurrentNote]);
|
||||
|
||||
const statusChart = useMemo(() => {
|
||||
const total = Object.values(vaultStats.statusDistribution).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0,
|
||||
);
|
||||
if (total === 0) return [];
|
||||
|
||||
return Object.entries(vaultStats.statusDistribution)
|
||||
.filter(([_, count]) => count > 0)
|
||||
.map(([statusName, count]) => ({
|
||||
name: statusName,
|
||||
count,
|
||||
percentage: Math.round((count / total) * 100),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}, [vaultStats.statusDistribution]);
|
||||
|
||||
const availableStatuses = BaseNoteStatusService.getAllAvailableStatuses();
|
||||
const statusMap = new Map(availableStatuses.map((s) => [s.name, s]));
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="status-dashboard">
|
||||
<div className="status-dashboard-loading">
|
||||
Loading dashboard...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="status-dashboard">
|
||||
<div className="status-dashboard-header">
|
||||
<h2 className="status-dashboard-title">Status Dashboard</h2>
|
||||
<button
|
||||
className="status-dashboard-refresh"
|
||||
onClick={loadData}
|
||||
title="Refresh Dashboard"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="status-dashboard-content">
|
||||
{/* Current Note Section */}
|
||||
<div className="status-dashboard-section">
|
||||
<div className="status-dashboard-section-header">
|
||||
<h3>Current Note</h3>
|
||||
</div>
|
||||
<div className="status-dashboard-current-note">
|
||||
{currentNote.file ? (
|
||||
<>
|
||||
<div className="current-note-info">
|
||||
<div className="current-note-name">
|
||||
{currentNote.file.basename}
|
||||
</div>
|
||||
<div className="current-note-path">
|
||||
{currentNote.file.path}
|
||||
</div>
|
||||
<div className="current-note-modified">
|
||||
Last modified:{" "}
|
||||
{new Date(
|
||||
currentNote.lastModified,
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="current-note-statuses">
|
||||
{Object.entries(currentNote.statuses).map(
|
||||
([tag, statuses]) => (
|
||||
<div
|
||||
key={tag}
|
||||
className="current-note-tag-group"
|
||||
>
|
||||
<GroupLabel name={tag} />
|
||||
<div className="current-note-status-list">
|
||||
{statuses.length > 0 ? (
|
||||
statuses.map(
|
||||
(status) => (
|
||||
<StatusBadge
|
||||
key={
|
||||
status.name
|
||||
}
|
||||
status={
|
||||
status
|
||||
}
|
||||
/>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<span className="current-note-no-status">
|
||||
No status assigned
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="current-note-empty">
|
||||
No note currently active
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vault Overview Section */}
|
||||
<div className="status-dashboard-section">
|
||||
<div className="status-dashboard-section-header">
|
||||
<h3>Vault Overview</h3>
|
||||
</div>
|
||||
<div className="vault-overview">
|
||||
<div className="vault-stats-grid">
|
||||
<div className="vault-stat-card">
|
||||
<div className="vault-stat-number">
|
||||
{vaultStats.totalNotes}
|
||||
</div>
|
||||
<div className="vault-stat-label">
|
||||
Total Notes
|
||||
</div>
|
||||
</div>
|
||||
<div className="vault-stat-card">
|
||||
<div className="vault-stat-number">
|
||||
{vaultStats.notesWithStatus}
|
||||
</div>
|
||||
<div className="vault-stat-label">
|
||||
Notes with Status
|
||||
</div>
|
||||
</div>
|
||||
<div className="vault-stat-card">
|
||||
<div className="vault-stat-number">
|
||||
{vaultStats.totalNotes > 0
|
||||
? Math.round(
|
||||
(vaultStats.notesWithStatus /
|
||||
vaultStats.totalNotes) *
|
||||
100,
|
||||
)
|
||||
: 0}
|
||||
%
|
||||
</div>
|
||||
<div className="vault-stat-label">Coverage</div>
|
||||
</div>
|
||||
<div className="vault-stat-card">
|
||||
<div className="vault-stat-number">
|
||||
{Object.values(
|
||||
vaultStats.statusDistribution,
|
||||
).reduce((sum, count) => sum + count, 0)}
|
||||
</div>
|
||||
<div className="vault-stat-label">
|
||||
Total Statuses
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Distribution Section */}
|
||||
<div className="status-dashboard-section">
|
||||
<div className="status-dashboard-section-header">
|
||||
<h3>Status Distribution</h3>
|
||||
</div>
|
||||
<div className="status-distribution">
|
||||
{statusChart.length > 0 ? (
|
||||
<div className="status-chart">
|
||||
{statusChart.map(
|
||||
({ name, count, percentage }) => {
|
||||
const status = statusMap.get(name);
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="status-chart-item"
|
||||
>
|
||||
<div className="status-chart-info">
|
||||
{status && (
|
||||
<StatusBadge
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
<span className="status-chart-count">
|
||||
{count} notes (
|
||||
{percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-chart-bar">
|
||||
<div
|
||||
className="status-chart-fill"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
backgroundColor:
|
||||
status?.color ||
|
||||
"var(--interactive-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="status-distribution-empty">
|
||||
No status data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions Section */}
|
||||
<div className="status-dashboard-section">
|
||||
<div className="status-dashboard-section-header">
|
||||
<h3>Quick Actions</h3>
|
||||
</div>
|
||||
<div className="quick-actions">
|
||||
<button
|
||||
className="quick-action-btn"
|
||||
onClick={() => {
|
||||
const leaf =
|
||||
BaseNoteStatusService.app.workspace.getLeaf();
|
||||
leaf.setViewState({
|
||||
type: "groupped-status-view",
|
||||
active: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
📊 View Grouped Statuses
|
||||
</button>
|
||||
<button
|
||||
className="quick-action-btn"
|
||||
onClick={() => {
|
||||
// Find notes without status
|
||||
const files =
|
||||
BaseNoteStatusService.app.vault.getMarkdownFiles();
|
||||
const filesWithoutStatus = files.filter(
|
||||
(file) => {
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(
|
||||
file,
|
||||
);
|
||||
const frontmatter =
|
||||
cachedMetadata?.frontmatter;
|
||||
return (
|
||||
!frontmatter ||
|
||||
!frontmatter[
|
||||
settingsService.settings
|
||||
.tagPrefix
|
||||
]
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Found ${filesWithoutStatus.length} notes without status:`,
|
||||
filesWithoutStatus.map((f) => f.path),
|
||||
);
|
||||
}}
|
||||
>
|
||||
🔍 Find Notes Without Status
|
||||
</button>
|
||||
<button className="quick-action-btn" onClick={loadData}>
|
||||
🔄 Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
64
components/StatusFileInfoPopup/StatusFileInfoPopup.tsx
Normal file
64
components/StatusFileInfoPopup/StatusFileInfoPopup.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import React from "react";
|
||||
import { GroupedStatuses } from "@/types/noteStatus";
|
||||
import { StatusBadge } from "../atoms/StatusBadge";
|
||||
|
||||
export interface Props {
|
||||
statuses: GroupedStatuses;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const StatusFileInfoPopup: React.FC<Props> = ({ statuses }) => {
|
||||
const statusEntries = Object.entries(statuses);
|
||||
|
||||
if (statusEntries.length === 0) {
|
||||
return (
|
||||
<div className="status-popup-empty">
|
||||
<span className="status-empty-icon">📋</span>
|
||||
No statuses found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="status-info-popup" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="status-popup-header">
|
||||
<span className="status-header-icon">📊</span>
|
||||
Status Overview
|
||||
</div>
|
||||
|
||||
<div className="status-popup-content">
|
||||
{statusEntries.map(([groupName, statusList]) => (
|
||||
<div key={groupName} className="status-group">
|
||||
<div className="status-group-header">
|
||||
<span className="status-group-name">
|
||||
{groupName.toLowerCase()}
|
||||
</span>
|
||||
<span className="status-group-count">
|
||||
{statusList.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="status-group-items">
|
||||
{statusList.map((status, index) => (
|
||||
<div
|
||||
key={`${groupName}-${index}`}
|
||||
className="status-item"
|
||||
>
|
||||
<StatusBadge status={status} />
|
||||
{status.description && (
|
||||
<div
|
||||
className="status-description"
|
||||
title={status.description}
|
||||
>
|
||||
{status.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
58
components/StatusModal/StatusModal.tsx
Normal file
58
components/StatusModal/StatusModal.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React from "react";
|
||||
import { GroupedStatuses, NoteStatus } from "@/types/noteStatus";
|
||||
import { StatusModalHeader } from "./StatusModalHeader";
|
||||
import {
|
||||
StatusSelectorGroupedByTag,
|
||||
Props as SSGByTagProps,
|
||||
} from "./StatusSelectorGroupedByTag";
|
||||
|
||||
export interface Props {
|
||||
currentStatuses: GroupedStatuses;
|
||||
filesQuantity: number;
|
||||
availableStatuses: NoteStatus[];
|
||||
onRemoveStatus: (
|
||||
frontmatterTagName: string,
|
||||
status: NoteStatus,
|
||||
) => Promise<void>;
|
||||
onSelectStatus: (
|
||||
frontmatterTagName: string,
|
||||
status: NoteStatus,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export const StatusModal: React.FC<Props> = ({
|
||||
currentStatuses: initialStatuses,
|
||||
filesQuantity,
|
||||
availableStatuses,
|
||||
onRemoveStatus,
|
||||
onSelectStatus,
|
||||
}) => {
|
||||
const currentStatuses = Object.entries(initialStatuses);
|
||||
|
||||
const handleSelectedState: SSGByTagProps["onSelectedState"] = (
|
||||
frontmatterTagName,
|
||||
status,
|
||||
action,
|
||||
) => {
|
||||
if (action === "select") {
|
||||
onSelectStatus(frontmatterTagName, status);
|
||||
} else {
|
||||
onRemoveStatus(frontmatterTagName, status);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="note-status-modal-content">
|
||||
<StatusModalHeader filesQuantity={filesQuantity} />
|
||||
{currentStatuses.map(([frontmatterTagName, statusList]) => (
|
||||
<StatusSelectorGroupedByTag
|
||||
key={frontmatterTagName}
|
||||
frontmatterTagName={frontmatterTagName}
|
||||
availableStatuses={availableStatuses}
|
||||
currentStatuses={statusList}
|
||||
onSelectedState={handleSelectedState}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
74
components/StatusModal/StatusModalChip.tsx
Normal file
74
components/StatusModal/StatusModalChip.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { FC, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
status: NoteStatus;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export const StatusModalChip: FC<Props> = ({ status, onRemove }) => {
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsRemoving(true);
|
||||
setTimeout(() => {
|
||||
onRemove();
|
||||
}, 150);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="note-status-chip"
|
||||
title={
|
||||
status.description
|
||||
? `${status.name} - ${status.description}`
|
||||
: status.name
|
||||
}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "4px 8px",
|
||||
background: "var(--interactive-accent)",
|
||||
color: "var(--text-on-accent)",
|
||||
borderRadius: "var(--radius-s)",
|
||||
fontSize: "var(--font-ui-smaller)",
|
||||
cursor: "pointer",
|
||||
transition: "all 150ms ease",
|
||||
opacity: isRemoving ? "0.5" : "1",
|
||||
}}
|
||||
>
|
||||
<span className="note-status-chip-icon">{status.icon}</span>
|
||||
<span className="note-status-chip-text">{status.name}</span>
|
||||
<div
|
||||
className="note-status-chip-remove"
|
||||
onClick={handleRemove}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255, 255, 255, 0.2)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
48
components/StatusModal/StatusModalHeader.tsx
Normal file
48
components/StatusModal/StatusModalHeader.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { FC } from "react";
|
||||
|
||||
export type Props = {
|
||||
filesQuantity: number;
|
||||
};
|
||||
export const StatusModalHeader: FC<Props> = ({ filesQuantity }) => {
|
||||
// TODO: Move the style to its css file
|
||||
return (
|
||||
<div className="modal-header" style={{ marginBottom: "16px" }}>
|
||||
<div
|
||||
className="modal-title-container"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div className="modal-title-icon">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
|
||||
<line x1="7" y1="7" x2="7.01" y2="7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="modal-title-text">Note status</span>
|
||||
{filesQuantity > 1 && (
|
||||
<span
|
||||
className="modal-title-count"
|
||||
style={{
|
||||
opacity: "0.7",
|
||||
fontSize: "var(--font-ui-smaller)",
|
||||
}}
|
||||
>
|
||||
({filesQuantity} files)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
89
components/StatusModal/StatusModalOption.tsx
Normal file
89
components/StatusModal/StatusModalOption.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { useState } from "react";
|
||||
|
||||
interface StatusOptionProps {
|
||||
status: NoteStatus;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
export const StatusModalOption: React.FC<StatusOptionProps> = ({
|
||||
status,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
setTimeout(() => {
|
||||
onSelect();
|
||||
}, 150);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="note-status-option"
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
title={
|
||||
status.description
|
||||
? `${status.name} - ${status.description}`
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
padding: "8px 12px",
|
||||
cursor: "pointer",
|
||||
borderBottom: "1px solid var(--background-modifier-border)",
|
||||
transition: "background-color 150ms ease",
|
||||
background:
|
||||
isSelected || isHovered
|
||||
? "var(--background-modifier-hover)"
|
||||
: "",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="note-status-option-icon"
|
||||
style={{
|
||||
fontSize: "16px",
|
||||
minWidth: "20px",
|
||||
}}
|
||||
>
|
||||
{status.icon}
|
||||
</span>
|
||||
<span
|
||||
className="note-status-option-text"
|
||||
style={{
|
||||
flex: "1",
|
||||
fontSize: "var(--font-ui-small)",
|
||||
}}
|
||||
>
|
||||
{status.name}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<div
|
||||
className="note-status-option-check"
|
||||
style={{
|
||||
color: "var(--interactive-accent)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20,6 9,17 4,12" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
57
components/StatusModal/StatusSelector.tsx
Normal file
57
components/StatusModal/StatusSelector.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import React from "react";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { StatusModalOption } from "./StatusModalOption";
|
||||
|
||||
export interface Props {
|
||||
currentStatuses: NoteStatus[];
|
||||
availableStatuses: NoteStatus[];
|
||||
onToggleStatus: (status: NoteStatus, selected: boolean) => void;
|
||||
}
|
||||
|
||||
export const StatusSelector: React.FC<Props> = ({
|
||||
currentStatuses,
|
||||
availableStatuses,
|
||||
onToggleStatus,
|
||||
}) => {
|
||||
const handleSelectStatus = async (status: NoteStatus) => {
|
||||
const selected =
|
||||
currentStatuses.findIndex((s) => s.name === status.name) !== -1;
|
||||
onToggleStatus(status, !selected);
|
||||
};
|
||||
|
||||
// TODO: The StatusSelector must be splitted by its template
|
||||
// FIXME: fix line 21
|
||||
return (
|
||||
<div className="setting-item">
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">Available statuses</div>
|
||||
</div>
|
||||
<div className="setting-item-control">
|
||||
<div
|
||||
className="note-status-options"
|
||||
style={{
|
||||
maxHeight: "300px",
|
||||
overflowY: "auto",
|
||||
border: "1px solid var(--background-modifier-border)",
|
||||
borderRadius: "var(--radius-s)",
|
||||
background: "var(--background-primary)",
|
||||
width: "300px",
|
||||
}}
|
||||
>
|
||||
{availableStatuses.map((status) => (
|
||||
<StatusModalOption
|
||||
key={`${status.name}${status.description}${status.color}${status.icon}`}
|
||||
status={status}
|
||||
isSelected={
|
||||
currentStatuses.findIndex(
|
||||
(s) => s.name === status.name,
|
||||
) !== -1
|
||||
}
|
||||
onSelect={() => handleSelectStatus(status)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
97
components/StatusModal/StatusSelectorGroupedByTag.tsx
Normal file
97
components/StatusModal/StatusSelectorGroupedByTag.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import React, { useState } from "react";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { StatusModalChip } from "./StatusModalChip";
|
||||
import { SearchFilter } from "../atoms/SearchFilter";
|
||||
import { StatusSelector } from "./StatusSelector";
|
||||
|
||||
export interface Props {
|
||||
frontmatterTagName: string;
|
||||
currentStatuses: NoteStatus[];
|
||||
availableStatuses: NoteStatus[];
|
||||
onSelectedState: (
|
||||
frontmatterTagName: string,
|
||||
status: NoteStatus,
|
||||
action: "select" | "unselected",
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const StatusSelectorGroupedByTag: React.FC<Props> = ({
|
||||
currentStatuses,
|
||||
availableStatuses,
|
||||
frontmatterTagName,
|
||||
onSelectedState,
|
||||
}) => {
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
|
||||
const filteredStatuses = searchFilter
|
||||
? availableStatuses.filter((status) =>
|
||||
status.name.toLowerCase().includes(searchFilter.toLowerCase()),
|
||||
)
|
||||
: availableStatuses;
|
||||
|
||||
const handleRemoveStatus = async (status: NoteStatus) => {
|
||||
onSelectedState(frontmatterTagName, status, "unselected");
|
||||
};
|
||||
|
||||
const handleSelectStatus = async (status: NoteStatus) => {
|
||||
onSelectedState(frontmatterTagName, status, "select");
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="setting-item">
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">Current statuses</div>
|
||||
</div>
|
||||
<div className="setting-item-control">
|
||||
<div
|
||||
className="note-status-chips"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "6px",
|
||||
minHeight: "32px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{currentStatuses.map((s) => (
|
||||
<StatusModalChip
|
||||
key={s.name}
|
||||
status={s}
|
||||
onRemove={() => handleRemoveStatus(s)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SearchFilter
|
||||
value={searchFilter}
|
||||
onFilterChange={(value) => setSearchFilter(value)}
|
||||
/>
|
||||
{filteredStatuses.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "16px",
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted)",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
{searchFilter
|
||||
? `No statuses match "${searchFilter}"`
|
||||
: "No statuses found"}
|
||||
</div>
|
||||
) : (
|
||||
<StatusSelector
|
||||
availableStatuses={filteredStatuses}
|
||||
currentStatuses={currentStatuses}
|
||||
onToggleStatus={(status, selected) =>
|
||||
selected
|
||||
? handleSelectStatus(status)
|
||||
: handleRemoveStatus(status)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { TFile, Notice, App } from "obsidian";
|
||||
import { NoteStatusSettings } from "../models/types";
|
||||
import { StatusService } from "../services/status-service";
|
||||
import {
|
||||
StatusPaneView,
|
||||
StatusPaneOptions,
|
||||
} from "../views/status-pane-view/StatusPaneView";
|
||||
|
||||
interface StatusPaneViewControllerProps {
|
||||
app: App;
|
||||
settings: NoteStatusSettings;
|
||||
statusService: StatusService;
|
||||
onSettingsChange?: (settings: NoteStatusSettings) => void;
|
||||
}
|
||||
|
||||
interface PaginationState {
|
||||
itemsPerPage: number;
|
||||
currentPage: Record<string, number>;
|
||||
}
|
||||
|
||||
export const StatusPaneViewController: React.FC<
|
||||
StatusPaneViewControllerProps
|
||||
> = ({ app, settings, statusService, onSettingsChange }) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [paginationState, setPaginationState] = useState<PaginationState>({
|
||||
itemsPerPage: 100,
|
||||
currentPage: {},
|
||||
});
|
||||
const [collapsedStatuses, setCollapsedStatuses] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [statusGroups, setStatusGroups] = useState<Record<string, TFile[]>>(
|
||||
{},
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const updateStatusGroups = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const groups = statusService.groupFilesByStatus(searchQuery);
|
||||
setStatusGroups(groups);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [statusService, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
updateStatusGroups();
|
||||
}, [updateStatusGroups]);
|
||||
|
||||
const handleSearch = useCallback((query: string) => {
|
||||
setPaginationState({
|
||||
itemsPerPage: 100,
|
||||
currentPage: {},
|
||||
});
|
||||
setSearchQuery(query);
|
||||
}, []);
|
||||
|
||||
const handleToggleView = useCallback(() => {
|
||||
if (onSettingsChange) {
|
||||
const newSettings = {
|
||||
...settings,
|
||||
compactView: !settings.compactView,
|
||||
};
|
||||
onSettingsChange(newSettings);
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("note-status:settings-changed"),
|
||||
);
|
||||
}
|
||||
}, [settings, onSettingsChange]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
await updateStatusGroups();
|
||||
new Notice("Status pane refreshed");
|
||||
}, [updateStatusGroups]);
|
||||
|
||||
const handleFileClick = useCallback(
|
||||
(file: TFile) => {
|
||||
app.workspace.getLeaf().openFile(file);
|
||||
},
|
||||
[app],
|
||||
);
|
||||
|
||||
const handleStatusToggle = useCallback(
|
||||
(status: string, collapsed: boolean) => {
|
||||
setCollapsedStatuses((prev) => ({
|
||||
...prev,
|
||||
[status]: collapsed,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleContextMenu = useCallback((e: MouseEvent, file: TFile) => {
|
||||
console.log("Context menu for file:", file.path);
|
||||
}, []);
|
||||
|
||||
const handlePageChange = useCallback((status: string, page: number) => {
|
||||
setPaginationState((prev) => ({
|
||||
...prev,
|
||||
currentPage: {
|
||||
...prev.currentPage,
|
||||
[status]: page,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleShowUnassigned = useCallback(() => {
|
||||
if (onSettingsChange) {
|
||||
const newSettings = {
|
||||
...settings,
|
||||
excludeUnknownStatus: false,
|
||||
};
|
||||
onSettingsChange(newSettings);
|
||||
}
|
||||
}, [settings, onSettingsChange]);
|
||||
|
||||
const options: StatusPaneOptions = {
|
||||
excludeUnknown: settings.excludeUnknownStatus || false,
|
||||
isCompactView: settings.compactView || false,
|
||||
collapsedStatuses,
|
||||
pagination: paginationState,
|
||||
callbacks: {
|
||||
onFileClick: handleFileClick,
|
||||
onStatusToggle: handleStatusToggle,
|
||||
onContextMenu: handleContextMenu,
|
||||
onPageChange: handlePageChange,
|
||||
},
|
||||
};
|
||||
|
||||
const headerCallbacks = {
|
||||
onSearch: handleSearch,
|
||||
onToggleView: handleToggleView,
|
||||
onRefresh: handleRefresh,
|
||||
};
|
||||
|
||||
return (
|
||||
<StatusPaneView
|
||||
statusGroups={statusGroups}
|
||||
options={options}
|
||||
statusService={statusService}
|
||||
searchQuery={searchQuery}
|
||||
isLoading={isLoading}
|
||||
headerCallbacks={headerCallbacks}
|
||||
onShowUnassigned={handleShowUnassigned}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusPaneViewController;
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
import React, { useRef } from "react";
|
||||
import { NoteStatusSettings } from "models/types";
|
||||
import { StatusService } from "services/status-service";
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
settings: NoteStatusSettings;
|
||||
statusService: StatusService;
|
||||
statuses: string[];
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface StatusBadgeProps {
|
||||
statuses: string[];
|
||||
settings: NoteStatusSettings;
|
||||
statusService: StatusService;
|
||||
}
|
||||
|
||||
const StatusBadge: React.FC<StatusBadgeProps> = ({
|
||||
statuses,
|
||||
settings,
|
||||
statusService,
|
||||
}) => {
|
||||
const hasValidStatus = statuses.length > 0 && statuses[0] !== "unknown";
|
||||
|
||||
if (hasValidStatus) {
|
||||
const primaryStatus = statuses[0];
|
||||
const icon = statusService.getStatusIcon(primaryStatus);
|
||||
|
||||
return (
|
||||
<div className="note-status-toolbar-badge-container">
|
||||
<span
|
||||
className={`note-status-toolbar-icon status-${primaryStatus}`}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
{settings.useMultipleStatuses && statuses.length > 1 && (
|
||||
<span className="note-status-count-badge">
|
||||
{statuses.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="note-status-toolbar-badge-container">
|
||||
<span className="note-status-toolbar-icon status-unknown">
|
||||
{statusService.getStatusIcon("unknown")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolbarButton: React.FC<ToolbarButtonProps> = ({
|
||||
settings,
|
||||
statusService,
|
||||
statuses,
|
||||
onClick,
|
||||
className = "",
|
||||
}) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const finalClassName =
|
||||
`note-status-toolbar-button clickable-icon view-action ${className}`.trim();
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className={finalClassName}
|
||||
aria-label="Note status"
|
||||
onClick={onClick}
|
||||
>
|
||||
<StatusBadge
|
||||
statuses={statuses}
|
||||
settings={settings}
|
||||
statusService={statusService}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export class ToolbarButtonManager {
|
||||
private element: HTMLElement | null = null;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private currentStatuses: string[] = ["unknown"];
|
||||
private onClick: (() => void) | undefined;
|
||||
|
||||
constructor(settings: NoteStatusSettings, statusService: StatusService) {
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
}
|
||||
|
||||
public createElement(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
container.addClass("note-status-toolbar-container");
|
||||
this.element = container;
|
||||
this.render();
|
||||
return container;
|
||||
}
|
||||
|
||||
public updateDisplay(statuses: string[]): void {
|
||||
this.currentStatuses = statuses;
|
||||
this.render();
|
||||
}
|
||||
|
||||
public setClickHandler(handler: () => void): void {
|
||||
this.onClick = handler;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
if (!this.element) return;
|
||||
|
||||
// Use ReactUtils to render the React component
|
||||
import("../utils/react-utils").then(({ ReactUtils }) => {
|
||||
ReactUtils.render(
|
||||
React.createElement(ToolbarButton, {
|
||||
settings: this.settings,
|
||||
statusService: this.statusService,
|
||||
statuses: this.currentStatuses,
|
||||
onClick: this.onClick,
|
||||
}),
|
||||
this.element!,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.render();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.element) {
|
||||
import("../utils/react-utils").then(({ ReactUtils }) => {
|
||||
ReactUtils.unmount(this.element!);
|
||||
});
|
||||
this.element.remove();
|
||||
this.element = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ToolbarButton;
|
||||
25
components/atoms/CollapsibleCounter.tsx
Normal file
25
components/atoms/CollapsibleCounter.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { FC } from "react";
|
||||
|
||||
export interface CollapsibleCounterProps {
|
||||
count: number;
|
||||
isCollapsed: boolean;
|
||||
onToggle: React.MouseEventHandler<HTMLSpanElement>;
|
||||
}
|
||||
|
||||
export const CollapsibleCounter: FC<CollapsibleCounterProps> = ({
|
||||
count,
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
}) => {
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="mod-clickable collapsible-counter-container"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{isCollapsed ? "+" : "-"}
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
42
components/atoms/GroupLabel.tsx
Normal file
42
components/atoms/GroupLabel.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { FC } from "react";
|
||||
|
||||
export interface GroupLabelProps {
|
||||
name: string;
|
||||
isHighlighted?: boolean;
|
||||
}
|
||||
|
||||
export const GroupLabel: FC<GroupLabelProps> = ({
|
||||
name,
|
||||
isHighlighted = false,
|
||||
}) => {
|
||||
if (!name) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="setting-item-name group-label-container"
|
||||
style={{
|
||||
color: isHighlighted
|
||||
? "var(--text-normal)"
|
||||
: "var(--text-muted)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-folder"
|
||||
>
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
46
components/atoms/SearchFilter.tsx
Normal file
46
components/atoms/SearchFilter.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { FC, useEffect, useRef, useState } from "react";
|
||||
|
||||
export type Props = {
|
||||
value: string;
|
||||
onFilterChange: (value: string) => void;
|
||||
};
|
||||
export const SearchFilter: FC<Props> = ({ value, onFilterChange }) => {
|
||||
const [searchFilter, setSearchFilter] = useState(value);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO:: Focus on render?
|
||||
// if (searchInputRef.current) {
|
||||
// setTimeout(() => searchInputRef.current?.focus(), 100);
|
||||
// }
|
||||
//
|
||||
setSearchFilter(value);
|
||||
}, [value]);
|
||||
|
||||
// TODO: Move the style to its css file
|
||||
return (
|
||||
<div className="setting-item">
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">Filter statuses</div>
|
||||
</div>
|
||||
<div className="setting-item-control">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search statuses..."
|
||||
className="note-status-search-input"
|
||||
value={searchFilter}
|
||||
onChange={(e) => onFilterChange(e.target.value)}
|
||||
style={{
|
||||
width: "200px",
|
||||
padding: "6px 12px",
|
||||
border: "1px solid var(--background-modifier-border)",
|
||||
borderRadius: "var(--radius-s)",
|
||||
background: "var(--background-primary)",
|
||||
color: "var(--text-normal)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
25
components/atoms/StatusBadge.tsx
Normal file
25
components/atoms/StatusBadge.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { FC } from "react";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
|
||||
export interface StatusBadgeProps {
|
||||
status: NoteStatus;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const StatusBadge: FC<StatusBadgeProps> = ({ status, onClick }) => {
|
||||
return (
|
||||
<div
|
||||
className="status-badge-container"
|
||||
style={{
|
||||
backgroundColor: `${status.color}15`,
|
||||
border: `1px solid ${status.color}30`,
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="status-badge-item">
|
||||
<span className="status-badge-icon">{status.icon}</span>
|
||||
<span className="status-badge-text">{status.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { StatusBarController } from "./StatusBarController";
|
||||
export { SettingsController } from "./SettingsController";
|
||||
export { StatusPaneViewController } from "./StatusPaneViewController";
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import React from "react";
|
||||
import { setTooltip } from "obsidian";
|
||||
|
||||
interface StatusInfo {
|
||||
name: string;
|
||||
icon: string;
|
||||
tooltipText: string;
|
||||
}
|
||||
|
||||
interface StatusBarComponentProps {
|
||||
statuses: StatusInfo[];
|
||||
isVisible: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const StatusBarComponent: React.FC<StatusBarComponentProps> = ({
|
||||
statuses,
|
||||
isVisible,
|
||||
className = "",
|
||||
}) => {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const statusRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Set tooltips after render
|
||||
statusRefs.current.forEach((ref, index) => {
|
||||
if (ref && statuses[index]) {
|
||||
setTooltip(ref, statuses[index].tooltipText);
|
||||
}
|
||||
});
|
||||
}, [statuses]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderSingleStatus = (status: StatusInfo, index: number) => (
|
||||
<React.Fragment key={`single-${status.name}`}>
|
||||
<span
|
||||
ref={(el) => (statusRefs.current[index * 2] = el)}
|
||||
className={`note-status-${status.name}`}
|
||||
>
|
||||
Status: {status.name}
|
||||
</span>
|
||||
<span
|
||||
ref={(el) => (statusRefs.current[index * 2 + 1] = el)}
|
||||
className={`note-status-icon status-${status.name}`}
|
||||
>
|
||||
{status.icon}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
const renderMultipleStatuses = () => (
|
||||
<React.Fragment>
|
||||
<span className="note-status-label">Statuses: </span>
|
||||
<span className="note-status-badges">
|
||||
{statuses.map((status, index) => (
|
||||
<span
|
||||
key={status.name}
|
||||
ref={(el) => (statusRefs.current[index] = el)}
|
||||
className={`note-status-badge status-${status.name}`}
|
||||
>
|
||||
<span className="note-status-badge-icon">
|
||||
{status.icon}
|
||||
</span>
|
||||
<span className="note-status-badge-text">
|
||||
{status.name}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`note-status-bar visible ${className}`}
|
||||
>
|
||||
{statuses.length === 1
|
||||
? renderSingleStatus(statuses[0], 0)
|
||||
: renderMultipleStatuses()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import React, { useEffect, useRef } from "react";
|
||||
import { setTooltip } from "obsidian";
|
||||
|
||||
interface StatusBarStatus {
|
||||
name: string;
|
||||
icon: string;
|
||||
tooltipText: string;
|
||||
}
|
||||
|
||||
interface StatusBarViewProps {
|
||||
statuses: StatusBarStatus[];
|
||||
isVisible: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: StatusBarStatus;
|
||||
}
|
||||
|
||||
const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
|
||||
const badgeRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (badgeRef.current) {
|
||||
setTooltip(badgeRef.current, status.tooltipText);
|
||||
}
|
||||
}, [status.tooltipText]);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={badgeRef}
|
||||
className={`note-status-badge status-${status.name}`}
|
||||
>
|
||||
<span className="note-status-badge-icon">{status.icon}</span>
|
||||
<span className="note-status-badge-text">{status.name}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface SingleStatusProps {
|
||||
status: StatusBarStatus;
|
||||
}
|
||||
|
||||
const SingleStatus: React.FC<SingleStatusProps> = ({ status }) => {
|
||||
const statusTextRef = useRef<HTMLSpanElement>(null);
|
||||
const statusIconRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (statusTextRef.current) {
|
||||
setTooltip(statusTextRef.current, status.tooltipText);
|
||||
}
|
||||
if (statusIconRef.current) {
|
||||
setTooltip(statusIconRef.current, status.tooltipText);
|
||||
}
|
||||
}, [status.tooltipText]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={statusTextRef} className={`note-status-${status.name}`}>
|
||||
Status: {status.name}
|
||||
</span>
|
||||
<span
|
||||
ref={statusIconRef}
|
||||
className={`note-status-icon status-${status.name}`}
|
||||
>
|
||||
{status.icon}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface MultipleStatusesProps {
|
||||
statuses: StatusBarStatus[];
|
||||
}
|
||||
|
||||
const MultipleStatuses: React.FC<MultipleStatusesProps> = ({ statuses }) => {
|
||||
return (
|
||||
<>
|
||||
<span className="note-status-label">Statuses: </span>
|
||||
<span className="note-status-badges">
|
||||
{statuses.map((status, index) => (
|
||||
<StatusBadge
|
||||
key={`${status.name}-${index}`}
|
||||
status={status}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatusBarView: React.FC<StatusBarViewProps> = ({
|
||||
statuses,
|
||||
isVisible,
|
||||
className = "",
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const baseClasses = "note-status-bar";
|
||||
const visibilityClasses = isVisible ? "visible" : "hidden";
|
||||
const finalClassName =
|
||||
`${baseClasses} ${visibilityClasses} ${className}`.trim();
|
||||
|
||||
if (!isVisible) {
|
||||
return <div className={finalClassName} ref={containerRef} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={finalClassName} ref={containerRef}>
|
||||
{statuses.length === 1 ? (
|
||||
<SingleStatus status={statuses[0]} />
|
||||
) : (
|
||||
<MultipleStatuses statuses={statuses} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusBarView;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import { StatusBarController } from "./status-bar-controller";
|
||||
|
||||
export { StatusBarController as StatusBar };
|
||||
export default StatusBarController;
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
import React from "react";
|
||||
import { NoteStatusSettings } from "../../models/types";
|
||||
import { StatusService } from "../../services/status-service";
|
||||
import { StatusBarView } from "./StatusBarView";
|
||||
import { ReactUtils } from "../../utils/react-utils";
|
||||
|
||||
/**
|
||||
* Controller for the status bar using React
|
||||
*/
|
||||
export class StatusBarController {
|
||||
private container: HTMLElement;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private currentStatuses: string[] = ["unknown"];
|
||||
|
||||
constructor(
|
||||
statusBarContainer: HTMLElement,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
) {
|
||||
this.container = statusBarContainer;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
|
||||
this.update(["unknown"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status bar with new statuses
|
||||
*/
|
||||
public update(statuses: string[]): void {
|
||||
this.currentStatuses = statuses;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the status bar using React
|
||||
*/
|
||||
private render(): void {
|
||||
if (!this.settings.showStatusBar) {
|
||||
ReactUtils.unmount(this.container);
|
||||
return;
|
||||
}
|
||||
|
||||
const statusesToShow = this.settings.useMultipleStatuses
|
||||
? this.currentStatuses
|
||||
: [this.currentStatuses[0]];
|
||||
|
||||
const statusDetails = statusesToShow.map((status) => {
|
||||
const statusObj = this.statusService
|
||||
.getAllStatuses()
|
||||
.find((s) => s.name === status);
|
||||
return {
|
||||
name: status,
|
||||
icon: this.statusService.getStatusIcon(status),
|
||||
tooltipText: statusObj?.description
|
||||
? `${status} - ${statusObj.description}`
|
||||
: status,
|
||||
};
|
||||
});
|
||||
|
||||
const shouldShow = this.shouldShowStatusBar();
|
||||
|
||||
ReactUtils.render(
|
||||
React.createElement(StatusBarView, {
|
||||
statuses: statusDetails,
|
||||
isVisible: shouldShow,
|
||||
}),
|
||||
this.container,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if status bar should be visible
|
||||
*/
|
||||
private shouldShowStatusBar(): boolean {
|
||||
const onlyUnknown =
|
||||
this.currentStatuses.length === 1 &&
|
||||
this.currentStatuses[0] === "unknown";
|
||||
|
||||
if (this.settings.autoHideStatusBar && onlyUnknown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update settings reference
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up when plugin is unloaded
|
||||
*/
|
||||
public unload(): void {
|
||||
ReactUtils.unmount(this.container);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,376 +0,0 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { App, TFile } from "obsidian";
|
||||
import {
|
||||
DropdownDependencies,
|
||||
StatusRemoveHandler,
|
||||
StatusSelectHandler,
|
||||
} from "./types";
|
||||
import { positionDropdown } from "./dropdown-position";
|
||||
import { StatusService } from "services/status-service";
|
||||
import { NoteStatusSettings } from "models/types";
|
||||
import { ReactUtils } from "../../utils/react-utils";
|
||||
|
||||
interface DropdownUIProps {
|
||||
app: App;
|
||||
statusService: StatusService;
|
||||
settings: NoteStatusSettings;
|
||||
currentStatuses: string[];
|
||||
targetFile: TFile | null;
|
||||
targetFiles: TFile[];
|
||||
isOpen: boolean;
|
||||
onRemoveStatus: StatusRemoveHandler;
|
||||
onSelectStatus: StatusSelectHandler;
|
||||
onClose: () => void;
|
||||
targetElement?: HTMLElement;
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface StatusItemProps {
|
||||
status: {
|
||||
name: string;
|
||||
icon: string;
|
||||
description?: string;
|
||||
};
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onRemove?: () => void;
|
||||
showRemove: boolean;
|
||||
}
|
||||
|
||||
const StatusItem: React.FC<StatusItemProps> = ({
|
||||
status,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onRemove,
|
||||
showRemove,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`note-status-dropdown-item ${isSelected ? "selected" : ""}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="note-status-item-content">
|
||||
<span className="note-status-item-icon">{status.icon}</span>
|
||||
<span className="note-status-item-name">{status.name}</span>
|
||||
{status.description && (
|
||||
<span className="note-status-item-description">
|
||||
{status.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showRemove && isSelected && onRemove && (
|
||||
<button
|
||||
className="note-status-remove-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
title={`Remove ${status.name} status`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DropdownContentProps {
|
||||
settings: NoteStatusSettings;
|
||||
statusService: StatusService;
|
||||
currentStatuses: string[];
|
||||
targetFile: TFile | null;
|
||||
targetFiles: TFile[];
|
||||
onRemoveStatus: StatusRemoveHandler;
|
||||
onSelectStatus: StatusSelectHandler;
|
||||
}
|
||||
|
||||
const DropdownContent: React.FC<DropdownContentProps> = ({
|
||||
settings,
|
||||
statusService,
|
||||
currentStatuses,
|
||||
targetFile,
|
||||
targetFiles,
|
||||
onRemoveStatus,
|
||||
onSelectStatus,
|
||||
}) => {
|
||||
const allStatuses = statusService.getAllStatuses();
|
||||
const isMultipleFiles = targetFiles.length > 1;
|
||||
|
||||
const handleStatusSelect = useCallback(
|
||||
async (statusName: string) => {
|
||||
const files =
|
||||
targetFiles.length > 0
|
||||
? targetFiles
|
||||
: targetFile
|
||||
? [targetFile]
|
||||
: [];
|
||||
await onSelectStatus(
|
||||
statusName,
|
||||
isMultipleFiles ? files : files[0],
|
||||
);
|
||||
},
|
||||
[targetFile, targetFiles, onSelectStatus, isMultipleFiles],
|
||||
);
|
||||
|
||||
const handleStatusRemove = useCallback(
|
||||
async (statusName: string) => {
|
||||
const files =
|
||||
targetFiles.length > 0
|
||||
? targetFiles
|
||||
: targetFile
|
||||
? [targetFile]
|
||||
: [];
|
||||
await onRemoveStatus(
|
||||
statusName,
|
||||
isMultipleFiles ? files : files[0],
|
||||
);
|
||||
},
|
||||
[targetFile, targetFiles, onRemoveStatus, isMultipleFiles],
|
||||
);
|
||||
|
||||
if (allStatuses.length === 0) {
|
||||
return (
|
||||
<div className="note-status-dropdown-empty">
|
||||
<p>No statuses available</p>
|
||||
<p className="note-status-dropdown-help">
|
||||
Configure statuses in plugin settings
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="note-status-dropdown-content">
|
||||
{isMultipleFiles && (
|
||||
<div className="note-status-dropdown-header">
|
||||
<p>Updating {targetFiles.length} files</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="note-status-dropdown-list">
|
||||
{allStatuses.map((status) => {
|
||||
const isSelected = currentStatuses.includes(status.name);
|
||||
return (
|
||||
<StatusItem
|
||||
key={status.name}
|
||||
status={status}
|
||||
isSelected={isSelected}
|
||||
onSelect={() => handleStatusSelect(status.name)}
|
||||
onRemove={() => handleStatusRemove(status.name)}
|
||||
showRemove={settings.useMultipleStatuses}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DropdownUI: React.FC<DropdownUIProps> = ({
|
||||
app,
|
||||
statusService,
|
||||
settings,
|
||||
currentStatuses,
|
||||
targetFile,
|
||||
targetFiles,
|
||||
isOpen,
|
||||
onRemoveStatus,
|
||||
onSelectStatus,
|
||||
onClose,
|
||||
targetElement,
|
||||
position,
|
||||
}) => {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
const handleClickOutside = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handleEscapeKey = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsAnimating(true);
|
||||
const timer = setTimeout(() => setIsAnimating(false), 220);
|
||||
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscapeKey);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscapeKey);
|
||||
};
|
||||
}
|
||||
}, [isOpen, handleClickOutside, handleEscapeKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && dropdownRef.current && targetElement) {
|
||||
positionDropdown({
|
||||
dropdownElement: dropdownRef.current,
|
||||
targetEl: targetElement,
|
||||
position,
|
||||
});
|
||||
}
|
||||
}, [isOpen, targetElement, position]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const className = `note-status-popover note-status-unified-dropdown ${
|
||||
isAnimating ? "note-status-popover-animate-in" : ""
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className={className}>
|
||||
<DropdownContent
|
||||
settings={settings}
|
||||
statusService={statusService}
|
||||
currentStatuses={currentStatuses}
|
||||
targetFile={targetFile}
|
||||
targetFiles={targetFiles}
|
||||
onRemoveStatus={onRemoveStatus}
|
||||
onSelectStatus={onSelectStatus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export class DropdownUIManager {
|
||||
private app: App;
|
||||
private statusService: StatusService;
|
||||
private settings: NoteStatusSettings;
|
||||
|
||||
private currentStatuses: string[] = ["unknown"];
|
||||
private targetFile: TFile | null = null;
|
||||
private targetFiles: TFile[] = [];
|
||||
private container: HTMLElement | null = null;
|
||||
|
||||
public isOpen = false;
|
||||
private onRemoveStatus: StatusRemoveHandler = async () => {};
|
||||
private onSelectStatus: StatusSelectHandler = async () => {};
|
||||
|
||||
constructor({ app, settings, statusService }: DropdownDependencies) {
|
||||
this.app = app;
|
||||
this.statusService = statusService;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public setTargetFile(file: TFile | null): void {
|
||||
this.targetFile = file;
|
||||
this.targetFiles = file ? [file] : [];
|
||||
}
|
||||
|
||||
public setTargetFiles(files: TFile[]): void {
|
||||
this.targetFiles = [...files];
|
||||
this.targetFile = files.length === 1 ? files[0] : null;
|
||||
}
|
||||
|
||||
public setOnRemoveStatusHandler(handler: StatusRemoveHandler): void {
|
||||
this.onRemoveStatus = handler;
|
||||
}
|
||||
|
||||
public setOnSelectStatusHandler(handler: StatusSelectHandler): void {
|
||||
this.onSelectStatus = handler;
|
||||
}
|
||||
|
||||
public updateStatuses(statuses: string[] | string): void {
|
||||
this.currentStatuses = Array.isArray(statuses)
|
||||
? [...statuses]
|
||||
: [statuses];
|
||||
this.render();
|
||||
}
|
||||
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.render();
|
||||
}
|
||||
|
||||
public open(
|
||||
targetEl: HTMLElement,
|
||||
position?: { x: number; y: number },
|
||||
): void {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
setTimeout(() => this.actuallyOpen(targetEl, position), 10);
|
||||
return;
|
||||
}
|
||||
|
||||
this.actuallyOpen(targetEl, position);
|
||||
}
|
||||
|
||||
private actuallyOpen(
|
||||
targetEl: HTMLElement,
|
||||
position?: { x: number; y: number },
|
||||
): void {
|
||||
this.isOpen = true;
|
||||
this.createContainer();
|
||||
this.render(targetEl, position);
|
||||
}
|
||||
|
||||
private createContainer(): void {
|
||||
if (!this.container) {
|
||||
this.container = document.createElement("div");
|
||||
document.body.appendChild(this.container);
|
||||
}
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.isOpen = false;
|
||||
this.render();
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.container) {
|
||||
ReactUtils.unmount(this.container);
|
||||
this.container.remove();
|
||||
this.container = null;
|
||||
}
|
||||
}, 220);
|
||||
}
|
||||
|
||||
private render(
|
||||
targetElement?: HTMLElement,
|
||||
position?: { x: number; y: number },
|
||||
): void {
|
||||
if (!this.container) return;
|
||||
|
||||
ReactUtils.render(
|
||||
React.createElement(DropdownUI, {
|
||||
app: this.app,
|
||||
statusService: this.statusService,
|
||||
settings: this.settings,
|
||||
currentStatuses: this.currentStatuses,
|
||||
targetFile: this.targetFile,
|
||||
targetFiles: this.targetFiles,
|
||||
isOpen: this.isOpen,
|
||||
onRemoveStatus: this.onRemoveStatus,
|
||||
onSelectStatus: this.onSelectStatus,
|
||||
onClose: () => this.close(),
|
||||
targetElement,
|
||||
position,
|
||||
}),
|
||||
this.container,
|
||||
);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,275 +0,0 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { TFile, setTooltip } from "obsidian";
|
||||
import { StatusService } from "../../services/status-service";
|
||||
import { NoteStatusSettings, Status } from "../../models/types";
|
||||
|
||||
interface StatusDropdownProps {
|
||||
isOpen: boolean;
|
||||
currentStatuses: string[];
|
||||
targetFiles: TFile[];
|
||||
settings: NoteStatusSettings;
|
||||
statusService: StatusService;
|
||||
onRemoveStatus: (status: string, target: TFile | TFile[]) => Promise<void>;
|
||||
onSelectStatus: (status: string, target: TFile[]) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const StatusDropdownComponent: React.FC<StatusDropdownProps> = ({
|
||||
isOpen,
|
||||
currentStatuses,
|
||||
targetFiles,
|
||||
settings,
|
||||
statusService,
|
||||
onRemoveStatus,
|
||||
onSelectStatus,
|
||||
onClose,
|
||||
}) => {
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && searchInputRef.current) {
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const allStatuses = statusService
|
||||
.getAllStatuses()
|
||||
.filter((status) => status.name !== "unknown");
|
||||
|
||||
const filteredStatuses = searchFilter
|
||||
? allStatuses.filter(
|
||||
(status) =>
|
||||
status.name
|
||||
.toLowerCase()
|
||||
.includes(searchFilter.toLowerCase()) ||
|
||||
status.icon.includes(searchFilter),
|
||||
)
|
||||
: allStatuses;
|
||||
|
||||
const hasNoValidStatus =
|
||||
currentStatuses.length === 0 ||
|
||||
(currentStatuses.length === 1 && currentStatuses[0] === "unknown");
|
||||
|
||||
const handleRemoveStatus = async (status: string) => {
|
||||
const target = targetFiles.length > 1 ? targetFiles : targetFiles[0];
|
||||
if (target) {
|
||||
await onRemoveStatus(status, target);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectStatus = async (status: string) => {
|
||||
if (targetFiles.length > 0) {
|
||||
await onSelectStatus(status, targetFiles);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="note-status-popover note-status-unified-dropdown note-status-popover-animate-in">
|
||||
{/* Header */}
|
||||
<div className="note-status-popover-header">
|
||||
<div className="note-status-popover-title">
|
||||
<div className="note-status-popover-icon">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
|
||||
<line x1="7" y1="7" x2="7.01" y2="7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="note-status-popover-label">
|
||||
Note status
|
||||
</span>
|
||||
{targetFiles.length > 1 && (
|
||||
<span className="note-status-popover-count">
|
||||
({targetFiles.length} files)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Status Chips */}
|
||||
<div className="note-status-popover-chips">
|
||||
{hasNoValidStatus ? (
|
||||
<div className="note-status-empty-indicator">
|
||||
No status assigned
|
||||
</div>
|
||||
) : (
|
||||
currentStatuses
|
||||
.filter((status) => status !== "unknown")
|
||||
.map((status) => {
|
||||
const statusObj = allStatuses.find(
|
||||
(s) => s.name === status,
|
||||
);
|
||||
if (!statusObj) return null;
|
||||
|
||||
return (
|
||||
<StatusChip
|
||||
key={status}
|
||||
status={statusObj}
|
||||
onRemove={() => handleRemoveStatus(status)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Filter */}
|
||||
<div className="note-status-popover-search">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Filter statuses..."
|
||||
className="note-status-popover-search-input"
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Options */}
|
||||
<div className="note-status-options-container">
|
||||
{filteredStatuses.length === 0 ? (
|
||||
<div className="note-status-empty-options">
|
||||
{searchFilter
|
||||
? `No statuses match "${searchFilter}"`
|
||||
: "No statuses found"}
|
||||
</div>
|
||||
) : (
|
||||
filteredStatuses.map((status) => (
|
||||
<StatusOption
|
||||
key={status.name}
|
||||
status={status}
|
||||
isSelected={currentStatuses.includes(status.name)}
|
||||
onSelect={() => handleSelectStatus(status.name)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StatusChipProps {
|
||||
status: Status;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const StatusChip: React.FC<StatusChipProps> = ({ status, onRemove }) => {
|
||||
const chipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (chipRef.current) {
|
||||
const tooltipValue = status.description
|
||||
? `${status.name} - ${status.description}`
|
||||
: status.name;
|
||||
setTooltip(chipRef.current, tooltipValue);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (chipRef.current) {
|
||||
chipRef.current.classList.add("note-status-chip-removing");
|
||||
setTimeout(() => {
|
||||
onRemove();
|
||||
}, 150);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={chipRef} className={`note-status-chip status-${status.name}`}>
|
||||
<span className="note-status-chip-icon">{status.icon}</span>
|
||||
<span className="note-status-chip-text">{status.name}</span>
|
||||
<div
|
||||
className="note-status-chip-remove"
|
||||
aria-label={`Remove ${status.name} status`}
|
||||
title={`Remove ${status.name} status`}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StatusOptionProps {
|
||||
status: Status;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
const StatusOption: React.FC<StatusOptionProps> = ({
|
||||
status,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}) => {
|
||||
const optionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (optionRef.current && status.description) {
|
||||
setTooltip(
|
||||
optionRef.current,
|
||||
`${status.name} - ${status.description}`,
|
||||
);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const handleClick = () => {
|
||||
if (optionRef.current) {
|
||||
optionRef.current.classList.add("note-status-option-selecting");
|
||||
setTimeout(() => {
|
||||
onSelect();
|
||||
}, 150);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={optionRef}
|
||||
className={`note-status-option ${
|
||||
isSelected ? "is-selected" : ""
|
||||
} status-${status.name}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="note-status-option-icon">{status.icon}</span>
|
||||
<span className="note-status-option-text">{status.name}</span>
|
||||
{isSelected && (
|
||||
<div className="note-status-option-check">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20,6 9,17 4,12" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,322 +0,0 @@
|
|||
import React from "react";
|
||||
import { MarkdownView, Editor, Notice, TFile, App } from "obsidian";
|
||||
import { StatusDropdownComponent } from "./StatusDropdownComponent";
|
||||
import { DropdownOptions } from "./types";
|
||||
import { StatusService } from "../../services/status-service";
|
||||
import { NoteStatusSettings } from "../../models/types";
|
||||
import { ReactUtils } from "../../utils/react-utils";
|
||||
|
||||
/**
|
||||
* High-level manager for status dropdown interactions using React
|
||||
*/
|
||||
export class StatusDropdownManager {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private currentStatuses: string[] = ["unknown"];
|
||||
private isOpen = false;
|
||||
private targetFiles: TFile[] = [];
|
||||
private dropdownContainer: HTMLElement | null = null;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the dropdown UI based on current statuses
|
||||
*/
|
||||
public update(currentStatuses: string[] | string, _file?: TFile): void {
|
||||
this.currentStatuses = Array.isArray(currentStatuses)
|
||||
? [...currentStatuses]
|
||||
: [currentStatuses];
|
||||
|
||||
if (this.isOpen) {
|
||||
this.renderDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates settings reference
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
if (this.isOpen) {
|
||||
this.renderDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get position from cursor or fallback
|
||||
*/
|
||||
private getCursorPosition(
|
||||
editor: Editor,
|
||||
view: MarkdownView,
|
||||
): { x: number; y: number } {
|
||||
try {
|
||||
const cursor = editor.getCursor("head");
|
||||
const lineElement = view.contentEl.querySelector(
|
||||
`.cm-line:nth-child(${cursor.line + 1})`,
|
||||
);
|
||||
|
||||
if (lineElement) {
|
||||
const rect = lineElement.getBoundingClientRect();
|
||||
return { x: rect.left + 20, y: rect.bottom + 5 };
|
||||
}
|
||||
|
||||
const editorEl = view.contentEl.querySelector(".cm-editor");
|
||||
if (editorEl) {
|
||||
const rect = editorEl.getBoundingClientRect();
|
||||
return { x: rect.left + 100, y: rect.top + 100 };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting position for dropdown:", error);
|
||||
}
|
||||
|
||||
return { x: window.innerWidth / 2, y: window.innerHeight / 3 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal function to open the status dropdown
|
||||
*/
|
||||
public openStatusDropdown(options: DropdownOptions): void {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
const files = options.files || (activeFile ? [activeFile] : []);
|
||||
|
||||
if (!files.length) {
|
||||
new Notice("No files selected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isOpen) {
|
||||
this.resetDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
this.targetFiles = files;
|
||||
|
||||
const isSingleFile = files.length === 1;
|
||||
|
||||
// Update current statuses based on files
|
||||
if (isSingleFile) {
|
||||
this.currentStatuses = this.statusService.getFileStatuses(files[0]);
|
||||
} else {
|
||||
this.currentStatuses = this.findCommonStatuses(files);
|
||||
}
|
||||
|
||||
this.positionAndOpenDropdown(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset dropdown state before opening
|
||||
*/
|
||||
public resetDropdown(): void {
|
||||
this.close();
|
||||
this.targetFiles = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Position and open the dropdown
|
||||
*/
|
||||
private positionAndOpenDropdown(options: {
|
||||
target?: HTMLElement;
|
||||
position?: { x: number; y: number };
|
||||
editor?: Editor;
|
||||
view?: MarkdownView;
|
||||
}): void {
|
||||
let position: { x: number; y: number };
|
||||
|
||||
if (options.editor && options.view) {
|
||||
position = this.getCursorPosition(options.editor, options.view);
|
||||
} else if (options.target) {
|
||||
if (options.position) {
|
||||
position = options.position;
|
||||
} else {
|
||||
const rect = options.target.getBoundingClientRect();
|
||||
position = { x: rect.left, y: rect.bottom + 5 };
|
||||
}
|
||||
} else if (options.position) {
|
||||
position = options.position;
|
||||
} else {
|
||||
position = { x: window.innerWidth / 2, y: window.innerHeight / 3 };
|
||||
}
|
||||
|
||||
this.open(position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open dropdown at a specific position
|
||||
*/
|
||||
private open(position: { x: number; y: number }): void {
|
||||
this.isOpen = true;
|
||||
|
||||
// Create container for the dropdown
|
||||
this.dropdownContainer = document.createElement("div");
|
||||
this.dropdownContainer.style.position = "fixed";
|
||||
this.dropdownContainer.style.left = `${position.x}px`;
|
||||
this.dropdownContainer.style.top = `${position.y}px`;
|
||||
this.dropdownContainer.style.zIndex = "1000";
|
||||
document.body.appendChild(this.dropdownContainer);
|
||||
|
||||
this.renderDropdown();
|
||||
|
||||
// Add event listeners for closing dropdown
|
||||
setTimeout(() => {
|
||||
document.addEventListener("click", this.handleClickOutside);
|
||||
document.addEventListener("keydown", this.handleEscapeKey);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the React dropdown component
|
||||
*/
|
||||
private renderDropdown(): void {
|
||||
if (!this.dropdownContainer) return;
|
||||
|
||||
ReactUtils.render(
|
||||
React.createElement(StatusDropdownComponent, {
|
||||
isOpen: this.isOpen,
|
||||
currentStatuses: this.currentStatuses,
|
||||
targetFiles: this.targetFiles,
|
||||
settings: this.settings,
|
||||
statusService: this.statusService,
|
||||
onRemoveStatus: this.handleRemoveStatus.bind(this),
|
||||
onSelectStatus: this.handleSelectStatus.bind(this),
|
||||
onClose: this.close.bind(this),
|
||||
}),
|
||||
this.dropdownContainer,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle status removal
|
||||
*/
|
||||
private async handleRemoveStatus(
|
||||
status: string,
|
||||
target: TFile | TFile[],
|
||||
): Promise<void> {
|
||||
const isMultiple = Array.isArray(target);
|
||||
|
||||
await this.statusService.handleStatusChange({
|
||||
files: target,
|
||||
statuses: status,
|
||||
operation: "remove",
|
||||
showNotice: isMultiple,
|
||||
});
|
||||
|
||||
this.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle status selection
|
||||
*/
|
||||
private async handleSelectStatus(
|
||||
status: string,
|
||||
targetFiles: TFile[],
|
||||
): Promise<void> {
|
||||
const isMultipleFiles = targetFiles.length > 1;
|
||||
|
||||
if (isMultipleFiles) {
|
||||
// Count how many files already have this status
|
||||
const filesWithStatus = targetFiles.filter((file) =>
|
||||
this.statusService.getFileStatuses(file).includes(status),
|
||||
);
|
||||
|
||||
// If ALL have the status, remove it. Otherwise, add it
|
||||
const operation =
|
||||
filesWithStatus.length === targetFiles.length
|
||||
? "remove"
|
||||
: !this.settings.useMultipleStatuses
|
||||
? "set"
|
||||
: "add";
|
||||
|
||||
await this.statusService.handleStatusChange({
|
||||
files: targetFiles,
|
||||
statuses: status,
|
||||
isMultipleSelection: true,
|
||||
operation: operation,
|
||||
});
|
||||
} else {
|
||||
// For individual files, maintain default behavior
|
||||
await this.statusService.handleStatusChange({
|
||||
files: targetFiles,
|
||||
statuses: status,
|
||||
});
|
||||
}
|
||||
|
||||
this.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the dropdown
|
||||
*/
|
||||
private close = (): void => {
|
||||
this.isOpen = false;
|
||||
|
||||
document.removeEventListener("click", this.handleClickOutside);
|
||||
document.removeEventListener("keydown", this.handleEscapeKey);
|
||||
|
||||
if (this.dropdownContainer) {
|
||||
ReactUtils.unmount(this.dropdownContainer);
|
||||
this.dropdownContainer.remove();
|
||||
this.dropdownContainer = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle click outside to close dropdown
|
||||
*/
|
||||
private handleClickOutside = (e: MouseEvent): void => {
|
||||
if (
|
||||
this.dropdownContainer &&
|
||||
!this.dropdownContainer.contains(e.target as Node)
|
||||
) {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle escape key to close dropdown
|
||||
*/
|
||||
private handleEscapeKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === "Escape") {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Find common statuses across multiple files
|
||||
*/
|
||||
private findCommonStatuses(files: TFile[]): string[] {
|
||||
if (files.length === 0) return ["unknown"];
|
||||
|
||||
const firstFileStatuses = this.statusService.getFileStatuses(files[0]);
|
||||
|
||||
return firstFileStatuses.filter(
|
||||
(status) =>
|
||||
status !== "unknown" &&
|
||||
files.every((file) =>
|
||||
this.statusService.getFileStatuses(file).includes(status),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render method (kept for compatibility)
|
||||
*/
|
||||
public render(): void {
|
||||
// No-op - dropdown component handles rendering internally
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove dropdown when plugin is unloaded
|
||||
*/
|
||||
public unload(): void {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
/**
|
||||
* Event handlers setup and cleanup for the dropdown
|
||||
*/
|
||||
|
||||
/**
|
||||
* Setup event handlers for the dropdown
|
||||
*/
|
||||
export function setupDropdownEvents(options: {
|
||||
clickOutsideHandler: (e: MouseEvent) => void;
|
||||
escapeKeyHandler: (e: KeyboardEvent) => void;
|
||||
}): void {
|
||||
const { clickOutsideHandler, escapeKeyHandler } = options;
|
||||
|
||||
document.addEventListener("click", clickOutsideHandler);
|
||||
document.addEventListener("keydown", escapeKeyHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove dropdown event handlers
|
||||
*/
|
||||
export function removeDropdownEvents(options: {
|
||||
clickOutsideHandler: (e: MouseEvent) => void;
|
||||
escapeKeyHandler: (e: KeyboardEvent) => void;
|
||||
}): void {
|
||||
const { clickOutsideHandler, escapeKeyHandler } = options;
|
||||
|
||||
document.removeEventListener("click", clickOutsideHandler);
|
||||
document.removeEventListener("keydown", escapeKeyHandler);
|
||||
}
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
import { MarkdownView, Editor, Notice, TFile, App } from "obsidian";
|
||||
import { DropdownUIManager } from "./DropdownUI";
|
||||
import { DropdownOptions, DropdownDependencies } from "./types";
|
||||
import { createDummyTarget } from "./dropdown-position";
|
||||
import { StatusService } from "services/status-service";
|
||||
import { NoteStatusSettings } from "models/types";
|
||||
|
||||
/**
|
||||
* High-level manager for status dropdown interactions
|
||||
*/
|
||||
export class DropdownManager {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private currentStatuses: string[] = ["unknown"];
|
||||
private toolbarButton?: HTMLElement;
|
||||
private dropdownUI: DropdownUIManager;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
|
||||
const deps: DropdownDependencies = { app, settings, statusService };
|
||||
this.dropdownUI = new DropdownUIManager(deps);
|
||||
this.setupDropdownCallbacks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up dropdown callbacks
|
||||
*/
|
||||
private setupDropdownCallbacks(): void {
|
||||
this.dropdownUI.setOnRemoveStatusHandler(async (status, targetFile) => {
|
||||
if (!targetFile) return;
|
||||
|
||||
const isMultiple = Array.isArray(targetFile);
|
||||
|
||||
await this.statusService.handleStatusChange({
|
||||
files: targetFile,
|
||||
statuses: status,
|
||||
operation: "remove",
|
||||
showNotice: isMultiple,
|
||||
});
|
||||
});
|
||||
|
||||
this.dropdownUI.setOnSelectStatusHandler(async (status, targetFile) => {
|
||||
// Check if handling multiple files
|
||||
const isMultipleFiles =
|
||||
Array.isArray(targetFile) && targetFile.length > 1;
|
||||
|
||||
if (isMultipleFiles) {
|
||||
const files = targetFile as TFile[];
|
||||
// Count how many files already have this status
|
||||
const filesWithStatus = files.filter((file) =>
|
||||
this.statusService.getFileStatuses(file).includes(status),
|
||||
);
|
||||
|
||||
// If ALL have the status, remove it. Otherwise, add it
|
||||
const operation =
|
||||
filesWithStatus.length === files.length
|
||||
? "remove"
|
||||
: !this.settings.useMultipleStatuses
|
||||
? "set"
|
||||
: "add";
|
||||
|
||||
await this.statusService.handleStatusChange({
|
||||
files: targetFile,
|
||||
statuses: status,
|
||||
isMultipleSelection: true,
|
||||
operation: operation,
|
||||
});
|
||||
} else {
|
||||
// For individual files, maintain default behavior
|
||||
await this.statusService.handleStatusChange({
|
||||
files: targetFile,
|
||||
statuses: status,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the dropdown UI based on current statuses
|
||||
*/
|
||||
public update(currentStatuses: string[] | string, _file?: TFile): void {
|
||||
this.currentStatuses = Array.isArray(currentStatuses)
|
||||
? [...currentStatuses]
|
||||
: [currentStatuses];
|
||||
|
||||
this.dropdownUI.updateStatuses(this.currentStatuses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates settings reference
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.dropdownUI.updateSettings(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get position from cursor or fallback
|
||||
*/
|
||||
private getCursorPosition(
|
||||
editor: Editor,
|
||||
view: MarkdownView,
|
||||
): { x: number; y: number } {
|
||||
try {
|
||||
const cursor = editor.getCursor("head");
|
||||
const lineElement = view.contentEl.querySelector(
|
||||
`.cm-line:nth-child(${cursor.line + 1})`,
|
||||
);
|
||||
|
||||
if (lineElement) {
|
||||
const rect = lineElement.getBoundingClientRect();
|
||||
return { x: rect.left + 20, y: rect.bottom + 5 };
|
||||
}
|
||||
|
||||
const editorEl = view.contentEl.querySelector(".cm-editor");
|
||||
if (editorEl) {
|
||||
const rect = editorEl.getBoundingClientRect();
|
||||
return { x: rect.left + 100, y: rect.top + 100 };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting position for dropdown:", error);
|
||||
}
|
||||
|
||||
return { x: window.innerWidth / 2, y: window.innerHeight / 3 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal function to open the status dropdown
|
||||
*/
|
||||
public openStatusDropdown(options: DropdownOptions): void {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
const files = options.files || (activeFile ? [activeFile] : []);
|
||||
if (!files.length) {
|
||||
new Notice("No files selected");
|
||||
return;
|
||||
}
|
||||
if (!files.length || !files[0]) {
|
||||
new Notice("No files selected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.dropdownUI.isOpen) {
|
||||
this.resetDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
const isSingleFile = files.length === 1;
|
||||
|
||||
// Set up target files appropriately
|
||||
if (isSingleFile) {
|
||||
const targetFile = files[0];
|
||||
this.dropdownUI.setTargetFile(targetFile);
|
||||
const currentStatuses =
|
||||
this.statusService.getFileStatuses(targetFile);
|
||||
this.dropdownUI.updateStatuses(currentStatuses);
|
||||
} else {
|
||||
// For multiple files, set the whole collection
|
||||
this.dropdownUI.setTargetFiles(files);
|
||||
const commonStatuses = this.findCommonStatuses(files);
|
||||
this.dropdownUI.updateStatuses(commonStatuses);
|
||||
}
|
||||
|
||||
this.positionAndOpenDropdown(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset dropdown state before opening
|
||||
*/
|
||||
public resetDropdown(): void {
|
||||
this.dropdownUI.close();
|
||||
this.dropdownUI.setTargetFile(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Position and open the dropdown
|
||||
*/
|
||||
private positionAndOpenDropdown(options: {
|
||||
target?: HTMLElement;
|
||||
position?: { x: number; y: number };
|
||||
editor?: Editor;
|
||||
view?: MarkdownView;
|
||||
}): void {
|
||||
if (options.editor && options.view) {
|
||||
const position = this.getCursorPosition(
|
||||
options.editor,
|
||||
options.view,
|
||||
);
|
||||
this.openWithPosition(position);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.target) {
|
||||
if (options.position) {
|
||||
this.dropdownUI.open(options.target, options.position);
|
||||
} else {
|
||||
const rect = options.target.getBoundingClientRect();
|
||||
this.dropdownUI.open(options.target, {
|
||||
x: rect.left,
|
||||
y: rect.bottom + 5,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.position) {
|
||||
this.openWithPosition(options.position);
|
||||
return;
|
||||
}
|
||||
|
||||
this.openWithPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 3,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open dropdown at a specific position using dummy target
|
||||
*/
|
||||
private openWithPosition(position: { x: number; y: number }): void {
|
||||
const dummyTarget = createDummyTarget(position);
|
||||
this.dropdownUI.open(dummyTarget, position);
|
||||
|
||||
setTimeout(() => {
|
||||
if (dummyTarget.parentNode) {
|
||||
dummyTarget.parentNode.removeChild(dummyTarget);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find common statuses across multiple files
|
||||
*/
|
||||
private findCommonStatuses(files: TFile[]): string[] {
|
||||
if (files.length === 0) return ["unknown"];
|
||||
|
||||
const firstFileStatuses = this.statusService.getFileStatuses(files[0]);
|
||||
|
||||
return firstFileStatuses.filter(
|
||||
(status) =>
|
||||
status !== "unknown" &&
|
||||
files.every((file) =>
|
||||
this.statusService.getFileStatuses(file).includes(status),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render method (kept for compatibility)
|
||||
*/
|
||||
public render(): void {
|
||||
// No-op - dropdown component handles rendering internally
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove dropdown when plugin is unloaded
|
||||
*/
|
||||
public unload(): void {
|
||||
this.dropdownUI.dispose();
|
||||
|
||||
if (this.toolbarButton) {
|
||||
this.toolbarButton.remove();
|
||||
this.toolbarButton = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
/**
|
||||
* Dropdown positioning utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Position the dropdown
|
||||
*/
|
||||
export function positionDropdown(options: {
|
||||
dropdownElement: HTMLElement;
|
||||
targetEl: HTMLElement;
|
||||
position?: { x: number; y: number };
|
||||
}): void {
|
||||
const { dropdownElement, targetEl, position } = options;
|
||||
|
||||
if (position) {
|
||||
positionAt(dropdownElement, position.x, position.y);
|
||||
} else {
|
||||
positionRelativeTo(dropdownElement, targetEl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Position the dropdown at specific coordinates
|
||||
*/
|
||||
function positionAt(dropdownElement: HTMLElement, x: number, y: number): void {
|
||||
dropdownElement.addClass("note-status-popover-fixed");
|
||||
dropdownElement.style.setProperty("--pos-x-px", `${x}px`);
|
||||
dropdownElement.style.setProperty("--pos-y-px", `${y}px`);
|
||||
|
||||
setTimeout(() => adjustPositionToViewport(dropdownElement), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust the dropdown position to ensure it's visible in the viewport
|
||||
*/
|
||||
function adjustPositionToViewport(dropdownElement: HTMLElement): void {
|
||||
const rect = dropdownElement.getBoundingClientRect();
|
||||
|
||||
if (rect.right > window.innerWidth) {
|
||||
dropdownElement.addClass("note-status-popover-right");
|
||||
dropdownElement.style.setProperty("--right-offset-px", "10px");
|
||||
} else {
|
||||
dropdownElement.removeClass("note-status-popover-right");
|
||||
}
|
||||
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
dropdownElement.addClass("note-status-popover-bottom");
|
||||
dropdownElement.style.setProperty("--bottom-offset-px", "10px");
|
||||
} else {
|
||||
dropdownElement.removeClass("note-status-popover-bottom");
|
||||
}
|
||||
|
||||
const maxHeight = window.innerHeight - rect.top - 20;
|
||||
dropdownElement.style.setProperty("--max-height-px", `${maxHeight}px`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Position the dropdown relative to a target element
|
||||
*/
|
||||
function positionRelativeTo(
|
||||
dropdownElement: HTMLElement,
|
||||
targetEl: HTMLElement,
|
||||
): void {
|
||||
dropdownElement.addClass("note-status-popover-fixed");
|
||||
|
||||
const targetRect = targetEl.getBoundingClientRect();
|
||||
|
||||
dropdownElement.style.setProperty(
|
||||
"--pos-y-px",
|
||||
`${targetRect.bottom + 5}px`,
|
||||
);
|
||||
dropdownElement.style.setProperty("--pos-x-px", `${targetRect.left}px`);
|
||||
|
||||
setTimeout(() => adjustRelativePosition(dropdownElement, targetRect), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust position when positioned relative to an element
|
||||
*/
|
||||
function adjustRelativePosition(
|
||||
dropdownElement: HTMLElement,
|
||||
targetRect: DOMRect,
|
||||
): void {
|
||||
const rect = dropdownElement.getBoundingClientRect();
|
||||
|
||||
if (rect.right > window.innerWidth) {
|
||||
dropdownElement.addClass("note-status-popover-right");
|
||||
dropdownElement.style.setProperty(
|
||||
"--right-offset-px",
|
||||
`${window.innerWidth - targetRect.right}px`,
|
||||
);
|
||||
} else {
|
||||
dropdownElement.removeClass("note-status-popover-right");
|
||||
}
|
||||
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
dropdownElement.addClass("note-status-popover-bottom");
|
||||
dropdownElement.style.setProperty(
|
||||
"--bottom-offset-px",
|
||||
`${window.innerHeight - targetRect.top + 5}px`,
|
||||
);
|
||||
} else {
|
||||
dropdownElement.removeClass("note-status-popover-bottom");
|
||||
}
|
||||
|
||||
const maxHeight = window.innerHeight - rect.top - 20;
|
||||
dropdownElement.style.setProperty("--max-height-px", `${maxHeight}px`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dummy target element for positioning
|
||||
*/
|
||||
export function createDummyTarget(position: {
|
||||
x: number;
|
||||
y: number;
|
||||
}): HTMLElement {
|
||||
const dummyTarget = document.createElement("div");
|
||||
dummyTarget.addClass("note-status-dummy-target");
|
||||
dummyTarget.style.setProperty("--pos-x-px", `${position.x}px`);
|
||||
dummyTarget.style.setProperty("--pos-y-px", `${position.y}px`);
|
||||
document.body.appendChild(dummyTarget);
|
||||
return dummyTarget;
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { StatusDropdownManager } from "./StatusDropdownManager";
|
||||
import { DropdownOptions } from "./types";
|
||||
|
||||
export type { DropdownOptions };
|
||||
export { StatusDropdownManager as StatusDropdown };
|
||||
export default StatusDropdownManager;
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
// # Tipos comunes
|
||||
import { NoteStatusSettings } from "models/types";
|
||||
import { App, TFile, Editor, MarkdownView } from "obsidian";
|
||||
import { StatusService } from "services/status-service";
|
||||
|
||||
/**
|
||||
* Options for opening status dropdown
|
||||
*/
|
||||
export interface DropdownOptions {
|
||||
target?: HTMLElement;
|
||||
position?: { x: number; y: number };
|
||||
files?: TFile[];
|
||||
editor?: Editor;
|
||||
view?: MarkdownView;
|
||||
mode?: "replace" | "add" | "remove" | "toggle";
|
||||
onStatusChange?: (statuses: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status removal handler function type
|
||||
*/
|
||||
export type StatusRemoveHandler = (
|
||||
status: string,
|
||||
targetFile?: TFile | TFile[],
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Status selection handler function type
|
||||
*/
|
||||
export type StatusSelectHandler = (
|
||||
status: string,
|
||||
targetFile: TFile | TFile[],
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Common dependencies for dropdown components
|
||||
*/
|
||||
export interface DropdownDependencies {
|
||||
app: App;
|
||||
settings: NoteStatusSettings;
|
||||
statusService: StatusService;
|
||||
}
|
||||
|
|
@ -1,18 +1,5 @@
|
|||
import { Status } from "../models/types";
|
||||
import { PluginSettings, StatusTemplate } from "types/pluginSettings";
|
||||
|
||||
/**
|
||||
* Status Template interface
|
||||
*/
|
||||
export interface StatusTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
statuses: Status[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined status templates
|
||||
*/
|
||||
export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
|
||||
{
|
||||
id: "colorful",
|
||||
|
|
@ -73,7 +60,26 @@ export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
|
|||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Default template IDs that should be enabled by default
|
||||
*/
|
||||
export const DEFAULT_ENABLED_TEMPLATES = ["colorful"];
|
||||
export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
|
||||
statusColors: {
|
||||
active: "var(--text-success)",
|
||||
onHold: "var(--text-warning)",
|
||||
completed: "var(--text-accent)",
|
||||
dropped: "var(--text-error)",
|
||||
unknown: "var(--text-muted)",
|
||||
},
|
||||
showStatusBar: true,
|
||||
autoHideStatusBar: false,
|
||||
customStatuses: [],
|
||||
showStatusIconsInExplorer: true,
|
||||
hideUnknownStatusInExplorer: false, // Default to show unknown status
|
||||
collapsedStatuses: {},
|
||||
compactView: false,
|
||||
enabledTemplates: [PREDEFINED_TEMPLATES[0].id],
|
||||
useCustomStatusesOnly: false,
|
||||
useMultipleStatuses: true,
|
||||
tagPrefix: "obsidian-note-status",
|
||||
strictStatuses: false, // Default to show all statuses from frontmatter
|
||||
excludeUnknownStatus: true, // Default to exclude unknown status files for better performance
|
||||
quickStatusCommands: ["active", "completed"], // Add default quick commands
|
||||
};
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { NoteStatusSettings } from "../models/types";
|
||||
import { DEFAULT_ENABLED_TEMPLATES } from "../constants/status-templates";
|
||||
|
||||
/**
|
||||
* Default plugin settings
|
||||
*/
|
||||
export const DEFAULT_SETTINGS: NoteStatusSettings = {
|
||||
statusColors: {
|
||||
active: "var(--text-success)",
|
||||
onHold: "var(--text-warning)",
|
||||
completed: "var(--text-accent)",
|
||||
dropped: "var(--text-error)",
|
||||
unknown: "var(--text-muted)",
|
||||
},
|
||||
showStatusBar: true,
|
||||
autoHideStatusBar: false,
|
||||
customStatuses: [],
|
||||
showStatusIconsInExplorer: true,
|
||||
hideUnknownStatusInExplorer: false, // Default to show unknown status
|
||||
collapsedStatuses: {},
|
||||
compactView: false,
|
||||
enabledTemplates: DEFAULT_ENABLED_TEMPLATES,
|
||||
useCustomStatusesOnly: false,
|
||||
useMultipleStatuses: true,
|
||||
tagPrefix: "obsidian-note-status",
|
||||
strictStatuses: false, // Default to show all statuses from frontmatter
|
||||
excludeUnknownStatus: true, // Default to exclude unknown status files for better performance
|
||||
quickStatusCommands: ["active", "completed"], // Add default quick commands
|
||||
};
|
||||
|
||||
/**
|
||||
* Default colors in hexadecimal format for backup and reset
|
||||
*/
|
||||
export const DEFAULT_COLORS: Record<string, string> = {
|
||||
active: "#00ff00", // Green for success
|
||||
onHold: "#ffaa00", // Orange for warning
|
||||
completed: "#00aaff", // Blue for accent
|
||||
dropped: "#ff0000", // Red for error
|
||||
unknown: "#888888", // Gray for muted
|
||||
};
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
/**
|
||||
* SVG icon definitions
|
||||
*/
|
||||
export const ICONS = {
|
||||
statusPane: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h10v2H7v-2z"/>
|
||||
</svg>`,
|
||||
search: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="search-icon">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>`,
|
||||
clear: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>`,
|
||||
standardView: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`,
|
||||
compactView: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="9" y1="3" x2="9" y2="21"></line></svg>`,
|
||||
refresh: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38"/></svg>`,
|
||||
collapseDown: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>`,
|
||||
collapseRight: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>`,
|
||||
file: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`,
|
||||
};
|
||||
393
core/commandsService.ts
Normal file
393
core/commandsService.ts
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import { Plugin, Notice, Editor, MarkdownView, TFile, App } from "obsidian";
|
||||
import { NoteStatusService, BaseNoteStatusService } from "./noteStatusService";
|
||||
import settingsService from "./settingsService";
|
||||
import eventBus from "./eventBus";
|
||||
|
||||
export class CommandsService {
|
||||
private plugin: Plugin;
|
||||
private app: App;
|
||||
private registeredCommands: Set<string> = new Set();
|
||||
|
||||
constructor(plugin: Plugin) {
|
||||
this.plugin = plugin;
|
||||
this.app = plugin.app;
|
||||
}
|
||||
|
||||
private createStatusService(file: TFile): NoteStatusService {
|
||||
const service = new NoteStatusService(file);
|
||||
service.populateStatuses();
|
||||
return service;
|
||||
}
|
||||
|
||||
registerAllCommands(): void {
|
||||
// Change status of current note
|
||||
this.plugin.addCommand({
|
||||
id: "change-status",
|
||||
name: "Change status of current note",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
const statusService = this.createStatusService(file);
|
||||
statusService.populateStatuses();
|
||||
eventBus.publish("triggered-open-modal", {
|
||||
statusService: statusService,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add("change-status");
|
||||
|
||||
// Insert status metadata
|
||||
this.plugin.addCommand({
|
||||
id: "insert-status-metadata",
|
||||
name: "Insert status metadata",
|
||||
editorCheckCallback: (
|
||||
checking: boolean,
|
||||
editor: Editor,
|
||||
view: MarkdownView,
|
||||
) => {
|
||||
if (!view.file) return false;
|
||||
|
||||
const cache = this.plugin.app.metadataCache.getFileCache(
|
||||
view.file,
|
||||
);
|
||||
const frontmatter = cache?.frontmatter;
|
||||
|
||||
// Check if any frontmatter property starts with tagPrefix
|
||||
let hasFronttmatter = false;
|
||||
if (frontmatter) {
|
||||
hasFronttmatter = Object.keys(frontmatter).some((key) =>
|
||||
key.startsWith(settingsService.settings.tagPrefix),
|
||||
);
|
||||
}
|
||||
|
||||
if (!checking && !hasFronttmatter) {
|
||||
BaseNoteStatusService.insertStatusMetadataInEditor(
|
||||
view.file,
|
||||
).then(() => {
|
||||
new Notice("Status metadata inserted");
|
||||
});
|
||||
}
|
||||
return !hasFronttmatter;
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add("insert-status-metadata");
|
||||
|
||||
// Cycle through statuses
|
||||
this.plugin.addCommand({
|
||||
id: "cycle-status",
|
||||
name: "Cycle to next status",
|
||||
checkCallback: (checking: boolean) => {
|
||||
if (settingsService.settings.useMultipleStatuses) {
|
||||
// For now the cycle status is for single statuses
|
||||
return false;
|
||||
}
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!checking &&
|
||||
!settingsService.settings.useMultipleStatuses
|
||||
) {
|
||||
const allStatuses =
|
||||
BaseNoteStatusService.getAllAvailableStatuses().map(
|
||||
(s) => s.name,
|
||||
);
|
||||
|
||||
if (allStatuses.length === 0) {
|
||||
new Notice("No statuses available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current file data
|
||||
const statusService = this.createStatusService(file);
|
||||
statusService.populateStatuses();
|
||||
const statuses =
|
||||
statusService.statuses[
|
||||
settingsService.settings.tagPrefix
|
||||
] || [];
|
||||
const currentStatus = statuses.length
|
||||
? statuses?.[0].name
|
||||
: undefined;
|
||||
let nextIndex = 0;
|
||||
if (currentStatus) {
|
||||
const currentIndex = allStatuses.indexOf(currentStatus);
|
||||
if (currentIndex !== -1) {
|
||||
nextIndex =
|
||||
currentIndex === -1
|
||||
? 0
|
||||
: (currentIndex + 1) % allStatuses.length;
|
||||
}
|
||||
}
|
||||
|
||||
statusService
|
||||
.addStatus(
|
||||
settingsService.settings.tagPrefix,
|
||||
allStatuses[nextIndex],
|
||||
)
|
||||
.then((resolve) => {
|
||||
new Notice(
|
||||
`Status changed to ${allStatuses[nextIndex]}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add("cycle-status");
|
||||
|
||||
// Register dynamic quick status commands
|
||||
this.registerQuickStatusCommands();
|
||||
|
||||
// Clear status
|
||||
this.plugin.addCommand({
|
||||
id: "clear-status",
|
||||
name: "Clear status",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
if (!checking) {
|
||||
const statusService = this.createStatusService(file);
|
||||
statusService
|
||||
.clearStatus(settingsService.settings.tagPrefix)
|
||||
.then(() => {
|
||||
new Notice("Status cleared");
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add("clear-status");
|
||||
|
||||
// Copy status from current note
|
||||
this.plugin.addCommand({
|
||||
id: "copy-status",
|
||||
name: "Copy status from current note",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
if (!checking) {
|
||||
const statuses = this.getFileStatuses(file);
|
||||
navigator.clipboard
|
||||
.writeText(statuses.join(", "))
|
||||
.then(() => {
|
||||
new Notice(`Copied status: ${statuses.join(", ")}`);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add("copy-status");
|
||||
|
||||
// Paste status to current note
|
||||
this.plugin.addCommand({
|
||||
id: "paste-status",
|
||||
name: "Paste status to current note",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
if (!checking) {
|
||||
const statusService = this.createStatusService(file);
|
||||
statusService.populateStatuses();
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
const statuses = text.split(", ");
|
||||
this.setFileStatuses(file, statuses);
|
||||
statusService
|
||||
.overrideStatuses(
|
||||
settingsService.settings.tagPrefix,
|
||||
statuses,
|
||||
)
|
||||
.then(() => {
|
||||
new Notice(
|
||||
`Pasted status: ${statuses.join(", ")}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add("paste-status");
|
||||
|
||||
// Toggle multiple statuses mode
|
||||
this.plugin.addCommand({
|
||||
id: "toggle-multiple-statuses",
|
||||
name: "Toggle multiple statuses mode",
|
||||
callback: () => {
|
||||
const currentValue =
|
||||
settingsService.settings.useMultipleStatuses;
|
||||
settingsService.setValue("useMultipleStatuses", !currentValue);
|
||||
new Notice(
|
||||
`Multiple statuses mode ${!currentValue ? "enabled" : "disabled"}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add("toggle-multiple-statuses");
|
||||
|
||||
// Search notes by status
|
||||
this.plugin.addCommand({
|
||||
id: "search-by-status",
|
||||
name: "Search notes by current status",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
const statuses = this.getFileStatuses(file);
|
||||
|
||||
if (!statuses || statuses.length === 0) return false;
|
||||
|
||||
const tagPrefix = settingsService.settings.tagPrefix;
|
||||
const queries = statuses.map(
|
||||
(status) => `[${tagPrefix}:"${status}"]`,
|
||||
);
|
||||
const query = queries.join(" OR ");
|
||||
|
||||
// @ts-ignore
|
||||
this.app.internalPlugins.plugins[
|
||||
"global-search"
|
||||
].instance.openGlobalSearch(query);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add("search-by-status");
|
||||
}
|
||||
|
||||
/**
|
||||
* Register quick status commands based on settings
|
||||
*/
|
||||
private registerQuickStatusCommands(): void {
|
||||
const quickCommands =
|
||||
settingsService.settings.quickStatusCommands || [];
|
||||
const allStatuses = BaseNoteStatusService.getAllAvailableStatuses();
|
||||
|
||||
quickCommands.forEach((statusName) => {
|
||||
const status = allStatuses.find((s) => s.name === statusName);
|
||||
if (!status) return;
|
||||
|
||||
this.plugin.addCommand({
|
||||
id: `set-status-${statusName}`,
|
||||
name: `Set status to ${statusName}`,
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.setFileStatuses(file, [statusName]);
|
||||
new Notice(`Status set to ${statusName}`);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add(`set-status-${statusName}`);
|
||||
|
||||
// Add toggle command for multiple status mode
|
||||
if (settingsService.settings.useMultipleStatuses) {
|
||||
this.plugin.addCommand({
|
||||
id: `toggle-status-${statusName}`,
|
||||
name: `Toggle status ${statusName}`,
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.toggleFileStatus(file, statusName);
|
||||
const currentStatuses = this.getFileStatuses(file);
|
||||
const hasStatus =
|
||||
currentStatuses.includes(statusName);
|
||||
new Notice(
|
||||
`Status ${statusName} ${hasStatus ? "added" : "removed"}`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add(`toggle-status-${statusName}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getFileStatuses(file: TFile): string[] {
|
||||
const statusService = this.createStatusService(file);
|
||||
statusService.populateStatuses();
|
||||
const tagPrefix = settingsService.settings.tagPrefix;
|
||||
const statuses = statusService.statuses[tagPrefix] || [];
|
||||
return statuses.length > 0 ? statuses.map((s) => s.name) : ["unknown"];
|
||||
}
|
||||
|
||||
private async setFileStatuses(
|
||||
file: TFile,
|
||||
statusNames: string[],
|
||||
): Promise<void> {
|
||||
const tagPrefix = settingsService.settings.tagPrefix;
|
||||
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
||||
if (
|
||||
statusNames.length === 0 ||
|
||||
(statusNames.length === 1 && statusNames[0] === "unknown")
|
||||
) {
|
||||
delete frontmatter[tagPrefix];
|
||||
} else if (statusNames.length === 1) {
|
||||
frontmatter[tagPrefix] = statusNames[0];
|
||||
} else {
|
||||
frontmatter[tagPrefix] = statusNames;
|
||||
}
|
||||
});
|
||||
eventBus.publish("frontmatter-manually-changed", { file });
|
||||
}
|
||||
|
||||
private async toggleFileStatus(
|
||||
file: TFile,
|
||||
statusName: string,
|
||||
): Promise<void> {
|
||||
const currentStatuses = this.getFileStatuses(file);
|
||||
let newStatuses: string[];
|
||||
|
||||
if (currentStatuses.includes(statusName)) {
|
||||
// Remove the status
|
||||
newStatuses = currentStatuses.filter((s) => s !== statusName);
|
||||
} else {
|
||||
// Add the status
|
||||
newStatuses = [
|
||||
...currentStatuses.filter((s) => s !== "unknown"),
|
||||
statusName,
|
||||
];
|
||||
}
|
||||
|
||||
await this.setFileStatuses(file, newStatuses);
|
||||
}
|
||||
|
||||
public unload(): void {
|
||||
// Remove existing commands
|
||||
this.removeQuickStatusCommands();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all quick status commands
|
||||
*/
|
||||
private removeQuickStatusCommands(): void {
|
||||
const allStatuses = BaseNoteStatusService.getAllAvailableStatuses();
|
||||
|
||||
allStatuses.forEach((status) => {
|
||||
// @ts-ignore
|
||||
this.app.commands.removeCommand(
|
||||
`note-status:set-status-${status.name}`,
|
||||
);
|
||||
// @ts-ignore
|
||||
this.app.commands.removeCommand(
|
||||
`note-status:toggle-status-${status.name}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
// Commands are automatically cleaned up when the plugin is disabled
|
||||
// But we can track them for debugging purposes
|
||||
this.registeredCommands.clear();
|
||||
}
|
||||
}
|
||||
50
core/eventBus.ts
Normal file
50
core/eventBus.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { EventBusEvents, EventName } from "@/types/eventBus";
|
||||
|
||||
class EventBus {
|
||||
private events: { [K in EventName]: Map<string, EventBusEvents[K]> } = {
|
||||
"active-file-change": new Map(),
|
||||
"plugin-settings-changed": new Map(),
|
||||
"frontmatter-manually-changed": new Map(),
|
||||
"triggered-open-modal": new Map(),
|
||||
};
|
||||
|
||||
subscribe<T extends EventName>(
|
||||
event: T,
|
||||
callback: EventBusEvents[T],
|
||||
id: string,
|
||||
) {
|
||||
this.events[event].set(id, callback);
|
||||
return () => {
|
||||
this.events[event].delete(id);
|
||||
};
|
||||
}
|
||||
|
||||
unsubscribe(event: EventName, id: string) {
|
||||
this.events[event].delete(id);
|
||||
}
|
||||
|
||||
publish(
|
||||
event: "active-file-change",
|
||||
...args: Parameters<EventBusEvents["active-file-change"]>
|
||||
): void;
|
||||
publish(
|
||||
event: "plugin-settings-changed",
|
||||
...args: Parameters<EventBusEvents["plugin-settings-changed"]>
|
||||
): void;
|
||||
publish(
|
||||
event: "frontmatter-manually-changed",
|
||||
...args: Parameters<EventBusEvents["frontmatter-manually-changed"]>
|
||||
): void;
|
||||
publish(
|
||||
event: "triggered-open-modal",
|
||||
...args: Parameters<EventBusEvents["triggered-open-modal"]>
|
||||
): void;
|
||||
publish(event: EventName, ...args: unknown[]) {
|
||||
const callbacks = this.events[event];
|
||||
callbacks.forEach((callback) => {
|
||||
(callback as (...args: unknown[]) => void)(...args);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new EventBus();
|
||||
156
core/lazyElementObserver.ts
Normal file
156
core/lazyElementObserver.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* Interface for element processing callbacks
|
||||
*/
|
||||
export interface IElementProcessor {
|
||||
processElement(element: HTMLElement): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for observing DOM changes in file explorer
|
||||
* Single responsibility: DOM observation and element detection
|
||||
*/
|
||||
export class LazyElementObserver {
|
||||
private mutationObserver: MutationObserver | null = null;
|
||||
private intersectionObserver: IntersectionObserver | null = null;
|
||||
private processedElements = new WeakSet<HTMLElement>();
|
||||
private elementProcessor: IElementProcessor;
|
||||
|
||||
private readonly INTERSECTION_ROOT_MARGIN = "50px";
|
||||
private readonly FILE_SELECTOR = "[data-path]";
|
||||
|
||||
constructor(elementProcessor: IElementProcessor) {
|
||||
this.elementProcessor = elementProcessor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up observers for a container element
|
||||
*/
|
||||
setupObservers(container: Element): void {
|
||||
this.cleanup();
|
||||
|
||||
this.initializeIntersectionObserver();
|
||||
this.initializeMutationObserver(container);
|
||||
this.observeExistingElements(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks element as unprocessed for re-processing
|
||||
*/
|
||||
markElementForReprocessing(element: HTMLElement): void {
|
||||
this.processedElements.delete(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all observers and reset state
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.disconnectObservers();
|
||||
this.resetState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes intersection observer for visibility detection
|
||||
*/
|
||||
private initializeIntersectionObserver(): void {
|
||||
this.intersectionObserver = new IntersectionObserver(
|
||||
(entries) => this.handleIntersectionEntries(entries),
|
||||
{ rootMargin: this.INTERSECTION_ROOT_MARGIN },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles intersection observer entries
|
||||
*/
|
||||
private handleIntersectionEntries(
|
||||
entries: IntersectionObserverEntry[],
|
||||
): void {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const element = entry.target as HTMLElement;
|
||||
this.processElementIfNeeded(element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes element if not already processed
|
||||
*/
|
||||
private processElementIfNeeded(element: HTMLElement): void {
|
||||
if (!this.processedElements.has(element)) {
|
||||
this.elementProcessor.processElement(element);
|
||||
this.processedElements.add(element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes mutation observer for DOM changes
|
||||
*/
|
||||
private initializeMutationObserver(container: Element): void {
|
||||
this.mutationObserver = new MutationObserver((mutations) =>
|
||||
this.handleMutations(mutations),
|
||||
);
|
||||
|
||||
this.mutationObserver.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles mutation observer entries
|
||||
*/
|
||||
private handleMutations(mutations: MutationRecord[]): void {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
this.handleAddedElement(node as HTMLElement);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles newly added DOM elements
|
||||
*/
|
||||
private handleAddedElement(element: HTMLElement): void {
|
||||
this.observeElement(element);
|
||||
|
||||
const fileElements = element.querySelectorAll(this.FILE_SELECTOR);
|
||||
fileElements.forEach((el) => this.observeElement(el as HTMLElement));
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes existing elements in container
|
||||
*/
|
||||
private observeExistingElements(container: Element): void {
|
||||
const fileElements = container.querySelectorAll(this.FILE_SELECTOR);
|
||||
fileElements.forEach((el) => this.observeElement(el as HTMLElement));
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts observing a single element
|
||||
*/
|
||||
private observeElement(element: HTMLElement): void {
|
||||
const dataPath = element.getAttribute("data-path");
|
||||
if (dataPath && this.intersectionObserver) {
|
||||
this.intersectionObserver.observe(element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects all observers
|
||||
*/
|
||||
private disconnectObservers(): void {
|
||||
this.mutationObserver?.disconnect();
|
||||
this.intersectionObserver?.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets internal state
|
||||
*/
|
||||
private resetState(): void {
|
||||
this.mutationObserver = null;
|
||||
this.intersectionObserver = null;
|
||||
this.processedElements = new WeakSet();
|
||||
}
|
||||
}
|
||||
391
core/noteStatusService.ts
Normal file
391
core/noteStatusService.ts
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
import { App, TFile } from "obsidian";
|
||||
import {
|
||||
GroupedStatuses,
|
||||
NoteStatus,
|
||||
NoteStatus as NoteStatusType,
|
||||
} from "@/types/noteStatus";
|
||||
import { PREDEFINED_TEMPLATES } from "@/constants/defaultSettings";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import eventBus from "./eventBus";
|
||||
|
||||
export abstract class BaseNoteStatusService {
|
||||
static app: App;
|
||||
statuses: GroupedStatuses;
|
||||
|
||||
constructor() {
|
||||
this.statuses = {};
|
||||
}
|
||||
|
||||
static initialize(app: App) {
|
||||
BaseNoteStatusService.app = app;
|
||||
}
|
||||
|
||||
private static allEnabledTemplatesStatuses(): NoteStatusType[] {
|
||||
if (settingsService.settings.useCustomStatusesOnly) {
|
||||
return [];
|
||||
}
|
||||
const enabledTemplates = PREDEFINED_TEMPLATES.filter((template) =>
|
||||
settingsService.settings.enabledTemplates.includes(template.id),
|
||||
);
|
||||
return enabledTemplates.flatMap((t) => t.statuses);
|
||||
}
|
||||
|
||||
static getAllAvailableStatuses(): NoteStatus[] {
|
||||
const availableStatuses = [
|
||||
...settingsService.settings.customStatuses,
|
||||
...BaseNoteStatusService.allEnabledTemplatesStatuses(),
|
||||
];
|
||||
return availableStatuses;
|
||||
}
|
||||
|
||||
static async insertStatusMetadataInEditor(file: TFile): Promise<void> {
|
||||
const tagPrefix = settingsService.settings.tagPrefix;
|
||||
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
file,
|
||||
(frontmatter) => {
|
||||
const noteStatusFrontmatter =
|
||||
(frontmatter?.[tagPrefix] as string[]) || [];
|
||||
if (!noteStatusFrontmatter.length) {
|
||||
frontmatter[tagPrefix] = [];
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
protected statusNameToObject(statusName: NoteStatusType["name"]) {
|
||||
const availableStatuses =
|
||||
BaseNoteStatusService.getAllAvailableStatuses();
|
||||
const s = availableStatuses.find((f) => f.name === statusName);
|
||||
return s;
|
||||
}
|
||||
|
||||
protected getStatusMetadataKeys(): string[] {
|
||||
return [settingsService.settings.tagPrefix];
|
||||
}
|
||||
|
||||
abstract populateStatuses(): void;
|
||||
abstract removeStatus(
|
||||
frontmatterTagName: string,
|
||||
status: NoteStatus,
|
||||
): Promise<boolean>;
|
||||
abstract addStatus(
|
||||
frontmatterTagName: string,
|
||||
statusIdentifier: NoteStatus["name"],
|
||||
): Promise<boolean>;
|
||||
}
|
||||
|
||||
export class NoteStatusService extends BaseNoteStatusService {
|
||||
private file: TFile;
|
||||
|
||||
constructor(file: TFile) {
|
||||
super();
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
public populateStatuses(): void {
|
||||
const STATUS_METADATA_KEYS = this.getStatusMetadataKeys();
|
||||
this.statuses = Object.fromEntries(
|
||||
STATUS_METADATA_KEYS.map((key) => [key, []]),
|
||||
);
|
||||
|
||||
if (!this.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(this.file);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
if (!frontmatter) {
|
||||
return;
|
||||
}
|
||||
|
||||
STATUS_METADATA_KEYS.forEach((key) => {
|
||||
const value = frontmatter[key];
|
||||
if (value) {
|
||||
let statuses: (NoteStatusType | undefined)[] = [];
|
||||
if (Array.isArray(value)) {
|
||||
statuses = value.map((v) =>
|
||||
this.statusNameToObject(v.toString()),
|
||||
);
|
||||
} else {
|
||||
statuses = [this.statusNameToObject(value.toString())];
|
||||
}
|
||||
|
||||
statuses = statuses.filter((s) => s !== undefined);
|
||||
if (
|
||||
statuses.length &&
|
||||
!settingsService.settings.useMultipleStatuses
|
||||
) {
|
||||
statuses = statuses.slice(0, 1);
|
||||
}
|
||||
this.statuses[key] = statuses as NoteStatusType[];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async removeStatus(
|
||||
frontmatterTagName: string,
|
||||
status: NoteStatus,
|
||||
): Promise<boolean> {
|
||||
let removed = false;
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
this.file,
|
||||
(frontmatter) => {
|
||||
const noteStatusFrontmatter =
|
||||
(frontmatter?.[frontmatterTagName] as string[]) || [];
|
||||
if (!noteStatusFrontmatter.length) return;
|
||||
|
||||
if (Array.isArray(noteStatusFrontmatter)) {
|
||||
const i = noteStatusFrontmatter.findIndex(
|
||||
(statusName: string) => statusName === status.name,
|
||||
);
|
||||
if (i !== -1) {
|
||||
noteStatusFrontmatter.splice(i, 1);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (removed) {
|
||||
eventBus.publish("frontmatter-manually-changed", {
|
||||
file: this.file,
|
||||
});
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
async clearStatus(frontmatterTagName: string): Promise<boolean> {
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
this.file,
|
||||
(frontmatter) => {
|
||||
if (frontmatterTagName in frontmatter) {
|
||||
delete frontmatter[frontmatterTagName];
|
||||
}
|
||||
},
|
||||
);
|
||||
eventBus.publish("frontmatter-manually-changed", { file: this.file });
|
||||
return true;
|
||||
}
|
||||
|
||||
async overrideStatuses(
|
||||
frontmatterTagName: string,
|
||||
statusIdentifiers: NoteStatus["name"][],
|
||||
): Promise<boolean> {
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
this.file,
|
||||
(frontmatter) => {
|
||||
if (
|
||||
frontmatterTagName in frontmatter &&
|
||||
Array.isArray(frontmatter[frontmatterTagName])
|
||||
) {
|
||||
frontmatter[frontmatterTagName].splice(0);
|
||||
frontmatter[frontmatterTagName].push(...statusIdentifiers);
|
||||
}
|
||||
frontmatter[frontmatterTagName] = [...statusIdentifiers];
|
||||
},
|
||||
);
|
||||
eventBus.publish("frontmatter-manually-changed", { file: this.file });
|
||||
return true;
|
||||
}
|
||||
|
||||
async addStatus(
|
||||
frontmatterTagName: string,
|
||||
statusIdentifier: NoteStatus["name"],
|
||||
): Promise<boolean> {
|
||||
let added = false;
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
this.file,
|
||||
(frontmatter) => {
|
||||
const noteStatusFrontmatter =
|
||||
(frontmatter?.[frontmatterTagName] as string[]) || [];
|
||||
if (!settingsService.settings.useMultipleStatuses) {
|
||||
frontmatter[frontmatterTagName] = statusIdentifier;
|
||||
added = true;
|
||||
} else {
|
||||
const i = noteStatusFrontmatter.findIndex(
|
||||
(statusName: string) => statusName === statusIdentifier,
|
||||
);
|
||||
if (i === -1) {
|
||||
frontmatter[frontmatterTagName].push(statusIdentifier);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
if (added) {
|
||||
eventBus.publish("frontmatter-manually-changed", {
|
||||
file: this.file,
|
||||
});
|
||||
}
|
||||
return added;
|
||||
}
|
||||
}
|
||||
|
||||
export class MultipleNoteStatusService extends BaseNoteStatusService {
|
||||
private files: TFile[];
|
||||
|
||||
constructor(files: TFile[]) {
|
||||
super();
|
||||
this.files = files;
|
||||
}
|
||||
|
||||
selectedFilesQTY() {
|
||||
return this.files.length;
|
||||
}
|
||||
|
||||
public populateStatuses(): void {
|
||||
const STATUS_METADATA_KEYS = this.getStatusMetadataKeys();
|
||||
|
||||
this.statuses = Object.fromEntries(
|
||||
STATUS_METADATA_KEYS.map((key) => [key, []]),
|
||||
);
|
||||
|
||||
const allStatuses = new Map<string, Set<string>>();
|
||||
|
||||
this.files.forEach((file) => {
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(file);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
if (!frontmatter) return;
|
||||
|
||||
STATUS_METADATA_KEYS.forEach((key) => {
|
||||
const value = frontmatter[key];
|
||||
if (value) {
|
||||
if (!allStatuses.has(key)) {
|
||||
allStatuses.set(key, new Set());
|
||||
}
|
||||
|
||||
const statusNames = Array.isArray(value) ? value : [value];
|
||||
statusNames.forEach((name) =>
|
||||
allStatuses.get(key)!.add(name.toString()),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
STATUS_METADATA_KEYS.forEach((key) => {
|
||||
const statusNames = allStatuses.get(key);
|
||||
if (statusNames) {
|
||||
const statuses = Array.from(statusNames)
|
||||
.map((name) => this.statusNameToObject(name))
|
||||
.filter((s) => s !== undefined) as NoteStatusType[];
|
||||
|
||||
this.statuses[key] = statuses;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async removeStatus(
|
||||
frontmatterTagName: string,
|
||||
status: NoteStatus,
|
||||
): Promise<boolean> {
|
||||
let removedFromAny = false;
|
||||
|
||||
const promises = this.files.map(async (file) => {
|
||||
let removed = false;
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
file,
|
||||
(frontmatter) => {
|
||||
const noteStatusFrontmatter =
|
||||
frontmatter[frontmatterTagName];
|
||||
if (!noteStatusFrontmatter) return;
|
||||
|
||||
if (Array.isArray(noteStatusFrontmatter)) {
|
||||
const index = noteStatusFrontmatter.findIndex(
|
||||
(statusName: string) => statusName === status.name,
|
||||
);
|
||||
if (index !== -1) {
|
||||
noteStatusFrontmatter.splice(index, 1);
|
||||
removed = true;
|
||||
}
|
||||
} else if (noteStatusFrontmatter === status.name) {
|
||||
delete frontmatter[frontmatterTagName];
|
||||
removed = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
return removed;
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
removedFromAny = results.some((result) => result);
|
||||
|
||||
if (removedFromAny) {
|
||||
this.files.forEach((file) => {
|
||||
eventBus.publish("frontmatter-manually-changed", { file });
|
||||
});
|
||||
}
|
||||
|
||||
return removedFromAny;
|
||||
}
|
||||
|
||||
async addStatus(
|
||||
frontmatterTagName: string,
|
||||
statusIdentifier: NoteStatus["name"],
|
||||
): Promise<boolean> {
|
||||
let addedToAny = false;
|
||||
|
||||
const promises = this.files.map(async (file) => {
|
||||
let added = false;
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
file,
|
||||
(frontmatter) => {
|
||||
const noteStatusFrontmatter =
|
||||
frontmatter[frontmatterTagName];
|
||||
|
||||
if (!noteStatusFrontmatter) {
|
||||
frontmatter[frontmatterTagName] = [statusIdentifier];
|
||||
added = true;
|
||||
} else if (Array.isArray(noteStatusFrontmatter)) {
|
||||
if (!noteStatusFrontmatter.includes(statusIdentifier)) {
|
||||
noteStatusFrontmatter.push(statusIdentifier);
|
||||
added = true;
|
||||
}
|
||||
} else {
|
||||
if (noteStatusFrontmatter !== statusIdentifier) {
|
||||
frontmatter[frontmatterTagName] = [
|
||||
noteStatusFrontmatter,
|
||||
statusIdentifier,
|
||||
];
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
return added;
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
addedToAny = results.some((result) => result);
|
||||
|
||||
if (addedToAny) {
|
||||
this.files.forEach((file) => {
|
||||
eventBus.publish("frontmatter-manually-changed", { file });
|
||||
});
|
||||
}
|
||||
|
||||
return addedToAny;
|
||||
}
|
||||
|
||||
getFilesWithStatus(
|
||||
frontmatterTagName: string,
|
||||
status: NoteStatus,
|
||||
): TFile[] {
|
||||
return this.files.filter((file) => {
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(file);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
if (!frontmatter) return false;
|
||||
|
||||
const value = frontmatter[frontmatterTagName];
|
||||
if (!value) return false;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.includes(status.name);
|
||||
}
|
||||
return value === status.name;
|
||||
});
|
||||
}
|
||||
}
|
||||
18
core/selectorService.ts
Normal file
18
core/selectorService.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { App } from "obsidian";
|
||||
import {
|
||||
MultipleNoteStatusService,
|
||||
NoteStatusService,
|
||||
} from "./noteStatusService";
|
||||
|
||||
class SelectorService {
|
||||
static app: App;
|
||||
public noteStatusService: NoteStatusService | MultipleNoteStatusService;
|
||||
|
||||
constructor(
|
||||
noteStatusService: NoteStatusService | MultipleNoteStatusService,
|
||||
) {
|
||||
this.noteStatusService = noteStatusService;
|
||||
}
|
||||
}
|
||||
|
||||
export default SelectorService;
|
||||
45
core/settingsService.ts
Normal file
45
core/settingsService.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { DEFAULT_PLUGIN_SETTINGS } from "@/constants/defaultSettings";
|
||||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import { Plugin } from "obsidian";
|
||||
import eventBus from "./eventBus";
|
||||
|
||||
class SettingsService {
|
||||
private plugin: Plugin;
|
||||
public settings: PluginSettings;
|
||||
|
||||
constructor() {}
|
||||
|
||||
async initialize(plugin: Plugin): Promise<void> {
|
||||
this.plugin = plugin;
|
||||
await this.loadSettings();
|
||||
}
|
||||
|
||||
setValue(key: keyof PluginSettings, value: unknown) {
|
||||
if (!(key in this.settings)) {
|
||||
throw new Error(`The "${key}" setting is not a known setting key`);
|
||||
}
|
||||
this.settings[key] = value;
|
||||
this.saveSettings().catch(console.error);
|
||||
// INFO: Send the propgation event
|
||||
eventBus.publish("plugin-settings-changed", {
|
||||
key,
|
||||
value,
|
||||
currentSettings: this.settings,
|
||||
});
|
||||
}
|
||||
|
||||
private async loadSettings() {
|
||||
this.settings = Object.assign(
|
||||
{},
|
||||
DEFAULT_PLUGIN_SETTINGS,
|
||||
await this.plugin.loadData(),
|
||||
);
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
private async saveSettings() {
|
||||
await this.plugin.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
|
||||
export default new SettingsService();
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { useStatusService } from "./useStatusService";
|
||||
export { useStyleService } from "./useStyleService";
|
||||
export { useDropdownManager } from "./useDropdownManager";
|
||||
|
|
@ -1,289 +0,0 @@
|
|||
import { useState, useCallback, useRef } from "react";
|
||||
import { App, TFile, MarkdownView, Editor, Notice } from "obsidian";
|
||||
import { NoteStatusSettings } from "../models/types";
|
||||
import { StatusService } from "../services/status-service";
|
||||
import {
|
||||
DropdownOptions,
|
||||
StatusRemoveHandler,
|
||||
StatusSelectHandler,
|
||||
} from "../components/status-dropdown/types";
|
||||
import { createDummyTarget } from "../components/status-dropdown/dropdown-position";
|
||||
import { DropdownUIManager } from "../components/status-dropdown/DropdownUI";
|
||||
|
||||
interface UseDropdownManagerOptions {
|
||||
app: App;
|
||||
settings: NoteStatusSettings;
|
||||
statusService: StatusService;
|
||||
}
|
||||
|
||||
export const useDropdownManager = ({
|
||||
app,
|
||||
settings,
|
||||
statusService,
|
||||
}: UseDropdownManagerOptions) => {
|
||||
const [currentStatuses, setCurrentStatuses] = useState<string[]>([
|
||||
"unknown",
|
||||
]);
|
||||
const dropdownUIRef = useRef<DropdownUIManager | null>(null);
|
||||
|
||||
const initializeDropdownUI = useCallback(() => {
|
||||
if (!dropdownUIRef.current) {
|
||||
const deps = { app, settings, statusService };
|
||||
dropdownUIRef.current = new DropdownUIManager(deps);
|
||||
|
||||
const onRemoveStatusHandler: StatusRemoveHandler = async (
|
||||
status,
|
||||
targetFile,
|
||||
) => {
|
||||
if (!targetFile) return;
|
||||
|
||||
const isMultiple = Array.isArray(targetFile);
|
||||
|
||||
await statusService.handleStatusChange({
|
||||
files: targetFile,
|
||||
statuses: status,
|
||||
operation: "remove",
|
||||
showNotice: isMultiple,
|
||||
});
|
||||
};
|
||||
|
||||
const onSelectStatusHandler: StatusSelectHandler = async (
|
||||
status,
|
||||
targetFile,
|
||||
) => {
|
||||
const isMultipleFiles =
|
||||
Array.isArray(targetFile) && targetFile.length > 1;
|
||||
|
||||
if (isMultipleFiles) {
|
||||
const files = targetFile as TFile[];
|
||||
const filesWithStatus = files.filter((file) =>
|
||||
statusService.getFileStatuses(file).includes(status),
|
||||
);
|
||||
|
||||
const operation =
|
||||
filesWithStatus.length === files.length
|
||||
? "remove"
|
||||
: !settings.useMultipleStatuses
|
||||
? "set"
|
||||
: "add";
|
||||
|
||||
await statusService.handleStatusChange({
|
||||
files: targetFile,
|
||||
statuses: status,
|
||||
isMultipleSelection: true,
|
||||
operation: operation,
|
||||
});
|
||||
} else {
|
||||
await statusService.handleStatusChange({
|
||||
files: targetFile,
|
||||
statuses: status,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
dropdownUIRef.current.setOnRemoveStatusHandler(
|
||||
onRemoveStatusHandler,
|
||||
);
|
||||
dropdownUIRef.current.setOnSelectStatusHandler(
|
||||
onSelectStatusHandler,
|
||||
);
|
||||
}
|
||||
return dropdownUIRef.current;
|
||||
}, [app, settings, statusService]);
|
||||
|
||||
const update = useCallback(
|
||||
(newStatuses: string[] | string, _file?: TFile): void => {
|
||||
const statuses = Array.isArray(newStatuses)
|
||||
? [...newStatuses]
|
||||
: [newStatuses];
|
||||
setCurrentStatuses(statuses);
|
||||
|
||||
const dropdownUI = initializeDropdownUI();
|
||||
dropdownUI.updateStatuses(statuses);
|
||||
},
|
||||
[initializeDropdownUI],
|
||||
);
|
||||
|
||||
const updateSettings = useCallback(
|
||||
(newSettings: NoteStatusSettings): void => {
|
||||
const dropdownUI = initializeDropdownUI();
|
||||
dropdownUI.updateSettings(newSettings);
|
||||
},
|
||||
[initializeDropdownUI],
|
||||
);
|
||||
|
||||
const getCursorPosition = useCallback(
|
||||
(editor: Editor, view: MarkdownView): { x: number; y: number } => {
|
||||
try {
|
||||
const cursor = editor.getCursor("head");
|
||||
const lineElement = view.contentEl.querySelector(
|
||||
`.cm-line:nth-child(${cursor.line + 1})`,
|
||||
);
|
||||
|
||||
if (lineElement) {
|
||||
const rect = lineElement.getBoundingClientRect();
|
||||
return { x: rect.left + 20, y: rect.bottom + 5 };
|
||||
}
|
||||
|
||||
const editorEl = view.contentEl.querySelector(".cm-editor");
|
||||
if (editorEl) {
|
||||
const rect = editorEl.getBoundingClientRect();
|
||||
return { x: rect.left + 100, y: rect.top + 100 };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting position for dropdown:", error);
|
||||
}
|
||||
|
||||
return { x: window.innerWidth / 2, y: window.innerHeight / 3 };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const findCommonStatuses = useCallback(
|
||||
(files: TFile[]): string[] => {
|
||||
if (files.length === 0) return ["unknown"];
|
||||
|
||||
const firstFileStatuses = statusService.getFileStatuses(files[0]);
|
||||
|
||||
return firstFileStatuses.filter(
|
||||
(status) =>
|
||||
status !== "unknown" &&
|
||||
files.every((file) =>
|
||||
statusService.getFileStatuses(file).includes(status),
|
||||
),
|
||||
);
|
||||
},
|
||||
[statusService],
|
||||
);
|
||||
|
||||
const resetDropdown = useCallback((): void => {
|
||||
const dropdownUI = initializeDropdownUI();
|
||||
dropdownUI.close();
|
||||
dropdownUI.setTargetFile(null);
|
||||
}, [initializeDropdownUI]);
|
||||
|
||||
const openWithPosition = useCallback(
|
||||
(position: { x: number; y: number }): void => {
|
||||
const dummyTarget = createDummyTarget(position);
|
||||
const dropdownUI = initializeDropdownUI();
|
||||
dropdownUI.open(dummyTarget, position);
|
||||
|
||||
setTimeout(() => {
|
||||
if (dummyTarget.parentNode) {
|
||||
dummyTarget.parentNode.removeChild(dummyTarget);
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
[initializeDropdownUI],
|
||||
);
|
||||
|
||||
const positionAndOpenDropdown = useCallback(
|
||||
(options: {
|
||||
target?: HTMLElement;
|
||||
position?: { x: number; y: number };
|
||||
editor?: Editor;
|
||||
view?: MarkdownView;
|
||||
}): void => {
|
||||
const dropdownUI = initializeDropdownUI();
|
||||
|
||||
if (options.editor && options.view) {
|
||||
const position = getCursorPosition(
|
||||
options.editor,
|
||||
options.view,
|
||||
);
|
||||
openWithPosition(position);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.target) {
|
||||
if (options.position) {
|
||||
dropdownUI.open(options.target, options.position);
|
||||
} else {
|
||||
const rect = options.target.getBoundingClientRect();
|
||||
dropdownUI.open(options.target, {
|
||||
x: rect.left,
|
||||
y: rect.bottom + 5,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.position) {
|
||||
openWithPosition(options.position);
|
||||
return;
|
||||
}
|
||||
|
||||
openWithPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 3,
|
||||
});
|
||||
},
|
||||
[initializeDropdownUI, getCursorPosition, openWithPosition],
|
||||
);
|
||||
|
||||
const openStatusDropdown = useCallback(
|
||||
(options: DropdownOptions): void => {
|
||||
const activeFile = app.workspace.getActiveFile();
|
||||
const files = options.files || (activeFile ? [activeFile] : []);
|
||||
if (!files.length) {
|
||||
new Notice("No files selected");
|
||||
return;
|
||||
}
|
||||
if (!files.length || !files[0]) {
|
||||
new Notice("No files selected");
|
||||
return;
|
||||
}
|
||||
|
||||
const dropdownUI = initializeDropdownUI();
|
||||
|
||||
if (dropdownUI.isOpen) {
|
||||
resetDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
const isSingleFile = files.length === 1;
|
||||
|
||||
if (isSingleFile) {
|
||||
const targetFile = files[0];
|
||||
dropdownUI.setTargetFile(targetFile);
|
||||
const fileStatuses = statusService.getFileStatuses(targetFile);
|
||||
dropdownUI.updateStatuses(fileStatuses);
|
||||
} else {
|
||||
dropdownUI.setTargetFiles(files);
|
||||
const commonStatuses = findCommonStatuses(files);
|
||||
dropdownUI.updateStatuses(commonStatuses);
|
||||
}
|
||||
|
||||
positionAndOpenDropdown(options);
|
||||
},
|
||||
[
|
||||
app,
|
||||
initializeDropdownUI,
|
||||
statusService,
|
||||
resetDropdown,
|
||||
findCommonStatuses,
|
||||
positionAndOpenDropdown,
|
||||
],
|
||||
);
|
||||
|
||||
const render = useCallback((): void => {
|
||||
// No-op - dropdown component handles rendering internally
|
||||
}, []);
|
||||
|
||||
const unload = useCallback((): void => {
|
||||
if (dropdownUIRef.current) {
|
||||
dropdownUIRef.current.dispose();
|
||||
dropdownUIRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
currentStatuses,
|
||||
update,
|
||||
updateSettings,
|
||||
openStatusDropdown,
|
||||
resetDropdown,
|
||||
render,
|
||||
unload,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,405 +0,0 @@
|
|||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { App, TFile, Editor, Notice } from "obsidian";
|
||||
import { NoteStatusSettings, Status } from "../models/types";
|
||||
import { PREDEFINED_TEMPLATES } from "../constants/status-templates";
|
||||
|
||||
interface UseStatusServiceOptions {
|
||||
app: App;
|
||||
settings: NoteStatusSettings;
|
||||
}
|
||||
|
||||
export const useStatusService = ({
|
||||
app,
|
||||
settings,
|
||||
}: UseStatusServiceOptions) => {
|
||||
const [allStatuses, setAllStatuses] = useState<Status[]>([]);
|
||||
|
||||
const updateAllStatuses = useCallback(() => {
|
||||
const newStatuses = [...settings.customStatuses];
|
||||
|
||||
if (!settings.useCustomStatusesOnly) {
|
||||
const templateStatuses = settings.enabledTemplates
|
||||
.map((id) => PREDEFINED_TEMPLATES.find((t) => t.id === id))
|
||||
.filter(Boolean)
|
||||
.flatMap((template) => (template ? template.statuses : []));
|
||||
|
||||
for (const status of templateStatuses) {
|
||||
const existingIndex = newStatuses.findIndex(
|
||||
(s) => s.name.toLowerCase() === status.name.toLowerCase(),
|
||||
);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
newStatuses.push(status);
|
||||
} else if (status.color) {
|
||||
if (!settings.statusColors[status.name]) {
|
||||
settings.statusColors[status.name] = status.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAllStatuses(newStatuses);
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
updateAllStatuses();
|
||||
}, [updateAllStatuses]);
|
||||
|
||||
const getAllStatuses = useCallback(() => allStatuses, [allStatuses]);
|
||||
|
||||
const getTemplateStatuses = useCallback((): Status[] => {
|
||||
return settings.enabledTemplates
|
||||
.map((id) => PREDEFINED_TEMPLATES.find((t) => t.id === id))
|
||||
.filter(Boolean)
|
||||
.flatMap((template) => (template ? template.statuses : []));
|
||||
}, [settings.enabledTemplates]);
|
||||
|
||||
const getFileStatuses = useCallback(
|
||||
(file: TFile): string[] => {
|
||||
const cachedMetadata = app.metadataCache.getFileCache(file);
|
||||
if (!cachedMetadata?.frontmatter) return ["unknown"];
|
||||
|
||||
const frontmatterStatus =
|
||||
cachedMetadata.frontmatter[settings.tagPrefix];
|
||||
if (!frontmatterStatus) return ["unknown"];
|
||||
|
||||
const statuses: string[] = [];
|
||||
|
||||
const addValidStatus = (
|
||||
statusName: string,
|
||||
targetStatuses: string[],
|
||||
): void => {
|
||||
const normalizedStatus = statusName.toLowerCase();
|
||||
const matchingStatus = allStatuses.find(
|
||||
(s) => s.name.toLowerCase() === normalizedStatus,
|
||||
);
|
||||
|
||||
if (matchingStatus) {
|
||||
targetStatuses.push(matchingStatus.name);
|
||||
}
|
||||
};
|
||||
|
||||
if (Array.isArray(frontmatterStatus)) {
|
||||
for (const statusName of frontmatterStatus) {
|
||||
if (settings.strictStatuses) {
|
||||
addValidStatus(statusName.toString(), statuses);
|
||||
} else {
|
||||
const cleanStatus = statusName.toString().trim();
|
||||
if (cleanStatus) statuses.push(cleanStatus);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (settings.strictStatuses) {
|
||||
addValidStatus(frontmatterStatus.toString(), statuses);
|
||||
} else {
|
||||
const cleanStatus = frontmatterStatus.toString().trim();
|
||||
if (cleanStatus) statuses.push(cleanStatus);
|
||||
}
|
||||
}
|
||||
|
||||
return statuses.length > 0 ? statuses : ["unknown"];
|
||||
},
|
||||
[app, settings, allStatuses],
|
||||
);
|
||||
|
||||
const getStatusIcon = useCallback(
|
||||
(status: string): string => {
|
||||
const customStatus = allStatuses.find(
|
||||
(s) => s.name.toLowerCase() === status.toLowerCase(),
|
||||
);
|
||||
return customStatus ? customStatus.icon : "❓";
|
||||
},
|
||||
[allStatuses],
|
||||
);
|
||||
|
||||
const insertStatusMetadataInEditor = useCallback(
|
||||
(editor: Editor): void => {
|
||||
const content = editor.getValue();
|
||||
const frontMatterMatch = content.match(/^---\n([\s\S]+?)\n---/);
|
||||
|
||||
const statusTagRegex = new RegExp(
|
||||
`${settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`,
|
||||
"m",
|
||||
);
|
||||
|
||||
const insertIntoExistingFrontmatter = (
|
||||
content: string,
|
||||
frontMatterMatch: RegExpMatchArray,
|
||||
statusMetadata: string,
|
||||
): void => {
|
||||
const frontMatter = frontMatterMatch[1];
|
||||
const updatedFrontMatter = frontMatter.match(statusTagRegex)
|
||||
? frontMatter.replace(statusTagRegex, statusMetadata)
|
||||
: `${frontMatter}\n${statusMetadata}`;
|
||||
|
||||
const updatedContent = content.replace(
|
||||
/^---\n([\s\S]+?)\n---/,
|
||||
`---\n${updatedFrontMatter}\n---`,
|
||||
);
|
||||
editor.setValue(updatedContent);
|
||||
};
|
||||
|
||||
const createFrontmatterWithStatus = (
|
||||
content: string,
|
||||
statusMetadata: string,
|
||||
): void => {
|
||||
const newFrontMatter = `---\n${statusMetadata}\n---\n\n${content.trim()}`;
|
||||
editor.setValue(newFrontMatter);
|
||||
};
|
||||
|
||||
if (frontMatterMatch) {
|
||||
const frontMatter = frontMatterMatch[1];
|
||||
if (frontMatter.match(statusTagRegex)) {
|
||||
return;
|
||||
}
|
||||
const defaultStatuses = ["unknown"];
|
||||
const statusMetadata = `${settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`;
|
||||
insertIntoExistingFrontmatter(
|
||||
content,
|
||||
frontMatterMatch,
|
||||
statusMetadata,
|
||||
);
|
||||
} else {
|
||||
if (content.match(statusTagRegex)) {
|
||||
return;
|
||||
}
|
||||
const defaultStatuses = ["unknown"];
|
||||
const statusMetadata = `${settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`;
|
||||
createFrontmatterWithStatus(content, statusMetadata);
|
||||
}
|
||||
},
|
||||
[settings],
|
||||
);
|
||||
|
||||
const getMarkdownFiles = useCallback(
|
||||
(searchQuery = ""): TFile[] => {
|
||||
const files = app.vault.getMarkdownFiles();
|
||||
if (!searchQuery) return files;
|
||||
|
||||
const lowerQuery = searchQuery.toLowerCase();
|
||||
return files.filter((file) =>
|
||||
file.basename.toLowerCase().includes(lowerQuery),
|
||||
);
|
||||
},
|
||||
[app],
|
||||
);
|
||||
|
||||
const groupFilesByStatus = useCallback(
|
||||
(searchQuery = ""): Record<string, TFile[]> => {
|
||||
const statusGroups: Record<string, TFile[]> = {};
|
||||
|
||||
for (const status of allStatuses) {
|
||||
statusGroups[status.name] = [];
|
||||
}
|
||||
|
||||
statusGroups["unknown"] = statusGroups["unknown"] || [];
|
||||
|
||||
const files = getMarkdownFiles(searchQuery);
|
||||
for (const file of files) {
|
||||
const statuses = getFileStatuses(file);
|
||||
|
||||
for (const status of statuses) {
|
||||
if (statusGroups[status]) {
|
||||
statusGroups[status].push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return statusGroups;
|
||||
},
|
||||
[allStatuses, getMarkdownFiles, getFileStatuses],
|
||||
);
|
||||
|
||||
const modifyNoteStatus = useCallback(
|
||||
async (options: {
|
||||
files: TFile | TFile[];
|
||||
statuses: string | string[];
|
||||
operation: "set" | "add" | "remove" | "toggle";
|
||||
showNotice?: boolean;
|
||||
}): Promise<void> => {
|
||||
const { operation, showNotice = true } = options;
|
||||
const targetFiles = Array.isArray(options.files)
|
||||
? options.files
|
||||
: [options.files];
|
||||
const targetStatuses = Array.isArray(options.statuses)
|
||||
? options.statuses
|
||||
: [options.statuses];
|
||||
|
||||
if (targetFiles.length === 0) {
|
||||
if (showNotice) new Notice("No files selected");
|
||||
return;
|
||||
}
|
||||
|
||||
const updatePromises = targetFiles.map(async (file) => {
|
||||
if (!file || file.extension !== "md") return;
|
||||
|
||||
const currentStatuses = getFileStatuses(file);
|
||||
let newStatuses: string[] = [];
|
||||
|
||||
switch (operation) {
|
||||
case "set":
|
||||
newStatuses = [...targetStatuses];
|
||||
break;
|
||||
|
||||
case "add":
|
||||
newStatuses = [
|
||||
...new Set([
|
||||
...currentStatuses.filter(
|
||||
(s) => s !== "unknown",
|
||||
),
|
||||
...targetStatuses,
|
||||
]),
|
||||
];
|
||||
break;
|
||||
|
||||
case "remove":
|
||||
newStatuses = currentStatuses.filter(
|
||||
(status) => !targetStatuses.includes(status),
|
||||
);
|
||||
break;
|
||||
|
||||
case "toggle":
|
||||
newStatuses = [...currentStatuses];
|
||||
for (const status of targetStatuses) {
|
||||
if (currentStatuses.includes(status)) {
|
||||
newStatuses = newStatuses.filter(
|
||||
(s) => s !== status,
|
||||
);
|
||||
} else {
|
||||
newStatuses = [
|
||||
...newStatuses.filter(
|
||||
(s) => s !== "unknown",
|
||||
),
|
||||
status,
|
||||
];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (newStatuses.length === 0) {
|
||||
newStatuses = ["unknown"];
|
||||
}
|
||||
|
||||
await app.fileManager.processFrontMatter(
|
||||
file,
|
||||
(frontmatter) => {
|
||||
frontmatter[settings.tagPrefix] = newStatuses;
|
||||
},
|
||||
);
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("note-status:status-changed", {
|
||||
detail: {
|
||||
statuses: newStatuses,
|
||||
file: file?.path,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
if (showNotice && targetFiles.length > 1) {
|
||||
const statusText =
|
||||
targetStatuses.length === 1
|
||||
? targetStatuses[0]
|
||||
: `${targetStatuses.length} statuses`;
|
||||
const operationText =
|
||||
operation === "set"
|
||||
? "updated"
|
||||
: operation === "add"
|
||||
? "added to"
|
||||
: operation === "remove"
|
||||
? "removed from"
|
||||
: "toggled on";
|
||||
|
||||
new Notice(
|
||||
`${statusText} ${operationText} ${targetFiles.length} files`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[app, settings, getFileStatuses],
|
||||
);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
async (options: {
|
||||
files: TFile | TFile[];
|
||||
statuses: string | string[];
|
||||
isMultipleSelection?: boolean;
|
||||
allowMultipleStatuses?: boolean;
|
||||
operation?: "set" | "add" | "remove" | "toggle";
|
||||
showNotice?: boolean;
|
||||
afterChange?: (updatedStatuses: string[]) => void;
|
||||
}): Promise<void> => {
|
||||
const {
|
||||
files,
|
||||
statuses,
|
||||
isMultipleSelection = false,
|
||||
allowMultipleStatuses = settings.useMultipleStatuses,
|
||||
operation: explicitOperation,
|
||||
showNotice = isMultipleSelection,
|
||||
} = options;
|
||||
|
||||
const targetFiles = Array.isArray(files) ? files : [files];
|
||||
const targetStatuses = Array.isArray(statuses)
|
||||
? statuses
|
||||
: [statuses];
|
||||
|
||||
let operation: "set" | "add" | "remove" | "toggle";
|
||||
|
||||
if (explicitOperation) {
|
||||
operation = explicitOperation;
|
||||
} else if (isMultipleSelection) {
|
||||
const firstStatus = targetStatuses[0];
|
||||
const filesWithStatus = targetFiles.filter((file) =>
|
||||
getFileStatuses(file).includes(firstStatus),
|
||||
);
|
||||
|
||||
operation =
|
||||
filesWithStatus.length > targetFiles.length / 2
|
||||
? "remove"
|
||||
: "add";
|
||||
} else {
|
||||
if (allowMultipleStatuses) {
|
||||
operation = "toggle";
|
||||
} else {
|
||||
operation = "set";
|
||||
}
|
||||
}
|
||||
|
||||
await modifyNoteStatus({
|
||||
files: targetFiles,
|
||||
statuses: targetStatuses,
|
||||
operation,
|
||||
showNotice,
|
||||
});
|
||||
},
|
||||
[settings, getFileStatuses, modifyNoteStatus],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
getAllStatuses,
|
||||
getTemplateStatuses,
|
||||
getFileStatuses,
|
||||
getStatusIcon,
|
||||
insertStatusMetadataInEditor,
|
||||
getMarkdownFiles,
|
||||
groupFilesByStatus,
|
||||
handleStatusChange,
|
||||
updateAllStatuses,
|
||||
}),
|
||||
[
|
||||
getAllStatuses,
|
||||
getTemplateStatuses,
|
||||
getFileStatuses,
|
||||
getStatusIcon,
|
||||
insertStatusMetadataInEditor,
|
||||
getMarkdownFiles,
|
||||
groupFilesByStatus,
|
||||
handleStatusChange,
|
||||
updateAllStatuses,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { NoteStatusSettings } from "../models/types";
|
||||
import { PREDEFINED_TEMPLATES } from "../constants/status-templates";
|
||||
|
||||
interface UseStyleServiceOptions {
|
||||
settings: NoteStatusSettings;
|
||||
}
|
||||
|
||||
export const useStyleService = ({ settings }: UseStyleServiceOptions) => {
|
||||
const dynamicStyleElRef = useRef<HTMLStyleElement | null>(null);
|
||||
|
||||
const getAllStatusColors = useCallback((): Record<string, string> => {
|
||||
const colors = { ...settings.statusColors };
|
||||
|
||||
if (settings.useCustomStatusesOnly) return colors;
|
||||
|
||||
for (const templateId of settings.enabledTemplates) {
|
||||
const template = PREDEFINED_TEMPLATES.find(
|
||||
(t) => t.id === templateId,
|
||||
);
|
||||
if (!template) continue;
|
||||
|
||||
for (const status of template.statuses) {
|
||||
if (status.color && !colors[status.name]) {
|
||||
colors[status.name] = status.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return colors;
|
||||
}, [settings]);
|
||||
|
||||
const generateColorCssRules = useCallback(
|
||||
(colors: Record<string, string>): string => {
|
||||
let css = "";
|
||||
|
||||
for (const [status, color] of Object.entries(colors)) {
|
||||
css += `
|
||||
.status-${status} {
|
||||
color: ${color} !important;
|
||||
}
|
||||
.note-status-bar .note-status-${status},
|
||||
.nav-file-title .note-status-${status} {
|
||||
color: ${color} !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
return css;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateDynamicStyles = useCallback((): void => {
|
||||
if (!dynamicStyleElRef.current) {
|
||||
dynamicStyleElRef.current = document.createElement("style");
|
||||
document.head.appendChild(dynamicStyleElRef.current);
|
||||
}
|
||||
|
||||
const allColors = getAllStatusColors();
|
||||
const cssRules = generateColorCssRules(allColors);
|
||||
dynamicStyleElRef.current.textContent = cssRules;
|
||||
}, [getAllStatusColors, generateColorCssRules]);
|
||||
|
||||
const unload = useCallback((): void => {
|
||||
if (dynamicStyleElRef.current) {
|
||||
dynamicStyleElRef.current.remove();
|
||||
dynamicStyleElRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateDynamicStyles();
|
||||
|
||||
return () => {
|
||||
unload();
|
||||
};
|
||||
}, [updateDynamicStyles, unload]);
|
||||
|
||||
useEffect(() => {
|
||||
updateDynamicStyles();
|
||||
}, [settings, updateDynamicStyles]);
|
||||
|
||||
return {
|
||||
updateDynamicStyles,
|
||||
unload,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,323 +0,0 @@
|
|||
// integrations/commands/command-integration.ts
|
||||
import { App, Editor, MarkdownView, Notice, TFile } from "obsidian";
|
||||
import { NoteStatusSettings } from "../../models/types";
|
||||
import { StatusService } from "services/status-service";
|
||||
import { StatusDropdown } from "components/status-dropdown";
|
||||
import { StatusPaneViewController } from "views/status-pane-view";
|
||||
import NoteStatus from "main";
|
||||
|
||||
export class CommandIntegration {
|
||||
private app: App;
|
||||
private plugin: NoteStatus;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private statusDropdown: StatusDropdown;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: NoteStatus,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
statusDropdown: StatusDropdown,
|
||||
) {
|
||||
this.app = app;
|
||||
this.plugin = plugin;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.statusDropdown = statusDropdown;
|
||||
}
|
||||
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
// Re-register commands when settings change
|
||||
this.unload();
|
||||
this.registerCommands();
|
||||
}
|
||||
|
||||
public registerCommands(): void {
|
||||
// Open status pane
|
||||
this.plugin.addCommand({
|
||||
id: "open-status-pane",
|
||||
name: "Open status pane",
|
||||
callback: () => StatusPaneViewController.open(this.app),
|
||||
});
|
||||
|
||||
// Change status of current note
|
||||
this.plugin.addCommand({
|
||||
id: "change-status",
|
||||
name: "Change status of current note",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.statusDropdown.openStatusDropdown({ files: [file] });
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Add status to current note
|
||||
this.plugin.addCommand({
|
||||
id: "add-status",
|
||||
name: "Add status to current note",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.statusDropdown.openStatusDropdown({
|
||||
files: [file],
|
||||
mode: this.settings.useMultipleStatuses
|
||||
? "add"
|
||||
: "replace",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Insert status metadata
|
||||
this.plugin.addCommand({
|
||||
id: "insert-status-metadata",
|
||||
name: "Insert status metadata",
|
||||
editorCheckCallback: (
|
||||
checking: boolean,
|
||||
editor: Editor,
|
||||
view: MarkdownView,
|
||||
) => {
|
||||
if (!view.file) return false;
|
||||
|
||||
const statuses = this.statusService.getFileStatuses(view.file);
|
||||
const hasNoStatus =
|
||||
statuses.length === 1 && statuses[0] === "unknown";
|
||||
|
||||
if (!checking && hasNoStatus) {
|
||||
this.statusService.insertStatusMetadataInEditor(editor);
|
||||
new Notice("Status metadata inserted");
|
||||
}
|
||||
return hasNoStatus;
|
||||
},
|
||||
});
|
||||
|
||||
// Cycle through statuses
|
||||
this.plugin.addCommand({
|
||||
id: "cycle-status",
|
||||
name: "Cycle to next status",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.cycleStatus(file);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Register dynamic quick status commands
|
||||
this.registerQuickStatusCommands();
|
||||
|
||||
// Clear status
|
||||
this.plugin.addCommand({
|
||||
id: "clear-status",
|
||||
name: "Clear status (set to unknown)",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.statusService.handleStatusChange({
|
||||
files: file,
|
||||
statuses: "unknown",
|
||||
operation: "set",
|
||||
});
|
||||
new Notice("Status cleared");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Copy status from current note
|
||||
this.plugin.addCommand({
|
||||
id: "copy-status",
|
||||
name: "Copy status from current note",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
const statuses = this.statusService.getFileStatuses(file);
|
||||
this.app.clipboard = statuses;
|
||||
new Notice(`Copied status: ${statuses.join(", ")}`);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Paste status to current note
|
||||
this.plugin.addCommand({
|
||||
id: "paste-status",
|
||||
name: "Paste status to current note",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
const clipboard = this.app.clipboard;
|
||||
if (!file || !clipboard || !Array.isArray(clipboard))
|
||||
return false;
|
||||
|
||||
if (!checking) {
|
||||
this.statusService.handleStatusChange({
|
||||
files: file,
|
||||
statuses: clipboard,
|
||||
operation: "set",
|
||||
});
|
||||
new Notice(`Pasted status: ${clipboard.join(", ")}`);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Toggle multiple statuses mode
|
||||
this.plugin.addCommand({
|
||||
id: "toggle-multiple-statuses",
|
||||
name: "Toggle multiple statuses mode",
|
||||
callback: () => {
|
||||
this.settings.useMultipleStatuses =
|
||||
!this.settings.useMultipleStatuses;
|
||||
this.plugin.saveSettings();
|
||||
new Notice(
|
||||
`Multiple statuses mode ${this.settings.useMultipleStatuses ? "enabled" : "disabled"}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Search notes by status
|
||||
this.plugin.addCommand({
|
||||
id: "search-by-status",
|
||||
name: "Search notes by current status",
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
const statuses = this.statusService.getFileStatuses(file);
|
||||
const query = `[${this.settings.tagPrefix}:"${statuses[0]}"]`;
|
||||
this.app.internalPlugins
|
||||
.getPluginById("global-search")
|
||||
?.instance.openGlobalSearch(query);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register quick status commands based on settings
|
||||
*/
|
||||
private registerQuickStatusCommands(): void {
|
||||
const quickCommands = this.settings.quickStatusCommands || [];
|
||||
const allStatuses = this.statusService.getAllStatuses();
|
||||
|
||||
quickCommands.forEach((statusName) => {
|
||||
const status = allStatuses.find((s) => s.name === statusName);
|
||||
if (!status) return;
|
||||
|
||||
this.plugin.addCommand({
|
||||
id: `set-status-${statusName}`,
|
||||
name: `Set status to ${statusName}`,
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.statusService.handleStatusChange({
|
||||
files: file,
|
||||
statuses: statusName,
|
||||
operation: "set",
|
||||
});
|
||||
new Notice(`Status set to ${statusName}`);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Add toggle command for multiple status mode
|
||||
if (this.settings.useMultipleStatuses) {
|
||||
this.plugin.addCommand({
|
||||
id: `toggle-status-${statusName}`,
|
||||
name: `Toggle status ${statusName}`,
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.statusService.handleStatusChange({
|
||||
files: file,
|
||||
statuses: statusName,
|
||||
operation: "toggle",
|
||||
});
|
||||
const currentStatuses =
|
||||
this.statusService.getFileStatuses(file);
|
||||
const hasStatus =
|
||||
currentStatuses.includes(statusName);
|
||||
new Notice(
|
||||
`Status ${statusName} ${hasStatus ? "added" : "removed"}`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private cycleStatus(file: TFile): void {
|
||||
const allStatuses = this.statusService
|
||||
.getAllStatuses()
|
||||
.filter((s) => s.name !== "unknown")
|
||||
.map((s) => s.name);
|
||||
|
||||
if (allStatuses.length === 0) {
|
||||
new Notice("No statuses available");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStatuses = this.statusService.getFileStatuses(file);
|
||||
const currentStatus = currentStatuses[0];
|
||||
|
||||
let nextIndex = 0;
|
||||
if (currentStatus !== "unknown") {
|
||||
const currentIndex = allStatuses.indexOf(currentStatus);
|
||||
nextIndex = (currentIndex + 1) % allStatuses.length;
|
||||
}
|
||||
|
||||
this.statusService.handleStatusChange({
|
||||
files: file,
|
||||
statuses: allStatuses[nextIndex],
|
||||
operation: "set",
|
||||
});
|
||||
|
||||
new Notice(`Status changed to ${allStatuses[nextIndex]}`);
|
||||
}
|
||||
|
||||
public unload(): void {
|
||||
// Remove existing commands
|
||||
this.removeQuickStatusCommands();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all quick status commands
|
||||
*/
|
||||
private removeQuickStatusCommands(): void {
|
||||
const allStatuses = this.statusService.getAllStatuses();
|
||||
|
||||
allStatuses.forEach((status) => {
|
||||
this.app.commands.removeCommand(
|
||||
`note-status:set-status-${status.name}`,
|
||||
);
|
||||
this.app.commands.removeCommand(
|
||||
`note-status:toggle-status-${status.name}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
36
integrations/commands/commandsIntegration.ts
Normal file
36
integrations/commands/commandsIntegration.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Plugin } from "obsidian";
|
||||
import { CommandsService } from "../../core/commandsService";
|
||||
|
||||
export class CommandsIntegration {
|
||||
private static instance: CommandsIntegration | null = null;
|
||||
private plugin: Plugin;
|
||||
private commandsService: CommandsService;
|
||||
|
||||
constructor(plugin: Plugin) {
|
||||
if (CommandsIntegration.instance) {
|
||||
throw new Error(
|
||||
"CommandsIntegration instance already created. Use getInstance() instead.",
|
||||
);
|
||||
}
|
||||
this.plugin = plugin;
|
||||
this.commandsService = new CommandsService(plugin);
|
||||
CommandsIntegration.instance = this;
|
||||
}
|
||||
|
||||
static getInstance(): CommandsIntegration | null {
|
||||
return CommandsIntegration.instance;
|
||||
}
|
||||
|
||||
async integrate(): Promise<void> {
|
||||
// Register all commands from the service
|
||||
this.commandsService.registerAllCommands();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.commandsService) {
|
||||
this.commandsService.unload();
|
||||
this.commandsService.destroy();
|
||||
}
|
||||
CommandsIntegration.instance = null;
|
||||
}
|
||||
}
|
||||
127
integrations/context-menu/contextMenuIntegration.tsx
Normal file
127
integrations/context-menu/contextMenuIntegration.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import eventBus from "core/eventBus";
|
||||
import {
|
||||
Menu,
|
||||
Notice,
|
||||
Plugin,
|
||||
TAbstractFile,
|
||||
TFile,
|
||||
WorkspaceLeaf,
|
||||
} from "obsidian";
|
||||
import {
|
||||
MultipleNoteStatusService,
|
||||
NoteStatusService,
|
||||
} from "@/core/noteStatusService";
|
||||
|
||||
export class ContextMenuIntegration {
|
||||
private static instance: ContextMenuIntegration | null = null;
|
||||
private plugin: Plugin;
|
||||
private currentLeaf: WorkspaceLeaf | null = null;
|
||||
private noteStatusService:
|
||||
| NoteStatusService
|
||||
| MultipleNoteStatusService
|
||||
| null = null;
|
||||
|
||||
constructor(plugin: Plugin) {
|
||||
if (ContextMenuIntegration.instance) {
|
||||
throw new Error("The context menu instance is already created");
|
||||
}
|
||||
this.plugin = plugin;
|
||||
ContextMenuIntegration.instance = this;
|
||||
}
|
||||
|
||||
async integrate() {
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.workspace.on(
|
||||
"files-menu",
|
||||
(
|
||||
menu: Menu,
|
||||
files: TAbstractFile[],
|
||||
source: string,
|
||||
leaf?: WorkspaceLeaf,
|
||||
) => {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Print file path 👈")
|
||||
.setIcon("document")
|
||||
.onClick(async () => {
|
||||
const tFiles = files.filter(
|
||||
(f): f is TFile => f instanceof TFile,
|
||||
);
|
||||
if (tFiles.length) {
|
||||
this.openMultipleFilesStatusesModal(tFiles);
|
||||
} else {
|
||||
new Notice(
|
||||
"The selected files are not valid to add status, just .md files can have status in this plugin version",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.workspace.on("file-menu", (menu, file, f, f2) => {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Print file path 👈")
|
||||
.setIcon("document")
|
||||
.onClick(async () => {
|
||||
if (file instanceof TFile) {
|
||||
this.openSingleFileStatusesModal(file);
|
||||
} else {
|
||||
new Notice(
|
||||
"The selected file is not valid to add status, just .md files can have status in this plugin version",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.workspace.on(
|
||||
"editor-menu",
|
||||
(menu, editor, view) => {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Print file path 👈")
|
||||
.setIcon("document")
|
||||
.onClick(async () => {
|
||||
if (view.file) {
|
||||
if (view.file instanceof TFile) {
|
||||
this.openSingleFileStatusesModal(
|
||||
view.file,
|
||||
);
|
||||
} else {
|
||||
new Notice(
|
||||
"The selected file is not valid to add status, just .md files can have status in this plugin version",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private openMultipleFilesStatusesModal(tFiles: TFile[]) {
|
||||
const multipleStatusService = new MultipleNoteStatusService(tFiles);
|
||||
multipleStatusService.populateStatuses();
|
||||
eventBus.publish("triggered-open-modal", {
|
||||
statusService: multipleStatusService,
|
||||
});
|
||||
}
|
||||
private openSingleFileStatusesModal(tFile: TFile) {
|
||||
const noteStatusService = new NoteStatusService(tFile);
|
||||
noteStatusService.populateStatuses();
|
||||
eventBus.publish("triggered-open-modal", {
|
||||
statusService: noteStatusService,
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.currentLeaf = null;
|
||||
this.noteStatusService = null;
|
||||
ContextMenuIntegration.instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default ContextMenuIntegration;
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
import { App, Menu, TFile } from "obsidian";
|
||||
import { NoteStatusSettings } from "models/types";
|
||||
import { StatusService } from "services/status-service";
|
||||
import { ExplorerIntegration } from "integrations/explorer/explorer-integration";
|
||||
import { StatusContextMenu } from "integrations/context-menu/status-context-menu";
|
||||
|
||||
/**
|
||||
* Gestiona la integración de menús contextuales con el explorador de archivos
|
||||
*/
|
||||
export class FileContextMenuIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private explorerIntegration: ExplorerIntegration;
|
||||
private statusContextMenu: StatusContextMenu;
|
||||
private fileMenuEventRef: (menu: Menu, file: TFile, source: string) => void;
|
||||
private filesMenuEventRef: (menu: Menu, files: TFile[]) => void;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
explorerIntegration: ExplorerIntegration,
|
||||
statusContextMenu: StatusContextMenu,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.explorerIntegration = explorerIntegration;
|
||||
this.statusContextMenu = statusContextMenu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza la configuración
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra los eventos del menú contextual de archivos
|
||||
*/
|
||||
public registerFileContextMenuEvents(): void {
|
||||
this.fileMenuEventRef = (menu, file, source) => {
|
||||
if (
|
||||
source === "file-explorer-context-menu" &&
|
||||
file instanceof TFile &&
|
||||
file.extension === "md"
|
||||
) {
|
||||
this.addStatusChangeMenu(menu, file);
|
||||
}
|
||||
};
|
||||
|
||||
this.filesMenuEventRef = (menu, files) => {
|
||||
const mdFiles = files.filter(
|
||||
(file) => file instanceof TFile && file.extension === "md",
|
||||
) as TFile[];
|
||||
|
||||
if (mdFiles.length > 0) {
|
||||
this.addBatchStatusChangeMenu(menu, mdFiles);
|
||||
}
|
||||
};
|
||||
|
||||
this.app.workspace.on("file-menu", this.fileMenuEventRef);
|
||||
this.app.workspace.on("files-menu", this.filesMenuEventRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Añade opción de cambio de estado al menú contextual de un archivo
|
||||
*/
|
||||
private addStatusChangeMenu(menu: Menu, file: TFile): void {
|
||||
this.statusContextMenu.addStatusMenuItemToSingleFile(
|
||||
menu,
|
||||
file,
|
||||
(file) => {
|
||||
const selectedFiles =
|
||||
this.explorerIntegration.getSelectedFiles();
|
||||
if (selectedFiles.length > 1) {
|
||||
this.statusContextMenu.showForFiles(selectedFiles);
|
||||
} else {
|
||||
this.statusContextMenu.showForFiles([file]);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Añade opción de cambio de estado para múltiples archivos
|
||||
*/
|
||||
private addBatchStatusChangeMenu(menu: Menu, files: TFile[]): void {
|
||||
this.statusContextMenu.addStatusMenuItemToBatch(
|
||||
menu,
|
||||
files,
|
||||
(files) => {
|
||||
this.statusContextMenu.showForFiles(files);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public unload(): void {
|
||||
if (this.fileMenuEventRef) {
|
||||
this.app.workspace.off("file-menu", this.fileMenuEventRef);
|
||||
}
|
||||
if (this.filesMenuEventRef) {
|
||||
this.app.workspace.off("files-menu", this.filesMenuEventRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
import { App, Menu, TFile } from "obsidian";
|
||||
import { NoteStatusSettings } from "models/types";
|
||||
import { StatusService } from "services/status-service";
|
||||
import { StatusDropdown } from "components/status-dropdown";
|
||||
import { ExplorerIntegration } from "integrations/explorer/explorer-integration";
|
||||
|
||||
/**
|
||||
* Gestiona los menús contextuales para cambios de estado
|
||||
*/
|
||||
export class StatusContextMenu {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private statusDropdown: StatusDropdown;
|
||||
private explorerIntegration: ExplorerIntegration;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
statusDropdown: StatusDropdown,
|
||||
explorerIntegration: ExplorerIntegration,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.statusDropdown = statusDropdown;
|
||||
this.explorerIntegration = explorerIntegration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza la configuración
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Añade ítem de menú para cambiar estado de un archivo
|
||||
*/
|
||||
public addStatusMenuItemToSingleFile(
|
||||
menu: Menu,
|
||||
file: TFile,
|
||||
onClick: (file: TFile) => void,
|
||||
): void {
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle("Change status")
|
||||
.setIcon("tag")
|
||||
.onClick(() => onClick(file)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Añade ítem de menú para cambiar estado de múltiples archivos
|
||||
*/
|
||||
public addStatusMenuItemToBatch(
|
||||
menu: Menu,
|
||||
files: TFile[],
|
||||
onClick: (files: TFile[]) => void,
|
||||
): void {
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle("Change status")
|
||||
.setIcon("tag")
|
||||
.onClick(() => onClick(files)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Muestra el menú contextual para cambiar estado de uno o más archivos
|
||||
* @param files Archivos a los que cambiar el estado
|
||||
* @param position Posición opcional para mostrar el menú
|
||||
*/
|
||||
public showForFiles(
|
||||
files: TFile[],
|
||||
position?: { x: number; y: number },
|
||||
): void {
|
||||
if (files.length === 0) return;
|
||||
|
||||
if (files.length === 1) {
|
||||
this.showForSingleFile(files[0], position);
|
||||
} else {
|
||||
this.showForMultipleFiles(files, position);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Muestra el menú contextual para un solo archivo
|
||||
* @param file Archivo al que cambiar el estado
|
||||
* @param position Posición opcional para mostrar el menú
|
||||
*/
|
||||
private showForSingleFile(
|
||||
file: TFile,
|
||||
position?: { x: number; y: number },
|
||||
): void {
|
||||
if (!(file instanceof TFile) || file.extension !== "md") return;
|
||||
|
||||
this.statusDropdown.openStatusDropdown({
|
||||
position,
|
||||
files: [file],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Muestra el menú contextual para múltiples archivos
|
||||
* @param files Archivos a los que cambiar el estado
|
||||
* @param position Posición opcional para mostrar el menú
|
||||
*/
|
||||
private showForMultipleFiles(
|
||||
files: TFile[],
|
||||
position?: { x: number; y: number },
|
||||
): void {
|
||||
const menu = new Menu();
|
||||
|
||||
// Elemento de información (deshabilitado)
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(`Update ${files.length} files`).setDisabled(true);
|
||||
return item;
|
||||
});
|
||||
|
||||
// Opción para gestionar estados
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle("Manage statuses...")
|
||||
.setIcon("tag")
|
||||
.onClick(() => {
|
||||
this.statusDropdown.openStatusDropdown({
|
||||
position,
|
||||
files,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Mostrar el menú en la posición adecuada
|
||||
if (position) {
|
||||
menu.showAtPosition(position);
|
||||
} else {
|
||||
// Use a centered position
|
||||
menu.showAtPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 3,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { App, Editor, MarkdownView, Menu } from "obsidian";
|
||||
import { NoteStatusSettings } from "../../models/types";
|
||||
import { StatusService } from "services/status-service";
|
||||
import { StatusDropdown } from "components/status-dropdown";
|
||||
|
||||
export class EditorIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private statusDropdown: StatusDropdown;
|
||||
private editorMenuRef: (
|
||||
menu: Menu,
|
||||
editor: Editor,
|
||||
view: MarkdownView,
|
||||
) => void;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
statusDropdown: StatusDropdown,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.statusDropdown = statusDropdown;
|
||||
}
|
||||
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public registerEditorMenus(): void {
|
||||
this.editorMenuRef = (
|
||||
menu: Menu,
|
||||
editor: Editor,
|
||||
view: MarkdownView,
|
||||
) => {
|
||||
if (view.file) {
|
||||
this.addStatusMenuItems(menu, editor, view);
|
||||
}
|
||||
};
|
||||
|
||||
this.app.workspace.on("editor-menu", this.editorMenuRef);
|
||||
}
|
||||
|
||||
private addStatusMenuItems(
|
||||
menu: Menu,
|
||||
editor: Editor,
|
||||
view: MarkdownView,
|
||||
): void {
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle("Change note status")
|
||||
.setIcon("tag")
|
||||
.onClick(() => {
|
||||
if (view.file) {
|
||||
this.statusDropdown.openStatusDropdown({
|
||||
files: [view.file],
|
||||
editor,
|
||||
view,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Only show insert metadata if it doesn't exist
|
||||
if (view.file) {
|
||||
const statuses = this.statusService.getFileStatuses(view.file);
|
||||
if (statuses.length === 1 && statuses[0] === "unknown") {
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle("Insert status metadata")
|
||||
.setIcon("plus-circle")
|
||||
.onClick(() => {
|
||||
this.insertStatusMetadata(editor);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public insertStatusMetadata(editor: Editor): void {
|
||||
this.statusService.insertStatusMetadataInEditor(editor);
|
||||
}
|
||||
|
||||
public unload(): void {
|
||||
if (this.editorMenuRef) {
|
||||
this.app.workspace.off("editor-menu", this.editorMenuRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { EditorIntegration } from "./editor-integration";
|
||||
export { ToolbarIntegration } from "./toolbar-integration";
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
import { App, MarkdownView } from "obsidian";
|
||||
import { NoteStatusSettings } from "../../models/types";
|
||||
import { StatusService } from "../../services/status-service";
|
||||
import { StatusDropdown } from "../../components/status-dropdown";
|
||||
import { ToolbarButtonManager } from "components/ToolbarButton";
|
||||
|
||||
/**
|
||||
* Gestiona la integración con la barra de herramientas del editor
|
||||
*/
|
||||
export class ToolbarIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private statusDropdown: StatusDropdown;
|
||||
private buttonView: ToolbarButtonManager;
|
||||
private buttonElement: HTMLElement | null = null;
|
||||
private currentLeafId: string | null = null;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
statusDropdown: StatusDropdown,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.statusDropdown = statusDropdown;
|
||||
this.buttonView = new ToolbarButtonManager(settings, statusService);
|
||||
}
|
||||
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.buttonView.updateSettings(settings);
|
||||
this.updateStatusDisplay([]);
|
||||
}
|
||||
|
||||
public addToolbarButtonToActiveLeaf(statuses?: string[]): void {
|
||||
const activeLeaf = this.app.workspace.activeLeaf;
|
||||
if (!activeLeaf?.view || !(activeLeaf.view instanceof MarkdownView))
|
||||
return;
|
||||
|
||||
const leafId = activeLeaf.id || activeLeaf.view.containerEl.id;
|
||||
|
||||
// Only recreate button if we're on a different leaf or button doesn't exist
|
||||
if (
|
||||
this.currentLeafId !== leafId ||
|
||||
!this.buttonElement ||
|
||||
!this.isButtonInDOM()
|
||||
) {
|
||||
this.recreateButton(activeLeaf.view, leafId);
|
||||
}
|
||||
|
||||
this.updateButtonDisplay(statuses);
|
||||
}
|
||||
|
||||
private recreateButton(view: MarkdownView, leafId: string): void {
|
||||
const toolbarContainer = view.containerEl.querySelector(
|
||||
".view-header .view-actions",
|
||||
);
|
||||
if (!toolbarContainer) return;
|
||||
|
||||
// Remove old button if it exists
|
||||
this.removeToolbarButton();
|
||||
|
||||
// Create new button
|
||||
this.buttonElement = this.buttonView.createElement();
|
||||
this.buttonElement.addEventListener(
|
||||
"click",
|
||||
this.handleButtonClick.bind(this),
|
||||
);
|
||||
|
||||
if (toolbarContainer.firstChild) {
|
||||
toolbarContainer.insertBefore(
|
||||
this.buttonElement,
|
||||
toolbarContainer.firstChild,
|
||||
);
|
||||
} else {
|
||||
toolbarContainer.appendChild(this.buttonElement);
|
||||
}
|
||||
|
||||
this.currentLeafId = leafId;
|
||||
}
|
||||
|
||||
private isButtonInDOM(): boolean {
|
||||
return this.buttonElement?.isConnected === true;
|
||||
}
|
||||
|
||||
private removeToolbarButton(): void {
|
||||
this.app.workspace.iterateAllLeaves((leaf) => {
|
||||
if (leaf.view instanceof MarkdownView) {
|
||||
const buttons = leaf.view.containerEl.querySelectorAll(
|
||||
".note-status-toolbar-button",
|
||||
);
|
||||
buttons.forEach((button) => button.remove());
|
||||
}
|
||||
});
|
||||
|
||||
this.buttonElement = null;
|
||||
this.currentLeafId = null;
|
||||
}
|
||||
|
||||
private updateButtonDisplay(overrideStatuses?: string[]): void {
|
||||
if (!this.buttonElement) return;
|
||||
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) return;
|
||||
|
||||
const statuses = overrideStatuses?.length
|
||||
? overrideStatuses
|
||||
: this.statusService.getFileStatuses(activeFile);
|
||||
this.buttonView.updateDisplay(statuses);
|
||||
}
|
||||
|
||||
private handleButtonClick(e: MouseEvent): void {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) return;
|
||||
|
||||
this.statusDropdown.openStatusDropdown({
|
||||
target: this.buttonElement || undefined,
|
||||
files: [activeFile],
|
||||
});
|
||||
}
|
||||
|
||||
public updateStatusDisplay(statuses: string[]): void {
|
||||
this.updateButtonDisplay(statuses);
|
||||
}
|
||||
|
||||
public unload(): void {
|
||||
// this.buttonView.destroy();
|
||||
this.removeToolbarButton();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,432 +0,0 @@
|
|||
import { App, TFile, setTooltip, debounce } from "obsidian";
|
||||
import { NoteStatusSettings, FileExplorerView } from "../../models/types";
|
||||
import { StatusService } from "services/status-service";
|
||||
|
||||
/**
|
||||
* Manages the logic for file explorer status integration
|
||||
*/
|
||||
export class ExplorerIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private ui: ExplorerIntegrationUI;
|
||||
private iconUpdateQueue = new Set<string>();
|
||||
private isProcessingQueue = false;
|
||||
private debouncedUpdateAll: ReturnType<typeof debounce>;
|
||||
private fileItemsWasUndefined = false;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.ui = new ExplorerIntegrationUI(
|
||||
app,
|
||||
settings,
|
||||
statusService,
|
||||
() => {
|
||||
this.fileItemsWasUndefined = true;
|
||||
},
|
||||
);
|
||||
this.debouncedUpdateAll = debounce(
|
||||
this.processUpdateQueue.bind(this),
|
||||
100,
|
||||
true,
|
||||
);
|
||||
|
||||
// Listen for layout changes to detect when file explorer becomes available
|
||||
this.app.workspace.on("layout-change", () => {
|
||||
this.checkFileExplorerAvailability();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file explorer became available after being unavailable
|
||||
*/
|
||||
private checkFileExplorerAvailability(): void {
|
||||
if (!this.settings.showStatusIconsInExplorer) return;
|
||||
|
||||
const fileExplorer = this.ui.findFileExplorerView();
|
||||
if (fileExplorer?.fileItems && this.fileItemsWasUndefined) {
|
||||
this.fileItemsWasUndefined = false;
|
||||
setTimeout(() => this.updateAllFileExplorerIcons(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates settings and refreshes UI if necessary
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
const shouldRefreshIcons =
|
||||
this.settings.showStatusIconsInExplorer !==
|
||||
settings.showStatusIconsInExplorer ||
|
||||
this.settings.hideUnknownStatusInExplorer !==
|
||||
settings.hideUnknownStatusInExplorer;
|
||||
|
||||
this.settings = { ...settings };
|
||||
this.ui.updateSettings(settings);
|
||||
|
||||
if (shouldRefreshIcons) {
|
||||
this.ui.removeAllFileExplorerIcons();
|
||||
|
||||
if (settings.showStatusIconsInExplorer) {
|
||||
setTimeout(() => this.updateAllFileExplorerIcons(), 50);
|
||||
}
|
||||
} else if (settings.showStatusIconsInExplorer) {
|
||||
this.updateAllFileExplorerIcons();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates icons for a specific file
|
||||
*/
|
||||
public updateFileExplorerIcons(file: TFile): void {
|
||||
if (
|
||||
!file ||
|
||||
!this.settings.showStatusIconsInExplorer ||
|
||||
file.extension !== "md"
|
||||
)
|
||||
return;
|
||||
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (activeFile?.path === file.path) {
|
||||
this.ui.updateSingleFileIconDirectly(file, this.statusService);
|
||||
}
|
||||
|
||||
this.queueFileUpdate(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates all icons in the explorer
|
||||
*/
|
||||
public updateAllFileExplorerIcons(): void {
|
||||
if (!this.settings.showStatusIconsInExplorer) {
|
||||
this.ui.removeAllFileExplorerIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
this.processFilesInBatches();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets selected files from the explorer
|
||||
*/
|
||||
public getSelectedFiles(): TFile[] {
|
||||
return this.ui.getSelectedFiles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a file for icon update
|
||||
*/
|
||||
private queueFileUpdate(file: TFile): void {
|
||||
if (!this.settings.showStatusIconsInExplorer || file.extension !== "md")
|
||||
return;
|
||||
|
||||
this.iconUpdateQueue.add(file.path);
|
||||
this.debouncedUpdateAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the update queue
|
||||
*/
|
||||
private async processUpdateQueue(): Promise<void> {
|
||||
if (this.isProcessingQueue || this.iconUpdateQueue.size === 0) return;
|
||||
|
||||
this.isProcessingQueue = true;
|
||||
|
||||
try {
|
||||
const fileExplorerView = this.ui.findFileExplorerView();
|
||||
if (!fileExplorerView || !fileExplorerView.fileItems) {
|
||||
this.fileItemsWasUndefined = true;
|
||||
setTimeout(() => this.debouncedUpdateAll(), 200);
|
||||
return;
|
||||
}
|
||||
|
||||
const allPaths = Array.from(this.iconUpdateQueue);
|
||||
const batchSize = 50;
|
||||
|
||||
for (let i = 0; i < allPaths.length; i += batchSize) {
|
||||
await this.processBatch(
|
||||
allPaths.slice(i, i + batchSize),
|
||||
fileExplorerView,
|
||||
);
|
||||
|
||||
if (i + batchSize < allPaths.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Note Status: Error processing file update queue",
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
this.isProcessingQueue = false;
|
||||
|
||||
if (this.iconUpdateQueue.size > 0) {
|
||||
this.debouncedUpdateAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch of files
|
||||
*/
|
||||
private async processBatch(
|
||||
paths: string[],
|
||||
fileExplorerView: FileExplorerView,
|
||||
): Promise<void> {
|
||||
for (const path of paths) {
|
||||
const file = this.app.vault.getFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
this.ui.updateSingleFileIcon(
|
||||
file,
|
||||
fileExplorerView,
|
||||
this.statusService,
|
||||
);
|
||||
}
|
||||
this.iconUpdateQueue.delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process files in batches
|
||||
*/
|
||||
private async processFilesInBatches(): Promise<void> {
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
const batchSize = 100;
|
||||
|
||||
for (let i = 0; i < files.length; i += batchSize) {
|
||||
files
|
||||
.slice(i, i + batchSize)
|
||||
.forEach((file) => this.queueFileUpdate(file));
|
||||
|
||||
if (i + batchSize < files.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup when unloading the plugin
|
||||
*/
|
||||
public unload(): void {
|
||||
this.ui.removeAllFileExplorerIcons();
|
||||
this.debouncedUpdateAll.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages UI operations for file explorer icons
|
||||
*/
|
||||
export class ExplorerIntegrationUI {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private onFileItemsUndefined: () => void;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
onFileItemsUndefined: () => void,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.onFileItemsUndefined = onFileItemsUndefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the settings
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the file explorer view
|
||||
*/
|
||||
public findFileExplorerView(): FileExplorerView | null {
|
||||
const leaf = this.app.workspace.getLeavesOfType("file-explorer")[0];
|
||||
if (leaf?.view) return leaf.view as FileExplorerView;
|
||||
|
||||
for (const leaf of this.app.workspace.getLeavesOfType("")) {
|
||||
if (leaf.view && "fileItems" in leaf.view) {
|
||||
return leaf.view as FileExplorerView;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a single file icon
|
||||
*/
|
||||
public updateSingleFileIcon(
|
||||
file: TFile,
|
||||
fileExplorerView: FileExplorerView,
|
||||
statusService: StatusService,
|
||||
): void {
|
||||
if (!this.settings.showStatusIconsInExplorer || file.extension !== "md")
|
||||
return;
|
||||
|
||||
try {
|
||||
// Check if fileItems is initialized
|
||||
if (!fileExplorerView.fileItems) {
|
||||
this.onFileItemsUndefined();
|
||||
return;
|
||||
}
|
||||
|
||||
const fileItem = fileExplorerView.fileItems[file.path];
|
||||
if (!fileItem) return;
|
||||
|
||||
const titleEl = fileItem.titleEl || fileItem.selfEl;
|
||||
if (!titleEl) return;
|
||||
|
||||
const statuses = statusService.getFileStatuses(file);
|
||||
|
||||
this.removeExistingIcons(titleEl);
|
||||
|
||||
if (this.shouldSkipIcon(statuses)) return;
|
||||
|
||||
this.addStatusIcons(titleEl, statuses, statusService);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Note Status: Error updating icon for ${file.path}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the icon for the active file directly
|
||||
*/
|
||||
public updateSingleFileIconDirectly(
|
||||
file: TFile,
|
||||
statusService: StatusService,
|
||||
): void {
|
||||
const fileExplorer = this.findFileExplorerView();
|
||||
if (fileExplorer) {
|
||||
this.updateSingleFileIcon(file, fileExplorer, statusService);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all file explorer icons
|
||||
*/
|
||||
public removeAllFileExplorerIcons(): void {
|
||||
const fileExplorer = this.findFileExplorerView();
|
||||
if (!fileExplorer?.fileItems) return;
|
||||
|
||||
Object.values(fileExplorer.fileItems).forEach((fileItem) => {
|
||||
const titleEl = fileItem.titleEl || fileItem.selfEl;
|
||||
if (titleEl) this.removeExistingIcons(titleEl);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the currently selected files
|
||||
*/
|
||||
public getSelectedFiles(): TFile[] {
|
||||
const fileExplorer = this.findFileExplorerView();
|
||||
if (!fileExplorer?.fileItems) return [];
|
||||
|
||||
return Object.values(fileExplorer.fileItems)
|
||||
.filter(
|
||||
(item) =>
|
||||
item.el?.classList.contains("is-selected") &&
|
||||
item.file instanceof TFile &&
|
||||
item.file.extension === "md",
|
||||
)
|
||||
.map((item) => item.file as TFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the icon should be skipped based on status
|
||||
*/
|
||||
private shouldSkipIcon(statuses: string[]): boolean {
|
||||
return (
|
||||
this.settings.hideUnknownStatusInExplorer &&
|
||||
statuses.length === 1 &&
|
||||
statuses[0] === "unknown"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes existing status icons
|
||||
*/
|
||||
private removeExistingIcons(element: HTMLElement): void {
|
||||
const iconSelectors = ".note-status-icon, .note-status-icon-container";
|
||||
element.querySelectorAll(iconSelectors).forEach((icon) => {
|
||||
if (
|
||||
icon.classList.contains("note-status-icon") ||
|
||||
icon.classList.contains("note-status-icon-container")
|
||||
) {
|
||||
icon.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds status icons to an element
|
||||
*/
|
||||
private addStatusIcons(
|
||||
titleEl: HTMLElement,
|
||||
statuses: string[],
|
||||
statusService: StatusService,
|
||||
): void {
|
||||
const iconContainer = document.createElement("span");
|
||||
iconContainer.className = "note-status-icon-container";
|
||||
|
||||
if (
|
||||
this.settings.useMultipleStatuses &&
|
||||
statuses.length > 0 &&
|
||||
statuses[0] !== "unknown"
|
||||
) {
|
||||
statuses.forEach((status) =>
|
||||
this.addSingleStatusIcon(iconContainer, status, statusService),
|
||||
);
|
||||
} else {
|
||||
const primaryStatus = statuses[0] || "unknown";
|
||||
if (
|
||||
primaryStatus !== "unknown" ||
|
||||
!this.settings.hideUnknownStatusInExplorer
|
||||
) {
|
||||
this.addSingleStatusIcon(
|
||||
iconContainer,
|
||||
primaryStatus,
|
||||
statusService,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (iconContainer.childElementCount > 0) {
|
||||
titleEl.appendChild(iconContainer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a single status icon
|
||||
*/
|
||||
private addSingleStatusIcon(
|
||||
container: HTMLElement,
|
||||
status: string,
|
||||
statusService: StatusService,
|
||||
): void {
|
||||
const iconEl = document.createElement("span");
|
||||
iconEl.className = `note-status-icon nav-file-tag status-${status}`;
|
||||
iconEl.textContent = statusService.getStatusIcon(status);
|
||||
|
||||
const statusObj = statusService
|
||||
.getAllStatuses()
|
||||
.find((s) => s.name === status);
|
||||
const tooltipValue = statusObj?.description
|
||||
? `${status} - ${statusObj.description}`
|
||||
: status;
|
||||
setTooltip(iconEl, tooltipValue);
|
||||
|
||||
container.appendChild(iconEl);
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { ExplorerIntegration } from "./explorer-integration";
|
||||
207
integrations/file-explorer/file-explorer-integration.tsx
Normal file
207
integrations/file-explorer/file-explorer-integration.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { FileExplorerIcon } from "@/components/FileExplorer/FileExplorerIcon";
|
||||
import eventBus from "@/core/eventBus";
|
||||
import {
|
||||
LazyElementObserver,
|
||||
IElementProcessor,
|
||||
} from "@/core/lazyElementObserver";
|
||||
import { NoteStatusService } from "@/core/noteStatusService";
|
||||
import { GroupedStatuses } from "@/types/noteStatus";
|
||||
import { Plugin, TFile, View } from "obsidian";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { StatusesInfoPopup } from "../popups/statusesInfoPopupIntegration";
|
||||
|
||||
export class FileExplorerIntegration implements IElementProcessor {
|
||||
private plugin: Plugin;
|
||||
private observerService: LazyElementObserver;
|
||||
|
||||
private readonly ICON_CLASS = "custom-icon";
|
||||
private readonly EVENT_SUBSCRIPTION_ID =
|
||||
"file-explorer-integration-subscription1";
|
||||
private readonly FILE_EXPLORER_TYPE = "file-explorer";
|
||||
private readonly CONTAINER_SELECTOR = ".nav-files-container";
|
||||
|
||||
constructor(plugin: Plugin) {
|
||||
this.plugin = plugin;
|
||||
this.observerService = new LazyElementObserver(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes file explorer integration
|
||||
*/
|
||||
async integrate(): Promise<void> {
|
||||
this.setupWorkspaceIntegration();
|
||||
|
||||
eventBus.subscribe(
|
||||
"frontmatter-manually-changed",
|
||||
({ file }) => {
|
||||
const element = this.findFileElement(file.path);
|
||||
if (!element) return;
|
||||
|
||||
this.refreshElementIcon(element, file.path);
|
||||
},
|
||||
this.EVENT_SUBSCRIPTION_ID,
|
||||
);
|
||||
}
|
||||
|
||||
private getFileNoteStatusService(
|
||||
dataPath: string,
|
||||
): NoteStatusService | null {
|
||||
if (!dataPath.endsWith(".md")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const file = this.plugin.app.vault.getAbstractFileByPath(
|
||||
dataPath,
|
||||
) as TFile;
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const noteStatusService = new NoteStatusService(file);
|
||||
noteStatusService.populateStatuses();
|
||||
return noteStatusService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes visible file explorer elements (IElementProcessor implementation)
|
||||
*/
|
||||
processElement(element: HTMLElement, dataPath?: string | null): void {
|
||||
if (!dataPath) {
|
||||
dataPath = element.getAttribute("data-path");
|
||||
}
|
||||
if (dataPath) {
|
||||
const textEl = element.querySelector(
|
||||
".nav-file-title-content, .tree-item-inner, .nav-folder-title-content",
|
||||
);
|
||||
if (!textEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const noteStatusService = this.getFileNoteStatusService(dataPath);
|
||||
|
||||
this.render(textEl, noteStatusService?.statuses ?? {});
|
||||
}
|
||||
}
|
||||
|
||||
render(element: Element, statuses: GroupedStatuses): void {
|
||||
// Remove existing icon
|
||||
const existingIcon = element.querySelector(`.${this.ICON_CLASS}`);
|
||||
if (existingIcon) {
|
||||
existingIcon.remove();
|
||||
}
|
||||
const icon = createSpan({ cls: this.ICON_CLASS });
|
||||
const root = createRoot(icon);
|
||||
root.render(
|
||||
<FileExplorerIcon
|
||||
statuses={statuses}
|
||||
onMouseEnter={(s) => this.openModalInfo(s)}
|
||||
onMouseLeave={this.closeModalInfo}
|
||||
/>,
|
||||
);
|
||||
|
||||
element.prepend(icon);
|
||||
}
|
||||
|
||||
private openModalInfo(statuses: GroupedStatuses) {
|
||||
if (!this.plugin) {
|
||||
return;
|
||||
}
|
||||
StatusesInfoPopup.open(statuses);
|
||||
}
|
||||
private closeModalInfo() {
|
||||
StatusesInfoPopup.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup integration and unsubscribe from events
|
||||
*/
|
||||
destroy(): void {
|
||||
this.observerService.cleanup();
|
||||
eventBus.unsubscribe(
|
||||
"frontmatter-manually-changed",
|
||||
this.EVENT_SUBSCRIPTION_ID,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up workspace-level integration
|
||||
*/
|
||||
private setupWorkspaceIntegration(): void {
|
||||
this.plugin.app.workspace.onLayoutReady(() => {
|
||||
this.patchFileExplorer();
|
||||
this.registerActiveLeafChangeHandler();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers handler for active leaf changes
|
||||
*/
|
||||
private registerActiveLeafChangeHandler(): void {
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.workspace.on("active-leaf-change", () => {
|
||||
this.patchFileExplorer();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds file element in the file explorer
|
||||
*/
|
||||
private findFileElement(filePath: string): HTMLElement | null {
|
||||
const fileExplorerView = this.getFileExplorerView();
|
||||
if (!fileExplorerView) return null;
|
||||
|
||||
return fileExplorerView.containerEl.querySelector(
|
||||
`[data-path="${filePath}"]`,
|
||||
) as HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes icon for a specific element
|
||||
*/
|
||||
private refreshElementIcon(element: HTMLElement, dataPath: string): void {
|
||||
//
|
||||
// Remove existing icon
|
||||
// this.iconRenderer.removeIcon(element);
|
||||
|
||||
// Mark for reprocessing
|
||||
this.observerService.markElementForReprocessing(element);
|
||||
|
||||
this.processElement(element, dataPath);
|
||||
//
|
||||
// // Re-add icon with updated status
|
||||
// this.iconRenderer.renderIcon(element, dataPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches the file explorer with observers
|
||||
*/
|
||||
private patchFileExplorer(): void {
|
||||
const container = this.getFileExplorerContainer();
|
||||
if (!container) return;
|
||||
|
||||
this.observerService.setupObservers(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file explorer view
|
||||
*/
|
||||
private getFileExplorerView(): View | null {
|
||||
const leaves = this.plugin.app.workspace.getLeavesOfType(
|
||||
this.FILE_EXPLORER_TYPE,
|
||||
);
|
||||
return leaves[0]?.view || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file explorer container element
|
||||
*/
|
||||
private getFileExplorerContainer(): Element | null {
|
||||
const fileExplorerView = this.getFileExplorerView();
|
||||
if (!fileExplorerView) return null;
|
||||
|
||||
return fileExplorerView.containerEl.querySelector(
|
||||
this.CONTAINER_SELECTOR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { MetadataIntegration } from "./metadata-integration";
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import { App, TFile } from "obsidian";
|
||||
import { NoteStatusSettings } from "../../models/types";
|
||||
import { ExplorerIntegration } from "../explorer/explorer-integration";
|
||||
import { StatusService } from "services/status-service";
|
||||
|
||||
export class MetadataIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private explorerIntegration: ExplorerIntegration;
|
||||
private metadataChangedRef: (file: TFile) => void;
|
||||
private metadataResolvedRef: () => void;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
explorerIntegration: ExplorerIntegration,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.explorerIntegration = explorerIntegration;
|
||||
}
|
||||
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public registerMetadataEvents(): void {
|
||||
this.metadataChangedRef = (file) => {
|
||||
if (file instanceof TFile && file.extension === "md") {
|
||||
this.handleMetadataChanged(file);
|
||||
}
|
||||
};
|
||||
|
||||
this.metadataResolvedRef = () => {
|
||||
setTimeout(() => {
|
||||
if (this.settings.showStatusIconsInExplorer) {
|
||||
this.explorerIntegration.updateAllFileExplorerIcons();
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
this.app.metadataCache.on("changed", this.metadataChangedRef);
|
||||
this.app.metadataCache.on("resolved", this.metadataResolvedRef);
|
||||
}
|
||||
|
||||
private handleMetadataChanged(file: TFile): void {
|
||||
if (this.settings.showStatusIconsInExplorer) {
|
||||
this.explorerIntegration.updateFileExplorerIcons(file);
|
||||
}
|
||||
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (activeFile?.path === file.path) {
|
||||
const statuses = this.statusService.getFileStatuses(file);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("note-status:status-changed", {
|
||||
detail: { statuses, file: file.path },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public unload(): void {
|
||||
if (this.metadataChangedRef) {
|
||||
this.app.metadataCache.off("changed", this.metadataChangedRef);
|
||||
}
|
||||
if (this.metadataResolvedRef) {
|
||||
this.app.metadataCache.off("resolved", this.metadataResolvedRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
134
integrations/modals/statusModalIntegration.tsx
Normal file
134
integrations/modals/statusModalIntegration.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { App, Modal, Notice } from "obsidian";
|
||||
import { createRoot, Root } from "react-dom/client";
|
||||
import {
|
||||
StatusModal as StatusModalComponent,
|
||||
Props,
|
||||
} from "@/components/StatusModal/StatusModal";
|
||||
import SelectorService from "@/core/selectorService";
|
||||
import {
|
||||
MultipleNoteStatusService,
|
||||
NoteStatusService,
|
||||
} from "@/core/noteStatusService";
|
||||
import eventBus from "@/core/eventBus";
|
||||
|
||||
export class StatusModalIntegration extends Modal {
|
||||
private root: Root | null = null;
|
||||
private static instance: StatusModalIntegration | null = null;
|
||||
private selectorService: SelectorService;
|
||||
|
||||
constructor(app: App) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
static open(
|
||||
app: App,
|
||||
noteStatusService: NoteStatusService | MultipleNoteStatusService,
|
||||
) {
|
||||
if (StatusModalIntegration.instance) {
|
||||
throw new Error("Status Modal is already open");
|
||||
}
|
||||
StatusModalIntegration.instance = new StatusModalIntegration(app);
|
||||
StatusModalIntegration.instance.selectorService = new SelectorService(
|
||||
noteStatusService,
|
||||
);
|
||||
|
||||
eventBus.subscribe(
|
||||
"frontmatter-manually-changed",
|
||||
() => {
|
||||
// TODO: There are multiple calls to populateStatuses, in this case the noteStatusService is passed by reference, so redundant computations
|
||||
// FIXME: Line 27
|
||||
StatusModalIntegration.instance?.selectorService.noteStatusService.populateStatuses();
|
||||
StatusModalIntegration.instance?.render();
|
||||
},
|
||||
"statusModalIntegrationSubscription1",
|
||||
);
|
||||
|
||||
StatusModalIntegration.instance.open();
|
||||
}
|
||||
|
||||
private onRemoveStatus: Props["onRemoveStatus"] = async (
|
||||
frontmatterTagName,
|
||||
status,
|
||||
) => {
|
||||
const removed =
|
||||
await this.selectorService.noteStatusService.removeStatus(
|
||||
frontmatterTagName,
|
||||
status,
|
||||
);
|
||||
if (removed) {
|
||||
new Notice(
|
||||
`Status ${status.name} removed successfully from the note`,
|
||||
);
|
||||
} else {
|
||||
new Notice(
|
||||
`Something went wrong removing the status ${status.name} from the note`,
|
||||
);
|
||||
}
|
||||
};
|
||||
private onSelectStatus: Props["onSelectStatus"] = async (
|
||||
frontmatterTagName,
|
||||
status,
|
||||
) => {
|
||||
const added = await this.selectorService.noteStatusService.addStatus(
|
||||
frontmatterTagName,
|
||||
status.name,
|
||||
);
|
||||
if (added) {
|
||||
new Notice(`Status ${status.name} added successfully to the note`);
|
||||
} else {
|
||||
new Notice(
|
||||
`Something went wrong adding the status ${status.name} to the note`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
onOpen() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
const { contentEl, titleEl } = this;
|
||||
|
||||
titleEl.setText("Note Status");
|
||||
// this.modalEl.addClass("note-status-modal");
|
||||
|
||||
if (!this.root) {
|
||||
this.root = createRoot(contentEl);
|
||||
}
|
||||
|
||||
let filesQuantity = 1;
|
||||
if (
|
||||
this.selectorService.noteStatusService instanceof
|
||||
MultipleNoteStatusService
|
||||
) {
|
||||
filesQuantity =
|
||||
this.selectorService.noteStatusService.selectedFilesQTY();
|
||||
}
|
||||
this.root.render(
|
||||
<StatusModalComponent
|
||||
availableStatuses={NoteStatusService.getAllAvailableStatuses()}
|
||||
currentStatuses={
|
||||
this.selectorService.noteStatusService.statuses ?? {}
|
||||
}
|
||||
filesQuantity={filesQuantity}
|
||||
onRemoveStatus={this.onRemoveStatus}
|
||||
onSelectStatus={this.onSelectStatus}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
StatusModalIntegration.instance = null;
|
||||
|
||||
eventBus.unsubscribe(
|
||||
"frontmatter-manually-changed",
|
||||
"statusModalIntegrationSubscription1",
|
||||
);
|
||||
}
|
||||
}
|
||||
47
integrations/popups/statusesInfoPopupIntegration.tsx
Normal file
47
integrations/popups/statusesInfoPopupIntegration.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { createRoot, Root } from "react-dom/client";
|
||||
import { GroupedStatuses } from "@/types/noteStatus";
|
||||
import { StatusFileInfoPopup } from "@/components/StatusFileInfoPopup/StatusFileInfoPopup";
|
||||
|
||||
export class StatusesInfoPopup {
|
||||
private root: Root | null = null;
|
||||
private static instance: StatusesInfoPopup | null = null;
|
||||
private element: HTMLElement | null = null;
|
||||
private statuses: GroupedStatuses;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static open(statuses: GroupedStatuses) {
|
||||
StatusesInfoPopup.close();
|
||||
|
||||
StatusesInfoPopup.instance = new StatusesInfoPopup();
|
||||
StatusesInfoPopup.instance.statuses = statuses;
|
||||
StatusesInfoPopup.instance.show();
|
||||
}
|
||||
|
||||
static close() {
|
||||
if (StatusesInfoPopup.instance) {
|
||||
StatusesInfoPopup.instance.destroy();
|
||||
StatusesInfoPopup.instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
private show() {
|
||||
this.element = createDiv({ cls: "" });
|
||||
|
||||
document.body.appendChild(this.element);
|
||||
|
||||
this.root = createRoot(this.element);
|
||||
this.root.render(<StatusFileInfoPopup statuses={this.statuses} />);
|
||||
}
|
||||
|
||||
private destroy() {
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
if (this.element) {
|
||||
this.element.remove();
|
||||
this.element = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,631 +0,0 @@
|
|||
import React, { useCallback } from "react";
|
||||
import { NoteStatusSettings, Status } from "../../models/types";
|
||||
import { PREDEFINED_TEMPLATES } from "../../constants/status-templates";
|
||||
import { SettingsUICallbacks } from "./types";
|
||||
|
||||
interface SettingsUIProps {
|
||||
settings: NoteStatusSettings;
|
||||
callbacks: SettingsUICallbacks;
|
||||
}
|
||||
|
||||
interface TemplateSettingsProps {
|
||||
settings: NoteStatusSettings;
|
||||
callbacks: SettingsUICallbacks;
|
||||
}
|
||||
|
||||
const TemplateSettings: React.FC<TemplateSettingsProps> = ({
|
||||
settings,
|
||||
callbacks,
|
||||
}) => {
|
||||
const handleTemplateToggle = useCallback(
|
||||
(templateId: string, enabled: boolean) => {
|
||||
callbacks.onTemplateToggle(templateId, enabled);
|
||||
},
|
||||
[callbacks],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="template-settings">
|
||||
<h3>Status templates</h3>
|
||||
<p className="setting-item-description">
|
||||
Enable predefined templates to quickly add common status
|
||||
workflows
|
||||
</p>
|
||||
|
||||
<div className="templates-container">
|
||||
{PREDEFINED_TEMPLATES.map((template) => {
|
||||
const isEnabled = settings.enabledTemplates.includes(
|
||||
template.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={template.id} className="template-item">
|
||||
<div className="template-header">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="template-checkbox"
|
||||
checked={isEnabled}
|
||||
onChange={(e) =>
|
||||
handleTemplateToggle(
|
||||
template.id,
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="template-name">
|
||||
{template.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="template-description">
|
||||
{template.description}
|
||||
</div>
|
||||
<div className="template-statuses">
|
||||
{template.statuses.map((status, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="template-status-chip"
|
||||
>
|
||||
<span
|
||||
className="status-color-dot"
|
||||
style={
|
||||
{
|
||||
"--dot-color":
|
||||
status.color ||
|
||||
"#ffffff",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
{status.icon} {status.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface UISettingsProps {
|
||||
settings: NoteStatusSettings;
|
||||
callbacks: SettingsUICallbacks;
|
||||
}
|
||||
|
||||
const UISettings: React.FC<UISettingsProps> = ({ settings, callbacks }) => {
|
||||
return (
|
||||
<div className="ui-settings">
|
||||
<h3>User interface</h3>
|
||||
|
||||
<div className="setting-item">
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">Show status bar</div>
|
||||
<div className="setting-item-description">
|
||||
Display the status bar
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-item-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showStatusBar}
|
||||
onChange={(e) =>
|
||||
callbacks.onSettingChange(
|
||||
"showStatusBar",
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">
|
||||
Auto-hide status bar
|
||||
</div>
|
||||
<div className="setting-item-description">
|
||||
Hide the status bar when status is unknown
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-item-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.autoHideStatusBar}
|
||||
onChange={(e) =>
|
||||
callbacks.onSettingChange(
|
||||
"autoHideStatusBar",
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">
|
||||
Show status icons in file explorer
|
||||
</div>
|
||||
<div className="setting-item-description">
|
||||
Display status icons in the file explorer
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-item-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showStatusIconsInExplorer}
|
||||
onChange={(e) =>
|
||||
callbacks.onSettingChange(
|
||||
"showStatusIconsInExplorer",
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">
|
||||
Hide unknown status in file explorer
|
||||
</div>
|
||||
<div className="setting-item-description">
|
||||
Hide status icons for files with unknown status in the
|
||||
file explorer
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-item-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.hideUnknownStatusInExplorer || false}
|
||||
onChange={(e) =>
|
||||
callbacks.onSettingChange(
|
||||
"hideUnknownStatusInExplorer",
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">
|
||||
Default to compact view
|
||||
</div>
|
||||
<div className="setting-item-description">
|
||||
Start the status pane in compact view by default
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-item-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.compactView || false}
|
||||
onChange={(e) =>
|
||||
callbacks.onSettingChange(
|
||||
"compactView",
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">
|
||||
Exclude unassigned notes from status pane
|
||||
</div>
|
||||
<div className="setting-item-description">
|
||||
Improves performance by excluding notes with no assigned
|
||||
status from the status pane. Recommended for large
|
||||
vaults.
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-item-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.excludeUnknownStatus || false}
|
||||
onChange={(e) =>
|
||||
callbacks.onSettingChange(
|
||||
"excludeUnknownStatus",
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TagSettingsProps {
|
||||
settings: NoteStatusSettings;
|
||||
callbacks: SettingsUICallbacks;
|
||||
}
|
||||
|
||||
const TagSettings: React.FC<TagSettingsProps> = ({ settings, callbacks }) => {
|
||||
return (
|
||||
<div className="tag-settings">
|
||||
<h3>Status tag</h3>
|
||||
|
||||
<div className="setting-item">
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">
|
||||
Enable multiple statuses
|
||||
</div>
|
||||
<div className="setting-item-description">
|
||||
Allow notes to have multiple statuses at the same time
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-item-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.useMultipleStatuses}
|
||||
onChange={(e) =>
|
||||
callbacks.onSettingChange(
|
||||
"useMultipleStatuses",
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">Status tag prefix</div>
|
||||
<div className="setting-item-description">
|
||||
The YAML frontmatter tag name used for status (default:
|
||||
obsidian-note-status)
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-item-control">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.tagPrefix}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.trim()) {
|
||||
callbacks.onSettingChange(
|
||||
"tagPrefix",
|
||||
e.target.value.trim(),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">
|
||||
Strict status validation
|
||||
</div>
|
||||
<div className="setting-item-description">
|
||||
Only show statuses that are defined in templates or
|
||||
custom statuses. ⚠️ WARNING: When enabled, any unknown
|
||||
statuses will be automatically removed when modifying
|
||||
file statuses.
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-item-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.strictStatuses || false}
|
||||
onChange={(e) =>
|
||||
callbacks.onSettingChange(
|
||||
"strictStatuses",
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CustomStatusItemProps {
|
||||
status: Status;
|
||||
index: number;
|
||||
settings: NoteStatusSettings;
|
||||
callbacks: SettingsUICallbacks;
|
||||
}
|
||||
|
||||
const CustomStatusItem: React.FC<CustomStatusItemProps> = ({
|
||||
status,
|
||||
index,
|
||||
settings,
|
||||
callbacks,
|
||||
}) => {
|
||||
return (
|
||||
<div className="custom-status-item setting-item">
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">{status.name}</div>
|
||||
</div>
|
||||
<div className="setting-item-controls">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={status.name}
|
||||
onChange={(e) =>
|
||||
callbacks.onCustomStatusChange(
|
||||
index,
|
||||
"name",
|
||||
e.target.value || "unnamed",
|
||||
)
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Icon"
|
||||
value={status.icon}
|
||||
onChange={(e) =>
|
||||
callbacks.onCustomStatusChange(
|
||||
index,
|
||||
"icon",
|
||||
e.target.value || "❓",
|
||||
)
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={settings.statusColors[status.name] || "#ffffff"}
|
||||
onChange={(e) =>
|
||||
callbacks.onCustomStatusChange(
|
||||
index,
|
||||
"color",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Description"
|
||||
value={status.description || ""}
|
||||
onChange={(e) =>
|
||||
callbacks.onCustomStatusChange(
|
||||
index,
|
||||
"description",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
className="status-remove-button"
|
||||
onClick={() => callbacks.onCustomStatusRemove(index)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CustomStatusSettingsProps {
|
||||
settings: NoteStatusSettings;
|
||||
callbacks: SettingsUICallbacks;
|
||||
}
|
||||
|
||||
const CustomStatusSettings: React.FC<CustomStatusSettingsProps> = ({
|
||||
settings,
|
||||
callbacks,
|
||||
}) => {
|
||||
return (
|
||||
<div className="custom-status-settings">
|
||||
<h3>Custom statuses</h3>
|
||||
|
||||
<div className="setting-item">
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">
|
||||
Use only custom statuses
|
||||
</div>
|
||||
<div className="setting-item-description">
|
||||
Ignore template statuses and use only the custom
|
||||
statuses defined below
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-item-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.useCustomStatusesOnly || false}
|
||||
onChange={(e) =>
|
||||
callbacks.onSettingChange(
|
||||
"useCustomStatusesOnly",
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="custom-status-list">
|
||||
{settings.customStatuses.map((status, index) => (
|
||||
<CustomStatusItem
|
||||
key={index}
|
||||
status={status}
|
||||
index={index}
|
||||
settings={settings}
|
||||
callbacks={callbacks}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">Add new status</div>
|
||||
<div className="setting-item-description">
|
||||
Add a custom status with a name, icon, and color
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-item-control">
|
||||
<button
|
||||
className="mod-cta"
|
||||
onClick={() => callbacks.onCustomStatusAdd()}
|
||||
>
|
||||
Add Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface QuickCommandsSettingsProps {
|
||||
settings: NoteStatusSettings;
|
||||
callbacks: SettingsUICallbacks;
|
||||
}
|
||||
|
||||
const QuickCommandsSettings: React.FC<QuickCommandsSettingsProps> = ({
|
||||
settings,
|
||||
callbacks,
|
||||
}) => {
|
||||
const getAllAvailableStatuses = useCallback((): Array<{
|
||||
name: string;
|
||||
icon: string;
|
||||
description?: string;
|
||||
}> => {
|
||||
const statuses: Array<{
|
||||
name: string;
|
||||
icon: string;
|
||||
description?: string;
|
||||
}> = [];
|
||||
|
||||
statuses.push(...settings.customStatuses);
|
||||
|
||||
if (!settings.useCustomStatusesOnly) {
|
||||
for (const templateId of settings.enabledTemplates) {
|
||||
const template = PREDEFINED_TEMPLATES.find(
|
||||
(t) => t.id === templateId,
|
||||
);
|
||||
if (template) {
|
||||
for (const status of template.statuses) {
|
||||
if (!statuses.find((s) => s.name === status.name)) {
|
||||
statuses.push(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return statuses.filter((s) => s.name !== "unknown");
|
||||
}, [settings]);
|
||||
|
||||
const allStatuses = getAllAvailableStatuses();
|
||||
const currentQuickCommands = settings.quickStatusCommands || [];
|
||||
|
||||
const handleQuickCommandToggle = useCallback(
|
||||
(statusName: string, enabled: boolean) => {
|
||||
const updatedCommands = enabled
|
||||
? [
|
||||
...currentQuickCommands.filter(
|
||||
(cmd) => cmd !== statusName,
|
||||
),
|
||||
statusName,
|
||||
]
|
||||
: currentQuickCommands.filter((cmd) => cmd !== statusName);
|
||||
|
||||
callbacks.onSettingChange("quickStatusCommands", updatedCommands);
|
||||
},
|
||||
[currentQuickCommands, callbacks],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="quick-commands-settings">
|
||||
<h3>Quick status commands</h3>
|
||||
<div className="setting-item-description">
|
||||
Select which statuses should have dedicated commands in the
|
||||
command palette. These can be assigned hotkeys for quick access.
|
||||
</div>
|
||||
|
||||
<div className="quick-commands-container">
|
||||
{allStatuses.length === 0 ? (
|
||||
<div className="setting-item-description">
|
||||
No statuses available. Enable templates or add custom
|
||||
statuses first.
|
||||
</div>
|
||||
) : (
|
||||
allStatuses.map((status) => (
|
||||
<div key={status.name} className="setting-item">
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">
|
||||
{status.icon} {status.name}
|
||||
</div>
|
||||
{status.description && (
|
||||
<div className="setting-item-description">
|
||||
{status.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="setting-item-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentQuickCommands.includes(
|
||||
status.name,
|
||||
)}
|
||||
onChange={(e) =>
|
||||
handleQuickCommandToggle(
|
||||
status.name,
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SettingsUI: React.FC<SettingsUIProps> = ({
|
||||
settings,
|
||||
callbacks,
|
||||
}) => {
|
||||
return (
|
||||
<div className="note-status-settings">
|
||||
<TemplateSettings settings={settings} callbacks={callbacks} />
|
||||
<UISettings settings={settings} callbacks={callbacks} />
|
||||
<TagSettings settings={settings} callbacks={callbacks} />
|
||||
<CustomStatusSettings settings={settings} callbacks={callbacks} />
|
||||
<QuickCommandsSettings settings={settings} callbacks={callbacks} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export class NoteStatusSettingsUIManager {
|
||||
private callbacks: SettingsUICallbacks;
|
||||
private container: HTMLElement | null = null;
|
||||
|
||||
constructor(callbacks: SettingsUICallbacks) {
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
render(containerEl: HTMLElement, settings: NoteStatusSettings): void {
|
||||
this.container = containerEl;
|
||||
containerEl.empty();
|
||||
|
||||
import("../../utils/react-utils").then(({ ReactUtils }) => {
|
||||
ReactUtils.render(
|
||||
React.createElement(SettingsUI, {
|
||||
settings,
|
||||
callbacks: this.callbacks,
|
||||
}),
|
||||
containerEl,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.container) {
|
||||
import("../../utils/react-utils").then(({ ReactUtils }) => {
|
||||
ReactUtils.unmount(this.container!);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SettingsUI;
|
||||
52
integrations/settings/pluginSettings.tsx
Normal file
52
integrations/settings/pluginSettings.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import SettingsUI from "@/components/SettingsUI.tsx";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import { Plugin, PluginSettingTab } from "obsidian";
|
||||
import { createRoot, Root } from "react-dom/client";
|
||||
|
||||
export class PluginSettingIntegration extends PluginSettingTab {
|
||||
private static instance: PluginSettingIntegration | null = null;
|
||||
private root: Root | null = null;
|
||||
private plugin: Plugin;
|
||||
|
||||
constructor(plugin: Plugin) {
|
||||
if (PluginSettingIntegration.instance) {
|
||||
throw new Error("The status bar instance is already created");
|
||||
}
|
||||
super(plugin.app, plugin);
|
||||
this.plugin = plugin;
|
||||
PluginSettingIntegration.instance = this;
|
||||
}
|
||||
|
||||
async integrate() {
|
||||
// INFO: This will integrate the "display" function component
|
||||
this.plugin.addSettingTab(this);
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
containerEl.empty();
|
||||
this.root = createRoot(containerEl);
|
||||
|
||||
this.root.render(
|
||||
<SettingsUI
|
||||
settings={settingsService.settings}
|
||||
onChange={(key, value) => {
|
||||
settingsService.setValue(key, value);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
PluginSettingIntegration.instance = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
import { App } from "obsidian";
|
||||
import { Status } from "../../models/types";
|
||||
import NoteStatus from "main";
|
||||
import { StatusService } from "services/status-service";
|
||||
import { NoteStatusSettingsUIManager } from "./SettingsUI";
|
||||
import { SettingsUICallbacks } from "./types";
|
||||
|
||||
/**
|
||||
* Controller for settings UI, handles business logic and plugin state updates
|
||||
*/
|
||||
export class NoteStatusSettingsController implements SettingsUICallbacks {
|
||||
private app: App;
|
||||
private plugin: NoteStatus;
|
||||
private statusService: StatusService;
|
||||
private ui: NoteStatusSettingsUIManager;
|
||||
|
||||
constructor(app: App, plugin: NoteStatus, statusService: StatusService) {
|
||||
this.app = app;
|
||||
this.plugin = plugin;
|
||||
this.statusService = statusService;
|
||||
this.ui = new NoteStatusSettingsUIManager(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the settings interface
|
||||
*/
|
||||
display(containerEl: HTMLElement): void {
|
||||
this.ui.render(containerEl, this.plugin.settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles template enable/disable toggle
|
||||
*/
|
||||
onTemplateToggle: SettingsUICallbacks["onTemplateToggle"] = async (
|
||||
templateId,
|
||||
enabled,
|
||||
) => {
|
||||
if (enabled) {
|
||||
if (!this.plugin.settings.enabledTemplates.includes(templateId)) {
|
||||
this.plugin.settings.enabledTemplates.push(templateId);
|
||||
}
|
||||
} else {
|
||||
this.plugin.settings.enabledTemplates =
|
||||
this.plugin.settings.enabledTemplates.filter(
|
||||
(id: string) => id !== templateId,
|
||||
);
|
||||
}
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles general setting changes
|
||||
*/
|
||||
onSettingChange: SettingsUICallbacks["onSettingChange"] = async (
|
||||
key,
|
||||
value,
|
||||
) => {
|
||||
this.plugin.settings[key] = value;
|
||||
await this.plugin.saveSettings();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles custom status field changes
|
||||
*/
|
||||
onCustomStatusChange: SettingsUICallbacks["onCustomStatusChange"] = async (
|
||||
index,
|
||||
field,
|
||||
value,
|
||||
) => {
|
||||
const status = this.plugin.settings.customStatuses[index];
|
||||
if (!status) return;
|
||||
|
||||
if (field === "name") {
|
||||
const oldName = status.name;
|
||||
status.name = value;
|
||||
|
||||
if (oldName !== status.name) {
|
||||
this.plugin.settings.statusColors[status.name] =
|
||||
this.plugin.settings.statusColors[oldName];
|
||||
delete this.plugin.settings.statusColors[oldName];
|
||||
}
|
||||
} else if (field === "color") {
|
||||
this.plugin.settings.statusColors[status.name] = value;
|
||||
} else {
|
||||
status[field] = value;
|
||||
}
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles custom status removal
|
||||
*/
|
||||
onCustomStatusRemove: SettingsUICallbacks["onCustomStatusRemove"] = async (
|
||||
index,
|
||||
) => {
|
||||
const status = this.plugin.settings.customStatuses[index];
|
||||
if (!status) return;
|
||||
|
||||
this.plugin.settings.customStatuses.splice(index, 1);
|
||||
delete this.plugin.settings.statusColors[status.name];
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
// Re-render the entire settings interface
|
||||
const container = document.querySelector(".note-status-settings")
|
||||
?.parentElement as HTMLElement;
|
||||
if (container) {
|
||||
this.display(container);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles adding new custom status
|
||||
*/
|
||||
|
||||
onCustomStatusAdd: SettingsUICallbacks["onCustomStatusAdd"] = async () => {
|
||||
const newStatus: Status = {
|
||||
name: `status${this.plugin.settings.customStatuses.length + 1}`,
|
||||
icon: "⭐",
|
||||
};
|
||||
|
||||
this.plugin.settings.customStatuses.push(newStatus);
|
||||
this.plugin.settings.statusColors[newStatus.name] = "#ffffff";
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
// Re-render the entire settings interface
|
||||
const container = document.querySelector(".note-status-settings")
|
||||
?.parentElement as HTMLElement;
|
||||
if (container) {
|
||||
this.display(container);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { App, PluginSettingTab } from "obsidian";
|
||||
import NoteStatus from "main";
|
||||
import { StatusService } from "services/status-service";
|
||||
import { NoteStatusSettingsController } from "./settings-controller";
|
||||
|
||||
/**
|
||||
* Settings tab for the Note Status plugin - delegates to controller
|
||||
*/
|
||||
export class NoteStatusSettingTab extends PluginSettingTab {
|
||||
private controller: NoteStatusSettingsController;
|
||||
|
||||
constructor(app: App, plugin: NoteStatus, statusService: StatusService) {
|
||||
super(app, plugin);
|
||||
this.controller = new NoteStatusSettingsController(
|
||||
app,
|
||||
plugin,
|
||||
statusService,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the settings interface
|
||||
*/
|
||||
display(): void {
|
||||
this.controller.display(this.containerEl);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { NoteStatusSettings, Status } from "../../models/types";
|
||||
|
||||
/**
|
||||
* Callbacks interface for settings UI interactions
|
||||
*/
|
||||
export interface SettingsUICallbacks {
|
||||
/** Handle template enable/disable toggle */
|
||||
onTemplateToggle: (templateId: string, enabled: boolean) => Promise<void>;
|
||||
/** Handle general setting changes */
|
||||
onSettingChange: (
|
||||
key: keyof NoteStatusSettings,
|
||||
value: boolean | string | string[],
|
||||
) => Promise<void>;
|
||||
/** Handle custom status field changes */
|
||||
onCustomStatusChange: (
|
||||
index: number,
|
||||
field: keyof Status,
|
||||
value: string,
|
||||
) => Promise<void>;
|
||||
/** Handle custom status removal */
|
||||
onCustomStatusRemove: (index: number) => Promise<void>;
|
||||
/** Handle adding new custom status */
|
||||
onCustomStatusAdd: () => Promise<void>;
|
||||
}
|
||||
161
integrations/status-bar/status-bar.tsx
Normal file
161
integrations/status-bar/status-bar.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { createRoot, Root } from "react-dom/client";
|
||||
import eventBus from "core/eventBus";
|
||||
import { MarkdownView, Plugin, WorkspaceLeaf } from "obsidian";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import { StatusBarEnableButton } from "@/components/StatusBar/StatusBarEnableButton";
|
||||
import { StatusBar } from "@/components/StatusBar/StatusBar";
|
||||
import { NoteStatusService } from "@/core/noteStatusService";
|
||||
|
||||
export class StatusBarIntegration {
|
||||
private static instance: StatusBarIntegration | null = null;
|
||||
private root: Root | null = null;
|
||||
private plugin: Plugin;
|
||||
private statusBarContainer: HTMLElement;
|
||||
private noteStatusService: NoteStatusService | null = null;
|
||||
private currentLeaf: WorkspaceLeaf | null = null;
|
||||
|
||||
constructor(plugin: Plugin) {
|
||||
if (StatusBarIntegration.instance) {
|
||||
throw new Error("The status bar instance is already created");
|
||||
}
|
||||
this.plugin = plugin;
|
||||
StatusBarIntegration.instance = this;
|
||||
}
|
||||
|
||||
async integrate() {
|
||||
this.statusBarContainer = this.plugin.addStatusBarItem();
|
||||
this.statusBarContainer.classList.add("mod-clickable");
|
||||
|
||||
eventBus.subscribe(
|
||||
"active-file-change",
|
||||
({ leaf }) => {
|
||||
if (this.isValidMarkdownLeaf(leaf)) {
|
||||
this.currentLeaf = leaf;
|
||||
this.handleActiveFileChange().catch(console.error);
|
||||
}
|
||||
},
|
||||
"statusBarIntegrationSubscription1",
|
||||
);
|
||||
|
||||
eventBus.subscribe(
|
||||
"plugin-settings-changed",
|
||||
({ value, key }) => {
|
||||
if (key === "showStatusBar") {
|
||||
this.render(); // INFO: Force a render to set disabled or enabled
|
||||
}
|
||||
if (key === "enabledTemplates") {
|
||||
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
|
||||
}
|
||||
if (key === "autoHideStatusBar") {
|
||||
this.render();
|
||||
}
|
||||
if (key === "useMultipleStatuses") {
|
||||
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
|
||||
}
|
||||
if (key === "tagPrefix") {
|
||||
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
|
||||
}
|
||||
if (key === "useCustomStatusesOnly") {
|
||||
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
|
||||
}
|
||||
if (key === "customStatuses") {
|
||||
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
|
||||
}
|
||||
},
|
||||
"statusBarIntegrationSubscription2",
|
||||
);
|
||||
|
||||
eventBus.subscribe(
|
||||
"frontmatter-manually-changed",
|
||||
() => {
|
||||
this.handleActiveFileChange().catch(console.error);
|
||||
},
|
||||
"statusBarIntegrationSubscription3",
|
||||
);
|
||||
}
|
||||
|
||||
private async handleActiveFileChange() {
|
||||
this.extractStatusesFromLeaf(this.currentLeaf);
|
||||
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 openStatusModal() {
|
||||
if (!this.noteStatusService) {
|
||||
throw new Error(
|
||||
"open status modal failed bcse there is no noteStatusService available",
|
||||
);
|
||||
}
|
||||
eventBus.publish("triggered-open-modal", {
|
||||
statusService: this.noteStatusService,
|
||||
});
|
||||
}
|
||||
|
||||
private render() {
|
||||
if (!this.root) {
|
||||
this.root = createRoot(this.statusBarContainer);
|
||||
this.statusBarContainer.style.padding = "unset";
|
||||
}
|
||||
if (!settingsService.settings.showStatusBar) {
|
||||
this.root.render(
|
||||
<StatusBarEnableButton
|
||||
onEnableClick={() =>
|
||||
settingsService.setValue("showStatusBar", true)
|
||||
}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
this.root.render(
|
||||
<StatusBar
|
||||
statuses={this.noteStatusService?.statuses || {}}
|
||||
hideIfNotStatuses={
|
||||
settingsService.settings.autoHideStatusBar
|
||||
}
|
||||
onStatusClick={() => this.openStatusModal()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
eventBus.unsubscribe(
|
||||
"active-file-change",
|
||||
"statusBarIntegrationSubscription1",
|
||||
);
|
||||
eventBus.unsubscribe(
|
||||
"plugin-settings-changed",
|
||||
"statusBarIntegrationSubscription2",
|
||||
);
|
||||
eventBus.unsubscribe(
|
||||
"plugin-settings-changed",
|
||||
"statusBarIntegrationSubscription3",
|
||||
);
|
||||
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
this.currentLeaf = null;
|
||||
this.noteStatusService = null;
|
||||
StatusBarIntegration.instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default StatusBarIntegration;
|
||||
170
integrations/views/groupped-dashboard-view.tsx
Normal file
170
integrations/views/groupped-dashboard-view.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { ItemView, WorkspaceLeaf, TFile } from "obsidian";
|
||||
import { Root, createRoot } from "react-dom/client";
|
||||
import { GrouppedStatusView as GrouppedStatusViewComponent } from "@/components/GrouppedStatusView/GrouppedStatusView";
|
||||
import { BaseNoteStatusService } from "@/core/noteStatusService";
|
||||
import eventBus from "@/core/eventBus";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
|
||||
export const VIEW_TYPE_GROUPPED_DASHBOARD = "groupped-dashboard-view";
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface StatusItem {
|
||||
name: string;
|
||||
color: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface FilesByStatus {
|
||||
[statusName: string]: FileItem[];
|
||||
}
|
||||
|
||||
interface GroupedByStatus {
|
||||
[frontmatterTag: string]: FilesByStatus;
|
||||
}
|
||||
|
||||
export class GrouppedDashboardView extends ItemView {
|
||||
root: Root | null = null;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf) {
|
||||
super(leaf);
|
||||
}
|
||||
|
||||
getViewType() {
|
||||
return VIEW_TYPE_GROUPPED_DASHBOARD;
|
||||
}
|
||||
|
||||
getDisplayText() {
|
||||
return "Groupped Status Dashboard";
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return "list-tree";
|
||||
}
|
||||
|
||||
private convertTFileToFileItem = (file: TFile): FileItem => ({
|
||||
id: file.path,
|
||||
name: file.basename,
|
||||
path: file.path,
|
||||
});
|
||||
|
||||
private convertStatusToStatusItem = (status: NoteStatus): StatusItem => ({
|
||||
name: status.name,
|
||||
color: status.color || "",
|
||||
icon: status.icon,
|
||||
});
|
||||
|
||||
private getAllFiles = (): FileItem[] => {
|
||||
const tFiles = BaseNoteStatusService.app.vault.getMarkdownFiles();
|
||||
return tFiles.map(this.convertTFileToFileItem);
|
||||
};
|
||||
|
||||
private processFiles = (files: FileItem[]): GroupedByStatus => {
|
||||
const result: GroupedByStatus = {};
|
||||
const statusMetadataKeys = [settingsService.settings.tagPrefix];
|
||||
const availableStatuses =
|
||||
BaseNoteStatusService.getAllAvailableStatuses();
|
||||
const statusMap = new Map(availableStatuses.map((s) => [s.name, s]));
|
||||
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
result[key] = {};
|
||||
availableStatuses.forEach((status) => {
|
||||
result[key][status.name] = [];
|
||||
});
|
||||
});
|
||||
|
||||
files.forEach((file) => {
|
||||
// Find the TFile to get metadata
|
||||
const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath(
|
||||
file.path,
|
||||
) as TFile;
|
||||
if (!tFile) return;
|
||||
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(tFile);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
|
||||
if (!frontmatter) return;
|
||||
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
const value = frontmatter[key];
|
||||
if (value) {
|
||||
const statusNames = Array.isArray(value) ? value : [value];
|
||||
statusNames.forEach((statusName) => {
|
||||
const statusStr = statusName.toString();
|
||||
if (statusMap.has(statusStr)) {
|
||||
if (!result[key][statusStr]) {
|
||||
result[key][statusStr] = [];
|
||||
}
|
||||
result[key][statusStr].push(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
private getAvailableStatuses = (): StatusItem[] => {
|
||||
const statuses = BaseNoteStatusService.getAllAvailableStatuses();
|
||||
return statuses.map(this.convertStatusToStatusItem);
|
||||
};
|
||||
|
||||
private handleFileClick = (file: FileItem) => {
|
||||
const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath(
|
||||
file.path,
|
||||
) as TFile;
|
||||
if (tFile) {
|
||||
const leaf = BaseNoteStatusService.app.workspace.getLeaf();
|
||||
leaf.openFile(tFile);
|
||||
}
|
||||
};
|
||||
|
||||
private subscribeToEvents = (onDataChange: () => void) => {
|
||||
eventBus.subscribe(
|
||||
"frontmatter-manually-changed",
|
||||
onDataChange,
|
||||
"groupped-dashboard-view-subscription",
|
||||
);
|
||||
const unsubscribeActiveFile = BaseNoteStatusService.app.workspace.on(
|
||||
"active-leaf-change",
|
||||
onDataChange,
|
||||
);
|
||||
|
||||
return () => {
|
||||
eventBus.unsubscribe(
|
||||
"frontmatter-manually-changed",
|
||||
"groupped-dashboard-view-subscription",
|
||||
);
|
||||
BaseNoteStatusService.app.workspace.offref(unsubscribeActiveFile);
|
||||
};
|
||||
};
|
||||
|
||||
async onOpen() {
|
||||
const container = this.containerEl.children[1];
|
||||
container.empty();
|
||||
container.addClass("groupped-dashboard-view-container");
|
||||
|
||||
this.root = createRoot(container);
|
||||
this.root.render(
|
||||
<GrouppedStatusViewComponent
|
||||
getAllFiles={this.getAllFiles}
|
||||
processFiles={this.processFiles}
|
||||
onFileClick={this.handleFileClick}
|
||||
subscribeToEvents={this.subscribeToEvents}
|
||||
getAvailableStatuses={this.getAvailableStatuses}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
this.root?.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
}
|
||||
155
integrations/views/groupped-status-view.tsx
Normal file
155
integrations/views/groupped-status-view.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { ItemView, WorkspaceLeaf, TFile } from "obsidian";
|
||||
import { Root, createRoot } from "react-dom/client";
|
||||
import {
|
||||
FileItem,
|
||||
GroupedByStatus,
|
||||
GrouppedStatusView as GrouppedStatusViewComponent,
|
||||
StatusItem,
|
||||
} from "@/components/GrouppedStatusView/GrouppedStatusView";
|
||||
import { BaseNoteStatusService } from "@/core/noteStatusService";
|
||||
import eventBus from "@/core/eventBus";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
|
||||
export const VIEW_TYPE_EXAMPLE = "groupped-status-view";
|
||||
|
||||
export class GrouppedStatusView extends ItemView {
|
||||
root: Root | null = null;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf) {
|
||||
super(leaf);
|
||||
}
|
||||
|
||||
getViewType() {
|
||||
return VIEW_TYPE_EXAMPLE;
|
||||
}
|
||||
|
||||
getDisplayText() {
|
||||
return "Groupped Status View";
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return "list-tree";
|
||||
}
|
||||
|
||||
private convertTFileToFileItem = (file: TFile): FileItem => ({
|
||||
id: file.path,
|
||||
name: file.basename,
|
||||
path: file.path,
|
||||
});
|
||||
|
||||
private convertStatusToStatusItem = (status: NoteStatus): StatusItem => ({
|
||||
name: status.name,
|
||||
color: status.color || "white",
|
||||
icon: status.icon,
|
||||
});
|
||||
|
||||
private getAllFiles = (): FileItem[] => {
|
||||
const tFiles = BaseNoteStatusService.app.vault.getMarkdownFiles();
|
||||
return tFiles.map(this.convertTFileToFileItem);
|
||||
};
|
||||
|
||||
private processFiles = (files: FileItem[]): GroupedByStatus => {
|
||||
const result: GroupedByStatus = {};
|
||||
const statusMetadataKeys = [settingsService.settings.tagPrefix];
|
||||
const availableStatuses =
|
||||
BaseNoteStatusService.getAllAvailableStatuses();
|
||||
const statusMap = new Map(availableStatuses.map((s) => [s.name, s]));
|
||||
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
result[key] = {};
|
||||
availableStatuses.forEach((status) => {
|
||||
result[key][status.name] = [];
|
||||
});
|
||||
});
|
||||
|
||||
files.forEach((file) => {
|
||||
// Find the TFile to get metadata
|
||||
const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath(
|
||||
file.path,
|
||||
) as TFile;
|
||||
if (!tFile) return;
|
||||
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(tFile);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
|
||||
if (!frontmatter) return;
|
||||
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
const value = frontmatter[key];
|
||||
if (value) {
|
||||
const statusNames = Array.isArray(value) ? value : [value];
|
||||
statusNames.forEach((statusName) => {
|
||||
const statusStr = statusName.toString();
|
||||
if (statusMap.has(statusStr)) {
|
||||
if (!result[key][statusStr]) {
|
||||
result[key][statusStr] = [];
|
||||
}
|
||||
result[key][statusStr].push(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
private getAvailableStatuses = (): StatusItem[] => {
|
||||
const statuses = BaseNoteStatusService.getAllAvailableStatuses();
|
||||
return statuses.map(this.convertStatusToStatusItem);
|
||||
};
|
||||
|
||||
private handleFileClick = (file: FileItem) => {
|
||||
const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath(
|
||||
file.path,
|
||||
) as TFile;
|
||||
if (tFile) {
|
||||
const leaf = BaseNoteStatusService.app.workspace.getLeaf();
|
||||
leaf.openFile(tFile);
|
||||
}
|
||||
};
|
||||
|
||||
private subscribeToEvents = (onDataChange: () => void) => {
|
||||
eventBus.subscribe(
|
||||
"frontmatter-manually-changed",
|
||||
onDataChange,
|
||||
"groupped-status-view-subscription",
|
||||
);
|
||||
const unsubscribeActiveFile = BaseNoteStatusService.app.workspace.on(
|
||||
"active-leaf-change",
|
||||
onDataChange,
|
||||
);
|
||||
|
||||
return () => {
|
||||
eventBus.unsubscribe(
|
||||
"frontmatter-manually-changed",
|
||||
"groupped-status-view-subscription",
|
||||
);
|
||||
BaseNoteStatusService.app.workspace.offref(unsubscribeActiveFile);
|
||||
};
|
||||
};
|
||||
|
||||
async onOpen() {
|
||||
const container = this.containerEl.children[1];
|
||||
container.empty();
|
||||
container.addClass("groupped-status-view-container");
|
||||
|
||||
this.root = createRoot(container);
|
||||
this.root.render(
|
||||
<GrouppedStatusViewComponent
|
||||
getAllFiles={this.getAllFiles}
|
||||
processFiles={this.processFiles}
|
||||
onFileClick={this.handleFileClick}
|
||||
subscribeToEvents={this.subscribeToEvents}
|
||||
getAvailableStatuses={this.getAvailableStatuses}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
this.root?.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
}
|
||||
39
integrations/views/status-dashboard-view.tsx
Normal file
39
integrations/views/status-dashboard-view.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { ItemView, WorkspaceLeaf } from "obsidian";
|
||||
import { Root, createRoot } from "react-dom/client";
|
||||
import { StatusDashboard } from "@/components/StatusDashboard/StatusDashboard";
|
||||
|
||||
export const VIEW_TYPE_STATUS_DASHBOARD = "status-dashboard-view";
|
||||
|
||||
export class StatusDashboardView extends ItemView {
|
||||
root: Root | null = null;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf) {
|
||||
super(leaf);
|
||||
}
|
||||
|
||||
getViewType() {
|
||||
return VIEW_TYPE_STATUS_DASHBOARD;
|
||||
}
|
||||
|
||||
getDisplayText() {
|
||||
return "Status Dashboard";
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return "bar-chart-2";
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
const container = this.containerEl.children[1];
|
||||
container.empty();
|
||||
container.addClass("status-dashboard-view-container");
|
||||
|
||||
this.root = createRoot(container);
|
||||
this.root.render(<StatusDashboard />);
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
this.root?.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { WorkspaceIntegration } from "./workspace-integration";
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import { App, TFile, WorkspaceLeaf } from "obsidian";
|
||||
import { NoteStatusSettings } from "../../models/types";
|
||||
import { ToolbarIntegration } from "../editor/toolbar-integration";
|
||||
import { StatusService } from "services/status-service";
|
||||
|
||||
/**
|
||||
* Gestiona la integración con el workspace de Obsidian
|
||||
*/
|
||||
export class WorkspaceIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private toolbarIntegration: ToolbarIntegration;
|
||||
private lastActiveFile: TFile | null = null;
|
||||
private fileOpenEventRef: (file: TFile) => void;
|
||||
private activeLeafChangeEventRef: (file: WorkspaceLeaf) => void;
|
||||
private layoutChangeEventRef: () => void;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
toolbarIntegration: ToolbarIntegration,
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.toolbarIntegration = toolbarIntegration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza la configuración
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra eventos del workspace
|
||||
*/
|
||||
public registerWorkspaceEvents(): void {
|
||||
this.fileOpenEventRef = (file: TFile) => {
|
||||
if (file instanceof TFile) {
|
||||
this.handleFileOpen(file);
|
||||
}
|
||||
};
|
||||
|
||||
this.activeLeafChangeEventRef = (leaf: WorkspaceLeaf) => {
|
||||
this.handleActiveLeafChange(leaf);
|
||||
};
|
||||
|
||||
this.layoutChangeEventRef = () => {
|
||||
this.handleLayoutChange();
|
||||
};
|
||||
|
||||
this.app.workspace.on("file-open", this.fileOpenEventRef);
|
||||
this.app.workspace.on(
|
||||
"active-leaf-change",
|
||||
this.activeLeafChangeEventRef,
|
||||
);
|
||||
this.app.workspace.on("layout-change", this.layoutChangeEventRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja apertura de archivo
|
||||
*/
|
||||
private handleFileOpen(file: TFile): void {
|
||||
// Añade el botón de la barra de herramientas
|
||||
this.toolbarIntegration.addToolbarButtonToActiveLeaf();
|
||||
|
||||
// Actualiza estado
|
||||
this.propagateNoteStatusChange(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja cambio de hoja activa
|
||||
*/
|
||||
private handleActiveLeafChange(leaf: WorkspaceLeaf): void {
|
||||
// Añade el botón de la barra de herramientas
|
||||
this.toolbarIntegration.addToolbarButtonToActiveLeaf();
|
||||
|
||||
//const activeFile = this.app.workspace.getActiveFile();
|
||||
// Solo actualiza si el archivo realmente cambió
|
||||
// if (this.lastActiveFile?.path !== activeFile?.path) {
|
||||
// this.lastActiveFile = activeFile;
|
||||
// this.propagateNoteStatusChange();
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja y propaga el cambio de layout
|
||||
*/
|
||||
private handleLayoutChange(): void {
|
||||
window.dispatchEvent(new CustomEvent("note-status:update-pane"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica y propaga el estado de la nota activa
|
||||
*/
|
||||
private propagateNoteStatusChange(file: TFile): void {
|
||||
try {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
let fileStatuses: string[] = [];
|
||||
if (activeFile && activeFile.extension === "md") {
|
||||
fileStatuses = this.statusService.getFileStatuses(activeFile);
|
||||
}
|
||||
// Dispara evento para que otros componentes se actualicen
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("note-status:status-changed", {
|
||||
detail: { statuses: fileStatuses, file: file },
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error checking note status:", error);
|
||||
}
|
||||
}
|
||||
public unload(): void {
|
||||
if (this.fileOpenEventRef) {
|
||||
this.app.workspace.off("file-open", this.fileOpenEventRef);
|
||||
}
|
||||
if (this.activeLeafChangeEventRef) {
|
||||
this.app.workspace.off(
|
||||
"active-leaf-change",
|
||||
this.activeLeafChangeEventRef,
|
||||
);
|
||||
}
|
||||
if (this.layoutChangeEventRef) {
|
||||
this.app.workspace.off("layout-change", this.layoutChangeEventRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
407
main.tsx
407
main.tsx
|
|
@ -1,274 +1,191 @@
|
|||
// React import needed for JSX compilation
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import React from "react";
|
||||
import { Plugin, Notice } from "obsidian";
|
||||
import { DEFAULT_SETTINGS } from "./constants/defaults";
|
||||
import { NoteStatusSettings } from "./models/types";
|
||||
import { StatusService } from "services/status-service";
|
||||
import { StyleService } from "services/style-service";
|
||||
import { ReactUtils } from "./utils/react-utils";
|
||||
import { Plugin, WorkspaceLeaf } from "obsidian";
|
||||
import StatusBarIntegration from "integrations/status-bar/status-bar";
|
||||
import eventBus from "core/eventBus";
|
||||
import { PluginSettingIntegration } from "./integrations/settings/pluginSettings";
|
||||
import settingsService from "./core/settingsService";
|
||||
import { BaseNoteStatusService } from "./core/noteStatusService";
|
||||
import { StatusModalIntegration } from "./integrations/modals/statusModalIntegration";
|
||||
import ContextMenuIntegration from "./integrations/context-menu/contextMenuIntegration";
|
||||
import { FileExplorerIntegration } from "./integrations/file-explorer/file-explorer-integration";
|
||||
import { CommandsIntegration } from "./integrations/commands/commandsIntegration";
|
||||
import {
|
||||
GrouppedStatusView,
|
||||
VIEW_TYPE_EXAMPLE,
|
||||
} from "./integrations/views/groupped-status-view";
|
||||
import {
|
||||
StatusDashboardView,
|
||||
VIEW_TYPE_STATUS_DASHBOARD,
|
||||
} from "./integrations/views/status-dashboard-view";
|
||||
|
||||
// Importar integraciones
|
||||
import { ExplorerIntegration } from "./integrations/explorer";
|
||||
import { EditorIntegration, ToolbarIntegration } from "./integrations/editor";
|
||||
import { MetadataIntegration } from "./integrations/metadata-cache";
|
||||
import { WorkspaceIntegration } from "./integrations/workspace";
|
||||
import { FileContextMenuIntegration } from "integrations/context-menu/file-context-menu-integration";
|
||||
import { NoteStatusSettingTab } from "integrations/settings/settings-tab";
|
||||
import { CommandIntegration } from "integrations/commands/command-integration";
|
||||
|
||||
// Importar vistas
|
||||
import { StatusPaneViewController } from "./views/status-pane-view/StatusPaneViewController";
|
||||
|
||||
// Importar componentes UI
|
||||
import { StatusBar } from "components/status-bar";
|
||||
import { StatusDropdownManager } from "components/status-dropdown/StatusDropdownManager";
|
||||
import { StatusContextMenu } from "integrations/context-menu/status-context-menu";
|
||||
|
||||
export default class NoteStatus extends Plugin {
|
||||
settings: NoteStatusSettings;
|
||||
|
||||
// Servicios
|
||||
statusService: StatusService;
|
||||
styleService: StyleService;
|
||||
|
||||
// Componentes UI
|
||||
statusBar: StatusBar;
|
||||
statusDropdown: StatusDropdownManager;
|
||||
|
||||
// Integraciones
|
||||
explorerIntegration: ExplorerIntegration;
|
||||
fileContextMenuIntegration: FileContextMenuIntegration;
|
||||
editorIntegration: EditorIntegration;
|
||||
toolbarIntegration: ToolbarIntegration;
|
||||
metadataIntegration: MetadataIntegration;
|
||||
workspaceIntegration: WorkspaceIntegration;
|
||||
commandIntegration: CommandIntegration;
|
||||
|
||||
statusPane: StatusPaneViewController;
|
||||
|
||||
private boundHandleStatusChanged: (event: CustomEvent) => void;
|
||||
export default class NoteStatusPlugin extends Plugin {
|
||||
private statusBarIntegration: StatusBarIntegration;
|
||||
private pluginSettingsIntegration: PluginSettingIntegration;
|
||||
private contextMenuIntegration: ContextMenuIntegration;
|
||||
private fileExplorerIntegration: FileExplorerIntegration;
|
||||
private commandsIntegration: CommandsIntegration;
|
||||
|
||||
async onload() {
|
||||
try {
|
||||
await this.loadSettings();
|
||||
this.initializeServices();
|
||||
this.registerViews();
|
||||
this.initializeUI();
|
||||
this.initializeIntegrations();
|
||||
this.setupCustomEvents();
|
||||
} catch (error) {
|
||||
console.error("Error loading Note Status plugin:", error);
|
||||
new Notice(
|
||||
"Error loading Note Status plugin. Check console for details.",
|
||||
);
|
||||
}
|
||||
}
|
||||
BaseNoteStatusService.initialize(this.app);
|
||||
await this.loadPluginSettings();
|
||||
|
||||
private async loadSettings() {
|
||||
this.settings = Object.assign(
|
||||
{},
|
||||
DEFAULT_SETTINGS,
|
||||
await this.loadData(),
|
||||
// INFO: initialize all integrations
|
||||
Promise.all([
|
||||
this.loadContextMenu(),
|
||||
this.loadStatusBar(),
|
||||
this.loadFileExplorer(),
|
||||
this.loadCommands(),
|
||||
this.loadEventBus(),
|
||||
]);
|
||||
|
||||
this.registerView(
|
||||
VIEW_TYPE_EXAMPLE,
|
||||
(leaf) => new GrouppedStatusView(leaf),
|
||||
);
|
||||
}
|
||||
|
||||
private initializeServices() {
|
||||
this.statusService = new StatusService(this.app, this.settings);
|
||||
this.styleService = new StyleService(this.settings);
|
||||
}
|
||||
this.registerView(
|
||||
VIEW_TYPE_STATUS_DASHBOARD,
|
||||
(leaf) => new StatusDashboardView(leaf),
|
||||
);
|
||||
|
||||
private registerViews() {
|
||||
// Register status pane view
|
||||
this.registerView("status-pane", (leaf) => {
|
||||
this.statusPane = new StatusPaneViewController(leaf, this);
|
||||
return this.statusPane;
|
||||
this.addRibbonIcon("dice", "Activate grouped view", () => {
|
||||
this.activateView();
|
||||
});
|
||||
|
||||
// Add ribbon icon
|
||||
this.addRibbonIcon("tag", "Open status pane", () => {
|
||||
this.openStatusPane();
|
||||
this.addRibbonIcon("bar-chart-2", "Status Dashboard", () => {
|
||||
this.activateDashboard();
|
||||
});
|
||||
}
|
||||
|
||||
// Añadir pestaña de configuración
|
||||
this.addSettingTab(
|
||||
new NoteStatusSettingTab(this.app, this, this.statusService),
|
||||
async onunload() {
|
||||
// Clean up all integrations
|
||||
this.statusBarIntegration?.destroy();
|
||||
this.contextMenuIntegration?.destroy();
|
||||
this.fileExplorerIntegration?.destroy();
|
||||
this.commandsIntegration?.destroy();
|
||||
this.pluginSettingsIntegration?.destroy();
|
||||
|
||||
// Clean up event subscriptions
|
||||
eventBus.unsubscribe(
|
||||
"triggered-open-modal",
|
||||
"main-triggered-open-modal-subscriptor",
|
||||
);
|
||||
}
|
||||
|
||||
private initializeIntegrations() {
|
||||
this.explorerIntegration = new ExplorerIntegration(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
);
|
||||
this.toolbarIntegration = new ToolbarIntegration(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
this.statusDropdown,
|
||||
);
|
||||
const statusContextMenu = new StatusContextMenu(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
this.statusDropdown,
|
||||
this.explorerIntegration,
|
||||
);
|
||||
this.fileContextMenuIntegration = new FileContextMenuIntegration(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
this.explorerIntegration,
|
||||
statusContextMenu,
|
||||
);
|
||||
async activateView() {
|
||||
const { workspace } = this.app;
|
||||
|
||||
this.editorIntegration = new EditorIntegration(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
this.statusDropdown,
|
||||
);
|
||||
this.commandIntegration = new CommandIntegration(
|
||||
this.app,
|
||||
this,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
this.statusDropdown,
|
||||
);
|
||||
this.metadataIntegration = new MetadataIntegration(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
this.explorerIntegration,
|
||||
);
|
||||
let leaf: WorkspaceLeaf | null = null;
|
||||
const leaves = workspace.getLeavesOfType(VIEW_TYPE_EXAMPLE);
|
||||
|
||||
this.workspaceIntegration = new WorkspaceIntegration(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
this.toolbarIntegration,
|
||||
);
|
||||
|
||||
// Registrar eventos en cada integración
|
||||
this.fileContextMenuIntegration.registerFileContextMenuEvents();
|
||||
this.editorIntegration.registerEditorMenus();
|
||||
this.commandIntegration.registerCommands();
|
||||
this.metadataIntegration.registerMetadataEvents();
|
||||
this.workspaceIntegration.registerWorkspaceEvents();
|
||||
}
|
||||
|
||||
private setupCustomEvents() {
|
||||
this.boundHandleStatusChanged = this.handleStatusChanged.bind(this);
|
||||
window.addEventListener(
|
||||
"note-status:status-changed",
|
||||
this.boundHandleStatusChanged,
|
||||
);
|
||||
}
|
||||
|
||||
private initializeUI() {
|
||||
this.statusDropdown = new StatusDropdownManager(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
);
|
||||
|
||||
// Inicializar barra de estado
|
||||
this.statusBar = new StatusBar(
|
||||
this.addStatusBarItem(),
|
||||
this.settings,
|
||||
this.statusService,
|
||||
);
|
||||
|
||||
// Inicializar iconos del explorador (con retraso para evitar ralentizar el inicio)
|
||||
if (this.settings.showStatusIconsInExplorer) {
|
||||
setTimeout(() => {
|
||||
this.explorerIntegration.updateAllFileExplorerIcons();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
private handleStatusChanged(event: CustomEvent) {
|
||||
const { statuses, file } = event.detail;
|
||||
|
||||
// Actualizar barra de estado
|
||||
this.statusBar.update(statuses);
|
||||
// Actualizar toolbar
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (activeFile?.path === file) {
|
||||
this.toolbarIntegration.updateStatusDisplay(statuses);
|
||||
}
|
||||
// Actualizar dropdown
|
||||
this.statusDropdown.update(statuses);
|
||||
// Actualizar status pane si está abierto
|
||||
const statusPaneLeaf =
|
||||
this.app.workspace.getLeavesOfType("status-pane")[0];
|
||||
if (
|
||||
statusPaneLeaf?.view &&
|
||||
statusPaneLeaf.view instanceof StatusPaneViewController
|
||||
) {
|
||||
(statusPaneLeaf.view as StatusPaneViewController).update();
|
||||
}
|
||||
// Actualizar explorador si es necesario
|
||||
if (this.settings.showStatusIconsInExplorer && file) {
|
||||
const fileObj = this.app.vault.getFileByPath(file);
|
||||
if (fileObj) {
|
||||
this.explorerIntegration.updateFileExplorerIcons(fileObj);
|
||||
if (leaves.length > 0) {
|
||||
// A leaf with our view already exists, use that
|
||||
leaf = leaves[0];
|
||||
} else {
|
||||
// Our view could not be found in the workspace, create a new leaf
|
||||
// in the right sidebar for it
|
||||
leaf = workspace.getRightLeaf(false);
|
||||
if (!leaf) {
|
||||
console.error(
|
||||
"getRightLeaf return null, unable to setup the view",
|
||||
);
|
||||
} else {
|
||||
await leaf.setViewState({
|
||||
type: VIEW_TYPE_EXAMPLE,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// "Reveal" the leaf in case it is in a collapsed sidebar
|
||||
if (!leaf) {
|
||||
console.error("leaf not found, unable to activate the view");
|
||||
} else {
|
||||
workspace.revealLeaf(leaf);
|
||||
}
|
||||
}
|
||||
|
||||
private async openStatusPane() {
|
||||
await StatusPaneViewController.open(this.app);
|
||||
}
|
||||
async activateDashboard() {
|
||||
const { workspace } = this.app;
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
let leaf: WorkspaceLeaf | null = null;
|
||||
const leaves = workspace.getLeavesOfType(VIEW_TYPE_STATUS_DASHBOARD);
|
||||
|
||||
// Actualizar servicios
|
||||
this.statusService.updateSettings(this.settings);
|
||||
this.styleService.updateSettings(this.settings);
|
||||
|
||||
// Actualizar integraciones
|
||||
this.explorerIntegration.updateSettings(this.settings);
|
||||
this.fileContextMenuIntegration.updateSettings(this.settings);
|
||||
this.editorIntegration.updateSettings(this.settings);
|
||||
this.metadataIntegration?.updateSettings(this.settings);
|
||||
this.commandIntegration?.updateSettings(this.settings);
|
||||
this.toolbarIntegration.updateSettings(this.settings);
|
||||
this.workspaceIntegration.updateSettings(this.settings);
|
||||
this.statusPane?.updateSettings(this.settings);
|
||||
// Actualizar componentes UI
|
||||
this.statusBar.updateSettings(this.settings);
|
||||
this.statusDropdown.updateSettings(this.settings);
|
||||
}
|
||||
|
||||
onunload() {
|
||||
// Clean up event listeners
|
||||
if (this.boundHandleStatusChanged) {
|
||||
window.removeEventListener(
|
||||
"note-status:status-changed",
|
||||
this.boundHandleStatusChanged,
|
||||
);
|
||||
if (leaves.length > 0) {
|
||||
// A leaf with our view already exists, use that
|
||||
leaf = leaves[0];
|
||||
} else {
|
||||
// Our view could not be found in the workspace, create a new leaf
|
||||
// in the right sidebar for it
|
||||
leaf = workspace.getRightLeaf(false);
|
||||
if (!leaf) {
|
||||
console.error(
|
||||
"getRightLeaf return null, unable to setup the dashboard",
|
||||
);
|
||||
} else {
|
||||
await leaf.setViewState({
|
||||
type: VIEW_TYPE_STATUS_DASHBOARD,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up integrations
|
||||
this.explorerIntegration?.unload();
|
||||
this.toolbarIntegration?.unload();
|
||||
this.fileContextMenuIntegration?.unload();
|
||||
this.workspaceIntegration?.unload();
|
||||
this.metadataIntegration?.unload();
|
||||
this.editorIntegration?.unload();
|
||||
this.commandIntegration?.unload();
|
||||
// "Reveal" the leaf in case it is in a collapsed sidebar
|
||||
if (!leaf) {
|
||||
console.error("leaf not found, unable to activate the dashboard");
|
||||
} else {
|
||||
workspace.revealLeaf(leaf);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up services
|
||||
this.styleService?.unload();
|
||||
private async loadEventBus() {
|
||||
// Propagate to custom event bus the new active file
|
||||
this.app.workspace.on(
|
||||
"active-leaf-change",
|
||||
(leaf: WorkspaceLeaf | null) => {
|
||||
eventBus.publish("active-file-change", { leaf });
|
||||
},
|
||||
);
|
||||
|
||||
// Clean up UI components
|
||||
this.statusBar?.unload();
|
||||
this.statusDropdown?.unload();
|
||||
// Propagate to custom event bus the manually frontmatter data
|
||||
this.registerEvent(
|
||||
this.app.metadataCache.on("changed", (file) => {
|
||||
eventBus.publish("frontmatter-manually-changed", { file });
|
||||
}),
|
||||
);
|
||||
|
||||
// Clean up all React roots
|
||||
ReactUtils.cleanup();
|
||||
// Register listeners
|
||||
eventBus.subscribe(
|
||||
"triggered-open-modal",
|
||||
({ statusService }) => {
|
||||
StatusModalIntegration.open(this.app, statusService);
|
||||
},
|
||||
"main-triggered-open-modal-subscriptor",
|
||||
);
|
||||
}
|
||||
|
||||
async loadPluginSettings() {
|
||||
// INFO: Loads the settings data
|
||||
await settingsService.initialize(this);
|
||||
// INFO: Integrates the plugin settings section
|
||||
this.pluginSettingsIntegration = new PluginSettingIntegration(this);
|
||||
await this.pluginSettingsIntegration.integrate();
|
||||
}
|
||||
|
||||
private async loadContextMenu() {
|
||||
this.contextMenuIntegration = new ContextMenuIntegration(this);
|
||||
await this.contextMenuIntegration.integrate();
|
||||
}
|
||||
private async loadStatusBar() {
|
||||
this.statusBarIntegration = new StatusBarIntegration(this);
|
||||
await this.statusBarIntegration.integrate();
|
||||
}
|
||||
private async loadFileExplorer() {
|
||||
this.fileExplorerIntegration = new FileExplorerIntegration(this);
|
||||
await this.fileExplorerIntegration.integrate();
|
||||
}
|
||||
|
||||
private async loadCommands() {
|
||||
this.commandsIntegration = new CommandsIntegration(this);
|
||||
await this.commandsIntegration.integrate();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
import { TFile, View } from "obsidian";
|
||||
|
||||
declare module "obsidian" {
|
||||
interface WorkspaceLeaf {
|
||||
// TODO: Is deprecated, fix me
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface Commands {
|
||||
removeCommand(id: string): void;
|
||||
}
|
||||
|
||||
interface App {
|
||||
clipboard?: unknown;
|
||||
internalPlugins: InternalPlugins;
|
||||
commands: Commands;
|
||||
}
|
||||
|
||||
interface GlobalSearchPlugin {
|
||||
openGlobalSearch(query: string): void;
|
||||
}
|
||||
|
||||
interface InternalPlugins {
|
||||
getPluginById(
|
||||
id: "global-search",
|
||||
): { instance: GlobalSearchPlugin } | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a status with name, icon and color
|
||||
*/
|
||||
export interface Status {
|
||||
name: string;
|
||||
icon: string;
|
||||
color?: string; // Optional color property
|
||||
description?: string; // Optional description property
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin settings interface
|
||||
*/
|
||||
export interface NoteStatusSettings {
|
||||
statusColors: Record<string, string>;
|
||||
showStatusBar: boolean;
|
||||
autoHideStatusBar: boolean;
|
||||
customStatuses: Status[];
|
||||
showStatusIconsInExplorer: boolean;
|
||||
hideUnknownStatusInExplorer: boolean;
|
||||
collapsedStatuses: Record<string, boolean>;
|
||||
compactView: boolean;
|
||||
enabledTemplates: string[]; // IDs of enabled templates
|
||||
useCustomStatusesOnly: boolean; // Whether to use only custom statuses or include templates
|
||||
useMultipleStatuses: boolean; // Whether to allow multiple statuses per note
|
||||
tagPrefix: string; // Prefix for the status tag (default: 'status')
|
||||
strictStatuses: boolean; // Whether to only show known statuses
|
||||
excludeUnknownStatus: boolean; // Whether to exclude files with unknown status from the status pane
|
||||
quickStatusCommands: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended interface for file explorer view
|
||||
*/
|
||||
export interface FileExplorerView extends View {
|
||||
fileItems: Record<
|
||||
string,
|
||||
{
|
||||
el?: HTMLElement;
|
||||
file?: TFile;
|
||||
titleEl?: HTMLElement;
|
||||
selfEl?: HTMLElement;
|
||||
}
|
||||
>;
|
||||
}
|
||||
481
package-lock.json
generated
481
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,434 +0,0 @@
|
|||
import { App, TFile, Editor, Notice } from "obsidian";
|
||||
import { NoteStatusSettings, Status } from "../models/types";
|
||||
import { PREDEFINED_TEMPLATES } from "../constants/status-templates";
|
||||
|
||||
/**
|
||||
* Service for handling note status operations
|
||||
*/
|
||||
export class StatusService {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private allStatuses: Status[] = [];
|
||||
|
||||
constructor(app: App, settings: NoteStatusSettings) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.updateAllStatuses();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the settings reference and recalculates all statuses
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.updateAllStatuses();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the combined list of all statuses (from templates and custom)
|
||||
*/
|
||||
private updateAllStatuses(): void {
|
||||
// Start with custom statuses
|
||||
this.allStatuses = [...this.settings.customStatuses];
|
||||
|
||||
// Add statuses from enabled templates if not using custom only
|
||||
if (!this.settings.useCustomStatusesOnly) {
|
||||
const templateStatuses = this.getTemplateStatuses();
|
||||
|
||||
for (const status of templateStatuses) {
|
||||
const existingIndex = this.allStatuses.findIndex(
|
||||
(s) => s.name.toLowerCase() === status.name.toLowerCase(),
|
||||
);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
// Add new status
|
||||
this.allStatuses.push(status);
|
||||
} else if (status.color) {
|
||||
// Update color in settings if it doesn't exist
|
||||
if (!this.settings.statusColors[status.name]) {
|
||||
this.settings.statusColors[status.name] = status.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all statuses from enabled templates
|
||||
*/
|
||||
public getTemplateStatuses(): Status[] {
|
||||
return this.settings.enabledTemplates
|
||||
.map((id) => PREDEFINED_TEMPLATES.find((t) => t.id === id))
|
||||
.filter(Boolean)
|
||||
.flatMap((template) => (template ? template.statuses : []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available statuses (combined from templates and custom)
|
||||
*/
|
||||
public getAllStatuses(): Status[] {
|
||||
return this.allStatuses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the statuses of a file from its metadata
|
||||
*/
|
||||
public getFileStatuses(file: TFile): string[] {
|
||||
const cachedMetadata = this.app.metadataCache.getFileCache(file);
|
||||
if (!cachedMetadata?.frontmatter) return ["unknown"];
|
||||
|
||||
const frontmatterStatus =
|
||||
cachedMetadata.frontmatter[this.settings.tagPrefix];
|
||||
if (!frontmatterStatus) return ["unknown"];
|
||||
|
||||
const statuses: string[] = [];
|
||||
|
||||
if (Array.isArray(frontmatterStatus)) {
|
||||
for (const statusName of frontmatterStatus) {
|
||||
if (this.settings.strictStatuses) {
|
||||
this.addValidStatus(statusName.toString(), statuses);
|
||||
} else {
|
||||
const cleanStatus = statusName.toString().trim();
|
||||
if (cleanStatus) statuses.push(cleanStatus);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.settings.strictStatuses) {
|
||||
this.addValidStatus(frontmatterStatus.toString(), statuses);
|
||||
} else {
|
||||
const cleanStatus = frontmatterStatus.toString().trim();
|
||||
if (cleanStatus) statuses.push(cleanStatus);
|
||||
}
|
||||
}
|
||||
|
||||
return statuses.length > 0 ? statuses : ["unknown"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a status to the list if it's valid
|
||||
*/
|
||||
private addValidStatus(statusName: string, targetStatuses: string[]): void {
|
||||
const normalizedStatus = statusName.toLowerCase();
|
||||
const matchingStatus = this.allStatuses.find(
|
||||
(s) => s.name.toLowerCase() === normalizedStatus,
|
||||
);
|
||||
|
||||
if (matchingStatus) {
|
||||
targetStatuses.push(matchingStatus.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the icon for a given status
|
||||
*/
|
||||
public getStatusIcon(status: string): string {
|
||||
const customStatus = this.allStatuses.find(
|
||||
(s) => s.name.toLowerCase() === status.toLowerCase(),
|
||||
);
|
||||
return customStatus ? customStatus.icon : "❓";
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert status metadata in the editor
|
||||
*/
|
||||
public insertStatusMetadataInEditor(editor: Editor): void {
|
||||
const content = editor.getValue();
|
||||
const frontMatterMatch = content.match(/^---\n([\s\S]+?)\n---/);
|
||||
|
||||
// Check if status metadata already exists
|
||||
const statusTagRegex = new RegExp(
|
||||
`${this.settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`,
|
||||
"m",
|
||||
);
|
||||
|
||||
if (frontMatterMatch) {
|
||||
const frontMatter = frontMatterMatch[1];
|
||||
if (frontMatter.match(statusTagRegex)) {
|
||||
// Status already exists, do nothing
|
||||
return;
|
||||
}
|
||||
// Add to existing frontmatter
|
||||
const defaultStatuses = ["unknown"];
|
||||
const statusMetadata = `${this.settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`;
|
||||
this.insertIntoExistingFrontmatter(
|
||||
editor,
|
||||
content,
|
||||
frontMatterMatch,
|
||||
statusMetadata,
|
||||
);
|
||||
} else {
|
||||
// No frontmatter, check if status exists in content somehow
|
||||
if (content.match(statusTagRegex)) {
|
||||
return;
|
||||
}
|
||||
// Create new frontmatter
|
||||
const defaultStatuses = ["unknown"];
|
||||
const statusMetadata = `${this.settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`;
|
||||
this.createFrontmatterWithStatus(editor, content, statusMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert status metadata into existing frontmatter
|
||||
*/
|
||||
private insertIntoExistingFrontmatter(
|
||||
editor: Editor,
|
||||
content: string,
|
||||
frontMatterMatch: RegExpMatchArray,
|
||||
statusMetadata: string,
|
||||
): void {
|
||||
const frontMatter = frontMatterMatch[1];
|
||||
const statusTagRegex = new RegExp(
|
||||
`${this.settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`,
|
||||
"m",
|
||||
);
|
||||
|
||||
const updatedFrontMatter = frontMatter.match(statusTagRegex)
|
||||
? frontMatter.replace(statusTagRegex, statusMetadata)
|
||||
: `${frontMatter}\n${statusMetadata}`;
|
||||
|
||||
const updatedContent = content.replace(
|
||||
/^---\n([\s\S]+?)\n---/,
|
||||
`---\n${updatedFrontMatter}\n---`,
|
||||
);
|
||||
editor.setValue(updatedContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new frontmatter with status metadata
|
||||
*/
|
||||
private createFrontmatterWithStatus(
|
||||
editor: Editor,
|
||||
content: string,
|
||||
statusMetadata: string,
|
||||
): void {
|
||||
const newFrontMatter = `---\n${statusMetadata}\n---\n\n${content.trim()}`;
|
||||
editor.setValue(newFrontMatter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all markdown files with optional filtering
|
||||
*/
|
||||
public getMarkdownFiles(searchQuery = ""): TFile[] {
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
if (!searchQuery) return files;
|
||||
|
||||
const lowerQuery = searchQuery.toLowerCase();
|
||||
return files.filter((file) =>
|
||||
file.basename.toLowerCase().includes(lowerQuery),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group files by their status
|
||||
*/
|
||||
public groupFilesByStatus(searchQuery = ""): Record<string, TFile[]> {
|
||||
const statusGroups: Record<string, TFile[]> = {};
|
||||
|
||||
// Initialize groups for all statuses
|
||||
for (const status of this.allStatuses) {
|
||||
statusGroups[status.name] = [];
|
||||
}
|
||||
|
||||
// Ensure 'unknown' status exists
|
||||
statusGroups["unknown"] = statusGroups["unknown"] || [];
|
||||
|
||||
// Get and process all files matching the search query
|
||||
const files = this.getMarkdownFiles(searchQuery);
|
||||
for (const file of files) {
|
||||
const statuses = this.getFileStatuses(file);
|
||||
|
||||
for (const status of statuses) {
|
||||
if (statusGroups[status]) {
|
||||
statusGroups[status].push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return statusGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralizes all status modification operations
|
||||
*/
|
||||
private async modifyNoteStatus(options: {
|
||||
files: TFile | TFile[];
|
||||
statuses: string | string[];
|
||||
operation: "set" | "add" | "remove" | "toggle";
|
||||
showNotice?: boolean;
|
||||
}): Promise<void> {
|
||||
const { operation, showNotice = true } = options;
|
||||
const targetFiles = Array.isArray(options.files)
|
||||
? options.files
|
||||
: [options.files];
|
||||
const targetStatuses = Array.isArray(options.statuses)
|
||||
? options.statuses
|
||||
: [options.statuses];
|
||||
|
||||
if (targetFiles.length === 0) {
|
||||
if (showNotice) new Notice("No files selected");
|
||||
return;
|
||||
}
|
||||
|
||||
// Process each file
|
||||
const updatePromises = targetFiles.map(async (file) => {
|
||||
if (!file || file.extension !== "md") return;
|
||||
|
||||
// Get current statuses for the file
|
||||
const currentStatuses = this.getFileStatuses(file);
|
||||
let newStatuses: string[] = [];
|
||||
|
||||
switch (operation) {
|
||||
case "set":
|
||||
// Replace all statuses with the new ones
|
||||
newStatuses = [...targetStatuses];
|
||||
break;
|
||||
|
||||
case "add":
|
||||
// Add new statuses without duplicates
|
||||
newStatuses = [
|
||||
...new Set([
|
||||
...currentStatuses.filter((s) => s !== "unknown"),
|
||||
...targetStatuses,
|
||||
]),
|
||||
];
|
||||
break;
|
||||
|
||||
case "remove":
|
||||
// Remove specified statuses
|
||||
newStatuses = currentStatuses.filter(
|
||||
(status) => !targetStatuses.includes(status),
|
||||
);
|
||||
break;
|
||||
|
||||
case "toggle":
|
||||
// Toggle each status (add if not present, remove if present)
|
||||
newStatuses = [...currentStatuses];
|
||||
for (const status of targetStatuses) {
|
||||
if (currentStatuses.includes(status)) {
|
||||
newStatuses = newStatuses.filter(
|
||||
(s) => s !== status,
|
||||
);
|
||||
} else {
|
||||
newStatuses = [
|
||||
...newStatuses.filter((s) => s !== "unknown"),
|
||||
status,
|
||||
];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle empty result (should revert to unknown)
|
||||
if (newStatuses.length === 0) {
|
||||
newStatuses = ["unknown"];
|
||||
}
|
||||
|
||||
// Apply updates to frontmatter
|
||||
await this.app.fileManager.processFrontMatter(
|
||||
file,
|
||||
(frontmatter) => {
|
||||
frontmatter[this.settings.tagPrefix] = newStatuses;
|
||||
},
|
||||
);
|
||||
|
||||
this.notifyStatusChanged(newStatuses, file);
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
// Show notification for batch operations
|
||||
if (showNotice && targetFiles.length > 1) {
|
||||
const statusText =
|
||||
targetStatuses.length === 1
|
||||
? targetStatuses[0]
|
||||
: `${targetStatuses.length} statuses`;
|
||||
const operationText =
|
||||
operation === "set"
|
||||
? "updated"
|
||||
: operation === "add"
|
||||
? "added to"
|
||||
: operation === "remove"
|
||||
? "removed from"
|
||||
: "toggled on";
|
||||
|
||||
new Notice(
|
||||
`${statusText} ${operationText} ${targetFiles.length} files`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles UI-triggered status changes with appropriate logic based on context
|
||||
* This is the central function that all UI components should call for status changes
|
||||
*/
|
||||
public async handleStatusChange(options: {
|
||||
files: TFile | TFile[];
|
||||
statuses: string | string[];
|
||||
isMultipleSelection?: boolean;
|
||||
allowMultipleStatuses?: boolean;
|
||||
operation?: "set" | "add" | "remove" | "toggle";
|
||||
showNotice?: boolean;
|
||||
afterChange?: (updatedStatuses: string[]) => void;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
files,
|
||||
statuses,
|
||||
isMultipleSelection = false,
|
||||
allowMultipleStatuses = this.settings.useMultipleStatuses,
|
||||
operation: explicitOperation,
|
||||
showNotice = isMultipleSelection,
|
||||
} = options;
|
||||
|
||||
const targetFiles = Array.isArray(files) ? files : [files];
|
||||
const targetStatuses = Array.isArray(statuses) ? statuses : [statuses];
|
||||
|
||||
// Determine operation based on context if not explicitly specified
|
||||
let operation: "set" | "add" | "remove" | "toggle";
|
||||
|
||||
if (explicitOperation) {
|
||||
operation = explicitOperation;
|
||||
} else if (isMultipleSelection) {
|
||||
// For multiple files, we need to check if we should add or remove
|
||||
const firstStatus = targetStatuses[0]; // Use first status for multi-file operations
|
||||
const filesWithStatus = targetFiles.filter((file) =>
|
||||
this.getFileStatuses(file).includes(firstStatus),
|
||||
);
|
||||
|
||||
operation =
|
||||
filesWithStatus.length > targetFiles.length / 2
|
||||
? "remove"
|
||||
: "add";
|
||||
} else {
|
||||
// For single file operations
|
||||
if (allowMultipleStatuses) {
|
||||
operation = "toggle";
|
||||
} else {
|
||||
operation = "set";
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the changes
|
||||
await this.modifyNoteStatus({
|
||||
files: targetFiles,
|
||||
statuses: targetStatuses,
|
||||
operation,
|
||||
showNotice,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch status changed event
|
||||
*/
|
||||
private notifyStatusChanged(statuses: string[], file?: TFile): void {
|
||||
// Dispatch the specific status change event
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("note-status:status-changed", {
|
||||
detail: {
|
||||
statuses,
|
||||
file: file?.path,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { NoteStatusSettings } from "../models/types";
|
||||
import { PREDEFINED_TEMPLATES } from "../constants/status-templates";
|
||||
|
||||
/**
|
||||
* Handles dynamic styling for the plugin
|
||||
*/
|
||||
export class StyleService {
|
||||
private dynamicStyleEl?: HTMLStyleElement;
|
||||
private settings: NoteStatusSettings;
|
||||
|
||||
constructor(settings: NoteStatusSettings) {
|
||||
this.settings = settings;
|
||||
this.initializeDynamicStyles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the settings reference
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.updateDynamicStyles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the style element if it doesn't exist
|
||||
*/
|
||||
private initializeDynamicStyles(): void {
|
||||
if (!this.dynamicStyleEl) {
|
||||
this.dynamicStyleEl = document.createElement("style");
|
||||
document.head.appendChild(this.dynamicStyleEl);
|
||||
}
|
||||
this.updateDynamicStyles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the dynamic styles based on current settings
|
||||
*/
|
||||
private updateDynamicStyles(): void {
|
||||
if (!this.dynamicStyleEl) {
|
||||
this.initializeDynamicStyles();
|
||||
return;
|
||||
}
|
||||
|
||||
const allColors = this.getAllStatusColors();
|
||||
const cssRules = this.generateColorCssRules(allColors);
|
||||
this.dynamicStyleEl.textContent = cssRules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all status colors including those from enabled templates
|
||||
*/
|
||||
private getAllStatusColors(): Record<string, string> {
|
||||
const colors = { ...this.settings.statusColors };
|
||||
|
||||
if (this.settings.useCustomStatusesOnly) return colors;
|
||||
|
||||
// Add colors from template statuses
|
||||
for (const templateId of this.settings.enabledTemplates) {
|
||||
const template = PREDEFINED_TEMPLATES.find(
|
||||
(t) => t.id === templateId,
|
||||
);
|
||||
if (!template) continue;
|
||||
|
||||
for (const status of template.statuses) {
|
||||
if (status.color && !colors[status.name]) {
|
||||
colors[status.name] = status.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CSS rules for status colors
|
||||
*/
|
||||
private generateColorCssRules(colors: Record<string, string>): string {
|
||||
let css = "";
|
||||
|
||||
for (const [status, color] of Object.entries(colors)) {
|
||||
css += `
|
||||
.status-${status} {
|
||||
color: ${color} !important;
|
||||
}
|
||||
.note-status-bar .note-status-${status},
|
||||
.nav-file-title .note-status-${status} {
|
||||
color: ${color} !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
return css;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up styles when plugin is unloaded
|
||||
*/
|
||||
public unload(): void {
|
||||
if (this.dynamicStyleEl) {
|
||||
this.dynamicStyleEl.remove();
|
||||
this.dynamicStyleEl = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
1073
styles.css
1073
styles.css
File diff suppressed because one or more lines are too long
|
|
@ -1,94 +0,0 @@
|
|||
/*
|
||||
* Base styles and variables for Note Status Plugin
|
||||
*/
|
||||
:root {
|
||||
--status-transition-time: 0.22s;
|
||||
--status-border-radius: var(--radius-s, 4px);
|
||||
--status-box-shadow: var(--shadow-s, 0 2px 8px rgba(0, 0, 0, 0.15));
|
||||
--status-hover-shadow: var(--shadow-m, 0 4px 12px rgba(0, 0, 0, 0.2));
|
||||
--status-icon-size: 16px;
|
||||
|
||||
/* Animation curves */
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes note-status-scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes note-status-slide-in-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes note-status-fade-in-slide-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes note-status-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
.theme-dark .note-status-popover {
|
||||
box-shadow: var(--shadow-l);
|
||||
}
|
||||
|
||||
.theme-dark .note-status-option-selecting {
|
||||
box-shadow: 0 0 0 1px var(--interactive-accent);
|
||||
}
|
||||
|
||||
/* High contrast improvements */
|
||||
@media (forced-colors: active) {
|
||||
.note-status-chip,
|
||||
.note-status-option.is-selected {
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
|
||||
.note-status-action-button:focus-visible,
|
||||
.note-status-chip:focus-visible,
|
||||
.note-status-option:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.note-status-unified-dropdown,
|
||||
.note-status-bar,
|
||||
.note-status-pane {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
11
styles/components/collapsible-counter.css
Normal file
11
styles/components/collapsible-counter.css
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.collapsible-counter-container {
|
||||
color: var(--text-muted);
|
||||
padding: var(--size-2-1) var(--size-2-3);
|
||||
borderradius: var(--radius-s);
|
||||
backgroundcolor: var(--background-modifier-border);
|
||||
border: 1px solid var(--background-modifier-border-hover);
|
||||
fontweight: var(--font-weight-medium);
|
||||
transition: all var(--anim-duration-fast) ease;
|
||||
minwidth: 20px;
|
||||
textalign: center;
|
||||
}
|
||||
64
styles/components/custom-status-item.css
Normal file
64
styles/components/custom-status-item.css
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
.custom-status-card {
|
||||
background: var(--background-secondary);
|
||||
border-radius: var(--radius-m);
|
||||
padding: var(--size-4-3);
|
||||
margin-bottom: var(--size-4-2);
|
||||
}
|
||||
|
||||
.custom-status-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-4-2);
|
||||
padding: var(--size-4-2);
|
||||
background: var(--background-primary-alt);
|
||||
border-radius: var(--radius-s);
|
||||
border-left: 4px solid var(--text-muted);
|
||||
}
|
||||
|
||||
.custom-status-icon-input {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
font-size: 1.2em;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.custom-status-text-inputs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.custom-status-name-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-normal);
|
||||
font-weight: var(--font-semibold);
|
||||
font-size: var(--font-ui-medium);
|
||||
width: 100%;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.custom-status-description-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.custom-status-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-4-2);
|
||||
}
|
||||
|
||||
.custom-status-color-input {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-s);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.custom-status-remove-btn {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
/*
|
||||
* Dropdown component styles
|
||||
*/
|
||||
.note-status-popover {
|
||||
position: absolute;
|
||||
background: var(--background-primary);
|
||||
border-radius: var(--radius-m);
|
||||
box-shadow: var(--status-box-shadow);
|
||||
z-index: calc(var(--layer-popover) + 10);
|
||||
max-height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
min-width: 200px;
|
||||
overflow: hidden;
|
||||
width: auto !important;
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transition:
|
||||
opacity var(--status-transition-time) var(--ease-out),
|
||||
transform var(--status-transition-time) var(--ease-out);
|
||||
transform-origin: top left;
|
||||
pointer-events: all !important;
|
||||
}
|
||||
|
||||
.note-status-popover.note-status-popover-animate-in {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.note-status-popover.note-status-popover-animate-out {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Search container */
|
||||
.note-status-popover-search {
|
||||
padding: 8px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--background-primary);
|
||||
z-index: 3;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
/* Status options container */
|
||||
.note-status-options-container {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
max-height: 250px;
|
||||
padding: var(--size-4-1, 4px);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--background-modifier-border) transparent;
|
||||
}
|
||||
|
||||
.note-status-options-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.note-status-options-container::-webkit-scrollbar-thumb {
|
||||
background-color: var(--background-modifier-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.note-status-options-container::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Empty status options */
|
||||
.note-status-empty-options {
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-size: var(--font-smaller);
|
||||
}
|
||||
|
||||
/* Status option items */
|
||||
.note-status-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
margin: 2px 4px;
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer !important;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.note-status-option:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.note-status-option.is-selected {
|
||||
background: var(--background-secondary-alt);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.note-status-option-selecting {
|
||||
background-color: var(--interactive-accent) !important;
|
||||
color: var(--text-on-accent) !important;
|
||||
animation: note-status-pulse 0.3s var(--ease-out);
|
||||
}
|
||||
|
||||
.note-status-option-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.note-status-option-text {
|
||||
flex: 1;
|
||||
font-size: var(--font-smaller);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.note-status-option-check {
|
||||
margin-left: auto;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
/* Unified dropdown */
|
||||
.note-status-unified-dropdown {
|
||||
width: 280px;
|
||||
max-width: 90vw;
|
||||
border-radius: var(--radius-m);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-m);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-primary);
|
||||
z-index: 9999 !important;
|
||||
pointer-events: all !important;
|
||||
}
|
||||
|
||||
/* Animation for unified dropdown */
|
||||
.note-status-popover-animate-in {
|
||||
animation: note-status-dropdown-fade-in 0.22s var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
@keyframes note-status-dropdown-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes note-status-dropdown-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
/* Popover header */
|
||||
.note-status-popover-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-secondary-alt);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.note-status-popover-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.note-status-popover-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.note-status-popover-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
background: var(--background-primary-alt);
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
/* Status chips styling */
|
||||
.note-status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-xs);
|
||||
font-size: var(--font-smaller);
|
||||
transition: all 0.15s var(--ease-out);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
max-width: 180px;
|
||||
animation: note-status-scale-in 0.2s var(--ease-out);
|
||||
}
|
||||
|
||||
.note-status-chip.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.note-status-chip.clickable:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--status-hover-shadow);
|
||||
}
|
||||
|
||||
.note-status-chip-removing {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.15s ease-out;
|
||||
}
|
||||
|
||||
.note-status-chip-icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.note-status-chip-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.note-status-chip-remove {
|
||||
margin-left: 6px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--background-modifier-border);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s var(--ease-out);
|
||||
}
|
||||
|
||||
.note-status-chip-remove:hover {
|
||||
background: var(--text-accent);
|
||||
color: var(--text-on-accent);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue