sveltekit-sync
Version:
Local-first sync engine for SvelteKit
108 lines (107 loc) • 3.36 kB
JavaScript
/**
* Track text selections for collaborative editing
*
* @param presence - The presence hook instance
* @param options - Configuration options
* @returns Selection tracking API
*
* @example
* ```typescript
* const presence = usePresence(channel, currentUser);
* const selectionTracking = useSelectionTracking(presence, {
* element: editorElement,
* throttle: 100
* });
*
* selectionTracking.startTracking();
*
* // Access selections map
* const selections = selectionTracking.selections;
* for (const [userId, { user, selection }] of selections) {
* highlightSelection(user, selection);
* }
* ```
*/
export function useSelectionTracking(presence, options) {
const opts = {
throttle: 100,
...options
};
const selections = $state(new Map());
let isTracking = $state(false);
let throttleTimer = null;
let unsubscribeJoin = null;
let unsubscribeUpdate = null;
let unsubscribeLeave = null;
function updateSelectionsMap() {
selections.clear();
for (const state of presence.others) {
if (state.selection) {
selections.set(state.user.id, {
user: state.user,
selection: state.selection
});
}
}
}
function handleSelectionChange() {
if (throttleTimer || !opts.element)
return;
const selection = window.getSelection();
if (selection && selection.rangeCount > 0 && selection.toString().length > 0) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const elementRect = opts.element.getBoundingClientRect();
presence.updateSelection({
start: {
x: rect.left - elementRect.left,
y: rect.top - elementRect.top
},
end: {
x: rect.right - elementRect.left,
y: rect.bottom - elementRect.top
},
text: selection.toString()
});
}
else {
presence.updateSelection(null);
}
throttleTimer = window.setTimeout(() => {
throttleTimer = null;
}, opts.throttle);
}
function startTracking() {
if (isTracking || !opts.element)
return;
isTracking = true;
document.addEventListener('selectionchange', handleSelectionChange);
// Listen for presence changes
unsubscribeJoin = presence.on('join', updateSelectionsMap);
unsubscribeUpdate = presence.on('update', updateSelectionsMap);
unsubscribeLeave = presence.on('leave', updateSelectionsMap);
// Initial update
updateSelectionsMap();
}
function stopTracking() {
if (!isTracking)
return;
isTracking = false;
document.removeEventListener('selectionchange', handleSelectionChange);
if (throttleTimer) {
clearTimeout(throttleTimer);
throttleTimer = null;
}
unsubscribeJoin?.();
unsubscribeUpdate?.();
unsubscribeLeave?.();
selections.clear();
}
return {
get selections() {
return selections;
},
startTracking,
stopTracking
};
}