Last active
March 20, 2022 14:12
-
-
Save Scribblerockerz/87e92e2c01a0510f48402771cc263367 to your computer and use it in GitHub Desktop.
Scroll Sync Directive for vue3
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
<template> | |
<!-- Sync only vertically --> | |
<div class="scrollable-area" v-scroll-sync:timeline.y /> | |
<!-- Sync only horizontally --> | |
<div class="scrollable-area" v-scroll-sync:timeline.x /> | |
<!-- Sync in all directions --> | |
<div class="scrollable-area" v-scroll-sync:timeline /> | |
<!-- Declare 3 different channels to sync to --> | |
<div class="scrollable-area" v-scroll-sync:sidebar /> | |
<div class="scrollable-area" v-scroll-sync:paneA /> | |
<div class="scrollable-area" v-scroll-sync:paneB /> | |
<!-- Default channel = 'default' to sync to --> | |
<div class="scrollable-area" v-scroll-sync /> | |
<div class="scrollable-area" v-scroll-sync:default /> | |
</template> |
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
import { ObjectDirective, VNode } from 'vue'; | |
export interface ScrollSyncEvent extends Event { | |
detail: { | |
source: number; | |
channel: string; | |
original: Event; | |
}; | |
} | |
declare global { | |
interface WindowEventMap { | |
'scroll-sync': ScrollSyncEvent; | |
} | |
} | |
type ScrollUpdate = { | |
top: number | null; | |
left: number | null; | |
}; | |
function calculatePositions(el: HTMLElement) { | |
const scrollDistanceTop = el.scrollHeight - el.clientHeight; | |
const percentTop = scrollDistanceTop === 0 ? 0 : (el.scrollTop * 100) / scrollDistanceTop; | |
const scrollDistanceLeft = el.scrollWidth - el.clientWidth; | |
const percentLeft = scrollDistanceLeft === 0 ? 0 : (el.scrollLeft * 100) / scrollDistanceLeft; | |
return { | |
left: percentLeft, | |
top: percentTop, | |
distanceTop: scrollDistanceTop, | |
distanceLeft: scrollDistanceLeft, | |
}; | |
} | |
function applyScrollUpdate(el: HTMLElement, update: ScrollUpdate) { | |
const { distanceTop, distanceLeft } = calculatePositions(el); | |
if (update.top !== null) { | |
el.scrollTop = distanceTop * (update.top / 100); | |
} | |
if (update.left !== null) { | |
el.scrollLeft = distanceLeft * (update.left / 100); | |
} | |
} | |
// Instance trackers | |
let ssid = 0; | |
const channelUpdates: { | |
[key: string]: number; | |
} = {}; | |
const latestChannelUpdates: { | |
[key: string]: { | |
top: number; | |
left: number; | |
}; | |
} = {}; | |
interface ScrollSyncVNode extends VNode { | |
__channelUpdates: number; | |
} | |
/** | |
* Scroll Sync Directive | |
*/ | |
export const scrollSync: ObjectDirective<any, boolean> = { | |
mounted(el: HTMLElement, binding, vnode) { | |
const channel = binding.arg || 'default'; | |
const id = ssid++; | |
const syncY = binding.modifiers.y || !binding.modifiers.x; | |
const syncX = binding.modifiers.x || !binding.modifiers.y; | |
if (!channelUpdates[channel]) { | |
channelUpdates[channel] = 0; | |
} | |
// Keep track of the last applied update counter | |
(vnode as ScrollSyncVNode).__channelUpdates = channelUpdates[channel]; | |
// Restore position for channels with events | |
const lastUpdate = latestChannelUpdates[channel]; | |
if (lastUpdate) { | |
applyScrollUpdate(el, { | |
top: syncY ? lastUpdate.top : null, | |
left: syncX ? lastUpdate.left : null, | |
}); | |
} | |
el.addEventListener('scroll', (e: Event) => { | |
const isBehindUpdates = channelUpdates[channel] !== (vnode as ScrollSyncVNode).__channelUpdates; | |
// Scroll event was initialized by current instance | |
if (!isBehindUpdates) { | |
// increase update count | |
channelUpdates[channel]++; | |
} | |
// Reset channel counter, to prevent overflow (╯°□°)╯︵ ┻━┻ | |
if (channelUpdates[channel] > 100) { | |
channelUpdates[channel] = 0; | |
} | |
(vnode as ScrollSyncVNode).__channelUpdates = channelUpdates[channel]; | |
// This scroll event is a sideffect of our change, exit | |
if (isBehindUpdates) { | |
return; | |
} | |
// Update the latest channel update, for future instances | |
const { top, left } = calculatePositions(el); | |
latestChannelUpdates[channel] = { | |
top, | |
left, | |
}; | |
window.dispatchEvent( | |
new CustomEvent('scroll-sync', { | |
detail: { | |
source: id, | |
channel: channel, | |
original: e, | |
}, | |
}) | |
); | |
}); | |
window.addEventListener('scroll-sync', (e: ScrollSyncEvent) => { | |
// we don't care about our own events, or from other channels | |
if (channel !== e.detail.channel || e.detail.source === id) { | |
return; | |
} | |
const { left, top } = calculatePositions(e.detail.original.target as HTMLElement); | |
const scrollUpdate: ScrollUpdate = { | |
top: syncY ? top : null, | |
left: syncX ? left : null, | |
}; | |
window.requestAnimationFrame(() => { | |
applyScrollUpdate(el, scrollUpdate); | |
}); | |
}); | |
}, | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment