Guia do core

O pacote @reelkit/core traz a lógica do slider sem amarras de framework. Use-o para criar integrações próprias ou para entender a arquitetura por baixo dos componentes.

Visão geral da arquitetura

O core adota um padrão de controladores com funções fábrica. Nada de classes — apenas objetos simples devolvidos por closures. Zero dependências. O core coordena:

  • SliderController — estado central e navegação
  • GestureController — arrasto por toque ou ponteiro
  • KeyboardController — setas e Escape
  • WheelController — roda do mouse, com debounce

createSliderController

Cria uma instância do controlador que cuida de todo o estado e do comportamento do slider.

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();

Métodos do controlador

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

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

Ciclo de vida

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);

Atualizações de estado

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

Virtualização

O core mantém apenas 3 slides no DOM a qualquer momento (atual, anterior, próximo). O extrator de intervalo decide quais índices entram na janela renderizada:

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;
};

O resultado é sempre limitado a no máximo 3 índices. Se o seu extrator devolver mais, o core fica com 3 centrados no slide atual.

Sinais

O core usa um sistema de sinais enxuto para a reatividade:

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();

Estado do controlador

Acesse o estado reativo por controller.state:

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();

Controlador da linha do tempo

Monte uma barra de progresso arrastável para qualquer elemento <video>. O controlador expõe sinais reativos de duração, tempo atual, trechos em buffer e estado do arrasto, e liga as interações de ponteiro e teclado a qualquer elemento do DOM em uma única chamada.

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();

Estado na URL

Leve o estado aberto de um overlay para a barra de endereços: o slide visível ganha um link que dá para compartilhar, abrir direto e fechar com o botão voltar. O modelo pertence ao core; os bindings o embrulham em um hook (useOverlayUrlState no React e no Vue, createOverlayUrlState no Angular) e em um componente de overlay guiado pela URL.

Como funciona

createUrlStateController espelha um parâmetro de consulta em um sinal e escreve as mudanças de volta. Abrir empilha uma entrada no histórico; cada navegação seguinte substitui essa entrada — cem deslizes não acrescentam nenhuma, então um único passo para trás sempre fecha. O UrlAdapter é a costura conectável de leitura e escrita: o padrão aciona history.pushState, e um app com rotas passa um adaptador ligado ao roteador, para que a localização do próprio roteador nunca fique desatualizada.

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 e locator — dois papéis

Uma chave é um par combinado { codec, locator }. Escrever uma identidade na URL e descobrir onde ela está agora são preocupações distintas, por isso vivem em objetos distintos:

  • codec — o fio. encode escreve uma identidade no texto do parâmetro; decode faz o caminho inverso e rejeita valores malformados, de modo que o parâmetro se cura sozinho saindo da URL.
  • locator — a busca. locate encontra onde a identidade decodificada está na coleção atual (ou null, se sumiu); identify faz o caminho de volta, da posição à identidade, na hora de escrever; locateAsync, opcional, pagina um feed em janela ou infinito quando a busca falha.

Manter os dois separados permite combinar qualquer fio com qualquer busca — um codec de id estável com um locator que pagina, por exemplo.

Chaves por índice e por id estável

Duas chaves prontas montam esse par por você; a diferença está apenas no que a URL nomeia:

  • urlIndexKey(() => count) endereça pela posição (?photo=3). É a mais simples, mas um favorito abre outro item assim que a lista muda de ordem.
  • urlStableIdKey({ items }) endereça pelo id estável de cada item (?photo=post_42), varrendo a lista atual — o favorito continua apontando para aquele item depois de uma reordenação, ou se desfaz sem sujeira quando o item some. hashCodec: base64UrlCodec disfarça o id em base64url (reversível, não é hash criptográfico).

Feed em janela, carregado aos poucos? As duas chaves prontas aceitam um locateAsync opcional — urlIndexKey(() => count, locateAsync) e urlStableIdKey({ items, locateAsync }). A busca síncrona responde pelo que já carregou; quando ela falha, o resto é paginado, então um link compartilhado que aponta além da janela ainda abre — sem codec nem locator escritos à mão.

Dois eixos? urlIndexTwoAxisKey carrega ?p=<externo>.<interno> para um post mais o índice da mídia interna. As opções completas estão na referência da API do core.

Itens já vistos

Lembre até onde o leitor chegou em uma galeria, entre recarregamentos e entre abas — um anel que mostra o que já foi visto, uma galeria que reabre onde parou. É o mesmo modelo do estado na URL, apontado para o armazenamento em vez da barra de endereços, e por isso os dois compartilham a mesma chave.

Como funciona

createViewedStateController guarda uma entrada com exatamente o texto que um link ?photo= carregaria, e a lê de volta pelo mesmo ciclo de decode seguido de locate. Nada confia em uma posição armazenada. Espalhe uma única chave nas duas superfícies e o favorito e a entrada guardada passam a ser a mesma 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

A durabilidade acompanha a chave

O armazenamento não acrescenta nenhum reparo próprio, então a chave que você espalha decide o que sobrevive: uma chave endereçada por id mantém o lugar mesmo que a coleção mude de ordem; uma endereçada por posição, não. A entrada guardada nomeia o ponto mais distante alcançado, e não um total de visualizações, então remover um item do meio encurta a contagem — a mesma cura espontânea que um link compartilhado recebe.

A leitura é só síncrona. Uma entrada cujos itens ainda não carregaram é lida como ausente e permanece intacta no armazenamento, de forma que um feed em janela nunca devore o próprio histórico.

Armazenamento e expiração

Por padrão o localStorage sustenta o armazenamento; createSessionStorageAdapter() esquece tudo ao fechar a aba, e um StorageAdapter seu leva os dados para qualquer lugar síncrono. Nada é lido antes de attach(), então a renderização no servidor e a primeira renderização no cliente concordam.

As entradas ficam guardadas até serem esquecidas explicitamente. Passe ttlMs para que expirem — por trilha, em um relógio deslizante, para que algo ainda em uso não envelheça junto com algo abandonado. Isso muda o que é gravado, e cada entrada vira um par [wire, timestamp], mas a leitura entende os dois formatos independentemente da opção, então uma entrada guardada antes de você ligar o recurso conta como recente em vez de ser apagada. maxTracks limita a quantidade em vez da idade: passando do limite, a trilha registrada há mais tempo cai na próxima escrita, e registrar uma trilha, mesmo em uma posição já vista, a manda para o fim da fila. As opções completas estão na referência da API do core.

Reprodução automática

Temporização para tudo o que avança sozinho. O Stories Player roda cada story com createTimerController; o Lightbox roda a apresentação com createSlideshowController, que se apoia nele. Os dois avançam com requestAnimationFrame, e toda pausa guarda o progresso exato.

createTimerController

Conta uma duração de 0 a 1 e avisa quando termina. start() sempre começa do 0, opcionalmente com outra duração para aquela execução; pause() e resume() guardam a posição exata.

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

A máquina de estados por trás de uma galeria que avança sozinha. Ela só roda enquanto está ligada, nada a segura e a página está visível; acompanha a visibilidade da página por conta própria, e você fornece o resto. Uma imagem conta o intervalo a partir do momento em que carregou, então uma imagem lenta nunca é pulada sem ser vista. Um vídeo avança quando termina, então precisa tocar uma vez em vez de repetir. Um vídeo que termina enquanto algo segura a apresentação avança quando ela é liberada.

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();

A configuração, o estado e os métodos completos estão na referência da API do core.

Download

downloadFile(url, filename?) salva um arquivo com o nome filename, por padrão o último segmento do caminho da URL. Um link de outra origem ignora o atributo download, então ele baixa o arquivo com fetch e salva o blob. Quando o fetch falha ou o servidor responde com erro — o caso comum é um servidor de outra origem sem cabeçalhos CORS —, a URL abre em uma nova aba, e o leitor ainda recebe o arquivo. Nunca rejeita e não faz nada sem uma janela. Veja a referência da API do core.

Próximos passos