feat: tag selection
This commit is contained in:
@@ -8,6 +8,7 @@ import { PostLinks } from '../PostLinks';
|
|||||||
import { ErrorModal } from '../ErrorModal';
|
import { ErrorModal } from '../ErrorModal';
|
||||||
import { SettingsView } from '../SettingsView';
|
import { SettingsView } from '../SettingsView';
|
||||||
import { TagsView } from '../TagsView';
|
import { TagsView } from '../TagsView';
|
||||||
|
import { TagInput } from '../TagInput';
|
||||||
import { AutoSaveManager } from '../../utils';
|
import { AutoSaveManager } from '../../utils';
|
||||||
import './Editor.css';
|
import './Editor.css';
|
||||||
|
|
||||||
@@ -149,7 +150,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
|
|
||||||
const [title, setTitle] = useState(post.title);
|
const [title, setTitle] = useState(post.title);
|
||||||
const [content, setContent] = useState(post.content);
|
const [content, setContent] = useState(post.content);
|
||||||
const [tags, setTags] = useState(post.tags.join(', '));
|
const [tags, setTags] = useState<string[]>(post.tags);
|
||||||
const [category, setCategory] = useState(post.categories[0] || 'article');
|
const [category, setCategory] = useState(post.categories[0] || 'article');
|
||||||
const [availableCategories, setAvailableCategories] = useState<string[]>(['article', 'picture', 'aside', 'page']);
|
const [availableCategories, setAvailableCategories] = useState<string[]>(['article', 'picture', 'aside', 'page']);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -191,7 +192,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
const pendingChangesRef = useRef<{
|
const pendingChangesRef = useRef<{
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
tags: string;
|
tags: string[];
|
||||||
category: string;
|
category: string;
|
||||||
postId: string;
|
postId: string;
|
||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
@@ -225,7 +226,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
window.electronAPI?.posts.update(pending.postId, {
|
window.electronAPI?.posts.update(pending.postId, {
|
||||||
title: pending.title,
|
title: pending.title,
|
||||||
content: pending.content,
|
content: pending.content,
|
||||||
tags: pending.tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
|
tags: pending.tags,
|
||||||
categories: pending.category ? [pending.category] : ['article'],
|
categories: pending.category ? [pending.category] : ['article'],
|
||||||
}).then((updated) => {
|
}).then((updated) => {
|
||||||
if (updated) {
|
if (updated) {
|
||||||
@@ -243,7 +244,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTitle(post.title);
|
setTitle(post.title);
|
||||||
setContent(post.content);
|
setContent(post.content);
|
||||||
setTags(post.tags.join(', '));
|
setTags(post.tags);
|
||||||
setCategory(post.categories[0] || 'article');
|
setCategory(post.categories[0] || 'article');
|
||||||
markClean(post.id);
|
markClean(post.id);
|
||||||
}, [post.id, post.title, post.content, post.tags, post.categories, markClean]);
|
}, [post.id, post.title, post.content, post.tags, post.categories, markClean]);
|
||||||
@@ -251,19 +252,21 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
// Track changes and notify auto-save manager
|
// Track changes and notify auto-save manager
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentCategory = post.categories[0] || 'article';
|
const currentCategory = post.categories[0] || 'article';
|
||||||
|
const tagsChanged = JSON.stringify(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort());
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
title !== post.title ||
|
title !== post.title ||
|
||||||
content !== post.content ||
|
content !== post.content ||
|
||||||
tags !== post.tags.join(', ') ||
|
tagsChanged ||
|
||||||
category !== currentCategory;
|
category !== currentCategory;
|
||||||
|
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
markDirty(post.id);
|
markDirty(post.id);
|
||||||
// Notify auto-save manager with accumulated changes
|
// Notify auto-save manager with accumulated changes
|
||||||
|
// Convert tags array to comma-separated string for auto-save compatibility
|
||||||
autoSaveManager.notifyChange(post.id, {
|
autoSaveManager.notifyChange(post.id, {
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
tags,
|
tags: tags.join(', '),
|
||||||
category,
|
category,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -288,7 +291,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
const updated = await window.electronAPI?.posts.update(post.id, {
|
const updated = await window.electronAPI?.posts.update(post.id, {
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
|
tags,
|
||||||
categories: category ? [category] : ['article'],
|
categories: category ? [category] : ['article'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -364,7 +367,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
if (reverted) {
|
if (reverted) {
|
||||||
setTitle(reverted.title);
|
setTitle(reverted.title);
|
||||||
setContent(reverted.content);
|
setContent(reverted.content);
|
||||||
setTags(reverted.tags.join(', '));
|
setTags(reverted.tags);
|
||||||
setCategory(reverted.categories[0] || 'article');
|
setCategory(reverted.categories[0] || 'article');
|
||||||
updatePost(post.id, reverted as Partial<PostData>);
|
updatePost(post.id, reverted as Partial<PostData>);
|
||||||
markClean(post.id);
|
markClean(post.id);
|
||||||
@@ -512,12 +515,11 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="editor-field-row">
|
<div className="editor-field-row">
|
||||||
<div className="editor-field">
|
<div className="editor-field">
|
||||||
<label>Tags (comma-separated)</label>
|
<label>Tags</label>
|
||||||
<input
|
<TagInput
|
||||||
type="text"
|
|
||||||
value={tags}
|
value={tags}
|
||||||
onChange={(e) => setTags(e.target.value)}
|
onChange={setTags}
|
||||||
placeholder="tag1, tag2, tag3"
|
placeholder="Add tags..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="editor-field">
|
<div className="editor-field">
|
||||||
|
|||||||
169
src/renderer/components/TagInput/TagInput.css
Normal file
169
src/renderer/components/TagInput/TagInput.css
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
.tag-input-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--background-primary);
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-input-wrapper:focus-within {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tag chips */
|
||||||
|
.tag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: var(--background-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip.has-color {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip-remove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 2px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: opacity 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip-remove:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip.has-color .tag-chip-remove:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input field */
|
||||||
|
.tag-input-field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-input-field::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-input-field:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Suggestions dropdown */
|
||||||
|
.tag-suggestions {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--background-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 100;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-suggestion {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-suggestion:hover,
|
||||||
|
.tag-suggestion.selected {
|
||||||
|
background: var(--background-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-suggestion-color {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-suggestion-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Create new tag option */
|
||||||
|
.tag-suggestion.create-new {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 12px;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-suggestion.create-new:first-child {
|
||||||
|
border-top: none;
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-suggestion-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 1px dashed currentColor;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
327
src/renderer/components/TagInput/TagInput.tsx
Normal file
327
src/renderer/components/TagInput/TagInput.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { showToast } from '../Toast';
|
||||||
|
import './TagInput.css';
|
||||||
|
|
||||||
|
interface TagData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagInputProps {
|
||||||
|
/** Current tags assigned to the item */
|
||||||
|
value: string[];
|
||||||
|
/** Callback when tags change */
|
||||||
|
onChange: (tags: string[]) => void;
|
||||||
|
/** Placeholder text */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Whether the input is disabled */
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get contrasting text color for background
|
||||||
|
const getContrastColor = (hex: string): string => {
|
||||||
|
const color = hex.replace('#', '');
|
||||||
|
let r: number, g: number, b: number;
|
||||||
|
if (color.length === 3) {
|
||||||
|
r = parseInt(color[0] + color[0], 16);
|
||||||
|
g = parseInt(color[1] + color[1], 16);
|
||||||
|
b = parseInt(color[2] + color[2], 16);
|
||||||
|
} else {
|
||||||
|
r = parseInt(color.substring(0, 2), 16);
|
||||||
|
g = parseInt(color.substring(2, 4), 16);
|
||||||
|
b = parseInt(color.substring(4, 6), 16);
|
||||||
|
}
|
||||||
|
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||||
|
return luminance > 0.5 ? '#000000' : '#ffffff';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TagInput: React.FC<TagInputProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Add tags...',
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [suggestions, setSuggestions] = useState<TagData[]>([]);
|
||||||
|
const [allTags, setAllTags] = useState<TagData[]>([]);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Load all available tags
|
||||||
|
const loadTags = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const tags = await window.electronAPI?.tags.getAll();
|
||||||
|
if (tags) {
|
||||||
|
setAllTags(tags as TagData[]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tags:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTags();
|
||||||
|
}, [loadTags]);
|
||||||
|
|
||||||
|
// Listen for tag changes
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribers: Array<() => void> = [];
|
||||||
|
|
||||||
|
unsubscribers.push(
|
||||||
|
window.electronAPI?.on('tag:created', () => loadTags()) || (() => {})
|
||||||
|
);
|
||||||
|
unsubscribers.push(
|
||||||
|
window.electronAPI?.on('tag:deleted', () => loadTags()) || (() => {})
|
||||||
|
);
|
||||||
|
unsubscribers.push(
|
||||||
|
window.electronAPI?.on('tag:renamed', () => loadTags()) || (() => {})
|
||||||
|
);
|
||||||
|
unsubscribers.push(
|
||||||
|
window.electronAPI?.on('tags:merged', () => loadTags()) || (() => {})
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribers.forEach(unsub => unsub());
|
||||||
|
};
|
||||||
|
}, [loadTags]);
|
||||||
|
|
||||||
|
// Filter suggestions based on input
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inputValue.trim()) {
|
||||||
|
setSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = inputValue.toLowerCase().trim();
|
||||||
|
const filtered = allTags
|
||||||
|
.filter(tag =>
|
||||||
|
tag.name.toLowerCase().includes(query) &&
|
||||||
|
!value.includes(tag.name)
|
||||||
|
)
|
||||||
|
.slice(0, 8); // Limit to 8 suggestions
|
||||||
|
|
||||||
|
setSuggestions(filtered);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}, [inputValue, allTags, value]);
|
||||||
|
|
||||||
|
// Close suggestions when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Add a tag
|
||||||
|
const addTag = useCallback((tagName: string) => {
|
||||||
|
const normalized = tagName.trim().toLowerCase();
|
||||||
|
if (!normalized) return;
|
||||||
|
|
||||||
|
if (value.includes(normalized)) {
|
||||||
|
showToast.info('Tag already added');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange([...value, normalized]);
|
||||||
|
setInputValue('');
|
||||||
|
setShowSuggestions(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, [value, onChange]);
|
||||||
|
|
||||||
|
// Remove a tag
|
||||||
|
const removeTag = useCallback((tagName: string) => {
|
||||||
|
onChange(value.filter(t => t !== tagName));
|
||||||
|
}, [value, onChange]);
|
||||||
|
|
||||||
|
// Create a new tag and add it
|
||||||
|
const createAndAddTag = useCallback(async (tagName: string) => {
|
||||||
|
const normalized = tagName.trim().toLowerCase();
|
||||||
|
if (!normalized) return;
|
||||||
|
|
||||||
|
// Check if it already exists
|
||||||
|
const exists = allTags.some(t => t.name === normalized);
|
||||||
|
if (exists) {
|
||||||
|
addTag(normalized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.tags.create({ name: normalized });
|
||||||
|
addTag(normalized);
|
||||||
|
showToast.success(`Tag "${normalized}" created`);
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
showToast.error(err.message);
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
}, [allTags, addTag]);
|
||||||
|
|
||||||
|
// Handle keyboard navigation
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
const maxIndex = suggestions.length + (inputValue.trim() && !suggestions.some(s => s.name === inputValue.trim().toLowerCase()) ? 0 : -1);
|
||||||
|
setSelectedIndex(prev => Math.min(prev + 1, maxIndex));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => Math.max(prev - 1, -1));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
||||||
|
addTag(suggestions[selectedIndex].name);
|
||||||
|
} else if (selectedIndex === suggestions.length && inputValue.trim()) {
|
||||||
|
// Create new tag option selected
|
||||||
|
createAndAddTag(inputValue.trim());
|
||||||
|
} else if (inputValue.trim()) {
|
||||||
|
// No selection, but there's input - check if exact match exists
|
||||||
|
const exactMatch = allTags.find(t => t.name === inputValue.trim().toLowerCase());
|
||||||
|
if (exactMatch) {
|
||||||
|
addTag(exactMatch.name);
|
||||||
|
} else {
|
||||||
|
createAndAddTag(inputValue.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setInputValue('');
|
||||||
|
} else if (e.key === 'Backspace' && !inputValue && value.length > 0) {
|
||||||
|
// Remove last tag when backspace on empty input
|
||||||
|
removeTag(value[value.length - 1]);
|
||||||
|
} else if (e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (inputValue.trim()) {
|
||||||
|
const exactMatch = allTags.find(t => t.name === inputValue.trim().toLowerCase());
|
||||||
|
if (exactMatch) {
|
||||||
|
addTag(exactMatch.name);
|
||||||
|
} else {
|
||||||
|
createAndAddTag(inputValue.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get tag data for display
|
||||||
|
const getTagData = (tagName: string): TagData | undefined => {
|
||||||
|
return allTags.find(t => t.name === tagName);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if input matches an existing tag exactly
|
||||||
|
const exactMatchExists = inputValue.trim() &&
|
||||||
|
allTags.some(t => t.name === inputValue.trim().toLowerCase());
|
||||||
|
|
||||||
|
// Should show "Create new tag" option
|
||||||
|
const showCreateOption = inputValue.trim() &&
|
||||||
|
!exactMatchExists &&
|
||||||
|
!value.includes(inputValue.trim().toLowerCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tag-input-container" ref={containerRef}>
|
||||||
|
<div className="tag-input-wrapper">
|
||||||
|
{/* Current tags */}
|
||||||
|
{value.map(tagName => {
|
||||||
|
const tagData = getTagData(tagName);
|
||||||
|
const hasColor = !!tagData?.color;
|
||||||
|
const style: React.CSSProperties = hasColor
|
||||||
|
? {
|
||||||
|
backgroundColor: tagData!.color,
|
||||||
|
color: getContrastColor(tagData!.color!),
|
||||||
|
borderColor: tagData!.color,
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={tagName}
|
||||||
|
className={`tag-chip ${hasColor ? 'has-color' : ''}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{tagName}
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tag-chip-remove"
|
||||||
|
onClick={() => removeTag(tagName)}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={`Remove ${tagName}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Input field */}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className="tag-input-field"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInputValue(e.target.value);
|
||||||
|
setShowSuggestions(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setShowSuggestions(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={value.length === 0 ? placeholder : ''}
|
||||||
|
disabled={disabled || isCreating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggestions dropdown */}
|
||||||
|
{showSuggestions && (suggestions.length > 0 || showCreateOption) && (
|
||||||
|
<div className="tag-suggestions">
|
||||||
|
{suggestions.map((tag, index) => {
|
||||||
|
const hasColor = !!tag.color;
|
||||||
|
const style: React.CSSProperties = hasColor
|
||||||
|
? {
|
||||||
|
'--tag-color': tag.color,
|
||||||
|
'--tag-text-color': getContrastColor(tag.color!),
|
||||||
|
} as React.CSSProperties
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
className={`tag-suggestion ${selectedIndex === index ? 'selected' : ''}`}
|
||||||
|
style={style}
|
||||||
|
onClick={() => addTag(tag.name)}
|
||||||
|
>
|
||||||
|
{hasColor && (
|
||||||
|
<span
|
||||||
|
className="tag-suggestion-color"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="tag-suggestion-name">{tag.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{showCreateOption && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`tag-suggestion create-new ${selectedIndex === suggestions.length ? 'selected' : ''}`}
|
||||||
|
onClick={() => createAndAddTag(inputValue.trim())}
|
||||||
|
>
|
||||||
|
<span className="tag-suggestion-icon">+</span>
|
||||||
|
<span>Create "{inputValue.trim()}"</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
src/renderer/components/TagInput/index.ts
Normal file
1
src/renderer/components/TagInput/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { TagInput } from './TagInput';
|
||||||
@@ -13,5 +13,6 @@ export { ResizablePanel } from './ResizablePanel';
|
|||||||
export { CredentialsPanel } from './CredentialsPanel';
|
export { CredentialsPanel } from './CredentialsPanel';
|
||||||
export { SettingsView } from './SettingsView';
|
export { SettingsView } from './SettingsView';
|
||||||
export { TagsView, scrollToTagsSection, type TagsCategory } from './TagsView';
|
export { TagsView, scrollToTagsSection, type TagsCategory } from './TagsView';
|
||||||
|
export { TagInput } from './TagInput';
|
||||||
export { PostLinks } from './PostLinks';
|
export { PostLinks } from './PostLinks';
|
||||||
export { ErrorModal, type ErrorDetails } from './ErrorModal';
|
export { ErrorModal, type ErrorDetails } from './ErrorModal';
|
||||||
|
|||||||
Reference in New Issue
Block a user