/**
* Editor Index Module v3
* Hierarchical, language-aware document structure navigation
* Uses localStorage for data access - no direct Ace dependency
*/
(function() {
'use strict';
console.log("[editor_index.js v3] Loading editor index module...");
// =========================================================================
// LOCALSTORAGE KEYS
// =========================================================================
const ACTIVE_FILES_KEY = "sftp_active_files";
// =========================================================================
// DEPENDENCIES (injected from main editor)
// =========================================================================
let deps = {
getGlobalEditor: null, // Optional - for navigation if available
showToast: null
};
// =========================================================================
// LANGUAGE DETECTION
// =========================================================================
const LANGUAGE_INFO = {
php: { icon: '📄', color: '#8892BF' },
js: { icon: '⚙️', color: '#F7DF1E' },
javascript: { icon: '⚙️', color: '#F7DF1E' },
css: { icon: '🎨', color: '#264DE4' },
html: { icon: '📦', color: '#E34F26' },
htm: { icon: '📦', color: '#E34F26' }
};
function getLanguageInfo(lang) {
const normalized = lang?.toLowerCase() || 'unknown';
return LANGUAGE_INFO[normalized] || { icon: '📝', color: '#888' };
}
// =========================================================================
// LOCALSTORAGE ACCESS
// =========================================================================
/**
* Get the active file content from localStorage
*/
function getActiveFileContent() {
try {
const files = JSON.parse(localStorage.getItem(ACTIVE_FILES_KEY) || "[]");
const active = files.find(f => f.active);
return {
content: active?.content || "",
name: active?.name || "Untitled",
path: active?.path || ""
};
} catch (err) {
console.error("[editor_index.js v3] Failed to load file from localStorage:", err);
return { content: "", name: "Untitled", path: "" };
}
}
/**
* Get all lines from the active file
*/
function getLines() {
const { content } = getActiveFileContent();
return content.split('\n');
}
/**
* Get a specific line by index
*/
function getLine(index) {
const lines = getLines();
return lines[index] || "";
}
/**
* Get total line count
*/
function getLineCount() {
return getLines().length;
}
// =========================================================================
// MARKER PARSING
// =========================================================================
/**
* Parse marker name into components
* Examples:
* "buttons_css_1" -> { component: "buttons", language: "css", number: 1 }
* "buttons_css" -> { component: "buttons", language: "css", number: null }
* "buttons" -> { component: "buttons", language: null, number: null }
*/
function parseMarkerName(markerName) {
// Remove brackets if present
const cleaned = markerName.replace(/[\[\]]/g, '').trim();
const parts = cleaned.split('_');
if (parts.length === 1) {
// Just component name: "buttons"
return {
component: parts[0],
language: null,
number: null,
fullName: cleaned
};
}
if (parts.length === 2) {
// Component + language: "buttons_css"
return {
component: parts[0],
language: parts[1],
number: null,
fullName: cleaned
};
}
if (parts.length >= 3) {
// Component + language + number: "buttons_css_1"
const number = parseInt(parts[2]);
return {
component: parts[0],
language: parts[1],
number: isNaN(number) ? null : number,
fullName: cleaned
};
}
return {
component: cleaned,
language: null,
number: null,
fullName: cleaned
};
}
/**
* Find the closing marker for a given opening marker
*/
function findMarkerEnd(startRow, markerName) {
const lineCount = getLineCount();
for (let row = startRow + 1; row < lineCount; row++) {
const line = getLine(row);
const trimmed = line.trim();
// Look for closing marker with >
if (trimmed.includes('>')) {
const closeMatch = trimmed.match(/(?:<!--|\/\*|\/\/\/|\/\/)\s*([\w\-\[\]_]+)\s*>/);
if (closeMatch && closeMatch[1].trim() === markerName) {
return row;
}
}
}
return startRow; // No closing found, return start
}
// =========================================================================
// INDEX GENERATION (HIERARCHICAL MODE)
// =========================================================================
function generateHierarchicalIndex() {
const lineCount = getLineCount();
const components = {}; // Hierarchical structure
const unmarked = []; // Items without proper markers
const markerRanges = []; // Track which rows are inside markers
// =====================================================================
// FIRST PASS: Find all markers and their ranges
// =====================================================================
for (let row = 0; row < lineCount; row++) {
const line = getLine(row);
const trimmed = line.trim();
if (!trimmed) continue;
// Improved marker detection - opening markers with <
if (trimmed.includes('<')) {
let markerMatch = trimmed.match(/(?:<!--|\/\*|\/\/\/|\/\/)\s*([\w\-\[\]_]+)\s*</);
if (markerMatch) {
const markerName = markerMatch[1].trim();
const parsed = parseMarkerName(markerName);
// Find closing marker
const endRow = findMarkerEnd(row, markerName);
const markerItem = {
type: 'marker',
row: row,
endRow: endRow,
label: markerName,
parsed: parsed,
preview: trimmed.substring(0, 60) + (trimmed.length > 60 ? '...' : ''),
children: [] // Will hold items found inside this marker
};
// Track this range
markerRanges.push({
startRow: row,
endRow: endRow,
markerItem: markerItem
});
if (parsed.language) {
// Has language specification - add to components
if (!components[parsed.component]) {
components[parsed.component] = {};
}
if (!components[parsed.component][parsed.language]) {
components[parsed.component][parsed.language] = [];
}
components[parsed.component][parsed.language].push(markerItem);
} else {
// No language - add to unmarked
unmarked.push(markerItem);
}
}
}
}
// =====================================================================
// SECOND PASS: Find all items and assign them to markers or unmarked
// =====================================================================
for (let row = 0; row < lineCount; row++) {
const line = getLine(row);
const trimmed = line.trim();
if (!trimmed) continue;
// Skip marker opening/closing lines
if (trimmed.includes('<') || trimmed.includes('>')) {
if (trimmed.match(/(?:<!--|\/\*|\/\/\/|\/\/)\s*[\w\-\[\]_]+\s*[<>]/)) {
continue;
}
}
let match;
let item = null;
// HTML tags
if ((match = trimmed.match(/^<(div|section|nav|header|footer|main|article|aside|button)[^>]*(?:id=["']([^"']+)["']|class=["']([^"']+)["'])?[^>]*>/i))) {
const tag = match[1];
const id = match[2];
const className = match[3];
const label = id ? `#${id}` : (className ? `.${className.split(' ')[0]}` : `<${tag}>`);
item = {
type: 'html',
row: row,
label: label,
icon: '📦',
preview: trimmed.substring(0, 60) + (trimmed.length > 60 ? '...' : '')
};
}
// JavaScript functions
else if ((match = trimmed.match(/(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:function|\([^)]*\)\s*=>))/))) {
const funcName = match[1] || match[2];
item = {
type: 'function',
row: row,
label: `${funcName}()`,
icon: '⚙️',
preview: trimmed.substring(0, 60) + (trimmed.length > 60 ? '...' : '')
};
}
// PHP functions
else if ((match = trimmed.match(/(?:public|private|protected|static)?\s*function\s+(\w+)\s*\(/))) {
item = {
type: 'function',
row: row,
label: `${match[1]}()`,
icon: '🔧',
preview: trimmed.substring(0, 60) + (trimmed.length > 60 ? '...' : '')
};
}
// CSS classes
else if ((match = trimmed.match(/^\.([a-zA-Z0-9_-]+)\s*\{/))) {
item = {
type: 'css',
row: row,
label: `.${match[1]}`,
icon: '🎨',
preview: trimmed.substring(0, 60) + (trimmed.length > 60 ? '...' : '')
};
}
// CSS IDs
else if ((match = trimmed.match(/^#([a-zA-Z0-9_-]+)\s*\{/))) {
item = {
type: 'css',
row: row,
label: `#${match[1]}`,
icon: '🎨',
preview: trimmed.substring(0, 60) + (trimmed.length > 60 ? '...' : '')
};
}
if (!item) continue;
// Find which marker this item belongs to
let belongsToMarker = null;
for (const range of markerRanges) {
if (row > range.startRow && row < range.endRow) {
belongsToMarker = range.markerItem;
break;
}
}
if (belongsToMarker) {
belongsToMarker.children.push(item);
} else {
unmarked.push(item);
}
}
return { components, unmarked };
}
// =========================================================================
// HTML GENERATION
// =========================================================================
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function generateHierarchicalHTML(data) {
const { components, unmarked } = data;
let html = `
<div style="padding: 20px; color: #e0e0e0;">
<h2 style="margin: 0 0 20px 0; color: #fff;">📑 Document Index</h2>
`;
// Render components
const componentNames = Object.keys(components).sort();
if (componentNames.length > 0) {
componentNames.forEach(componentName => {
const languages = components[componentName];
const languageNames = Object.keys(languages).sort();
html += `
<div style="margin-bottom: 16px; background: #2d2d2d; border-radius: 6px; overflow: hidden;">
<div class="component-header" data-component="${escapeHtml(componentName)}" style="
padding: 12px 16px;
background: #3a3a3a;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 15px;
user-select: none;
">
<span class="collapse-icon">▼</span>
<span>📦 ${escapeHtml(componentName)}</span>
<span style="color: #888; font-size: 12px; font-weight: normal;">(${languageNames.length} language${languageNames.length !== 1 ? 's' : ''})</span>
</div>
<div class="component-content" data-component="${escapeHtml(componentName)}" style="padding: 8px;">
`;
// Render languages within component
languageNames.forEach(langName => {
const markers = languages[langName];
const langInfo = getLanguageInfo(langName);
html += `
<div style="margin-bottom: 8px;">
<div class="language-header" style="
padding: 8px 12px;
background: #3a3a3a;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
border-radius: 4px;
font-size: 13px;
user-select: none;
">
<span>▼</span>
<span>${langInfo.icon}</span>
<span style="color: ${langInfo.color}; font-weight: 500;">${escapeHtml(langName)}</span>
<span style="color: #888; font-size: 11px;">(${markers.length} marker${markers.length !== 1 ? 's' : ''})</span>
</div>
<div class="language-content" style="padding-left: 16px;">
`;
// Render markers
markers.forEach(marker => {
html += `
<div class="index-item" data-row="${marker.row}" data-is-marker="true" data-label="${escapeHtml(marker.label)}" style="
padding: 8px 12px;
margin: 4px 0;
background: #2d2d2d;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 16px;">🏷️</span>
<div style="flex: 1; min-width: 0;">
<div style="color: #e0e0e0; font-weight: 500; font-size: 13px;">${escapeHtml(marker.label)}</div>
<div style="color: #888; font-size: 11px;">Lines ${marker.row + 1}–${marker.endRow + 1}</div>
</div>
</div>
`;
// Render children (items inside this marker)
if (marker.children && marker.children.length > 0) {
html += `<div style="margin-top: 8px; padding-left: 20px; border-left: 2px solid #444;">`;
marker.children.forEach(child => {
html += `
<div class="index-item-child" data-row="${child.row}" style="
padding: 6px 10px;
margin: 2px 0;
background: #252525;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
">
<div style="display: flex; align-items: center; gap: 6px;">
<span>${child.icon}</span>
<span style="color: #d0d0d0;">${escapeHtml(child.label)}</span>
<span style="color: #666; font-size: 10px;">:${child.row + 1}</span>
</div>
</div>
`;
});
html += `</div>`;
}
html += `</div>`;
});
html += `
</div>
</div>
`;
});
html += `
</div>
</div>
`;
});
}
// Render unmarked items
if (unmarked.length > 0) {
html += `
<div style="margin-top: 20px;">
<h3 style="color: #aaa; font-size: 14px; margin-bottom: 10px;">📌 Unmarked Items</h3>
`;
unmarked.forEach(item => {
const icon = item.type === 'marker' ? '🏷️' : item.icon;
const isMarker = item.type === 'marker';
html += `
<div class="index-item" data-row="${item.row}" data-is-marker="${isMarker}" data-label="${isMarker ? escapeHtml(item.label) : ''}" style="
padding: 8px 12px;
margin: 4px 0;
background: #3d3d3d;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 16px;">${icon}</span>
<div style="flex: 1; min-width: 0;">
<div style="color: #e0e0e0; font-weight: 500; font-size: 14px;">${escapeHtml(item.label)}</div>
<div style="color: #888; font-size: 12px;">Line ${item.row + 1}</div>
</div>
</div>
</div>
`;
});
html += `</div>`;
}
if (componentNames.length === 0 && unmarked.length === 0) {
html += `
<div style="padding: 40px; text-align: center; color: #888;">
<div style="font-size: 48px; margin-bottom: 16px;">📄</div>
<div style="font-size: 16px;">No structure found</div>
<div style="font-size: 13px; margin-top: 8px;">Add markers or structural elements to see the index</div>
</div>
`;
}
html += `</div>`;
return html;
}
// =========================================================================
// SHOW INDEX (Integrated into editor overlay)
// =========================================================================
function showIndexOverlay() {
console.log("[editor_index.js v3] Generating hierarchical index...");
const data = generateHierarchicalIndex();
const html = generateHierarchicalHTML(data);
// Use AppOverlay if available (should be the editor's overlay system)
if (window.AppOverlay && window.AppOverlay.open) {
console.log("[editor_index.js v3] Opening index in AppOverlay");
const indexItem = {
title: "📑 Document Index",
html: html,
onRender: (el) => {
attachEventListeners(el);
}
};
// This will show the index in the same overlay system as the editor
window.AppOverlay.open([indexItem], 0);
} else {
console.error("[editor_index.js v3] AppOverlay not available");
if (deps.showToast) {
deps.showToast("⚠️ Overlay system not loaded", "error");
}
}
}
// =========================================================================
// EVENT LISTENERS
// =========================================================================
function attachEventListeners(el) {
setTimeout(() => {
// Component collapse/expand
el.querySelectorAll('.component-header').forEach(header => {
header.addEventListener('click', (e) => {
const component = header.dataset.component;
const content = el.querySelector(`.component-content[data-component="${component}"]`);
const icon = header.querySelector('.collapse-icon');
if (content.style.display === 'none') {
content.style.display = 'block';
icon.textContent = '▼';
} else {
content.style.display = 'none';
icon.textContent = '▶';
}
});
});
// Language collapse/expand
el.querySelectorAll('.language-header').forEach(header => {
header.addEventListener('click', (e) => {
e.stopPropagation();
const content = header.nextElementSibling;
const icon = header.querySelector('span:first-child');
if (content.style.display === 'none') {
content.style.display = 'block';
icon.textContent = '▼';
} else {
content.style.display = 'none';
icon.textContent = '▶';
}
});
});
// Item click - navigate directly
el.querySelectorAll('.index-item').forEach(item => {
item.addEventListener('mouseenter', () => {
item.style.background = '#4a5568';
});
item.addEventListener('mouseleave', () => {
item.style.background = item.closest('.language-content') ? '#2d2d2d' : '#3d3d3d';
});
item.addEventListener('click', () => {
const row = parseInt(item.dataset.row);
const isMarker = item.dataset.isMarker === 'true';
const label = item.dataset.label;
if (isMarker) {
navigateToMarker(row, label);
} else {
navigateToRow(row);
}
// Navigation function handles closing the overlay
});
});
// Child item click - navigate directly
el.querySelectorAll('.index-item-child').forEach(item => {
item.addEventListener('mouseenter', () => {
item.style.background = '#3a3a3a';
});
item.addEventListener('mouseleave', () => {
item.style.background = '#252525';
});
item.addEventListener('click', (e) => {
e.stopPropagation(); // Don't trigger parent marker click
const row = parseInt(item.dataset.row);
navigateToRow(row);
// Navigation function handles closing the overlay
});
});
}, 50);
}
// =========================================================================
// NAVIGATION (FAST - Direct editor communication)
// =========================================================================
/**
* Navigate directly to marker and close index
*/
function navigateToMarker(startRow, label) {
const globalEditorInstance = deps.getGlobalEditor ? deps.getGlobalEditor() : null;
if (!globalEditorInstance) {
console.error("[editor_index.js v3] No editor instance available");
if (deps.showToast) {
deps.showToast("⚠️ Editor not available", "error");
}
return;
}
const endRow = findMarkerEnd(startRow, label);
console.log(`[editor_index.js v3] Direct navigation to ${label} at rows ${startRow}-${endRow}`);
// Navigate immediately if Ace is available
if (typeof ace !== 'undefined') {
const session = globalEditorInstance.getSession();
const Range = ace.require('ace/range').Range;
const range = new Range(
startRow,
0,
endRow,
session.getLine(endRow).length
);
globalEditorInstance.selection.setRange(range, false);
globalEditorInstance.scrollToLine(startRow, true, true, () => {});
globalEditorInstance.focus();
// Show toast
if (deps.showToast) {
deps.showToast(`📍 ${label}`, "success");
}
console.log(`[editor_index.js v3] ✓ Navigated to ${label}`);
}
// Close the index by going back to previous item in AppOverlay
if (window.AppOverlay && window.AppOverlay.prev) {
window.AppOverlay.prev();
}
}
/**
* Navigate directly to row and close index
*/
function navigateToRow(row) {
const globalEditorInstance = deps.getGlobalEditor ? deps.getGlobalEditor() : null;
if (!globalEditorInstance) {
console.error("[editor_index.js v3] No editor instance available");
if (deps.showToast) {
deps.showToast("⚠️ Editor not available", "error");
}
return;
}
const line = getLine(row);
// Determine type and label from line content
let type = 'line';
let label = `Line ${row + 1}`;
if (line.includes('function')) {
type = 'function';
const match = line.match(/function\s+(\w+)|(\w+)\s*=\s*function/);
if (match) label = (match[1] || match[2]) + '()';
} else if (line.includes('class=') || line.includes('id=')) {
type = 'html';
const idMatch = line.match(/id=["']([^"']+)["']/);
const classMatch = line.match(/class=["']([^"']+)["']/);
if (idMatch) label = `#${idMatch[1]}`;
else if (classMatch) label = `.${classMatch[1].split(' ')[0]}`;
} else if (line.match(/^[\.\#][\w-]+\s*\{/)) {
type = 'css';
const match = line.match(/^([\.\#][\w-]+)/);
if (match) label = match[1];
}
console.log(`[editor_index.js v3] Direct navigation to ${label} (${type}) at row ${row}`);
// Navigate immediately if Ace is available
if (typeof ace !== 'undefined') {
const session = globalEditorInstance.getSession();
const Range = ace.require('ace/range').Range;
const foldRange = session.getFoldWidgetRange(row);
if (foldRange) {
const extended = new Range(
foldRange.start.row,
0,
foldRange.end.row,
session.getLine(foldRange.end.row).length
);
globalEditorInstance.selection.setRange(extended, false);
} else {
const range = new Range(row, 0, row, line.length);
globalEditorInstance.selection.setRange(range, false);
}
globalEditorInstance.scrollToLine(row, true, true, () => {});
globalEditorInstance.focus();
// Show toast
if (deps.showToast) {
deps.showToast(`📍 ${label}`, "success");
}
console.log(`[editor_index.js v3] ✓ Navigated to ${label}`);
}
// Close the index by going back to previous item in AppOverlay
if (window.AppOverlay && window.AppOverlay.prev) {
window.AppOverlay.prev();
}
}
// =========================================================================
// INITIALIZATION
// =========================================================================
function init(dependencies) {
deps = { ...deps, ...dependencies };
console.log("[editor_index.js v3] Initialized with dependencies");
}
// =========================================================================
// PUBLIC API
// =========================================================================
window.EditorIndex = {
init: init,
generateDocumentIndex: generateHierarchicalIndex,
findMarkerEnd: findMarkerEnd,
showIndexOverlay: showIndexOverlay,
navigateToMarker: navigateToMarker,
navigateToRow: navigateToRow,
parseMarkerName: parseMarkerName,
// Data access methods for external use
getActiveFileContent: getActiveFileContent,
getLines: getLines,
getLine: getLine,
getLineCount: getLineCount
};
console.log("[editor_index.js v3] Module loaded successfully");
})();