wrolpi/app/src/contexts/contexts.js
Roland e00f0439da
ui: the last of Semantic UI, in name and in dead CSS
The library has been uninstalled for some time, but the codebase still read as
though it were there: identifiers named after it, rules written against its
class names, and about 180 comments explaining a rule in terms of a thing that
is no longer installed.

Renamed, keeping the vocabulary the app actually uses: semanticUIColorMap ->
navColorHexMap, semanticColorNames -> paletteColorNames, semanticNames ->
iconNameAliases, semanticSizes -> sizeAliases, and SemanticUIColors ->
NavColors in wrolpi/schema.py, which is what validates nav_color on the way in.
ColorToSemanticHexColor had no callers at all and is gone.

The baseline ratchet has done its job -- the list reached zero, so the JSON is
deleted and the guard keeps only the checks with teeth: no package, no import,
no `className="ui "`, no toast library.  Its Theme.tsx check went with the
wrappers it was watching for.  It also asserts its own walk found files, so a
clean result cannot mean it scanned nothing.

Deleted from App.css: every rule requiring `.ui`, `.inverted`, `.segment` or
`.modal`, none of which any component has set since the stylesheet stopped
loading, plus the form skeletons, ui-alerts and progress-text rules that
nothing renders.  Two of them were doing harm rather than nothing: the drag
tints had a dark-theme variant under `.inverted` that stopped applying, leaving
15% of a fixed blue over a dark row, and now mix a token instead.

Two selectors naming markup that no longer exists were live bugs.  The file
browser's filter had `.ui.input.file-browser-filter`, so it rendered at the
browser default width on a bar laid out to give it 34em -- now qualified by its
container, because `.wrolpi-action-input` sets `width: 100%` at the same
specificity and is loaded later.  And SearchBox takes named props rather than a
rest spread, so the `id` DestinationForm hands it was dropped in silence and
the label it renders pointed at nothing; the download forms' Destination field
has had no associated label since the migration.

"Semantic roles" survives in four places, where it means what it says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:54 -06:00

293 lines
11 KiB
JavaScript

import React, {useCallback, useEffect, useRef, useState} from "react";
import {createMedia} from "@artsy/fresnel";
// Touch device detection hook (defined here to avoid circular import with customHooks)
export const useIsTouchDevice = () => {
const [isTouchDevice, setIsTouchDevice] = useState(false);
useEffect(() => {
// Check if matchMedia is available (not in jsdom test environment)
if (typeof window.matchMedia !== 'function') return;
const touchQuery = window.matchMedia('(pointer: coarse)');
// Guard against matchMedia returning null/undefined
if (!touchQuery) return;
setIsTouchDevice(touchQuery.matches);
const handleChange = (e) => setIsTouchDevice(e.matches);
touchQuery.addEventListener('change', handleChange);
return () => touchQuery.removeEventListener('change', handleChange);
}, []);
return isTouchDevice;
};
/** @type {React.Context<import('../types/theme').ThemeContextValue>} */
/*
* The default is the declared shape of this context, and test-fixtures.test.js holds the
* fixtures to it key for key. It used to carry `i`, `s`, `t` and `inverted` -- the old
* compatibility props holding hardcoded greys -- long after ThemeProvider stopped supplying
* them, so the context advertised four properties that were always undefined in a real tree.
* FileBrowser was still reading one of them.
*/
export const ThemeContext = React.createContext({
theme: 'light',
savedTheme: null,
isDark: false,
mediaFilter: undefined,
mediaFilterEnabled: false,
setMediaFilterEnabled: () => {},
setTheme: () => {},
setDarkTheme: () => {},
setLightTheme: () => {},
cycleSavedTheme: () => {},
});
export const StatusContext = React.createContext({
status: {},
fetchStatus: null,
});
export const SettingsContext = React.createContext({
settings: {},
fetchSettings: null,
saveSettings: null,
pending: false,
});
// `useQuery`
export const QueryContext = React.createContext({
searchParams: null,
setSearchParams: null,
updateQuery: null,
getLocationStr: null,
});
export const AppMedia = createMedia({
breakpoints: {
mobile: 0, tablet: 700, wideTablet: 880, computer: 1024,
}
});
export const mediaStyles = AppMedia.createMediaStyle();
export const {Media, MediaContextProvider} = AppMedia;
// Flatten browseFiles tree into ordered list of paths for drag selection
// Must match the sort order used by SortableTable (default: path alphabetically)
function flattenBrowseFiles(files, openFolders) {
const result = [];
const traverse = (items) => {
if (!items) return;
const itemList = Array.isArray(items) ? items : Object.values(items);
// Sort alphabetically by path (lowercase) to match SortableTable default sort
const sortedItems = [...itemList].sort((a, b) =>
a.path.toLowerCase().localeCompare(b.path.toLowerCase())
);
sortedItems.forEach(item => {
result.push(item.path);
if (item.children && openFolders && openFolders.includes(item.path)) {
traverse(item.children);
}
});
};
traverse(files);
return result;
}
// Drag Selection Context for FileBrowser
export const DragSelectionContext = React.createContext({
isDragging: false,
handleDragStart: () => {},
handleDragMove: () => {},
getDragState: () => ({isDragSelecting: false, isDragDeselecting: false}),
shouldAllowClick: () => true,
// Selection state and functions
selectedPaths: [],
onSelect: () => {},
isSelected: () => false,
clearSelection: () => {},
});
export function DragSelectionProvider({children, selectedPaths, setSelectedPaths, browseFiles, openFolders}) {
const DRAG_THRESHOLD = 5; // Pixels to move before considering it a drag
// Drag selection state
const isTouchDevice = useIsTouchDevice();
const [isDragging, setIsDragging] = useState(false);
const [isMouseDown, setIsMouseDown] = useState(false);
const [dragStartPath, setDragStartPath] = useState(null);
const [dragSelection, setDragSelection] = useState(new Set());
const [dragMode, setDragMode] = useState('select'); // 'select' or 'deselect'
const allRowPathsRef = useRef([]);
const dragStartPosRef = useRef({x: 0, y: 0});
// Track pending drag start - stored in refs to avoid visual changes before threshold
const pendingDragPathRef = useRef(null);
const pendingDragModeRef = useRef('select');
// Track if a drag just completed - used to prevent onClick from firing after drag
const dragJustCompletedRef = useRef(false);
// Update ordered list of visible row paths when files change
useEffect(() => {
if (browseFiles) {
allRowPathsRef.current = flattenBrowseFiles(browseFiles, openFolders);
}
}, [browseFiles, openFolders]);
// Drag selection handlers
const handleDragStart = useCallback((path, event) => {
// Only start drag on left mouse button, and not on touch devices
if (isTouchDevice || event.button !== 0) return;
// Don't start drag if clicking on checkbox or inside it
if (event.target.closest('input[type="checkbox"]') || event.target.closest('.ui.checkbox')) return;
// Determine mode: if starting from selected item, we're deselecting; otherwise selecting
const mode = selectedPaths.includes(path) ? 'deselect' : 'select';
// Store in refs - don't set dragSelection yet to avoid visual flap
// Visual changes only happen after drag threshold is exceeded
dragStartPosRef.current = {x: event.clientX, y: event.clientY};
pendingDragPathRef.current = path;
pendingDragModeRef.current = mode;
setIsMouseDown(true);
}, [isTouchDevice, selectedPaths]);
const handleDragMove = useCallback((path) => {
if (!dragStartPath) return;
// Check if we've moved enough to consider it a drag
if (!isDragging) return;
// Calculate all paths between dragStartPath and current path
const allPaths = allRowPathsRef.current;
const startIndex = allPaths.indexOf(dragStartPath);
const currentIndex = allPaths.indexOf(path);
if (startIndex === -1 || currentIndex === -1) return;
const minIndex = Math.min(startIndex, currentIndex);
const maxIndex = Math.max(startIndex, currentIndex);
// Update drag selection to include all paths in range
setDragSelection(new Set(allPaths.slice(minIndex, maxIndex + 1)));
}, [isDragging, dragStartPath]);
const handleDragEnd = useCallback(() => {
if (!isMouseDown) return;
const draggedPaths = Array.from(dragSelection);
if (draggedPaths.length > 0 && isDragging) {
let newSelectedPaths;
if (dragMode === 'deselect') {
// Remove dragged paths from selection
newSelectedPaths = selectedPaths.filter(p => !dragSelection.has(p));
} else {
// Add dragged paths to selection (use Set to avoid duplicates)
newSelectedPaths = [...new Set([...selectedPaths, ...draggedPaths])];
}
setSelectedPaths(newSelectedPaths);
// Mark that a drag just completed - onClick handlers should not fire
dragJustCompletedRef.current = true;
}
// Reset all drag state
setIsMouseDown(false);
setIsDragging(false);
setDragStartPath(null);
setDragSelection(new Set());
setDragMode('select');
pendingDragPathRef.current = null;
pendingDragModeRef.current = 'select';
}, [isMouseDown, isDragging, selectedPaths, dragSelection, dragMode, setSelectedPaths]);
// Global mouse event listeners for drag selection
useEffect(() => {
if (!isMouseDown) return;
const handleGlobalMouseMove = (e) => {
if (!isDragging && pendingDragPathRef.current) {
// Check if we've moved enough to start dragging
const dx = e.clientX - dragStartPosRef.current.x;
const dy = e.clientY - dragStartPosRef.current.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > DRAG_THRESHOLD) {
// NOW initialize drag state - this triggers visual feedback
setDragStartPath(pendingDragPathRef.current);
setDragSelection(new Set([pendingDragPathRef.current]));
setDragMode(pendingDragModeRef.current);
setIsDragging(true);
}
}
};
const handleGlobalMouseUp = () => handleDragEnd();
const handleSelectStart = (e) => {
if (isMouseDown) e.preventDefault();
};
document.addEventListener('mousemove', handleGlobalMouseMove);
document.addEventListener('mouseup', handleGlobalMouseUp);
document.addEventListener('selectstart', handleSelectStart);
return () => {
document.removeEventListener('mousemove', handleGlobalMouseMove);
document.removeEventListener('mouseup', handleGlobalMouseUp);
document.removeEventListener('selectstart', handleSelectStart);
};
}, [isMouseDown, isDragging, handleDragEnd, DRAG_THRESHOLD]);
// Helper function for components to compute their visual drag state
const getDragState = useCallback((path, isSelected) => {
const inDragRange = dragSelection.has(path);
const isDragSelecting = inDragRange && dragMode === 'select';
const isDragDeselecting = inDragRange && dragMode === 'deselect' && isSelected;
return {isDragSelecting, isDragDeselecting};
}, [dragSelection, dragMode]);
// Check if a click should be allowed (not blocked by a recent drag)
// This is called by onClick handlers to determine if they should proceed
const shouldAllowClick = useCallback(() => {
if (dragJustCompletedRef.current) {
dragJustCompletedRef.current = false;
return false;
}
return true;
}, []);
// Selection functions - moved from FileBrowser to eliminate prop drilling
const onSelect = useCallback((path) => {
setSelectedPaths(prev => {
if (prev.includes(path)) {
return prev.filter(i => i !== path);
}
return [...prev, path];
});
}, [setSelectedPaths]);
const isSelected = useCallback((path) => {
return selectedPaths.includes(path);
}, [selectedPaths]);
const clearSelection = useCallback(() => {
setSelectedPaths([]);
}, [setSelectedPaths]);
const value = {
isDragging,
handleDragStart,
handleDragMove,
getDragState,
shouldAllowClick,
// Selection state and functions
selectedPaths,
onSelect,
isSelected,
clearSelection,
};
return (
<DragSelectionContext.Provider value={value}>
{children}
</DragSelectionContext.Provider>
);
}