/**
* Overlay Editor Module
* Handles the edit selection overlay functionality for the Ace editor
*/
(function() {
'use strict';
console.log("[overlay_editor.js] Loading overlay editor module...");
// =========================================================================
// MODULE STATE
// =========================================================================
let overlayEditorInstance = null;
// Global per-file overlay state cache
window._overlayStates = window._overlayStates || {};
let selectionRange = null;
let editHistory = [];
let currentEditIndex = 0;
// =========================================================================
// DEPENDENCIES (injected from main editor)
// =========================================================================
let deps = {
getGlobalEditor: null, // Function that returns globalEditorInstance
getEl: null, // Function that returns el (DOM element)
showOverlay: null, // Function to show overlay
hideOverlay: null, // Function to hide overlay
generateDocumentIndex: null,// Function to generate document index
findMarkerEnd: null, // Function to find marker end
escapeHtml: null, // Function to escape HTML
showToast: null, // Function to show toast notifications
onHideOverlay: null // Callback when overlay is hidden
};
// =========================================================================
// INITIALIZATION
// =========================================================================
function init(dependencies) {
deps = { ...deps, ...dependencies };
console.log("[overlay_editor.js] Initialized with dependencies");
}
// =========================================================================
// MAIN OVERLAY FUNCTION
// =========================================================================
function showEditSelectionOverlay() {
const globalEditorInstance = deps.getGlobalEditor();
if (!globalEditorInstance) {
console.error("[overlay_editor.js] Global editor instance not available");
return;
}
// Get current selection or cursor position
const selected = globalEditorInstance.getSelectedText();
const hasInitialSelection = selected && selected.trim().length > 0;
let initialContent = '';
if (hasInitialSelection) {
// Store the selection range
selectionRange = globalEditorInstance.getSelectionRange();
// Check if selection overlaps with any index items and expand if needed
const expandedContent = expandSelectionToIndexItems(selectionRange);
if (expandedContent) {
initialContent = expandedContent.content;
} else {
initialContent = selected;
}
} else {
// No selection - store cursor position
const pos = globalEditorInstance.getCursorPosition();
const Range = ace.require('ace/range').Range;
selectionRange = new Range(pos.row, pos.column, pos.row, pos.column);
initialContent = '';
}
// Initialize edit history with current content
editHistory = [initialContent];
currentEditIndex = 0;
// Create editor container with section info area
const editorHtml = `
<div style="
display: flex;
flex-direction: column;
gap: 12px;
height: calc(100vh - 160px);
">
<!-- Section info + actions -->
<div id="sectionInfoDisplay" style="
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #1e293b;
border-radius: 4px;
">
<span style="font-size: 16px;">❓</span>
<span style="
flex: 1;
color: #888;
font-size: 14px;
font-family: 'Segoe UI', sans-serif;
font-weight: 500;
">Target: Not detected yet</span>
<button id="refreshSectionBtn" style="
padding: 6px 10px;
background: #3d3d3d;
border: 1px solid #555;
border-radius: 4px;
color: #e0e0e0;
cursor: pointer;
font-size: 12px;
font-family: 'Segoe UI', sans-serif;
">🔄 Detect</button>
<button id="applyBtn" style="
padding: 6px 12px;
background: #16a34a;
border: 1px solid #15803d;
border-radius: 4px;
color: #fff;
cursor: pointer;
font-size: 12px;
font-family: 'Segoe UI', sans-serif;
font-weight: 600;
">✅ Apply</button>
</div>
<!-- Edit navigation -->
<div style="
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #1e293b;
border-radius: 4px;
">
<div style="display: flex; gap: 8px; align-items: center;">
<button id="prevEditBtn" style="
padding: 6px 12px;
background: #3d3d3d;
border: 1px solid #555;
border-radius: 4px;
color: #e0e0e0;
cursor: pointer;
font-size: 16px;
display: none;
">←</button>
<span id="editIndexDisplay" style="
color: #888;
font-size: 13px;
font-family: 'Segoe UI', sans-serif;
display: none;
">1 / 1</span>
<button id="nextEditBtn" style="
padding: 6px 12px;
background: #3d3d3d;
border: 1px solid #555;
border-radius: 4px;
color: #e0e0e0;
cursor: pointer;
font-size: 16px;
display: none;
">→</button>
</div>
<button id="addEditBtn" style="
padding: 6px 12px;
background: #3b82f6;
border: 1px solid #2563eb;
border-radius: 4px;
color: #fff;
cursor: pointer;
font-size: 13px;
font-family: 'Segoe UI', sans-serif;
font-weight: 500;
">+ New</button>
</div>
<!-- Editor -->
<div id="overlayEditor" style="
flex: 1;
min-height: 55vh;
border: 1px solid #555;
border-radius: 6px;
background: #0f172a;
"></div>
</div>
`;
const footerHtml = `
<button id="applyBtn" style="
padding: 8px 16px;
background: #16a34a;
border: 1px solid #15803d;
border-radius: 4px;
color: #fff;
cursor: pointer;
font-size: 14px;
font-family: 'Segoe UI', sans-serif;
font-weight: 600;
">✅ Apply Changes</button>
<button id="cancelEditBtn" style="
padding: 8px 16px;
background: #3d3d3d;
border: 1px solid #555;
border-radius: 4px;
color: #e0e0e0;
cursor: pointer;
font-size: 14px;
font-family: 'Segoe UI', sans-serif;
">Cancel</button>
`;
deps.showOverlay('Edit Content', editorHtml, null, false);
// Initialize Ace editor in overlay
// Try restoring overlay state for the current file
const files = JSON.parse(localStorage.getItem('sftp_active_files') || '[]');
const active = files.find(f => f.active);
const fileKey = active ? active.name : 'default';
if (window._overlayStates[fileKey]) {
const state = window._overlayStates[fileKey];
editHistory = state.editHistory || [''];
currentEditIndex = state.currentEditIndex || 0;
selectionRange = state.selectionRange || selectionRange;
console.log(`[overlay_editor.js] Restored overlay state for ${fileKey}`);
}
setTimeout(() => {
initializeOverlayEditor();
setupEditNavigation();
}, 100);
}
// =========================================================================
// OVERLAY EDITOR INITIALIZATION
// =========================================================================
function initializeOverlayEditor() {
const el = deps.getEl();
const globalEditorInstance = deps.getGlobalEditor();
const overlayEditorContainer = el.querySelector('#overlayEditor');
if (!overlayEditorContainer) {
console.error("[overlay_editor.js] Could not find #overlayEditor container");
return;
}
overlayEditorInstance = ace.edit(overlayEditorContainer);
overlayEditorInstance.setTheme("ace/theme/monokai");
// Match the main editor's mode
const currentMode = globalEditorInstance.getSession().getMode().$id;
overlayEditorInstance.session.setMode(currentMode);
// Set initial content
overlayEditorInstance.setValue(editHistory[currentEditIndex], -1);
overlayEditorInstance.setOptions({
fontSize: "14px",
wrap: true,
showPrintMargin: false,
useWorker: false,
enableAutoIndent: true
});
overlayEditorInstance.focus();
}
// =========================================================================
// NAVIGATION SETUP
// =========================================================================
function setupEditNavigation() {
const el = deps.getEl();
const prevBtn = el.querySelector('#prevEditBtn');
const nextBtn = el.querySelector('#nextEditBtn');
const addBtn = el.querySelector('#addEditBtn');
const applyBtn = el.querySelector('#applyBtn');
const cancelBtn = el.querySelector('#cancelEditBtn');
const refreshBtn = el.querySelector('#refreshSectionBtn');
// Update display
updateEditNavigation();
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
detectTargetSection();
});
}
if (prevBtn) {
prevBtn.addEventListener('click', () => {
if (currentEditIndex > 0) {
saveCurrentEdit();
currentEditIndex--;
loadCurrentEdit();
updateEditNavigation();
}
});
}
if (nextBtn) {
nextBtn.addEventListener('click', () => {
if (currentEditIndex < editHistory.length - 1) {
saveCurrentEdit();
currentEditIndex++;
loadCurrentEdit();
updateEditNavigation();
}
});
}
if (addBtn) {
addBtn.addEventListener('click', () => {
saveCurrentEdit();
editHistory.push('');
currentEditIndex = editHistory.length - 1;
loadCurrentEdit();
updateEditNavigation();
});
}
if (applyBtn) {
applyBtn.addEventListener('click', handleApplyChanges);
}
// Try restoring overlay state for the current file
const files = JSON.parse(localStorage.getItem('sftp_active_files') || '[]');
const active = files.find(f => f.active);
const fileKey = active ? active.name : 'default';
if (window._overlayStates[fileKey]) {
const state = window._overlayStates[fileKey];
editHistory = state.editHistory || [''];
currentEditIndex = state.currentEditIndex || 0;
selectionRange = state.selectionRange || selectionRange;
console.log(`[overlay_editor.js] Restored overlay state for ${fileKey}`);
}
}
// =========================================================================
// EDIT MANAGEMENT
// =========================================================================
function saveCurrentEdit() {
if (overlayEditorInstance) {
editHistory[currentEditIndex] = overlayEditorInstance.getValue();
}
}
function loadCurrentEdit() {
if (overlayEditorInstance) {
overlayEditorInstance.setValue(editHistory[currentEditIndex], -1);
overlayEditorInstance.focus();
}
}
function updateEditNavigation() {
const el = deps.getEl();
const prevBtn = el.querySelector('#prevEditBtn');
const nextBtn = el.querySelector('#nextEditBtn');
const indexDisplay = el.querySelector('#editIndexDisplay');
const hasMultiple = editHistory.length > 1;
if (prevBtn) {
prevBtn.style.display = hasMultiple ? 'block' : 'none';
prevBtn.disabled = currentEditIndex === 0;
prevBtn.style.opacity = currentEditIndex === 0 ? '0.5' : '1';
}
if (nextBtn) {
nextBtn.style.display = hasMultiple ? 'block' : 'none';
nextBtn.disabled = currentEditIndex === editHistory.length - 1;
nextBtn.style.opacity = currentEditIndex === editHistory.length - 1 ? '0.5' : '1';
}
if (indexDisplay) {
indexDisplay.style.display = hasMultiple ? 'block' : 'none';
indexDisplay.textContent = `${currentEditIndex + 1} / ${editHistory.length}`;
}
}
// =========================================================================
// APPLY CHANGES
// =========================================================================
function handleApplyChanges() {
const globalEditorInstance = deps.getGlobalEditor();
if (!globalEditorInstance || !overlayEditorInstance) return;
// Save current edit before applying
saveCurrentEdit();
// Combine all edits with line breaks
const allContent = editHistory.filter(e => e.trim().length > 0).join('\n\n');
if (!allContent.trim()) {
if (deps.showToast) {
deps.showToast('⚠️ No content to apply', 'error');
}
return;
}
// Check if we have a valid target
if (!selectionRange) {
if (deps.showToast) {
deps.showToast('⚠️ No target. Using cursor position.', 'info');
}
// Fallback to cursor
const pos = globalEditorInstance.getCursorPosition();
const Range = ace.require('ace/range').Range;
selectionRange = new Range(pos.row, pos.column, pos.row, pos.column);
}
// Check if it's an empty range (insertion) or actual selection (replacement)
const Range = ace.require('ace/range').Range;
const isEmpty = selectionRange.isEmpty();
if (isEmpty) {
// Insert at position
const contentToInsert = '\n' + allContent + '\n';
globalEditorInstance.session.insert(selectionRange.start, contentToInsert);
if (deps.showToast) {
deps.showToast('✅ Content inserted', 'success');
}
} else {
// Replace the range
globalEditorInstance.session.replace(selectionRange, allContent);
if (deps.showToast) {
deps.showToast('✅ Content replaced', 'success');
}
}
// Clear state for this file after successful apply
const files = JSON.parse(localStorage.getItem('sftp_active_files') || '[]');
const active = files.find(f => f.active);
const fileKey = active ? active.name : 'default';
delete window._overlayStates[fileKey];
cleanup();
deps.hideOverlay();
deps.showOverlay("Edit Content", editorHtml, null, true);
globalEditorInstance.focus();
}
// =========================================================================
// SELECTION EXPANSION
// =========================================================================
function expandSelectionToIndexItems(range) {
const globalEditorInstance = deps.getGlobalEditor();
if (!globalEditorInstance) return null;
const session = globalEditorInstance.getSession();
const Range = ace.require('ace/range').Range;
const startRow = range.start.row;
const endRow = range.end.row;
// Build index of ALL sections (markers + foldable)
const allSections = [];
// 1. Find all markers
const lineCount = session.getLength();
for (let row = 0; row < lineCount; row++) {
const line = session.getLine(row);
if (line.includes('<') && (line.includes('<!--') || line.includes('/*') || line.includes('//'))) {
const openMatch = line.match(/(?:<!--|\/\*|\/\/\/|\/\/)\s*([\w\-\[\]_]+)\s*</);
if (openMatch) {
const markerName = openMatch[1].trim();
for (let closeRow = row + 1; closeRow < lineCount; closeRow++) {
const closeLine = session.getLine(closeRow);
if (closeLine.includes('>')) {
const closeMatch = closeLine.match(/(?:<!--|\/\*|\/\/\/|\/\/)\s*([\w\-\[\]_]+)\s*>/);
if (closeMatch && closeMatch[1].trim() === markerName) {
allSections.push({
startRow: row,
endRow: closeRow,
length: closeRow - row,
type: 'marker',
icon: '🏷️',
label: `[${markerName}]`,
name: markerName
});
break;
}
}
}
}
}
}
// 2. Find all foldable sections
for (let row = 0; row < lineCount; row++) {
const foldWidget = session.getFoldWidget(row);
if (!foldWidget || foldWidget === '') continue;
const foldRange = session.getFoldWidgetRange(row);
if (!foldRange) continue;
const line = session.getLine(foldRange.start.row).trim();
let icon = '📦';
let label = line.substring(0, 40);
// Detect type for icon
if (line.match(/^\.([a-zA-Z0-9_-]+)/)) {
icon = '🎨';
label = line.match(/^(\.([a-zA-Z0-9_-]+))/)[1];
} else if (line.match(/^(body|html|h[1-6])/)) {
icon = '🎨';
label = line.match(/^([a-zA-Z0-9]+)/)[1];
} else if (line.match(/function\s+(\w+)/)) {
icon = '⚙️';
const match = line.match(/function\s+(\w+)/);
label = match[1] + '()';
}
allSections.push({
startRow: foldRange.start.row,
endRow: foldRange.end.row,
length: foldRange.end.row - foldRange.start.row,
type: 'fold',
icon: icon,
label: label,
name: line.substring(0, 40)
});
}
// 3. Check which sections have BOTH start AND end inside them
const fullyContainingSections = allSections.filter(section =>
section.startRow <= startRow && section.endRow >= endRow
);
if (fullyContainingSections.length === 0) {
return null; // No sections contain the selection
}
// 4. Sort by length (shortest first) and pick the smallest
fullyContainingSections.sort((a, b) => a.length - b.length);
const smallest = fullyContainingSections[0];
// 5. Expand selection to this section
selectionRange = new Range(
smallest.startRow,
0,
smallest.endRow,
session.getLine(smallest.endRow).length
);
const lines = [];
for (let row = smallest.startRow; row <= smallest.endRow; row++) {
lines.push(session.getLine(row));
}
if (deps.showToast) {
deps.showToast(`📦 Expanded to ${smallest.label}`, 'info', 3000);
}
return {
content: lines.join('\n'),
sectionInfo: smallest
};
}
// =========================================================================
// TARGET DETECTION
// =========================================================================
function detectTargetSection() {
const globalEditorInstance = deps.getGlobalEditor();
if (!overlayEditorInstance) {
if (deps.showToast) {
deps.showToast('⚠️ Overlay editor not initialized', 'error');
}
return;
}
const content = overlayEditorInstance.getValue();
if (!content || !content.trim()) {
if (deps.showToast) {
deps.showToast('⚠️ No content to analyze', 'error');
}
return;
}
// Get index - handle both hierarchical and flat structures
const indexResult = deps.generateDocumentIndex();
let flatIndex = [];
// Convert hierarchical structure to flat array if needed
if (indexResult && typeof indexResult === 'object' && 'components' in indexResult) {
// Hierarchical structure from new index
const { components, unmarked } = indexResult;
// Flatten components
for (const [componentName, languages] of Object.entries(components)) {
for (const [language, sections] of Object.entries(languages)) {
sections.forEach(section => {
flatIndex.push({
...section,
isMarker: true
});
});
}
}
// Add unmarked items
flatIndex = flatIndex.concat(unmarked);
} else if (Array.isArray(indexResult)) {
// Flat array from old index
flatIndex = indexResult;
}
if (flatIndex.length === 0) {
// No matches - find smart insertion point
findSmartInsertionPoint(content);
return;
}
const session = globalEditorInstance.getSession();
const Range = ace.require('ace/range').Range;
let candidates = [];
// Get lines from overlay content
const contentLines = content.split('\n').map(l => l.trim()).filter(l => l.length > 0);
const firstContentLine = contentLines[0] || '';
// Score each section in the document
for (const item of flatIndex) {
let itemStartRow, itemEndRow;
if (item.isMarker || item.type === 'marker') {
itemStartRow = item.row;
itemEndRow = item.endRow || deps.findMarkerEnd(item.row, item.label);
} else {
const foldRange = session.getFoldWidgetRange(item.row);
if (foldRange) {
itemStartRow = foldRange.start.row;
itemEndRow = foldRange.end.row;
} else {
itemStartRow = itemEndRow = item.row;
}
}
// Get all lines from this section
const itemLines = [];
for (let row = itemStartRow; row <= itemEndRow; row++) {
const line = session.getLine(row).trim();
if (line.length > 0) {
itemLines.push(line);
}
}
// Calculate header match score (70%)
const headerScore = calculateHeaderScore(item, firstContentLine, itemLines[0] || '');
// Calculate content match score (30%)
const contentScore = calculateContentScore(contentLines, itemLines);
// Final score: 70% header + 30% content
const finalScore = (headerScore * 0.7) + (contentScore.score * 0.3);
candidates.push({
...item,
startRow: itemStartRow,
endRow: itemEndRow,
score: finalScore,
headerScore: headerScore,
contentScore: contentScore.score,
exactMatches: contentScore.exactMatches,
partialMatches: contentScore.partialMatches,
totalLines: contentLines.length,
size: itemEndRow - itemStartRow
});
}
if (candidates.length === 0) {
findSmartInsertionPoint(content);
return;
}
// Sort by score (descending), then by size (ascending for ties)
candidates.sort((a, b) => {
if (Math.abs(a.score - b.score) < 0.05) {
return a.size - b.size;
}
return b.score - a.score;
});
const bestMatch = candidates[0];
// Check match quality thresholds
if (bestMatch.score >= 0.9) {
// Excellent match (90%+) - just use it
selectionRange = new Range(
bestMatch.startRow,
0,
bestMatch.endRow,
session.getLine(bestMatch.endRow).length
);
updateSectionDisplay(bestMatch);
} else if (bestMatch.score >= 0.6) {
// Good match (60-90%) - show options including the match
const cursorPos = globalEditorInstance.getCursorPosition();
const options = [];
// Option 1: Best match
options.push({
type: 'match',
row: bestMatch.startRow,
endRow: bestMatch.endRow,
label: `Replace ${bestMatch.label}`,
detail: `${(bestMatch.score * 100).toFixed(0)}% match`,
icon: bestMatch.icon,
score: bestMatch.score,
isReplacement: true,
matchData: bestMatch
});
// Option 2: After best match
options.push({
type: 'after',
row: bestMatch.endRow + 1,
label: `After ${bestMatch.label}`,
detail: 'Insert new section',
icon: '📍',
score: bestMatch.score * 0.8,
isReplacement: false
});
// Option 3: At cursor
options.push({
type: 'cursor',
row: cursorPos.row,
label: `At cursor (line ${cursorPos.row + 1})`,
detail: 'Current position',
icon: '📍',
score: 0,
isReplacement: false
});
// Set default to best option
if (options[0].isReplacement) {
selectionRange = new Range(
options[0].row,
0,
options[0].endRow,
session.getLine(options[0].endRow).length
);
} else {
selectionRange = new Range(options[0].row, 0, options[0].row, 0);
}
updateSectionDisplay(null, null, {
type: 'options',
options: options,
selectedIndex: 0
});
} else {
// Poor match (<60%) - use smart insertion
findSmartInsertionPoint(content, bestMatch);
}
}
// =========================================================================
// SMART INSERTION POINT
// =========================================================================
function findSmartInsertionPoint(content, poorMatch = null) {
const globalEditorInstance = deps.getGlobalEditor();
const session = globalEditorInstance.getSession();
const Range = ace.require('ace/range').Range;
// Analyze content to determine type
const contentLines = content.split('\n');
let contentType = detectContentType(contentLines);
// Get cursor position for fallback
const cursorPos = globalEditorInstance.getCursorPosition();
const cursorRow = cursorPos.row;
// Get index to find nearby sections
const indexResult = deps.generateDocumentIndex();
let flatIndex = [];
// Convert hierarchical structure to flat array if needed
if (indexResult && typeof indexResult === 'object' && 'components' in indexResult) {
const { components, unmarked } = indexResult;
for (const [componentName, languages] of Object.entries(components)) {
for (const [language, sections] of Object.entries(languages)) {
sections.forEach(section => {
flatIndex.push({
...section,
isMarker: true
});
});
}
}
flatIndex = flatIndex.concat(unmarked);
} else if (Array.isArray(indexResult)) {
flatIndex = indexResult;
}
// Strategy 1: Find sections of same content type
const matchingTypeSections = flatIndex.filter(item => {
if (contentType === 'css' && item.icon === '🎨') return true;
if (contentType === 'function' && (item.icon === '⚙️' || item.icon === '🔧')) return true;
if (contentType === 'html' && item.icon === '📦') return true;
return false;
});
// Find the last matching section (or closest to cursor)
let smartSection = null;
let smartRow = null;
if (matchingTypeSections.length > 0) {
// Use last section of matching type
smartSection = matchingTypeSections[matchingTypeSections.length - 1];
smartRow = smartSection.endRow ? smartSection.endRow + 1 : smartSection.row + 1;
}
// Strategy 2: Find closest section to cursor
let nearestSection = null;
let nearestDistance = Infinity;
flatIndex.forEach(item => {
const itemRow = item.row;
const distance = Math.abs(itemRow - cursorRow);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestSection = item;
}
});
// Prepare insertion options
const insertionOptions = [];
// Option 1: Smart match based on content type
if (smartSection) {
insertionOptions.push({
type: 'smart',
row: smartRow,
label: `After ${smartSection.label}`,
icon: smartSection.icon,
section: smartSection
});
}
// Option 2: After nearest section
if (nearestSection && (!smartSection || nearestSection.label !== smartSection.label)) {
const afterRow = nearestSection.endRow ? nearestSection.endRow + 1 : nearestSection.row + 1;
insertionOptions.push({
type: 'nearest',
row: afterRow,
label: `After ${nearestSection.label}`,
icon: nearestSection.icon,
section: nearestSection
});
}
// Option 3: At cursor
insertionOptions.push({
type: 'cursor',
row: cursorRow,
label: `At cursor (line ${cursorRow + 1})`,
icon: '📍',
section: null
});
// Default to first option (smart match or cursor)
const defaultOption = insertionOptions[0];
selectionRange = new Range(defaultOption.row, 0, defaultOption.row, 0);
updateSectionDisplay(null, poorMatch, {
type: 'insertion',
contentType: contentType,
options: insertionOptions,
selectedIndex: 0
});
}
function detectContentType(lines) {
const content = lines.join('\n').trim();
// Check for CSS
if (content.match(/^\.([a-zA-Z0-9_-]+)\s*\{/) || content.match(/^(body|html|h[1-6])\s*\{/i)) {
return 'css';
}
// Check for function
if (content.match(/function\s+(\w+)/) || content.match(/const\s+(\w+)\s*=\s*(?:function|\()/)) {
return 'function';
}
// Check for HTML
if (content.match(/^<\w+/)) {
return 'html';
}
// Check for PHP
if (content.includes('<?php') || content.match(/function\s+\w+\s*\(/)) {
return 'php';
}
return 'unknown';
}
function findClosingBrace(startRow) {
const globalEditorInstance = deps.getGlobalEditor();
const session = globalEditorInstance.getSession();
const lineCount = session.getLength();
let braceCount = 0;
let foundOpening = false;
for (let row = startRow; row < lineCount; row++) {
const line = session.getLine(row);
for (let i = 0; i < line.length; i++) {
if (line[i] === '{') {
braceCount++;
foundOpening = true;
} else if (line[i] === '}') {
braceCount--;
if (foundOpening && braceCount === 0) {
return row;
}
}
}
}
return startRow;
}
// =========================================================================
// SCORING HELPERS
// =========================================================================
function calculateHeaderScore(item, firstContentLine, firstItemLine) {
let score = 0;
// Extract normalized names
const itemLabel = (item.label || '').replace(/[\[\]()]/g, '').trim().toLowerCase();
const contentHeader = (firstContentLine || '').replace(/[\[\]()<>]/g, '').trim().toLowerCase();
// Small helper for edit distance (Levenshtein)
function stringDistance(a, b) {
const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
for (let i = 0; i <= a.length; i++) dp[i][0] = i;
for (let j = 0; j <= b.length; j++) dp[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost
);
}
}
return dp[a.length][b.length];
}
// Detect content language from first line
const contentLanguage = detectContentLanguage(firstContentLine);
// Get item language (from marker name if hierarchical)
let itemLanguage = null;
if (item.parsed && item.parsed.language) {
itemLanguage = item.parsed.language.toLowerCase();
} else if (item.icon === '🎨') {
itemLanguage = 'css';
} else if (item.icon === '⚙️' || item.icon === '🔧') {
itemLanguage = 'js'; // or php, but we'll check syntax
} else if (item.icon === '📦') {
itemLanguage = 'html';
}
// Language mismatch penalty - if we're confident about both languages and they don't match
if (itemLanguage && contentLanguage && itemLanguage !== contentLanguage) {
// Strong mismatch: CSS content vs JS function, etc.
if ((contentLanguage === 'css' && itemLanguage !== 'css') ||
(contentLanguage === 'js' && itemLanguage === 'css') ||
(contentLanguage === 'php' && itemLanguage === 'css')) {
return 0; // No match - wrong language
}
}
// --- Marker match ---
if (item.isMarker || item.type === 'marker') {
const distance = stringDistance(itemLabel, contentHeader);
const maxLen = Math.max(itemLabel.length, contentHeader.length);
const similarity = 1 - distance / maxLen;
// Hard cutoff: must be > 0.9 to be "same header"
if (similarity > 0.9) {
score = 1.0;
} else if (similarity > 0.75) {
score = 0.6;
} else {
score = 0;
}
}
// --- Function header ---
else if (item.icon === '⚙️' || item.icon === '🔧') {
const funcName = item.label.replace('()', '').trim().toLowerCase();
const match = firstContentLine.match(/function\s+(\w+)/i);
const arrowMatch = firstContentLine.match(/(?:const|let|var)\s+(\w+)\s*=/);
if (match && match[1].toLowerCase() === funcName) {
score = 1.0;
} else if (arrowMatch && arrowMatch[1].toLowerCase() === funcName) {
score = 1.0;
} else if (contentHeader.includes(funcName)) {
score = 0.5;
}
}
// --- CSS selectors ---
else if (item.icon === '🎨') {
const sel = item.label.trim();
const selNormalized = sel.toLowerCase();
if (firstContentLine.startsWith(sel + ' {') || firstContentLine.startsWith(sel + '{')) {
score = 1.0;
} else if (firstContentLine.match(new RegExp(`^${sel.replace('.', '\\.')}\\s*\\{`, 'i'))) {
score = 1.0;
} else if (contentHeader.includes(selNormalized)) {
score = 0.8;
}
}
// --- HTML tags ---
else if (item.icon === '📦') {
const tagMatch = firstItemLine.match(/<([a-zA-Z0-9-]+)/);
const tagContent = firstContentLine.match(/<([a-zA-Z0-9-]+)/);
if (tagMatch && tagContent && tagMatch[1].toLowerCase() === tagContent[1].toLowerCase()) {
score = 1.0;
}
}
return score;
}
function detectContentLanguage(line) {
const normalized = line.trim().toLowerCase();
// CSS detection - very specific patterns
if (normalized.match(/^\.[\w-]+\s*\{/) ||
normalized.match(/^(body|html|header|footer|main|section|nav|article|aside|h[1-6]|p|div|span|a|button|input|form)\s*\{/i) ||
normalized.match(/^[\w-]+\s*:\s*[\w-]+;/)) {
return 'css';
}
// PHP detection
if (normalized.includes('<?php') ||
normalized.match(/function\s+\w+\s*\([^)]*\)\s*\{/) && normalized.includes('$')) {
return 'php';
}
// JavaScript detection
if (normalized.match(/function\s+\w+\s*\(/) ||
normalized.match(/(?:const|let|var)\s+\w+\s*=/) ||
normalized.includes('=>')) {
return 'js';
}
// HTML detection
if (normalized.match(/^<[a-z]+/i)) {
return 'html';
}
return null; // Unknown
}
function calculateContentScore(contentLines, itemLines) {
let exactMatches = 0;
let partialMatches = 0;
for (const contentLine of contentLines) {
let foundExact = false;
let foundPartial = false;
for (const itemLine of itemLines) {
if (contentLine === itemLine) {
foundExact = true;
break;
}
if (contentLine.length > 10 && itemLine.length > 10) {
if (itemLine.includes(contentLine) || contentLine.includes(itemLine)) {
foundPartial = true;
}
}
}
if (foundExact) exactMatches++;
else if (foundPartial) partialMatches++;
}
const totalMatches = exactMatches + (partialMatches * 0.5);
const score = totalMatches / contentLines.length;
return { score, exactMatches, partialMatches };
}
// =========================================================================
// UI UPDATES
// =========================================================================
function updateSectionDisplay(bestMatch, poorMatch = null, insertionInfo = null) {
const el = deps.getEl();
const sectionDisplay = el.querySelector('#sectionInfoDisplay');
if (!sectionDisplay) return;
// Preserve existing buttons
const detectBtn = sectionDisplay.querySelector('#refreshSectionBtn');
const applyBtn = sectionDisplay.querySelector('#applyBtn');
// Clear everything
sectionDisplay.innerHTML = '';
sectionDisplay.style.display = 'flex';
sectionDisplay.style.flexDirection = 'column';
sectionDisplay.style.gap = '6px';
sectionDisplay.style.padding = '8px 12px';
sectionDisplay.style.background = '#1e293b';
sectionDisplay.style.borderRadius = '4px';
// Create the top row (label + buttons)
const topRow = document.createElement('div');
topRow.style.display = 'flex';
topRow.style.alignItems = 'center';
topRow.style.gap = '8px';
const labelEl = document.createElement('span');
labelEl.style.flex = '1';
labelEl.style.fontFamily = `'Segoe UI', sans-serif`;
labelEl.style.fontSize = '14px';
labelEl.style.fontWeight = '500';
// --- Determine label text & color ---
if (!bestMatch && insertionInfo && insertionInfo.type === 'options') {
labelEl.innerHTML = '⚠️ Multiple options found:';
labelEl.style.color = '#fbbf24';
} else if (!bestMatch && insertionInfo && insertionInfo.options) {
labelEl.innerHTML = '📍 Choose insertion point:';
labelEl.style.color = '#3b82f6';
} else if (!bestMatch) {
if (poorMatch) {
labelEl.innerHTML = `❌ No match found <span style="color:#888;font-size:11px;">(best: ${deps.escapeHtml(
poorMatch.label
)} ${(poorMatch.score * 100).toFixed(0)}%)</span>`;
} else {
labelEl.innerHTML = '❌ No match found';
}
labelEl.style.color = '#ef4444';
} else {
const matchInfo = `H:${(bestMatch.headerScore * 100).toFixed(0)}% C:${(
bestMatch.contentScore * 100
).toFixed(0)}%`;
labelEl.innerHTML = `🎯 Target: ${deps.escapeHtml(bestMatch.label)} <span style="color:#888;font-size:11px;">(${(
bestMatch.score * 100
).toFixed(0)}% • ${matchInfo})</span>`;
labelEl.style.color = '#22c55e';
}
// Add label + buttons to the top row
topRow.appendChild(labelEl);
if (detectBtn) {
const newDetect = detectBtn.cloneNode(true);
newDetect.addEventListener('click', detectTargetSection);
topRow.appendChild(newDetect);
}
if (applyBtn) {
const newApply = applyBtn.cloneNode(true);
newApply.addEventListener('click', handleApplyChanges);
topRow.appendChild(newApply);
}
sectionDisplay.appendChild(topRow);
// --- Render choice options if present ---
if ((!bestMatch && insertionInfo && insertionInfo.type === 'options') ||
(!bestMatch && insertionInfo && insertionInfo.options)) {
const options = insertionInfo.options;
const selectedIndex = insertionInfo.selectedIndex || 0;
const optionsBox = document.createElement('div');
optionsBox.style.display = 'flex';
optionsBox.style.flexDirection = 'column';
optionsBox.style.gap = '4px';
optionsBox.style.marginTop = '4px';
optionsBox.innerHTML = options.map((opt, idx) => {
const scoreInfo = opt.score ? ` • ${(opt.score * 100).toFixed(0)}%` : '';
return `
<label class="insertion-option" data-index="${idx}" style="
display:flex;
align-items:center;
gap:6px;
padding:6px 10px;
background:${idx === selectedIndex ? '#4a5568' : '#2d2d2d'};
border-radius:4px;
cursor:pointer;
transition:background 0.15s;
font-family:'Segoe UI',sans-serif;
font-size:12px;
">
<input type="radio" name="insertionChoice" value="${idx}" ${idx === selectedIndex ? 'checked' : ''} style="accent-color:#3b82f6;">
<span style="font-size:14px;">${opt.icon}</span>
<span style="color:#e0e0e0;flex:1;">
${deps.escapeHtml(opt.label)}
${opt.detail ? `<span style="color:#888;font-size:11px;">${opt.detail}${scoreInfo}</span>` : ''}
</span>
</label>
`;
}).join('');
sectionDisplay.appendChild(optionsBox);
setTimeout(() => {
const globalEditorInstance = deps.getGlobalEditor();
const session = globalEditorInstance.getSession();
const Range = ace.require('ace/range').Range;
const optionElements = optionsBox.querySelectorAll('.insertion-option');
optionElements.forEach((optEl, idx) => {
optEl.addEventListener('click', () => {
const option = options[idx];
if (option.isReplacement) {
selectionRange = new Range(option.row, 0, option.endRow, session.getLine(option.endRow).length);
} else {
selectionRange = new Range(option.row, 0, option.row, 0);
}
updateSectionDisplay(null, poorMatch, { ...insertionInfo, selectedIndex: idx });
if (deps.showToast) deps.showToast(`📍 Selected: ${option.label}`, 'info');
});
});
}, 10);
}
}
// =========================================================================
// CLEANUP
// =========================================================================
function cleanup() {
if (overlayEditorInstance) {
overlayEditorInstance.destroy();
overlayEditorInstance = null;
}
editHistory = [];
currentEditIndex = 0;
selectionRange = null;
}
// =========================================================================
// PUBLIC API
// =========================================================================
window.OverlayEditor = {
init: init,
showEditSelectionOverlay: showEditSelectionOverlay,
cleanup: cleanup
};
console.log("[overlay_editor.js] Module loaded successfully");
})();