Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
291 changes: 234 additions & 57 deletions packages/webamp/js/components/MilkdropWindow/PresetOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useMemo } from "react";
import { promptForFileReferences } from "../../fileUtils";
import * as Selectors from "../../selectors";
import * as Actions from "../../actionCreators";
Expand Down Expand Up @@ -42,12 +42,70 @@ interface Props {
width: number;
}

function presetIndexFromListIndex(listIndex: number) {
return listIndex - 1;
export interface PresetItem {
type: "LOCAL_DIR" | "PRESET";
presetIndex: number;
label: string;
}

function listIndexFromPresetIndex(listIndex: number) {
return listIndex + 1;
export function getFilteredItems(
presetKeys: string[] | null,
searchQuery: string
): PresetItem[] {
if (presetKeys == null) {
return [];
}
const query = searchQuery.trim().toLowerCase();
if (!query) {
const items: PresetItem[] = [
{ type: "LOCAL_DIR", presetIndex: -1, label: "Load Local Directory" },
];
for (let i = 0; i < presetKeys.length; i++) {
items.push({ type: "PRESET", presetIndex: i, label: presetKeys[i] });
}
return items;
}

const items: PresetItem[] = [];
for (let i = 0; i < presetKeys.length; i++) {
if (presetKeys[i].toLowerCase().includes(query)) {
items.push({ type: "PRESET", presetIndex: i, label: presetKeys[i] });
}
}
return items;
}

export function highlightMatch(text: string, query: string): React.ReactNode {
const trimmed = query.trim();
if (!trimmed) {
return text;
}
const lowerText = text.toLowerCase();
const lowerQuery = trimmed.toLowerCase();
const index = lowerText.indexOf(lowerQuery);
if (index === -1) {
return text;
}

const before = text.slice(0, index);
const match = text.slice(index, index + trimmed.length);
const after = text.slice(index + trimmed.length);

return (
<>
{before}
<span
style={{
textDecoration: "underline",
fontWeight: "bold",
color: "#FFFFFF",
}}
>
{match}
</span>
{after}
</>
);
}

function PresetOverlay({ height, width }: Props) {
Expand All @@ -58,47 +116,20 @@ function PresetOverlay({ height, width }: Props) {
const appendPresetFileList = useActionCreator(Actions.appendPresetFileList);

const unmountedRef = useUnmountedRef();
const [searchQuery, setSearchQuery] = useState("");

const filteredItems = useMemo(
() => getFilteredItems(presetKeys, searchQuery),
[presetKeys, searchQuery]
);

const [selectedListIndex, setSelectedListIndex] = useState(() => {
if (currentPresetIndex != null) {
return listIndexFromPresetIndex(currentPresetIndex);
return currentPresetIndex + 1;
}
return 0;
});

// Number of presets, plus one for the "Load Local Directory" option, minus
// one to convert a length to an index.
const maxListIndex = presetKeys.length; // - 1 + 1;

const renderList = useCallback(() => {
const maxVisibleRows = Math.floor((height - HEIGHT_PADDING) / ENTRY_HEIGHT);
const rowsToShow = Math.floor(maxVisibleRows * 0.75); // Only fill 3/4 of the screen.
const [startIndex, endIndex] = getRangeCenteredOnIndex(
maxListIndex + 1, // Add one to convert an index to a length
rowsToShow,
selectedListIndex
);

const presetElms = [];
for (let i = startIndex; i <= endIndex; i++) {
const presetIndex = presetIndexFromListIndex(i);
const isSelected = i === selectedListIndex;
const isCurrent = presetIndex === currentPresetIndex;
let color: string;
if (isSelected) {
color = isCurrent ? "#FFCC22" : "#FF5050";
} else {
color = isCurrent ? "#CCFF03" : "#CCCCCC";
}
presetElms.push(
<li key={i} style={{ color, lineHeight: `${ENTRY_HEIGHT}px` }}>
{i === 0 ? "Load Local Directory" : presetKeys[presetIndex]}
</li>
);
}

return presetElms;
}, [currentPresetIndex, height, maxListIndex, presetKeys, selectedListIndex]);

const loadLocalDir = useCallback(async () => {
const fileReferences = await promptForFileReferences({ directory: true });
if (unmountedRef.current) {
Expand All @@ -107,44 +138,162 @@ function PresetOverlay({ height, width }: Props) {
appendPresetFileList(fileReferences);
}, [appendPresetFileList, unmountedRef]);

const selectItem = useCallback(
(item: PresetItem) => {
if (item.type === "LOCAL_DIR") {
loadLocalDir();
} else {
requestPresetAtIndex(item.presetIndex, TransitionType.DEFAULT, true);
}
},
[loadLocalDir, requestPresetAtIndex]
);

const handleFocusedKeyboardInput = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const maxIndex = Math.max(0, filteredItems.length - 1);

switch (e.keyCode) {
case 38: // up arrow
setSelectedListIndex((value) => Math.max(value - 1, 0));
e.stopPropagation();
break;
e.preventDefault();
return;
case 40: // down arrow
setSelectedListIndex((value) => Math.min(value + 1, maxListIndex));
setSelectedListIndex((value) => Math.min(value + 1, maxIndex));
e.stopPropagation();
e.preventDefault();
return;
case 33: // page up
setSelectedListIndex((value) => Math.max(value - 10, 0));
e.stopPropagation();
e.preventDefault();
return;
case 34: // page down
setSelectedListIndex((value) => Math.min(value + 10, maxIndex));
e.stopPropagation();
break;
e.preventDefault();
return;
case 13: // enter
if (selectedListIndex === 0) {
loadLocalDir();
} else {
requestPresetAtIndex(
presetIndexFromListIndex(selectedListIndex),
TransitionType.DEFAULT,
true
);
if (filteredItems.length > 0 && selectedListIndex <= maxIndex) {
selectItem(filteredItems[selectedListIndex]);
}
e.stopPropagation();
break;
e.preventDefault();
return;
case 27: // escape
togglePresetOverlay();
if (searchQuery.length > 0) {
setSearchQuery("");
setSelectedListIndex(0);
} else {
togglePresetOverlay();
}
e.stopPropagation();
break;
e.preventDefault();
return;
case 8: // backspace
if (searchQuery.length > 0) {
setSearchQuery((prev) => prev.slice(0, -1));
setSelectedListIndex(0);
e.stopPropagation();
e.preventDefault();
}
return;
}

// Printable character typeahead filtering
if (
e.key.length === 1 &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
!(e.key === " " && searchQuery.length === 0)
) {
setSearchQuery((prev) => prev + e.key);
setSelectedListIndex(0);
e.stopPropagation();
e.preventDefault();
}
},
[
loadLocalDir,
maxListIndex,
requestPresetAtIndex,
filteredItems,
searchQuery,
selectedListIndex,
selectItem,
togglePresetOverlay,
]
);

const renderList = useCallback(() => {
if (filteredItems.length === 0) {
return (
<li
style={{
color: "#888888",
fontStyle: "italic",
lineHeight: `${ENTRY_HEIGHT}px`,
padding: "2px 4px",
}}
>
No matching presets
</li>
);
}

const maxVisibleRows = Math.floor((height - HEIGHT_PADDING) / ENTRY_HEIGHT);
const searchHeaderOffset = searchQuery.trim() ? 2 : 0;
const rowsToShow = Math.max(
1,
Math.floor(maxVisibleRows * 0.75) - searchHeaderOffset
);

const [startIndex, endIndex] = getRangeCenteredOnIndex(
filteredItems.length,
rowsToShow,
selectedListIndex
);

const presetElms = [];
for (let i = startIndex; i <= endIndex; i++) {
const item = filteredItems[i];
const isSelected = i === selectedListIndex;
const isCurrent =
item.type === "PRESET" && item.presetIndex === currentPresetIndex;

let color: string;
if (isSelected) {
color = isCurrent ? "#FFCC22" : "#FF5050";
} else {
color = isCurrent ? "#CCFF03" : "#CCCCCC";
}

presetElms.push(
<li
key={`${item.type}-${item.presetIndex}-${i}`}
onClick={() => selectItem(item)}
style={{
color,
lineHeight: `${ENTRY_HEIGHT}px`,
cursor: "pointer",
}}
>
{item.type === "LOCAL_DIR"
? item.label
: highlightMatch(item.label, searchQuery)}
</li>
);
}

return presetElms;
}, [
currentPresetIndex,
filteredItems,
height,
searchQuery,
selectedListIndex,
selectItem,
]);

const handleNode = useCallback((node: HTMLDivElement | null) => {
if (node != null && document.activeElement !== node) {
node.focus();
Expand All @@ -158,6 +307,7 @@ function PresetOverlay({ height, width }: Props) {
</div>
);
}

return (
<div
ref={handleNode}
Expand All @@ -172,6 +322,29 @@ function PresetOverlay({ height, width }: Props) {
maxHeight: height - HEIGHT_PADDING,
}}
>
{searchQuery.trim().length > 0 && (
<div
style={{
padding: "2px 6px",
borderBottom: "1px solid #444444",
background: "rgba(20, 20, 20, 0.9)",
color: "#FFCC22",
fontSize: "11px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>
Search: <span style={{ color: "#FFFFFF" }}>{searchQuery}</span>
<span style={{ opacity: 0.7 }}>_</span>
</span>
<span style={{ color: "#888888", fontSize: "10px" }}>
{filteredItems.length} match
{filteredItems.length === 1 ? "" : "es"}
</span>
</div>
)}
<ul style={{ listStyleType: "none", padding: 0, margin: 0 }}>
{renderList()}
</ul>
Expand All @@ -188,11 +361,15 @@ export function getRangeCenteredOnIndex(
maxRangeSize: number,
indexToCenter: number
): [number, number] {
if (length <= 0) {
return [0, -1];
}
const rangeSize = Math.min(length, maxRangeSize);
const halfRangeSize = Math.floor(rangeSize / 2);
const idealStartIndex = indexToCenter - halfRangeSize;
const startIndex = clamp(idealStartIndex, 0, length - rangeSize);
const endIndex = startIndex + rangeSize - 1;
return [startIndex, endIndex];
}

export default PresetOverlay;
Loading