mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-27 11:41:51 +02:00
refactor: tags
This commit is contained in:
@@ -427,8 +427,14 @@ impl ProfileManager {
|
|||||||
.find(|p| p.name == profile_name)
|
.find(|p| p.name == profile_name)
|
||||||
.ok_or_else(|| format!("Profile {profile_name} not found"))?;
|
.ok_or_else(|| format!("Profile {profile_name} not found"))?;
|
||||||
|
|
||||||
// Update tags as-is; preserve characters and order given by caller
|
let mut seen = std::collections::HashSet::new();
|
||||||
profile.tags = tags;
|
let mut deduped: Vec<String> = Vec::with_capacity(tags.len());
|
||||||
|
for t in tags.into_iter() {
|
||||||
|
if seen.insert(t.clone()) {
|
||||||
|
deduped.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
profile.tags = deduped;
|
||||||
|
|
||||||
// Save profile
|
// Save profile
|
||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
|
|||||||
@@ -445,6 +445,40 @@ const MultipleSelector = React.forwardRef<
|
|||||||
setInputValue(value);
|
setInputValue(value);
|
||||||
inputProps?.onValueChange?.(value);
|
inputProps?.onValueChange?.(value);
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Allow consumer to handle first
|
||||||
|
inputProps?.onKeyDown?.(
|
||||||
|
e as unknown as React.KeyboardEvent<HTMLInputElement>,
|
||||||
|
);
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
const value = inputValue.trim();
|
||||||
|
if (value.length === 0) return;
|
||||||
|
// If option already exists among available options, pick that; otherwise create
|
||||||
|
const entries = Object.values(options).flat();
|
||||||
|
const existing = entries.find(
|
||||||
|
(o) => o.value === value && !o.disable,
|
||||||
|
);
|
||||||
|
// Prevent duplicates in the current selection
|
||||||
|
if (
|
||||||
|
selected.some((s) => s.value === (existing?.value ?? value))
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
setInputValue("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selected.length >= maxSelected) {
|
||||||
|
onMaxSelected?.(selected.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
setInputValue("");
|
||||||
|
const picked = existing ?? { value, label: value };
|
||||||
|
const newOptions = [...selected, picked];
|
||||||
|
setSelected(newOptions);
|
||||||
|
onChange?.(newOptions);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onBlur={(event) => {
|
onBlur={(event) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
inputProps?.onBlur?.(event);
|
inputProps?.onBlur?.(event);
|
||||||
|
|||||||
@@ -100,18 +100,18 @@ const TagsCell: React.FC<{
|
|||||||
[allTags],
|
[allTags],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSearch = React.useCallback(
|
|
||||||
async (q: string): Promise<Option[]> => {
|
|
||||||
const query = q.trim().toLowerCase();
|
|
||||||
if (!query) return allOptions;
|
|
||||||
return allOptions.filter((o) => o.value.toLowerCase().includes(query));
|
|
||||||
},
|
|
||||||
[allOptions],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChange = React.useCallback(
|
const handleChange = React.useCallback(
|
||||||
async (opts: Option[]) => {
|
async (opts: Option[]) => {
|
||||||
const newTags = opts.map((o) => o.value);
|
const newTagsRaw = opts.map((o) => o.value);
|
||||||
|
// Dedupe while preserving order
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const newTags: string[] = [];
|
||||||
|
for (const t of newTagsRaw) {
|
||||||
|
if (!seen.has(t)) {
|
||||||
|
seen.add(t);
|
||||||
|
newTags.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
setTagsOverrides((prev) => ({ ...prev, [profile.name]: newTags }));
|
setTagsOverrides((prev) => ({ ...prev, [profile.name]: newTags }));
|
||||||
try {
|
try {
|
||||||
await invoke<BrowserProfile>("update_profile_tags", {
|
await invoke<BrowserProfile>("update_profile_tags", {
|
||||||
@@ -131,11 +131,13 @@ const TagsCell: React.FC<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const editorRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const [visibleCount, setVisibleCount] = React.useState<number>(
|
const [visibleCount, setVisibleCount] = React.useState<number>(
|
||||||
effectiveTags.length,
|
effectiveTags.length,
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
|
// Only measure when not editing this profile's tags
|
||||||
if (openTagsEditorFor === profile.name) return;
|
if (openTagsEditorFor === profile.name) return;
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -181,62 +183,81 @@ const TagsCell: React.FC<{
|
|||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, [effectiveTags, openTagsEditorFor, profile.name]);
|
}, [effectiveTags, openTagsEditorFor, profile.name]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (openTagsEditorFor !== profile.name) return;
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node | null;
|
||||||
|
if (editorRef.current && target && !editorRef.current.contains(target)) {
|
||||||
|
setOpenTagsEditorFor(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, [openTagsEditorFor, profile.name, setOpenTagsEditorFor]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (openTagsEditorFor === profile.name && editorRef.current) {
|
||||||
|
// Focus the inner input of MultipleSelector on open
|
||||||
|
const inputEl = editorRef.current.querySelector("input");
|
||||||
|
if (inputEl) {
|
||||||
|
(inputEl as HTMLInputElement).focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [openTagsEditorFor, profile.name]);
|
||||||
|
|
||||||
if (openTagsEditorFor !== profile.name) {
|
if (openTagsEditorFor !== profile.name) {
|
||||||
const hiddenCount = Math.max(0, effectiveTags.length - visibleCount);
|
const hiddenCount = Math.max(0, effectiveTags.length - visibleCount);
|
||||||
return (
|
return (
|
||||||
<div className="w-48 h-full cursor-pointer">
|
<div className="w-48 h-full cursor-pointer">
|
||||||
<div
|
<button
|
||||||
ref={containerRef}
|
type="button"
|
||||||
|
ref={containerRef as unknown as React.RefObject<HTMLButtonElement>}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1 overflow-hidden",
|
"flex items-center gap-1 overflow-hidden cursor-pointer bg-transparent border-none p-1 w-full h-full",
|
||||||
isDisabled && "opacity-60",
|
isDisabled && "opacity-60",
|
||||||
)}
|
)}
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isDisabled) setOpenTagsEditorFor(profile.name);
|
if (!isDisabled) setOpenTagsEditorFor(profile.name);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (!isDisabled && (e.key === "Enter" || e.key === " ")) {
|
|
||||||
e.preventDefault();
|
|
||||||
setOpenTagsEditorFor(profile.name);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyUp={() => {}}
|
|
||||||
>
|
>
|
||||||
{effectiveTags.slice(0, visibleCount).map((t) => (
|
{effectiveTags.slice(0, visibleCount).map((t) => (
|
||||||
<Badge key={t} variant="secondary" className="px-2 py-0 text-xs">
|
<Badge key={t} variant="secondary" className="px-2 py-0 text-xs">
|
||||||
{t}
|
{t}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
{effectiveTags.length === 0 && (
|
||||||
|
<span className="inline-block h-4 min-w-10" />
|
||||||
|
)}
|
||||||
{hiddenCount > 0 && (
|
{hiddenCount > 0 && (
|
||||||
<Badge variant="outline" className="px-2 py-0 text-xs">
|
<Badge variant="outline" className="px-2 py-0 text-xs">
|
||||||
+{hiddenCount}
|
+{hiddenCount}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-48", isDisabled && "opacity-60 pointer-events-none")}>
|
<div className={cn("w-48", isDisabled && "opacity-60 pointer-events-none")}>
|
||||||
<MultipleSelector
|
<div ref={editorRef}>
|
||||||
value={valueOptions}
|
<MultipleSelector
|
||||||
options={allOptions}
|
value={valueOptions}
|
||||||
onChange={(opts) => void handleChange(opts)}
|
options={allOptions}
|
||||||
onSearch={onSearch}
|
onChange={(opts) => void handleChange(opts)}
|
||||||
creatable
|
creatable
|
||||||
placeholder={effectiveTags.length === 0 ? "Add tags" : ""}
|
selectFirstItem={false}
|
||||||
className="bg-transparent"
|
placeholder={effectiveTags.length === 0 ? "Add tags" : ""}
|
||||||
badgeClassName=""
|
className="bg-transparent"
|
||||||
inputProps={{
|
badgeClassName=""
|
||||||
className: "py-1",
|
inputProps={{
|
||||||
onKeyDown: (e) => {
|
className: "py-1",
|
||||||
if (e.key === "Escape") setOpenTagsEditorFor(null);
|
onKeyDown: (e) => {
|
||||||
},
|
if (e.key === "Escape") setOpenTagsEditorFor(null);
|
||||||
}}
|
},
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user