diff --git a/components/FileExplorer/FileExplorerIcon.tsx b/components/FileExplorer/FileExplorerIcon.tsx new file mode 100644 index 0000000..b97d287 --- /dev/null +++ b/components/FileExplorer/FileExplorerIcon.tsx @@ -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 = ({ + 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 ( +
+
onMouseEnter(statuses)} + onMouseLeave={() => onMouseLeave(statuses)} + style={ + { + "--primary-color": + primaryStatus.color || "var(--text-accent)", + } as React.CSSProperties + } + > + {primaryStatus.icon} + {totalStatuses > 1 && ( + {totalStatuses} + )} +
+
+ ); +}; diff --git a/components/GrouppedStatusView/GrouppedStatusView.tsx b/components/GrouppedStatusView/GrouppedStatusView.tsx new file mode 100644 index 0000000..ea18300 --- /dev/null +++ b/components/GrouppedStatusView/GrouppedStatusView.tsx @@ -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({}); + const [searchFilter, setSearchFilter] = useState(""); + const [noteNameFilter, setNoteNameFilter] = useState(""); + const [expandedGroups, setExpandedGroups] = useState>( + new Set(), + ); + const [expandedFiles, setExpandedFiles] = useState>(new Set()); + const [loadedItems, setLoadedItems] = useState>({}); + 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, + 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 ( +
+
+ Loading statuses... +
+
+ ); + } + + return ( +
+ + +
+ {Object.entries(filteredData).map(([tag, statusGroups]) => { + const tagKey = `tag-${tag}`; + const isTagExpanded = expandedGroups.has(tagKey); + + return ( + toggleGroup(tagKey)} + onToggleFiles={toggleFiles} + onFileClick={handleFileClickCallback} + onScroll={handleScroll} + onLoadMore={loadMoreItems} + /> + ); + })} + + {Object.keys(filteredData).length === 0 && ( +
+ {searchFilter + ? "No files match your search." + : "No statuses found."} +
+ )} +
+
+ ); +}; diff --git a/components/GrouppedStatusView/components/FileList.tsx b/components/GrouppedStatusView/components/FileList.tsx new file mode 100644 index 0000000..3b66dcd --- /dev/null +++ b/components/GrouppedStatusView/components/FileList.tsx @@ -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, + 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) => { + onScroll(e, groupKey, files.length); + }, + [onScroll, groupKey, files.length], + ); + + return ( +
+
+ {visibleFiles.map((file) => ( +
handleFileClick(file)} + > + + {file.name} + + + {file.path} + +
+ ))} + + {hasMoreItems && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/components/GrouppedStatusView/components/FilterSection.tsx b/components/GrouppedStatusView/components/FilterSection.tsx new file mode 100644 index 0000000..0790fd2 --- /dev/null +++ b/components/GrouppedStatusView/components/FilterSection.tsx @@ -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 ( +
+

Status Groups

+
+ +
+ onNoteNameFilterChange(e.target.value)} + /> +
+
+
+ ); +}; diff --git a/components/GrouppedStatusView/components/StatusGroup.tsx b/components/GrouppedStatusView/components/StatusGroup.tsx new file mode 100644 index 0000000..a36c481 --- /dev/null +++ b/components/GrouppedStatusView/components/StatusGroup.tsx @@ -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, + 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 ( +
+
+ +
+ +
+
+ + {isExpanded && ( + + )} +
+ ); +}; diff --git a/components/GrouppedStatusView/components/TagSection.tsx b/components/GrouppedStatusView/components/TagSection.tsx new file mode 100644 index 0000000..2940dd9 --- /dev/null +++ b/components/GrouppedStatusView/components/TagSection.tsx @@ -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; + expandedFiles: Set; + getLoadedCount: (groupKey: string) => number; + onToggle: () => void; + onToggleFiles: (groupKey: string) => void; + onFileClick: (file: FileItem) => void; + onScroll: ( + e: React.UIEvent, + 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 ( +
+
+ + +
+ + {isExpanded && ( +
+ {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 ( + handleToggleFiles(groupKey)} + onFileClick={onFileClick} + onScroll={onScroll} + onLoadMore={onLoadMore} + /> + ); + })} +
+ )} +
+ ); +}; diff --git a/components/SettingsController.tsx b/components/SettingsController.tsx deleted file mode 100644 index e54f43f..0000000 --- a/components/SettingsController.tsx +++ /dev/null @@ -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; -} - -export const SettingsController: React.FC = ({ - 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 ; -}; - -export default SettingsController; diff --git a/components/SettingsUI.tsx/BehaviourSettings.tsx b/components/SettingsUI.tsx/BehaviourSettings.tsx new file mode 100644 index 0000000..313c39a --- /dev/null +++ b/components/SettingsUI.tsx/BehaviourSettings.tsx @@ -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 = ({ settings, onChange }) => { + return ( +
+

Status tag

+ + + + onChange("useMultipleStatuses", e.target.checked) + } + /> + + + + { + if (e.target.value.trim()) { + onChange("tagPrefix", e.target.value.trim()); + } + }} + /> + + + + + onChange("strictStatuses", e.target.checked) + } + /> + +
+ ); +}; diff --git a/components/SettingsUI.tsx/CustomStatusItem.tsx b/components/SettingsUI.tsx/CustomStatusItem.tsx new file mode 100644 index 0000000..d27024a --- /dev/null +++ b/components/SettingsUI.tsx/CustomStatusItem.tsx @@ -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 = ({ + status, + index, + onCustomStatusChange, + onCustomStatusRemove, +}) => { + return ( +
+
+ + onCustomStatusChange( + index, + "icon", + e.target.value || "❓", + ) + } + /> +
+ + onCustomStatusChange( + index, + "name", + e.target.value || "unnamed", + ) + } + style={{ color: status.color || "var(--text-normal)" }} + /> + + onCustomStatusChange( + index, + "description", + e.target.value, + ) + } + /> +
+
+ + onCustomStatusChange(index, "color", e.target.value) + } + /> + +
+
+
+ ); +}; diff --git a/components/SettingsUI.tsx/CustomStatusSettings.tsx b/components/SettingsUI.tsx/CustomStatusSettings.tsx new file mode 100644 index 0000000..97456cf --- /dev/null +++ b/components/SettingsUI.tsx/CustomStatusSettings.tsx @@ -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 = ({ + 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 ( +
+

Custom statuses

+ + + + onChange("useCustomStatusesOnly", e.target.checked) + } + /> + + + +
+ {settings.customStatuses.map((status, index) => ( + + ))} +
+
+ + + + +
+ ); +}; diff --git a/components/SettingsUI.tsx/NoStatusesMessage.tsx b/components/SettingsUI.tsx/NoStatusesMessage.tsx new file mode 100644 index 0000000..becb1ae --- /dev/null +++ b/components/SettingsUI.tsx/NoStatusesMessage.tsx @@ -0,0 +1,7 @@ +import React from "react"; + +export const NoStatusesMessage: React.FC = () => ( +
+ No statuses available. Enable templates or add custom statuses first. +
+); diff --git a/components/SettingsUI.tsx/QuickCommandsSettings.tsx b/components/SettingsUI.tsx/QuickCommandsSettings.tsx new file mode 100644 index 0000000..af3fd1d --- /dev/null +++ b/components/SettingsUI.tsx/QuickCommandsSettings.tsx @@ -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 = ({ + 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 ( +
+

Quick status commands

+

+ Select which statuses should have dedicated commands in the + command palette. These can be assigned hotkeys for quick access. +

+
+ {!hasAnyStatuses ? ( + + ) : ( + <> + {groupedStatuses.customStatuses.length > 0 && ( + + )} + {groupedStatuses.templateGroups.map( + ({ template, statuses }) => ( + + ), + )} + + )} +
+
+ ); +}; diff --git a/components/SettingsUI.tsx/SettingItem.tsx b/components/SettingsUI.tsx/SettingItem.tsx new file mode 100644 index 0000000..f3d0415 --- /dev/null +++ b/components/SettingsUI.tsx/SettingItem.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +interface SettingItemProps { + name: string; + description: string; + vertical?: boolean; + children: React.ReactNode; +} + +export const SettingItem: React.FC = ({ + name, + description, + children, + vertical, +}) => ( +
+
+
{name}
+
{description}
+
+
+ {children} +
+
+); diff --git a/components/SettingsUI.tsx/StatusGroup.tsx b/components/SettingsUI.tsx/StatusGroup.tsx new file mode 100644 index 0000000..da3f3d4 --- /dev/null +++ b/components/SettingsUI.tsx/StatusGroup.tsx @@ -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 = ({ + statuses, + title, + description, + currentQuickCommands, + onToggle, +}) => ( +
+
+
{title}
+ {description && ( +
{description}
+ )} +
+
+ {statuses.map((status) => ( + + + onToggle(status.name, e.target.checked) + } + /> + + ))} +
+
+); diff --git a/components/SettingsUI.tsx/TemplateItem.tsx b/components/SettingsUI.tsx/TemplateItem.tsx new file mode 100644 index 0000000..ed7c16b --- /dev/null +++ b/components/SettingsUI.tsx/TemplateItem.tsx @@ -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 = ({ + template, + isEnabled, + onToggle, +}) => ( +
onToggle(template.id, !isEnabled)} + > +
+ + {template.name} +
+
{template.description}:
+
+ {template.statuses.map((status, index) => ( + + ))} +
+
+); diff --git a/components/SettingsUI.tsx/TemplateSettings.tsx b/components/SettingsUI.tsx/TemplateSettings.tsx new file mode 100644 index 0000000..67d6756 --- /dev/null +++ b/components/SettingsUI.tsx/TemplateSettings.tsx @@ -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 = ({ + 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 ( +
+

Status templates

+

+ Enable predefined templates to quickly add common status + workflows +

+
+ {templates.map((template) => ( + + ))} +
+
+ ); +}; diff --git a/components/SettingsUI.tsx/TemplateStatusChip.tsx b/components/SettingsUI.tsx/TemplateStatusChip.tsx new file mode 100644 index 0000000..f83d18b --- /dev/null +++ b/components/SettingsUI.tsx/TemplateStatusChip.tsx @@ -0,0 +1,18 @@ +import { NoteStatus } from "@/types/noteStatus"; +import React from "react"; + +interface StatusChipProps { + status: NoteStatus; +} + +export const TemplateStatusChip: React.FC = ({ status }) => ( +
+ + + {status.icon} {status.name} + +
+); diff --git a/components/SettingsUI.tsx/UISettings.tsx b/components/SettingsUI.tsx/UISettings.tsx new file mode 100644 index 0000000..ceaf2a9 --- /dev/null +++ b/components/SettingsUI.tsx/UISettings.tsx @@ -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 = ({ settings, onChange }) => { + const handleChange = + (key: keyof PluginSettings) => + (e: React.ChangeEvent) => { + onChange(key, e.target.checked); + }; + + return ( +
+

User interface

+ + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/components/SettingsUI.tsx/index.tsx b/components/SettingsUI.tsx/index.tsx new file mode 100644 index 0000000..1a573f9 --- /dev/null +++ b/components/SettingsUI.tsx/index.tsx @@ -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 = ({ 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 ( +
+ + + + + +
+ ); +}; +export default SettingsUI; diff --git a/components/StatusBar/StatusBar.tsx b/components/StatusBar/StatusBar.tsx new file mode 100644 index 0000000..23ad753 --- /dev/null +++ b/components/StatusBar/StatusBar.tsx @@ -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 = ({ + statuses, + hideIfNotStatuses, + onStatusClick, +}) => { + const statusEntries = Object.entries(statuses); + const hasStatuses = statusEntries.flatMap((s) => s[1]).length > 0; + + if (!hasStatuses) { + if (hideIfNotStatuses) return null; + return ; + } + + return ( + +
+ {statusEntries.map(([frontmatterTagName, statusList]) => ( + + ))} +
+
+ ); +}; diff --git a/components/StatusBar/StatusBarContext.tsx b/components/StatusBar/StatusBarContext.tsx new file mode 100644 index 0000000..2b10f8c --- /dev/null +++ b/components/StatusBar/StatusBarContext.tsx @@ -0,0 +1,34 @@ +import { createContext, useContext, ReactNode } from "react"; +import { NoteStatus } from "@/types/noteStatus"; + +interface StatusBarContextValue { + onStatusClick: (status: NoteStatus) => void; +} + +const StatusBarContext = createContext({ + onStatusClick: () => {}, +}); + +export const useStatusBarContext = () => useContext(StatusBarContext); + +export type Props = { + children: ReactNode; + onStatusClick: StatusBarContextValue["onStatusClick"]; +}; + +export const StatusBarProvider: React.FC = ({ + children, + onStatusClick, +}) => { + const value = { + onStatusClick: (status: NoteStatus) => { + onStatusClick(status); + }, + }; + + return ( + + {children} + + ); +}; diff --git a/components/StatusBar/StatusBarEnableButton.tsx b/components/StatusBar/StatusBarEnableButton.tsx new file mode 100644 index 0000000..b3a34ef --- /dev/null +++ b/components/StatusBar/StatusBarEnableButton.tsx @@ -0,0 +1,17 @@ +import { FC } from "react"; + +export type Props = { + onEnableClick: () => void; +}; + +export const StatusBarEnableButton: FC = ({ onEnableClick }) => { + return ( + + 📊 + + ); +}; diff --git a/components/StatusBar/StatusBarGroup.tsx b/components/StatusBar/StatusBarGroup.tsx new file mode 100644 index 0000000..0fc109d --- /dev/null +++ b/components/StatusBar/StatusBarGroup.tsx @@ -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 = ({ + 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 ( + <> + +
+ {visibleStatuses.map((status, i) => ( +
= maxVisible + ? `status-slide-in var(--anim-duration-fast) ease-out ${i * 0.05}s both` + : "none", + }} + title={status.description} + > + { + onStatusClick(status); + }} + /> +
+ ))} + {hiddenCount > 0 && ( + setIsUncollapsed(!isUncollapsed)} + /> + )} +
+ + ); +}; diff --git a/components/StatusBarController.tsx b/components/StatusBarController.tsx deleted file mode 100644 index 57e34db..0000000 --- a/components/StatusBarController.tsx +++ /dev/null @@ -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 = ({ - app, - settings, - statusService, - initialStatuses = ["unknown"], -}) => { - const [currentStatuses, setCurrentStatuses] = - useState(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 ; -}; - -export default StatusBarController; diff --git a/components/StatusDashboard/StatusDashboard.tsx b/components/StatusDashboard/StatusDashboard.tsx new file mode 100644 index 0000000..5b79619 --- /dev/null +++ b/components/StatusDashboard/StatusDashboard.tsx @@ -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; + tagDistribution: Record; + recentChanges: Array<{ + file: TFile; + status: NoteStatus; + timestamp: number; + action: "added" | "removed"; + }>; +} + +interface CurrentNoteInfo { + file: TFile | null; + statuses: Record; + lastModified: number; +} + +export const StatusDashboard = () => { + const [vaultStats, setVaultStats] = useState({ + totalNotes: 0, + notesWithStatus: 0, + statusDistribution: {}, + tagDistribution: {}, + recentChanges: [], + }); + + const [currentNote, setCurrentNote] = useState({ + 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 = {}; + const tagDistribution: Record = {}; + + // 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 ( +
+
+ Loading dashboard... +
+
+ ); + } + + return ( +
+
+

Status Dashboard

+ +
+ +
+ {/* Current Note Section */} +
+
+

Current Note

+
+
+ {currentNote.file ? ( + <> +
+
+ {currentNote.file.basename} +
+
+ {currentNote.file.path} +
+
+ Last modified:{" "} + {new Date( + currentNote.lastModified, + ).toLocaleString()} +
+
+ +
+ {Object.entries(currentNote.statuses).map( + ([tag, statuses]) => ( +
+ +
+ {statuses.length > 0 ? ( + statuses.map( + (status) => ( + + ), + ) + ) : ( + + No status assigned + + )} +
+
+ ), + )} +
+ + ) : ( +
+ No note currently active +
+ )} +
+
+ + {/* Vault Overview Section */} +
+
+

Vault Overview

+
+
+
+
+
+ {vaultStats.totalNotes} +
+
+ Total Notes +
+
+
+
+ {vaultStats.notesWithStatus} +
+
+ Notes with Status +
+
+
+
+ {vaultStats.totalNotes > 0 + ? Math.round( + (vaultStats.notesWithStatus / + vaultStats.totalNotes) * + 100, + ) + : 0} + % +
+
Coverage
+
+
+
+ {Object.values( + vaultStats.statusDistribution, + ).reduce((sum, count) => sum + count, 0)} +
+
+ Total Statuses +
+
+
+
+
+ + {/* Status Distribution Section */} +
+
+

Status Distribution

+
+
+ {statusChart.length > 0 ? ( +
+ {statusChart.map( + ({ name, count, percentage }) => { + const status = statusMap.get(name); + return ( +
+
+ {status && ( + + )} + + {count} notes ( + {percentage}%) + +
+
+
+
+
+ ); + }, + )} +
+ ) : ( +
+ No status data available +
+ )} +
+
+ + {/* Quick Actions Section */} +
+
+

Quick Actions

+
+
+ + + +
+
+
+
+ ); +}; diff --git a/components/StatusFileInfoPopup/StatusFileInfoPopup.tsx b/components/StatusFileInfoPopup/StatusFileInfoPopup.tsx new file mode 100644 index 0000000..4d80e08 --- /dev/null +++ b/components/StatusFileInfoPopup/StatusFileInfoPopup.tsx @@ -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 = ({ statuses }) => { + const statusEntries = Object.entries(statuses); + + if (statusEntries.length === 0) { + return ( +
+ 📋 + No statuses found +
+ ); + } + + return ( +
e.stopPropagation()}> +
+ 📊 + Status Overview +
+ +
+ {statusEntries.map(([groupName, statusList]) => ( +
+
+ + {groupName.toLowerCase()} + + + {statusList.length} + +
+ +
+ {statusList.map((status, index) => ( +
+ + {status.description && ( +
+ {status.description} +
+ )} +
+ ))} +
+
+ ))} +
+
+ ); +}; diff --git a/components/StatusModal/StatusModal.tsx b/components/StatusModal/StatusModal.tsx new file mode 100644 index 0000000..591df9a --- /dev/null +++ b/components/StatusModal/StatusModal.tsx @@ -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; + onSelectStatus: ( + frontmatterTagName: string, + status: NoteStatus, + ) => Promise; +} + +export const StatusModal: React.FC = ({ + 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 ( +
+ + {currentStatuses.map(([frontmatterTagName, statusList]) => ( + + ))} +
+ ); +}; diff --git a/components/StatusModal/StatusModalChip.tsx b/components/StatusModal/StatusModalChip.tsx new file mode 100644 index 0000000..359a9ff --- /dev/null +++ b/components/StatusModal/StatusModalChip.tsx @@ -0,0 +1,74 @@ +import { NoteStatus } from "@/types/noteStatus"; +import { FC, useState } from "react"; + +interface Props { + status: NoteStatus; + onRemove: () => void; +} + +export const StatusModalChip: FC = ({ status, onRemove }) => { + const [isRemoving, setIsRemoving] = useState(false); + + const handleRemove = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsRemoving(true); + setTimeout(() => { + onRemove(); + }, 150); + }; + + return ( +
+ {status.icon} + {status.name} +
+ + + + +
+
+ ); +}; diff --git a/components/StatusModal/StatusModalHeader.tsx b/components/StatusModal/StatusModalHeader.tsx new file mode 100644 index 0000000..81f5f15 --- /dev/null +++ b/components/StatusModal/StatusModalHeader.tsx @@ -0,0 +1,48 @@ +import { FC } from "react"; + +export type Props = { + filesQuantity: number; +}; +export const StatusModalHeader: FC = ({ filesQuantity }) => { + // TODO: Move the style to its css file + return ( +
+
+
+ + + + +
+ Note status + {filesQuantity > 1 && ( + + ({filesQuantity} files) + + )} +
+
+ ); +}; diff --git a/components/StatusModal/StatusModalOption.tsx b/components/StatusModal/StatusModalOption.tsx new file mode 100644 index 0000000..580bde5 --- /dev/null +++ b/components/StatusModal/StatusModalOption.tsx @@ -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 = ({ + status, + isSelected, + onSelect, +}) => { + const [isHovered, setIsHovered] = useState(false); + + const handleClick = () => { + setTimeout(() => { + onSelect(); + }, 150); + }; + + return ( +
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)" + : "", + }} + > + + {status.icon} + + + {status.name} + + {isSelected && ( +
+ + + +
+ )} +
+ ); +}; diff --git a/components/StatusModal/StatusSelector.tsx b/components/StatusModal/StatusSelector.tsx new file mode 100644 index 0000000..d4fb180 --- /dev/null +++ b/components/StatusModal/StatusSelector.tsx @@ -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 = ({ + 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 ( +
+
+
Available statuses
+
+
+
+ {availableStatuses.map((status) => ( + s.name === status.name, + ) !== -1 + } + onSelect={() => handleSelectStatus(status)} + /> + ))} +
+
+
+ ); +}; diff --git a/components/StatusModal/StatusSelectorGroupedByTag.tsx b/components/StatusModal/StatusSelectorGroupedByTag.tsx new file mode 100644 index 0000000..a49bb11 --- /dev/null +++ b/components/StatusModal/StatusSelectorGroupedByTag.tsx @@ -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 = ({ + 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 ( +
+
+
+
Current statuses
+
+
+
+ {currentStatuses.map((s) => ( + handleRemoveStatus(s)} + /> + ))} +
+
+
+ setSearchFilter(value)} + /> + {filteredStatuses.length === 0 ? ( +
+ {searchFilter + ? `No statuses match "${searchFilter}"` + : "No statuses found"} +
+ ) : ( + + selected + ? handleSelectStatus(status) + : handleRemoveStatus(status) + } + /> + )} +
+ ); +}; diff --git a/components/StatusPaneViewController.tsx b/components/StatusPaneViewController.tsx deleted file mode 100644 index c7051c4..0000000 --- a/components/StatusPaneViewController.tsx +++ /dev/null @@ -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; -} - -export const StatusPaneViewController: React.FC< - StatusPaneViewControllerProps -> = ({ app, settings, statusService, onSettingsChange }) => { - const [searchQuery, setSearchQuery] = useState(""); - const [paginationState, setPaginationState] = useState({ - itemsPerPage: 100, - currentPage: {}, - }); - const [collapsedStatuses, setCollapsedStatuses] = useState< - Record - >({}); - const [statusGroups, setStatusGroups] = useState>( - {}, - ); - 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 ( - - ); -}; - -export default StatusPaneViewController; diff --git a/components/ToolbarButton.tsx b/components/ToolbarButton.tsx deleted file mode 100644 index c734c7c..0000000 --- a/components/ToolbarButton.tsx +++ /dev/null @@ -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 = ({ - statuses, - settings, - statusService, -}) => { - const hasValidStatus = statuses.length > 0 && statuses[0] !== "unknown"; - - if (hasValidStatus) { - const primaryStatus = statuses[0]; - const icon = statusService.getStatusIcon(primaryStatus); - - return ( -
- - {icon} - - {settings.useMultipleStatuses && statuses.length > 1 && ( - - {statuses.length} - - )} -
- ); - } - - return ( -
- - {statusService.getStatusIcon("unknown")} - -
- ); -}; - -export const ToolbarButton: React.FC = ({ - settings, - statusService, - statuses, - onClick, - className = "", -}) => { - const buttonRef = useRef(null); - - const finalClassName = - `note-status-toolbar-button clickable-icon view-action ${className}`.trim(); - - return ( - - ); -}; - -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; diff --git a/components/atoms/CollapsibleCounter.tsx b/components/atoms/CollapsibleCounter.tsx new file mode 100644 index 0000000..b5519d8 --- /dev/null +++ b/components/atoms/CollapsibleCounter.tsx @@ -0,0 +1,25 @@ +import { FC } from "react"; + +export interface CollapsibleCounterProps { + count: number; + isCollapsed: boolean; + onToggle: React.MouseEventHandler; +} + +export const CollapsibleCounter: FC = ({ + count, + isCollapsed, + onToggle, +}) => { + if (count <= 0) return null; + + return ( + + {isCollapsed ? "+" : "-"} + {count} + + ); +}; diff --git a/components/atoms/GroupLabel.tsx b/components/atoms/GroupLabel.tsx new file mode 100644 index 0000000..aa8f727 --- /dev/null +++ b/components/atoms/GroupLabel.tsx @@ -0,0 +1,42 @@ +import { FC } from "react"; + +export interface GroupLabelProps { + name: string; + isHighlighted?: boolean; +} + +export const GroupLabel: FC = ({ + name, + isHighlighted = false, +}) => { + if (!name) return null; + + return ( + + + + + {name} + + ); +}; diff --git a/components/atoms/SearchFilter.tsx b/components/atoms/SearchFilter.tsx new file mode 100644 index 0000000..f3024be --- /dev/null +++ b/components/atoms/SearchFilter.tsx @@ -0,0 +1,46 @@ +import { FC, useEffect, useRef, useState } from "react"; + +export type Props = { + value: string; + onFilterChange: (value: string) => void; +}; +export const SearchFilter: FC = ({ value, onFilterChange }) => { + const [searchFilter, setSearchFilter] = useState(value); + const searchInputRef = useRef(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 ( +
+
+
Filter statuses
+
+
+ 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)", + }} + /> +
+
+ ); +}; diff --git a/components/atoms/StatusBadge.tsx b/components/atoms/StatusBadge.tsx new file mode 100644 index 0000000..4f3dab1 --- /dev/null +++ b/components/atoms/StatusBadge.tsx @@ -0,0 +1,25 @@ +import { FC } from "react"; +import { NoteStatus } from "@/types/noteStatus"; + +export interface StatusBadgeProps { + status: NoteStatus; + onClick?: () => void; +} + +export const StatusBadge: FC = ({ status, onClick }) => { + return ( +
+
+ {status.icon} + {status.name} +
+
+ ); +}; diff --git a/components/index.ts b/components/index.ts deleted file mode 100644 index 10b8692..0000000 --- a/components/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { StatusBarController } from "./StatusBarController"; -export { SettingsController } from "./SettingsController"; -export { StatusPaneViewController } from "./StatusPaneViewController"; diff --git a/components/status-bar/StatusBarComponent.tsx b/components/status-bar/StatusBarComponent.tsx deleted file mode 100644 index c74f70b..0000000 --- a/components/status-bar/StatusBarComponent.tsx +++ /dev/null @@ -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 = ({ - statuses, - isVisible, - className = "", -}) => { - const containerRef = React.useRef(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) => ( - - (statusRefs.current[index * 2] = el)} - className={`note-status-${status.name}`} - > - Status: {status.name} - - (statusRefs.current[index * 2 + 1] = el)} - className={`note-status-icon status-${status.name}`} - > - {status.icon} - - - ); - - const renderMultipleStatuses = () => ( - - Statuses: - - {statuses.map((status, index) => ( - (statusRefs.current[index] = el)} - className={`note-status-badge status-${status.name}`} - > - - {status.icon} - - - {status.name} - - - ))} - - - ); - - return ( -
- {statuses.length === 1 - ? renderSingleStatus(statuses[0], 0) - : renderMultipleStatuses()} -
- ); -}; diff --git a/components/status-bar/StatusBarView.tsx b/components/status-bar/StatusBarView.tsx deleted file mode 100644 index 4fa7646..0000000 --- a/components/status-bar/StatusBarView.tsx +++ /dev/null @@ -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 = ({ status }) => { - const badgeRef = useRef(null); - - useEffect(() => { - if (badgeRef.current) { - setTooltip(badgeRef.current, status.tooltipText); - } - }, [status.tooltipText]); - - return ( - - {status.icon} - {status.name} - - ); -}; - -interface SingleStatusProps { - status: StatusBarStatus; -} - -const SingleStatus: React.FC = ({ status }) => { - const statusTextRef = useRef(null); - const statusIconRef = useRef(null); - - useEffect(() => { - if (statusTextRef.current) { - setTooltip(statusTextRef.current, status.tooltipText); - } - if (statusIconRef.current) { - setTooltip(statusIconRef.current, status.tooltipText); - } - }, [status.tooltipText]); - - return ( - <> - - Status: {status.name} - - - {status.icon} - - - ); -}; - -interface MultipleStatusesProps { - statuses: StatusBarStatus[]; -} - -const MultipleStatuses: React.FC = ({ statuses }) => { - return ( - <> - Statuses: - - {statuses.map((status, index) => ( - - ))} - - - ); -}; - -export const StatusBarView: React.FC = ({ - statuses, - isVisible, - className = "", -}) => { - const containerRef = useRef(null); - - const baseClasses = "note-status-bar"; - const visibilityClasses = isVisible ? "visible" : "hidden"; - const finalClassName = - `${baseClasses} ${visibilityClasses} ${className}`.trim(); - - if (!isVisible) { - return
; - } - - return ( -
- {statuses.length === 1 ? ( - - ) : ( - - )} -
- ); -}; - -export default StatusBarView; diff --git a/components/status-bar/index.ts b/components/status-bar/index.ts deleted file mode 100644 index 6338305..0000000 --- a/components/status-bar/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { StatusBarController } from "./status-bar-controller"; - -export { StatusBarController as StatusBar }; -export default StatusBarController; diff --git a/components/status-bar/status-bar-controller.ts b/components/status-bar/status-bar-controller.ts deleted file mode 100644 index 13aa2df..0000000 --- a/components/status-bar/status-bar-controller.ts +++ /dev/null @@ -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); - } -} diff --git a/components/status-dropdown/DropdownUI.tsx b/components/status-dropdown/DropdownUI.tsx deleted file mode 100644 index bf92874..0000000 --- a/components/status-dropdown/DropdownUI.tsx +++ /dev/null @@ -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 = ({ - status, - isSelected, - onSelect, - onRemove, - showRemove, -}) => { - return ( -
-
- {status.icon} - {status.name} - {status.description && ( - - {status.description} - - )} -
- {showRemove && isSelected && onRemove && ( - - )} -
- ); -}; - -interface DropdownContentProps { - settings: NoteStatusSettings; - statusService: StatusService; - currentStatuses: string[]; - targetFile: TFile | null; - targetFiles: TFile[]; - onRemoveStatus: StatusRemoveHandler; - onSelectStatus: StatusSelectHandler; -} - -const DropdownContent: React.FC = ({ - 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 ( -
-

No statuses available

-

- Configure statuses in plugin settings -

-
- ); - } - - return ( -
- {isMultipleFiles && ( -
-

Updating {targetFiles.length} files

-
- )} - -
- {allStatuses.map((status) => { - const isSelected = currentStatuses.includes(status.name); - return ( - handleStatusSelect(status.name)} - onRemove={() => handleStatusRemove(status.name)} - showRemove={settings.useMultipleStatuses} - /> - ); - })} -
-
- ); -}; - -export const DropdownUI: React.FC = ({ - app, - statusService, - settings, - currentStatuses, - targetFile, - targetFiles, - isOpen, - onRemoveStatus, - onSelectStatus, - onClose, - targetElement, - position, -}) => { - const dropdownRef = useRef(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 ( -
- -
- ); -}; - -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(); - } -} diff --git a/components/status-dropdown/StatusDropdownComponent.tsx b/components/status-dropdown/StatusDropdownComponent.tsx deleted file mode 100644 index f5ea535..0000000 --- a/components/status-dropdown/StatusDropdownComponent.tsx +++ /dev/null @@ -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; - onSelectStatus: (status: string, target: TFile[]) => Promise; - onClose: () => void; -} - -export const StatusDropdownComponent: React.FC = ({ - isOpen, - currentStatuses, - targetFiles, - settings, - statusService, - onRemoveStatus, - onSelectStatus, - onClose, -}) => { - const [searchFilter, setSearchFilter] = useState(""); - const searchInputRef = useRef(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 ( -
- {/* Header */} -
-
-
- - - - -
- - Note status - - {targetFiles.length > 1 && ( - - ({targetFiles.length} files) - - )} -
-
- - {/* Current Status Chips */} -
- {hasNoValidStatus ? ( -
- No status assigned -
- ) : ( - currentStatuses - .filter((status) => status !== "unknown") - .map((status) => { - const statusObj = allStatuses.find( - (s) => s.name === status, - ); - if (!statusObj) return null; - - return ( - handleRemoveStatus(status)} - /> - ); - }) - )} -
- - {/* Search Filter */} -
- setSearchFilter(e.target.value)} - /> -
- - {/* Status Options */} -
- {filteredStatuses.length === 0 ? ( -
- {searchFilter - ? `No statuses match "${searchFilter}"` - : "No statuses found"} -
- ) : ( - filteredStatuses.map((status) => ( - handleSelectStatus(status.name)} - /> - )) - )} -
-
- ); -}; - -interface StatusChipProps { - status: Status; - onRemove: () => void; -} - -const StatusChip: React.FC = ({ status, onRemove }) => { - const chipRef = useRef(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 ( -
- {status.icon} - {status.name} -
- - - - -
-
- ); -}; - -interface StatusOptionProps { - status: Status; - isSelected: boolean; - onSelect: () => void; -} - -const StatusOption: React.FC = ({ - status, - isSelected, - onSelect, -}) => { - const optionRef = useRef(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 ( -
- {status.icon} - {status.name} - {isSelected && ( -
- - - -
- )} -
- ); -}; diff --git a/components/status-dropdown/StatusDropdownManager.tsx b/components/status-dropdown/StatusDropdownManager.tsx deleted file mode 100644 index c27d6f2..0000000 --- a/components/status-dropdown/StatusDropdownManager.tsx +++ /dev/null @@ -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 { - 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 { - 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(); - } -} diff --git a/components/status-dropdown/dropdown-events.ts b/components/status-dropdown/dropdown-events.ts deleted file mode 100644 index 55942df..0000000 --- a/components/status-dropdown/dropdown-events.ts +++ /dev/null @@ -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); -} diff --git a/components/status-dropdown/dropdown-manager.ts b/components/status-dropdown/dropdown-manager.ts deleted file mode 100644 index 91d7571..0000000 --- a/components/status-dropdown/dropdown-manager.ts +++ /dev/null @@ -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; - } - } -} diff --git a/components/status-dropdown/dropdown-position.ts b/components/status-dropdown/dropdown-position.ts deleted file mode 100644 index 751b38f..0000000 --- a/components/status-dropdown/dropdown-position.ts +++ /dev/null @@ -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; -} diff --git a/components/status-dropdown/index.ts b/components/status-dropdown/index.ts deleted file mode 100644 index ec05d5b..0000000 --- a/components/status-dropdown/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { StatusDropdownManager } from "./StatusDropdownManager"; -import { DropdownOptions } from "./types"; - -export type { DropdownOptions }; -export { StatusDropdownManager as StatusDropdown }; -export default StatusDropdownManager; diff --git a/components/status-dropdown/types.ts b/components/status-dropdown/types.ts deleted file mode 100644 index 08af411..0000000 --- a/components/status-dropdown/types.ts +++ /dev/null @@ -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; - -/** - * Status selection handler function type - */ -export type StatusSelectHandler = ( - status: string, - targetFile: TFile | TFile[], -) => Promise; - -/** - * Common dependencies for dropdown components - */ -export interface DropdownDependencies { - app: App; - settings: NoteStatusSettings; - statusService: StatusService; -} diff --git a/constants/status-templates.ts b/constants/defaultSettings.ts similarity index 70% rename from constants/status-templates.ts rename to constants/defaultSettings.ts index 2cceac3..75af3fd 100644 --- a/constants/status-templates.ts +++ b/constants/defaultSettings.ts @@ -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 +}; diff --git a/constants/defaults.ts b/constants/defaults.ts deleted file mode 100644 index 71c1fbb..0000000 --- a/constants/defaults.ts +++ /dev/null @@ -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 = { - 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 -}; diff --git a/constants/icons.ts b/constants/icons.ts deleted file mode 100644 index 00d4571..0000000 --- a/constants/icons.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * SVG icon definitions - */ -export const ICONS = { - statusPane: ` - - `, - search: ` - - - `, - clear: ` - - - `, - standardView: ``, - compactView: ``, - refresh: ``, - collapseDown: ``, - collapseRight: ``, - file: ``, -}; diff --git a/core/commandsService.ts b/core/commandsService.ts new file mode 100644 index 0000000..2df897d --- /dev/null +++ b/core/commandsService.ts @@ -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 = 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 { + 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 { + 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(); + } +} diff --git a/core/eventBus.ts b/core/eventBus.ts new file mode 100644 index 0000000..57cdf0c --- /dev/null +++ b/core/eventBus.ts @@ -0,0 +1,50 @@ +import { EventBusEvents, EventName } from "@/types/eventBus"; + +class EventBus { + private events: { [K in EventName]: Map } = { + "active-file-change": new Map(), + "plugin-settings-changed": new Map(), + "frontmatter-manually-changed": new Map(), + "triggered-open-modal": new Map(), + }; + + subscribe( + 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 + ): void; + publish( + event: "plugin-settings-changed", + ...args: Parameters + ): void; + publish( + event: "frontmatter-manually-changed", + ...args: Parameters + ): void; + publish( + event: "triggered-open-modal", + ...args: Parameters + ): void; + publish(event: EventName, ...args: unknown[]) { + const callbacks = this.events[event]; + callbacks.forEach((callback) => { + (callback as (...args: unknown[]) => void)(...args); + }); + } +} + +export default new EventBus(); diff --git a/core/lazyElementObserver.ts b/core/lazyElementObserver.ts new file mode 100644 index 0000000..298aa4b --- /dev/null +++ b/core/lazyElementObserver.ts @@ -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(); + 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(); + } +} diff --git a/core/noteStatusService.ts b/core/noteStatusService.ts new file mode 100644 index 0000000..56a98c0 --- /dev/null +++ b/core/noteStatusService.ts @@ -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 { + 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; + abstract addStatus( + frontmatterTagName: string, + statusIdentifier: NoteStatus["name"], + ): Promise; +} + +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 { + 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 { + 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 { + 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 { + 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>(); + + 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 { + 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 { + 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; + }); + } +} diff --git a/core/selectorService.ts b/core/selectorService.ts new file mode 100644 index 0000000..2775ab3 --- /dev/null +++ b/core/selectorService.ts @@ -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; diff --git a/core/settingsService.ts b/core/settingsService.ts new file mode 100644 index 0000000..b570e45 --- /dev/null +++ b/core/settingsService.ts @@ -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 { + 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(); diff --git a/hooks/index.ts b/hooks/index.ts deleted file mode 100644 index 99dea2b..0000000 --- a/hooks/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { useStatusService } from "./useStatusService"; -export { useStyleService } from "./useStyleService"; -export { useDropdownManager } from "./useDropdownManager"; diff --git a/hooks/useDropdownManager.tsx b/hooks/useDropdownManager.tsx deleted file mode 100644 index 8758ee8..0000000 --- a/hooks/useDropdownManager.tsx +++ /dev/null @@ -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([ - "unknown", - ]); - const dropdownUIRef = useRef(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, - }; -}; diff --git a/hooks/useStatusService.tsx b/hooks/useStatusService.tsx deleted file mode 100644 index 42ec340..0000000 --- a/hooks/useStatusService.tsx +++ /dev/null @@ -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([]); - - 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 => { - const statusGroups: Record = {}; - - 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 => { - 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 => { - 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, - ], - ); -}; diff --git a/hooks/useStyleService.tsx b/hooks/useStyleService.tsx deleted file mode 100644 index 577347a..0000000 --- a/hooks/useStyleService.tsx +++ /dev/null @@ -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(null); - - const getAllStatusColors = useCallback((): Record => { - 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 => { - 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, - }; -}; diff --git a/integrations/commands/command-integration.ts b/integrations/commands/command-integration.ts deleted file mode 100644 index efa9201..0000000 --- a/integrations/commands/command-integration.ts +++ /dev/null @@ -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}`, - ); - }); - } -} diff --git a/integrations/commands/commandsIntegration.ts b/integrations/commands/commandsIntegration.ts new file mode 100644 index 0000000..3e3d4db --- /dev/null +++ b/integrations/commands/commandsIntegration.ts @@ -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 { + // Register all commands from the service + this.commandsService.registerAllCommands(); + } + + destroy(): void { + if (this.commandsService) { + this.commandsService.unload(); + this.commandsService.destroy(); + } + CommandsIntegration.instance = null; + } +} diff --git a/integrations/context-menu/contextMenuIntegration.tsx b/integrations/context-menu/contextMenuIntegration.tsx new file mode 100644 index 0000000..5d5c463 --- /dev/null +++ b/integrations/context-menu/contextMenuIntegration.tsx @@ -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; diff --git a/integrations/context-menu/file-context-menu-integration.ts b/integrations/context-menu/file-context-menu-integration.ts deleted file mode 100644 index c92c06d..0000000 --- a/integrations/context-menu/file-context-menu-integration.ts +++ /dev/null @@ -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); - } - } -} diff --git a/integrations/context-menu/status-context-menu.ts b/integrations/context-menu/status-context-menu.ts deleted file mode 100644 index 807fc23..0000000 --- a/integrations/context-menu/status-context-menu.ts +++ /dev/null @@ -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, - }); - } - } -} diff --git a/integrations/editor/editor-integration.ts b/integrations/editor/editor-integration.ts deleted file mode 100644 index 3997a70..0000000 --- a/integrations/editor/editor-integration.ts +++ /dev/null @@ -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); - } - } -} diff --git a/integrations/editor/index.ts b/integrations/editor/index.ts deleted file mode 100644 index c0393ff..0000000 --- a/integrations/editor/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { EditorIntegration } from "./editor-integration"; -export { ToolbarIntegration } from "./toolbar-integration"; diff --git a/integrations/editor/toolbar-integration.ts b/integrations/editor/toolbar-integration.ts deleted file mode 100644 index c7cef89..0000000 --- a/integrations/editor/toolbar-integration.ts +++ /dev/null @@ -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(); - } -} diff --git a/integrations/explorer/explorer-integration.ts b/integrations/explorer/explorer-integration.ts deleted file mode 100644 index 8c60027..0000000 --- a/integrations/explorer/explorer-integration.ts +++ /dev/null @@ -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(); - private isProcessingQueue = false; - private debouncedUpdateAll: ReturnType; - 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 { - 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 { - 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 { - 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); - } -} diff --git a/integrations/explorer/index.ts b/integrations/explorer/index.ts deleted file mode 100644 index 8cccce5..0000000 --- a/integrations/explorer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ExplorerIntegration } from "./explorer-integration"; diff --git a/integrations/file-explorer/file-explorer-integration.tsx b/integrations/file-explorer/file-explorer-integration.tsx new file mode 100644 index 0000000..3787d12 --- /dev/null +++ b/integrations/file-explorer/file-explorer-integration.tsx @@ -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 { + 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( + 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, + ); + } +} diff --git a/integrations/metadata-cache/index.ts b/integrations/metadata-cache/index.ts deleted file mode 100644 index 92e849e..0000000 --- a/integrations/metadata-cache/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MetadataIntegration } from "./metadata-integration"; diff --git a/integrations/metadata-cache/metadata-integration.ts b/integrations/metadata-cache/metadata-integration.ts deleted file mode 100644 index 07fe629..0000000 --- a/integrations/metadata-cache/metadata-integration.ts +++ /dev/null @@ -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); - } - } -} diff --git a/integrations/modals/statusModalIntegration.tsx b/integrations/modals/statusModalIntegration.tsx new file mode 100644 index 0000000..dc1ce03 --- /dev/null +++ b/integrations/modals/statusModalIntegration.tsx @@ -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( + , + ); + } + + onClose() { + if (this.root) { + this.root.unmount(); + this.root = null; + } + const { contentEl } = this; + contentEl.empty(); + StatusModalIntegration.instance = null; + + eventBus.unsubscribe( + "frontmatter-manually-changed", + "statusModalIntegrationSubscription1", + ); + } +} diff --git a/integrations/popups/statusesInfoPopupIntegration.tsx b/integrations/popups/statusesInfoPopupIntegration.tsx new file mode 100644 index 0000000..b35ce00 --- /dev/null +++ b/integrations/popups/statusesInfoPopupIntegration.tsx @@ -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(); + } + + private destroy() { + if (this.root) { + this.root.unmount(); + this.root = null; + } + if (this.element) { + this.element.remove(); + this.element = null; + } + } +} diff --git a/integrations/settings/SettingsUI.tsx b/integrations/settings/SettingsUI.tsx deleted file mode 100644 index e38ad0a..0000000 --- a/integrations/settings/SettingsUI.tsx +++ /dev/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 = ({ - settings, - callbacks, -}) => { - const handleTemplateToggle = useCallback( - (templateId: string, enabled: boolean) => { - callbacks.onTemplateToggle(templateId, enabled); - }, - [callbacks], - ); - - return ( -
-

Status templates

-

- Enable predefined templates to quickly add common status - workflows -

- -
- {PREDEFINED_TEMPLATES.map((template) => { - const isEnabled = settings.enabledTemplates.includes( - template.id, - ); - - return ( -
-
- - handleTemplateToggle( - template.id, - e.target.checked, - ) - } - /> - - {template.name} - -
-
- {template.description} -
-
- {template.statuses.map((status, index) => ( -
- - - {status.icon} {status.name} - -
- ))} -
-
- ); - })} -
-
- ); -}; - -interface UISettingsProps { - settings: NoteStatusSettings; - callbacks: SettingsUICallbacks; -} - -const UISettings: React.FC = ({ settings, callbacks }) => { - return ( -
-

User interface

- -
-
-
Show status bar
-
- Display the status bar -
-
-
- - callbacks.onSettingChange( - "showStatusBar", - e.target.checked, - ) - } - /> -
-
- -
-
-
- Auto-hide status bar -
-
- Hide the status bar when status is unknown -
-
-
- - callbacks.onSettingChange( - "autoHideStatusBar", - e.target.checked, - ) - } - /> -
-
- -
-
-
- Show status icons in file explorer -
-
- Display status icons in the file explorer -
-
-
- - callbacks.onSettingChange( - "showStatusIconsInExplorer", - e.target.checked, - ) - } - /> -
-
- -
-
-
- Hide unknown status in file explorer -
-
- Hide status icons for files with unknown status in the - file explorer -
-
-
- - callbacks.onSettingChange( - "hideUnknownStatusInExplorer", - e.target.checked, - ) - } - /> -
-
- -
-
-
- Default to compact view -
-
- Start the status pane in compact view by default -
-
-
- - callbacks.onSettingChange( - "compactView", - e.target.checked, - ) - } - /> -
-
- -
-
-
- Exclude unassigned notes from status pane -
-
- Improves performance by excluding notes with no assigned - status from the status pane. Recommended for large - vaults. -
-
-
- - callbacks.onSettingChange( - "excludeUnknownStatus", - e.target.checked, - ) - } - /> -
-
-
- ); -}; - -interface TagSettingsProps { - settings: NoteStatusSettings; - callbacks: SettingsUICallbacks; -} - -const TagSettings: React.FC = ({ settings, callbacks }) => { - return ( -
-

Status tag

- -
-
-
- Enable multiple statuses -
-
- Allow notes to have multiple statuses at the same time -
-
-
- - callbacks.onSettingChange( - "useMultipleStatuses", - e.target.checked, - ) - } - /> -
-
- -
-
-
Status tag prefix
-
- The YAML frontmatter tag name used for status (default: - obsidian-note-status) -
-
-
- { - if (e.target.value.trim()) { - callbacks.onSettingChange( - "tagPrefix", - e.target.value.trim(), - ); - } - }} - /> -
-
- -
-
-
- Strict status validation -
-
- 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. -
-
-
- - callbacks.onSettingChange( - "strictStatuses", - e.target.checked, - ) - } - /> -
-
-
- ); -}; - -interface CustomStatusItemProps { - status: Status; - index: number; - settings: NoteStatusSettings; - callbacks: SettingsUICallbacks; -} - -const CustomStatusItem: React.FC = ({ - status, - index, - settings, - callbacks, -}) => { - return ( -
-
-
{status.name}
-
-
- - callbacks.onCustomStatusChange( - index, - "name", - e.target.value || "unnamed", - ) - } - /> - - callbacks.onCustomStatusChange( - index, - "icon", - e.target.value || "❓", - ) - } - /> - - callbacks.onCustomStatusChange( - index, - "color", - e.target.value, - ) - } - /> - - callbacks.onCustomStatusChange( - index, - "description", - e.target.value, - ) - } - /> - -
-
- ); -}; - -interface CustomStatusSettingsProps { - settings: NoteStatusSettings; - callbacks: SettingsUICallbacks; -} - -const CustomStatusSettings: React.FC = ({ - settings, - callbacks, -}) => { - return ( -
-

Custom statuses

- -
-
-
- Use only custom statuses -
-
- Ignore template statuses and use only the custom - statuses defined below -
-
-
- - callbacks.onSettingChange( - "useCustomStatusesOnly", - e.target.checked, - ) - } - /> -
-
- -
- {settings.customStatuses.map((status, index) => ( - - ))} -
- -
-
-
Add new status
-
- Add a custom status with a name, icon, and color -
-
-
- -
-
-
- ); -}; - -interface QuickCommandsSettingsProps { - settings: NoteStatusSettings; - callbacks: SettingsUICallbacks; -} - -const QuickCommandsSettings: React.FC = ({ - 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 ( -
-

Quick status commands

-
- Select which statuses should have dedicated commands in the - command palette. These can be assigned hotkeys for quick access. -
- -
- {allStatuses.length === 0 ? ( -
- No statuses available. Enable templates or add custom - statuses first. -
- ) : ( - allStatuses.map((status) => ( -
-
-
- {status.icon} {status.name} -
- {status.description && ( -
- {status.description} -
- )} -
-
- - handleQuickCommandToggle( - status.name, - e.target.checked, - ) - } - /> -
-
- )) - )} -
-
- ); -}; - -export const SettingsUI: React.FC = ({ - settings, - callbacks, -}) => { - return ( -
- - - - - -
- ); -}; - -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; diff --git a/integrations/settings/pluginSettings.tsx b/integrations/settings/pluginSettings.tsx new file mode 100644 index 0000000..8f7a9cb --- /dev/null +++ b/integrations/settings/pluginSettings.tsx @@ -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( + { + settingsService.setValue(key, value); + }} + />, + ); + } + + destroy(): void { + if (this.root) { + this.root.unmount(); + this.root = null; + } + PluginSettingIntegration.instance = null; + } +} diff --git a/integrations/settings/settings-controller.ts b/integrations/settings/settings-controller.ts deleted file mode 100644 index d72408f..0000000 --- a/integrations/settings/settings-controller.ts +++ /dev/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); - } - }; -} diff --git a/integrations/settings/settings-tab.ts b/integrations/settings/settings-tab.ts deleted file mode 100644 index 5fc0042..0000000 --- a/integrations/settings/settings-tab.ts +++ /dev/null @@ -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); - } -} diff --git a/integrations/settings/types.ts b/integrations/settings/types.ts deleted file mode 100644 index 3552f8a..0000000 --- a/integrations/settings/types.ts +++ /dev/null @@ -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; - /** Handle general setting changes */ - onSettingChange: ( - key: keyof NoteStatusSettings, - value: boolean | string | string[], - ) => Promise; - /** Handle custom status field changes */ - onCustomStatusChange: ( - index: number, - field: keyof Status, - value: string, - ) => Promise; - /** Handle custom status removal */ - onCustomStatusRemove: (index: number) => Promise; - /** Handle adding new custom status */ - onCustomStatusAdd: () => Promise; -} diff --git a/integrations/status-bar/status-bar.tsx b/integrations/status-bar/status-bar.tsx new file mode 100644 index 0000000..0b9f584 --- /dev/null +++ b/integrations/status-bar/status-bar.tsx @@ -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( + + settingsService.setValue("showStatusBar", true) + } + />, + ); + } else { + this.root.render( + 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; diff --git a/integrations/views/groupped-dashboard-view.tsx b/integrations/views/groupped-dashboard-view.tsx new file mode 100644 index 0000000..de1bd84 --- /dev/null +++ b/integrations/views/groupped-dashboard-view.tsx @@ -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( + , + ); + } + + async onClose() { + this.root?.unmount(); + this.root = null; + } +} diff --git a/integrations/views/groupped-status-view.tsx b/integrations/views/groupped-status-view.tsx new file mode 100644 index 0000000..8be4c90 --- /dev/null +++ b/integrations/views/groupped-status-view.tsx @@ -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( + , + ); + } + + async onClose() { + this.root?.unmount(); + this.root = null; + } +} diff --git a/integrations/views/status-dashboard-view.tsx b/integrations/views/status-dashboard-view.tsx new file mode 100644 index 0000000..381d9e2 --- /dev/null +++ b/integrations/views/status-dashboard-view.tsx @@ -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(); + } + + async onClose() { + this.root?.unmount(); + this.root = null; + } +} diff --git a/integrations/workspace/index.ts b/integrations/workspace/index.ts deleted file mode 100644 index 9a37a29..0000000 --- a/integrations/workspace/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceIntegration } from "./workspace-integration"; diff --git a/integrations/workspace/workspace-integration.ts b/integrations/workspace/workspace-integration.ts deleted file mode 100644 index 6c34f6d..0000000 --- a/integrations/workspace/workspace-integration.ts +++ /dev/null @@ -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); - } - } -} diff --git a/main.tsx b/main.tsx index 0f475f8..d492d16 100644 --- a/main.tsx +++ b/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(); } } diff --git a/models/types.ts b/models/types.ts deleted file mode 100644 index 03a7e1a..0000000 --- a/models/types.ts +++ /dev/null @@ -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; - showStatusBar: boolean; - autoHideStatusBar: boolean; - customStatuses: Status[]; - showStatusIconsInExplorer: boolean; - hideUnknownStatusInExplorer: boolean; - collapsedStatuses: Record; - 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; - } - >; -} diff --git a/package-lock.json b/package-lock.json index cc76a63..dc59c22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,21 +39,20 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "dev": true, - "license": "MIT", "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "node_modules/@codemirror/view": { - "version": "6.36.5", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.36.5.tgz", - "integrity": "sha512-cd+FZEUlu3GQCYnguYm3EkhJ8KJVisqqUsCOKedBoAt/d9c76JUUap6U0UrpElln5k6VyrEOYliMuDAKIeDQLg==", + "version": "6.37.2", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.37.2.tgz", + "integrity": "sha512-XD3LdgQpxQs5jhOOZ2HRVT+Rj59O4Suc7g2ULvZ+Yi8eCkickrkZ5JFuoDhs2ST1mNI5zSsNYgR3NGa4OUrbnw==", "dev": true, - "license": "MIT", "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } @@ -66,7 +65,6 @@ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -83,7 +81,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -100,7 +97,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -117,7 +113,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -134,7 +129,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -151,7 +145,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -168,7 +161,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -185,7 +177,6 @@ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -202,7 +193,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -219,7 +209,6 @@ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -236,7 +225,6 @@ "loong64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -253,7 +241,6 @@ "mips64el" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -270,7 +257,6 @@ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -287,7 +273,6 @@ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -304,7 +289,6 @@ "s390x" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -321,7 +305,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -338,7 +321,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "netbsd" @@ -355,7 +337,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openbsd" @@ -372,7 +353,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "sunos" @@ -389,7 +369,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -406,7 +385,6 @@ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -423,7 +401,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -433,11 +410,10 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz", - "integrity": "sha512-soEIOALTfTK6EjmKMMoLugwaP0rzkad90iIWd1hMO9ARkSAyjfMfkRRhLvD5qH7vvM0Cg72pieUfR6yh6XxC4w==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, - "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -456,7 +432,6 @@ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, - "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } @@ -466,7 +441,6 @@ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, - "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -485,12 +459,33 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/js": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, - "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -501,7 +496,6 @@ "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", "deprecated": "Use @eslint/config-array instead", "dev": true, - "license": "Apache-2.0", "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", @@ -511,12 +505,33 @@ "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=12.22" }, @@ -530,15 +545,13 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "deprecated": "Use @eslint/object-schema instead", - "dev": true, - "license": "BSD-3-Clause" + "dev": true }, "node_modules/@marijn/find-cluster-break": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "dev": true, - "license": "MIT", "peer": true }, "node_modules/@nodelib/fs.scandir": { @@ -546,7 +559,6 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, - "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -560,7 +572,6 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, - "license": "MIT", "engines": { "node": ">= 8" } @@ -570,7 +581,6 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, - "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -590,17 +600,15 @@ "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", "dev": true, - "license": "MIT", "dependencies": { "@types/tern": "*" } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true, - "license": "MIT" + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true }, "node_modules/@types/json-schema": { "version": "7.0.15", @@ -618,8 +626,7 @@ "version": "16.18.126", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@types/prop-types": { "version": "15.7.15", @@ -657,7 +664,6 @@ "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", "dev": true, - "license": "MIT", "dependencies": { "@types/estree": "*" } @@ -810,30 +816,6 @@ } } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", @@ -880,15 +862,13 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -901,7 +881,6 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -911,7 +890,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -943,7 +921,6 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -953,7 +930,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -968,8 +944,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" + "dev": true }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", @@ -988,17 +963,19 @@ } }, "node_modules/array-includes": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "is-string": "^1.0.7" + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -1158,18 +1135,15 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, - "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^1.0.0" } }, "node_modules/braces": { @@ -1189,7 +1163,6 @@ "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" }, @@ -1249,7 +1222,6 @@ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -1259,7 +1231,6 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -1307,7 +1278,6 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -1319,8 +1289,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/colorette": { "version": "2.0.20", @@ -1341,15 +1310,20 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "dev": true, - "license": "MIT" + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, - "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1417,11 +1391,10 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dev": true, - "license": "MIT", "dependencies": { "ms": "^2.1.3" }, @@ -1438,8 +1411,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/define-data-property": { "version": "1.1.4", @@ -1492,7 +1464,6 @@ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, - "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" }, @@ -1533,9 +1504,9 @@ } }, "node_modules/es-abstract": { - "version": "1.23.10", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.10.tgz", - "integrity": "sha512-MtUbM072wlJNyeYAe0mhzrD+M6DIJa96CZAOBBrhDbgKnB4MApIKefcyAB1eOdYn8cUNZgvwBvEzdoAYsxgEIw==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", "dev": true, "dependencies": { "array-buffer-byte-length": "^1.0.2", @@ -1565,7 +1536,9 @@ "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", + "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", @@ -1580,6 +1553,7 @@ "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", @@ -1704,7 +1678,6 @@ "integrity": "sha512-9n3AsBRe6sIyOc6kmoXg2ypCLgf3eZSraWFRpnkto+svt8cZNuKTkb1bhQcitBcvIqjNiK7K0J3KPmwGSfkA8g==", "dev": true, "hasInstallScript": true, - "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -1741,7 +1714,6 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, @@ -1755,7 +1727,6 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -1897,6 +1868,16 @@ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint-plugin-import/node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", @@ -1918,6 +1899,18 @@ "node": ">=0.10.0" } }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-import/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -1971,6 +1964,16 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" } }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint-plugin-react/node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -1983,6 +1986,18 @@ "node": ">=0.10.0" } }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-react/node_modules/resolve": { "version": "2.0.0-next.5", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", @@ -2009,25 +2024,11 @@ "semver": "bin/semver.js" } }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/eslint-scope": { + "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -2039,12 +2040,45 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -2062,7 +2096,6 @@ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, @@ -2075,7 +2108,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -2097,7 +2129,6 @@ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } @@ -2135,8 +2166,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fast-glob": { "version": "3.3.3", @@ -2170,22 +2200,19 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", "dev": true, - "license": "ISC", "dependencies": { "reusify": "^1.0.4" } @@ -2195,7 +2222,6 @@ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, - "license": "MIT", "dependencies": { "flat-cache": "^3.0.4" }, @@ -2220,7 +2246,6 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, - "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -2237,7 +2262,6 @@ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, - "license": "MIT", "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", @@ -2251,8 +2275,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/for-each": { "version": "0.3.5", @@ -2273,8 +2296,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/function-bind": { "version": "1.1.2", @@ -2398,7 +2420,6 @@ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, - "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -2419,7 +2440,6 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "ISC", "dependencies": { "is-glob": "^4.0.3" }, @@ -2427,12 +2447,33 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, - "license": "MIT", "dependencies": { "type-fest": "^0.20.2" }, @@ -2495,8 +2536,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/has-bigints": { "version": "1.1.0", @@ -2515,7 +2555,6 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -2615,7 +2654,6 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" } @@ -2625,7 +2663,6 @@ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, - "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -2642,7 +2679,6 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.8.19" } @@ -2653,7 +2689,6 @@ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, - "license": "ISC", "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -2663,8 +2698,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/internal-slot": { "version": "1.1.0", @@ -2812,7 +2846,6 @@ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -2867,7 +2900,6 @@ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, - "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" }, @@ -2887,6 +2919,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -2917,7 +2961,6 @@ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -3080,8 +3123,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/iterator.prototype": { "version": "1.1.5", @@ -3110,7 +3152,6 @@ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, - "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -3122,22 +3163,19 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/json5": { "version": "1.0.2", @@ -3171,7 +3209,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, - "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } @@ -3181,7 +3218,6 @@ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, - "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -3263,7 +3299,6 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, - "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, @@ -3278,8 +3313,7 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/log-update": { "version": "6.1.0", @@ -3443,16 +3477,18 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", "dev": true, - "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -3469,7 +3505,6 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", "dev": true, - "license": "MIT", "engines": { "node": "*" } @@ -3478,15 +3513,13 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/npm-run-path": { "version": "5.3.0", @@ -3635,7 +3668,6 @@ "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.8.7.tgz", "integrity": "sha512-h4bWwNFAGRXlMlMAzdEiIM2ppTGlrh7uGOJS6w4gClrsjc+ei/3YAtU2VdFUlCiPuTHpY4aBpFJJW75S1Tl/JA==", "dev": true, - "license": "MIT", "dependencies": { "@types/codemirror": "5.60.8", "moment": "2.29.4" @@ -3650,7 +3682,6 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, - "license": "ISC", "dependencies": { "wrappy": "1" } @@ -3675,7 +3706,6 @@ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, - "license": "MIT", "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -3710,7 +3740,6 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, - "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -3726,7 +3755,6 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, - "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, @@ -3742,7 +3770,6 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, - "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -3755,7 +3782,6 @@ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -3765,7 +3791,6 @@ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -3775,7 +3800,6 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -3833,7 +3857,6 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.8.0" } @@ -3869,7 +3892,6 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -3892,8 +3914,7 @@ "type": "consulting", "url": "https://feross.org/support" } - ], - "license": "MIT" + ] }, "node_modules/react": { "version": "18.3.1", @@ -3991,7 +4012,6 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, - "license": "MIT", "engines": { "node": ">=4" } @@ -4032,7 +4052,6 @@ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, - "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -4050,7 +4069,6 @@ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, - "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -4080,7 +4098,6 @@ "url": "https://feross.org/support" } ], - "license": "MIT", "dependencies": { "queue-microtask": "^1.2.2" } @@ -4208,7 +4225,6 @@ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, - "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -4221,7 +4237,6 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -4347,6 +4362,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -4498,7 +4526,6 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, - "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -4532,7 +4559,6 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" }, @@ -4545,7 +4571,6 @@ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==", "dev": true, - "license": "MIT", "peer": true }, "node_modules/supports-color": { @@ -4553,7 +4578,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, - "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -4577,8 +4601,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -4620,15 +4643,13 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", - "dev": true, - "license": "0BSD" + "dev": true }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, - "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" }, @@ -4641,7 +4662,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, - "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -4728,7 +4748,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", "dev": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4760,7 +4779,6 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } @@ -4770,7 +4788,6 @@ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "dev": true, - "license": "MIT", "peer": true }, "node_modules/which": { @@ -4778,7 +4795,6 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, - "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -4879,7 +4895,6 @@ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -4944,8 +4959,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/yaml": { "version": "2.8.0", @@ -4964,7 +4978,6 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, diff --git a/services/status-service.ts b/services/status-service.ts deleted file mode 100644 index 98000e5..0000000 --- a/services/status-service.ts +++ /dev/null @@ -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 { - const statusGroups: Record = {}; - - // 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 { - 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 { - 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, - }, - }), - ); - } -} diff --git a/services/style-service.ts b/services/style-service.ts deleted file mode 100644 index 896abec..0000000 --- a/services/style-service.ts +++ /dev/null @@ -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 { - 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 { - 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; - } - } -} diff --git a/styles.css b/styles.css index 4179f0b..ac0bfcb 100644 --- a/styles.css +++ b/styles.css @@ -1 +1,1072 @@ -:root{--status-transition-time: .22s;--status-border-radius: var(--radius-s, 4px);--status-box-shadow: var(--shadow-s, 0 2px 8px rgba(0, 0, 0, .15));--status-hover-shadow: var(--shadow-m, 0 4px 12px rgba(0, 0, 0, .2));--status-icon-size: 16px;--ease-in-out: cubic-bezier(.4, 0, .2, 1);--ease-out: cubic-bezier(0, 0, .2, 1);--ease-in: cubic-bezier(.4, 0, 1, 1)}@keyframes note-status-scale-in{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes note-status-slide-in-fade{0%{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}@keyframes note-status-fade-in-slide-down{0%{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:translateY(0)}}@keyframes note-status-pulse{0%{transform:scale(1)}50%{transform:scale(.98)}to{transform:scale(1)}}.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)}@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}}@media print{.note-status-unified-dropdown,.note-status-bar,.note-status-pane{display:none!important}}.note-status-empty-indicator{color:var(--text-muted);font-style:italic;padding:var(--size-4-1, 4px);display:flex;align-items:center;justify-content:center;color:var(--text-accent);font-size:1.2em}.note-status-action-button{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:var(--radius-s);background:transparent;color:var(--text-muted);cursor:pointer;transition:all .15s ease;border:none;padding:0}.note-status-action-button:hover{background-color:var(--background-modifier-hover);color:var(--text-normal)}.note-status-action-button.note-status-button-active{background-color:var(--interactive-accent);color:var(--text-on-accent);transform:scale(.95)}.note-status-loading{display:flex;justify-content:center;align-items:center;padding:20px;color:var(--text-muted);font-style:italic}.note-status-loading span{position:relative;padding-left:24px}.note-status-loading span:before{content:"";position:absolute;left:0;top:50%;width:16px;height:16px;margin-top:-8px;border:2px solid var(--background-modifier-border);border-top-color:var(--text-accent);border-radius:50%;animation:note-status-loading-spinner .8s linear infinite}@keyframes note-status-loading-spinner{to{transform:rotate(360deg)}}.note-status-dummy-target{position:fixed;z-index:1000;width:0;height:0;left:var(--pos-x-px, 0);top:var(--pos-y-px, 0);pointer-events:none}.note-status-popover-fixed{position:fixed;z-index:999;--pos-x: 0;--pos-y: 0;--max-height: 300px;left:var(--pos-x-px, 0);top:var(--pos-y-px, 0);max-height:var(--max-height-px, 300px)}.note-status-popover-right{left:auto!important;right:var(--right-offset-px, 10px)}.note-status-popover-bottom{top:auto!important;bottom:var(--bottom-offset-px, 10px)}.note-status-bar{display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:var(--radius-s);transition:all .2s var(--ease-out);cursor:pointer;height:22px}.note-status-bar:hover{background-color:var(--background-modifier-hover)}.note-status-badges{display:flex;align-items:center;gap:4px}.note-status-badge{display:flex;align-items:center;padding:2px 6px;border-radius:10px;background:var(--background-secondary-alt);font-size:var(--font-ui-smaller);max-width:100px;margin-right:2px}.note-status-badge-icon{margin-right:2px}.note-status-badge-text{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.status-bar .note-status-icon:after{bottom:auto;top:-30px}.note-status-bar.hidden{display:none}.note-status-bar.auto-hide{opacity:.5;transition:opacity .2s var(--ease-out)}.note-status-bar.visible{opacity:1}.note-status-pane{padding:var(--size-4-2, 8px);background:var(--background-secondary);color:var(--text-normal);font-family:var(--font-interface);overflow-y:auto;height:100%;display:flex;flex-direction:column}.note-status-header{position:sticky;top:0;z-index:10;background:var(--background-secondary);padding-bottom:var(--size-4-2, 8px);border-bottom:1px solid var(--background-modifier-border);margin-bottom:var(--size-4-2, 8px);display:flex;flex-direction:column;gap:var(--size-4-2, 8px)}.note-status-search{width:100%;position:relative}.search-input-wrapper,.note-status-search-wrapper{position:relative;display:flex;align-items:center}.search-input-icon,.note-status-search-icon{position:absolute;left:var(--size-4-1, 4px);color:var(--text-muted);display:flex;align-items:center;pointer-events:none;padding:4px}.note-status-search-input,.note-status-popover-search-input{width:100%;padding:var(--input-padding);padding-left:calc(var(--size-4-1, 4px) * 3 + 18px);border:var(--input-border-width) solid var(--background-modifier-border);border-radius:var(--input-radius);background:var(--background-primary);color:var(--text-normal);outline:none;transition:all var(--status-transition-time) var(--ease-out)}.note-status-search-input:focus,.note-status-popover-search-input:focus{border-color:var(--interactive-accent);box-shadow:0 0 0 2px var(--interactive-accent-hover)}.search-input-clear-button,.note-status-search-clear-button{opacity:0;position:absolute;right:var(--size-4-1, 4px);cursor:pointer;color:var(--text-muted);display:flex;align-items:center;transition:opacity var(--status-transition-time) var(--ease-out);padding:4px;border-radius:50%}.search-input-clear-button.is-visible,.note-status-search-clear-button.is-visible{opacity:1}.search-input-clear-button:hover,.note-status-search-clear-button:hover{color:var(--text-normal);background-color:var(--background-modifier-hover)}.status-pane-actions-container{display:flex;gap:var(--size-4-2, 8px);justify-content:flex-end;margin-top:var(--size-4-1, 4px)}.note-status-view-toggle,.note-status-actions-refresh{padding:var(--size-4-1, 4px) var(--size-4-2, 8px);border:var(--input-border-width) solid var(--background-modifier-border);border-radius:var(--button-radius);background:var(--background-primary);color:var(--text-normal);cursor:pointer;transition:all var(--status-transition-time) var(--ease-out);display:flex;align-items:center;justify-content:center}.note-status-view-toggle:hover,.note-status-actions-refresh:hover{background:var(--background-modifier-hover);box-shadow:var(--shadow-s)}.note-status-view-toggle:active,.note-status-actions-refresh:active{transform:translateY(1px);box-shadow:none}.note-status-groups-container{flex:1;overflow-y:auto;padding-right:2px}.note-status-group{margin-bottom:var(--size-4-3, 12px);border-radius:var(--radius-s);background:var(--background-primary-alt);box-shadow:var(--shadow-xs);overflow:hidden;animation:note-status-fade-in-slide-down .3s var(--ease-out)}.note-status-group .nav-folder-title{cursor:pointer;padding:var(--size-4-1, 4px) var(--size-4-2, 8px);background:var(--background-secondary-alt);transition:background var(--status-transition-time) var(--ease-out)}.note-status-group .nav-folder-title:hover{background:var(--background-modifier-hover)}.note-status-group .nav-folder-title-content{font-weight:var(--font-semibold);display:flex;align-items:center;gap:var(--size-4-1, 4px)}.note-status-collapse-indicator{margin-right:var(--size-4-2, 8px);display:flex;align-items:center;color:var(--text-muted);transition:transform var(--status-transition-time) var(--ease-in-out);opacity:1}.note-status-is-collapsed .note-status-collapse-indicator{transform:rotate(-90deg)}.note-status-group .nav-folder-children{padding:var(--size-4-1, 4px);background:var(--background-primary);transition:height var(--status-transition-time) var(--ease-out)}.note-status-group.nav-folder.note-status-is-collapsed .nav-folder-children{display:none}.nav-file{border-radius:var(--radius-s);transition:background var(--status-transition-time) var(--ease-out);margin-bottom:2px;position:relative}.nav-file:hover{background:var(--background-modifier-hover)}.nav-file-title{display:flex;align-items:center;padding:var(--size-4-1, 4px) var(--size-4-2, 8px);flex-wrap:nowrap}.nav-file-icon{color:var(--text-muted);margin-right:var(--size-4-2, 8px);display:flex;align-items:center;flex-shrink:0}.nav-file-title-content{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.note-status-compact-view .nav-file-title{padding:2px var(--size-4-2, 8px);font-size:var(--font-smaller)}.note-status-compact-view .nav-folder-children{padding:0}.note-status-compact-view .nav-file{margin-bottom:0;border-radius:0;border-bottom:1px solid var(--background-modifier-border)}.note-status-compact-view .nav-file:last-child{border-bottom:none}.note-status-empty-message{margin-bottom:12px;text-align:center;color:var(--text-muted);font-style:italic}.note-status-button-container{display:flex;justify-content:center;margin-top:16px}.note-status-show-unassigned-button{background-color:var(--interactive-accent);color:var(--text-on-accent);padding:8px 16px;border-radius:var(--radius-s, 4px);border:none;cursor:pointer;font-size:var(--font-ui-small);transition:all .2s var(--ease-out);box-shadow:var(--shadow-s)}.note-status-show-unassigned-button:hover{background-color:var(--interactive-accent-hover);transform:translateY(-1px);box-shadow:var(--shadow-m)}.note-status-show-unassigned-button:active{transform:translateY(0);box-shadow:var(--shadow-xs)}.note-status-pagination{display:flex;justify-content:space-between;align-items:center;padding:8px;border-top:1px solid var(--background-modifier-border);margin-top:8px}.note-status-pagination-button{padding:4px 8px;border-radius:var(--radius-s);background:var(--background-secondary);border:var(--input-border-width) solid var(--background-modifier-border);color:var(--text-normal);cursor:pointer;transition:all .15s var(--ease-out);font-size:var(--font-smaller)}.note-status-pagination-button:hover{background:var(--background-modifier-hover);box-shadow:var(--shadow-s)}.note-status-pagination-info{font-size:var(--font-smaller);color:var(--text-muted)}.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(.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(.95);pointer-events:none}.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)}.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}.note-status-empty-options{padding:16px 12px;text-align:center;color:var(--text-muted);font-style:italic;font-size:var(--font-smaller)}.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 .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 .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)}.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}.note-status-popover-animate-in{animation:note-status-dropdown-fade-in .22s var(--ease-out) forwards}@keyframes note-status-dropdown-fade-in{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}@keyframes note-status-dropdown-fade-out{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.95)}}.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)}.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 .15s var(--ease-out);border:1px solid var(--background-modifier-border);max-width:180px;animation:note-status-scale-in .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(.8);opacity:0;pointer-events:none;transition:all .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 .15s var(--ease-out)}.note-status-chip-remove:hover{background:var(--text-accent);color:var(--text-on-accent);transform:scale(1.1)}.note-status-toolbar-badge-container{display:flex;align-items:center;justify-content:center;position:relative}.note-status-toolbar-icon{color:var(--text-normal)!important;display:flex!important;align-items:center!important;justify-content:center!important}.note-status-count-badge{position:absolute;top:-5px;right:-8px;background-color:var(--interactive-accent);color:var(--text-on-accent);border-radius:10px;font-size:10px;min-width:14px;height:14px;padding:0 3px;display:flex;align-items:center;justify-content:center;font-weight:var(--font-bold)}.note-status-icon-container{display:inline-flex;margin-left:var(--size-4-1, 4px);gap:2px;align-items:center;flex-shrink:0}.note-status-icon{font-size:var(--font-ui-smaller);display:inline-flex;align-items:center;justify-content:center;width:var(--status-icon-size);height:var(--status-icon-size);position:relative;z-index:500}.nav-file-title .note-status-icon:after{bottom:150%}.status-color-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background-color:var(--dot-color, #ffffff);margin-right:4px}.template-buttons{margin-top:15px;display:flex;gap:10px}.template-item{border:1px solid var(--background-modifier-border);border-radius:var(--status-border-radius);margin-bottom:10px;padding:10px;background:var(--background-primary-alt)}.template-header{display:flex;align-items:center;margin-bottom:5px}.template-checkbox{margin-right:10px}.template-name{font-weight:700}.template-description{font-size:.9em;color:var(--text-muted);margin-bottom:8px}.template-statuses{display:flex;flex-wrap:wrap;gap:5px}.template-status-chip{display:inline-flex;align-items:center;padding:2px 8px;border-radius:12px;background:var(--background-secondary);font-size:.85em} +/* styles/components/setting-item.css */ +.setting-item-full { + width: 100%; +} +.setting-item-vertical { + flex-direction: column; + align-items: start; + gap: 4px; +} + +/* styles/components/template-status-chip.css */ +.template-status-chip { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--radius-xl); + background: var(--background-secondary); + font-size: 0.85em; + border: 1px solid var(--color-base-30); + gap: 4px; +} +.template-status-color-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--dot-color, --color-base-30); +} + +/* styles/components/template-item.css */ +.template-item { + border: var(--border-width) solid var(--color-base-30); + border-radius: var(--radius-xl); + margin-bottom: var(--size-4-2); + padding: var(--size-4-3); + background: var(--background-primary-alt); + cursor: pointer; + transition: background-color 0.1s ease; +} +.template-item:hover { + background: var(--background-modifier-hover); +} +.template-item.enabled { + background: var(--background-modifier-success-hover); +} +.template-item.enabled:hover { + background: var(--background-modifier-success); +} +.template-header { + display: flex; + align-items: center; +} +.template-checkbox { + pointer-events: none; +} +.template-statuses { + margin-top: 7px; + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +/* styles/components/custom-status-item.css */ +.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; +} + +/* styles/components/quick-commands-settings.css */ +.quick-commands-container { + margin-top: 1em; +} + +/* styles/components/status-group.css */ +.status-group { + margin-bottom: 1.5em; +} +.status-group-description { + font-size: 0.85em; + opacity: 0.7; +} +.status-group-items { + margin-left: 1em; +} + +/* styles/components/status-bar-enable-button.css */ +.status-bar-enable-button { + opacity: var(--icon-opacity); +} +.status-bar-enable-button:hover { + opacity: var(--icon-opacity-hover); + background: var(--background-modifier-active); +} + +/* styles/components/status-badge.css */ +.status-badge-container { + display: inline-flex; + flex-direction: column; + align-items: center; + border-radius: var(--radius-s); + transition: all var(--anim-duration-moderate) var(--anim-motion-smooth); + overflow: hidden; + min-width: auto; + max-width: none; +} +.status-badge-item { + display: flex; + align-items: center; + gap: var(--size-2-1); + padding: var(--size-2-1) var(--size-2-3); + transition: all var(--anim-duration-fast) ease; +} +.status-badge-icon { + transition: transform var(--anim-duration-fast) ease; +} +.status-badge-text { + font-weight: var(--font-weight-medium); +} + +/* styles/components/status-bar-group.css */ +.status-bar-group-container { + display: flex; + gap: var(--size-2-1); + flex-wrap: wrap; + align-items: center; +} +@keyframes status-slide-in { + from { + opacity: 0; + transform: scale(0.8) translateY(4px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* styles/components/status-bar.css */ +.status-bar-group-row { + display: flex; + align-items: center; + gap: var(--size-4-2); +} + +/* styles/components/collapsible-counter.css */ +.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; +} + +/* styles/components/group-label.css */ +.group-label-container { + fontSize: var(--font-text-size); + textTransform: uppercase; + letterSpacing: 0.8px; + fontWeight: var(--font-weight-semibold); + transition: color var(--anim-duration-fast) ease; +} + +/* styles/components/file-explorer-icon.css */ +.status-wrapper { + position: relative; + display: inline-block; +} +.status-minimal { + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: 6px; + cursor: pointer; + opacity: 0.6; + transition: all 0.2s ease; + padding: 2px; + border-radius: var(--radius-s); +} +.status-minimal:hover { + opacity: 1; + background: color-mix(in srgb, var(--primary-color) 10%, transparent); + transform: scale(1.05); +} +.status-icon { + font-size: 11px; + line-height: 1; + filter: grayscale(0.4); + transition: filter 0.2s ease; +} +.status-minimal:hover .status-icon { + filter: grayscale(0); +} +.status-count { + background: var(--primary-color); + color: var(--text-on-accent); + font-size: var(--font-smaller); + font-weight: var(--font-semibold); + min-width: 12px; + height: 12px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-left: 1px; + box-shadow: var(--shadow-s); + border: 1px solid var(--background-primary); +} +.status-modal { + position: fixed; + left: 50%; + transform: translateX(-50%); + background: var(--background-primary); + border: var(--border-width) solid var(--background-modifier-border); + border-radius: var(--radius-l); + box-shadow: var(--shadow-l); + z-index: var(--layer-tooltip); + min-width: 280px; + max-width: 360px; + animation: modalIn 0.15s ease-out; + backdrop-filter: blur(8px); +} +.status-modal.bottom { + top: calc(100% + 8px); +} +.status-modal.top { + bottom: calc(100% + 8px); +} +@keyframes modalIn { + from { + opacity: 0; + transform: translateX(-50%) scale(0.95); + } + to { + opacity: 1; + transform: translateX(-50%) scale(1); + } +} +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--size-4-4) var(--size-4-4) var(--size-4-2) var(--size-4-4); + border-bottom: var(--border-width) solid var(--background-modifier-border); +} +.modal-title { + font-size: var(--font-ui-medium); + font-weight: var(--font-semibold); + color: var(--text-normal); +} +.modal-total { + background: var(--background-modifier-hover); + color: var(--text-muted); + font-size: var(--font-smallest); + font-weight: var(--font-medium); + padding: var(--size-2-1) var(--size-2-3); + border-radius: var(--radius-m); +} +.modal-content { + padding: var(--size-4-2) var(--size-4-4) var(--size-4-4) var(--size-4-4); + max-height: 320px; + overflow-y: auto; +} +.status-group:not(:last-child) { + margin-bottom: var(--size-4-4); +} +.group-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--size-2-3); +} +.group-name { + font-size: var(--font-ui-small); + font-weight: var(--font-semibold); + color: var(--text-normal); + text-transform: capitalize; +} +.group-count { + background: var(--background-modifier-hover); + color: var(--text-muted); + font-size: var(--font-smallest); + font-weight: var(--font-medium); + padding: var(--size-2-1) var(--size-2-2); + border-radius: var(--radius-s); + min-width: 16px; + text-align: center; +} +.status-list { + display: flex; + flex-direction: column; + gap: var(--size-2-2); +} +.status-item { + display: flex; + align-items: center; + gap: var(--size-4-2); + padding: var(--size-2-3) var(--size-4-2); + background: color-mix(in srgb, var(--item-color) 8%, transparent); + border: var(--border-width) solid color-mix(in srgb, var(--item-color) 20%, transparent); + border-radius: var(--radius-s); + transition: all 0.2s ease; + cursor: pointer; +} +.status-item:hover { + background: color-mix(in srgb, var(--item-color) 12%, transparent); + border-color: color-mix(in srgb, var(--item-color) 35%, transparent); + transform: translateX(2px); +} +.item-icon { + font-size: var(--font-ui-medium); + flex-shrink: 0; +} +.item-info { + display: flex; + flex-direction: column; + gap: var(--size-2-1); + min-width: 0; +} +.item-name { + font-size: var(--font-ui-small); + font-weight: var(--font-medium); + color: var(--text-normal); +} +.item-description { + font-size: var(--font-smallest); + color: var(--text-muted); + line-height: var(--line-height-tight); +} +.modal-arrow { + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 10px; + height: 10px; + background: var(--background-primary); + border: var(--border-width) solid var(--background-modifier-border); + rotate: 45deg; +} +.status-modal.bottom .modal-arrow { + top: -5px; + border-bottom: none; + border-right: none; +} +.status-modal.top .modal-arrow { + bottom: -5px; + border-top: none; + border-left: none; +} +.modal-content::-webkit-scrollbar { + width: var(--scrollbar-thin-thumb); +} +.modal-content::-webkit-scrollbar-track { + background: var(--background-modifier-hover); + border-radius: var(--radius-s); +} +.modal-content::-webkit-scrollbar-thumb { + background: var(--background-modifier-border); + border-radius: var(--radius-s); +} +.modal-content::-webkit-scrollbar-thumb:hover { + background: var(--background-modifier-border-hover); +} + +/* styles/components/status-file-info-popup.css */ +.status-info-popup { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1000; + background: var(--background-primary); + border: var(--border-width) solid var(--background-modifier-border); + border-radius: var(--radius-l); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + min-width: 280px; + max-width: 400px; + pointer-events: none; + overflow: hidden; + backdrop-filter: blur(8px); +} +.status-popup-header { + background: linear-gradient(135deg, var(--background-secondary) 0%, var(--background-modifier-hover) 100%); + border-bottom: var(--border-width) solid var(--background-modifier-border); + padding: var(--size-4-2) var(--size-4-3); + font-weight: var(--font-weight-semibold); + color: var(--text-normal); + font-size: var(--font-ui-small); + display: flex; + align-items: center; + gap: var(--size-2-2); +} +.status-header-icon { + font-size: var(--font-ui-medium); + opacity: 0.8; +} +.status-popup-content { + padding: var(--size-4-2); + max-height: none; + overflow: visible; +} +.status-popup-empty { + padding: var(--size-4-4) var(--size-4-3); + text-align: center; + color: var(--text-muted); + font-size: var(--font-ui-smaller); + background: var(--background-primary); + border: var(--border-width) solid var(--background-modifier-border); + border-radius: var(--radius-l); + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1000; + pointer-events: none; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--size-2-2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); +} +.status-empty-icon { + font-size: var(--font-ui-large); + opacity: 0.6; +} +.status-group { + margin-bottom: var(--size-4-3); + background: var(--background-secondary); + border-radius: var(--radius-m); + border: var(--border-width) solid var(--background-modifier-border-hover); + overflow: hidden; +} +.status-group:last-child { + margin-bottom: 0; +} +.status-group-header { + padding: var(--size-2-3) var(--size-4-2); + background: linear-gradient(90deg, var(--interactive-accent-hover) 0%, var(--interactive-accent) 100%); + color: var(--text-on-accent); + font-size: var(--font-ui-smaller); + font-weight: var(--font-weight-medium); + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: var(--border-width) solid var(--background-modifier-border); +} +.status-group-name { + text-transform: capitalize; + letter-spacing: 0.3px; +} +.status-group-count { + background: rgba(255, 255, 255, 0.2); + color: var(--text-on-accent); + font-size: var(--font-ui-smaller); + font-weight: var(--font-weight-semibold); + padding: var(--size-2-1) var(--size-2-3); + border-radius: var(--radius-s); + min-width: var(--size-4-3); + text-align: center; + backdrop-filter: blur(4px); +} +.status-group-items { + padding: var(--size-2-3); + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: var(--size-2-2); + background: var(--background-primary); +} +.status-item { + display: flex; + flex-direction: column; + gap: var(--size-2-1); + padding: var(--size-2-2) var(--size-2-3); + background: var(--background-modifier-hover); + border-radius: var(--radius-s); + border: var(--border-width) solid var(--background-modifier-border); + transition: all 0.15s ease; + min-width: 0; +} +.status-item:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border-color: var(--interactive-accent); +} +.status-description { + color: var(--text-muted); + font-size: var(--font-ui-smaller); + line-height: 1.3; + background: var(--background-primary-alt); + padding: var(--size-2-1) var(--size-2-2); + border-radius: var(--radius-s); + border-left: 2px solid var(--interactive-accent); + font-style: italic; + opacity: 0.8; + word-wrap: break-word; + hyphens: auto; +} + +/* styles/components/groupped-status-view.css */ +.groupped-status-view-container { + height: 100%; + overflow: hidden; +} +.groupped-status-view { + height: 100%; + display: flex; + flex-direction: column; + background-color: var(--background-primary); +} +.groupped-status-header { + padding: var(--size-4-2) var(--size-4-3); + border-bottom: 1px solid var(--background-modifier-border); + background-color: var(--background-secondary); + flex-shrink: 0; +} +.groupped-status-filters { + display: flex; + flex-direction: column; + gap: var(--size-4-2); +} +.groupped-status-note-filter { + display: flex; + flex-direction: column; +} +.groupped-status-note-input { + width: 100%; + padding: var(--size-2-1) var(--size-2-3); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background: var(--background-primary); + color: var(--text-normal); + font-size: var(--font-ui-medium); + transition: border-color var(--anim-duration-fast) var(--anim-motion-smooth); +} +.groupped-status-note-input:focus { + outline: none; + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--interactive-accent-hover); +} +.groupped-status-note-input::placeholder { + color: var(--text-muted); +} +.groupped-status-title { + margin: 0 0 var(--size-4-2) 0; + font-size: var(--font-ui-large); + font-weight: var(--font-weight-semibold); + color: var(--text-normal); +} +.groupped-status-content { + flex: 1; + overflow-y: auto; + padding: var(--size-4-2); + max-height: calc(100vh - 120px); +} +.groupped-status-tag-section { + margin-bottom: var(--size-4-4); +} +.groupped-status-tag-section:last-child { + margin-bottom: 0; +} +.groupped-status-tag-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--size-4-2) var(--size-4-3); + cursor: pointer; + transition: background-color var(--anim-duration-fast) var(--anim-motion-smooth); + border-radius: var(--radius-m); + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + margin-bottom: var(--size-4-2); +} +.groupped-status-tag-header:hover { + background-color: var(--background-modifier-hover); +} +.groupped-status-tag-content { + padding-left: var(--size-4-2); +} +.groupped-status-group { + margin-bottom: var(--size-4-2); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + background-color: var(--background-primary); + overflow: hidden; +} +.groupped-status-group-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--size-4-2) var(--size-4-3); + cursor: pointer; + transition: background-color var(--anim-duration-fast) var(--anim-motion-smooth); + border-bottom: 1px solid transparent; +} +.groupped-status-group-header:hover { + background-color: var(--background-modifier-hover); +} +.groupped-status-group-header:active { + background-color: var(--background-modifier-active); +} +.groupped-status-group-info { + display: flex; + align-items: center; + gap: var(--size-2-1); +} +.groupped-status-files { + border-top: 1px solid var(--background-modifier-border); + background-color: var(--background-secondary); +} +.groupped-status-files-list { + max-height: 300px; + overflow-y: auto; +} +.groupped-status-file-item { + display: flex; + flex-direction: column; + padding: var(--size-2-3) var(--size-4-3); + cursor: pointer; + transition: background-color var(--anim-duration-fast) var(--anim-motion-smooth); + border-bottom: 1px solid var(--background-modifier-border-hover); +} +.groupped-status-file-item:last-child { + border-bottom: none; +} +.groupped-status-file-item:hover { + background-color: var(--background-modifier-hover); +} +.groupped-status-file-item:active { + background-color: var(--background-modifier-active); +} +.groupped-status-file-name { + font-weight: var(--font-weight-medium); + color: var(--text-normal); + margin-bottom: var(--size-2-1); + font-size: var(--font-ui-medium); +} +.groupped-status-file-path { + font-size: var(--font-ui-small); + color: var(--text-muted); + opacity: 0.8; +} +.groupped-status-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: var(--font-ui-medium); + color: var(--text-muted); +} +.groupped-status-empty { + display: flex; + align-items: center; + justify-content: center; + padding: var(--size-4-6); + font-size: var(--font-ui-medium); + color: var(--text-muted); + text-align: center; +} +@media (max-width: 768px) { + .groupped-status-header { + padding: var(--size-4-1) var(--size-4-2); + } + .groupped-status-content { + padding: var(--size-4-1); + } + .groupped-status-group-header { + padding: var(--size-4-1) var(--size-4-2); + } + .groupped-status-file-item { + padding: var(--size-2-2) var(--size-4-2); + } +} +.theme-dark .groupped-status-view { + border-color: var(--background-modifier-border); +} +.theme-dark .groupped-status-group { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} +.theme-light .groupped-status-group { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} +.groupped-status-content::-webkit-scrollbar { + width: var(--scrollbar-width); +} +.groupped-status-content::-webkit-scrollbar-track { + background: var(--background-secondary); +} +.groupped-status-content::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-bg); + border-radius: var(--radius-s); +} +.groupped-status-content::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-bg-hover); +} +.groupped-status-load-more { + display: flex; + justify-content: center; + padding: var(--size-4-2); + border-top: 1px solid var(--background-modifier-border-hover); +} +.groupped-status-load-btn { + padding: var(--size-2-2) var(--size-4-2); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background-color: var(--interactive-normal); + color: var(--text-normal); + cursor: pointer; + transition: all var(--anim-duration-fast) var(--anim-motion-smooth); + font-size: var(--font-ui-small); + font-weight: var(--font-weight-medium); +} +.groupped-status-load-btn:hover { + background-color: var(--interactive-hover); + transform: translateY(-1px); +} +.groupped-status-load-btn:active { + background-color: var(--interactive-active); + transform: translateY(0); +} +.groupped-status-files-list::-webkit-scrollbar { + width: var(--scrollbar-width); +} +.groupped-status-files-list::-webkit-scrollbar-track { + background: var(--background-secondary); +} +.groupped-status-files-list::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-bg); + border-radius: var(--radius-s); +} +.groupped-status-files-list::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-bg-hover); +} + +/* styles/components/status-dashboard.css */ +.status-dashboard-view-container { + height: 100%; + overflow: hidden; +} +.status-dashboard { + height: 100%; + display: flex; + flex-direction: column; + background-color: var(--background-primary); +} +.status-dashboard-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--size-4-3) var(--size-4-4); + border-bottom: 1px solid var(--background-modifier-border); + background-color: var(--background-secondary); + flex-shrink: 0; +} +.status-dashboard-title { + margin: 0; + font-size: var(--font-ui-larger); + font-weight: var(--font-weight-bold); + color: var(--text-normal); +} +.status-dashboard-refresh { + padding: var(--size-2-2) var(--size-2-3); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background-color: var(--interactive-normal); + color: var(--text-normal); + cursor: pointer; + transition: all var(--anim-duration-fast) var(--anim-motion-smooth); + font-size: var(--font-ui-medium); +} +.status-dashboard-refresh:hover { + background-color: var(--interactive-hover); + transform: rotate(90deg); +} +.status-dashboard-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: var(--font-ui-medium); + color: var(--text-muted); +} +.status-dashboard-content { + flex: 1; + overflow-y: auto; + padding: var(--size-4-3); + display: flex; + flex-direction: column; + gap: var(--size-4-4); + max-height: calc(100vh - 80px); +} +.status-dashboard-section { + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + overflow: hidden; +} +.status-dashboard-section-header { + padding: var(--size-4-2) var(--size-4-3); + background-color: var(--background-modifier-hover); + border-bottom: 1px solid var(--background-modifier-border); +} +.status-dashboard-section-header h3 { + margin: 0; + font-size: var(--font-ui-large); + font-weight: var(--font-weight-semibold); + color: var(--text-normal); +} +.status-dashboard-current-note { + padding: var(--size-4-3); +} +.current-note-info { + margin-bottom: var(--size-4-3); + padding-bottom: var(--size-4-2); + border-bottom: 1px solid var(--background-modifier-border-hover); +} +.current-note-name { + font-size: var(--font-ui-large); + font-weight: var(--font-weight-semibold); + color: var(--text-normal); + margin-bottom: var(--size-2-1); +} +.current-note-path { + font-size: var(--font-ui-small); + color: var(--text-muted); + margin-bottom: var(--size-2-1); + font-family: var(--font-monospace); +} +.current-note-modified { + font-size: var(--font-ui-smaller); + color: var(--text-faint); + font-style: italic; +} +.current-note-statuses { + display: flex; + flex-direction: column; + gap: var(--size-4-2); +} +.current-note-tag-group { + display: flex; + flex-direction: column; + gap: var(--size-2-2); +} +.current-note-status-list { + display: flex; + flex-wrap: wrap; + gap: var(--size-2-2); +} +.current-note-no-status { + font-size: var(--font-ui-small); + color: var(--text-muted); + font-style: italic; + padding: var(--size-2-1) var(--size-2-3); + background-color: var(--background-modifier-border-hover); + border-radius: var(--radius-s); +} +.current-note-empty { + text-align: center; + color: var(--text-muted); + font-style: italic; + padding: var(--size-4-4); +} +.vault-overview { + padding: var(--size-4-3); +} +.vault-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--size-4-2); +} +.vault-stat-card { + text-align: center; + padding: var(--size-4-2); + background-color: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + transition: all var(--anim-duration-fast) var(--anim-motion-smooth); +} +.vault-stat-card:hover { + background-color: var(--background-modifier-hover); + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} +.vault-stat-number { + font-size: var(--font-ui-largest); + font-weight: var(--font-weight-bold); + color: var(--interactive-accent); + margin-bottom: var(--size-2-1); +} +.vault-stat-label { + font-size: var(--font-ui-small); + color: var(--text-muted); + font-weight: var(--font-weight-medium); + text-transform: uppercase; + letter-spacing: 0.5px; +} +.status-distribution { + padding: var(--size-4-3); +} +.status-chart { + display: flex; + flex-direction: column; + gap: var(--size-4-2); +} +.status-chart-item { + display: flex; + flex-direction: column; + gap: var(--size-2-1); +} +.status-chart-info { + display: flex; + align-items: center; + gap: var(--size-2-3); +} +.status-chart-count { + font-size: var(--font-ui-small); + color: var(--text-muted); + font-weight: var(--font-weight-medium); +} +.status-chart-bar { + height: 8px; + background-color: var(--background-modifier-border); + border-radius: var(--radius-s); + overflow: hidden; + position: relative; +} +.status-chart-fill { + height: 100%; + transition: width var(--anim-duration-moderate) var(--anim-motion-smooth); + border-radius: var(--radius-s); +} +.status-distribution-empty { + text-align: center; + color: var(--text-muted); + font-style: italic; + padding: var(--size-4-4); +} +.quick-actions { + padding: var(--size-4-3); + display: flex; + flex-wrap: wrap; + gap: var(--size-4-2); +} +.quick-action-btn { + padding: var(--size-2-3) var(--size-4-2); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + background-color: var(--interactive-normal); + color: var(--text-normal); + cursor: pointer; + transition: all var(--anim-duration-fast) var(--anim-motion-smooth); + font-size: var(--font-ui-medium); + font-weight: var(--font-weight-medium); + text-decoration: none; + flex: 1; + min-width: 200px; + text-align: center; +} +.quick-action-btn:hover { + background-color: var(--interactive-hover); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} +.quick-action-btn:active { + background-color: var(--interactive-active); + transform: translateY(0); +} +@media (max-width: 768px) { + .status-dashboard-header { + padding: var(--size-4-2) var(--size-4-3); + } + .status-dashboard-content { + padding: var(--size-4-2); + } + .status-dashboard-current-note { + padding: var(--size-4-2); + } + .vault-overview { + padding: var(--size-4-2); + } + .vault-stats-grid { + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: var(--size-4-1); + } + .quick-actions { + flex-direction: column; + } + .quick-action-btn { + min-width: auto; + } +} +.theme-dark .status-dashboard-section { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} +.theme-dark .vault-stat-card:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} +.theme-light .status-dashboard-section { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} +.theme-light .vault-stat-card:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} +.status-dashboard-content::-webkit-scrollbar { + width: var(--scrollbar-width); +} +.status-dashboard-content::-webkit-scrollbar-track { + background: var(--background-secondary); +} +.status-dashboard-content::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-bg); + border-radius: var(--radius-s); +} +.status-dashboard-content::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-bg-hover); +} + +/* styles/index.css */ diff --git a/styles/base.css b/styles/base.css deleted file mode 100644 index 096a10b..0000000 --- a/styles/base.css +++ /dev/null @@ -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; - } -} diff --git a/styles/components/collapsible-counter.css b/styles/components/collapsible-counter.css new file mode 100644 index 0000000..878a6ee --- /dev/null +++ b/styles/components/collapsible-counter.css @@ -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; +} diff --git a/styles/components/custom-status-item.css b/styles/components/custom-status-item.css new file mode 100644 index 0000000..73dda11 --- /dev/null +++ b/styles/components/custom-status-item.css @@ -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; +} diff --git a/styles/components/dropdown.css b/styles/components/dropdown.css deleted file mode 100644 index c465ac8..0000000 --- a/styles/components/dropdown.css +++ /dev/null @@ -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); -} diff --git a/styles/components/explorer.css b/styles/components/explorer.css deleted file mode 100644 index 4d63bae..0000000 --- a/styles/components/explorer.css +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Explorer integration styles - */ -.note-status-toolbar-badge-container { - display: flex; - align-items: center; - justify-content: center; - position: relative; -} - -.note-status-toolbar-icon { - color: var(--text-normal) !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; -} - -.note-status-count-badge { - position: absolute; - top: -5px; - right: -8px; - background-color: var(--interactive-accent); - color: var(--text-on-accent); - border-radius: 10px; - font-size: 10px; - min-width: 14px; - height: 14px; - padding: 0 3px; - display: flex; - align-items: center; - justify-content: center; - font-weight: var(--font-bold); -} - -/* Explorer icons container */ -.note-status-icon-container { - display: inline-flex; - margin-left: var(--size-4-1, 4px); - gap: 2px; - align-items: center; - flex-shrink: 0; -} - -/* Individual status icons in explorer */ -.note-status-icon { - font-size: var(--font-ui-smaller); - display: inline-flex; - align-items: center; - justify-content: center; - width: var(--status-icon-size); - height: var(--status-icon-size); - position: relative; - z-index: 500; /* Higher than most elements */ -} - -.nav-file-title .note-status-icon::after { - bottom: 150%; /* Position above in file explorer */ -} diff --git a/styles/components/file-explorer-icon.css b/styles/components/file-explorer-icon.css new file mode 100644 index 0000000..a5b18c1 --- /dev/null +++ b/styles/components/file-explorer-icon.css @@ -0,0 +1,233 @@ +/* FileExplorerIcon.css */ + +.status-wrapper { + position: relative; + display: inline-block; +} + +.status-minimal { + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: 6px; + cursor: pointer; + opacity: 0.6; + transition: all 0.2s ease; + padding: 2px; + border-radius: var(--radius-s); +} + +.status-minimal:hover { + opacity: 1; + background: color-mix(in srgb, var(--primary-color) 10%, transparent); + transform: scale(1.05); +} + +.status-icon { + font-size: 11px; + line-height: 1; + filter: grayscale(0.4); + transition: filter 0.2s ease; +} + +.status-minimal:hover .status-icon { + filter: grayscale(0); +} + +.status-count { + background: var(--primary-color); + color: var(--text-on-accent); + font-size: var(--font-smaller); + font-weight: var(--font-semibold); + min-width: 12px; + height: 12px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-left: 1px; + box-shadow: var(--shadow-s); + border: 1px solid var(--background-primary); +} + +.status-modal { + position: fixed; + left: 50%; + transform: translateX(-50%); + background: var(--background-primary); + border: var(--border-width) solid var(--background-modifier-border); + border-radius: var(--radius-l); + box-shadow: var(--shadow-l); + z-index: var(--layer-tooltip); + min-width: 280px; + max-width: 360px; + animation: modalIn 0.15s ease-out; + backdrop-filter: blur(8px); +} + +.status-modal.bottom { + top: calc(100% + 8px); +} + +.status-modal.top { + bottom: calc(100% + 8px); +} + +@keyframes modalIn { + from { + opacity: 0; + transform: translateX(-50%) scale(0.95); + } + to { + opacity: 1; + transform: translateX(-50%) scale(1); + } +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--size-4-4) var(--size-4-4) var(--size-4-2) var(--size-4-4); + border-bottom: var(--border-width) solid var(--background-modifier-border); +} + +.modal-title { + font-size: var(--font-ui-medium); + font-weight: var(--font-semibold); + color: var(--text-normal); +} + +.modal-total { + background: var(--background-modifier-hover); + color: var(--text-muted); + font-size: var(--font-smallest); + font-weight: var(--font-medium); + padding: var(--size-2-1) var(--size-2-3); + border-radius: var(--radius-m); +} + +.modal-content { + padding: var(--size-4-2) var(--size-4-4) var(--size-4-4) var(--size-4-4); + max-height: 320px; + overflow-y: auto; +} + +.status-group:not(:last-child) { + margin-bottom: var(--size-4-4); +} + +.group-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--size-2-3); +} + +.group-name { + font-size: var(--font-ui-small); + font-weight: var(--font-semibold); + color: var(--text-normal); + text-transform: capitalize; +} + +.group-count { + background: var(--background-modifier-hover); + color: var(--text-muted); + font-size: var(--font-smallest); + font-weight: var(--font-medium); + padding: var(--size-2-1) var(--size-2-2); + border-radius: var(--radius-s); + min-width: 16px; + text-align: center; +} + +.status-list { + display: flex; + flex-direction: column; + gap: var(--size-2-2); +} + +.status-item { + display: flex; + align-items: center; + gap: var(--size-4-2); + padding: var(--size-2-3) var(--size-4-2); + background: color-mix(in srgb, var(--item-color) 8%, transparent); + border: var(--border-width) solid + color-mix(in srgb, var(--item-color) 20%, transparent); + border-radius: var(--radius-s); + transition: all 0.2s ease; + cursor: pointer; +} + +.status-item:hover { + background: color-mix(in srgb, var(--item-color) 12%, transparent); + border-color: color-mix(in srgb, var(--item-color) 35%, transparent); + transform: translateX(2px); +} + +.item-icon { + font-size: var(--font-ui-medium); + flex-shrink: 0; +} + +.item-info { + display: flex; + flex-direction: column; + gap: var(--size-2-1); + min-width: 0; +} + +.item-name { + font-size: var(--font-ui-small); + font-weight: var(--font-medium); + color: var(--text-normal); +} + +.item-description { + font-size: var(--font-smallest); + color: var(--text-muted); + line-height: var(--line-height-tight); +} + +.modal-arrow { + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 10px; + height: 10px; + background: var(--background-primary); + border: var(--border-width) solid var(--background-modifier-border); + rotate: 45deg; +} + +.status-modal.bottom .modal-arrow { + top: -5px; + border-bottom: none; + border-right: none; +} + +.status-modal.top .modal-arrow { + bottom: -5px; + border-top: none; + border-left: none; +} + +.modal-content::-webkit-scrollbar { + width: var(--scrollbar-thin-thumb); +} + +.modal-content::-webkit-scrollbar-track { + background: var(--background-modifier-hover); + border-radius: var(--radius-s); +} + +.modal-content::-webkit-scrollbar-thumb { + background: var(--background-modifier-border); + border-radius: var(--radius-s); +} + +.modal-content::-webkit-scrollbar-thumb:hover { + background: var(--background-modifier-border-hover); +} diff --git a/styles/components/group-label.css b/styles/components/group-label.css new file mode 100644 index 0000000..85cdd8c --- /dev/null +++ b/styles/components/group-label.css @@ -0,0 +1,7 @@ +.group-label-container { + fontsize: var(--font-text-size); + texttransform: uppercase; + letterspacing: 0.8px; + fontweight: var(--font-weight-semibold); + transition: color var(--anim-duration-fast) ease; +} diff --git a/styles/components/groupped-status-view.css b/styles/components/groupped-status-view.css new file mode 100644 index 0000000..146356a --- /dev/null +++ b/styles/components/groupped-status-view.css @@ -0,0 +1,290 @@ +.groupped-status-view-container { + height: 100%; + overflow: hidden; +} + +.groupped-status-view { + height: 100%; + display: flex; + flex-direction: column; + background-color: var(--background-primary); +} + +.groupped-status-header { + padding: var(--size-4-2) var(--size-4-3); + border-bottom: 1px solid var(--background-modifier-border); + background-color: var(--background-secondary); + flex-shrink: 0; +} + +.groupped-status-filters { + display: flex; + flex-direction: column; + gap: var(--size-4-2); +} + +.groupped-status-note-filter { + display: flex; + flex-direction: column; +} + +.groupped-status-note-input { + width: 100%; + padding: var(--size-2-1) var(--size-2-3); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background: var(--background-primary); + color: var(--text-normal); + font-size: var(--font-ui-medium); + transition: border-color var(--anim-duration-fast) var(--anim-motion-smooth); +} + +.groupped-status-note-input:focus { + outline: none; + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--interactive-accent-hover); +} + +.groupped-status-note-input::placeholder { + color: var(--text-muted); +} + +.groupped-status-title { + margin: 0 0 var(--size-4-2) 0; + font-size: var(--font-ui-large); + font-weight: var(--font-weight-semibold); + color: var(--text-normal); +} + +.groupped-status-content { + flex: 1; + overflow-y: auto; + padding: var(--size-4-2); + max-height: calc(100vh - 120px); +} + +.groupped-status-tag-section { + margin-bottom: var(--size-4-4); +} + +.groupped-status-tag-section:last-child { + margin-bottom: 0; +} + +.groupped-status-tag-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--size-4-2) var(--size-4-3); + cursor: pointer; + transition: background-color var(--anim-duration-fast) + var(--anim-motion-smooth); + border-radius: var(--radius-m); + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + margin-bottom: var(--size-4-2); +} + +.groupped-status-tag-header:hover { + background-color: var(--background-modifier-hover); +} + +.groupped-status-tag-content { + padding-left: var(--size-4-2); +} + +.groupped-status-group { + margin-bottom: var(--size-4-2); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + background-color: var(--background-primary); + overflow: hidden; +} + +.groupped-status-group-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--size-4-2) var(--size-4-3); + cursor: pointer; + transition: background-color var(--anim-duration-fast) + var(--anim-motion-smooth); + border-bottom: 1px solid transparent; +} + +.groupped-status-group-header:hover { + background-color: var(--background-modifier-hover); +} + +.groupped-status-group-header:active { + background-color: var(--background-modifier-active); +} + +.groupped-status-group-info { + display: flex; + align-items: center; + gap: var(--size-2-1); +} + +.groupped-status-files { + border-top: 1px solid var(--background-modifier-border); + background-color: var(--background-secondary); +} + +.groupped-status-files-list { + max-height: 300px; + overflow-y: auto; +} + +.groupped-status-file-item { + display: flex; + flex-direction: column; + padding: var(--size-2-3) var(--size-4-3); + cursor: pointer; + transition: background-color var(--anim-duration-fast) + var(--anim-motion-smooth); + border-bottom: 1px solid var(--background-modifier-border-hover); +} + +.groupped-status-file-item:last-child { + border-bottom: none; +} + +.groupped-status-file-item:hover { + background-color: var(--background-modifier-hover); +} + +.groupped-status-file-item:active { + background-color: var(--background-modifier-active); +} + +.groupped-status-file-name { + font-weight: var(--font-weight-medium); + color: var(--text-normal); + margin-bottom: var(--size-2-1); + font-size: var(--font-ui-medium); +} + +.groupped-status-file-path { + font-size: var(--font-ui-small); + color: var(--text-muted); + opacity: 0.8; +} + +.groupped-status-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: var(--font-ui-medium); + color: var(--text-muted); +} + +.groupped-status-empty { + display: flex; + align-items: center; + justify-content: center; + padding: var(--size-4-6); + font-size: var(--font-ui-medium); + color: var(--text-muted); + text-align: center; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .groupped-status-header { + padding: var(--size-4-1) var(--size-4-2); + } + + .groupped-status-content { + padding: var(--size-4-1); + } + + .groupped-status-group-header { + padding: var(--size-4-1) var(--size-4-2); + } + + .groupped-status-file-item { + padding: var(--size-2-2) var(--size-4-2); + } +} + +/* Dark theme adjustments */ +.theme-dark .groupped-status-view { + border-color: var(--background-modifier-border); +} + +.theme-dark .groupped-status-group { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +/* Light theme adjustments */ +.theme-light .groupped-status-group { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +/* Scrollbar styling for consistency with Obsidian */ +.groupped-status-content::-webkit-scrollbar { + width: var(--scrollbar-width); +} + +.groupped-status-content::-webkit-scrollbar-track { + background: var(--background-secondary); +} + +.groupped-status-content::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-bg); + border-radius: var(--radius-s); +} + +.groupped-status-content::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-bg-hover); +} + +/* Infinite scroll styles */ +.groupped-status-load-more { + display: flex; + justify-content: center; + padding: var(--size-4-2); + border-top: 1px solid var(--background-modifier-border-hover); +} + +.groupped-status-load-btn { + padding: var(--size-2-2) var(--size-4-2); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background-color: var(--interactive-normal); + color: var(--text-normal); + cursor: pointer; + transition: all var(--anim-duration-fast) var(--anim-motion-smooth); + font-size: var(--font-ui-small); + font-weight: var(--font-weight-medium); +} + +.groupped-status-load-btn:hover { + background-color: var(--interactive-hover); + transform: translateY(-1px); +} + +.groupped-status-load-btn:active { + background-color: var(--interactive-active); + transform: translateY(0); +} + +/* File list scrollbar styling */ +.groupped-status-files-list::-webkit-scrollbar { + width: var(--scrollbar-width); +} + +.groupped-status-files-list::-webkit-scrollbar-track { + background: var(--background-secondary); +} + +.groupped-status-files-list::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-bg); + border-radius: var(--radius-s); +} + +.groupped-status-files-list::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-bg-hover); +} diff --git a/styles/components/quick-commands-settings.css b/styles/components/quick-commands-settings.css new file mode 100644 index 0000000..49193fc --- /dev/null +++ b/styles/components/quick-commands-settings.css @@ -0,0 +1,3 @@ +.quick-commands-container { + margin-top: 1em; +} diff --git a/styles/components/setting-item.css b/styles/components/setting-item.css new file mode 100644 index 0000000..018477e --- /dev/null +++ b/styles/components/setting-item.css @@ -0,0 +1,8 @@ +.setting-item-full { + width: 100%; +} +.setting-item-vertical { + flex-direction: column; + align-items: start; + gap: 4px; +} diff --git a/styles/components/settings.css b/styles/components/settings.css deleted file mode 100644 index 651456e..0000000 --- a/styles/components/settings.css +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Settings styles - */ -/* Template styling */ -.status-color-dot { - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - background-color: var(--dot-color, #ffffff); - margin-right: 4px; -} - -.template-buttons { - margin-top: 15px; - display: flex; - gap: 10px; -} - -/* Template selection styling */ -.template-item { - border: 1px solid var(--background-modifier-border); - border-radius: var(--status-border-radius); - margin-bottom: 10px; - padding: 10px; - background: var(--background-primary-alt); -} - -.template-header { - display: flex; - align-items: center; - margin-bottom: 5px; -} - -.template-checkbox { - margin-right: 10px; -} - -.template-name { - font-weight: bold; -} - -.template-description { - font-size: 0.9em; - color: var(--text-muted); - margin-bottom: 8px; -} - -.template-statuses { - display: flex; - flex-wrap: wrap; - gap: 5px; -} - -.template-status-chip { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 12px; - background: var(--background-secondary); - font-size: 0.85em; -} diff --git a/styles/components/status-badge.css b/styles/components/status-badge.css new file mode 100644 index 0000000..cd4afc0 --- /dev/null +++ b/styles/components/status-badge.css @@ -0,0 +1,27 @@ +.status-badge-container { + display: inline-flex; + flex-direction: column; + align-items: center; + border-radius: var(--radius-s); + /* backgroundColor and border need dynamic color values - handle with CSS custom properties or JS */ + transition: all var(--anim-duration-moderate) var(--anim-motion-smooth); + overflow: hidden; + min-width: auto; + max-width: none; +} + +.status-badge-item { + display: flex; + align-items: center; + gap: var(--size-2-1); + padding: var(--size-2-1) var(--size-2-3); + transition: all var(--anim-duration-fast) ease; +} + +.status-badge-icon { + transition: transform var(--anim-duration-fast) ease; +} + +.status-badge-text { + font-weight: var(--font-weight-medium); +} diff --git a/styles/components/status-bar-enable-button.css b/styles/components/status-bar-enable-button.css new file mode 100644 index 0000000..ec1be8c --- /dev/null +++ b/styles/components/status-bar-enable-button.css @@ -0,0 +1,8 @@ +.status-bar-enable-button { + opacity: var(--icon-opacity); +} + +.status-bar-enable-button:hover { + opacity: var(--icon-opacity-hover); + background: var(--background-modifier-active); +} diff --git a/styles/components/status-bar-group.css b/styles/components/status-bar-group.css new file mode 100644 index 0000000..87e8e28 --- /dev/null +++ b/styles/components/status-bar-group.css @@ -0,0 +1,17 @@ +.status-bar-group-container { + display: flex; + gap: var(--size-2-1); + flex-wrap: wrap; + align-items: center; +} + +@keyframes status-slide-in { + from { + opacity: 0; + transform: scale(0.8) translateY(4px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} diff --git a/styles/components/status-bar.css b/styles/components/status-bar.css index e08c5ab..92c8c3b 100644 --- a/styles/components/status-bar.css +++ b/styles/components/status-bar.css @@ -1,64 +1,5 @@ -/* - * Status bar styles - */ -.note-status-bar { +.status-bar-group-row { display: flex; align-items: center; - gap: 6px; - padding: 4px 8px; - border-radius: var(--radius-s); - transition: all 0.2s var(--ease-out); - cursor: pointer; - height: 22px; -} - -.note-status-bar:hover { - background-color: var(--background-modifier-hover); -} - -.note-status-badges { - display: flex; - align-items: center; - gap: 4px; -} - -.note-status-badge { - display: flex; - align-items: center; - padding: 2px 6px; - border-radius: 10px; - background: var(--background-secondary-alt); - font-size: var(--font-ui-smaller); - max-width: 100px; - margin-right: 2px; -} - -.note-status-badge-icon { - margin-right: 2px; -} - -.note-status-badge-text { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Status bar positioning and visibility */ -.status-bar .note-status-icon::after { - bottom: auto; - top: -30px; -} - -/* Auto-hide behavior */ -.note-status-bar.hidden { - display: none; -} - -.note-status-bar.auto-hide { - opacity: 0.5; - transition: opacity 0.2s var(--ease-out); -} - -.note-status-bar.visible { - opacity: 1; + gap: var(--size-4-2); } diff --git a/styles/components/status-dashboard.css b/styles/components/status-dashboard.css new file mode 100644 index 0000000..0c451d0 --- /dev/null +++ b/styles/components/status-dashboard.css @@ -0,0 +1,343 @@ +.status-dashboard-view-container { + height: 100%; + overflow: hidden; +} + +.status-dashboard { + height: 100%; + display: flex; + flex-direction: column; + background-color: var(--background-primary); +} + +.status-dashboard-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--size-4-3) var(--size-4-4); + border-bottom: 1px solid var(--background-modifier-border); + background-color: var(--background-secondary); + flex-shrink: 0; +} + +.status-dashboard-title { + margin: 0; + font-size: var(--font-ui-larger); + font-weight: var(--font-weight-bold); + color: var(--text-normal); +} + +.status-dashboard-refresh { + padding: var(--size-2-2) var(--size-2-3); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background-color: var(--interactive-normal); + color: var(--text-normal); + cursor: pointer; + transition: all var(--anim-duration-fast) var(--anim-motion-smooth); + font-size: var(--font-ui-medium); +} + +.status-dashboard-refresh:hover { + background-color: var(--interactive-hover); + transform: rotate(90deg); +} + +.status-dashboard-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: var(--font-ui-medium); + color: var(--text-muted); +} + +.status-dashboard-content { + flex: 1; + overflow-y: auto; + padding: var(--size-4-3); + display: flex; + flex-direction: column; + gap: var(--size-4-4); + max-height: calc(100vh - 80px); +} + +/* Section Styles */ +.status-dashboard-section { + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + overflow: hidden; +} + +.status-dashboard-section-header { + padding: var(--size-4-2) var(--size-4-3); + background-color: var(--background-modifier-hover); + border-bottom: 1px solid var(--background-modifier-border); +} + +.status-dashboard-section-header h3 { + margin: 0; + font-size: var(--font-ui-large); + font-weight: var(--font-weight-semibold); + color: var(--text-normal); +} + +/* Current Note Section */ +.status-dashboard-current-note { + padding: var(--size-4-3); +} + +.current-note-info { + margin-bottom: var(--size-4-3); + padding-bottom: var(--size-4-2); + border-bottom: 1px solid var(--background-modifier-border-hover); +} + +.current-note-name { + font-size: var(--font-ui-large); + font-weight: var(--font-weight-semibold); + color: var(--text-normal); + margin-bottom: var(--size-2-1); +} + +.current-note-path { + font-size: var(--font-ui-small); + color: var(--text-muted); + margin-bottom: var(--size-2-1); + font-family: var(--font-monospace); +} + +.current-note-modified { + font-size: var(--font-ui-smaller); + color: var(--text-faint); + font-style: italic; +} + +.current-note-statuses { + display: flex; + flex-direction: column; + gap: var(--size-4-2); +} + +.current-note-tag-group { + display: flex; + flex-direction: column; + gap: var(--size-2-2); +} + +.current-note-status-list { + display: flex; + flex-wrap: wrap; + gap: var(--size-2-2); +} + +.current-note-no-status { + font-size: var(--font-ui-small); + color: var(--text-muted); + font-style: italic; + padding: var(--size-2-1) var(--size-2-3); + background-color: var(--background-modifier-border-hover); + border-radius: var(--radius-s); +} + +.current-note-empty { + text-align: center; + color: var(--text-muted); + font-style: italic; + padding: var(--size-4-4); +} + +/* Vault Overview Section */ +.vault-overview { + padding: var(--size-4-3); +} + +.vault-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--size-4-2); +} + +.vault-stat-card { + text-align: center; + padding: var(--size-4-2); + background-color: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + transition: all var(--anim-duration-fast) var(--anim-motion-smooth); +} + +.vault-stat-card:hover { + background-color: var(--background-modifier-hover); + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.vault-stat-number { + font-size: var(--font-ui-largest); + font-weight: var(--font-weight-bold); + color: var(--interactive-accent); + margin-bottom: var(--size-2-1); +} + +.vault-stat-label { + font-size: var(--font-ui-small); + color: var(--text-muted); + font-weight: var(--font-weight-medium); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Status Distribution Section */ +.status-distribution { + padding: var(--size-4-3); +} + +.status-chart { + display: flex; + flex-direction: column; + gap: var(--size-4-2); +} + +.status-chart-item { + display: flex; + flex-direction: column; + gap: var(--size-2-1); +} + +.status-chart-info { + display: flex; + align-items: center; + gap: var(--size-2-3); +} + +.status-chart-count { + font-size: var(--font-ui-small); + color: var(--text-muted); + font-weight: var(--font-weight-medium); +} + +.status-chart-bar { + height: 8px; + background-color: var(--background-modifier-border); + border-radius: var(--radius-s); + overflow: hidden; + position: relative; +} + +.status-chart-fill { + height: 100%; + transition: width var(--anim-duration-moderate) var(--anim-motion-smooth); + border-radius: var(--radius-s); +} + +.status-distribution-empty { + text-align: center; + color: var(--text-muted); + font-style: italic; + padding: var(--size-4-4); +} + +/* Quick Actions Section */ +.quick-actions { + padding: var(--size-4-3); + display: flex; + flex-wrap: wrap; + gap: var(--size-4-2); +} + +.quick-action-btn { + padding: var(--size-2-3) var(--size-4-2); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + background-color: var(--interactive-normal); + color: var(--text-normal); + cursor: pointer; + transition: all var(--anim-duration-fast) var(--anim-motion-smooth); + font-size: var(--font-ui-medium); + font-weight: var(--font-weight-medium); + text-decoration: none; + flex: 1; + min-width: 200px; + text-align: center; +} + +.quick-action-btn:hover { + background-color: var(--interactive-hover); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.quick-action-btn:active { + background-color: var(--interactive-active); + transform: translateY(0); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .status-dashboard-header { + padding: var(--size-4-2) var(--size-4-3); + } + + .status-dashboard-content { + padding: var(--size-4-2); + } + + .status-dashboard-current-note { + padding: var(--size-4-2); + } + + .vault-overview { + padding: var(--size-4-2); + } + + .vault-stats-grid { + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: var(--size-4-1); + } + + .quick-actions { + flex-direction: column; + } + + .quick-action-btn { + min-width: auto; + } +} + +/* Dark theme adjustments */ +.theme-dark .status-dashboard-section { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.theme-dark .vault-stat-card:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +/* Light theme adjustments */ +.theme-light .status-dashboard-section { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.theme-light .vault-stat-card:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +/* Scrollbar styling */ +.status-dashboard-content::-webkit-scrollbar { + width: var(--scrollbar-width); +} + +.status-dashboard-content::-webkit-scrollbar-track { + background: var(--background-secondary); +} + +.status-dashboard-content::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-bg); + border-radius: var(--radius-s); +} + +.status-dashboard-content::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-bg-hover); +} diff --git a/styles/components/status-file-info-popup.css b/styles/components/status-file-info-popup.css new file mode 100644 index 0000000..6e471f2 --- /dev/null +++ b/styles/components/status-file-info-popup.css @@ -0,0 +1,154 @@ +.status-info-popup { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1000; + background: var(--background-primary); + border: var(--border-width) solid var(--background-modifier-border); + border-radius: var(--radius-l); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + min-width: 280px; + max-width: 400px; + pointer-events: none; + overflow: hidden; + backdrop-filter: blur(8px); +} + +.status-popup-header { + background: linear-gradient( + 135deg, + var(--background-secondary) 0%, + var(--background-modifier-hover) 100% + ); + border-bottom: var(--border-width) solid var(--background-modifier-border); + padding: var(--size-4-2) var(--size-4-3); + font-weight: var(--font-weight-semibold); + color: var(--text-normal); + font-size: var(--font-ui-small); + display: flex; + align-items: center; + gap: var(--size-2-2); +} + +.status-header-icon { + font-size: var(--font-ui-medium); + opacity: 0.8; +} + +.status-popup-content { + padding: var(--size-4-2); + max-height: none; + overflow: visible; +} + +.status-popup-empty { + padding: var(--size-4-4) var(--size-4-3); + text-align: center; + color: var(--text-muted); + font-size: var(--font-ui-smaller); + background: var(--background-primary); + border: var(--border-width) solid var(--background-modifier-border); + border-radius: var(--radius-l); + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1000; + pointer-events: none; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--size-2-2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); +} + +.status-empty-icon { + font-size: var(--font-ui-large); + opacity: 0.6; +} + +.status-group { + margin-bottom: var(--size-4-3); + background: var(--background-secondary); + border-radius: var(--radius-m); + border: var(--border-width) solid var(--background-modifier-border-hover); + overflow: hidden; +} + +.status-group:last-child { + margin-bottom: 0; +} + +.status-group-header { + padding: var(--size-2-3) var(--size-4-2); + background: linear-gradient( + 90deg, + var(--interactive-accent-hover) 0%, + var(--interactive-accent) 100% + ); + color: var(--text-on-accent); + font-size: var(--font-ui-smaller); + font-weight: var(--font-weight-medium); + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: var(--border-width) solid var(--background-modifier-border); +} + +.status-group-name { + text-transform: capitalize; + letter-spacing: 0.3px; +} + +.status-group-count { + background: rgba(255, 255, 255, 0.2); + color: var(--text-on-accent); + font-size: var(--font-ui-smaller); + font-weight: var(--font-weight-semibold); + padding: var(--size-2-1) var(--size-2-3); + border-radius: var(--radius-s); + min-width: var(--size-4-3); + text-align: center; + backdrop-filter: blur(4px); +} + +.status-group-items { + padding: var(--size-2-3); + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: var(--size-2-2); + background: var(--background-primary); +} + +.status-item { + display: flex; + flex-direction: column; + gap: var(--size-2-1); + padding: var(--size-2-2) var(--size-2-3); + background: var(--background-modifier-hover); + border-radius: var(--radius-s); + border: var(--border-width) solid var(--background-modifier-border); + transition: all 0.15s ease; + min-width: 0; +} + +.status-item:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border-color: var(--interactive-accent); +} + +.status-description { + color: var(--text-muted); + font-size: var(--font-ui-smaller); + line-height: 1.3; + background: var(--background-primary-alt); + padding: var(--size-2-1) var(--size-2-2); + border-radius: var(--radius-s); + border-left: 2px solid var(--interactive-accent); + font-style: italic; + opacity: 0.8; + word-wrap: break-word; + hyphens: auto; +} diff --git a/styles/components/status-file-popup.css b/styles/components/status-file-popup.css new file mode 100644 index 0000000..b442224 --- /dev/null +++ b/styles/components/status-file-popup.css @@ -0,0 +1,22 @@ +.status-info-popup { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1000; + background: var(--background-primary); + border: var(--border-width) solid var(--background-modifier-border); + border-radius: var(--radius-l); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + min-width: 280px; + max-width: 400px; + pointer-events: none; + overflow: hidden; + backdrop-filter: blur(8px); +} + +.status-popup-content { + padding: var(--size-4-2); + max-height: none; + overflow: visible; +} diff --git a/styles/components/status-group.css b/styles/components/status-group.css new file mode 100644 index 0000000..6436795 --- /dev/null +++ b/styles/components/status-group.css @@ -0,0 +1,12 @@ +.status-group { + margin-bottom: 1.5em; +} + +.status-group-description { + font-size: 0.85em; + opacity: 0.7; +} + +.status-group-items { + margin-left: 1em; +} diff --git a/styles/components/status-pane.css b/styles/components/status-pane.css deleted file mode 100644 index 5d19417..0000000 --- a/styles/components/status-pane.css +++ /dev/null @@ -1,313 +0,0 @@ -/* - * Status Pane Styles - */ -.note-status-pane { - padding: var(--size-4-2, 8px); - background: var(--background-secondary); - color: var(--text-normal); - font-family: var(--font-interface); - overflow-y: auto; - height: 100%; - display: flex; - flex-direction: column; -} - -/* Header elements */ -.note-status-header { - position: sticky; - top: 0; - z-index: 10; - background: var(--background-secondary); - padding-bottom: var(--size-4-2, 8px); - border-bottom: 1px solid var(--background-modifier-border); - margin-bottom: var(--size-4-2, 8px); - display: flex; - flex-direction: column; - gap: var(--size-4-2, 8px); -} - -/* Search input styles */ -.note-status-search { - width: 100%; - position: relative; -} - -.search-input-wrapper, -.note-status-search-wrapper { - position: relative; - display: flex; - align-items: center; -} - -.search-input-icon, -.note-status-search-icon { - position: absolute; - left: var(--size-4-1, 4px); - color: var(--text-muted); - display: flex; - align-items: center; - pointer-events: none; - padding: 4px; -} - -.note-status-search-input, -.note-status-popover-search-input { - width: 100%; - padding: var(--input-padding); - padding-left: calc(var(--size-4-1, 4px) * 3 + 18px); - border: var(--input-border-width) solid var(--background-modifier-border); - border-radius: var(--input-radius); - background: var(--background-primary); - color: var(--text-normal); - outline: none; - transition: all var(--status-transition-time) var(--ease-out); -} - -.note-status-search-input:focus, -.note-status-popover-search-input:focus { - border-color: var(--interactive-accent); - box-shadow: 0 0 0 2px var(--interactive-accent-hover); -} - -.search-input-clear-button, -.note-status-search-clear-button { - opacity: 0; - position: absolute; - right: var(--size-4-1, 4px); - cursor: pointer; - color: var(--text-muted); - display: flex; - align-items: center; - transition: opacity var(--status-transition-time) var(--ease-out); - padding: 4px; - border-radius: 50%; -} - -.search-input-clear-button.is-visible, -.note-status-search-clear-button.is-visible { - opacity: 1; -} - -.search-input-clear-button:hover, -.note-status-search-clear-button:hover { - color: var(--text-normal); - background-color: var(--background-modifier-hover); -} - -/* Actions toolbar */ -.status-pane-actions-container { - display: flex; - gap: var(--size-4-2, 8px); - justify-content: flex-end; - margin-top: var(--size-4-1, 4px); -} - -.note-status-view-toggle, -.note-status-actions-refresh { - padding: var(--size-4-1, 4px) var(--size-4-2, 8px); - border: var(--input-border-width) solid var(--background-modifier-border); - border-radius: var(--button-radius); - background: var(--background-primary); - color: var(--text-normal); - cursor: pointer; - transition: all var(--status-transition-time) var(--ease-out); - display: flex; - align-items: center; - justify-content: center; -} - -.note-status-view-toggle:hover, -.note-status-actions-refresh:hover { - background: var(--background-modifier-hover); - box-shadow: var(--shadow-s); -} - -.note-status-view-toggle:active, -.note-status-actions-refresh:active { - transform: translateY(1px); - box-shadow: none; -} - -/* Status Groups Container */ -.note-status-groups-container { - flex: 1; - overflow-y: auto; - padding-right: 2px; /* Prevents scrollbar from hugging the edge */ -} - -/* Status Group Styling */ -.note-status-group { - margin-bottom: var(--size-4-3, 12px); - border-radius: var(--radius-s); - background: var(--background-primary-alt); - box-shadow: var(--shadow-xs); - overflow: hidden; - animation: note-status-fade-in-slide-down 0.3s var(--ease-out); -} - -.note-status-group .nav-folder-title { - cursor: pointer; - padding: var(--size-4-1, 4px) var(--size-4-2, 8px); - background: var(--background-secondary-alt); - transition: background var(--status-transition-time) var(--ease-out); -} - -.note-status-group .nav-folder-title:hover { - background: var(--background-modifier-hover); -} - -.note-status-group .nav-folder-title-content { - font-weight: var(--font-semibold); - display: flex; - align-items: center; - gap: var(--size-4-1, 4px); -} - -/* Collapse indicators */ -.note-status-collapse-indicator { - margin-right: var(--size-4-2, 8px); - display: flex; - align-items: center; - color: var(--text-muted); - transition: transform var(--status-transition-time) var(--ease-in-out); - opacity: 1; -} - -.note-status-is-collapsed .note-status-collapse-indicator { - transform: rotate(-90deg); -} - -/* File items */ -.note-status-group .nav-folder-children { - padding: var(--size-4-1, 4px); - background: var(--background-primary); - transition: height var(--status-transition-time) var(--ease-out); -} - -.note-status-group.nav-folder.note-status-is-collapsed .nav-folder-children { - display: none; -} - -.nav-file { - border-radius: var(--radius-s); - transition: background var(--status-transition-time) var(--ease-out); - margin-bottom: 2px; - position: relative; -} - -.nav-file:hover { - background: var(--background-modifier-hover); -} - -.nav-file-title { - display: flex; - align-items: center; - padding: var(--size-4-1, 4px) var(--size-4-2, 8px); - flex-wrap: nowrap; -} - -.nav-file-icon { - color: var(--text-muted); - margin-right: var(--size-4-2, 8px); - display: flex; - align-items: center; - flex-shrink: 0; -} - -.nav-file-title-content { - flex: 1; - min-width: 0; /* Allows text to shrink properly */ - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Compact view */ -.note-status-compact-view .nav-file-title { - padding: 2px var(--size-4-2, 8px); - font-size: var(--font-smaller); -} - -.note-status-compact-view .nav-folder-children { - padding: 0; -} - -.note-status-compact-view .nav-file { - margin-bottom: 0; - border-radius: 0; - border-bottom: 1px solid var(--background-modifier-border); -} - -.note-status-compact-view .nav-file:last-child { - border-bottom: none; -} - -/* Empty message and show unassigned button */ -.note-status-empty-message { - margin-bottom: 12px; - text-align: center; - color: var(--text-muted); - font-style: italic; -} - -.note-status-button-container { - display: flex; - justify-content: center; - margin-top: 16px; -} - -.note-status-show-unassigned-button { - background-color: var(--interactive-accent); - color: var(--text-on-accent); - padding: 8px 16px; - border-radius: var(--radius-s, 4px); - border: none; - cursor: pointer; - font-size: var(--font-ui-small); - transition: all 0.2s var(--ease-out); - box-shadow: var(--shadow-s); -} - -.note-status-show-unassigned-button:hover { - background-color: var(--interactive-accent-hover); - transform: translateY(-1px); - box-shadow: var(--shadow-m); -} - -.note-status-show-unassigned-button:active { - transform: translateY(0); - box-shadow: var(--shadow-xs); -} - -/* - * Pagination styles - */ -.note-status-pagination { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px; - border-top: 1px solid var(--background-modifier-border); - margin-top: 8px; -} - -.note-status-pagination-button { - padding: 4px 8px; - border-radius: var(--radius-s); - background: var(--background-secondary); - border: var(--input-border-width) solid var(--background-modifier-border); - color: var(--text-normal); - cursor: pointer; - transition: all 0.15s var(--ease-out); - font-size: var(--font-smaller); -} - -.note-status-pagination-button:hover { - background: var(--background-modifier-hover); - box-shadow: var(--shadow-s); -} - -.note-status-pagination-info { - font-size: var(--font-smaller); - color: var(--text-muted); -} diff --git a/styles/components/template-item.css b/styles/components/template-item.css new file mode 100644 index 0000000..90883e1 --- /dev/null +++ b/styles/components/template-item.css @@ -0,0 +1,37 @@ +.template-item { + border: var(--border-width) solid var(--color-base-30); + border-radius: var(--radius-xl); + margin-bottom: var(--size-4-2); + padding: var(--size-4-3); + background: var(--background-primary-alt); + cursor: pointer; + transition: background-color 0.1s ease; +} + +.template-item:hover { + background: var(--background-modifier-hover); +} + +.template-item.enabled { + background: var(--background-modifier-success-hover); +} + +.template-item.enabled:hover { + background: var(--background-modifier-success); +} + +.template-header { + display: flex; + align-items: center; +} + +.template-checkbox { + pointer-events: none; +} + +.template-statuses { + margin-top: 7px; + display: flex; + flex-wrap: wrap; + gap: 5px; +} diff --git a/styles/components/template-status-chip.css b/styles/components/template-status-chip.css new file mode 100644 index 0000000..ad3158a --- /dev/null +++ b/styles/components/template-status-chip.css @@ -0,0 +1,17 @@ +.template-status-chip { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--radius-xl); + background: var(--background-secondary); + font-size: 0.85em; + border: 1px solid var(--color-base-30); + gap: 4px; +} +.template-status-color-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--dot-color, --color-base-30); +} diff --git a/styles/index.css b/styles/index.css index 52bb9fc..c00f08f 100644 --- a/styles/index.css +++ b/styles/index.css @@ -4,11 +4,19 @@ * * Author: Aleix Soler */ - -@import "base.css"; -@import "utils.css"; +@import "components/setting-item.css"; +@import "components/template-status-chip.css"; +@import "components/template-item.css"; +@import "components/custom-status-item.css"; +@import "components/quick-commands-settings.css"; +@import "components/status-group.css"; +@import "components/status-bar-enable-button.css"; +@import "components/status-badge.css"; +@import "components/status-bar-group.css"; @import "components/status-bar.css"; -@import "components/status-pane.css"; -@import "components/dropdown.css"; -@import "components/explorer.css"; -@import "components/settings.css"; +@import "components/collapsible-counter.css"; +@import "components/group-label.css"; +@import "components/file-explorer-icon.css"; +@import "components/status-file-info-popup.css"; +@import "components/groupped-status-view.css"; +@import "components/status-dashboard.css"; diff --git a/styles/utils.css b/styles/utils.css deleted file mode 100644 index 36d2e50..0000000 --- a/styles/utils.css +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Utility classes for Note Status Plugin - */ -.note-status-empty-indicator { - color: var(--text-muted); - font-style: italic; - padding: var(--size-4-1, 4px); - display: flex; - align-items: center; - justify-content: center; - color: var(--text-accent); - font-size: 1.2em; -} - -/* Action buttons in modal */ -.note-status-action-button { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: var(--radius-s); - background: transparent; - color: var(--text-muted); - cursor: pointer; - transition: all 0.15s ease; - border: none; - padding: 0; -} - -.note-status-action-button:hover { - background-color: var(--background-modifier-hover); - color: var(--text-normal); -} - -.note-status-action-button.note-status-button-active { - background-color: var(--interactive-accent); - color: var(--text-on-accent); - transform: scale(0.95); -} - -/* Loading indicator */ -.note-status-loading { - display: flex; - justify-content: center; - align-items: center; - padding: 20px; - color: var(--text-muted); - font-style: italic; -} - -.note-status-loading span { - position: relative; - padding-left: 24px; -} - -.note-status-loading span:before { - content: ""; - position: absolute; - left: 0; - top: 50%; - width: 16px; - height: 16px; - margin-top: -8px; - border: 2px solid var(--background-modifier-border); - border-top-color: var(--text-accent); - border-radius: 50%; - animation: note-status-loading-spinner 0.8s linear infinite; -} - -@keyframes note-status-loading-spinner { - to { - transform: rotate(360deg); - } -} - -/* Positioning classes for dropdowns */ -.note-status-dummy-target { - position: fixed; - z-index: 1000; - width: 0; - height: 0; - left: var(--pos-x-px, 0); - top: var(--pos-y-px, 0); - pointer-events: none; -} - -.note-status-popover-fixed { - position: fixed; - z-index: 999; - --pos-x: 0; - --pos-y: 0; - --max-height: 300px; - left: var(--pos-x-px, 0); - top: var(--pos-y-px, 0); - max-height: var(--max-height-px, 300px); -} - -/* Position adjustment classes */ -.note-status-popover-right { - left: auto !important; - right: var(--right-offset-px, 10px); -} - -.note-status-popover-bottom { - top: auto !important; - bottom: var(--bottom-offset-px, 10px); -} diff --git a/tsconfig.json b/tsconfig.json index 82d7928..8e5fbcb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,17 @@ { "compilerOptions": { "baseUrl": ".", + "paths": { + "@/components/*": ["./components/*"], + "@/constants/*": ["./constants/*"], + "@/contexts/*": ["./contexts/*"], + "@/core/*": ["./core/*"], + "@/hooks/*": ["./hooks/*"], + "@/integrations/*": ["./integrations/*"], + "@/styles/*": ["./styles/*"], + "@/types/*": ["./types/*"], + "@/views/*": ["./views/*"] + }, "inlineSourceMap": true, "inlineSources": true, "module": "ESNext", diff --git a/types/eventBus.ts b/types/eventBus.ts new file mode 100644 index 0000000..7eb65a4 --- /dev/null +++ b/types/eventBus.ts @@ -0,0 +1,27 @@ +import { + MultipleNoteStatusService, + NoteStatusService, +} from "@/core/noteStatusService"; +import { PluginSettings } from "@/types/pluginSettings"; +import { TFile, WorkspaceLeaf } from "obsidian"; + +export type EventBusEvents = { + "active-file-change": ({ leaf }: { leaf: WorkspaceLeaf | null }) => void; + "plugin-settings-changed": ({ + key, + value, + currentSettings, + }: { + key: keyof PluginSettings; + value: unknown; + currentSettings: PluginSettings; + }) => void; + "frontmatter-manually-changed": ({ file }: { file: TFile }) => void; + "triggered-open-modal": ({ + statusService, + }: { + statusService: NoteStatusService | MultipleNoteStatusService; + }) => void; +}; + +export type EventName = keyof EventBusEvents; diff --git a/types/noteStatus.ts b/types/noteStatus.ts new file mode 100644 index 0000000..ef40123 --- /dev/null +++ b/types/noteStatus.ts @@ -0,0 +1,9 @@ +export type NoteStatus = { + name: string; + icon: string; + color?: string; // Optional color property + description?: string; // Optional description property + [key: string]: unknown; +}; + +export type GroupedStatuses = Record; diff --git a/types/pluginSettings.ts b/types/pluginSettings.ts new file mode 100644 index 0000000..7810f8d --- /dev/null +++ b/types/pluginSettings.ts @@ -0,0 +1,27 @@ +import { NoteStatus } from "./noteStatus"; + +export interface StatusTemplate { + id: string; + name: string; + description: string; + statuses: NoteStatus[]; +} + +export type PluginSettings = { + statusColors: Record; + showStatusBar: boolean; + autoHideStatusBar: boolean; + customStatuses: NoteStatus[]; + showStatusIconsInExplorer: boolean; + hideUnknownStatusInExplorer: boolean; + collapsedStatuses: Record; + 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; +}; diff --git a/types/selectorService.ts b/types/selectorService.ts new file mode 100644 index 0000000..f1d2e76 --- /dev/null +++ b/types/selectorService.ts @@ -0,0 +1,6 @@ +export interface SelectorState { + selectedFile: File | null; + isSelectorOpened: boolean; +} + +export type SelectorListener = (state: SelectorState) => void; diff --git a/utils/react-utils.ts b/utils/react-utils.ts deleted file mode 100644 index 04e4626..0000000 --- a/utils/react-utils.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { createRoot, Root } from "react-dom/client"; - -/** - * Utility functions for React integration with Obsidian plugins - */ -export class ReactUtils { - private static roots = new Map(); - - /** - * Render a React component into a container element - */ - static render(component: React.ReactElement, container: HTMLElement): Root { - let root = this.roots.get(container); - - if (!root) { - root = createRoot(container); - this.roots.set(container, root); - } - - root.render(component); - return root; - } - - /** - * Unmount a React component from a container element - */ - static unmount(container: HTMLElement): void { - const root = this.roots.get(container); - if (root) { - root.unmount(); - this.roots.delete(container); - } - } - - /** - * Clean up all React roots - */ - static cleanup(): void { - this.roots.forEach((root) => { - try { - root.unmount(); - } catch (error) { - console.error("Error unmounting React root:", error); - } - }); - this.roots.clear(); - } -} diff --git a/views/status-pane-view/StatusPaneComponent.tsx b/views/status-pane-view/StatusPaneComponent.tsx deleted file mode 100644 index 4bb558d..0000000 --- a/views/status-pane-view/StatusPaneComponent.tsx +++ /dev/null @@ -1,519 +0,0 @@ -import React, { useState } from "react"; -import { TFile } from "obsidian"; -import { StatusService } from "../../services/status-service"; - -interface StatusPaneProps { - statusGroups: Record; - statusService: StatusService; - isCompactView: boolean; - excludeUnknown: boolean; - collapsedStatuses: Record; - pagination: { - itemsPerPage: number; - currentPage: Record; - }; - onFileClick: (file: TFile) => void; - onStatusToggle: (status: string, collapsed: boolean) => void; - onContextMenu: (e: React.MouseEvent, file: TFile) => void; - onPageChange: (status: string, page: number) => void; - onSearch: (query: string) => void; - onToggleView: () => void; - onRefresh: () => void; - onShowUnassigned: () => void; -} - -export const StatusPaneComponent: React.FC = ({ - statusGroups, - statusService, - isCompactView, - excludeUnknown, - collapsedStatuses, - pagination, - onFileClick, - onStatusToggle, - onContextMenu, - onPageChange, - onSearch, - onToggleView, - onRefresh, - onShowUnassigned, -}) => { - const [searchQuery, setSearchQuery] = useState(""); - - const handleSearch = (query: string) => { - setSearchQuery(query); - onSearch(query.toLowerCase()); - }; - - const hasGroups = Object.entries(statusGroups).some( - ([status, files]) => - files.length > 0 && !(status === "unknown" && excludeUnknown), - ); - - return ( -
- {/* Header */} - - - {/* Content */} - {hasGroups ? ( -
- {Object.entries(statusGroups).map(([status, files]) => { - if ( - files.length === 0 || - (status === "unknown" && excludeUnknown) - ) { - return null; - } - - return ( - - ); - })} -
- ) : ( - - )} -
- ); -}; - -interface StatusPaneHeaderProps { - isCompactView: boolean; - searchQuery: string; - onSearch: (query: string) => void; - onToggleView: () => void; - onRefresh: () => void; -} - -const StatusPaneHeader: React.FC = ({ - isCompactView, - searchQuery, - onSearch, - onToggleView, - onRefresh, -}) => { - const [showClearButton, setShowClearButton] = useState(false); - - const handleSearchChange = (e: React.ChangeEvent) => { - const value = e.target.value; - onSearch(value); - setShowClearButton(!!value); - }; - - const handleClearSearch = () => { - onSearch(""); - setShowClearButton(false); - }; - - return ( -
-
-
- - - - - - - - {showClearButton && ( - - - - - - - )} -
-
- -
- - - -
-
- ); -}; - -interface StatusGroupProps { - status: string; - files: TFile[]; - statusService: StatusService; - isCompactView: boolean; - isCollapsed: boolean; - pagination: { - itemsPerPage: number; - currentPage: Record; - }; - onFileClick: (file: TFile) => void; - onStatusToggle: (status: string, collapsed: boolean) => void; - onContextMenu: (e: React.MouseEvent, file: TFile) => void; - onPageChange: (status: string, page: number) => void; -} - -const StatusGroup: React.FC = ({ - status, - files, - statusService, - isCompactView, - isCollapsed, - pagination, - onFileClick, - onStatusToggle, - onContextMenu, - onPageChange, -}) => { - const currentPage = pagination.currentPage[status] || 0; - const itemsPerPage = pagination.itemsPerPage; - const totalPages = Math.ceil(files.length / itemsPerPage); - const startIndex = currentPage * itemsPerPage; - const endIndex = Math.min(startIndex + itemsPerPage, files.length); - const paginatedFiles = files.slice(startIndex, endIndex); - - const handleToggleCollapse = () => { - onStatusToggle(status, !isCollapsed); - }; - - const statusIcon = statusService.getStatusIcon(status); - - return ( -
-
-
- {isCollapsed ? ( - - - - ) : ( - - - - )} -
-
- - {status} {statusIcon} ({files.length}) - -
-
- -
- {paginatedFiles.map((file) => ( - - ))} - - {files.length > itemsPerPage && ( - - )} -
-
- ); -}; - -interface FileItemProps { - file: TFile; - status: string; - statusIcon: string; - isCompactView: boolean; - onFileClick: (file: TFile) => void; - onContextMenu: (e: React.MouseEvent, file: TFile) => void; -} - -const FileItem: React.FC = ({ - file, - status, - statusIcon, - isCompactView, - onFileClick, - onContextMenu, -}) => { - const handleClick = (e: React.MouseEvent) => { - e.preventDefault(); - onFileClick(file); - }; - - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - onContextMenu(e, file); - }; - - return ( -
-
- {!isCompactView && ( -
- - - - -
- )} - {file.basename} - - {statusIcon} - -
-
- ); -}; - -interface StatusGroupPaginationProps { - status: string; - currentPage: number; - totalPages: number; - totalItems: number; - onPageChange: (status: string, page: number) => void; -} - -const StatusGroupPagination: React.FC = ({ - status, - currentPage, - totalPages, - totalItems, - onPageChange, -}) => { - const handlePrevious = (e: React.MouseEvent) => { - e.stopPropagation(); - onPageChange(status, currentPage - 1); - }; - - const handleNext = (e: React.MouseEvent) => { - e.stopPropagation(); - onPageChange(status, currentPage + 1); - }; - - return ( -
- {currentPage > 0 && ( - - )} - - - Page {currentPage + 1} of {totalPages} ({totalItems} notes) - - - {currentPage < totalPages - 1 && ( - - )} -
- ); -}; - -interface StatusPaneEmptyStateProps { - searchQuery: string; - excludeUnknown: boolean; - onShowUnassigned: () => void; -} - -const StatusPaneEmptyState: React.FC = ({ - searchQuery, - excludeUnknown, - onShowUnassigned, -}) => { - if (searchQuery) { - return ( -
- No notes found matching "{searchQuery}" -
- ); - } - - if (excludeUnknown) { - return ( -
-
- No notes with status found. Unassigned notes are currently - hidden. -
-
- -
-
- ); - } - - return null; -}; diff --git a/views/status-pane-view/StatusPaneView.tsx b/views/status-pane-view/StatusPaneView.tsx deleted file mode 100644 index 1de0f49..0000000 --- a/views/status-pane-view/StatusPaneView.tsx +++ /dev/null @@ -1,460 +0,0 @@ -import React, { useState, useCallback, useRef, useEffect } from "react"; -import { TFile, setIcon } from "obsidian"; -import { StatusService } from "../../services/status-service"; - -export interface StatusPaneOptions { - excludeUnknown: boolean; - isCompactView: boolean; - collapsedStatuses: Record; - pagination: { - itemsPerPage: number; - currentPage: Record; - }; - callbacks: { - onFileClick: (file: TFile) => void; - onStatusToggle: (status: string, collapsed: boolean) => void; - onContextMenu: (e: MouseEvent, file: TFile) => void; - onPageChange: (status: string, page: number) => void; - }; -} - -interface StatusPaneHeaderProps { - isCompactView: boolean; - onSearch: (query: string) => void; - onToggleView: () => void; - onRefresh: () => void; -} - -const StatusPaneHeader: React.FC = ({ - isCompactView, - onSearch, - onToggleView, - onRefresh, -}) => { - const [searchValue, setSearchValue] = useState(""); - const searchIconRef = useRef(null); - const clearBtnRef = useRef(null); - const viewBtnRef = useRef(null); - const refreshBtnRef = useRef(null); - - useEffect(() => { - if (searchIconRef.current) { - setIcon(searchIconRef.current, "search"); - } - if (clearBtnRef.current) { - setIcon(clearBtnRef.current, "x"); - } - if (viewBtnRef.current) { - setIcon(viewBtnRef.current, isCompactView ? "layout" : "table"); - } - if (refreshBtnRef.current) { - setIcon(refreshBtnRef.current, "refresh-cw"); - } - }, [isCompactView]); - - const handleSearchChange = useCallback( - (e: React.ChangeEvent) => { - const value = e.target.value; - setSearchValue(value); - onSearch(value.toLowerCase()); - }, - [onSearch], - ); - - const handleClearSearch = useCallback(() => { - setSearchValue(""); - onSearch(""); - }, [onSearch]); - - return ( -
-
-
- - - -
-
-
-
-
- ); -}; - -interface FileItemProps { - file: TFile; - status: string; - isCompactView: boolean; - statusService: StatusService; - onFileClick: (file: TFile) => void; - onContextMenu: (e: MouseEvent, file: TFile) => void; -} - -const FileItem: React.FC = ({ - file, - status, - isCompactView, - statusService, - onFileClick, - onContextMenu, -}) => { - const fileIconRef = useRef(null); - - useEffect(() => { - if (!isCompactView && fileIconRef.current) { - setIcon(fileIconRef.current, "file"); - } - }, [isCompactView]); - - const handleClick = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - onFileClick(file); - }, - [file, onFileClick], - ); - - const handleContextMenu = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - onContextMenu(e.nativeEvent as MouseEvent, file); - }, - [file, onContextMenu], - ); - - return ( -
-
- {!isCompactView && ( -
- )} - {file.basename} - - {statusService.getStatusIcon(status)} - -
-
- ); -}; - -interface PaginationProps { - status: string; - currentPage: number; - totalPages: number; - totalItems: number; - onPageChange: (status: string, page: number) => void; -} - -const Pagination: React.FC = ({ - status, - currentPage, - totalPages, - totalItems, - onPageChange, -}) => { - const handlePrevious = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - onPageChange(status, currentPage - 1); - }, - [status, currentPage, onPageChange], - ); - - const handleNext = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - onPageChange(status, currentPage + 1); - }, - [status, currentPage, onPageChange], - ); - - return ( -
- {currentPage > 0 && ( - - )} - - Page {currentPage + 1} of {totalPages} ({totalItems} notes) - - {currentPage < totalPages - 1 && ( - - )} -
- ); -}; - -interface StatusGroupProps { - status: string; - files: TFile[]; - options: StatusPaneOptions; - statusService: StatusService; -} - -const StatusGroup: React.FC = ({ - status, - files, - options, - statusService, -}) => { - const collapseIconRef = useRef(null); - const isCollapsed = options.collapsedStatuses[status] ?? false; - const [collapsed, setCollapsed] = useState(isCollapsed); - - useEffect(() => { - if (collapseIconRef.current) { - setIcon( - collapseIconRef.current, - collapsed ? "chevron-right" : "chevron-down", - ); - } - }, [collapsed]); - - const handleToggle = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - const newCollapsed = !collapsed; - setCollapsed(newCollapsed); - options.callbacks.onStatusToggle(status, newCollapsed); - }, - [collapsed, status, options.callbacks], - ); - - // Pagination - const currentPage = options.pagination.currentPage[status] || 0; - const itemsPerPage = options.pagination.itemsPerPage; - const totalPages = Math.ceil(files.length / itemsPerPage); - const startIndex = currentPage * itemsPerPage; - const endIndex = Math.min(startIndex + itemsPerPage, files.length); - const paginatedFiles = files.slice(startIndex, endIndex); - - const statusIcon = statusService.getStatusIcon(status); - - return ( -
-
-
-
- - {status} {statusIcon} ({files.length}) - -
-
-
- {paginatedFiles.map((file) => ( - - ))} - {files.length > itemsPerPage && ( - - )} -
-
- ); -}; - -interface EmptyStateProps { - searchQuery: string; - excludeUnknown: boolean; - onShowUnassigned: () => void; -} - -const EmptyState: React.FC = ({ - searchQuery, - excludeUnknown, - onShowUnassigned, -}) => { - if (searchQuery) { - return ( -
- No notes found matching "{searchQuery}" -
- ); - } - - if (excludeUnknown) { - return ( -
-
- No notes with status found. Unassigned notes are currently - hidden. -
-
- -
-
- ); - } - - return null; -}; - -interface LoadingIndicatorProps { - text?: string; -} - -const LoadingIndicator: React.FC = ({ - text = "Loading notes...", -}) => { - return ( -
- {text} -
- ); -}; - -interface StatusPaneViewProps { - statusGroups: Record; - options: StatusPaneOptions; - statusService: StatusService; - searchQuery: string; - isLoading?: boolean; - loadingText?: string; - headerCallbacks: { - onSearch: (query: string) => void; - onToggleView: () => void; - onRefresh: () => void; - }; - onShowUnassigned: () => void; -} - -export const StatusPaneView: React.FC = ({ - statusGroups, - options, - statusService, - searchQuery, - isLoading = false, - loadingText, - headerCallbacks, - onShowUnassigned, -}) => { - const hasGroups = Object.entries(statusGroups).some( - ([status, files]) => - files.length > 0 && - !(status === "unknown" && options.excludeUnknown), - ); - - return ( -
- - -
- {isLoading ? ( - - ) : !hasGroups ? ( - - ) : ( - Object.entries(statusGroups).map(([status, files]) => { - if ( - files.length === 0 || - (status === "unknown" && options.excludeUnknown) - ) { - return null; - } - - return ( - - ); - }) - )} -
-
- ); -}; - -export class StatusPaneViewManager { - constructor(private statusService: StatusService) {} - - createLoadingIndicator(container: HTMLElement, text?: string): HTMLElement { - const loadingIndicator = container.createDiv({ - cls: "note-status-loading", - }); - loadingIndicator.innerHTML = `${text || "Loading notes..."}`; - return loadingIndicator; - } -} - -export default StatusPaneView; diff --git a/views/status-pane-view/StatusPaneViewController.tsx b/views/status-pane-view/StatusPaneViewController.tsx deleted file mode 100644 index d6cd8c6..0000000 --- a/views/status-pane-view/StatusPaneViewController.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import React from "react"; -import { TFile, WorkspaceLeaf, View, Notice, App } from "obsidian"; -import { NoteStatusSettings } from "../../models/types"; -import { StatusService } from "../../services/status-service"; -import NoteStatus from "../../main"; -import { StatusPaneComponent } from "./StatusPaneComponent"; -import { ReactUtils } from "../../utils/react-utils"; - -export class StatusPaneViewController extends View { - private settings: NoteStatusSettings; - private statusService: StatusService; - private plugin: NoteStatus; - private searchQuery = ""; - private paginationState = { - itemsPerPage: 100, - currentPage: {} as Record, - }; - - static async open(app: App): Promise { - const existing = app.workspace.getLeavesOfType("status-pane")[0]; - if (existing) { - app.workspace.setActiveLeaf(existing); - return; - } - - const leaf = app.workspace.getLeftLeaf(false); - if (leaf) { - await leaf.setViewState({ type: "status-pane", active: true }); - } - } - - constructor(leaf: WorkspaceLeaf, plugin: NoteStatus) { - super(leaf); - this.plugin = plugin; - this.settings = plugin.settings; - this.statusService = plugin.statusService; - } - - getViewType(): string { - return "status-pane"; - } - - getDisplayText(): string { - return "Status pane"; - } - - getIcon(): string { - return "tag"; - } - - async onOpen(): Promise { - await this.setupPane(); - } - - onClose(): Promise { - ReactUtils.unmount(this.containerEl); - this.containerEl.empty(); - return Promise.resolve(); - } - - private async setupPane(): Promise { - const { containerEl } = this; - containerEl.empty(); - containerEl.addClass("note-status-pane", "nav-files-container"); - containerEl.toggleClass( - "note-status-compact-view", - this.settings.compactView, - ); - - this.renderStatusPane(); - } - - private renderStatusPane(): void { - const statusGroups = this.getFilteredStatusGroups(this.searchQuery); - - ReactUtils.render( - React.createElement(StatusPaneWrapper, { - statusGroups, - statusService: this.statusService, - settings: this.settings, - searchQuery: this.searchQuery, - paginationState: this.paginationState, - onSearch: this.handleSearch.bind(this), - onToggleView: this.handleToggleView.bind(this), - onRefresh: this.handleRefresh.bind(this), - onFileClick: this.handleFileClick.bind(this), - onStatusToggle: this.handleStatusToggle.bind(this), - onContextMenu: this.handleContextMenu.bind(this), - onPageChange: this.handlePageChange.bind(this), - onShowUnassigned: this.handleShowUnassigned.bind(this), - }), - this.containerEl, - ); - } - - private handleSearch(query: string): void { - this.paginationState = { - itemsPerPage: 100, - currentPage: {} as Record, - }; - this.searchQuery = query; - this.renderStatusPane(); - } - - private handleToggleView(): void { - this.settings.compactView = !this.settings.compactView; - this.containerEl.toggleClass( - "note-status-compact-view", - this.settings.compactView, - ); - window.dispatchEvent(new CustomEvent("note-status:settings-changed")); - this.renderStatusPane(); - } - - private async handleRefresh(): Promise { - this.renderStatusPane(); - new Notice("Status pane refreshed"); - } - - private handleFileClick(file: TFile): void { - this.app.workspace.openLinkText(file.path, file.path, true); - } - - private handleStatusToggle(status: string, collapsed: boolean): void { - this.settings.collapsedStatuses[status] = collapsed; - } - - private handleContextMenu(e: React.MouseEvent, file: TFile): void { - // Context menu handling can be implemented here if needed - } - - private handlePageChange(status: string, page: number): void { - this.paginationState.currentPage[status] = page; - this.renderStatusPane(); - } - - private async handleShowUnassigned(): Promise { - this.settings.excludeUnknownStatus = false; - await this.plugin.saveSettings(); - this.renderStatusPane(); - } - - private getFilteredStatusGroups(searchQuery = ""): Record { - const rawGroups = this.statusService.groupFilesByStatus(searchQuery); - const filteredGroups: Record = {}; - - Object.entries(rawGroups).forEach(([status, files]) => { - if (files.length > 0) { - filteredGroups[status] = files; - } - }); - - return filteredGroups; - } - - updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - this.containerEl.toggleClass( - "note-status-compact-view", - settings.compactView, - ); - this.renderStatusPane(); - } - - public update(): void { - this.renderStatusPane(); - } -} - -interface StatusPaneWrapperProps { - statusGroups: Record; - statusService: StatusService; - settings: NoteStatusSettings; - searchQuery: string; - paginationState: { - itemsPerPage: number; - currentPage: Record; - }; - onSearch: (query: string) => void; - onToggleView: () => void; - onRefresh: () => Promise; - onFileClick: (file: TFile) => void; - onStatusToggle: (status: string, collapsed: boolean) => void; - onContextMenu: (e: React.MouseEvent, file: TFile) => void; - onPageChange: (status: string, page: number) => void; - onShowUnassigned: () => Promise; -} - -const StatusPaneWrapper: React.FC = ({ - statusGroups, - statusService, - settings, - paginationState, - onSearch, - onToggleView, - onRefresh, - onFileClick, - onStatusToggle, - onContextMenu, - onPageChange, - onShowUnassigned, -}) => { - return ( - - ); -}; diff --git a/views/status-pane-view/index.ts b/views/status-pane-view/index.ts deleted file mode 100644 index 48dc391..0000000 --- a/views/status-pane-view/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { StatusPaneComponent } from "./StatusPaneComponent"; -export { StatusPaneViewController } from "./StatusPaneViewController"; diff --git a/views/status-pane-view/status-pane-view-controller.ts b/views/status-pane-view/status-pane-view-controller.ts deleted file mode 100644 index 62f4c4f..0000000 --- a/views/status-pane-view/status-pane-view-controller.ts +++ /dev/null @@ -1,153 +0,0 @@ -import React from "react"; -import { TFile, WorkspaceLeaf, View, Notice, App } from "obsidian"; -import { NoteStatusSettings } from "../../models/types"; -import { StatusService } from "../../services/status-service"; -import NoteStatus from "main"; -import { StatusPaneView, StatusPaneOptions } from "./StatusPaneView"; -import { ReactUtils } from "../../utils/react-utils"; - -export class StatusPaneViewController extends View { - private settings: NoteStatusSettings; - private statusService: StatusService; - private plugin: NoteStatus; - private searchQuery = ""; - private paginationState = { - itemsPerPage: 100, - currentPage: {} as Record, - }; - private collapsedStatuses: Record = {}; - - static async open(app: App): Promise { - const existing = app.workspace.getLeavesOfType("status-pane")[0]; - if (existing) { - app.workspace.setActiveLeaf(existing); - return; - } - - const leaf = app.workspace.getLeftLeaf(false); - if (leaf) { - await leaf.setViewState({ type: "status-pane", active: true }); - } - } - - constructor(leaf: WorkspaceLeaf, plugin: NoteStatus) { - super(leaf); - this.plugin = plugin; - this.settings = plugin.settings; - this.statusService = plugin.statusService; - } - - getViewType(): string { - return "status-pane"; - } - - getDisplayText(): string { - return "Status pane"; - } - - getIcon(): string { - return "tag"; - } - - async onOpen(): Promise { - await this.setupPane(); - } - - onClose(): Promise { - ReactUtils.unmount(this.containerEl); - return Promise.resolve(); - } - - private async setupPane(): Promise { - const { containerEl } = this; - containerEl.empty(); - containerEl.addClass("note-status-pane", "nav-files-container"); - containerEl.toggleClass( - "note-status-compact-view", - this.settings.compactView, - ); - - await this.renderPane(); - } - - private async renderPane(): Promise { - const statusGroups = this.statusService.groupFilesByStatus( - this.searchQuery, - ); - - const options: StatusPaneOptions = { - excludeUnknown: this.settings.excludeUnknownStatus || false, - isCompactView: this.settings.compactView || false, - collapsedStatuses: this.collapsedStatuses, - pagination: this.paginationState, - callbacks: { - onFileClick: (file: TFile) => { - this.app.workspace.getLeaf().openFile(file); - }, - onStatusToggle: (status: string, collapsed: boolean) => { - this.collapsedStatuses[status] = collapsed; - }, - onContextMenu: (e: MouseEvent, file: TFile) => { - // Handle context menu for file - console.log("Context menu for file:", file.path); - }, - onPageChange: (status: string, page: number) => { - this.paginationState.currentPage[status] = page; - this.renderPane(); - }, - }, - }; - - const headerCallbacks = { - onSearch: (query: string) => { - this.paginationState = { - itemsPerPage: 100, - currentPage: {} as Record, - }; - this.searchQuery = query; - this.renderPane(); - }, - onToggleView: () => { - this.settings.compactView = !this.settings.compactView; - this.containerEl.toggleClass( - "note-status-compact-view", - this.settings.compactView, - ); - window.dispatchEvent( - new CustomEvent("note-status:settings-changed"), - ); - this.renderPane(); - }, - onRefresh: async () => { - await this.renderPane(); - new Notice("Status pane refreshed"); - }, - }; - - const onShowUnassigned = () => { - this.settings.excludeUnknownStatus = false; - this.renderPane(); - }; - - ReactUtils.render( - React.createElement(StatusPaneView, { - statusGroups, - options, - statusService: this.statusService, - searchQuery: this.searchQuery, - headerCallbacks, - onShowUnassigned, - }), - this.containerEl, - ); - } - - public async update(): Promise { - await this.renderPane(); - } - - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - this.renderPane(); - } -}