feat: tag selection
This commit is contained in:
@@ -8,6 +8,7 @@ import { PostLinks } from '../PostLinks';
|
||||
import { ErrorModal } from '../ErrorModal';
|
||||
import { SettingsView } from '../SettingsView';
|
||||
import { TagsView } from '../TagsView';
|
||||
import { TagInput } from '../TagInput';
|
||||
import { AutoSaveManager } from '../../utils';
|
||||
import './Editor.css';
|
||||
|
||||
@@ -149,7 +150,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
|
||||
const [title, setTitle] = useState(post.title);
|
||||
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 [availableCategories, setAvailableCategories] = useState<string[]>(['article', 'picture', 'aside', 'page']);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -191,7 +192,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
const pendingChangesRef = useRef<{
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string;
|
||||
tags: string[];
|
||||
category: string;
|
||||
postId: string;
|
||||
isDirty: boolean;
|
||||
@@ -225,7 +226,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
window.electronAPI?.posts.update(pending.postId, {
|
||||
title: pending.title,
|
||||
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'],
|
||||
}).then((updated) => {
|
||||
if (updated) {
|
||||
@@ -243,7 +244,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
useEffect(() => {
|
||||
setTitle(post.title);
|
||||
setContent(post.content);
|
||||
setTags(post.tags.join(', '));
|
||||
setTags(post.tags);
|
||||
setCategory(post.categories[0] || 'article');
|
||||
markClean(post.id);
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
const currentCategory = post.categories[0] || 'article';
|
||||
const tagsChanged = JSON.stringify(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort());
|
||||
const hasChanges =
|
||||
title !== post.title ||
|
||||
content !== post.content ||
|
||||
tags !== post.tags.join(', ') ||
|
||||
tagsChanged ||
|
||||
category !== currentCategory;
|
||||
|
||||
if (hasChanges) {
|
||||
markDirty(post.id);
|
||||
// Notify auto-save manager with accumulated changes
|
||||
// Convert tags array to comma-separated string for auto-save compatibility
|
||||
autoSaveManager.notifyChange(post.id, {
|
||||
title,
|
||||
content,
|
||||
tags,
|
||||
tags: tags.join(', '),
|
||||
category,
|
||||
});
|
||||
} else {
|
||||
@@ -288,7 +291,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
const updated = await window.electronAPI?.posts.update(post.id, {
|
||||
title,
|
||||
content,
|
||||
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
|
||||
tags,
|
||||
categories: category ? [category] : ['article'],
|
||||
});
|
||||
|
||||
@@ -364,7 +367,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
if (reverted) {
|
||||
setTitle(reverted.title);
|
||||
setContent(reverted.content);
|
||||
setTags(reverted.tags.join(', '));
|
||||
setTags(reverted.tags);
|
||||
setCategory(reverted.categories[0] || 'article');
|
||||
updatePost(post.id, reverted as Partial<PostData>);
|
||||
markClean(post.id);
|
||||
@@ -512,12 +515,11 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
</div>
|
||||
<div className="editor-field-row">
|
||||
<div className="editor-field">
|
||||
<label>Tags (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
<label>Tags</label>
|
||||
<TagInput
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="tag1, tag2, tag3"
|
||||
onChange={setTags}
|
||||
placeholder="Add tags..."
|
||||
/>
|
||||
</div>
|
||||
<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 { SettingsView } from './SettingsView';
|
||||
export { TagsView, scrollToTagsSection, type TagsCategory } from './TagsView';
|
||||
export { TagInput } from './TagInput';
|
||||
export { PostLinks } from './PostLinks';
|
||||
export { ErrorModal, type ErrorDetails } from './ErrorModal';
|
||||
|
||||
Reference in New Issue
Block a user