@ai-stack/payloadcms
Version:
<p align="center"> <img alt="Payload AI Plugin" src="assets/payload-ai-intro.gif" width="100%" /> </p>
225 lines (224 loc) • 7.51 kB
JavaScript
'use client';
import { useEffect } from 'react';
/**
* Allowed field type classes that should show the active state
*/ const ALLOWED_FIELD_TYPES = [
'upload',
'text',
'textarea',
'rich-text-lexical'
];
let currentContainer = null;
let rafId = null // Track RAF to cancel if needed
;
/**
* Safely escape CSS selector values
*/ const cssEscape = (value)=>{
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(value);
}
return value.replace(/([ #;?%&,.+*~':"!^$[\]()=>|/@])/g, '\\$1');
};
/**
* Find container from React Select dropdown elements
*/ const findContainerFromReactSelect = (target)=>{
const listbox = target.closest('[role="listbox"]');
if (!listbox?.id) {
return null;
}
const id = cssEscape(listbox.id);
const selector = `[aria-controls="${id}"], [aria-owns="${id}"]`;
const control = document.querySelector(selector);
return control?.closest('.field-type') ?? null;
};
/**
* Check if a container has one of the allowed field type classes
*/ const isAllowedFieldType = (container)=>{
return ALLOWED_FIELD_TYPES.some((type)=>container.classList.contains(type) || container.classList.contains(`field-type-${type}`));
};
/**
* Resolve the .field-type container for a given event target
* Only returns containers that match allowed field types
*/ const resolveContainerFromTarget = (target)=>{
if (!(target instanceof HTMLElement)) {
return null;
}
// Check for direct parent first
let container = target.closest('.field-type');
// If not found, fall back to React Select logic
if (!container) {
container = findContainerFromReactSelect(target);
}
// Only return if it's an allowed field type
if (container && isAllowedFieldType(container)) {
return container;
}
return null;
};
/**
* Update the active container and toggle CSS class
* - Avoids acting on disconnected nodes
* - Avoids redundant class work
*/ const setActiveContainer = (next)=>{
// Normalize both references against disconnected nodes
if (currentContainer && !currentContainer.isConnected) {
currentContainer = null;
}
if (next && !next.isConnected) {
next = null;
}
if (currentContainer === next) {
return;
}
currentContainer?.classList.remove('ai-plugin-active');
if (next) {
next.classList.add('ai-plugin-active');
}
currentContainer = next;
};
const clearActiveContainer = ()=>{
if (currentContainer) {
currentContainer.classList.remove('ai-plugin-active');
currentContainer = null;
}
// Cancel any pending RAF
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
};
const isInteractiveElement = (element)=>{
const tagName = element.tagName.toLowerCase();
const interactiveTags = [
'input',
'textarea',
'select',
'button'
];
if (interactiveTags.includes(tagName)) {
return true;
}
// Check for contenteditable
if (element.isContentEditable) {
return true;
}
// Check for elements with role="textbox" or role="combobox" (React Select)
const role = element.getAttribute('role');
if (role && [
'combobox',
'listbox',
'searchbox',
'textbox'
].includes(role)) {
return true;
}
return false;
};
/**
* Handle focus events - only activate if focus is on an interactive element within .field-type
*/ const onFocusIn = (e)=>{
const target = e.target;
if (!(target instanceof HTMLElement)) {
return;
}
// Early exit if we're already inside the current container
if (currentContainer?.isConnected && currentContainer.contains(target)) {
return;
}
// Only activate if the focused element is actually interactive
if (!isInteractiveElement(target)) {
return;
}
const container = resolveContainerFromTarget(target);
setActiveContainer(container);
};
/**
* Handle pointer/mouse events - only switch when clicking a different .field-type
*/ const onPointerDown = (e)=>{
const target = e.target;
if (!(target instanceof HTMLElement)) {
return;
}
// Early exit if clicking within current container
if (currentContainer?.isConnected && currentContainer.contains(target)) {
return;
}
const container = resolveContainerFromTarget(target);
setActiveContainer(container);
};
/**
* Handle keyboard navigation (Tab key)
*/ const onKeyDown = (e)=>{
if (e.key !== 'Tab') {
return;
}
// Cancel any pending RAF to prevent queuing
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
// Defer until after focus has shifted
rafId = requestAnimationFrame(()=>{
rafId = null;
const container = resolveContainerFromTarget(document.activeElement);
setActiveContainer(container);
});
};
/**
* Handle visibility changes to properly cleanup when page is hidden
*/ const onVisibilityChange = ()=>{
if (typeof document !== 'undefined' && document.hidden) {
// Clear active state and cancel pending operations
clearActiveContainer();
}
};
/**
* Initialize document-level listeners to track the active field container.
* When a container is active, it receives the 'ai-plugin-active' class.
*/ export const useActiveFieldTracking = ()=>{
useEffect(()=>{
if (typeof window === 'undefined') {
return;
}
const pluginWindow = window;
// Track number of mounted users of the hook
pluginWindow.__aiComposeTrackingCount = (pluginWindow.__aiComposeTrackingCount ?? 0) + 1;
// Initialize listeners only once
if (!pluginWindow.__aiComposeTracking) {
const controller = new AbortController();
pluginWindow.__aiComposeTrackingController = controller;
// Use capture for early handling
document.addEventListener('focusin', onFocusIn, {
capture: true,
signal: controller.signal
});
document.addEventListener('pointerdown', onPointerDown, {
capture: true,
passive: true,
signal: controller.signal
});
document.addEventListener('keydown', onKeyDown, {
capture: true,
signal: controller.signal
});
document.addEventListener('visibilitychange', onVisibilityChange, {
signal: controller.signal
});
pluginWindow.__aiComposeTracking = true;
}
return ()=>{
// Decrement and cleanup when the last user unmounts
pluginWindow.__aiComposeTrackingCount = (pluginWindow.__aiComposeTrackingCount ?? 1) - 1;
if ((pluginWindow.__aiComposeTrackingCount ?? 0) <= 0) {
// Atomically remove all listeners
pluginWindow.__aiComposeTrackingController?.abort();
pluginWindow.__aiComposeTrackingController = undefined;
// Clear active state and cancel pending operations
clearActiveContainer();
// Reset all state
pluginWindow.__aiComposeTracking = false;
pluginWindow.__aiComposeTrackingCount = 0;
}
};
}, []);
};
//# sourceMappingURL=useActiveFieldTracking.js.map