implement Web Worker-based search to eliminate input lag
All checks were successful
CI / update (push) Successful in 1m13s
All checks were successful
CI / update (push) Successful in 1m13s
Replace synchronous DOM manipulation with Web Worker + Svelte reactive state for recipe search. This moves text normalization and filtering off the main thread, ensuring zero input lag while typing. Search now runs in parallel with UI rendering, improving performance significantly for 240+ recipes. - Add search.worker.js for background search processing - Update Search.svelte to use Web Worker with $state runes - Update +page.svelte with reactive filtering based on worker results - Add language-aware recipe data synchronization for proper English/German search - Migrate to Svelte 5 event handlers (onsubmit, onclick)
This commit is contained in:
@@ -1,10 +1,19 @@
|
|||||||
<script>
|
<script>
|
||||||
import {onMount} from "svelte";
|
import {onMount, onDestroy} from "svelte";
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import "$lib/css/nordtheme.css";
|
import "$lib/css/nordtheme.css";
|
||||||
|
|
||||||
// Filter props for different contexts
|
// Filter props for different contexts
|
||||||
let { category = null, tag = null, icon = null, season = null, favoritesOnly = false, lang = 'de' } = $props();
|
let {
|
||||||
|
category = null,
|
||||||
|
tag = null,
|
||||||
|
icon = null,
|
||||||
|
season = null,
|
||||||
|
favoritesOnly = false,
|
||||||
|
lang = 'de',
|
||||||
|
recipes = [],
|
||||||
|
onSearchResults = (matchedIds, matchedCategories) => {}
|
||||||
|
} = $props();
|
||||||
|
|
||||||
const isEnglish = $derived(lang === 'en');
|
const isEnglish = $derived(lang === 'en');
|
||||||
const searchResultsUrl = $derived(isEnglish ? '/recipes/search' : '/rezepte/search');
|
const searchResultsUrl = $derived(isEnglish ? '/recipes/search' : '/rezepte/search');
|
||||||
@@ -15,7 +24,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
|
let worker = $state(null);
|
||||||
|
let isWorkerReady = $state(false);
|
||||||
|
|
||||||
// Build search URL with current filters
|
// Build search URL with current filters
|
||||||
function buildSearchUrl(query) {
|
function buildSearchUrl(query) {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
@@ -45,85 +56,98 @@
|
|||||||
|
|
||||||
function clearSearch() {
|
function clearSearch() {
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
if (browser) {
|
// Trigger search with empty query to show all results
|
||||||
// Reset any client-side filtering if present
|
if (worker && isWorkerReady) {
|
||||||
const recipes = document.querySelectorAll(".search_me");
|
worker.postMessage({
|
||||||
recipes.forEach(recipe => {
|
type: 'search',
|
||||||
recipe.style.display = 'flex';
|
data: { query: '' }
|
||||||
recipe.classList.remove("matched-recipe");
|
|
||||||
});
|
|
||||||
document.querySelectorAll(".media_scroller_wrapper").forEach( scroller => {
|
|
||||||
scroller.style.display= 'block'
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Effect to update worker data when recipes change (e.g., language switch)
|
||||||
|
$effect(() => {
|
||||||
|
if (worker && isWorkerReady && browser && recipes.length > 0) {
|
||||||
|
worker.postMessage({
|
||||||
|
type: 'init',
|
||||||
|
data: { recipes }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Effect to trigger search when query changes
|
||||||
|
$effect(() => {
|
||||||
|
if (worker && isWorkerReady && browser) {
|
||||||
|
worker.postMessage({
|
||||||
|
type: 'search',
|
||||||
|
data: { query: searchQuery }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Swap buttons for JS-enabled experience
|
// Swap buttons for JS-enabled experience
|
||||||
const submitButton = document.getElementById('submit-search');
|
const submitButton = document.getElementById('submit-search');
|
||||||
const clearButton = document.getElementById('clear-search');
|
const clearButton = document.getElementById('clear-search');
|
||||||
|
|
||||||
if (submitButton && clearButton) {
|
if (submitButton && clearButton) {
|
||||||
submitButton.style.display = 'none';
|
submitButton.style.display = 'none';
|
||||||
clearButton.style.display = 'flex';
|
clearButton.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get initial search value from URL if present
|
// Get initial search value from URL if present
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const urlQuery = urlParams.get('q');
|
const urlQuery = urlParams.get('q');
|
||||||
if (urlQuery) {
|
if (urlQuery) {
|
||||||
searchQuery = urlQuery;
|
searchQuery = urlQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced client-side filtering (existing functionality)
|
|
||||||
const recipes = document.querySelectorAll(".search_me");
|
|
||||||
const search = document.getElementById("search");
|
|
||||||
|
|
||||||
if (recipes.length > 0 && search) {
|
|
||||||
function do_search(click_only_result=false){
|
|
||||||
const searchText = search.value.toLowerCase().trim().normalize('NFD').replace(/\p{Diacritic}/gu, "");
|
|
||||||
const searchTerms = searchText.split(" ");
|
|
||||||
const hasFilter = searchText.length > 0;
|
|
||||||
|
|
||||||
let scrollers_with_results = [];
|
// Initialize Web Worker for search
|
||||||
let scrollers = [];
|
if (recipes.length > 0) {
|
||||||
|
worker = new Worker(
|
||||||
recipes.forEach(recipe => {
|
new URL('./search.worker.js', import.meta.url),
|
||||||
const searchString = `${recipe.textContent} ${recipe.dataset.tags}`.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, "").replace(/­|/g, '');
|
{ type: 'module' }
|
||||||
const isMatch = searchTerms.every(term => searchString.includes(term));
|
);
|
||||||
|
|
||||||
recipe.style.display = (isMatch ? 'flex' : 'none');
|
// Handle messages from worker
|
||||||
recipe.classList.toggle("matched-recipe", hasFilter && isMatch);
|
worker.onmessage = (e) => {
|
||||||
if(!scrollers.includes(recipe.parentNode)){
|
const { type, matchedIds, matchedCategories } = e.data;
|
||||||
scrollers.push(recipe.parentNode)
|
|
||||||
|
if (type === 'ready') {
|
||||||
|
isWorkerReady = true;
|
||||||
|
// Perform initial search if URL had query
|
||||||
|
if (urlQuery) {
|
||||||
|
worker.postMessage({
|
||||||
|
type: 'search',
|
||||||
|
data: { query: urlQuery }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Show all recipes initially
|
||||||
|
worker.postMessage({
|
||||||
|
type: 'search',
|
||||||
|
data: { query: '' }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if(!scrollers_with_results.includes(recipe.parentNode) && isMatch){
|
|
||||||
scrollers_with_results.push(recipe.parentNode)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
scrollers_with_results.forEach( scroller => {
|
|
||||||
scroller.parentNode.style.display= 'block'
|
|
||||||
})
|
|
||||||
scrollers.filter(item => !scrollers_with_results.includes(item)).forEach( scroller => {
|
|
||||||
scroller.parentNode.style.display= 'none'
|
|
||||||
})
|
|
||||||
|
|
||||||
let items = document.querySelectorAll(".matched-recipe");
|
|
||||||
items = [...new Set(items)]
|
|
||||||
if(click_only_result && scrollers_with_results.length == 1 && items.length == 1){
|
|
||||||
items[0].click();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
search.addEventListener("input", () => {
|
if (type === 'results') {
|
||||||
searchQuery = search.value;
|
// Pass results to parent component
|
||||||
do_search();
|
onSearchResults(new Set(matchedIds), matchedCategories);
|
||||||
})
|
}
|
||||||
|
};
|
||||||
// Initial search if URL had query
|
|
||||||
if (urlQuery) {
|
// Initialize worker with recipe data
|
||||||
do_search(true);
|
worker.postMessage({
|
||||||
}
|
type: 'init',
|
||||||
|
data: { recipes }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
// Clean up worker
|
||||||
|
if (worker) {
|
||||||
|
worker.terminate();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,7 +210,7 @@ scale: 0.8 0.8;
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<form class="search" method="get" action={buildSearchUrl('')} on:submit|preventDefault={handleSubmit}>
|
<form class="search" method="get" action={buildSearchUrl('')} onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
|
||||||
{#if category}<input type="hidden" name="category" value={category} />{/if}
|
{#if category}<input type="hidden" name="category" value={category} />{/if}
|
||||||
{#if tag}<input type="hidden" name="tag" value={tag} />{/if}
|
{#if tag}<input type="hidden" name="tag" value={tag} />{/if}
|
||||||
{#if icon}<input type="hidden" name="icon" value={icon} />{/if}
|
{#if icon}<input type="hidden" name="icon" value={icon} />{/if}
|
||||||
@@ -201,7 +225,7 @@ scale: 0.8 0.8;
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Clear button (hidden by default, shown when JS loads) -->
|
<!-- Clear button (hidden by default, shown when JS loads) -->
|
||||||
<button type="button" id="clear-search" class="search-button js-only" style="display: none;" on:click={clearSearch}>
|
<button type="button" id="clear-search" class="search-button js-only" style="display: none;" onclick={clearSearch}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><title>{labels.clearTitle}</title><path d="M135.19 390.14a28.79 28.79 0 0021.68 9.86h246.26A29 29 0 00432 371.13V140.87A29 29 0 00403.13 112H156.87a28.84 28.84 0 00-21.67 9.84v0L46.33 256l88.86 134.11z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32"></path><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M336.67 192.33L206.66 322.34M336.67 322.34L206.66 192.33M336.67 192.33L206.66 322.34M336.67 322.34L206.66 192.33"></path></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><title>{labels.clearTitle}</title><path d="M135.19 390.14a28.79 28.79 0 0021.68 9.86h246.26A29 29 0 00432 371.13V140.87A29 29 0 00403.13 112H156.87a28.84 28.84 0 00-21.67 9.84v0L46.33 256l88.86 134.11z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32"></path><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M336.67 192.33L206.66 322.34M336.67 322.34L206.66 192.33M336.67 192.33L206.66 322.34M336.67 322.34L206.66 192.33"></path></svg>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
60
src/lib/components/search.worker.js
Normal file
60
src/lib/components/search.worker.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Web Worker for recipe search
|
||||||
|
* Handles text normalization and filtering off the main thread
|
||||||
|
*/
|
||||||
|
|
||||||
|
let recipes = [];
|
||||||
|
|
||||||
|
self.onmessage = (e) => {
|
||||||
|
const { type, data } = e.data;
|
||||||
|
|
||||||
|
if (type === 'init') {
|
||||||
|
// Initialize worker with recipe data
|
||||||
|
recipes = data.recipes || [];
|
||||||
|
self.postMessage({ type: 'ready' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'search') {
|
||||||
|
const query = data.query;
|
||||||
|
|
||||||
|
// Empty query = show all recipes
|
||||||
|
if (!query || query.trim().length === 0) {
|
||||||
|
self.postMessage({
|
||||||
|
type: 'results',
|
||||||
|
matchedIds: recipes.map(r => r._id),
|
||||||
|
matchedCategories: new Set(recipes.map(r => r.category))
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize and split search query
|
||||||
|
const searchText = query.toLowerCase().trim()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/\p{Diacritic}/gu, "");
|
||||||
|
const searchTerms = searchText.split(" ").filter(term => term.length > 0);
|
||||||
|
|
||||||
|
// Filter recipes
|
||||||
|
const matched = recipes.filter(recipe => {
|
||||||
|
// Build searchable string from recipe data
|
||||||
|
const searchString = [
|
||||||
|
recipe.name || '',
|
||||||
|
recipe.description || '',
|
||||||
|
...(recipe.tags || [])
|
||||||
|
].join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/\p{Diacritic}/gu, "")
|
||||||
|
.replace(/­|/g, ''); // Remove soft hyphens
|
||||||
|
|
||||||
|
// All search terms must match
|
||||||
|
return searchTerms.every(term => searchString.includes(term));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return matched recipe IDs and categories with results
|
||||||
|
self.postMessage({
|
||||||
|
type: 'results',
|
||||||
|
matchedIds: matched.map(r => r._id),
|
||||||
|
matchedCategories: new Set(matched.map(r => r.category))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -5,7 +5,19 @@
|
|||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Search from '$lib/components/Search.svelte';
|
import Search from '$lib/components/Search.svelte';
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
let current_month = new Date().getMonth() + 1
|
let current_month = new Date().getMonth() + 1;
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
let matchedRecipeIds = $state(new Set());
|
||||||
|
let matchedCategories = $state(new Set());
|
||||||
|
let hasActiveSearch = $state(false);
|
||||||
|
|
||||||
|
// Handle search results from Search component
|
||||||
|
function handleSearchResults(ids, categories) {
|
||||||
|
matchedRecipeIds = ids;
|
||||||
|
matchedCategories = categories || new Set();
|
||||||
|
hasActiveSearch = ids.size < data.all_brief.length;
|
||||||
|
}
|
||||||
|
|
||||||
const isEnglish = $derived(data.lang === 'en');
|
const isEnglish = $derived(data.lang === 'en');
|
||||||
const categories = $derived(isEnglish
|
const categories = $derived(isEnglish
|
||||||
@@ -49,20 +61,33 @@ h1{
|
|||||||
<h1>{labels.title}</h1>
|
<h1>{labels.title}</h1>
|
||||||
<p class=subheading>{labels.subheading}</p>
|
<p class=subheading>{labels.subheading}</p>
|
||||||
|
|
||||||
<Search lang={data.lang}></Search>
|
<Search lang={data.lang} recipes={data.all_brief} onSearchResults={handleSearchResults}></Search>
|
||||||
|
|
||||||
<MediaScroller title={labels.inSeason}>
|
{#if true}
|
||||||
{#each data.season as recipe}
|
{@const seasonRecipes = data.season.filter(recipe =>
|
||||||
<Card {recipe} {current_month} loading_strat={"eager"} do_margin_right={true} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
|
!hasActiveSearch || matchedRecipeIds.has(recipe._id)
|
||||||
{/each}
|
)}
|
||||||
</MediaScroller>
|
{#if seasonRecipes.length > 0}
|
||||||
|
<MediaScroller title={labels.inSeason}>
|
||||||
|
{#each seasonRecipes as recipe}
|
||||||
|
<Card {recipe} {current_month} loading_strat={"eager"} do_margin_right={true} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
|
||||||
|
{/each}
|
||||||
|
</MediaScroller>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#each categories as category}
|
{#each categories as category}
|
||||||
<MediaScroller title={category}>
|
{@const categoryRecipes = data.all_brief.filter(recipe =>
|
||||||
{#each data.all_brief.filter(recipe => recipe.category == category) as recipe}
|
recipe.category === category &&
|
||||||
<Card {recipe} {current_month} do_margin_right={true} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
|
(!hasActiveSearch || matchedRecipeIds.has(recipe._id))
|
||||||
{/each}
|
)}
|
||||||
</MediaScroller>
|
{#if categoryRecipes.length > 0}
|
||||||
|
<MediaScroller title={category}>
|
||||||
|
{#each categoryRecipes as recipe}
|
||||||
|
<Card {recipe} {current_month} do_margin_right={true} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
|
||||||
|
{/each}
|
||||||
|
</MediaScroller>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{#if !isEnglish}
|
{#if !isEnglish}
|
||||||
<AddButton href="/rezepte/add"></AddButton>
|
<AddButton href="/rezepte/add"></AddButton>
|
||||||
|
|||||||
Reference in New Issue
Block a user