fix: switch highlight to global store instead of URL params

URL param timing was unreliable with SvelteKit client-side routing.
Now CrossLink calls requestHighlight(id) setting a global variable
before goto(), and highlightFromUrl() reads it after data loads.
Double requestAnimationFrame ensures DOM has rendered after loaded=true.
Falls back to ?highlight= URL param for direct links.
This commit is contained in:
2026-03-22 00:11:32 +03:00
parent f47df934ed
commit 88e21e41e2
2 changed files with 49 additions and 40 deletions
+6 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import MdiIcon from './MdiIcon.svelte';
import { requestHighlight } from '$lib/highlight';
let {
href,
@@ -16,20 +17,17 @@
title?: string;
} = $props();
const targetHref = $derived(
entityId != null
? `${href.split('?')[0]}?highlight=${entityId}${href.includes('?') ? '&' + href.split('?')[1] : ''}`
: href
);
function navigate(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
goto(targetHref);
if (entityId != null) {
requestHighlight(entityId);
}
goto(href);
}
</script>
<a href={targetHref} class="crosslink" title={title || label} onclick={navigate}>
<a {href} class="crosslink" title={title || label} onclick={navigate}>
<MdiIcon name={icon} size={12} />
<span>{label}</span>
</a>
+43 -32
View File
@@ -1,50 +1,63 @@
/**
* Card highlight system for cross-entity navigation.
*
* When navigating via CrossLink, the target entity ID is passed as ?highlight=<id>.
* The destination page calls highlightFromUrl() after data loads, which:
* 1. Shows a dim overlay behind everything
* 2. Finds the card with [data-entity-id="<id>"]
* 3. Scrolls to it smoothly
* 4. Applies a pulsing glow via direct style manipulation
* 5. Cleans up after 2.5 seconds
* CrossLink calls requestHighlight(id) before goto(), storing the ID globally.
* The destination page calls highlightFromUrl() after data loads, which
* picks up the pending ID and highlights the matching card.
*/
const HIGHLIGHT_DURATION = 2500;
const PULSE_INTERVAL = 400;
const WAIT_TIMEOUT = 5000;
/** Show dim overlay, find card, scroll & highlight. Call after data loads. */
/** Pending highlight ID — set by CrossLink before navigation. */
let _pendingHighlight: string | null = null;
/** Request a card highlight. Called by CrossLink before goto(). */
export function requestHighlight(id: string | number): void {
_pendingHighlight = String(id);
}
/** Check for pending highlight, find card, scroll & highlight. Call after data loads. */
export function highlightFromUrl(): void {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
const id = params.get('highlight');
// Check global pending first, then URL param as fallback
let id = _pendingHighlight;
_pendingHighlight = null;
if (!id) {
const params = new URLSearchParams(window.location.search);
id = params.get('highlight');
if (id) {
params.delete('highlight');
const qs = params.toString();
const cleanUrl = window.location.pathname + (qs ? '?' + qs : '');
window.history.replaceState(null, '', cleanUrl);
}
}
if (!id) return;
// Clean the URL immediately (remove highlight param, keep others)
params.delete('highlight');
const qs = params.toString();
const cleanUrl = window.location.pathname + (qs ? '?' + qs : '');
window.history.replaceState(null, '', cleanUrl);
// Try to find the card now, or wait for it
const card = document.querySelector(`[data-entity-id="${id}"]`);
if (card) {
// Small delay for layout to settle after loaded=true
setTimeout(() => _highlightCard(card as HTMLElement), 100);
} else {
_waitForCard(id);
}
// Wait a tick for DOM to render after loaded=true
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const card = document.querySelector(`[data-entity-id="${id}"]`);
if (card) {
_highlightCard(card as HTMLElement);
} else {
_waitForCard(id!);
}
});
});
}
function _highlightCard(card: HTMLElement): void {
// Show dim overlay
const overlay = _showDimOverlay();
// Scroll to card
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Save original styles
// Save originals
const origBoxShadow = card.style.boxShadow;
const origZIndex = card.style.zIndex;
const origPosition = card.style.position;
@@ -54,11 +67,11 @@ function _highlightCard(card: HTMLElement): void {
card.style.position = 'relative';
card.style.zIndex = '11';
// Get primary color from CSS variable
// Get primary color
const primary = getComputedStyle(document.documentElement)
.getPropertyValue('--color-primary').trim();
// Pulse effect via interval
// Pulsing glow
let on = true;
const glowOn = `0 0 0 3px ${primary}, 0 0 24px ${primary}60`;
const glowOff = `0 0 0 2px ${primary}80`;
@@ -74,7 +87,6 @@ function _highlightCard(card: HTMLElement): void {
// Cleanup
setTimeout(() => {
clearInterval(pulseTimer);
// Fade out: transition the box-shadow away
card.style.transition = 'box-shadow 0.3s ease, border-color 0.3s ease';
card.style.boxShadow = origBoxShadow;
card.style.borderColor = origBorderColor;
@@ -96,7 +108,6 @@ function _showDimOverlay(): HTMLElement {
overlay.className = 'nav-dim-overlay';
document.body.appendChild(overlay);
}
// Force reflow then activate
void overlay.offsetHeight;
overlay.classList.add('active');
return overlay;
@@ -109,7 +120,7 @@ function _waitForCard(id: string): void {
const card = document.querySelector(`[data-entity-id="${id}"]`);
if (card) {
observer.disconnect();
setTimeout(() => _highlightCard(card as HTMLElement), 100);
setTimeout(() => _highlightCard(card as HTMLElement), 50);
return;
}
if (Date.now() - start > WAIT_TIMEOUT) {