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
52 changes: 52 additions & 0 deletions css/palette-search.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@

input[id^="paletteSearch_"]:focus {
border-color: #96d3f3;
box-shadow: 0 0 0 2px rgba(150, 211, 243, 0.2);
}

input[id^="paletteSearch_"]::placeholder {
opacity: 0.6;
font-style: italic;
}

input[id^="paletteSearch_"] + button:hover {
opacity: 0.8;
transform: scale(1.1);
}

.palette-search-highlight {
background-color: rgba(150, 211, 243, 0.2) !important;
transition: background-color 0.2s ease;
}

#noResultsMessage td {
animation: fadeIn 0.3s ease;
}

@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

#PaletteBody_items tr {
transition: opacity 0.2s ease, max-height 0.2s ease;
}

#PaletteBody_items tr[style*="display: none"] {
opacity: 0;
max-height: 0;
overflow: hidden;
}

@media (max-width: 768px) {
input[id^="paletteSearch_"] {
font-size: 14px;
padding: 6px 10px;
}
}
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<link rel="preload" href="dist/css/windows.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<link rel="preload" href="lib/materialize-iso.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<link rel="preload" href="css/darkmode.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<link rel="preload" href="css/palette-search.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<link rel="prefetch" as="video" href="loading-animation.webm" type="video/webm">
<link rel="prefetch" as="video" href="loading-animation.mp4" type="video/mp4">
<link rel="prefetch" as="image" href="loading-animation-ja.svg">
Expand Down
139 changes: 139 additions & 0 deletions js/__tests__/palette-search.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@

describe("Palette Search Functionality", () => {
let palette;
let mockActivity;
let mockPalettes;

beforeEach(() => {
document.body.innerHTML = `
<div id="palette">
<div></div>
</div>
`;

mockActivity = {
cellSize: 55,
blocksContainer: { x: 0, y: 0 }
};

mockPalettes = {
activity: mockActivity,
cellSize: 27,
top: 75,
mobile: false
};

const Palette = require("../palette.js").Palette;
palette = new Palette(mockPalettes, "rhythm");
});

test("fuzzyMatch should return high score for exact matches", () => {
const score = palette._fuzzyMatch("note", "note");
expect(score).toBeGreaterThan(90);
});

test("fuzzyMatch should return positive score for fuzzy matches", () => {
const score = palette._fuzzyMatch("nt", "note");
expect(score).toBeGreaterThan(0);
});

test("fuzzyMatch should return -1 for no match", () => {
const score = palette._fuzzyMatch("xyz", "note");
expect(score).toBe(-1);
});

test("fuzzyMatch should be case insensitive", () => {
const score1 = palette._fuzzyMatch("NOTE", "note");
const score2 = palette._fuzzyMatch("note", "NOTE");
expect(score1).toBeGreaterThan(0);
expect(score2).toBeGreaterThan(0);
});

test("fuzzyMatch should handle empty pattern", () => {
const score = palette._fuzzyMatch("", "note");
expect(score).toBe(100);
});

test("fuzzyMatch should prefer substring matches", () => {
const exactScore = palette._fuzzyMatch("drum", "start drum");
const fuzzyScore = palette._fuzzyMatch("drm", "start drum");
expect(exactScore).toBeGreaterThan(fuzzyScore);
});

test("filterBlocks should show all blocks when query is empty", () => {
document.body.innerHTML = `
<table id="PaletteBody_items">
<tr>
<td data-block-name="note">Block 1</td>
</tr>
<tr>
<td data-block-name="drum">Block 2</td>
</tr>
</table>
`;

palette._filterBlocks("");

const rows = document.querySelectorAll("#PaletteBody_items tr");
expect(rows[0].style.display).not.toBe("none");
expect(rows[1].style.display).not.toBe("none");
});

test("filterBlocks should hide non-matching blocks", () => {
document.body.innerHTML = `
<table id="PaletteBody_items">
<tr>
<td data-block-name="note">Block 1</td>
</tr>
<tr>
<td data-block-name="drum">Block 2</td>
</tr>
</table>
`;

palette._filterBlocks("note");

const rows = document.querySelectorAll("#PaletteBody_items tr");
expect(rows[0].style.display).not.toBe("none");
expect(rows[1].style.display).toBe("none");
});

test("filterBlocks should show no results message when no matches found", () => {
document.body.innerHTML = `
<table id="PaletteBody_items">
<tr>
<td data-block-name="note">Block 1</td>
</tr>
</table>
`;

palette._filterBlocks("xyz");

const noResultsMsg = document.getElementById("noResultsMessage");
expect(noResultsMsg).toBeTruthy();
});

test("filterBlocks should remove no results message when matches are found", () => {
document.body.innerHTML = `
<table id="PaletteBody_items">
<tr>
<td data-block-name="note">Block 1</td>
</tr>
<tr id="noResultsMessage">
<td>No results</td>
</tr>
</table>
`;

palette._filterBlocks("note");

const noResultsMsg = document.getElementById("noResultsMessage");
expect(noResultsMsg).toBeFalsy();
});

test("fuzzyMatch should handle consecutive character matches", () => {
const consecutiveScore = palette._fuzzyMatch("drum", "start drum");
const nonConsecutiveScore = palette._fuzzyMatch("drm", "start drum");
expect(consecutiveScore).toBeGreaterThan(nonConsecutiveScore);
});
});
174 changes: 174 additions & 0 deletions js/palette.js
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,111 @@ class Palette {
this.showMenu(true);
}

_fuzzyMatch(pattern, text) {
if (!pattern) return 100;

pattern = pattern.toLowerCase();
text = text.toLowerCase();

if (text.includes(pattern)) {
return 100 - text.indexOf(pattern);
}

let patternIdx = 0;
let score = 0;
let consecutiveMatch = 0;

for (let i = 0; i < text.length && patternIdx < pattern.length; i++) {
if (text[i] === pattern[patternIdx]) {
score += 1 + consecutiveMatch;
consecutiveMatch++;
patternIdx++;
} else {
consecutiveMatch = 0;
}
}

return patternIdx === pattern.length ? score : -1;
}

/**
* Filter blocks based on search query
* @param {string} query - Search query
*/
_filterBlocks(query) {
const paletteBody = docById("PaletteBody_items");
if (!paletteBody) return;

const rows = paletteBody.getElementsByTagName("tr");
let visibleCount = 0;

for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const cells = row.getElementsByTagName("td");

if (cells.length === 0) continue;

let maxScore = -1;

for (let j = 0; j < cells.length; j++) {
const cell = cells[j];
const blockName = cell.getAttribute("data-block-name");

if (blockName) {
const score = this._fuzzyMatch(query, blockName);
maxScore = Math.max(maxScore, score);
}
}

if (maxScore >= 0) {
row.style.display = "";
visibleCount++;

for (let j = 0; j < cells.length; j++) {
const cell = cells[j];
if (query && maxScore > 0) {
cell.style.backgroundColor = platformColor.selectorBackground;
} else {
cell.style.backgroundColor = "";
}
}
} else {
row.style.display = "none";
}
}

this._updateNoResultsMessage(query, visibleCount);
}

/**
* Show/hide "no results" message
* @param {string} query - Search query
* @param {number} visibleCount - Number of visible blocks
*/
_updateNoResultsMessage(query, visibleCount) {
const paletteBody = docById("PaletteBody_items");
if (!paletteBody) return;

const existingMsg = docById("noResultsMessage");
if (existingMsg) {
existingMsg.remove();
}

if (query && visibleCount === 0) {
const noResultsRow = document.createElement("tr");
noResultsRow.id = "noResultsMessage";
const noResultsCell = document.createElement("td");
noResultsCell.colSpan = "100";
noResultsCell.style.textAlign = "center";
noResultsCell.style.padding = "20px";
noResultsCell.style.color = platformColor.textColor;
noResultsCell.style.fontStyle = "italic";
noResultsCell.innerHTML = `${_("No blocks found for")} "${query}"`;
noResultsRow.appendChild(noResultsCell);
paletteBody.appendChild(noResultsRow);
}
}

hideMenu() {
docById(
"palette"
Expand Down Expand Up @@ -928,6 +1033,68 @@ class Palette {
label.style.color = platformColor.textColor;
header.appendChild(label);

const searchContainer = document.createElement("div");
searchContainer.style.display = "flex";
searchContainer.style.alignItems = "center";
searchContainer.style.gap = "4px";
searchContainer.style.flex = "1";
searchContainer.style.marginLeft = "8px";
searchContainer.style.maxWidth = "200px";

const searchInput = document.createElement("input");
searchInput.type = "text";
searchInput.placeholder = _("Search blocks...");
searchInput.id = `paletteSearch_${this.name}`;
searchInput.style.flex = "1";
searchInput.style.padding = "4px 8px";
searchInput.style.border = `1px solid ${platformColor.selectorSelected}`;
searchInput.style.borderRadius = "4px";
searchInput.style.backgroundColor = platformColor.paletteBackground;
searchInput.style.color = platformColor.textColor;
searchInput.style.fontSize = "12px";
searchInput.style.outline = "none";

const paletteInstance = this;

let searchTimeout;
searchInput.addEventListener("input", function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
paletteInstance._filterBlocks(this.value);
}, 300);
});

searchInput.addEventListener("keydown", (e) => {
e.stopPropagation();
});

const clearBtn = document.createElement("button");
clearBtn.innerHTML = "βœ•";
clearBtn.title = _("Clear search");
clearBtn.style.padding = "4px 8px";
clearBtn.style.border = "none";
clearBtn.style.borderRadius = "4px";
clearBtn.style.backgroundColor = platformColor.selectorSelected;
clearBtn.style.color = platformColor.paletteBackground;
clearBtn.style.cursor = "pointer";
clearBtn.style.fontSize = "14px";
clearBtn.style.fontWeight = "bold";
clearBtn.style.display = "none";

clearBtn.onclick = () => {
searchInput.value = "";
paletteInstance._filterBlocks("");
clearBtn.style.display = "none";
};

searchInput.addEventListener("input", function() {
clearBtn.style.display = this.value ? "block" : "none";
});

searchContainer.appendChild(searchInput);
searchContainer.appendChild(clearBtn);
header.appendChild(searchContainer);

const closeDownImg = document.createElement("span");
closeDownImg.style.height = `${this.palettes.cellSize}px`;
const closeImg = makePaletteIcons(
Expand Down Expand Up @@ -1079,6 +1246,13 @@ class Palette {

itemCell.style.width = `${img.width}px`;
itemCell.style.paddingRight = `${this.palettes.cellSize}px`;

const blockLabel = b.staticLabels && b.staticLabels.length > 0
? b.staticLabels.join(" ")
: b.blkname;
itemCell.setAttribute("data-block-name", blockLabel);
itemCell.title = blockLabel;

itemCell.appendChild(img);
}

Expand Down
Loading