notebook/app/views/javascripts/_content_linking_alpine.html.erb
2025-09-05 22:03:01 -07:00

454 lines
18 KiB
Plaintext

<%# Alpine.js-based content linking system %>
<% if @linkables_cache %>
<script>
// Global linkables data for Alpine components
window.notebookLinkables = [
<% @linkables_cache.each do |class_name, collection| %>
<% linkable_class = content_class_from_name(class_name) %>
<% collection.each do |page_name, page_id| %>
{
name: <%= page_name.to_s.strip.to_json.html_safe %>,
value: '[[<%= class_name %>-<%= page_id %>]]',
type: '<%= class_name %>',
color: '<%= linkable_class.color %>',
icon: '<%= linkable_class.icon %>',
textColor: '<%= linkable_class.text_color %>'
},
<% end %>
<% end %>
];
// Alpine.js component for content linking
document.addEventListener('alpine:init', () => {
Alpine.data('contentLinking', () => ({
showDropdown: false,
searchTerm: '',
selectedIndex: 0,
caretPosition: 0,
triggerPosition: null,
textElement: null,
filteredResults: [],
groupedResults: [],
maxResults: 50,
maxResultsPerSection: 6,
expandedSections: [],
dropdownPosition: { top: 0, left: 0 },
init() {
// Find the text element (textarea or contenteditable with the mention class)
this.textElement = this.$el.querySelector('.js-can-mention-pages, textarea, [contenteditable="true"]');
if (!this.textElement) return;
// Add event listeners with custom names to avoid conflicts
this.textElement.addEventListener('input', (e) => this.handleMentionInput(e));
this.textElement.addEventListener('keydown', (e) => this.handleMentionKeydown(e));
this.textElement.addEventListener('blur', (e) => this.handleMentionBlur(e));
this.textElement.addEventListener('click', (e) => this.handleMentionClick(e));
},
handleMentionInput(event) {
const text = this.textElement.value || this.textElement.innerText;
const cursorPos = this.getCursorPosition();
// Look for @ symbol before cursor
const beforeCursor = text.substring(0, cursorPos);
const lastAtIndex = beforeCursor.lastIndexOf('@');
if (lastAtIndex !== -1) {
const afterAt = beforeCursor.substring(lastAtIndex + 1);
// Check if we're still in a mention (no spaces, brackets, etc.)
if (!/[\s\[\]]/.test(afterAt)) {
this.searchTerm = afterAt.toLowerCase();
this.triggerPosition = lastAtIndex;
this.updateFilteredResults();
if (this.filteredResults.length > 0) {
this.calculateDropdownPosition();
this.showDropdown = true;
this.selectedIndex = 0;
this.$dispatch('dropdown-open');
} else {
this.hideDropdown();
}
} else {
this.hideDropdown();
}
} else {
this.hideDropdown();
}
},
handleMentionKeydown(event) {
if (!this.showDropdown) return;
switch(event.key) {
case 'ArrowDown':
event.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredResults.length - 1);
this.scrollToSelected();
break;
case 'ArrowUp':
event.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.scrollToSelected();
break;
case 'Enter':
event.preventDefault();
if (this.filteredResults[this.selectedIndex]) {
this.selectItem(this.filteredResults[this.selectedIndex]);
}
break;
case 'Escape':
event.preventDefault();
this.hideDropdown();
this.triggerAutosave();
break;
case 'Tab':
if (this.filteredResults[this.selectedIndex]) {
event.preventDefault();
this.selectItem(this.filteredResults[this.selectedIndex]);
}
break;
}
},
handleMentionBlur(event) {
// Delay to allow click events on dropdown items
setTimeout(() => {
// Only hide if the focus has moved completely away from our component
if (!this.$el.contains(document.activeElement) && !this.showDropdown) {
this.hideDropdown();
}
}, 150);
},
handleMentionClick(event) {
// Re-check for @ mentions at cursor position
this.handleMentionInput(event);
},
updateFilteredResults() {
// Group all linkables by content type
const grouped = {};
// Apply filtering if there's a search term
let itemsToGroup = window.notebookLinkables;
if (this.searchTerm) {
// Score-based search for better results
const scored = window.notebookLinkables.map(item => {
const nameMatch = item.name.toLowerCase();
const search = this.searchTerm.toLowerCase();
let score = 0;
if (nameMatch.startsWith(search)) {
score = 100 - (nameMatch.length - search.length);
} else if (nameMatch.includes(search)) {
score = 50 - nameMatch.indexOf(search);
}
return { ...item, score };
});
itemsToGroup = scored
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score);
}
// Group items by content type
itemsToGroup.forEach(item => {
if (!grouped[item.type]) {
grouped[item.type] = [];
}
grouped[item.type].push(item);
});
// Convert to array format for template rendering
this.groupedResults = Object.keys(grouped).map(contentType => {
const isExpanded = this.expandedSections.includes(contentType);
const itemsToShow = isExpanded ? grouped[contentType].length : this.maxResultsPerSection;
return {
type: contentType,
items: grouped[contentType].slice(0, itemsToShow),
allItems: grouped[contentType], // Keep all items for expansion
totalCount: grouped[contentType].length,
hasMore: grouped[contentType].length > this.maxResultsPerSection,
isExpanded: isExpanded
};
});
// Also maintain flat filteredResults for keyboard navigation compatibility
this.filteredResults = [];
this.groupedResults.forEach(group => {
this.filteredResults.push(...group.items);
});
},
selectItem(item) {
const text = this.textElement.value || this.textElement.innerText;
const beforeTrigger = text.substring(0, this.triggerPosition);
const afterCursor = text.substring(this.getCursorPosition());
// Replace @mention with the selected item
const newText = beforeTrigger + item.value + ' ' + afterCursor;
if (this.textElement.tagName === 'TEXTAREA') {
this.textElement.value = newText;
// Set cursor position after the inserted text
const newCursorPos = this.triggerPosition + item.value.length + 1;
// Focus the textarea first, then set selection
this.textElement.focus();
this.textElement.setSelectionRange(newCursorPos, newCursorPos);
} else {
// For contenteditable (including Medium Editor)
// Check if this is a Medium Editor instance
const isMediumEditor = this.textElement.classList.contains('medium-editor-element') ||
this.textElement.id === 'editor';
if (isMediumEditor && typeof MediumEditor !== 'undefined') {
// For Medium Editor, we need to use execCommand to maintain undo/redo history
this.textElement.focus();
// Get current selection and replace the text
const selection = window.getSelection();
const range = selection.getRangeAt(0);
// Create a text node with the content before the trigger
const beforeNode = document.createTextNode(beforeTrigger);
// Create a text node with the token and space
const tokenNode = document.createTextNode(item.value + ' ');
// Create a text node with content after cursor
const afterNode = document.createTextNode(afterCursor);
// Clear the current content and insert new nodes
range.selectNodeContents(this.textElement);
range.deleteContents();
// Build the new content
const fragment = document.createDocumentFragment();
fragment.appendChild(beforeNode);
fragment.appendChild(tokenNode);
fragment.appendChild(afterNode);
range.insertNode(fragment);
// Position cursor after the inserted token
range.setStartAfter(tokenNode);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
// Trigger Medium Editor's input event to update its internal state
if (window.editor && window.editor.trigger) {
window.editor.trigger('editableInput', {}, this.textElement);
}
} else {
// Fallback for regular contenteditable
this.textElement.innerText = newText;
this.textElement.focus();
}
}
// Trigger input event for any listeners
this.textElement.dispatchEvent(new Event('input', { bubbles: true }));
this.hideDropdown();
// Trigger autosave after inserting a link
setTimeout(() => {
this.triggerAutosave();
}, 100);
},
hideDropdown() {
this.showDropdown = false;
this.searchTerm = '';
this.selectedIndex = 0;
this.triggerPosition = null;
this.expandedSections = []; // Reset expanded sections
this.$dispatch('dropdown-close');
},
toggleSection(contentType) {
if (this.expandedSections.includes(contentType)) {
this.expandedSections = this.expandedSections.filter(t => t !== contentType);
} else {
this.expandedSections.push(contentType);
}
this.updateFilteredResults(); // Re-render with expanded items
},
getCursorPosition() {
if (this.textElement.tagName === 'TEXTAREA') {
return this.textElement.selectionStart;
} else {
// For contenteditable
const selection = window.getSelection();
if (selection.rangeCount === 0) return 0;
const range = selection.getRangeAt(0);
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(this.textElement);
preCaretRange.setEnd(range.endContainer, range.endOffset);
return preCaretRange.toString().length;
}
},
scrollToSelected() {
this.$nextTick(() => {
const dropdown = this.$el.querySelector('.linkables-dropdown');
// Find the selected item by looking for the element with bg-blue-50 class
const selected = dropdown?.querySelector('.bg-blue-50');
if (selected) {
selected.scrollIntoView({ block: 'nearest' });
}
});
},
getIconColorClass(color) {
// The color is already in Tailwind format like 'bg-red-500'
// Convert bg-* to text-* for icon colors
if (color && color.startsWith('bg-')) {
return color.replace('bg-', 'text-');
}
return color || 'text-gray-500';
},
getBackgroundColor(color) {
// Extract the color name from bg-*-500 format
if (color && color.startsWith('bg-')) {
const colorName = color.match(/bg-(\w+)-/)?.[1];
if (colorName) {
return `bg-${colorName}-50`;
}
}
return 'bg-gray-50';
},
calculateDropdownPosition() {
// Get the text before the @ symbol to calculate position
const text = this.textElement.value || this.textElement.innerText || '';
const textBeforeTrigger = text.substring(0, this.triggerPosition);
// Get computed styles
const styles = window.getComputedStyle(this.textElement);
const fontSize = parseFloat(styles.fontSize);
const lineHeight = parseFloat(styles.lineHeight) || fontSize * 1.5;
const paddingTop = parseFloat(styles.paddingTop);
const paddingLeft = parseFloat(styles.paddingLeft);
// Create a mirror element to measure text
const mirror = document.createElement('div');
mirror.style.position = 'absolute';
mirror.style.visibility = 'hidden';
mirror.style.whiteSpace = 'pre-wrap';
mirror.style.wordWrap = 'break-word';
mirror.style.fontSize = styles.fontSize;
mirror.style.fontFamily = styles.fontFamily;
mirror.style.fontWeight = styles.fontWeight;
mirror.style.lineHeight = styles.lineHeight;
mirror.style.padding = styles.padding;
mirror.style.width = (this.textElement.offsetWidth - paddingLeft * 2) + 'px';
// Add text up to the @ symbol
mirror.textContent = textBeforeTrigger + '@';
document.body.appendChild(mirror);
// Get the height of all text to find vertical position
const totalHeight = mirror.offsetHeight;
// Get the position of the @ symbol
const atSpan = document.createElement('span');
atSpan.textContent = '@';
mirror.innerHTML = '';
// Add text before @ and then the @ in a span
const beforeText = document.createTextNode(textBeforeTrigger);
mirror.appendChild(beforeText);
mirror.appendChild(atSpan);
// Get position of the @ symbol
const atRect = atSpan.getBoundingClientRect();
const mirrorRect = mirror.getBoundingClientRect();
// Calculate relative positions
const relativeTop = atRect.top - mirrorRect.top;
const relativeLeft = atRect.left - mirrorRect.left;
// Account for textarea scroll
const scrollTop = this.textElement.scrollTop;
// Calculate final position relative to textarea
this.dropdownPosition = {
top: paddingTop + relativeTop - scrollTop + lineHeight,
left: paddingLeft + relativeLeft
};
// Ensure dropdown doesn't go off-screen
const textareaRect = this.textElement.getBoundingClientRect();
const maxLeft = textareaRect.width - 400; // 400px is dropdown width
if (this.dropdownPosition.left > maxLeft) {
this.dropdownPosition.left = maxLeft;
}
// Clean up
document.body.removeChild(mirror);
},
triggerAutosave() {
// Check if this is a document editor with Medium Editor
if (this.textElement.id === 'editor' && typeof queueAutosave === 'function') {
// For documents with Medium Editor, use the document's autosave system
queueAutosave();
// Also trigger Medium Editor's input event
if (window.editor && window.editor.trigger) {
window.editor.trigger('editableInput', {}, this.textElement);
}
}
// The existing autosave system listens for 'change' events on elements with 'autosave-closest-form-on-change' class
else if (this.textElement.classList.contains('autosave-closest-form-on-change')) {
// Dispatch a change event to trigger the existing autosave system
const changeEvent = new Event('change', {
bubbles: true,
cancelable: true
});
this.textElement.dispatchEvent(changeEvent);
// Also try jQuery trigger as a fallback
if (window.$ && window.$(this.textElement).length) {
window.$(this.textElement).trigger('change');
}
}
// Also check for enhanced autosave system
else if (this.textElement.classList.contains('js-enhanced-autosave')) {
// For enhanced autosave, trigger a blur event which immediately saves
const blurEvent = new Event('blur', {
bubbles: true,
cancelable: true
});
this.textElement.dispatchEvent(blurEvent);
}
// Fallback: try to find and trigger jQuery change event
else {
// Try jQuery if available (for backward compatibility)
if (window.$ && window.$(this.textElement).length) {
window.$(this.textElement).trigger('change');
}
}
}
}));
});
</script>
<style>
/* Ensure dropdown appears above other elements and isn't clipped */
.linkables-dropdown-container {
z-index: 9999;
}
/* Ensure parent containers don't clip the dropdown */
[x-data*="contentLinking"] {
overflow: visible !important;
}
</style>
<% end %>