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 { socketSpecAcceptsType } from './constants';
|
||||||
import { outputTypeCanConnectToTarget } from './connectionUtils';
|
import { outputTypeCanConnectToTarget } from './connectionUtils';
|
||||||
import { compareMenuNodes, compareMenuCategories } from './canvasEvents';
|
import { compareMenuNodes, compareMenuCategories } from './canvasEvents';
|
||||||
|
import { useFavorites } from './favorites';
|
||||||
|
|
||||||
|
const FAVORITES_CATEGORY = 'favorites';
|
||||||
|
|
||||||
export default function ContextMenu({
|
export default function ContextMenu({
|
||||||
x,
|
x,
|
||||||
@@ -26,6 +29,7 @@ export default function ContextMenu({
|
|||||||
selectedNodeCount?: number;
|
selectedNodeCount?: number;
|
||||||
onCreateGroup?: (() => void) | null;
|
onCreateGroup?: (() => void) | null;
|
||||||
}) {
|
}) {
|
||||||
|
const favorites = useFavorites();
|
||||||
const [openCat, setOpenCat] = useState<string | null>(null);
|
const [openCat, setOpenCat] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
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) => ({
|
.map((category: any) => ({
|
||||||
...category,
|
...category,
|
||||||
items: [...category.items].sort(compareMenuNodes),
|
items: [...category.items].sort(compareMenuNodes),
|
||||||
}))
|
}))
|
||||||
.sort(compareMenuCategories);
|
.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
|
// Flat filtered list for search
|
||||||
const searchResults = useMemo(() => {
|
const searchResults = useMemo(() => {
|
||||||
@@ -262,10 +284,12 @@ export default function ContextMenu({
|
|||||||
<div
|
<div
|
||||||
key={cat}
|
key={cat}
|
||||||
ref={(el) => { catRowRefs.current[cat] = el; }}
|
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)}
|
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>
|
<span className="ctx-cat-arrow">▶</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { getGroupMinimumSize } from './groupSizing';
|
import { getGroupMinimumSize } from './groupSizing';
|
||||||
import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout';
|
import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout';
|
||||||
import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns, parseNumberWithUnit, formatSI, parseSI } from './valueFormatting';
|
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';
|
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 }) {
|
function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||||
const ctx = useContext(NodeContext);
|
const ctx = useContext(NodeContext);
|
||||||
const def = data.definition;
|
const def = data.definition;
|
||||||
|
const favorited = useIsFavorite(data.className);
|
||||||
const scalarDisplay = formatScalarDisplay(data.scalarValue);
|
const scalarDisplay = formatScalarDisplay(data.scalarValue);
|
||||||
const processingTimeText = formatProcessingTime(data.processingTimeMs);
|
const processingTimeText = formatProcessingTime(data.processingTimeMs);
|
||||||
const nodeWidth = useStore(
|
const nodeWidth = useStore(
|
||||||
@@ -1244,6 +1246,14 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
|||||||
<div className="node-title-left">
|
<div className="node-title-left">
|
||||||
<span className="node-title-main">{data.label}</span>
|
<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-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>
|
</div>
|
||||||
{headerMeta && <span className="node-title-meta" title={headerMeta}>{headerMeta}</span>}
|
{headerMeta && <span className="node-title-meta" title={headerMeta}>{headerMeta}</span>}
|
||||||
</div>
|
</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);
|
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 panel ─────────────────────────────────────── */
|
||||||
|
|
||||||
.node-help-tabs {
|
.node-help-tabs {
|
||||||
@@ -2484,6 +2512,9 @@ html, body, #root {
|
|||||||
.ctx-cat-active .ctx-cat-arrow {
|
.ctx-cat-active .ctx-cat-arrow {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
.ctx-cat-favorites .ctx-cat-label {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Submenu panel (separate fixed-position sibling) ── */
|
/* ── Submenu panel (separate fixed-position sibling) ── */
|
||||||
.ctx-submenu {
|
.ctx-submenu {
|
||||||
|
|||||||
Reference in New Issue
Block a user