export function animate(
from: P,
to: { [K in keyof P]: P[K] },
cb: (value: P, done: boolean, progress: number) => void,
config?: Partial
): CancelFunction {
let canceled = false
const cancel = () => {
canceled = true
}
const mergedConfig = { ...DEFAULT_CONFIG, ...config }
let start: number
function update(ts: number) {
if (start === undefined) {
start = ts
}
const elapsed = ts - start
const t = clamp(elapsed / mergedConfig.duration, 0, 1)
const names = Object.keys(from) as Array
const toKeys = Object.keys(to) as Array
if (!names.every((name) => toKeys.includes(name))) {
console.error('animate Error: `from` keys are different than `to`')
return
}
const result = {} as P
names.forEach((name) => {
if (typeof from[name] === 'number' && typeof to[name] === 'number') {
result[name] = lerp(
from[name],
to[name],
mergedConfig.easing(t)
) as P[keyof P]
} else if (isBorderRadius(from[name]) && isBorderRadius(to[name])) {
result[name] = lerpBorderRadius(
from[name],
to[name],
mergedConfig.easing(t)
) as P[keyof P]
} else if (isVec2(from[name]) && isVec2(to[name])) {
result[name] = lerpVectors(
from[name],
to[name],
mergedConfig.easing(t)
) as P[keyof P]
}
})
cb(result, t >= 1, t)
if (t < 1 && !canceled) {
requestAnimationFrame(update)
}
}
requestAnimationFrame(update)
return cancel
}
================================================
FILE: src/borderRadius.ts
================================================
export type BorderRadius = {
x: {
topLeft: number
topRight: number
bottomRight: number
bottomLeft: number
}
y: {
topLeft: number
topRight: number
bottomRight: number
bottomLeft: number
}
unit: string
}
export function isBorderRadius(
borderRadius: any
): borderRadius is BorderRadius {
return (
typeof borderRadius === 'object' &&
borderRadius !== null &&
'x' in borderRadius &&
'y' in borderRadius &&
'unit' in borderRadius &&
typeof borderRadius.unit === 'string' &&
typeof borderRadius.x === 'object' &&
typeof borderRadius.y === 'object' &&
'topLeft' in borderRadius.x &&
'topRight' in borderRadius.x &&
'bottomRight' in borderRadius.x &&
'bottomLeft' in borderRadius.x &&
'topLeft' in borderRadius.y &&
'topRight' in borderRadius.y &&
'bottomRight' in borderRadius.y &&
'bottomLeft' in borderRadius.y
)
}
export function parseBorderRadius(borderRadius: string): BorderRadius {
// Regular expression to match numbers with units (e.g., 6px, 10%)
const match = borderRadius.match(/(\d+(?:\.\d+)?)(px|%)/g)
if (!match) {
return {
x: { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 },
y: { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 },
unit: 'px'
}
}
// Parse each matched value with its unit
const values = match.map((value) => {
const [_, num, unit] = value.match(/(\d+(?:\.\d+)?)(px|%)/) ?? []
return { value: parseFloat(num), unit }
})
// Ensure all units are consistent
const unit = values[0]?.unit || 'px'
if (values.some((v) => v.unit !== unit)) {
throw new Error('Inconsistent units in border-radius string.')
}
// Handle 1 to 4 values
const [v1, v2, v3, v4] = values.map((v) => v.value)
const result = {
topLeft: v1 ?? 0,
topRight: v2 ?? v1 ?? 0,
bottomRight: v3 ?? v1 ?? 0,
bottomLeft: v4 ?? v2 ?? v1 ?? 0
}
return {
x: { ...result },
y: { ...result },
unit
}
}
export function calculateBorderRadiusInverse(
{ x, y, unit }: BorderRadius,
scaleX: number,
scaleY: number
): BorderRadius {
if (unit === 'px') {
const RadiusXInverse = {
topLeft: x.topLeft / scaleX,
topRight: x.topRight / scaleX,
bottomLeft: x.bottomLeft / scaleX,
bottomRight: x.bottomRight / scaleX
}
const RadiusYInverse = {
topLeft: y.topLeft / scaleY,
topRight: y.topRight / scaleY,
bottomLeft: y.bottomLeft / scaleY,
bottomRight: y.bottomRight / scaleY
}
return { x: RadiusXInverse, y: RadiusYInverse, unit: 'px' }
} else if (unit === '%') {
return { x, y, unit: '%' }
}
return { x, y, unit }
}
export function borderRadiusToString(borderRadius: BorderRadius): string {
return `
${borderRadius.x.topLeft}${borderRadius.unit} ${borderRadius.x.topRight}${borderRadius.unit} ${borderRadius.x.bottomRight}${borderRadius.unit} ${borderRadius.x.bottomLeft}${borderRadius.unit}
/
${borderRadius.y.topLeft}${borderRadius.unit} ${borderRadius.y.topRight}${borderRadius.unit} ${borderRadius.y.bottomRight}${borderRadius.unit} ${borderRadius.y.bottomLeft}${borderRadius.unit}
`
}
export function isBorderRadiusNone(borderRadius: BorderRadius) {
return (
borderRadius.x.topLeft === 0 &&
borderRadius.x.topRight === 0 &&
borderRadius.x.bottomRight === 0 &&
borderRadius.x.bottomLeft === 0 &&
borderRadius.y.topLeft === 0 &&
borderRadius.y.topRight === 0 &&
borderRadius.y.bottomRight === 0 &&
borderRadius.y.bottomLeft === 0
)
}
================================================
FILE: src/draggable.ts
================================================
import { View, ViewPlugin } from './view'
interface Draggable {
onDrag(handler: OnDragListener): void
onDrop(handler: OnDropListener): void
onHold(handler: OnHoldListener): void
onRelease(handler: OnReleaseListener): void
destroy(): void
readjust(): void
}
export type DraggablePlugin = Draggable & ViewPlugin
export type DragEvent = {
x: number
y: number
width: number
height: number
pointerX: number
pointerY: number
relativeX: number
relativeY: number
el: HTMLElement
}
export type OnDragListener = (dragEvent: DragEvent) => void
export type OnDropListener = (dragEvent: DragEvent) => void
export type OnHoldListener = ({ el }: { el: HTMLElement }) => void
export type OnReleaseListener = ({ el }: { el: HTMLElement }) => void
export type DraggableConfig = {
startDelay: number
targetEl?: HTMLElement | null
}
const DEFAULT_CONFIG: DraggableConfig = {
startDelay: 0,
targetEl: null
}
export function makeDraggable(
view: View,
userConfig?: Partial
): DraggablePlugin {
const config: DraggableConfig = { ...DEFAULT_CONFIG, ...userConfig }
let el = view.el()
let isPointerDown = false
let dragListener: OnDragListener | null = null
let dropListener: OnDropListener | null = null
let holdListener: OnHoldListener | null = null
let releaseListener: OnReleaseListener | null = null
let initialX = 0
let initialY = 0
let lastX = 0
let lastY = 0
let layoutLeft = 0
let layoutTop = 0
let initialClientX = 0
let initialClientY = 0
let relativeX = 0
let relativeY = 0
let draggingEl: HTMLElement | null = null
let timer: NodeJS.Timeout | null
el.addEventListener('pointerdown', onPointerDown)
document.body.addEventListener('pointerup', onPointerUp)
document.body.addEventListener('pointermove', onPointerMove)
document.body.addEventListener('touchmove', onTouchMove, { passive: false })
function onPointerDown(e: PointerEvent) {
if (
config.targetEl &&
e.target !== config.targetEl &&
!config.targetEl.contains(e.target as HTMLElement)
)
return
if (isPointerDown) return
if (!e.isPrimary) return
if (config.startDelay > 0) {
holdListener?.({ el: e.target as HTMLElement })
timer = setTimeout(() => {
start()
}, config.startDelay)
} else {
start()
}
function start() {
draggingEl = e.target as HTMLElement
const rect = view.boundingRect()
const layout = view.layoutRect()
layoutLeft = layout.x
layoutTop = layout.y
lastX = rect.x - layoutLeft
lastY = rect.y - layoutTop
initialX = e.clientX - lastX
initialY = e.clientY - lastY
initialClientX = e.clientX
initialClientY = e.clientY
relativeX = (e.clientX - rect.x) / rect.width
relativeY = (e.clientY - rect.y) / rect.height
isPointerDown = true
onPointerMove(e)
}
}
function readjust() {
const layout = view.layoutRect()
initialX -= layoutLeft - layout.x
initialY -= layoutTop - layout.y
layoutLeft = layout.x
layoutTop = layout.y
}
function onPointerUp(e: PointerEvent) {
if (!isPointerDown) {
if (timer) {
clearTimeout(timer)
timer = null
releaseListener?.({ el: e.target as HTMLElement })
}
return
}
if (!e.isPrimary) return
isPointerDown = false
const width = e.clientX - initialClientX
const height = e.clientY - initialClientY
dropListener?.({
x: lastX,
y: lastY,
pointerX: e.clientX,
pointerY: e.clientY,
width,
height,
relativeX,
relativeY,
el: draggingEl!
})
draggingEl = null
}
function onPointerMove(e: PointerEvent) {
if (!isPointerDown) {
if (timer) {
clearTimeout(timer)
timer = null
releaseListener?.({ el: e.target as HTMLElement })
}
return
}
if (!e.isPrimary) return
const width = e.clientX - initialClientX
const height = e.clientY - initialClientY
const dx = (lastX = e.clientX - initialX)
const dy = (lastY = e.clientY - initialY)
dragListener?.({
width,
height,
x: dx,
y: dy,
pointerX: e.clientX,
pointerY: e.clientY,
relativeX,
relativeY,
el: draggingEl!
})
}
function onTouchMove(e: TouchEvent) {
if (!isPointerDown) return true
e.preventDefault()
}
function onDrag(listener: OnDragListener) {
dragListener = listener
}
function onDrop(listener: OnDropListener) {
dropListener = listener
}
function onHold(listener: OnHoldListener) {
holdListener = listener
}
function onRelease(listener: OnReleaseListener) {
releaseListener = listener
}
function onElementUpdate() {
el.removeEventListener('pointerdown', onPointerDown)
el = view.el()
el.addEventListener('pointerdown', onPointerDown)
}
function destroy() {
view.el().removeEventListener('pointerdown', onPointerDown)
document.body.removeEventListener('pointerup', onPointerUp)
document.body.removeEventListener('pointermove', onPointerMove)
document.body.removeEventListener('touchmove', onTouchMove)
dragListener = null
dropListener = null
holdListener = null
releaseListener = null
}
return {
onDrag,
onDrop,
onHold,
onRelease,
onElementUpdate,
destroy,
readjust
}
}
================================================
FILE: src/easings.ts
================================================
export function easeOutBack(x: number): number {
const c1 = 1.70158
const c3 = c1 + 1
return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2)
}
export function easeOutCubic(x: number): number {
return 1 - Math.pow(1 - x, 3)
}
================================================
FILE: src/flip.ts
================================================
import {
BorderRadius,
borderRadiusToString,
calculateBorderRadiusInverse,
parseBorderRadius
} from './borderRadius'
import {
createRectFromBoundingRect,
getCorrectedBoundingRect,
getLayoutRect,
getScrollOffset,
Rect
} from './rect'
import { vec2, Vec2 } from './vector'
import { Transform, View } from './view'
type TransitionValue = {
width: number
height: number
translate: Vec2
scale: Vec2
borderRadius: BorderRadius
}
type FlipTransitionValues = { from: TransitionValue; to: TransitionValue }
type FlipChildTransitionData = {
el: HTMLElement
fromTranslate: Vec2
fromScale: Vec2
fromBorderRadius: BorderRadius
toBorderRadius: BorderRadius
parentScale: Vec2
}
type ElementFlipRect = { el: HTMLElement; initialRect: Rect; finalRect?: Rect }
type ParentChildrenTreeData = Array<{
parent: ElementFlipRect
children: Array
}>
type ChildElement = HTMLElement & { originalBorderRadius: string }
export interface Flip {
readInitial(): void
readFinalAndReverse(): void
transitionValues(): FlipTransitionValues
childrenTransitionData(): Array
}
export function flipView(view: View): Flip {
let state: 'unread' | 'readInitial' | 'readFinal' = 'unread'
let current: Transform
let parentInitialRect: Rect
let scrollOffset: Vec2
let parentDx: number
let parentDy: number
let parentDw: number
let parentDh: number
let parentInverseBorderRadius: BorderRadius
let parentFinalRect: Rect
let childrenData: Array
let parentChildrenTreeData: ParentChildrenTreeData
function readInitial() {
current = view.currentTransform()
parentInitialRect = getCorrectedBoundingRect(view.el())
scrollOffset = getScrollOffset(view.el())
const tree = getParentChildTree(view.el())
parentChildrenTreeData = tree.map(({ parent, children }) => ({
parent: {
el: parent,
initialRect: createRectFromBoundingRect(parent.getBoundingClientRect())
},
children: children
.filter((child) => child instanceof HTMLElement)
.map((child) => {
const childEl = child as ChildElement
if (!childEl.originalBorderRadius) {
childEl.originalBorderRadius = getComputedStyle(child).borderRadius
}
return {
el: child,
borderRadius: parseBorderRadius(childEl.originalBorderRadius),
initialRect: createRectFromBoundingRect(
child.getBoundingClientRect()
)
}
})
}))
state = 'readInitial'
}
function readFinalAndReverse() {
if (state !== 'readInitial') {
throw new Error(
'FlipView: Cannot read final values before reading initial values'
)
}
parentFinalRect = view.layoutRect()
parentDw = parentInitialRect.width / parentFinalRect.width
parentDh = parentInitialRect.height / parentFinalRect.height
parentDx =
parentInitialRect.x - parentFinalRect.x - current.dragX + scrollOffset.x
parentDy =
parentInitialRect.y - parentFinalRect.y - current.dragY + scrollOffset.y
parentInverseBorderRadius = calculateBorderRadiusInverse(
view.borderRadius(),
parentDw,
parentDh
)
const tree = getParentChildTree(view.el())
parentChildrenTreeData = parentChildrenTreeData.map(
({ parent, children }, i) => {
const parentEl = tree[i].parent
return {
parent: {
...parent,
el: parentEl,
finalRect: getLayoutRect(parentEl)
},
children: children.map((child, j) => {
const childEl = tree[i].children[j]
let finalRect = getLayoutRect(childEl)
if (childEl.hasAttribute('data-swapy-text')) {
finalRect = {
...finalRect,
width: child.initialRect.width,
height: child.initialRect.height
}
}
return {
...child,
el: childEl,
finalRect
}
})
}
}
)
const targetTransform: Omit = {
translateX: parentDx,
translateY: parentDy,
scaleX: parentDw,
scaleY: parentDh
}
view.el().style.transformOrigin = '0 0'
view.el().style.borderRadius = borderRadiusToString(
parentInverseBorderRadius
)
view.setTransform(targetTransform)
childrenData = []
parentChildrenTreeData.forEach(({ parent, children }) => {
const childData = children.map(
({ el, initialRect, finalRect, borderRadius }) =>
calculateChildData(
el,
initialRect,
finalRect!,
borderRadius,
parent.initialRect,
parent.finalRect!
)
)
childrenData.push(...childData)
})
state = 'readFinal'
}
function transitionValues(): FlipTransitionValues {
if (state !== 'readFinal') {
throw new Error('FlipView: Cannot get transition values before reading')
}
return {
from: {
width: parentInitialRect.width,
height: parentInitialRect.height,
translate: vec2(parentDx, parentDy),
scale: vec2(parentDw, parentDh),
borderRadius: parentInverseBorderRadius
},
to: {
width: parentFinalRect.width,
height: parentFinalRect.height,
translate: vec2(0, 0),
scale: vec2(1, 1),
borderRadius: view.borderRadius()
}
}
}
function childrenTransitionData(): Array {
if (state !== 'readFinal') {
throw new Error(
'FlipView: Cannot get children transition values before reading'
)
}
return childrenData
}
return {
readInitial,
readFinalAndReverse,
transitionValues,
childrenTransitionData
}
}
function calculateChildData(
childEl: HTMLElement,
childInitialRect: Rect,
childFinalRect: Rect,
childBorderRadius: BorderRadius,
parentInitialRect: Rect,
parentFinalRect: Rect
): FlipChildTransitionData {
childEl.style.transformOrigin = '0 0'
const parentDw = parentInitialRect.width / parentFinalRect.width
const parentDh = parentInitialRect.height / parentFinalRect.height
const dw = childInitialRect.width / childFinalRect.width
const dh = childInitialRect.height / childFinalRect.height
const fromBorderRadius = calculateBorderRadiusInverse(
childBorderRadius,
dw,
dh
)
const initialX = childInitialRect.x - parentInitialRect.x
const finalX = childFinalRect.x - parentFinalRect.x
const initialY = childInitialRect.y - parentInitialRect.y
const finalY = childFinalRect.y - parentFinalRect.y
const fromTranslateX = (initialX - finalX * parentDw) / parentDw
const fromTranslateY = (initialY - finalY * parentDh) / parentDh
childEl.style.transform = `translate(${fromTranslateX}px, ${fromTranslateY}px) scale(${
dw / parentDw
}, ${dh / parentDh})`
childEl.style.borderRadius = borderRadiusToString(fromBorderRadius)
return {
el: childEl,
fromTranslate: vec2(fromTranslateX, fromTranslateY),
fromScale: vec2(dw, dh),
fromBorderRadius,
toBorderRadius: childBorderRadius,
parentScale: { x: parentDw, y: parentDh }
}
}
function getParentChildTree(
element: HTMLElement
): { parent: HTMLElement; children: HTMLElement[] }[] {
const result: { parent: HTMLElement; children: HTMLElement[] }[] = []
function traverse(parent: HTMLElement) {
const children = Array.from(parent.children).filter(
(el) => el instanceof HTMLElement
) as HTMLElement[]
if (children.length > 0) {
result.push({
parent: parent,
children: children
})
children.forEach((child) => traverse(child))
}
}
traverse(element)
return result
}
================================================
FILE: src/index.ts
================================================
import { animate, AnimateConfig, CancelFunction } from './animators'
import { borderRadiusToString, isBorderRadiusNone } from './borderRadius'
import {
DragEvent,
DraggableConfig,
DraggablePlugin,
makeDraggable,
OnDragListener,
OnDropListener,
OnHoldListener,
OnReleaseListener
} from './draggable'
import { easeOutBack, easeOutCubic } from './easings'
import { Flip, flipView } from './flip'
import { clamp, lerp, lerpBorderRadius, remap } from './math'
import {
createRectFromBoundingRect,
pointIntersectsWithRect,
Rect
} from './rect'
import { Vec2, vec2 } from './vector'
import { createView, View } from './view'
export * as utils from './utils'
export interface Swapy {
enable(enabled: boolean): void
onSwapStart(handler: SwapStartEventHandler): void
onSwap(handler: SwapEventHandler): void
onSwapEnd(handler: SwapEndEventHandler): void
onBeforeSwap(handler: BeforeSwapHandler): void
slotItemMap(): SlotItemMap
update(): void
destroy(): void
}
export type SwapStartEvent = {
slotItemMap: SlotItemMap
draggingItem: string
fromSlot: string
}
export type SwapStartEventHandler = (event: SwapStartEvent) => void
export type SwapEvent = {
oldSlotItemMap: SlotItemMap
newSlotItemMap: SlotItemMap
fromSlot: string
toSlot: string
draggingItem: string
swappedWithItem: string
}
export type SwapEventHandler = (event: SwapEvent) => void
export type SwapEndEvent = {
slotItemMap: SlotItemMap
hasChanged: boolean
}
export type SwapEndEventHandler = (event: SwapEndEvent) => void
export type BeforeSwapEvent = {
fromSlot: string
toSlot: string
draggingItem: string
swapWithItem: string
}
export type BeforeSwapHandler = (event: BeforeSwapEvent) => boolean
interface Slot {
id(): string
item(): Item | undefined
view(): View
itemId(): string | null
rect(): Rect
highlight(): void
unhighlightAllSlots(): void
isHighlighted(): boolean
destroy(): void
}
interface Item {
id(): string
slot(): Slot
view(): View
slotId(): string
store(): Store
onDrag(handler: OnDragListener): void
onDrop(handler: OnDropListener): void
onHold(handler: OnHoldListener): void
onRelease(handler: OnReleaseListener): void
isDragging(): boolean
destroy(): void
cancelAnimation(): ItemCancelAnimation
dragEvent(): DragEvent | null
continuousDrag(): boolean
setContinuousDrag(value: boolean): void
}
type ItemCancelAnimation = Record<
'drop' | 'moveToSlot',
CancelFunction | undefined
>
type ScrollHandler = (e: Event) => void
interface Store {
items(): Array-
slots(): Array
setItems(items: Array
- ): void
setSlots(slots: Array): void
itemById(id: string): Item | undefined
slotById(id: string): Slot | undefined
config(): Config
zIndex(inc?: boolean): number
resetZIndex(): void
eventHandlers(): {
onSwapStart: SwapStartEventHandler
onSwap: SwapEventHandler
onSwapEnd: SwapEndEventHandler
onBeforeSwap: BeforeSwapHandler
}
syncSlotItemMap(): void
slotItemMap(clone?: boolean): SlotItemMap
onScroll(handler: ScrollHandler | null): void
swapItems(item: Item, toSlot: Slot): void
destroy(): void
}
export type AnimationType = 'dynamic' | 'spring' | 'none'
export type SlotItemMapObject = Record
export type SlotItemMapMap = Map
export type SlotItemMapArray = Array<{ slot: string; item: string }>
export type SlotItemMap = {
asObject: SlotItemMapObject
asMap: SlotItemMapMap
asArray: SlotItemMapArray
}
type DragAxis = 'x' | 'y' | 'both'
export type Config = {
animation: AnimationType
enabled: boolean
swapMode: 'hover' | 'drop'
dragOnHold: boolean
autoScrollOnDrag: boolean
dragAxis: DragAxis
manualSwap: boolean
}
const DEFAULT_CONFIG: Config = {
animation: 'dynamic',
enabled: true,
swapMode: 'hover',
dragOnHold: false,
autoScrollOnDrag: false,
dragAxis: 'both',
manualSwap: false
}
function getAnimateConfig(animationType: AnimationType): AnimateConfig {
switch (animationType) {
case 'dynamic':
return { easing: easeOutCubic, duration: 300 }
case 'spring':
return { easing: easeOutBack, duration: 350 }
case 'none':
return { easing: (t: number) => t, duration: 1 }
}
}
export function createSwapy(
container: HTMLElement,
config?: Partial
): Swapy {
const userConfig = { ...DEFAULT_CONFIG, ...config }
const store = createStore({ slots: [], items: [], config: userConfig })
let slots: Array = []
let items: Array
- = []
init()
function init() {
if (!isContainerValid(container)) {
throw new Error(
'Cannot create a Swapy instance because your HTML structure is invalid. Fix all above errors and then try!'
)
}
slots = Array.from(container.querySelectorAll('[data-swapy-slot]')).map(
(slotEl) => createSlot(slotEl as HTMLElement, store)
)
store.setSlots(slots)
items = Array.from(container.querySelectorAll('[data-swapy-item]')).map(
(itemEl) => createItem(itemEl as HTMLElement, store)
)
store.setItems(items)
store.syncSlotItemMap()
items.forEach((item) => {
item.onDrag(({ pointerX, pointerY }) => {
disableDefaultSelectAndDrag()
let intersected = false
slots.forEach((slot) => {
const rect = slot.rect()
if (pointIntersectsWithRect({ x: pointerX, y: pointerY }, rect)) {
intersected = true
if (!slot.isHighlighted()) {
slot.highlight()
}
}
})
if (!intersected && store.config().swapMode === 'drop') {
item.slot().highlight()
}
if (userConfig.swapMode === 'hover') {
swapWithPointer(item, { pointerX, pointerY })
}
})
item.onDrop(({ pointerX, pointerY }) => {
enableDefaultSelectAndDrag()
if (userConfig.swapMode === 'drop') {
swapWithPointer(item, { pointerX, pointerY })
}
})
item.onHold(() => {
disableDefaultSelectAndDrag()
})
item.onRelease(() => {
enableDefaultSelectAndDrag()
})
})
}
function swapWithPointer(
item: Item,
{ pointerX, pointerY }: Pick
) {
slots.forEach((slot) => {
const rect = slot.rect()
if (pointIntersectsWithRect({ x: pointerX, y: pointerY }, rect)) {
if (item.id() === slot.itemId()) return
if (store.config().swapMode === 'hover') {
item.setContinuousDrag(true)
}
const fromSlot = item.slot()
const slotItem = slot.item()
if (
!store.eventHandlers().onBeforeSwap({
fromSlot: fromSlot.id(),
toSlot: slot.id(),
draggingItem: item.id(),
swapWithItem: slotItem?.id() || ''
})
) {
return
}
if (store.config().manualSwap) {
const oldSlotItemMap = structuredClone(store.slotItemMap())
store.swapItems(item, slot)
const newSlotItemMap = store.slotItemMap()
const draggingFlip = flipView(item.view())
draggingFlip.readInitial()
const swappedFlip: Flip | null = slotItem
? flipView(slotItem.view())
: null
swappedFlip?.readInitial()
// ------------------------------------------------------------
// Store current scroll position (before swap)
// ------------------------------------------------------------
let scrollYBeforeSwap = 0
let scrollXBeforeSwap = 0
const scrollContainer = getClosestScrollableContainer(
item.view().el()
)
if (scrollContainer instanceof Window) {
scrollYBeforeSwap = scrollContainer.scrollY
scrollXBeforeSwap = scrollContainer.scrollX
} else {
scrollYBeforeSwap = scrollContainer.scrollTop
scrollXBeforeSwap = scrollContainer.scrollLeft
}
// ------------------------------------------------------------
// Framework should swap elements in onSwap event
// ------------------------------------------------------------
store.eventHandlers().onSwap({
oldSlotItemMap,
newSlotItemMap,
fromSlot: fromSlot.id(),
toSlot: slot.id(),
draggingItem: item.id(),
swappedWithItem: slotItem?.id() || ''
})
requestAnimationFrame(() => {
const itemEls = container.querySelectorAll('[data-swapy-item]')
store.items().forEach((item) => {
const itemEl = Array.from(itemEls).find(
(el) => (el as HTMLElement).dataset.swapyItem === item.id()
) as HTMLElement
item.view().updateElement(itemEl)
})
store.syncSlotItemMap()
draggingFlip.readFinalAndReverse()
swappedFlip?.readFinalAndReverse()
animateFlippedItem(item, draggingFlip)
if (slotItem && swappedFlip) {
animateFlippedItem(slotItem, swappedFlip)
}
// Restore scroll position before swap
scrollContainer.scrollTo({
left: scrollXBeforeSwap,
top: scrollYBeforeSwap
})
})
} else {
let scrollYBeforeSwap = 0
let scrollXBeforeSwap = 0
const scrollContainer = getClosestScrollableContainer(
item.view().el()
)
if (scrollContainer instanceof Window) {
scrollYBeforeSwap = scrollContainer.scrollY
scrollXBeforeSwap = scrollContainer.scrollX
} else {
scrollYBeforeSwap = scrollContainer.scrollTop
scrollXBeforeSwap = scrollContainer.scrollLeft
}
moveItemToSlot(item, slot, true)
if (slotItem) {
moveItemToSlot(slotItem, fromSlot)
}
scrollContainer.scrollTo({
left: scrollXBeforeSwap,
top: scrollYBeforeSwap
})
const oldSlotItemMap = store.slotItemMap()
store.syncSlotItemMap()
const newSlotItemMap = store.slotItemMap()
store.eventHandlers().onSwap({
oldSlotItemMap,
newSlotItemMap,
fromSlot: fromSlot.id(),
toSlot: slot.id(),
draggingItem: item.id(),
swappedWithItem: slotItem?.id() || ''
})
}
}
})
}
function disableDefaultSelectAndDrag() {
container.querySelectorAll('img').forEach((img) => {
img.style.pointerEvents = 'none'
})
container.style.userSelect = 'none'
container.style.webkitUserSelect = 'none'
}
function enableDefaultSelectAndDrag() {
container.querySelectorAll('img').forEach((img) => {
img.style.pointerEvents = ''
})
container.style.userSelect = ''
container.style.webkitUserSelect = ''
}
function enable(enabled: boolean) {
store.config().enabled = enabled
}
function onSwapStart(handler: SwapStartEventHandler) {
store.eventHandlers().onSwapStart = handler
}
function onSwap(handler: SwapEventHandler) {
store.eventHandlers().onSwap = handler
}
function onSwapEnd(handler: SwapEndEventHandler) {
store.eventHandlers().onSwapEnd = handler
}
function onBeforeSwap(handler: BeforeSwapHandler) {
store.eventHandlers().onBeforeSwap = handler
}
function update(): void {
destroy()
requestAnimationFrame(() => {
init()
})
}
function destroy(): void {
items.forEach((item) => item.destroy())
slots.forEach((slot) => slot.destroy())
store.destroy()
items = []
slots = []
}
return {
enable,
slotItemMap: () => store.slotItemMap(),
onSwapStart,
onSwap,
onSwapEnd,
onBeforeSwap,
update,
destroy
}
}
function createStore({
slots,
items,
config
}: {
slots: Array
items: Array
-
config: Config
}): Store {
const initialStore = {
slots,
items,
config,
slotItemMap: { asObject: {}, asMap: new Map(), asArray: [] } as SlotItemMap,
zIndexCount: 1,
eventHandlers: {
onSwapStart: () => {},
onSwap: () => {},
onSwapEnd: () => {},
onBeforeSwap: () => true
},
scrollOffsetWhileDragging: { x: 0, y: 0 } as Vec2,
scrollHandler: null as ScrollHandler | null
}
let store = {
...initialStore
}
const scrollHandler = (e: Event) => {
store.scrollHandler?.(e)
}
window.addEventListener('scroll', scrollHandler)
function slotById(id: string): Slot | undefined {
return store.slots.find((slot) => slot.id() === id)
}
function itemById(id: string): Item | undefined {
return store.items.find((item) => item.id() === id)
}
function syncSlotItemMap() {
const asObject: SlotItemMapObject = {}
const asMap: SlotItemMapMap = new Map()
const asArray: SlotItemMapArray = []
store.slots.forEach((slot) => {
const slotId = slot.id()
const itemId = slot.item()?.id() || ''
asObject[slotId] = itemId
asMap.set(slotId, itemId)
asArray.push({ slot: slotId, item: itemId })
})
store.slotItemMap = { asObject, asMap, asArray }
}
/**
* Only used for manualSwap
*/
function swapItems(item: Item, toSlot: Slot) {
const currentSlotItemMap = store.slotItemMap
const sourceItemId = item.id()
const targetItemId = toSlot.item()?.id() || ''
const toSlotId = toSlot.id()
const fromSlotId = item.slot().id()
currentSlotItemMap.asObject[toSlotId] = sourceItemId
currentSlotItemMap.asObject[fromSlotId] = targetItemId
currentSlotItemMap.asMap.set(toSlotId, sourceItemId)
currentSlotItemMap.asMap.set(fromSlotId, targetItemId)
const toSlotIndex = currentSlotItemMap.asArray.findIndex(
(slotItem) => slotItem.slot === toSlotId
)
const fromSlotIndex = currentSlotItemMap.asArray.findIndex(
(slotItem) => slotItem.slot === fromSlotId
)
currentSlotItemMap.asArray[toSlotIndex].item = sourceItemId
currentSlotItemMap.asArray[fromSlotIndex].item = targetItemId
}
function destroy() {
window.removeEventListener('scroll', scrollHandler)
store = { ...initialStore }
}
return {
slots: () => store.slots,
items: () => store.items,
config: () => config,
setItems: (items) => (store.items = items),
setSlots: (slots) => (store.slots = slots),
slotById,
itemById,
zIndex: (inc = false) => {
if (inc) {
return ++store.zIndexCount
}
return store.zIndexCount
},
resetZIndex: () => {
store.zIndexCount = 1
},
eventHandlers: () => store.eventHandlers,
syncSlotItemMap,
slotItemMap: (clone: boolean = false) =>
clone ? structuredClone(store.slotItemMap) : store.slotItemMap,
onScroll: (handler: ScrollHandler | null) => {
store.scrollHandler = handler
},
swapItems,
destroy
}
}
function createSlot(slotEl: HTMLElement, store: Store): Slot {
const view = createView(slotEl)
function id(): string {
return view.el().dataset.swapySlot!
}
function itemId(): string | null {
const itemEl = view.el().children[0] as HTMLElement | null
return itemEl?.dataset.swapyItem || null
}
function rect(): Rect {
return createRectFromBoundingRect(view.el().getBoundingClientRect())
}
function item(): Item | undefined {
const itemEl = view.el().children[0] as HTMLElement
if (itemEl) {
return store.itemById(itemEl.dataset.swapyItem!)
}
}
function unhighlightAllSlots() {
store.slots().forEach((slot) => {
slot.view().el().removeAttribute('data-swapy-highlighted')
})
}
function highlight() {
unhighlightAllSlots()
view.el().setAttribute('data-swapy-highlighted', '')
}
function destroy() {}
return {
id,
view: () => view,
itemId,
rect,
item,
highlight,
unhighlightAllSlots,
isHighlighted: () => view.el().hasAttribute('data-swapy-highlighted'),
destroy
}
}
function createItem(itemEl: HTMLElement, store: Store): Item {
const view = createView(itemEl)
const cancelAnimation: ItemCancelAnimation = {} as ItemCancelAnimation
let autoScroller: AutoScroller | null = null
let slotItemMapSessionStart: SlotItemMap | null = null
// ------------------------------------------------------------
// Variables for dragging
// ------------------------------------------------------------
let dragging = false
let continuousDrag = true
let currentDragEvent: DragEvent | null
const dragSyncUpdate = createSyncUpdate()
let dragListener: OnDragListener | null = () => {}
let dropListener: OnDropListener | null = () => {}
let holdListener: OnHoldListener | null = () => {}
let releaseListener: OnReleaseListener | null = () => {}
const { onDrag, onDrop, onHold, onRelease } = view.usePlugin<
DraggablePlugin,
DraggableConfig
>(makeDraggable, {
startDelay: store.config().dragOnHold ? 400 : 0,
targetEl: handle()
})
// ------------------------------------------------------------
// Variables for handling scrolling while dragging
// ------------------------------------------------------------
const lastScroll = vec2(0, 0)
const containerLastScroll = vec2(0, 0)
const scrollOffset = vec2(0, 0)
const containerScrollOffset = vec2(0, 0)
let scrollContainer: HTMLElement | Window | null = null
let scrollContainerHandler: ScrollHandler | null = null
// ------------------------------------------------------------
// Run only when dragOnHold is enabled.
// Executed the moment the user clicks on the element and holds
// without moving the pointer.
// ------------------------------------------------------------
onHold((e) => {
if (!store.config().enabled) {
return
}
if (hasHandle() && !isHandleEl(e.el)) {
return
}
if (hasNoDrag() && isNoDrag(e.el)) {
return
}
holdListener?.(e)
})
// ------------------------------------------------------------
// Run only when dragOnHold is enabled.
// Executed when the user releases the pointer (pointerUp)
// before the startDelay passes.
//
// Use case: the user has to click and hold for a few hundered
// milliseconds before the drag is activated, but the user
// releases the pointer before that, cancelling the drag.
// ------------------------------------------------------------
onRelease((e) => {
if (!store.config().enabled) {
return
}
if (hasHandle() && !isHandleEl(e.el)) {
return
}
if (hasNoDrag() && isNoDrag(e.el)) {
return
}
releaseListener?.(e)
})
function onDragStart(dragEvent: DragEvent) {
markAsDragging()
slot().highlight()
cancelAnimation.drop?.()
const slotRects = store.slots().map((slot) => slot.view().boundingRect())
store.slots().forEach((slot, i) => {
const rect = slotRects[i]
slot.view().el().style.width = `${rect.width}px`
slot.view().el().style.maxWidth = `${rect.width}px`
slot.view().el().style.flexShrink = '0'
slot.view().el().style.height = `${rect.height}px`
})
const slotItemMap = store.slotItemMap(true)
store.eventHandlers().onSwapStart({
draggingItem: id(),
fromSlot: slotId(),
slotItemMap
})
slotItemMapSessionStart = slotItemMap
view.el().style.position = 'relative'
view.el().style.zIndex = `${store.zIndex(true)}`
scrollContainer = getClosestScrollableContainer(dragEvent.el)
if (store.config().autoScrollOnDrag) {
autoScroller = createAutoScroller(
scrollContainer,
store.config().dragAxis
)
autoScroller.updatePointer({
x: dragEvent.pointerX,
y: dragEvent.pointerY
})
}
// ------------------------------------------------------------
// Handling scrolling while dragging
// ------------------------------------------------------------
lastScroll.x = window.scrollX
lastScroll.y = window.scrollY
scrollOffset.x = 0
scrollOffset.y = 0
// When the scrollContainer is not the window
if (scrollContainer instanceof HTMLElement) {
containerLastScroll.x = scrollContainer.scrollLeft
containerLastScroll.y = scrollContainer.scrollTop
// Handler for scrolling the closest scroll container
scrollContainerHandler = () => {
containerScrollOffset.x =
(scrollContainer as HTMLElement).scrollLeft - containerLastScroll.x
containerScrollOffset.y =
(scrollContainer as HTMLElement).scrollTop - containerLastScroll.y
view.setTransform({
dragX:
(currentDragEvent?.width || 0) +
scrollOffset.x +
containerScrollOffset.x,
dragY:
(currentDragEvent?.height || 0) +
scrollOffset.y +
containerScrollOffset.y
})
}
scrollContainer.addEventListener('scroll', scrollContainerHandler)
}
// When scrolling the window
store.onScroll(() => {
scrollOffset.x = window.scrollX - lastScroll.x
scrollOffset.y = window.scrollY - lastScroll.y
const containerOffsetX = containerScrollOffset.x || 0
const containerOffsetY = containerScrollOffset.y || 0
view.setTransform({
dragX:
(currentDragEvent?.width || 0) + scrollOffset.x + containerOffsetX,
dragY:
(currentDragEvent?.height || 0) + scrollOffset.y + containerOffsetY
})
})
}
onDrag((dragEvent) => {
if (!store.config().enabled) {
return
}
// On drag start
if (!dragging) {
if (hasHandle() && !isHandleEl(dragEvent.el)) {
return
}
if (hasNoDrag() && isNoDrag(dragEvent.el)) {
return
}
onDragStart(dragEvent)
}
dragging = true
if (autoScroller) {
autoScroller.updatePointer({
x: dragEvent.pointerX,
y: dragEvent.pointerY
})
}
currentDragEvent = dragEvent
cancelAnimation.drop?.()
dragSyncUpdate(() => {
view.el().style.position = 'relative'
const dragX = dragEvent.width + scrollOffset.x + containerScrollOffset.x
const dragY = dragEvent.height + scrollOffset.y + containerScrollOffset.y
if (store.config().dragAxis === 'y') {
view.setTransform({
dragY
})
} else if (store.config().dragAxis === 'x') {
view.setTransform({
dragX
})
} else {
view.setTransform({
dragX,
dragY
})
}
dragListener?.(dragEvent)
})
})
onDrop((dragEvent) => {
if (!dragging) return
unmarkAsDragging()
dragging = false
continuousDrag = false
currentDragEvent = null
if (scrollContainer) {
scrollContainer.removeEventListener('scroll', scrollContainerHandler!)
scrollContainerHandler = null
}
scrollContainer = null
containerScrollOffset.x = 0
containerScrollOffset.y = 0
scrollOffset.x = 0
scrollOffset.y = 0
if (autoScroller) {
autoScroller.destroy()
autoScroller = null
}
slot().unhighlightAllSlots()
dropListener?.(dragEvent)
store.eventHandlers().onSwapEnd({
slotItemMap: store.slotItemMap(),
hasChanged: slotItemMapSessionStart?.asMap
? !areMapsEqual(
slotItemMapSessionStart?.asMap,
store.slotItemMap().asMap
)
: false
})
slotItemMapSessionStart = null
store.onScroll(null)
store.slots().forEach((slot) => {
slot.view().el().style.width = ''
slot.view().el().style.maxWidth = ''
slot.view().el().style.flexShrink = ''
slot.view().el().style.height = ''
})
if (store.config().manualSwap && store.config().swapMode === 'drop') {
requestAnimationFrame(animateDrop)
} else {
animateDrop()
}
function animateDrop() {
const current = view.currentTransform()
const currentX = current.dragX + current.translateX
const currentY = current.dragY + current.translateY
cancelAnimation.drop = animate(
{ translate: vec2(currentX, currentY) },
{ translate: vec2(0, 0) },
({ translate }, done) => {
if (done) {
if (!dragging) {
view.clearTransform()
view.el().style.transformOrigin = ''
}
} else {
view.setTransform({
dragX: 0,
dragY: 0,
translateX: translate.x,
translateY: translate.y
})
}
if (done) {
store.items().forEach((item) => {
if (!item.isDragging()) {
item.view().el().style.zIndex = ''
}
})
store.resetZIndex()
view.el().style.position = ''
continuousDrag = true
}
},
getAnimateConfig(store.config().animation)
)
}
})
function onItemDrag(listener: OnDragListener) {
dragListener = listener
}
function onItemDrop(listener: OnDropListener) {
dropListener = listener
}
function onItemHold(listener: OnHoldListener) {
holdListener = listener
}
function onItemRelease(listener: OnReleaseListener) {
releaseListener = listener
}
function handle(): HTMLElement | null {
return view.el().querySelector('[data-swapy-handle]')
}
function isHandleEl(el: HTMLElement): boolean {
const handleEl = handle()
if (!handleEl) {
return false
}
return handleEl === el || handleEl.contains(el)
}
function hasHandle(): boolean {
return handle() !== null
}
function noDragEls(): Array {
return Array.from(view.el().querySelectorAll('[data-swapy-no-drag]'))
}
function isNoDrag(el: HTMLElement): boolean {
const noDragElements = noDragEls()
if (!noDragElements || noDragElements.length === 0) {
return false
}
return (
noDragElements.includes(el) ||
noDragElements.some((noDragEl) => noDragEl.contains(el))
)
}
function hasNoDrag(): boolean {
return noDragEls().length > 0
}
function markAsDragging() {
view.el().setAttribute('data-swapy-dragging', '')
}
function unmarkAsDragging() {
view.el().removeAttribute('data-swapy-dragging')
}
function destroy() {
dragListener = null
dropListener = null
holdListener = null
releaseListener = null
currentDragEvent = null
slotItemMapSessionStart = null
if (autoScroller) {
autoScroller.destroy()
autoScroller = null
}
if (scrollContainer && scrollContainerHandler) {
scrollContainer.removeEventListener('scroll', scrollContainerHandler)
}
view.destroy()
}
function id(): string {
return view.el().dataset.swapyItem!
}
function slot(): Slot {
return store.slotById(view.el().parentElement!.dataset.swapySlot!)!
}
function slotId(): string {
return view.el().parentElement!.dataset.swapySlot!
}
return {
id,
view: () => view,
slot,
slotId,
onDrag: onItemDrag,
onDrop: onItemDrop,
onHold: onItemHold,
onRelease: onItemRelease,
destroy,
isDragging: () => dragging,
cancelAnimation: () => cancelAnimation,
dragEvent: () => currentDragEvent,
store: () => store,
continuousDrag: () => continuousDrag,
setContinuousDrag: (value: boolean) => (continuousDrag = value)
}
}
function moveItemToSlot(item: Item, slot: Slot, from = false) {
if (from) {
const targetItem = slot.item()
if (targetItem) {
slot.view().el().style.position = 'relative'
targetItem.view().el().style.position = 'absolute'
}
} else {
const slotOfItem = item.slot()
slotOfItem.view().el().style.position = ''
item.view().el().style.position = ''
}
if (!item) {
return
}
const flip = flipView(item.view())
flip.readInitial()
slot.view().el().appendChild(item.view().el())
flip.readFinalAndReverse()
animateFlippedItem(item, flip)
}
function createSyncUpdate() {
let isUpdating = false
return (cb: () => void) => {
if (isUpdating) return
isUpdating = true
requestAnimationFrame(() => {
cb()
isUpdating = false
})
}
}
function animateFlippedItem(item: Item, flip: Flip) {
item.cancelAnimation().moveToSlot?.()
item.cancelAnimation().drop?.()
const animateConfig = getAnimateConfig(item.store().config().animation)
const transitionValues = flip.transitionValues()
let current = item.view().currentTransform()
let lastProgress = 0
let draggedAfterDrop = false
item.cancelAnimation().moveToSlot = animate(
{
translate: transitionValues.from.translate,
scale: transitionValues.from.scale,
borderRadius: transitionValues.from.borderRadius
},
{
translate: transitionValues.to.translate,
scale: transitionValues.to.scale,
borderRadius: transitionValues.to.borderRadius
},
({ translate, scale, borderRadius }, done, progress) => {
if (item.isDragging()) {
if (lastProgress !== 0) {
draggedAfterDrop = true
}
const relativeX = item.dragEvent()!.relativeX
const relativeY = item.dragEvent()!.relativeY
/**
* ContinuousDrag means the user didn't drop the item
* and dragged it again quickly before it reaches final position on drop.
* This might not be possible if the animation is very quick.
* We need this to avoid updating translateX and translateY for non-continuousDrag.
* We update them to adjust the item position based on cursor position when scaling,
* for example scaling from left if holding the item from the right edge
* (similar to transform-origin).
* If we update translateX and translateY for non-continuousDrag, we'll see some
* animation issues, like the item sliding while dragging again. Subtle, but important.
*/
if (item.continuousDrag()) {
item.view().setTransform({
translateX: lerp(
current.translateX,
current.translateX +
(transitionValues.from.width - transitionValues.to.width) *
relativeX,
animateConfig.easing(progress - lastProgress)
),
translateY: lerp(
current.translateY,
current.translateY +
(transitionValues.from.height - transitionValues.to.height) *
relativeY,
animateConfig.easing(progress - lastProgress)
),
scaleX: scale.x,
scaleY: scale.y
})
} else {
item.view().setTransform({ scaleX: scale.x, scaleY: scale.y })
}
} else {
current = item.view().currentTransform()
lastProgress = progress
// If the user dragged the item while it was moving to new slot,
// we just need to animate the scale, because the translate animation
// will now be handled by the dropping animation when the user drop it.
if (draggedAfterDrop) {
item.view().setTransform({
scaleX: scale.x,
scaleY: scale.y
})
} else {
item.view().setTransform({
dragX: 0,
dragY: 0,
translateX: translate.x,
translateY: translate.y,
scaleX: scale.x,
scaleY: scale.y
})
}
}
const children = flip.childrenTransitionData()
children.forEach(
({
el,
fromTranslate,
fromScale,
fromBorderRadius,
toBorderRadius,
parentScale
}) => {
const parentScaleX = lerp(
parentScale.x,
1,
animateConfig.easing(progress)
)
const parentScaleY = lerp(
parentScale.y,
1,
animateConfig.easing(progress)
)
el.style.transform = `translate(${
fromTranslate.x +
(0 - fromTranslate.x / parentScaleX) *
animateConfig.easing(progress)
}px, ${
fromTranslate.y +
(0 - fromTranslate.y / parentScaleY) *
animateConfig.easing(progress)
}px) scale(${lerp(
fromScale.x / parentScaleX,
1 / parentScaleX,
animateConfig.easing(progress)
)}, ${lerp(
fromScale.y / parentScaleY,
1 / parentScaleY,
animateConfig.easing(progress)
)})`
if (!isBorderRadiusNone(fromBorderRadius)) {
el.style.borderRadius = borderRadiusToString(
lerpBorderRadius(
fromBorderRadius,
toBorderRadius,
animateConfig.easing(progress)
)
)
}
}
)
if (!isBorderRadiusNone(borderRadius)) {
item.view().el().style.borderRadius = borderRadiusToString(borderRadius)
}
if (done) {
if (!item.isDragging()) {
item.view().el().style.transformOrigin = ''
item.view().clearTransform()
}
item.view().el().style.borderRadius = ''
children.forEach(({ el }) => {
el.style.transform = ''
el.style.transformOrigin = ''
el.style.borderRadius = ''
})
}
},
animateConfig
)
}
function logError(...args: unknown[]) {
console.error('Swapy Error:', ...args)
}
function isContainerValid(container: Element) {
const containerEl = container as HTMLElement
let isValid = true
const slotEls = containerEl.querySelectorAll('[data-swapy-slot]')
if (!containerEl) {
logError('container passed to createSwapy() is undefined or null')
isValid = false
}
slotEls.forEach((_slotEl) => {
const slotEl = _slotEl as HTMLElement
const slotId = slotEl.dataset.swapySlot
const slotChildren = slotEl.children
const slotFirstChild = slotChildren[0] as HTMLElement
if (!slotId || slotId.length === 0) {
logError(slotEl, 'does not contain a slotId using data-swapy-slot')
isValid = false
}
if (slotChildren.length > 1) {
logError('slot:', `"${slotId}"`, 'cannot contain more than one element')
isValid = false
}
if (
slotFirstChild &&
(!slotFirstChild.dataset.swapyItem ||
slotFirstChild.dataset.swapyItem.length === 0)
) {
logError(
'slot',
`"${slotId}"`,
'does not contain an element with an item id using data-swapy-item'
)
isValid = false
}
})
const slotIds = Array.from(slotEls).map(
(slotEl) => (slotEl as HTMLElement).dataset.swapySlot
)
const itemEls = containerEl.querySelectorAll('[data-swapy-item]')
const itemIds = Array.from(itemEls).map(
(itemEl) => (itemEl as HTMLElement).dataset.swapyItem
)
if (hasDuplicates(slotIds)) {
const duplicates = findDuplicates(slotIds)
logError(
'your container has duplicate slot ids',
`(${duplicates.join(', ')})`
)
isValid = false
}
if (hasDuplicates(itemIds)) {
const duplicates = findDuplicates(itemIds)
logError(
'your container has duplicate item ids',
`(${duplicates.join(', ')})`
)
isValid = false
}
return isValid
}
function hasDuplicates(array: Array): boolean {
return new Set(array).size !== array.length
}
function findDuplicates(array: T[]): T[] {
const seen = new Set()
const duplicates = new Set()
for (const item of array) {
if (seen.has(item)) {
duplicates.add(item)
} else {
seen.add(item)
}
}
return Array.from(duplicates)
}
function areMapsEqual(
map1: Map,
map2: Map
): boolean {
if (map1.size !== map2.size) return false
for (const [key, value] of map1) {
if (map2.get(key) !== value) return false
}
return true
}
export function getClosestScrollableContainer(
element: HTMLElement
): HTMLElement | Window {
let current: HTMLElement | null = element
while (current) {
const computedStyle = window.getComputedStyle(current)
const overflowY = computedStyle.overflowY
const overflowX = computedStyle.overflowX
if (
((overflowY === 'auto' || overflowY === 'scroll') &&
current.scrollHeight > current.clientHeight) ||
((overflowX === 'auto' || overflowX === 'scroll') &&
current.scrollWidth > current.clientWidth)
) {
return current
}
current = current.parentElement
}
return window
}
interface AutoScroller {
updatePointer(pointer: Vec2): void
destroy(): void
}
function createAutoScroller(
container: HTMLElement | Window,
dragAxis: DragAxis
): AutoScroller {
const MAX_DISTANCE = 100
const MAX_SPEED = 5
let scrolling = false
let rect: Rect
let maxScrollY = 0
let maxScrollX = 0
let currentScrollY = 0
let currentScrollX = 0
let scrollTopBy = 0
let scrollLeftBy = 0
let raf: number | null = null
if (container instanceof HTMLElement) {
rect = createRectFromBoundingRect(container.getBoundingClientRect())
maxScrollY = container.scrollHeight - rect.height
maxScrollX = container.scrollWidth - rect.width
} else {
rect = {
x: 0,
y: 0,
width: window.innerWidth,
height: window.innerHeight
}
maxScrollY = document.documentElement.scrollHeight - window.innerHeight
maxScrollX = document.documentElement.scrollWidth - window.innerWidth
}
function updateCurrentScroll() {
if (container instanceof HTMLElement) {
currentScrollY = container.scrollTop
currentScrollX = container.scrollLeft
} else {
currentScrollY = window.scrollY
currentScrollX = window.scrollX
}
}
function updatePointer(pointer: Vec2) {
scrolling = false
const rectTop = rect.y
const rectBottom = rect.y + rect.height
const rectLeft = rect.x
const rectRight = rect.x + rect.width
const closerToTop =
Math.abs(rectTop - pointer.y) < Math.abs(rectBottom - pointer.y)
const closerToLeft =
Math.abs(rectLeft - pointer.x) < Math.abs(rectRight - pointer.x)
updateCurrentScroll()
if (dragAxis !== 'x') {
if (closerToTop) {
const distanceToTop = rectTop - pointer.y
if (distanceToTop >= -MAX_DISTANCE) {
const v = clamp(distanceToTop, -MAX_DISTANCE, 0)
const scrollAmount = remap(-MAX_DISTANCE, 0, 0, MAX_SPEED, v)
scrollTopBy = -scrollAmount
scrolling = true
}
} else {
const distanceToBottom = rectBottom - pointer.y
if (distanceToBottom <= MAX_DISTANCE) {
const v = clamp(distanceToBottom, 0, MAX_DISTANCE)
const scrollAmount = remap(MAX_DISTANCE, 0, 0, MAX_SPEED, v)
scrollTopBy = scrollAmount
scrolling = true
}
}
}
if (dragAxis !== 'y') {
if (closerToLeft) {
const distanceToLeft = rectLeft - pointer.x
if (distanceToLeft >= -MAX_DISTANCE) {
const v = clamp(distanceToLeft, -MAX_DISTANCE, 0)
const scrollAmount = remap(-MAX_DISTANCE, 0, 0, MAX_SPEED, v)
scrollLeftBy = -scrollAmount
scrolling = true
}
} else {
const distanceToRight = rectRight - pointer.x
if (distanceToRight <= MAX_DISTANCE) {
const v = clamp(distanceToRight, 0, MAX_DISTANCE)
const scrollAmount = remap(MAX_DISTANCE, 0, 0, MAX_SPEED, v)
scrollLeftBy = scrollAmount
scrolling = true
}
}
}
if (scrolling) {
if (raf) {
cancelAnimationFrame(raf)
}
scroll()
}
}
function scroll() {
updateCurrentScroll()
if (dragAxis !== 'x') {
scrollTopBy = currentScrollY + scrollTopBy >= maxScrollY ? 0 : scrollTopBy
}
if (dragAxis !== 'y') {
scrollLeftBy =
currentScrollX + scrollLeftBy >= maxScrollX ? 0 : scrollLeftBy
}
container.scrollBy({ top: scrollTopBy, left: scrollLeftBy })
if (scrolling) {
raf = requestAnimationFrame(scroll)
}
}
function destroy() {
scrolling = false
}
return {
updatePointer,
destroy
}
}
================================================
FILE: src/math.ts
================================================
import { BorderRadius } from './borderRadius'
import { Vec2, vec2Add, vec2Scale, vec2Sub } from './vector'
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
export function lerpVectors(v1: Vec2, v2: Vec2, t: number): Vec2 {
return vec2Add(v1, vec2Scale(vec2Sub(v2, v1), t))
}
export function lerpBorderRadius(
b1: BorderRadius,
b2: BorderRadius,
t: number
): BorderRadius {
return {
x: {
topLeft: lerp(b1.x.topLeft, b2.x.topLeft, t),
topRight: lerp(b1.x.topRight, b2.x.topRight, t),
bottomRight: lerp(b1.x.bottomRight, b2.x.bottomRight, t),
bottomLeft: lerp(b1.x.bottomLeft, b2.x.bottomLeft, t)
},
y: {
topLeft: lerp(b1.y.topLeft, b2.y.topLeft, t),
topRight: lerp(b1.y.topRight, b2.y.topRight, t),
bottomRight: lerp(b1.y.bottomRight, b2.y.bottomRight, t),
bottomLeft: lerp(b1.y.bottomLeft, b2.y.bottomLeft, t)
},
unit: b1.unit
}
}
export function inverseLerp(min: number, max: number, value: number) {
return clamp((value - min) / (max - min), 0, 1)
}
export function remap(
a: number,
b: number,
c: number,
d: number,
value: number
) {
return lerp(c, d, inverseLerp(a, b, value))
}
export function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
================================================
FILE: src/rect.ts
================================================
import { Vec2 } from './vector'
export type Position = { x: number; y: number }
export type Size = { width: number; height: number }
export type Rect = Position & Size
export function createRectFromBoundingRect(rect: DOMRect): Rect {
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height
}
}
/**
* For returning the boundingRect without any
* transform translates set on any of its parents.
* For example, if any of its parents has translate(0, 100px),
* the return y value will be y - 100.
*/
export function getCorrectedBoundingRect(element: HTMLElement): Rect {
const boundingRect = element.getBoundingClientRect()
let offsetX = 0
let offsetY = 0
let currentElement: HTMLElement | null = element.parentElement
while (currentElement) {
const style = getComputedStyle(currentElement)
const transform = style.transform
if (transform && transform !== 'none') {
const matrixMatch = transform.match(/matrix.*\((.+)\)/)
if (matrixMatch) {
const values = matrixMatch[1].split(', ').map(Number)
offsetX += values[4] || 0
offsetY += values[5] || 0
}
}
currentElement = currentElement.parentElement
}
return {
y: boundingRect.top - offsetY,
x: boundingRect.left - offsetX,
width: boundingRect.width,
height: boundingRect.height
}
}
export function getLayoutRect(el: HTMLElement): Rect {
let current = el
let top = 0
let left = 0
while (current) {
top += current.offsetTop
left += current.offsetLeft
current = current.offsetParent as HTMLElement
}
return {
x: left,
y: top,
width: el.offsetWidth,
height: el.offsetHeight
}
}
export function pointIntersectsWithRect(point: Position, rect: Rect) {
return (
point.x >= rect.x &&
point.x <= rect.x + rect.width &&
point.y >= rect.y &&
point.y <= rect.y + rect.height
)
}
export function getScrollOffset(el: HTMLElement): Vec2 {
let current: HTMLElement | null = el
let y = 0
let x = 0
while (current) {
// Check if the current element is scrollable
const isScrollable = (node: HTMLElement) => {
const style = getComputedStyle(node)
return /(auto|scroll)/.test(
style.overflow + style.overflowY + style.overflowX
)
}
// If scrollable, add its scroll offsets
if (current === document.body) {
// Use window scroll for the element
x += window.scrollX
y += window.scrollY
break
}
if (isScrollable(current)) {
x += current.scrollLeft
y += current.scrollTop
}
current = current.parentElement
}
return { x, y }
}
================================================
FILE: src/utils.ts
================================================
import { SlotItemMapArray, Swapy } from '.'
export type SlottedItems
- = Array<{
slotId: string
itemId: string
item: Item | null
}>
export function toSlottedItems
- (
items: Array
- ,
idField: keyof Item,
slotItemMap: SlotItemMapArray
): SlottedItems
- {
return slotItemMap.map((slotItem) => ({
slotId: slotItem.slot,
itemId: slotItem.item,
item:
slotItem.item === ''
? null
: items.find((item) => slotItem.item === item[idField])!
}))
}
export function initSlotItemMap
- (
items: Array
- ,
idField: keyof Item
): SlotItemMapArray {
return items.map((item) => ({
item: item[idField] as string,
slot: item[idField] as string
}))
}
export function dynamicSwapy
- (
swapy: Swapy | null,
items: Array
- ,
idField: keyof Item,
slotItemMap: SlotItemMapArray,
setSlotItemMap: (slotItemMap: SlotItemMapArray) => void,
removeItemOnly = false
) {
// Get the newly added items and convert them to slotItem objects
const newItems: SlotItemMapArray = items
.filter(
(item) => !slotItemMap.some((slotItem) => slotItem.item === item[idField])
)
.map((item) => ({
slot: item[idField] as string,
item: item[idField] as string
}))
let withoutRemovedItems: SlotItemMapArray
// Remove slot and item
if (!removeItemOnly) {
withoutRemovedItems = slotItemMap.filter(
(slotItem) =>
items.some((item) => item[idField] === slotItem.item) || !slotItem.item
)
} else {
withoutRemovedItems = slotItemMap.map((slotItem) => {
if (!items.some((item) => item[idField] === slotItem.item)) {
return { slot: slotItem.slot as string, item: '' }
}
return slotItem
})
}
const updatedSlotItemsMap: SlotItemMapArray = [
...withoutRemovedItems,
...newItems
]
setSlotItemMap(updatedSlotItemsMap)
if (
newItems.length > 0 ||
withoutRemovedItems.length !== slotItemMap.length
) {
requestAnimationFrame(() => {
swapy?.update()
})
}
}
================================================
FILE: src/vector.ts
================================================
export type Vec2 = { x: number; y: number }
export function isVec2(v: any): v is Vec2 {
return typeof v === 'object' && 'x' in v && 'y' in v
}
export function vec2(x: number, y: number): Vec2 {
return { x, y }
}
export function vec2Add(v1: Vec2, v2: Vec2): Vec2 {
return vec2(v1.x + v2.x, v1.y + v2.y)
}
export function vec2Sub(v1: Vec2, v2: Vec2): Vec2 {
return vec2(v1.x - v2.x, v1.y - v2.y)
}
export function vec2Scale(v: Vec2, a: number): Vec2 {
return vec2(v.x * a, v.y * a)
}
================================================
FILE: src/view.ts
================================================
import { BorderRadius, parseBorderRadius } from './borderRadius'
import { createRectFromBoundingRect, getLayoutRect, Rect } from './rect'
export interface View {
el(): HTMLElement
setTransform(transform: Partial): void
clearTransform(): void
currentTransform: () => Transform
borderRadius: () => BorderRadius
layoutRect(): Rect
boundingRect(): Rect
usePlugin
(
pluginFactory: (v: View, config: C) => P,
config: C
): P
updateElement(el: HTMLElement): void
destroy(): void
}
export interface ViewPlugin {
onElementUpdate(): void
destroy(): void
}
export type Transform = {
dragX: number
dragY: number
translateX: number
translateY: number
scaleX: number
scaleY: number
}
export function createView(el: HTMLElement): View {
const plugins: Array = []
let element = el
let currentTransform: Transform = {
dragX: 0,
dragY: 0,
translateX: 0,
translateY: 0,
scaleX: 1,
scaleY: 1
}
const borderRadius = parseBorderRadius(
window.getComputedStyle(element).borderRadius
)
const thisView = {
el: () => element,
setTransform,
clearTransform,
currentTransform: () => currentTransform,
borderRadius: () => borderRadius,
layoutRect: () => getLayoutRect(element),
boundingRect: () =>
createRectFromBoundingRect(element.getBoundingClientRect()),
usePlugin,
destroy,
updateElement
}
function setTransform(newTransform: Partial) {
currentTransform = { ...currentTransform, ...newTransform }
renderTransform()
}
function clearTransform() {
currentTransform = {
dragX: 0,
dragY: 0,
translateX: 0,
translateY: 0,
scaleX: 1,
scaleY: 1
}
renderTransform()
}
function renderTransform() {
const { dragX, dragY, translateX, translateY, scaleX, scaleY } =
currentTransform
if (
dragX === 0 &&
dragY === 0 &&
translateX === 0 &&
translateY === 0 &&
scaleX === 1 &&
scaleY === 1
) {
element.style.transform = ''
} else {
element.style.transform = `translate(${dragX + translateX}px, ${
dragY + translateY
}px) scale(${scaleX}, ${scaleY})`
}
}
function usePlugin(
pluginFactory: (v: View, config: C) => P,
config: C
) {
const plugin = pluginFactory(thisView, config)
plugins.push(plugin)
return plugin
}
function destroy() {
plugins.forEach((plugin) => plugin.destroy())
}
function updateElement(el: HTMLElement) {
if (!el) return
const hasDraggingAttr = element.hasAttribute('data-swapy-dragging')
const previousStyles = element.style.cssText
element = el
if (hasDraggingAttr) {
element.setAttribute('data-swapy-dragging', '')
}
element.style.cssText = previousStyles
plugins.forEach((plugin) => plugin.onElementUpdate())
}
return thisView
}
================================================
FILE: src/vite-env.d.ts
================================================
///
================================================
FILE: tsconfig.build.json
================================================
{
"extends": "./tsconfig.json",
"include": ["src"]
}
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "examples/**/*.ts", "examples/**/*.tsx", "examples/**/*.svelte", "examples/**/*.vue"]
}
================================================
FILE: vite.config.ts
================================================
import { resolve } from 'path'
import { defineConfig } from 'vite'
import dts from 'vite-plugin-dts'
import react from '@vitejs/plugin-react'
import vue from '@vitejs/plugin-vue'
import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'Swapy',
fileName: (format) => `swapy.${format === 'es' ? 'js' : 'min.js'}`
}
},
plugins: [dts({ rollupTypes: true }), react(), vue(), svelte()]
})