mirror of
https://github.com/Django-LiveView/liveview
synced 2026-01-09 06:43:40 +01:00
- Add data-liveview-keyboard-map attribute to map keyboard shortcuts to handlers - Implement keyboard event handling in page controller - Support letter keys (a-z), numbers (0-9), and special keys - Support modifier keys: ctrl, alt, meta, shift - Support key combinations like ctrl+s, alt+f, etc. - Add automatic focus management for keyboard-enabled elements - Handle dynamically added elements via MutationObserver - Add proper cleanup of keyboard listeners on disconnect - Update rollup config to output to liveview/static directory - Compile and minify JavaScript bundles
598 lines
18 KiB
JavaScript
598 lines
18 KiB
JavaScript
import { Controller } from '@hotwired/stimulus'
|
|
import { sendData } from "../webSocketsCli.js";
|
|
import { getLang } from "../mixins/miscellaneous.js";
|
|
|
|
export default class extends Controller {
|
|
|
|
connect() {
|
|
// Initialize observers map to store different threshold observers
|
|
this.intersectionObservers = new Map();
|
|
// Initialize map to store debounce timers
|
|
this.debounceTimers = new Map();
|
|
// Initialize map to store keyboard event listeners
|
|
this.keyboardListeners = new Map();
|
|
|
|
// Find all elements with intersection attributes within the controller
|
|
this.setupIntersectionObservers();
|
|
// Setup mutation observer to detect dynamically added elements
|
|
this.setupMutationObserver();
|
|
// Setup focus for existing elements
|
|
this.setupFocusForExistingElements();
|
|
// Setup init functions for existing elements
|
|
this.setupInitForExistingElements();
|
|
// Setup keyboard maps for existing elements
|
|
this.setupKeyboardMaps();
|
|
}
|
|
|
|
setupIntersectionObservers() {
|
|
// Find elements with intersection attributes
|
|
const intersectionElements = this.element.querySelectorAll(
|
|
'[data-liveview-intersect-appear], [data-liveview-intersect-disappear]'
|
|
);
|
|
|
|
if (intersectionElements.length > 0) {
|
|
// Group elements by their threshold values
|
|
const elementsByThreshold = new Map();
|
|
|
|
intersectionElements.forEach(element => {
|
|
// Skip if already being observed
|
|
if (element.hasAttribute('data-intersection-observed')) {
|
|
return;
|
|
}
|
|
|
|
// Get threshold value (default to 0 if not specified)
|
|
const threshold = element.dataset.liveviewIntersectThreshold || '0';
|
|
|
|
if (!elementsByThreshold.has(threshold)) {
|
|
elementsByThreshold.set(threshold, []);
|
|
}
|
|
elementsByThreshold.get(threshold).push(element);
|
|
});
|
|
|
|
// Create observers for each threshold group
|
|
elementsByThreshold.forEach((elements, threshold) => {
|
|
this.createObserverForThreshold(threshold, elements);
|
|
});
|
|
}
|
|
}
|
|
|
|
createObserverForThreshold(threshold, elements) {
|
|
const thresholdValue = parseInt(threshold) || 0;
|
|
|
|
// Create rootMargin string - negative values extend the root's bounding box
|
|
// For "100px before entering viewport", we use negative margin
|
|
const rootMargin = thresholdValue > 0 ? `${thresholdValue}px` : '0px';
|
|
|
|
// Check if we already have an observer for this threshold
|
|
if (!this.intersectionObservers.has(threshold)) {
|
|
const observer = new IntersectionObserver(
|
|
(entries) => this.handleIntersections(entries),
|
|
{
|
|
rootMargin: rootMargin,
|
|
threshold: 0
|
|
}
|
|
);
|
|
this.intersectionObservers.set(threshold, observer);
|
|
}
|
|
|
|
const observer = this.intersectionObservers.get(threshold);
|
|
|
|
// Observe each element with this threshold
|
|
elements.forEach(element => {
|
|
observer.observe(element);
|
|
element.setAttribute('data-intersection-observed', 'true');
|
|
// Store the threshold value for reference
|
|
element.setAttribute('data-intersection-threshold-used', threshold);
|
|
});
|
|
}
|
|
|
|
setupFocusForExistingElements() {
|
|
// Find elements that should have focus on load
|
|
const focusElements = this.element.querySelectorAll('[data-liveview-focus="true"]');
|
|
|
|
if (focusElements.length > 0) {
|
|
// Focus the first element found (in case there are multiple)
|
|
// Use setTimeout to ensure the element is fully rendered
|
|
setTimeout(() => {
|
|
const elementToFocus = focusElements[0];
|
|
if (this.canReceiveFocus(elementToFocus)) {
|
|
elementToFocus.focus();
|
|
console.debug("Auto-focused element on load:", elementToFocus);
|
|
}
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
setupInitForExistingElements() {
|
|
// Find elements that should execute init functions on load
|
|
const initElements = this.element.querySelectorAll('[data-liveview-init]');
|
|
|
|
if (initElements.length > 0) {
|
|
// Execute init function for each element found
|
|
// Use setTimeout to ensure the element is fully rendered
|
|
setTimeout(() => {
|
|
initElements.forEach(element => {
|
|
const initFunction = element.dataset.liveviewInit;
|
|
if (initFunction) {
|
|
console.debug("Executing init function for element:", element, "Function:", initFunction);
|
|
this.executeInitFunction(element, initFunction);
|
|
}
|
|
});
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
setupKeyboardMaps() {
|
|
// Find elements with keyboard map attributes
|
|
const keyboardElements = this.element.querySelectorAll('[data-liveview-keyboard-map]');
|
|
|
|
if (keyboardElements.length > 0) {
|
|
keyboardElements.forEach(element => {
|
|
// Skip if already initialized
|
|
if (element.hasAttribute('data-keyboard-map-initialized')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const keyMap = JSON.parse(element.dataset.liveviewKeyboardMap);
|
|
this.attachKeyboardListeners(element, keyMap);
|
|
element.setAttribute('data-keyboard-map-initialized', 'true');
|
|
console.debug("Keyboard map initialized for element:", element, "Map:", keyMap);
|
|
} catch (error) {
|
|
console.error("Invalid keyboard map JSON:", element.dataset.liveviewKeyboardMap, error);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
attachKeyboardListeners(element, keyMap) {
|
|
const handler = (event) => this.handleKeyboardEvent(event, keyMap, element);
|
|
|
|
// Add keydown listener to the element
|
|
element.addEventListener('keydown', handler);
|
|
|
|
// Store reference for cleanup
|
|
if (!this.keyboardListeners.has(element)) {
|
|
this.keyboardListeners.set(element, []);
|
|
}
|
|
this.keyboardListeners.get(element).push({ event: 'keydown', handler });
|
|
|
|
// Make element focusable if it's not already
|
|
if (!element.hasAttribute('tabindex') && !this.canReceiveFocus(element)) {
|
|
element.setAttribute('tabindex', '-1');
|
|
console.debug("Added tabindex to element for keyboard navigation:", element);
|
|
}
|
|
}
|
|
|
|
handleKeyboardEvent(event, keyMap, element) {
|
|
const keyString = this.normalizeKeyEvent(event);
|
|
|
|
// Check if this key combination is mapped
|
|
if (keyMap[keyString]) {
|
|
event.preventDefault();
|
|
const functionName = keyMap[keyString];
|
|
console.debug(`Keyboard event triggered: ${keyString} -> ${functionName}`);
|
|
this.executeLiveviewFunction(element, functionName, 'keyboard');
|
|
}
|
|
}
|
|
|
|
normalizeKeyEvent(event) {
|
|
const modifiers = [];
|
|
|
|
// Collect modifiers in consistent order
|
|
if (event.ctrlKey) modifiers.push('ctrl');
|
|
if (event.altKey) modifiers.push('alt');
|
|
if (event.metaKey) modifiers.push('meta');
|
|
if (event.shiftKey && !this.isShiftableKey(event.key)) modifiers.push('shift');
|
|
|
|
const key = this.normalizeKey(event.key);
|
|
|
|
if (modifiers.length > 0) {
|
|
return modifiers.join('+') + '+' + key;
|
|
}
|
|
return key;
|
|
}
|
|
|
|
normalizeKey(key) {
|
|
// Map special keys to simplified names
|
|
const keyMap = {
|
|
'Escape': 'esc',
|
|
' ': 'space',
|
|
'ArrowUp': 'up',
|
|
'ArrowDown': 'down',
|
|
'ArrowLeft': 'left',
|
|
'ArrowRight': 'right',
|
|
'Enter': 'enter',
|
|
'Tab': 'tab',
|
|
'Home': 'home',
|
|
'End': 'end',
|
|
'PageUp': 'page_up',
|
|
'PageDown': 'page_down'
|
|
};
|
|
|
|
return keyMap[key] || key.toLowerCase();
|
|
}
|
|
|
|
isShiftableKey(key) {
|
|
// Keys that change with shift don't need shift in the modifier list
|
|
// (letters become uppercase, numbers become symbols)
|
|
return key.length === 1 && /[a-zA-Z0-9]/.test(key);
|
|
}
|
|
|
|
executeInitFunction(element, functionName) {
|
|
// Create synthetic event to reuse existing logic
|
|
const syntheticEvent = {
|
|
currentTarget: element,
|
|
preventDefault: () => {} // Mock to avoid errors
|
|
};
|
|
|
|
// Temporarily set liveviewFunction for execution
|
|
const originalFunction = element.dataset.liveviewFunction;
|
|
element.dataset.liveviewFunction = functionName;
|
|
|
|
// Add information about trigger type
|
|
const originalTriggerType = element.dataset.initTrigger;
|
|
element.dataset.initTrigger = 'init';
|
|
|
|
// Execute using existing logic with specific element
|
|
this.executeFunction(syntheticEvent, element);
|
|
|
|
// Restore original values
|
|
if (originalFunction) {
|
|
element.dataset.liveviewFunction = originalFunction;
|
|
} else {
|
|
delete element.dataset.liveviewFunction;
|
|
}
|
|
|
|
if (originalTriggerType) {
|
|
element.dataset.initTrigger = originalTriggerType;
|
|
} else {
|
|
delete element.dataset.initTrigger;
|
|
}
|
|
}
|
|
|
|
setupMutationObserver() {
|
|
// Create mutation observer to detect dynamically added elements
|
|
this.mutationObserver = new MutationObserver((mutations) => {
|
|
let hasNewIntersectionElements = false;
|
|
let hasNewKeyboardMaps = false;
|
|
let newFocusElements = [];
|
|
|
|
mutations.forEach((mutation) => {
|
|
if (mutation.type === 'childList') {
|
|
// Check added nodes for intersection attributes
|
|
mutation.addedNodes.forEach((node) => {
|
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
// Check if the node itself has intersection attributes
|
|
if (node.hasAttribute('data-liveview-intersect-appear') ||
|
|
node.hasAttribute('data-liveview-intersect-disappear')) {
|
|
hasNewIntersectionElements = true;
|
|
}
|
|
|
|
// Check if any descendants have intersection attributes
|
|
const descendants = node.querySelectorAll ?
|
|
node.querySelectorAll('[data-liveview-intersect-appear], [data-liveview-intersect-disappear]') :
|
|
[];
|
|
if (descendants.length > 0) {
|
|
hasNewIntersectionElements = true;
|
|
}
|
|
|
|
// Check if the node itself has keyboard map attribute
|
|
if (node.hasAttribute && node.hasAttribute('data-liveview-keyboard-map')) {
|
|
hasNewKeyboardMaps = true;
|
|
}
|
|
|
|
// Check if any descendants have keyboard map attributes
|
|
const keyboardDescendants = node.querySelectorAll ?
|
|
node.querySelectorAll('[data-liveview-keyboard-map]') :
|
|
[];
|
|
if (keyboardDescendants.length > 0) {
|
|
hasNewKeyboardMaps = true;
|
|
}
|
|
|
|
// Check for focus attributes on the node itself
|
|
if (node.hasAttribute && node.hasAttribute('data-liveview-focus') &&
|
|
node.dataset.liveviewFocus === "true") {
|
|
newFocusElements.push(node);
|
|
}
|
|
|
|
// Check for focus attributes in descendants
|
|
const focusDescendants = node.querySelectorAll ?
|
|
node.querySelectorAll('[data-liveview-focus="true"]') :
|
|
[];
|
|
newFocusElements.push(...focusDescendants);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// If new intersection elements were found, set up observers for them
|
|
if (hasNewIntersectionElements) {
|
|
this.setupIntersectionObservers();
|
|
}
|
|
|
|
// If new keyboard map elements were found, set up listeners for them
|
|
if (hasNewKeyboardMaps) {
|
|
this.setupKeyboardMaps();
|
|
}
|
|
|
|
// Handle focus for new elements
|
|
if (newFocusElements.length > 0) {
|
|
this.handleNewFocusElements(newFocusElements);
|
|
}
|
|
});
|
|
|
|
// Start observing mutations
|
|
this.mutationObserver.observe(this.element, {
|
|
childList: true,
|
|
subtree: true
|
|
});
|
|
}
|
|
|
|
handleNewFocusElements(elements) {
|
|
// Focus the first focusable element found
|
|
// Use setTimeout to ensure the element is fully rendered and positioned
|
|
setTimeout(() => {
|
|
for (const element of elements) {
|
|
if (this.canReceiveFocus(element)) {
|
|
element.focus();
|
|
console.debug("Auto-focused new element:", element);
|
|
break; // Only focus the first one
|
|
}
|
|
}
|
|
}, 0);
|
|
}
|
|
|
|
canReceiveFocus(element) {
|
|
// Check if element can receive focus
|
|
const focusableElements = [
|
|
'input', 'select', 'textarea', 'button', 'a'
|
|
];
|
|
|
|
const tagName = element.tagName.toLowerCase();
|
|
|
|
// Check if it's a focusable element type
|
|
if (focusableElements.includes(tagName)) {
|
|
// Check if it's not disabled and visible
|
|
return !element.disabled &&
|
|
!element.hidden &&
|
|
element.offsetWidth > 0 &&
|
|
element.offsetHeight > 0 &&
|
|
window.getComputedStyle(element).visibility !== 'hidden';
|
|
}
|
|
|
|
// Check if it has tabindex
|
|
if (element.hasAttribute('tabindex')) {
|
|
const tabindex = parseInt(element.getAttribute('tabindex'));
|
|
return tabindex >= 0 &&
|
|
!element.hidden &&
|
|
element.offsetWidth > 0 &&
|
|
element.offsetHeight > 0 &&
|
|
window.getComputedStyle(element).visibility !== 'hidden';
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
handleIntersections(entries) {
|
|
entries.forEach(entry => {
|
|
const element = entry.target;
|
|
const threshold = element.dataset.liveviewIntersectThreshold || '0';
|
|
|
|
if (entry.isIntersecting) {
|
|
// Element appeared
|
|
const appearFunction = element.dataset.liveviewIntersectAppear;
|
|
if (appearFunction) {
|
|
console.debug(`Element appeared in viewport (threshold: ${threshold}px):`, element);
|
|
this.executeLiveviewFunction(element, appearFunction, 'appear');
|
|
}
|
|
} else {
|
|
// Element disappeared
|
|
const disappearFunction = element.dataset.liveviewIntersectDisappear;
|
|
if (disappearFunction) {
|
|
console.debug(`Element disappeared from viewport (threshold: ${threshold}px):`, element);
|
|
this.executeLiveviewFunction(element, disappearFunction, 'disappear');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
disconnect() {
|
|
// Clean up all observers when controller disconnects
|
|
this.intersectionObservers.forEach(observer => {
|
|
observer.disconnect();
|
|
});
|
|
this.intersectionObservers.clear();
|
|
|
|
if (this.mutationObserver) {
|
|
this.mutationObserver.disconnect();
|
|
}
|
|
|
|
// Clean up all debounce timers
|
|
this.debounceTimers.forEach(timer => {
|
|
clearTimeout(timer);
|
|
});
|
|
this.debounceTimers.clear();
|
|
|
|
// Clean up all keyboard event listeners
|
|
this.keyboardListeners.forEach((listeners, element) => {
|
|
listeners.forEach(({ event, handler }) => {
|
|
element.removeEventListener(event, handler);
|
|
});
|
|
});
|
|
this.keyboardListeners.clear();
|
|
}
|
|
|
|
// Helper method to execute intersection functions
|
|
executeLiveviewFunction(element, functionName, triggerType) {
|
|
// Create synthetic event to reuse existing logic
|
|
const syntheticEvent = {
|
|
currentTarget: element,
|
|
preventDefault: () => {} // Mock to avoid errors
|
|
};
|
|
|
|
// Temporarily set liveviewFunction for execution
|
|
const originalFunction = element.dataset.liveviewFunction;
|
|
element.dataset.liveviewFunction = functionName;
|
|
|
|
// Add information about trigger type
|
|
const originalTriggerType = element.dataset.intersectionTrigger;
|
|
element.dataset.intersectionTrigger = triggerType;
|
|
|
|
// Execute using existing logic with specific element
|
|
this.executeFunction(syntheticEvent, element);
|
|
|
|
// Restore original values
|
|
if (originalFunction) {
|
|
element.dataset.liveviewFunction = originalFunction;
|
|
} else {
|
|
delete element.dataset.liveviewFunction;
|
|
}
|
|
|
|
if (originalTriggerType) {
|
|
element.dataset.intersectionTrigger = originalTriggerType;
|
|
} else {
|
|
delete element.dataset.intersectionTrigger;
|
|
}
|
|
}
|
|
|
|
allData(targetElement = null) {
|
|
let data = {};
|
|
const omitKeys = [
|
|
"action",
|
|
"controller",
|
|
"liveviewAction",
|
|
"liveviewFunction",
|
|
"liveviewIntersectAppear",
|
|
"liveviewIntersectDisappear",
|
|
"liveviewIntersectThreshold",
|
|
"intersectionTrigger",
|
|
"liveviewFocus",
|
|
"liveviewInit",
|
|
"initTrigger",
|
|
"liveviewDebounce",
|
|
"liveviewKeyboardMap",
|
|
"keyboardMapInitialized"
|
|
];
|
|
|
|
// Use the provided targetElement, or fallback to event.currentTarget or this.element
|
|
const target = targetElement || event?.currentTarget || this.element;
|
|
|
|
for (const [key, value] of Object.entries(target.dataset)) {
|
|
if (!omitKeys.includes(key)) {
|
|
data[key] = value;
|
|
}
|
|
}
|
|
return data;
|
|
}
|
|
|
|
allValues(targetElement = null) {
|
|
let data = {};
|
|
const inputsNames = [
|
|
"input",
|
|
"select",
|
|
"textarea"
|
|
];
|
|
|
|
// Helper function to get correct value based on input type
|
|
const getInputValue = (input) => {
|
|
if (input.type === "checkbox") {
|
|
return input.checked;
|
|
} else if (input.type === "radio") {
|
|
return input.checked ? input.value : undefined;
|
|
} else {
|
|
return input.value;
|
|
}
|
|
};
|
|
|
|
// Use the provided targetElement, or fallback to event.currentTarget or this.element
|
|
const target = targetElement || event?.currentTarget || this.element;
|
|
|
|
// Check if current element is a form
|
|
if (target.tagName.toLowerCase() === "form") {
|
|
const inputs = target.querySelectorAll(inputsNames.join(","));
|
|
inputs.forEach((input) => {
|
|
const value = getInputValue(input);
|
|
if (value !== undefined) {
|
|
data[input.name] = value;
|
|
}
|
|
});
|
|
return data;
|
|
}
|
|
|
|
// Check if current element has a parent form
|
|
const parentForm = target.closest("form");
|
|
if (parentForm) {
|
|
const inputs = parentForm.querySelectorAll(inputsNames.join(","));
|
|
inputs.forEach((input) => {
|
|
const value = getInputValue(input);
|
|
if (value !== undefined) {
|
|
data[input.name] = value;
|
|
}
|
|
});
|
|
return data;
|
|
}
|
|
|
|
// If no form, check if current element is an input
|
|
if (inputsNames.includes(target.tagName.toLowerCase())) {
|
|
const value = getInputValue(target);
|
|
if (value !== undefined) {
|
|
data[target.name] = value;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
// Refactored method to reuse execution logic
|
|
executeFunction(event, targetElement = null) {
|
|
const target = targetElement || event?.currentTarget || this.element;
|
|
const liveviewFunction = target.dataset.liveviewFunction;
|
|
|
|
// Check if data is defined
|
|
if (liveviewFunction === undefined) {
|
|
console.error("data-liveview-function is not defined");
|
|
return;
|
|
}
|
|
|
|
// Send data to server
|
|
const myData = {
|
|
function: liveviewFunction,
|
|
data: this.allData(target),
|
|
form: this.allValues(target)
|
|
};
|
|
|
|
console.debug(myData);
|
|
sendData(myData);
|
|
}
|
|
|
|
run(event) {
|
|
event.preventDefault();
|
|
|
|
const target = event.currentTarget;
|
|
const debounceTime = parseInt(target.dataset.liveviewDebounce);
|
|
|
|
// If NO debounce, execute immediately (current behavior)
|
|
if (!debounceTime || debounceTime === 0 || isNaN(debounceTime)) {
|
|
this.executeFunction(event);
|
|
return;
|
|
}
|
|
|
|
// If debounce is set, apply debounce logic
|
|
// Clear previous timer if exists
|
|
if (this.debounceTimers.has(target)) {
|
|
clearTimeout(this.debounceTimers.get(target));
|
|
}
|
|
|
|
// Create new timer
|
|
const timer = setTimeout(() => {
|
|
this.executeFunction(null, target);
|
|
this.debounceTimers.delete(target);
|
|
}, debounceTime);
|
|
|
|
// Store timer reference
|
|
this.debounceTimers.set(target, timer);
|
|
}
|
|
}
|