Core ガイド

@reelkit/core パッケージは、フレームワークに依存しないスライダーのロジックを提供します。独自の組み込みを作るときや、内部のアーキテクチャを理解したいときに使います。

アーキテクチャの概要

コアはファクトリー関数による コントローラーパターン を採用しています。クラスは使わず、すべてクロージャーから返されるプレーンなオブジェクトです。依存関係はありません。コアが連携させるのは次のものです:

  • SliderController — 状態の一元管理とナビゲーション
  • GestureController — タッチやポインターによるドラッグの処理
  • KeyboardController — 矢印キーと Escape
  • WheelController — デバウンス付きのマウスホイール

createSliderController

スライダーのすべての状態と挙動を管理する、新しいコントローラーのインスタンスを作成します。

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

コントローラーのメソッド

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

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

ライフサイクル

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

状態の更新

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

仮想化

コアは常に DOM に 3 枚のスライド(現在・前・次)だけを描画します。レンジエクストラクターが、描画ウィンドウに含めるインデックスを決めます:

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

結果は常に最大 3 つのインデックスに制限されます。エクストラクターがそれより多く返した場合、コアは現在のスライドを中心とした 3 つを残します。

シグナル

コアはリアクティビティのために軽量なシグナルシステムを使います:

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

コントローラーの状態

リアクティブな状態には 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();

タイムラインコントローラー

任意の <video> 要素に独自のシークバーを作れます。コントローラーは再生時間、現在時刻、バッファー済みの範囲、シーク中かどうかをリアクティブなシグナルとして公開し、1 回の呼び出しで任意の DOM 要素にポインターとキーボードの操作を取り付けます。

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 の状態

オーバーレイの開閉状態をアドレスバーに持たせます。表示中のスライドに、共有でき、直接開けて、戻るボタンで閉じられるリンクが付きます。モデルはコアが持ち、各バインディングがそれをフック(React/Vue の useOverlayUrlState、Angular の createOverlayUrlState)と URL 駆動のオーバーレイコンポーネントで包みます。

仕組み

createUrlStateController は 1 つのクエリパラメーターをシグナルに映し、変更を書き戻します。開くと履歴エントリーを 1 つ 積み、以降のナビゲーションはそれを 置き換え ます。100 回スワイプしてもエントリーは増えないので、1 回戻れば必ず閉じます。UrlAdapter は差し替え可能な読み書きの接点です。デフォルトは history.pushState を操作し、ルーターを使うアプリはルーターに連動したアダプターを渡して、ルーター自身の位置情報が古くならないようにします。

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 と locator — 2 つの役割

キーは対になった { codec, locator } です。識別子を URL に書き込むことと、その識別子がいまどこにあるかを探すことは別の関心事なので、別のオブジェクトになっています:

  • codec — 通信形式。 encode は識別子をパラメーターの文字列にし、decode はそれを読み戻します。不正な値は拒否するので、パラメーターは URL から自動的に取り除かれます。
  • locator — 検索。 locate はデコードした識別子が現在のコレクションのどこにあるかを探します(なくなっていれば null)。identify は書き込み時に位置を識別子へ戻します。任意の locateAsync は、見つからなかったときにウィンドウ方式や無限フィードをページングします。

分けておくことで、どの通信形式とどの検索でも組み合わせられます。たとえば、安定した id の codec とページングする locator です。

インデックスのキーと安定した id のキー

組み込みの 2 つのキーがこの対を作ってくれます。違いは URL が何を指すかだけです:

  • urlIndexKey(() => count) は 位置 で指します(?photo=3)。いちばん単純ですが、リストの並びが変わるとブックマークは別のアイテムを開きます。
  • urlStableIdKey({ items }) は各アイテムの安定した id で指し(?photo=post_42)、現在のリストを走査します。並べ替えのあともブックマークは同じアイテムを指し、アイテムがなくなればきれいに外れます。hashCodec: base64UrlCodec を指定すると、id を base64url で見えにくくします(元に戻せる変換で、暗号学的ハッシュではありません)。

ウィンドウ方式のフィードをページングする場合は? 組み込みの 2 つのキーはどちらも任意の locateAsync を受け取ります。urlIndexKey(() => count, locateAsync) と urlStableIdKey({ items, locateAsync }) です。同期の検索は読み込み済みのものに答え、見つからなければ残りをページングして読み込むので、ウィンドウの外を指す共有リンクも開きます。codec や locator を手書きする必要はありません。

二軸の場合は? urlIndexTwoAxisKey は、投稿と内側のメディアのインデックスを ?p=<outer>.<inner> で運びます。すべてのオプションは Core API リファレンス にあります。

既読の状態

閲覧者がギャラリーのどこまで見たかを、リロードやタブをまたいで覚えておきます。見たものを示すリングや、前回の続きから開くギャラリーに使えます。URL の状態と同じモデルをアドレスバーではなくストレージに向けたものなので、2 つは同じキーを共有します。

仕組み

createViewedStateController は、?photo= リンクが持つのとまったく同じ文字列でエントリーを保存し、同じ decode から locate への流れで読み戻します。保存された位置をそのまま信用することはありません。1 つのキーを両方に展開すれば、ブックマークと保存されたエントリーは同じ文字列になります。

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

持続性はキーで決まる

ストアは独自の補正を加えないので、何が残るかは展開したキーで決まります。id で指すキーはコレクションが並べ替えられても位置を保ち、位置で指すキーは保ちません。保存されるエントリーは閲覧回数の合計ではなく到達したいちばん先の地点を表すので、途中のアイテムを削除すると件数が減ります。共有リンクと同じ自己修復です。

読み込みは同期のみです。アイテムがまだ読み込まれていないエントリーは存在しないものとして読まれ、ストレージにはそのまま残るので、ウィンドウ方式のフィードが自分の履歴を消してしまうことはありません。

ストレージと有効期限

デフォルトでは localStorage に保存します。createSessionStorageAdapter() はタブを閉じると忘れ、独自の StorageAdapter を使えば同期的に扱える任意の場所に保存できます。attach() までは何も読まないので、サーバーでの描画とクライアントでの最初の描画が一致します。

エントリーは明示的に忘れるまで残ります。代わりに ttlMs を渡すと期限切れにできます。期限はトラックごとで、スライドする時計なので、まだ見られている場所が放置された場所と一緒に古くなることはありません。書き込まれる内容は変わり、各エントリーが [wire, timestamp] の組になりますが、読み込みはオプションに関係なくどちらの形式も扱えるので、有効にする前に保存されたエントリーは削除されずに新しいものとして数えられます。maxTracks は期間ではなく件数を制限します。上限を超えると、次の書き込みで記録がいちばん古いトラックが削除されます。トラックを記録すると、すでに通過した位置であっても、そのトラックは列の最後尾に移ります。すべてのオプションは Core API リファレンス にあります。

自動再生

ひとりでに進むもののためのタイミングです。Stories Player は各ストーリーを createTimerController で動かし、Lightbox はそれを土台にした createSlideshowController でスライドショーを動かします。どちらも requestAnimationFrame で進み、一時停止しても進み具合は正確に保たれます。

createTimerController

1 つの長さを 0 から 1 まで数え、終わるとコールバックを呼びます。start() は常に 0 から始まり、その回だけ別の長さを渡すこともできます。pause() と resume() は位置を正確に保ちます。

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

ひとりでに進むギャラリーの裏にあるステートマシンです。オンになっていて、何にも止められておらず、ページが表示されている間だけ動きます。ページの表示状態は自分で監視し、それ以外はあなたが伝えます。画像は読み込みが終わった時点から間隔を数えるので、遅い画像が見られないまま飛ばされることはありません。動画は終わったときに進むので、ループではなく 1 回だけ再生する必要があります。何かがスライドショーを止めている間に終わった動画は、それが解除されたときに進みます。

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

設定、状態、メソッドの全体は Core API リファレンスにあります。

ダウンロード

downloadFile(url, filename?) はファイルを filename という名前で保存します。既定は URL のパスの最後の部分です。別オリジンのリンクでは download 属性が無視されるため、ファイルを fetch して blob を保存します。fetch が失敗したりサーバーがエラーを返したりしたとき(よくあるのは CORS ヘッダーのない別オリジンのサーバーです)は、URL を新しいタブで開くので、読者はそれでもファイルを手に入れられます。reject することはなく、ウィンドウがなければ何もしません。Core API リファレンスを参照してください。

次のステップ