diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15c4605..35cd367 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,3 @@ -# .github/workflows/release.yml name: Release on: @@ -17,7 +16,6 @@ on: jobs: release-please: runs-on: ubuntu-latest - # Only run on merged PRs or manual dispatch if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true) outputs: releases_created: ${{ steps.manual_release.outputs.releases_created }} @@ -38,58 +36,65 @@ jobs: run: | npm install -g release-please npm install semver - - # Configure git + git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - - # Get current version from manifest + CURRENT_VERSION=$(cat .release-please-manifest.json | jq -r '.Website') echo "Current version: $CURRENT_VERSION" - - # Calculate next version based on release type using Node.js + NEXT_VERSION=$(node -e " const semver = require('semver'); const current = '$CURRENT_VERSION'; const type = '${{ github.event.inputs.release_type }}'; console.log(semver.inc(current, type)); ") - + echo "Next version will be: $NEXT_VERSION" - - # Update version in package.json + cd Website npm version $NEXT_VERSION --no-git-tag-version cd .. - - # Update manifest file + jq --arg version "$NEXT_VERSION" '.Website = $version' .release-please-manifest.json > temp.json && mv temp.json .release-please-manifest.json - - # Generate changelog entry + echo "## [$NEXT_VERSION] - $(date +'%Y-%m-%d')" > temp_changelog.md echo "" >> temp_changelog.md echo "### Changed" >> temp_changelog.md - echo "- Manual ${{ github.event.inputs.release_type }} release" >> temp_changelog.md + + LAST_TAG="website-v$CURRENT_VERSION" + git fetch --tags + if git rev-parse "$LAST_TAG" >/dev/null 2>&1; then + echo "- Changes since $CURRENT_VERSION:" >> temp_changelog.md + COMMITS=$(git log "$LAST_TAG"..HEAD --pretty=format:"- %s") + if [ -n "$COMMITS" ]; then + echo "$COMMITS" >> temp_changelog.md + else + echo "- No new commits since last version" >> temp_changelog.md + fi + else + echo "- Manual ${{ github.event.inputs.release_type }} release" >> temp_changelog.md + echo "- (No previous tag $LAST_TAG found to compare commits)" >> temp_changelog.md + fi echo "" >> temp_changelog.md - - # Prepend to existing changelog if it exists + if [ -f "Website/CHANGELOG.md" ]; then cat temp_changelog.md Website/CHANGELOG.md > temp_full_changelog.md mv temp_full_changelog.md Website/CHANGELOG.md else mv temp_changelog.md Website/CHANGELOG.md fi - - # Commit and push changes + git add . git commit -m "chore(release): release $NEXT_VERSION Release type: ${{ github.event.inputs.release_type }} Previous version: $CURRENT_VERSION New version: $NEXT_VERSION" - - git push origin HEAD - + + git tag -a "website-v$NEXT_VERSION" -m "Release $NEXT_VERSION" + git push origin HEAD --tags + echo "releases_created=true" >> $GITHUB_OUTPUT echo "version=$NEXT_VERSION" >> $GITHUB_OUTPUT diff --git a/Website/app/page.tsx b/Website/app/page.tsx index d83dcd8..dc292db 100644 --- a/Website/app/page.tsx +++ b/Website/app/page.tsx @@ -1,12 +1,15 @@ import { DatamodelView } from "@/components/datamodelview/DatamodelView"; import { TouchProvider } from "@/components/ui/hybridtooltop"; import { Loading } from "@/components/ui/loading"; +import { DatamodelDataProvider } from "@/contexts/DatamodelDataContext"; import { Suspense } from "react"; export default function Home() { return }> - + + + } diff --git a/Website/components/AppSidebar.tsx b/Website/components/AppSidebar.tsx index 91b0dc2..cafca4c 100644 --- a/Website/components/AppSidebar.tsx +++ b/Website/components/AppSidebar.tsx @@ -5,6 +5,7 @@ import { SidebarClose, SidebarOpen } from 'lucide-react' import { useIsMobile } from '@/hooks/use-mobile' import SidebarNavRail from './SidebarNavRail' import clsx from 'clsx' +import { useState } from 'react'; interface IAppSidebarProps {} @@ -12,11 +13,16 @@ export const AppSidebar = ({}: IAppSidebarProps) => { const { element, isOpen } = useSidebar() const dispatch = useSidebarDispatch() const isMobile = useIsMobile() + const [showElement, setShowElement] = useState(true) const toggleSidebar = () => { dispatch({ type: 'SET_OPEN', payload: !isOpen }) } + const toggleElement = () => { + setShowElement(v => !v) + } + return ( <> {/* Toggle Button (mobile only) */} @@ -32,31 +38,66 @@ export const AppSidebar = ({}: IAppSidebarProps) => { )} + {/* Overlay for mobile sidebar */} + {isMobile && isOpen && ( +
+ )} + {/* Sidebar */}
{/* Header */} -
+
{isMobile ? ( Logo ) : ( - Logo + showElement ? ( + Logo + ) : ( + Logo + ) )}
+ {/* Vertically centered sidebar toggle button (desktop only) */} + {!isMobile && ( + + )} + {/* Content */}
- {element} + {(isMobile || showElement) && element}
diff --git a/Website/components/attributes/StatusAttribute.tsx b/Website/components/attributes/StatusAttribute.tsx index 41974bb..4d05c28 100644 --- a/Website/components/attributes/StatusAttribute.tsx +++ b/Website/components/attributes/StatusAttribute.tsx @@ -1,31 +1,48 @@ -import { StatusAttributeType, StatusOption } from "@/lib/Types" -import { formatNumberSeperator } from "@/lib/utils" +import { StatusAttributeType, StatusOption } from "@/lib/Types"; +import { formatNumberSeperator } from "@/lib/utils"; +import { Circle } from "lucide-react"; export default function StatusAttribute({ attribute }: { attribute: StatusAttributeType }) { const groupedOptions = attribute.Options.reduce((acc, option) => { if (!acc[option.State]) { - acc[option.State] = [] + acc[option.State] = []; } - acc[option.State].push(option) - return acc - }, {} as Record) + acc[option.State].push(option); + return acc; + }, {} as Record); return ( -
- State/Status +
+
+ State/Status + {/* No DefaultValue for StatusAttributeType, so no default badge */} +
{Object.entries(groupedOptions).map(([state, options]) => (
{state} -
+
{options.map(option => ( -
- {option.Name} - {formatNumberSeperator(option.Value)} +
+
+
+
+ {/* No DefaultValue, so always show Circle icon */} + + {option.Name} +
+
+
+ + {formatNumberSeperator(option.Value)} + +
+
+ {/* No Description property */}
))}
))}
- ) + ); } \ No newline at end of file diff --git a/Website/components/datamodelview/Attributes.tsx b/Website/components/datamodelview/Attributes.tsx index 6e18c35..c89d934 100644 --- a/Website/components/datamodelview/Attributes.tsx +++ b/Website/components/datamodelview/Attributes.tsx @@ -4,9 +4,8 @@ import { EntityType, AttributeType } from "@/lib/Types" import { TableHeader, TableRow, TableHead, TableBody, TableCell, Table } from "../ui/table" import { Button } from "../ui/button" import { useState } from "react" -import { ArrowUpDown, ArrowUp, ArrowDown, Search, X, EyeOff, Eye } from "lucide-react" +import { ArrowUpDown, ArrowUp, ArrowDown, EyeOff, Eye } from "lucide-react" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./../ui/select" -import { Input } from "./../ui/input" import { AttributeDetails } from "./../entity/AttributeDetails" import BooleanAttribute from "./../attributes/BooleanAttribute" import ChoiceAttribute from "./../attributes/ChoiceAttribute" @@ -18,20 +17,22 @@ import IntegerAttribute from "./../attributes/IntegerAttribute" import LookupAttribute from "./../attributes/LookupAttribute" import StatusAttribute from "./../attributes/StatusAttribute" import StringAttribute from "./../attributes/StringAttribute" +import React from "react" +import { highlightMatch } from "../datamodelview/List"; type SortDirection = 'asc' | 'desc' | null type SortColumn = 'displayName' | 'schemaName' | 'type' | 'description' | null interface IAttributeProps { entity: EntityType + onVisibleCountChange?: (count: number) => void } -export const Attributes = ({ entity }: IAttributeProps) => { +export const Attributes = ({ entity, onVisibleCountChange, search = "" }: IAttributeProps & { search?: string }) => { const [sortColumn, setSortColumn] = useState("displayName") const [sortDirection, setSortDirection] = useState("asc") const [typeFilter, setTypeFilter] = useState("all") const [hideStandardFields, setHideStandardFields] = useState(true) - const [searchQuery, setSearchQuery] = useState("") const handleSort = (column: SortColumn) => { if (sortColumn === column) { @@ -56,14 +57,13 @@ export const Attributes = ({ entity }: IAttributeProps) => { filteredAttributes = filteredAttributes.filter(attr => attr.AttributeType === typeFilter) } - if (searchQuery) { - const query = searchQuery.toLowerCase() + // Filter by search prop (from parent) + if (search && search.length >= 3) { + const query = search.toLowerCase(); filteredAttributes = filteredAttributes.filter(attr => attr.DisplayName.toLowerCase().includes(query) || - attr.SchemaName.toLowerCase().includes(query) || - attr.AttributeType.toLowerCase().includes(query) || - (attr.Description?.toLowerCase().includes(query) ?? false) - ) + attr.SchemaName.toLowerCase().includes(query) + ); } if (hideStandardFields) filteredAttributes = filteredAttributes.filter(attr => attr.IsCustomAttribute || attr.IsStandardFieldModified); @@ -103,6 +103,15 @@ export const Attributes = ({ entity }: IAttributeProps) => { }) } + const sortedAttributes = getSortedAttributes(); + + // Notify parent of visible count + React.useEffect(() => { + if (onVisibleCountChange) { + onVisibleCountChange(sortedAttributes.length); + } + }, [onVisibleCountChange, sortedAttributes.length]); + const SortIcon = ({ column }: { column: SortColumn }) => { if (sortColumn !== column) return if (sortDirection === 'asc') return @@ -126,15 +135,7 @@ export const Attributes = ({ entity }: IAttributeProps) => { return <>
-
- - setSearchQuery(e.target.value)} - className="pl-6 h-8 text-xs md:pl-8 md:h-10 md:text-sm" - /> -
+ {/* Removed internal search input, now using parent search */} handleSearch(e.target.value)} className="pl-8 pr-8 h-8 text-xs" @@ -189,7 +211,7 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
{ - Groups.map((group) => + groups.map((group) => ) } diff --git a/Website/components/datamodelview/TimeSlicedSearch.tsx b/Website/components/datamodelview/TimeSlicedSearch.tsx new file mode 100644 index 0000000..3b195dd --- /dev/null +++ b/Website/components/datamodelview/TimeSlicedSearch.tsx @@ -0,0 +1,223 @@ +'use client' + +import React, { useState, useRef, useCallback, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { Input } from '../ui/input'; +import { Search, ChevronUp, ChevronDown } from 'lucide-react'; + +interface TimeSlicedSearchProps { + onSearch: (value: string) => void; + onLoadingChange: (loading: boolean) => void; + onNavigateNext?: () => void; + onNavigatePrevious?: () => void; + currentIndex?: number; + totalResults?: number; + placeholder?: string; +} + +// Time-sliced input that maintains 60fps regardless of background work +export const TimeSlicedSearch = ({ + onSearch, + onLoadingChange, + onNavigateNext, + onNavigatePrevious, + currentIndex = 0, + totalResults = 0, + placeholder = "Search attributes...", +}: TimeSlicedSearchProps) => { + const [localValue, setLocalValue] = useState(''); + const [isTyping, setIsTyping] = useState(false); + const [portalRoot, setPortalRoot] = useState(null); + + const searchTimeoutRef = useRef(); + const typingTimeoutRef = useRef(); + const frameRef = useRef(); + + // Time-sliced debouncing using requestAnimationFrame + const scheduleSearch = useCallback((value: string) => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + searchTimeoutRef.current = window.setTimeout(() => { + // Use MessageChannel for immediate callback without blocking main thread + const channel = new MessageChannel(); + channel.port2.onmessage = () => { + onSearch(value); + + // Reset typing state in next frame + frameRef.current = requestAnimationFrame(() => { + setIsTyping(false); + }); + }; + channel.port1.postMessage(null); + }, 350); + }, [onSearch]); + + const handleChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value; + + // Immediate visual update (highest priority) + setLocalValue(value); + + // Manage typing state + if (!isTyping) { + setIsTyping(true); + onLoadingChange(true); + } + + // Reset typing timeout + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + + // Schedule search + scheduleSearch(value); + + // Auto-reset typing state if user stops typing + typingTimeoutRef.current = window.setTimeout(() => { + setIsTyping(false); + }, 2000); + + }, [isTyping, onLoadingChange, scheduleSearch]); + + // Handle keyboard navigation + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + onNavigateNext?.(); + if ('vibrate' in navigator) { + navigator.vibrate(50); + } + } else if (e.key === 'Enter' && e.shiftKey) { + e.preventDefault(); + onNavigatePrevious?.(); + if ('vibrate' in navigator) { + navigator.vibrate(50); + } + } else if (e.key === 'ArrowDown' && e.ctrlKey) { + e.preventDefault(); + onNavigateNext?.(); + } else if (e.key === 'ArrowUp' && e.ctrlKey) { + e.preventDefault(); + onNavigatePrevious?.(); + } + }, [onNavigateNext, onNavigatePrevious]); + + const hasResults = totalResults > 0; + const showNavigation = hasResults && localValue.length >= 3; + + // Cleanup + useEffect(() => { + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + if (frameRef.current) { + cancelAnimationFrame(frameRef.current); + } + }; + }, []); + + // Portal setup + useEffect(() => { + let container = document.getElementById('time-sliced-search-portal'); + if (!container) { + container = document.createElement('div'); + container.id = 'time-sliced-search-portal'; + container.style.position = 'fixed'; + container.style.top = '0'; + container.style.left = '0'; + container.style.width = '100%'; + container.style.height = '100%'; + container.style.pointerEvents = 'none'; + container.style.zIndex = '9999'; + document.body.appendChild(container); + } + + const searchContainer = document.createElement('div'); + searchContainer.style.pointerEvents = 'auto'; + container.appendChild(searchContainer); + setPortalRoot(searchContainer); + + return () => { + if (searchContainer && container?.contains(searchContainer)) { + container.removeChild(searchContainer); + } + }; + }, []); + + const searchInput = ( +
+ {/* Search Input Container */} +
+
+ + + {isTyping && ( +
+
+
+ )} +
+ + {/* Navigation Buttons */} + {showNavigation && ( +
+ + + +
+ )} +
+ + {/* Results Counter */} + {showNavigation && ( +
+ + {totalResults > 0 ? ( + `${currentIndex} of ${totalResults} sections` + ) : ( + 'No results' + )} + +
+ Enter next section • + Shift+Enter prev section • + Ctrl+↑↓ navigate +
+
+ )} +
+ ); + + return portalRoot ? createPortal(searchInput, portalRoot) : null; +}; diff --git a/Website/components/datamodelview/dataLoaderWorker.js b/Website/components/datamodelview/dataLoaderWorker.js new file mode 100644 index 0000000..df2f774 --- /dev/null +++ b/Website/components/datamodelview/dataLoaderWorker.js @@ -0,0 +1,5 @@ +import { Groups } from '../../generated/Data'; + +self.onmessage = function() { + self.postMessage(Groups); +}; \ No newline at end of file diff --git a/Website/components/datamodelview/searchWorker.js b/Website/components/datamodelview/searchWorker.js new file mode 100644 index 0000000..2ac5573 --- /dev/null +++ b/Website/components/datamodelview/searchWorker.js @@ -0,0 +1,65 @@ +let groups = null; +const CHUNK_SIZE = 20; // Process results in chunks + +self.onmessage = async function(e) { + if (e.data && e.data.type === 'init') { + groups = e.data.groups; + return; + } + + const search = (e.data || '').trim().toLowerCase(); + if (!groups) { + self.postMessage({ type: 'results', data: [], complete: true }); + return; + } + + // First quickly send back a "started" message + self.postMessage({ type: 'started' }); + + const allItems = []; + + // Find all matches + for (const group of groups) { + const filteredEntities = group.Entities.filter(entity => { + if (!search) return true; + const entityMatch = entity.SchemaName.toLowerCase().includes(search) || + (entity.DisplayName && entity.DisplayName.toLowerCase().includes(search)); + const attrMatch = entity.Attributes.some(attr => + attr.SchemaName.toLowerCase().includes(search) || + (attr.DisplayName && attr.DisplayName.toLowerCase().includes(search)) + ); + return entityMatch || attrMatch; + }); + + if (filteredEntities.length > 0) { + allItems.push({ type: 'group', group }); + for (const entity of filteredEntities) { + allItems.push({ type: 'entity', group, entity }); + } + } + } + + // Send results in chunks to prevent UI blocking + for (let i = 0; i < allItems.length; i += CHUNK_SIZE) { + const chunk = allItems.slice(i, i + CHUNK_SIZE); + const isLastChunk = i + CHUNK_SIZE >= allItems.length; + + self.postMessage({ + type: 'results', + data: chunk, + complete: isLastChunk, + progress: Math.min(100, Math.round((i + CHUNK_SIZE) / allItems.length * 100)) + }); + + // Small delay between chunks to let the UI breathe + if (!isLastChunk) { + // Use a proper yielding mechanism to let the UI breathe + await sleep(5); + } + } + + // Helper function to pause execution for a specified duration + function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } +}; \ No newline at end of file diff --git a/Website/contexts/DatamodelDataContext.tsx b/Website/contexts/DatamodelDataContext.tsx new file mode 100644 index 0000000..708f8e8 --- /dev/null +++ b/Website/contexts/DatamodelDataContext.tsx @@ -0,0 +1,59 @@ +'use client' + +import React, { createContext, useContext, useReducer, ReactNode } from "react"; +import { GroupType } from "@/lib/Types"; + +interface DatamodelDataState { + groups: GroupType[]; + search: string; + filtered: any[]; +} + +const initialState: DatamodelDataState = { + groups: [], + search: "", + filtered: [] +}; + +const DatamodelDataContext = createContext(initialState); +const DatamodelDataDispatchContext = createContext>(() => {}); + +const datamodelDataReducer = (state: DatamodelDataState, action: any): DatamodelDataState => { + switch (action.type) { + case "SET_GROUPS": + return { ...state, groups: action.payload }; + case "SET_SEARCH": + return { ...state, search: action.payload }; + case "SET_FILTERED": + return { ...state, filtered: action.payload }; + case "APPEND_FILTERED": + return { ...state, filtered: [...state.filtered, ...action.payload] }; + default: + return state; + } +}; + +export const DatamodelDataProvider = ({ children }: { children: ReactNode }) => { + const [state, dispatch] = useReducer(datamodelDataReducer, initialState); + + React.useEffect(() => { + const worker = new Worker(new URL("../components/datamodelview/dataLoaderWorker.js", import.meta.url)); + worker.onmessage = (e) => { + dispatch({ type: "SET_GROUPS", payload: e.data }); + worker.terminate(); + }; + worker.postMessage({}); + return () => worker.terminate(); + }, []); + + return ( + + + {children} + + + ); +}; + +export const useDatamodelData = () => useContext(DatamodelDataContext); +export const useDatamodelDataDispatch = () => useContext(DatamodelDataDispatchContext); \ No newline at end of file diff --git a/Website/contexts/DatamodelViewContext.tsx b/Website/contexts/DatamodelViewContext.tsx index a67c9de..3d9b756 100644 --- a/Website/contexts/DatamodelViewContext.tsx +++ b/Website/contexts/DatamodelViewContext.tsx @@ -8,18 +8,21 @@ export interface DatamodelViewState { currentGroup: string | null; currentSection: string | null; scrollToSection: (sectionId: string) => void; + loading: boolean; } const initialState: DatamodelViewState = { currentGroup: null, currentSection: null, scrollToSection: () => { throw new Error("scrollToSection not initialized yet!"); }, + loading: true, } type DatamodelViewAction = | { type: 'SET_CURRENT_GROUP', payload: string | null } | { type: 'SET_CURRENT_SECTION', payload: string | null } | { type: 'SET_SCROLL_TO_SECTION', payload: (sectionId: string) => void } + | { type: 'SET_LOADING', payload: boolean } const datamodelViewReducer = (state: DatamodelViewState, action: DatamodelViewAction): DatamodelViewState => { @@ -30,6 +33,8 @@ const datamodelViewReducer = (state: DatamodelViewState, action: DatamodelViewAc return { ...state, currentSection: action.payload } case 'SET_SCROLL_TO_SECTION': return { ...state, scrollToSection: action.payload } + case 'SET_LOADING': + return { ...state, loading: action.payload } default: return state; } diff --git a/Website/contexts/SearchPerformanceContext.tsx b/Website/contexts/SearchPerformanceContext.tsx new file mode 100644 index 0000000..4825503 --- /dev/null +++ b/Website/contexts/SearchPerformanceContext.tsx @@ -0,0 +1,74 @@ +'use client' + +import React, { createContext, useContext, useRef, useCallback, ReactNode } from 'react'; + +interface SearchPerformanceContextType { + scheduleImmediateUpdate: (callback: () => void) => void; + scheduleBackgroundUpdate: (callback: () => void) => void; + cancelScheduledUpdate: (id: number) => void; +} + +const SearchPerformanceContext = createContext(null); + +export const SearchPerformanceProvider = ({ children }: { children: ReactNode }) => { + const immediateUpdatesRef = useRef void>>(new Set()); + const backgroundUpdatesRef = useRef void>>(new Set()); + + const immediateUpdateMap = useRef>(new Map()); + const scheduleImmediateUpdate = useCallback((callback: () => void): number => { + const id = Date.now() + Math.random(); // Generate a unique id + const channel = new MessageChannel(); + channel.port2.onmessage = () => { + callback(); + immediateUpdateMap.current.delete(id); // Clean up after execution + }; + immediateUpdateMap.current.set(id, channel); + channel.port1.postMessage(null); + return id; + }, []); + + const scheduleBackgroundUpdate = useCallback((callback: () => void): number => { + if ('requestIdleCallback' in window) { + const id = (window as any).requestIdleCallback(callback, { timeout: 1000 }); + return id; + } else { + const id = setTimeout(callback, 0); + return id as unknown as number; // Ensure consistent type + } + }, []); + + const cancelScheduledUpdate = useCallback((id: number) => { + if (immediateUpdateMap.current.has(id)) { + const channel = immediateUpdateMap.current.get(id); + if (channel) { + channel.port1.close(); + channel.port2.close(); + } + immediateUpdateMap.current.delete(id); + } else if ('cancelIdleCallback' in window) { + (window as any).cancelIdleCallback(id); + } else { + clearTimeout(id); + } + }, []); + + const value = { + scheduleImmediateUpdate, + scheduleBackgroundUpdate, + cancelScheduledUpdate + }; + + return ( + + {children} + + ); +}; + +export const useSearchPerformance = () => { + const context = useContext(SearchPerformanceContext); + if (!context) { + throw new Error('useSearchPerformance must be used within SearchPerformanceProvider'); + } + return context; +}; diff --git a/Website/lib/utils.ts b/Website/lib/utils.ts index 7493c66..16a0c90 100644 --- a/Website/lib/utils.ts +++ b/Website/lib/utils.ts @@ -10,4 +10,55 @@ export function formatNumberSeperator(value: number): string { const parts = value.toString().split('.'); parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.'); return parts.join(','); +} + +/** + * Creates a debounced function that delays invoking func until after wait milliseconds + */ +export function debounce unknown>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: ReturnType | null = null; + + return function(...args: Parameters): void { + const later = () => { + timeout = null; + func(...args); + }; + + if (timeout) clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + +/** + * Creates a throttled function that only invokes func at most once per every wait milliseconds + */ +export function throttle unknown>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: ReturnType | null = null; + let previous = 0; + + return function(...args: Parameters): void { + const now = Date.now(); + const remaining = wait - (now - previous); + + if (remaining <= 0 || remaining > wait) { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + previous = now; + func(...args); + } else if (!timeout) { + timeout = setTimeout(() => { + previous = Date.now(); + timeout = null; + func(...args); + }, remaining); + } + }; } \ No newline at end of file