Last active
April 13, 2023 06:52
-
-
Save dragon-fish/916c5a0f5988dfbb185a3c5a40719fe6 to your computer and use it in GitHub Desktop.
An iPadOS cursor simulation for the browser
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @author dragon-fish <dragon-fish@qq.com> | |
* @lisence MIT | |
* @refered https://github.com/hongkiulam/ipad-cursor-js by @hongkiulam | |
*/ | |
const TEXT_ELEMENT_TAGS = ['P', 'SPAN', 'H1', 'H2', 'H3', 'H4', 'TEXTAREA'] | |
export class IpadCursor { | |
public cursor = document.createElement('div') | |
private nativeCursorStyles = document.createElement('style') | |
private states = { | |
// width and height for general cursor | |
baseCursorWidth: '1rem', | |
baseCursorHeight: '1rem', | |
// current width and height and borderRadius, see `onCursorMove` | |
cursorWidth: '1rem', | |
cursorHeight: '1rem', | |
borderRadius: '0px', | |
// current mouse position on page | |
mouseX: 0, | |
mouseY: 0, | |
hoveredElement: null as HTMLElement | null, | |
// when true, cursor will stop following mouse position | |
isCursorLocked: false, | |
} | |
constructor(public options?: any) {} | |
load() { | |
// start animation | |
requestAnimationFrame(this.onCursorMove.bind(this)) | |
// dont want on mobile | |
if ('ontouchstart' in window) { | |
return | |
} | |
// create cursor | |
document.body.appendChild(this.cursor) | |
this.cursor.id = 'cursor' | |
this.states.cursorWidth = this.states.baseCursorWidth | |
this.states.cursorHeight = this.states.baseCursorHeight | |
this.states.borderRadius = `calc(${this.states.baseCursorWidth} / 2)` | |
// disable default cursor, add base cursor styles | |
this.nativeCursorStyles.id = 'native-cursor-styles' | |
document.head.appendChild(this.nativeCursorStyles) | |
this.nativeCursorStyles.innerText = '*{cursor:none}' | |
// apply styles to cursor | |
const customCursorStyles = document.createElement('style') | |
document.head.appendChild(customCursorStyles) | |
customCursorStyles.innerText = ` | |
#cursor { | |
position:fixed; | |
top:0; | |
left:0; | |
width: ${this.states.cursorWidth}; | |
height: ${this.states.cursorHeight}; | |
background:${this.cursor.dataset.bg || 'gray'}; | |
opacity: 0.5; | |
border-radius: 50%; | |
display: none; | |
pointer-events:none; | |
transition-timing-function: ease; | |
transition: ${this.transition({ | |
width: 0.15, | |
height: 0.15, | |
opacity: 0.15, | |
transform: 0.07, | |
})}; | |
margin: 0px !important; | |
padding: 0px !important; | |
z-index: 99999999; | |
}` | |
// keep track of current mouse position on page | |
document.addEventListener('mousemove', (e) => { | |
this.states.mouseX = e.clientX | |
this.states.mouseY = e.clientY | |
}) | |
} | |
// main cursor logic here | |
onCursorMove() { | |
const nextFrame = () => | |
requestAnimationFrame(() => { | |
this.onCursorMove() | |
}) | |
if (!this.cursor) return nextFrame() | |
// mouse hasn't moved yet | |
if (this.states.mouseX === undefined || this.states.mouseY === undefined) return nextFrame() | |
// on every frame, we update the cursor based on our app state | |
this.cursor.style.width = this.states.cursorWidth | |
this.cursor.style.height = this.states.cursorHeight | |
this.cursor.style.borderRadius = this.states.borderRadius | |
this.cursor.style.display = 'block' | |
if (!this.states.isCursorLocked) { | |
this.cursor.style.transform = `translate(calc(${this.states.mouseX}px - (${this.states.cursorWidth} / 2)), calc(${this.states.mouseY}px - (${this.states.cursorHeight} / 2))) ` | |
} | |
// (because of drag cursor) remove lingering transform in case the element is no longer hovered | |
if (this.states.hoveredElement) { | |
this.states.hoveredElement.style.removeProperty('transform') | |
} | |
// grab the element under cursor, usually the most nested element | |
this.states.hoveredElement = document.elementFromPoint(this.states.mouseX, this.states.mouseY) as HTMLElement | |
// cursor probably left browser and we received negative coords | |
if (!this.states.hoveredElement) return nextFrame() | |
// check if any parent element has the data-cursor attribute as that will take priority | |
// added this to prevent text based elements taking priority | |
/** | |
* e.g. div[data-cursor='fill'] > h1 > span | |
* It's likely the effect wants to be applied to the div here but the | |
* span would be picked up by elementFromPoint | |
*/ | |
const elementsFromPoint = document.elementsFromPoint(this.states.mouseX, this.states.mouseY) as HTMLElement[] | |
if (elementsFromPoint) { | |
for (const el of elementsFromPoint) { | |
// if anything we are hovering over has data-cursor then we set that as the hovered element | |
if (el.dataset['cursor']) { | |
this.states.hoveredElement = el | |
break | |
} | |
} | |
} | |
if (this.states.hoveredElement.dataset['cursor'] === 'reset') { | |
this.states.isCursorLocked = false | |
this.useNativeCursor() | |
return nextFrame() | |
} | |
// handle fill cursor | |
if (this.states.hoveredElement.dataset['cursor'] === 'fill') { | |
this.states.isCursorLocked = true | |
this.useFillCursor() | |
return nextFrame() | |
} | |
// handle drag cursor | |
if (this.states.hoveredElement.dataset['cursor'] === 'drag') { | |
this.states.isCursorLocked = true | |
this.useDragCursor() | |
return nextFrame() | |
} | |
// handle text cursor | |
if (TEXT_ELEMENT_TAGS.includes(this.states.hoveredElement.tagName)) { | |
this.states.isCursorLocked = false | |
this.useTextCursor() | |
return nextFrame() | |
} | |
// handle inputs | |
if (this.states.hoveredElement.tagName === 'INPUT') { | |
this.states.isCursorLocked = false | |
this.resetCursor() | |
this.hideNativeCursor() | |
const textInputs = ['text', 'email', 'number', 'password', 'search', 'tel', 'url', null] | |
if (textInputs.includes(this.states.hoveredElement.getAttribute('type'))) { | |
this.useTextCursor() | |
} else { | |
this.useGeneralCursor() | |
} | |
return nextFrame() | |
} | |
// use general cursor | |
this.states.isCursorLocked = false | |
this.useGeneralCursor() | |
nextFrame() | |
} | |
resetCursor() { | |
// removes all possible overrides, results in the defaults applied by css stylesheet | |
this.cursor.style.removeProperty('z-index') | |
this.cursor.style.removeProperty('opacity') | |
this.cursor.style.removeProperty('transition') | |
} | |
hideNativeCursor() { | |
if (this.nativeCursorStyles.innerText !== '*{cursor:none}') { | |
this.nativeCursorStyles.innerText = `*{cursor:none}` | |
} | |
} | |
useNativeCursor() { | |
this.cursor.style.setProperty('opacity', '0') | |
this.nativeCursorStyles.innerText = `` | |
} | |
useGeneralCursor() { | |
this.resetCursor() | |
this.hideNativeCursor() | |
this.states.cursorWidth = this.states.baseCursorWidth | |
this.states.cursorHeight = this.states.baseCursorHeight | |
this.states.borderRadius = `calc(${this.states.baseCursorWidth} / 2)` | |
} | |
useFillCursor() { | |
this.hideNativeCursor() | |
const { width: w, height: h, x, y } = this.states.hoveredElement?.getBoundingClientRect()! | |
const offsetW = w / 30 | |
const offsetH = h / 30 | |
// expand to containers size + offset | |
this.states.cursorWidth = `${w + offsetW}px` | |
this.states.cursorHeight = `${h + offsetH}px` | |
this.states.borderRadius = '3px' | |
// slow down transition | |
this.cursor.style.transition = this.transition({ | |
width: 0.2, | |
height: 0.2, | |
opacity: 0.15, | |
transform: 0.15, | |
}) | |
// make cursor go below hovered element | |
this.cursor.style.zIndex = '-1' | |
this.cursor.style.opacity = '0.3' | |
const alignCentreCoords = { | |
x: x - offsetW / 2, | |
y: y - offsetH / 2, | |
} | |
const cursorParallax = this.parallaxShiftAmount(this.states.hoveredElement!, offsetW, offsetH) | |
const hoveredElementParallax = this.parallaxShiftAmount(this.states.hoveredElement!, offsetW / 2, offsetH / 2) | |
const resultantCoords = { | |
x: alignCentreCoords.x + cursorParallax.x, | |
y: alignCentreCoords.y + cursorParallax.y, | |
} | |
this.cursor.style.transform = `translate(${resultantCoords.x}px, ${resultantCoords.y}px)` | |
this.states.hoveredElement!.style.transform = `translate(${hoveredElementParallax.x}px,${hoveredElementParallax.y}px)` | |
} | |
useDragCursor() { | |
this.hideNativeCursor() | |
// shrink cursor, then hide with opacity | |
const { width, height } = this.states.hoveredElement!.getBoundingClientRect() | |
this.states.cursorWidth = '5px' | |
this.states.cursorHeight = '5px' | |
this.cursor.style.opacity = '0' | |
this.cursor.style.transition = 'initial' | |
const { x, y } = this.parallaxShiftAmount(this.states.hoveredElement!, width / 20, height / 20) | |
this.states.hoveredElement!.style.transform = `translate(${x}px,${y}px)` | |
} | |
useTextCursor() { | |
this.resetCursor() | |
this.hideNativeCursor() | |
const fontSize = window.getComputedStyle(this.states.hoveredElement!).getPropertyValue('font-size') | |
this.states.cursorWidth = '1px' | |
this.states.cursorHeight = fontSize | |
} | |
/** | |
* @param {HTMLElement} elementToGetDimensionsFrom | |
* @param {number} offset | |
*/ | |
parallaxShiftAmount(elementToGetDimensionsFrom: HTMLElement, offsetW: number, offsetH: number) { | |
// calculates mouse distance from the centre of the element | |
// then calculates how many pixels to shift an element for every pixel moved by mouse | |
// where offset is the total distance moveable | |
const { width: w, height: h, x, y } = elementToGetDimensionsFrom.getBoundingClientRect() | |
const elementCentre = { x: x + w / 2, y: y + h / 2 } | |
const mouseDistanceFromCentre = { | |
x: this.states.mouseX - elementCentre.x, | |
y: this.states.mouseY - elementCentre.y, | |
} | |
const parallaxFactor = { | |
x: offsetW / (w / 2), | |
y: offsetH / (h / 2), | |
} | |
return { | |
x: mouseDistanceFromCentre.x * parallaxFactor.x, | |
y: mouseDistanceFromCentre.y * parallaxFactor.y, | |
} | |
} | |
transition(properties: Record<string, any>) { | |
return Object.entries(properties) | |
.map(([key, value]) => `${key} ${value}s`) | |
.join(',') | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment