add favorites
This commit is contained in:
@@ -2,6 +2,9 @@ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
||||
import { socketSpecAcceptsType } from './constants';
|
||||
import { outputTypeCanConnectToTarget } from './connectionUtils';
|
||||
import { compareMenuNodes, compareMenuCategories } from './canvasEvents';
|
||||
import { useFavorites } from './favorites';
|
||||
|
||||
const FAVORITES_CATEGORY = 'favorites';
|
||||
|
||||
export default function ContextMenu({
|
||||
x,
|
||||
@@ -26,6 +29,7 @@ export default function ContextMenu({
|
||||
selectedNodeCount?: number;
|
||||
onCreateGroup?: (() => void) | null;
|
||||
}) {
|
||||
const favorites = useFavorites();
|
||||
const [openCat, setOpenCat] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
@@ -88,13 +92,31 @@ export default function ContextMenu({
|
||||
});
|
||||
}
|
||||
}
|
||||
return Object.values(cats)
|
||||
const sorted = Object.values(cats)
|
||||
.map((category: any) => ({
|
||||
...category,
|
||||
items: [...category.items].sort(compareMenuNodes),
|
||||
}))
|
||||
.sort(compareMenuCategories);
|
||||
}, [nodeDefs, filterDirection, filterSpec, filterType]);
|
||||
|
||||
const favItems: any[] = [];
|
||||
const seenFav = new Set<string>();
|
||||
for (const category of sorted) {
|
||||
for (const item of category.items) {
|
||||
if (favorites.has(item.className) && !seenFav.has(item.className)) {
|
||||
seenFav.add(item.className);
|
||||
favItems.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (favItems.length > 0) {
|
||||
return [
|
||||
{ name: FAVORITES_CATEGORY, order: -Infinity, items: favItems.sort(compareMenuNodes) },
|
||||
...sorted,
|
||||
];
|
||||
}
|
||||
return sorted;
|
||||
}, [nodeDefs, filterDirection, filterSpec, filterType, favorites]);
|
||||
|
||||
// Flat filtered list for search
|
||||
const searchResults = useMemo(() => {
|
||||
@@ -262,10 +284,12 @@ export default function ContextMenu({
|
||||
<div
|
||||
key={cat}
|
||||
ref={(el) => { catRowRefs.current[cat] = el; }}
|
||||
className={`ctx-cat-item${openCat === cat ? ' ctx-cat-active' : ''}`}
|
||||
className={`ctx-cat-item${openCat === cat ? ' ctx-cat-active' : ''}${cat === FAVORITES_CATEGORY ? ' ctx-cat-favorites' : ''}`}
|
||||
onMouseEnter={() => handleCatEnter(cat)}
|
||||
>
|
||||
<span className="ctx-cat-label">{cat}</span>
|
||||
<span className="ctx-cat-label">
|
||||
{cat === FAVORITES_CATEGORY ? '♥ favorites' : cat}
|
||||
</span>
|
||||
<span className="ctx-cat-arrow">▶</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { getGroupMinimumSize } from './groupSizing';
|
||||
import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout';
|
||||
import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns, parseNumberWithUnit, formatSI, parseSI } from './valueFormatting';
|
||||
import { useIsFavorite, toggleFavorite } from './favorites';
|
||||
|
||||
import type { NodeData, NodeContextValue, InputSpec, InputOptions, WidgetDescriptor, PreviewPayload, OverlayData } from './types';
|
||||
|
||||
@@ -1001,6 +1002,7 @@ function NodeTable({ rows }: { rows: Array<Record<string, unknown>> }) {
|
||||
function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
const ctx = useContext(NodeContext);
|
||||
const def = data.definition;
|
||||
const favorited = useIsFavorite(data.className);
|
||||
const scalarDisplay = formatScalarDisplay(data.scalarValue);
|
||||
const processingTimeText = formatProcessingTime(data.processingTimeMs);
|
||||
const nodeWidth = useStore(
|
||||
@@ -1244,6 +1246,14 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
<div className="node-title-left">
|
||||
<span className="node-title-main">{data.label}</span>
|
||||
<button className="node-help-btn nodrag nopan" title="Documentation" onClick={(e) => { e.stopPropagation(); ctx?.openHelp(data.label); }}>?</button>
|
||||
<button
|
||||
className={`node-fav-btn nodrag nopan${favorited ? ' is-favorited' : ''}`}
|
||||
title={favorited ? 'Remove from favorites' : 'Add to favorites'}
|
||||
aria-pressed={favorited}
|
||||
onClick={(e) => { e.stopPropagation(); toggleFavorite(data.className); }}
|
||||
>
|
||||
{favorited ? '♥' : '♡'}
|
||||
</button>
|
||||
</div>
|
||||
{headerMeta && <span className="node-title-meta" title={headerMeta}>{headerMeta}</span>}
|
||||
</div>
|
||||
|
||||
68
frontend/src/favorites.ts
Normal file
68
frontend/src/favorites.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'tono_favorite_nodes';
|
||||
|
||||
let favorites: Set<string> = loadFromStorage();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function loadFromStorage(): Set<string> {
|
||||
if (typeof localStorage === 'undefined') return new Set();
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return new Set();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return new Set();
|
||||
return new Set(parsed.filter((x): x is string => typeof x === 'string'));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function persist(): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...favorites]));
|
||||
} catch {
|
||||
// Storage full or disabled — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
for (const cb of listeners) cb();
|
||||
}
|
||||
|
||||
export function getFavorites(): Set<string> {
|
||||
return favorites;
|
||||
}
|
||||
|
||||
export function isFavorite(className: string): boolean {
|
||||
return favorites.has(className);
|
||||
}
|
||||
|
||||
export function toggleFavorite(className: string): void {
|
||||
const next = new Set(favorites);
|
||||
if (next.has(className)) next.delete(className);
|
||||
else next.add(className);
|
||||
favorites = next;
|
||||
persist();
|
||||
notify();
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void): () => void {
|
||||
listeners.add(cb);
|
||||
return () => {
|
||||
listeners.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
export function useFavorites(): Set<string> {
|
||||
return useSyncExternalStore(subscribe, getFavorites, getFavorites);
|
||||
}
|
||||
|
||||
export function useIsFavorite(className: string): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => favorites.has(className),
|
||||
() => favorites.has(className),
|
||||
);
|
||||
}
|
||||
@@ -792,6 +792,34 @@ html, body, #root {
|
||||
border-color: var(--node-help-btn-border-hover);
|
||||
}
|
||||
|
||||
.node-fav-btn {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
background: var(--node-help-btn-bg);
|
||||
border: 1px solid var(--node-help-btn-border);
|
||||
color: var(--node-help-btn-text);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.node-fav-btn:hover {
|
||||
background: var(--node-help-btn-bg-hover);
|
||||
border-color: var(--node-help-btn-border-hover);
|
||||
}
|
||||
|
||||
.node-fav-btn.is-favorited {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ── Node help panel ─────────────────────────────────────── */
|
||||
|
||||
.node-help-tabs {
|
||||
@@ -2484,6 +2512,9 @@ html, body, #root {
|
||||
.ctx-cat-active .ctx-cat-arrow {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ctx-cat-favorites .ctx-cat-label {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* ── Submenu panel (separate fixed-position sibling) ── */
|
||||
.ctx-submenu {
|
||||
|
||||
Reference in New Issue
Block a user