18 |
19 | export function animate(
20 | from: P,
21 | to: { [K in keyof P]: P[K] },
22 | cb: (value: P, done: boolean, progress: number) => void,
23 | config?: Partial
24 | ): CancelFunction {
25 | let canceled = false
26 | const cancel = () => {
27 | canceled = true
28 | }
29 | const mergedConfig = { ...DEFAULT_CONFIG, ...config }
30 | let start: number
31 | function update(ts: number) {
32 | if (start === undefined) {
33 | start = ts
34 | }
35 | const elapsed = ts - start
36 | const t = clamp(elapsed / mergedConfig.duration, 0, 1)
37 | const names = Object.keys(from) as Array
38 | const toKeys = Object.keys(to) as Array
39 | if (!names.every((name) => toKeys.includes(name))) {
40 | console.error('animate Error: `from` keys are different than `to`')
41 | return
42 | }
43 |
44 | const result = {} as P
45 |
46 | names.forEach((name) => {
47 | if (typeof from[name] === 'number' && typeof to[name] === 'number') {
48 | result[name] = lerp(
49 | from[name],
50 | to[name],
51 | mergedConfig.easing(t)
52 | ) as P[keyof P]
53 | } else if (isBorderRadius(from[name]) && isBorderRadius(to[name])) {
54 | result[name] = lerpBorderRadius(
55 | from[name],
56 | to[name],
57 | mergedConfig.easing(t)
58 | ) as P[keyof P]
59 | } else if (isVec2(from[name]) && isVec2(to[name])) {
60 | result[name] = lerpVectors(
61 | from[name],
62 | to[name],
63 | mergedConfig.easing(t)
64 | ) as P[keyof P]
65 | }
66 | })
67 | cb(result, t >= 1, t)
68 | if (t < 1 && !canceled) {
69 | requestAnimationFrame(update)
70 | }
71 | }
72 | requestAnimationFrame(update)
73 | return cancel
74 | }
75 |
--------------------------------------------------------------------------------
/src/borderRadius.ts:
--------------------------------------------------------------------------------
1 | export type BorderRadius = {
2 | x: {
3 | topLeft: number
4 | topRight: number
5 | bottomRight: number
6 | bottomLeft: number
7 | }
8 | y: {
9 | topLeft: number
10 | topRight: number
11 | bottomRight: number
12 | bottomLeft: number
13 | }
14 | unit: string
15 | }
16 |
17 | export function isBorderRadius(
18 | borderRadius: any
19 | ): borderRadius is BorderRadius {
20 | return (
21 | typeof borderRadius === 'object' &&
22 | borderRadius !== null &&
23 | 'x' in borderRadius &&
24 | 'y' in borderRadius &&
25 | 'unit' in borderRadius &&
26 | typeof borderRadius.unit === 'string' &&
27 | typeof borderRadius.x === 'object' &&
28 | typeof borderRadius.y === 'object' &&
29 | 'topLeft' in borderRadius.x &&
30 | 'topRight' in borderRadius.x &&
31 | 'bottomRight' in borderRadius.x &&
32 | 'bottomLeft' in borderRadius.x &&
33 | 'topLeft' in borderRadius.y &&
34 | 'topRight' in borderRadius.y &&
35 | 'bottomRight' in borderRadius.y &&
36 | 'bottomLeft' in borderRadius.y
37 | )
38 | }
39 |
40 | export function parseBorderRadius(borderRadius: string): BorderRadius {
41 | // Regular expression to match numbers with units (e.g., 6px, 10%)
42 | const match = borderRadius.match(/(\d+(?:\.\d+)?)(px|%)/g)
43 |
44 | if (!match) {
45 | return {
46 | x: { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 },
47 | y: { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 },
48 | unit: 'px'
49 | }
50 | }
51 |
52 | // Parse each matched value with its unit
53 | const values = match.map((value) => {
54 | const [_, num, unit] = value.match(/(\d+(?:\.\d+)?)(px|%)/) ?? []
55 | return { value: parseFloat(num), unit }
56 | })
57 |
58 | // Ensure all units are consistent
59 | const unit = values[0]?.unit || 'px'
60 | if (values.some((v) => v.unit !== unit)) {
61 | throw new Error('Inconsistent units in border-radius string.')
62 | }
63 |
64 | // Handle 1 to 4 values
65 | const [v1, v2, v3, v4] = values.map((v) => v.value)
66 | const result = {
67 | topLeft: v1 ?? 0,
68 | topRight: v2 ?? v1 ?? 0,
69 | bottomRight: v3 ?? v1 ?? 0,
70 | bottomLeft: v4 ?? v2 ?? v1 ?? 0
71 | }
72 | return {
73 | x: { ...result },
74 | y: { ...result },
75 | unit
76 | }
77 | }
78 |
79 | export function calculateBorderRadiusInverse(
80 | { x, y, unit }: BorderRadius,
81 | scaleX: number,
82 | scaleY: number
83 | ): BorderRadius {
84 | if (unit === 'px') {
85 | const RadiusXInverse = {
86 | topLeft: x.topLeft / scaleX,
87 | topRight: x.topRight / scaleX,
88 | bottomLeft: x.bottomLeft / scaleX,
89 | bottomRight: x.bottomRight / scaleX
90 | }
91 | const RadiusYInverse = {
92 | topLeft: y.topLeft / scaleY,
93 | topRight: y.topRight / scaleY,
94 | bottomLeft: y.bottomLeft / scaleY,
95 | bottomRight: y.bottomRight / scaleY
96 | }
97 | return { x: RadiusXInverse, y: RadiusYInverse, unit: 'px' }
98 | } else if (unit === '%') {
99 | return { x, y, unit: '%' }
100 | }
101 | return { x, y, unit }
102 | }
103 |
104 | export function borderRadiusToString(borderRadius: BorderRadius): string {
105 | return `
106 | ${borderRadius.x.topLeft}${borderRadius.unit} ${borderRadius.x.topRight}${borderRadius.unit} ${borderRadius.x.bottomRight}${borderRadius.unit} ${borderRadius.x.bottomLeft}${borderRadius.unit}
107 | /
108 | ${borderRadius.y.topLeft}${borderRadius.unit} ${borderRadius.y.topRight}${borderRadius.unit} ${borderRadius.y.bottomRight}${borderRadius.unit} ${borderRadius.y.bottomLeft}${borderRadius.unit}
109 | `
110 | }
111 |
112 | export function isBorderRadiusNone(borderRadius: BorderRadius) {
113 | return (
114 | borderRadius.x.topLeft === 0 &&
115 | borderRadius.x.topRight === 0 &&
116 | borderRadius.x.bottomRight === 0 &&
117 | borderRadius.x.bottomLeft === 0 &&
118 | borderRadius.y.topLeft === 0 &&
119 | borderRadius.y.topRight === 0 &&
120 | borderRadius.y.bottomRight === 0 &&
121 | borderRadius.y.bottomLeft === 0
122 | )
123 | }
124 |
--------------------------------------------------------------------------------
/src/draggable.ts:
--------------------------------------------------------------------------------
1 | import { View, ViewPlugin } from './view'
2 |
3 | interface Draggable {
4 | onDrag(handler: OnDragListener): void
5 | onDrop(handler: OnDropListener): void
6 | onHold(handler: OnHoldListener): void
7 | onRelease(handler: OnReleaseListener): void
8 | destroy(): void
9 | readjust(): void
10 | }
11 |
12 | export type DraggablePlugin = Draggable & ViewPlugin
13 |
14 | export type DragEvent = {
15 | x: number
16 | y: number
17 | width: number
18 | height: number
19 | pointerX: number
20 | pointerY: number
21 | relativeX: number
22 | relativeY: number
23 | el: HTMLElement
24 | }
25 |
26 | export type OnDragListener = (dragEvent: DragEvent) => void
27 | export type OnDropListener = (dragEvent: DragEvent) => void
28 | export type OnHoldListener = ({ el }: { el: HTMLElement }) => void
29 | export type OnReleaseListener = ({ el }: { el: HTMLElement }) => void
30 |
31 | export type DraggableConfig = {
32 | startDelay: number
33 | targetEl?: HTMLElement | null
34 | }
35 |
36 | const DEFAULT_CONFIG: DraggableConfig = {
37 | startDelay: 0,
38 | targetEl: null
39 | }
40 |
41 | export function makeDraggable(
42 | view: View,
43 | userConfig?: Partial
44 | ): DraggablePlugin {
45 | const config: DraggableConfig = { ...DEFAULT_CONFIG, ...userConfig }
46 |
47 | let el = view.el()
48 | let isPointerDown = false
49 | let dragListener: OnDragListener | null = null
50 | let dropListener: OnDropListener | null = null
51 | let holdListener: OnHoldListener | null = null
52 | let releaseListener: OnReleaseListener | null = null
53 | let initialX = 0
54 | let initialY = 0
55 | let lastX = 0
56 | let lastY = 0
57 | let layoutLeft = 0
58 | let layoutTop = 0
59 | let initialClientX = 0
60 | let initialClientY = 0
61 | let relativeX = 0
62 | let relativeY = 0
63 | let draggingEl: HTMLElement | null = null
64 | let timer: NodeJS.Timeout | null
65 |
66 | el.addEventListener('pointerdown', onPointerDown)
67 | document.body.addEventListener('pointerup', onPointerUp)
68 | document.body.addEventListener('pointermove', onPointerMove)
69 | document.body.addEventListener('touchmove', onTouchMove, { passive: false })
70 |
71 | function onPointerDown(e: PointerEvent) {
72 | if (
73 | config.targetEl &&
74 | e.target !== config.targetEl &&
75 | !config.targetEl.contains(e.target as HTMLElement)
76 | )
77 | return
78 | if (isPointerDown) return
79 | if (!e.isPrimary) return
80 | if (config.startDelay > 0) {
81 | holdListener?.({ el: e.target as HTMLElement })
82 | timer = setTimeout(() => {
83 | start()
84 | }, config.startDelay)
85 | } else {
86 | start()
87 | }
88 |
89 | function start() {
90 | draggingEl = e.target as HTMLElement
91 | const rect = view.boundingRect()
92 | const layout = view.layoutRect()
93 | layoutLeft = layout.x
94 | layoutTop = layout.y
95 | lastX = rect.x - layoutLeft
96 | lastY = rect.y - layoutTop
97 | initialX = e.clientX - lastX
98 | initialY = e.clientY - lastY
99 | initialClientX = e.clientX
100 | initialClientY = e.clientY
101 | relativeX = (e.clientX - rect.x) / rect.width
102 | relativeY = (e.clientY - rect.y) / rect.height
103 | isPointerDown = true
104 | onPointerMove(e)
105 | }
106 | }
107 |
108 | function readjust() {
109 | const layout = view.layoutRect()
110 | initialX -= layoutLeft - layout.x
111 | initialY -= layoutTop - layout.y
112 | layoutLeft = layout.x
113 | layoutTop = layout.y
114 | }
115 |
116 | function onPointerUp(e: PointerEvent) {
117 | if (!isPointerDown) {
118 | if (timer) {
119 | clearTimeout(timer)
120 | timer = null
121 | releaseListener?.({ el: e.target as HTMLElement })
122 | }
123 | return
124 | }
125 | if (!e.isPrimary) return
126 | isPointerDown = false
127 | const width = e.clientX - initialClientX
128 | const height = e.clientY - initialClientY
129 | dropListener?.({
130 | x: lastX,
131 | y: lastY,
132 | pointerX: e.clientX,
133 | pointerY: e.clientY,
134 | width,
135 | height,
136 | relativeX,
137 | relativeY,
138 | el: draggingEl!
139 | })
140 | draggingEl = null
141 | }
142 |
143 | function onPointerMove(e: PointerEvent) {
144 | if (!isPointerDown) {
145 | if (timer) {
146 | clearTimeout(timer)
147 | timer = null
148 | releaseListener?.({ el: e.target as HTMLElement })
149 | }
150 | return
151 | }
152 | if (!e.isPrimary) return
153 |
154 | const width = e.clientX - initialClientX
155 | const height = e.clientY - initialClientY
156 | const dx = (lastX = e.clientX - initialX)
157 | const dy = (lastY = e.clientY - initialY)
158 | dragListener?.({
159 | width,
160 | height,
161 | x: dx,
162 | y: dy,
163 | pointerX: e.clientX,
164 | pointerY: e.clientY,
165 | relativeX,
166 | relativeY,
167 | el: draggingEl!
168 | })
169 | }
170 |
171 | function onTouchMove(e: TouchEvent) {
172 | if (!isPointerDown) return true
173 | e.preventDefault()
174 | }
175 |
176 | function onDrag(listener: OnDragListener) {
177 | dragListener = listener
178 | }
179 |
180 | function onDrop(listener: OnDropListener) {
181 | dropListener = listener
182 | }
183 |
184 | function onHold(listener: OnHoldListener) {
185 | holdListener = listener
186 | }
187 |
188 | function onRelease(listener: OnReleaseListener) {
189 | releaseListener = listener
190 | }
191 |
192 | function onElementUpdate() {
193 | el.removeEventListener('pointerdown', onPointerDown)
194 | el = view.el()
195 | el.addEventListener('pointerdown', onPointerDown)
196 | }
197 |
198 | function destroy() {
199 | view.el().removeEventListener('pointerdown', onPointerDown)
200 | document.body.removeEventListener('pointerup', onPointerUp)
201 | document.body.removeEventListener('pointermove', onPointerMove)
202 | document.body.removeEventListener('touchmove', onTouchMove)
203 | dragListener = null
204 | dropListener = null
205 | holdListener = null
206 | releaseListener = null
207 | }
208 |
209 | return {
210 | onDrag,
211 | onDrop,
212 | onHold,
213 | onRelease,
214 | onElementUpdate,
215 | destroy,
216 | readjust
217 | }
218 | }
219 |
--------------------------------------------------------------------------------
/src/easings.ts:
--------------------------------------------------------------------------------
1 | export function easeOutBack(x: number): number {
2 | const c1 = 1.70158
3 | const c3 = c1 + 1
4 |
5 | return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2)
6 | }
7 |
8 | export function easeOutCubic(x: number): number {
9 | return 1 - Math.pow(1 - x, 3)
10 | }
11 |
--------------------------------------------------------------------------------
/src/flip.ts:
--------------------------------------------------------------------------------
1 | import {
2 | BorderRadius,
3 | borderRadiusToString,
4 | calculateBorderRadiusInverse,
5 | parseBorderRadius
6 | } from './borderRadius'
7 | import {
8 | createRectFromBoundingRect,
9 | getCorrectedBoundingRect,
10 | getLayoutRect,
11 | getScrollOffset,
12 | Rect
13 | } from './rect'
14 | import { vec2, Vec2 } from './vector'
15 | import { Transform, View } from './view'
16 |
17 | type TransitionValue = {
18 | width: number
19 | height: number
20 | translate: Vec2
21 | scale: Vec2
22 | borderRadius: BorderRadius
23 | }
24 |
25 | type FlipTransitionValues = { from: TransitionValue; to: TransitionValue }
26 |
27 | type FlipChildTransitionData = {
28 | el: HTMLElement
29 | fromTranslate: Vec2
30 | fromScale: Vec2
31 | fromBorderRadius: BorderRadius
32 | toBorderRadius: BorderRadius
33 | parentScale: Vec2
34 | }
35 |
36 | type ElementFlipRect = { el: HTMLElement; initialRect: Rect; finalRect?: Rect }
37 |
38 | type ParentChildrenTreeData = Array<{
39 | parent: ElementFlipRect
40 | children: Array
41 | }>
42 |
43 | type ChildElement = HTMLElement & { originalBorderRadius: string }
44 |
45 | export interface Flip {
46 | readInitial(): void
47 | readFinalAndReverse(): void
48 | transitionValues(): FlipTransitionValues
49 | childrenTransitionData(): Array
50 | }
51 |
52 | export function flipView(view: View): Flip {
53 | let state: 'unread' | 'readInitial' | 'readFinal' = 'unread'
54 |
55 | let current: Transform
56 | let parentInitialRect: Rect
57 | let scrollOffset: Vec2
58 |
59 | let parentDx: number
60 | let parentDy: number
61 | let parentDw: number
62 | let parentDh: number
63 | let parentInverseBorderRadius: BorderRadius
64 | let parentFinalRect: Rect
65 |
66 | let childrenData: Array
67 |
68 | let parentChildrenTreeData: ParentChildrenTreeData
69 |
70 | function readInitial() {
71 | current = view.currentTransform()
72 | parentInitialRect = getCorrectedBoundingRect(view.el())
73 | scrollOffset = getScrollOffset(view.el())
74 |
75 | const tree = getParentChildTree(view.el())
76 | parentChildrenTreeData = tree.map(({ parent, children }) => ({
77 | parent: {
78 | el: parent,
79 | initialRect: createRectFromBoundingRect(parent.getBoundingClientRect())
80 | },
81 | children: children
82 | .filter((child) => child instanceof HTMLElement)
83 | .map((child) => {
84 | const childEl = child as ChildElement
85 | if (!childEl.originalBorderRadius) {
86 | childEl.originalBorderRadius = getComputedStyle(child).borderRadius
87 | }
88 | return {
89 | el: child,
90 | borderRadius: parseBorderRadius(childEl.originalBorderRadius),
91 | initialRect: createRectFromBoundingRect(
92 | child.getBoundingClientRect()
93 | )
94 | }
95 | })
96 | }))
97 |
98 | state = 'readInitial'
99 | }
100 |
101 | function readFinalAndReverse() {
102 | if (state !== 'readInitial') {
103 | throw new Error(
104 | 'FlipView: Cannot read final values before reading initial values'
105 | )
106 | }
107 | parentFinalRect = view.layoutRect()
108 | parentDw = parentInitialRect.width / parentFinalRect.width
109 | parentDh = parentInitialRect.height / parentFinalRect.height
110 | parentDx =
111 | parentInitialRect.x - parentFinalRect.x - current.dragX + scrollOffset.x
112 | parentDy =
113 | parentInitialRect.y - parentFinalRect.y - current.dragY + scrollOffset.y
114 |
115 | parentInverseBorderRadius = calculateBorderRadiusInverse(
116 | view.borderRadius(),
117 | parentDw,
118 | parentDh
119 | )
120 |
121 | const tree = getParentChildTree(view.el())
122 |
123 | parentChildrenTreeData = parentChildrenTreeData.map(
124 | ({ parent, children }, i) => {
125 | const parentEl = tree[i].parent
126 | return {
127 | parent: {
128 | ...parent,
129 | el: parentEl,
130 | finalRect: getLayoutRect(parentEl)
131 | },
132 | children: children.map((child, j) => {
133 | const childEl = tree[i].children[j]
134 | let finalRect = getLayoutRect(childEl)
135 | if (childEl.hasAttribute('data-swapy-text')) {
136 | finalRect = {
137 | ...finalRect,
138 | width: child.initialRect.width,
139 | height: child.initialRect.height
140 | }
141 | }
142 | return {
143 | ...child,
144 | el: childEl,
145 | finalRect
146 | }
147 | })
148 | }
149 | }
150 | )
151 |
152 | const targetTransform: Omit = {
153 | translateX: parentDx,
154 | translateY: parentDy,
155 | scaleX: parentDw,
156 | scaleY: parentDh
157 | }
158 |
159 | view.el().style.transformOrigin = '0 0'
160 | view.el().style.borderRadius = borderRadiusToString(
161 | parentInverseBorderRadius
162 | )
163 | view.setTransform(targetTransform)
164 |
165 | childrenData = []
166 | parentChildrenTreeData.forEach(({ parent, children }) => {
167 | const childData = children.map(
168 | ({ el, initialRect, finalRect, borderRadius }) =>
169 | calculateChildData(
170 | el,
171 | initialRect,
172 | finalRect!,
173 | borderRadius,
174 | parent.initialRect,
175 | parent.finalRect!
176 | )
177 | )
178 | childrenData.push(...childData)
179 | })
180 |
181 | state = 'readFinal'
182 | }
183 |
184 | function transitionValues(): FlipTransitionValues {
185 | if (state !== 'readFinal') {
186 | throw new Error('FlipView: Cannot get transition values before reading')
187 | }
188 | return {
189 | from: {
190 | width: parentInitialRect.width,
191 | height: parentInitialRect.height,
192 | translate: vec2(parentDx, parentDy),
193 | scale: vec2(parentDw, parentDh),
194 | borderRadius: parentInverseBorderRadius
195 | },
196 | to: {
197 | width: parentFinalRect.width,
198 | height: parentFinalRect.height,
199 | translate: vec2(0, 0),
200 | scale: vec2(1, 1),
201 | borderRadius: view.borderRadius()
202 | }
203 | }
204 | }
205 |
206 | function childrenTransitionData(): Array {
207 | if (state !== 'readFinal') {
208 | throw new Error(
209 | 'FlipView: Cannot get children transition values before reading'
210 | )
211 | }
212 | return childrenData
213 | }
214 |
215 | return {
216 | readInitial,
217 | readFinalAndReverse,
218 | transitionValues,
219 | childrenTransitionData
220 | }
221 | }
222 |
223 | function calculateChildData(
224 | childEl: HTMLElement,
225 | childInitialRect: Rect,
226 | childFinalRect: Rect,
227 | childBorderRadius: BorderRadius,
228 | parentInitialRect: Rect,
229 | parentFinalRect: Rect
230 | ): FlipChildTransitionData {
231 | childEl.style.transformOrigin = '0 0'
232 | const parentDw = parentInitialRect.width / parentFinalRect.width
233 | const parentDh = parentInitialRect.height / parentFinalRect.height
234 | const dw = childInitialRect.width / childFinalRect.width
235 | const dh = childInitialRect.height / childFinalRect.height
236 | const fromBorderRadius = calculateBorderRadiusInverse(
237 | childBorderRadius,
238 | dw,
239 | dh
240 | )
241 | const initialX = childInitialRect.x - parentInitialRect.x
242 | const finalX = childFinalRect.x - parentFinalRect.x
243 | const initialY = childInitialRect.y - parentInitialRect.y
244 | const finalY = childFinalRect.y - parentFinalRect.y
245 | const fromTranslateX = (initialX - finalX * parentDw) / parentDw
246 | const fromTranslateY = (initialY - finalY * parentDh) / parentDh
247 | childEl.style.transform = `translate(${fromTranslateX}px, ${fromTranslateY}px) scale(${
248 | dw / parentDw
249 | }, ${dh / parentDh})`
250 | childEl.style.borderRadius = borderRadiusToString(fromBorderRadius)
251 |
252 | return {
253 | el: childEl,
254 | fromTranslate: vec2(fromTranslateX, fromTranslateY),
255 | fromScale: vec2(dw, dh),
256 | fromBorderRadius,
257 | toBorderRadius: childBorderRadius,
258 | parentScale: { x: parentDw, y: parentDh }
259 | }
260 | }
261 |
262 | function getParentChildTree(
263 | element: HTMLElement
264 | ): { parent: HTMLElement; children: HTMLElement[] }[] {
265 | const result: { parent: HTMLElement; children: HTMLElement[] }[] = []
266 |
267 | function traverse(parent: HTMLElement) {
268 | const children = Array.from(parent.children).filter(
269 | (el) => el instanceof HTMLElement
270 | ) as HTMLElement[]
271 | if (children.length > 0) {
272 | result.push({
273 | parent: parent,
274 | children: children
275 | })
276 | children.forEach((child) => traverse(child))
277 | }
278 | }
279 |
280 | traverse(element)
281 | return result
282 | }
283 |
--------------------------------------------------------------------------------
/src/math.ts:
--------------------------------------------------------------------------------
1 | import { BorderRadius } from './borderRadius'
2 | import { Vec2, vec2Add, vec2Scale, vec2Sub } from './vector'
3 |
4 | export function lerp(a: number, b: number, t: number): number {
5 | return a + (b - a) * t
6 | }
7 |
8 | export function lerpVectors(v1: Vec2, v2: Vec2, t: number): Vec2 {
9 | return vec2Add(v1, vec2Scale(vec2Sub(v2, v1), t))
10 | }
11 |
12 | export function lerpBorderRadius(
13 | b1: BorderRadius,
14 | b2: BorderRadius,
15 | t: number
16 | ): BorderRadius {
17 | return {
18 | x: {
19 | topLeft: lerp(b1.x.topLeft, b2.x.topLeft, t),
20 | topRight: lerp(b1.x.topRight, b2.x.topRight, t),
21 | bottomRight: lerp(b1.x.bottomRight, b2.x.bottomRight, t),
22 | bottomLeft: lerp(b1.x.bottomLeft, b2.x.bottomLeft, t)
23 | },
24 | y: {
25 | topLeft: lerp(b1.y.topLeft, b2.y.topLeft, t),
26 | topRight: lerp(b1.y.topRight, b2.y.topRight, t),
27 | bottomRight: lerp(b1.y.bottomRight, b2.y.bottomRight, t),
28 | bottomLeft: lerp(b1.y.bottomLeft, b2.y.bottomLeft, t)
29 | },
30 | unit: b1.unit
31 | }
32 | }
33 |
34 | export function inverseLerp(min: number, max: number, value: number) {
35 | return clamp((value - min) / (max - min), 0, 1)
36 | }
37 |
38 | export function remap(
39 | a: number,
40 | b: number,
41 | c: number,
42 | d: number,
43 | value: number
44 | ) {
45 | return lerp(c, d, inverseLerp(a, b, value))
46 | }
47 |
48 | export function clamp(value: number, min: number, max: number) {
49 | return Math.min(Math.max(value, min), max)
50 | }
51 |
--------------------------------------------------------------------------------
/src/rect.ts:
--------------------------------------------------------------------------------
1 | import { Vec2 } from './vector'
2 |
3 | export type Position = { x: number; y: number }
4 | export type Size = { width: number; height: number }
5 |
6 | export type Rect = Position & Size
7 |
8 | export function createRectFromBoundingRect(rect: DOMRect): Rect {
9 | return {
10 | x: rect.x,
11 | y: rect.y,
12 | width: rect.width,
13 | height: rect.height
14 | }
15 | }
16 |
17 | /**
18 | * For returning the boundingRect without any
19 | * transform translates set on any of its parents.
20 | * For example, if any of its parents has translate(0, 100px),
21 | * the return y value will be y - 100.
22 | */
23 | export function getCorrectedBoundingRect(element: HTMLElement): Rect {
24 | const boundingRect = element.getBoundingClientRect()
25 |
26 | let offsetX = 0
27 | let offsetY = 0
28 |
29 | let currentElement: HTMLElement | null = element.parentElement
30 |
31 | while (currentElement) {
32 | const style = getComputedStyle(currentElement)
33 | const transform = style.transform
34 |
35 | if (transform && transform !== 'none') {
36 | const matrixMatch = transform.match(/matrix.*\((.+)\)/)
37 | if (matrixMatch) {
38 | const values = matrixMatch[1].split(', ').map(Number)
39 | offsetX += values[4] || 0
40 | offsetY += values[5] || 0
41 | }
42 | }
43 |
44 | currentElement = currentElement.parentElement
45 | }
46 |
47 | return {
48 | y: boundingRect.top - offsetY,
49 | x: boundingRect.left - offsetX,
50 | width: boundingRect.width,
51 | height: boundingRect.height
52 | }
53 | }
54 |
55 | export function getLayoutRect(el: HTMLElement): Rect {
56 | let current = el
57 | let top = 0
58 | let left = 0
59 |
60 | while (current) {
61 | top += current.offsetTop
62 | left += current.offsetLeft
63 | current = current.offsetParent as HTMLElement
64 | }
65 |
66 | return {
67 | x: left,
68 | y: top,
69 | width: el.offsetWidth,
70 | height: el.offsetHeight
71 | }
72 | }
73 |
74 | export function pointIntersectsWithRect(point: Position, rect: Rect) {
75 | return (
76 | point.x >= rect.x &&
77 | point.x <= rect.x + rect.width &&
78 | point.y >= rect.y &&
79 | point.y <= rect.y + rect.height
80 | )
81 | }
82 |
83 | export function getScrollOffset(el: HTMLElement): Vec2 {
84 | let current: HTMLElement | null = el
85 | let y = 0
86 | let x = 0
87 |
88 | while (current) {
89 | // Check if the current element is scrollable
90 | const isScrollable = (node: HTMLElement) => {
91 | const style = getComputedStyle(node)
92 | return /(auto|scroll)/.test(
93 | style.overflow + style.overflowY + style.overflowX
94 | )
95 | }
96 |
97 | // If scrollable, add its scroll offsets
98 | if (current === document.body) {
99 | // Use window scroll for the element
100 | x += window.scrollX
101 | y += window.scrollY
102 | break
103 | }
104 |
105 | if (isScrollable(current)) {
106 | x += current.scrollLeft
107 | y += current.scrollTop
108 | }
109 |
110 | current = current.parentElement
111 | }
112 |
113 | return { x, y }
114 | }
115 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { SlotItemMapArray, Swapy } from '.'
2 |
3 | export type SlottedItems- = Array<{
4 | slotId: string
5 | itemId: string
6 | item: Item | null
7 | }>
8 |
9 | export function toSlottedItems
- (
10 | items: Array
- ,
11 | idField: keyof Item,
12 | slotItemMap: SlotItemMapArray
13 | ): SlottedItems
- {
14 | return slotItemMap.map((slotItem) => ({
15 | slotId: slotItem.slot,
16 | itemId: slotItem.item,
17 | item:
18 | slotItem.item === ''
19 | ? null
20 | : items.find((item) => slotItem.item === item[idField])!
21 | }))
22 | }
23 |
24 | export function initSlotItemMap
- (
25 | items: Array
- ,
26 | idField: keyof Item
27 | ): SlotItemMapArray {
28 | return items.map((item) => ({
29 | item: item[idField] as string,
30 | slot: item[idField] as string
31 | }))
32 | }
33 |
34 | export function dynamicSwapy
- (
35 | swapy: Swapy | null,
36 | items: Array
- ,
37 | idField: keyof Item,
38 | slotItemMap: SlotItemMapArray,
39 | setSlotItemMap: (slotItemMap: SlotItemMapArray) => void,
40 | removeItemOnly = false
41 | ) {
42 | // Get the newly added items and convert them to slotItem objects
43 | const newItems: SlotItemMapArray = items
44 | .filter(
45 | (item) => !slotItemMap.some((slotItem) => slotItem.item === item[idField])
46 | )
47 | .map((item) => ({
48 | slot: item[idField] as string,
49 | item: item[idField] as string
50 | }))
51 |
52 | let withoutRemovedItems: SlotItemMapArray
53 |
54 | // Remove slot and item
55 | if (!removeItemOnly) {
56 | withoutRemovedItems = slotItemMap.filter(
57 | (slotItem) =>
58 | items.some((item) => item[idField] === slotItem.item) || !slotItem.item
59 | )
60 | } else {
61 | withoutRemovedItems = slotItemMap.map((slotItem) => {
62 | if (!items.some((item) => item[idField] === slotItem.item)) {
63 | return { slot: slotItem.slot as string, item: '' }
64 | }
65 | return slotItem
66 | })
67 | }
68 |
69 | const updatedSlotItemsMap: SlotItemMapArray = [
70 | ...withoutRemovedItems,
71 | ...newItems
72 | ]
73 |
74 | setSlotItemMap(updatedSlotItemsMap)
75 |
76 | if (
77 | newItems.length > 0 ||
78 | withoutRemovedItems.length !== slotItemMap.length
79 | ) {
80 | requestAnimationFrame(() => {
81 | swapy?.update()
82 | })
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/vector.ts:
--------------------------------------------------------------------------------
1 | export type Vec2 = { x: number; y: number }
2 |
3 | export function isVec2(v: any): v is Vec2 {
4 | return typeof v === 'object' && 'x' in v && 'y' in v
5 | }
6 |
7 | export function vec2(x: number, y: number): Vec2 {
8 | return { x, y }
9 | }
10 |
11 | export function vec2Add(v1: Vec2, v2: Vec2): Vec2 {
12 | return vec2(v1.x + v2.x, v1.y + v2.y)
13 | }
14 |
15 | export function vec2Sub(v1: Vec2, v2: Vec2): Vec2 {
16 | return vec2(v1.x - v2.x, v1.y - v2.y)
17 | }
18 |
19 | export function vec2Scale(v: Vec2, a: number): Vec2 {
20 | return vec2(v.x * a, v.y * a)
21 | }
22 |
--------------------------------------------------------------------------------
/src/view.ts:
--------------------------------------------------------------------------------
1 | import { BorderRadius, parseBorderRadius } from './borderRadius'
2 | import { createRectFromBoundingRect, getLayoutRect, Rect } from './rect'
3 |
4 | export interface View {
5 | el(): HTMLElement
6 | setTransform(transform: Partial): void
7 | clearTransform(): void
8 | currentTransform: () => Transform
9 | borderRadius: () => BorderRadius
10 | layoutRect(): Rect
11 | boundingRect(): Rect
12 | usePlugin
(
13 | pluginFactory: (v: View, config: C) => P,
14 | config: C
15 | ): P
16 | updateElement(el: HTMLElement): void
17 | destroy(): void
18 | }
19 |
20 | export interface ViewPlugin {
21 | onElementUpdate(): void
22 | destroy(): void
23 | }
24 |
25 | export type Transform = {
26 | dragX: number
27 | dragY: number
28 | translateX: number
29 | translateY: number
30 | scaleX: number
31 | scaleY: number
32 | }
33 |
34 | export function createView(el: HTMLElement): View {
35 | const plugins: Array = []
36 | let element = el
37 | let currentTransform: Transform = {
38 | dragX: 0,
39 | dragY: 0,
40 | translateX: 0,
41 | translateY: 0,
42 | scaleX: 1,
43 | scaleY: 1
44 | }
45 | const borderRadius = parseBorderRadius(
46 | window.getComputedStyle(element).borderRadius
47 | )
48 | const thisView = {
49 | el: () => element,
50 | setTransform,
51 | clearTransform,
52 | currentTransform: () => currentTransform,
53 | borderRadius: () => borderRadius,
54 | layoutRect: () => getLayoutRect(element),
55 | boundingRect: () =>
56 | createRectFromBoundingRect(element.getBoundingClientRect()),
57 | usePlugin,
58 | destroy,
59 | updateElement
60 | }
61 |
62 | function setTransform(newTransform: Partial) {
63 | currentTransform = { ...currentTransform, ...newTransform }
64 | renderTransform()
65 | }
66 |
67 | function clearTransform() {
68 | currentTransform = {
69 | dragX: 0,
70 | dragY: 0,
71 | translateX: 0,
72 | translateY: 0,
73 | scaleX: 1,
74 | scaleY: 1
75 | }
76 | renderTransform()
77 | }
78 |
79 | function renderTransform() {
80 | const { dragX, dragY, translateX, translateY, scaleX, scaleY } =
81 | currentTransform
82 | if (
83 | dragX === 0 &&
84 | dragY === 0 &&
85 | translateX === 0 &&
86 | translateY === 0 &&
87 | scaleX === 1 &&
88 | scaleY === 1
89 | ) {
90 | element.style.transform = ''
91 | } else {
92 | element.style.transform = `translate(${dragX + translateX}px, ${
93 | dragY + translateY
94 | }px) scale(${scaleX}, ${scaleY})`
95 | }
96 | }
97 |
98 | function usePlugin(
99 | pluginFactory: (v: View, config: C) => P,
100 | config: C
101 | ) {
102 | const plugin = pluginFactory(thisView, config)
103 | plugins.push(plugin)
104 | return plugin
105 | }
106 |
107 | function destroy() {
108 | plugins.forEach((plugin) => plugin.destroy())
109 | }
110 |
111 | function updateElement(el: HTMLElement) {
112 | if (!el) return
113 | const hasDraggingAttr = element.hasAttribute('data-swapy-dragging')
114 | const previousStyles = element.style.cssText
115 | element = el
116 | if (hasDraggingAttr) {
117 | element.setAttribute('data-swapy-dragging', '')
118 | }
119 | element.style.cssText = previousStyles
120 | plugins.forEach((plugin) => plugin.onElementUpdate())
121 | }
122 |
123 | return thisView
124 | }
125 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": ["src"]
4 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "module": "ESNext",
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "Bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true
22 | },
23 | "include": ["src", "examples/**/*.ts", "examples/**/*.tsx", "examples/**/*.svelte", "examples/**/*.vue"]
24 | }
25 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from 'path'
2 | import { defineConfig } from 'vite'
3 | import dts from 'vite-plugin-dts'
4 | import react from '@vitejs/plugin-react'
5 | import vue from '@vitejs/plugin-vue'
6 | import { svelte } from '@sveltejs/vite-plugin-svelte'
7 |
8 | export default defineConfig({
9 | build: {
10 | lib: {
11 | entry: resolve(__dirname, 'src/index.ts'),
12 | name: 'Swapy',
13 | fileName: (format) => `swapy.${format === 'es' ? 'js' : 'min.js'}`
14 | }
15 | },
16 |
17 | plugins: [dts({ rollupTypes: true }), react(), vue(), svelte()]
18 | })
19 |
--------------------------------------------------------------------------------