Merge pull request #73 from devonthesofa/feature/lucid-icons-support

[Feature] Support Lucide icons in statuses
This commit is contained in:
Aleix Soler 2025-11-21 09:49:08 +01:00 committed by GitHub
commit 6ea99c29fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 850 additions and 1533 deletions

View file

@ -1,6 +1,7 @@
import { StatusIconPreview } from "@/components/atoms/StatusIconPreview";
import { GroupedStatuses } from "@/types/noteStatus";
import React, { FC, memo } from "react";
import { StatusIcon } from "@/components/atoms/StatusIcon";
type Props = {
statuses: GroupedStatuses;
@ -9,6 +10,7 @@ type Props = {
hideUnknownStatus?: boolean;
unknownStatusConfig?: {
icon: string;
lucideIcon?: string;
color: string;
};
iconFrameMode?: "always" | "never";
@ -46,7 +48,13 @@ export const FileExplorerIcon: FC<Props> = memo(
return (
<StatusIconPreview
icon={icon}
icon={
<StatusIcon
icon={icon}
lucideIcon={unknownStatusConfig?.lucideIcon}
size={16}
/>
}
color={color}
iconColorMode={iconColorMode}
iconFrameMode={iconFrameMode}
@ -64,7 +72,13 @@ export const FileExplorerIcon: FC<Props> = memo(
return (
<StatusIconPreview
icon={primaryStatus.icon}
icon={
<StatusIcon
icon={primaryStatus.icon}
lucideIcon={primaryStatus.lucideIcon}
size={16}
/>
}
color={iconColor}
count={totalStatuses}
iconFrameMode={iconFrameMode}

View file

@ -18,6 +18,7 @@ export type StatusItem = {
name: string;
color: string;
icon?: string;
lucideIcon?: string;
};
export type FilesByStatus = {
@ -69,6 +70,7 @@ const GroupedStatusViewContent = () => {
name: noteStatus.name,
color: noteStatus.color || "white",
icon: noteStatus.icon,
lucideIcon: noteStatus.lucideIcon,
};
const scopedIdentifier = noteStatus.templateId

View file

@ -54,7 +54,11 @@ export const StatusGroup = ({
<div className="grouped-status-group__status">
<StatusDisplay
status={
{ ...status, icon: status.icon || "" } as NoteStatus
{
...status,
icon: status.icon || "",
lucideIcon: status.lucideIcon,
} as NoteStatus
}
variant="badge"
/>

View file

@ -1,13 +1,15 @@
import { NoteStatus } from "@/types/noteStatus";
import React from "react";
import { Input } from "@/components/atoms/Input";
import { StatusIcon } from "@/components/atoms/StatusIcon";
import { IconSelectionField } from "./IconSelectionField";
export type Props = {
status: NoteStatus;
index: number;
onCustomStatusChange: (
index: number,
column: "name" | "icon" | "color" | "description",
column: "name" | "icon" | "color" | "description" | "lucideIcon",
value: string,
) => void;
onCustomStatusRemove: (index: number) => void;
@ -28,26 +30,28 @@ export const CustomStatusItem: React.FC<Props> = ({
canMoveDown = false,
}) => {
const isValid = status.name.trim().length > 0;
const displayIcon = status.icon.trim() || "📝";
return (
<div className="custom-status-item">
{/* Simple horizontal layout with all inputs in a row */}
<div className="custom-status-item__row">
{/* Icon field - simple text input */}
<div className="custom-status-item__field">
<label className="custom-status-item__label">Icon</label>
<Input
variant="text"
value={status.icon}
onChange={(value) =>
onCustomStatusChange(index, "icon", value || "")
}
placeholder="📝"
className="custom-status-item__input custom-status-item__input--icon"
/>
</div>
<IconSelectionField
className="custom-status-item__icon-fields"
emojiValue={status.icon}
onEmojiChange={(value) =>
onCustomStatusChange(index, "icon", value || "")
}
emojiLabel="Emoji icon"
emojiPlaceholder="Example: ✅ or 🚧"
emojiHint="Shown anywhere Lucide is not available."
lucideValue={status.lucideIcon || ""}
onLucideChange={(value) =>
onCustomStatusChange(index, "lucideIcon", value)
}
lucideLabel="Lucide icon (optional)"
lucidePlaceholder="Browse Lucide icons"
lucideHint="Matches Obsidian's toolbar icons so your status button blends in."
/>
{/* Simple horizontal layout with remaining inputs */}
<div className="custom-status-item__row">
{/* Name field */}
<div className="custom-status-item__field custom-status-item__field--name">
<label className="custom-status-item__label">
@ -139,12 +143,13 @@ export const CustomStatusItem: React.FC<Props> = ({
{/* Preview row - shows how the status will look */}
<div className="custom-status-item__preview">
<span
<StatusIcon
icon={status.icon}
lucideIcon={status.lucideIcon}
className="custom-status-item__preview-icon"
size={18}
style={{ color: status.color }}
>
{displayIcon}
</span>
/>
<span
className="custom-status-item__preview-text"
style={{ color: status.color }}

View file

@ -20,6 +20,7 @@ export const CustomStatusSettings: React.FC<Props> = ({
currentStatuses.push({
name: "",
icon: "",
lucideIcon: "",
});
onChange("customStatuses", currentStatuses);
};
@ -84,7 +85,7 @@ export const CustomStatusSettings: React.FC<Props> = ({
<SettingItem
name="Custom statuses"
description="Create custom statuses with icons, names, and colors. All statuses require a name."
description="Create custom statuses with emoji icons and optional Lucide icon names plus colors. All statuses require a name."
vertical
>
<div className="custom-status-list">

View file

@ -0,0 +1,75 @@
import React from "react";
import { Input } from "@/components/atoms/Input";
import { LucideIconPicker } from "./LucideIconPicker";
export interface IconSelectionFieldProps {
className?: string;
emojiValue: string;
onEmojiChange: (value: string) => void;
emojiLabel?: string;
emojiPlaceholder?: string;
emojiHint?: string;
lucideValue?: string;
onLucideChange?: (value: string) => void;
lucideLabel?: string;
lucidePlaceholder?: string;
lucideHint?: string;
lucideAllowClear?: boolean;
}
export const IconSelectionField: React.FC<IconSelectionFieldProps> = ({
className = "",
emojiValue,
onEmojiChange,
emojiLabel = "Emoji fallback",
emojiPlaceholder = "Example: ✅",
emojiHint,
lucideValue,
onLucideChange,
lucideLabel = "Lucide icon",
lucidePlaceholder = "Choose Lucide icon",
lucideHint,
lucideAllowClear = true,
}) => {
const containerClass = ["icon-selection-field", className]
.filter(Boolean)
.join(" ");
return (
<div className={containerClass}>
<div className="icon-selection-field__column">
<label className="icon-selection-field__label">
{emojiLabel}
</label>
<Input
variant="text"
value={emojiValue}
onChange={onEmojiChange}
placeholder={emojiPlaceholder}
className="icon-selection-field__emoji-input"
/>
{emojiHint && (
<p className="icon-selection-field__hint">{emojiHint}</p>
)}
</div>
{onLucideChange && (
<div className="icon-selection-field__column">
<label className="icon-selection-field__label">
{lucideLabel}
</label>
<LucideIconPicker
value={lucideValue}
onChange={onLucideChange}
placeholder={lucidePlaceholder}
allowClear={lucideAllowClear}
/>
{lucideHint && (
<p className="icon-selection-field__hint">
{lucideHint}
</p>
)}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,223 @@
import React, {
FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { App, Modal } from "obsidian";
import { IconName, getIconIds } from "obsidian";
import { Input } from "@/components/atoms/Input";
import { StatusIcon } from "@/components/atoms/StatusIcon";
import { ObsidianIcon } from "@/components/atoms/ObsidianIcon";
import { createRoot, Root } from "react-dom/client";
const BATCH_SIZE = 80;
interface LucideIconModalProps {
initialValue?: string;
onSelect: (value: string) => void;
onCloseRequest: () => void;
}
const LucideIconModalContent: FC<LucideIconModalProps> = ({
initialValue = "",
onSelect,
onCloseRequest,
}) => {
const [query, setQuery] = useState("");
const [visibleCount, setVisibleCount] = useState(BATCH_SIZE);
const [showLabels, setShowLabels] = useState(true);
const listRef = useRef<HTMLDivElement>(null);
const iconIds = useMemo(() => {
try {
return getIconIds()
.slice()
.sort((a, b) => a.localeCompare(b));
} catch (error) {
console.error("Failed to fetch Lucide icon list", error);
return [] as IconName[];
}
}, []);
const computeFuzzyScore = useCallback(
(needle: string, haystack: string): number | null => {
let lastIndex = -1;
let score = 0;
for (let i = 0; i < needle.length; i += 1) {
const char = needle[i];
const idx = haystack.indexOf(char, lastIndex + 1);
if (idx === -1) {
return null;
}
score += idx - lastIndex;
lastIndex = idx;
}
return score + (haystack.length - lastIndex);
},
[],
);
const filteredIcons = useMemo(() => {
if (!iconIds.length) {
return [];
}
const normalized = query.trim().toLowerCase();
if (!normalized.length) {
return iconIds;
}
const matches = iconIds
.map((name) => {
const target = name.toLowerCase();
const score = computeFuzzyScore(normalized, target);
return score === null ? null : { name, score };
})
.filter(
(
entry,
): entry is {
name: string;
score: number;
} => entry !== null,
)
.sort((a, b) => a.score - b.score || a.name.localeCompare(b.name));
return matches.map((entry) => entry.name);
}, [iconIds, query, computeFuzzyScore]);
const visibleIcons = useMemo(
() => filteredIcons.slice(0, visibleCount),
[filteredIcons, visibleCount],
);
useEffect(() => {
setVisibleCount(BATCH_SIZE);
listRef.current?.scrollTo({ top: 0 });
}, [query, filteredIcons.length]);
const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
if (
target.scrollTop + target.clientHeight >=
target.scrollHeight - 64
) {
setVisibleCount((prev) =>
Math.min(prev + BATCH_SIZE, filteredIcons.length),
);
}
},
[filteredIcons.length],
);
const handleSelect = (iconName: string) => {
onSelect(iconName);
onCloseRequest();
};
return (
<div className="lucide-icon-modal">
<div className="lucide-icon-modal__header">
<div>
<h3 className="modal-title">Select Lucide icon</h3>
<p className="lucide-icon-modal__count">
Showing {visibleIcons.length} of {filteredIcons.length}{" "}
( Total {iconIds.length})
</p>
</div>
<button
type="button"
className="modal-close-button lucide-icon-modal__close"
onClick={onCloseRequest}
aria-label="Close"
>
<ObsidianIcon name="x" size={14} />
</button>
</div>
<Input
variant="search"
value={query}
onChange={setQuery}
placeholder="Search icons…"
className="lucide-icon-modal__search"
/>
<label className="lucide-icon-modal__toggle">
<input
type="checkbox"
checked={showLabels}
onChange={(e) => setShowLabels(e.target.checked)}
/>
Show icon names
</label>
<div
ref={listRef}
className={`lucide-icon-modal__list${showLabels ? "" : " lucide-icon-modal__list--icons-only"}`}
onScroll={handleScroll}
>
{visibleIcons.map((iconName) => (
<button
key={iconName}
type="button"
className={`lucide-icon-modal__option${iconName === initialValue ? " lucide-icon-modal__option--selected" : ""}${showLabels ? "" : " lucide-icon-modal__option--compact"}`}
onClick={() => handleSelect(iconName)}
>
<StatusIcon
icon={iconName}
lucideIcon={iconName}
size={16}
/>
<span>{iconName}</span>
</button>
))}
{visibleIcons.length === 0 && (
<div className="lucide-icon-modal__empty">
No icons match {query.trim()}.
</div>
)}
</div>
</div>
);
};
type ModalOptions = {
initialValue?: string;
onSelect: (value: string) => void;
};
export class LucideIconModal extends Modal {
private root: Root | null = null;
private readonly options: ModalOptions;
constructor(app: App, options: ModalOptions) {
super(app);
this.options = options;
}
onOpen(): void {
this.contentEl.empty();
this.contentEl.addClass("lucide-icon-modal-wrapper");
this.root = createRoot(this.contentEl);
this.root.render(
<LucideIconModalContent
initialValue={this.options.initialValue}
onSelect={this.options.onSelect}
onCloseRequest={() => this.close()}
/>,
);
}
onClose(): void {
this.root?.unmount();
this.root = null;
this.contentEl.empty();
}
}

View file

@ -0,0 +1,79 @@
import React from "react";
import { StatusIcon } from "@/components/atoms/StatusIcon";
import { LucideIconModal } from "./LucideIconModal";
import { App } from "obsidian";
import { Input } from "@/components/atoms/Input";
const getAppInstance = (): App | undefined => {
return (window as typeof window & { app?: App }).app;
};
export interface LucideIconPickerProps {
value?: string;
onChange: (value: string) => void;
placeholder?: string;
allowTextInput?: boolean;
allowClear?: boolean;
}
export const LucideIconPicker: React.FC<LucideIconPickerProps> = ({
value = "",
onChange,
placeholder = "Choose Lucide icon",
allowTextInput = false,
allowClear = true,
}) => {
const openModal = () => {
const app = getAppInstance();
if (!app) {
console.warn("Cannot open Lucide icon modal without Obsidian app");
return;
}
const modal = new LucideIconModal(app, {
initialValue: value,
onSelect: (icon) => onChange(icon),
});
modal.open();
};
const handleClear = () => {
onChange("");
};
return (
<div className="lucide-icon-picker">
{allowTextInput && (
<Input
variant="text"
value={value}
onChange={(val) => onChange(val)}
placeholder="Emoji or icon name"
className="lucide-icon-picker__text-input"
/>
)}
<div className="lucide-icon-picker__row">
<button
type="button"
className="lucide-icon-picker__modal-button"
onClick={openModal}
>
<span className="lucide-icon-picker__preview">
<StatusIcon icon={value} lucideIcon={value} size={18} />
</span>
<span className="lucide-icon-picker__label">
{value || placeholder}
</span>
</button>
{allowClear && value && (
<button
type="button"
className="lucide-icon-picker__clear-trigger"
onClick={handleClear}
>
Clear
</button>
)}
</div>
</div>
);
};

View file

@ -1,5 +1,6 @@
import React from "react";
import { PluginSettings } from "@/types/pluginSettings";
import { StatusIcon } from "@/components/atoms/StatusIcon";
export type StatusGroupProps = {
statuses: PluginSettings["customStatuses"];
@ -39,9 +40,12 @@ export const StatusGroup: React.FC<StatusGroupProps> = ({
>
<div className="status-selector__content">
<div className="status-selector__status">
<span className="status-selector__icon">
{status.icon}
</span>
<StatusIcon
icon={status.icon}
lucideIcon={status.lucideIcon}
className="status-selector__icon"
size={16}
/>
<span className="status-selector__name">
{status.name}
</span>

View file

@ -23,6 +23,7 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
{
name: "",
icon: "",
lucideIcon: "",
color: "#888888",
templateId: template?.id || "",
},
@ -63,6 +64,7 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
{
name: "",
icon: "",
lucideIcon: "",
color: "#888888",
templateId: template?.id || "",
},
@ -72,7 +74,7 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
const handleStatusChange = useCallback(
(
index: number,
column: "name" | "icon" | "color" | "description",
column: "name" | "icon" | "color" | "description" | "lucideIcon",
value: string,
) => {
setStatuses((prev) =>
@ -145,7 +147,7 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
<SettingItem
name="Statuses"
description="Define the statuses included in this template"
description="Define the statuses included in this template, including emojis and optional Lucide icons"
vertical
>
<div className="template-editor-statuses">

View file

@ -3,6 +3,7 @@ import React from "react";
import { Input } from "../atoms/Input";
import { Select } from "../atoms/Select";
import { SettingItem } from "./SettingItem";
import { IconSelectionField } from "./IconSelectionField";
export type Props = {
settings: PluginSettings;
@ -268,14 +269,22 @@ export const UISettings: React.FC<Props> = ({ settings, onChange }) => {
<SettingItem
name="Icon for unknown status"
description="Icon displayed whenever a note does not have a status."
description="Emoji or Lucide icon name displayed whenever a note does not have a status."
vertical
>
<Input
variant="text"
value={settings.unknownStatusIcon}
onChange={(value) => onChange("unknownStatusIcon", value)}
placeholder="❓"
style={{ maxWidth: "150px" }}
<IconSelectionField
emojiValue={settings.unknownStatusIcon}
onEmojiChange={(value) =>
onChange("unknownStatusIcon", value)
}
emojiPlaceholder="❓"
emojiHint="Type any emoji or text fallback."
lucideValue={settings.unknownStatusLucideIcon || ""}
onLucideChange={(value) =>
onChange("unknownStatusLucideIcon", value)
}
lucidePlaceholder="Choose Lucide icon"
lucideHint="Matches the Obsidian toolbar style."
/>
</SettingItem>

View file

@ -5,6 +5,7 @@ import {
Props as StatusBarContextProps,
} from "./StatusBarContext";
import { StatusBarGroup } from "./StatusBarGroup";
import { StatusIcon } from "@/components/atoms/StatusIcon";
export type Props = {
statuses: GroupedStatuses;
@ -17,6 +18,7 @@ export type Props = {
showIcon: boolean;
showText: boolean;
icon: string;
lucideIcon?: string;
color: string;
};
};
@ -51,13 +53,15 @@ export const StatusBar: FC<Props> = ({
}}
>
{noStatusConfig.showIcon && (
<span
<StatusIcon
icon={noStatusConfig.icon}
lucideIcon={noStatusConfig.lucideIcon}
size={14}
className="status-bar-no-status-icon"
style={{
marginRight: noStatusConfig.showText ? "4px" : "0",
}}
>
{noStatusConfig.icon}
</span>
/>
)}
{noStatusConfig.showText && noStatusConfig.text}
</span>

View file

@ -1,12 +1,14 @@
import React, { FC, memo } from "react";
import { GroupedStatuses } from "@/types/noteStatus";
import { StatusesInfoPopup } from "@/integrations/popups/statusesInfoPopupIntegration";
import { StatusIcon } from "@/components/atoms/StatusIcon";
interface EditorToolbarButtonProps {
statuses: GroupedStatuses;
onClick: () => void;
unknownStatusConfig: {
icon: string;
lucideIcon?: string;
color: string;
};
}
@ -40,12 +42,13 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
onMouseLeave={handleMouseLeave}
aria-label="Add status to note"
>
<span
<StatusIcon
icon={unknownStatusConfig.icon}
lucideIcon={unknownStatusConfig.lucideIcon}
size={16}
className="editor-toolbar-button__icon"
style={{ color: unknownStatusConfig.color }}
>
{unknownStatusConfig.icon}
</span>
/>
</button>
);
}
@ -63,16 +66,17 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
onMouseLeave={handleMouseLeave}
aria-label={`Current status: ${primaryStatus.name}. Click to change.`}
>
<span
<StatusIcon
icon={primaryStatus.icon}
lucideIcon={primaryStatus.lucideIcon}
size={16}
className="editor-toolbar-button__icon editor-toolbar-button__icon--has-status"
style={{
color:
primaryStatus.color ||
"var(--interactive-accent)",
}}
>
{primaryStatus.icon || "📝"}
</span>
/>
</button>
);
}
@ -88,16 +92,17 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
aria-label={`${totalStatuses} statuses assigned. Click to change.`}
>
<div className="editor-toolbar-button__icon-container">
<span
<StatusIcon
icon={primaryStatus.icon}
lucideIcon={primaryStatus.lucideIcon}
size={16}
className="editor-toolbar-button__icon editor-toolbar-button__icon--has-status"
style={{
color:
primaryStatus.color ||
"var(--interactive-accent)",
}}
>
{primaryStatus.icon || "📝"}
</span>
/>
<span className="editor-toolbar-button__counter">
{totalStatuses}
</span>

View file

@ -1,7 +1,8 @@
import { NoteStatus } from "@/types/noteStatus";
import { FC, memo, useState } from "react";
import { FC, ReactNode, memo, useMemo, useState } from "react";
import { getStatusTooltip } from "@/utils/statusUtils";
import { ObsidianIcon } from "./ObsidianIcon";
import { StatusIcon } from "./StatusIcon";
export type StatusDisplayVariant = "chip" | "badge" | "template";
@ -11,6 +12,7 @@ interface StatusDisplayProps {
removable?: boolean;
hasNameConflicts?: boolean;
templateNameMode?: "always" | "never" | "auto";
icon?: ReactNode;
onRemove?: () => void;
onClick?: () => void;
}
@ -22,6 +24,7 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
removable = false,
hasNameConflicts = false,
templateNameMode = "auto",
icon,
onRemove,
onClick,
}) => {
@ -62,6 +65,19 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
}
};
const defaultIcon = useMemo(() => {
const baseSize = variant === "chip" ? 16 : 14;
return (
<StatusIcon
icon={status.icon}
lucideIcon={status.lucideIcon}
size={baseSize}
/>
);
}, [status.icon, status.lucideIcon, variant]);
const iconNode = icon ?? defaultIcon;
if (variant === "chip") {
return (
<div
@ -82,9 +98,7 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
}}
onClick={handleClick}
>
<span className="note-status-chip-icon">
{status.icon ? status.icon : "📝"}
</span>
<span className="note-status-chip-icon">{iconNode}</span>
<span className="note-status-chip-text">
{getDisplayName()}
</span>
@ -121,9 +135,7 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
onClick={handleClick}
>
<div className="status-badge-item">
<span className="status-badge-icon">
{status.icon ? status.icon : "📝"}
</span>
<span className="status-badge-icon">{iconNode}</span>
<span className="status-badge-text">
{getDisplayName()}
</span>
@ -144,7 +156,8 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
}
/>
<span>
{status.icon ? status.icon : "📝"} {getDisplayName()}
<span style={{ marginRight: "4px" }}>{iconNode}</span>
{getDisplayName()}
</span>
</div>
);

View file

@ -0,0 +1,52 @@
import React, { CSSProperties, FC } from "react";
import { ObsidianIcon } from "./ObsidianIcon";
import { resolveLucideIconName } from "@/utils/iconUtils";
interface StatusIconProps {
icon?: string;
lucideIcon?: string;
fallbackIcon?: string;
className?: string;
size?: number;
style?: CSSProperties;
}
export const StatusIcon: FC<StatusIconProps> = ({
icon,
lucideIcon,
fallbackIcon = "📝",
className = "",
size,
style = {},
}) => {
const lucideName = resolveLucideIconName(lucideIcon, icon);
const resolvedSize = size ?? 16;
const combinedClassName = ["status-icon", className]
.filter(Boolean)
.join(" ");
const wrapperStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
lineHeight: 1,
...style,
};
if (size) {
wrapperStyle.fontSize = `${size}px`;
}
const fallbackValue =
icon && icon.trim().length ? icon : (fallbackIcon ?? "");
return (
<span className={combinedClassName} style={wrapperStyle}>
{lucideName ? (
<ObsidianIcon name={lucideName} size={resolvedSize} />
) : (
fallbackValue
)}
</span>
);
};

View file

@ -1,10 +1,10 @@
import React, { CSSProperties, FC, memo } from "react";
import React, { CSSProperties, FC, ReactNode, memo } from "react";
type IconFrameMode = "always" | "never";
type IconColorMode = "status" | "theme";
export interface StatusIconPreviewProps {
icon?: string;
icon?: ReactNode;
color?: string;
count?: number;
iconFrameMode?: IconFrameMode;
@ -33,7 +33,6 @@ export const StatusIconPreview: FC<StatusIconPreviewProps> = memo(
onMouseEnter,
onMouseLeave,
}) => {
const iconDisplay = icon?.trim().length ? icon : "📝";
const useStatusColor = iconColorMode === "status";
const appliedColor = useStatusColor && color ? color.trim() : undefined;
@ -58,7 +57,7 @@ export const StatusIconPreview: FC<StatusIconPreviewProps> = memo(
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<span className="status-minimal__icon">{iconDisplay}</span>
<span className="status-minimal__icon">{icon ?? "📝"}</span>
{count && count > 1 && (
<span
className="status-minimal__count"

View file

@ -3,6 +3,7 @@ import { NoteStatus } from "@/types/noteStatus";
import { SelectableListItem } from "./SelectableListItem";
import { getStatusTooltip, isStatusSelected } from "@/utils/statusUtils";
import { StatusIconPreview } from "./StatusIconPreview";
import { StatusIcon } from "./StatusIcon";
interface StatusOptionProps {
status: NoteStatus;
@ -33,7 +34,13 @@ export const StatusModalOption: React.FC<StatusOptionProps> = memo(
icon={
<div className="note-status-option__icon-wrapper">
<StatusIconPreview
icon={status.icon}
icon={
<StatusIcon
icon={status.icon}
lucideIcon={status.lucideIcon}
size={13}
/>
}
color={status.color}
iconFrameMode={iconFrameMode}
iconColorMode={iconColorMode}
@ -101,7 +108,7 @@ export const StatusSelector: React.FC<Props> = ({
>
{availableStatuses.map((status, index) => (
<StatusModalOption
key={`${status.templateId || "custom"}:${status.name}:${status.description}:${status.color}:${status.icon}`}
key={`${status.templateId || "custom"}:${status.name}:${status.description}:${status.color}:${status.icon}:${status.lucideIcon ?? ""}`}
status={status}
isSelected={isStatusSelected(status, currentStatuses)}
isFocused={index === focusedIndex}

View file

@ -32,6 +32,7 @@ export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
quickStatusCommands: ["active", "completed"], // Add default quick commands
// Unknown status customization
unknownStatusIcon: "❓",
unknownStatusLucideIcon: "",
unknownStatusColor: "#8b949e",
statusBarNoStatusText: "No status",
statusBarShowNoStatusIcon: false,

View file

@ -59,6 +59,7 @@ export class FileExplorerIntegration implements IElementProcessor {
key === "fileExplorerIconFrame" ||
key === "fileExplorerIconColorMode" ||
key === "unknownStatusIcon" ||
key === "unknownStatusLucideIcon" ||
key === "unknownStatusColor" ||
key === "templates"
) {
@ -117,6 +118,7 @@ export class FileExplorerIntegration implements IElementProcessor {
const settings = settingsService.settings;
return {
icon: settings.unknownStatusIcon || "❓",
lucideIcon: settings.unknownStatusLucideIcon || "",
color: settings.unknownStatusColor || "#8b949e",
};
}

View file

@ -62,6 +62,7 @@ export class StatusBarIntegration {
}
if (
key === "unknownStatusIcon" ||
key === "unknownStatusLucideIcon" ||
key === "unknownStatusColor" ||
key === "statusBarNoStatusText" ||
key === "statusBarShowNoStatusIcon" ||
@ -123,6 +124,7 @@ export class StatusBarIntegration {
showIcon: settings.statusBarShowNoStatusIcon || false,
showText: settings.statusBarShowNoStatusText ?? true,
icon: settings.unknownStatusIcon || "❓",
lucideIcon: settings.unknownStatusLucideIcon || "",
color: settings.unknownStatusColor || "#8b949e",
};
}

View file

@ -79,6 +79,7 @@ export class EditorToolbarIntegration {
}
if (
key === "unknownStatusIcon" ||
key === "unknownStatusLucideIcon" ||
key === "unknownStatusColor"
) {
this.renderAllButtons();
@ -378,6 +379,7 @@ export class EditorToolbarIntegration {
const settings = settingsService.settings;
return {
icon: settings.unknownStatusIcon || "❓",
lucideIcon: settings.unknownStatusLucideIcon || "",
color: settings.unknownStatusColor || "#8b949e",
};
}

View file

@ -18,6 +18,7 @@ interface StatusItem {
name: string;
color: string;
icon?: string;
lucideIcon?: string;
}
interface FilesByStatus {
@ -57,6 +58,7 @@ export class GroupedDashboardView extends ItemView {
name: status.name,
color: status.color || "",
icon: status.icon,
lucideIcon: status.lucideIcon,
});
private getAllFiles = (): FileItem[] => {

View file

@ -42,6 +42,7 @@ export class GroupedStatusView extends ItemView {
name: status.name,
color: status.color || "white",
icon: status.icon,
lucideIcon: status.lucideIcon,
});
private getAllFiles = (): FileItem[] => {

1463
styles.css

File diff suppressed because one or more lines are too long

View file

@ -53,6 +53,9 @@
.status-minimal__icon {
font-size: 11px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
filter: grayscale(0.2);
transition: filter var(--anim-duration-fast) ease;
}

View file

@ -0,0 +1,186 @@
.lucide-icon-picker {
position: relative;
display: flex;
flex-direction: column;
gap: var(--size-2-1);
}
.lucide-icon-picker__text-input {
max-width: 240px;
}
.lucide-icon-picker__row {
display: flex;
gap: var(--size-2-1);
align-items: center;
}
.lucide-icon-picker__modal-button {
display: inline-flex;
align-items: center;
gap: var(--size-2-1);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: var(--size-2-1) var(--size-4-1);
background: var(--background-secondary);
cursor: pointer;
transition:
border var(--anim-duration-fast) ease,
background var(--anim-duration-fast) ease;
min-width: 220px;
justify-content: space-between;
}
.lucide-icon-picker__modal-button:hover {
border-color: var(--interactive-accent);
}
.lucide-icon-picker__preview {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
}
.lucide-icon-picker__label {
flex: 1;
text-align: left;
font-size: var(--font-ui-smaller);
color: var(--text-muted);
}
.lucide-icon-picker__clear-trigger {
background: transparent;
border: none;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
cursor: pointer;
padding: var(--size-2-1) var(--size-2-2);
}
.lucide-icon-picker__clear-trigger:hover {
color: var(--text-normal);
text-decoration: underline;
}
.lucide-icon-modal-wrapper {
padding: var(--size-4-2);
}
.lucide-icon-modal {
display: flex;
flex-direction: column;
gap: var(--size-2-1);
}
.lucide-icon-modal__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--size-2-2);
}
.lucide-icon-modal__count {
margin: 0;
font-size: var(--font-ui-smaller);
color: var(--text-muted);
}
.lucide-icon-modal__close {
display: inline-flex;
align-items: center;
justify-content: center;
}
.lucide-icon-modal__search {
width: 100%;
}
.lucide-icon-modal__toggle {
display: inline-flex;
align-items: center;
gap: var(--size-2-1);
font-size: var(--font-ui-smaller);
color: var(--text-muted);
}
.lucide-icon-modal__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--size-2-1);
max-height: 360px;
overflow-y: auto;
padding-right: var(--size-2-1);
}
.lucide-icon-modal__list--icons-only {
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
}
.lucide-icon-modal__option {
display: flex;
align-items: center;
gap: var(--size-2-1);
padding: var(--size-2-1);
border-radius: var(--radius-s);
border: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
cursor: pointer;
font-size: var(--font-ui-smaller);
justify-content: flex-start;
}
.lucide-icon-modal__option:hover {
background: var(--background-modifier-hover);
}
.lucide-icon-modal__option--selected {
border-color: var(--interactive-accent);
background: var(--background-modifier-success);
}
.lucide-icon-modal__option--compact {
justify-content: center;
}
.lucide-icon-modal__option--compact span {
display: none;
}
.lucide-icon-modal__empty {
grid-column: 1 / -1;
text-align: center;
font-size: var(--font-ui-smaller);
color: var(--text-muted);
padding: var(--size-4-2);
}
.icon-selection-field {
display: flex;
flex-wrap: wrap;
gap: var(--size-4-2);
}
.icon-selection-field__column {
flex: 1;
min-width: 220px;
}
.icon-selection-field__label {
display: block;
font-size: var(--font-ui-smaller);
font-weight: var(--font-medium);
color: var(--text-muted);
margin-bottom: var(--size-2-1);
}
.icon-selection-field__emoji-input {
max-width: 260px;
}
.icon-selection-field__hint {
margin-top: var(--size-2-1);
font-size: var(--font-ui-smaller);
color: var(--text-muted);
}

View file

@ -36,12 +36,23 @@
margin-bottom: var(--size-4-2);
}
.custom-status-item__row--icons {
align-items: stretch;
flex-wrap: wrap;
}
.custom-status-item__field {
display: flex;
flex-direction: column;
gap: var(--size-2-1);
}
.custom-status-item__field--icon,
.custom-status-item__field--lucide {
flex: 1;
min-width: 240px;
}
.custom-status-item__field--name {
flex: 2;
min-width: 120px;
@ -82,9 +93,15 @@
}
.custom-status-item__input--icon {
width: 50px !important;
text-align: center;
font-size: 1.2em;
width: 100% !important;
text-align: left;
font-size: 1em;
}
.custom-status-item__hint {
margin: 0;
font-size: var(--font-ui-smaller);
color: var(--text-muted);
}
.custom-status-item__input--name {

View file

@ -0,0 +1,6 @@
.status-icon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}

View file

@ -21,6 +21,9 @@
align-items: center;
justify-content: center;
color: var(--text-muted);
width: 18px;
height: 18px;
line-height: 1;
}
/* Icon with status color */

View file

@ -15,5 +15,7 @@
@import "components/group-label.css";
@import "components/collapsible-counter.css";
@import "components/status-display.css";
@import "components/lucide-icon-picker.css";
@import "components/status-icon.css";
@import "components/obsidian-icon.css";
@import "components/toolbar-button.css";

View file

@ -1,6 +1,7 @@
export type NoteStatus = {
name: string;
icon: string;
lucideIcon?: string;
color?: string; // Optional color property
description?: string; // Optional description property
templateId?: string; // Optional template scope for namespacing

View file

@ -32,6 +32,7 @@ export type PluginSettings = {
quickStatusCommands: string[];
// Unknown status customization
unknownStatusIcon: string; // Custom icon for unknown status
unknownStatusLucideIcon?: string; // Optional Lucide icon for unknown status
unknownStatusColor: string; // Custom hex color for unknown status
statusBarNoStatusText: string; // Custom text for status bar when no status
statusBarShowNoStatusIcon: boolean; // Whether to show icon in status bar for no status

51
utils/iconUtils.ts Normal file
View file

@ -0,0 +1,51 @@
import { IconName, getIcon, getIconIds } from "obsidian";
let cachedIconSet: Set<IconName> | null = null;
const getLucideIconSet = () => {
if (!cachedIconSet || cachedIconSet.size === 0) {
try {
const ids = getIconIds();
if (ids?.length) {
cachedIconSet = new Set(ids);
}
} catch (error) {
// Ignore fallback detection will rely on getIcon
cachedIconSet = null;
}
}
return cachedIconSet;
};
const iconExists = (iconName: string): boolean => {
const iconSet = getLucideIconSet();
if (iconSet?.size) {
return iconSet.has(iconName as IconName);
}
try {
return !!getIcon(iconName as IconName);
} catch {
return false;
}
};
export const resolveLucideIconName = (
...candidates: Array<string | undefined | null>
): IconName | null => {
for (const candidate of candidates) {
const trimmed = candidate?.trim();
if (trimmed && iconExists(trimmed)) {
return trimmed as IconName;
}
}
return null;
};
export const isLucideIconName = (name?: string | null) => {
const trimmed = name?.trim();
if (!trimmed) {
return false;
}
return iconExists(trimmed);
};