diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 882918e..2900925 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,6 +1,7 @@ name: CI on: + push: pull_request: types: [opened, synchronize] @@ -20,5 +21,8 @@ jobs: - name: Install dependencies run: bun install + - name: Install dependencies + run: bun run test + - name: Build run: bun run build \ No newline at end of file diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 724b6b2..bab8be3 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -24,5 +24,11 @@ jobs: uses: oven-sh/setup-bun@v2 - run: bun ci - - run: bun run build --if-present - - run: bunx npm publish --provenance \ No newline at end of file + - run: bun run build + - run: | + TAG="" + if [[ "$GITHUB_REF_NAME" == *"-beta"* ]]; then TAG="--tag beta"; fi + if [[ "$GITHUB_REF_NAME" == *"-alpha"* ]]; then TAG="--tag alpha"; fi + if [[ "$GITHUB_REF_NAME" == *"-rc"* ]]; then TAG="--tag rc"; fi + bunx npm publish --provenance $TAG + working-directory: packages/svelte-dnd \ No newline at end of file diff --git a/.gitignore b/.gitignore index 32ca9a2..233fef5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,39 @@ +# dependencies (bun install) node_modules -# Output -.output -.vercel -.netlify -.wrangler -/.svelte-kit -/build -/dist +# output +out +dist +*.tgz -# OS -.DS_Store -Thumbs.db +# code coverage +coverage +*.lcov -# Env +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files .env -.env.* -!.env.example -!.env.test +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo -# Vite -vite.config.js.timestamp-* -vite.config.ts.timestamp-* -/old +# IntelliJ based IDEs +.idea +# Finder (MacOS) folder config .DS_Store +/.turbo + + +.turbo +.vercel diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml index a55e7a1..79ee123 100644 --- a/.idea/codeStyles/codeStyleConfig.xml +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -1,5 +1,5 @@ - \ No newline at end of file diff --git a/.idea/svelte-dnd.iml b/.idea/svelte-dnd.iml index 25dca50..f875039 100644 --- a/.idea/svelte-dnd.iml +++ b/.idea/svelte-dnd.iml @@ -2,7 +2,13 @@ + + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a82526f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,209 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [v1.0.0] - 2026-05-11 + +First stable release. + +### Breaking Changes + +- **`scroll` config replaced by `behaviors[]`**. Auto-scroll and scroll-sync are now `Behavior` plugins; built-in factories `autoScroll()` and `scrollSync()`, both included by default. Per-droppable override via `sortable({ behaviors })` / `target({ behaviors })`. `controller.setScrollConfig` removed — use `controller.setBehaviors`. `ScrollConfig` type replaced by `AutoScrollConfig`. +- **Simulator API consolidated**. `simulateReturn`, `simulateDrop`, `simulateSwap`, `simulateBatchSwap` are replaced by `controller.animateItem` and `controller.animateLayout`. `SimulateOptions` removed; new types `AnimateItemOptions`, `AnimateLayoutOptions`, `ContainerPosition`. +- **Animation config unified under `animation`**. Top-level `preview` option on `DndController` removed. Renames: `dropDuration` → `drop`, `returnDuration` → `return`, `slotCollapseDuration` → `slotCollapse`, `swapDuration` → `layout`. Preview delays moved into `animation.preview.show` / `animation.preview.hide` as `DelayedTransition` (`{ delay, duration, easing }`). `siblingShift` and `ghostResize` take a `Transition`; `drop`, `return`, `slotCollapse`, `layout`, `keyboardFlight` take a number or `Transition`. +- **`controller.previewConfig` and `setPreviewConfig` removed**. Read from `controller.animation`, patch via `controller.setAnimation`. +- **Ghost rotation no longer applied by the library**. Removed CSS vars `--dnd-ghost-rotation`, `--dnd-ghost-rotation-duration` and the `.dnd-ghost--returning` reset rule. Implement rotation in custom CSS if needed. +- **`PreviewConfig` and `ScrollConfig` types are no longer exported**. Use `AnimationConfig` / `AutoScrollConfig` instead. + +### Features + +- **Sortable virtualization** via `sortable({ virtual })`. New `VirtualSource` and `SortableSource` types let a virtualizer drive zone geometry from live slot rects. Tested with virtua; other virtualizers should work via the same interface but are unverified. Grid layout falls back to DOM mode. +- **Pluggable `Behavior` plugins**. Custom plugins implement `wrapDropAnimation(next, ctx)` and/or expose `autoScrollConfig`. New exports: `Behavior`, `BehaviorContext`, `AutoScrollConfig`, `autoScroll`, `scrollSync`, `ScrollSyncOptions`. +- **`scrollSync({ threshold })`** — engagement gated by the destination slot's visible fraction. +- **Ghost & preview auto-resize to the destination's item size**, mixing target-sibling and dragged-item dimensions per layout. Exposed reactively as `controller.dropPreviewSize`. CSS vars `--dnd-ghost-resize-duration` / `--dnd-ghost-resize-easing` driven by `animation.ghostResize`. +- **Keyboard navigation expansions**. `Home` / `End`, cross-axis hops between sibling containers, same-row / same-column grid movement, per-keystroke ghost flight to the live slot rect, lockstep scroll-into-view for off-screen targets. Iterates the full position list, so virtualized slots stay reachable. Tunable via `animation.keyboardFlight`. +- **Configurable easing for every animation**. rAF-driven steps accept a number or `{ duration, easing }`; CSS-driven steps (preview show/hide, `siblingShift`, `ghostResize`) gain matching easing fields. `scrollSync` inherits the wrapped step's easing. +- **`parseEasing(str)` helper** exported for custom `AnimationStep` implementations. +- **New CSS vars** `--dnd-preview-easing-in`, `--dnd-preview-easing-out`, `--dnd-ghost-resize-easing` written from `controller.animation`; user CSS still overrides. +- **Runtime setters**: `setBehaviors`, `setAnimation` (deep-merge), `setSensors`, `setAnnouncements`, `setModifiers`, `setDebug`. No need to recreate the controller. +- **Default flex direction for sortable `DndDroppable`** via `:where(.dnd-droppable[data-dnd-layout='vertical'|'horizontal'])`. Consumer classes (Tailwind, custom) win without `!important`. Grid sortables get no default. +- **`Droppable.itemCount` and `Droppable.isVirtualized`** getters expose data length and virtualization mode. +- **Per-call `behaviors` / `easing` override on `animateItem`**. +- **`animateLayout({ morph: true })`** copies missing classes from pre- to post-state element so class-driven CSS transitions run in lockstep with the FLIP transform. +- **New exports**: `GhostSnippet`, `GhostSnippetProps`, `ZonesInvalidatedCallback`. + +### Bug Fixes + +- Honour explicit `spacing={0}` on `DndDraggable`. +- Clamp sortable drop zones to the container's scroll viewport. +- Siblings no longer jump during slot collapse in scrollable sources. +- Drop no longer fights the virtualizer's scroll-jump compensation. +- Ghost tracks the tail preview correctly when the source slot collapses. +- Repeated `preview.hide()` calls no longer leak collapse timers. +- Overlapping `animateLayout` invocations reject with a clear error instead of corrupting state silently. +- `scrollSync` now engages on tail previews in empty containers (degenerate-rect case). +- Keyboard ghost flight reads the post-update tail spacing, so the first key after a drop lands at the correct rect. +- `KeyboardSensor` no longer activates twice when `Enter` / `Space` bubbles from nested draggables. +- Keyboard navigation no longer triggers auto-scroll (auto-scroll was tied to pointer updates). +- Reactive prop updates on `DndDraggable` / `DndDroppable` / `DndPreview` propagate from the first read — entities are no longer pinned to the initial snapshot. +- Post-drop rect reads in `animateLayout` happen against the updated DOM, fixing stale FLIP transforms. +- `package.json` gains `exports.import` and `exports.default` conditions for correct ESM resolution. +- Removed `aria-grabbed` from the dragged element (deprecated in ARIA 1.1). + +### Performance + +- Droppables mounting or unmounting mid-drag update zone derivations reactively instead of going stale until the next pointermove. +- First-slot rect cached per drag session, avoiding a synchronous layout on every pointermove when computing `dropPreviewSize`. + +### Docs + +- New examples: virtualization, target-zones, behaviors live demo. +- New pages: behaviors, `VirtualSource`, SSR FAQ. Examples updated to the consolidated simulator API and runtime setters. + +## [v1.0.0-rc.1] - 2026-04-19 + +Release candidate for v1.0. Public API stabilized — please report regressions before stable. + +### Breaking Changes + +- **Strategy-instance API on `DndDroppable`**. The `mode` and `direction` props are replaced with a single `strategy` prop that takes a strategy instance. Factories exported: `sortable({ layout, flow })`, `target()`. Custom strategies implement the `ContainerStrategy` interface. +- **`DndLayout` replaces `DndDirection`**. New values: `'vertical' | 'horizontal' | 'grid'`. +- **Rich event objects replace primitive callback arguments**. `onDragStart`, `onDragEnd`, `onDrop`, `onDragOver`, `onDropCancelled` now receive structured `{ item, source, target, … }` objects (`DragStartEvent`, `DropEvent`, `DragEndEvent`, `DragOverEvent`, `DropCancelledEvent`). +- **Per-item drag callbacks removed from `DndDraggable`**. Subscribe via `controller.onDragStart(…)` / `onDrop(…)` / `onDragEnd(…)` / `onDropCancelled(…)` / `onDragOver(…)` instead. +- **Collision API replaces `overlap` prop**. `DndDroppable` now accepts a `collision` prop (or set globally via `DndController({ collision })`) conforming to the `CollisionAlgorithm` contract. Built-ins: `centerPoint` (default), `cursorOver`, `overlap`, `closestCenter`. +- **`overlap()` takes a flat threshold**: `overlap(25)` or `overlap('25%')` instead of the previous object form. +- **`centerPoint` semantics split**: it now tests the ghost's center; the previous cursor-based behaviour is now `cursorOver`. +- **`DndSimulator` class no longer exported**. Use `controller.simulateReturn / simulateDrop / simulateSwap / simulateBatchSwap` instead. +- **`DndState` class no longer exported** (type export remains for advanced use). +- **`--dnd-slot-spacing` CSS variable removed**. Use the reactive `spacing` prop on `DndDroppable` instead. +- **`DropPreview` shape simplified**: `.visible` flag removed (use nullable `DropPreview | null`); ghost-size fields unified into `ghostSize` on the controller. + +### Features + +- **Grid layout**. `sortable({ layout: 'grid', flow: 'row' | 'column' })` with dedicated grid zone geometry and cross-container slot animations. +- **Sensor system + keyboard accessibility**. Configurable `sensors` on `DndController` (defaults to `[PointerSensor, KeyboardSensor]`) with per-item override on `DndDraggable`. `KeyboardSensor` enables Space/Enter to grab, arrow keys to navigate, Escape to cancel. ARIA live region built into `DndProvider`, plus customizable `announcements` (with `defaultAnnouncements` helper) on `DndController`. Exports: `PointerSensor`, `KeyboardSensor`, `Distance`, `Delay`, plus `SensorDescriptor` / `DistanceConfig` / `DelayConfig` types. +- **Transform modifiers**. Pluggable `Modifier` pipeline on `DndController`. Built-ins: `restrictToVerticalAxis`, `restrictToHorizontalAxis`, `restrictToContainer`, `snapToGrid`. +- **Pluggable collision algorithms** as described above. +- **`onDragOver` event**. Fires when the drag-over target container or position changes. +- **Simulator expansions**. New `simulateSwap` (two-item swap) and `simulateBatchSwap` (FLIP-based multi-item reorder). `simulateReturn` / `simulateDrop` accept `SimulateOptions` with `emitEvents` to fire real `onDrop` / `onDropCancelled`. +- **`stopOnDrop` scroll option** on `ScrollConfig`. +- **Reactive `spacing` prop** on `DndDroppable` (replaces the removed CSS var). +- **Explicit runtime error** when `DndDraggable` / `DndDroppable` render outside a `DndProvider`. + +### Bug Fixes + +- Slot `position` now syncs reactively with the prop, preventing stale order after `{#each}` reorders. +- Variable-height previews: correct alignment, slot sizing and ghost positioning in both vertical and horizontal lists. +- Cross-container slot size uses the dragged element's own dimensions plus the target container's gap. +- Drop animation no longer resets scroll position; auto-scroll stops cleanly before the ghost flight. +- Drop-animation flags reset on each new drag session so rapid successive drags render correctly. +- Reactive props on `DndDroppable` / `DndDraggable` (`disabled`, `data`, `type`, `accepts`, `collision`, `strategy`, `spacing`, `sensors`) propagate live to the underlying entities. +- Container IDs with special characters work (querySelector escaping). +- `disabled` on `DndDraggable` blocks drag activation in all sensors. +- `KeyboardSensor` deferred listener is cleaned up on destroy. +- Preview `hidePreviewTimeout` cleared on destroy; `clearAll` cleanup regression fixed. +- `data-dnd-scroll` auto-scroll honours scope again; `scheduleRefresh` deduplicated during auto-scroll. + +### Internal + +- Entity-based architecture (`Draggable`, `Droppable`, `Preview`, `Slot`, `DragSession`) replaces the legacy handler/registrar/session-manager plumbing; strategies read entity state directly rather than traversing the DOM. +- `DndController` split into `DragSessionManager` + `DropAnimationCoordinator` + `TranslationEngine` + `DropResolver`. +- Layout snapshot captured at drag start (transform-free) so reactive transforms never feed back into zone calculations. +- Internal-only surface marked with `@internal`. +- Custom-strategy primitives exposed for advanced use: `AnimationStep`, `InstantStep`, `GhostToTargetStep`, `GhostReturnStep`, `StrategyBindContext`, `ContainerStrategy`, `Droppable`, `DragSession`, `DragSource`. + +### Docs + +- New pages: sensors, collision, modifiers, accessibility, custom strategies, `DndController` API, simulations. +- Home page redesign. + +## [v1.0.0-beta.1] - 2025-04-06 + +Complete rewrite with a new modular architecture. All previous APIs have changed — see the documentation. + +### Breaking Changes +- `DragController` renamed to `DndController` +- `position` is now a required prop on `DndDraggable` +- Type filtering moved from `data={{ type, accepts }}` to dedicated `type` / `accepts` props +- `DndPreview` is no longer used directly — remove all manual placements + +### Features +- Drop previews and dragged item visibility handled automatically — no manual `DndPreview`, `hiddenId`, or `visibleItems` needed +- `onDragStart` / `onDragEnd` callbacks no longer return an unsubscribe function — cleanup is automatic +- `DndSimulator` for programmatic drag simulation +- `mode="target"` on `DndDroppable` for non-sortable drop zones (trash, board columns) +- `overlap` prop for intersection-based hit detection +- `onDropCancelled` callback +- `PreviewConfig` and `ScrollConfig` for fine-tuning animations and auto-scroll +- `DndControllerConfig` constructor config on `DndController` +- Touch support: long-press delay, momentum scroll, configurable scroll cancel threshold +- SSR compatibility + +### Bug Fixes +- Post-drop translation snap eliminated +- Ghost return animation is scroll-aware +- Drop preview correctly scoped in nested containers +- Drag activation prevented in draggable padding area + +### Build +- Migrated to Turborepo monorepo + +### Docs +- New docs with live examples + +## [v0.3.0] - 2025-02-19 + +### Features +- Drag handle support via `data-dnd-handle` attribute +- Cursor styling applied to handles only +- Multiple handles per item supported + +### Bug Fixes +- Drop zone visibility in debug overlay +- Drag event propagation in nested draggables +- Debug overlay state persistence + +### Internal +- Data attributes now use the `data-dnd-` prefix to prevent conflicts with other libraries + +### Documentation +- Updated attribute references +- Added drag handle examples +- Expanded FAQ + +## [v0.2.0] - 2025-02-17 + +### Features +- Ghost animation returns to origin with scrolling during cancelled drags +- Auto-scroll functionality scoped to `data-dnd-scroll` attribute + +### Bug Fixes +- Mobile support improvements +- Drop zones clipped to visible container viewport boundaries + +### Refactoring +- DOM operations and animation logic separated into dedicated modules + +## [v0.1.2] - 2025-02-14 + +No notable changes. + +## [v0.1.1] - 2025-02-14 + +### Bug Fixes +- Fixed placeholder flicker during drag initiation + +## [v0.1.0] - 2025-02-14 + +Initial release. + +[v1.0.0]: https://github.com/Horuse/svelte-dnd/compare/v1.0.0-rc.1...v1.0.0 +[v1.0.0-rc.1]: https://github.com/Horuse/svelte-dnd/compare/v1.0.0-beta.1...v1.0.0-rc.1 +[v1.0.0-beta.1]: https://github.com/Horuse/svelte-dnd/compare/v0.3.0...v1.0.0-beta.1 +[v0.3.0]: https://github.com/Horuse/svelte-dnd/compare/v0.2.0...v0.3.0 +[v0.2.0]: https://github.com/Horuse/svelte-dnd/compare/v0.1.2...v0.2.0 +[v0.1.2]: https://github.com/Horuse/svelte-dnd/compare/v0.1.1...v0.1.2 +[v0.1.1]: https://github.com/Horuse/svelte-dnd/compare/v0.1.0...v0.1.1 +[v0.1.0]: https://github.com/Horuse/svelte-dnd/releases/tag/v0.1.0 diff --git a/README.md b/README.md deleted file mode 100644 index 121b55d..0000000 --- a/README.md +++ /dev/null @@ -1,115 +0,0 @@ -# @horuse/svelte-dnd - -[![npm](https://img.shields.io/npm/v/@horuse/svelte-dnd.svg?style=flat-square)](https://www.npmjs.com/package/@horuse/svelte-dnd) -[![GitHub issues](https://img.shields.io/github/issues/Horuse/svelte-dnd.svg?style=flat-square)](https://github.com/Horuse/svelte-dnd/issues) - -A drag-and-drop library for Svelte 5 with animated drop previews, auto-scroll, and multi-container support. - -![DND preview](https://github.com/Horuse/svelte-dnd/blob/main/static/preview.gif?raw=true) - -## Features - -- Vertical, horizontal layouts -- Pointer & touch support - works seamlessly on mobile devices -- Animated drop previews that follow the dragged item -- Auto-scroll when dragging near container edges -- Move items between multiple containers (kanban-style) -- Custom ghost element via Svelte snippets -- Zero dependencies beyond Svelte 5 - -## Installation - -```bash -npm install @horuse/svelte-dnd -``` - -## Basic Example - -A minimal working drag-and-drop setup requires four components: `DndProvider`, `DndDroppable`, `DndDraggable`, and `DndPreview`. - -```svelte - - - - - {#each visibleItems as item, index (item.id)} - - - - {item.label} - - {/each} - - - - -``` - -## How It Works - -1. **DndProvider** wraps your app and creates a `DragController` context. -2. **DndDroppable** defines a container where items can be dropped. Set `direction` to `"vertical"` or `"horizontal"`. -3. **DndDraggable** wraps each draggable item. Each must have a unique `id`. -4. **DndPreview** renders a placeholder at each potential drop position. Place one before each item and one after the last item. -5. **Hide the dragged item** — subscribe to `onDragStart` / `onDragEnd` and filter out the dragged item from the rendered list. This removes the original element from the DOM flow so only the ghost follows the cursor. -6. Use `controller.onDrop()` to handle reordering logic when an item is dropped. - -## Documentation - -Full docs and live examples are available at the [documentation site](https://svelte-dnd.vercel.app). diff --git a/README.md b/README.md new file mode 120000 index 0000000..94559f8 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +packages/svelte-dnd/README.md \ No newline at end of file diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/apps/docs/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/.npmrc b/apps/docs/.npmrc similarity index 100% rename from .npmrc rename to apps/docs/.npmrc diff --git a/apps/docs/.prettierignore b/apps/docs/.prettierignore new file mode 100644 index 0000000..7d74fe2 --- /dev/null +++ b/apps/docs/.prettierignore @@ -0,0 +1,9 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ diff --git a/apps/docs/.prettierrc b/apps/docs/.prettierrc new file mode 100644 index 0000000..3d918d4 --- /dev/null +++ b/apps/docs/.prettierrc @@ -0,0 +1,17 @@ +{ + "useTabs": true, + "tabWidth": 4, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ], + "tailwindStylesheet": "./src/routes/app.css" +} diff --git a/apps/docs/README.md b/apps/docs/README.md new file mode 100644 index 0000000..42b69dd --- /dev/null +++ b/apps/docs/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +bun x sv@0.12.8 create --template minimal --types ts --add prettier eslint tailwindcss="plugins:none" mdsvex --install bun ./ +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/apps/docs/bun.lock b/apps/docs/bun.lock new file mode 100644 index 0000000..46b5347 --- /dev/null +++ b/apps/docs/bun.lock @@ -0,0 +1,665 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "docs", + "devDependencies": { + "@eslint/compat": "^2.0.2", + "@eslint/js": "^9.39.2", + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/kit": "^2.55.0", + "@sveltejs/package": "^2.5.7", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^22", + "eslint": "^10.0.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.14.0", + "globals": "^17.3.0", + "mdsvex": "^0.12.6", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.4.1", + "prettier-plugin-tailwindcss": "^0.7.2", + "publint": "^0.3.17", + "shiki": "^3.22.0", + "svelte": "^5.53.13", + "svelte-check": "^4.4.0", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "typescript-eslint": "^8.54.0", + "vite": "^7.3.1", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/compat": ["@eslint/compat@2.0.3", "", { "dependencies": { "@eslint/core": "^1.1.1" }, "peerDependencies": { "eslint": "^8.40 || 9 || 10" }, "optionalPeers": ["eslint"] }, "sha512-SjIJhGigp8hmd1YGIBwh7Ovri7Kisl42GYFjrOyHhtfYGGoLW6teYi/5p8W50KSsawUPpuLOSmsq1bD0NGQLBw=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.3", "", { "dependencies": { "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.5.3", "", { "dependencies": { "@eslint/core": "^1.1.1" } }, "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw=="], + + "@eslint/core": ["@eslint/core@1.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ=="], + + "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.3", "", {}, "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.1", "", { "dependencies": { "@eslint/core": "^1.1.1", "levn": "^0.4.1" } }, "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@publint/pack": ["@publint/pack@0.1.4", "", {}, "sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + + "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], + + "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], + + "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + + "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="], + + "@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.1", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ=="], + + "@sveltejs/kit": ["@sveltejs/kit@2.55.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA=="], + + "@sveltejs/package": ["@sveltejs/package@2.5.7", "", { "dependencies": { "chokidar": "^5.0.0", "kleur": "^4.1.5", "sade": "^1.8.1", "semver": "^7.5.4", "svelte2tsx": "~0.7.33" }, "peerDependencies": { "svelte": "^3.44.0 || ^4.0.0 || ^5.0.0-next.1" }, "bin": { "svelte-package": "svelte-package.js" } }, "sha512-qqD9xa9H7TDiGFrF6rz7AirOR8k15qDK/9i4MIE8te4vWsv5GEogPks61rrZcLy+yWph+aI6pIj2MdoK3YI8AQ=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="], + + "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], + + "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], + + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/type-utils": "8.57.1", "@typescript-eslint/utils": "8.57.1", "@typescript-eslint/visitor-keys": "8.57.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", "@typescript-eslint/typescript-estree": "8.57.1", "@typescript-eslint/visitor-keys": "8.57.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.1", "@typescript-eslint/types": "^8.57.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.1", "", { "dependencies": { "@typescript-eslint/types": "8.57.1", "@typescript-eslint/visitor-keys": "8.57.1" } }, "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.57.1", "", { "dependencies": { "@typescript-eslint/types": "8.57.1", "@typescript-eslint/typescript-estree": "8.57.1", "@typescript-eslint/utils": "8.57.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.57.1", "", {}, "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.1", "@typescript-eslint/tsconfig-utils": "8.57.1", "@typescript-eslint/types": "8.57.1", "@typescript-eslint/visitor-keys": "8.57.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", "@typescript-eslint/typescript-estree": "8.57.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.1", "", { "dependencies": { "@typescript-eslint/types": "8.57.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "dedent-js": ["dedent-js@1.0.1", "", {}, "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + + "esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@10.0.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.3", "@eslint/config-helpers": "^0.5.2", "@eslint/core": "^1.1.1", "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ=="], + + "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], + + "eslint-plugin-svelte": ["eslint-plugin-svelte@3.15.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", "globals": "^16.0.0", "known-css-properties": "^0.37.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", "svelte-eslint-parser": "^1.4.0" }, "peerDependencies": { "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-k4Nsjs3bHujeEnnckoTM4mFYR1e8Mb9l2rTwNdmYiamA+Tjzn8X+2F+fuSP2w4VbXYhn2bmySyACQYdmUDW2Cg=="], + + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrap": ["esrap@2.2.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@typescript-eslint/types": "^8.2.0" } }, "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@17.4.0", "", {}, "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdsvex": ["mdsvex@0.12.7", "", { "dependencies": { "@types/mdast": "^4.0.4", "@types/unist": "^2.0.3", "prism-svelte": "^0.4.7", "prismjs": "^1.17.1", "unist-util-visit": "^2.0.1", "vfile-message": "^2.0.4" }, "peerDependencies": { "svelte": "^3.56.0 || ^4.0.0 || ^5.0.0-next.120" } }, "sha512-gx4bReLCUvq+MPErHXYeyX+TEq1hsS2KfiZtEOMNTcbibSouFy8AHc5h04KbGCl+g5tLuo4/lbgRVYRnc7bJZw=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="], + + "postcss-safe-parser": ["postcss-safe-parser@7.0.1", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A=="], + + "postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + + "prettier-plugin-svelte": ["prettier-plugin-svelte@3.5.1", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg=="], + + "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.2", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA=="], + + "prism-svelte": ["prism-svelte@0.4.7", "", {}, "sha512-yABh19CYbM24V7aS7TuPYRNMqthxwbvx6FF/Rw920YbyBWO3tnyPIqRMgHuSVsLmuHkkBS1Akyof463FVdkeDQ=="], + + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "publint": ["publint@0.3.18", "", { "dependencies": { "@publint/pack": "^0.1.4", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "sade": "^1.8.1" }, "bin": { "publint": "src/cli.js" } }, "sha512-JRJFeBTrfx4qLwEuGFPk+haJOJN97KnPuK01yj+4k/Wj5BgoOK5uNsivporiqBjk2JDaslg7qJOhGRnpltGeog=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + + "scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "svelte": ["svelte@5.54.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg=="], + + "svelte-check": ["svelte-check@4.4.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw=="], + + "svelte-eslint-parser": ["svelte-eslint-parser@1.6.0", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0", "semver": "^7.7.2" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-qoB1ehychT6OxEtQAqc/guSqLS20SlA53Uijl7x375s8nlUT0lb9ol/gzraEEatQwsyPTJo87s2CmKL9Xab+Uw=="], + + "svelte2tsx": ["svelte2tsx@0.7.52", "", { "dependencies": { "dedent-js": "^1.0.1", "scule": "^1.3.0" }, "peerDependencies": { "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", "typescript": "^4.9.4 || ^5.0.0" } }, "sha512-svdT1FTrCLpvlU62evO5YdJt/kQ7nxgQxII/9BpQUvKr+GJRVdAXNVw8UWOt0fhoe5uWKyU0WsUTMRVAtRbMQg=="], + + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "typescript-eslint": ["typescript-eslint@8.57.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.57.1", "@typescript-eslint/parser": "8.57.1", "@typescript-eslint/typescript-estree": "8.57.1", "@typescript-eslint/utils": "8.57.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unist-util-is": ["unist-util-is@4.1.0", "", {}, "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@2.0.3", "", { "dependencies": { "@types/unist": "^2.0.2" } }, "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g=="], + + "unist-util-visit": ["unist-util-visit@2.0.3", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^4.0.0", "unist-util-visit-parents": "^3.0.0" } }, "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@3.1.1", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^4.0.0" } }, "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@2.0.4", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-stringify-position": "^2.0.0" } }, "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ=="], + + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], + + "hast-util-to-html/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "mdast-util-to-hast/unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "svelte-check/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "svelte-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "svelte-eslint-parser/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "svelte-eslint-parser/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], + + "unist-util-position/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "vfile/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "vfile/vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "mdast-util-to-hast/unist-util-visit/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "mdast-util-to-hast/unist-util-visit/unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "mdast-util-to-hast/unist-util-visit/unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "svelte-check/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "vfile/vfile-message/unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + } +} diff --git a/eslint.config.js b/apps/docs/eslint.config.js similarity index 92% rename from eslint.config.js rename to apps/docs/eslint.config.js index 9690379..bf092d2 100644 --- a/eslint.config.js +++ b/apps/docs/eslint.config.js @@ -13,10 +13,10 @@ const gitignorePath = path.resolve(import.meta.dirname, '.gitignore'); export default defineConfig( includeIgnoreFile(gitignorePath), js.configs.recommended, - ...ts.configs.recommended, - ...svelte.configs.recommended, + ts.configs.recommended, + svelte.configs.recommended, prettier, - ...svelte.configs.prettier, + svelte.configs.prettier, { languageOptions: { globals: { ...globals.browser, ...globals.node } }, rules: { diff --git a/apps/docs/package.json b/apps/docs/package.json new file mode 100644 index 0000000..fd48ac0 --- /dev/null +++ b/apps/docs/package.json @@ -0,0 +1,52 @@ +{ + "name": "docs", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --check . && eslint .", + "format": "prettier --write ." + }, + "devDependencies": { + "@eslint/compat": "^2.0.2", + "@eslint/js": "^9.39.2", + "@horuse/svelte-dnd": "workspace:*", + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/adapter-vercel": "^6.3.3", + "@sveltejs/kit": "^2.56.1", + "@sveltejs/package": "^2.5.7", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^22", + "eslint": "^10.0.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.14.0", + "globals": "^17.3.0", + "mdsvex": "^0.12.6", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.4.1", + "prettier-plugin-tailwindcss": "^0.7.2", + "publint": "^0.3.17", + "shiki": "^3.22.0", + "svelte": "^5.55.1", + "svelte-check": "^4.4.0", + "tailwindcss": "^4.1.18", + "turbo": "^2.9.3", + "typescript": "^5.9.3", + "typescript-eslint": "^8.54.0", + "vite": "^7.3.1" + }, + "imports": { + "#app.css": "./src/app.css" + }, + "dependencies": { + "virtua": "^0.49.1" + } +} diff --git a/src/app.css b/apps/docs/src/app.css similarity index 70% rename from src/app.css rename to apps/docs/src/app.css index dfb8288..4fdd703 100644 --- a/src/app.css +++ b/apps/docs/src/app.css @@ -1,6 +1,6 @@ @import 'tailwindcss'; -@plugin '@tailwindcss/typography'; +/*@plugin '@tailwindcss/typography';*/ @custom-variant dark (&:where(.dark, .dark *)); @@ -49,6 +49,9 @@ --opacity-2: 2%; --opacity-1_5: 1.5%; --opacity-1: 1%; + + --font-sans: "Inter", sans-serif; + --font-mono: "Roboto Mono", monospace; } .dark { @@ -103,11 +106,74 @@ @layer base { body { - @apply bg-background + @apply bg-background; } button { - @apply cursor-pointer + @apply cursor-pointer; + } + + *::-webkit-scrollbar { + @apply w-2 h-2; + } + *::-webkit-scrollbar-track, + *::-webkit-scrollbar-corner { + @apply bg-transparent; + } + *::-webkit-scrollbar-thumb { + @apply rounded-full border-2 border-transparent; + } + + input[type="range"] { + @apply w-full h-5 bg-transparent cursor-pointer appearance-none; + -webkit-appearance: none; + } + input[type="range"]:focus { @apply outline-none; } + input[type="range"]:disabled { @apply cursor-not-allowed opacity-50; } + + /* WebKit / Chromium */ + input[type="range"]::-webkit-slider-runnable-track { + @apply h-1.5 rounded-full; + background: color-mix(in oklch, var(--color-theme) 15%, transparent); + } + input[type="range"]::-webkit-slider-thumb { + @apply w-4 h-4 rounded-full appearance-none border-0 transition-transform duration-150 ease-out; + -webkit-appearance: none; + background: var(--color-theme); + margin-top: -5px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + } + input[type="range"]::-webkit-slider-thumb:hover { @apply scale-110; } + input[type="range"]:focus-visible::-webkit-slider-thumb { + box-shadow: 0 0 0 4px color-mix(in oklch, var(--color-theme) 25%, transparent); + } + + /* Firefox */ + input[type="range"]::-moz-range-track { + @apply h-1.5 rounded-full border-0; + background: color-mix(in oklch, var(--color-theme) 15%, transparent); + } + input[type="range"]::-moz-range-thumb { + @apply w-4 h-4 rounded-full border-0 transition-transform duration-150 ease-out; + background: var(--color-theme); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + } + input[type="range"]::-moz-range-thumb:hover { @apply scale-110; } + input[type="range"]:focus-visible::-moz-range-thumb { + box-shadow: 0 0 0 4px color-mix(in oklch, var(--color-theme) 25%, transparent); + } + + * { + scrollbar-width: thin; + scrollbar-color: color-mix(in oklch, var(--color-theme) 25%, transparent) transparent; + } + *::-webkit-scrollbar-thumb { + background: color-mix(in oklch, var(--color-theme) 25%, transparent); + background-clip: padding-box; + } + *::-webkit-scrollbar-thumb:hover { + background: color-mix(in oklch, var(--color-theme) 40%, transparent); + background-clip: padding-box; } } @@ -146,12 +212,29 @@ } .drag-item { - @apply p-4 flex whitespace-nowrap items-center text-lg justify-center bg-primary border-dashed border-2 text-neutral-500 rounded-xl border-primary-border shadow-sm transition-all; + @apply p-4 flex whitespace-nowrap items-center text-lg justify-center bg-primary border-dashed border-2 text-neutral-500 rounded-xl border-primary-border transition-all; @variant hover { @apply bg-primary-hover border-primary-hover-border } } + + + .warning-block { + @apply text-sm flex flex-col dark:border-amber-500/20 dark:text-amber-500 border-amber-500/50 border-dashed border gap-2 bg-amber-500/10 p-4 rounded-xl text-amber-500; + } + + .info-block { + @apply text-sm flex flex-col dark:border-blue-500/20 border-blue-500/50 border-dashed border gap-2 bg-blue-500/10 p-4 rounded-xl text-blue-500; + } + + .warning-block a { + @apply underline underline-offset-2 hover:text-amber-600; + } + + .code-block { + @apply rounded-lg relative box-decoration-clone inline w-fit bg-theme/10 text-theme/60 px-2 mx-1 py-0.5 + } } .prose { diff --git a/src/app.d.ts b/apps/docs/src/app.d.ts similarity index 100% rename from src/app.d.ts rename to apps/docs/src/app.d.ts diff --git a/apps/docs/src/app.html b/apps/docs/src/app.html new file mode 100644 index 0000000..51e8ade --- /dev/null +++ b/apps/docs/src/app.html @@ -0,0 +1,17 @@ + + + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/docs/src/lib/components/ComponentPreview.svelte b/apps/docs/src/lib/components/ComponentPreview.svelte new file mode 100644 index 0000000..48264c1 --- /dev/null +++ b/apps/docs/src/lib/components/ComponentPreview.svelte @@ -0,0 +1,177 @@ + + +
+ {#if showPreview} +
+ {#key previewKey} + {@render children?.()} + {/key} +
+ {/if} +
+ {#if tabs.length} +
+
+ {#each tabs as tab, index (tab.name)} + + {/each} +
+
+ {#if activeSource} + + {/if} +
+
+ {/if} +
+
+ {#if activeSource} + {#if highlightedSources[activeSource.name]} + + {:else} +
{activeSource.code}
+ {/if} + {:else} + {@render codeSlot?.()} + {/if} +
+ {#if collapsible && !expanded} +
+
+ +
+ {/if} + {#if collapsible && expanded} +
+ +
+ {/if} +
+
+
\ No newline at end of file diff --git a/apps/docs/src/lib/components/InstallTabs.svelte b/apps/docs/src/lib/components/InstallTabs.svelte new file mode 100644 index 0000000..5bb18b9 --- /dev/null +++ b/apps/docs/src/lib/components/InstallTabs.svelte @@ -0,0 +1,80 @@ + + +
+
+
+ {#each packageManagers as pm (pm)} + + {/each} +
+ +
+
+ {#if highlightedCommands[packageManagerStore.active]} + + {:else} + + {activeCommand} + + {/if} +
+
\ No newline at end of file diff --git a/apps/docs/src/lib/components/ShikiCodeBlock.svelte b/apps/docs/src/lib/components/ShikiCodeBlock.svelte new file mode 100644 index 0000000..716343e --- /dev/null +++ b/apps/docs/src/lib/components/ShikiCodeBlock.svelte @@ -0,0 +1,37 @@ + + +
+	
+ {@html htmlLight} +
+
+ {@html htmlDark ?? htmlLight} +
+
+ + \ No newline at end of file diff --git a/apps/docs/src/lib/components/contentsList.svelte b/apps/docs/src/lib/components/contentsList.svelte new file mode 100644 index 0000000..889a9aa --- /dev/null +++ b/apps/docs/src/lib/components/contentsList.svelte @@ -0,0 +1,380 @@ + + + \ No newline at end of file diff --git a/src/docs/components/header.svelte b/apps/docs/src/lib/components/header.svelte similarity index 98% rename from src/docs/components/header.svelte rename to apps/docs/src/lib/components/header.svelte index f53c8c0..66fece0 100644 --- a/src/docs/components/header.svelte +++ b/apps/docs/src/lib/components/header.svelte @@ -1,5 +1,5 @@ diff --git a/apps/docs/src/lib/components/index.ts b/apps/docs/src/lib/components/index.ts new file mode 100644 index 0000000..84e8617 --- /dev/null +++ b/apps/docs/src/lib/components/index.ts @@ -0,0 +1,6 @@ +export { default as ContentsList } from './contentsList.svelte'; +export { default as Header } from './header.svelte'; +export { default as InstallTabs } from './InstallTabs.svelte'; +export { default as ShikiCodeBlock } from './ShikiCodeBlock.svelte'; +export { default as Sidebar } from './sidebar.svelte'; +export { default as ComponentPreview } from './ComponentPreview.svelte'; \ No newline at end of file diff --git a/apps/docs/src/lib/components/markdown/Blockquote.svelte b/apps/docs/src/lib/components/markdown/Blockquote.svelte new file mode 100644 index 0000000..993e833 --- /dev/null +++ b/apps/docs/src/lib/components/markdown/Blockquote.svelte @@ -0,0 +1,22 @@ + + +
+ {@render children?.()} +
\ No newline at end of file diff --git a/apps/docs/src/lib/components/markdown/Code.svelte b/apps/docs/src/lib/components/markdown/Code.svelte new file mode 100644 index 0000000..2ba29d4 --- /dev/null +++ b/apps/docs/src/lib/components/markdown/Code.svelte @@ -0,0 +1,35 @@ + + +{#if isBlock(typeof className === 'string' ? className : undefined, restProps['data-theme'])} + + {@render children?.()} + +{:else} + + {@render children?.()} + +{/if} \ No newline at end of file diff --git a/apps/docs/src/lib/components/markdown/CopyCodeButton.svelte b/apps/docs/src/lib/components/markdown/CopyCodeButton.svelte new file mode 100644 index 0000000..61b86df --- /dev/null +++ b/apps/docs/src/lib/components/markdown/CopyCodeButton.svelte @@ -0,0 +1,81 @@ + + + diff --git a/apps/docs/src/lib/components/markdown/Divider.svelte b/apps/docs/src/lib/components/markdown/Divider.svelte new file mode 100644 index 0000000..abfa1cf --- /dev/null +++ b/apps/docs/src/lib/components/markdown/Divider.svelte @@ -0,0 +1,8 @@ + + +
\ No newline at end of file diff --git a/apps/docs/src/lib/components/markdown/H1.svelte b/apps/docs/src/lib/components/markdown/H1.svelte new file mode 100644 index 0000000..d2614df --- /dev/null +++ b/apps/docs/src/lib/components/markdown/H1.svelte @@ -0,0 +1,22 @@ + + +

+ {@render children?.()} +

\ No newline at end of file diff --git a/apps/docs/src/lib/components/markdown/H2.svelte b/apps/docs/src/lib/components/markdown/H2.svelte new file mode 100644 index 0000000..75b090e --- /dev/null +++ b/apps/docs/src/lib/components/markdown/H2.svelte @@ -0,0 +1,22 @@ + + +

+ {@render children?.()} +

diff --git a/apps/docs/src/lib/components/markdown/H3.svelte b/apps/docs/src/lib/components/markdown/H3.svelte new file mode 100644 index 0000000..aa3e503 --- /dev/null +++ b/apps/docs/src/lib/components/markdown/H3.svelte @@ -0,0 +1,22 @@ + + +

+ {@render children?.()} +

diff --git a/apps/docs/src/lib/components/markdown/H4.svelte b/apps/docs/src/lib/components/markdown/H4.svelte new file mode 100644 index 0000000..82cd425 --- /dev/null +++ b/apps/docs/src/lib/components/markdown/H4.svelte @@ -0,0 +1,22 @@ + + +

+ {@render children?.()} +

diff --git a/apps/docs/src/lib/components/markdown/Info.svelte b/apps/docs/src/lib/components/markdown/Info.svelte new file mode 100644 index 0000000..558d659 --- /dev/null +++ b/apps/docs/src/lib/components/markdown/Info.svelte @@ -0,0 +1,19 @@ + + +

+ {@render children?.()} +

diff --git a/apps/docs/src/lib/components/markdown/Link.svelte b/apps/docs/src/lib/components/markdown/Link.svelte new file mode 100644 index 0000000..eedc35e --- /dev/null +++ b/apps/docs/src/lib/components/markdown/Link.svelte @@ -0,0 +1,22 @@ + + + + {@render children?.()} + diff --git a/apps/docs/src/lib/components/markdown/ListItem.svelte b/apps/docs/src/lib/components/markdown/ListItem.svelte new file mode 100644 index 0000000..076a5a5 --- /dev/null +++ b/apps/docs/src/lib/components/markdown/ListItem.svelte @@ -0,0 +1,22 @@ + + +
  • + {@render children?.()} +
  • diff --git a/apps/docs/src/lib/components/markdown/MarkdownPre.svelte b/apps/docs/src/lib/components/markdown/MarkdownPre.svelte new file mode 100644 index 0000000..72d7582 --- /dev/null +++ b/apps/docs/src/lib/components/markdown/MarkdownPre.svelte @@ -0,0 +1,28 @@ + + +{#if code} +
    +		{@render code?.()}
    +	
    +{:else} + +{/if} \ No newline at end of file diff --git a/apps/docs/src/lib/components/markdown/OrderedList.svelte b/apps/docs/src/lib/components/markdown/OrderedList.svelte new file mode 100644 index 0000000..0f73eb3 --- /dev/null +++ b/apps/docs/src/lib/components/markdown/OrderedList.svelte @@ -0,0 +1,22 @@ + + +
      li]:pl-1', + className + )} +> + {@render children?.()} +
    diff --git a/apps/docs/src/lib/components/markdown/Paragraph.svelte b/apps/docs/src/lib/components/markdown/Paragraph.svelte new file mode 100644 index 0000000..e8ba5b7 --- /dev/null +++ b/apps/docs/src/lib/components/markdown/Paragraph.svelte @@ -0,0 +1,22 @@ + + +

    + {@render children?.()} +

    diff --git a/apps/docs/src/lib/components/markdown/Pre.svelte b/apps/docs/src/lib/components/markdown/Pre.svelte new file mode 100644 index 0000000..a1f154e --- /dev/null +++ b/apps/docs/src/lib/components/markdown/Pre.svelte @@ -0,0 +1,51 @@ + + +
    +
    + {@render children?.()} +
    + {#if code} + + {/if} +
    + + \ No newline at end of file diff --git a/apps/docs/src/lib/components/markdown/Step.svelte b/apps/docs/src/lib/components/markdown/Step.svelte new file mode 100644 index 0000000..cf6374f --- /dev/null +++ b/apps/docs/src/lib/components/markdown/Step.svelte @@ -0,0 +1,46 @@ + + +
    + {#if title} +
    +
    + + +
    +

    + {title} +

    +
    + {:else} +
    + + +
    + {/if} +
    + {@render children?.()} +
    +
    diff --git a/apps/docs/src/lib/components/markdown/Steps.svelte b/apps/docs/src/lib/components/markdown/Steps.svelte new file mode 100644 index 0000000..9b96a73 --- /dev/null +++ b/apps/docs/src/lib/components/markdown/Steps.svelte @@ -0,0 +1,18 @@ + + +
    + {@render children?.()} +
    diff --git a/apps/docs/src/lib/components/markdown/Strong.svelte b/apps/docs/src/lib/components/markdown/Strong.svelte new file mode 100644 index 0000000..18908d2 --- /dev/null +++ b/apps/docs/src/lib/components/markdown/Strong.svelte @@ -0,0 +1,19 @@ + + + + {@render children?.()} + diff --git a/apps/docs/src/lib/components/markdown/Table.svelte b/apps/docs/src/lib/components/markdown/Table.svelte new file mode 100644 index 0000000..0de0f4d --- /dev/null +++ b/apps/docs/src/lib/components/markdown/Table.svelte @@ -0,0 +1,20 @@ + + +
    +
    + + {@render children?.()} +
    +
    +
    diff --git a/apps/docs/src/lib/components/markdown/Tbody.svelte b/apps/docs/src/lib/components/markdown/Tbody.svelte new file mode 100644 index 0000000..9428360 --- /dev/null +++ b/apps/docs/src/lib/components/markdown/Tbody.svelte @@ -0,0 +1,16 @@ + + + + {@render children?.()} + diff --git a/apps/docs/src/lib/components/markdown/Td.svelte b/apps/docs/src/lib/components/markdown/Td.svelte new file mode 100644 index 0000000..9a1e15f --- /dev/null +++ b/apps/docs/src/lib/components/markdown/Td.svelte @@ -0,0 +1,19 @@ + + + + {@render children?.()} + diff --git a/apps/docs/src/lib/components/markdown/Th.svelte b/apps/docs/src/lib/components/markdown/Th.svelte new file mode 100644 index 0000000..0660a3a --- /dev/null +++ b/apps/docs/src/lib/components/markdown/Th.svelte @@ -0,0 +1,19 @@ + + + + {@render children?.()} + diff --git a/apps/docs/src/lib/components/markdown/Thead.svelte b/apps/docs/src/lib/components/markdown/Thead.svelte new file mode 100644 index 0000000..db2f52e --- /dev/null +++ b/apps/docs/src/lib/components/markdown/Thead.svelte @@ -0,0 +1,19 @@ + + + + {@render children?.()} + diff --git a/apps/docs/src/lib/components/markdown/Tr.svelte b/apps/docs/src/lib/components/markdown/Tr.svelte new file mode 100644 index 0000000..de15182 --- /dev/null +++ b/apps/docs/src/lib/components/markdown/Tr.svelte @@ -0,0 +1,22 @@ + + + + {@render children?.()} + diff --git a/apps/docs/src/lib/components/markdown/UnorderedList.svelte b/apps/docs/src/lib/components/markdown/UnorderedList.svelte new file mode 100644 index 0000000..21279fa --- /dev/null +++ b/apps/docs/src/lib/components/markdown/UnorderedList.svelte @@ -0,0 +1,22 @@ + + +
      li]:pl-1', + className + )} +> + {@render children?.()} +
    diff --git a/apps/docs/src/lib/components/markdown/Warning.svelte b/apps/docs/src/lib/components/markdown/Warning.svelte new file mode 100644 index 0000000..558d659 --- /dev/null +++ b/apps/docs/src/lib/components/markdown/Warning.svelte @@ -0,0 +1,19 @@ + + +

    + {@render children?.()} +

    diff --git a/apps/docs/src/lib/components/markdown/index.ts b/apps/docs/src/lib/components/markdown/index.ts new file mode 100644 index 0000000..d9eefec --- /dev/null +++ b/apps/docs/src/lib/components/markdown/index.ts @@ -0,0 +1,27 @@ +export { default as Blockquote } from './Blockquote.svelte'; +export { default as Code } from './Code.svelte'; +export { default as CopyCodeButton } from './CopyCodeButton.svelte'; +export { default as Divider } from './Divider.svelte'; +export { default as H1 } from './H1.svelte'; +export { default as H2 } from './H2.svelte'; +export { default as H3 } from './H3.svelte'; +export { default as H4 } from './H4.svelte'; +export { default as Link } from './Link.svelte'; +export { default as ListItem } from './ListItem.svelte'; +export { default as MarkdownPre } from './MarkdownPre.svelte'; +export { default as OrderedList } from './OrderedList.svelte'; +export { default as Paragraph } from './Paragraph.svelte'; +export { default as Pre } from './Pre.svelte'; +export { default as Step } from './Step.svelte'; +export { default as Steps } from './Steps.svelte'; +export { default as Strong } from './Strong.svelte'; +export { default as Table } from './Table.svelte'; +export { default as Tbody } from './Tbody.svelte'; +export { default as Td } from './Td.svelte'; +export { default as Th } from './Th.svelte'; +export { default as Thead } from './Thead.svelte'; +export { default as Tr } from './Tr.svelte'; +export { default as UnorderedList } from './UnorderedList.svelte'; + +export { default as Warning } from './Warning.svelte'; +export { default as Info } from './Info.svelte'; diff --git a/apps/docs/src/lib/components/mdsvex.svelte b/apps/docs/src/lib/components/mdsvex.svelte new file mode 100644 index 0000000..0ae9e69 --- /dev/null +++ b/apps/docs/src/lib/components/mdsvex.svelte @@ -0,0 +1,40 @@ + + + + +
    + {@render children?.()} +
    \ No newline at end of file diff --git a/apps/docs/src/lib/components/sidebar.svelte b/apps/docs/src/lib/components/sidebar.svelte new file mode 100644 index 0000000..8ce0783 --- /dev/null +++ b/apps/docs/src/lib/components/sidebar.svelte @@ -0,0 +1,186 @@ + + +{#if $sidebarOpen} + +{/if} \ No newline at end of file diff --git a/apps/docs/src/lib/examples/AccessibilityExample.svelte b/apps/docs/src/lib/examples/AccessibilityExample.svelte new file mode 100644 index 0000000..2ef5da9 --- /dev/null +++ b/apps/docs/src/lib/examples/AccessibilityExample.svelte @@ -0,0 +1,170 @@ + + +
    +
    + Tab to focus a task · Space/Enter to pick up · + to move · + Home/End to jump · Esc to cancel. +
    + + +
    + {#each Object.entries(columnLabels) as [id, label] (id)} +
    +

    {label}

    + + {#each board[id] as item, index (item.id)} + +
    {item.label}
    +
    + {/each} +
    +
    + {/each} +
    +
    + +
    + Screen reader log + {#if announcements.length === 0} + Announcements will appear here… + {:else} + {#each announcements as entry, idx (entry.id)} + + {entry.text} + + {/each} + {/if} +
    +
    + + diff --git a/apps/docs/src/lib/examples/CollisionExample.svelte b/apps/docs/src/lib/examples/CollisionExample.svelte new file mode 100644 index 0000000..a1b3a18 --- /dev/null +++ b/apps/docs/src/lib/examples/CollisionExample.svelte @@ -0,0 +1,73 @@ + + +
    +

    Right column requires 40% overlap — drag slowly across the border to feel the difference.

    + + +
    +
    +

    centerPoint default

    + + {#each left as item, index (item.id)} + +
    {item.label}
    +
    + {/each} +
    +
    + +
    +

    overlap 40%

    + + {#each right as item, index (item.id)} + +
    {item.label}
    +
    + {/each} +
    +
    +
    +
    +
    + + diff --git a/apps/docs/src/lib/examples/CustomBehaviorExample.svelte b/apps/docs/src/lib/examples/CustomBehaviorExample.svelte new file mode 100644 index 0000000..1e8b28b --- /dev/null +++ b/apps/docs/src/lib/examples/CustomBehaviorExample.svelte @@ -0,0 +1,155 @@ + + + + + {#each items as item, index (item.id)} + +
    + {item.label} +
    +
    + {/each} +
    +
    + + diff --git a/apps/docs/src/lib/examples/CustomGhostExample.svelte b/apps/docs/src/lib/examples/CustomGhostExample.svelte new file mode 100644 index 0000000..8153fdb --- /dev/null +++ b/apps/docs/src/lib/examples/CustomGhostExample.svelte @@ -0,0 +1,110 @@ + + + + {#snippet ghost({ data, itemId })} +
    + {data?.label ?? itemId} +
    + {/snippet} + +
    + {#each Object.entries(columns) as [columnId, columnItems] (columnId)} +
    +

    {columnMeta[columnId]}

    + + {#each columnItems as item, index (item.id)} + +
    + {item.label} +
    +
    + {/each} +
    +
    + {/each} +
    +
    + + diff --git a/apps/docs/src/lib/examples/GridExample.svelte b/apps/docs/src/lib/examples/GridExample.svelte new file mode 100644 index 0000000..fcabd32 --- /dev/null +++ b/apps/docs/src/lib/examples/GridExample.svelte @@ -0,0 +1,48 @@ + + + + + {#each items as item, index (item.id)} + +
    + {item.id} +
    +
    + {/each} +
    +
    + + diff --git a/apps/docs/src/lib/examples/HorizontalExample.svelte b/apps/docs/src/lib/examples/HorizontalExample.svelte new file mode 100644 index 0000000..3787532 --- /dev/null +++ b/apps/docs/src/lib/examples/HorizontalExample.svelte @@ -0,0 +1,43 @@ + + + + + {#each items as item, index (item.id)} + +
    + {item.id} +
    +
    + {/each} +
    +
    + + diff --git a/apps/docs/src/lib/examples/ModifiersExample.svelte b/apps/docs/src/lib/examples/ModifiersExample.svelte new file mode 100644 index 0000000..a59d15c --- /dev/null +++ b/apps/docs/src/lib/examples/ModifiersExample.svelte @@ -0,0 +1,77 @@ + + +
    +
    + {#each options as opt, i} + + {/each} +
    + + + + {#each items as item, index (item.id)} + +
    + {item.label} +
    +
    + {/each} +
    +
    +
    + + diff --git a/apps/docs/src/lib/examples/MultiContainerExample.svelte b/apps/docs/src/lib/examples/MultiContainerExample.svelte new file mode 100644 index 0000000..6bee577 --- /dev/null +++ b/apps/docs/src/lib/examples/MultiContainerExample.svelte @@ -0,0 +1,80 @@ + + + +
    + {#each Object.entries(columns) as [columnId, columnItems] (columnId)} +
    +

    {columnMeta[columnId]}

    + + {#each columnItems as item, index (item.id)} + +
    + {item.label} +
    +
    + {/each} +
    +
    + {/each} +
    +
    + + diff --git a/apps/docs/src/lib/examples/SensorsExample.svelte b/apps/docs/src/lib/examples/SensorsExample.svelte new file mode 100644 index 0000000..cc46b9d --- /dev/null +++ b/apps/docs/src/lib/examples/SensorsExample.svelte @@ -0,0 +1,111 @@ + + +
    +
    + + + + + +
    + +

    + Tab to focus, Enter/Space to pick up, arrow keys to move, Enter to drop +

    + + + + {#each items as item, index (item.id)} + +
    + {item.label} +
    +
    + {/each} +
    +
    +
    + + diff --git a/apps/docs/src/lib/examples/SimulationsBatchSwapExample.svelte b/apps/docs/src/lib/examples/SimulationsBatchSwapExample.svelte new file mode 100644 index 0000000..f11e2fd --- /dev/null +++ b/apps/docs/src/lib/examples/SimulationsBatchSwapExample.svelte @@ -0,0 +1,111 @@ + + +
    + + + +
    +
    + Team A + + {#each teamA as item, index (item.id)} + +
    + {item.label} +
    +
    + {/each} +
    +
    + +
    + Team B + + {#each teamB as item, index (item.id)} + +
    + {item.label} +
    +
    + {/each} +
    +
    +
    +
    +
    + + diff --git a/apps/docs/src/lib/examples/SimulationsExample.svelte b/apps/docs/src/lib/examples/SimulationsExample.svelte new file mode 100644 index 0000000..8c5b384 --- /dev/null +++ b/apps/docs/src/lib/examples/SimulationsExample.svelte @@ -0,0 +1,79 @@ + + +
    +
    + + +
    + + + + {#each items as item, index (item.id)} + +
    + {item.label} +
    +
    + {/each} +
    +
    +
    + + diff --git a/apps/docs/src/lib/examples/SimulationsGroupByColorExample.svelte b/apps/docs/src/lib/examples/SimulationsGroupByColorExample.svelte new file mode 100644 index 0000000..94ccec4 --- /dev/null +++ b/apps/docs/src/lib/examples/SimulationsGroupByColorExample.svelte @@ -0,0 +1,150 @@ + + +
    +
    + + +
    + + +
    + {#each containerColors as color (color)} +
    +
    + + {labelMap[color]} +
    + + {#each containers[color] as task, index (task.id)} + +
    + + {task.label} +
    +
    + {/each} +
    +
    + {/each} +
    +
    +
    + + diff --git a/apps/docs/src/lib/examples/SortableContainersExample/TrashZone.svelte b/apps/docs/src/lib/examples/SortableContainersExample/TrashZone.svelte new file mode 100644 index 0000000..b74b637 --- /dev/null +++ b/apps/docs/src/lib/examples/SortableContainersExample/TrashZone.svelte @@ -0,0 +1,175 @@ + + + +{#if items.length > 0} +
    + + + + + {#each items as item (item.task.id)} +
    + +
    + {item.task.label} + +
    + {/each} + +

    Items will be permanently deleted when the bar empties.

    +
    +{/if} + + + + + {#each items as item (item.task.id)} +
    +
    + {item.task.label} + +
    +
    + {/each} + +
    + + + +
    +
    + diff --git a/apps/docs/src/lib/examples/SortableContainersExample/index.svelte b/apps/docs/src/lib/examples/SortableContainersExample/index.svelte new file mode 100644 index 0000000..dcb2b88 --- /dev/null +++ b/apps/docs/src/lib/examples/SortableContainersExample/index.svelte @@ -0,0 +1,178 @@ + + +
    +
    +

    Control panel

    +
    + + +

    - To see, start dragging

    +
    + + +
    + + + + + + {#each columns as column, colIndex (column.id)} + +
    +

    + + {column.title} +

    + + + {#each column.tasks as task, taskIndex (task.id)} + +
    + {task.label} + +
    +
    + {/each} +
    +
    +
    + {/each} +
    + + +
    + +
    + diff --git a/apps/docs/src/lib/examples/StrategiesExample.svelte b/apps/docs/src/lib/examples/StrategiesExample.svelte new file mode 100644 index 0000000..171bd81 --- /dev/null +++ b/apps/docs/src/lib/examples/StrategiesExample.svelte @@ -0,0 +1,107 @@ + + +
    +

    Drag from Inbox to Triage — top half marks as high priority, bottom half as low.

    + + +
    +
    +

    Inbox

    + + {#each inbox as item, index (item.id)} + +
    {item.label}
    +
    + {/each} +
    +
    + +
    +

    Triage priority mode

    + +
    ⬆ High
    + {#each triage as item, index (item.id)} + +
    {item.label}
    +
    + {/each} +
    ⬇ Low
    +
    +
    +
    +
    +
    + + diff --git a/apps/docs/src/lib/examples/TargetZonesExample.svelte b/apps/docs/src/lib/examples/TargetZonesExample.svelte new file mode 100644 index 0000000..054fb3f --- /dev/null +++ b/apps/docs/src/lib/examples/TargetZonesExample.svelte @@ -0,0 +1,132 @@ + + + + {#snippet ghost({ data, itemId })} + {@const overSlot = !!controller.dropPreview && controller.dropPreview.containerId !== 'pool'} +
    + {data?.label ?? itemId} +
    + {/snippet} + +
    +
    +

    Pool

    + + {#each zones.pool as item, index (item.id)} + +
    {item.label}
    +
    + {/each} +
    +
    + +
    + {#each targetZones as zone (zone.id)} +
    +

    {zone.label}

    + + {#each zones[zone.id] as item, index (item.id)} + +
    {item.label}
    +
    + {/each} +
    +
    + {/each} +
    +
    +
    + + diff --git a/apps/docs/src/lib/examples/VerticalExample.svelte b/apps/docs/src/lib/examples/VerticalExample.svelte new file mode 100644 index 0000000..ad81dba --- /dev/null +++ b/apps/docs/src/lib/examples/VerticalExample.svelte @@ -0,0 +1,43 @@ + + + + + {#each items as item, index (item.id)} + +
    + {item.id} +
    +
    + {/each} +
    +
    + + diff --git a/apps/docs/src/lib/examples/VerticalSortableContainersExample/index.svelte b/apps/docs/src/lib/examples/VerticalSortableContainersExample/index.svelte new file mode 100644 index 0000000..e43b967 --- /dev/null +++ b/apps/docs/src/lib/examples/VerticalSortableContainersExample/index.svelte @@ -0,0 +1,90 @@ + + +
    + + {#each containers as container, containerIndex (container.id)} + +
    + {container.title} + ({container.items.length}) +
    + {#each container.items as item, itemIndex (item.id)} + +
    + {item.label} +
    +
    + {/each} +
    + {/each} +
    +
    + + diff --git a/apps/docs/src/lib/examples/VirtualizationExample.svelte b/apps/docs/src/lib/examples/VirtualizationExample.svelte new file mode 100644 index 0000000..7e67a84 --- /dev/null +++ b/apps/docs/src/lib/examples/VirtualizationExample.svelte @@ -0,0 +1,105 @@ + + + + + it.id} {keepMounted} style="padding: 12px;" data-dnd-scroll> + {#snippet children(item, index)} + +
    + {item.id} +
    +
    + {/snippet} +
    +
    +
    + + diff --git a/apps/docs/src/lib/site-config.ts b/apps/docs/src/lib/site-config.ts new file mode 100644 index 0000000..004184e --- /dev/null +++ b/apps/docs/src/lib/site-config.ts @@ -0,0 +1,22 @@ +export const siteConfig = { + name: '@horuse/svelte-dnd', + shortName: '@horuse/svelte-dnd', + description: + 'A lightweight drag-and-drop library for Svelte 5. Vertical, horizontal, and grid layouts, virtualized lists, animated drop previews, pointer & keyboard sensors, full TypeScript types.', + tagline: + 'A drag-and-drop library for Svelte 5 with animated drop previews, auto-scroll, pointer & touch support, keyboard navigation, and multi-container layouts.', + url: 'https://svelte-dnd.vercel.app', + ogImage: '/preview.gif', + keywords: [ + 'svelte', + 'svelte 5', + 'drag and drop', + 'dnd', + 'sortable', + 'runes', + 'typescript', + 'accessibility' + ], + author: 'Horuse', + pkg: '@horuse/svelte-dnd' +} as const; diff --git a/src/docs/stores.ts b/apps/docs/src/lib/stores.ts similarity index 100% rename from src/docs/stores.ts rename to apps/docs/src/lib/stores.ts diff --git a/apps/docs/src/lib/stores/package-manager.svelte.ts b/apps/docs/src/lib/stores/package-manager.svelte.ts new file mode 100644 index 0000000..3077f36 --- /dev/null +++ b/apps/docs/src/lib/stores/package-manager.svelte.ts @@ -0,0 +1,24 @@ +import { browser } from '$app/environment'; + +export type PackageManagerSvelte = 'npm' | 'pnpm' | 'bun' | 'yarn'; + +export const packageManagers: PackageManagerSvelte[] = ['npm', 'pnpm', 'bun', 'yarn']; + +function createPackageManagerStore() { + const storageKey = 'package-manager'; + const stored = browser ? localStorage.getItem(storageKey) as PackageManagerSvelte : null; + + let active = $state( + stored && packageManagers.includes(stored) ? stored : 'npm' + ); + + return { + get active() { return active; }, + set active(v: PackageManagerSvelte) { + active = v; + if (browser) localStorage.setItem(storageKey, v); +} +}; +} + +export const packageManagerStore = createPackageManagerStore(); \ No newline at end of file diff --git a/apps/docs/src/lib/utils/cn.ts b/apps/docs/src/lib/utils/cn.ts new file mode 100644 index 0000000..eb30078 --- /dev/null +++ b/apps/docs/src/lib/utils/cn.ts @@ -0,0 +1,3 @@ +export function cn(...classes: Array) { + return classes.filter(Boolean).join(' '); +} \ No newline at end of file diff --git a/apps/docs/src/lib/utils/docs-manifest.ts b/apps/docs/src/lib/utils/docs-manifest.ts new file mode 100644 index 0000000..911a178 --- /dev/null +++ b/apps/docs/src/lib/utils/docs-manifest.ts @@ -0,0 +1,96 @@ +/** + * Builds a lightweight manifest of every `+page.svx` file under + * `src/routes/docs/**` and `src/routes/examples/**` from their YAML + * frontmatter, without pulling the compiled mdsvex modules into the bundle. + * + * Used by `/llms.txt`, `/sitemap.xml`, and per-page SEO in docs/examples + * layouts. + */ + +const docsRaw = import.meta.glob('../../routes/docs/**/+page.svx', { + query: '?raw', + import: 'default', + eager: true +}) as Record + +const examplesRaw = import.meta.glob('../../routes/examples/**/+page.svx', { + query: '?raw', + import: 'default', + eager: true +}) as Record + +export type ManifestSection = 'Docs' | 'Components' | 'Migrations' | 'Examples' + +export type ManifestEntry = { + href: string + title: string + description: string + section: ManifestSection + source: string + modified?: string +} + +function parseFrontmatter(raw: string): Record { + const m = raw.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/) + if (!m) return {} + const out: Record = {} + for (const line of m[1].split(/\r?\n/)) { + const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.+?)\s*$/) + if (kv) out[kv[1]] = kv[2].replace(/^['"]|['"]$/g, '') + } + return out +} + +function pathToHref(filePath: string): string { + return filePath.replace(/^.*\/routes/, '').replace(/\/\+page\.svx$/, '') +} + +function classify(href: string, override?: string): ManifestSection { + if (override === 'Docs' || override === 'Components' || override === 'Migrations' || override === 'Examples') { + return override + } + if (href.startsWith('/examples/')) return 'Examples' + if (href.startsWith('/docs/components-api/')) return 'Components' + if (href.startsWith('/docs/migrations/')) return 'Migrations' + return 'Docs' +} + +function buildEntry(filePath: string, raw: string): ManifestEntry { + const fm = parseFrontmatter(raw) + const href = pathToHref(filePath) + const fallbackTitle = href.split('/').filter(Boolean).pop() ?? href + return { + href, + title: fm.title || fallbackTitle, + description: fm.description || '', + section: classify(href, fm.category), + source: filePath, + modified: fm.modified || undefined + } +} + +function buildManifest(files: Record): ManifestEntry[] { + return Object.entries(files) + .map(([p, raw]) => buildEntry(p, raw)) + .sort((a, b) => a.href.localeCompare(b.href)) +} + +export const docsManifest: ManifestEntry[] = buildManifest(docsRaw) +export const examplesManifest: ManifestEntry[] = buildManifest(examplesRaw) +export const allManifest: ManifestEntry[] = [...docsManifest, ...examplesManifest] + +export function findManifestEntry(pathname: string | undefined): ManifestEntry | undefined { + if (!pathname) return undefined + const normalized = pathname.replace(/\/+$/, '') || '/' + return allManifest.find((e) => e.href === normalized) +} + +export function groupBySection(entries: ManifestEntry[]): Map { + const groups = new Map() + for (const e of entries) { + const arr = groups.get(e.section) ?? [] + arr.push(e) + groups.set(e.section, arr) + } + return groups +} diff --git a/apps/docs/src/lib/utils/highlighter.ts b/apps/docs/src/lib/utils/highlighter.ts new file mode 100644 index 0000000..571e9bb --- /dev/null +++ b/apps/docs/src/lib/utils/highlighter.ts @@ -0,0 +1,25 @@ +import { createHighlighterCore } from 'shiki/core'; +import { createOnigurumaEngine } from 'shiki/engine/oniguruma'; +import getWasm from 'shiki/wasm'; +import githubLight from 'shiki/themes/github-light.mjs'; +import githubDark from 'shiki/themes/github-dark.mjs'; + +import typescript from 'shiki/langs/typescript.mjs'; +import svelte from 'shiki/langs/svelte.mjs'; +import xml from 'shiki/langs/xml.mjs'; +import bash from 'shiki/langs/bash.mjs'; +import json from 'shiki/langs/json.mjs'; +import wgsl from 'shiki/langs/wgsl.mjs'; + +let highlighter: Awaited> | null = null; + +export async function getHighlighter() { + if (!highlighter) { + highlighter = await createHighlighterCore({ + themes: [githubLight, githubDark], + langs: [typescript, svelte, xml, bash, json, wgsl], + engine: createOnigurumaEngine(getWasm) + }); + } + return highlighter; +} \ No newline at end of file diff --git a/apps/docs/src/lib/utils/svx-to-markdown.ts b/apps/docs/src/lib/utils/svx-to-markdown.ts new file mode 100644 index 0000000..ed5fbbe --- /dev/null +++ b/apps/docs/src/lib/utils/svx-to-markdown.ts @@ -0,0 +1,63 @@ +/** + * Transforms raw .svx content into clean markdown for LLM consumption. + * Replaces blocks with fenced code blocks, + * and removes Svelte + + + {title} + + + + + + + + + + + + + + + + + + +
    + + +
    +
    +
    +

    + @horuse/svelte-dnd +

    +
    + +

    + A lightweight drag-and-drop library for Svelte 5. + Vertical, horizontal, and grid layouts with smooth animations out of the box. +

    + +
      + {#each [ + 'Svelte 5 Runes — no legacy adapter', + 'Cross-container drag with slot collapse animation', + 'TypeScript + full event types', + 'Pointer & keyboard sensors built-in', + ] as feat} +
    • + - + {feat} +
    • + {/each} +
    + + +
    + + svelte-dnd demo +
    + + + + + +
    +

    Installation

    + +
    + + +
    +

    Quick start

    + + +
    + +
    diff --git a/apps/docs/src/routes/docs/+layout.svelte b/apps/docs/src/routes/docs/+layout.svelte new file mode 100644 index 0000000..80f94c9 --- /dev/null +++ b/apps/docs/src/routes/docs/+layout.svelte @@ -0,0 +1,56 @@ + + + + {title} + + + + + + + + + + + + + + + + + +
    +
    + {@render props.children?.()} + {#if modifiedLabel} +

    + Updated +

    + {/if} +
    + + +
    diff --git a/apps/docs/src/routes/docs/[...path].md/+server.ts b/apps/docs/src/routes/docs/[...path].md/+server.ts new file mode 100644 index 0000000..c860e41 --- /dev/null +++ b/apps/docs/src/routes/docs/[...path].md/+server.ts @@ -0,0 +1,13 @@ +import type { RequestHandler } from './$types'; +import { svxToMarkdown } from '$lib/utils/svx-to-markdown'; + +const rawFiles = import.meta.glob('../../../lib/**/*', { query: '?raw', import: 'default', eager: true }) as Record; + +export const GET: RequestHandler = async ({ params }) => { + const file = await import(`../${params.path}/+page.svx?raw`); + const content = svxToMarkdown(file.default, rawFiles, '../../../lib/'); + + return new Response(content, { + headers: { 'Content-Type': 'text/markdown' } + }); +}; diff --git a/apps/docs/src/routes/docs/accessibility/+page.svx b/apps/docs/src/routes/docs/accessibility/+page.svx new file mode 100644 index 0000000..e7c1f2b --- /dev/null +++ b/apps/docs/src/routes/docs/accessibility/+page.svx @@ -0,0 +1,101 @@ +--- +title: Accessibility +description: Keyboard navigation, ARIA attributes, and screen-reader announcements. +modified: 2026-05-11 +--- + +# Accessibility + +## Keyboard navigation + +Keyboard drag-and-drop is powered by `KeyboardSensor` and is active by default. Focusable items have `role="button"` and `tabindex="0"` automatically set. + +| Key | Action | +|-----|--------| +| `Tab` | Move focus between draggable items | +| `Enter` / `Space` | Pick up the focused item and start drag | +| Main-axis arrows | Move within the current container by one position | +| Cross-axis arrows | Switch to the visually adjacent container (any time, not just at the edge) | +| `Home` / `End` | Jump to the first / last position of the current container | +| `Enter` / `Space` | Drop at the current preview position | +| `Escape` | Cancel drag, ghost returns to origin | + +Main and cross axes are derived from the source droppable's `layout`: + +- `vertical`: `↑` / `↓` move within, `←` / `→` switch container. +- `horizontal`: `←` / `→` move within, `↑` / `↓` switch container. +- `grid`: all four arrows move within (next column / next row). + +The adjacent container is picked by visual position (sorted by `getBoundingClientRect()`), and inside it the landing position is the one whose slot center is closest to the slot you came from on the cross axis — so a Kanban move from "To Do · pos 2" to the column on the right lands near the same vertical level rather than at the top. + +The ghost smoothly flies to the live slot rect on each keypress (`animation.keyboardFlight`, default 150 ms), and the target slot is auto-scrolled into view when it sits past the visible edge of any scrollable ancestor. + +## Screen-reader announcements + +`DndProvider` renders a visually-hidden ARIA live region (`aria-live="assertive"`) that announces drag events to screen readers. The default announcements are: + +| Event | Default message | +|-------|----------------| +| Drag start | `Started dragging item {item.id}.` | +| Drag over new position | `Item {item.id} is over {current.id} at position {current.position}.` | +| Drop | `Dropped item {item.id} into {target.id} at position {target.position}.` | +| Cancel | `Dragging {item.id} was cancelled.` | + +### Custom announcements + +Pass `announcements` in the `DndController` constructor to customise the messages: + +```ts +import { DndController, defaultAnnouncements } from '@horuse/svelte-dnd'; + +const controller = new DndController({ + announcements: { + ...defaultAnnouncements, + onDragStart: ({ item }) => `Picked up ${item.id}. Use arrow keys to move it.`, + onDragOver: ({ item, current }) => + `Moving ${item.id} to column ${current.id}, position ${current.position + 1}.`, + onDrop: ({ item, target }) => `Dropped ${item.id} into ${target.id}.`, + onCancel: ({ item }) => `Cancelled moving ${item.id}.`, + }, +}); +``` + +Each callback receives a rich event object. All event types are exported from `@horuse/svelte-dnd`. + +### Announcements type + +```ts +import type { DragStartEvent, DragOverEvent, DropEvent, DropCancelledEvent } from '@horuse/svelte-dnd'; + +interface Announcements { + onDragStart?: (event: DragStartEvent) => string; + onDragOver?: (event: DragOverEvent) => string; + onDrop?: (event: DropEvent) => string; + onCancel?: (event: DropCancelledEvent) => string; +} +``` + +## ARIA attributes + +The library sets these attributes automatically: + +| Element | Attributes | +|---------|-----------| +| `DndDraggable` inner div | `role="button"`, `tabindex="0"`, `aria-roledescription="draggable item"` | +| `DndDroppable` | `aria-dropeffect="move"` (or `"none"` when `disabled`) | + +`aria-grabbed` is intentionally not set — it was deprecated in WAI-ARIA 1.1 and has poor screen-reader support. Pickup state is communicated through the live-region announcer instead. + +--- + +## Example + + + + + + diff --git a/apps/docs/src/routes/docs/behaviors/+page.svx b/apps/docs/src/routes/docs/behaviors/+page.svx new file mode 100644 index 0000000..c4c9413 --- /dev/null +++ b/apps/docs/src/routes/docs/behaviors/+page.svx @@ -0,0 +1,314 @@ +--- +title: Behaviors +description: Pluggable drop-side hooks — auto-scroll under drag, scroll-sync during drop animations, and any future plugins. Composable per controller and per strategy. +modified: 2026-05-11 +--- + + + +# Behaviors + +Behaviors are the plugin layer for drop-side concerns. They mirror the patterns the rest of the library already uses — `modifiers[]`, `sensors[]`, `collision` factories — so you compose, swap, or replace them the same way. + +```ts +import { autoScroll, scrollSync } from '@horuse/svelte-dnd' + +new DndController({ + behaviors: [autoScroll(), scrollSync()] // default if omitted +}) +``` + +## Where they plug in + + + + Set the behaviors field on new DndController. Applied to every droppable that doesn't override, plus non-droppable data-dnd-scroll wrappers. + + + Set the behaviors option on sortable or target. Replaces the controller defaults for that droppable. + + + Set the behaviors option on animateItem. Overrides everything for that animation only. + + + +Pass `behaviors: []` to opt out of all defaults. + +--- + +## `autoScroll()` + +Edge-triggered auto-scroll while a drag is in progress. When the pointer enters the trigger zone near a scrollable container's edge, the container scrolls automatically — speed scales with proximity to the edge. + + + +### Options + +| Option | Default | Effect | +|--------|---------|--------| +| `zoneRatio` | `0.3` | Trigger zone size as a fraction of the container's height/width. `0.3` = innermost 30% from each edge engages scroll. | +| `maxSpeed` | `30` | Maximum scroll speed in px/frame at 60fps. | +| `stopOnDrop` | `false` | Halt scroll the moment the user releases the pointer. Controller-level only — per-strategy override is ignored. | + +### Examples + +```ts +// Global default — also applies to data-dnd-scroll wrappers +new DndController({ + behaviors: [autoScroll({ zoneRatio: 0.2, maxSpeed: 50 })] +}) + +// Override on one droppable +sortable({ + layout: 'vertical', + behaviors: [autoScroll({ maxSpeed: 60 })] +}) + +// Disable auto-scroll for a specific list (keep scroll-sync) +sortable({ + layout: 'horizontal', + behaviors: [scrollSync()] +}) +``` + +--- + +## `scrollSync()` + +Wraps the drop / return animation: when the destination slot is hidden enough inside its scrollable container, the container scrolls together with the ghost flight so the ghost stays inside the viewport. + + + +### Options + +| Option | Default | Effect | +|--------|---------|--------| +| `threshold` | `1` | Visibility ratio (0..1) below which scroll-sync engages. `1` = scroll if any pixel of the target slot is hidden. `0.5` = scroll only when less than half visible. `0` = never scroll-sync (pass-through to the standard ghost flight). | + +### Examples + +```ts +// Tighter threshold — only scroll when most of the slot is off-screen +sortable({ + layout: 'vertical', + behaviors: [autoScroll(), scrollSync({ threshold: 0.5 })] +}) + +// Disable scroll-sync for a specific list +sortable({ + layout: 'horizontal', + behaviors: [autoScroll()] +}) + +// Per-call override — disable for a single programmatic animation +await controller.animateItem('task-3', { + to: { containerId: 'in-progress', position: 0 }, + behaviors: [autoScroll()] +}) +``` + +--- + +## Composing behaviors + +Behaviors stack — pass as many as you want. **The first listed wraps outer-most** for any `wrapDropAnimation` hooks. Order matters when a behavior fully *replaces* the inner step: anything wrapped inside it doesn't run on that branch. `scrollSync()` is the typical case — when its threshold engages, it substitutes a scroll-synchronised flight for the base step, so put any side-effect behaviors that should fire on every drop earlier in the list (i.e. outer). + +```ts +new DndController({ + behaviors: [ + autoScroll({ maxSpeed: 60 }), + dropSound, // side effect — fires regardless of scrollSync branch + scrollSync({ threshold: 0.5 }) + // future: clipGhost(), edgeIndicator(), … + ] +}) +``` + +--- + +## Replacing at runtime + +```ts +controller.setBehaviors([ + autoScroll({ maxSpeed: 60 }), + scrollSync({ threshold: 0.5 }) +]) +``` + +Updates only the controller-level defaults. Per-strategy `behaviors` set on `sortable()` / `target()` are unaffected. + +--- + +## Custom behaviors + +A `Behavior` is duck-typed — implement only the hooks you care about. Both library built-ins are written this way. + +```ts +import type { Behavior } from '@horuse/svelte-dnd' + +// Compose four lightweight drop reactions: +// 1. dropSound — short Web Audio chirp on every drop +// 2. slotRipple — destination slot flashes indigo, fades out +// 3. confettiBurst — six 🎉 spans burst from the drop position +// 4. arrivalBounce — destination slot pops (scale 1 → 1.08 → 1) +// +// Each is its own Behavior so they can be toggled / reused independently. + +let audioCtx: AudioContext | null = null +const dropSound: Behavior = { + name: 'dropSound', + wrapDropAnimation(next) { + return { + execute() { + audioCtx ??= new AudioContext() + if (audioCtx.state === 'suspended') audioCtx.resume() + const osc = audioCtx.createOscillator() + const gain = audioCtx.createGain() + osc.connect(gain).connect(audioCtx.destination) + osc.type = 'sine' + osc.frequency.value = 660 + gain.gain.setValueAtTime(0.15, audioCtx.currentTime) + gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.15) + osc.start() + osc.stop(audioCtx.currentTime + 0.15) + return next.execute() + }, + cancel: () => next.cancel?.() + } + } +} + +const slotRipple: Behavior = { + name: 'slotRipple', + wrapDropAnimation(next, ctx) { + return { + execute() { + ctx.targetEl?.animate( + [ + { backgroundColor: 'rgba(99, 102, 241, 0.35)', borderRadius: '12px' }, + { backgroundColor: 'transparent', borderRadius: '12px' } + ], + { duration: 500, easing: 'ease-out' } + ) + return next.execute() + }, + cancel: () => next.cancel?.() + } + } +} + +const confettiBurst: Behavior = { + name: 'confettiBurst', + wrapDropAnimation(next, ctx) { + return { + execute() { + if (ctx.targetEl) { + const rect = ctx.targetEl.getBoundingClientRect() + const cx = rect.left + rect.width / 2 + const cy = rect.top + rect.height / 2 + for (let i = 0; i < 6; i++) { + const span = document.createElement('span') + span.textContent = '🎉' + span.style.cssText = `position:fixed;left:${cx}px;top:${cy}px;font-size:22px;pointer-events:none;z-index:99999;transform:translate(-50%,-50%)` + document.body.appendChild(span) + const angle = (Math.PI * 2 * i) / 6 + (Math.random() - 0.5) * 0.6 + const dist = 60 + Math.random() * 50 + const tx = Math.cos(angle) * dist + const ty = Math.sin(angle) * dist - 20 + const anim = span.animate( + [ + { transform: 'translate(-50%, -50%) scale(0.5)', opacity: 1 }, + { transform: `translate(calc(-50% + ${tx}px), calc(-50% + ${ty}px)) scale(1.2)`, opacity: 0 } + ], + { duration: 700, easing: 'ease-out', fill: 'forwards' } + ) + anim.onfinish = () => span.remove() + } + } + return next.execute() + }, + cancel: () => next.cancel?.() + } + } +} + +const arrivalBounce: Behavior = { + name: 'arrivalBounce', + wrapDropAnimation(next, ctx) { + return { + execute() { + ctx.targetEl?.animate( + [ + { transform: 'scale(1)' }, + { transform: 'scale(1.08)', offset: 0.5 }, + { transform: 'scale(1)' } + ], + { duration: 400, easing: 'ease-out' } + ) + return next.execute() + }, + cancel: () => next.cancel?.() + } + } +} + +new DndController({ + // Side-effect behaviors go BEFORE scrollSync — when scrollSync engages + // (off-screen drop) it replaces the inner flight, so anything wrapped + // INSIDE it would never run. Earlier-listed behaviors wrap outer-most, + // so they fire first regardless of scrollSync's branch. + behaviors: [autoScroll(), dropSound, slotRipple, confettiBurst, arrivalBounce, scrollSync()] +}) +``` + + + + + +### Hook reference + +```ts +interface Behavior { + /** Optional debug-friendly identifier. */ + name?: string + + /** Read by ScrollController to drive auto-scroll under drag. */ + autoScrollConfig?: AutoScrollConfig + + /** Wraps the drop / return animation step (middleware-style). */ + wrapDropAnimation?(next: AnimationStep, ctx: BehaviorContext): AnimationStep +} + +interface BehaviorContext { + state: DndState + /** Layout axis of the destination container. */ + direction: 'vertical' | 'horizontal' + /** Slot wrapper element of the destination position, when known. */ + targetEl: HTMLElement | null + /** Destination droppable's root element, when known. */ + container: HTMLElement | null + /** Configured duration of the inner animation step. */ + duration: number + /** Spacing between sibling items, used as edge padding by scroll-sync. */ + padding: number +} +``` + +The first behavior in the list wraps outer-most: `[clip, scrollSync]` means `clip` sees the result of `scrollSync` wrapping the base step. + +--- + +## When to put a behavior where + +| You want to … | Place it here | +|---------------|---------------| +| Apply to every droppable + `data-dnd-scroll` wrappers | Controller-level — `new DndController({ behaviors })` | +| One container needs different auto-scroll speed / disabled scroll-sync | Per-strategy — `sortable({ behaviors })` | +| One programmatic animation (undo, demo, onboarding) skips a default | Per-call — `animateItem({ behaviors })` | +| Replace defaults at runtime (settings panel, accessibility toggle) | `controller.setBehaviors([...])` | diff --git a/apps/docs/src/routes/docs/collision/+page.svx b/apps/docs/src/routes/docs/collision/+page.svx new file mode 100644 index 0000000..8ebd1af --- /dev/null +++ b/apps/docs/src/routes/docs/collision/+page.svx @@ -0,0 +1,105 @@ +--- +title: Collision Detection +description: Pluggable collision algorithms — centerPoint, cursorOver, overlap, closestCenter, or custom. +modified: 2026-05-11 +--- + +# Collision Detection + +Collision detection determines which drop zone is the active target while an item is being dragged. The library ships with four algorithms and exposes the `CollisionAlgorithm` interface for custom implementations. + +## Built-in algorithms + +| Algorithm | Description | +|-----------|-------------| +| `centerPoint` | Active zone is the one whose rect contains the ghost's center. **Default.** | +| `cursorOver` | Active zone is the one whose rect contains the pointer (cursor/touch) position | +| `overlap` | Active zone is the one with the most overlap with the ghost rect. Accepts a threshold option | +| `closestCenter` | Active zone is the one whose center is closest to the ghost's center | + +All four are exported from `@horuse/svelte-dnd`. + +## Global algorithm + +Set a global algorithm on `DndController`: + +```ts +import { DndController, overlap } from '@horuse/svelte-dnd'; + +const controller = new DndController({ + collision: overlap('25%'), +}); +``` + +## Per-container override + +Use the `collision` prop on `DndDroppable` to override the algorithm for a specific container: + +```svelte + + + + ... + +``` + +**Priority:** per-container `collision` prop → global `collision` on controller → `centerPoint` default. + +## overlap options + +```ts +import { overlap } from '@horuse/svelte-dnd'; + +// pixels of intersection required (0 = any overlap) +collision: overlap(0) + +// percentage of ghost's smaller dimension +collision: overlap('25%') +``` + +## Custom algorithm + +Implement `CollisionAlgorithm` to write your own: + +```ts +import type { CollisionAlgorithm, CollisionContext } from '@horuse/svelte-dnd'; + +const myAlgorithm: CollisionAlgorithm = ({ zones, pointer, ghost }) => { + // zones: DropZone[] — all candidate zones + // pointer: { x, y } — current mouse/touch position + // ghost: { x, y, width, height } — ghost bounding box + + // Return the winning zone or null + return zones.find(z => z.rect.x < pointer.x && pointer.x < z.rect.x + z.rect.width) ?? null; +}; + +const controller = new DndController({ collision: myAlgorithm }); +``` + +## Types + +```ts +type CollisionAlgorithm = (context: CollisionContext) => DropZone | null; + +interface CollisionContext { + zones: DropZone[]; + pointer: { x: number; y: number }; + ghost: { x: number; y: number; width: number; height: number }; +} +``` + +--- + +## Example + + + + + + diff --git a/apps/docs/src/routes/docs/components-api/DndDraggable/+page.svx b/apps/docs/src/routes/docs/components-api/DndDraggable/+page.svx new file mode 100644 index 0000000..0a66a03 --- /dev/null +++ b/apps/docs/src/routes/docs/components-api/DndDraggable/+page.svx @@ -0,0 +1,59 @@ +--- +title: DndDraggable +description: API reference for the DndDraggable component — props, events, CSS variables, and drag handle attributes. +modified: 2026-05-11 +--- + +# DndDraggable + +Wraps a single draggable element. Must be inside a `DndDroppable` which must be inside a `DndProvider`. + +Automatically renders a `DndPreview` placeholder at its position - you do not need to place `DndPreview` manually. + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `id` | `string` | **required** | Unique identifier for this draggable item | +| `position` | `number` | `undefined` | Index of this item within its container. Required for `DndPreview` placeholder rendering — omit only when using fully custom container layouts | +| `type` | `string` | `undefined` | Item type used for filtering — matched against `accepts` on `DndDroppable` | +| `data` | `Record` | `{}` | Arbitrary data passed to drop callbacks | +| `disabled` | `boolean` | `false` | Disables dragging when `true` | +| `sensors` | `SensorDescriptor[]` | `undefined` | Override the active sensors for this item. Falls back to the controller-level sensors if omitted. See [Sensors](/docs/sensors) | +| `class` | `string` | — | Additional CSS class names | + +### Accessibility + +The component sets `role="button"`, `aria-grabbed`, and `aria-roledescription="draggable item"` automatically. `tabindex` switches between `0` and `-1` based on the `disabled` prop, so disabled items leave the focus order until they are re-enabled. + +### CSS Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `--dnd-draggable-cursor` | `grab` | Cursor on the draggable element | +| `--dnd-draggable-cursor-active` | `grabbing` | Cursor while dragging | +| `--dnd-draggable-cursor-disabled` | `default` | Cursor when disabled | +| `--dnd-draggable-opacity-dragging` | `0` | Opacity of the original element while its ghost is active | +| `--dnd-draggable-opacity-disabled` | `0.5` | Opacity when disabled | + +### No drag + +Add `data-dnd-no-drag` to any child element to prevent drag initiation on it. Useful for buttons, inputs, and links. + +```svelte + + + {task.label} + +``` + +### Drag handle + +Add `data-dnd-handle` to restrict dragging to specific elements. When at least one handle is present, `data-dnd-no-drag` is ignored — only handle elements can initiate a drag. + +```svelte + +

    ☰ {column.title}

    +
    +
    +``` diff --git a/apps/docs/src/routes/docs/components-api/DndDroppable/+page.svx b/apps/docs/src/routes/docs/components-api/DndDroppable/+page.svx new file mode 100644 index 0000000..9faf824 --- /dev/null +++ b/apps/docs/src/routes/docs/components-api/DndDroppable/+page.svx @@ -0,0 +1,128 @@ +--- +title: DndDroppable +description: API reference for the DndDroppable and DndPreview components — props, strategy, and type filtering. +modified: 2026-05-11 +--- + +# DndDroppable + +Defines a container that accepts draggable items. Automatically registers drop zones and renders a tail preview (drop after the last item) when using the `sortable()` strategy. + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `id` | `string` | **required** | Unique container identifier | +| `strategy` | `ContainerStrategy` | **required** | Strategy instance that owns zone calculation, translations, and drop/return animations. Use the built-in factories `sortable()` / `target()` or your own class. See [Strategies](#strategies) | +| `data` | `Record` | `{}` | Arbitrary container metadata passed to drop callbacks | +| `accepts` | `string \| string[]` | `undefined` | Item type(s) this container accepts. If omitted, accepts everything. See [Type filtering](#type-filtering) | +| `disabled` | `boolean` | `false` | Disables dropping when `true`. Also sets `opacity: 0.5; pointer-events: none` | +| `collision` | `CollisionAlgorithm` | `undefined` | Per-container collision algorithm. Overrides the global algorithm set on `DndController`. `undefined` falls back to the global, or `centerPoint` if none. See [Collision Detection](/docs/collision) | +| `spacing` | `number` | `undefined` | Gap between items in pixels. Applied as margin between adjacent slots (not after the last one). Useful as a drop-in for `space-y-*` / `space-x-*` | +| `class` | `string` | — | Additional CSS class names | + +### Strategies + +The `strategy` prop decides how a container behaves. Two built-in factories cover the common cases: + +```svelte + + + + + + + + + + + + + + + + +``` + +`sortable()` renders position-based drop zones with insert previews between items. `target()` covers the whole container with a single zone and emits no previews — useful for trash zones or anywhere a target isn't a list. + +For writing your own strategy see [Custom Strategies](/docs/custom-strategies). + +#### sortable options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `layout` | `'vertical' \| 'horizontal' \| 'grid'` | `'vertical'` | Container layout | +| `flow` | `'row' \| 'column'` | `'row'` | For `layout: 'grid'`, which axis items fill first. `'row'` = left-to-right wrapping; `'column'` = top-to-bottom wrapping | +| `behaviors` | `Behavior[]` | inherited | Per-container behavior list (e.g. `[autoScroll(), scrollSync()]`). Replaces the controller-level defaults for this droppable only. See [Behaviors](/docs/behaviors) | +| `virtual` | `VirtualSource` | `undefined` | Virtualization adapter — set when items are rendered through a virtualizer. The bundled integration is tested with [`virtua`](https://github.com/inokawa/virtua); see [Virtualization example](/examples/virtualization) | + +### Snippets + +| Snippet | Description | +|---------|-------------| +| `children` | **Required.** Content of the droppable container | + +### CSS Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `--dnd-droppable-min-height` | `20px` | Minimum height of the container | + +### Auto-scroll + +`DndDroppable` automatically receives a `data-dnd-scroll` attribute. Auto-scroll only activates on elements with this attribute, so only droppable containers (not arbitrary scrollable ancestors) will be scrolled during a drag. + +### Type filtering + +Use the `accepts` prop to control which item types can be dropped into this container. Item types come from the `type` prop of `DndDraggable`. + +```svelte + + + + + +``` + +On the draggable side, set the `type` prop: + +```svelte + + {item.label} + +``` + +--- + +## DndPreview + +Renders a placeholder at a specific position within a container to indicate where a dragged item will be dropped. + +> **Note:** `DndPreview` is rendered automatically by `DndDraggable` (per-slot preview) and `DndDroppable` (tail preview). You should not place it manually — it requires internal `Slot`/`Droppable` entity references and is not intended for direct consumer use. Style it via the CSS variables below. + +### CSS Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `--dnd-preview-bg` | `rgba(99, 102, 241, 0.15)` | Background color of the preview | +| `--dnd-preview-border` | `2px dashed rgba(99, 102, 241, 0.4)` | Border of the preview | +| `--dnd-preview-border-radius` | `1rem` | Border radius of the preview | +| `--dnd-preview-duration-in` | `200ms` | Transition duration when preview appears. Also tunable via `controller.animation.preview.show.duration`. | +| `--dnd-preview-duration-out` | `200ms` | Transition duration when preview disappears. Also tunable via `controller.animation.preview.hide.duration`. | +| `--dnd-preview-easing-in` | `ease` | Easing for the reveal transition. Also tunable via `controller.animation.preview.show.easing`. | +| `--dnd-preview-easing-out` | `ease` | Easing for the collapse transition. Also tunable via `controller.animation.preview.hide.easing`. | + +--- + +## Types +#### DndLayout +```ts +export type DndLayout = 'vertical' | 'horizontal' | 'grid' +``` +#### DndMode +```ts +export type DndMode = 'sortable' | 'target' +``` diff --git a/apps/docs/src/routes/docs/components-api/DndProvider/+page.svx b/apps/docs/src/routes/docs/components-api/DndProvider/+page.svx new file mode 100644 index 0000000..16137cf --- /dev/null +++ b/apps/docs/src/routes/docs/components-api/DndProvider/+page.svx @@ -0,0 +1,55 @@ +--- +title: DndProvider +description: API reference for the DndProvider component — props, ghost snippet, and CSS variables. +modified: 2026-05-11 +--- + +# DndProvider + +Wraps your drag-and-drop area and provides the `DndController` context to all child components. + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `controller` | `DndController` | auto-created | Optional pre-created controller instance. Configure it via its constructor — see [DndController API](/docs/dnd-controller-api) | + +### Snippets + +| Snippet | Props | Description | +|---------|-------|-------------| +| `ghost` | `GhostSnippetProps` | Custom ghost element renderer during drag. If omitted, the dragged element's `innerHTML` is cloned as the ghost | + +### GhostSnippetProps + +```ts +interface GhostSnippetProps { + element: HTMLElement; + data?: Record; + itemId: string; +} +``` + +### Drag cursor + +While a drag is in progress the provider injects a ` diff --git a/packages/svelte-dnd/src/lib/components/DndDroppable.svelte b/packages/svelte-dnd/src/lib/components/DndDroppable.svelte new file mode 100644 index 0000000..0bd570b --- /dev/null +++ b/packages/svelte-dnd/src/lib/components/DndDroppable.svelte @@ -0,0 +1,195 @@ + + +
    + {@render children?.()} + {#if isSortable} +
    + = 0 ? tailPosition : lastValidTailPosition} + /> +
    + {/if} + + +
    + + diff --git a/packages/svelte-dnd/src/lib/components/DndPreview.svelte b/packages/svelte-dnd/src/lib/components/DndPreview.svelte new file mode 100644 index 0000000..4f27c95 --- /dev/null +++ b/packages/svelte-dnd/src/lib/components/DndPreview.svelte @@ -0,0 +1,114 @@ + + +
    + + diff --git a/packages/svelte-dnd/src/lib/components/DndProvider.svelte b/packages/svelte-dnd/src/lib/components/DndProvider.svelte new file mode 100644 index 0000000..ef8468e --- /dev/null +++ b/packages/svelte-dnd/src/lib/components/DndProvider.svelte @@ -0,0 +1,191 @@ + + +{@render children()} + +
    {announcement}
    + +{#if (dragController.dragging || dragController.animatingReturn) && dragController.element && dragController.transform && dragController.ghostSize} + {@const previewSize = dragController.dropPreviewSize} + {@const ghostW = previewSize?.width ?? dragController.ghostSize.width} + {@const ghostH = previewSize?.height ?? dragController.ghostSize.height} + {@const resize = dragController.animation.ghostResize} +
    + {#if ghost && dragController.draggedItem} + {@render ghost({ + element: dragController.element, + data: dragController.draggedItemData, + itemId: dragController.draggedItem + })} + {:else} +
    + {/if} +
    +{/if} + +{#if dragController.debugZones && dragController.dropZones} + {#each dragController.dragging ? dragController.filteredDropZones : dragController.dropZones as zone (zone.containerId + ':' + zone.position)} +
    + + {zone.containerId} pos:{zone.position} + +
    + {/each} +{/if} + + diff --git a/packages/svelte-dnd/src/lib/core/animation/animation-config.ts b/packages/svelte-dnd/src/lib/core/animation/animation-config.ts new file mode 100644 index 0000000..f4bc279 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/animation/animation-config.ts @@ -0,0 +1,197 @@ +/** + * CSS-driven animation transition with configurable duration and easing. + */ +export type Transition = { + /** Duration in milliseconds. */ + duration: number + /** + * CSS easing string. Accepts any valid CSS timing-function value: + * `'ease'`, `'ease-out'`, `'linear'`, `'cubic-bezier(0.25, 0.46, 0.45, 0.94)'`, etc. + */ + easing?: string +} + +type ResolvedTransition = Required + +/** + * Transition with an optional debounce `delay` before it starts. Used for + * preview show/hide where the library waits a moment after the pointer + * enters/leaves a target before triggering the CSS reveal/collapse. + */ +export type DelayedTransition = Transition & { + /** Milliseconds to wait before the transition begins. Default: 0. */ + delay?: number +} + +type ResolvedDelayedTransition = Required + +/** + * Shorthand: a bare number is treated as `{ duration: }` keeping the + * default easing. Pass a full `Transition` object to also set easing. + */ +export type DurationOrTransition = number | Transition + +/** + * Tuning knobs for built-in drag animations. + * + * Every field accepts either a plain millisecond `number` (shorthand — + * easing stays at the library default) or a {@link Transition} object + * `{ duration, easing }` to override both. + */ +export interface AnimationConfig { + /** + * Drop preview — the placeholder that appears in the destination slot + * showing where the dragged item will land on release. + */ + preview?: { + /** + * Reveal animation for the preview slot (opacity 0→1, transform scale + * 0.5→1). The optional `delay` debounces against pointer fly-bys — + * higher values prevent flicker, lower values feel more responsive. + * Default: `{ delay: 300, duration: 200, easing: 'ease' }`. + */ + show?: DelayedTransition + /** + * Collapse animation for the preview slot (opacity 1→0, transform + * scale 1→0.5). The optional `delay` gives the user a beat to "come + * back" before the slot shrinks. Default: + * `{ delay: 200, duration: 200, easing: 'ease' }`. + */ + hide?: DelayedTransition + } + + /** + * Items repositioning to make room for the ghost. Covers BOTH: + * - intra-container — siblings translate aside via `transform`. + * - inter-container — target container's spacer grows in `height`/`width` + * to accommodate the incoming ghost. + * + * One field for both because they form a single visual effect: "items + * rearranging during drag". Default: + * `{ duration: 200, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }`. + */ + siblingShift?: Transition + + /** + * Ghost auto-resize transition when crossing containers with different + * item dimensions — the dragged ghost morphs in size to preview the + * destination layout. Default: `{ duration: 150, easing: 'ease' }`. + */ + ghostResize?: Transition + + /** + * Ghost flight to a destination on a successful drop (rAF-driven). + * Default: `{ duration: 250, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }`. + */ + drop?: DurationOrTransition + + /** + * Ghost return flight to origin on a cancelled drop (rAF-driven, + * scroll-aware). Default: + * `{ duration: 300, easing: 'cubic-bezier(0.33, 1, 0.68, 1)' }` (out-cubic). + */ + return?: DurationOrTransition + + /** + * Source slot collapse animation on a cross-container drop — the empty + * space the moved item used to occupy shrinks shut so siblings flow back. + * Default: + * `{ duration: 250, easing: 'cubic-bezier(0.45, 0, 0.55, 1)' }` (in-out-quad). + */ + slotCollapse?: DurationOrTransition + + /** + * Default for `controller.animateLayout()` FLIP transitions. Overridable + * per-call via the method's `duration` / `easing` options. Default: + * `{ duration: 300, easing: 'ease' }`. + */ + layout?: DurationOrTransition + + /** + * Ghost flight per keyboard navigation keystroke (cancelled on each new key). + * Default: `{ duration: 150, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }`. + */ + keyboardFlight?: DurationOrTransition +} + +export interface ResolvedAnimationConfig { + preview: { + show: ResolvedDelayedTransition + hide: ResolvedDelayedTransition + } + siblingShift: ResolvedTransition + ghostResize: ResolvedTransition + drop: ResolvedTransition + return: ResolvedTransition + slotCollapse: ResolvedTransition + layout: ResolvedTransition + keyboardFlight: ResolvedTransition +} + +export const DEFAULT_ANIMATION_CONFIG: ResolvedAnimationConfig = { + preview: { + show: { delay: 300, duration: 200, easing: 'ease' }, + hide: { delay: 200, duration: 200, easing: 'ease' } + }, + siblingShift: { duration: 200, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }, + ghostResize: { duration: 150, easing: 'ease' }, + drop: { duration: 250, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }, + return: { duration: 300, easing: 'cubic-bezier(0.33, 1, 0.68, 1)' }, + slotCollapse: { duration: 250, easing: 'cubic-bezier(0.45, 0, 0.55, 1)' }, + layout: { duration: 300, easing: 'ease' }, + keyboardFlight: { duration: 150, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' } +} + +function resolveTransition( + partial: Transition | undefined, + fallback: ResolvedTransition +): ResolvedTransition { + return { + duration: partial?.duration ?? fallback.duration, + easing: partial?.easing ?? fallback.easing + } +} + +function resolveDelayedTransition( + partial: DelayedTransition | undefined, + fallback: ResolvedDelayedTransition +): ResolvedDelayedTransition { + return { + delay: partial?.delay ?? fallback.delay, + duration: partial?.duration ?? fallback.duration, + easing: partial?.easing ?? fallback.easing + } +} + +function resolveDurationOrTransition( + val: DurationOrTransition | undefined, + fallback: ResolvedTransition +): ResolvedTransition { + if (val == null) return fallback + if (typeof val === 'number') return { duration: val, easing: fallback.easing } + return resolveTransition(val, fallback) +} + +/** + * Resolve a partial AnimationConfig against a base. Defaults to library + * defaults when no base is supplied. Use a different base (e.g. the + * controller's current resolved config) to apply a partial patch. + */ +export function resolveAnimationConfig( + config?: AnimationConfig, + base: ResolvedAnimationConfig = DEFAULT_ANIMATION_CONFIG +): ResolvedAnimationConfig { + return { + preview: { + show: resolveDelayedTransition(config?.preview?.show, base.preview.show), + hide: resolveDelayedTransition(config?.preview?.hide, base.preview.hide) + }, + siblingShift: resolveTransition(config?.siblingShift, base.siblingShift), + ghostResize: resolveTransition(config?.ghostResize, base.ghostResize), + drop: resolveDurationOrTransition(config?.drop, base.drop), + return: resolveDurationOrTransition(config?.return, base.return), + slotCollapse: resolveDurationOrTransition(config?.slotCollapse, base.slotCollapse), + layout: resolveDurationOrTransition(config?.layout, base.layout), + keyboardFlight: resolveDurationOrTransition(config?.keyboardFlight, base.keyboardFlight) + } +} diff --git a/packages/svelte-dnd/src/lib/core/animation/apply-behaviors.ts b/packages/svelte-dnd/src/lib/core/animation/apply-behaviors.ts new file mode 100644 index 0000000..5bca06e --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/animation/apply-behaviors.ts @@ -0,0 +1,50 @@ +import type { AnimationStep } from './steps/animation-step.js' +import type { Behavior, BehaviorContext } from './behavior.js' +import type { Droppable } from '../entities/droppable.svelte.js' + +/** + * Resolves the effective behavior list for a droppable: per-strategy behaviors + * if non-empty, otherwise the controller-level defaults. The first listed + * behavior wraps outer-most. + */ +export function resolveBehaviors(droppable: Droppable | null, defaults: Behavior[]): Behavior[] { + const strategyBehaviors = droppable?.strategy.behaviors + if (strategyBehaviors && strategyBehaviors.length > 0) return strategyBehaviors + return defaults +} + +/** + * Wraps a base animation step with every behavior that provides + * `wrapDropAnimation`. Order matches the resolved behavior list — the first + * listed becomes the outer-most wrapper. + */ +export function wrapWithBehaviors( + step: AnimationStep, + behaviors: Behavior[], + ctx: BehaviorContext +): AnimationStep { + let wrapped = step + for (let i = behaviors.length - 1; i >= 0; i--) { + const b = behaviors[i] + if (b.wrapDropAnimation) { + wrapped = b.wrapDropAnimation(wrapped, ctx) + } + } + return wrapped +} + +/** + * Finds the slot wrapper element of the destination position inside a + * droppable. Returns `null` for `target()` containers (no per-slot preview) + * or when no preview entity is registered for that position yet. + */ +export function findTargetSlotWrapper( + droppable: Droppable | null, + position: number +): HTMLElement | null { + if (!droppable || droppable.mode === 'target') return null + const previewEntity = droppable.getSlotAt(position)?.preview ?? droppable.tailPreview + const previewEl = previewEntity?.element + if (!previewEl) return null + return (previewEl.parentElement ?? previewEl) as HTMLElement +} diff --git a/packages/svelte-dnd/src/lib/core/animation/behavior.ts b/packages/svelte-dnd/src/lib/core/animation/behavior.ts new file mode 100644 index 0000000..56ac4f3 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/animation/behavior.ts @@ -0,0 +1,57 @@ +import type { DndState } from '../dnd/dnd-state.svelte.js' +import type { AnimationStep } from './steps/animation-step.js' + +/** + * Per-droppable / per-controller pluggable hook for drop-side concerns. + * + * Mirrors the `modifiers` / `sensors` / `collision` plugin patterns: + * built-in factories such as `autoScroll(...)` and `scrollSync(...)` produce + * `Behavior` instances; custom behaviors can be authored by anyone. + * + * A behavior is duck-typed — implement only the hooks you care about: + * + * - `autoScrollConfig` — data-only hook read by `ScrollController` to drive + * edge-triggered auto-scroll while a drag is in progress. + * - `wrapDropAnimation` — middleware-style hook that wraps the drop / return + * animation step. Behaviors are applied outer-first: the first behavior in + * the list becomes the outer-most wrapper. + */ +export interface Behavior { + /** Optional debug-friendly identifier (e.g. `'autoScroll'`). */ + name?: string + + /** Auto-scroll tuning consumed by `ScrollController` while dragging. */ + autoScrollConfig?: AutoScrollConfig + + /** Wrap the inner animation step with extra behavior. Order: first listed wraps outer-most. */ + wrapDropAnimation?(next: AnimationStep, ctx: BehaviorContext): AnimationStep +} + +export interface BehaviorContext { + state: DndState + /** Layout axis of the destination container. */ + direction: 'vertical' | 'horizontal' + /** Slot wrapper element of the destination position, when known. */ + targetEl: HTMLElement | null + /** Destination droppable's root element, when known. */ + container: HTMLElement | null + /** Configured duration of the inner animation step. */ + duration: number + /** + * CSS easing string of the inner animation step. Behaviors that replace + * the inner step (e.g. `scrollSync`) should use this to keep visual + * continuity. Pass through `parseEasing()` to get an interpolation fn. + */ + easing: string + /** Spacing between sibling items, used as edge padding by scroll-sync. */ + padding: number +} + +export interface AutoScrollConfig { + /** Fraction of container size that triggers scroll. Default: 0.3 */ + zoneRatio?: number + /** Max scroll speed in px/frame at 60fps. Default: 30 */ + maxSpeed?: number + /** Stop auto-scroll the moment the user releases. Default: false */ + stopOnDrop?: boolean +} diff --git a/packages/svelte-dnd/src/lib/core/animation/behaviors/auto-scroll.ts b/packages/svelte-dnd/src/lib/core/animation/behaviors/auto-scroll.ts new file mode 100644 index 0000000..aa4892c --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/animation/behaviors/auto-scroll.ts @@ -0,0 +1,28 @@ +import type { Behavior, AutoScrollConfig } from '../behavior.js' + +/** + * Edge-triggered auto-scroll while a drag is in progress. + * + * Built-in default — present in the controller's behavior list out of the box, + * so opting out means passing an explicit `behaviors` array without this entry. + * + * @example + * ```ts + * // Controller-level (also applies to non-droppable `data-dnd-scroll` wrappers) + * new DndController({ + * behaviors: [autoScroll({ zoneRatio: 0.2, maxSpeed: 20 })] + * }) + * + * // Per-strategy override + * sortable({ + * layout: 'vertical', + * behaviors: [autoScroll({ maxSpeed: 60 })] + * }) + * ``` + */ +export function autoScroll(opts: AutoScrollConfig = {}): Behavior { + return { + name: 'autoScroll', + autoScrollConfig: opts + } +} diff --git a/packages/svelte-dnd/src/lib/core/animation/behaviors/scroll-sync.ts b/packages/svelte-dnd/src/lib/core/animation/behaviors/scroll-sync.ts new file mode 100644 index 0000000..cae5786 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/animation/behaviors/scroll-sync.ts @@ -0,0 +1,98 @@ +import type { Behavior } from '../behavior.js' +import type { AnimationStep } from '../steps/animation-step.js' +import { DOMHelper } from '../../utils/dom-helper.js' +import { planScrollSync } from '../scroll-sync-runner.js' +import { parseEasing } from '../easing.js' + +export interface ScrollSyncOptions { + /** + * Visibility ratio (0..1) below which the destination container scrolls + * in lockstep with the ghost flight, so the ghost stays inside the + * container's viewport. + * + * - `1.0` (default) — engage scroll-sync as soon as any pixel of the target slot is hidden + * - `0.5` — only when less than half is visible + * - `0` — disable (delegate fully to the wrapped step) + */ + threshold?: number +} + +/** + * Wraps the drop / return animation: when the destination slot is hidden + * enough inside its scrollable container, the container scrolls together + * with the ghost flight instead of letting the ghost fly off-screen. + * + * Built-in default — present in the controller's behavior list out of the box. + * + * @example + * ```ts + * sortable({ + * layout: 'vertical', + * behaviors: [scrollSync({ threshold: 0.5 })] + * }) + * ``` + */ +export function scrollSync(opts: ScrollSyncOptions = {}): Behavior { + const threshold = opts.threshold ?? 1 + + return { + name: 'scrollSync', + wrapDropAnimation(next, ctx) { + let cancelled = false + + return { + execute(): Promise { + return new Promise((resolve) => { + // No target rect / disabled / fully visible → fall through to inner step. + if (!ctx.targetEl || !ctx.container || threshold <= 0) { + next.execute().then(resolve) + return + } + const visible = DOMHelper.computeVisibleFraction( + ctx.targetEl, + ctx.container + ) + if (visible >= threshold) { + next.execute().then(resolve) + return + } + + // Replace inner flight with a scroll-synchronised one. + ctx.state.setAnimating(true) + const plan = planScrollSync({ + state: ctx.state, + container: ctx.container, + targetEl: ctx.targetEl, + direction: ctx.direction, + padding: ctx.padding + }) + + const startTime = Date.now() + const easeFn = parseEasing(ctx.easing) + const animate = () => { + if (cancelled) { + ctx.state.setAnimating(false) + resolve() + return + } + const progress = Math.min((Date.now() - startTime) / plan.duration, 1) + plan.update(easeFn(progress)) + if (progress < 1) { + requestAnimationFrame(animate) + } else { + plan.finalize() + ctx.state.setAnimating(false) + resolve() + } + } + requestAnimationFrame(animate) + }) + }, + cancel(): void { + cancelled = true + next.cancel?.() + } + } satisfies AnimationStep + } + } +} diff --git a/src/lib/core/animation/direction-adapter.ts b/packages/svelte-dnd/src/lib/core/animation/direction-adapter.ts similarity index 100% rename from src/lib/core/animation/direction-adapter.ts rename to packages/svelte-dnd/src/lib/core/animation/direction-adapter.ts diff --git a/packages/svelte-dnd/src/lib/core/animation/drop-animation-coordinator.ts b/packages/svelte-dnd/src/lib/core/animation/drop-animation-coordinator.ts new file mode 100644 index 0000000..7e112b5 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/animation/drop-animation-coordinator.ts @@ -0,0 +1,447 @@ +import type { DndState } from '../dnd/dnd-state.svelte.js' +import type { DndEventEmitter } from '../dnd/dnd-event-emitter.js' +import type { ScrollController } from '../scroll/scroll-controller.js' +import type { DropResolver } from '../zones/drop-resolver.js' +import type { AnimationStep } from './steps/animation-step.js' +import type { + DropPreview, + DndItemInfo, + DndContainerInfo, + DropEvent, + DragEndEvent, + DragOverEvent, + DropCancelledEvent +} from '../../types.js' +import type { Droppable } from '../entities/droppable.svelte.js' +import type { ResolvedAnimationConfig } from './animation-config.js' +import type { Behavior, BehaviorContext } from './behavior.js' +import { AnimationPipeline } from './steps/animation-pipeline.js' +import { GhostToTargetStep } from './steps/ghost-to-target-step.js' +import { GhostReturnStep } from './steps/ghost-return-step.js' +import { DEFAULT_ANIMATION_CONFIG } from './animation-config.js' +import { parseEasing } from './easing.js' +import { resolveBehaviors, wrapWithBehaviors, findTargetSlotWrapper } from './apply-behaviors.js' + +export class DropAnimationCoordinator { + private currentAnimation: AnimationPipeline | null = null + private lastPreviewKey: string | null = null + + constructor( + private state: DndState, + private eventEmitter: DndEventEmitter, + private scrollController: ScrollController, + private dropResolver: DropResolver, + private droppablesById: Map = new Map(), + private animation: ResolvedAnimationConfig = DEFAULT_ANIMATION_CONFIG, + private defaultBehaviors: Behavior[] = [] + ) {} + + private buildContext( + droppable: Droppable | null, + position: number, + duration: number, + easing: string + ): BehaviorContext { + const layout = droppable?.layout + return { + state: this.state, + direction: layout === 'horizontal' ? 'horizontal' : 'vertical', + targetEl: findTargetSlotWrapper(droppable, position), + container: droppable?.element ?? null, + duration, + easing, + padding: droppable?.spacing ?? 0 + } + } + + private wrap( + step: AnimationStep, + droppable: Droppable | null, + position: number, + duration: number, + easing: string + ): AnimationStep { + const behaviors = resolveBehaviors(droppable, this.defaultBehaviors) + const ctx = this.buildContext(droppable, position, duration, easing) + return wrapWithBehaviors(step, behaviors, ctx) + } + + setDefaultBehaviors(behaviors: Behavior[]) { + this.defaultBehaviors = behaviors + } + + setAnimationConfig(animation: ResolvedAnimationConfig) { + this.animation = animation + } + + updateDropPreview(pointer: { x: number; y: number }) { + if (!this.state.dragging) { + this.state.setDropPreview(null) + return + } + + const targetZone = this.dropResolver.findZoneAt(pointer) + + if (targetZone) { + this.notifyPreviewChange({ + containerId: targetZone.containerId, + position: targetZone.position + }) + + if (this.state.skipDropPreviewAnimation) { + requestAnimationFrame(() => { + this.state.setSkipDropPreviewAnimation(false) + }) + } + } else if (this.state.dropPreview) { + // Pointer left all zones — drop the preview immediately. The Preview entity + // plays its own collapse animation via collapseTimer, so visual fade-out keeps working. + this.state.setDropPreview(null) + this.lastPreviewKey = null + } + } + + /** Set the active drop preview from outside the pointer path (e.g. keyboard navigation). Fires `onDragOver` like `updateDropPreview`. */ + setActivePreview(preview: DropPreview) { + if (!this.state.dragging) return + this.notifyPreviewChange(preview) + if (this.state.skipDropPreviewAnimation) { + requestAnimationFrame(() => { + this.state.setSkipDropPreviewAnimation(false) + }) + } + } + + private notifyPreviewChange(preview: DropPreview) { + this.state.setDropPreview(preview) + + const previewKey = `${preview.containerId}:${preview.position}` + if (previewKey === this.lastPreviewKey) return + + const prevKey = this.lastPreviewKey + this.lastPreviewKey = previewKey + + const sourceId = this.state.draggedItem + const element = this.state.element + if (!sourceId || !element) return + + const originContainerId = this.state.originContainerId + const sourceDroppable = originContainerId + ? this.droppablesById.get(originContainerId) + : null + const targetDroppable = this.droppablesById.get(preview.containerId) + if (!sourceDroppable || !targetDroppable) return + + const itemInfo: DndItemInfo = { + id: sourceId, + data: this.state.draggedItemData, + type: this.state.draggedType ?? undefined, + element + } + const sourceInfo: DndContainerInfo = sourceDroppable.toContainerInfo( + this.state.originPosition + ) + const currentInfo: DndContainerInfo = targetDroppable.toContainerInfo(preview.position) + + let previousInfo: DndContainerInfo | null = null + if (prevKey) { + const sepIdx = prevKey.lastIndexOf(':') + const prevContainerId = prevKey.slice(0, sepIdx) + const prevPosition = parseInt(prevKey.slice(sepIdx + 1)) + const prevDroppable = this.droppablesById.get(prevContainerId) + if (prevDroppable) { + previousInfo = prevDroppable.toContainerInfo(prevPosition) + } + } + + const event: DragOverEvent = { + item: itemInfo, + source: sourceInfo, + current: currentInfo, + previous: previousInfo + } + this.eventEmitter.notifyDragOver(event) + } + + performDrop( + sourceId: string, + sourceData: Record | undefined, + targetContainerId: string, + position: number + ) { + const element = this.state.element + const type = this.state.draggedType ?? undefined + const originContainerId = this.state.originContainerId + const originPosition = this.state.originPosition + const sourceDroppable = originContainerId + ? this.droppablesById.get(originContainerId) + : null + const targetDroppable = this.droppablesById.get(targetContainerId) + + const targetZone = this.state.zones.find( + (zone) => zone.containerId === targetContainerId && zone.position === position + ) + + this.state.setPerformingDrop(true) + if (this.scrollController.stopOnDrop) this.scrollController.clearAll() + + const isCrossContainer = targetContainerId !== originContainerId + + // Smooth source-slot collapse runs in parallel with the ghost flight so + // items below the dragged source move up smoothly while the ghost flies. + const collapsePromise = + isCrossContainer && element && sourceDroppable + ? this.startSlotCollapse(element, sourceDroppable, sourceId, originPosition) + : Promise.resolve() + + const onDropComplete = async () => { + await collapsePromise + + if (element && sourceDroppable && targetDroppable) { + const itemInfo: DndItemInfo = { id: sourceId, data: sourceData, type, element } + const sourceInfo: DndContainerInfo = sourceDroppable.toContainerInfo(originPosition) + const targetInfo: DndContainerInfo = targetDroppable.toContainerInfo(position) + const dropEvent: DropEvent = { + item: itemInfo, + source: sourceInfo, + target: targetInfo + } + const dragEndEvent: DragEndEvent = { + item: itemInfo, + source: sourceInfo, + target: targetInfo, + cancelled: false + } + + // Save scroll positions before DOM reorder — browser scroll anchoring + // can shift scrollTop when content height changes after items update. + // Skip for virtualized containers entirely: they manage their own + // scroll state and any extra write fights their reconciliation. + const srcScrollTarget = !sourceDroppable.isVirtualized + ? sourceDroppable.element + : undefined + const tgtScrollTarget = + sourceDroppable !== targetDroppable && !targetDroppable.isVirtualized + ? targetDroppable.element + : undefined + const srcScroll = srcScrollTarget?.scrollTop + const tgtScroll = tgtScrollTarget?.scrollTop + + this.eventEmitter.notifyDrop(dropEvent) + this.finalizeDragEnd(dragEndEvent) + + queueMicrotask(() => + queueMicrotask(() => { + if (srcScroll !== undefined && srcScrollTarget) + srcScrollTarget.scrollTop = srcScroll + if (tgtScroll !== undefined && tgtScrollTarget) + tgtScrollTarget.scrollTop = tgtScroll + }) + ) + } else { + this.finalizeDragEnd(null) + } + } + + if (targetZone && this.state.element && this.state.transform) { + const baseStep = new GhostToTargetStep( + this.state, + targetZone, + this.droppablesById, + this.animation.drop.duration, + this.animation.drop.easing + ) + const wrapped = this.wrap( + baseStep, + targetDroppable ?? null, + position, + this.animation.drop.duration, + this.animation.drop.easing + ) + this.animate(wrapped, onDropComplete) + } else { + onDropComplete() + } + } + + endDrag(shouldAnimate = true) { + const itemId = this.state.draggedItem + const session = this.state.session + + // Capture session data before any async animation + const element = this.state.element + const type = this.state.draggedType ?? undefined + const originContainerId = this.state.originContainerId + const originPosition = this.state.originPosition + const sourceDroppable = originContainerId + ? this.droppablesById.get(originContainerId) + : null + + let cancelledEvent: DropCancelledEvent | null = null + let dragEndEvent: DragEndEvent | null = null + + if (itemId && element && sourceDroppable) { + const itemInfo: DndItemInfo = { + id: itemId, + data: this.state.draggedItemData, + type, + element + } + const sourceInfo: DndContainerInfo = sourceDroppable.toContainerInfo(originPosition) + cancelledEvent = { item: itemInfo, source: sourceInfo } + dragEndEvent = { item: itemInfo, source: sourceInfo, target: null, cancelled: true } + } + + if (cancelledEvent) this.eventEmitter.notifyDropCancelled(cancelledEvent) + + if (shouldAnimate && session?.originContainerId) { + this.state.setDropPreview({ + containerId: session.originContainerId, + position: session.originPosition + }) + } + + if (shouldAnimate && session) { + requestAnimationFrame(() => { + const originId = this.state.originContainerId + const originPos = this.state.originPosition + const originDroppable = originId + ? (this.droppablesById.get(originId) ?? null) + : null + const baseStep = new GhostReturnStep( + this.state, + originId, + originPos, + this.droppablesById, + this.animation.return.duration, + this.animation.return.easing + ) + const wrapped = this.wrap( + baseStep, + originDroppable, + originPos, + this.animation.return.duration, + this.animation.return.easing + ) + this.animate(wrapped, () => this.finalizeDragEnd(dragEndEvent)) + }) + } else { + this.finalizeDragEnd(dragEndEvent) + } + } + + cancelCurrentAnimation() { + this.currentAnimation?.cancel() + } + + destroy() { + this.currentAnimation?.cancel() + } + + // --- Private --- + + private startSlotCollapse( + element: HTMLElement, + sourceDroppable: Droppable, + sourceId: string, + originPosition: number + ): Promise { + const slotEl = element.parentElement + if (!slotEl?.hasAttribute('data-dnd-slot')) return Promise.resolve() + + const isHorizontal = sourceDroppable.layout === 'horizontal' + const slotSize = this.state.dragSlotSize + const fullSize = isHorizontal ? (slotSize?.width ?? 0) : (slotSize?.height ?? 0) + const startDim = isHorizontal ? slotEl.offsetWidth : slotEl.offsetHeight + const computed = getComputedStyle(slotEl) + const startMargin = isHorizontal + ? parseFloat(computed.marginRight) || 0 + : parseFloat(computed.marginBottom) || 0 + + // Items below the dragged item need their transforms adjusted per-frame + // so they appear to stay still as the DOM layout shrinks beneath them. + const affectedDraggables = sourceDroppable + .getSortedSlots() + .filter((s) => s.draggable.id !== sourceId && s.position > originPosition) + .map((s) => s.draggable.element) + + // `flex-shrink: 0` blocks the flex container from collapsing the slot itself + // when `overflow: hidden` (next line) drops min-height/width to 0. Without it, + // in an overflowing scrollable parent the slot snaps to 0 on the very first + // frame and siblings jump up by `fullSize` before our per-frame transform can + // compensate. + slotEl.style.flexShrink = '0' + slotEl.style.overflow = 'hidden' + + // Matches the ghost flight duration so both animations finish together. + const duration = this.animation.slotCollapse.duration + const easeFn = parseEasing(this.animation.slotCollapse.easing) + return new Promise((resolve) => { + const startTime = performance.now() + + const tick = (now: number) => { + const elapsed = now - startTime + const t = Math.min(elapsed / duration, 1) + const eased = easeFn(t) + const remaining = 1 - eased + const collapseAmount = (startDim + startMargin) * eased + + if (isHorizontal) { + slotEl.style.width = startDim * remaining + 'px' + slotEl.style.marginRight = startMargin * remaining + 'px' + } else { + slotEl.style.height = startDim * remaining + 'px' + slotEl.style.marginBottom = startMargin * remaining + 'px' + } + + if (fullSize > 0 && affectedDraggables.length > 0) { + const adj = -(fullSize - collapseAmount) + for (const el of affectedDraggables) { + el.style.transform = isHorizontal + ? `translate3d(${adj}px, 0, 0)` + : `translate3d(0, ${adj}px, 0)` + } + } + + if (t < 1) { + requestAnimationFrame(tick) + } else { + resolve() + } + } + + requestAnimationFrame(tick) + }) + } + + private animate(step: AnimationStep, onComplete: () => void | Promise): void { + this.currentAnimation?.cancel() + const pipeline = AnimationPipeline.chain(step) + this.currentAnimation = pipeline + pipeline.execute().then(() => { + this.currentAnimation = null + return onComplete() + }) + } + + private finalizeDragEnd(dragEndEvent: DragEndEvent | null) { + this.lastPreviewKey = null + // Ordering matters: set skip=true BEFORE reset() so Preview sees "skip" when session + // clears; keep performingDrop=true across reset() so Preview.hide() reads it as true and + // collapses instantly (otherwise it uses the delayed path). Clear on next frame. + this.state.setSkipDropPreviewAnimation(true) + this.scrollController.clearAll() + this.state.reset() + requestAnimationFrame(() => { + this.state.setPerformingDrop(false) + }) + + if (dragEndEvent) { + this.eventEmitter.notifyDragEnd(dragEndEvent) + } + + setTimeout(() => { + this.state.setSkipDropPreviewAnimation(false) + }, 100) + } +} diff --git a/packages/svelte-dnd/src/lib/core/animation/easing.ts b/packages/svelte-dnd/src/lib/core/animation/easing.ts new file mode 100644 index 0000000..76af8f3 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/animation/easing.ts @@ -0,0 +1,78 @@ +/** + * Parses a CSS timing-function string into a JS interpolation function. + * + * Supports: + * - Keywords: `linear`, `ease`, `ease-in`, `ease-out`, `ease-in-out` + * - `cubic-bezier(x1, y1, x2, y2)` with arbitrary control points + * + * Falls back to `ease-out` and emits a console warning on unknown input. + * + * @example + * const ease = parseEasing('cubic-bezier(0.25, 0.46, 0.45, 0.94)') + * ease(0) // => 0 + * ease(0.5) // => ~0.62 + * ease(1) // => 1 + */ +export function parseEasing(easing: string): (t: number) => number { + const trimmed = easing.trim().toLowerCase() + if (trimmed === 'linear') return (t) => t + if (trimmed === 'ease') return cubicBezierFn(0.25, 0.1, 0.25, 1) + if (trimmed === 'ease-in') return cubicBezierFn(0.42, 0, 1, 1) + if (trimmed === 'ease-out') return cubicBezierFn(0, 0, 0.58, 1) + if (trimmed === 'ease-in-out') return cubicBezierFn(0.42, 0, 0.58, 1) + + const m = trimmed.match( + /^cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)$/ + ) + if (m) { + return cubicBezierFn(parseFloat(m[1]), parseFloat(m[2]), parseFloat(m[3]), parseFloat(m[4])) + } + + if (typeof console !== 'undefined') { + console.warn(`[svelte-dnd] Unsupported easing "${easing}", falling back to ease-out`) + } + return cubicBezierFn(0, 0, 0.58, 1) +} + +/** + * Cubic-bezier solver. Builds a sample table once, then for each t finds the + * matching point on the curve via Newton-Raphson. Same algorithm browsers use. + */ +function cubicBezierFn(x1: number, y1: number, x2: number, y2: number): (t: number) => number { + const SAMPLE_COUNT = 11 + const SAMPLE_STEP = 1 / (SAMPLE_COUNT - 1) + const samples = new Float32Array(SAMPLE_COUNT) + + const a = (a1: number, a2: number) => 1 - 3 * a2 + 3 * a1 + const b = (a1: number, a2: number) => 3 * a2 - 6 * a1 + const c = (a1: number) => 3 * a1 + const calc = (t: number, a1: number, a2: number) => + ((a(a1, a2) * t + b(a1, a2)) * t + c(a1)) * t + const slope = (t: number, a1: number, a2: number) => + 3 * a(a1, a2) * t * t + 2 * b(a1, a2) * t + c(a1) + + for (let i = 0; i < SAMPLE_COUNT; i++) samples[i] = calc(i * SAMPLE_STEP, x1, x2) + + const tForX = (x: number): number => { + let intervalStart = 0 + let i = 1 + for (; i !== SAMPLE_COUNT - 1 && samples[i] <= x; i++) intervalStart += SAMPLE_STEP + i-- + + const dist = (x - samples[i]) / (samples[i + 1] - samples[i]) + let guess = intervalStart + dist * SAMPLE_STEP + + for (let j = 0; j < 4; j++) { + const s = slope(guess, x1, x2) + if (s === 0) return guess + guess -= (calc(guess, x1, x2) - x) / s + } + return guess + } + + return (x: number) => { + if (x <= 0) return 0 + if (x >= 1) return 1 + return calc(tForX(x), y1, y2) + } +} diff --git a/packages/svelte-dnd/src/lib/core/animation/keyboard-flight.ts b/packages/svelte-dnd/src/lib/core/animation/keyboard-flight.ts new file mode 100644 index 0000000..46d3ef9 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/animation/keyboard-flight.ts @@ -0,0 +1,64 @@ +import type { DndState } from '../dnd/dnd-state.svelte.js' +import { parseEasing } from './easing.js' + +export interface KeyboardFlightPlan { + duration: number + update(eased: number): void + finalize?(): void +} + +/** Cancellable rAF runner for keyboard-driven ghost flights. A new `run`/`animateTo` replaces any in-flight. */ +export class KeyboardFlight { + private rafId: number | null = null + + constructor(private state: DndState) {} + + run(plan: KeyboardFlightPlan, easing: string) { + this.cancel() + const startTime = performance.now() + const easeFn = parseEasing(easing) + + const tick = (now: number) => { + const progress = Math.min((now - startTime) / plan.duration, 1) + plan.update(easeFn(progress)) + if (progress < 1) { + this.rafId = requestAnimationFrame(tick) + } else { + plan.finalize?.() + this.rafId = null + } + } + + this.rafId = requestAnimationFrame(tick) + } + + animateTo(target: { x: number; y: number }, options: { duration: number; easing: string }) { + const start = this.state.transform + if (!start) { + this.cancel() + this.state.setTransform(target) + return + } + const startPos = { x: start.x, y: start.y } + const state = this.state + this.run( + { + duration: options.duration, + update(eased) { + state.setTransform({ + x: startPos.x + (target.x - startPos.x) * eased, + y: startPos.y + (target.y - startPos.y) * eased + }) + } + }, + options.easing + ) + } + + cancel() { + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId) + this.rafId = null + } + } +} diff --git a/packages/svelte-dnd/src/lib/core/animation/preview-slot-rect.ts b/packages/svelte-dnd/src/lib/core/animation/preview-slot-rect.ts new file mode 100644 index 0000000..dcd7f9c --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/animation/preview-slot-rect.ts @@ -0,0 +1,27 @@ +import type { Droppable } from '../entities/droppable.svelte.js' + +/** + * Absolute viewport position the ghost should sit at for the preview slot at + * `(droppable, position)`. Returns `null` for `target` mode, an unmounted + * preview entity, or missing ghost size. + */ +export function computePreviewSlotTarget( + droppable: Droppable, + position: number, + ghostSize: { width: number; height: number } | null +): { x: number; y: number } | null { + if (droppable.mode === 'target') return null + + const previewEntity = droppable.getSlotAt(position)?.preview ?? droppable.tailPreview + const previewEl = previewEntity?.element + if (!previewEl || !previewEntity) return null + + const slotWrapper = (previewEl.parentElement ?? previewEl) as HTMLElement + const wrapperRect = slotWrapper.getBoundingClientRect() + const isHorizontal = previewEntity.isHorizontal + const alignEndY = previewEntity.align === 'end' && !isHorizontal + const alignEndX = previewEntity.align === 'end' && isHorizontal + const y = alignEndY ? wrapperRect.bottom - (ghostSize?.height ?? 0) : wrapperRect.top + const x = alignEndX ? wrapperRect.right - (ghostSize?.width ?? 0) : wrapperRect.left + return { x, y } +} diff --git a/src/lib/core/animation/scroll-sync-calculator.ts b/packages/svelte-dnd/src/lib/core/animation/scroll-sync-calculator.ts similarity index 55% rename from src/lib/core/animation/scroll-sync-calculator.ts rename to packages/svelte-dnd/src/lib/core/animation/scroll-sync-calculator.ts index 7411a0a..b309389 100644 --- a/src/lib/core/animation/scroll-sync-calculator.ts +++ b/packages/svelte-dnd/src/lib/core/animation/scroll-sync-calculator.ts @@ -1,4 +1,4 @@ -import { getDirectionAdapter, type DirectionAdapter } from './direction-adapter.js' +import { getDirectionAdapter } from './direction-adapter.js' // Animation timing constants const ANIMATION_DURATION = { @@ -9,10 +9,12 @@ const ANIMATION_DURATION = { const SCROLL_SPEED_PX_PER_SEC = 1800 interface ScrollTargetParams { - placeholder: HTMLElement + preview: HTMLElement container: HTMLElement expectedSize: number direction: 'vertical' | 'horizontal' + /** Extra space (px) to keep between the preview and the container edge after scrolling. Defaults to 0. */ + padding?: number } interface ScrollTargetResult { @@ -21,17 +23,17 @@ interface ScrollTargetResult { } interface FinalGhostPositionParams { - placeholderRect: DOMRect + previewRect: DOMRect scrollDelta: number direction: 'vertical' | 'horizontal' } export class ScrollSyncCalculator { calculateScrollTarget(params: ScrollTargetParams): ScrollTargetResult { - const { placeholder, container, expectedSize, direction } = params + const { preview, container, expectedSize, direction, padding = 0 } = params const adapter = getDirectionAdapter(direction) - const placeholderRect = placeholder.getBoundingClientRect() + const previewRect = preview.getBoundingClientRect() const containerRect = container.getBoundingClientRect() const startScroll = adapter.getScroll(container) @@ -39,19 +41,24 @@ export class ScrollSyncCalculator { const containerStart = adapter.getPosition(containerRect) const containerEnd = containerStart + containerSize - const placeholderSize = adapter.getSize(placeholderRect) || expectedSize - const placeholderStart = adapter.getPosition(placeholderRect) - const placeholderEnd = adapter.getEndPosition(placeholderRect, placeholderSize) + const previewSize = adapter.getSize(previewRect) || expectedSize + const previewStart = adapter.getPosition(previewRect) + const previewEnd = adapter.getEndPosition(previewRect, previewSize) + + // Inset the visible band by `padding` so the preview lands with a gap + // from the container edge — same value droppable.spacing leaves between items. + const visibleStart = containerStart + padding + const visibleEnd = containerEnd - padding let targetScroll = startScroll - if (placeholderStart < containerStart) { - // Placeholder вище/лівіше видимої області - const overflow = containerStart - placeholderStart + if (previewStart < visibleStart) { + // Preview is above/left of the visible band + const overflow = visibleStart - previewStart targetScroll = startScroll - overflow - } else if (placeholderEnd > containerEnd) { - // Placeholder нижче/правіше видимої області - const overflow = placeholderEnd - containerEnd + } else if (previewEnd > visibleEnd) { + // Preview is below/right of the visible band + const overflow = previewEnd - visibleEnd targetScroll = startScroll + overflow } @@ -72,17 +79,17 @@ export class ScrollSyncCalculator { } calculateFinalGhostPosition(params: FinalGhostPositionParams): { x: number; y: number } { - const { placeholderRect, scrollDelta, direction } = params + const { previewRect, scrollDelta, direction } = params if (direction === 'horizontal') { return { - x: placeholderRect.left - scrollDelta, - y: placeholderRect.top + x: previewRect.left - scrollDelta, + y: previewRect.top } } else { return { - x: placeholderRect.left, - y: placeholderRect.top - scrollDelta + x: previewRect.left, + y: previewRect.top - scrollDelta } } } diff --git a/packages/svelte-dnd/src/lib/core/animation/scroll-sync-runner.ts b/packages/svelte-dnd/src/lib/core/animation/scroll-sync-runner.ts new file mode 100644 index 0000000..1434855 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/animation/scroll-sync-runner.ts @@ -0,0 +1,85 @@ +import { ScrollSyncCalculator } from './scroll-sync-calculator.js' +import { getDirectionAdapter } from './direction-adapter.js' +import type { DndState } from '../dnd/dnd-state.svelte.js' + +const calc = new ScrollSyncCalculator() + +export interface ScrollSyncPlan { + /** Adaptive duration in ms, based on scroll distance. */ + duration: number + /** Update ghost transform + container scroll for an eased progress in [0, 1]. */ + update(eased: number): void + /** Snap to final exact scroll + transform — reads fresh target rect. */ + finalize(): void +} + +/** + * Builds an adaptive scroll-sync plan that ferries a ghost to a target element + * inside a scrollable container. Used when the target element is off-screen: + * the container scrolls in lockstep with the ghost flight so the ghost never + * disappears past the container's viewport. + * + * Caller drives the rAF loop and supplies its own cancellation check; this + * helper stays loop-agnostic so different animation steps can share the same + * scroll-aware behaviour. + */ +export function planScrollSync(args: { + state: DndState + container: HTMLElement + targetEl: HTMLElement + direction: 'vertical' | 'horizontal' + /** Extra space to keep between the target and the container edge after scrolling. Defaults to 0. */ + padding?: number + /** When `true` (default), `finalize()` also re-reads the target rect and snaps the ghost there. */ + snapToTargetOnFinalize?: boolean +}): ScrollSyncPlan { + const { + state, + container, + targetEl, + direction, + padding = 0, + snapToTargetOnFinalize = true + } = args + const adapter = getDirectionAdapter(direction) + const startScroll = adapter.getScroll(container) + const startGhostPos = { ...(state.transform ?? { x: 0, y: 0 }) } + const previewRect = targetEl.getBoundingClientRect() + + const expectedSize = + direction === 'horizontal' ? (state.ghostSize?.width ?? 0) : (state.ghostSize?.height ?? 0) + + const { targetScroll, scrollDelta } = calc.calculateScrollTarget({ + preview: targetEl, + container, + expectedSize, + direction, + padding + }) + + const duration = calc.calculateAdaptiveDuration(Math.abs(scrollDelta)) + + const finalGhostPos = calc.calculateFinalGhostPosition({ + previewRect, + scrollDelta, + direction + }) + + return { + duration, + update(eased: number) { + adapter.setScroll(container, startScroll + scrollDelta * eased) + state.setTransform({ + x: startGhostPos.x + (finalGhostPos.x - startGhostPos.x) * eased, + y: startGhostPos.y + (finalGhostPos.y - startGhostPos.y) * eased + }) + }, + finalize() { + adapter.setScroll(container, targetScroll) + if (snapToTargetOnFinalize) { + const finalRect = targetEl.getBoundingClientRect() + state.setTransform({ x: finalRect.left, y: finalRect.top }) + } + } + } +} diff --git a/packages/svelte-dnd/src/lib/core/animation/steps/animation-pipeline.ts b/packages/svelte-dnd/src/lib/core/animation/steps/animation-pipeline.ts new file mode 100644 index 0000000..d233e61 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/animation/steps/animation-pipeline.ts @@ -0,0 +1,27 @@ +import type { AnimationStep } from './animation-step.js' + +export class AnimationPipeline implements AnimationStep { + private steps: AnimationStep[] = [] + private currentIndex = 0 + private cancelled = false + + static chain(...steps: AnimationStep[]): AnimationPipeline { + const pipeline = new AnimationPipeline() + pipeline.steps = steps + return pipeline + } + + async execute(): Promise { + for (this.currentIndex = 0; this.currentIndex < this.steps.length; this.currentIndex++) { + if (this.cancelled) return + await this.steps[this.currentIndex].execute() + } + } + + cancel(): void { + this.cancelled = true + if (this.steps[this.currentIndex]) { + this.steps[this.currentIndex].cancel() + } + } +} diff --git a/packages/svelte-dnd/src/lib/core/animation/steps/animation-step.ts b/packages/svelte-dnd/src/lib/core/animation/steps/animation-step.ts new file mode 100644 index 0000000..30a10bc --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/animation/steps/animation-step.ts @@ -0,0 +1,11 @@ +export interface AnimationStep { + execute(onProgress?: (progress: number) => void): Promise + cancel(): void +} + +export class InstantStep implements AnimationStep { + execute(): Promise { + return Promise.resolve() + } + cancel(): void {} +} diff --git a/packages/svelte-dnd/src/lib/core/animation/steps/ghost-return-step.ts b/packages/svelte-dnd/src/lib/core/animation/steps/ghost-return-step.ts new file mode 100644 index 0000000..16fc27c --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/animation/steps/ghost-return-step.ts @@ -0,0 +1,102 @@ +import type { AnimationStep } from './animation-step.js' +import type { DndState } from '../../dnd/dnd-state.svelte.js' +import type { Droppable } from '../../entities/droppable.svelte.js' +import { DOMHelper } from '../../utils/dom-helper.js' +import { DEFAULT_ANIMATION_CONFIG } from '../animation-config.js' +import { parseEasing } from '../easing.js' + +/** + * Animates the ghost back to the dragged item's origin position. + * + * The step itself is layout-agnostic — it interpolates from the current ghost + * transform to the live origin slot rect each frame. Scroll-aware behaviour + * (scroll the container in lockstep when the origin slot is off-screen) is + * provided by the `scrollSync()` behavior wrapping this step. + */ +export class GhostReturnStep implements AnimationStep { + private cancelled = false + + constructor( + private state: DndState, + private containerId: string | null, + private position: number, + private droppablesById: Map, + private duration: number = DEFAULT_ANIMATION_CONFIG.return.duration, + private easing: string = DEFAULT_ANIMATION_CONFIG.return.easing + ) {} + + execute(): Promise { + return new Promise((resolve, reject) => { + if (!this.state.element || !this.state.transform || !this.state.originalPosition) { + resolve() + return + } + + const fallbackPos = { ...this.state.originalPosition } + const startPos = { ...this.state.transform } + this.state.setAnimating(true) + const startTime = Date.now() + const easeFn = parseEasing(this.easing) + // Single exit point — every path through animate() either calls finish() + // or rethrows after clearing setAnimating, so the flag never leaks. + const finish = () => { + this.state.setAnimating(false) + resolve() + } + + const animate = () => { + try { + if (this.cancelled) { + finish() + return + } + + const progress = Math.min((Date.now() - startTime) / this.duration, 1) + const eased = easeFn(progress) + const target = this.getCurrentSlotPosition(fallbackPos) + + this.state.setTransform({ + x: startPos.x + (target.x - startPos.x) * eased, + y: startPos.y + (target.y - startPos.y) * eased + }) + + if (progress < 1) { + requestAnimationFrame(animate) + } else { + finish() + } + } catch (err) { + this.state.setAnimating(false) + reject(err) + } + } + + requestAnimationFrame(animate) + }) + } + + cancel(): void { + this.cancelled = true + } + + private getCurrentSlotPosition(fallback: { x: number; y: number }): { x: number; y: number } { + if (!this.containerId) return fallback + const slotEl = this.getSlotWrapper() + if (!slotEl) return fallback + const rect = slotEl.getBoundingClientRect() + return { x: rect.left, y: rect.top } + } + + /** Returns the slot wrapper element for the origin position, using entity lookup. */ + private getSlotWrapper(): HTMLElement | null { + if (!this.containerId) return null + const droppable = this.droppablesById.get(this.containerId) + // Entity lookup (works for all regular slot positions) + const slotEl = droppable?.getSlotAt(this.position)?.element + if (slotEl) return slotEl + // DOM fallback for tail preview or missing entity + const container = droppable?.element + if (!container) return null + return DOMHelper.findPreviewSlot(container, this.position) + } +} diff --git a/packages/svelte-dnd/src/lib/core/animation/steps/ghost-to-target-step.ts b/packages/svelte-dnd/src/lib/core/animation/steps/ghost-to-target-step.ts new file mode 100644 index 0000000..a055946 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/animation/steps/ghost-to-target-step.ts @@ -0,0 +1,110 @@ +import type { AnimationStep } from './animation-step.js' +import type { DndState } from '../../dnd/dnd-state.svelte.js' +import type { DropZone } from '../../../types.js' +import type { Droppable } from '../../entities/droppable.svelte.js' +import { DEFAULT_ANIMATION_CONFIG } from '../animation-config.js' +import { parseEasing } from '../easing.js' +import { computePreviewSlotTarget } from '../preview-slot-rect.js' + +export class GhostToTargetStep implements AnimationStep { + private cancelled = false + + constructor( + private state: DndState, + private targetZone: DropZone, + private droppablesById: Map, + private duration: number = DEFAULT_ANIMATION_CONFIG.drop.duration, + private easing: string = DEFAULT_ANIMATION_CONFIG.drop.easing + ) {} + + execute(): Promise { + return new Promise((resolve, reject) => { + if (!this.state.element || !this.state.transform) { + resolve() + return + } + + this.state.setAnimating(true) + const startPos = { ...this.state.transform } + const startTime = Date.now() + const easeFn = parseEasing(this.easing) + // Single exit point — every path through animate() either calls finish() + // or rethrows after clearing setAnimating, so the flag never leaks. + const finish = () => { + this.state.setAnimating(false) + resolve() + } + + const animate = () => { + try { + if (this.cancelled) { + finish() + return + } + + const elapsed = Date.now() - startTime + const progress = Math.min(elapsed / this.duration, 1) + const easedProgress = easeFn(progress) + const targetPos = this.calculateTargetPosition() + + this.state.setTransform({ + x: startPos.x + (targetPos.x - startPos.x) * easedProgress, + y: startPos.y + (targetPos.y - startPos.y) * easedProgress + }) + + if (progress < 1) { + requestAnimationFrame(animate) + } else { + finish() + } + } catch (err) { + this.state.setAnimating(false) + reject(err) + } + } + + requestAnimationFrame(animate) + }) + } + + cancel(): void { + this.cancelled = true + } + + private calculateTargetPosition(): { x: number; y: number } { + const droppable = this.droppablesById.get(this.targetZone.containerId) + const container = droppable?.element ?? null + if (!container || !droppable) return this.fallbackPosition() + + if (droppable.mode === 'target') { + const rect = container.getBoundingClientRect() + const width = this.state.ghostSize?.width ?? 0 + const height = this.state.ghostSize?.height ?? 0 + return { + x: rect.left + rect.width / 2 - width / 2, + y: rect.top + rect.height / 2 - height / 2 + } + } + + const slotTarget = computePreviewSlotTarget( + droppable, + this.targetZone.position, + this.state.ghostSize + ) + if (slotTarget) return slotTarget + + const containerRect = container.getBoundingClientRect() + return { x: containerRect.left, y: this.targetZone.rect.y } + } + + private fallbackPosition(): { x: number; y: number } { + return { + x: + this.targetZone.rect.x + + (this.targetZone.rect.width - (this.state.ghostSize?.width ?? 0)) / 2, + y: + this.targetZone.rect.y + + (this.targetZone.rect.height - (this.state.ghostSize?.height ?? 0)) / 2 + } + } +} diff --git a/packages/svelte-dnd/src/lib/core/collision/center-point.ts b/packages/svelte-dnd/src/lib/core/collision/center-point.ts new file mode 100644 index 0000000..43e4757 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/collision/center-point.ts @@ -0,0 +1,15 @@ +import type { CollisionAlgorithm } from './collision-algorithm.js' + +export const centerPoint: CollisionAlgorithm = ({ zones, ghost }) => { + const cx = ghost.x + ghost.width / 2 + const cy = ghost.y + ghost.height / 2 + return ( + zones.find( + (zone) => + cx >= zone.rect.x && + cx <= zone.rect.x + zone.rect.width && + cy >= zone.rect.y && + cy <= zone.rect.y + zone.rect.height + ) ?? null + ) +} diff --git a/packages/svelte-dnd/src/lib/core/collision/closest-center.ts b/packages/svelte-dnd/src/lib/core/collision/closest-center.ts new file mode 100644 index 0000000..a73b5af --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/collision/closest-center.ts @@ -0,0 +1,20 @@ +import type { CollisionAlgorithm } from './collision-algorithm.js' + +export const closestCenter: CollisionAlgorithm = ({ zones, ghost }) => { + const ghostCenterX = ghost.x + ghost.width / 2 + const ghostCenterY = ghost.y + ghost.height / 2 + let closest = null + let minDist = Infinity + + for (const zone of zones) { + const zoneCenterX = zone.rect.x + zone.rect.width / 2 + const zoneCenterY = zone.rect.y + zone.rect.height / 2 + const dist = Math.hypot(ghostCenterX - zoneCenterX, ghostCenterY - zoneCenterY) + if (dist < minDist) { + minDist = dist + closest = zone + } + } + + return closest +} diff --git a/packages/svelte-dnd/src/lib/core/collision/collision-algorithm.ts b/packages/svelte-dnd/src/lib/core/collision/collision-algorithm.ts new file mode 100644 index 0000000..7e62481 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/collision/collision-algorithm.ts @@ -0,0 +1,9 @@ +import type { DropZone } from '../../types.js' + +export interface CollisionContext { + zones: DropZone[] + pointer: { x: number; y: number } + ghost: { x: number; y: number; width: number; height: number } +} + +export type CollisionAlgorithm = (context: CollisionContext) => DropZone | null diff --git a/packages/svelte-dnd/src/lib/core/collision/cursor-over.ts b/packages/svelte-dnd/src/lib/core/collision/cursor-over.ts new file mode 100644 index 0000000..6e839a5 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/collision/cursor-over.ts @@ -0,0 +1,13 @@ +import type { CollisionAlgorithm } from './collision-algorithm.js' + +export const cursorOver: CollisionAlgorithm = ({ zones, pointer }) => { + return ( + zones.find( + (zone) => + pointer.x >= zone.rect.x && + pointer.x <= zone.rect.x + zone.rect.width && + pointer.y >= zone.rect.y && + pointer.y <= zone.rect.y + zone.rect.height + ) ?? null + ) +} diff --git a/packages/svelte-dnd/src/lib/core/collision/overlap.ts b/packages/svelte-dnd/src/lib/core/collision/overlap.ts new file mode 100644 index 0000000..dfbc1b8 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/collision/overlap.ts @@ -0,0 +1,32 @@ +import type { CollisionAlgorithm } from './collision-algorithm.js' + +function parseThreshold(value: number | string, ghost: { width: number; height: number }): number { + if (typeof value === 'string' && value.endsWith('%')) { + const pct = parseFloat(value) / 100 + return pct * Math.min(ghost.width, ghost.height) + } + return typeof value === 'number' ? value : parseFloat(value) || 0 +} + +/** + * Minimum overlap required on both axes before a zone is considered hit. + * Number = pixels. String ending in `%` = fraction of `min(ghost.width, ghost.height)`. + * Defaults to 0 (any overlap). + */ +export const overlap = (threshold: number | string = 0): CollisionAlgorithm => { + return ({ zones, ghost }) => { + for (const zone of zones) { + const px = parseThreshold(threshold, ghost) + + const intersectW = + Math.min(ghost.x + ghost.width, zone.rect.x + zone.rect.width) - + Math.max(ghost.x, zone.rect.x) + const intersectH = + Math.min(ghost.y + ghost.height, zone.rect.y + zone.rect.height) - + Math.max(ghost.y, zone.rect.y) + + if (intersectW > px && intersectH > px) return zone + } + return null + } +} diff --git a/packages/svelte-dnd/src/lib/core/containers/strategies/container-strategy.ts b/packages/svelte-dnd/src/lib/core/containers/strategies/container-strategy.ts new file mode 100644 index 0000000..08db241 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/containers/strategies/container-strategy.ts @@ -0,0 +1,44 @@ +import type { DropZone, DndMode, DndLayout } from '../../../types.js' +import type { DragSession } from '../../dnd/drag-session.svelte.js' +import type { DndState } from '../../dnd/dnd-state.svelte.js' +import type { AnimationStep } from '../../animation/steps/animation-step.js' +import type { Droppable } from '../../entities/droppable.svelte.js' +import type { Behavior } from '../../animation/behavior.js' + +export interface StrategyBindContext { + state: DndState + droppablesById: Map +} + +export interface ContainerStrategy { + readonly mode: DndMode + /** + * Layout hint for strategies that have a layout concept (sortable). + * Target-like strategies can omit it. + */ + readonly layout?: DndLayout + /** + * Per-strategy behaviors (auto-scroll, scroll-sync, future plugins). + * When `undefined` or empty, the controller's default behaviors apply. + * When non-empty, replaces the controller defaults for this droppable. + */ + readonly behaviors?: Behavior[] + calculateDropZones(droppable: Droppable, session: DragSession | null): DropZone[] + getTranslations( + droppable: Droppable, + session: DragSession + ): Map + getDropAnimation(session: DragSession, targetZone: DropZone): AnimationStep + getReturnAnimation(session: DragSession): AnimationStep + /** + * Called once per strategy instance when the owning droppable is first attached + * to a controller. Binds controller-level state (animation coordination, etc). + */ + bindContext?(ctx: StrategyBindContext): void + /** + * Optional hook invoked on every droppable using this strategy at drag start, + * before any reactive cycle runs. Use it to capture layout snapshots or any + * other transform-free state the strategy needs during the drag. + */ + onSessionStart?(droppable: Droppable, session: DragSession): void +} diff --git a/packages/svelte-dnd/src/lib/core/containers/strategies/sortable-container-strategy.ts b/packages/svelte-dnd/src/lib/core/containers/strategies/sortable-container-strategy.ts new file mode 100644 index 0000000..967ae81 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/containers/strategies/sortable-container-strategy.ts @@ -0,0 +1,302 @@ +import type { ContainerStrategy, StrategyBindContext } from './container-strategy.js' +import type { DropZone, DndMode, DndLayout } from '../../../types.js' +import type { DragSession } from '../../dnd/drag-session.svelte.js' +import type { DndState } from '../../dnd/dnd-state.svelte.js' +import type { AnimationStep } from '../../animation/steps/animation-step.js' +import type { Droppable } from '../../entities/droppable.svelte.js' +import type { Behavior } from '../../animation/behavior.js' +import type { SlotLayoutRect } from '../../zones/layout-snapshot.js' +import { captureLayoutSnapshot } from '../../zones/layout-snapshot.js' +import type { VirtualSource } from '../../zones/sortable-source.js' +import { DomSortableSource, VirtualSortableSource } from '../../zones/sortable-source.js' +import { GhostToTargetStep } from '../../animation/steps/ghost-to-target-step.js' +import { GhostReturnStep } from '../../animation/steps/ghost-return-step.js' +import { pickGeometry } from '../../zones/geometry-registry.js' + +export interface SortableOptions { + /** Container layout for drop-zone geometry. Defaults to `'vertical'`. */ + layout?: DndLayout + /** + * For `layout: 'grid'`, which axis items fill first. + * `'row'` (default): items fill left-to-right, wrapping to next row. + * `'column'`: items fill top-to-bottom, wrapping to next column. + */ + flow?: 'row' | 'column' + /** + * Per-strategy behaviors (auto-scroll, scroll-sync, future plugins). + * Pass an array to replace the controller's default behaviors for this droppable. + * Omit (or pass an empty array) to inherit the controller defaults. + * + * @example + * ```ts + * sortable({ + * layout: 'vertical', + * behaviors: [autoScroll({ maxSpeed: 60 }), scrollSync({ threshold: 0.5 })] + * }) + * ``` + */ + behaviors?: Behavior[] + /** + * Opt into virtualized layout. When set, the strategy will not capture a + * one-shot DOM layout snapshot at drag start; it asks `virtual.getOffset(i)` + * and `virtual.getSize(i)` for any slot it needs to position. + * + * Only supported for `layout: 'vertical' | 'horizontal'` — `grid` falls back + * to DOM mode and ignores this option. + * + * Typical setup with [virtua](https://github.com/inokawa/virtua) (Svelte): + * ```svelte + * items.length, + * getOffset: (i) => vlist.getItemOffset(i), + * getSize: (i) => vlist.getItemSize(i) + * } + * })}> + * + * {#snippet children(item, index)} + * ... + * {/snippet} + * + * + * ``` + * + * Keep the dragged item mounted while dragging (e.g. virtua's `keepMounted`) + * so its slot stays in the DOM and translations stay coherent. + */ + virtual?: VirtualSource +} + +/** + * Sortable container strategy — position-based drop zones with insert previews. + * + * Layout-agnostic. Reads all geometry from a LayoutSnapshot captured at drag start + * (transform-free), so reactive transforms during drag never feed back into + * zone or translation calculations. Direction-specific logic lives exclusively + * in ZoneGeometry implementations. + */ +export class SortableContainerStrategy implements ContainerStrategy { + readonly mode: DndMode = 'sortable' + readonly layout: DndLayout + readonly flow: 'row' | 'column' + readonly behaviors: Behavior[] + readonly virtual?: VirtualSource + + private state!: DndState + private droppablesById!: Map + + constructor(options: SortableOptions = {}) { + this.layout = options.layout ?? 'vertical' + this.flow = options.flow ?? 'row' + this.behaviors = options.behaviors ?? [] + this.virtual = options.virtual + } + + bindContext(ctx: StrategyBindContext): void { + this.state = ctx.state + this.droppablesById = ctx.droppablesById + } + + onSessionStart(droppable: Droppable, session: DragSession): void { + if (this.virtual && (this.layout === 'vertical' || this.layout === 'horizontal')) { + const isOrigin = droppable.id === session.originContainerId + const draggedIndex = isOrigin ? session.originPosition : -1 + session.setSource( + droppable.id, + new VirtualSortableSource( + droppable.id, + draggedIndex, + droppable, + this.virtual, + session.itemId + ) + ) + return + } + const snapshot = captureLayoutSnapshot(droppable, session.itemId) + session.setSource(droppable.id, new DomSortableSource(snapshot, session.itemId)) + } + + calculateDropZones(droppable: Droppable, session: DragSession | null): DropZone[] { + const containerRect = droppable.element.getBoundingClientRect() + const scrollLeft = droppable.element.scrollLeft + const scrollTop = droppable.element.scrollTop + const geometry = pickGeometry(this.layout, this.flow) + + const source = session?.getSource(droppable.id) + const ctx = { + containerId: droppable.id, + containerRect, + scrollLeft, + scrollTop, + draggedIndex: source?.draggedIndex ?? -1 + } + + if (!source) return [geometry.buildEmptyZone(ctx)] + + const visible = source.visibleRects(ctx) + if (visible.length === 0) return [geometry.buildEmptyZone(ctx)] + return geometry.buildZones(visible, ctx) + } + + getTranslations( + droppable: Droppable, + session: DragSession + ): Map { + const map = new Map() + const source = session.getSource(droppable.id) + if (!source) return map + + const containerId = droppable.id + const slotSize = session.slotSize + const layout = this.layout + + // Grid keeps the adjacency-based shift: each item slides to the next/prev rect's + // absolute position. Requires the full set of rects, so it only runs against a + // DOM-backed source. + if (layout === 'grid') { + if (!(source instanceof DomSortableSource)) return map + const snapshot = source.snapshot + return this.getGridTranslations( + snapshot.rects, + snapshot.draggedIndex, + session.dropPreview, + containerId, + session + ) + } + + // Vertical/horizontal: every displaced item shifts by the dragged slot's own size + // (width/height including spacing). Each mounted slot whose `position` falls in + // the shift range moves up/down one "slot worth" — works for both DOM and virtual + // sources, where only a subset of slots is mounted at any moment. + const axis: 'x' | 'y' = layout === 'vertical' ? 'y' : 'x' + const step = !slotSize ? 0 : axis === 'y' ? slotSize.height : slotSize.width + if (step === 0) return map + + const applyShift = (slotId: string, delta: number) => { + if (delta === 0) return + map.set(slotId, axis === 'y' ? { x: 0, y: delta } : { x: delta, y: 0 }) + } + + const D = source.draggedIndex + const preview = session.dropPreview + const mounted = source.mountedSlots() + + if (!preview) { + // No hover target: collapse the gap left by the dragged item in its origin container. + if (containerId !== session.originContainerId || D === -1) return map + for (const s of mounted) if (s.position > D) applyShift(s.id, -step) + return map + } + + if (preview.containerId === containerId) { + const P = preview.position + + if (D === -1) { + // Cross-container target: items at position >= P shift forward by one slot. + for (const s of mounted) if (s.position >= P) applyShift(s.id, step) + } else { + // Same-container reorder. targetIdx is the full-array drop index that accounts + // for the dragged slot still occupying its origin position. + const targetIdx = P <= D ? P : P + 1 + if (targetIdx < D) { + // Drag moves earlier — items in [targetIdx..D-1] make room by shifting forward. + for (const s of mounted) + if (s.position >= targetIdx && s.position < D) applyShift(s.id, step) + } else if (targetIdx > D) { + // Drag moves later — items in (D..targetIdx) fill the gap by shifting back. + for (const s of mounted) + if (s.position > D && s.position < targetIdx) applyShift(s.id, -step) + } + } + } else if (containerId === session.originContainerId && D !== -1) { + // Origin container when the item is hovering over a different container: collapse the gap. + for (const s of mounted) if (s.position > D) applyShift(s.id, -step) + } + + return map + } + + private getGridTranslations( + rects: SlotLayoutRect[], + D: number, + preview: { containerId: string; position: number } | null, + containerId: string, + session: DragSession + ): Map { + const map = new Map() + const slotSize = session.slotSize + + const extrapolateNext = (rect: SlotLayoutRect): SlotLayoutRect | null => { + if (!slotSize) return null + return { + ...rect, + offsetLeft: rect.offsetLeft + slotSize.width, + offsetTop: rect.offsetTop + } + } + + const shift = (rect: SlotLayoutRect, target: SlotLayoutRect | null) => { + if (!target) return + const dx = target.offsetLeft - rect.offsetLeft + const dy = target.offsetTop - rect.offsetTop + if (dx === 0 && dy === 0) return + map.set(rect.slotId, { x: dx, y: dy }) + } + + if (!preview) { + if (containerId !== session.originContainerId || D === -1) return map + for (let i = D + 1; i < rects.length; i++) shift(rects[i], rects[i - 1]) + return map + } + + if (preview.containerId === containerId) { + const P = preview.position + if (D === -1) { + for (let i = P; i < rects.length; i++) { + const target = rects[i + 1] ?? extrapolateNext(rects[i]) + shift(rects[i], target) + } + } else { + const targetIdx = P <= D ? P : P + 1 + for (let i = 0; i < rects.length; i++) { + if (i === D) continue + if (i < D && i >= targetIdx) shift(rects[i], rects[i + 1]) + else if (i > D && i < targetIdx) shift(rects[i], rects[i - 1]) + } + } + } else if (containerId === session.originContainerId && D !== -1) { + for (let i = D + 1; i < rects.length; i++) shift(rects[i], rects[i - 1]) + } + + return map + } + + getDropAnimation(session: DragSession, targetZone: DropZone): AnimationStep { + return new GhostToTargetStep(this.state, targetZone, this.droppablesById) + } + + getReturnAnimation(session: DragSession): AnimationStep { + return new GhostReturnStep( + this.state, + session.originContainerId, + session.originPosition, + this.droppablesById + ) + } +} + +/** + * Factory for `SortableContainerStrategy`. Pass options to configure layout + * and (for grid) flow axis. + * + * @example + * ```svelte + * + * ``` + */ +export function sortable(options?: SortableOptions): SortableContainerStrategy { + return new SortableContainerStrategy(options) +} diff --git a/packages/svelte-dnd/src/lib/core/containers/strategies/target-container-strategy.ts b/packages/svelte-dnd/src/lib/core/containers/strategies/target-container-strategy.ts new file mode 100644 index 0000000..5f5e85c --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/containers/strategies/target-container-strategy.ts @@ -0,0 +1,96 @@ +import type { ContainerStrategy, StrategyBindContext } from './container-strategy.js' +import type { DropZone, DndMode } from '../../../types.js' +import type { DragSession } from '../../dnd/drag-session.svelte.js' +import type { DndState } from '../../dnd/dnd-state.svelte.js' +import type { AnimationStep } from '../../animation/steps/animation-step.js' +import type { Droppable } from '../../entities/droppable.svelte.js' +import type { Behavior } from '../../animation/behavior.js' +import { DOMHelper } from '../../utils/dom-helper.js' +import { GhostToTargetStep } from '../../animation/steps/ghost-to-target-step.js' +import { GhostReturnStep } from '../../animation/steps/ghost-return-step.js' + +export interface TargetOptions { + /** + * Per-strategy behaviors (auto-scroll, scroll-sync, future plugins). + * Pass an array to replace the controller's default behaviors for this droppable. + * + * @example + * ```ts + * target({ behaviors: [autoScroll({ maxSpeed: 60 })] }) + * ``` + */ + behaviors?: Behavior[] +} + +/** + * Target container strategy — single drop zone covering the whole container, no insert previews. + * Useful for trash zones, boards, or any container that isn't a sorted list. + */ +export class TargetContainerStrategy implements ContainerStrategy { + readonly mode: DndMode = 'target' + readonly behaviors: Behavior[] + + private state!: DndState + private droppablesById!: Map + + constructor(options: TargetOptions = {}) { + this.behaviors = options.behaviors ?? [] + } + + bindContext(ctx: StrategyBindContext): void { + this.state = ctx.state + this.droppablesById = ctx.droppablesById + } + + calculateDropZones(droppable: Droppable, _session: DragSession | null): DropZone[] { + const rect = DOMHelper.getRect(droppable.element) + return [ + { + containerId: droppable.id, + position: 0, + layout: 'vertical', + rect: { + x: rect.left, + y: rect.top, + width: rect.width, + height: Math.max(rect.height, 20) + } + } + ] + } + + // Target containers don't have sortable items — no translations needed. + // The origin container's strategy handles gap collapse. + getTranslations( + _droppable: Droppable, + _session: DragSession + ): Map { + return new Map() + } + + getDropAnimation(session: DragSession, targetZone: DropZone): AnimationStep { + return new GhostToTargetStep(this.state, targetZone, this.droppablesById) + } + + getReturnAnimation(session: DragSession): AnimationStep { + return new GhostReturnStep( + this.state, + session.originContainerId, + session.originPosition, + this.droppablesById + ) + } +} + +/** + * Factory for `TargetContainerStrategy`. + * + * @example + * ```svelte + * + * + * ``` + */ +export function target(options?: TargetOptions): TargetContainerStrategy { + return new TargetContainerStrategy(options) +} diff --git a/packages/svelte-dnd/src/lib/core/dnd/dnd-controller.svelte.ts b/packages/svelte-dnd/src/lib/core/dnd/dnd-controller.svelte.ts new file mode 100644 index 0000000..541abde --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/dnd/dnd-controller.svelte.ts @@ -0,0 +1,977 @@ +import { SvelteMap } from 'svelte/reactivity' +import { DndState } from './dnd-state.svelte.js' +import { ScrollController } from '../scroll/scroll-controller.js' +import { DndEventEmitter } from './dnd-event-emitter.js' +import { TranslationEngine } from '../zones/translation-engine.svelte.js' +import type { SensorDescriptor, NavigationDirection } from '../sensors/sensor.js' +import { PointerSensor } from '../sensors/pointer-sensor.js' +import { KeyboardSensor } from '../sensors/keyboard-sensor.js' +import type { CollisionAlgorithm } from '../collision/collision-algorithm.js' +import { DropResolver } from '../zones/drop-resolver.js' +import { DropAnimationCoordinator } from '../animation/drop-animation-coordinator.js' +import type { Modifier } from '../modifiers/modifier.js' +import { DndSimulator } from './dnd-simulator.js' +import type { AnimateItemOptions, AnimateLayoutOptions } from './dnd-simulator.js' +import type { + DragStartCallback, + DragEndCallback, + DropCallback, + DragOverCallback, + DropCancelledCallback, + ZonesInvalidatedCallback, + DndItemInfo, + DndContainerInfo, + DragStartEvent, + Announcements, + DndLayout +} from '../../types.js' +import type { AnimationConfig, ResolvedAnimationConfig } from '../animation/animation-config.js' +import { resolveAnimationConfig, DEFAULT_ANIMATION_CONFIG } from '../animation/animation-config.js' +import type { Behavior, AutoScrollConfig } from '../animation/behavior.js' +import { autoScroll } from '../animation/behaviors/auto-scroll.js' +import { scrollSync } from '../animation/behaviors/scroll-sync.js' +import { resolveBehaviors, findTargetSlotWrapper } from '../animation/apply-behaviors.js' +import { computePreviewSlotTarget } from '../animation/preview-slot-rect.js' +import { KeyboardFlight } from '../animation/keyboard-flight.js' +import { getDirectionAdapter } from '../animation/direction-adapter.js' +import { findScrollTarget } from '../scroll/scroll-into-view.js' +import { DragSession } from './drag-session.svelte.js' +import type { Draggable } from '../entities/draggable.svelte.js' +import type { Droppable } from '../entities/droppable.svelte.js' +import type { Slot } from '../entities/slot.js' + +export interface DndControllerConfig { + animation?: AnimationConfig + /** + * Drop-side plugins. Default — `[autoScroll(), scrollSync()]`. Pass an + * explicit list (including empty `[]` to opt out of all defaults) to + * customise. Strategies can override per-droppable via their own + * `behaviors` option. + */ + behaviors?: Behavior[] + debug?: boolean + sensors?: SensorDescriptor[] + collision?: CollisionAlgorithm + modifiers?: Modifier[] + announcements?: Announcements +} + +export type { + DragStartCallback, + DragEndCallback, + DropCallback, + DragOverCallback, + DropCancelledCallback, + ZonesInvalidatedCallback +} from '../../types.js' + +/** + * Central controller for drag-and-drop. Create one instance and pass it to + * `DndProvider` — all child `DndDroppable` and `DndDraggable` components will + * share the same state. + * + * @example + * ```ts + * const controller = new DndController() + * + * controller.onDrop(({ item, source, target }) => { + * // reorder / move items in your data model + * }) + * ``` + */ +export class DndController { + private state = new DndState() + private eventEmitter = new DndEventEmitter() + private translationEngine: TranslationEngine + private dropResolver: DropResolver + private scrollController: ScrollController + private animationCoordinator: DropAnimationCoordinator + private simulator: DndSimulator + private modifiers: Modifier[] + private keyboardFlight: KeyboardFlight | null = null + + // --- Entity maps --- + // Reactive maps so $derived computations (TranslationEngine, dropPreviewSize) + // re-run when droppables mount/unmount mid-drag (lazy routes, virtualized parents). + private droppables = new SvelteMap() + private droppablesById = new SvelteMap() + /** + * Global element→Slot lookup used by sensors and attachSlot. + * @internal + */ + slots = new Map() + /** Current drag session. Read-only — stored in DndState. */ + get session(): DragSession | null { + return this.state.session + } + + debug = $state(false) + sensors = $state(undefined) + announcements = $state(undefined) + private defaultBehaviors: Behavior[] = [] + /** + * Reactive resolved animation config. DndPreview, DndProvider, DndDraggable + * and DndDroppable read live values from here for delays, durations and + * easings. + */ + animation = $state(DEFAULT_ANIMATION_CONFIG) + + constructor({ + animation, + behaviors, + debug = false, + sensors, + collision, + modifiers = [], + announcements + }: DndControllerConfig = {}) { + this.debug = debug + this.sensors = sensors ?? [new PointerSensor(), new KeyboardSensor()] + this.announcements = announcements + this.modifiers = modifiers + this.animation = resolveAnimationConfig(animation) + this.defaultBehaviors = behaviors ?? [autoScroll(), scrollSync()] + + this.translationEngine = new TranslationEngine(this.state, this.droppables) + this.dropResolver = new DropResolver(this.state, this.droppablesById, collision) + + this.scrollController = new ScrollController(this.state, { + onZoneRefresh: () => this.eventEmitter.notifyZonesInvalidated(), + onMouseUpdate: (x, y) => this.updateMousePosition(x, y), + resolveAutoScrollConfig: (container) => this.resolveAutoScrollConfig(container), + stopOnDrop: this.controllerAutoScrollConfig()?.stopOnDrop ?? false + }) + + this.animationCoordinator = new DropAnimationCoordinator( + this.state, + this.eventEmitter, + this.scrollController, + this.dropResolver, + this.droppablesById, + this.animation, + this.defaultBehaviors + ) + + this.simulator = new DndSimulator( + this.state, + this.droppablesById, + this.slots, + this.eventEmitter, + this.animation, + this.defaultBehaviors + ) + } + + /** First `autoScroll(...)` config among the controller's default behaviors. */ + private controllerAutoScrollConfig(): AutoScrollConfig | null { + return this.defaultBehaviors.find((b) => b.autoScrollConfig)?.autoScrollConfig ?? null + } + + /** Looks up the active auto-scroll config for any scrollable element ScrollController encounters. */ + private resolveAutoScrollConfig(container: HTMLElement): AutoScrollConfig | null { + const dropId = container.getAttribute('data-dnd-drop-id') + if (dropId) { + const droppable = this.droppablesById.get(dropId) + const behaviors = resolveBehaviors(droppable ?? null, this.defaultBehaviors) + return behaviors.find((b) => b.autoScrollConfig)?.autoScrollConfig ?? null + } + // `data-dnd-scroll` (non-droppable wrappers) — controller defaults only. + return this.controllerAutoScrollConfig() + } + + // --- Reactive state (read-only) --- + + /** + * CSS translate offsets for each draggable item during an active drag. Keyed by item id. + * @internal + */ + get translations() { + return this.translationEngine.translations + } + + /** + * Extra margin for the cross-container drop target so it grows in layout flow, + * preventing translated items from visually overflowing into siblings. + * null when not in a cross-container drag. + * @internal + */ + get dropTargetPadding() { + return this.translationEngine.dropTargetPadding + } + + /** `true` while the user is dragging an item. */ + get dragging() { + return this.state.dragging + } + + /** The DOM element currently being dragged. */ + get element() { + return this.state.element + } + + /** @internal Alias of `element` used by `DroppableControllerRef` to disambiguate the dragged element from other DOM elements a consumer might track. */ + get draggedElement() { + return this.state.element + } + + /** Current `{ x, y }` transform of the ghost element. */ + get transform() { + return this.state.transform + } + + /** Id of the item being dragged. */ + get draggedItem() { + return this.state.draggedItem + } + + /** `type` field from the dragged item's data, used for accept filtering. */ + get draggedType() { + return this.state.draggedType + } + + /** Full data object of the dragged item. */ + get draggedItemData() { + return this.state.draggedItemData + } + + /** Width/height of the dragged element. */ + get ghostSize() { + return this.state.ghostSize + } + + /** `true` while the ghost is animating back to its origin. */ + get animatingReturn() { + return this.state.animating + } + + /** Current drop preview (target container + insertion position). */ + get dropPreview() { + return this.state.dropPreview + } + + // Cache for first-slot rects keyed by containerId. Avoids `getBoundingClientRect` + // on every pointermove (the $derived below otherwise re-runs at 60fps and forces a + // synchronous layout each tick). The slotId is stored alongside so we re-measure if + // the first non-dragged slot identity changes (e.g. virtualizer remounts). + // Cleared at session start so a stale rect from the previous drag never leaks in. + private firstSlotRectCache = new Map< + string, + { slotId: string; width: number; height: number } + >() + + /** + * Reactive size for sizing ghost/preview to match the destination layout. + * Returns `null` when no drop preview is active, the target is empty, no + * first slot is mounted yet, or the target uses `target()` strategy (which + * doesn't constrain item size at all). + * + * Mixes target-sibling and dragged-item dimensions based on the target's + * layout — only the cross-axis is borrowed, because that's where target-side + * layout shrinkage shows up (e.g. a scrollbar narrowing items): + * + * - `vertical` sortable: width from target sibling, height from dragged. + * - `horizontal` sortable: height from target sibling, width from dragged. + * - `grid` sortable: both from target sibling (cells constrain both axes). + * + * For dimensions taken from the dragged item, falls back to the target + * sibling when `ghostSize` is missing (defensive — should always be set + * during an active preview). + */ + dropPreviewSize = $derived.by((): { width: number; height: number } | null => { + const preview = this.state.dropPreview + if (!preview) return null + const droppable = this.droppablesById.get(preview.containerId) + if (!droppable || droppable.mode !== 'sortable') return null + + const draggedId = this.state.draggedItem + const firstSlot = droppable.getSortedSlots().find((s) => s.draggable?.id !== draggedId) + if (!firstSlot?.draggable?.element) return null + + const slotId = firstSlot.draggable.id + let cached = this.firstSlotRectCache.get(preview.containerId) + if (!cached || cached.slotId !== slotId) { + const r = firstSlot.draggable.element.getBoundingClientRect() + cached = { slotId, width: r.width, height: r.height } + this.firstSlotRectCache.set(preview.containerId, cached) + } + + const ghost = this.state.ghostSize + const ghostW = ghost?.width ?? cached.width + const ghostH = ghost?.height ?? cached.height + + const layout = droppable.layout + if (layout === 'horizontal') return { width: ghostW, height: cached.height } + if (layout === 'grid') return { width: cached.width, height: cached.height } + // vertical (default) + return { width: cached.width, height: ghostH } + }) + + /** All registered drop zones across every `DndDroppable`. */ + get dropZones() { + return this.state.zones + } + + /** @internal */ + get debugZones() { + return this.state.debugZones + } + + /** + * Drop zones filtered to only those that accept the currently dragged item type. + * @internal + */ + get filteredDropZones() { + return this.dropResolver.filteredZones + } + + /** `true` while the drop animation is in progress. */ + get performingDrop() { + return this.state.performingDrop + } + + /** @internal */ + get skipDropPreviewAnimation() { + return this.state.skipDropPreviewAnimation + } + + /** `'user'` during real drag, `'programmatic'` during simulation. */ + get dragSource() { + return this.state.dragSource + } + + // --- Event subscriptions --- + + /** Fired when a drag begins. */ + onDragStart(cb: DragStartCallback) { + return this.eventEmitter.onDragStart(cb) + } + + /** Fired when a drag ends (drop or cancel). */ + onDragEnd(cb: DragEndCallback) { + return this.eventEmitter.onDragEnd(cb) + } + + /** Fired when a drag is cancelled (ghost returns to origin, no drop occurred). */ + onDropCancelled(cb: DropCancelledCallback) { + return this.eventEmitter.onDropCancelled(cb) + } + + /** Fired when an item is successfully dropped into a container. */ + onDrop(cb: DropCallback) { + return this.eventEmitter.onDrop(cb) + } + + /** Fired each time the drag-over target (container + position) changes. */ + onDragOver(cb: DragOverCallback) { + return this.eventEmitter.onDragOver(cb) + } + + /** + * Fired after auto-scroll moves a container, invalidating existing drop zone + * coordinates. Subscribe to recalculate zones if you manage them manually. + */ + onZonesInvalidated(cb: ZonesInvalidatedCallback) { + return this.eventEmitter.onZonesInvalidated(cb) + } + + // --- Lifecycle --- + + /** @internal */ + setSkipDropPreviewAnimation(value: boolean) { + this.state.setSkipDropPreviewAnimation(value) + } + + updateTransform(rawTransform: { x: number; y: number }) { + if (this.modifiers.length === 0) { + this.state.setTransform(rawTransform) + return + } + + const initialTransform = this.state.session?.ghostTransform ?? rawTransform + const ghostSize = this.state.ghostSize ?? { width: 0, height: 0 } + const originContainerId = this.state.session?.originContainerId ?? '' + + let transform = rawTransform + for (const modifier of this.modifiers) { + transform = modifier({ transform, initialTransform, ghostSize, originContainerId }) + } + this.state.setTransform(transform) + } + + updateMousePosition(mouseX: number, mouseY: number) { + if (this.state.dragging && !this.state.performingDrop) { + this.animationCoordinator.updateDropPreview({ x: mouseX, y: mouseY }) + } + } + + handleAutoScroll(mouseX: number, mouseY: number) { + if (this.state.dragging && !this.state.performingDrop) { + this.scrollController.handleAutoScroll(mouseX, mouseY) + } + } + + /** @internal */ + navigate(direction: NavigationDirection) { + if (!this.state.dragging) return + const session = this.state.session + if (!session) return + + const current = this.state.dropPreview ?? { + containerId: session.originContainerId, + position: session.originPosition + } + const currentDroppable = this.droppablesById.get(current.containerId) + if (!currentDroppable) return + + const target = this.isMainAxisDirection(currentDroppable.layout, direction) + ? this.navigateWithinContainer(currentDroppable, current.position, direction) + : this.navigateAcrossContainers(currentDroppable, current.position, direction) + + if (target) this.applyKeyboardTarget(target) + } + + private isMainAxisDirection(layout: DndLayout, direction: NavigationDirection): boolean { + if (direction === 'home' || direction === 'end') return true + if (layout === 'grid') return true + if (layout === 'horizontal') return direction === 'left' || direction === 'right' + return direction === 'up' || direction === 'down' + } + + // Full position list for keyboard navigation — independent of viewport + // (unlike `dropResolver.filteredZones`, which filters by viewport visibility + // for pointer-collision purposes). Same-container: 0..itemCount-1, tail not + // reachable. Cross-container: 0..itemCount inclusive (tail allowed). + private validPositions(droppable: Droppable): number[] { + const itemCount = droppable.itemCount + const isSource = this.state.session?.originContainerId === droppable.id + const max = isSource ? itemCount - 1 : itemCount + const out: number[] = [] + for (let i = 0; i <= max; i++) out.push(i) + return out + } + + private slotOrTailRect(droppable: Droppable, position: number): DOMRect | null { + const slotEl = droppable.getSlotAt(position)?.element + if (slotEl) return slotEl.getBoundingClientRect() + if (position === droppable.itemCount) { + const tailEl = droppable.tailPreview?.element?.parentElement + if (tailEl) return tailEl.getBoundingClientRect() + } + return null + } + + private navigateWithinContainer( + droppable: Droppable, + currentPosition: number, + direction: NavigationDirection + ): { containerId: string; position: number } | null { + const positions = this.validPositions(droppable) + if (positions.length === 0) return null + + if (direction === 'home') return { containerId: droppable.id, position: positions[0] } + if (direction === 'end') + return { containerId: droppable.id, position: positions[positions.length - 1] } + + const currentIdx = positions.indexOf(currentPosition) + const safeIdx = currentIdx === -1 ? 0 : currentIdx + + if (droppable.layout === 'grid') { + return this.navigateWithinGrid(droppable, positions, safeIdx, direction) + } + + const isForward = direction === 'down' || direction === 'right' + const next = positions[isForward ? safeIdx + 1 : safeIdx - 1] + if (next === undefined) return null + return { containerId: droppable.id, position: next } + } + + private navigateWithinGrid( + droppable: Droppable, + positions: number[], + currentIdx: number, + direction: NavigationDirection + ): { containerId: string; position: number } | null { + const currentPos = positions[currentIdx] + const currentRect = this.slotOrTailRect(droppable, currentPos) + + if (!currentRect) { + const isForward = direction === 'down' || direction === 'right' + const next = positions[isForward ? currentIdx + 1 : currentIdx - 1] + if (next === undefined) return null + return { containerId: droppable.id, position: next } + } + + const isHorizontal = direction === 'left' || direction === 'right' + const isForward = direction === 'down' || direction === 'right' + const bandHalf = isHorizontal ? currentRect.height / 2 : currentRect.width / 2 + const currentMain = isHorizontal + ? currentRect.left + currentRect.width / 2 + : currentRect.top + currentRect.height / 2 + const currentCross = isHorizontal + ? currentRect.top + currentRect.height / 2 + : currentRect.left + currentRect.width / 2 + + let best: number | null = null + let bestScore = Infinity + for (const position of positions) { + if (position === currentPos) continue + const rect = this.slotOrTailRect(droppable, position) + if (!rect) continue + const main = isHorizontal ? rect.left + rect.width / 2 : rect.top + rect.height / 2 + const cross = isHorizontal ? rect.top + rect.height / 2 : rect.left + rect.width / 2 + const dMain = main - currentMain + if (isForward ? dMain <= 0 : dMain >= 0) continue + + if (isHorizontal) { + const sameLine = Math.abs(cross - currentCross) < bandHalf + if (!sameLine) continue + const score = Math.abs(dMain) + if (score < bestScore) { + bestScore = score + best = position + } + } else { + const score = Math.abs(dMain) * 2 + Math.abs(cross - currentCross) + if (score < bestScore) { + bestScore = score + best = position + } + } + } + + if (best === null) return null + return { containerId: droppable.id, position: best } + } + + private navigateAcrossContainers( + currentDroppable: Droppable, + currentPosition: number, + direction: NavigationDirection + ): { containerId: string; position: number } | null { + const isHorizontal = direction === 'left' || direction === 'right' + const draggedType = this.state.draggedType ?? undefined + + const candidates: Array<{ droppable: Droppable; rect: DOMRect }> = [] + for (const droppable of this.droppablesById.values()) { + if (droppable.disabled) continue + if (droppable.mode !== 'sortable') continue + if (!droppable.acceptsType(draggedType)) continue + candidates.push({ droppable, rect: droppable.element.getBoundingClientRect() }) + } + candidates.sort((a, b) => + isHorizontal ? a.rect.left - b.rect.left : a.rect.top - b.rect.top + ) + + const currentIdx = candidates.findIndex((c) => c.droppable.id === currentDroppable.id) + if (currentIdx === -1) return null + + const isForward = direction === 'down' || direction === 'right' + const next = candidates[isForward ? currentIdx + 1 : currentIdx - 1] + if (!next) return null + + const nextPositions = this.validPositions(next.droppable) + if (nextPositions.length === 0) return null + + const refRect = this.slotOrTailRect(currentDroppable, currentPosition) + if (!refRect) return { containerId: next.droppable.id, position: nextPositions[0] } + + const refCoord = isHorizontal + ? refRect.top + refRect.height / 2 + : refRect.left + refRect.width / 2 + + let best: number = nextPositions[0] + let bestDist = Infinity + for (const position of nextPositions) { + const slotRect = this.slotOrTailRect(next.droppable, position) + if (!slotRect) continue + const center = isHorizontal + ? slotRect.top + slotRect.height / 2 + : slotRect.left + slotRect.width / 2 + const dist = Math.abs(center - refCoord) + if (dist < bestDist) { + bestDist = dist + best = position + } + } + + return { containerId: next.droppable.id, position: best } + } + + private applyKeyboardTarget(target: { containerId: string; position: number }) { + const targetDroppable = this.droppablesById.get(target.containerId) + if (!targetDroppable) return + + this.animationCoordinator.setActivePreview({ + containerId: target.containerId, + position: target.position + }) + + // Defer one frame so reactive style updates (last-slot margin toggle on + // tail activation, sibling translations) flush before we measure. Rapid + // keypresses coalesce via the dropPreview match check. + requestAnimationFrame(() => { + if (!this.state.dragging) return + const preview = this.state.dropPreview + if (preview?.containerId !== target.containerId) return + if (preview?.position !== target.position) return + + const slotEl = findTargetSlotWrapper(targetDroppable, target.position) + const padding = targetDroppable.spacing ?? 0 + const flightCfg = this.animation.keyboardFlight + const flight = this.getKeyboardFlight() + const ghostSize = this.state.ghostSize + + // Target off-screen — animate scroll + ghost transform in lockstep. + const scrollTarget = slotEl ? findScrollTarget(slotEl, padding) : null + if (slotEl && scrollTarget) { + this.runScrollSyncFlight( + flight, + flightCfg, + targetDroppable, + target.position, + slotEl, + scrollTarget.container, + scrollTarget.direction, + padding, + ghostSize + ) + return + } + + // Target already visible — fly straight to its live slot rect. + const ghostTarget = computePreviewSlotTarget( + targetDroppable, + target.position, + ghostSize + ) + if (ghostTarget) { + flight.animateTo(ghostTarget, flightCfg) + return + } + + // Slot not mounted (e.g. virtualized) — fall back to zone rect. + const fallback = this.state.zones.find( + (z) => z.containerId === target.containerId && z.position === target.position + ) + if (fallback) { + flight.animateTo( + { + x: fallback.rect.x + (fallback.rect.width - (ghostSize?.width ?? 0)) / 2, + y: fallback.rect.y + }, + flightCfg + ) + } + }) + } + + // Scroll + ghost flight in lockstep. Anchors the GHOST rect (not the slot + // wrapper) to the visible band — so ghost.top sits at visibleStart on Up + // and ghost.bottom sits at visibleEnd on Down, independent of slot height. + private runScrollSyncFlight( + flight: KeyboardFlight, + flightCfg: { duration: number; easing: string }, + droppable: Droppable, + position: number, + slotEl: HTMLElement, + container: HTMLElement, + direction: 'vertical' | 'horizontal', + padding: number, + ghostSize: { width: number; height: number } | null + ) { + const adapter = getDirectionAdapter(direction) + const ghostW = ghostSize?.width ?? 0 + const ghostH = ghostSize?.height ?? 0 + + // Live wrapper rect → align-aware ghost rect in current viewport space. + const previewRect = slotEl.getBoundingClientRect() + const previewEntity = droppable.getSlotAt(position)?.preview ?? droppable.tailPreview + const previewHorizontal = previewEntity?.isHorizontal ?? false + const alignEnd = previewEntity?.align === 'end' + const currentGhostX = + alignEnd && previewHorizontal ? previewRect.right - ghostW : previewRect.left + const currentGhostY = + alignEnd && !previewHorizontal ? previewRect.bottom - ghostH : previewRect.top + + const containerRect = container.getBoundingClientRect() + const startScroll = adapter.getScroll(container) + + let scrollDelta = 0 + let ghostFinalX = currentGhostX + let ghostFinalY = currentGhostY + + if (direction === 'vertical') { + const visibleTop = containerRect.top + padding + const visibleBottom = containerRect.bottom - padding + if (currentGhostY < visibleTop) { + ghostFinalY = visibleTop + scrollDelta = currentGhostY - visibleTop + } else if (currentGhostY + ghostH > visibleBottom) { + ghostFinalY = visibleBottom - ghostH + scrollDelta = currentGhostY + ghostH - visibleBottom + } + } else { + const visibleLeft = containerRect.left + padding + const visibleRight = containerRect.right - padding + if (currentGhostX < visibleLeft) { + ghostFinalX = visibleLeft + scrollDelta = currentGhostX - visibleLeft + } else if (currentGhostX + ghostW > visibleRight) { + ghostFinalX = visibleRight - ghostW + scrollDelta = currentGhostX + ghostW - visibleRight + } + } + + if (scrollDelta === 0) { + flight.animateTo({ x: currentGhostX, y: currentGhostY }, flightCfg) + return + } + + const targetScroll = startScroll + scrollDelta + const startGhost = { ...(this.state.transform ?? { x: 0, y: 0 }) } + const state = this.state + + flight.run( + { + duration: flightCfg.duration, + update(eased) { + adapter.setScroll(container, startScroll + scrollDelta * eased) + state.setTransform({ + x: startGhost.x + (ghostFinalX - startGhost.x) * eased, + y: startGhost.y + (ghostFinalY - startGhost.y) * eased + }) + }, + finalize: () => { + adapter.setScroll(container, targetScroll) + // Snap to live align-aware target — corrects for any drift + // from translation updates that landed during the flight. + const snap = computePreviewSlotTarget(droppable, position, ghostSize) + if (snap) state.setTransform(snap) + } + }, + flightCfg.easing + ) + } + + private getKeyboardFlight(): KeyboardFlight { + if (!this.keyboardFlight) this.keyboardFlight = new KeyboardFlight(this.state) + return this.keyboardFlight + } + + performDrop( + sourceId: string, + sourceData: Record | undefined, + targetContainerId: string, + position: number + ) { + this.animationCoordinator.performDrop(sourceId, sourceData, targetContainerId, position) + } + + /** Cancel the current drag and animate the ghost back to its origin. */ + endDrag(shouldAnimate = true) { + this.keyboardFlight?.cancel() + this.animationCoordinator.endDrag(shouldAnimate) + } + + /** + * Recalculate drop zones for a Droppable entity. Called by Droppable.invalidateZones(). + * @internal + */ + refreshDroppableZones(droppable: Droppable) { + const newZones = droppable.strategy.calculateDropZones(droppable, this.state.session) + const otherZones = this.state.zones.filter((z) => z.containerId !== droppable.id) + this.state.setDropZones([...otherZones, ...newZones]) + } + + /** + * Replace the controller-level default `behaviors` at runtime. Per-strategy + * `behaviors` set on a `sortable()` / `target()` instance are unaffected. + * Changes take effect on the next scroll tick / next drop animation. + */ + setBehaviors(behaviors: Behavior[]) { + this.defaultBehaviors = behaviors + this.scrollController.updateOptions({ + stopOnDrop: this.controllerAutoScrollConfig()?.stopOnDrop ?? false + }) + this.animationCoordinator.setDefaultBehaviors(behaviors) + this.simulator.setDefaultBehaviors(behaviors) + } + + /** + * Update animation config at runtime. Accepts a partial — provided fields + * override, omitted fields keep their current value. Changes propagate + * reactively to every live DndPreview, DndDraggable, DndDroppable, and to + * the next drop / return / FLIP animation. + */ + setAnimation(config: AnimationConfig = {}) { + this.animation = resolveAnimationConfig(config, this.animation) + this.animationCoordinator.setAnimationConfig(this.animation) + this.simulator.setAnimationConfig(this.animation) + } + + /** Toggle dev warnings (duplicate positions, etc.) at runtime. */ + setDebug(value: boolean) { + this.debug = value + } + + /** + * Replace the active sensors at runtime. Pass `undefined` to fall back to + * the default `[PointerSensor, KeyboardSensor]` set. + */ + setSensors(sensors: SensorDescriptor[] | undefined) { + this.sensors = sensors ?? [new PointerSensor(), new KeyboardSensor()] + } + + /** + * Replace the screen-reader announcement strings at runtime. Pass + * `undefined` to restore the defaults. + */ + setAnnouncements(announcements: Announcements | undefined) { + this.announcements = announcements + } + + /** + * Replace the active transform modifier pipeline at runtime. Pass an empty + * array to disable all modifiers. + */ + setModifiers(modifiers: Modifier[]) { + this.modifiers = modifiers + } + + /** Toggle visual overlay of drop zones — useful for debugging layout. */ + toggleDebugZones() { + this.state.toggleDebugZones() + } + + /** Delegates to {@link DndSimulator.animateItem}. */ + animateItem(itemId: string, options: AnimateItemOptions): Promise { + return this.simulator.animateItem(itemId, options) + } + + /** Delegates to {@link DndSimulator.animateLayout}. */ + animateLayout( + applyState: () => void | Promise, + options?: AnimateLayoutOptions + ): Promise { + return this.simulator.animateLayout(applyState, options) + } + + // --- Entity-based API --- + + /** + * @attach handler for DndDroppable — registers a Droppable in the entity maps + * and binds its strategy to controller state on first attach. + * @internal + */ + attachDroppable(droppable: Droppable) { + return (element: HTMLElement) => { + droppable.element = element + droppable.strategy.bindContext?.({ + state: this.state, + droppablesById: this.droppablesById + }) + this.droppables.set(element, droppable) + this.droppablesById.set(droppable.id, droppable) + droppable.setupEventListeners() + + return () => { + this.droppables.delete(element) + this.droppablesById.delete(droppable.id) + droppable.destroy() + this.state.setDropZones( + this.state.zones.filter((z) => z.containerId !== droppable.id) + ) + } + } + } + + /** + * Start a drag session from an entity-based Draggable. + * The same DragSession instance is shared with DndState. + * @internal + */ + startSession(draggable: Draggable, initialTransform: { x: number; y: number }): DragSession { + const slot = draggable.slot + const sourceContainer = slot.droppable + const rect = draggable.element.getBoundingClientRect() + + const newSession = new DragSession( + draggable, + sourceContainer, + rect, + initialTransform, + 'user' + ) + + // Give every strategy a chance to capture transform-free state before the + // reactive cycle runs. Built-in sortable uses this to snapshot layout rects; + // custom strategies can hook in the same way. + for (const droppable of this.droppablesById.values()) { + droppable.strategy.onSessionStart?.(droppable, newSession) + } + + // Drop the per-session geometry cache so a fresh first-slot rect is taken on + // the next dropPreviewSize read. Without this, a previous drag's measurement + // would size the new session's preview/ghost. + this.firstSlotRectCache.clear() + + this.state.startSession(newSession) + this.state.setSkipDropPreviewAnimation(true) + + const itemInfo: DndItemInfo = { + id: draggable.id, + data: draggable.data, + type: draggable.type, + element: draggable.element + } + const sourceInfo: DndContainerInfo = sourceContainer.toContainerInfo(slot.position) + const startEvent: DragStartEvent = { item: itemInfo, source: sourceInfo } + this.eventEmitter.notifyDragStart(startEvent) + + return newSession + } + + /** + * Cancel the current drag session (no drop, no return animation). + * @internal + */ + cancelSession() { + this.endDrag(false) + } + + /** + * Commit the current drag session — perform drop if a valid target exists, + * otherwise animate the ghost back to origin. + * @internal + */ + commitSession() { + this.keyboardFlight?.cancel() + const dropPreview = this.state.dropPreview + const session = this.state.session + const srcId = session?.source.id ?? this.state.draggedItem + const srcData = session?.source.data ?? this.state.draggedItemData + + if (dropPreview && srcId) { + this.state.setSkipDropPreviewAnimation(true) + this.animationCoordinator.performDrop( + srcId, + srcData, + dropPreview.containerId, + dropPreview.position + ) + } else { + this.endDrag(true) + } + } + + /** Release all resources. Call when the `DndProvider` is destroyed. */ + destroy() { + this.keyboardFlight?.cancel() + this.animationCoordinator.destroy() + this.scrollController.destroy() + this.eventEmitter.destroy() + // Drop any active session so a mid-drag teardown (e.g. route change) doesn't leave + // the ghost element, translations, or drop preview lingering in the DOM. + this.state.reset() + } +} diff --git a/packages/svelte-dnd/src/lib/core/dnd/dnd-event-emitter.ts b/packages/svelte-dnd/src/lib/core/dnd/dnd-event-emitter.ts new file mode 100644 index 0000000..f07be55 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/dnd/dnd-event-emitter.ts @@ -0,0 +1,97 @@ +import type { + DragStartCallback, + DragEndCallback, + DropCallback, + DragOverCallback, + DropCancelledCallback, + ZonesInvalidatedCallback, + DragStartEvent, + DragEndEvent, + DropEvent, + DragOverEvent, + DropCancelledEvent +} from '../../types.js' + +export class DndEventEmitter { + private dragStartCallbacks = new Set() + private dragEndCallbacks = new Set() + private dropCallbacks = new Set() + private dragOverCallbacks = new Set() + private dropCancelledCallbacks = new Set() + private zonesInvalidatedCallbacks = new Set() + + onDragStart(cb: DragStartCallback): () => void { + this.dragStartCallbacks.add(cb) + return () => { + this.dragStartCallbacks.delete(cb) + } + } + + onDragEnd(cb: DragEndCallback): () => void { + this.dragEndCallbacks.add(cb) + return () => { + this.dragEndCallbacks.delete(cb) + } + } + + onDrop(cb: DropCallback): () => void { + this.dropCallbacks.add(cb) + return () => { + this.dropCallbacks.delete(cb) + } + } + + onDragOver(cb: DragOverCallback): () => void { + this.dragOverCallbacks.add(cb) + return () => { + this.dragOverCallbacks.delete(cb) + } + } + + onDropCancelled(cb: DropCancelledCallback): () => void { + this.dropCancelledCallbacks.add(cb) + return () => { + this.dropCancelledCallbacks.delete(cb) + } + } + + notifyDragStart(event: DragStartEvent) { + this.dragStartCallbacks.forEach((cb) => cb(event)) + } + + notifyDragEnd(event: DragEndEvent) { + this.dragEndCallbacks.forEach((cb) => cb(event)) + } + + notifyDrop(event: DropEvent) { + this.dropCallbacks.forEach((cb) => cb(event)) + } + + notifyDragOver(event: DragOverEvent) { + this.dragOverCallbacks.forEach((cb) => cb(event)) + } + + notifyDropCancelled(event: DropCancelledEvent) { + this.dropCancelledCallbacks.forEach((cb) => cb(event)) + } + + onZonesInvalidated(cb: ZonesInvalidatedCallback): () => void { + this.zonesInvalidatedCallbacks.add(cb) + return () => { + this.zonesInvalidatedCallbacks.delete(cb) + } + } + + notifyZonesInvalidated() { + this.zonesInvalidatedCallbacks.forEach((cb) => cb()) + } + + destroy() { + this.dragStartCallbacks.clear() + this.dragEndCallbacks.clear() + this.dropCallbacks.clear() + this.dragOverCallbacks.clear() + this.dropCancelledCallbacks.clear() + this.zonesInvalidatedCallbacks.clear() + } +} diff --git a/packages/svelte-dnd/src/lib/core/dnd/dnd-simulator.ts b/packages/svelte-dnd/src/lib/core/dnd/dnd-simulator.ts new file mode 100644 index 0000000..0e8fc04 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/dnd/dnd-simulator.ts @@ -0,0 +1,472 @@ +import type { DndState } from './dnd-state.svelte.js' +import type { DndEventEmitter } from './dnd-event-emitter.js' +import { DragSession } from './drag-session.svelte.js' +import type { + DropZone, + DropEvent, + DropCancelledEvent, + DndItemInfo, + DndContainerInfo +} from '../../types.js' +import type { Droppable } from '../entities/droppable.svelte.js' +import type { Slot } from '../entities/slot.js' +import type { ResolvedAnimationConfig } from '../animation/animation-config.js' +import type { Behavior, BehaviorContext } from '../animation/behavior.js' +import type { AnimationStep } from '../animation/steps/animation-step.js' +import { flushSync } from 'svelte' +import { AnimationPipeline } from '../animation/steps/animation-pipeline.js' +import { GhostToTargetStep } from '../animation/steps/ghost-to-target-step.js' +import { GhostReturnStep } from '../animation/steps/ghost-return-step.js' +import { DEFAULT_ANIMATION_CONFIG } from '../animation/animation-config.js' +import { + resolveBehaviors, + wrapWithBehaviors, + findTargetSlotWrapper +} from '../animation/apply-behaviors.js' + +export interface ContainerPosition { + containerId: string + /** Default 0 for `target` containers; required for sortable destinations. */ + position?: number +} + +export interface AnimateItemOptions { + /** Where the item should fly to. */ + to: ContainerPosition + /** Where the item starts. Defaults to its current location in the slot map. */ + from?: ContainerPosition + /** + * Animation style: + * - `'drop'` (default) — uses `GhostToTargetStep` for both same- and cross-container moves. + * - `'return'` — uses `GhostReturnStep` when the destination is the same container, + * falls back to `GhostToTargetStep` for cross-container moves. + */ + style?: 'return' | 'drop' + /** Fire `onDrop` (style 'drop') or `onDropCancelled` (style 'return') after the animation. */ + emitEvents?: boolean + /** Override the configured drop/return duration. */ + duration?: number + /** Override the configured drop/return easing for this call. */ + easing?: string + /** + * Per-call override for the destination droppable's behaviors. Replaces both + * the strategy-level and controller-level defaults for this animation. + */ + behaviors?: Behavior[] +} + +export interface AnimateLayoutOptions { + /** + * Items to FLIP. Defaults to every registered draggable except `state.draggedItem` + * (whose ghost flight handles its own animation during a real drag). + */ + items?: string[] + /** + * When `true`, copies the missing classes from each item's pre-state classList + * onto the post-state element so class-driven CSS properties (border-radius, + * scale, background, color, …) transition together with the FLIP transform. + */ + morph?: boolean + /** Override the configured layout duration. */ + duration?: number + /** Override the configured layout easing (any valid CSS timing-function). */ + easing?: string +} + +type EmitKind = 'drop' | 'cancel' + +export class DndSimulator { + // Guards against concurrent animateLayout invocations. Two overlapping calls would + // race on captured rects and DOM transforms, producing visible jumps. + private layoutAnimating = false + + constructor( + private state: DndState, + private droppablesById: Map, + private slots: Map, + private eventEmitter?: DndEventEmitter, + private animation: ResolvedAnimationConfig = DEFAULT_ANIMATION_CONFIG, + private defaultBehaviors: Behavior[] = [] + ) {} + + setDefaultBehaviors(behaviors: Behavior[]) { + this.defaultBehaviors = behaviors + } + + setAnimationConfig(animation: ResolvedAnimationConfig) { + this.animation = animation + } + + /** + * Animate a single item flying to a destination through the ghost system. + * + * @example + * // Animate task `t1` from backlog to position 0 in in-progress. + * await controller.animateItem('t1', { + * to: { containerId: 'in-progress', position: 0 } + * }) + * + * @example + * // Undo a move — fly back to where it came from with scroll-aware return. + * await controller.animateItem('t1', { + * to: { containerId: 'backlog', position: 4 }, + * style: 'return' + * }) + */ + animateItem(itemId: string, options: AnimateItemOptions): Promise { + const { + to, + from, + style = 'drop', + emitEvents = false, + duration, + easing, + behaviors + } = options + + const sourceContainerId = from?.containerId ?? this.findItemContainer(itemId) + if (!sourceContainerId) { + return Promise.reject( + new Error(`DndSimulator.animateItem: item "${itemId}" not found in any container`) + ) + } + const toPosition = to.position ?? 0 + const useReturnStep = style === 'return' && to.containerId === sourceContainerId + const baseTransition = useReturnStep ? this.animation.return : this.animation.drop + const stepDuration = duration ?? baseTransition.duration + const stepEasing = easing ?? baseTransition.easing + + const makeStep = (): AnimationStep => { + const baseStep = useReturnStep + ? new GhostReturnStep( + this.state, + to.containerId, + toPosition, + this.droppablesById, + stepDuration, + stepEasing + ) + : new GhostToTargetStep( + this.state, + this.syntheticZone(to.containerId, toPosition), + this.droppablesById, + stepDuration, + stepEasing + ) + const targetDroppable = this.droppablesById.get(to.containerId) ?? null + const resolved = behaviors ?? resolveBehaviors(targetDroppable, this.defaultBehaviors) + const ctx: BehaviorContext = { + state: this.state, + direction: targetDroppable?.layout === 'horizontal' ? 'horizontal' : 'vertical', + targetEl: findTargetSlotWrapper(targetDroppable, toPosition), + container: targetDroppable?.element ?? null, + duration: stepDuration, + easing: stepEasing, + padding: targetDroppable?.spacing ?? 0 + } + return wrapWithBehaviors(baseStep, resolved, ctx) + } + + const emitKind: EmitKind = style === 'return' ? 'cancel' : 'drop' + return this.run( + itemId, + sourceContainerId, + to.containerId, + toPosition, + makeStep, + emitEvents, + emitKind + ) + } + + /** + * Animate a layout/state change. Captures item rects before `applyState`, + * mutates state via `applyState`, then FLIPs items to their new positions. + * + * @example + * // Sort the list and animate every item to its new spot. + * await controller.animateLayout(() => { + * items = [...items].sort((a, b) => a.label.localeCompare(b.label)) + * }) + * + * @example + * // Swap two items by id and morph their class-driven shapes. + * await controller.animateLayout(() => swap(a, b), { + * items: [a.id, b.id], + * morph: true + * }) + */ + async animateLayout( + applyState: () => void | Promise, + options: AnimateLayoutOptions = {} + ): Promise { + if (this.layoutAnimating) { + throw new Error( + 'DndSimulator.animateLayout: another layout animation is already running' + ) + } + this.layoutAnimating = true + + const { + items, + morph = false, + duration = this.animation.layout.duration, + easing = this.animation.layout.easing + } = options + const ids = items ?? this.collectAllItemIds().filter((id) => id !== this.state.draggedItem) + + // Capture old rects (visual rects — include any active translates) and class lists. + const oldRects = new Map() + const oldClasses = morph ? new Map() : null + for (const id of ids) { + const el = this.findElementById(id) + if (!el) continue + oldRects.set(id, el.getBoundingClientRect()) + if (oldClasses) oldClasses.set(id, [...el.classList]) + } + + // Tracked outside the try block so the finally clause can always undo morph + // classes / inline styles even if applyState or a rAF rejects mid-flight. + interface Patched { + el: HTMLElement + addedClasses: string[] + } + const elements: Patched[] = [] + + try { + // Apply state change (sync or async). + const result = applyState() + if (result && typeof (result as Promise).then === 'function') await result + + // Synchronously flush pending Svelte updates so the new DOM layout is readable + // in the same sync block as the inverse transform below. `await tick()` would + // leave a microtask gap during which the browser could paint the post-state + // layout — visible as a 1-frame jump to the new position. + flushSync() + + // Invert — apply inverse transforms (and restore the old class diff when morphing) + // so items visually stay at their pre-state-change positions. + for (const id of ids) { + const el = this.findElementById(id) + const oldRect = oldRects.get(id) + if (!el || !oldRect) continue + + const newRect = el.getBoundingClientRect() + const dx = oldRect.left - newRect.left + const dy = oldRect.top - newRect.top + + let addedClasses: string[] = [] + if (oldClasses) { + const old = oldClasses.get(id) ?? [] + const newSet = new Set(el.classList) + addedClasses = old.filter((c) => !newSet.has(c)) + for (const c of addedClasses) el.classList.add(c) + } + + if (dx === 0 && dy === 0 && addedClasses.length === 0) continue + + el.style.transition = 'none' + if (dx !== 0 || dy !== 0) el.style.transform = `translate(${dx}px, ${dy}px)` + elements.push({ el, addedClasses }) + } + + if (elements.length === 0) return + + // Double rAF to force reflow before enabling transitions. + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + + // Play — animate to final positions/styles. `all` lets class-driven properties + // (border-radius, scale, color, …) ride along with the FLIP transform. + const transitionProp = morph ? 'all' : 'transform' + for (const { el, addedClasses } of elements) { + el.style.transition = `${transitionProp} ${duration}ms ${easing}` + el.style.transform = '' + for (const c of addedClasses) el.classList.remove(c) + } + + await new Promise((resolve) => setTimeout(resolve, duration + 50)) + } finally { + // Always strip transition + leftover morph classes — without this an exception + // during applyState or a rAF leaves stray classes on the element forever. + for (const { el, addedClasses } of elements) { + el.style.transition = '' + for (const c of addedClasses) el.classList.remove(c) + } + this.layoutAnimating = false + } + } + + // --- Private --- + + private run( + itemId: string, + fromContainerId: string, + toContainerId: string, + toPosition: number, + makeStep: () => AnimationStep, + emitEvents: boolean, + emitKind: EmitKind + ): Promise { + return new Promise((resolve, reject) => { + if (this.state.dragging) { + reject(new Error('DndSimulator: cannot animate while a drag is in progress')) + return + } + + const fromDroppable = this.droppablesById.get(fromContainerId) + if (!fromDroppable) { + reject(new Error(`DndSimulator: container "${fromContainerId}" not found`)) + return + } + + const fromSlot = fromDroppable.getSortedSlots().find((s) => s.draggable.id === itemId) + if (!fromSlot) { + reject( + new Error( + `DndSimulator: item "${itemId}" not found in container "${fromContainerId}"` + ) + ) + return + } + + const element = fromSlot.draggable.element + const rect = element.getBoundingClientRect() + const positionInFrom = fromSlot.position + + let slotSize = fromSlot.getSize() + if (toContainerId !== fromContainerId) { + const toDroppable = this.droppablesById.get(toContainerId) + if (toDroppable) { + const toSlots = toDroppable.getSortedSlots() + if (toSlots.length > 0) { + const toSlotSize = toSlots[0].getSize() + const gapH = toSlotSize.height - toSlots[0].draggable.element.offsetHeight + const gapW = toSlotSize.width - toSlots[0].draggable.element.offsetWidth + slotSize = { + height: element.offsetHeight + Math.max(0, gapH), + width: element.offsetWidth + Math.max(0, gapW) + } + } + } + } + + const session = new DragSession( + fromSlot.draggable, + fromDroppable, + rect, + { x: rect.left, y: rect.top }, + 'programmatic' + ) + session.slotSize = slotSize + session.ghostSize = { width: element.offsetWidth, height: element.offsetHeight } + session.dropPreview = { + containerId: toContainerId, + position: toPosition + } + + // Let each strategy capture transform-free state before reactive cycle runs. + for (const droppable of this.droppablesById.values()) { + droppable.strategy.onSessionStart?.(droppable, session) + } + + this.state.startSession(session) + // setAnimating(true) makes the dragged element opacity:0 immediately via + // the animatingReturn path, without disabling CSS transitions on siblings. + this.state.setAnimating(true) + + const step = makeStep() + + // Wait one frame for DndPreview to render at toContainerId/toPosition. + requestAnimationFrame(() => { + AnimationPipeline.chain(step) + .execute() + .then(() => { + // setPerformingDrop(true) so Preview.hide() collapses instantly. + this.state.setPerformingDrop(true) + if (emitEvents && this.eventEmitter) { + const draggable = fromSlot.draggable + const itemInfo: DndItemInfo = { + id: itemId, + data: draggable.data, + type: draggable.type, + element + } + const sourceInfo: DndContainerInfo = fromDroppable.toContainerInfo( + positionInFrom >= 0 ? positionInFrom : 0 + ) + + if (emitKind === 'drop') { + const toDroppable = this.droppablesById.get(toContainerId) + if (toDroppable) { + const targetInfo: DndContainerInfo = + toDroppable.toContainerInfo(toPosition) + const dropEvent: DropEvent = { + item: itemInfo, + source: sourceInfo, + target: targetInfo + } + this.eventEmitter.notifyDrop(dropEvent) + } + } else { + const cancelEvent: DropCancelledEvent = { + item: itemInfo, + source: sourceInfo + } + this.eventEmitter.notifyDropCancelled(cancelEvent) + } + } + this.cleanup() + resolve() + }) + }) + }) + } + + private syntheticZone(containerId: string, position: number): DropZone { + return { + containerId, + position, + layout: 'vertical', + rect: { x: 0, y: 0, width: 0, height: 0 } + } + } + + private collectAllItemIds(): string[] { + const ids: string[] = [] + for (const slot of this.slots.values()) { + ids.push(slot.draggable.id) + } + return ids + } + + private findItemContainer(itemId: string): string | null { + for (const droppable of this.droppablesById.values()) { + if (droppable.getSortedSlots().some((s) => s.draggable.id === itemId)) { + return droppable.id + } + } + return null + } + + private findElementById(id: string): HTMLElement | null { + for (const slot of this.slots.values()) { + if (slot.draggable.id === id) return slot.draggable.element + } + return null + } + + private cleanup(): void { + // Mirrors finalizeDragEnd ordering — see DropAnimationCoordinator.finalizeDragEnd + // for the rationale behind the skip/performingDrop sequencing. + this.state.setSkipDropPreviewAnimation(true) + this.state.reset() + requestAnimationFrame(() => { + this.state.setPerformingDrop(false) + }) + setTimeout(() => { + this.state.setSkipDropPreviewAnimation(false) + }, 100) + } +} diff --git a/packages/svelte-dnd/src/lib/core/dnd/dnd-state.svelte.ts b/packages/svelte-dnd/src/lib/core/dnd/dnd-state.svelte.ts new file mode 100644 index 0000000..8a32a35 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/dnd/dnd-state.svelte.ts @@ -0,0 +1,118 @@ +import type { DropZone, DropPreview } from '../../types.js' +import type { DragSession } from './drag-session.svelte.js' + +export class DndState { + session = $state(null) + dropZones = $state([]) + showDebugZones = $state(false) + isPerformingDrop = $state(false) + shouldSkipDropPreviewAnimation = $state(false) + isAnimating = $state(false) + + // --- Getters (same public API as before) --- + + get dragging() { + return this.session !== null + } + get element() { + return this.session?.element ?? null + } + get transform() { + return this.session?.ghostTransform ?? null + } + get draggedItem() { + return this.session?.itemId ?? null + } + get draggedType() { + return this.session?.draggedItemType ?? null + } + get draggedItemData() { + return this.session?.itemData + } + get ghostSize() { + return this.session?.ghostSize ?? null + } + get animating() { + return this.isAnimating + } + get dropPreview() { + return this.session?.dropPreview ?? null + } + get zones() { + return this.dropZones + } + get debugZones() { + return this.showDebugZones + } + get performingDrop() { + return this.isPerformingDrop + } + get skipDropPreviewAnimation() { + return this.shouldSkipDropPreviewAnimation + } + get originContainerId() { + return this.session?.originContainerId ?? null + } + get originPosition() { + return this.session?.originPosition ?? 0 + } + get dragSlotSize() { + return this.session?.slotSize ?? null + } + get dragSource() { + return this.session?.dragSource ?? null + } + get originalPosition() { + const r = this.session?.startRect + return r ? { x: r.left, y: r.top } : null + } + + // --- Session management --- + + startSession(session: DragSession): void { + this.session = session + this.isPerformingDrop = false + this.shouldSkipDropPreviewAnimation = false + } + + endSession(): void { + this.session = null + } + + // --- Setters that mutate current session or direct fields --- + + setTransform(transform: { x: number; y: number } | null): void { + if (this.session && transform) this.session.ghostTransform = transform + } + + setDropPreview(preview: DropPreview | null): void { + if (this.session) this.session.dropPreview = preview + } + + setAnimating(value: boolean): void { + this.isAnimating = value + } + setDropZones(zones: DropZone[]): void { + this.dropZones = zones + } + setPerformingDrop(value: boolean): void { + this.isPerformingDrop = value + } + setSkipDropPreviewAnimation(value: boolean): void { + this.shouldSkipDropPreviewAnimation = value + } + toggleDebugZones(): void { + this.showDebugZones = !this.showDebugZones + } + + // NOTE: intentionally does NOT reset `isPerformingDrop` or `shouldSkipDropPreviewAnimation`. + // Callers (finalizeDragEnd / DndSimulator.cleanup) manage those flags around reset() on their + // own schedule — `isPerformingDrop` is cleared next frame so Preview.hide() sees it as `true` + // and collapses instantly; `shouldSkipDropPreviewAnimation` is cleared ~100ms later. + // startSession() clears both defensively, so leftover values can't leak into a new drag. + reset(): void { + this.session = null + this.isAnimating = false + this.dropZones = [] + } +} diff --git a/packages/svelte-dnd/src/lib/core/dnd/drag-session.svelte.ts b/packages/svelte-dnd/src/lib/core/dnd/drag-session.svelte.ts new file mode 100644 index 0000000..0283ac2 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/dnd/drag-session.svelte.ts @@ -0,0 +1,73 @@ +import type { DropPreview } from '../../types.js' +import type { Draggable } from '../entities/draggable.svelte.js' +import type { Droppable } from '../entities/droppable.svelte.js' +import type { SortableSource } from '../zones/sortable-source.js' + +export type DragSource = 'user' | 'programmatic' + +export class DragSession { + source: Draggable + sourceContainer: Droppable + startRect: DOMRect + dragSource: DragSource + + ghostTransform = $state({ x: 0, y: 0 }) + dropPreview = $state(null) + ghostSize = $state<{ width: number; height: number }>({ width: 0, height: 0 }) + slotSize = $state<{ width: number; height: number } | null>(null) + + private sources = new Map() + + constructor( + source: Draggable, + sourceContainer: Droppable, + startRect: DOMRect, + initialTransform: { x: number; y: number }, + dragSource: DragSource = 'user' + ) { + this.source = source + this.sourceContainer = sourceContainer + this.startRect = startRect + this.ghostTransform = initialTransform + this.dragSource = dragSource + this.ghostSize = { width: source.element.offsetWidth, height: source.element.offsetHeight } + this.slotSize = source.slot ? source.slot.getSize() : null + } + + /** + * Register a strategy-owned geometry source for a container. Called from + * `ContainerStrategy.onSessionStart`, before any reactive translations are + * applied to slot elements. + */ + setSource(containerId: string, source: SortableSource): void { + this.sources.set(containerId, source) + } + + getSource(containerId: string): SortableSource | undefined { + return this.sources.get(containerId) + } + + // --- Derived accessors --- + + get itemId() { + return this.source.id + } + get element() { + return this.source.element + } + get itemData() { + return this.source.data + } + get draggedItemType() { + return this.source.type ?? null + } + get originContainerId() { + return this.sourceContainer.id + } + get originPosition() { + return this.source.slot?.position ?? 0 + } + get originalPosition() { + return { x: this.startRect.left, y: this.startRect.top } + } +} diff --git a/packages/svelte-dnd/src/lib/core/entities/draggable.svelte.ts b/packages/svelte-dnd/src/lib/core/entities/draggable.svelte.ts new file mode 100644 index 0000000..a2b8117 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/entities/draggable.svelte.ts @@ -0,0 +1,214 @@ +import type { SensorDescriptor, SensorActivation, NavigationDirection } from '../sensors/sensor.js' +import { PointerSensor } from '../sensors/pointer-sensor.js' +import type { DropPreview } from '../../types.js' +import type { Slot } from './slot.js' + +export type DraggableControllerRef = { + session: { source: Draggable } | null + sensors: SensorDescriptor[] | undefined + startSession(draggable: Draggable, initialTransform: { x: number; y: number }): void + updateTransform(transform: { x: number; y: number }): void + updateMousePosition?(mouseX: number, mouseY: number): void + handleAutoScroll?(mouseX: number, mouseY: number): void + navigate(direction: NavigationDirection): void + dropPreview: DropPreview | null + setSkipDropPreviewAnimation(value: boolean): void + performDrop( + sourceId: string, + sourceData: Record | undefined, + targetContainerId: string, + position: number + ): void + endDrag(shouldAnimate?: boolean): void +} + +interface DraggableConfig { + id: string + data?: Record + type?: string + disabled?: boolean + sensors?: SensorDescriptor[] +} + +const DEFAULT_SENSORS: SensorDescriptor[] = [new PointerSensor()] + +export class Draggable { + element!: HTMLElement + slot!: Slot + + id: string + data: Record | undefined + type: string | undefined + disabled: boolean + sensors: SensorDescriptor[] | undefined + + isDragging = $state(false) + dragOccurred = $state(false) + translate = $state({ x: 0, y: 0 }) + + private dragOffset = { x: 0, y: 0 } + private activeActivation: SensorActivation | null = null + private controller: DraggableControllerRef + + constructor(config: DraggableConfig, controller: DraggableControllerRef) { + this.id = config.id + this.data = config.data + this.type = config.type + this.disabled = config.disabled ?? false + this.sensors = config.sensors + this.controller = controller + } + + get isDraggingSession() { + return this.controller.session?.source === this + } + + getCenter(): { x: number; y: number } { + const rect = this.element.getBoundingClientRect() + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2 + } + } + + // --- Public event handlers (bound in template) --- + + handlePointerDown = (e: PointerEvent) => { + if (this.disabled) return + if (!this.element) return + + const sensors = this.getSensors() + + for (const sensor of sensors) { + const activationRef = { current: null as SensorActivation | null } + const activation = sensor.activate(e, this.element, { + onStart: (transform) => { + this.dragOffset = activationRef.current?.offset ?? { x: 0, y: 0 } + this.startDragSession(transform) + }, + onMove: (transform, mouseX, mouseY) => { + this.handleDragMove(transform, mouseX, mouseY) + }, + onEnd: () => { + this.handleDragEnd() + }, + onCancel: () => { + this.handleDragCancel() + }, + onNavigate: (direction) => { + this.controller.navigate(direction) + } + }) + + if (activation) { + activationRef.current = activation + this.activeActivation = activation + this.dragOffset = activation.offset + break + } + } + } + + handleKeyDown = (e: KeyboardEvent) => { + if (this.isDragging) return + if (this.disabled) return + if (!this.element) return + + const sensors = this.getSensors() + let handled = false + + for (const sensor of sensors) { + const activationRef = { current: null as SensorActivation | null } + const activation = sensor.activate(e, this.element, { + onStart: (transform) => { + this.dragOffset = activationRef.current?.offset ?? { x: 0, y: 0 } + this.startDragSession(transform) + }, + onMove: (transform, mouseX, mouseY) => { + this.handleDragMove(transform, mouseX, mouseY) + }, + onEnd: () => { + this.handleDragEnd() + }, + onCancel: () => { + this.handleDragCancel() + }, + onNavigate: (direction) => { + this.controller.navigate(direction) + } + }) + + if (activation) { + activationRef.current = activation + this.activeActivation = activation + this.dragOffset = activation.offset + handled = true + break + } + } + + // Fallback: Enter/Space triggers click for non-DnD keyboard interactions + if (!handled && (e.key === 'Enter' || e.key === ' ')) { + this.element.click() + } + } + + destroy() { + this.activeActivation?.destroy() + this.activeActivation = null + } + + // --- Private --- + + private getSensors(): SensorDescriptor[] { + if (this.sensors) return this.sensors + if (this.controller.sensors) return this.controller.sensors + return DEFAULT_SENSORS + } + + private startDragSession(initialTransform: { x: number; y: number }) { + if (this.isDragging) return + this.isDragging = true + this.dragOccurred = true + + this.controller.startSession(this, initialTransform) + this.controller.updateMousePosition?.( + initialTransform.x + this.dragOffset.x, + initialTransform.y + this.dragOffset.y + ) + } + + private handleDragMove(transform: { x: number; y: number }, mouseX: number, mouseY: number) { + if (!this.isDragging) return + + this.controller.updateTransform(transform) + this.controller.updateMousePosition?.(mouseX, mouseY) + this.controller.handleAutoScroll?.(mouseX, mouseY) + } + + private handleDragEnd() { + if (!this.isDragging) return + this.isDragging = false + this.activeActivation = null + + const dropPreview = this.controller.dropPreview + if (dropPreview) { + this.controller.setSkipDropPreviewAnimation(true) + this.controller.performDrop( + this.id, + this.data, + dropPreview.containerId, + dropPreview.position + ) + } else { + this.controller.endDrag(true) + } + } + + private handleDragCancel() { + if (!this.isDragging) return + this.isDragging = false + this.activeActivation = null + this.controller.endDrag(false) + } +} diff --git a/packages/svelte-dnd/src/lib/core/entities/droppable.svelte.ts b/packages/svelte-dnd/src/lib/core/entities/droppable.svelte.ts new file mode 100644 index 0000000..80ce635 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/entities/droppable.svelte.ts @@ -0,0 +1,238 @@ +import { SvelteMap } from 'svelte/reactivity' +import { isBrowser } from '../utils/dom-helper.js' +import type { + DndContainerInfo, + DndLayout, + DndMode, + DragStartCallback, + DragEndCallback +} from '../../types.js' +import type { CollisionAlgorithm } from '../collision/collision-algorithm.js' +import type { ContainerStrategy } from '../containers/strategies/container-strategy.js' +import type { Slot } from './slot.js' +import type { Preview } from './preview.svelte.js' +import type { DragSession } from '../dnd/drag-session.svelte.js' + +// Minimal controller interface needed by Droppable +export type DroppableControllerRef = { + session: DragSession | null + /** DOM element of the item currently being dragged (not the ghost). */ + draggedElement: HTMLElement | null + cancelSession(): void + slots: Map + onDragStart(cb: DragStartCallback): () => void + onZonesInvalidated(cb: () => void): () => void + onDragEnd(cb: DragEndCallback): () => void + refreshDroppableZones(droppable: Droppable): void + dragging: boolean +} + +interface DroppableConfig { + id: string + data?: Record + disabled?: boolean + collision?: CollisionAlgorithm + accepts?: string | string[] + strategy: ContainerStrategy +} + +export class Droppable { + element!: HTMLElement + + id: string + data: Record | undefined + disabled: boolean + collision: CollisionAlgorithm | undefined + accepts: string | string[] | undefined + strategy: ContainerStrategy + + spacing = $state(undefined) + slots = new SvelteMap() + tailPreview: Preview | undefined = undefined + + private controller: DroppableControllerRef + private scrollListeners: HTMLElement[] = [] + private scrollTimeout: ReturnType | null = null + private unsubscribeDragStart: (() => void) | undefined + private unsubscribeZonesInvalidated: (() => void) | undefined + private unsubscribeDragEnd: (() => void) | undefined + + constructor(config: DroppableConfig, controller: DroppableControllerRef) { + this.id = config.id + this.data = config.data + this.disabled = config.disabled ?? false + this.collision = config.collision + this.accepts = config.accepts + this.strategy = config.strategy + this.controller = controller + } + + get mode(): DndMode { + return this.strategy.mode + } + + get layout(): DndLayout { + return this.strategy.layout ?? 'vertical' + } + + get isHorizontal(): boolean { + return this.layout === 'horizontal' + } + + /** + * Number of items the container holds in its public list (the array users + * splice into on drop). For DOM-rendered lists this is `slots.size`; for + * virtualized strategies it comes from the strategy's virtual source so + * "last slot" / "tail position" math reflects the full data length, not the + * mounted window. + */ + get itemCount(): number { + const virt = (this.strategy as { virtual?: { itemCount?: () => number } }).virtual + const reported = virt?.itemCount?.() + if (typeof reported === 'number' && reported >= 0) return reported + return this.slots.size + } + + /** + * Whether this container delegates its scroll viewport to a virtualizer + * (virtua, tanstack/virtual, …) rather than scrolling its own element. + * The drop coordinator uses this to skip its DOM scroll save/restore for + * virtualized droppables — those manage their own scroll-jump compensation + * and an extra write fights it. + */ + get isVirtualized(): boolean { + return !!(this.strategy as { virtual?: unknown }).virtual + } + + /** Build a public-facing snapshot for event callbacks. */ + toContainerInfo(position: number): DndContainerInfo { + return { + id: this.id, + data: this.data, + layout: this.layout, + mode: this.mode, + disabled: this.disabled, + accepts: this.accepts, + position + } + } + + get isOver(): boolean { + return this.controller.session?.dropPreview?.containerId === this.id + } + + // --- Slot management --- + + /** + * @attach handler — registers a Slot with this droppable and the global controller slots map. + * Called via {@attach droppable.attachSlot(slot)} on the wrapper element in DndDraggable. + */ + attachSlot(slot: Slot) { + return (element: HTMLElement) => { + slot.element = element + slot.droppable = this + this.slots.set(element, slot) + this.controller.slots.set(element, slot) + + return () => { + if (this.controller.session?.source.slot === slot) { + this.controller.cancelSession() + } + this.slots.delete(element) + this.controller.slots.delete(element) + } + } + } + + getSlotAt(position: number): Slot | undefined { + for (const slot of this.slots.values()) { + if (slot.position === position) return slot + } + return undefined + } + + getSortedSlots(): Slot[] { + return Array.from(this.slots.values()).sort((a, b) => a.position - b.position) + } + + acceptsType(type: string | undefined): boolean { + if (!this.accepts) return true + if (!type) return true + if (Array.isArray(this.accepts)) return this.accepts.includes(type) + return this.accepts === type + } + + getScrollOffset(): { x: number; y: number } { + if (!this.element) return { x: 0, y: 0 } + return { x: this.element.scrollLeft, y: this.element.scrollTop } + } + + invalidateZones() { + if (!this.element || this.disabled) return + // Skip if this droppable is nested inside the currently dragged element — + // the zone moves with the ghost, so its stored rect is still correct relative to the cursor. + if (this.controller.draggedElement?.contains(this.element)) return + this.controller.refreshDroppableZones(this) + } + + // --- Lifecycle (set up from attachDroppable in controller) --- + + setupEventListeners() { + this.unsubscribeDragStart = this.controller.onDragStart(() => { + this.invalidateZones() + this.setupScrollListeners() + }) + + this.unsubscribeZonesInvalidated = this.controller.onZonesInvalidated(() => { + this.invalidateZones() + }) + + this.unsubscribeDragEnd = this.controller.onDragEnd(() => { + this.cleanupScrollListeners() + }) + } + + private handleScroll = () => { + if (this.controller.dragging) { + if (this.scrollTimeout) clearTimeout(this.scrollTimeout) + this.scrollTimeout = setTimeout(() => { + this.invalidateZones() + this.scrollTimeout = null + }, 10) + } + } + + private setupScrollListeners() { + if (!isBrowser) return + let parent = this.element?.parentElement + while (parent) { + const style = window.getComputedStyle(parent) + if ( + ['auto', 'scroll', 'overlay'].includes(style.overflowY) || + ['auto', 'scroll', 'overlay'].includes(style.overflowX) + ) { + parent.addEventListener('scroll', this.handleScroll, { passive: true }) + this.scrollListeners.push(parent) + } + parent = parent.parentElement + } + window.addEventListener('scroll', this.handleScroll, { passive: true }) + } + + private cleanupScrollListeners() { + this.scrollListeners.forEach((el) => el.removeEventListener('scroll', this.handleScroll)) + if (isBrowser) window.removeEventListener('scroll', this.handleScroll) + this.scrollListeners = [] + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout) + this.scrollTimeout = null + } + } + + destroy() { + this.cleanupScrollListeners() + this.unsubscribeDragStart?.() + this.unsubscribeZonesInvalidated?.() + this.unsubscribeDragEnd?.() + } +} diff --git a/packages/svelte-dnd/src/lib/core/entities/preview.svelte.ts b/packages/svelte-dnd/src/lib/core/entities/preview.svelte.ts new file mode 100644 index 0000000..0ba6757 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/entities/preview.svelte.ts @@ -0,0 +1,168 @@ +import { untrack } from 'svelte' +import type { Slot } from './slot.js' +import type { Droppable } from './droppable.svelte.js' +import type { DropPreview } from '../../types.js' + +export interface PreviewConfig { + /** Debounce delay (ms) before revealing the preview slot. Default: 300 */ + showDelay?: number + /** Delay (ms) before collapsing the slot. Default: 200 */ + hideDelay?: number +} + +// Minimal controller interface needed by Preview +export type PreviewControllerRef = { + dropPreview: DropPreview | null + dropPreviewSize: { width: number; height: number } | null + ghostSize: { width: number; height: number } | null + skipDropPreviewAnimation: boolean + performingDrop: boolean + translations: Map +} + +export interface PreviewInit { + slot?: Slot + droppable?: Droppable + position: number + config?: PreviewConfig +} + +export class Preview { + element!: HTMLElement + slot = $state(undefined) + droppable = $state(undefined) + position = $state(0) + + height = $state(0) + width = $state(0) + revealed = $state(false) + instant = $state(false) + + showDelay: number + hideDelay: number + + private controller: PreviewControllerRef + private showTimer: ReturnType | null = null + private collapseTimer: ReturnType | null = null + + constructor(controller: PreviewControllerRef, init: PreviewInit) { + this.controller = controller + this.slot = init.slot + this.droppable = init.droppable + this.position = init.position + this.showDelay = init.config?.showDelay ?? 300 + this.hideDelay = init.config?.hideDelay ?? 200 + } + + get containerId(): string { + return this.slot?.droppable?.id ?? this.droppable?.id ?? '' + } + + get isHorizontal(): boolean { + return this.slot?.droppable?.isHorizontal ?? this.droppable?.isHorizontal ?? false + } + + get isVisible(): boolean { + const dp = this.controller.dropPreview + const cid = this.containerId + if (!cid) return false + return !!dp && dp.containerId === cid && dp.position === this.position + } + + /** + * Which edge of the slot wrapper the preview anchors to. + * Computed from this slot's translate — a negative translate means the slot + * has moved backward, so the ghost lands at the far edge of the wrapper. + * Tail previews (no slot) always anchor to 'start'. + */ + get align(): 'start' | 'end' { + const id = this.slot?.draggable?.id + if (!id) return 'start' + const translate = this.controller.translations.get(id) ?? { x: 0, y: 0 } + const val = this.isHorizontal ? translate.x : translate.y + return val < 0 ? 'end' : 'start' + } + + show() { + // Prefer the target's own item size so the preview matches what the + // dropped item will actually be rendered as (covers cases where the + // target shrinks items, e.g. a scrollbar appearing in the source/target). + // Falls back to the dragged element's size when the target is empty. + const previewSize = this.controller.dropPreviewSize + const ghostSize = this.controller.ghostSize + this.height = previewSize?.height ?? ghostSize?.height ?? 0 + this.width = previewSize?.width ?? ghostSize?.width ?? 0 + + if (this.collapseTimer) { + clearTimeout(this.collapseTimer) + this.collapseTimer = null + } + + const skip = untrack(() => this.controller.skipDropPreviewAnimation) + if (skip) { + if (this.showTimer) { + clearTimeout(this.showTimer) + this.showTimer = null + } + this.revealed = true + this.instant = true + requestAnimationFrame(() => { + this.instant = false + }) + } else if (!this.showTimer) { + this.showTimer = setTimeout(() => { + this.revealed = true + this.showTimer = null + }, this.showDelay) + } + } + + hide(instant = false) { + if (this.showTimer) { + clearTimeout(this.showTimer) + this.showTimer = null + } + this.revealed = false + + // Repeated hide() during a drag would otherwise leak orphan timers that later zero out height after show(). + if (this.collapseTimer) { + clearTimeout(this.collapseTimer) + this.collapseTimer = null + } + + if (instant) { + this.instant = true + this.height = 0 + this.width = 0 + requestAnimationFrame(() => { + this.instant = false + }) + } else { + this.instant = false + this.collapseTimer = setTimeout(() => { + this.height = 0 + this.width = 0 + this.collapseTimer = null + }, this.hideDelay) + } + } + + destroy() { + if (this.showTimer) clearTimeout(this.showTimer) + if (this.collapseTimer) clearTimeout(this.collapseTimer) + } + + /** @attach handler — sets element reference and links to slot or droppable (tail preview). */ + attach() { + return (element: HTMLElement) => { + this.element = element + if (this.slot) this.slot.preview = this + else if (this.droppable) this.droppable.tailPreview = this + return () => { + this.destroy() + if (this.slot) this.slot.preview = undefined + else if (this.droppable) this.droppable.tailPreview = undefined + } + } + } +} diff --git a/packages/svelte-dnd/src/lib/core/entities/slot.ts b/packages/svelte-dnd/src/lib/core/entities/slot.ts new file mode 100644 index 0000000..510b335 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/entities/slot.ts @@ -0,0 +1,70 @@ +import type { Draggable } from './draggable.svelte.js' +import type { Preview } from './preview.svelte.js' +import type { Droppable } from './droppable.svelte.js' + +export class Slot { + element!: HTMLElement + draggable!: Draggable + preview: Preview | undefined = undefined + droppable!: Droppable + position: number + + constructor(position: number) { + this.position = position + } + + /** + * Replaces DOMHelper.calculateSlotSize — finds neighbors via droppable.getSlotAt() + * instead of DOM traversal. Preserves all 4 edge cases of the original implementation. + */ + getSize(): { width: number; height: number } { + const nextSlot = this.droppable.getSlotAt(this.position + 1) + const prevSlot = this.droppable.getSlotAt(this.position - 1) + + if (nextSlot) { + const elementRect = this.element.getBoundingClientRect() + const nextRect = nextSlot.element.getBoundingClientRect() + return { + width: nextRect.left - elementRect.left, + height: nextRect.top - elementRect.top + } + } else if (prevSlot) { + const elementRect = this.element.getBoundingClientRect() + const prevRect = prevSlot.element.getBoundingClientRect() + const gapH = elementRect.top - (prevRect.top + prevSlot.draggable.element.offsetHeight) + const gapW = elementRect.left - (prevRect.left + prevSlot.draggable.element.offsetWidth) + return { + width: this.draggable.element.offsetWidth + Math.max(0, gapW), + height: this.draggable.element.offsetHeight + Math.max(0, gapH) + } + } else { + // No slot neighbors — use element size + droppable spacing. + // Cannot rely on DOM margin because suppressSpacing may have zeroed it on the last slot. + const spacing = this.droppable?.spacing ?? 0 + const isHorizontal = this.droppable?.layout === 'horizontal' + return { + width: this.draggable.element.offsetWidth + (isHorizontal ? spacing : 0), + height: this.draggable.element.offsetHeight + (isHorizontal ? 0 : spacing) + } + } + } + + getBoundingRect(): DOMRect { + return this.element.getBoundingClientRect() + } + + /** + * @attach handler — sets element, links Draggable↔Slot, registers data-dnd-drag-id. + * Called via {@attach slot.attachDraggable(draggable)} on the inner draggable div. + */ + attachDraggable(draggable: Draggable): (element: HTMLElement) => () => void { + return (element: HTMLElement) => { + draggable.element = element + draggable.slot = this + this.draggable = draggable + return () => { + draggable.destroy() + } + } + } +} diff --git a/packages/svelte-dnd/src/lib/core/modifiers/modifier.ts b/packages/svelte-dnd/src/lib/core/modifiers/modifier.ts new file mode 100644 index 0000000..bd2cace --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/modifiers/modifier.ts @@ -0,0 +1,8 @@ +export interface ModifierContext { + transform: { x: number; y: number } + initialTransform: { x: number; y: number } + ghostSize: { width: number; height: number } + originContainerId: string +} + +export type Modifier = (context: ModifierContext) => { x: number; y: number } diff --git a/packages/svelte-dnd/src/lib/core/modifiers/restrict-to-container.ts b/packages/svelte-dnd/src/lib/core/modifiers/restrict-to-container.ts new file mode 100644 index 0000000..5174fb9 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/modifiers/restrict-to-container.ts @@ -0,0 +1,11 @@ +import type { Modifier } from './modifier.js' +import { DOMHelper } from '../utils/dom-helper.js' + +export const restrictToContainer: Modifier = ({ transform, ghostSize, originContainerId }) => { + const rect = DOMHelper.getContainerRect(originContainerId) + if (!rect) return transform + return { + x: Math.max(rect.left, Math.min(transform.x, rect.right - ghostSize.width)), + y: Math.max(rect.top, Math.min(transform.y, rect.bottom - ghostSize.height)) + } +} diff --git a/packages/svelte-dnd/src/lib/core/modifiers/restrict-to-horizontal-axis.ts b/packages/svelte-dnd/src/lib/core/modifiers/restrict-to-horizontal-axis.ts new file mode 100644 index 0000000..ffaafbd --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/modifiers/restrict-to-horizontal-axis.ts @@ -0,0 +1,6 @@ +import type { Modifier } from './modifier.js' + +export const restrictToHorizontalAxis: Modifier = ({ transform, initialTransform }) => ({ + x: transform.x, + y: initialTransform.y +}) diff --git a/packages/svelte-dnd/src/lib/core/modifiers/restrict-to-vertical-axis.ts b/packages/svelte-dnd/src/lib/core/modifiers/restrict-to-vertical-axis.ts new file mode 100644 index 0000000..e051b18 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/modifiers/restrict-to-vertical-axis.ts @@ -0,0 +1,6 @@ +import type { Modifier } from './modifier.js' + +export const restrictToVerticalAxis: Modifier = ({ transform, initialTransform }) => ({ + x: initialTransform.x, + y: transform.y +}) diff --git a/packages/svelte-dnd/src/lib/core/modifiers/snap-to-grid.ts b/packages/svelte-dnd/src/lib/core/modifiers/snap-to-grid.ts new file mode 100644 index 0000000..a2875be --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/modifiers/snap-to-grid.ts @@ -0,0 +1,12 @@ +import type { Modifier } from './modifier.js' + +export const snapToGrid = + (gridSize: number | { x: number; y: number }): Modifier => + ({ transform }) => { + const gx = typeof gridSize === 'number' ? gridSize : gridSize.x + const gy = typeof gridSize === 'number' ? gridSize : gridSize.y + return { + x: Math.round(transform.x / gx) * gx, + y: Math.round(transform.y / gy) * gy + } + } diff --git a/src/lib/core/scroll-controller.ts b/packages/svelte-dnd/src/lib/core/scroll/scroll-controller.ts similarity index 54% rename from src/lib/core/scroll-controller.ts rename to packages/svelte-dnd/src/lib/core/scroll/scroll-controller.ts index 0cde798..57941a2 100644 --- a/src/lib/core/scroll-controller.ts +++ b/packages/svelte-dnd/src/lib/core/scroll/scroll-controller.ts @@ -1,17 +1,32 @@ -import type { DragState } from './drag-state.svelte.js' +import type { DndState } from '../dnd/dnd-state.svelte.js' +import type { AutoScrollConfig } from '../animation/behavior.js' -export interface ScrollOptions { +export interface ScrollControllerOptions { onZoneRefresh?: () => void onMouseUpdate?: (x: number, y: number) => void + /** + * Returns the active auto-scroll config for a scrollable container. + * Source: the first `autoScroll(...)` behavior on that container's + * strategy (per-droppable), falling back to the controller-level + * defaults for `data-dnd-scroll` wrappers. Return `null` to opt this + * container out of auto-scroll entirely. + */ + resolveAutoScrollConfig?: (container: HTMLElement) => AutoScrollConfig | null + /** + * Whether to halt auto-scroll the moment the user releases the pointer. + * Sourced from the controller-level `autoScroll(...)` behavior. + */ + stopOnDrop?: boolean } export class ScrollController { - private scrollIntervals = new Map() + private scrollFrames = new Map() private lastMousePosition = { x: 0, y: 0 } + private refreshTimer: ReturnType | null = null constructor( - private state: DragState, - private options: ScrollOptions = {} + private state: DndState, + private options: ScrollControllerOptions = {} ) {} handleAutoScroll(mouseX: number, mouseY: number) { @@ -24,10 +39,10 @@ export class ScrollController { } private clearInvalidIntervals(validContainers: HTMLElement[]) { - for (const [container, intervalId] of this.scrollIntervals) { + for (const [container, frameId] of this.scrollFrames) { if (!validContainers.includes(container)) { - clearInterval(intervalId) - this.scrollIntervals.delete(container) + cancelAnimationFrame(frameId) + this.scrollFrames.delete(container) } } } @@ -36,19 +51,35 @@ export class ScrollController { containers.forEach((container) => { const scrollConfig = this.calculateScrollConfig(container) - if (scrollConfig.shouldScroll && !this.scrollIntervals.has(container)) { + if (scrollConfig.shouldScroll && !this.scrollFrames.has(container)) { this.startScrolling(container) - } else if (!scrollConfig.shouldScroll && this.scrollIntervals.has(container)) { + } else if (!scrollConfig.shouldScroll && this.scrollFrames.has(container)) { this.stopScrolling(container) } }) } + private resolveConfig(container: HTMLElement): AutoScrollConfig | null { + return this.options.resolveAutoScrollConfig?.(container) ?? null + } + private calculateScrollConfig(container: HTMLElement) { + const cfg = this.resolveConfig(container) + if (!cfg) + return { + shouldScroll: false, + directionY: null, + speedY: 0, + directionX: null, + speedX: 0 + } as const + const rect = container.getBoundingClientRect() const { x: mouseX, y: mouseY } = this.lastMousePosition - const scrollZoneY = rect.height * 0.3 - const scrollZoneX = rect.width * 0.3 + const ratio = cfg.zoneRatio ?? 0.3 + const maxSpeed = cfg.maxSpeed ?? 30 + const scrollZoneY = rect.height * ratio + const scrollZoneX = rect.width * ratio const distanceFromTop = mouseY - rect.top const distanceFromBottom = rect.bottom - mouseY @@ -60,10 +91,10 @@ export class ScrollController { if (distanceFromTop < scrollZoneY && distanceFromTop > 0) { directionY = 'up' - speedY = this.calculateSpeed(1 - distanceFromTop / scrollZoneY) + speedY = this.calculateSpeed(1 - distanceFromTop / scrollZoneY, maxSpeed) } else if (distanceFromBottom < scrollZoneY && distanceFromBottom > 0) { directionY = 'down' - speedY = this.calculateSpeed(1 - distanceFromBottom / scrollZoneY) + speedY = this.calculateSpeed(1 - distanceFromBottom / scrollZoneY, maxSpeed) } let speedX = 0 @@ -71,10 +102,10 @@ export class ScrollController { if (distanceFromLeft < scrollZoneX && distanceFromLeft > 0) { directionX = 'left' - speedX = this.calculateSpeed(1 - distanceFromLeft / scrollZoneX) + speedX = this.calculateSpeed(1 - distanceFromLeft / scrollZoneX, maxSpeed) } else if (distanceFromRight < scrollZoneX && distanceFromRight > 0) { directionX = 'right' - speedX = this.calculateSpeed(1 - distanceFromRight / scrollZoneX) + speedX = this.calculateSpeed(1 - distanceFromRight / scrollZoneX, maxSpeed) } return { @@ -83,23 +114,37 @@ export class ScrollController { speedY, directionX, speedX - } + } as const } - private calculateSpeed(proximityRatio: number): number { + private calculateSpeed(proximityRatio: number, maxSpeed: number): number { + let base: number if (proximityRatio < 0.33) { - return 2 + proximityRatio * 3 * 6 + base = 2 + proximityRatio * 3 * 6 } else if (proximityRatio < 0.66) { - return 8 + (proximityRatio - 0.33) * 3 * 10 + base = 8 + (proximityRatio - 0.33) * 3 * 10 } else { - return 18 + (proximityRatio - 0.66) * 3 * 12 + base = 18 + (proximityRatio - 0.66) * 3 * 12 } + return base * (maxSpeed / 30) } private startScrolling(container: HTMLElement) { - const intervalId = setInterval(() => { + let lastTime = 0 + + const tick = (time: number) => { + if (!this.scrollFrames.has(container)) return + + const delta = lastTime ? time - lastTime : 16 + lastTime = time + const config = this.calculateScrollConfig(container) + if (!config.shouldScroll) { + this.stopScrolling(container) + return + } + let scrolledX = false let scrolledY = false @@ -122,36 +167,43 @@ export class ScrollController { } if (scrolledX || scrolledY) { + const scale = delta / 16 container.scrollBy({ top: scrolledY ? config.directionY === 'up' - ? -config.speedY - : config.speedY + ? -config.speedY * scale + : config.speedY * scale : 0, left: scrolledX ? config.directionX === 'left' - ? -config.speedX - : config.speedX + ? -config.speedX * scale + : config.speedX * scale : 0, behavior: 'auto' }) this.scheduleRefresh() } - }, 16) - this.scrollIntervals.set(container, intervalId as unknown as number) + const frameId = requestAnimationFrame(tick) + this.scrollFrames.set(container, frameId) + } + + const frameId = requestAnimationFrame(tick) + this.scrollFrames.set(container, frameId) } private stopScrolling(container: HTMLElement) { - const intervalId = this.scrollIntervals.get(container) - if (intervalId) { - clearInterval(intervalId) - this.scrollIntervals.delete(container) + const frameId = this.scrollFrames.get(container) + if (frameId) { + cancelAnimationFrame(frameId) + this.scrollFrames.delete(container) } } private scheduleRefresh() { - setTimeout(() => { + if (this.refreshTimer) return + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null this.options.onZoneRefresh?.() this.options.onMouseUpdate?.(this.lastMousePosition.x, this.lastMousePosition.y) }, 10) @@ -162,7 +214,11 @@ export class ScrollController { const elementsAtPoint = document.elementsFromPoint(mouseX, mouseY) for (const element of elementsAtPoint) { - if (element instanceof HTMLElement && element.hasAttribute('data-dnd-scroll')) { + if ( + element instanceof HTMLElement && + (element.hasAttribute('data-dnd-droppable') || + element.hasAttribute('data-dnd-scroll')) + ) { const computedStyle = window.getComputedStyle(element) const overflowY = computedStyle.overflowY const overflowX = computedStyle.overflowX @@ -184,14 +240,24 @@ export class ScrollController { return containers } + get stopOnDrop() { + return this.options.stopOnDrop ?? false + } + + /** Replace runtime callbacks/options. Used by `setBehaviors`. */ + updateOptions(options: Partial) { + this.options = { ...this.options, ...options } + } + clearAll() { - for (const [, intervalId] of this.scrollIntervals) { - clearInterval(intervalId) + for (const [, frameId] of this.scrollFrames) { + cancelAnimationFrame(frameId) } - this.scrollIntervals.clear() + this.scrollFrames.clear() } destroy() { this.clearAll() + if (this.refreshTimer) clearTimeout(this.refreshTimer) } } diff --git a/packages/svelte-dnd/src/lib/core/scroll/scroll-into-view.ts b/packages/svelte-dnd/src/lib/core/scroll/scroll-into-view.ts new file mode 100644 index 0000000..302e5dc --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/scroll/scroll-into-view.ts @@ -0,0 +1,63 @@ +import { isBrowser } from '../utils/dom-helper.js' + +export interface ScrollTarget { + container: HTMLElement + direction: 'vertical' | 'horizontal' +} + +const SCROLL_OVERFLOW_VALUES = new Set(['auto', 'scroll', 'overlay']) + +function isScrollableY(el: HTMLElement, style: CSSStyleDeclaration): boolean { + return SCROLL_OVERFLOW_VALUES.has(style.overflowY) && el.scrollHeight > el.clientHeight +} + +function isScrollableX(el: HTMLElement, style: CSSStyleDeclaration): boolean { + return SCROLL_OVERFLOW_VALUES.has(style.overflowX) && el.scrollWidth > el.clientWidth +} + +function* scrollableAncestors(start: HTMLElement): Generator { + let parent = start.parentElement + while (parent) { + const style = window.getComputedStyle(parent) + if (isScrollableY(parent, style) || isScrollableX(parent, style)) { + yield parent + } + parent = parent.parentElement + } +} + +/** + * Find the nearest scrollable ancestor of `slotEl` where the slot sits outside + * the visible band (container rect minus `padding` on each edge), and return + * the axis that needs to scroll. Returns `null` when the slot is already fully + * inside every scrollable ancestor. + */ +export function findScrollTarget(slotEl: HTMLElement, padding = 0): ScrollTarget | null { + if (!isBrowser) return null + + for (const container of scrollableAncestors(slotEl)) { + const slotRect = slotEl.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + const style = window.getComputedStyle(container) + + if (isScrollableY(container, style)) { + if ( + slotRect.top < containerRect.top + padding || + slotRect.bottom > containerRect.bottom - padding + ) { + return { container, direction: 'vertical' } + } + } + + if (isScrollableX(container, style)) { + if ( + slotRect.left < containerRect.left + padding || + slotRect.right > containerRect.right - padding + ) { + return { container, direction: 'horizontal' } + } + } + } + + return null +} diff --git a/packages/svelte-dnd/src/lib/core/scroll/touch-scroll.ts b/packages/svelte-dnd/src/lib/core/scroll/touch-scroll.ts new file mode 100644 index 0000000..c3c089a --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/scroll/touch-scroll.ts @@ -0,0 +1,91 @@ +export class TouchScroll { + private scrollTarget: Element | null = null + private lastPos = { x: 0, y: 0 } + private lastTime = 0 + private velocity = { x: 0, y: 0 } + private raf: number | null = null + + private static readonly DECELERATION = 0.92 + private static readonly VELOCITY_STOP = 0.05 + private static readonly VELOCITY_ALPHA = 0.7 + + get isScrolling() { + return this.scrollTarget !== null + } + + start(fromElement: HTMLElement, clientX: number, clientY: number) { + this.stopMomentum() + this.scrollTarget = this.findScrollableParent(fromElement) + this.lastPos = { x: clientX, y: clientY } + this.lastTime = Date.now() + this.velocity = { x: 0, y: 0 } + } + + update(clientX: number, clientY: number) { + if (!this.scrollTarget) return + + const dx = this.lastPos.x - clientX + const dy = this.lastPos.y - clientY + const now = Date.now() + const dt = now - this.lastTime + + this.scrollTarget.scrollBy(dx, dy) + + if (dt > 0) { + const a = TouchScroll.VELOCITY_ALPHA + this.velocity.x = a * (dx / dt) + (1 - a) * this.velocity.x + this.velocity.y = a * (dy / dt) + (1 - a) * this.velocity.y + } + + this.lastPos = { x: clientX, y: clientY } + this.lastTime = now + } + + end() { + const target = this.scrollTarget + this.scrollTarget = null + if (target) this.applyMomentum(target) + } + + stopMomentum() { + if (this.raf !== null) { + cancelAnimationFrame(this.raf) + this.raf = null + } + } + + private applyMomentum(target: Element) { + const tick = () => { + this.velocity.x *= TouchScroll.DECELERATION + this.velocity.y *= TouchScroll.DECELERATION + + if ( + Math.abs(this.velocity.x) < TouchScroll.VELOCITY_STOP && + Math.abs(this.velocity.y) < TouchScroll.VELOCITY_STOP + ) { + this.raf = null + return + } + + target.scrollBy(this.velocity.x * 16, this.velocity.y * 16) + this.raf = requestAnimationFrame(tick) + } + + this.raf = requestAnimationFrame(tick) + } + + private findScrollableParent(el: HTMLElement): Element | null { + let parent = el.parentElement + while (parent && parent !== document.body) { + const style = getComputedStyle(parent) + if ( + ['auto', 'scroll', 'overlay'].includes(style.overflowY) || + ['auto', 'scroll', 'overlay'].includes(style.overflowX) + ) { + return parent + } + parent = parent.parentElement + } + return document.scrollingElement + } +} diff --git a/packages/svelte-dnd/src/lib/core/sensors/activation-constraints.ts b/packages/svelte-dnd/src/lib/core/sensors/activation-constraints.ts new file mode 100644 index 0000000..11637ca --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/sensors/activation-constraints.ts @@ -0,0 +1,59 @@ +import type { StartCondition, ActivationState, ConditionResult } from './sensor.js' + +export interface DistanceConfig { + value: number + tolerance?: number +} + +export class Distance implements StartCondition { + constructor(private config: DistanceConfig) {} + + evaluate(state: ActivationState): ConditionResult { + const dx = Math.abs(state.currentX - state.startX) + const dy = Math.abs(state.currentY - state.startY) + const distance = Math.sqrt(dx * dx + dy * dy) + + if (this.config.tolerance !== undefined && distance > this.config.tolerance) { + return 'aborted' + } + + if (distance >= this.config.value) { + return 'satisfied' + } + + return 'pending' + } + + getRequiredDuration(): number | null { + return null + } +} + +export interface DelayConfig { + value: number + tolerance?: number +} + +export class Delay implements StartCondition { + constructor(private config: DelayConfig) {} + + evaluate(state: ActivationState): ConditionResult { + const dx = Math.abs(state.currentX - state.startX) + const dy = Math.abs(state.currentY - state.startY) + const distance = Math.sqrt(dx * dx + dy * dy) + + if (this.config.tolerance !== undefined && distance > this.config.tolerance) { + return 'aborted' + } + + if (state.elapsedMs >= this.config.value) { + return 'satisfied' + } + + return 'pending' + } + + getRequiredDuration(): number | null { + return this.config.value + } +} diff --git a/packages/svelte-dnd/src/lib/core/sensors/keyboard-sensor.ts b/packages/svelte-dnd/src/lib/core/sensors/keyboard-sensor.ts new file mode 100644 index 0000000..4d2beed --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/sensors/keyboard-sensor.ts @@ -0,0 +1,78 @@ +import type { + SensorDescriptor, + SensorActivation, + SensorCallbacks, + NavigationDirection +} from './sensor.js' + +export class KeyboardSensor implements SensorDescriptor { + activate( + event: Event, + element: HTMLElement, + callbacks: SensorCallbacks + ): SensorActivation | null { + if (!(event instanceof KeyboardEvent)) return null + if (event.key !== 'Enter' && event.key !== ' ') return null + // Skip bubbled events from nested draggables. + if (event.target && event.target !== element) return null + + event.preventDefault() + event.stopPropagation() + + const rect = element.getBoundingClientRect() + const offset = { x: rect.width / 2, y: rect.height / 2 } + const initialTransform = { x: rect.left, y: rect.top } + + let started = false + + const keyToDirection: Record = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', + Home: 'home', + End: 'end' + } + + const onWindowKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + cleanup() + callbacks.onCancel() + return + } + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + cleanup() + callbacks.onEnd() + return + } + + const direction = keyToDirection[e.key] + if (direction) { + e.preventDefault() + if (!started) { + callbacks.onStart(initialTransform) + started = true + } + callbacks.onNavigate?.(direction) + } + } + + // Defer listener so the current Enter keydown event doesn't immediately trigger onEnd. + // Track the timer so cleanup() can cancel it if destroy fires before the listener attaches. + const timerId = setTimeout(() => window.addEventListener('keydown', onWindowKeyDown), 0) + + // Start drag immediately on Enter/Space + callbacks.onStart(initialTransform) + started = true + + function cleanup() { + clearTimeout(timerId) + window.removeEventListener('keydown', onWindowKeyDown) + } + + return { initialTransform, offset, destroy: cleanup } + } +} diff --git a/packages/svelte-dnd/src/lib/core/sensors/pointer-sensor.ts b/packages/svelte-dnd/src/lib/core/sensors/pointer-sensor.ts new file mode 100644 index 0000000..6d937c8 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/sensors/pointer-sensor.ts @@ -0,0 +1,252 @@ +import { TouchScroll } from '../scroll/touch-scroll.js' +import { isBrowser } from '../utils/dom-helper.js' +import type { + SensorDescriptor, + SensorActivation, + SensorCallbacks, + ActivationState, + StartCondition, + StartConditionInput +} from './sensor.js' +import { Distance, Delay } from './activation-constraints.js' + +export interface PointerSensorOptions { + startConditions?: StartConditionInput +} + +const DEFAULT_MOUSE_CONDITIONS: StartCondition[] = [new Distance({ value: 5 })] +const DEFAULT_TOUCH_CONDITIONS: StartCondition[] = [new Delay({ value: 300, tolerance: 8 })] + +function getDefaultStartConditions(pointerType: string): StartCondition[] { + if (pointerType === 'mouse') return DEFAULT_MOUSE_CONDITIONS + return DEFAULT_TOUCH_CONDITIONS +} + +function resolveStartConditions( + event: PointerEvent, + options: PointerSensorOptions | undefined +): StartCondition[] { + const input = options?.startConditions + if (!input) return getDefaultStartConditions(event.pointerType) + if (typeof input === 'function') return input(event) + return input +} + +export class PointerSensor implements SensorDescriptor { + constructor(private options?: PointerSensorOptions) {} + + activate( + event: Event, + element: HTMLElement, + callbacks: SensorCallbacks + ): SensorActivation | null { + if (!(event instanceof PointerEvent)) return null + if (event.button !== 0) return null + + const e = event + + const target = e.target as HTMLElement + const hasHandle = !!element.querySelector('[data-dnd-handle]') + if (hasHandle) { + if (!target.closest('[data-dnd-handle]')) return null + } else { + if (target.closest('[data-dnd-no-drag]')) return null + } + + const rect = element.getBoundingClientRect() + const styles = getComputedStyle(element) + const contentTop = rect.top + parseFloat(styles.paddingTop) + const contentBottom = rect.bottom - parseFloat(styles.paddingBottom) + const contentLeft = rect.left + parseFloat(styles.paddingLeft) + const contentRight = rect.right - parseFloat(styles.paddingRight) + if ( + e.clientY < contentTop || + e.clientY > contentBottom || + e.clientX < contentLeft || + e.clientX > contentRight + ) + return null + + e.stopPropagation() + + const offset = { x: e.clientX - rect.left, y: e.clientY - rect.top } + const initialTransform = { x: e.clientX - offset.x, y: e.clientY - offset.y } + + const conditions = resolveStartConditions(e, this.options) + const startTime = performance.now() + + const state: ActivationState = { + startX: e.clientX, + startY: e.clientY, + currentX: e.clientX, + currentY: e.clientY, + elapsedMs: 0, + pointerType: e.pointerType as 'mouse' | 'touch' | 'pen' + } + + let isDragging = false + let isPotentialDrag = true + const lastPointerId = e.pointerId + const lastPointerPos = { x: e.clientX, y: e.clientY } + let conditionTimer: ReturnType | null = null + const touchScroll = new TouchScroll() + + touchScroll.stopMomentum() + + const startActualDrag = (clientX: number, clientY: number, pointerId: number) => { + if (isDragging) return + isDragging = true + isPotentialDrag = false + + if (element.hasPointerCapture(pointerId)) { + element.releasePointerCapture(pointerId) + } + + if (isBrowser) { + window.addEventListener('pointermove', onWindowMove) + window.addEventListener('pointerup', onWindowUp) + window.addEventListener('pointercancel', onWindowCancel) + } + + const transform = { x: clientX - offset.x, y: clientY - offset.y } + callbacks.onStart(transform) + } + + const cancelTimer = () => { + if (conditionTimer) { + clearTimeout(conditionTimer) + conditionTimer = null + } + } + + const evaluateConditions = (): 'satisfied' | 'pending' | 'all_aborted' => { + let allAborted = true + + for (const condition of conditions) { + const result = condition.evaluate(state) + if (result === 'satisfied') return 'satisfied' + if (result !== 'aborted') allAborted = false + } + + return allAborted ? 'all_aborted' : 'pending' + } + + const onWindowMove = (e: PointerEvent) => { + if (!isDragging) return + const transform = { x: e.clientX - offset.x, y: e.clientY - offset.y } + callbacks.onMove(transform, e.clientX, e.clientY) + } + + const onWindowUp = (_e: PointerEvent) => { + if (!isDragging) return + isDragging = false + removeWindowListeners() + callbacks.onEnd() + } + + const onWindowCancel = () => { + if (!isDragging) return + isDragging = false + removeWindowListeners() + callbacks.onCancel() + } + + const removeWindowListeners = () => { + if (!isBrowser) return + window.removeEventListener('pointermove', onWindowMove) + window.removeEventListener('pointerup', onWindowUp) + window.removeEventListener('pointercancel', onWindowCancel) + } + + const onElementMove = (e: PointerEvent) => { + if (!isPotentialDrag && !touchScroll.isScrolling) return + + lastPointerPos.x = e.clientX + lastPointerPos.y = e.clientY + + if (!isPotentialDrag) { + touchScroll.update(e.clientX, e.clientY) + return + } + + state.currentX = e.clientX + state.currentY = e.clientY + state.elapsedMs = performance.now() - startTime + + const result = evaluateConditions() + + if (result === 'satisfied') { + cancelTimer() + startActualDrag(e.clientX, e.clientY, e.pointerId) + } else if (result === 'all_aborted') { + cancelTimer() + isPotentialDrag = false + touchScroll.start(element, e.clientX, e.clientY) + } + } + + const onElementUp = (e: PointerEvent) => { + if (element.hasPointerCapture(e.pointerId)) { + element.releasePointerCapture(e.pointerId) + } + if (touchScroll.isScrolling) touchScroll.end() + if (isPotentialDrag) { + cancelTimer() + isPotentialDrag = false + } + element.removeEventListener('pointermove', onElementMove) + element.removeEventListener('pointerup', onElementUp) + element.removeEventListener('pointercancel', onElementCancel) + } + + const onElementCancel = () => { + if (touchScroll.isScrolling) touchScroll.end() + cancelTimer() + isPotentialDrag = false + element.removeEventListener('pointermove', onElementMove) + element.removeEventListener('pointerup', onElementUp) + element.removeEventListener('pointercancel', onElementCancel) + } + + element.addEventListener('pointermove', onElementMove) + element.addEventListener('pointerup', onElementUp) + element.addEventListener('pointercancel', onElementCancel) + + // Set timer for the minimum required duration among all conditions + const durations = conditions + .map((c) => c.getRequiredDuration?.()) + .filter((d): d is number => d !== null && d !== undefined) + const minDuration = durations.length > 0 ? Math.min(...durations) : null + + if (minDuration !== null && minDuration > 0) { + conditionTimer = setTimeout(() => { + conditionTimer = null + if (!isPotentialDrag || touchScroll.isScrolling) return + + state.elapsedMs = performance.now() - startTime + const result = evaluateConditions() + + if (result === 'satisfied') { + element.setPointerCapture(lastPointerId) + startActualDrag(lastPointerPos.x, lastPointerPos.y, lastPointerId) + } else if (result === 'all_aborted') { + isPotentialDrag = false + touchScroll.start(element, lastPointerPos.x, lastPointerPos.y) + } + }, minDuration) + } + + return { + initialTransform, + offset, + destroy: () => { + cancelTimer() + touchScroll.stopMomentum() + removeWindowListeners() + element.removeEventListener('pointermove', onElementMove) + element.removeEventListener('pointerup', onElementUp) + element.removeEventListener('pointercancel', onElementCancel) + } + } + } +} diff --git a/packages/svelte-dnd/src/lib/core/sensors/sensor.ts b/packages/svelte-dnd/src/lib/core/sensors/sensor.ts new file mode 100644 index 0000000..8e9a787 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/sensors/sensor.ts @@ -0,0 +1,44 @@ +export type NavigationDirection = 'up' | 'down' | 'left' | 'right' | 'home' | 'end' + +export interface SensorCallbacks { + onStart: (transform: { x: number; y: number }) => void + onMove: (transform: { x: number; y: number }, mouseX: number, mouseY: number) => void + onEnd: () => void + onCancel: () => void + onNavigate?: (direction: NavigationDirection) => void +} + +export interface SensorActivation { + /** Initial position of the ghost element */ + initialTransform: { x: number; y: number } + /** Offset from the element's top-left corner to the pointer */ + offset: { x: number; y: number } + /** Cancel pending timers/listeners if drag never started */ + destroy: () => void +} + +export interface ActivationState { + startX: number + startY: number + currentX: number + currentY: number + elapsedMs: number + pointerType: 'mouse' | 'touch' | 'pen' +} + +export type ConditionResult = 'satisfied' | 'pending' | 'aborted' + +export interface StartCondition { + evaluate(state: ActivationState): ConditionResult + getRequiredDuration?(): number | null +} + +export type StartConditionInput = StartCondition[] | ((event: PointerEvent) => StartCondition[]) + +export interface SensorDescriptor { + activate( + event: Event, + element: HTMLElement, + callbacks: SensorCallbacks + ): SensorActivation | null +} diff --git a/packages/svelte-dnd/src/lib/core/utils/dom-helper.ts b/packages/svelte-dnd/src/lib/core/utils/dom-helper.ts new file mode 100644 index 0000000..d2fcb60 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/utils/dom-helper.ts @@ -0,0 +1,71 @@ +/** + * Static DOM utilities for querying DnD-related elements by their data attributes. + * Used internally by the library; exposed for advanced custom implementations. + */ + +export const isBrowser = typeof window !== 'undefined' + +// DOM selectors +const SELECTORS = { + container: (id: string) => `[data-dnd-drop-id="${CSS.escape(id)}"]`, + preview: (position: number) => `[data-dnd-preview-position="${position}"]` +} as const + +export class DOMHelper { + // Container queries + static findContainer(containerId: string): HTMLElement | null { + return document.querySelector(SELECTORS.container(containerId)) + } + + static getContainerRect(containerId: string): DOMRect | null { + const container = DOMHelper.findContainer(containerId) + return container ? container.getBoundingClientRect() : null + } + + // Preview queries + static findPreview(container: HTMLElement, position: number): HTMLElement | null { + const all = container.querySelectorAll(SELECTORS.preview(position)) + for (const el of all) { + if (el.closest('[data-dnd-drop-id]') === container) return el + } + return null + } + + static findPreviewSlot(container: HTMLElement, position: number): HTMLElement | null { + const preview = DOMHelper.findPreview(container, position) + if (!preview) return null + return preview.parentElement ?? preview + } + + // Visibility checks + static isElementVisibleInContainer(element: HTMLElement, container: HTMLElement): boolean { + const containerRect = container.getBoundingClientRect() + const elementRect = element.getBoundingClientRect() + + return ( + elementRect.top >= containerRect.top && + elementRect.bottom <= containerRect.bottom && + elementRect.left >= containerRect.left && + elementRect.right <= containerRect.right + ) + } + + /** + * Fraction of the element's area that is currently inside the container's + * rect, in the range [0, 1]. Returns 0 when the element has zero area. + */ + static computeVisibleFraction(element: HTMLElement, container: HTMLElement): number { + const e = element.getBoundingClientRect() + const c = container.getBoundingClientRect() + const ix = Math.max(0, Math.min(e.right, c.right) - Math.max(e.left, c.left)) + const iy = Math.max(0, Math.min(e.bottom, c.bottom) - Math.max(e.top, c.top)) + const intersection = ix * iy + const area = e.width * e.height + return area > 0 ? intersection / area : 0 + } + + // Rect helpers + static getRect(element: HTMLElement): DOMRect { + return element.getBoundingClientRect() + } +} diff --git a/packages/svelte-dnd/src/lib/core/zones/drop-resolver.ts b/packages/svelte-dnd/src/lib/core/zones/drop-resolver.ts new file mode 100644 index 0000000..b2ecf7c --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/zones/drop-resolver.ts @@ -0,0 +1,77 @@ +import type { DropZone } from '../../types.js' +import type { DndState } from '../dnd/dnd-state.svelte.js' +import type { Droppable } from '../entities/droppable.svelte.js' +import type { CollisionAlgorithm } from '../collision/collision-algorithm.js' +import { centerPoint } from '../collision/center-point.js' + +/** + * Resolves which drop zone contains a given point during an active drag. + * Uses pluggable collision algorithms with the following priority: + * 1. Per-container algorithm (from droppable entity) + * 2. Global algorithm (passed to constructor) + * 3. centerPoint (default) + */ +export class DropResolver { + constructor( + private state: DndState, + private droppablesById: Map, + private globalAlgorithm?: CollisionAlgorithm + ) {} + + /** All zones filtered to only those that accept the currently dragged item type. */ + get filteredZones(): DropZone[] { + return this.filterZonesByType(this.state.zones) + } + + findZoneAt(point: { x: number; y: number }): DropZone | null { + const draggedItemId = this.state.draggedItem + if (!draggedItemId) return null + + const filteredZones = this.filterZonesByType(this.state.zones) + const ghost = this.getGhostRect() + + // Group zones by container so each per-container algorithm sees all of its + // candidates at once. Calling algorithms with a single-zone array makes + // closestCenter/overlap degenerate (they can't compare anything). + const groups = new Map() + for (const zone of filteredZones) { + const list = groups.get(zone.containerId) + if (list) list.push(zone) + else groups.set(zone.containerId, [zone]) + } + + for (const [containerId, zones] of groups) { + const droppable = this.droppablesById.get(containerId) + const algorithm = droppable?.collision ?? this.globalAlgorithm ?? centerPoint + + const hit = algorithm({ zones, pointer: point, ghost }) + if (hit) return hit + } + + return null + } + + private filterZonesByType(zones: DropZone[]): DropZone[] { + const draggedType = this.state.draggedType + if (!draggedType) return zones + + return zones.filter((zone) => { + const droppable = this.droppablesById.get(zone.containerId) + const accepts = droppable?.accepts + if (!accepts) return true + if (Array.isArray(accepts)) return accepts.includes(draggedType) + return accepts === draggedType + }) + } + + private getGhostRect(): { x: number; y: number; width: number; height: number } { + const transform = this.state.transform + const size = this.state.ghostSize + return { + x: transform?.x ?? 0, + y: transform?.y ?? 0, + width: size?.width ?? 0, + height: size?.height ?? 0 + } + } +} diff --git a/packages/svelte-dnd/src/lib/core/zones/geometries/axis-zone-geometry.ts b/packages/svelte-dnd/src/lib/core/zones/geometries/axis-zone-geometry.ts new file mode 100644 index 0000000..7f4b7b3 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/zones/geometries/axis-zone-geometry.ts @@ -0,0 +1,147 @@ +import type { DropZone, DndLayout } from '../../../types.js' +import type { SlotLayoutRect } from '../layout-snapshot.js' +import type { ZoneGeometry, ZoneGeometryContext } from '../zone-geometry.js' + +type Axis = 'vertical' | 'horizontal' + +/** + * Zone geometry for single-axis layouts (vertical / horizontal lists). + * Each item contributes two half-rect zones: one for "insert before me", + * one for "insert after me". Adjacent halves belong to neighbouring positions. + */ +export class AxisZoneGeometry implements ZoneGeometry { + constructor(private axis: Axis) {} + + buildZones(visibleRects: SlotLayoutRect[], ctx: ZoneGeometryContext): DropZone[] { + if (visibleRects.length === 0) return [this.buildEmptyZone(ctx)] + + const zones: DropZone[] = [] + const layout: DndLayout = this.axis + const beforePos = (rect: SlotLayoutRect) => + ctx.draggedIndex !== -1 && rect.position > ctx.draggedIndex + ? rect.position - 1 + : rect.position + + visibleRects.forEach((rect, index) => { + const viewport = this.toViewport(rect, ctx) + const before = beforePos(rect) + + if (this.axis === 'vertical') { + const halfHeight = viewport.height / 2 + + if (index === 0) { + zones.push({ + containerId: ctx.containerId, + position: before, + layout, + rect: { + x: ctx.containerRect.left, + y: ctx.containerRect.top, + width: ctx.containerRect.width, + height: Math.max( + halfHeight, + viewport.y - ctx.containerRect.top + halfHeight + ) + } + }) + } + + const next = visibleRects[index + 1] + const zoneY = viewport.y + halfHeight + let zoneHeight = halfHeight + + if (next) { + const nextViewport = this.toViewport(next, ctx) + zoneHeight = + halfHeight + + (nextViewport.y - (viewport.y + viewport.height)) + + nextViewport.height / 2 + } else { + zoneHeight = Math.max(halfHeight, ctx.containerRect.bottom - zoneY) + } + + zones.push({ + containerId: ctx.containerId, + position: before + 1, + layout, + rect: { + x: ctx.containerRect.left, + y: zoneY, + width: ctx.containerRect.width, + height: zoneHeight + } + }) + } else { + const halfWidth = viewport.width / 2 + + if (index === 0) { + zones.push({ + containerId: ctx.containerId, + position: before, + layout, + rect: { + x: ctx.containerRect.left, + y: ctx.containerRect.top, + width: Math.max( + halfWidth, + viewport.x - ctx.containerRect.left + halfWidth + ), + height: ctx.containerRect.height + } + }) + } + + const next = visibleRects[index + 1] + const zoneX = viewport.x + halfWidth + let zoneWidth = halfWidth + + if (next) { + const nextViewport = this.toViewport(next, ctx) + zoneWidth = + halfWidth + + (nextViewport.x - (viewport.x + viewport.width)) + + nextViewport.width / 2 + } else { + zoneWidth = Math.max(halfWidth, ctx.containerRect.right - zoneX) + } + + zones.push({ + containerId: ctx.containerId, + position: before + 1, + layout, + rect: { + x: zoneX, + y: ctx.containerRect.top, + width: zoneWidth, + height: ctx.containerRect.height + } + }) + } + }) + + return zones + } + + buildEmptyZone(ctx: ZoneGeometryContext): DropZone { + return { + containerId: ctx.containerId, + position: 0, + layout: this.axis, + rect: { + x: ctx.containerRect.left, + y: ctx.containerRect.top, + width: ctx.containerRect.width, + height: Math.max(ctx.containerRect.height, 20) + } + } + } + + private toViewport(rect: SlotLayoutRect, ctx: ZoneGeometryContext) { + return { + x: rect.offsetLeft + ctx.containerRect.left - ctx.scrollLeft, + y: rect.offsetTop + ctx.containerRect.top - ctx.scrollTop, + width: rect.width, + height: rect.height + } + } +} diff --git a/packages/svelte-dnd/src/lib/core/zones/geometries/grid-zone-geometry.ts b/packages/svelte-dnd/src/lib/core/zones/geometries/grid-zone-geometry.ts new file mode 100644 index 0000000..4a1635e --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/zones/geometries/grid-zone-geometry.ts @@ -0,0 +1,187 @@ +import type { DropZone } from '../../../types.js' +import type { SlotLayoutRect } from '../layout-snapshot.js' +import type { ZoneGeometry, ZoneGeometryContext } from '../zone-geometry.js' + +export type GridFlow = 'row' | 'column' + +/** + * Zone geometry for 2D grid layouts. + * + * `flow: 'row'` (default): items fill left-to-right, wrapping to next row. + * `flow: 'column'`: items fill top-to-bottom, wrapping to next column. + * + * Items are grouped along the secondary (wrap) axis using the captured snapshot, + * then each item is split into two halves along the primary axis — one for + * "insert before me", one for "insert after me". + */ +export class GridZoneGeometry implements ZoneGeometry { + constructor(private flow: GridFlow = 'row') {} + + buildZones(visibleRects: SlotLayoutRect[], ctx: ZoneGeometryContext): DropZone[] { + if (visibleRects.length === 0) return [this.buildEmptyZone(ctx)] + + const ax = axisFor(this.flow) + const groups = groupByTrack(visibleRects, ax) + const zones: DropZone[] = [] + const beforePos = (rect: SlotLayoutRect) => + ctx.draggedIndex !== -1 && rect.position > ctx.draggedIndex + ? rect.position - 1 + : rect.position + + for (let gi = 0; gi < groups.length; gi++) { + const group = groups[gi] + const prevGroup = groups[gi - 1] + const nextGroup = groups[gi + 1] + + const trackStart = Math.min(...group.map((r) => ax.secondaryOf(r))) + const trackEnd = Math.max( + ...group.map((r) => ax.secondaryOf(r) + ax.secondarySizeOf(r)) + ) + + const secStart = prevGroup + ? (Math.max(...prevGroup.map((r) => ax.secondaryOf(r) + ax.secondarySizeOf(r))) + + trackStart) / + 2 + : Math.min(0, trackStart) + const secEnd = nextGroup + ? (trackEnd + Math.min(...nextGroup.map((r) => ax.secondaryOf(r)))) / 2 + : Math.max(ax.containerSecondaryLength(ctx) + ax.scrollSecondary(ctx), trackEnd) + + for (let ii = 0; ii < group.length; ii++) { + const rect = group[ii] + const prev = group[ii - 1] + const next = group[ii + 1] + const before = beforePos(rect) + + const primaryStart = ax.primaryOf(rect) + const primarySize = ax.primarySizeOf(rect) + const primaryMid = primaryStart + primarySize / 2 + + const beforeStart = prev + ? (ax.primaryOf(prev) + ax.primarySizeOf(prev) + primaryStart) / 2 + : Math.min(0, primaryStart) + zones.push(ax.toZone(ctx, before, beforeStart, primaryMid, secStart, secEnd)) + + const afterEnd = next + ? (primaryStart + primarySize + ax.primaryOf(next)) / 2 + : Math.max( + ax.containerPrimaryLength(ctx) + ax.scrollPrimary(ctx), + primaryStart + primarySize + ) + zones.push(ax.toZone(ctx, before + 1, primaryMid, afterEnd, secStart, secEnd)) + } + } + + return zones + } + + buildEmptyZone(ctx: ZoneGeometryContext): DropZone { + return { + containerId: ctx.containerId, + position: 0, + layout: 'grid', + rect: { + x: ctx.containerRect.left, + y: ctx.containerRect.top, + width: ctx.containerRect.width, + height: Math.max(ctx.containerRect.height, 20) + } + } + } +} + +/** + * Axis abstraction lets the grouping + zone-splitting logic stay symmetric + * across `flow: 'row'` (primary = X, secondary = Y) and `flow: 'column'` + * (primary = Y, secondary = X). + */ +interface AxisMapping { + primaryOf(r: SlotLayoutRect): number + secondaryOf(r: SlotLayoutRect): number + primarySizeOf(r: SlotLayoutRect): number + secondarySizeOf(r: SlotLayoutRect): number + containerPrimaryLength(ctx: ZoneGeometryContext): number + containerSecondaryLength(ctx: ZoneGeometryContext): number + scrollPrimary(ctx: ZoneGeometryContext): number + scrollSecondary(ctx: ZoneGeometryContext): number + toZone( + ctx: ZoneGeometryContext, + position: number, + primaryStart: number, + primaryEnd: number, + secondaryStart: number, + secondaryEnd: number + ): DropZone +} + +function axisFor(flow: GridFlow): AxisMapping { + if (flow === 'row') { + return { + primaryOf: (r) => r.offsetLeft, + secondaryOf: (r) => r.offsetTop, + primarySizeOf: (r) => r.width, + secondarySizeOf: (r) => r.height, + containerPrimaryLength: (ctx) => ctx.containerRect.width, + containerSecondaryLength: (ctx) => ctx.containerRect.height, + scrollPrimary: (ctx) => ctx.scrollLeft, + scrollSecondary: (ctx) => ctx.scrollTop, + toZone: (ctx, position, pStart, pEnd, sStart, sEnd) => ({ + containerId: ctx.containerId, + position, + layout: 'grid', + rect: { + x: pStart + ctx.containerRect.left - ctx.scrollLeft, + y: sStart + ctx.containerRect.top - ctx.scrollTop, + width: pEnd - pStart, + height: sEnd - sStart + } + }) + } + } + // column + return { + primaryOf: (r) => r.offsetTop, + secondaryOf: (r) => r.offsetLeft, + primarySizeOf: (r) => r.height, + secondarySizeOf: (r) => r.width, + containerPrimaryLength: (ctx) => ctx.containerRect.height, + containerSecondaryLength: (ctx) => ctx.containerRect.width, + scrollPrimary: (ctx) => ctx.scrollTop, + scrollSecondary: (ctx) => ctx.scrollLeft, + toZone: (ctx, position, pStart, pEnd, sStart, sEnd) => ({ + containerId: ctx.containerId, + position, + layout: 'grid', + rect: { + x: sStart + ctx.containerRect.left - ctx.scrollLeft, + y: pStart + ctx.containerRect.top - ctx.scrollTop, + width: sEnd - sStart, + height: pEnd - pStart + } + }) + } +} + +/** + * Group rects into tracks along the secondary axis. A track is a set of rects + * whose secondary position overlaps (same row in flow=row, same column in flow=column). + */ +function groupByTrack(rects: SlotLayoutRect[], ax: AxisMapping): SlotLayoutRect[][] { + if (rects.length === 0) return [] + const groups: SlotLayoutRect[][] = [] + let current: SlotLayoutRect[] = [rects[0]] + let trackStart = ax.secondaryOf(rects[0]) + + for (let i = 1; i < rects.length; i++) { + const r = rects[i] + if (Math.abs(ax.secondaryOf(r) - trackStart) < ax.secondarySizeOf(r) * 0.5) { + current.push(r) + } else { + groups.push(current) + current = [r] + trackStart = ax.secondaryOf(r) + } + } + groups.push(current) + return groups +} diff --git a/packages/svelte-dnd/src/lib/core/zones/geometry-registry.ts b/packages/svelte-dnd/src/lib/core/zones/geometry-registry.ts new file mode 100644 index 0000000..cc40ca5 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/zones/geometry-registry.ts @@ -0,0 +1,15 @@ +import type { DndLayout } from '../../types.js' +import type { ZoneGeometry } from './zone-geometry.js' +import { AxisZoneGeometry } from './geometries/axis-zone-geometry.js' +import { GridZoneGeometry, type GridFlow } from './geometries/grid-zone-geometry.js' + +const VERTICAL_GEOMETRY: ZoneGeometry = new AxisZoneGeometry('vertical') +const HORIZONTAL_GEOMETRY: ZoneGeometry = new AxisZoneGeometry('horizontal') +const GRID_ROW_GEOMETRY: ZoneGeometry = new GridZoneGeometry('row') +const GRID_COLUMN_GEOMETRY: ZoneGeometry = new GridZoneGeometry('column') + +export function pickGeometry(layout: DndLayout, flow: GridFlow = 'row'): ZoneGeometry { + if (layout === 'grid') return flow === 'column' ? GRID_COLUMN_GEOMETRY : GRID_ROW_GEOMETRY + if (layout === 'horizontal') return HORIZONTAL_GEOMETRY + return VERTICAL_GEOMETRY +} diff --git a/packages/svelte-dnd/src/lib/core/zones/layout-snapshot.ts b/packages/svelte-dnd/src/lib/core/zones/layout-snapshot.ts new file mode 100644 index 0000000..5bfbae1 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/zones/layout-snapshot.ts @@ -0,0 +1,78 @@ +import type { Droppable } from '../entities/droppable.svelte.js' + +/** + * Snapshot of a slot's layout geometry in container content-space. + * Coordinates are relative to the container's scroll content, so they stay + * valid under container scroll. Captured at drag start before any CSS transforms + * exist, so transforms applied during drag do not feed back into calculations. + */ +export interface SlotLayoutRect { + slotId: string + position: number + offsetLeft: number + offsetTop: number + width: number + height: number +} + +export interface LayoutSnapshot { + containerId: string + rects: SlotLayoutRect[] + draggedIndex: number +} + +/** + * Capture the current layout of a droppable's slots. Must be called at drag start, + * before any translations are applied. Uses the slot wrapper element (not the inner + * draggable) because the wrapper never receives transforms — its rect is always + * the true layout rect. + */ +export function captureLayoutSnapshot( + droppable: Droppable, + draggedId: string | null +): LayoutSnapshot { + const container = droppable.element + const containerRect = container.getBoundingClientRect() + const scrollLeft = container.scrollLeft + const scrollTop = container.scrollTop + + const slots = droppable.getSortedSlots() + const rects: SlotLayoutRect[] = [] + let draggedIndex = -1 + + for (const slot of slots) { + const r = slot.element.getBoundingClientRect() + rects.push({ + slotId: slot.draggable.id, + position: slot.position, + offsetLeft: r.left - containerRect.left + scrollLeft, + offsetTop: r.top - containerRect.top + scrollTop, + width: r.width, + height: r.height + }) + if (slot.draggable.id === draggedId) { + draggedIndex = rects.length - 1 + } + } + + return { containerId: droppable.id, rects, draggedIndex } +} + +/** + * Project a content-space rect back to viewport coordinates using the current + * container rect and scroll offset. Used when building DropZones, which need + * viewport rects for pointer-collision. + */ +export function toViewportRect( + rect: SlotLayoutRect, + containerRect: DOMRect, + scrollLeft: number, + scrollTop: number +): { x: number; y: number; width: number; height: number } { + return { + x: rect.offsetLeft + containerRect.left - scrollLeft, + y: rect.offsetTop + containerRect.top - scrollTop, + width: rect.width, + height: rect.height + } +} diff --git a/packages/svelte-dnd/src/lib/core/zones/sortable-source.ts b/packages/svelte-dnd/src/lib/core/zones/sortable-source.ts new file mode 100644 index 0000000..5ab6860 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/zones/sortable-source.ts @@ -0,0 +1,133 @@ +import type { Droppable } from '../entities/droppable.svelte.js' +import type { SlotLayoutRect, LayoutSnapshot } from './layout-snapshot.js' +import type { ZoneGeometryContext } from './zone-geometry.js' + +/** + * Geometry source for a sortable container. Hides the difference between a + * DOM-driven layout (every slot mounted) and a virtualized layout (only a + * window of slots mounted at a time) behind a uniform contract. + * + * SortableContainerStrategy reads `visibleRects` to build drop zones and + * `mountedSlots` to drive translation shifts; both implementations decide what + * "visible" / "mounted" means in their world. + */ +export interface SortableSource { + readonly containerId: string + /** Index of the dragged item in the full source list, or -1 when the drag + * came from a different container. Drives the splice-position correction. */ + readonly draggedIndex: number + /** Rects whose viewport projection intersects the container rect, sorted by + * position and excluding the dragged slot. The zone geometry consumes this. */ + visibleRects(ctx: ZoneGeometryContext): SlotLayoutRect[] + /** Slot ids + positions for items currently animated as displaced neighbours. + * Translations only ever touch mounted DOM elements. */ + mountedSlots(): { id: string; position: number }[] +} + +/** + * Source backed by a one-shot DOM snapshot taken at drag start. All rects are + * known up front — `visibleRects` just clips the snapshot to the live viewport, + * `mountedSlots` returns every slot in the snapshot. + */ +export class DomSortableSource implements SortableSource { + constructor( + readonly snapshot: LayoutSnapshot, + private draggedId: string | null + ) {} + + get containerId(): string { + return this.snapshot.containerId + } + + get draggedIndex(): number { + return this.snapshot.draggedIndex + } + + visibleRects(ctx: ZoneGeometryContext): SlotLayoutRect[] { + const { containerRect, scrollLeft, scrollTop } = ctx + return this.snapshot.rects.filter((r) => { + if (r.slotId === this.draggedId) return false + const vy = r.offsetTop + containerRect.top - scrollTop + const vx = r.offsetLeft + containerRect.left - scrollLeft + return ( + vy + r.height > containerRect.top && + vy < containerRect.bottom && + vx + r.width > containerRect.left && + vx < containerRect.right + ) + }) + } + + mountedSlots(): { id: string; position: number }[] { + return this.snapshot.rects.map((r) => ({ id: r.slotId, position: r.position })) + } +} + +/** + * User-supplied virtualization hooks. All fields are optional today — the MVP + * only uses `itemCount` for bounds checks. `getOffset` / `getSize` are part of + * the contract for future features (drop into not-yet-mounted gaps, virtual + * preview rendering) and can be wired up by callers without affecting current + * behavior. + */ +export interface VirtualSource { + itemCount?: () => number + getOffset?: (index: number) => number + getSize?: (index: number) => number +} + +/** + * Source for virtualized sortable lists. Geometry is read live from each + * mounted slot's bounding rect — that lets us work alongside any virtualizer + * (virtua, tanstack/virtual, …) without knowing where its scroll container + * lives, since the rect is already in viewport coordinates. Slots not mounted + * by the virtualizer simply don't contribute zones or translations. + */ +export class VirtualSortableSource implements SortableSource { + constructor( + readonly containerId: string, + readonly draggedIndex: number, + private droppable: Droppable, + private virtual: VirtualSource, + private draggedId: string | null + ) {} + + visibleRects(ctx: ZoneGeometryContext): SlotLayoutRect[] { + const { containerRect } = ctx + const slots = this.droppable.getSortedSlots() + const itemCount = this.virtual.itemCount?.() + const out: SlotLayoutRect[] = [] + for (const slot of slots) { + if (slot.draggable.id === this.draggedId) continue + const position = slot.position + if (itemCount !== undefined && (position < 0 || position >= itemCount)) continue + if (!slot.element) continue + const r = slot.element.getBoundingClientRect() + if ( + r.bottom <= containerRect.top || + r.top >= containerRect.bottom || + r.right <= containerRect.left || + r.left >= containerRect.right + ) + continue + out.push({ + slotId: slot.draggable.id, + position, + offsetLeft: r.left - containerRect.left, + offsetTop: r.top - containerRect.top, + width: r.width, + height: r.height + }) + } + out.sort((a, b) => a.position - b.position) + return out + } + + mountedSlots(): { id: string; position: number }[] { + const out: { id: string; position: number }[] = [] + for (const slot of this.droppable.getSortedSlots()) { + out.push({ id: slot.draggable.id, position: slot.position }) + } + return out + } +} diff --git a/packages/svelte-dnd/src/lib/core/zones/translation-engine.svelte.ts b/packages/svelte-dnd/src/lib/core/zones/translation-engine.svelte.ts new file mode 100644 index 0000000..a6263b5 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/zones/translation-engine.svelte.ts @@ -0,0 +1,69 @@ +import type { DndState } from '../dnd/dnd-state.svelte.js' +import type { Droppable } from '../entities/droppable.svelte.js' + +/** + * Computes CSS translate offsets for draggable items during an active drag. + * Item translations delegate to ContainerStrategy.getTranslations(). + * + * Cross-container overflow is handled by dropTargetPadding: the target DndDroppable + * adds margin-bottom/margin-right equal to the effective slot size, so the container + * actually grows in layout flow and the browser naturally pushes subsequent siblings down. + */ +export class TranslationEngine { + constructor( + private state: DndState, + private droppables: Map + ) {} + + translations = $derived.by((): Map => { + const map = new Map() + + if (!this.state.dragging || !this.state.session) return map + + const session = this.state.session + + for (const droppable of this.droppables.values()) { + const containerTranslations = droppable.strategy.getTranslations(droppable, session) + for (const [itemId, offset] of containerTranslations) { + map.set(itemId, offset) + } + } + + return map + }) + + /** + * Extra margin to add to the cross-container drop target so it grows in layout flow, + * preventing translated items from visually overflowing into siblings below. + * Only set for cross-container drags; null otherwise. + */ + dropTargetPadding = $derived.by((): { containerId: string; x: number; y: number } | null => { + if (!this.state.dragging || !this.state.session) return null + + const session = this.state.session + const preview = session.dropPreview + if (!preview) return null + if (session.originContainerId === preview.containerId) return null + + let targetDroppable: Droppable | undefined + for (const d of this.droppables.values()) { + if (d.id === preview.containerId) { + targetDroppable = d + break + } + } + if (!targetDroppable) return null + + const slotSize = session.slotSize + const layout = targetDroppable.layout + const size = layout === 'horizontal' ? (slotSize?.width ?? 0) : (slotSize?.height ?? 0) + const ghostSize = session.ghostSize + const elementSize = layout === 'horizontal' ? ghostSize.width : ghostSize.height + const effectiveSize = size || elementSize + if (effectiveSize === 0) return null + + return layout === 'horizontal' + ? { containerId: preview.containerId, x: effectiveSize, y: 0 } + : { containerId: preview.containerId, x: 0, y: effectiveSize } + }) +} diff --git a/packages/svelte-dnd/src/lib/core/zones/zone-geometry.ts b/packages/svelte-dnd/src/lib/core/zones/zone-geometry.ts new file mode 100644 index 0000000..fcb89c0 --- /dev/null +++ b/packages/svelte-dnd/src/lib/core/zones/zone-geometry.ts @@ -0,0 +1,30 @@ +import type { DropZone } from '../../types.js' +import type { SlotLayoutRect } from './layout-snapshot.js' + +export interface ZoneGeometryContext { + containerId: string + containerRect: DOMRect + scrollLeft: number + scrollTop: number + /** + * Index of the dragged item inside the captured snapshot, or -1 when the + * drag originated in a different container. Geometries use it to translate + * a slot's full-array `position` into its `splice`-target position in the + * array-without-dragged that drop handlers consume. + */ + draggedIndex: number +} + +/** + * Builds drop zones from a layout snapshot. A zone geometry only decides + * the SHAPE of collision rects for each insertion position — drop logic + * (translations, splice math) is direction-agnostic and lives elsewhere. + * + * Adding a new layout (masonry, square, …) means adding a new implementation, + * not touching SortableContainerStrategy. + */ +export interface ZoneGeometry { + buildZones(visibleRects: SlotLayoutRect[], context: ZoneGeometryContext): DropZone[] + + buildEmptyZone(context: ZoneGeometryContext): DropZone +} diff --git a/packages/svelte-dnd/src/lib/index.ts b/packages/svelte-dnd/src/lib/index.ts new file mode 100644 index 0000000..3134c6d --- /dev/null +++ b/packages/svelte-dnd/src/lib/index.ts @@ -0,0 +1,92 @@ +export { default as DndProvider } from './components/DndProvider.svelte' +export { default as DndDraggable } from './components/DndDraggable.svelte' +export { default as DndDroppable } from './components/DndDroppable.svelte' +export { default as DndPreview } from './components/DndPreview.svelte' +export { DndController } from './core/dnd/dnd-controller.svelte.js' +export type { DndControllerConfig } from './core/dnd/dnd-controller.svelte.js' +export type { + AnimationConfig, + ResolvedAnimationConfig, + Transition, + DelayedTransition, + DurationOrTransition +} from './core/animation/animation-config.js' +export { parseEasing } from './core/animation/easing.js' +export type { Behavior, BehaviorContext, AutoScrollConfig } from './core/animation/behavior.js' +export { autoScroll } from './core/animation/behaviors/auto-scroll.js' +export { scrollSync, type ScrollSyncOptions } from './core/animation/behaviors/scroll-sync.js' +export type { + DropZone, + DropPreview, + DndLayout, + DndMode, + DndItemInfo, + DndContainerInfo, + DragStartEvent, + DropEvent, + DragEndEvent, + DragOverEvent, + DropCancelledEvent, + DragStartCallback, + DragEndCallback, + DropCallback, + DragOverCallback, + DropCancelledCallback, + ZonesInvalidatedCallback, + GhostSnippet, + GhostSnippetProps, + Announcements +} from './types.js' +export { defaultAnnouncements } from './types.js' +export type { + AnimateItemOptions, + AnimateLayoutOptions, + ContainerPosition +} from './core/dnd/dnd-simulator.js' +export type { DragSource, DragSession } from './core/dnd/drag-session.svelte.js' +export type { DndState } from './core/dnd/dnd-state.svelte.js' +export type { Droppable } from './core/entities/droppable.svelte.js' +export type { + ContainerStrategy, + StrategyBindContext +} from './core/containers/strategies/container-strategy.js' +export { + SortableContainerStrategy, + sortable, + type SortableOptions +} from './core/containers/strategies/sortable-container-strategy.js' +export type { VirtualSource, SortableSource } from './core/zones/sortable-source.js' +export { + TargetContainerStrategy, + target, + type TargetOptions +} from './core/containers/strategies/target-container-strategy.js' +export type { GridFlow } from './core/zones/geometries/grid-zone-geometry.js' +export type { AnimationStep } from './core/animation/steps/animation-step.js' +export { InstantStep } from './core/animation/steps/animation-step.js' +export { GhostToTargetStep } from './core/animation/steps/ghost-to-target-step.js' +export { GhostReturnStep } from './core/animation/steps/ghost-return-step.js' +export type { + SensorDescriptor, + SensorActivation, + SensorCallbacks, + ActivationState, + ConditionResult, + StartCondition, + StartConditionInput, + NavigationDirection +} from './core/sensors/sensor.js' +export { PointerSensor, type PointerSensorOptions } from './core/sensors/pointer-sensor.js' +export { KeyboardSensor } from './core/sensors/keyboard-sensor.js' +export { Distance, Delay } from './core/sensors/activation-constraints.js' +export type { DistanceConfig, DelayConfig } from './core/sensors/activation-constraints.js' +export type { CollisionAlgorithm, CollisionContext } from './core/collision/collision-algorithm.js' +export type { Modifier, ModifierContext } from './core/modifiers/modifier.js' +export { restrictToVerticalAxis } from './core/modifiers/restrict-to-vertical-axis.js' +export { restrictToHorizontalAxis } from './core/modifiers/restrict-to-horizontal-axis.js' +export { restrictToContainer } from './core/modifiers/restrict-to-container.js' +export { snapToGrid } from './core/modifiers/snap-to-grid.js' +export { centerPoint } from './core/collision/center-point.js' +export { cursorOver } from './core/collision/cursor-over.js' +export { overlap } from './core/collision/overlap.js' +export { closestCenter } from './core/collision/closest-center.js' diff --git a/packages/svelte-dnd/src/lib/types.ts b/packages/svelte-dnd/src/lib/types.ts new file mode 100644 index 0000000..1800737 --- /dev/null +++ b/packages/svelte-dnd/src/lib/types.ts @@ -0,0 +1,113 @@ +import type { Snippet } from 'svelte' + +export type DndLayout = 'vertical' | 'horizontal' | 'grid' + +// --- Rich event types --- + +export interface DndItemInfo { + id: string + data: Record | undefined + type: string | undefined + element: HTMLElement +} + +export interface DndContainerInfo { + id: string + data: Record | undefined + layout: DndLayout + mode: DndMode + disabled: boolean + accepts: string | string[] | undefined + /** + * Insertion index within the container. + * `0` = before all items, `items.length` = after all items. + * On `source`, it's the item's original index; on `target`/`current`, it's where the + * item would be inserted. Not an index into your data array — always the slot between items. + */ + position: number +} + +export interface DragStartEvent { + item: DndItemInfo + source: DndContainerInfo +} + +export interface DropEvent { + item: DndItemInfo + source: DndContainerInfo + target: DndContainerInfo +} + +export interface DragEndEvent { + item: DndItemInfo + source: DndContainerInfo + target: DndContainerInfo | null + cancelled: boolean +} + +export interface DragOverEvent { + item: DndItemInfo + source: DndContainerInfo + current: DndContainerInfo + previous: DndContainerInfo | null +} + +export interface DropCancelledEvent { + item: DndItemInfo + source: DndContainerInfo +} + +// --- Callback types --- + +export type DragStartCallback = (event: DragStartEvent) => void +export type DragEndCallback = (event: DragEndEvent) => void +export type DropCallback = (event: DropEvent) => void +export type DragOverCallback = (event: DragOverEvent) => void +export type DropCancelledCallback = (event: DropCancelledEvent) => void +export type ZonesInvalidatedCallback = () => void + +// --- Announcements --- + +export interface Announcements { + onDragStart?: (event: DragStartEvent) => string + onDragOver?: (event: DragOverEvent) => string + onDrop?: (event: DropEvent) => string + onCancel?: (event: DropCancelledEvent) => string +} + +export const defaultAnnouncements: Announcements = { + onDragStart: ({ item }) => `Started dragging item ${item.id}.`, + onDragOver: ({ item, current }) => + `Item ${item.id} is over ${current.id} at position ${current.position}.`, + onDrop: ({ item, target }) => + `Dropped item ${item.id} into ${target.id} at position ${target.position}.`, + onCancel: ({ item }) => `Dragging ${item.id} was cancelled.` +} + +export type DndMode = 'sortable' | 'target' | (string & {}) + +export interface DropZone { + containerId: string + position: number + layout: DndLayout + itemId?: string + rect: { + x: number + y: number + width: number + height: number + } +} + +export interface DropPreview { + containerId: string + position: number +} + +export interface GhostSnippetProps { + element: HTMLElement + data?: Record + itemId: string +} + +export type GhostSnippet = Snippet<[GhostSnippetProps]> diff --git a/packages/svelte-dnd/svelte.config.js b/packages/svelte-dnd/svelte.config.js new file mode 100644 index 0000000..68d13ed --- /dev/null +++ b/packages/svelte-dnd/svelte.config.js @@ -0,0 +1,17 @@ +import adapter from '@sveltejs/adapter-auto' + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter() + }, + vitePlugin: { + dynamicCompileOptions: ({ filename }) => + filename.includes('node_modules') ? undefined : { runes: true } + } +} + +export default config diff --git a/packages/svelte-dnd/tests/animation/animation-config.test.ts b/packages/svelte-dnd/tests/animation/animation-config.test.ts new file mode 100644 index 0000000..5ff13a1 --- /dev/null +++ b/packages/svelte-dnd/tests/animation/animation-config.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest' +import { + resolveAnimationConfig, + DEFAULT_ANIMATION_CONFIG +} from '../../src/lib/core/animation/animation-config.js' + +describe('resolveAnimationConfig', () => { + it('returns all defaults when no config is provided', () => { + expect(resolveAnimationConfig()).toEqual(DEFAULT_ANIMATION_CONFIG) + }) + + it('returns all defaults when an empty config is provided', () => { + expect(resolveAnimationConfig({})).toEqual(DEFAULT_ANIMATION_CONFIG) + }) + + it('shorthand number is normalised to a Transition with default easing', () => { + const resolved = resolveAnimationConfig({ drop: 100, layout: 500 }) + expect(resolved.drop.duration).toBe(100) + expect(resolved.drop.easing).toBe(DEFAULT_ANIMATION_CONFIG.drop.easing) + expect(resolved.layout.duration).toBe(500) + expect(resolved.layout.easing).toBe(DEFAULT_ANIMATION_CONFIG.layout.easing) + expect(resolved.return).toEqual(DEFAULT_ANIMATION_CONFIG.return) + expect(resolved.slotCollapse).toEqual(DEFAULT_ANIMATION_CONFIG.slotCollapse) + }) + + it('object form lets you set both duration and easing', () => { + const resolved = resolveAnimationConfig({ + drop: { duration: 400, easing: 'linear' } + }) + expect(resolved.drop).toEqual({ duration: 400, easing: 'linear' }) + }) + + it('object form keeps default easing when only duration is provided', () => { + const resolved = resolveAnimationConfig({ + drop: { duration: 400 } + }) + expect(resolved.drop.duration).toBe(400) + expect(resolved.drop.easing).toBe(DEFAULT_ANIMATION_CONFIG.drop.easing) + }) + + it('treats 0 as an explicit value, not a falsy fallback', () => { + const resolved = resolveAnimationConfig({ drop: 0 }) + expect(resolved.drop.duration).toBe(0) + }) + + it('preview.show accepts delay alongside duration / easing', () => { + const resolved = resolveAnimationConfig({ + preview: { show: { delay: 50, duration: 300, easing: 'linear' } } + }) + expect(resolved.preview.show).toEqual({ delay: 50, duration: 300, easing: 'linear' }) + }) + + it('preview.hide.delay overrides only delay; duration and easing keep defaults', () => { + const resolved = resolveAnimationConfig({ + preview: { + hide: { delay: 500, duration: DEFAULT_ANIMATION_CONFIG.preview.hide.duration } + } + }) + expect(resolved.preview.hide.delay).toBe(500) + expect(resolved.preview.hide.duration).toBe(DEFAULT_ANIMATION_CONFIG.preview.hide.duration) + expect(resolved.preview.hide.easing).toBe(DEFAULT_ANIMATION_CONFIG.preview.hide.easing) + }) + + it('preview show is independent of hide', () => { + const resolved = resolveAnimationConfig({ + preview: { + show: { delay: 100, duration: DEFAULT_ANIMATION_CONFIG.preview.show.duration } + } + }) + expect(resolved.preview.show.delay).toBe(100) + expect(resolved.preview.hide).toEqual(DEFAULT_ANIMATION_CONFIG.preview.hide) + }) + + it('deep-merges Transition fields preserving the unspecified half', () => { + const resolved = resolveAnimationConfig({ + siblingShift: { duration: 350 } + }) + expect(resolved.siblingShift.duration).toBe(350) + expect(resolved.siblingShift.easing).toBe(DEFAULT_ANIMATION_CONFIG.siblingShift.easing) + }) + + it('uses a custom base when provided (partial patch on top of current state)', () => { + const current = resolveAnimationConfig({ + drop: { duration: 999, easing: 'linear' }, + slotCollapse: 555 + }) + const patched = resolveAnimationConfig({ drop: 100 }, current) + expect(patched.drop.duration).toBe(100) + expect(patched.drop.easing).toBe('linear') // preserved from current base + expect(patched.slotCollapse.duration).toBe(555) + }) +}) diff --git a/packages/svelte-dnd/tests/animation/direction-adapter.test.ts b/packages/svelte-dnd/tests/animation/direction-adapter.test.ts new file mode 100644 index 0000000..9d39520 --- /dev/null +++ b/packages/svelte-dnd/tests/animation/direction-adapter.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' +import { getDirectionAdapter } from '../../src/lib/core/animation/direction-adapter.js' +import { setRect, makeElement } from '../helpers/dom.js' + +describe('getDirectionAdapter', () => { + it('reads/writes scrollTop and uses rect.top/.height for vertical', () => { + const adapter = getDirectionAdapter('vertical') + const container = makeElement() + Object.defineProperty(container, 'scrollTop', { + configurable: true, + writable: true, + value: 30 + }) + Object.defineProperty(container, 'scrollLeft', { + configurable: true, + writable: true, + value: 0 + }) + + expect(adapter.getScroll(container)).toBe(30) + adapter.setScroll(container, 100) + expect(container.scrollTop).toBe(100) + expect(container.scrollLeft).toBe(0) + + const rect = makeElement() + setRect(rect, { x: 5, y: 50, width: 200, height: 100 }) + const r = rect.getBoundingClientRect() + expect(adapter.getPosition(r)).toBe(50) + expect(adapter.getSize(r)).toBe(100) + expect(adapter.getEndPosition(r, 100)).toBe(150) + }) + + it('reads/writes scrollLeft and uses rect.left/.width for horizontal', () => { + const adapter = getDirectionAdapter('horizontal') + const container = makeElement() + Object.defineProperty(container, 'scrollTop', { + configurable: true, + writable: true, + value: 0 + }) + Object.defineProperty(container, 'scrollLeft', { + configurable: true, + writable: true, + value: 25 + }) + + expect(adapter.getScroll(container)).toBe(25) + adapter.setScroll(container, 80) + expect(container.scrollLeft).toBe(80) + expect(container.scrollTop).toBe(0) + + const rect = makeElement() + setRect(rect, { x: 30, y: 0, width: 150, height: 50 }) + const r = rect.getBoundingClientRect() + expect(adapter.getPosition(r)).toBe(30) + expect(adapter.getSize(r)).toBe(150) + expect(adapter.getEndPosition(r, 150)).toBe(180) + }) +}) diff --git a/packages/svelte-dnd/tests/animation/easing.test.ts b/packages/svelte-dnd/tests/animation/easing.test.ts new file mode 100644 index 0000000..fde102d --- /dev/null +++ b/packages/svelte-dnd/tests/animation/easing.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi } from 'vitest' +import { parseEasing } from '../../src/lib/core/animation/easing.js' + +describe('parseEasing', () => { + it('linear is identity', () => { + const ease = parseEasing('linear') + expect(ease(0)).toBe(0) + expect(ease(0.5)).toBe(0.5) + expect(ease(1)).toBe(1) + }) + + it('endpoints are exact for any keyword', () => { + for (const k of ['ease', 'ease-in', 'ease-out', 'ease-in-out']) { + const ease = parseEasing(k) + expect(ease(0)).toBeCloseTo(0, 5) + expect(ease(1)).toBeCloseTo(1, 5) + } + }) + + it('ease-out decelerates (mid-point above linear)', () => { + const ease = parseEasing('ease-out') + expect(ease(0.5)).toBeGreaterThan(0.5) + }) + + it('ease-in accelerates (mid-point below linear)', () => { + const ease = parseEasing('ease-in') + expect(ease(0.5)).toBeLessThan(0.5) + }) + + it('cubic-bezier(...) parses arbitrary control points', () => { + const ease = parseEasing('cubic-bezier(0.25, 0.1, 0.25, 1)') + expect(ease(0)).toBeCloseTo(0, 5) + expect(ease(1)).toBeCloseTo(1, 5) + expect(ease(0.5)).toBeGreaterThan(0) + expect(ease(0.5)).toBeLessThan(1) + }) + + it('cubic-bezier handles whitespace and casing', () => { + const a = parseEasing('CUBIC-BEZIER( 0.25,0.1, 0.25, 1 )') + const b = parseEasing('cubic-bezier(0.25, 0.1, 0.25, 1)') + expect(a(0.4)).toBeCloseTo(b(0.4), 4) + }) + + it('clamps inputs outside [0,1]', () => { + const ease = parseEasing('ease') + expect(ease(-1)).toBe(0) + expect(ease(2)).toBe(1) + }) + + it('falls back to ease-out and warns on invalid input', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const ease = parseEasing('not-a-real-easing') + expect(warn).toHaveBeenCalled() + expect(ease(0)).toBe(0) + expect(ease(1)).toBe(1) + expect(ease(0.5)).toBeGreaterThan(0.5) // ease-out shape + warn.mockRestore() + }) +}) diff --git a/packages/svelte-dnd/tests/animation/scroll-sync-calculator.test.ts b/packages/svelte-dnd/tests/animation/scroll-sync-calculator.test.ts new file mode 100644 index 0000000..a05c604 --- /dev/null +++ b/packages/svelte-dnd/tests/animation/scroll-sync-calculator.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'vitest' +import { ScrollSyncCalculator } from '../../src/lib/core/animation/scroll-sync-calculator.js' +import { setRect, makeElement, type FakeRect } from '../helpers/dom.js' +import { scrollableEl } from '../helpers/fixtures.js' + +const buildContainer = (rect: FakeRect, scrollTop = 0, scrollLeft = 0) => + scrollableEl(rect, { left: scrollLeft, top: scrollTop }) + +describe('ScrollSyncCalculator.calculateAdaptiveDuration', () => { + const calc = new ScrollSyncCalculator() + + it('clamps to the minimum duration for very short scrolls', () => { + expect(calc.calculateAdaptiveDuration(10)).toBe(400) + }) + + it('clamps to the maximum duration for very long scrolls', () => { + expect(calc.calculateAdaptiveDuration(100_000)).toBe(1500) + }) + + it('scales linearly with distance between the bounds', () => { + // 1800 px/sec → 900px takes 500ms → between min(400) and max(1500), so use 500. + expect(calc.calculateAdaptiveDuration(900)).toBe(500) + }) +}) + +describe('ScrollSyncCalculator.calculateScrollTarget', () => { + const calc = new ScrollSyncCalculator() + + it('returns no scroll change when the preview is already inside the viewport (vertical)', () => { + const container = buildContainer({ x: 0, y: 0, width: 200, height: 400 }, 50) + const preview = makeElement() + setRect(preview, { x: 0, y: 100, width: 100, height: 100 }) + + const result = calc.calculateScrollTarget({ + preview, + container, + expectedSize: 100, + direction: 'vertical' + }) + expect(result.scrollDelta).toBe(0) + }) + + it('scrolls up when the preview sits above the viewport (vertical)', () => { + const container = buildContainer({ x: 0, y: 100, width: 200, height: 400 }, 200) + const preview = makeElement() + // preview top = 50 < container top 100 → overflow = 50 + setRect(preview, { x: 0, y: 50, width: 100, height: 100 }) + + const result = calc.calculateScrollTarget({ + preview, + container, + expectedSize: 100, + direction: 'vertical' + }) + expect(result.scrollDelta).toBe(-50) + expect(result.targetScroll).toBe(150) + }) + + it('scrolls down when the preview hangs below the viewport (vertical)', () => { + const container = buildContainer({ x: 0, y: 0, width: 200, height: 200 }, 0) + const preview = makeElement() + // preview bottom = 250 > container bottom 200 → overflow = 50 + setRect(preview, { x: 0, y: 150, width: 100, height: 100 }) + + const result = calc.calculateScrollTarget({ + preview, + container, + expectedSize: 100, + direction: 'vertical' + }) + expect(result.scrollDelta).toBe(50) + expect(result.targetScroll).toBe(50) + }) + + it('respects the padding option, scrolling extra to keep a gap from the edge', () => { + const container = buildContainer({ x: 0, y: 0, width: 200, height: 200 }, 0) + const preview = makeElement() + // preview bottom = 200 sits exactly on the container edge — without + // padding scrollDelta would be 0; with padding 16 we should scroll 16 more. + setRect(preview, { x: 0, y: 100, width: 100, height: 100 }) + + const result = calc.calculateScrollTarget({ + preview, + container, + expectedSize: 100, + direction: 'vertical', + padding: 16 + }) + expect(result.scrollDelta).toBe(16) + expect(result.targetScroll).toBe(16) + }) + + it('clamps the target scroll position to zero (cannot scroll past the start)', () => { + const container = buildContainer({ x: 0, y: 0, width: 200, height: 200 }, 30) + const preview = makeElement() + setRect(preview, { x: 0, y: -100, width: 100, height: 100 }) + + const result = calc.calculateScrollTarget({ + preview, + container, + expectedSize: 100, + direction: 'vertical' + }) + expect(result.targetScroll).toBe(0) + }) +}) + +describe('ScrollSyncCalculator.calculateFinalGhostPosition', () => { + const calc = new ScrollSyncCalculator() + + it('subtracts scrollDelta from y for vertical direction', () => { + const result = calc.calculateFinalGhostPosition({ + previewRect: { left: 100, top: 200 } as DOMRect, + scrollDelta: 30, + direction: 'vertical' + }) + expect(result).toEqual({ x: 100, y: 170 }) + }) + + it('subtracts scrollDelta from x for horizontal direction', () => { + const result = calc.calculateFinalGhostPosition({ + previewRect: { left: 100, top: 200 } as DOMRect, + scrollDelta: 30, + direction: 'horizontal' + }) + expect(result).toEqual({ x: 70, y: 200 }) + }) +}) diff --git a/packages/svelte-dnd/tests/collision/collision.test.ts b/packages/svelte-dnd/tests/collision/collision.test.ts new file mode 100644 index 0000000..2b03a17 --- /dev/null +++ b/packages/svelte-dnd/tests/collision/collision.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from 'vitest' +import { centerPoint } from '../../src/lib/core/collision/center-point.js' +import { cursorOver } from '../../src/lib/core/collision/cursor-over.js' +import { overlap } from '../../src/lib/core/collision/overlap.js' +import { closestCenter } from '../../src/lib/core/collision/closest-center.js' +import type { DropZone } from '../../src/lib/types.js' +import type { CollisionContext } from '../../src/lib/core/collision/collision-algorithm.js' + +const zone = ( + containerId: string, + position: number, + x: number, + y: number, + w: number, + h: number +): DropZone => ({ + containerId, + position, + layout: 'vertical', + rect: { x, y, width: w, height: h } +}) + +const ctx = ( + zones: DropZone[], + pointer: { x: number; y: number }, + ghost: { x: number; y: number; width: number; height: number } +): CollisionContext => ({ zones, pointer, ghost }) + +describe('centerPoint', () => { + const zones = [zone('a', 0, 0, 0, 100, 50), zone('a', 1, 0, 50, 100, 50)] + + it('returns the zone whose rect contains the ghost center', () => { + // ghost (10,10) 20x20 → center (20,20) — inside first zone + const hit = centerPoint(ctx(zones, { x: 0, y: 0 }, { x: 10, y: 10, width: 20, height: 20 })) + expect(hit).toBe(zones[0]) + }) + + it('moves to the next zone when the center crosses the boundary', () => { + // center at (50, 60) — inside second zone + const hit = centerPoint(ctx(zones, { x: 0, y: 0 }, { x: 40, y: 50, width: 20, height: 20 })) + expect(hit).toBe(zones[1]) + }) + + it('returns null when no zone contains the center', () => { + const hit = centerPoint( + ctx(zones, { x: 0, y: 0 }, { x: 200, y: 200, width: 10, height: 10 }) + ) + expect(hit).toBeNull() + }) + + it('counts touching the right/bottom edge as inside (≤ comparison)', () => { + // center exactly on (100, 50) — at the bottom-right corner of zones[0] + const hit = centerPoint(ctx(zones, { x: 0, y: 0 }, { x: 90, y: 40, width: 20, height: 20 })) + expect(hit).toBe(zones[0]) + }) + + it('ignores pointer position entirely (only ghost rect matters)', () => { + const hit = centerPoint( + ctx(zones, { x: 999, y: 999 }, { x: 10, y: 10, width: 20, height: 20 }) + ) + expect(hit).toBe(zones[0]) + }) +}) + +describe('cursorOver', () => { + const zones = [zone('a', 0, 0, 0, 100, 50), zone('a', 1, 0, 50, 100, 50)] + + it('returns the zone whose rect contains the pointer', () => { + const hit = cursorOver(ctx(zones, { x: 30, y: 20 }, { x: 0, y: 0, width: 0, height: 0 })) + expect(hit).toBe(zones[0]) + }) + + it('returns null when the pointer is outside all zones', () => { + const hit = cursorOver(ctx(zones, { x: 200, y: 200 }, { x: 0, y: 0, width: 0, height: 0 })) + expect(hit).toBeNull() + }) + + it('ignores ghost size entirely', () => { + // Ghost would overlap zones[0] but cursor is in zones[1] + const hit = cursorOver(ctx(zones, { x: 30, y: 80 }, { x: 0, y: 0, width: 100, height: 50 })) + expect(hit).toBe(zones[1]) + }) +}) + +describe('overlap', () => { + const zones = [zone('a', 0, 0, 0, 100, 100)] + + it('returns the zone when the ghost overlaps any amount above the threshold', () => { + const hit = overlap(0)(ctx(zones, { x: 0, y: 0 }, { x: 50, y: 50, width: 60, height: 60 })) + expect(hit).toBe(zones[0]) + }) + + it('returns null when the overlap is smaller than the pixel threshold on either axis', () => { + // 5px overlap on both axes — threshold of 10 rejects + const hit = overlap(10)(ctx(zones, { x: 0, y: 0 }, { x: 95, y: 95, width: 60, height: 60 })) + expect(hit).toBeNull() + }) + + it('respects a percentage threshold relative to min(ghost.width, ghost.height)', () => { + // 50% of min(40, 40) = 20px required. Ghost overlaps zone by 30px on both axes. + const hit = overlap('50%')( + ctx(zones, { x: 0, y: 0 }, { x: 70, y: 70, width: 40, height: 40 }) + ) + expect(hit).toBe(zones[0]) + }) + + it('rejects when percentage threshold is not met on one axis', () => { + // 50% of min(40, 40) = 20px. Ghost overlaps 25px horizontally but only 5px vertically. + const hit = overlap('50%')( + ctx(zones, { x: 0, y: 0 }, { x: 75, y: 95, width: 40, height: 40 }) + ) + expect(hit).toBeNull() + }) + + it('returns the first zone hit when several zones qualify', () => { + const multi = [zone('a', 0, 0, 0, 100, 100), zone('a', 1, 0, 0, 200, 200)] + const hit = overlap(0)(ctx(multi, { x: 0, y: 0 }, { x: 10, y: 10, width: 50, height: 50 })) + expect(hit).toBe(multi[0]) + }) + + it('returns null when there are no zones', () => { + expect( + overlap(0)(ctx([], { x: 0, y: 0 }, { x: 0, y: 0, width: 50, height: 50 })) + ).toBeNull() + }) +}) + +describe('closestCenter', () => { + it('picks the zone whose center is nearest to the ghost center', () => { + const zones = [ + zone('a', 0, 0, 0, 100, 100), // center 50,50 + zone('a', 1, 200, 0, 100, 100), // center 250,50 + zone('a', 2, 0, 200, 100, 100) // center 50,250 + ] + // Ghost center 60,60 → zones[0] is closest + const hit = closestCenter( + ctx(zones, { x: 0, y: 0 }, { x: 10, y: 10, width: 100, height: 100 }) + ) + expect(hit).toBe(zones[0]) + }) + + it('returns null on an empty zone list', () => { + const hit = closestCenter(ctx([], { x: 0, y: 0 }, { x: 0, y: 0, width: 10, height: 10 })) + expect(hit).toBeNull() + }) + + it('breaks ties by preferring the first equidistant zone in the list', () => { + const zones = [zone('a', 0, 0, 0, 100, 100), zone('a', 1, 200, 0, 100, 100)] + // Ghost center 150,50 — equidistant from both + const hit = closestCenter( + ctx(zones, { x: 0, y: 0 }, { x: 100, y: 0, width: 100, height: 100 }) + ) + expect(hit).toBe(zones[0]) + }) + + it('always returns a zone — even far ones — unlike point-in-rect algorithms', () => { + const zones = [zone('a', 0, 1000, 1000, 50, 50)] + const hit = closestCenter(ctx(zones, { x: 0, y: 0 }, { x: 0, y: 0, width: 10, height: 10 })) + expect(hit).toBe(zones[0]) + }) +}) diff --git a/packages/svelte-dnd/tests/entities/droppable-accepts.test.ts b/packages/svelte-dnd/tests/entities/droppable-accepts.test.ts new file mode 100644 index 0000000..bcaf9fa --- /dev/null +++ b/packages/svelte-dnd/tests/entities/droppable-accepts.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { Droppable } from '../../src/lib/core/entities/droppable.svelte.js' +import { sortable } from '../../src/lib/core/containers/strategies/sortable-container-strategy.js' +import { noopController } from '../helpers/fixtures.js' + +function makeDroppable(accepts?: string | string[]): Droppable { + return new Droppable({ id: 'd', strategy: sortable(), accepts }, noopController()) +} + +describe('Droppable.acceptsType', () => { + it('accepts every type when no filter is configured', () => { + const d = makeDroppable() + expect(d.acceptsType('task')).toBe(true) + expect(d.acceptsType('card')).toBe(true) + expect(d.acceptsType(undefined)).toBe(true) + }) + + it('accepts every untyped item even when a filter is set', () => { + const d = makeDroppable('task') + expect(d.acceptsType(undefined)).toBe(true) + }) + + it('matches a single string accept exactly', () => { + const d = makeDroppable('task') + expect(d.acceptsType('task')).toBe(true) + expect(d.acceptsType('card')).toBe(false) + }) + + it('matches any element of an array accept', () => { + const d = makeDroppable(['task', 'card']) + expect(d.acceptsType('task')).toBe(true) + expect(d.acceptsType('card')).toBe(true) + expect(d.acceptsType('column')).toBe(false) + }) +}) diff --git a/packages/svelte-dnd/tests/entities/slot.test.ts b/packages/svelte-dnd/tests/entities/slot.test.ts new file mode 100644 index 0000000..62cf95c --- /dev/null +++ b/packages/svelte-dnd/tests/entities/slot.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest' +import { Slot } from '../../src/lib/core/entities/slot.js' +import { makeElement, setRect } from '../helpers/dom.js' + +interface FakeDroppable { + getSlotAt(position: number): Slot | undefined + spacing?: number + layout: 'vertical' | 'horizontal' | 'grid' +} + +interface SlotFixture { + position: number + slotRect: { x: number; y: number; width: number; height: number } + draggableRect: { x: number; y: number; width: number; height: number } +} + +function buildSlot(droppable: FakeDroppable, fixture: SlotFixture): Slot { + const slot = new Slot(fixture.position) + slot.element = makeElement() + setRect(slot.element, fixture.slotRect) + + const draggableEl = makeElement() + setRect(draggableEl, fixture.draggableRect) + // Slot only reads .draggable.element offsetWidth/Height, so the minimal stub is enough. + slot.draggable = { element: draggableEl } as Slot['draggable'] + + slot.droppable = droppable as unknown as Slot['droppable'] + return slot +} + +describe('Slot.getSize', () => { + it('uses the next sibling rect when one exists (vertical step)', () => { + const slots = new Map() + const droppable: FakeDroppable = { + getSlotAt: (p) => slots.get(p), + layout: 'vertical' + } + + const slot0 = buildSlot(droppable, { + position: 0, + slotRect: { x: 0, y: 0, width: 100, height: 50 }, + draggableRect: { x: 0, y: 0, width: 100, height: 40 } + }) + const slot1 = buildSlot(droppable, { + position: 1, + slotRect: { x: 0, y: 60, width: 100, height: 50 }, + draggableRect: { x: 0, y: 60, width: 100, height: 40 } + }) + slots.set(0, slot0) + slots.set(1, slot1) + + expect(slot0.getSize()).toEqual({ width: 0, height: 60 }) + }) + + it('uses the previous sibling and the actual gap when there is no next slot (vertical)', () => { + const slots = new Map() + const droppable: FakeDroppable = { + getSlotAt: (p) => slots.get(p), + layout: 'vertical' + } + + const slot0 = buildSlot(droppable, { + position: 0, + slotRect: { x: 0, y: 0, width: 100, height: 50 }, + draggableRect: { x: 0, y: 0, width: 100, height: 40 } + }) + const slot1 = buildSlot(droppable, { + position: 1, + slotRect: { x: 0, y: 60, width: 100, height: 50 }, + draggableRect: { x: 0, y: 60, width: 100, height: 40 } + }) + slots.set(0, slot0) + slots.set(1, slot1) + + // gap = slot1.top(60) - (slot0.top(0) + slot0.draggable.offsetHeight(40)) = 20 + // height = slot1.draggable.offsetHeight(40) + max(0, 20) = 60 + expect(slot1.getSize()).toEqual({ width: 100, height: 60 }) + }) + + it('falls back to draggable size + spacing on a vertical-only solitary slot', () => { + const slots = new Map() + const droppable: FakeDroppable = { + getSlotAt: (p) => slots.get(p), + layout: 'vertical', + spacing: 12 + } + + const solo = buildSlot(droppable, { + position: 0, + slotRect: { x: 0, y: 0, width: 100, height: 40 }, + draggableRect: { x: 0, y: 0, width: 100, height: 40 } + }) + slots.set(0, solo) + + // width takes nothing from spacing in vertical layout; height adds spacing. + expect(solo.getSize()).toEqual({ width: 100, height: 52 }) + }) + + it('falls back to draggable size + spacing on a horizontal-only solitary slot', () => { + const slots = new Map() + const droppable: FakeDroppable = { + getSlotAt: (p) => slots.get(p), + layout: 'horizontal', + spacing: 8 + } + + const solo = buildSlot(droppable, { + position: 0, + slotRect: { x: 0, y: 0, width: 60, height: 60 }, + draggableRect: { x: 0, y: 0, width: 60, height: 60 } + }) + slots.set(0, solo) + + expect(solo.getSize()).toEqual({ width: 68, height: 60 }) + }) + + it('treats undefined spacing as zero on a solitary slot', () => { + const slots = new Map() + const droppable: FakeDroppable = { + getSlotAt: (p) => slots.get(p), + layout: 'vertical' + // spacing intentionally omitted + } + + const solo = buildSlot(droppable, { + position: 0, + slotRect: { x: 0, y: 0, width: 100, height: 40 }, + draggableRect: { x: 0, y: 0, width: 100, height: 40 } + }) + slots.set(0, solo) + + expect(solo.getSize()).toEqual({ width: 100, height: 40 }) + }) +}) diff --git a/packages/svelte-dnd/tests/helpers/dom.ts b/packages/svelte-dnd/tests/helpers/dom.ts new file mode 100644 index 0000000..9620bac --- /dev/null +++ b/packages/svelte-dnd/tests/helpers/dom.ts @@ -0,0 +1,37 @@ +/** + * jsdom does not run a real layout engine, so getBoundingClientRect / offsetWidth + * / offsetHeight return zeros. These helpers stamp deterministic geometry onto an + * element so layout-driven code paths (Slot.getSize, DropResolver, etc.) can be + * exercised in unit tests. + */ + +export interface FakeRect { + x: number + y: number + width: number + height: number +} + +export function setRect(el: HTMLElement, rect: FakeRect): void { + const { x, y, width, height } = rect + const domRect: DOMRect = { + x, + y, + width, + height, + top: y, + left: x, + right: x + width, + bottom: y + height, + toJSON() { + return { x, y, width, height, top: y, left: x, right: x + width, bottom: y + height } + } + } + el.getBoundingClientRect = () => domRect + Object.defineProperty(el, 'offsetWidth', { configurable: true, value: width }) + Object.defineProperty(el, 'offsetHeight', { configurable: true, value: height }) +} + +export function makeElement(tag = 'div'): HTMLElement { + return document.createElement(tag) +} diff --git a/packages/svelte-dnd/tests/helpers/fixtures.ts b/packages/svelte-dnd/tests/helpers/fixtures.ts new file mode 100644 index 0000000..983ce6c --- /dev/null +++ b/packages/svelte-dnd/tests/helpers/fixtures.ts @@ -0,0 +1,131 @@ +import { vi } from 'vitest' +import { setRect, makeElement, type FakeRect } from './dom.js' +import type { DroppableControllerRef } from '../../src/lib/core/entities/droppable.svelte.js' +import type { ZoneGeometryContext } from '../../src/lib/core/zones/zone-geometry.js' +import type { SlotLayoutRect } from '../../src/lib/core/zones/layout-snapshot.js' +import type { DropZone, DndLayout } from '../../src/lib/types.js' +import type { SensorCallbacks } from '../../src/lib/core/sensors/sensor.js' + +export function noopController(): DroppableControllerRef { + return { + session: null, + draggedElement: null, + cancelSession: () => {}, + slots: new Map(), + onDragStart: () => () => {}, + onZonesInvalidated: () => () => {}, + onDragEnd: () => () => {}, + refreshDroppableZones: () => {}, + dragging: false + } +} + +export function geometryCtx(partial: Partial = {}): ZoneGeometryContext { + const containerRect = + partial.containerRect ?? + ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 200, + bottom: 600, + width: 200, + height: 600, + toJSON: () => ({}) + } as DOMRect) + return { + containerId: 'list', + containerRect, + scrollLeft: 0, + scrollTop: 0, + draggedIndex: -1, + ...partial + } +} + +export function slotRect( + slotId: string, + position: number, + x: number, + y: number, + w = 100, + h = 50 +): SlotLayoutRect { + return { slotId, position, offsetLeft: x, offsetTop: y, width: w, height: h } +} + +export function dropZone( + containerId: string, + position: number, + x: number, + y: number, + w: number, + h: number, + layout: DndLayout = 'vertical' +): DropZone { + return { containerId, position, layout, rect: { x, y, width: w, height: h } } +} + +export function scrollableEl( + rect: FakeRect, + scroll: { left?: number; top?: number } = {} +): HTMLElement { + const el = makeElement() + setRect(el, rect) + Object.defineProperty(el, 'scrollLeft', { + configurable: true, + writable: true, + value: scroll.left ?? 0 + }) + Object.defineProperty(el, 'scrollTop', { + configurable: true, + writable: true, + value: scroll.top ?? 0 + }) + return el +} + +export interface PointerEventInit { + type?: string + clientX?: number + clientY?: number + button?: number + pointerType?: 'mouse' | 'touch' | 'pen' + pointerId?: number + target?: HTMLElement +} + +export function pointerEvent(opts: PointerEventInit = {}): PointerEvent { + const event = new PointerEvent(opts.type ?? 'pointerdown', { + clientX: opts.clientX ?? 100, + clientY: opts.clientY ?? 100, + button: opts.button ?? 0, + pointerType: opts.pointerType ?? 'mouse', + pointerId: opts.pointerId ?? 1, + bubbles: true, + cancelable: true + }) + if (opts.target) { + Object.defineProperty(event, 'target', { configurable: true, value: opts.target }) + } + return event +} + +export type SpyCallbacks = { + onStart: ReturnType> + onMove: ReturnType> + onEnd: ReturnType> + onCancel: ReturnType> + onNavigate: ReturnType>> +} + +export function spyCallbacks(): SpyCallbacks { + return { + onStart: vi.fn(), + onMove: vi.fn(), + onEnd: vi.fn(), + onCancel: vi.fn(), + onNavigate: vi.fn>() + } +} diff --git a/packages/svelte-dnd/tests/modifiers/modifiers.test.ts b/packages/svelte-dnd/tests/modifiers/modifiers.test.ts new file mode 100644 index 0000000..be257e3 --- /dev/null +++ b/packages/svelte-dnd/tests/modifiers/modifiers.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { restrictToVerticalAxis } from '../../src/lib/core/modifiers/restrict-to-vertical-axis.js' +import { restrictToHorizontalAxis } from '../../src/lib/core/modifiers/restrict-to-horizontal-axis.js' +import { restrictToContainer } from '../../src/lib/core/modifiers/restrict-to-container.js' +import { snapToGrid } from '../../src/lib/core/modifiers/snap-to-grid.js' +import type { ModifierContext } from '../../src/lib/core/modifiers/modifier.js' +import { setRect } from '../helpers/dom.js' + +function ctx(partial: Partial): ModifierContext { + return { + transform: { x: 0, y: 0 }, + initialTransform: { x: 0, y: 0 }, + ghostSize: { width: 0, height: 0 }, + originContainerId: 'container', + ...partial + } +} + +describe('restrictToVerticalAxis', () => { + it('locks x to the initial value while letting y move freely', () => { + const result = restrictToVerticalAxis( + ctx({ + transform: { x: 99, y: 50 }, + initialTransform: { x: 10, y: 0 } + }) + ) + expect(result).toEqual({ x: 10, y: 50 }) + }) +}) + +describe('restrictToHorizontalAxis', () => { + it('locks y to the initial value while letting x move freely', () => { + const result = restrictToHorizontalAxis( + ctx({ + transform: { x: 50, y: 99 }, + initialTransform: { x: 0, y: 20 } + }) + ) + expect(result).toEqual({ x: 50, y: 20 }) + }) +}) + +describe('restrictToContainer', () => { + let container: HTMLElement + + beforeEach(() => { + container = document.createElement('div') + container.setAttribute('data-dnd-drop-id', 'container') + setRect(container, { x: 100, y: 200, width: 400, height: 300 }) + document.body.appendChild(container) + }) + + afterEach(() => { + container.remove() + }) + + it('clamps the transform inside the container bounds accounting for ghost size', () => { + const result = restrictToContainer( + ctx({ + transform: { x: 1000, y: 1000 }, + ghostSize: { width: 50, height: 40 } + }) + ) + expect(result).toEqual({ x: 100 + 400 - 50, y: 200 + 300 - 40 }) + }) + + it('clamps the transform up to the top-left corner', () => { + const result = restrictToContainer( + ctx({ + transform: { x: -500, y: -500 }, + ghostSize: { width: 50, height: 40 } + }) + ) + expect(result).toEqual({ x: 100, y: 200 }) + }) + + it('returns the transform unchanged when the container element cannot be found', () => { + const result = restrictToContainer( + ctx({ + transform: { x: 9999, y: 9999 }, + ghostSize: { width: 50, height: 50 }, + originContainerId: 'missing' + }) + ) + expect(result).toEqual({ x: 9999, y: 9999 }) + }) +}) + +describe('snapToGrid', () => { + it('snaps both axes to a single grid size when given a number', () => { + const modifier = snapToGrid(20) + expect(modifier(ctx({ transform: { x: 23, y: 31 } }))).toEqual({ x: 20, y: 40 }) + }) + + it('uses independent x and y grid sizes when given an object', () => { + const modifier = snapToGrid({ x: 10, y: 25 }) + expect(modifier(ctx({ transform: { x: 47, y: 38 } }))).toEqual({ x: 50, y: 50 }) + }) + + it('rounds half-step values upward (Math.round behaviour)', () => { + const modifier = snapToGrid(10) + expect(modifier(ctx({ transform: { x: 5, y: 5 } }))).toEqual({ x: 10, y: 10 }) + }) +}) diff --git a/packages/svelte-dnd/tests/sensors/activation-constraints.test.ts b/packages/svelte-dnd/tests/sensors/activation-constraints.test.ts new file mode 100644 index 0000000..78fcab9 --- /dev/null +++ b/packages/svelte-dnd/tests/sensors/activation-constraints.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest' +import { Distance, Delay } from '../../src/lib/core/sensors/activation-constraints.js' +import type { ActivationState } from '../../src/lib/core/sensors/sensor.js' + +function state(partial: Partial): ActivationState { + return { + startX: 0, + startY: 0, + currentX: 0, + currentY: 0, + elapsedMs: 0, + pointerType: 'mouse', + ...partial + } +} + +describe('Distance', () => { + it('returns pending while distance is below the threshold', () => { + const cond = new Distance({ value: 10 }) + expect(cond.evaluate(state({ currentX: 5 }))).toBe('pending') + }) + + it('returns satisfied once the diagonal distance reaches the threshold', () => { + const cond = new Distance({ value: 5 }) + expect(cond.evaluate(state({ currentX: 3, currentY: 4 }))).toBe('satisfied') + }) + + it('aborts when tolerance is exceeded before the threshold is met', () => { + const cond = new Distance({ value: 100, tolerance: 10 }) + expect(cond.evaluate(state({ currentX: 20 }))).toBe('aborted') + }) + + it('does not require a hold duration', () => { + expect(new Distance({ value: 10 }).getRequiredDuration()).toBeNull() + }) +}) + +describe('Delay', () => { + it('returns pending while elapsed time is below the threshold', () => { + const cond = new Delay({ value: 200 }) + expect(cond.evaluate(state({ elapsedMs: 100 }))).toBe('pending') + }) + + it('returns satisfied once the elapsed time reaches the threshold', () => { + const cond = new Delay({ value: 200 }) + expect(cond.evaluate(state({ elapsedMs: 200 }))).toBe('satisfied') + }) + + it('aborts when the pointer drifts past the tolerance during the wait', () => { + const cond = new Delay({ value: 500, tolerance: 5 }) + expect(cond.evaluate(state({ currentX: 8, elapsedMs: 100 }))).toBe('aborted') + }) + + it('reports the configured wait as its required duration', () => { + expect(new Delay({ value: 250 }).getRequiredDuration()).toBe(250) + }) +}) diff --git a/packages/svelte-dnd/tests/sensors/keyboard-sensor.test.ts b/packages/svelte-dnd/tests/sensors/keyboard-sensor.test.ts new file mode 100644 index 0000000..a825beb --- /dev/null +++ b/packages/svelte-dnd/tests/sensors/keyboard-sensor.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { KeyboardSensor } from '../../src/lib/core/sensors/keyboard-sensor.js' +import { setRect, makeElement } from '../helpers/dom.js' +import { spyCallbacks } from '../helpers/fixtures.js' + +let element: HTMLElement +beforeEach(() => { + element = makeElement() + setRect(element, { x: 100, y: 80, width: 200, height: 60 }) + document.body.appendChild(element) +}) + +async function flushTimers() { + await new Promise((resolve) => setTimeout(resolve, 1)) +} + +describe('KeyboardSensor.activate — guards', () => { + it('returns null for non-keyboard events', () => { + const sensor = new KeyboardSensor() + const result = sensor.activate(new MouseEvent('click'), element, spyCallbacks()) + expect(result).toBeNull() + }) + + it('returns null for keys other than Enter or Space', () => { + const sensor = new KeyboardSensor() + const event = new KeyboardEvent('keydown', { key: 'Tab' }) + expect(sensor.activate(event, element, spyCallbacks())).toBeNull() + }) +}) + +describe('KeyboardSensor.activate — lifecycle', () => { + it('starts the drag immediately on Enter and reports the element top-left as initialTransform', () => { + const sensor = new KeyboardSensor() + const callbacks = spyCallbacks() + const event = new KeyboardEvent('keydown', { key: 'Enter' }) + + const result = sensor.activate(event, element, callbacks) + + expect(result).not.toBeNull() + expect(result!.initialTransform).toEqual({ x: 100, y: 80 }) + // offset = element center + expect(result!.offset).toEqual({ x: 100, y: 30 }) + expect(callbacks.onStart).toHaveBeenCalledTimes(1) + expect(callbacks.onStart).toHaveBeenCalledWith({ x: 100, y: 80 }) + }) + + it('also activates on Space', () => { + const sensor = new KeyboardSensor() + const callbacks = spyCallbacks() + const event = new KeyboardEvent('keydown', { key: ' ' }) + + expect(sensor.activate(event, element, callbacks)).not.toBeNull() + expect(callbacks.onStart).toHaveBeenCalledTimes(1) + }) + + it('translates ArrowDown into an onNavigate("down") callback', async () => { + const sensor = new KeyboardSensor() + const callbacks = spyCallbacks() + sensor.activate(new KeyboardEvent('keydown', { key: 'Enter' }), element, callbacks) + await flushTimers() + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })) + expect(callbacks.onNavigate).toHaveBeenCalledTimes(1) + expect(callbacks.onNavigate).toHaveBeenCalledWith('down') + }) + + it('maps every arrow direction', async () => { + const sensor = new KeyboardSensor() + const callbacks = spyCallbacks() + sensor.activate(new KeyboardEvent('keydown', { key: 'Enter' }), element, callbacks) + await flushTimers() + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' })) + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' })) + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })) + expect(callbacks.onNavigate.mock.calls.map((c) => c[0])).toEqual(['up', 'left', 'right']) + }) + + it('calls onCancel when Escape is pressed and stops listening afterwards', async () => { + const sensor = new KeyboardSensor() + const callbacks = spyCallbacks() + sensor.activate(new KeyboardEvent('keydown', { key: 'Enter' }), element, callbacks) + await flushTimers() + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(callbacks.onCancel).toHaveBeenCalledTimes(1) + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })) + expect(callbacks.onNavigate).not.toHaveBeenCalled() + }) + + it('calls onEnd when Enter is pressed a second time and unbinds the listener', async () => { + const sensor = new KeyboardSensor() + const callbacks = spyCallbacks() + sensor.activate(new KeyboardEvent('keydown', { key: 'Enter' }), element, callbacks) + await flushTimers() + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })) + expect(callbacks.onEnd).toHaveBeenCalledTimes(1) + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })) + expect(callbacks.onNavigate).not.toHaveBeenCalled() + }) + + it('destroy() cancels the deferred listener so a follow-up Enter does not fire onEnd', () => { + const sensor = new KeyboardSensor() + const callbacks = spyCallbacks() + const result = sensor.activate( + new KeyboardEvent('keydown', { key: 'Enter' }), + element, + callbacks + )! + + // destroy before the deferred setTimeout(0) attaches the window listener + result.destroy() + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })) + expect(callbacks.onEnd).not.toHaveBeenCalled() + }) +}) diff --git a/packages/svelte-dnd/tests/sensors/pointer-sensor.test.ts b/packages/svelte-dnd/tests/sensors/pointer-sensor.test.ts new file mode 100644 index 0000000..31f02ff --- /dev/null +++ b/packages/svelte-dnd/tests/sensors/pointer-sensor.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { PointerSensor } from '../../src/lib/core/sensors/pointer-sensor.js' +import { setRect, makeElement } from '../helpers/dom.js' +import { pointerEvent, spyCallbacks } from '../helpers/fixtures.js' + +let element: HTMLElement +beforeEach(() => { + element = makeElement() + setRect(element, { x: 50, y: 50, width: 200, height: 100 }) + document.body.appendChild(element) +}) + +describe('PointerSensor.activate — guards', () => { + it('returns null when the event is not a PointerEvent', () => { + const sensor = new PointerSensor() + const result = sensor.activate(new MouseEvent('mousedown'), element, spyCallbacks()) + expect(result).toBeNull() + }) + + it('returns null for non-primary mouse buttons', () => { + const sensor = new PointerSensor() + const event = pointerEvent({ button: 2, target: element }) + expect(sensor.activate(event, element, spyCallbacks())).toBeNull() + }) + + it('returns null when the pointer is outside the padding box', () => { + const sensor = new PointerSensor() + // container goes from x=50..250, y=50..150. Click at (300, 300) is outside. + const event = pointerEvent({ clientX: 300, clientY: 300, target: element }) + expect(sensor.activate(event, element, spyCallbacks())).toBeNull() + }) + + it('returns null when the pointerdown lands on a data-dnd-no-drag descendant', () => { + const sensor = new PointerSensor() + const child = document.createElement('button') + child.setAttribute('data-dnd-no-drag', '') + element.appendChild(child) + + const event = pointerEvent({ target: child }) + expect(sensor.activate(event, element, spyCallbacks())).toBeNull() + }) + + it('returns null when the element has handles but the target is not inside one', () => { + const sensor = new PointerSensor() + const handle = document.createElement('span') + handle.setAttribute('data-dnd-handle', '') + element.appendChild(handle) + const other = document.createElement('span') + element.appendChild(other) + + const event = pointerEvent({ target: other }) + expect(sensor.activate(event, element, spyCallbacks())).toBeNull() + }) + + it('activates when the pointer hits an element-with-handle through that handle', () => { + const sensor = new PointerSensor() + const handle = document.createElement('span') + handle.setAttribute('data-dnd-handle', '') + setRect(handle, { x: 50, y: 50, width: 30, height: 30 }) + element.appendChild(handle) + + const event = pointerEvent({ target: handle, clientX: 60, clientY: 60 }) + const result = sensor.activate(event, element, spyCallbacks()) + expect(result).not.toBeNull() + }) +}) + +describe('PointerSensor.activate — activation lifecycle', () => { + it('returns an activation with offset and initialTransform anchored to the click point', () => { + const sensor = new PointerSensor() + const event = pointerEvent({ target: element, clientX: 80, clientY: 70 }) + const result = sensor.activate(event, element, spyCallbacks()) + + expect(result).not.toBeNull() + // offset = clientX/Y - rect.left/top + expect(result!.offset).toEqual({ x: 30, y: 20 }) + // initialTransform = client - offset → element top-left + expect(result!.initialTransform).toEqual({ x: 50, y: 50 }) + }) + + it('does not call onStart on a tiny mouse move below the 5px threshold', () => { + const sensor = new PointerSensor() + const callbacks = spyCallbacks() + const event = pointerEvent({ target: element, clientX: 100, clientY: 100 }) + sensor.activate(event, element, callbacks) + + element.dispatchEvent(pointerEvent({ type: 'pointermove', clientX: 102, clientY: 101 })) + expect(callbacks.onStart).not.toHaveBeenCalled() + }) + + it('calls onStart once a mouse move exceeds the 5px Distance default', () => { + const sensor = new PointerSensor() + const callbacks = spyCallbacks() + const event = pointerEvent({ target: element, clientX: 100, clientY: 100 }) + sensor.activate(event, element, callbacks) + + element.dispatchEvent(pointerEvent({ type: 'pointermove', clientX: 110, clientY: 100 })) + expect(callbacks.onStart).toHaveBeenCalledTimes(1) + // transform = client - offset = (110 - 50, 100 - 50) = (60, 50) + expect(callbacks.onStart).toHaveBeenCalledWith({ x: 60, y: 50 }) + }) + + it('routes subsequent window pointermove events to onMove after start', () => { + const sensor = new PointerSensor() + const callbacks = spyCallbacks() + const event = pointerEvent({ target: element, clientX: 100, clientY: 100 }) + sensor.activate(event, element, callbacks) + + // Trigger start + element.dispatchEvent(pointerEvent({ type: 'pointermove', clientX: 110, clientY: 100 })) + callbacks.onMove.mockClear() + // Now window-level moves should propagate + window.dispatchEvent(pointerEvent({ type: 'pointermove', clientX: 120, clientY: 130 })) + expect(callbacks.onMove).toHaveBeenCalledTimes(1) + expect(callbacks.onMove).toHaveBeenCalledWith({ x: 70, y: 80 }, 120, 130) + }) + + it('calls onEnd on a window pointerup after the drag has started', () => { + const sensor = new PointerSensor() + const callbacks = spyCallbacks() + const event = pointerEvent({ target: element, clientX: 100, clientY: 100 }) + sensor.activate(event, element, callbacks) + + element.dispatchEvent(pointerEvent({ type: 'pointermove', clientX: 110, clientY: 100 })) + window.dispatchEvent(pointerEvent({ type: 'pointerup' })) + expect(callbacks.onEnd).toHaveBeenCalledTimes(1) + }) + + it('destroy() removes element listeners — subsequent moves do not fire onStart', () => { + const sensor = new PointerSensor() + const callbacks = spyCallbacks() + const event = pointerEvent({ target: element, clientX: 100, clientY: 100 }) + const result = sensor.activate(event, element, callbacks)! + + result.destroy() + element.dispatchEvent(pointerEvent({ type: 'pointermove', clientX: 200, clientY: 200 })) + expect(callbacks.onStart).not.toHaveBeenCalled() + }) +}) diff --git a/packages/svelte-dnd/tests/setup.ts b/packages/svelte-dnd/tests/setup.ts new file mode 100644 index 0000000..16b301b --- /dev/null +++ b/packages/svelte-dnd/tests/setup.ts @@ -0,0 +1,26 @@ +// jsdom 29 ships without CSS.escape — polyfill it for tests that exercise +// DOMHelper selectors (CSS.escape escapes special characters in CSS selectors). +if (typeof globalThis.CSS === 'undefined' || typeof globalThis.CSS.escape !== 'function') { + const cssShim = { + escape(value: string): string { + return String(value).replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g, '\\$&') + } + } + ;(globalThis as unknown as { CSS: typeof globalThis.CSS }).CSS = + cssShim as unknown as typeof globalThis.CSS +} + +// jsdom 29 also lacks the Pointer Capture APIs that PointerSensor calls. +// The library only uses these for browser pointer routing, so a no-op shim +// is sufficient under jsdom. +if (typeof Element.prototype.hasPointerCapture !== 'function') { + Element.prototype.hasPointerCapture = function () { + return false + } +} +if (typeof Element.prototype.setPointerCapture !== 'function') { + Element.prototype.setPointerCapture = function () {} +} +if (typeof Element.prototype.releasePointerCapture !== 'function') { + Element.prototype.releasePointerCapture = function () {} +} diff --git a/packages/svelte-dnd/tests/strategies/sortable-translations.test.ts b/packages/svelte-dnd/tests/strategies/sortable-translations.test.ts new file mode 100644 index 0000000..b4332a5 --- /dev/null +++ b/packages/svelte-dnd/tests/strategies/sortable-translations.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect } from 'vitest' +import { sortable } from '../../src/lib/core/containers/strategies/sortable-container-strategy.js' +import type { DragSession } from '../../src/lib/core/dnd/drag-session.svelte.js' +import type { Droppable } from '../../src/lib/core/entities/droppable.svelte.js' +import type { LayoutSnapshot } from '../../src/lib/core/zones/layout-snapshot.js' +import { DomSortableSource } from '../../src/lib/core/zones/sortable-source.js' +import { slotRect } from '../helpers/fixtures.js' + +const rect = (slotId: string, position: number, x: number, y: number, w = 100, h = 50) => + slotRect(slotId, position, x, y, w, h) + +interface FakeSessionInit { + snapshot: LayoutSnapshot + dropPreview: { containerId: string; position: number } | null + slotSize: { width: number; height: number } | null + originContainerId: string + itemId?: string +} + +function fakeSession(init: FakeSessionInit): DragSession { + const itemId = init.itemId ?? 'dragged' + const source = new DomSortableSource(init.snapshot, itemId) + return { + getSource: (id: string) => + id === init.originContainerId || init.dropPreview?.containerId === id + ? source + : undefined, + dropPreview: init.dropPreview, + slotSize: init.slotSize, + originContainerId: init.originContainerId, + itemId + } as unknown as DragSession +} + +const droppable = (id: string) => ({ id }) as unknown as Droppable + +describe('SortableContainerStrategy.getTranslations — vertical', () => { + const strategy = sortable({ layout: 'vertical' }) + const snapshot: LayoutSnapshot = { + containerId: 'list', + rects: [rect('a', 0, 0, 0), rect('b', 1, 0, 60), rect('c', 2, 0, 120)], + draggedIndex: 2 + } + + it('shifts earlier items forward when the drag moves to an earlier position', () => { + const session = fakeSession({ + snapshot, + dropPreview: { containerId: 'list', position: 0 }, + slotSize: { width: 100, height: 60 }, + originContainerId: 'list' + }) + + const map = strategy.getTranslations(droppable('list'), session) + + // Items in [P..D-1] = [0..1] shift forward by the dragged slot's step (60). + expect(map.get('a')).toEqual({ x: 0, y: 60 }) + expect(map.get('b')).toEqual({ x: 0, y: 60 }) + expect(map.has('c')).toBe(false) + }) + + it('shifts later items backward when the drag moves to a later position', () => { + const earlySnapshot: LayoutSnapshot = { + containerId: 'list', + rects: [rect('a', 0, 0, 0), rect('b', 1, 0, 60), rect('c', 2, 0, 120)], + draggedIndex: 0 + } + const session = fakeSession({ + snapshot: earlySnapshot, + dropPreview: { containerId: 'list', position: 2 }, + slotSize: { width: 100, height: 60 }, + originContainerId: 'list' + }) + + const map = strategy.getTranslations(droppable('list'), session) + + // targetIdx = P+1=3, items in (D..targetIdx) = (0..3) -> indices 1,2 shift back. + expect(map.get('b')).toEqual({ x: 0, y: -60 }) + expect(map.get('c')).toEqual({ x: 0, y: -60 }) + expect(map.has('a')).toBe(false) + }) + + it('collapses the gap behind the dragged item when there is no drop preview', () => { + const session = fakeSession({ + snapshot, + dropPreview: null, + slotSize: { width: 100, height: 60 }, + originContainerId: 'list' + }) + + const map = strategy.getTranslations(droppable('list'), session) + + // D=2, items at indices > 2 shift back by step. There are none, so map stays empty. + expect(map.size).toBe(0) + }) + + it('collapses the origin gap when hovering a different container', () => { + const earlySnapshot: LayoutSnapshot = { + containerId: 'list', + rects: [rect('a', 0, 0, 0), rect('b', 1, 0, 60), rect('c', 2, 0, 120)], + draggedIndex: 0 + } + const session = fakeSession({ + snapshot: earlySnapshot, + dropPreview: { containerId: 'other', position: 0 }, + slotSize: { width: 100, height: 60 }, + originContainerId: 'list' + }) + + const map = strategy.getTranslations(droppable('list'), session) + + // Source container collapses items at index > D back by step. + expect(map.get('b')).toEqual({ x: 0, y: -60 }) + expect(map.get('c')).toEqual({ x: 0, y: -60 }) + expect(map.has('a')).toBe(false) + }) + + it('shifts target items forward when the dragged item arrives from another container', () => { + const targetSnapshot: LayoutSnapshot = { + containerId: 'target', + rects: [rect('x', 0, 0, 0), rect('y', 1, 0, 60), rect('z', 2, 0, 120)], + draggedIndex: -1 // dragged item is not native to this snapshot + } + const session = fakeSession({ + snapshot: targetSnapshot, + dropPreview: { containerId: 'target', position: 1 }, + slotSize: { width: 100, height: 60 }, + originContainerId: 'source', + itemId: 'dragged' + }) + + const map = strategy.getTranslations(droppable('target'), session) + + // Items at positions P..end (1..2) shift forward by step. + expect(map.get('y')).toEqual({ x: 0, y: 60 }) + expect(map.get('z')).toEqual({ x: 0, y: 60 }) + expect(map.has('x')).toBe(false) + }) + + it('returns an empty map when slotSize is missing', () => { + const session = fakeSession({ + snapshot, + dropPreview: { containerId: 'list', position: 0 }, + slotSize: null, + originContainerId: 'list' + }) + + const map = strategy.getTranslations(droppable('list'), session) + expect(map.size).toBe(0) + }) +}) + +describe('SortableContainerStrategy.getTranslations — horizontal', () => { + const strategy = sortable({ layout: 'horizontal' }) + + it('shifts later items backward along the x axis', () => { + const snapshot: LayoutSnapshot = { + containerId: 'row', + rects: [ + rect('a', 0, 0, 0, 80, 50), + rect('b', 1, 90, 0, 80, 50), + rect('c', 2, 180, 0, 80, 50) + ], + draggedIndex: 0 + } + const session = fakeSession({ + snapshot, + dropPreview: { containerId: 'row', position: 2 }, + slotSize: { width: 90, height: 50 }, + originContainerId: 'row' + }) + + const map = strategy.getTranslations(droppable('row'), session) + expect(map.get('b')).toEqual({ x: -90, y: 0 }) + expect(map.get('c')).toEqual({ x: -90, y: 0 }) + expect(map.has('a')).toBe(false) + }) +}) diff --git a/packages/svelte-dnd/tests/strategies/sortable-virtual.test.ts b/packages/svelte-dnd/tests/strategies/sortable-virtual.test.ts new file mode 100644 index 0000000..806588a --- /dev/null +++ b/packages/svelte-dnd/tests/strategies/sortable-virtual.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect } from 'vitest' +import { sortable } from '../../src/lib/core/containers/strategies/sortable-container-strategy.js' +import type { DragSession } from '../../src/lib/core/dnd/drag-session.svelte.js' +import type { Droppable } from '../../src/lib/core/entities/droppable.svelte.js' +import type { Slot } from '../../src/lib/core/entities/slot.js' +import type { VirtualSource } from '../../src/lib/core/zones/sortable-source.js' +import { scrollableEl } from '../helpers/fixtures.js' +import { setRect, makeElement } from '../helpers/dom.js' + +interface MountedSlotInit { + id: string + position: number + rect?: { x: number; y: number; width: number; height: number } +} + +const mountedSlot = ({ id, position, rect }: MountedSlotInit): Slot => { + const element = makeElement() + if (rect) setRect(element, rect) + return { position, draggable: { id }, element } as unknown as Slot +} + +const droppableWith = ( + id: string, + rect: { x: number; y: number; width: number; height: number }, + slots: Slot[] +) => { + const element = scrollableEl(rect) + return { + id, + element, + getSortedSlots: () => slots + } as unknown as Droppable +} + +const itemSize = 50 +const itemCount = 5000 +const virtual: VirtualSource = { + itemCount: () => itemCount, + getOffset: (i: number) => i * itemSize, + getSize: () => itemSize +} + +interface SessionInit { + itemId: string + originContainerId?: string + originPosition?: number + dropPreview?: { containerId: string; position: number } | null + slotSize?: { width: number; height: number } | null +} + +function makeSession(init: SessionInit): DragSession { + const sources = new Map() + return { + setSource: (id: string, src: unknown) => sources.set(id, src), + getSource: (id: string) => sources.get(id), + itemId: init.itemId, + originContainerId: init.originContainerId ?? 'virt', + originPosition: init.originPosition ?? 0, + dropPreview: init.dropPreview ?? null, + slotSize: init.slotSize ?? null + } as unknown as DragSession +} + +describe('SortableContainerStrategy.calculateDropZones — virtual mode', () => { + const strategy = sortable({ layout: 'vertical', virtual }) + + it('emits zones only for slots mounted in the visible viewport window', () => { + // Container 200x400 at viewport (0, 0). 8 mounted slots stack inside the viewport + // — that's what virtua would render at this scroll position. + const slots = [100, 101, 102, 103, 104, 105, 106, 107].map((p, i) => + mountedSlot({ + id: `item-${p}`, + position: p, + rect: { x: 0, y: i * itemSize, width: 200, height: itemSize } + }) + ) + const droppable = droppableWith('virt', { x: 0, y: 0, width: 200, height: 400 }, slots) + const session = makeSession({ itemId: 'none', originContainerId: 'other' }) + + strategy.onSessionStart(droppable, session) + + const zones = strategy.calculateDropZones(droppable, session) + const positions = zones.map((z) => z.position).sort((a, b) => a - b) + + expect(positions[0]).toBe(100) + expect(positions[positions.length - 1]).toBe(108) + }) + + it('drops zones for slots whose rect falls outside the container viewport', () => { + // item-100 is mounted but offscreen (e.g. virtua keeps it via keepMounted). + // Two more slots are inside the viewport. + const slots = [ + mountedSlot({ + id: 'item-100', + position: 100, + rect: { x: 0, y: -200, width: 200, height: itemSize } + }), + mountedSlot({ + id: 'item-201', + position: 201, + rect: { x: 0, y: 0, width: 200, height: itemSize } + }), + mountedSlot({ + id: 'item-202', + position: 202, + rect: { x: 0, y: itemSize, width: 200, height: itemSize } + }) + ] + const droppable = droppableWith('virt', { x: 0, y: 0, width: 200, height: 400 }, slots) + const session = makeSession({ itemId: 'none', originContainerId: 'other' }) + + strategy.onSessionStart(droppable, session) + const zones = strategy.calculateDropZones(droppable, session) + const positions = new Set(zones.map((z) => z.position)) + + // item-100 is offscreen, so position 100 is unreachable from this viewport. + expect(positions.has(100)).toBe(false) + expect(positions.has(201)).toBe(true) + expect(positions.has(203)).toBe(true) + }) + + it('returns the empty zone when no slots are mounted', () => { + const droppable = droppableWith('virt', { x: 0, y: 0, width: 200, height: 400 }, []) + const session = makeSession({ itemId: 'none', originContainerId: 'other' }) + + strategy.onSessionStart(droppable, session) + const zones = strategy.calculateDropZones(droppable, session) + expect(zones).toHaveLength(1) + expect(zones[0].position).toBe(0) + }) +}) + +describe('SortableContainerStrategy.getTranslations — virtual mode', () => { + const strategy = sortable({ layout: 'vertical', virtual }) + + it('shifts later mounted slots back when the drag moves to a higher position', () => { + const slots = [100, 101, 102, 103, 104].map((p) => + mountedSlot({ id: `item-${p}`, position: p }) + ) + const droppable = droppableWith('virt', { x: 0, y: 0, width: 200, height: 400 }, slots) + const session = makeSession({ + itemId: 'item-100', + originPosition: 100, + dropPreview: { containerId: 'virt', position: 103 }, + slotSize: { width: 200, height: itemSize } + }) + + strategy.onSessionStart(droppable, session) + const map = strategy.getTranslations(droppable, session) + + // D=100, P=103, targetIdx=104. Items at positions 101..103 fill the gap by shifting up by 50. + expect(map.get('item-101')).toEqual({ x: 0, y: -itemSize }) + expect(map.get('item-102')).toEqual({ x: 0, y: -itemSize }) + expect(map.get('item-103')).toEqual({ x: 0, y: -itemSize }) + expect(map.has('item-104')).toBe(false) + }) + + it('does not produce translations for slots that were unmounted by the virtualizer', () => { + // Only positions 100 and 102 are mounted; 101 was unmounted (some weird virtualizer). + // We should still emit translations for the mounted ones based on their slot.position. + const slots = [100, 102, 103].map((p) => mountedSlot({ id: `item-${p}`, position: p })) + const droppable = droppableWith('virt', { x: 0, y: 0, width: 200, height: 400 }, slots) + const session = makeSession({ + itemId: 'item-100', + originPosition: 100, + dropPreview: { containerId: 'virt', position: 103 }, + slotSize: { width: 200, height: itemSize } + }) + + strategy.onSessionStart(droppable, session) + const map = strategy.getTranslations(droppable, session) + + // 102 is in (D=100..targetIdx=104) → shift back. 101 is not mounted → skipped. + expect(map.get('item-102')).toEqual({ x: 0, y: -itemSize }) + expect(map.get('item-103')).toEqual({ x: 0, y: -itemSize }) + expect(map.has('item-101')).toBe(false) + }) +}) diff --git a/packages/svelte-dnd/tests/strategies/sortable-zones.test.ts b/packages/svelte-dnd/tests/strategies/sortable-zones.test.ts new file mode 100644 index 0000000..7e07c29 --- /dev/null +++ b/packages/svelte-dnd/tests/strategies/sortable-zones.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest' +import { sortable } from '../../src/lib/core/containers/strategies/sortable-container-strategy.js' +import type { DragSession } from '../../src/lib/core/dnd/drag-session.svelte.js' +import type { Droppable } from '../../src/lib/core/entities/droppable.svelte.js' +import type { LayoutSnapshot } from '../../src/lib/core/zones/layout-snapshot.js' +import { DomSortableSource } from '../../src/lib/core/zones/sortable-source.js' +import { scrollableEl, slotRect } from '../helpers/fixtures.js' + +const droppableWith = ( + id: string, + rect: { x: number; y: number; width: number; height: number }, + scroll = { top: 0, left: 0 } +) => { + const element = scrollableEl(rect, scroll) + return { id, element } as unknown as Droppable +} + +const sessionWith = (snapshot: LayoutSnapshot, itemId = 'dragged'): DragSession => { + const source = new DomSortableSource(snapshot, itemId) + return { + getSource: (id: string) => (id === snapshot.containerId ? source : undefined), + itemId + } as unknown as DragSession +} + +describe('SortableContainerStrategy.calculateDropZones — scroll viewport clipping', () => { + const strategy = sortable({ layout: 'vertical' }) + + it('skips slots scrolled out of the container viewport', () => { + // Container 200x400 at (0, 0). Items each 50 tall, 50 of them ⇒ content 2500. + // scrollTop=0 ⇒ only items whose offsetTop < 400 are visible (≤ first 8). + const rects = Array.from({ length: 50 }, (_, i) => + slotRect(`item-${i}`, i, 0, i * 50, 200, 50) + ) + const snapshot: LayoutSnapshot = { containerId: 'list', rects, draggedIndex: -1 } + const droppable = droppableWith('list', { x: 0, y: 0, width: 200, height: 400 }) + const session = sessionWith(snapshot, 'none') + + const zones = strategy.calculateDropZones(droppable, session) + + // 8 visible items (item-0..item-7) ⇒ 9 insert positions (0..8). + // item-8 onward is clipped by overflow ⇒ no zones for them. + const positions = zones.map((z) => z.position).sort((a, b) => a - b) + expect(positions).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]) + }) + + it('does not emit zones below the visible container bottom', () => { + const rects = Array.from({ length: 50 }, (_, i) => + slotRect(`item-${i}`, i, 0, i * 50, 200, 50) + ) + const snapshot: LayoutSnapshot = { containerId: 'list', rects, draggedIndex: -1 } + const droppable = droppableWith('list', { x: 0, y: 0, width: 200, height: 400 }) + const session = sessionWith(snapshot, 'none') + + const zones = strategy.calculateDropZones(droppable, session) + + // Bug guard: pointing at y=800 (well beyond container.bottom=400) must not fall + // inside any drop zone — historically zones for scrolled-out slots leaked there. + for (const zone of zones) { + expect(zone.rect.y).toBeLessThan(400) + expect(zone.rect.y + zone.rect.height).toBeLessThanOrEqual(400) + } + }) + + it('shifts the visible window with scrollTop', () => { + const rects = Array.from({ length: 50 }, (_, i) => + slotRect(`item-${i}`, i, 0, i * 50, 200, 50) + ) + const snapshot: LayoutSnapshot = { containerId: 'list', rects, draggedIndex: -1 } + // Scrolled to y=500 ⇒ content rows 10..17 become visible. + const droppable = droppableWith( + 'list', + { x: 0, y: 0, width: 200, height: 400 }, + { top: 500, left: 0 } + ) + const session = sessionWith(snapshot, 'none') + + const zones = strategy.calculateDropZones(droppable, session) + const positions = new Set(zones.map((z) => z.position)) + + // Position 0 (insert before item-0) is no longer reachable — that slot is far above. + expect(positions.has(0)).toBe(false) + // Position 10 (before item-10) is the new "first visible" insert point. + expect(positions.has(10)).toBe(true) + expect(positions.has(18)).toBe(true) // after item-17, still inside the window + }) + + it('returns the empty zone when every slot is scrolled out', () => { + const rects = [slotRect('only', 0, 0, 5000, 200, 50)] + const snapshot: LayoutSnapshot = { containerId: 'list', rects, draggedIndex: -1 } + const droppable = droppableWith('list', { x: 0, y: 0, width: 200, height: 400 }) + const session = sessionWith(snapshot, 'none') + + const zones = strategy.calculateDropZones(droppable, session) + + expect(zones).toHaveLength(1) + expect(zones[0].position).toBe(0) + }) +}) diff --git a/packages/svelte-dnd/tests/utils/dom-helper.test.ts b/packages/svelte-dnd/tests/utils/dom-helper.test.ts new file mode 100644 index 0000000..0958096 --- /dev/null +++ b/packages/svelte-dnd/tests/utils/dom-helper.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { DOMHelper } from '../../src/lib/core/utils/dom-helper.js' +import { setRect, makeElement } from '../helpers/dom.js' + +function mountContainer(id = 'list'): HTMLElement { + const el = makeElement() + el.setAttribute('data-dnd-drop-id', id) + document.body.appendChild(el) + return el +} + +describe('DOMHelper.findContainer', () => { + let container: HTMLElement + + beforeEach(() => { + container = mountContainer() + }) + afterEach(() => container.remove()) + + it('locates a container by its data-dnd-drop-id', () => { + expect(DOMHelper.findContainer('list')).toBe(container) + }) + + it('returns null when no container matches the id', () => { + expect(DOMHelper.findContainer('missing')).toBeNull() + }) + + it('escapes ids that contain special CSS characters', () => { + const fancy = makeElement() + fancy.setAttribute('data-dnd-drop-id', 'list[1].column') + document.body.appendChild(fancy) + try { + expect(DOMHelper.findContainer('list[1].column')).toBe(fancy) + } finally { + fancy.remove() + } + }) +}) + +describe('DOMHelper.getContainerRect', () => { + it('returns the container rect when found, null when missing', () => { + const container = mountContainer() + setRect(container, { x: 10, y: 20, width: 200, height: 100 }) + try { + const rect = DOMHelper.getContainerRect('list') + expect(rect?.left).toBe(10) + expect(rect?.width).toBe(200) + expect(DOMHelper.getContainerRect('missing')).toBeNull() + } finally { + container.remove() + } + }) +}) + +describe('DOMHelper.isElementVisibleInContainer', () => { + let container: HTMLElement + + beforeEach(() => { + container = makeElement() + setRect(container, { x: 0, y: 0, width: 200, height: 200 }) + }) + + it('returns true when the element fits inside on all four sides', () => { + const child = makeElement() + setRect(child, { x: 10, y: 10, width: 100, height: 100 }) + expect(DOMHelper.isElementVisibleInContainer(child, container)).toBe(true) + }) + + it('returns false when the element overhangs the top edge', () => { + const child = makeElement() + setRect(child, { x: 10, y: -10, width: 100, height: 50 }) + expect(DOMHelper.isElementVisibleInContainer(child, container)).toBe(false) + }) + + it('returns false when the element overhangs the bottom edge', () => { + const child = makeElement() + setRect(child, { x: 10, y: 180, width: 100, height: 30 }) + expect(DOMHelper.isElementVisibleInContainer(child, container)).toBe(false) + }) + + it('returns false when the element overhangs the left edge', () => { + const child = makeElement() + setRect(child, { x: -10, y: 10, width: 50, height: 50 }) + expect(DOMHelper.isElementVisibleInContainer(child, container)).toBe(false) + }) + + it('returns false when the element overhangs the right edge', () => { + const child = makeElement() + setRect(child, { x: 180, y: 10, width: 50, height: 50 }) + expect(DOMHelper.isElementVisibleInContainer(child, container)).toBe(false) + }) +}) + +describe('DOMHelper.findPreview / findPreviewSlot', () => { + let container: HTMLElement + + beforeEach(() => { + container = mountContainer() + }) + afterEach(() => container.remove()) + + it('finds a preview owned by the given container', () => { + const slot = document.createElement('div') + const preview = document.createElement('div') + preview.setAttribute('data-dnd-preview-position', '2') + slot.appendChild(preview) + container.appendChild(slot) + + expect(DOMHelper.findPreview(container, 2)).toBe(preview) + }) + + it('skips previews that belong to a nested droppable', () => { + const inner = document.createElement('div') + inner.setAttribute('data-dnd-drop-id', 'inner') + const innerPreview = document.createElement('div') + innerPreview.setAttribute('data-dnd-preview-position', '0') + inner.appendChild(innerPreview) + container.appendChild(inner) + + // only the inner droppable owns this preview, the outer one has none + expect(DOMHelper.findPreview(container, 0)).toBeNull() + }) + + it('returns the preview wrapper element for the slot lookup', () => { + const slot = document.createElement('div') + slot.className = 'wrapper' + const preview = document.createElement('div') + preview.setAttribute('data-dnd-preview-position', '1') + slot.appendChild(preview) + container.appendChild(slot) + + expect(DOMHelper.findPreviewSlot(container, 1)).toBe(slot) + expect(DOMHelper.findPreviewSlot(container, 999)).toBeNull() + }) +}) diff --git a/packages/svelte-dnd/tests/zones/axis-zone-geometry.test.ts b/packages/svelte-dnd/tests/zones/axis-zone-geometry.test.ts new file mode 100644 index 0000000..18bd1c6 --- /dev/null +++ b/packages/svelte-dnd/tests/zones/axis-zone-geometry.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest' +import { AxisZoneGeometry } from '../../src/lib/core/zones/geometries/axis-zone-geometry.js' +import { geometryCtx as ctx, slotRect as rect } from '../helpers/fixtures.js' + +describe('AxisZoneGeometry — vertical', () => { + const geometry = new AxisZoneGeometry('vertical') + + it('emits a single empty zone for an empty list', () => { + const zones = geometry.buildZones([], ctx()) + expect(zones).toHaveLength(1) + expect(zones[0].position).toBe(0) + expect(zones[0].rect.height).toBeGreaterThanOrEqual(20) + }) + + it('emits N+1 zones for N visible items (insert before/after)', () => { + const rects = [ + rect('a', 0, 0, 0, 200, 50), + rect('b', 1, 0, 60, 200, 50), + rect('c', 2, 0, 120, 200, 50) + ] + const zones = geometry.buildZones(rects, ctx()) + expect(zones.map((z) => z.position)).toEqual([0, 1, 2, 3]) + }) + + it('zone 0 spans from container top to the first item midpoint', () => { + const rects = [rect('a', 0, 0, 0, 200, 50)] + const [first] = geometry.buildZones(rects, ctx()) + // Item starts at y=0 and is 50 tall, so the first zone covers y=0..(half=25). + expect(first.position).toBe(0) + expect(first.rect.y).toBe(0) + expect(first.rect.height).toBe(25) + }) + + it('inter-item zone spans from one midpoint to the next', () => { + const rects = [ + rect('a', 0, 0, 0, 200, 50), // mid y=25 + rect('b', 1, 0, 60, 200, 50) // mid y=85 + ] + const zones = geometry.buildZones(rects, ctx()) + // zone position 1 (between a and b) goes from y=25 to y=85 → height 60. + const between = zones.find((z) => z.position === 1)! + expect(between.rect.y).toBe(25) + expect(between.rect.height).toBe(60) + }) + + it('last zone extends to the container bottom', () => { + const rects = [rect('a', 0, 0, 0, 200, 50)] + const zones = geometry.buildZones(rects, ctx()) + const last = zones.find((z) => z.position === 1)! + // container bottom = 600, last item ends at y=50, mid at 25; last zone covers from 25 down. + expect(last.rect.y).toBe(25) + // remaining = 600 - 25 = 575 + expect(last.rect.height).toBe(575) + }) + + it('subtracts scrollTop from offsetTop when projecting to viewport', () => { + const rects = [rect('a', 0, 0, 100, 200, 50)] // offsetTop 100 + const zones = geometry.buildZones(rects, ctx({ scrollTop: 40 })) + // viewport y = offsetTop - scrollTop = 60. Zone 0 covers from 0 to mid=60+25=85. + expect(zones[0].rect.y).toBe(0) + expect(zones[0].rect.height).toBe(85) + }) +}) + +describe('AxisZoneGeometry — horizontal', () => { + const geometry = new AxisZoneGeometry('horizontal') + + it('emits zones along the x axis using container height as zone height', () => { + const rects = [rect('a', 0, 0, 0, 80, 100), rect('b', 1, 90, 0, 80, 100)] + const zones = geometry.buildZones( + rects, + ctx({ + containerRect: { + x: 0, + y: 0, + left: 0, + top: 0, + right: 400, + bottom: 100, + width: 400, + height: 100, + toJSON: () => ({}) + } as DOMRect + }) + ) + + // 2 items → 3 zones (positions 0, 1, 2) + expect(zones.map((z) => z.position)).toEqual([0, 1, 2]) + // Every zone should be the full container height + for (const z of zones) { + expect(z.rect.height).toBe(100) + } + }) +}) diff --git a/packages/svelte-dnd/tests/zones/drop-resolver.test.ts b/packages/svelte-dnd/tests/zones/drop-resolver.test.ts new file mode 100644 index 0000000..3580768 --- /dev/null +++ b/packages/svelte-dnd/tests/zones/drop-resolver.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest' +import { DropResolver } from '../../src/lib/core/zones/drop-resolver.js' +import { closestCenter } from '../../src/lib/core/collision/closest-center.js' +import type { DndState } from '../../src/lib/core/dnd/dnd-state.svelte.js' +import type { Droppable } from '../../src/lib/core/entities/droppable.svelte.js' +import type { DropZone } from '../../src/lib/types.js' +import { dropZone as zone } from '../helpers/fixtures.js' + +interface FakeDroppable { + accepts?: string | string[] + collision?: Droppable['collision'] +} + +interface FakeStateInit { + draggedItem?: string | null + draggedType?: string | undefined + zones: DropZone[] + transform?: { x: number; y: number } + ghostSize?: { width: number; height: number } +} + +function fakeState(init: FakeStateInit): DndState { + return { + // Preserve an explicitly passed `null` — `??` would replace it with the default. + draggedItem: 'draggedItem' in init ? init.draggedItem : 'item-1', + draggedType: init.draggedType, + zones: init.zones, + transform: init.transform ?? { x: 0, y: 0 }, + ghostSize: init.ghostSize ?? { width: 0, height: 0 } + } as unknown as DndState +} + +const fakeDroppables = (map: Record): Map => + new Map(Object.entries(map).map(([id, d]) => [id, d as unknown as Droppable])) + +describe('DropResolver.findZoneAt', () => { + it('returns null when no item is being dragged', () => { + const state = fakeState({ draggedItem: null, zones: [zone('a', 0, 0, 0, 50, 50)] }) + const resolver = new DropResolver(state, fakeDroppables({ a: {} })) + + expect(resolver.findZoneAt({ x: 25, y: 25 })).toBeNull() + }) + + it('matches a zone via the default centerPoint algorithm', () => { + const z = zone('a', 0, 0, 0, 100, 100) + const state = fakeState({ + zones: [z], + transform: { x: 20, y: 20 }, + ghostSize: { width: 30, height: 30 } + }) + const resolver = new DropResolver(state, fakeDroppables({ a: {} })) + + // ghost center is (20+15, 20+15) = (35, 35), inside the zone + expect(resolver.findZoneAt({ x: 0, y: 0 })).toBe(z) + }) + + it('skips zones whose container does not accept the dragged type', () => { + const accepted = zone('a', 0, 0, 0, 100, 100) + const rejected = zone('b', 0, 200, 0, 100, 100) + const state = fakeState({ + draggedType: 'card', + zones: [accepted, rejected], + transform: { x: 220, y: 20 }, + ghostSize: { width: 30, height: 30 } + }) + const resolver = new DropResolver( + state, + fakeDroppables({ + a: { accepts: 'card' }, + b: { accepts: 'task' } + }) + ) + + // ghost center sits inside rejected, but `b` does not accept 'card'. + expect(resolver.findZoneAt({ x: 0, y: 0 })).toBeNull() + }) + + it('uses the per-container collision algorithm when one is set', () => { + const z = zone('a', 0, 1000, 0, 50, 50) + const state = fakeState({ + zones: [z], + transform: { x: 0, y: 0 }, + ghostSize: { width: 10, height: 10 } + }) + // Default centerPoint would not match (ghost far from zone), but closestCenter always picks the + // nearest zone within a container. + const resolver = new DropResolver( + state, + fakeDroppables({ + a: { collision: closestCenter } + }) + ) + + expect(resolver.findZoneAt({ x: 0, y: 0 })).toBe(z) + }) + + it('falls back to the global algorithm when no per-container one is set', () => { + const z = zone('a', 0, 1000, 0, 50, 50) + const state = fakeState({ + zones: [z], + transform: { x: 0, y: 0 }, + ghostSize: { width: 10, height: 10 } + }) + const resolver = new DropResolver(state, fakeDroppables({ a: {} }), closestCenter) + + expect(resolver.findZoneAt({ x: 0, y: 0 })).toBe(z) + }) +}) diff --git a/packages/svelte-dnd/tests/zones/grid-zone-geometry.test.ts b/packages/svelte-dnd/tests/zones/grid-zone-geometry.test.ts new file mode 100644 index 0000000..0552df7 --- /dev/null +++ b/packages/svelte-dnd/tests/zones/grid-zone-geometry.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest' +import { GridZoneGeometry } from '../../src/lib/core/zones/geometries/grid-zone-geometry.js' +import { geometryCtx, slotRect } from '../helpers/fixtures.js' + +const GRID_RECT = { + x: 0, + y: 0, + left: 0, + top: 0, + right: 400, + bottom: 400, + width: 400, + height: 400, + toJSON: () => ({}) +} as DOMRect + +const ctx = (partial: Parameters[0] = {}) => + geometryCtx({ containerId: 'grid', containerRect: GRID_RECT, ...partial }) + +const rect = (slotId: string, position: number, x: number, y: number, w = 100, h = 100) => + slotRect(slotId, position, x, y, w, h) + +describe('GridZoneGeometry — row flow', () => { + const geometry = new GridZoneGeometry('row') + + it('emits an empty zone with at-least 20px height for an empty grid', () => { + const zones = geometry.buildZones([], ctx()) + expect(zones).toHaveLength(1) + expect(zones[0].layout).toBe('grid') + expect(zones[0].rect.height).toBeGreaterThanOrEqual(20) + }) + + it('groups items on the same row into one track and emits 2 zones per item', () => { + // Two items on the same row at y=0 + const rects = [rect('a', 0, 0, 0), rect('b', 1, 100, 0)] + const zones = geometry.buildZones(rects, ctx()) + // 2 items × 2 zones each = 4 zones. Positions overlap (afterMe[i] === beforeMe[i+1]), + // so unique positions span 0..N (3 distinct values for 2 items). + expect(zones).toHaveLength(4) + expect(new Set(zones.map((z) => z.position))).toEqual(new Set([0, 1, 2])) + // Same row → all zones share roughly the same y span + expect(zones[0].rect.y).toBe(zones[2].rect.y) + }) + + it('separates items on different rows into different tracks', () => { + // 2x2 grid + const rects = [ + rect('a', 0, 0, 0), + rect('b', 1, 100, 0), + rect('c', 2, 0, 100), + rect('d', 3, 100, 100) + ] + const zones = geometry.buildZones(rects, ctx()) + // 4 items × 2 zones each = 8 zones + expect(zones).toHaveLength(8) + // First two items are on the top row (y around 0..50), last two on bottom row (y around 50..) + const topZones = zones.filter((z) => z.rect.y < 100) + const bottomZones = zones.filter((z) => z.rect.y >= 100) + expect(topZones.length).toBeGreaterThan(0) + expect(bottomZones.length).toBeGreaterThan(0) + }) + + it('uses primary axis = X for row flow', () => { + // Two adjacent items: "before a" zone should sit to the left, "after a" between a and b. + const rects = [rect('a', 0, 100, 0), rect('b', 1, 200, 0)] + const zones = geometry.buildZones(rects, ctx()) + const beforeA = zones.find((z) => z.position === 0)! + const afterA = zones.find((z) => z.position === 1)! + // "before" zone's x is the lesser, "after" zone's x is greater. + expect(beforeA.rect.x).toBeLessThan(afterA.rect.x) + }) +}) + +describe('GridZoneGeometry — column flow', () => { + const geometry = new GridZoneGeometry('column') + + it('emits zones along the y axis using primary = Y', () => { + const rects = [rect('a', 0, 0, 0), rect('b', 1, 0, 100)] + const zones = geometry.buildZones(rects, ctx()) + const beforeA = zones.find((z) => z.position === 0)! + const afterA = zones.find((z) => z.position === 1)! + // In column flow the primary axis is Y, so "after" zone is below "before". + expect(beforeA.rect.y).toBeLessThan(afterA.rect.y) + }) + + it('still produces 2 zones per item', () => { + const rects = [rect('a', 0, 0, 0), rect('b', 1, 0, 100), rect('c', 2, 0, 200)] + const zones = geometry.buildZones(rects, ctx()) + expect(zones).toHaveLength(6) + // Adjacent before/after zones share a position, so 3 items yield 4 unique positions (0..3). + expect(new Set(zones.map((z) => z.position))).toEqual(new Set([0, 1, 2, 3])) + }) +}) diff --git a/packages/svelte-dnd/tests/zones/layout-snapshot.test.ts b/packages/svelte-dnd/tests/zones/layout-snapshot.test.ts new file mode 100644 index 0000000..e5db40e --- /dev/null +++ b/packages/svelte-dnd/tests/zones/layout-snapshot.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'vitest' +import { captureLayoutSnapshot, toViewportRect } from '../../src/lib/core/zones/layout-snapshot.js' +import { Droppable } from '../../src/lib/core/entities/droppable.svelte.js' +import { Slot } from '../../src/lib/core/entities/slot.js' +import { sortable } from '../../src/lib/core/containers/strategies/sortable-container-strategy.js' +import { setRect, makeElement, type FakeRect } from '../helpers/dom.js' +import { noopController, scrollableEl } from '../helpers/fixtures.js' + +interface SlotInit { + id: string + position: number + rect: FakeRect +} + +function buildDroppable( + containerRect: FakeRect, + slotInits: SlotInit[], + scroll = { left: 0, top: 0 } +): Droppable { + const droppable = new Droppable({ id: 'list', strategy: sortable() }, noopController()) + droppable.element = scrollableEl(containerRect, scroll) + + for (const init of slotInits) { + const slot = new Slot(init.position) + const slotEl = makeElement() + setRect(slotEl, init.rect) + slot.element = slotEl + slot.draggable = { id: init.id } as Slot['draggable'] + slot.droppable = droppable + droppable.slots.set(slotEl, slot) + } + + return droppable +} + +describe('captureLayoutSnapshot', () => { + it('returns an empty rects array and draggedIndex=-1 for an empty droppable', () => { + const droppable = buildDroppable({ x: 0, y: 0, width: 200, height: 600 }, []) + const snapshot = captureLayoutSnapshot(droppable, null) + + expect(snapshot.containerId).toBe('list') + expect(snapshot.rects).toHaveLength(0) + expect(snapshot.draggedIndex).toBe(-1) + }) + + it('records each slot in content-space coordinates relative to the container', () => { + const droppable = buildDroppable({ x: 100, y: 200, width: 200, height: 600 }, [ + { id: 'a', position: 0, rect: { x: 100, y: 200, width: 200, height: 50 } }, + { id: 'b', position: 1, rect: { x: 100, y: 260, width: 200, height: 50 } } + ]) + + const snapshot = captureLayoutSnapshot(droppable, null) + + // containerRect.left = 100, containerRect.top = 200 → offsets are slot.left - container.left. + expect(snapshot.rects[0]).toMatchObject({ + slotId: 'a', + position: 0, + offsetLeft: 0, + offsetTop: 0, + width: 200, + height: 50 + }) + expect(snapshot.rects[1]).toMatchObject({ + slotId: 'b', + position: 1, + offsetLeft: 0, + offsetTop: 60, + width: 200, + height: 50 + }) + }) + + it('adds container scroll offsets back into the captured offsets', () => { + const droppable = buildDroppable( + { x: 0, y: 0, width: 200, height: 200 }, + [{ id: 'a', position: 0, rect: { x: 0, y: -50, width: 200, height: 50 } }], + { left: 0, top: 100 } + ) + + const snapshot = captureLayoutSnapshot(droppable, null) + // content-space top = viewport top (-50) - container top (0) + scrollTop (100) = 50 + expect(snapshot.rects[0].offsetTop).toBe(50) + }) + + it('finds the dragged slot index by id and orders rects by slot.position', () => { + // Insert in scrambled order to verify sorting. + const droppable = buildDroppable({ x: 0, y: 0, width: 200, height: 600 }, [ + { id: 'b', position: 1, rect: { x: 0, y: 60, width: 200, height: 50 } }, + { id: 'c', position: 2, rect: { x: 0, y: 120, width: 200, height: 50 } }, + { id: 'a', position: 0, rect: { x: 0, y: 0, width: 200, height: 50 } } + ]) + + const snapshot = captureLayoutSnapshot(droppable, 'b') + + // Sorted by position + expect(snapshot.rects.map((r) => r.slotId)).toEqual(['a', 'b', 'c']) + expect(snapshot.draggedIndex).toBe(1) + }) + + it('returns draggedIndex=-1 when the dragged id is not in the snapshot', () => { + const droppable = buildDroppable({ x: 0, y: 0, width: 200, height: 600 }, [ + { id: 'a', position: 0, rect: { x: 0, y: 0, width: 200, height: 50 } } + ]) + const snapshot = captureLayoutSnapshot(droppable, 'missing') + expect(snapshot.draggedIndex).toBe(-1) + }) +}) + +describe('toViewportRect', () => { + it('reverses captureLayoutSnapshot — projects content space back to viewport', () => { + const containerRect = { left: 100, top: 200 } as DOMRect + const result = toViewportRect( + { slotId: 'a', position: 0, offsetLeft: 0, offsetTop: 60, width: 200, height: 50 }, + containerRect, + 0, + 0 + ) + expect(result).toEqual({ x: 100, y: 260, width: 200, height: 50 }) + }) + + it('subtracts the current scroll offset from the projected rect', () => { + const containerRect = { left: 0, top: 0 } as DOMRect + const result = toViewportRect( + { slotId: 'a', position: 0, offsetLeft: 0, offsetTop: 100, width: 200, height: 50 }, + containerRect, + 0, + 40 + ) + expect(result.y).toBe(60) + }) +}) diff --git a/packages/svelte-dnd/tsconfig.json b/packages/svelte-dnd/tsconfig.json new file mode 100644 index 0000000..ef685d4 --- /dev/null +++ b/packages/svelte-dnd/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "stripInternal": true, + "module": "NodeNext", + "moduleResolution": "NodeNext" + } +} diff --git a/packages/svelte-dnd/vite.config.ts b/packages/svelte-dnd/vite.config.ts new file mode 100644 index 0000000..9c691c5 --- /dev/null +++ b/packages/svelte-dnd/vite.config.ts @@ -0,0 +1,12 @@ +import { sveltekit } from '@sveltejs/kit/vite' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [sveltekit()], + test: { + environment: 'jsdom', + include: ['tests/**/*.test.ts'], + setupFiles: ['tests/setup.ts'] + }, + resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined +}) diff --git a/src/docs/components/contentsList.svelte b/src/docs/components/contentsList.svelte deleted file mode 100644 index dfac334..0000000 --- a/src/docs/components/contentsList.svelte +++ /dev/null @@ -1,199 +0,0 @@ - - - \ No newline at end of file diff --git a/src/docs/components/sidebar.svelte b/src/docs/components/sidebar.svelte deleted file mode 100644 index 8a4da9e..0000000 --- a/src/docs/components/sidebar.svelte +++ /dev/null @@ -1,83 +0,0 @@ - - -{#if $sidebarOpen} - -{/if} diff --git a/src/lib/components/DndDraggable.svelte b/src/lib/components/DndDraggable.svelte deleted file mode 100644 index 3b94fdc..0000000 --- a/src/lib/components/DndDraggable.svelte +++ /dev/null @@ -1,246 +0,0 @@ - - -
    - {@render children()} -
    - - diff --git a/src/lib/components/DndDroppable.svelte b/src/lib/components/DndDroppable.svelte deleted file mode 100644 index 8f2c4bd..0000000 --- a/src/lib/components/DndDroppable.svelte +++ /dev/null @@ -1,131 +0,0 @@ - - -
    - {@render children()} -
    - - diff --git a/src/lib/components/DndPreview.svelte b/src/lib/components/DndPreview.svelte deleted file mode 100644 index ba03cce..0000000 --- a/src/lib/components/DndPreview.svelte +++ /dev/null @@ -1,119 +0,0 @@ - - -{#if visible} -
    - {#if previewHeight > height * 0.85} -
    - {/if} -
    -{/if} - - \ No newline at end of file diff --git a/src/lib/components/DndProvider.svelte b/src/lib/components/DndProvider.svelte deleted file mode 100644 index 870d93a..0000000 --- a/src/lib/components/DndProvider.svelte +++ /dev/null @@ -1,132 +0,0 @@ - - -{@render children()} - -{#if (dragController.dragging || dragController.animatingReturn) && dragController.element && dragController.transform && dragController.size} -
    - {#if ghost && dragController.draggedItem} - {@render ghost({ - element: dragController.element, - data: dragController.draggedItemData, - itemId: dragController.draggedItem - })} - {:else} - {@html dragController.element.outerHTML} - {/if} -
    -{/if} - -{#if dragController.debugZones && dragController.dropZones} - {#each dragController.dragging ? dragController.filteredDropZones : dragController.dropZones as zone, index} -
    - - {zone.containerId} pos:{zone.position} - -
    - {/each} -{/if} - - diff --git a/src/lib/core/animation-controller.ts b/src/lib/core/animation-controller.ts deleted file mode 100644 index b312357..0000000 --- a/src/lib/core/animation-controller.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { DragState } from './drag-state.svelte.js' -import type { DropZone } from '../types.js' -import { ReturnAnimationStrategy } from './animation/strategies/return-animation.js' -import { DropAnimationStrategy } from './animation/strategies/drop-animation.js' - -export class AnimationController { - constructor(private state: DragState) {} - - animateReturn(onComplete?: () => void) { - const strategy = new ReturnAnimationStrategy(this.state) - strategy.execute(onComplete) - } - - animateToTarget(targetZone: DropZone, onComplete?: () => void) { - const strategy = new DropAnimationStrategy(this.state, targetZone) - strategy.execute(onComplete) - } -} diff --git a/src/lib/core/animation/strategies/animation-strategy.ts b/src/lib/core/animation/strategies/animation-strategy.ts deleted file mode 100644 index 7dc78f2..0000000 --- a/src/lib/core/animation/strategies/animation-strategy.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface AnimationStrategy { - execute(onComplete?: () => void): void -} diff --git a/src/lib/core/animation/strategies/drop-animation.ts b/src/lib/core/animation/strategies/drop-animation.ts deleted file mode 100644 index 6d142bb..0000000 --- a/src/lib/core/animation/strategies/drop-animation.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { AnimationStrategy } from './animation-strategy.js' -import type { DragState } from '../../drag-state.svelte.js' -import type { DropZone } from '../../../types.js' -import { DOMHelper } from '../../dom-helper.js' - -const ANIMATION_DURATION = 250 -const easing = { - outQuad: (t: number) => 1 - Math.pow(1 - t, 2) -} - -export class DropAnimationStrategy implements AnimationStrategy { - private domHelper = new DOMHelper() - - constructor( - private state: DragState, - private targetZone: DropZone - ) {} - - execute(onComplete?: () => void): void { - if (!this.state.element || !this.state.transform) { - onComplete?.() - return - } - - this.state.setAnimating(true) - - const startPos = { ...this.state.transform } - const startTime = Date.now() - - const animate = () => { - const elapsed = Date.now() - startTime - const progress = Math.min(elapsed / ANIMATION_DURATION, 1) - const easedProgress = easing.outQuad(progress) - - // Recalculate target position each frame in case container scrolls - const targetPos = this.calculatePlaceholderPosition() - - this.state.setTransform({ - x: startPos.x + (targetPos.x - startPos.x) * easedProgress, - y: startPos.y + (targetPos.y - startPos.y) * easedProgress - }) - - if (progress < 1) { - requestAnimationFrame(animate) - } else { - this.state.setAnimating(false) - onComplete?.() - } - } - - requestAnimationFrame(animate) - } - - private calculatePlaceholderPosition(): { x: number; y: number } { - const container = this.domHelper.findContainer(this.targetZone.containerId) - if (!container) { - return this.calculateFallbackPosition() - } - - const placeholder = this.domHelper.findPlaceholder(container, this.targetZone.position) - if (placeholder) { - const rect = placeholder.getBoundingClientRect() - return { x: rect.left, y: rect.top } - } - - const containerRect = container.getBoundingClientRect() - return { - x: containerRect.left, - y: this.targetZone.rect.y - } - } - - private calculateFallbackPosition(): { x: number; y: number } { - return { - x: this.targetZone.rect.x + (this.targetZone.rect.width - (this.state.size?.width || 0)) / 2, - y: this.targetZone.rect.y + (this.targetZone.rect.height - (this.state.size?.height || 0)) / 2 - } - } -} diff --git a/src/lib/core/animation/strategies/return-animation.ts b/src/lib/core/animation/strategies/return-animation.ts deleted file mode 100644 index 6d4e62c..0000000 --- a/src/lib/core/animation/strategies/return-animation.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { AnimationStrategy } from './animation-strategy.js' -import type { DragState } from '../../drag-state.svelte.js' -import { DOMHelper } from '../../dom-helper.js' -import { ScrollSyncAnimationStrategy } from './scroll-sync-animation.js' - -const ANIMATION_DURATION = 300 -const easing = { - outCubic: (t: number) => 1 - Math.pow(1 - t, 3) -} - -export class ReturnAnimationStrategy implements AnimationStrategy { - private domHelper = new DOMHelper() - - constructor(private state: DragState) {} - - execute(onComplete?: () => void): void { - if (!this.canAnimate()) { - onComplete?.() - return - } - - const originContainerId = this.state.originContainerId - const originPosition = this.state.originPosition - - if (!originContainerId) { - this.startSimpleReturnAnimation(onComplete) - return - } - - this.handlePlaceholderBasedReturn(originContainerId, originPosition, onComplete) - } - - private canAnimate(): boolean { - return !!(this.state.element && this.state.transform && this.state.originalPosition) - } - - private handlePlaceholderBasedReturn( - containerId: string, - position: number, - onComplete?: () => void - ): void { - const container = this.domHelper.findContainer(containerId) - if (!container) { - this.startSimpleReturnAnimation(onComplete) - return - } - - const placeholder = this.domHelper.findPlaceholder(container, position) - if (placeholder) { - this.handleFoundPlaceholder(container, placeholder, containerId, position, onComplete) - } else { - this.retryPlaceholderSearch(container, containerId, position, onComplete) - } - } - - private handleFoundPlaceholder( - container: HTMLElement, - placeholder: HTMLElement, - containerId: string, - position: number, - onComplete?: () => void - ): void { - if (this.domHelper.isElementVisibleInContainer(placeholder, container)) { - this.startSimpleReturnAnimation(onComplete) - } else { - // Delegate to scroll sync strategy - const scrollSyncStrategy = new ScrollSyncAnimationStrategy( - this.state, - containerId, - position - ) - scrollSyncStrategy.execute(onComplete) - } - } - - private retryPlaceholderSearch( - container: HTMLElement, - containerId: string, - position: number, - onComplete?: () => void - ): void { - // Placeholder might not be rendered yet, retry next frame - requestAnimationFrame(() => { - const placeholder = this.domHelper.findPlaceholder(container, position) - if (placeholder) { - this.handleFoundPlaceholder(container, placeholder, containerId, position, onComplete) - } else { - this.startSimpleReturnAnimation(onComplete) - } - }) - } - - private startSimpleReturnAnimation(onComplete?: () => void): void { - const originContainerId = this.state.originContainerId - const originPosition = this.state.originPosition - const fallbackPos = { ...this.state.originalPosition! } - const startPos = { ...this.state.transform! } - - this.state.setAnimating(true) - - const startTime = Date.now() - - const animate = () => { - const progress = Math.min((Date.now() - startTime) / ANIMATION_DURATION, 1) - const easedProgress = easing.outCubic(progress) - - const targetPos = this.getCurrentPlaceholderPosition( - originContainerId, - originPosition, - fallbackPos - ) - - this.state.setTransform({ - x: startPos.x + (targetPos.x - startPos.x) * easedProgress, - y: startPos.y + (targetPos.y - startPos.y) * easedProgress - }) - - if (progress < 1) { - requestAnimationFrame(animate) - } else { - this.state.setAnimating(false) - onComplete?.() - } - } - - requestAnimationFrame(animate) - } - - private getCurrentPlaceholderPosition( - containerId: string | null, - position: number, - fallback: { x: number; y: number } - ): { x: number; y: number } { - if (!containerId) return fallback - - const container = this.domHelper.findContainer(containerId) - if (!container) return fallback - - const placeholder = this.domHelper.findPlaceholder(container, position) - if (!placeholder) return fallback - - const rect = placeholder.getBoundingClientRect() - return { x: rect.left, y: rect.top } - } -} diff --git a/src/lib/core/animation/strategies/scroll-sync-animation.ts b/src/lib/core/animation/strategies/scroll-sync-animation.ts deleted file mode 100644 index bffac68..0000000 --- a/src/lib/core/animation/strategies/scroll-sync-animation.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { AnimationStrategy } from './animation-strategy.js' -import type { DragState } from '../../drag-state.svelte.js' -import { DOMHelper } from '../../dom-helper.js' -import { ScrollSyncCalculator } from '../scroll-sync-calculator.js' -import { getDirectionAdapter } from '../direction-adapter.js' - -const easing = { - outCubic: (t: number) => 1 - Math.pow(1 - t, 3) -} - -export class ScrollSyncAnimationStrategy implements AnimationStrategy { - private domHelper = new DOMHelper() - private scrollCalc = new ScrollSyncCalculator() - - constructor( - private state: DragState, - private containerId: string, - private position: number - ) {} - - execute(onComplete?: () => void): void { - const container = this.domHelper.findContainer(this.containerId) - if (!container) { - onComplete?.() - return - } - - const placeholder = this.domHelper.findPlaceholder(container, this.position) - if (!placeholder) { - // Retry next frame if placeholder not rendered yet - requestAnimationFrame(() => { - const retryPlaceholder = this.domHelper.findPlaceholder(container, this.position) - if (retryPlaceholder) { - this.executeScrollSync(container, retryPlaceholder, onComplete) - } else { - onComplete?.() - } - }) - return - } - - this.executeScrollSync(container, placeholder, onComplete) - } - - private executeScrollSync( - container: HTMLElement, - placeholder: HTMLElement, - onComplete?: () => void - ): void { - this.state.setAnimating(true) - - const direction = this.domHelper.getContainerDirection(container) - const adapter = getDirectionAdapter(direction) - - const startScroll = adapter.getScroll(container) - const startGhostPos = { ...this.state.transform! } - const placeholderRect = placeholder.getBoundingClientRect() - - const expectedSize = - direction === 'horizontal' - ? this.state.dropPreview?.draggedElementWidth || this.state.elementSize?.width || 0 - : this.state.dropPreview?.draggedElementHeight || this.state.elementSize?.height || 0 - - const { targetScroll, scrollDelta } = this.scrollCalc.calculateScrollTarget({ - placeholder, - container, - expectedSize, - direction - }) - - const scrollDistance = Math.abs(scrollDelta) - const duration = this.scrollCalc.calculateAdaptiveDuration(scrollDistance) - - const finalGhostPos = this.scrollCalc.calculateFinalGhostPosition({ - placeholderRect, - scrollDelta, - direction - }) - - this.runScrollAnimation({ - container, - placeholder, - adapter, - startScroll, - targetScroll, - scrollDelta, - startGhostPos, - finalGhostPos, - duration, - onComplete - }) - } - - private runScrollAnimation(params: { - container: HTMLElement - placeholder: HTMLElement - adapter: ReturnType - startScroll: number - targetScroll: number - scrollDelta: number - startGhostPos: { x: number; y: number } - finalGhostPos: { x: number; y: number } - duration: number - onComplete?: () => void - }): void { - const startTime = Date.now() - - const animate = () => { - const progress = Math.min((Date.now() - startTime) / params.duration, 1) - const easedProgress = easing.outCubic(progress) - - params.adapter.setScroll( - params.container, - params.startScroll + params.scrollDelta * easedProgress - ) - - this.state.setTransform({ - x: params.startGhostPos.x + (params.finalGhostPos.x - params.startGhostPos.x) * easedProgress, - y: params.startGhostPos.y + (params.finalGhostPos.y - params.startGhostPos.y) * easedProgress - }) - - if (progress < 1) { - requestAnimationFrame(animate) - } else { - this.finalizeScrollAnimation(params.container, params.placeholder, params.adapter, params.targetScroll, params.onComplete) - } - } - - requestAnimationFrame(animate) - } - - private finalizeScrollAnimation( - container: HTMLElement, - placeholder: HTMLElement, - adapter: ReturnType, - targetScroll: number, - onComplete?: () => void - ): void { - adapter.setScroll(container, targetScroll) - const finalRect = placeholder.getBoundingClientRect() - this.state.setTransform({ x: finalRect.left, y: finalRect.top }) - this.state.setAnimating(false) - onComplete?.() - } -} diff --git a/src/lib/core/dom-helper.ts b/src/lib/core/dom-helper.ts deleted file mode 100644 index 00a983f..0000000 --- a/src/lib/core/dom-helper.ts +++ /dev/null @@ -1,57 +0,0 @@ -// DOM selectors -const SELECTORS = { - container: (id: string) => `[data-dnd-drop-id="${id}"]`, - placeholder: (position: number) => `[data-dnd-preview-position="${position}"]`, - draggableItems: ':scope > [data-dnd-draggable-item]' -} as const - -export class DOMHelper { - // Container queries - findContainer(containerId: string): HTMLElement | null { - return document.querySelector(SELECTORS.container(containerId)) - } - - getContainerRect(containerId: string): DOMRect | null { - const container = this.findContainer(containerId) - return container ? container.getBoundingClientRect() : null - } - - getContainerDirection(container: HTMLElement): 'vertical' | 'horizontal' { - return (container.dataset.dndDirection as 'vertical' | 'horizontal') || 'vertical' - } - - // Placeholder queries - findPlaceholder(container: HTMLElement, position: number): HTMLElement | null { - return container.querySelector(SELECTORS.placeholder(position)) - } - - // Draggable items queries - findDraggableItems(container: HTMLElement): HTMLElement[] { - return Array.from(container.querySelectorAll(SELECTORS.draggableItems)) - } - - filterItemsByContainer(items: HTMLElement[], containerElement: HTMLElement): HTMLElement[] { - return items.filter((item) => { - const closestDropZone = item.closest('[data-dnd-drop-id]') - return closestDropZone === containerElement - }) - } - - // Visibility checks - isElementVisibleInContainer(element: HTMLElement, container: HTMLElement): boolean { - const containerRect = container.getBoundingClientRect() - const elementRect = element.getBoundingClientRect() - - return ( - elementRect.top >= containerRect.top && - elementRect.bottom <= containerRect.bottom && - elementRect.left >= containerRect.left && - elementRect.right <= containerRect.right - ) - } - - // Rect helpers - getRect(element: HTMLElement): DOMRect { - return element.getBoundingClientRect() - } -} diff --git a/src/lib/core/drag-controller.svelte.ts b/src/lib/core/drag-controller.svelte.ts deleted file mode 100644 index 59e7038..0000000 --- a/src/lib/core/drag-controller.svelte.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { DragState } from './drag-state.svelte.js' -import { AnimationController } from './animation-controller.js' -import { ScrollController } from './scroll-controller.js' -import { DropZoneCalculator } from './dropzone-calculator.js' -import type { DndDragEvent, DndDropEvent, DropZone, DndDirection } from '../types.js' - -export type DragStartCallback = (itemId: string) => void -export type DragEndCallback = (itemId: string) => void -export type DropCallback = ( - sourceId: string, - sourceData: any, - targetContainerId: string, - position: number -) => void - -export class DragController { - private state = new DragState() - private animationController = new AnimationController(this.state) - private scrollController = new ScrollController(this.state, { - onZoneRefresh: () => this.refreshDropZones(), - onMouseUpdate: (x, y) => this.updateMousePosition(x, y) - }) - private droppableDataRegistry = new Map>() - private dropZoneCalculator = new DropZoneCalculator(this.state, this.droppableDataRegistry) - - private dragStartCallbacks = new Set() - private dragEndCallbacks = new Set() - private dropCallbacks = new Set() - - get dragging() { - return this.state.dragging - } - get element() { - return this.state.element - } - get transform() { - return this.state.transform - } - get draggedItem() { - return this.state.draggedItem - } - get draggedType() { - return this.state.draggedType - } - get draggedItemData() { - return this.state.draggedItemData - } - get size() { - return this.state.size - } - get animatingReturn() { - return this.state.animating - } - get dropPreview() { - return this.state.dropPreview - } - get dropZones() { - return this.state.zones - } - get debugZones() { - return this.state.debugZones - } - get filteredDropZones() { - return this.dropZoneCalculator.filterZonesByDraggedItemType( - this.state.zones, - this.state.draggedItem || '' - ) - } - get performingDrop() { - return this.state.performingDrop - } - get skipDropPreviewAnimation() { - return this.state.skipDropPreviewAnimation - } - - setSkipDropPreviewAnimation(value: boolean) { - this.state.setSkipDropPreviewAnimation(value) - } - - registerDroppableData(id: string, data: Record) { - this.droppableDataRegistry.set(id, data) - } - - unregisterDroppableData(id: string) { - this.droppableDataRegistry.delete(id) - } - - onDragStart(callback: DragStartCallback) { - this.dragStartCallbacks.add(callback) - return () => this.dragStartCallbacks.delete(callback) - } - - onDragEnd(callback: DragEndCallback) { - this.dragEndCallbacks.add(callback) - return () => this.dragEndCallbacks.delete(callback) - } - - onDrop(callback: DropCallback) { - this.dropCallbacks.add(callback) - return () => this.dropCallbacks.delete(callback) - } - - startDrag( - element: HTMLElement, - itemId: string, - initialPosition: { x: number; y: number }, - data?: Record - ) { - const rect = element.getBoundingClientRect() - - const containerEl = element.closest('[data-dnd-drop-id]') - if (containerEl) { - const containerId = containerEl.getAttribute('data-dnd-drop-id')! - const items = containerEl.querySelectorAll(':scope > [data-dnd-draggable-item]') - const position = Array.from(items).indexOf(element) - this.state.setOriginContainerId(containerId) - this.state.setOriginPosition(position >= 0 ? position : 0) - } - - this.state.setDragging(true) - this.state.setElement(element) - this.state.setDraggedItemId(itemId) - this.state.setDraggedItemType(data?.type || null) - this.state.setDraggedItemData(data) - this.state.setTransform(initialPosition) - this.state.setElementSize({ - width: element.offsetWidth, - height: element.offsetHeight - }) - this.state.setOriginalPosition({ - x: rect.left, - y: rect.top - }) - - this.state.setSkipDropPreviewAnimation(true) - this.notifyDragStart(itemId) - } - - updateTransform(transform: { x: number; y: number }) { - this.state.setTransform(transform) - } - - updateMousePosition(mouseX: number, mouseY: number) { - if (this.state.dragging) { - const ghostCenter = this.getGhostCenter() - this.dropZoneCalculator.updateDropPreview(ghostCenter) - this.scrollController.handleAutoScroll(mouseX, mouseY) - } - } - - private getGhostCenter(): { x: number; y: number } { - const transform = this.state.transform - const size = this.state.elementSize - if (transform && size) { - return { - x: transform.x + size.width / 2, - y: transform.y + size.height / 2 - } - } - return this.state.transform ?? { x: 0, y: 0 } - } - - performDrop( - sourceId: string, - sourceData: any, - targetContainerId: string, - position: number - ) { - const targetZone = this.state.zones.find( - (zone) => zone.containerId === targetContainerId && zone.position === position - ) - - this.state.setPerformingDrop(true) - - if (targetZone && this.state.element && this.state.transform) { - this.animationController.animateToTarget(targetZone, () => { - this.state.setDropPreview(null) - this.notifyDrop(sourceId, sourceData, targetContainerId, position) - this.finalizeDragEnd(sourceId) - }) - } else { - this.state.setDropPreview(null) - this.notifyDrop(sourceId, sourceData, targetContainerId, position) - this.finalizeDragEnd(sourceId) - } - } - - endDrag(shouldAnimate = true) { - const itemId = this.state.draggedItem - - if (shouldAnimate && this.state.originContainerId !== null) { - this.state.setDropPreview({ - containerId: this.state.originContainerId, - position: this.state.originPosition, - visible: true, - draggedElementHeight: this.state.elementSize?.height, - draggedElementWidth: this.state.elementSize?.width - }) - } - - if (shouldAnimate && this.state.element && this.state.transform) { - // Wait for next frame to ensure placeholder is rendered in DOM - requestAnimationFrame(() => { - this.animationController.animateReturn(() => { - this.finalizeDragEnd(itemId) - }) - }) - } else { - this.finalizeDragEnd(itemId) - } - - setTimeout(() => { - this.state.setSkipDropPreviewAnimation(false) - }, 100) - } - - registerDropZones(zones: DropZone[]) { - this.state.setDropZones(zones) - } - - refreshDropZones() { - this.notifyDragStart(this.state.draggedItem || '') - } - - calculateDropZones( - containerId: string, - containerElement: HTMLElement, - direction: DndDirection = 'vertical' - ): DropZone[] { - return this.dropZoneCalculator.calculateDropZones(containerId, containerElement, direction) - } - - mergeDropZones( - existingZones: DropZone[], - newZones: DropZone[], - containerId: string - ): DropZone[] { - return this.dropZoneCalculator.mergeZones(existingZones, newZones, containerId) - } - - toggleDebugZones() { - this.state.toggleDebugZones() - } - - private finalizeDragEnd(itemId: string | null) { - this.state.setSkipDropPreviewAnimation(true) - this.scrollController.clearAll() - this.state.reset() - - if (itemId) { - this.notifyDragEnd(itemId) - } - - setTimeout(() => { - this.state.setSkipDropPreviewAnimation(false) - }, 100) - } - - private notifyDragStart(itemId: string) { - this.dragStartCallbacks.forEach((callback) => callback(itemId)) - } - - private notifyDragEnd(itemId: string) { - this.dragEndCallbacks.forEach((callback) => callback(itemId)) - } - - private notifyDrop( - sourceId: string, - sourceData: any, - targetContainerId: string, - position: number - ) { - this.dropCallbacks.forEach((callback) => - callback(sourceId, sourceData, targetContainerId, position) - ) - } - - destroy() { - this.scrollController.destroy() - this.droppableDataRegistry.clear() - this.dragStartCallbacks.clear() - this.dragEndCallbacks.clear() - this.dropCallbacks.clear() - } -} diff --git a/src/lib/core/drag-state.svelte.ts b/src/lib/core/drag-state.svelte.ts deleted file mode 100644 index ccbb077..0000000 --- a/src/lib/core/drag-state.svelte.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { DropZone, DropPreview } from '../types.js' - -export class DragState { - isDragging = $state(false) - dragElement = $state(null) - draggedItemId = $state(null) - draggedItemType = $state(null) - draggedItemData = $state | undefined>(undefined) - currentTransform = $state<{ x: number; y: number } | null>(null) - elementSize = $state<{ width: number; height: number } | null>(null) - originalPosition = $state<{ x: number; y: number } | null>(null) - isAnimating = $state(false) - dropZones = $state([]) - currentDropPreview = $state(null) - showDebugZones = $state(false) - isPerformingDrop = $state(false) - shouldSkipDropPreviewAnimation = $state(false) - originContainerId = $state(null) - originPosition = $state(0) - - get dragging() { - return this.isDragging - } - - get element() { - return this.dragElement - } - - get transform() { - return this.currentTransform - } - - get draggedItem() { - return this.draggedItemId - } - - get draggedType() { - return this.draggedItemType - } - - get size() { - return this.elementSize - } - - get animating() { - return this.isAnimating - } - - get dropPreview() { - return this.currentDropPreview - } - - get zones() { - return this.dropZones - } - - get debugZones() { - return this.showDebugZones - } - - get performingDrop() { - return this.isPerformingDrop - } - - get skipDropPreviewAnimation() { - return this.shouldSkipDropPreviewAnimation - } - - setDragging(value: boolean) { - this.isDragging = value - } - - setElement(element: HTMLElement | null) { - this.dragElement = element - } - - setDraggedItemId(id: string | null) { - this.draggedItemId = id - } - - setDraggedItemType(type: string | null) { - this.draggedItemType = type - } - - setDraggedItemData(data: Record | undefined) { - this.draggedItemData = data - } - - setTransform(transform: { x: number; y: number } | null) { - this.currentTransform = transform - } - - setElementSize(size: { width: number; height: number } | null) { - this.elementSize = size - } - - setOriginalPosition(position: { x: number; y: number } | null) { - this.originalPosition = position - } - - setAnimating(value: boolean) { - this.isAnimating = value - } - - setDropZones(zones: DropZone[]) { - this.dropZones = zones - } - - setDropPreview(preview: DropPreview | null) { - this.currentDropPreview = preview - } - - setDebugZones(value: boolean) { - this.showDebugZones = value - } - - setPerformingDrop(value: boolean) { - this.isPerformingDrop = value - } - - setSkipDropPreviewAnimation(value: boolean) { - this.shouldSkipDropPreviewAnimation = value - } - - setOriginContainerId(id: string | null) { - this.originContainerId = id - } - - setOriginPosition(position: number) { - this.originPosition = position - } - - toggleDebugZones() { - this.showDebugZones = !this.showDebugZones - } - - reset() { - this.isDragging = false - this.dragElement = null - this.currentTransform = null - this.draggedItemId = null - this.draggedItemType = null - this.draggedItemData = undefined - this.elementSize = null - this.originalPosition = null - this.isAnimating = false - this.isPerformingDrop = false - this.originContainerId = null - this.originPosition = 0 - this.dropZones = [] - this.currentDropPreview = null - } -} diff --git a/src/lib/core/dropzone-calculator.ts b/src/lib/core/dropzone-calculator.ts deleted file mode 100644 index 65ae066..0000000 --- a/src/lib/core/dropzone-calculator.ts +++ /dev/null @@ -1,376 +0,0 @@ -import type { DropZone, DropPreview, DndDirection } from '../types.js' -import type { DragState } from './drag-state.svelte.js' -import { DOMHelper } from './dom-helper.js' - -export class DropZoneCalculator { - private domHelper = new DOMHelper() - - constructor( - private state: DragState, - private droppableDataRegistry: Map> - ) {} - - calculateDropZones( - containerId: string, - containerElement: HTMLElement, - direction: DndDirection = 'vertical' - ): DropZone[] { - if (!containerElement) return [] - - const containerRect = this.domHelper.getRect(containerElement) - - const allDraggableItems = this.domHelper.findDraggableItems(containerElement) - const draggedId = this.state.draggedItem - const draggableItems = allDraggableItems.filter((item) => { - if (item.getAttribute('data-dnd-drag-id') === draggedId) return false - return this.domHelper.filterItemsByContainer([item], containerElement).length > 0 - }) - - if (draggableItems.length === 0) { - return this.createEmptyContainerZone(containerId, containerRect, direction) - } - - switch (direction) { - case 'horizontal': - return this.createHorizontalZones(containerId, containerRect, draggableItems) - case 'grid': - return this.createGridZones(containerId, containerRect, draggableItems) - default: - return this.createVerticalZones(containerId, containerRect, draggableItems) - } - } - - private createEmptyContainerZone( - containerId: string, - containerRect: DOMRect, - direction: DndDirection - ): DropZone[] { - return [ - { - containerId, - position: 0, - direction, - rect: { - x: containerRect.left, - y: containerRect.top, - width: containerRect.width, - height: Math.max(containerRect.height, 20) - } - } - ] - } - - private createVerticalZones( - containerId: string, - containerRect: DOMRect, - items: HTMLElement[] - ): DropZone[] { - const zones: DropZone[] = [] - - items.forEach((item, index) => { - const itemRect = this.domHelper.getRect(item) - const halfHeight = itemRect.height / 2 - - if (index === 0) { - const zoneTop = Math.min(containerRect.top, itemRect.top) - const zoneHeight = Math.max( - halfHeight, - itemRect.top - containerRect.top + halfHeight - ) - - zones.push({ - containerId, - position: 0, - direction: 'vertical', - rect: { - x: containerRect.left, - y: zoneTop, - width: containerRect.width, - height: zoneHeight - } - }) - } - - const nextItem = items[index + 1] - const zoneY = itemRect.top + halfHeight - let zoneHeight = halfHeight - - if (nextItem) { - const nextItemRect = this.domHelper.getRect(nextItem) - const nextHalfHeight = nextItemRect.height / 2 - const gapBetweenItems = nextItemRect.top - itemRect.bottom - zoneHeight = halfHeight + gapBetweenItems + nextHalfHeight - } else { - const remainingSpace = containerRect.bottom - zoneY - zoneHeight = Math.max(halfHeight, remainingSpace) - } - - zones.push({ - containerId, - position: index + 1, - direction: 'vertical', - rect: { - x: containerRect.left, - y: zoneY, - width: containerRect.width, - height: zoneHeight - } - }) - }) - - return zones - } - - private createHorizontalZones( - containerId: string, - containerRect: DOMRect, - items: HTMLElement[] - ): DropZone[] { - const zones: DropZone[] = [] - - items.forEach((item, index) => { - const itemRect = this.domHelper.getRect(item) - const halfWidth = itemRect.width / 2 - - if (index === 0) { - const zoneLeft = Math.min(containerRect.left, itemRect.left) - const zoneWidth = Math.max( - halfWidth, - itemRect.left - containerRect.left + halfWidth - ) - - zones.push({ - containerId, - position: 0, - direction: 'horizontal', - rect: { - x: zoneLeft, - y: containerRect.top, - width: zoneWidth, - height: containerRect.height - } - }) - } - - const nextItem = items[index + 1] - const zoneX = itemRect.left + halfWidth - let zoneWidth = halfWidth - - if (nextItem) { - const nextItemRect = this.domHelper.getRect(nextItem) - const nextHalfWidth = nextItemRect.width / 2 - const gapBetweenItems = nextItemRect.left - itemRect.right - zoneWidth = halfWidth + gapBetweenItems + nextHalfWidth - } else { - const remainingSpace = containerRect.right - zoneX - zoneWidth = Math.max(halfWidth, remainingSpace) - } - - zones.push({ - containerId, - position: index + 1, - direction: 'horizontal', - rect: { - x: zoneX, - y: containerRect.top, - width: zoneWidth, - height: containerRect.height - } - }) - }) - - return zones - } - - private createGridZones( - containerId: string, - containerRect: DOMRect, - items: HTMLElement[] - ): DropZone[] { - const zones: DropZone[] = [] - const rows = this.groupItemsIntoRows(items) - let positionIndex = 0 - - rows.forEach((row, rowIndex) => { - const nextRow = rows[rowIndex + 1] - - row.forEach((item, colIndex) => { - const itemRect = this.domHelper.getRect(item) - const halfWidth = itemRect.width / 2 - const halfHeight = itemRect.height / 2 - - const zoneTop = - rowIndex === 0 - ? Math.min(containerRect.top, itemRect.top) - : itemRect.top - halfHeight - const zoneBottom = nextRow - ? itemRect.bottom + (this.domHelper.getRect(nextRow[0]).top - itemRect.bottom) / 2 - : Math.max(containerRect.bottom, itemRect.bottom) - - if (colIndex === 0) { - const zoneLeft = Math.min(containerRect.left, itemRect.left) - const zoneRight = itemRect.left + halfWidth - - zones.push({ - containerId, - position: positionIndex, - direction: 'grid', - rect: { - x: zoneLeft, - y: zoneTop, - width: zoneRight - zoneLeft, - height: zoneBottom - zoneTop - } - }) - positionIndex++ - } - - const nextItem = row[colIndex + 1] - const zoneLeft = itemRect.left + halfWidth - let zoneRight: number - - if (nextItem) { - const nextRect = this.domHelper.getRect(nextItem) - zoneRight = nextRect.left + nextRect.width / 2 - } else { - zoneRight = Math.max(containerRect.right, itemRect.right) - } - - zones.push({ - containerId, - position: positionIndex, - direction: 'grid', - rect: { - x: zoneLeft, - y: zoneTop, - width: zoneRight - zoneLeft, - height: zoneBottom - zoneTop - } - }) - positionIndex++ - }) - }) - - return zones - } - - private groupItemsIntoRows(items: HTMLElement[]): HTMLElement[][] { - if (items.length === 0) return [] - - const rows: HTMLElement[][] = [] - let currentRow: HTMLElement[] = [items[0]] - let currentRowTop = this.domHelper.getRect(items[0]).top - - for (let i = 1; i < items.length; i++) { - const itemRect = this.domHelper.getRect(items[i]) - const rowThreshold = this.domHelper.getRect(items[i]).height * 0.5 - - if (Math.abs(itemRect.top - currentRowTop) < rowThreshold) { - currentRow.push(items[i]) - } else { - rows.push(currentRow) - currentRow = [items[i]] - currentRowTop = itemRect.top - } - } - - rows.push(currentRow) - return rows - } - - updateDropPreview(mousePos: { x: number; y: number }) { - if (!this.state.dragging) { - this.state.setDropPreview(null) - return - } - - const targetZone = this.findZoneAtPosition(mousePos) - - if (targetZone) { - const preview: DropPreview = { - containerId: targetZone.containerId, - position: targetZone.position, - visible: true, - draggedElementHeight: this.state.size?.height, - draggedElementWidth: this.state.size?.width - } - this.state.setDropPreview(preview) - - if (this.state.skipDropPreviewAnimation) { - requestAnimationFrame(() => { - this.state.setSkipDropPreviewAnimation(false) - }) - } - } else { - this.state.setDropPreview(null) - } - } - - private findZoneAtPosition(mousePos: { x: number; y: number }): DropZone | null { - const draggedItemId = this.state.draggedItem - if (!draggedItemId) return null - - const filteredZones = this.filterZonesByDraggedItemType( - this.state.zones, - draggedItemId - ) - - for (const zone of filteredZones) { - if (this.isPointInZone(mousePos, zone) && this.isPointInContainer(mousePos, zone.containerId)) { - return zone - } - } - return null - } - - private isPointInContainer(point: { x: number; y: number }, containerId: string): boolean { - const containerElement = this.domHelper.findContainer(containerId) - if (!containerElement) return true - - const rect = this.domHelper.getRect(containerElement) - return ( - point.x >= rect.left && - point.x <= rect.right && - point.y >= rect.top && - point.y <= rect.bottom - ) - } - - filterZonesByDraggedItemType(zones: DropZone[], draggedItemId: string): DropZone[] { - const draggedType = this.state.draggedType - if (!draggedType) return zones - - return zones.filter((zone) => { - const droppableData = this.droppableDataRegistry.get(zone.containerId) - if (!droppableData) return true - - const accepts = droppableData.accepts || droppableData.type - - if (!accepts) return true - - if (Array.isArray(accepts)) { - return accepts.includes(draggedType) - } - - return accepts === draggedType - }) - } - - private isPointInZone(point: { x: number; y: number }, zone: DropZone): boolean { - return ( - point.x >= zone.rect.x && - point.x <= zone.rect.x + zone.rect.width && - point.y >= zone.rect.y && - point.y <= zone.rect.y + zone.rect.height - ) - } - - mergeZones( - existingZones: DropZone[], - newZones: DropZone[], - containerId: string - ): DropZone[] { - const otherZones = existingZones.filter((z) => z.containerId !== containerId) - return [...otherZones, ...newZones] - } -} diff --git a/src/lib/index.ts b/src/lib/index.ts deleted file mode 100644 index 4a90b73..0000000 --- a/src/lib/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { default as DndProvider } from './components/DndProvider.svelte' -export { default as DndDraggable } from './components/DndDraggable.svelte' -export { default as DndDroppable } from './components/DndDroppable.svelte' -export { default as DndPreview } from './components/DndPreview.svelte' -export { DragController } from './core/drag-controller.svelte.js' -export { createConditionalSlide, createConditionalScale } from './utils/conditional-transition.js' -export type { DndDragEvent, DndDropEvent, DropZone, DropPreview, DndDirection } from './types.js' diff --git a/src/lib/types.ts b/src/lib/types.ts deleted file mode 100644 index ebfed3a..0000000 --- a/src/lib/types.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Snippet } from 'svelte' - -export type DndDirection = 'vertical' | 'horizontal' | 'grid' - -export interface DndDragEvent { - source: { - id: string - element: HTMLElement - data?: Record - } - target?: { - id: string - element: HTMLElement - data?: Record - } | null - transform: { - x: number - y: number - } -} - -export interface DndDropEvent { - source: { - id: string - element: HTMLElement - data?: Record - } - target: { - id: string - element: HTMLElement - data?: Record - } | null -} - -export interface DropZone { - containerId: string - position: number - direction: DndDirection - itemId?: string - rect: { - x: number - y: number - width: number - height: number - } -} - -export interface DropPreview { - containerId: string - position: number - visible: boolean - draggedElementHeight?: number - draggedElementWidth?: number -} - -export interface GhostSnippetProps { - element: HTMLElement - data?: Record - itemId: string -} - -export type GhostSnippet = Snippet<[GhostSnippetProps]> diff --git a/src/lib/utils/conditional-transition.ts b/src/lib/utils/conditional-transition.ts deleted file mode 100644 index f915243..0000000 --- a/src/lib/utils/conditional-transition.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { slide, scale } from 'svelte/transition' -import type { DragController } from '../core/drag-controller.svelte.js' - -export function createConditionalSlide(dndManager: DragController) { - return function conditionalSlide(node: HTMLElement, options: any) { - if (dndManager?.skipDropPreviewAnimation) { - return { duration: 0 } - } - if (dndManager?.animatingReturn) { - return slide(node, { ...options, duration: 200 }) - } - return slide(node, options) - } -} - -export function createConditionalScale(dndManager: DragController) { - return function conditionalScale(node: HTMLElement, options: any) { - if (dndManager?.skipDropPreviewAnimation) { - return { duration: 0 } - } - if (dndManager?.animatingReturn) { - return scale(node, { ...options, duration: 100 }) - } - return scale(node, options) - } -} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte deleted file mode 100644 index 92f42cf..0000000 --- a/src/routes/+page.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - -
    -
    -

    svelte-dnd

    -

    - A drag-and-drop library for Svelte 5 with support for vertical, horizontal, and grid layouts. -

    -
    - - - -
    -
    -

    Examples

    -

    See interactive demos of drag-and-drop in action.

    - - View Examples → - -
    - -
    -

    Documentation

    -

    Learn how to install and use the library.

    - - Read Docs → - -
    -
    -
    diff --git a/src/routes/docs/+layout.svelte b/src/routes/docs/+layout.svelte deleted file mode 100644 index 5ef1466..0000000 --- a/src/routes/docs/+layout.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - -
    -
    - {@render children()} -
    - - -
    \ No newline at end of file diff --git a/src/routes/docs/components-api/+page.md b/src/routes/docs/components-api/+page.md deleted file mode 100644 index f8d8207..0000000 --- a/src/routes/docs/components-api/+page.md +++ /dev/null @@ -1,172 +0,0 @@ -# Components API - -## DndProvider - -Wraps your drag-and-drop area and provides the `DragController` context to all child components. - -### Props - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `controller` | `DragController` | auto-created | Optional pre-created controller instance | -| `ghost` | `Snippet<[GhostSnippetProps]>` | — | Custom ghost element renderer during drag | - -### GhostSnippetProps - -```ts -interface GhostSnippetProps { - element: HTMLElement; - data?: Record; - itemId: string; -} -``` - -If no `ghost` snippet is provided, the dragged element's HTML is cloned as the ghost. - ---- - -## DndDraggable - -Wraps a single draggable element. Must be a child of `DndDroppable` inside a `DndProvider`. - -### Props - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `id` | `string` | **required** | Unique identifier for this draggable item | -| `data` | `Record` | `{}` | Arbitrary data attached to the item | -| `disabled` | `boolean` | `false` | Disables dragging when `true` | -| `class` | `string` | — | Additional CSS class names | - -### Events - -| Event | Type | Description | -|-------|------|-------------| -| `onDragStart` | `(event: DndDragEvent) => void` | Fired when the drag begins | -| `onDrag` | `(event: DndDragEvent) => void` | Fired on every pointer move during drag | -| `onDragEnd` | `(event: DndDragEvent) => void` | Fired when the drag ends | - -### No drag - -Add `data-dnd-no-drag` to any element inside `DndDraggable` to prevent drag handling on it. Useful for buttons, inputs, and links that need to receive native events. - -```svelte - - - {task.label} - -``` - -### Drag handle - -Add `data-dnd-handle` to restrict dragging to specific elements. When at least one handle is present, `data-dnd-no-drag` is ignored — only handle elements can initiate a drag. - -```svelte - -

    ☰ {column.title}

    -
    -
    -``` - - -### DndDragEvent - -```ts -interface DndDragEvent { - source: { - id: string; - element: HTMLElement; - data?: Record; - }; - target?: { - id: string; - element: HTMLElement; - data?: Record; - } | null; - transform: { x: number; y: number }; -} -``` - ---- - -## DndDroppable - -Defines a container that accepts draggable items. Automatically registers and calculates drop zones. - -### Props - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `id` | `string` | **required** | Unique container identifier | -| `data` | `Record` | `{}` | Container data (supports `type` or `accepts` for filtering) | -| `disabled` | `boolean` | `false` | Disables dropping when `true` | -| `direction` | `DndDirection` | `'vertical'` | Layout direction: `'vertical'`, `'horizontal'` | -| `class` | `string` | — | Additional CSS class names | - -### Auto-scroll - -`DndDroppable` automatically marks itself with a `data-dnd-scroll` attribute. The auto-scroll feature only activates for containers that carry this attribute, so only `DndDroppable` elements (not arbitrary scrollable ancestors) will be scrolled during a drag. - -### Type Filtering - -Use the `data` prop to control which items can be dropped into a container. The `accepts` field can be a string or an array of strings: - -```svelte - - - - - -``` - ---- - -## DndPreview - -Renders a placeholder at a specific position within a container to indicate where a dragged item will be dropped. - -### Props - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `containerId` | `string` | **required** | Which container this preview belongs to | -| `position` | `number` | **required** | Position index within the container | -| `show` | `boolean` | `true` | Whether the preview should be visible | -| `direction` | `DndDirection` | `'vertical'` | Layout direction — must match the parent `DndDroppable` | -| `fallbackHeight` | `number` | `48` | Default height in pixels if element height is unknown | -| `fallbackWidth` | `number` | `48` | Default width in pixels if element width is unknown | -| `class` | `string` | — | Additional CSS class names | - -### Usage Pattern - -Place a `DndPreview` before each `DndDraggable` and one after the last item: - -```svelte -{#each items as item, index (item.id)} - - ... -{/each} - - -``` - ---- - -## DndDropEvent - -Exported type describing a drop event (available from `@horuse/svelte-dnd`). - -```ts -interface DndDropEvent { - source: { - id: string; - element: HTMLElement; - data?: Record; - }; - target: { - id: string; - element: HTMLElement; - data?: Record; - } | null; -} -``` diff --git a/src/routes/docs/drag-controller-api/+page.md b/src/routes/docs/drag-controller-api/+page.md deleted file mode 100644 index 9a4c703..0000000 --- a/src/routes/docs/drag-controller-api/+page.md +++ /dev/null @@ -1,222 +0,0 @@ -# DragController API - -The `DragController` is the core engine that manages all drag-and-drop state and logic. You can create one explicitly or let `DndProvider` create one automatically. - -## Constructor - -```ts -import { DragController } from '@horuse/svelte-dnd'; - -const controller = new DragController(); -``` - -Pass it to the provider via the `controller` prop: - -```svelte - - - -``` - -This gives you direct access to the controller's methods and reactive getters. - -## Reactive Getters - -All getters are Svelte 5 reactive (`$state` / `$derived` internally). - -| Getter | Type | Description | -|--------|------|-------------| -| `dragging` | `boolean` | Whether a drag is currently in progress | -| `element` | `HTMLElement \| null` | The DOM element being dragged | -| `transform` | `{ x: number; y: number } \| null` | Current ghost position | -| `draggedItem` | `string \| null` | ID of the item being dragged | -| `draggedType` | `string \| null` | Type of the dragged item (from `data.type`) | -| `draggedItemData` | `Record \| undefined` | Data attached to the dragged item | -| `size` | `{ width: number; height: number } \| null` | Size of the dragged element | -| `animatingReturn` | `boolean` | Whether the ghost is animating back to origin | -| `dropPreview` | `DropPreview \| null` | Current drop preview state | -| `dropZones` | `DropZone[]` | All registered drop zones | -| `filteredDropZones` | `DropZone[]` | Drop zones filtered by the dragged item's type | -| `debugZones` | `boolean` | Whether debug zone visualization is enabled | -| `performingDrop` | `boolean` | Whether a drop animation is in progress | -| `skipDropPreviewAnimation` | `boolean` | Whether preview animations are skipped | - -## Event Callbacks - -All event methods return an unsubscribe function. - -### onDragStart - -```ts -const unsubscribe = controller.onDragStart((itemId: string) => { - console.log('Started dragging:', itemId); -}); - -// Later: unsubscribe() -``` - -### onDragEnd - -```ts -controller.onDragEnd((itemId: string) => { - console.log('Stopped dragging:', itemId); -}); -``` - -### onDrop - -The main callback for handling item reordering or moving between containers. - -```ts -controller.onDrop((sourceId, sourceData, targetContainerId, position) => { - // sourceId: ID of the dragged item - // sourceData: data attached to the dragged item - // targetContainerId: ID of the container where item was dropped - // position: index within the target container -}); -``` - -## Methods - -### startDrag - -```ts -controller.startDrag( - element: HTMLElement, - itemId: string, - initialPosition: { x: number; y: number }, - data?: Record -): void -``` - -Initiates a drag operation. Called internally by `DndDraggable`. - -### updateTransform - -```ts -controller.updateTransform(transform: { x: number; y: number }): void -``` - -Updates the ghost element position during drag. - -### updateMousePosition - -```ts -controller.updateMousePosition(mouseX: number, mouseY: number): void -``` - -Updates the mouse position for drop zone detection and auto-scroll. - -### performDrop - -```ts -controller.performDrop( - sourceId: string, - sourceData: any, - targetContainerId: string, - position: number -): void -``` - -Performs a drop with animation to the target zone. - -### endDrag - -```ts -controller.endDrag(shouldAnimate?: boolean): void -``` - -Ends the drag. When `shouldAnimate` is `true` (default), the ghost animates back to its origin. - -### setSkipDropPreviewAnimation - -```ts -controller.setSkipDropPreviewAnimation(value: boolean): void -``` - -Controls whether `DndPreview` open/close animations are skipped. Useful when you need previews to appear or disappear instantly (e.g. during rapid container switches). - -### toggleDebugZones - -```ts -controller.toggleDebugZones(): void -``` - -Toggles visual overlay of all drop zones. Useful for development and debugging. - -### destroy - -```ts -controller.destroy(): void -``` - -Cleans up all internal state, listeners, and registries. Called automatically when `DndProvider` unmounts (if it created the controller). - -## Drop Zone Management - -These methods are used internally by `DndDroppable` but can be called directly for advanced use cases. - -| Method | Description | -|--------|-------------| -| `registerDropZones(zones)` | Register all drop zones | -| `calculateDropZones(containerId, element, direction?)` | Calculate zones for a container | -| `mergeDropZones(existing, newZones, containerId)` | Merge new zones into existing set | -| `refreshDropZones()` | Recalculate all drop zones | -| `registerDroppableData(id, data)` | Register container data | -| `unregisterDroppableData(id)` | Unregister container data | - -## Transition Utilities - -Two helper functions are exported for building custom drop preview components. They produce Svelte transition functions that are aware of the controller's animation state — skipping animation when previews should appear instantly, and using shorter durations during ghost return. - -### createConditionalSlide - -```ts -import { createConditionalSlide } from '@horuse/svelte-dnd'; - -const slide = createConditionalSlide(controller); -``` - -Returns a Svelte `slide`-based transition function. Behaves as follows: -- If `controller.skipDropPreviewAnimation` is `true` — duration is `0ms` -- If `controller.animatingReturn` is `true` — duration is `200ms` -- Otherwise — uses the options passed to the transition - -### createConditionalScale - -```ts -import { createConditionalScale } from '@horuse/svelte-dnd'; - -const scale = createConditionalScale(controller); -``` - -Returns a Svelte `scale`-based transition function. Behaves as follows: -- If `controller.skipDropPreviewAnimation` is `true` — duration is `0ms` -- If `controller.animatingReturn` is `true` — duration is `100ms` -- Otherwise — uses the options passed to the transition - -These are used internally by `DndPreview`. Use them if you implement a fully custom preview component. - ---- - -## Types - -```ts -interface DropPreview { - containerId: string; - position: number; - visible: boolean; - draggedElementHeight?: number; - draggedElementWidth?: number; -} - -interface DropZone { - containerId: string; - position: number; - direction: DndDirection; - itemId?: string; - rect: { x: number; y: number; width: number; height: number }; -} - -type DndDirection = 'vertical' | 'horizontal'; -``` diff --git a/src/routes/docs/faq/+page.md b/src/routes/docs/faq/+page.md deleted file mode 100644 index f7ca80a..0000000 --- a/src/routes/docs/faq/+page.md +++ /dev/null @@ -1,189 +0,0 @@ -# FAQ - -Common questions - ---- - -## The button doesn't click - -Add `data-dnd-no-drag` attribute to the element so that it behaves as usual and does not start dragging the element. - -```svelte - - - {task.label} - -``` - ---- - -## How do I drag only by a handle? - -Add `data-dnd-handle` to the element that should act as the drag trigger. Once at least one handle is present, dragging from anywhere else on the item is blocked automatically - no need for `data-dnd-no-drag`. - -```svelte - -

    ☰ {column.title}

    -
    -
    -``` - -Multiple handles are supported — just add the attribute to each element: - -```svelte - -
    ☰ drag
    -

    content

    -
    ☰ drag
    -
    -``` - ---- - -## How do I enable auto-scroll in a scrollable container? - -Auto-scroll activates for any element that has the `data-dnd-scroll` attribute **and** `overflow: auto` or `overflow: scroll`, when the pointer is near its edge during a drag. - -`DndDroppable` adds `data-dnd-scroll` automatically, so its own scrolling works out of the box. - -If you have an **external** scrollable wrapper around several droppables (e.g. a horizontal kanban board), add `data-dnd-scroll` manually: - -```svelte - -
    - {#each columns as col} - - ... - - {/each} -
    -``` - ---- - -## How do I restrict which items can be dropped into a container? - -Set a `type` on each `DndDraggable` and an `accepts` list on each `DndDroppable`: - -```svelte -Task - - -... - - -... -``` - ---- - -## How do I move items between multiple containers? - -Use a single `DragController` shared across all containers. Read `targetContainerId` inside `onDrop` to determine where the item landed: - -```svelte - - - - -``` - ---- - -## How do I create a custom ghost element? - -Pass a `ghost` snippet to `DndProvider`. The snippet receives `{ element, data, itemId }`: - -```svelte - - {#snippet ghost({ element, data })} -
    - {data.label} -
    - {/snippet} - - -
    -``` - ---- - -## Items aren't draggable on touch / mobile devices - -Make sure `touch-action: none` is set on draggable elements. The library sets this via `.dnd-draggable` — do not override it in your own CSS: - -```css -/* ✗ don't do this */ -.my-item { - touch-action: auto; -} -``` - -If you apply `touch-action` somewhere in your layout that affects draggable children, remove it or scope it away from `.dnd-draggable`. - ---- - -## The drop preview doesn't appear - -Check two things: - -1. The `containerId` prop on `DndPreview` exactly matches the `id` prop on the target `DndDroppable`. -2. The `position` prop is in the range `0 … items.length` (inclusive). - -```svelte - - - - -``` - ---- - -See the **Sortable Containers** example for a working implementation. - ---- - -## Ghost returns to the wrong position after a cancelled drag - -The library animates the ghost back to the position of the placeholder element. Make sure you restore the original item in `onDragEnd` so the placeholder is still in the DOM at the correct index when the animation starts: - -```svelte -controller.onDragEnd(() => { - hiddenId = null; - draggedType = null; -}); -``` - ---- - -## How do I debug drop zones? - -Call `controller.toggleDebugZones()` — blue overlays will appear on all drop zones so you can verify their boundaries: - -```svelte - -``` - -See the [CSS Custom Properties & Classes](/docs/css-custom-props) page for the classes used by the overlay elements. - ---- - -## How do I disable dragging conditionally? - -Pass the `disabled` prop to `DndDraggable`: - -```svelte - - {item.label} - -``` - -A disabled item is not draggable and receives the `.dnd-draggable--disabled` CSS class. diff --git a/src/routes/docs/getting-started/+page.md b/src/routes/docs/getting-started/+page.md deleted file mode 100644 index 2f44d83..0000000 --- a/src/routes/docs/getting-started/+page.md +++ /dev/null @@ -1,100 +0,0 @@ -# Getting Started - -## Installation - -```bash -bun add @horuse/svelte-dnd -``` - -Or with npm: - -```bash -npm install @horuse/svelte-dnd -``` - -## Basic Example - -A minimal working drag-and-drop setup requires four components: `DndProvider`, `DndDroppable`, `DndDraggable`, and `DndPreview`. - -```svelte - - - - - {#each visibleItems as item, index (item.id)} - - - - {item.label} - - {/each} - - - - -``` - -## How It Works - -1. **DndProvider** wraps your app and creates a `DragController` context. -2. **DndDroppable** defines a container where items can be dropped. Set `direction` to `"vertical"` or `"horizontal"`. -3. **DndDraggable** wraps each draggable item. Each must have a unique `id`. -4. **DndPreview** renders a placeholder at each potential drop position. Place one before each item and one after the last item. -5. **Hide the dragged item** — subscribe to `onDragStart` / `onDragEnd` and filter out the dragged item from the rendered list. This removes the original element from the DOM flow so only the ghost follows the cursor. -6. Use `controller.onDrop()` to handle reordering logic when an item is dropped. diff --git a/src/routes/docs/html-attributes/+page.md b/src/routes/docs/html-attributes/+page.md deleted file mode 100644 index 27769af..0000000 --- a/src/routes/docs/html-attributes/+page.md +++ /dev/null @@ -1,63 +0,0 @@ -# HTML Attributes - -The library sets these attributes automatically — you don't need to add them manually. However, knowing them is useful for CSS targeting and debugging. - ---- - -## Data Attributes - -| Attribute | Set by | Purpose | -|-----------------------------|--------|---------| -| `data-dnd-drop-id` | `DndDroppable` | Unique container ID — used for DOM lookup of the container | -| `data-dnd-direction` | `DndDroppable` | `"vertical"` or `"horizontal"` — read when calculating drop zones | -| `data-dnd-scroll` | `DndDroppable` | Flag — auto-scroll activates only for elements with this attribute | -| `data-dnd-drag-id` | `DndDraggable` | Unique item ID — used when identifying a draggable element | -| `data-dnd-handle` | `DndDraggable` | Marks an element as the drag handle. When present, dragging starts only from handle elements; `data-dnd-no-drag` is ignored. | -| `data-dnd-no-drag` | `DndDraggable` | Disables dragging for this element. Ignored when `data-dnd-handle` is used. | -| `data-dnd-draggable-item` | `DndDraggable` | Marker — the selector `:scope > [data-dnd-draggable-item]` collects container items | -| `data-dnd-preview` | `DndPreview` | Marks a preview (placeholder) element | -| `data-dnd-preview-position` | `DndPreview` | Numeric position of the preview — used to find a specific placeholder | -| `data-dnd-dragged-element` | `DndProvider` | Marks the ghost element (the div that follows the cursor) | - -### External scrollable containers - -`DndDroppable` automatically adds `data-dnd-scroll` to itself, so its own scrolling works out of the box. If you have an **external** scrollable wrapper around several droppables, add `data-dnd-scroll` manually: - -```html -
    - - -
    -``` - -### Disabling drag on child elements - -Add `data-dnd-no-drag` to any element inside a `DndDraggable` to let it receive native pointer events without triggering a drag: - -```svelte - - - {item.label} - -``` - -### Restricting drag to a handle - -Add `data-dnd-handle` to one or more elements inside a `DndDraggable`. When at least one handle is present, dragging starts only from those elements — everything else is automatically blocked: - -```svelte - -

    ☰ Drag here

    -
    clicks here won't drag
    -
    -``` - -Multiple handles are supported — just add the attribute to each: - -```svelte - -
    ☰ Top handle
    -

    content

    -
    ☰ Bottom handle
    -
    -``` \ No newline at end of file diff --git a/src/routes/examples/+layout.svelte b/src/routes/examples/+layout.svelte deleted file mode 100644 index a60a733..0000000 --- a/src/routes/examples/+layout.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - -{@render children()} diff --git a/src/routes/examples/custom-ghost/+page.svelte b/src/routes/examples/custom-ghost/+page.svelte deleted file mode 100644 index 6f8e7bb..0000000 --- a/src/routes/examples/custom-ghost/+page.svelte +++ /dev/null @@ -1,149 +0,0 @@ - - -
    -
    - -
    - - - {#snippet ghost({ data, itemId })} -
    - {data?.label ?? itemId} -
    - {/snippet} - -
    - {#each Object.entries(columns) as [columnId, columnItems] (columnId)} - {@const visible = getVisibleItems(columnItems)} -
    -

    {columnMeta[columnId]}

    - - {#each visible as item, index (item.id)} - - -
    - {item.label} -
    -
    - {/each} - -
    -
    - {/each} -
    -
    -
    - - diff --git a/src/routes/examples/custom-ghost/description.md b/src/routes/examples/custom-ghost/description.md deleted file mode 100644 index 26dc2b1..0000000 --- a/src/routes/examples/custom-ghost/description.md +++ /dev/null @@ -1,9 +0,0 @@ -# Custom Ghost & Preview Styling - -This example combines two features: a **custom ghost** snippet and **scoped preview styling** via CSS custom properties. - -Pass a `ghost` snippet to `DndProvider` to replace the default cloned element with a custom drag ghost. The snippet receives `{ element, data, itemId }` — use `data` to render a colored card that differs from the list item. - -Each column wrapper sets its own `--dnd-preview-bg`, `--dnd-preview-border`, and `--dnd-preview-border-radius` values. Because `DndPreview` reads these properties from its nearest ancestor, the preview automatically adopts the target container's style as you drag between columns. - -[view code](https://github.com/Horuse/svelte-dnd/blob/main/src/routes/examples/custom-ghost/%2Bpage.svelte) diff --git a/src/routes/examples/horizontal/+page.svelte b/src/routes/examples/horizontal/+page.svelte deleted file mode 100644 index 9e2715c..0000000 --- a/src/routes/examples/horizontal/+page.svelte +++ /dev/null @@ -1,87 +0,0 @@ - - -
    -
    - -
    - - - - {#each visibleItems as item, index (item.id)} - - - -
    - {item.label} -
    -
    - - - {/each} - - -
    -
    -
    - - diff --git a/src/routes/examples/horizontal/description.md b/src/routes/examples/horizontal/description.md deleted file mode 100644 index f49f5e3..0000000 --- a/src/routes/examples/horizontal/description.md +++ /dev/null @@ -1,7 +0,0 @@ -# Horizontal List - -Set `direction="horizontal"` on both `DndDroppable` and `DndPreview`. - -Without `direction="horizontal"` on `DndPreview`, the open/close animation slides vertically instead of horizontally. The reorder logic is the same as the vertical example — only the layout direction changes. - -[view code](https://github.com/Horuse/svelte-dnd/blob/main/src/routes/examples/horizontal/%2Bpage.svelte) \ No newline at end of file diff --git a/src/routes/examples/multi-container/+page.svelte b/src/routes/examples/multi-container/+page.svelte deleted file mode 100644 index 8ae8ef6..0000000 --- a/src/routes/examples/multi-container/+page.svelte +++ /dev/null @@ -1,118 +0,0 @@ - - -
    -
    - -
    - - -
    - {#each Object.entries(columns) as [columnId, columnItems] (columnId)} - {@const visible = getVisibleItems(columnItems)} -
    -

    {columnMeta[columnId]}

    - - {#each visible as item, index (item.id)} - - -
    - {item.label} -
    -
    - {/each} - -
    -
    - {/each} -
    -
    -
    - - \ No newline at end of file diff --git a/src/routes/examples/multi-container/description.md b/src/routes/examples/multi-container/description.md deleted file mode 100644 index c6725f4..0000000 --- a/src/routes/examples/multi-container/description.md +++ /dev/null @@ -1,7 +0,0 @@ -# Multi Container - -A kanban board with three columns — items can be moved between containers. - -Each column is its own `DndDroppable`, and all share one `DragController` via `DndProvider`. The `onDrop` callback receives `targetContainerId` — find the source column, remove the item, and insert it into the target column at `position`. - -[view code](https://github.com/Horuse/svelte-dnd/blob/main/src/routes/examples/multi-container/%2Bpage.svelte) \ No newline at end of file diff --git a/src/routes/examples/sortable-containers/+page.svelte b/src/routes/examples/sortable-containers/+page.svelte deleted file mode 100644 index 1206938..0000000 --- a/src/routes/examples/sortable-containers/+page.svelte +++ /dev/null @@ -1,185 +0,0 @@ - - -
    -
    - -
    - -
    - Debug: {controller.debugZones} - -

    - To see, start dragging

    -
    - - - - {#each visibleColumns as column, colIndex (column.id)} - - -

    - - {column.title} -

    - - - {@const visible = getVisibleTasks(column.tasks)} - {#each visible as task, taskIndex (task.id)} - - - {task.label} - - - {/each} - - -
    - {/each} - -
    -
    -
    - - diff --git a/src/routes/examples/sortable-containers/description.md b/src/routes/examples/sortable-containers/description.md deleted file mode 100644 index 0f48f60..0000000 --- a/src/routes/examples/sortable-containers/description.md +++ /dev/null @@ -1,13 +0,0 @@ -# Sortable Containers - -A horizontal board where both **containers** and **tasks** within them can be dragged. - -Drag a container by its header to reorder columns horizontally. Drag tasks within or between containers to move them. This is achieved with a single `DragController` and nested `DndDroppable` components: - -- The **board** droppable uses `direction="horizontal"` and accepts `'column'` type items -- Each **column** droppable uses `direction="vertical"` and accepts `'task'` type items -- `stopPropagation` on the task area prevents `pointerdown` from bubbling to the column's `DndDraggable`, so dragging from the header moves the column while dragging from the task area moves a task - -The existing drop zone calculator filters `[data-dnd-draggable-item]` elements by their closest `[data-dnd-drop-id]` parent, so nested droppables work correctly without extra configuration. - -[view code](https://github.com/Horuse/svelte-dnd/blob/main/src/routes/examples/sortable-containers/%2Bpage.svelte) diff --git a/src/routes/examples/vertical/+page.svelte b/src/routes/examples/vertical/+page.svelte deleted file mode 100644 index 7170ce2..0000000 --- a/src/routes/examples/vertical/+page.svelte +++ /dev/null @@ -1,80 +0,0 @@ - - -
    -
    - -
    - - - - {#each visibleItems as item, index (item.id)} - - - -
    - {item.label} -
    -
    - {/each} - - -
    -
    -
    - - - \ No newline at end of file diff --git a/src/routes/examples/vertical/description.md b/src/routes/examples/vertical/description.md deleted file mode 100644 index 6b62ffd..0000000 --- a/src/routes/examples/vertical/description.md +++ /dev/null @@ -1,9 +0,0 @@ -# Vertical List - -The simplest setup — a single `DndDroppable` with `direction="vertical"` (the default). - -This example uses 50 items to demonstrate **auto-scroll** near the container edges. A `DndPreview` is placed before each item and one after the last item to show where the dragged item will land. - -The `onDrop` callback receives the drop `position` — splice the source item out and insert it at the new position. - -[view code](https://github.com/Horuse/svelte-dnd/blob/main/src/routes/examples/vertical/%2Bpage.svelte) \ No newline at end of file diff --git a/static/favicon.svg b/static/favicon.svg deleted file mode 100644 index cc5dc66..0000000 --- a/static/favicon.svg +++ /dev/null @@ -1 +0,0 @@ -svelte-logo \ No newline at end of file diff --git a/static/preview.gif b/static/preview.gif deleted file mode 100644 index ce0231f..0000000 Binary files a/static/preview.gif and /dev/null differ diff --git a/svelte.config.js b/svelte.config.js deleted file mode 100644 index 28c9779..0000000 --- a/svelte.config.js +++ /dev/null @@ -1,34 +0,0 @@ -import adapter from '@sveltejs/adapter-auto'; -import { mdsvex } from 'mdsvex'; -import { createHighlighter } from 'shiki' - -const highlighterPromise = createHighlighter({ - themes: ['github-dark'], - langs: ['javascript', 'bash', 'typescript', 'css', 'html', 'svelte'] -}) - -/** @type {import('@sveltejs/kit').Config} */ -const config = { - extensions: ['.svelte', '.svx', '.md'], - preprocess: [mdsvex({ - extensions: ['.svx', '.md'], - highlight: { - highlighter: async (code, lang = 'text') => { - const highlighter = await highlighterPromise - const html = highlighter.codeToHtml(code, { - lang, - theme: 'github-dark' - }); - return `{@html \`${html}\` }` - } - }, - })], - kit: { - alias: { - '$docs/*': 'src/docs/*', - }, - adapter: adapter() - } -}; - -export default config; diff --git a/tsconfig.json b/tsconfig.json index e7c4a9a..146fe4e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,29 @@ { - "extends": "./.svelte-kit/tsconfig.json", "compilerOptions": { - "rewriteRelativeImportExtensions": true, + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", "allowJs": true, - "checkJs": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "sourceMap": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices "strict": true, - "module": "NodeNext", - "moduleResolution": "NodeNext" + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false } } diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..c376d5f --- /dev/null +++ b/turbo.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://turborepo.dev/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", ".svelte-kit/**", "./build/**", ".vercel/**"] + }, + "dev": { + "persistent": true, + "cache": false + }, + "test": { + "cache": false + } + } +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index 56f40c7..0000000 --- a/vite.config.ts +++ /dev/null @@ -1,5 +0,0 @@ -import tailwindcss from '@tailwindcss/vite'; -import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; - -export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });