Kurs JavaScript i TypeScript · Moduł 8: Zdarzenia i DOM

Wydarzenia myszy i klawiatury

12 min czytania
W tej lekcji5

"W systemach bezpieczeństwa Parku Jurajskiego," Ray Arnold pokazuje wielki panel kontrolny, "mamy różne typy sensorów. Czujniki ruchu reagują na ruch, czujniki ciśnienia na dotyk, czujniki dźwięku na hałas. Każdy typ wymaga innej reakcji. W JavaScript mamy podobną sytuację - różne typy wejścia wymagają różnej obsługi."

Ray wskazuje na monitor i klawiaturę. "Mysz i klawiatura to dwa podstawowe źródła interakcji użytkownika. Każde ma swoje unikalne wydarzenia i zachowania."

Wydarzenia myszy - pełny zakres interakcji

Mysz generuje kilkanaście różnych typów wydarzeń, od prostych kliknięć po zaawansowane gesty przeciągania. Jedno kliknięcie to trzy sygnały: mousedown, mouseup i dopiero na końcu click.

Podstawowe wydarzenia kliknięć

Obszar obserwacyjny rozróżnia pojedyncze i podwójne kliknięcie.

1const dinosaurViewingArea = document.getElementById('viewing-area');
2
3// click - pojedyncze kliknięcie (najpopularniejsze)
4dinosaurViewingArea.addEventListener('click', (event) => {
5  console.log('Pojedyncze kliknięcie w obszar obserwacyjny');
6  console.log('Pozycja:', event.clientX, event.clientY);
7
8  selectDinosaurAtPosition(event.clientX, event.clientY);
9});
10
11// dblclick - podwójne kliknięcie
12dinosaurViewingArea.addEventListener('dblclick', (event) => {
13  console.log('Podwójne kliknięcie - zbliżenie');
14
15  // Uwaga: to NIE blokuje zaznaczania tekstu, które startuje już przy mousedown
16  event.preventDefault();
17
18  zoomInOnDinosaur(event.clientX, event.clientY);
19});

Pola clientX i clientY to pozycja kursora w oknie. Uwaga: preventDefault() w dblclick nie zablokuje zaznaczania tekstu, bo startuje ono już przy mousedown. Do tego służy reguła CSS user-select: none.

1// auxclick - kliknięcie środkowym/innym przyciskiem (nie lewy)
2dinosaurViewingArea.addEventListener('auxclick', (event) => {
3  if (event.button === 1) {
4    console.log('Środkowy przycisk - otwieranie w nowym oknie');
5    openInNewWindow(event.target);
6  }
7});
8
9// contextmenu - prawy przycisk myszy (menu kontekstowe)
10dinosaurViewingArea.addEventListener('contextmenu', (event) => {
11  event.preventDefault(); // Zablokuj domyślne menu
12
13  console.log('Prawy przycisk - własne menu kontekstowe');
14  showCustomContextMenu(event.clientX, event.clientY);
15});
16
17function selectDinosaurAtPosition(x, y) {
18  console.log(`Sprawdzanie czy dinozaur znajduje się na pozycji (${x}, ${y})`);
19  // Logika selekcji
20}
21
22function zoomInOnDinosaur(x, y) {
23  console.log(`Zbliżenie na pozycję (${x}, ${y})`);
24  // Logika zoom
25}
26
27function showCustomContextMenu(x, y) {
28  const menu = document.getElementById('custom-menu');
29  menu.style.left = x + 'px';
30  menu.style.top = y + 'px';
31  menu.style.display = 'block';
32
33  console.log('Menu kontekstowe wyświetlone');
34}

Wartość event.button równa 1 to środkowy przycisk, a preventDefault() przy contextmenu chowa menu przeglądarki, robiąc miejsce dla własnego.

Wydarzenia przycisków myszy

Między wciśnięciem a puszczeniem przycisku trwa przeciąganie, więc zapamiętujemy punkt startu.

1const controlPanel = document.getElementById('control-panel');
2
3// mousedown - naciśnięcie przycisku myszy (przed puszczeniem)
4controlPanel.addEventListener('mousedown', (event) => {
5  console.log('Przycisk myszy wciśnięty');
6  console.log('Który przycisk:', event.button); // 0=lewy, 1=środek, 2=prawy
7
8  if (event.button === 0) {
9    console.log('Lewy przycisk - rozpoczynam przeciąganie');
10    startDragging(event);
11  }
12});
13
14// mouseup - puszczenie przycisku myszy
15controlPanel.addEventListener('mouseup', (event) => {
16  console.log('Przycisk myszy puszczony');
17
18  if (isDragging) {
19    console.log('Kończę przeciąganie');
20    stopDragging(event);
21  }
22});
23
24let isDragging = false;
25let dragStartX = 0;
26let dragStartY = 0;
27
28function startDragging(event) {
29  isDragging = true;
30  dragStartX = event.clientX;
31  dragStartY = event.clientY;
32
33  console.log(`Rozpoczęcie przeciągania z pozycji (${dragStartX}, ${dragStartY})`);
34}
35
36function stopDragging(event) {
37  const deltaX = event.clientX - dragStartX;
38  const deltaY = event.clientY - dragStartY;
39
40  console.log(`Przeciągnięto o: X=${deltaX}px, Y=${deltaY}px`);
41
42  isDragging = false;
43}

Flaga isDragging łączy oba wydarzenia w jedną historię, a różnica współrzędnych mówi, jak daleko przesunięto kursor.

Wydarzenia ruchu myszy

Ruch kursora nad mapą parku śledzi mousemove, czujnik odzywający się przy każdym drgnięciu myszy.

1const parkMap = document.getElementById('interactive-park-map');
2
3// mousemove - ruch myszy (UWAGA: bardzo częste!)
4// Używaj z rozwagą - może generować setki eventów na sekundę
5
6let mouseMoveCount = 0;
7
8parkMap.addEventListener('mousemove', (event) => {
9  mouseMoveCount++;
10
11  // Aktualizuj pozycję kursora
12  updateCursorPosition(event.clientX, event.clientY);
13
14  // Jeśli przeciągamy, aktualizuj pozycję
15  if (isDragging) {
16    updateDragPosition(event.clientX, event.clientY);
17  }
18
19  // Log co 10-te wydarzenie (żeby nie zaśmiecać konsoli)
20  if (mouseMoveCount % 10 === 0) {
21    console.log(`Ruch myszy #${mouseMoveCount}: (${event.clientX}, ${event.clientY})`);
22  }
23});
24
25function updateCursorPosition(x, y) {
26  const coordsDisplay = document.getElementById('mouse-coords');
27  if (coordsDisplay) {
28    coordsDisplay.textContent = `X: ${x}, Y: ${y}`;
29  }
30}
31
32function updateDragPosition(x, y) {
33  const draggableElement = document.getElementById('draggable');
34  if (draggableElement) {
35    draggableElement.style.left = x + 'px';
36    draggableElement.style.top = y + 'px';
37  }
38}

Dlatego log trafia do konsoli co dziesiąte wydarzenie, a lekcja o throttlingu pokaże, jak ograniczać takie lawiny. Teraz wejście i wyjście kursora.

1// mouseenter - mysz wchodzi w obszar elementu (nie bąbelkuje!)
2parkMap.addEventListener('mouseenter', (event) => {
3  console.log('Mysz weszła na mapę parku');
4  parkMap.classList.add('mouse-active');
5  showMapControls();
6});
7
8// mouseleave - mysz opuszcza obszar elementu (nie bąbelkuje!)
9parkMap.addEventListener('mouseleave', (event) => {
10  console.log('Mysz opuściła mapę parku');
11  parkMap.classList.remove('mouse-active');
12  hideMapControls();
13});
14
15// mouseover - mysz wchodzi (bąbelkuje - uruchamia się też dla dzieci!)
16parkMap.addEventListener('mouseover', (event) => {
17  if (event.target.classList.contains('enclosure-marker')) {
18    const enclosureName = event.target.dataset.name;
19    console.log(`Najechano na marker zagrody: ${enclosureName}`);
20    showEnclosureTooltip(event.target, enclosureName);
21  }
22});
23
24// mouseout - mysz wychodzi (bąbelkuje)
25parkMap.addEventListener('mouseout', (event) => {
26  if (event.target.classList.contains('enclosure-marker')) {
27    console.log('Opuszczono marker zagrody');
28    hideEnclosureTooltip();
29  }
30});
31
32function showMapControls() {
33  const controls = document.getElementById('map-controls');
34  if (controls) {
35    controls.style.display = 'block';
36  }
37}
38
39function hideMapControls() {
40  const controls = document.getElementById('map-controls');
41  if (controls) {
42    controls.style.display = 'none';
43  }
44}

Para mouseenter i mouseleave nie bąbelkuje, więc reaguje tylko na samą mapę, a mouseover i mouseout bąbelkują z każdego dziecka, stąd sprawdzanie event.target.

Praktyczny przykład - system przeciągania

"Jedną z najbardziej użytecznych funkcji," mówi Ray, "jest drag & drop." Pierwsza część klasy zaczyna przeciąganie.

1class DinosaurCardDragSystem {
2  constructor() {
3    this.draggedElement = null;
4    this.dragOffsetX = 0;
5    this.dragOffsetY = 0;
6    this.initialize();
7  }
8
9  initialize() {
10    const cards = document.querySelectorAll('.dinosaur-card');
11
12    cards.forEach(card => {
13      card.addEventListener('mousedown', (e) => this.handleDragStart(e));
14    });
15
16    document.addEventListener('mousemove', (e) => this.handleDragMove(e));
17    document.addEventListener('mouseup', (e) => this.handleDragEnd(e));
18
19    console.log('System drag & drop zainicjalizowany');
20  }
21
22  handleDragStart(event) {
23    this.draggedElement = event.currentTarget;
24
25    const rect = this.draggedElement.getBoundingClientRect();
26    this.dragOffsetX = event.clientX - rect.left;
27    this.dragOffsetY = event.clientY - rect.top;
28
29    this.draggedElement.style.position = 'fixed';
30    this.draggedElement.style.zIndex = '1000';
31    this.draggedElement.classList.add('dragging');
32
33    const dinoName = this.draggedElement.dataset.dinoName;
34    console.log(`Rozpoczęto przeciąganie karty: ${dinoName}`);
35
36    event.preventDefault(); // Prevent text selection
37  }

Listenery mousemove i mouseup wiszą na dokumencie, bo szybki ruch wyprowadzi kursor poza kartę. Tu preventDefault() w mousedown naprawdę blokuje zaznaczanie tekstu, a getBoundingClientRect() pozwala wyliczyć punkt chwytu.

1  handleDragMove(event) {
2    if (!this.draggedElement) return;
3
4    const x = event.clientX - this.dragOffsetX;
5    const y = event.clientY - this.dragOffsetY;
6
7    this.draggedElement.style.left = x + 'px';
8    this.draggedElement.style.top = y + 'px';
9  }
10
11  handleDragEnd(event) {
12    if (!this.draggedElement) return;
13
14    const dinoName = this.draggedElement.dataset.dinoName;
15    console.log(`Zakończono przeciąganie karty: ${dinoName}`);
16
17    // Sprawdź czy karta została upuszczona w odpowiedniej strefie
18    const dropZone = this.findDropZone(event.clientX, event.clientY);
19
20    if (dropZone) {
21      console.log('Karta upuszczona w strefie:', dropZone.id);
22      this.handleDrop(this.draggedElement, dropZone);
23    } else {
24      console.log('Karta upuszczona poza strefą - powrót do pozycji początkowej');
25      this.returnToOriginalPosition();
26    }
27
28    this.draggedElement.classList.remove('dragging');
29    this.draggedElement = null;
30  }

Karta podąża za kursorem, a po puszczeniu przycisku klasa szuka strefy, w której wylądowała.

1  findDropZone(x, y) {
2    const dropZones = document.querySelectorAll('.drop-zone');
3
4    for (const zone of dropZones) {
5      const rect = zone.getBoundingClientRect();
6
7      if (x >= rect.left && x <= rect.right &&
8          y >= rect.top && y <= rect.bottom) {
9        return zone;
10      }
11    }
12
13    return null;
14  }
15
16  handleDrop(card, dropZone) {
17    console.log('Dodawanie karty do strefy drop');
18    dropZone.appendChild(card);
19
20    // Reset stylów
21    card.style.position = '';
22    card.style.left = '';
23    card.style.top = '';
24    card.style.zIndex = '';
25  }
26
27  returnToOriginalPosition() {
28    // Powrót na miejsce - bez animacji, bo zmiany position nie da się animować.
29    // Bez setTimeout: handleDragEnd zaraz potem ustawia this.draggedElement na null
30    this.draggedElement.style.position = '';
31    this.draggedElement.style.left = '';
32    this.draggedElement.style.top = '';
33    this.draggedElement.style.zIndex = '';
34  }
35}
36
37// Inicjalizacja
38const dragSystem = new DinosaurCardDragSystem();

Karta upuszczona w strefie przenosi się do nowej zagrody, a poza nią wraca na miejsce.

Przeciąganie wbudowane w przeglądarkę

Przeglądarka ma też własne API Drag and Drop. Element z atrybutem draggable="true" wysyła dragstart, a strefa docelowa dostaje dragover i drop.

1// HTML: <div class="dino-item" draggable="true">T-Rex</div>
2const dino = document.querySelector('.dino-item');
3const zone = document.getElementById('zone-c');
4
5dino.addEventListener('dragstart', (e) => {
6  e.dataTransfer.setData('text/plain', dino.textContent);
7  dino.classList.add('dragging');
8});
9dino.addEventListener('dragend', () => dino.classList.remove('dragging'));
10
11zone.addEventListener('dragover', (e) => {
12  e.preventDefault(); // bez tego strefa nie przyjmie upuszczenia
13});
14
15zone.addEventListener('drop', (e) => {
16  e.preventDefault(); // blokuje domyślną akcję, np. otwarcie linku
17  zone.appendChild(dino);
18});

Kluczowy jest preventDefault() w dragover, bo bez niego upuszczenie jest zabronione, a dragend sprząta po każdym przeciąganiu.

Wydarzenia klawiatury - kontrola z klawiatury

"Klawiatura," Ray wskazuje na panel kontrolny, "to drugi kluczowy sposób interakcji. Skróty klawiszowe, nawigacja, wprowadzanie danych."

Podstawowe wydarzenia klawiatury

Każde naciśnięcie zostawia w obiekcie wydarzenia pełny raport.

1// keydown - klawisz naciśnięty (powtarza się gdy trzymany)
2document.addEventListener('keydown', (event) => {
3  console.log('=== KEYDOWN ===');
4  console.log('Klawisz:', event.key); // 'a', 'Enter', 'ArrowUp', etc.
5  console.log('Kod:', event.code); // 'KeyA', 'Enter', 'ArrowUp', etc.
6  console.log('KeyCode (deprecated):', event.keyCode);
7  console.log('Czy to powtórzenie:', event.repeat);
8  console.log('Ctrl:', event.ctrlKey);
9  console.log('Shift:', event.shiftKey);
10  console.log('Alt:', event.altKey);
11  console.log('Meta (Cmd/Win):', event.metaKey);
12});
13
14// keyup - klawisz puszczony
15document.addEventListener('keyup', (event) => {
16  console.log(`Puszczono klawisz: ${event.key}`);
17});
18
19// keypress - DEPRECATED! Nie używaj (użyj keydown zamiast tego)
20// keypress jest przestarzały i nie odpala np. dla strzałek ani Escape

event.key to znak lub nazwa klawisza, a event.code to fizyczny klawisz, niezależny od układu klawiatury. Trzymany klawisz wysyła kolejne keydown z event.repeat równym true, a keyup przychodzi raz.

Praktyczne skróty klawiszowe

Skróty obsługuje jeden listener na dokumencie. Najpierw zapis, wyszukiwanie i Escape.

1// System skrótów klawiszowych dla parku
2document.addEventListener('keydown', (event) => {
3  // Ctrl/Cmd + S - Zapisz
4  if ((event.ctrlKey || event.metaKey) && event.key === 's') {
5    event.preventDefault();
6    console.log('Zapisywanie konfiguracji parku...');
7    saveParkConfiguration();
8    return;
9  }
10
11  // Ctrl/Cmd + F - Szukaj
12  if ((event.ctrlKey || event.metaKey) && event.key === 'f') {
13    event.preventDefault();
14    console.log('Otwieranie wyszukiwarki dinozaurów');
15    openSearchDialog();
16    return;
17  }
18
19  // Escape - Zamknij wszystkie okna
20  if (event.key === 'Escape') {
21    console.log('Zamykanie wszystkich okien dialogowych');
22    closeAllModals();
23    return;
24  }

Sprawdzamy ctrlKey albo metaKey, żeby skrót działał też z Cmd na Macu, a preventDefault() zatrzymuje zapis strony przez przeglądarkę. Dalej strzałki, spacja, F1 i cyfry.

1  // Strzałki - Nawigacja po mapie
2  const arrowKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
3  if (arrowKeys.includes(event.key)) {
4    event.preventDefault();
5
6    const direction = {
7      'ArrowUp': 'north',
8      'ArrowDown': 'south',
9      'ArrowLeft': 'west',
10      'ArrowRight': 'east'
11    }[event.key];
12
13    console.log(`Przesunięcie mapy w kierunku: ${direction}`);
14    moveMap(direction);
15    return;
16  }
17
18  // Spacja - Pauza/Wznów monitoring
19  if (event.key === ' ') {
20    event.preventDefault();
21    console.log('Toggle pauzy monitoringu');
22    toggleMonitoringPause();
23    return;
24  }
25
26  // F1 - Pomoc
27  if (event.key === 'F1') {
28    event.preventDefault();
29    console.log('Otwieranie pomocy');
30    openHelp();
31    return;
32  }
33
34  // Cyfry 1-9 - Szybki dostęp do zagród
35  if (event.key >= '1' && event.key <= '9') {
36    const enclosureNumber = parseInt(event.key);
37    console.log(`Przełączanie na zagrodę #${enclosureNumber}`);
38    jumpToEnclosure(enclosureNumber);
39    return;
40  }
41});

Spacja ma nazwę ' ', a mapa kierunków zamienia klawisz na stronę świata. Dalej proste funkcje wykonawcze.

1function saveParkConfiguration() {
2  console.log('Konfiguracja zapisana');
3}
4
5function openSearchDialog() {
6  console.log('Dialog wyszukiwania otwarty');
7}
8
9function closeAllModals() {
10  document.querySelectorAll('.modal').forEach(modal => {
11    modal.style.display = 'none';
12  });
13  console.log('Wszystkie okna zamknięte');
14}
15
16function moveMap(direction) {
17  console.log(`Mapa przesunięta w kierunku: ${direction}`);
18}
19
20function toggleMonitoringPause() {
21  console.log('Monitoring wstrzymany/wznowiony');
22}
23
24function openHelp() {
25  console.log('Pomoc otwarta');
26}
27
28function jumpToEnclosure(number) {
29  console.log(`Przejście do zagrody #${number}`);
30}

Listener rozpoznaje skrót, a logika żyje w funkcjach.

Walidacja wejścia z klawiatury

Listener keydown może odrzucić znak, zanim trafi do pola: kod bezpieczeństwa przyjmuje tylko cyfry, telefon także spacje, nawiasy i myślniki, a imię dostaje wielką literę.

1const securityCodeInput = document.getElementById('security-code');
2const phoneInput = document.getElementById('phone-number');
3const nameInput = document.getElementById('visitor-name');
4
5// Tylko cyfry - kod bezpieczeństwa
6securityCodeInput.addEventListener('keydown', (event) => {
7  // Zezwól na klawisze funkcyjne
8  const allowedKeys = [
9    'Backspace', 'Delete', 'Tab', 'Escape', 'Enter',
10    'ArrowLeft', 'ArrowRight', 'Home', 'End'
11  ];
12
13  if (allowedKeys.includes(event.key)) {
14    return; // Pozwól
15  }
16
17  // Tylko cyfry
18  if (event.key >= '0' && event.key <= '9') {
19    // Maksymalnie 6 cyfr
20    if (securityCodeInput.value.length >= 6 && !event.ctrlKey && !event.metaKey) {
21      event.preventDefault();
22      console.log('Maksymalnie 6 cyfr');
23    }
24    return;
25  }
26
27  // Wszystko inne - blokuj
28  event.preventDefault();
29  console.log('Tylko cyfry są dozwolone');
30});
31
32// Formatowanie numeru telefonu
33phoneInput.addEventListener('keydown', (event) => {
34  const allowedKeys = [
35    'Backspace', 'Delete', 'Tab', 'Escape', 'Enter',
36    'ArrowLeft', 'ArrowRight', 'Home', 'End'
37  ];
38
39  if (allowedKeys.includes(event.key)) return;
40
41  // Tylko cyfry, spacje, myślniki, nawiasy
42  const phoneChars = /^[0-9 ()-]$/;
43
44  if (!phoneChars.test(event.key)) {
45    event.preventDefault();
46    console.log('Niedozwolony znak w numerze telefonu');
47  }
48});
49
50// Auto-kapitalizacja pierwszej litery
51nameInput.addEventListener('keyup', (event) => {
52  const value = nameInput.value;
53
54  if (value.length === 1) {
55    nameInput.value = value.toUpperCase();
56  }
57});

Taki filtr to tylko wygoda, bo tekst da się wkleić z menu kontekstowego, więc wartość sprawdzaj też przy input lub submit.

Kombinacja myszy i klawiatury

"Najlepsze interfejsy," mówi Ray, "łączą oba typy wejścia." Klawisze modyfikujące zmieniają znaczenie kliknięcia.

1const enclosureCards = document.querySelectorAll('.enclosure-card');
2let selectedCards = new Set();
3
4enclosureCards.forEach(card => {
5  card.addEventListener('click', (event) => {
6    const enclosureId = card.dataset.enclosureId;
7
8    // Różne zachowania w zależności od klawiszy modyfikujących
9    if (event.ctrlKey || event.metaKey) {
10      // Ctrl + Click = Toggle selekcji
11      console.log('Ctrl+Click: Toggle selekcji');
12
13      if (selectedCards.has(enclosureId)) {
14        selectedCards.delete(enclosureId);
15        card.classList.remove('selected');
16      } else {
17        selectedCards.add(enclosureId);
18        card.classList.add('selected');
19      }
20    } else if (event.shiftKey) {
21      // Shift + Click = Wybór zakresu
22      console.log('Shift+Click: Wybór zakresu');
23      selectRange(lastSelected, enclosureId);
24    } else {
25      // Zwykłe kliknięcie = Tylko ta jedna karta
26      console.log('Zwykłe kliknięcie: Pojedyncza selekcja');
27
28      selectedCards.clear();
29      document.querySelectorAll('.enclosure-card.selected').forEach(c => {
30        c.classList.remove('selected');
31      });
32
33      selectedCards.add(enclosureId);
34      card.classList.add('selected');
35    }
36
37    lastSelected = enclosureId;
38    updateSelectionInfo();
39  });
40});
41
42let lastSelected = null;
43
44function selectRange(startId, endId) {
45  console.log(`Wybieranie zakresu od ${startId} do ${endId}`);
46  // Logika wyboru zakresu
47}
48
49function updateSelectionInfo() {
50  console.log(`Wybrano ${selectedCards.size} zagród`);
51}

Ctrl lub Cmd przełącza zaznaczenie, Shift wybiera zakres, zwykłe kliknięcie zostawia jedną kartę. Dalej strzałki.

1// Nawigacja klawiaturą przez karty
2let focusedCardIndex = 0;
3const cards = Array.from(enclosureCards);
4
5document.addEventListener('keydown', (event) => {
6  // Tylko gdy żadne pole input nie jest aktywne
7  if (document.activeElement.tagName === 'INPUT') return;
8
9  switch(event.key) {
10    case 'ArrowRight':
11    case 'ArrowDown':
12      event.preventDefault();
13      focusedCardIndex = Math.min(focusedCardIndex + 1, cards.length - 1);
14      focusCard(focusedCardIndex);
15      break;
16
17    case 'ArrowLeft':
18    case 'ArrowUp':
19      event.preventDefault();
20      focusedCardIndex = Math.max(focusedCardIndex - 1, 0);
21      focusCard(focusedCardIndex);
22      break;
23
24    case 'Enter':
25    case ' ':
26      event.preventDefault();
27      cards[focusedCardIndex].click();
28      break;
29  }
30});
31
32function focusCard(index) {
33  cards.forEach(card => card.classList.remove('focused'));
34  cards[index].classList.add('focused');
35  cards[index].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
36
37  console.log(`Fokus na karcie #${index}: ${cards[index].dataset.enclosureName}`);
38}

Warunek z activeElement nie przeszkadza w pisaniu w polach, a click() na karcie uruchamia ten sam listener co mysz.

Wydarzenia okna i dotyku

DOMContentLoaded odpala, gdy HTML jest wczytany, choć obrazy mogą się jeszcze ładować. load na window czeka na całą stronę z zasobami. Przewijanie zgłasza scroll, a window.scrollY podaje przewinięte piksele, więc większa wartość niż poprzednio oznacza ruch w dół. Na ekranach dotykowych palec wysyła po kolei touchstart, touchmove i touchend, do których wrócimy przy gestach.

Podsumowanie

"Wydarzenia myszy i klawiatury," podsumowuje Ray, "to fundament interaktywności. Podobnie jak różne sensory w Parku Jurajskim wykrywają różne typy zagrożeń, tak różne wydarzenia pozwalają na różnorodne interakcje użytkownika. Kluczem jest używanie odpowiednich wydarzeń do odpowiednich zadań."

Kluczowe wydarzenia myszy:

  • click, dblclick, contextmenu - kliknięcia
  • mousedown, mouseup - kontrola przycisków
  • mousemove - śledzenie pozycji (używaj ostrożnie!)
  • mouseenter, mouseleave - najechanie (nie bąbelkują)
  • mouseover, mouseout - najechanie (bąbelkują)

Kluczowe wydarzenia klawiatury:

  • keydown - naciśnięcie klawisza (użyj tego)
  • keyup - puszczenie klawisza
  • event.key - znak/nazwa klawisza ('a', 'Enter', 'ArrowUp')
  • event.code - fizyczny klawisz ('KeyA', 'Enter', 'ArrowUp')
  • Klawisze modyfikujące: ctrlKey, shiftKey, altKey, metaKey

Moja rada: skróty opieraj na keydown, a przenoszenie między strefami na wbudowanym Drag and Drop. Następna lekcja: formularze.

Pamiętaj: każdy czujnik w parku reaguje na inny sygnał, więc dobieraj wydarzenie do zadania.

Kod do tej lekcji: index.html
1<!DOCTYPE html>
2<html lang="pl">
3<head>
4    <meta charset="UTF-8">
5    <meta name="viewport" content="width=device-width, initial-scale=1.0">
6    <title>Wydarzenia myszy i klawiatury - Sterowanie kamerą</title>
7    <style>
8        body {
9            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
10            background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
11            margin: 0;
12            padding: 20px;
13            color: white;
14        }
15
16        .container {
17            max-width: 1000px;
18            margin: 0 auto;
19        }
20
21        h1 {
22            text-align: center;
23            margin-bottom: 20px;
24        }
25
26        .instructions {
27            background: rgba(52, 152, 219, 0.2);
28            border: 1px solid #3498db;
29            border-radius: 10px;
30            padding: 15px;
31            margin-bottom: 20px;
32            text-align: center;
33        }
34
35        .instructions kbd {
36            background: rgba(255, 255, 255, 0.2);
37            padding: 2px 8px;
38            border-radius: 4px;
39            font-family: monospace;
40            margin: 0 3px;
41        }
42
43        .camera-viewport {
44            position: relative;
45            width: 100%;
46            height: 400px;
47            background: linear-gradient(180deg, #34495e 0%, #2c3e50 100%);
48            border-radius: 15px;
49            overflow: hidden;
50            cursor: move;
51            margin-bottom: 20px;
52            border: 3px solid #7f8c8d;
53        }
54
55        .camera-content {
56            position: absolute;
57            width: 200%;
58            height: 200%;
59            transition: transform 0.3s ease;
60        }
61
62        .dinosaur {
63            position: absolute;
64            font-size: 60px;
65            cursor: pointer;
66            transition: transform 0.2s ease;
67            user-select: none;
68        }
69
70        .dinosaur:hover {
71            transform: scale(1.2);
72            filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.5));
73        }
74
75        .dinosaur.clicked {
76            animation: bounce 0.5s ease;
77        }
78
79        @keyframes bounce {
80            0%, 100% { transform: scale(1); }
81            50% { transform: scale(1.3); }
82        }
83
84        .controls-panel {
85            display: grid;
86            grid-template-columns: 1fr 1fr;
87            gap: 20px;
88            margin-bottom: 20px;
89        }
90
91        .control-box {
92            background: rgba(0, 0, 0, 0.6);
93            border-radius: 10px;
94            padding: 20px;
95            border: 1px solid #34495e;
96        }
97
98        .control-box h3 {
99            margin-top: 0;
100            color: #3498db;
101        }
102
103        .stats {
104            display: grid;
105            grid-template-columns: repeat(2, 1fr);
106            gap: 10px;
107        }
108
109        .stat-item {
110            background: rgba(255, 255, 255, 0.1);
111            padding: 10px;
112            border-radius: 5px;
113        }
114
115        .stat-label {
116            color: #bdc3c7;
117            font-size: 12px;
118        }
119
120        .stat-value {
121            color: #ecf0f1;
122            font-size: 18px;
123            font-weight: bold;
124        }
125
126        .arrow-buttons {
127            display: grid;
128            grid-template-areas:
129                ". up ."
130                "left . right"
131                ". down .";
132            gap: 5px;
133            justify-content: center;
134            align-items: center;
135        }
136
137        .arrow-btn {
138            width: 60px;
139            height: 60px;
140            background: linear-gradient(45deg, #3498db, #2980b9);
141            border: none;
142            border-radius: 10px;
143            color: white;
144            font-size: 24px;
145            cursor: pointer;
146            transition: all 0.2s ease;
147        }
148
149        .arrow-btn:hover {
150            background: linear-gradient(45deg, #2980b9, #21618c);
151            transform: scale(1.05);
152        }
153
154        .arrow-btn:active {
155            transform: scale(0.95);
156        }
157
158        .arrow-btn.up { grid-area: up; }
159        .arrow-btn.down { grid-area: down; }
160        .arrow-btn.left { grid-area: left; }
161        .arrow-btn.right { grid-area: right; }
162
163        .event-log {
164            background: rgba(0, 0, 0, 0.8);
165            border-radius: 10px;
166            padding: 15px;
167            max-height: 200px;
168            overflow-y: auto;
169            font-family: 'Courier New', monospace;
170            font-size: 12px;
171        }
172
173        .log-entry {
174            padding: 5px;
175            border-bottom: 1px solid rgba(255, 255, 255, 0.1);
176            color: #bdc3c7;
177        }
178
179        .log-entry.mouse {
180            color: #3498db;
181        }
182
183        .log-entry.keyboard {
184            color: #e74c3c;
185        }
186
187        .log-entry.click {
188            color: #f39c12;
189        }
190
191        @media (max-width: 768px) {
192            .controls-panel {
193                grid-template-columns: 1fr;
194            }
195        }
196    </style>
197</head>
198<body>
199    <div class="container">
200        <h1>Sterowanie Kamerą w Parku Jurajskim</h1>
201
202        <div class="instructions">
203            <strong>Sterowanie:</strong> Strzałki <kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd> |
204            <strong>Pauza:</strong> <kbd>Space</kbd> |
205            <strong>Zoom:</strong> Podwójne kliknięcie |
206            <strong>Zamknij:</strong> <kbd>Escape</kbd>
207        </div>
208
209        <div class="camera-viewport" id="viewport">
210            <div class="camera-content" id="camera">
211                <div class="dinosaur" style="top: 50px; left: 100px;" data-name="T-Rex">T</div>
212                <div class="dinosaur" style="top: 150px; left: 400px;" data-name="Velociraptor">V</div>
213                <div class="dinosaur" style="top: 300px; left: 200px;" data-name="Triceratops">Tr</div>
214                <div class="dinosaur" style="top: 100px; left: 600px;" data-name="Brachiosaurus">B</div>
215                <div class="dinosaur" style="top: 250px; left: 700px;" data-name="Stegosaurus">S</div>
216            </div>
217        </div>
218
219        <div class="controls-panel">
220            <div class="control-box">
221                <h3>Statystyki</h3>
222                <div class="stats">
223                    <div class="stat-item">
224                        <div class="stat-label">Pozycja myszy</div>
225                        <div class="stat-value" id="mouse-pos">0, 0</div>
226                    </div>
227                    <div class="stat-item">
228                        <div class="stat-label">Kliknięcia</div>
229                        <div class="stat-value" id="click-count">0</div>
230                    </div>
231                    <div class="stat-item">
232                        <div class="stat-label">Pozycja kamery</div>
233                        <div class="stat-value" id="camera-pos">0, 0</div>
234                    </div>
235                    <div class="stat-item">
236                        <div class="stat-label">Ostatni klawisz</div>
237                        <div class="stat-value" id="last-key">-</div>
238                    </div>
239                </div>
240            </div>
241
242            <div class="control-box">
243                <h3>Sterowanie kamerą</h3>
244                <div class="arrow-buttons">
245                    <button class="arrow-btn up" data-direction="up">↑</button>
246                    <button class="arrow-btn left" data-direction="left">←</button>
247                    <button class="arrow-btn right" data-direction="right">→</button>
248                    <button class="arrow-btn down" data-direction="down">↓</button>
249                </div>
250            </div>
251        </div>
252
253        <div class="control-box">
254            <h3>Log wydarzeń</h3>
255            <div class="event-log" id="event-log"></div>
256        </div>
257    </div>
258
259    <script>
260        // Referencje do elementów
261        const camera = document.getElementById('camera');
262        const viewport = document.getElementById('viewport');
263        const mousePosDisplay = document.getElementById('mouse-pos');
264        const clickCountDisplay = document.getElementById('click-count');
265        const cameraPosDisplay = document.getElementById('camera-pos');
266        const lastKeyDisplay = document.getElementById('last-key');
267        const eventLog = document.getElementById('event-log');
268
269        // Stan kamery
270        let cameraX = 0;
271        let cameraY = 0;
272        let clickCount = 0;
273        let isPaused = false;
274        const moveSpeed = 50;
275
276        // Funkcja logowania wydarzeń
277        function logEvent(message, type = 'mouse') {
278            const logEntry = document.createElement('div');
279            logEntry.className = `log-entry ${type}`;
280            logEntry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
281            eventLog.insertBefore(logEntry, eventLog.firstChild);
282
283            // Ograniczamy liczbę wpisów
284            if (eventLog.children.length > 20) {
285                eventLog.removeChild(eventLog.lastChild);
286            }
287        }
288
289        // Funkcja przesuwania kamery
290        function moveCamera(direction) {
291            if (isPaused) {
292                logEvent('Kamera wstrzymana! Naciśnij Space aby wznowić', 'keyboard');
293                return;
294            }
295
296            switch(direction) {
297                case 'up':
298                    cameraY = Math.max(cameraY - moveSpeed, -200);
299                    break;
300                case 'down':
301                    cameraY = Math.min(cameraY + moveSpeed, 200);
302                    break;
303                case 'left':
304                    cameraX = Math.max(cameraX - moveSpeed, -200);
305                    break;
306                case 'right':
307                    cameraX = Math.min(cameraX + moveSpeed, 200);
308                    break;
309            }
310
311            camera.style.transform = `translate(${cameraX}px, ${cameraY}px)`;
312            cameraPosDisplay.textContent = `${cameraX}, ${cameraY}`;
313            logEvent(`Przesunięcie kamery: ${direction}`, 'keyboard');
314        }
315
316        // OBSŁUGA MYSZY
317
318        // Mousemove - śledzenie pozycji myszy
319        viewport.addEventListener('mousemove', function(e) {
320            const rect = viewport.getBoundingClientRect();
321            const x = Math.floor(e.clientX - rect.left);
322            const y = Math.floor(e.clientY - rect.top);
323            mousePosDisplay.textContent = `${x}, ${y}`;
324        });
325
326        // Click - pojedyncze kliknięcie
327        viewport.addEventListener('click', function(e) {
328            clickCount++;
329            clickCountDisplay.textContent = clickCount;
330
331            const rect = viewport.getBoundingClientRect();
332            const x = Math.floor(e.clientX - rect.left);
333            const y = Math.floor(e.clientY - rect.top);
334
335            logEvent(`Kliknięcie w pozycji: (${x}, ${y})`, 'click');
336        });
337
338        // Double click - podwójne kliknięcie (zoom)
339        viewport.addEventListener('dblclick', function(e) {
340            const currentScale = camera.style.transform.includes('scale') ? 1 : 1.5;
341            const newScale = currentScale === 1 ? 1.5 : 1;
342
343            camera.style.transform = `translate(${cameraX}px, ${cameraY}px) scale(${newScale})`;
344            logEvent(`Zoom: ${newScale === 1 ? 'Wyłączony' : 'Włączony'}`, 'click');
345        });
346
347        // Mouseenter/mouseleave - najechanie na viewport
348        viewport.addEventListener('mouseenter', function() {
349            viewport.style.borderColor = '#3498db';
350            logEvent('Mysz weszła w obszar kamery', 'mouse');
351        });
352
353        viewport.addEventListener('mouseleave', function() {
354            viewport.style.borderColor = '#7f8c8d';
355            logEvent('Mysz opuściła obszar kamery', 'mouse');
356        });
357
358        // Kliknięcie na dinozaura
359        document.querySelectorAll('.dinosaur').forEach(dino => {
360            dino.addEventListener('click', function(e) {
361                e.stopPropagation(); // Zatrzymujemy propagację do viewport
362
363                const name = this.dataset.name;
364                this.classList.add('clicked');
365
366                setTimeout(() => {
367                    this.classList.remove('clicked');
368                }, 500);
369
370                logEvent(`Kliknięto dinozaura: ${name}`, 'click');
371                console.log(`Obserwacja: ${name}`);
372            });
373
374            dino.addEventListener('mouseenter', function() {
375                const name = this.dataset.name;
376                logEvent(`Najechano na: ${name}`, 'mouse');
377            });
378        });
379
380        // OBSŁUGA KLAWIATURY
381
382        // Keydown - naciśnięcie klawisza
383        document.addEventListener('keydown', function(e) {
384            lastKeyDisplay.textContent = e.key;
385
386            switch(e.key) {
387                case 'ArrowUp':
388                    e.preventDefault();
389                    moveCamera('up');
390                    break;
391                case 'ArrowDown':
392                    e.preventDefault();
393                    moveCamera('down');
394                    break;
395                case 'ArrowLeft':
396                    e.preventDefault();
397                    moveCamera('left');
398                    break;
399                case 'ArrowRight':
400                    e.preventDefault();
401                    moveCamera('right');
402                    break;
403                case ' ':
404                    e.preventDefault();
405                    isPaused = !isPaused;
406                    logEvent(`Pauza: ${isPaused ? 'WŁĄCZONA' : 'WYŁĄCZONA'}`, 'keyboard');
407                    viewport.style.opacity = isPaused ? '0.5' : '1';
408                    break;
409                case 'Escape':
410                    logEvent('Naciśnięto Escape - zamykanie widoku', 'keyboard');
411                    alert('Widok kamery zostanie zamknięty');
412                    break;
413                case 'r':
414                case 'R':
415                    // Reset kamery
416                    cameraX = 0;
417                    cameraY = 0;
418                    camera.style.transform = 'translate(0px, 0px)';
419                    cameraPosDisplay.textContent = '0, 0';
420                    logEvent('Reset pozycji kamery', 'keyboard');
421                    break;
422                default:
423                    logEvent(`Naciśnięto klawisz: ${e.key}`, 'keyboard');
424            }
425        });
426
427        // Obsługa przycisków strzałek
428        document.querySelectorAll('.arrow-btn').forEach(btn => {
429            btn.addEventListener('click', function() {
430                const direction = this.dataset.direction;
431                moveCamera(direction);
432            });
433        });
434
435        // Inicjalizacja
436        logEvent('System kamery uruchomiony', 'keyboard');
437        console.log('System monitoringu Parku Jurajskiego - ONLINE');
438        console.log('Użyj strzałek do poruszania kamerą');
439        console.log('Kliknij na dinozaury, aby je obserwować');
440    </script>
441</body>
442</html>

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Które zdarzenie klawiatury przeglądarka wysyła po wciśnięciu każdego klawisza (także Shift, Ctrl czy strzałek) i powtarza, dopóki klawisz jest przytrzymany?

  2. 2. Które wydarzenie wywoływane jest po całkowitym załadowaniu strony?

Zadania praktyczne w grze

  • Edytor kodu

    W pliku index.html są dwie zagrody (.enclosure) i karty dinozaurów z atrybutem draggable="true". Przy dragstart karta zapisuje swoje id w dataTransfer. Uzupełnij luki: ___BLANK1___ to zdarzenie, które zagroda dostaje, gdy przeciągana karta jest nad nią (w nim preventDefault() zezwala na upuszczenie), a ___BLANK2___ to metoda dataTransfer, która w drop odczytuje zapisane id. Upuszczona karta ma trafić do zagrody, nad którą ją upuszczono.

  • Układanie w pionie

    Ułóż wydarzenia myszy w kolejności ich następowania podczas kliknięcia:

  • Klikanie w kolejności

    Ułóż kod sprawdzający kombinację Ctrl+S.

  • Układanie w pionie

    Ułóż wydarzenia dotykowe w kolejności ich występowania:

Przydatne artykuły