// editor.js
(function () {
try {
console.log("[editor.js] Loading HTML editor module...");
window.AppItems = window.AppItems || [];
// Store editor instance globally so close hook can access it
let globalEditorInstance = null;
let saveTimeout = null;
// Search state
let searchState = { matches: [], idx: -1, markers: [] };
// Fold selection state
let lastCursorPos = null;
let lastFoldIndex = -1;
let cachedFolds = [];
// Improved save function with proper error handling
function saveToLocalStorage(editorInstance) {
if (!editorInstance) return;
try {
const files = JSON.parse(localStorage.getItem('sftp_active_files') || '[]');
const active = files.find(f => f.active);
if (active) {
active.content = editorInstance.getValue();
localStorage.setItem('sftp_active_files', JSON.stringify(files));
console.log(`[editor.js] ✓ Saved ${active.name}`);
return true;
}
} catch (err) {
console.error("[editor.js] Failed to save:", err);
return false;
}
}
// Debounced save - saves 500ms after last keystroke
function debouncedSave(editorInstance) {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
saveToLocalStorage(editorInstance);
}, 500);
}
// === SMART MARKER FUNCTIONS ===
function detectSubLanguage(editor) {
const pos = editor.getCursorPosition();
const token = editor.session.getTokenAt(pos.row, pos.column);
if (!token) return "php";
const t = token.type || "";
if (t.includes("php")) return "php";
if (t.includes("js")) return "javascript";
if (t.includes("css")) return "css";
if (t.includes("tag") || t.includes("attr")) return "html";
return "php";
}
function getCommentStyleFor(lang) {
switch (lang) {
case "html": return { open: "<!--", close: "-->" };
case "css": return { open: "/*", close: "*/" };
case "javascript": return { open: "//", close: "" };
case "php": return { open: "/*", close: "*/" };
default: return { open: "//", close: "" };
}
}
function wrapSelectionWithSmartMarker(markerName) {
if (!globalEditorInstance) return;
const selected = globalEditorInstance.getSelectedText();
if (!selected) {
if (typeof showToast === 'function') {
showToast('⚠️ Select some text first!', 'error');
}
return;
}
const range = globalEditorInstance.getSelectionRange();
const subLang = detectSubLanguage(globalEditorInstance);
const { open, close } = getCommentStyleFor(subLang);
// Build wrapped text with proper comment syntax
let wrapped;
if (close) {
// Block comment style (HTML, CSS, PHP) - each marker closes on same line
wrapped = `${open}${markerName}<${close}\n${selected}\n${open}${markerName}>${close}`;
} else {
// Line comment style (JavaScript, PHP with //)
wrapped = `${open}${markerName}<\n${selected}\n${open}${markerName}>`;
}
// Temporarily enable editing to insert marker
const wasReadOnly = globalEditorInstance.getReadOnly();
globalEditorInstance.setReadOnly(false);
globalEditorInstance.session.replace(range, wrapped);
// Restore read-only state
globalEditorInstance.setReadOnly(wasReadOnly);
if (typeof showToast === 'function') {
showToast(`✅ Wrapped with marker: ${markerName}`, 'success');
}
console.log(`[editor.js] Wrapped selection with marker "${markerName}" using ${subLang} syntax`);
}
const section = {
title: "HTML Editor",
html: `
<div class="editor-section">
<div class="editor-toolbar" style="
display: flex;
gap: 8px;
padding: 8px 12px;
background: #1e1e1e;
border-bottom: 1px solid #333;
align-items: center;
">
<button
id="indexBtn"
title="Show document index"
style="
padding: 6px 12px;
background: #3d3d3d;
border: 1px solid #555;
border-radius: 4px;
color: #e0e0e0;
cursor: pointer;
font-size: 13px;
font-family: 'Segoe UI', sans-serif;
"
>📑 Index</button>
<button
id="editSelectionBtn"
title="Edit selection in overlay"
style="
padding: 6px 12px;
background: #3d3d3d;
border: 1px solid #555;
border-radius: 4px;
color: #e0e0e0;
cursor: pointer;
font-size: 13px;
font-family: 'Segoe UI', sans-serif;
"
>✏️ Edit</button>
<input
type="text"
id="editorSearchInput"
placeholder="Find in file... (Ctrl+F)"
style="
flex: 1;
padding: 6px 10px;
background: #2d2d2d;
border: 1px solid #444;
border-radius: 4px;
color: #e0e0e0;
font-size: 13px;
font-family: 'Segoe UI', sans-serif;
"
/>
<button
id="searchPrevBtn"
title="Previous match (Shift+Enter)"
style="
padding: 6px 12px;
background: #3d3d3d;
border: 1px solid #555;
border-radius: 4px;
color: #e0e0e0;
cursor: pointer;
font-size: 16px;
"
>↑</button>
<button
id="searchNextBtn"
title="Next match (Enter)"
style="
padding: 6px 12px;
background: #3d3d3d;
border: 1px solid #555;
border-radius: 4px;
color: #e0e0e0;
cursor: pointer;
font-size: 16px;
"
>↓</button>
<span id="matchCounter" style="
color: #888;
font-size: 13px;
font-family: 'Segoe UI', sans-serif;
min-width: 60px;
"></span>
</div>
<div class="ace-editor" id="ace-editor-placeholder"></div>
</div>
<!-- Multi-purpose Overlay -->
<div id="multiOverlay" style="
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.7);
z-index: 999998;
align-items: center;
justify-content: center;
">
<div style="
background: #2d2d2d;
border: 1px solid #555;
border-radius: 8px;
width: 90%;
max-width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
">
<div style="
padding: 16px 20px;
border-bottom: 1px solid #555;
display: flex;
justify-content: space-between;
align-items: center;
">
<h3 id="overlayTitle" style="margin: 0; color: #e0e0e0; font-size: 18px; font-family: 'Segoe UI', sans-serif;">Overlay</h3>
<button id="closeOverlayBtn" style="
background: none;
border: none;
color: #888;
font-size: 24px;
cursor: pointer;
padding: 0;
line-height: 1;
">×</button>
</div>
<div id="overlayContent" style="
padding: 12px;
overflow-y: auto;
flex: 1;
"></div>
<div id="overlayFooter" style="
padding: 12px 20px;
border-top: 1px solid #555;
display: none;
gap: 8px;
"></div>
</div>
</div>
`,
onRender(el) {
console.log("[editor.js] onRender fired");
const container = el.querySelector('.ace-editor');
if (!container) return console.warn("[editor.js] No .ace-editor found");
container.style.minHeight = "calc(70vh - 50px)";
container.style.display = "block";
function loadAce(cb) {
if (window.ace) return cb();
const s = document.createElement('script');
s.src = "https://cdnjs.cloudflare.com/ajax/libs/ace/1.32.3/ace.js";
s.onload = cb;
document.head.appendChild(s);
}
function fitToOverlayBody() {
const body = container.closest('.app-dialog')?.querySelector('.app-dialog__body');
if (!body) return;
const bodyRect = body.getBoundingClientRect();
const topInBody = container.getBoundingClientRect().top - bodyRect.top;
const targetH = Math.max(200, Math.floor(bodyRect.height - topInBody - 6));
container.style.height = targetH + "px";
}
// === OVERLAY MANAGEMENT ===
let overlayEditorInstance = null;
let selectionRange = null;
let editHistory = [];
let currentEditIndex = 0;
function showOverlay(title, content, footer = null) {
const overlay = el.querySelector('#multiOverlay');
const titleEl = el.querySelector('#overlayTitle');
const contentEl = el.querySelector('#overlayContent');
const footerEl = el.querySelector('#overlayFooter');
if (!overlay || !titleEl || !contentEl || !footerEl) return;
titleEl.textContent = title;
contentEl.innerHTML = content;
if (footer) {
footerEl.innerHTML = footer;
footerEl.style.display = 'flex';
} else {
footerEl.style.display = 'none';
}
overlay.style.display = 'flex';
}
function hideOverlay() {
const overlay = el.querySelector('#multiOverlay');
if (overlay) overlay.style.display = 'none';
// Clean up overlay editor if exists
if (overlayEditorInstance) {
overlayEditorInstance.destroy();
overlayEditorInstance = null;
}
// Clear edit history
editHistory = [];
currentEditIndex = 0;
}
// === EDIT SELECTION FUNCTIONS ===
function showEditSelectionOverlay() {
if (!globalEditorInstance) return;
const selected = globalEditorInstance.getSelectedText();
let hasSelection = selected && selected.trim().length > 0;
let finalContent = '';
// Store the selection range (or cursor position)
if (hasSelection) {
selectionRange = globalEditorInstance.getSelectionRange();
// Check if selection overlaps with any index items
const expandedContent = expandSelectionToIndexItems(selectionRange);
if (expandedContent) {
finalContent = expandedContent;
hasSelection = true;
} else {
finalContent = selected;
}
} else {
// No selection - get cursor position
const pos = globalEditorInstance.getCursorPosition();
const Range = ace.require('ace/range').Range;
selectionRange = new Range(pos.row, pos.column, pos.row, pos.column);
finalContent = '';
}
// Initialize edit history with current content
editHistory = [finalContent];
currentEditIndex = 0;
// Create editor container with navigation
const editorHtml = `
<div style="display: flex; gap: 12px; height: 400px;">
<div style="flex: 1; display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: #1e293b; border-radius: 4px;">
<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="
flex: 1;
text-align: center;
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>
<button id="addEditBtn" style="
padding: 6px 12px;
background: #3d3d3d;
border: 1px solid #555;
border-radius: 4px;
color: #e0e0e0;
cursor: pointer;
font-size: 13px;
font-family: 'Segoe UI', sans-serif;
">+ New</button>
</div>
<div id="overlayEditor" style="flex: 1; border: 1px solid #555; border-radius: 4px;"></div>
</div>
</div>
`;
const buttonText = hasSelection ? '✅ Replace Selection' : '➕ Add at Cursor';
const footerHtml = `
<button id="replaceBtn" 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;
">${buttonText}</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>
`;
const title = hasSelection ? 'Edit Selection' : 'Add Content';
showOverlay(title, editorHtml, footerHtml);
// Initialize Ace editor in overlay
setTimeout(() => {
initializeOverlayEditor(hasSelection);
setupEditNavigation(hasSelection);
}, 100);
}
function expandSelectionToIndexItems(range) {
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*(.+?)</);
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*(.+?)>/);
if (closeMatch && closeMatch[1].trim() === markerName) {
allSections.push({
startRow: row,
endRow: closeRow,
length: closeRow - row,
type: 'marker',
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;
allSections.push({
startRow: foldRange.start.row,
endRow: foldRange.end.row,
length: foldRange.end.row - foldRange.start.row,
type: 'fold',
name: session.getLine(foldRange.start.row).trim().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 (typeof showToast === 'function') {
const label = smallest.type === 'marker'
? `marker: ${smallest.name}`
: smallest.name;
showToast(`📦 Expanded to ${label}...`, 'info', 3000);
}
return lines.join('\n');
}
function findContainingMarker(startRow, endRow) {
// This function is no longer needed, integrated above
return null;
}
function initializeOverlayEditor(hasSelection) {
const overlayEditorContainer = el.querySelector('#overlayEditor');
if (!overlayEditorContainer) 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();
}
function setupEditNavigation(hasSelection) {
const prevBtn = el.querySelector('#prevEditBtn');
const nextBtn = el.querySelector('#nextEditBtn');
const addBtn = el.querySelector('#addEditBtn');
const indexDisplay = el.querySelector('#editIndexDisplay');
const replaceBtn = el.querySelector('#replaceBtn');
const cancelBtn = el.querySelector('#cancelEditBtn');
// Update display
updateEditNavigation();
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 (replaceBtn) {
replaceBtn.addEventListener('click', () => handleReplaceSelection(hasSelection));
}
if (cancelBtn) {
cancelBtn.addEventListener('click', hideOverlay);
}
}
function saveCurrentEdit() {
if (overlayEditorInstance) {
editHistory[currentEditIndex] = overlayEditorInstance.getValue();
}
}
function loadCurrentEdit() {
if (overlayEditorInstance) {
overlayEditorInstance.setValue(editHistory[currentEditIndex], -1);
overlayEditorInstance.focus();
}
}
function updateEditNavigation() {
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}`;
}
}
function handleReplaceSelection(hasSelection) {
if (!globalEditorInstance || !overlayEditorInstance || !selectionRange) 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 (hasSelection) {
// Replace the selection
globalEditorInstance.session.replace(selectionRange, allContent);
if (typeof showToast === 'function') {
showToast('✅ Selection replaced', 'success');
}
} else {
// Insert at cursor with line break before
const contentToInsert = '\n' + allContent;
globalEditorInstance.session.insert(selectionRange.start, contentToInsert);
if (typeof showToast === 'function') {
showToast('✅ Content added', 'success');
}
}
hideOverlay();
globalEditorInstance.focus();
}
// === INDEX FUNCTIONS ===
function generateDocumentIndex() {
if (!globalEditorInstance) return [];
const session = globalEditorInstance.getSession();
const lineCount = session.getLength();
const index = [];
for (let row = 0; row < lineCount; row++) {
const line = session.getLine(row);
const trimmed = line.trim();
if (!trimmed) continue;
let label = '';
let icon = '';
let indent = 0;
let match;
let isMarker = false;
// Opening markers - check for < symbol in comments
// Matches: <!--anything<-->, /*anything<*/, //anything<, ///anything<
if (trimmed.includes('<') && (trimmed.includes('<!--') || trimmed.includes('/*') || trimmed.includes('//'))) {
// Extract text before the < symbol
let markerMatch = trimmed.match(/(?:<!--|\/\*|\/\/\/|\/\/)\s*(.+?)</);
if (markerMatch) {
label = `[${markerMatch[1].trim()}]`;
icon = '🏷️';
indent = 0;
isMarker = true;
}
}
// HTML tags
else 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];
label = id ? `#${id}` : (className ? `.${className.split(' ')[0]}` : `<${tag}>`);
icon = '📦';
indent = 0;
}
// JavaScript functions (function keyword or arrow functions)
else if ((match = trimmed.match(/(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:function|\([^)]*\)\s*=>))/))) {
const funcName = match[1] || match[2];
label = `${funcName}()`;
icon = '⚙️';
indent = 1;
}
// PHP functions
else if ((match = trimmed.match(/(?:public|private|protected|static)?\s*function\s+(\w+)\s*\(/))) {
label = `${match[1]}()`;
icon = '🔧';
indent = 1;
}
// CSS classes (with dot)
else if ((match = trimmed.match(/^\.([a-zA-Z0-9_-]+)\s*\{/))) {
label = `.${match[1]}`;
icon = '🎨';
indent = 1;
}
// CSS element selectors (body, html, h1, etc.)
else if ((match = trimmed.match(/^(body|html|header|footer|main|section|nav|article|aside|h[1-6]|p|div|span|a|button|input|form)\s*\{/i))) {
label = match[1];
icon = '🎨';
indent = 1;
}
if (label) {
index.push({
row,
label,
icon,
indent,
isMarker,
preview: trimmed.substring(0, 60) + (trimmed.length > 60 ? '...' : '')
});
}
}
return index;
}
function findMarkerEnd(startRow, markerName) {
if (!globalEditorInstance) return startRow;
const session = globalEditorInstance.getSession();
const lineCount = session.getLength();
// Clean marker name for matching
const cleanMarker = markerName.replace(/[\[\]]/g, '').trim();
// Look for closing marker with > symbol
for (let row = startRow + 1; row < lineCount; row++) {
const line = session.getLine(row);
// Check if line contains the marker name followed by >
if (line.includes('>')) {
const closingMatch = line.match(/(?:<!--|\/\*|\/\/\/|\/\/)\s*(.+?)>/);
if (closingMatch && closingMatch[1].trim() === cleanMarker) {
return row;
}
}
}
// If no closing found, just return start row
return startRow;
}
function showIndexOverlay() {
const index = generateDocumentIndex();
if (index.length === 0) {
showOverlay('Document Index', '<div style="color: #888; text-align: center; padding: 40px;">No sections found in document</div>');
} else {
const indexHtml = index.map(item => `
<div class="index-item" data-row="${item.row}" data-is-marker="${item.isMarker}" data-label="${escapeHtml(item.label)}" style="
padding: 10px 12px;
margin: 4px 0;
background: #3d3d3d;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
padding-left: ${12 + (item.indent * 20)}px;
font-family: 'Segoe UI', monospace;
">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 16px;">${item.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; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">Line ${item.row + 1}</div>
</div>
</div>
</div>
`).join('');
showOverlay('Document Index', indexHtml);
// Add hover effect and click handlers
setTimeout(() => {
const contentEl = el.querySelector('#overlayContent');
if (contentEl) {
contentEl.querySelectorAll('.index-item').forEach(item => {
item.addEventListener('mouseenter', () => {
item.style.background = '#4a5568';
});
item.addEventListener('mouseleave', () => {
item.style.background = '#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);
}
hideOverlay();
});
});
}
}, 50);
}
}
function navigateToMarker(startRow, label) {
if (!globalEditorInstance) return;
const session = globalEditorInstance.getSession();
const Range = ace.require('ace/range').Range;
// Find the closing marker
const endRow = findMarkerEnd(startRow, label);
// Select from start marker to end marker (entire section)
const range = new Range(
startRow,
0,
endRow,
session.getLine(endRow).length
);
globalEditorInstance.selection.setRange(range, false);
globalEditorInstance.scrollToLine(startRow, true, true, () => {});
globalEditorInstance.focus();
}
function navigateToRow(row) {
if (!globalEditorInstance) return;
const session = globalEditorInstance.getSession();
const Range = ace.require('ace/range').Range;
// Get the fold range for this row
const foldRange = session.getFoldWidgetRange(row);
if (foldRange) {
// Select entire fold
const extended = new Range(
foldRange.start.row,
0,
foldRange.end.row,
session.getLine(foldRange.end.row).length
);
globalEditorInstance.selection.setRange(extended, false);
} else {
// Just select the line
const line = session.getLine(row);
const range = new Range(row, 0, row, line.length);
globalEditorInstance.selection.setRange(range, false);
}
globalEditorInstance.scrollToLine(row, true, true, () => {});
globalEditorInstance.focus();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// === SEARCH FUNCTIONS ===
function clearMarkers() {
if (!globalEditorInstance) return;
const session = globalEditorInstance.getSession();
searchState.markers.forEach(id => {
try { session.removeMarker(id); } catch (e) {}
});
searchState.markers = [];
}
function markMatches() {
if (!globalEditorInstance) return;
clearMarkers();
const session = globalEditorInstance.getSession();
const Range = ace.require('ace/range').Range;
searchState.matches.forEach((m, i) => {
const r = new Range(m.r, m.s, m.r, m.e);
const cls = i === searchState.idx ? 'ace_selected-word' : 'ace_selection';
const markerId = session.addMarker(r, cls, 'text');
searchState.markers.push(markerId);
});
}
function searchInEditor(query) {
if (!globalEditorInstance || !query) {
clearMarkers();
searchState = { matches: [], idx: -1, markers: [] };
updateMatchCounter();
return;
}
const session = globalEditorInstance.getSession();
const lines = session.getDocument().getAllLines();
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
searchState.matches = [];
searchState.idx = -1;
clearMarkers();
lines.forEach((line, r) => {
let m;
regex.lastIndex = 0;
while ((m = regex.exec(line))) {
searchState.matches.push({ r, s: m.index, e: m.index + m[0].length });
}
});
if (searchState.matches.length) {
searchState.idx = 0;
gotoMatch();
}
updateMatchCounter();
}
function gotoMatch() {
if (searchState.idx < 0 || !searchState.matches.length || !globalEditorInstance) return;
const m = searchState.matches[searchState.idx];
const Range = ace.require('ace/range').Range;
const r = new Range(m.r, m.s, m.r, m.e);
globalEditorInstance.selection.setRange(r, false);
globalEditorInstance.scrollToLine(m.r, true, true, () => {});
markMatches();
updateMatchCounter();
}
function nextMatch() {
if (!searchState.matches.length) return;
searchState.idx = (searchState.idx + 1) % searchState.matches.length;
gotoMatch();
}
function prevMatch() {
if (!searchState.matches.length) return;
searchState.idx = (searchState.idx - 1 + searchState.matches.length) % searchState.matches.length;
gotoMatch();
}
function updateMatchCounter() {
const counter = el.querySelector('#matchCounter');
if (counter) {
if (searchState.matches.length > 0) {
counter.textContent = `${searchState.idx + 1} / ${searchState.matches.length}`;
} else {
counter.textContent = '';
}
}
}
// === FOLD/SCOPE SELECTION FUNCTIONS ===
function getAllFoldsForRowTokenAware(targetRow) {
if (!globalEditorInstance) return [];
const session = globalEditorInstance.getSession();
const lineCount = session.getLength();
const stack = [];
const allPairs = [];
const isCodeToken = (type) => !/comment|string|regex/i.test(type);
for (let row = 0; row < lineCount; row++) {
const tokens = session.getTokens(row);
let col = 0;
for (const tok of tokens) {
const { type, value } = tok;
if (isCodeToken(type)) {
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (ch === '{') stack.push({ row, col: col + i });
else if (ch === '}') {
const open = stack.pop();
if (open) {
allPairs.push({
startRow: open.row,
startCol: open.col,
endRow: row,
endCol: col + i,
});
}
}
}
}
col += value.length;
}
}
const cursor = globalEditorInstance.getCursorPosition();
const containsCursor = (p) => {
if (cursor.row < p.startRow || cursor.row > p.endRow) return false;
if (cursor.row === p.startRow && cursor.column <= p.startCol) return false;
if (cursor.row === p.endRow && cursor.column >= p.endCol) return false;
return true;
};
const filtered = allPairs.filter(containsCursor);
filtered.sort((a, b) => (a.endRow - a.startRow) - (b.endRow - b.startRow));
return filtered.map((p) => ({ start: p.startRow, end: p.endRow }));
}
function selectFold() {
if (!globalEditorInstance) return;
const pos = globalEditorInstance.getCursorPosition();
const session = globalEditorInstance.getSession();
const R = ace.require('ace/range').Range;
if (!lastCursorPos || lastCursorPos.row !== pos.row) {
cachedFolds = getAllFoldsForRowTokenAware(pos.row);
lastFoldIndex = -1;
lastCursorPos = { row: pos.row, column: pos.column };
}
if (lastFoldIndex === -1) {
const line = session.getLine(pos.row);
const range = new R(pos.row, 0, pos.row, line.length);
globalEditorInstance.selection.setRange(range, false);
globalEditorInstance.focus();
lastFoldIndex = 0;
return;
}
if (lastFoldIndex < cachedFolds.length) {
const fold = cachedFolds[lastFoldIndex];
const range = new R(fold.start, 0, fold.end, session.getLine(fold.end).length);
globalEditorInstance.selection.setRange(range, false);
globalEditorInstance.scrollToLine(fold.start, true, true, () => {});
globalEditorInstance.focus();
lastFoldIndex++;
if (lastFoldIndex >= cachedFolds.length) lastFoldIndex = -1;
}
}
loadAce(() => {
console.log("[editor.js] Ace script loaded");
requestAnimationFrame(() => {
globalEditorInstance = ace.edit(container);
globalEditorInstance.setTheme("ace/theme/monokai");
globalEditorInstance.session.setMode("ace/mode/html");
let fileContent = '';
let fileName = 'Untitled';
let detectedMode = 'html';
try {
const files = JSON.parse(localStorage.getItem('sftp_active_files') || '[]');
const active = files.find(f => f.active);
if (active && typeof active.content === 'string') {
fileContent = active.content;
fileName = active.name;
console.log(`[editor.js] Loaded content for ${active.name}`);
}
} catch (err) {
console.warn("[editor.js] Failed to load saved file content:", err);
}
if (fileName !== 'Untitled') {
const ext = fileName.split('.').pop().toLowerCase();
const modeMap = {
php: 'php', html: 'html', htm: 'html', js: 'javascript',
css: 'css', json: 'json', py: 'python', md: 'markdown', txt: 'text'
};
detectedMode = modeMap[ext] || 'html';
globalEditorInstance.session.setMode(`ace/mode/${detectedMode}`);
}
globalEditorInstance.setValue(
fileContent.trim() !== '' ? fileContent : `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Document</title>
</head>
<body>
<h1>Hello!</h1>
</body>
</html>`, -1
);
globalEditorInstance.setOptions({
fontSize: "14px",
wrap: true,
showPrintMargin: false,
useWorker: false,
showFoldWidgets: true,
foldStyle: 'markbegin',
enableAutoIndent: true,
readOnly: true,
highlightActiveLine: false,
highlightGutterLine: false
});
globalEditorInstance.commands.removeCommand('toggleFoldWidget');
globalEditorInstance.commands.removeCommand('toggleParentFoldWidget');
globalEditorInstance.getSession().addFold = () => false;
globalEditorInstance.on('guttermousedown', function (e) {
const target = e.domEvent.target;
if (target.classList.contains('ace_fold-widget')) {
const row = e.getDocumentPosition().row;
const range = globalEditorInstance.getSession().getFoldWidgetRange(row);
e.stop();
e.stopPropagation();
e.domEvent.stopPropagation();
e.domEvent.preventDefault();
if (range) {
const Range = ace.require('ace/range').Range;
const extended = new Range(range.start.row, 0, range.end.row,
globalEditorInstance.getSession().getLine(range.end.row).length);
globalEditorInstance.selection.setRange(extended, false);
globalEditorInstance.scrollToLine(range.start.row, true, true, () => {});
globalEditorInstance.focus();
}
return true;
}
});
globalEditorInstance.getSession().on('change', () => {
cachedFolds = [];
lastCursorPos = null;
lastFoldIndex = -1;
debouncedSave(globalEditorInstance);
});
globalEditorInstance.on("blur", () => {
clearTimeout(saveTimeout);
saveToLocalStorage(globalEditorInstance);
});
const searchInput = el.querySelector('#editorSearchInput');
const prevBtn = el.querySelector('#searchPrevBtn');
const nextBtn = el.querySelector('#searchNextBtn');
const indexBtn = el.querySelector('#indexBtn');
const editSelectionBtn = el.querySelector('#editSelectionBtn');
const closeOverlayBtn = el.querySelector('#closeOverlayBtn');
const multiOverlay = el.querySelector('#multiOverlay');
if (indexBtn) {
indexBtn.addEventListener('click', showIndexOverlay);
}
if (editSelectionBtn) {
editSelectionBtn.addEventListener('click', showEditSelectionOverlay);
}
if (closeOverlayBtn) {
closeOverlayBtn.addEventListener('click', hideOverlay);
}
if (multiOverlay) {
multiOverlay.addEventListener('click', (e) => {
if (e.target === multiOverlay) {
hideOverlay();
}
});
}
if (searchInput) {
searchInput.addEventListener('input', (e) => searchInEditor(e.target.value));
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.shiftKey ? prevMatch() : nextMatch();
} else if (e.key === 'Escape') {
searchInput.value = '';
searchInEditor('');
globalEditorInstance.focus();
}
});
}
if (prevBtn) prevBtn.addEventListener('click', () => { prevMatch(); globalEditorInstance.focus(); });
if (nextBtn) nextBtn.addEventListener('click', () => { nextMatch(); globalEditorInstance.focus(); });
globalEditorInstance.commands.addCommand({
name: 'focusSearch',
bindKey: {win: 'Ctrl-F', mac: 'Command-F'},
exec: () => { searchInput?.focus(); searchInput?.select(); }
});
globalEditorInstance.commands.addCommand({
name: 'showIndex',
bindKey: {win: 'Ctrl-I', mac: 'Command-I'},
exec: showIndexOverlay
});
globalEditorInstance.commands.addCommand({
name: 'selectScopeUp',
bindKey: {win: 'Alt-Up', mac: 'Alt-Up'},
exec: selectFold
});
globalEditorInstance.commands.addCommand({
name: 'selectScopeDown',
bindKey: {win: 'Alt-Down', mac: 'Alt-Down'},
exec: selectFold
});
fitToOverlayBody();
globalEditorInstance.resize(true);
window.addEventListener("resize", () => { fitToOverlayBody(); globalEditorInstance.resize(true); });
});
});
}
};
window.AppItems.push(section);
if (!window.AppOverlayMenuItems) window.AppOverlayMenuItems = [];
window.AppOverlayMenuItems.push({
label: "Toggle Edit Mode",
action: () => {
if (!globalEditorInstance) return;
const isReadOnly = globalEditorInstance.getReadOnly();
globalEditorInstance.setReadOnly(!isReadOnly);
globalEditorInstance.setOptions({
highlightActiveLine: !isReadOnly,
highlightGutterLine: !isReadOnly
});
if (typeof showToast === 'function') {
showToast(isReadOnly ? '✏️ Editor now editable' : '🔒 Editor now read-only', 'success');
}
}
});
window.AppOverlayMenuItems.push({
label: "Add Marker",
action: () => {
if (!globalEditorInstance) return;
const selected = globalEditorInstance.getSelectedText();
if (!selected) {
if (typeof showToast === 'function') showToast('⚠️ Select some text first!', 'error');
return;
}
const markerName = prompt("Enter marker name:");
if (markerName && markerName.trim()) wrapSelectionWithSmartMarker(markerName.trim());
}
});
window.AppOverlayMenuItems.push({
label: "Language",
submenu: [
{ label: "HTML", action: () => {
if (globalEditorInstance) {
globalEditorInstance.session.setMode('ace/mode/html');
if (typeof showToast === 'function') showToast('✅ Switched to HTML', 'success');
}}},
{ label: "PHP", action: () => {
if (globalEditorInstance) {
globalEditorInstance.session.setMode('ace/mode/php');
if (typeof showToast === 'function') showToast('✅ Switched to PHP', 'success');
}}},
{ label: "JavaScript", action: () => {
if (globalEditorInstance) {
globalEditorInstance.session.setMode('ace/mode/javascript');
if (typeof showToast === 'function') showToast('✅ Switched to JavaScript', 'success');
}}},
{ label: "CSS", action: () => {
if (globalEditorInstance) {
globalEditorInstance.session.setMode('ace/mode/css');
if (typeof showToast === 'function') showToast('✅ Switched to CSS', 'success');
}}},
{ label: "JSON", action: () => {
if (globalEditorInstance) {
globalEditorInstance.session.setMode('ace/mode/json');
if (typeof showToast === 'function') showToast('✅ Switched to JSON', 'success');
}}},
{ label: "Markdown", action: () => {
if (globalEditorInstance) {
globalEditorInstance.session.setMode('ace/mode/markdown');
if (typeof showToast === 'function') showToast('✅ Switched to Markdown', 'success');
}}},
{ label: "Python", action: () => {
if (globalEditorInstance) {
globalEditorInstance.session.setMode('ace/mode/python');
if (typeof showToast === 'function') showToast('✅ Switched to Python', 'success');
}}},
{ label: "Plain Text", action: () => {
if (globalEditorInstance) {
globalEditorInstance.session.setMode('ace/mode/text');
if (typeof showToast === 'function') showToast('✅ Switched to Plain Text', 'success');
}}}
]
});
if (window.AppOverlay && typeof window.AppOverlay.close === "function") {
const originalClose = window.AppOverlay.close;
window.AppOverlay.close = function(...args) {
clearTimeout(saveTimeout);
if (globalEditorInstance) {
const saved = saveToLocalStorage(globalEditorInstance);
if (saved && typeof showToast === "function") {
const files = JSON.parse(localStorage.getItem('sftp_active_files') || '[]');
const active = files.find(f => f.active);
if (active) showToast(`💾 Saved ${active.name}`, "success");
}
}
return originalClose.apply(this, args);
};
}
} catch (err) {
console.error("[editor.js] Fatal error:", err);
}
})();