Core-Guide

Das Paket @reelkit/core enthält die Slider-Logik unabhängig vom Framework. Nutze es für eigene Integrationen oder um die Architektur darunter zu verstehen.

Architektur im Überblick

Der Core nutzt ein Controller-Muster mit Factory-Funktionen. Keine Klassen — nur einfache Objekte, die aus Closures zurückgegeben werden. Keine Abhängigkeiten. Der Core koordiniert:

  • SliderController — zentrale Zustandsverwaltung und Navigation
  • GestureController — Ziehen per Touch und Pointer
  • KeyboardController — Pfeiltasten und Escape
  • WheelController — Mausrad mit Debounce

createSliderController

Erstellt eine neue Instanz des Slider-Controllers, die den gesamten Zustand und das Verhalten des Sliders verwaltet.

typescript
import { createSliderController } from '@reelkit/core';

const controller = createSliderController(
  {
    count: 10,
    direction: 'vertical',
    enableWheel: true,
    transitionDuration: 300,
  },
  {
    onAfterChange: (index) => console.log('Changed to:', index),
  }
);

// Attach to DOM element
controller.attach(element);
controller.observe();

Methoden des Controllers

typescript
// Go to specific index
controller.goTo(5);           // instant
controller.goTo(5, true);     // animated, returns Promise

// Navigate to next/previous
controller.next();
controller.prev();

Lebenszyklus

typescript
// Connect to DOM element
controller.attach(element);

// Start gesture, keyboard, and wheel observation
controller.observe();

// Stop gesture, keyboard, and wheel observation
controller.unobserve();

// Detach DOM listeners (reversible — use for React effect cleanup)
controller.detach();

// Permanent teardown (use for Angular onDestroy)
controller.dispose();

// Recalculate positions
controller.adjust();

// Update size
controller.setPrimarySize(600);

Zustand aktualisieren

typescript
// Update configuration
controller.updateConfig({
  count: 20,
  loop: true,
});

Virtualisierung

Der Core rendert zu jedem Zeitpunkt nur 3 Slides ins DOM (den aktuellen, den vorherigen und den nächsten). Der Range-Extractor bestimmt, welche Indizes im gerenderten Fenster enthalten sind:

typescript
import { defaultRangeExtractor } from '@reelkit/core';

// Default: renders current ± 1 (3 DOM nodes)
const indexes = defaultRangeExtractor(currentIndex, count);

// Custom: skip hidden slides by shifting to next valid index
const hiddenSlides = new Set([2, 5]);

const skipHiddenExtractor = (current: number, count: number) => {
  const result: number[] = [];
  // Collect prev, current, next — skip hidden, shift forward
  for (let i = current - 1, added = 0; added < 3 && i < count; i++) {
    if (i >= 0 && !hiddenSlides.has(i)) {
      result.push(i);
      added++;
    }
  }
  return result;
};

Das Ergebnis wird immer auf höchstens 3 Indizes begrenzt. Liefert dein Extractor mehr, behält der Core 3 um den aktuellen Slide herum.

Signals

Für die Reaktivität nutzt der Core ein schlankes Signal-System:

typescript
import { createSignal, createComputed, reaction } from '@reelkit/core';

// Create a signal
const count = createSignal(0);

// Observe changes (returns a disposer function)
const dispose = count.observe(() => console.log(count.value));

// Update value
count.value = 5;

// Create computed signal (requires a deps factory)
const doubled = createComputed(() => count.value * 2, () => [count]);

// Run side effects on signal changes
const disposeReaction = reaction(
  () => [count],
  () => console.log('Count changed:', count.value)
);

// Cleanup
dispose();
disposeReaction();

Zustand des Controllers

Auf den reaktiven Zustand greifst du über controller.state zu:

typescript
const { index, axisValue, indexes } = controller.state;

// Observe index changes (returns a disposer function)
const disposeIndex = index.observe(() => {
  console.log('Current index:', index.value);
});

// Observe visible indexes for virtualization
const disposeIndexes = indexes.observe(() => {
  console.log('Visible:', indexes.value);
});

// Cleanup when done
disposeIndex();
disposeIndexes();

Timeline-Controller

Baue eine eigene Zeitleiste zum Spulen für jedes <video>-Element. Der Controller stellt reaktive Signals für Dauer, aktuelle Zeit, gepufferte Bereiche und den Spulzustand bereit und verbindet Pointer- und Tastatur-Interaktionen mit einem einzigen Aufruf mit einem beliebigen DOM-Element.

typescript
import { createTimelineController } from '@reelkit/core';

const timeline = createTimelineController({
  onScrubStart: () => video.pause(),
  onScrubEnd: () => video.play(),
});

timeline.attach(video);
const dispose = timeline.bindInteractions(trackEl);

// Render: read signals and update DOM
timeline.progress.observe(() => {
  fillEl.style.width = `${timeline.progress.value * 100}%`;
});

// Cleanup
dispose();
timeline.detach();

URL-State

Lege den Öffnungszustand eines Overlays in die Adresszeile: Der sichtbare Slide bekommt einen Link, der sich teilen, direkt aufrufen und mit der Zurück-Taste schließen lässt. Der Core besitzt das Modell; die Bindings verpacken es in einen Hook (React/Vue useOverlayUrlState, Angular createOverlayUrlState) und eine Overlay-Komponente, die von der URL gesteuert wird.

So funktioniert es

createUrlStateController spiegelt einen Query-Parameter in ein Signal und schreibt Änderungen zurück. Das Öffnen legt einen Eintrag im Verlauf an; jede Navigation ersetzt ihn — hundert Wischgesten fügen keinen hinzu, also schließt ein Schritt zurück immer. Ein UrlAdapter ist die austauschbare Schnittstelle zum Lesen und Schreiben: Der Standard nutzt history.pushState, und eine App mit Router übergibt einen Adapter auf Basis des Routers, damit dessen eigene Location nie veraltet.

typescript
import { createUrlStateController, urlIndexKey } from '@reelkit/core';

const controller = createUrlStateController({
  param: 'photo',
  ...urlIndexKey(() => items.length),
});

const detach = controller.attach(); // begin mirroring the URL
controller.position.observe(() => {
  // null → closed; a number → open at that slide
  render(controller.position.value);
});

// Write back: opening pushes once, navigating replaces, closing clears
controller.set(3);
controller.set(null);

Codec und Locator — zwei Aufgaben

Ein Key ist ein passendes Paar { codec, locator }. Eine Identität in die URL zu schreiben und herauszufinden, wo sie gerade steht, sind getrennte Aufgaben, also sind es getrennte Objekte:

  • Codec — das Format in der URL. encode schreibt eine Identität in den Text des Parameters; decode liest sie zurück und lehnt einen fehlerhaften Wert ab, sodass sich der Parameter selbst aus der URL entfernt.
  • Locator — die Suche. locate findet, wo eine decodierte Identität in der aktuellen Sammlung steht (oder null, wenn sie fehlt); identify liest eine Position für das Schreiben zurück in ihre Identität; das optionale locateAsync lädt bei einem Fehltreffer weitere Seiten eines gefensterten oder endlosen Feeds.

Weil beide getrennt sind, kannst du jedes Format mit jeder Suche kombinieren — zum Beispiel einen Codec für stabile IDs mit einem Locator, der Seiten nachlädt.

Index oder stabile ID als Key

Zwei eingebaute Keys bauen dieses Paar für dich; sie unterscheiden sich nur darin, was die URL benennt:

  • urlIndexKey(() => count) adressiert über die Position (?photo=3). Am einfachsten, aber ein Lesezeichen öffnet ein anderes Element, sobald die Liste neu sortiert wird.
  • urlStableIdKey({ items }) adressiert über die stabile id jedes Elements (?photo=post_42) und durchsucht die aktuelle Liste — das Lesezeichen benennt dasselbe Element auch nach einer Neusortierung oder fällt sauber weg, wenn es nicht mehr existiert. hashCodec: base64UrlCodec verschleiert die ID mit base64url (umkehrbar, kein kryptografischer Hash).

Ein gefensterter Feed mit Seiten? Beide eingebauten Keys nehmen ein optionales locateAsync — urlIndexKey(() => count, locateAsync) und urlStableIdKey({ items, locateAsync }). Die synchrone Suche antwortet für das, was schon geladen ist; ein Fehltreffer lädt den Rest nach, sodass ein geteilter Link hinter dem Fenster trotzdem öffnet — ohne selbst geschriebenen Codec oder Locator.

Zwei Achsen? urlIndexTwoAxisKey trägt ?p=<outer>.<inner> für einen Beitrag plus einen inneren Medienindex. Alle Optionen stehen in der Core-API-Referenz.

Gesehen-Status

Merke dir, wie weit ein Nutzer in einer Galerie gekommen ist, über Neuladen und Tabs hinweg — ein Ring, der zeigt, was gesehen wurde, eine Galerie, die dort wieder öffnet, wo sie verlassen wurde. Es ist dasselbe Modell wie der URL-State, nur auf einen Speicher statt auf die Adresszeile gerichtet, also teilen sich beide einen Key.

So funktioniert es

createViewedStateController speichert einen Eintrag als genau den Text, den ein Link mit ?photo= tragen würde, und liest ihn über denselben Ablauf aus decode und dann locate zurück. Nichts vertraut einer gespeicherten Position. Übergib einen Key an beide Stellen, und ein Lesezeichen und ein gespeicherter Eintrag sind derselbe String.

typescript
import {
  createUrlStateController,
  createViewedStateController,
  urlStableIdTwoAxisKey,
  twoAxisViewedTracking,
} from '@reelkit/core';

const key = urlStableIdTwoAxisKey({ outerItems, innerItems });

const url = createUrlStateController({ param: 'story', ...key });
const seen = createViewedStateController({
  storageKey: 'stories-seen',
  ...key,
  ...twoAxisViewedTracking,
});

seen.attach();                       // reads storage, follows other tabs
seen.record({ outer: 2, inner: 1 }); // furthest point wins, a rewatch never rewinds
seen.resolve('user_42');             // → { outer: 2, inner: 1 } | null

Die Haltbarkeit folgt dem Key

Der Speicher repariert selbst nichts, also entscheidet der Key, den du übergibst, was erhalten bleibt: Ein Key über IDs behält eine Stelle, auch wenn die Sammlung neu sortiert wird, ein Key über Positionen nicht. Ein gespeicherter Eintrag benennt den weitesten erreichten Punkt und keine Zahl von Aufrufen, also verkürzt das Entfernen eines Elements aus der Mitte die Zählung — dieselbe Selbstheilung, die ein geteilter Link bekommt.

Gelesen wird nur synchron. Ein Eintrag, dessen Elemente noch nicht geladen sind, gilt als nicht vorhanden und bleibt unverändert im Speicher, sodass ein gefensterter Feed nie seinen eigenen Verlauf verliert.

Speicher und Ablauf

Standardmäßig steht localStorage hinter dem Speicher; createSessionStorageAdapter() vergisst beim Schließen des Tabs, und mit einem eigenen StorageAdapter liegt er an einem beliebigen synchronen Ort. Bis attach() wird nichts gelesen, sodass ein Server-Render und der erste Client-Render übereinstimmen.

Einträge bleiben, bis sie ausdrücklich vergessen werden. Übergib ttlMs, damit sie stattdessen ablaufen — pro Track und mit gleitender Uhr, sodass eine Stelle, die noch angeschaut wird, nie neben einer verlassenen veraltet. Das ändert, was geschrieben wird, jeder Eintrag wird zu einem Paar [wire, timestamp], aber das Lesen kommt mit beiden Formen zurecht, egal was die Option sagt, sodass ein Eintrag von vor dem Einschalten als frisch gilt, statt gelöscht zu werden. maxTracks begrenzt die Anzahl statt des Alters: Darüber hinaus wird beim nächsten Schreiben der am längsten nicht mehr erfasste Track entfernt, und das Erfassen eines Tracks, selbst an einer schon überholten Position, stellt ihn wieder ans Ende der Reihe. Alle Optionen stehen in der Core-API-Referenz.

Autoplay

Timing für alles, was von selbst weitergeht. Die Stories Player lassen jede Story über createTimerController laufen; die Lightboxen betreiben ihre Diashow mit createSlideshowController, der darauf aufbaut. Beide takten über requestAnimationFrame, und jede Pause behält den genauen Fortschritt.

createTimerController

Zählt eine Dauer von 0 bis 1 und ruft zurück, wenn sie vorbei ist. start() beginnt immer bei 0, optional mit einer anderen Dauer für diesen Lauf; pause() und resume() behalten die genaue Position.

typescript
import { createTimerController, reaction } from '@reelkit/core';

const timer = createTimerController({
  duration: 5000,
  onComplete: () => console.log('Timer finished!'),
});

// Observe progress (0 to 1)
const dispose = reaction(
  () => [timer.progress],
  () => {
    console.log('Progress:', timer.progress.value);
  },
);

// Start with default duration
timer.start();

// Or override duration for a specific story
timer.start(8000);

// Pause/resume preserves exact position
timer.pause();
timer.resume();

// Reset to 0
timer.reset();

// Cleanup
dispose();
timer.dispose();

createSlideshowController

Die Zustandsmaschine hinter einer Galerie, die von selbst weitergeht. Sie läuft nur, solange sie eingeschaltet ist, nichts sie anhält und die Seite sichtbar ist; die Sichtbarkeit der Seite beobachtet sie selbst, den Rest lieferst du ihr. Ein Bild zählt sein Intervall ab dem Moment, in dem es geladen ist, sodass ein langsames nie ungesehen übersprungen wird. Ein Video geht weiter, wenn es endet, also muss es einmal abspielen und darf nicht in Schleife laufen. Ein Video, das endet, während etwas die Diashow anhält, geht beim Loslassen weiter.

typescript
import { createSlideshowController, createSliderController } from '@reelkit/core';

const items = [{ type: 'image' }, { type: 'video' }, { type: 'image' }];

const slider = createSliderController(
  { count: items.length, direction: 'horizontal' },
  { onAfterChange: (index) => onSlideChange(index) },
);

const slideshow = createSlideshowController({
  interval: 5000,
  onAdvance: () => {
    if (slider.state.index.value >= items.length - 1) slideshow.pause();
    else slider.next();
  },
});

// Every slide change starts over and waits for the new slide.
function onSlideChange(index: number) {
  slideshow.setSlide(items[index].type === 'video' ? 'video' : 'image');
}

// From the slide: an image counts its interval once it loaded or failed,
// a video advances when it ends (let it play once rather than loop).
const onImageSettled = () => slideshow.ready();
const onVideoEnded = () => slideshow.ended();

// Holds stack: the slideshow runs again once every reason is released.
const onControlsEnter = () => slideshow.hold('hover');
const onControlsLeave = () => slideshow.release('hover');

onSlideChange(0);
slideshow.play();

// Later
slideshow.dispose();

Alle Konfigurationen, Zustände und Methoden stehen in der Core-API-Referenz.

Download

downloadFile(url, filename?) speichert eine Datei unter filename, standardmäßig unter dem letzten Segment des Pfads der URL. Ein download-Attribut wird bei einem Link auf einen anderen Origin ignoriert, deshalb holt die Funktion die Datei und speichert stattdessen den Blob. Schlägt der Abruf fehl oder antwortet der Server mit einem Fehler — üblicherweise ein fremder Server ohne CORS-Header —, öffnet sich die URL in einem neuen Tab, sodass der Leser die Datei trotzdem bekommt. Sie lehnt nie ab und tut nichts ohne ein Fenster. Siehe die Core-API-Referenz.

Nächste Schritte