+
+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
+}
diff --git a/src/borderRadius.ts b/src/borderRadius.ts
new file mode 100644
index 0000000..516ff8a
--- /dev/null
+++ b/src/borderRadius.ts
@@ -0,0 +1,123 @@
+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
+ )
+}
diff --git a/src/draggable.ts b/src/draggable.ts
new file mode 100644
index 0000000..d5c32e8
--- /dev/null
+++ b/src/draggable.ts
@@ -0,0 +1,212 @@
+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
+}
+
+const DEFAULT_CONFIG: DraggableConfig = {
+ startDelay: 0
+}
+
+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 (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
+ e.preventDefault()
+ 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
+
+ e.preventDefault()
+ 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
+ }
+}
diff --git a/src/easings.ts b/src/easings.ts
new file mode 100644
index 0000000..a2ca7f2
--- /dev/null
+++ b/src/easings.ts
@@ -0,0 +1,10 @@
+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)
+}
diff --git a/src/flip.ts b/src/flip.ts
new file mode 100644
index 0000000..1136213
--- /dev/null
+++ b/src/flip.ts
@@ -0,0 +1,275 @@
+import {
+ BorderRadius,
+ borderRadiusToString,
+ calculateBorderRadiusInverse,
+ parseBorderRadius
+} from './borderRadius'
+import {
+ createRectFromBoundingRect,
+ 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 = view.boundingRect()
+ scrollOffset = getScrollOffset(view.el())
+
+ const tree = getParentChildTree(view.el())
+ parentChildrenTreeData = tree.map(({ parent, children }) => ({
+ parent: {
+ el: parent,
+ initialRect: createRectFromBoundingRect(parent.getBoundingClientRect())
+ },
+ children: children.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) as HTMLElement[]
+ if (children.length > 0) {
+ result.push({
+ parent: parent,
+ children: children
+ })
+ children.forEach((child) => traverse(child))
+ }
+ }
+
+ traverse(element)
+ return result
+}
diff --git a/src/index.ts b/src/index.ts
index bc63e81..71836b9 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,6 +1,1428 @@
-export { createSwapy, type Swapy, type SlotItemMap } from './instance'
-export {
- type SwapEventObject,
- type SwapEventArray,
- type SwapEventMap
-} from './veloxi-plugin/SwapyPlugin'
+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(): 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: () => 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
+ })
+
+ // ------------------------------------------------------------
+ // 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?.()
+
+ store.slots().forEach((slot) => {
+ const rect = slot.view().boundingRect()
+ slot.view().el().style.width = `${rect.width}px`
+ slot.view().el().style.flexShrink = '0'
+ slot.view().el().style.height = `${rect.height}px`
+ })
+
+ const slotItemMap = store.slotItemMap()
+ 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.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
+ }
+}
diff --git a/src/instance.ts b/src/instance.ts
deleted file mode 100644
index 26709c4..0000000
--- a/src/instance.ts
+++ /dev/null
@@ -1,386 +0,0 @@
-import { getUniqueId, mapsAreEqual } from './utils'
-import { installPlugin } from './veloxi-plugin'
-import {
- InitEvent,
- SwapData,
- SwapEndEvent,
- SwapEndEventData,
- SwapEvent,
- SwapEventArray,
- SwapEventData,
- SwapStartEvent,
- SwapyPlugin,
- SwapyPluginApi
-} from './veloxi-plugin/SwapyPlugin'
-
-type SwapCallback = (event: SwapEventData) => void
-type SwapEndCallback = (event: SwapEndEventData) => void
-type SwapStartCallback = () => void
-
-export interface Swapy {
- onSwap(callback: SwapCallback): void
- onSwapEnd(callback: SwapEndCallback): void
- onSwapStart(callback: SwapStartCallback): void
- enable(enabled: boolean): void
- destroy(): void
- setData(swapData: SwapData): void
-}
-
-export type SlotItemMap = SwapEventArray
-
-export type AnimationType = 'dynamic' | 'spring' | 'none'
-export type SwapMode = 'hover' | 'stop' | 'drop'
-
-export type Config = {
- animation: AnimationType
- continuousMode: boolean
- manualSwap: boolean
- swapMode: SwapMode
- autoScrollOnDrag: boolean
-}
-
-const DEFAULT_CONFIG: Config = {
- animation: 'dynamic',
- continuousMode: true,
- manualSwap: false,
- swapMode: 'hover',
- autoScrollOnDrag: false
-}
-
-function validate(root: HTMLElement): boolean {
- let isValid = true
- const slotElements = root.querySelectorAll('[data-swapy-slot]')
- if (slotElements.length === 0) {
- console.error('There are no slots defined in your root element:', root)
- isValid = false
- }
- slotElements.forEach((slot) => {
- const slotEl = slot as HTMLElement
- const slotId = slotEl.dataset.swapySlot
- if (!slotId || slotId.length === 0) {
- console.error(slot, 'does not contain a slotId using data-swapy-slot')
- isValid = false
- }
- const slotChildren = slotEl.children
- if (slotChildren.length > 1) {
- console.error(
- 'slot:',
- `"${slotId}"`,
- 'cannot contain more than one element'
- )
- isValid = false
- }
- const slotChild = slotChildren[0] as HTMLElement
- if (
- slotChild &&
- (!slotChild.dataset.swapyItem || slotChild.dataset.swapyItem.length === 0)
- ) {
- console.error(
- 'slot:',
- `"${slotId}"`,
- 'does not contain an element with item id using data-swapy-item'
- )
- isValid = false
- }
- })
- return isValid
-}
-
-function addVeloxiDataAttributes(
- root: HTMLElement,
- config = {} as Config
-): string {
- const pluginKey = getUniqueId()
- root.dataset.velPluginKey = pluginKey
- root.dataset.velPlugin = 'Swapy'
- root.dataset.velView = 'root'
- root.dataset.velDataConfigAnimation = config.animation
- root.dataset.velDataConfigSwapMode = config.swapMode
- if (config.continuousMode) {
- root.dataset.velDataConfigContinuousMode = 'true'
- }
- if (config.manualSwap) {
- root.dataset.velDataConfigManualSwap = 'true'
- }
- const slots = Array.from(
- root.querySelectorAll('[data-swapy-slot]')
- ) as HTMLElement[]
- slots.forEach((slot) => {
- slot.dataset.velView = 'slot'
- })
-
- const items = Array.from(
- root.querySelectorAll('[data-swapy-item]')
- ) as HTMLElement[]
- items.forEach((item) => {
- item.dataset.velView = 'item'
- item.dataset.velLayoutId = item.dataset.swapyItem
- const handle = item.querySelector('[data-swapy-handle]') as HTMLElement
- if (handle) {
- handle.dataset.velView = 'handle'
- }
- })
-
- const textElements = Array.from(
- root.querySelectorAll('[data-swapy-text]')
- ) as HTMLElement[]
- textElements.forEach((el) => {
- el.dataset.velLayoutPosition = ''
- })
-
- const excludedElements = Array.from(
- root.querySelectorAll('[data-swapy-exclude]')
- ) as HTMLElement[]
- excludedElements.forEach((el) => {
- el.dataset.velIgnore = ''
- })
-
- return pluginKey
-}
-
-function resyncItems(root: HTMLElement): boolean {
- const slots = Array.from(
- root.querySelectorAll('[data-swapy-slot]:not([data-vel-view])')
- ) as HTMLElement[]
- slots.forEach((slot) => {
- slot.dataset.velView = 'slot'
- })
- const items = Array.from(
- root.querySelectorAll('[data-swapy-item]:not([data-vel-view])')
- ) as HTMLElement[]
- items.forEach((item) => {
- item.dataset.velView = 'item'
- item.dataset.velLayoutId = item.dataset.swapyItem
- const handle = item.querySelector('[data-swapy-handle]') as HTMLElement
- if (handle) {
- handle.dataset.velView = 'handle'
- }
-
- const textElements = Array.from(
- item.querySelectorAll('[data-swapy-text]')
- ) as HTMLElement[]
- textElements.forEach((el) => {
- el.dataset.velLayoutPosition = ''
- })
-
- const excludedElements = Array.from(
- item.querySelectorAll('[data-swapy-exclude]')
- ) as HTMLElement[]
- excludedElements.forEach((el) => {
- el.dataset.velIgnore = ''
- })
- })
- return items.length > 0 || slots.length > 0
-}
-
-function createSwapy(
- root: Element | null,
- userConfig: Partial = {} as Partial
-): Swapy {
- if (!root) {
- throw new Error(
- 'Cannot create a Swapy instance because the element you provided does not exist on the page!'
- )
- }
- const config = { ...DEFAULT_CONFIG, ...userConfig }
- const rootEl = root as HTMLElement
- if (!validate(rootEl)) {
- throw new Error(
- 'Cannot create a Swapy instance because your HTML structure is invalid. Fix all above errors and then try!'
- )
- }
- const pluginKey = addVeloxiDataAttributes(rootEl, config)
-
- const swapy = new SwapyInstance(rootEl, pluginKey, config)
- return {
- onSwap(callback) {
- swapy.setSwapCallback(callback)
- },
- onSwapEnd(callback) {
- swapy.setSwapEndCallback(callback)
- },
- onSwapStart(callback) {
- swapy.setSwapStartCallback(callback)
- },
- enable(enabled) {
- swapy.setEnabled(enabled)
- },
- destroy() {
- swapy.destroy()
- },
- setData(swapData) {
- swapy.setData(swapData)
- }
- }
-}
-
-class SwapyInstance {
- private _rootEl: HTMLElement
- private _veloxiApp
- private _slotElMap: Map
- private _itemElMap: Map
- private _swapCallback?: SwapCallback
- private _swapEndCallback?: SwapEndCallback
- private _swapStartCallback?: SwapStartCallback
- private _previousMap?: Map
- private _pluginKey: string
- constructor(rootEl: HTMLElement, pluginKey: string, config: Partial) {
- this._rootEl = rootEl
- this._veloxiApp = installPlugin()
- this._slotElMap = this._createSlotElMap()
- this._itemElMap = this._createItemElMap()
- this.setAutoScrollOnDrag(config.autoScrollOnDrag!)
- this._pluginKey = pluginKey
- this._veloxiApp.onPluginEvent(
- SwapyPlugin,
- InitEvent,
- ({ data }) => {
- this._previousMap = data.map
- },
- pluginKey
- )
-
- this._veloxiApp.onPluginEvent(
- SwapyPlugin,
- SwapEvent,
- (event) => {
- if (
- this._previousMap &&
- mapsAreEqual(this._previousMap, event.data.map)
- ) {
- return
- }
- if (!config.manualSwap) {
- this._applyOrder(event.data.map)
- }
- this._previousMap = event.data.map
- this._swapCallback?.(event)
- },
- pluginKey
- )
-
- this._veloxiApp.onPluginEvent(
- SwapyPlugin,
- SwapEndEvent,
- (event) => {
- this._swapEndCallback?.(event)
- },
- pluginKey
- )
-
- this._veloxiApp.onPluginEvent(
- SwapyPlugin,
- SwapStartEvent,
- () => {
- this._swapStartCallback?.()
- },
- pluginKey
- )
-
- this.setupMutationObserver()
- }
-
- private setupMutationObserver() {
- const observer = new MutationObserver((mutations) => {
- if (mutations.some((mutation) => mutation.type === 'childList')) {
- if (resyncItems(this._rootEl)) {
- this._slotElMap = this._createSlotElMap()
- this._itemElMap = this._createItemElMap()
- }
- }
- })
- observer.observe(this._rootEl, {
- childList: true,
- subtree: true
- })
- }
-
- setData(swapData: SwapData) {
- try {
- const plugin = this._veloxiApp.getPlugin(
- 'Swapy',
- this._pluginKey
- )
- plugin.setData(swapData)
- } catch (e) {}
- }
-
- destroy() {
- this._veloxiApp.destroy('Swapy')
- }
-
- setEnabled(enabledValue: boolean) {
- requestAnimationFrame(() => {
- try {
- const plugin = this._veloxiApp.getPlugin(
- 'Swapy',
- this._pluginKey
- )
- plugin.setEnabled(enabledValue)
- } catch (e) {}
- })
- }
-
- private setAutoScrollOnDrag(autoScrollOnDrag: boolean) {
- requestAnimationFrame(() => {
- try {
- const plugin = this._veloxiApp.getPlugin(
- 'Swapy',
- this._pluginKey
- )
- plugin.setAutoScrollOnDrag(autoScrollOnDrag)
- } catch (e) {}
- })
- }
-
- setSwapCallback(callback: SwapCallback) {
- this._swapCallback = callback
- }
-
- setSwapEndCallback(callback: SwapEndCallback) {
- this._swapEndCallback = callback
- }
-
- setSwapStartCallback(callback: SwapStartCallback) {
- this._swapStartCallback = callback
- }
-
- private _applyOrder(map: Map) {
- Array.from(map.keys()).forEach((slotName) => {
- if (map.get(slotName) === this._previousMap?.get(slotName)) {
- return
- }
- const itemName = map.get(slotName)
- if (!itemName) return
- const slot = this._slotElMap.get(slotName)
- const item = this._itemElMap.get(itemName)
- if (!slot || !item) return
- slot.innerHTML = ''
- slot.appendChild(item)
- })
- }
-
- private _createSlotElMap() {
- return (
- Array.from(
- this._rootEl.querySelectorAll('[data-swapy-slot]')
- ) as HTMLElement[]
- ).reduce((map, el) => {
- map.set(el.dataset.swapySlot, el)
- return map
- }, new Map())
- }
-
- private _createItemElMap() {
- return (
- Array.from(
- this._rootEl.querySelectorAll('[data-swapy-item]')
- ) as HTMLElement[]
- ).reduce((map, el) => {
- map.set(el.dataset.swapyItem, el)
- return map
- }, new Map())
- }
-}
-
-export { createSwapy }
diff --git a/src/math.ts b/src/math.ts
new file mode 100644
index 0000000..c4a86a4
--- /dev/null
+++ b/src/math.ts
@@ -0,0 +1,50 @@
+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)
+}
diff --git a/src/rect.ts b/src/rect.ts
new file mode 100644
index 0000000..0f560ac
--- /dev/null
+++ b/src/rect.ts
@@ -0,0 +1,76 @@
+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
+ }
+}
+
+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 }
+}
diff --git a/src/utils.ts b/src/utils.ts
index b010488..43a47c4 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,17 +1,84 @@
-export function mapsAreEqual(map1: Map, map2: Map) {
- if (map1.size !== map2.size) {
- return false
- }
+import { SlotItemMapArray, Swapy } from '.'
- for (let [key, value] of map1) {
- if (!map2.has(key) || map2.get(key) !== value) {
- return false
- }
- }
- return true
+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])!
+ }))
}
-let id = 0
-export function getUniqueId() {
- return id++ + ''
+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()
+ })
+ }
}
diff --git a/src/vector.ts b/src/vector.ts
new file mode 100644
index 0000000..d6a0d73
--- /dev/null
+++ b/src/vector.ts
@@ -0,0 +1,21 @@
+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)
+}
diff --git a/src/veloxi-plugin/SwapyPlugin.ts b/src/veloxi-plugin/SwapyPlugin.ts
deleted file mode 100644
index 461887b..0000000
--- a/src/veloxi-plugin/SwapyPlugin.ts
+++ /dev/null
@@ -1,516 +0,0 @@
-import {
- DragEvent,
- DragEventPlugin,
- EventBus,
- Events,
- PluginFactory,
- View
-} from 'veloxi'
-import { AnimationType, Config, SwapMode } from '../instance'
-import { mapsAreEqual } from '../utils'
-import { getScrollContainer, ScrollContainer } from '../ScrollContainer'
-
-export type SwapEventArray = Array<{ slotId: string; itemId: string | null }>
-export type SwapEventMap = Map
-export type SwapEventObject = Record
-
-interface SwapEventDataData {
- map: SwapEventMap
- array: SwapEventArray
- object: SwapEventObject
-}
-
-type RequireOnlyOne = Keys extends keyof T
- ? { [K in Keys]: T[K] } & Partial, never>>
- : never
-
-export type SwapData = RequireOnlyOne<
- SwapEventDataData,
- 'map' | 'array' | 'object'
->
-export interface SwapEventData {
- data: SwapEventDataData
-}
-
-export type SwapEndEventData = SwapEventData & { hasChanged: boolean }
-
-export interface SwapyConfig {
- animation: 'dynamic' | 'spring' | 'none'
-}
-
-export interface SwapyPluginApi {
- setEnabled(isEnabled: boolean): void
- setData(data: SwapData): void
- setAutoScrollOnDrag(autoScrollOnDrag: boolean): void
-}
-
-export class SwapEvent {
- data: SwapEventDataData
- constructor(props: SwapEventData) {
- this.data = props.data
- }
-}
-
-export class SwapEndEvent {
- data: SwapEventDataData
- hasChanged: boolean
- constructor(props: SwapEndEventData) {
- this.data = props.data
- this.hasChanged = props.hasChanged
- }
-}
-
-export class SwapStartEvent {}
-
-export class InitEvent {
- data: SwapEventDataData
- constructor(props: SwapEventData) {
- this.data = props.data
- }
-}
-
-function createEventData(eventMap: SwapEventMap): SwapEventDataData {
- const map = new Map(eventMap)
- return {
- map,
- array: Array.from(eventMap).map(([slotId, itemId]) => ({ slotId, itemId })),
- object: Array.from(eventMap).reduce(
- (result, [slot, item]) => {
- result[slot] = item
- return result
- },
- {}
- )
- }
-}
-
-function createEventDataFromAny(data: SwapData): SwapEventDataData {
- if (data.map) {
- const map = new Map(data.map)
- return {
- map,
- array: Array.from(data.map).map(([slotId, itemId]) => ({
- slotId,
- itemId
- })),
- object: Array.from(data.map).reduce(
- (result, [slot, item]) => {
- result[slot] = item
- return result
- },
- {}
- )
- }
- } else if (data.object) {
- const object = { ...data.object }
- return {
- map: new Map(Object.entries(object)),
- array: Object.entries(object).map(([slotId, itemId]) => ({
- slotId,
- itemId
- })),
- object
- }
- } else {
- const array = [...data.array]
- return {
- map: new Map(array.map(({ slotId, itemId }) => [slotId, itemId])),
- array,
- object: array.reduce((result, { slotId, itemId }) => {
- result[slotId] = itemId
- return result
- }, {})
- }
- }
-}
-
-export const SwapyPlugin: PluginFactory = (
- context
-) => {
- const dragEventPlugin = context.useEventPlugin(DragEventPlugin)
- dragEventPlugin.on(DragEvent, onDrag)
-
- const MAX_SCROLL_SPEED = 20
- const MAX_SCROLL_THRESHOLD = 100
-
- let root: View
- let scrollContainer: ScrollContainer
- let slots: View[]
- let items: View[]
- let draggingItem: View
- let slotItemMapOnDragStart: SwapEventMap | null = null
- let slotItemMap: SwapEventMap = new Map()
- let previousSlotItemMap: SwapEventMap = new Map()
- let offsetX: number | null
- let offsetY: number | null
- let handleOffsetX: number | null = null
- let handleOffsetY: number | null = null
- let initialWidth: number | null
- let initialHeight: number | null
- let enabled = true
- let draggingEvent: DragEvent | null
- let isContinuousMode: boolean
- let isManualSwap: boolean
- let draggingSlot: View | null
- let startedDragging: boolean = false
- let hasSwapped: boolean = false
- let triggerSwap = () => {}
- let shouldAutoScrollOnDrag = false
- let scrollDY = 0
- let scrollDX = 0
-
- context.api({
- setEnabled(isEnabled) {
- enabled = isEnabled
- },
- setData(data: SwapData) {
- const eventData = createEventDataFromAny(data)
- slotItemMap = new Map(eventData.map)
- previousSlotItemMap = new Map(slotItemMap)
- },
- setAutoScrollOnDrag(autoScrollOnDrag) {
- shouldAutoScrollOnDrag = autoScrollOnDrag
- }
- })
-
- function getConfig(): Config {
- return {
- animation: root.data.configAnimation as AnimationType,
- continuousMode: typeof root.data.configContinuousMode !== 'undefined',
- manualSwap: typeof root.data.configManualSwap !== 'undefined',
- swapMode: root.data.configSwapMode as SwapMode,
- autoScrollOnDrag: shouldAutoScrollOnDrag
- }
- }
-
- function getAnimation(): {
- animator: 'dynamic' | 'spring' | 'instant'
- config: any
- } {
- const animationConfig = getConfig().animation
- if (animationConfig === 'dynamic') {
- return {
- animator: 'dynamic',
- config: {}
- }
- } else if (animationConfig === 'spring') {
- return {
- animator: 'spring',
- config: {
- damping: 0.7,
- stiffness: 0.62
- }
- }
- } else if (animationConfig === 'none') {
- return {
- animator: 'instant',
- config: {}
- }
- }
- return {
- animator: 'instant',
- config: {}
- }
- }
-
- function prepareSwap(newSlotItemMap: SwapEventMap) {
- return () => {
- slotItemMap = newSlotItemMap
- previousSlotItemMap = new Map(slotItemMap)
- }
- }
-
- context.setup(() => {
- root = context.getView('root')!
- slots = context.getViews('slot')
- items = context.getViews('item')
- scrollContainer = getScrollContainer(items[0].element)
- scrollContainer.onScroll(() => {
- updateDraggingPosition()
- })
- isContinuousMode = getConfig().continuousMode
- isManualSwap = getConfig().manualSwap
-
- slots.forEach((slot) => {
- setupSlot(slot)
- })
-
- setupRemainingChildren()
-
- previousSlotItemMap = new Map(slotItemMap)
- requestAnimationFrame(() => {
- context.emit(InitEvent, { data: createEventData(slotItemMap) })
- })
- })
-
- function setupSlot(slot: View) {
- const item = slot.getChild('item')
- if (item) {
- setupItem(item)
- }
- slotItemMap.set(
- slot.element.dataset.swapySlot!,
- item ? item.element.dataset.swapyItem! : null
- )
- }
-
- function setupItem(item: View) {
- const animation = getAnimation()
- item.styles.position = 'relative'
- item.styles.userSelect = 'none'
- item.styles.webkitUserSelect = 'none'
- item.position.setAnimator(animation.animator, animation.config)
- item.scale.setAnimator(animation.animator, animation.config)
- item.layoutTransition(true)
-
- requestAnimationFrame(() => {
- const handle = item.getChild('handle')
- if (handle) {
- dragEventPlugin.addView(handle)
- handle.styles.touchAction = 'none'
- } else {
- dragEventPlugin.addView(item)
- item.styles.touchAction = 'none'
- }
- })
- }
-
- context.onViewAdded((view) => {
- if (context.initialized) {
- if (view.name === 'item') {
- items = context.getViews('item')
- const slot = view.getParent('slot')!
- setupSlot(slot)
- setupRemainingChildren()
- previousSlotItemMap = new Map(slotItemMap)
- context.emit(SwapEvent, { data: createEventData(slotItemMap) })
- } else if (view.name === 'slot') {
- slots = context.getViews('slot')
- }
- }
- })
-
- function setupRemainingChildren() {
- const animation = getAnimation()
- const remainingChildren = context.getViews('root-child')
- remainingChildren.forEach((child) => {
- child.position.setAnimator(animation.animator, animation.config)
- child.scale.setAnimator(animation.animator, animation.config)
- child.layoutTransition(true)
- })
- }
-
- function updateDraggingPosition() {
- if (!draggingEvent) return
- if (!offsetX || !offsetY) {
- const draggingItemScrollOffset = draggingItem.getScroll()
- offsetX =
- draggingEvent.pointerX -
- draggingItem.position.x +
- draggingItemScrollOffset.x
- offsetY =
- draggingEvent.pointerY -
- draggingItem.position.y +
- draggingItemScrollOffset.y
- }
- if (!initialWidth || !initialHeight) {
- initialWidth = draggingItem.size.width
- initialHeight = draggingItem.size.height
- }
- const scaleX = draggingItem.size.width / initialWidth
- const scaleY = draggingItem.size.height / initialHeight
- const newOffsetX = offsetX * (scaleX - 1)
- const newOffsetY = offsetY * (scaleY - 1)
- const { x: scrollOffsetX, y: scrollOffsetY } =
- scrollContainer.getScrollOffset()
- draggingItem.position.set(
- {
- x: draggingEvent.x - newOffsetX - (handleOffsetX || 0) + scrollOffsetX,
- y: draggingEvent.y - newOffsetY - (handleOffsetY || 0) + scrollOffsetY
- },
- draggingItem.scale.x !== 1 || draggingItem.scale.y !== 1
- )
- }
-
- context.subscribeToEvents((eventBus: EventBus) => {
- eventBus.subscribeToEvent(Events.PointerMoveEvent, ({ x, y }) => {
- if (!scrollContainer) {
- return
- }
- if (scrollContainer.height - y <= MAX_SCROLL_THRESHOLD) {
- scrollDY = Math.max(
- 0,
- MAX_SCROLL_SPEED *
- (1 -
- Math.min(scrollContainer.height - y, MAX_SCROLL_THRESHOLD) /
- MAX_SCROLL_THRESHOLD)
- )
- } else if (y <= MAX_SCROLL_THRESHOLD) {
- scrollDY =
- -1 *
- Math.max(
- 0,
- MAX_SCROLL_SPEED *
- (1 - Math.min(y, MAX_SCROLL_THRESHOLD) / MAX_SCROLL_THRESHOLD)
- )
- } else {
- scrollDY = 0
- }
-
- if (scrollContainer.width - x <= MAX_SCROLL_THRESHOLD) {
- scrollDX = Math.max(
- 0,
- MAX_SCROLL_SPEED *
- (1 -
- Math.min(scrollContainer.width - x, MAX_SCROLL_THRESHOLD) /
- MAX_SCROLL_THRESHOLD)
- )
- } else if (x <= MAX_SCROLL_THRESHOLD) {
- scrollDX =
- -1 *
- Math.max(
- 0,
- MAX_SCROLL_SPEED *
- (1 - Math.min(x, MAX_SCROLL_THRESHOLD) / MAX_SCROLL_THRESHOLD)
- )
- } else {
- scrollDX = 0
- }
- })
- })
-
- context.update(() => {
- if (draggingEvent?.isDragging && shouldAutoScrollOnDrag) {
- scrollContainer.container.scrollBy({ top: scrollDY, left: scrollDX })
- }
- })
-
- function onDrag(event: DragEvent) {
- if (!enabled || !event.hasMoved) return
- const swapMode = getConfig().swapMode
- const withHandle = event.view.name === 'handle'
- draggingItem = withHandle ? event.view.getParent('item')! : event.view
- if (!draggingSlot) {
- draggingSlot = draggingItem.getParent('slot')!
- }
- if (handleOffsetX === null && handleOffsetY === null) {
- const resetHandleX = withHandle
- ? event.view.position.initialX - event.view.position.x
- : 0
- const resetHandleY = withHandle
- ? event.view.position.initialY - event.view.position.y
- : 0
- handleOffsetX =
- event.view.position.x - draggingItem.position.x - resetHandleX
- handleOffsetY =
- event.view.position.y - draggingItem.position.y - resetHandleY
- }
- const hoveringOverASlot = slots.some((slot) =>
- slot.intersects(event.pointerX, event.pointerY)
- )
- if (event.isDragging) {
- scrollContainer.startScrollTracking()
- if (!startedDragging) {
- startedDragging = true
- context.emit(SwapStartEvent, {})
- }
- if (slotItemMapOnDragStart === null) {
- slotItemMapOnDragStart = new Map(slotItemMap)
- }
- draggingEvent = event
- updateDraggingPosition()
- slots.forEach((slot) => {
- if (!slot.intersects(event.pointerX, event.pointerY)) {
- if (slot !== draggingSlot) {
- slot.element.removeAttribute('data-swapy-highlighted')
- }
- return
- }
-
- if (typeof slot.element.dataset.swapyHighlighted === 'undefined') {
- slot.element.dataset.swapyHighlighted = ''
- }
-
- if (!draggingSlot) {
- return
- }
-
- if (
- (swapMode === 'stop' || (swapMode !== 'drop' && !isContinuousMode)) &&
- !event.stopped
- ) {
- return
- }
- const targetSlotName = slot.element.dataset.swapySlot
- const targetItemName = slot.getChild('item')?.element.dataset.swapyItem
- const draggingSlotName = draggingSlot!.element.dataset.swapySlot
- const draggingItemName = draggingItem.element.dataset.swapyItem
-
- if (!targetSlotName || !draggingSlotName || !draggingItemName) {
- return
- }
- const newSlotItemMap = new Map(slotItemMap)
- newSlotItemMap.set(targetSlotName, draggingItemName)
- if (targetItemName) {
- newSlotItemMap.set(draggingSlotName, targetItemName)
- } else {
- newSlotItemMap.set(draggingSlotName, null)
- }
- triggerSwap = prepareSwap(new Map(newSlotItemMap))
- if (!mapsAreEqual(newSlotItemMap, previousSlotItemMap)) {
- if (!isManualSwap && swapMode !== 'drop') {
- triggerSwap()
- }
- draggingSlot = null
- if (swapMode !== 'drop') {
- context.emit(SwapEvent, { data: createEventData(newSlotItemMap) })
- }
- }
- })
-
- items.forEach((item) => {
- item.styles.zIndex = item === draggingItem ? '2' : ''
- })
- } else {
- slots.forEach((slot) => {
- slot.element.removeAttribute('data-swapy-highlighted')
- })
- draggingItem.position.reset()
- draggingSlot = null
- offsetX = null
- offsetY = null
- initialWidth = null
- initialHeight = null
- draggingEvent = null
- handleOffsetX = null
- handleOffsetY = null
- startedDragging = false
-
- if (swapMode === 'drop') {
- if (!hoveringOverASlot) {
- triggerSwap = prepareSwap(new Map(slotItemMap))
- }
- triggerSwap()
- context.emit(SwapEvent, { data: createEventData(slotItemMap) })
- }
- triggerSwap = () => {}
-
- hasSwapped = !mapsAreEqual(slotItemMap, slotItemMapOnDragStart!)
-
- context.emit(SwapEndEvent, {
- data: createEventData(slotItemMap),
- hasChanged: hasSwapped
- })
-
- hasSwapped = false
- slotItemMapOnDragStart = null
- scrollContainer.endScrollTracking()
- }
- requestAnimationFrame(() => {
- updateDraggingPosition()
- })
- }
-}
-
-SwapyPlugin.pluginName = 'Swapy'
-SwapyPlugin.scope = 'root'
diff --git a/src/veloxi-plugin/index.ts b/src/veloxi-plugin/index.ts
deleted file mode 100644
index 5ce5d40..0000000
--- a/src/veloxi-plugin/index.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { createApp, type VeloxiApp } from 'veloxi'
-import { SwapyConfig, SwapyPlugin, SwapyPluginApi } from './SwapyPlugin'
-
-let app: VeloxiApp
-function installPlugin() {
- if (app) {
- app.updatePlugin(SwapyPlugin)
- return app
- }
- app = createApp()
- app.addPlugin(SwapyPlugin)
- app.run()
- return app
-}
-
-export { installPlugin }
diff --git a/src/view.ts b/src/view.ts
new file mode 100644
index 0000000..734011a
--- /dev/null
+++ b/src/view.ts
@@ -0,0 +1,120 @@
+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 previousStyles = element.style.cssText
+ element = el
+ element.style.cssText = previousStyles
+ plugins.forEach((plugin) => plugin.onElementUpdate())
+ }
+
+ return thisView
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 4078e74..11f02fe 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -1,2 +1 @@
-///
///
diff --git a/tsconfig.build.json b/tsconfig.build.json
new file mode 100644
index 0000000..e9041fd
--- /dev/null
+++ b/tsconfig.build.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["src"]
+}
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index 27e7a52..1e0d5a4 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -7,9 +7,8 @@
"skipLibCheck": true,
/* Bundler mode */
- "moduleResolution": "bundler",
+ "moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
- "resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
@@ -21,6 +20,5 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
- "include": ["src", "examples/**/*.ts", "examples/**/*.tsx"],
- "exclude": ["examples/vue"]
+ "include": ["src", "examples/**/*.ts", "examples/**/*.tsx", "examples/**/*.svelte", "examples/**/*.vue"]
}
diff --git a/vite.config.js b/vite.config.ts
similarity index 100%
rename from vite.config.js
rename to vite.config.ts