// Storage Editor - Scopes Module v2 (scopes.js)
// Block-based visual editor with containers, scopes, and unmarked blocks
(function() {
console.log('đ Loading Scopes Block Editor v2...');
// Language styles
const LANG_STYLES = {
javascript: { color: '#f7df1e', bg: 'rgba(247, 223, 30, 0.1)', icon: 'đ¨', label: 'JS' },
css: { color: '#264de4', bg: 'rgba(38, 77, 228, 0.1)', icon: 'đĻ', label: 'CSS' },
php: { color: '#8892bf', bg: 'rgba(136, 146, 191, 0.1)', icon: 'đĒ', label: 'PHP' },
html: { color: '#e34c26', bg: 'rgba(227, 76, 38, 0.1)', icon: 'đ§', label: 'HTML' },
python: { color: '#3776ab', bg: 'rgba(55, 118, 171, 0.1)', icon: 'đ', label: 'PY' },
text: { color: '#64748b', bg: 'rgba(100, 116, 139, 0.1)', icon: 'đ', label: 'TXT' }
};
const UNMARKED_STYLE = {
color: '#6b7280',
bg: 'rgba(55, 65, 81, 0.05)',
border: '2px dashed #374151',
icon: 'đ',
label: 'UNMARKED'
};
const CONTAINER_STYLE = {
color: '#8b5cf6',
bg: 'rgba(139, 92, 246, 0.05)',
border: '3px solid #8b5cf6',
icon: 'đĻ',
headerBg: '#7c3aed'
};
function getLanguageStyle(lang) {
return LANG_STYLES[lang] || LANG_STYLES.text;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Parse scope attributes: @key:value@ @key:value@
function parseScopeAttributes(line) {
const attributes = {};
const attrPattern = /@([a-zA-Z0-9_-]+):([^@]+)@/g;
let match;
while ((match = attrPattern.exec(line)) !== null) {
attributes[match[1]] = match[2].trim();
}
return Object.keys(attributes).length > 0 ? attributes : null;
}
// Parse snippet header: @@container-name[position] | language | action@@
// NOTE: This is ONLY for AI chat snippets, NOT for full file parsing
function parseSnippetHeader(line) {
const match = line.match(/^@@([a-z0-9-]+)\[(\d+)\]\s*\|\s*(\w+)\s*\|\s*(new|edit)@@$/i);
if (!match) return null;
return {
container: match[1],
position: parseInt(match[2]),
language: match[3].toLowerCase(),
action: match[4].toLowerCase()
};
}
// Detect language from context AND scope name
function detectLanguage(lines, startLine, endLine, scopeName) {
// 1. Check scope name for language hints
if (scopeName) {
const nameLower = scopeName.toLowerCase();
if (/-js[-\d]|javascript/i.test(nameLower)) return 'javascript';
if (/-css[-\d]|styles?/i.test(nameLower)) return 'css';
if (/-php[-\d]/i.test(nameLower)) return 'php';
if (/-html[-\d]/i.test(nameLower)) return 'html';
if (/-py[-\d]|python/i.test(nameLower)) return 'python';
}
// 2. Check surrounding context
let inScript = false, inStyle = false, inPhp = false;
for (let i = 0; i <= startLine; i++) {
const line = lines[i];
if (/<script[^>]*>/i.test(line)) inScript = true;
if (/<\/script>/i.test(line)) inScript = false;
if (/<style[^>]*>/i.test(line)) inStyle = true;
if (/<\/style>/i.test(line)) inStyle = false;
if (/<\?php/i.test(line)) inPhp = true;
if (/\?>/i.test(line)) inPhp = false;
}
if (inScript) return 'javascript';
if (inStyle) return 'css';
if (inPhp) return 'php';
// 3. Check content patterns
const content = lines.slice(startLine, endLine + 1).join('\n');
if (/<\?php/i.test(content)) return 'php';
if (/<script/i.test(content)) return 'javascript';
if (/<style/i.test(content)) return 'css';
if (/<[a-z]+/i.test(content)) return 'html';
// 4. Check for JS/CSS syntax patterns
if (/\bfunction\b|\bconst\b|\blet\b|\bvar\b|=>|\bconsole\./i.test(content)) return 'javascript';
if (/\{[^}]*:[^}]*;[^}]*\}|@media|\.[\w-]+\s*\{/i.test(content)) return 'css';
return 'text';
}
// Parse scopes and containers from file content
function parseScopes(content) {
if (!content) return { scopes: [], containers: [] };
const lines = content.split('\n');
const scopes = [];
const containers = [];
const stack = [];
const containerStack = [];
// Track the last seen snippet header (@@container[pos] | lang | action@@)
let lastSnippetHeader = null;
let lastSnippetHeaderLine = -1;
// Patterns for containers
const containerOpenPatterns = [
/\/\/\s*([a-z0-9-]+):\s*container<\s*$/,
/\/\*\s*([a-z0-9-]+):\s*container<\s*\*\//,
/<!--\s*([a-z0-9-]+):\s*container<\s*-->/,
/#\s*([a-z0-9-]+):\s*container<\s*$/
];
// Patterns for regular scopes
const openPatterns = [
/\/\/\s*([a-z0-9-]+)<\s*/,
/\/\*\s*([a-z0-9-]+)<\s*\*\//,
/<!--\s*([a-z0-9-]+)<\s*/,
/#\s*([a-z0-9-]+)<\s*/
];
lines.forEach((line, idx) => {
const trimmedLine = line.trim();
// Check if this line is a snippet header
const snippetHeader = parseSnippetHeader(trimmedLine);
if (snippetHeader) {
lastSnippetHeader = snippetHeader;
lastSnippetHeaderLine = idx;
return; // Don't process this line further
}
// Check for container opening
for (const pattern of containerOpenPatterns) {
const match = line.match(pattern);
if (match) {
containerStack.push({ name: match[1], startLine: idx });
break;
}
}
// Check for container closing
if (containerStack.length > 0) {
const current = containerStack[containerStack.length - 1];
const closePatterns = [
new RegExp(`\\/\\/\\s*${current.name}:\\s*container>\\s*$`),
new RegExp(`\\/\\*\\s*${current.name}:\\s*container>\\s*\\*\\/`),
new RegExp(`<!--\\s*${current.name}:\\s*container>\\s*-->`),
new RegExp(`#\\s*${current.name}:\\s*container>\\s*$`)
];
for (const pattern of closePatterns) {
if (pattern.test(line)) {
current.endLine = idx;
containers.push(current);
containerStack.pop();
break;
}
}
}
// Check for scope opening
for (const pattern of openPatterns) {
const match = line.match(pattern);
if (match) {
const scopeData = {
name: match[1],
startLine: idx,
container: containerStack.length > 0 ? containerStack[containerStack.length - 1].name : null,
header: null,
attributes: parseScopeAttributes(line) // Parse @key:value@ attributes
};
// Check if we have a recent snippet header (within 5 lines)
if (lastSnippetHeader && (idx - lastSnippetHeaderLine) <= 5) {
scopeData.header = lastSnippetHeader;
scopeData.startLine = lastSnippetHeaderLine; // Include header in scope
console.log(`[parseScopes] Found snippet header at line ${lastSnippetHeaderLine} for scope "${match[1]}" at line ${idx}`);
// Clear the header so it's not reused
lastSnippetHeader = null;
lastSnippetHeaderLine = -1;
}
stack.push(scopeData);
break;
}
}
// Check for scope closing
if (stack.length > 0) {
const current = stack[stack.length - 1];
const closePatterns = [
new RegExp(`\\/\\/\\s*${current.name}>\\s*$`),
new RegExp(`\\/\\*\\s*${current.name}>\\s*\\*\\/`),
new RegExp(`<!--\\s*${current.name}>\\s*-->`),
new RegExp(`#\\s*${current.name}>\\s*$`)
];
for (const pattern of closePatterns) {
if (pattern.test(line)) {
current.endLine = idx;
current.lineCount = current.endLine - current.startLine + 1;
current.language = detectLanguage(lines, current.startLine, current.endLine, current.name);
scopes.push(current);
stack.pop();
break;
}
}
}
});
return { scopes, containers };
}
// Build hierarchical block structure with unmarked blocks
function buildBlockStructure(content) {
const lines = content.split('\n');
const parsed = parseScopes(content);
const { scopes, containers } = parsed;
// Create a map of all marked line ranges
const markedRanges = [];
// Add container ranges
containers.forEach(c => {
markedRanges.push({
type: 'container',
start: c.startLine,
end: c.endLine,
data: c
});
});
// Add scope ranges
scopes.forEach(s => {
markedRanges.push({
type: 'scope',
start: s.startLine,
end: s.endLine,
data: s
});
});
// Sort by start line
markedRanges.sort((a, b) => a.start - b.start);
// Build block structure
const blocks = [];
let currentLine = 0;
markedRanges.forEach(range => {
// Add unmarked block before this range if there's a gap
if (currentLine < range.start) {
const content = lines.slice(currentLine, range.start).join('\n');
const trimmedContent = content.trim();
// Only add unmarked block if it has actual content (not just whitespace)
if (trimmedContent.length > 0) {
blocks.push({
type: 'unmarked',
startLine: currentLine,
endLine: range.start - 1,
content: content,
container: null
});
}
}
// Add the marked range
if (range.type === 'container') {
const container = range.data;
const containerScopes = scopes.filter(s => s.container === container.name);
const containerBlocks = [];
let containerCurrentLine = container.startLine + 1; // Skip opening marker
containerScopes.forEach(scope => {
// Add unmarked block inside container before scope
if (containerCurrentLine < scope.startLine) {
const content = lines.slice(containerCurrentLine, scope.startLine).join('\n');
const trimmedContent = content.trim();
// Only add if it has actual content
if (trimmedContent.length > 0) {
containerBlocks.push({
type: 'unmarked',
startLine: containerCurrentLine,
endLine: scope.startLine - 1,
content: content,
container: container.name
});
}
}
// Add scope block
containerBlocks.push({
type: 'scope',
startLine: scope.startLine,
endLine: scope.endLine,
content: lines.slice(scope.startLine + 1, scope.endLine).join('\n'), // Exclude markers
data: scope,
container: container.name
});
containerCurrentLine = scope.endLine + 1;
});
// Add trailing unmarked block inside container
if (containerCurrentLine < container.endLine) {
const content = lines.slice(containerCurrentLine, container.endLine).join('\n');
const trimmedContent = content.trim();
// Only add if it has actual content
if (trimmedContent.length > 0) {
containerBlocks.push({
type: 'unmarked',
startLine: containerCurrentLine,
endLine: container.endLine - 1,
content: content,
container: container.name
});
}
}
blocks.push({
type: 'container',
startLine: container.startLine,
endLine: container.endLine,
data: container,
children: containerBlocks
});
currentLine = container.endLine + 1;
} else if (range.type === 'scope' && !range.data.container) {
// Top-level scope (not in a container)
blocks.push({
type: 'scope',
startLine: range.start,
endLine: range.end,
content: lines.slice(range.start + 1, range.end).join('\n'), // Exclude markers
data: range.data,
container: null
});
currentLine = range.end + 1;
}
});
// Add trailing unmarked block
if (currentLine < lines.length) {
const content = lines.slice(currentLine).join('\n');
const trimmedContent = content.trim();
// Only add if it has actual content
if (trimmedContent.length > 0) {
blocks.push({
type: 'unmarked',
startLine: currentLine,
endLine: lines.length - 1,
content: content,
container: null
});
}
}
return blocks;
}
// Render a scope block
function renderScopeBlock(block, blockId, isInChat) {
const style = getLanguageStyle(block.data.language);
const lineRange = `Lines ${block.startLine + 1}-${block.endLine + 1}`;
// Build metadata badges for pull-up menu
let metadataBadges = '';
// Show container badge
if (block.data.header) {
const h = block.data.header;
const actionIcon = h.action === 'new' ? 'â¨' : 'âī¸';
metadataBadges += `
<div style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between;">
<span style="color: #6b7280; font-size: 11px; font-weight: 600;">Container:</span>
<span style="color: #111827; font-size: 11px; font-family: monospace;">${escapeHtml(h.container)}</span>
</div>
<div style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between;">
<span style="color: #6b7280; font-size: 11px; font-weight: 600;">Position:</span>
<span style="color: #111827; font-size: 11px; font-family: monospace;">#${h.position}</span>
</div>
<div style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between;">
<span style="color: #6b7280; font-size: 11px; font-weight: 600;">Action:</span>
<span style="color: #111827; font-size: 11px; font-family: monospace;">${actionIcon} ${h.action.toUpperCase()}</span>
</div>
`;
} else if (block.data.container) {
metadataBadges += `
<div style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between;">
<span style="color: #6b7280; font-size: 11px; font-weight: 600;">Container:</span>
<span style="color: #111827; font-size: 11px; font-family: monospace;">${escapeHtml(block.data.container)}</span>
</div>
`;
}
// Add language badge
metadataBadges += `
<div style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between;">
<span style="color: #6b7280; font-size: 11px; font-weight: 600;">Language:</span>
<span style="color: #111827; font-size: 11px; font-family: monospace;">${style.label}</span>
</div>
`;
// Add attribute badges
if (block.data.attributes) {
const attrs = block.data.attributes;
if (attrs.editedBy) {
metadataBadges += `
<div style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between;">
<span style="color: #6b7280; font-size: 11px; font-weight: 600;">Edited By:</span>
<span style="color: #111827; font-size: 11px; font-family: monospace;">${escapeHtml(attrs.editedBy)}</span>
</div>
`;
}
if (attrs.editedAt) {
const timestamp = attrs.editedAt;
let displayTime = timestamp;
try {
const date = new Date(parseInt(timestamp));
if (!isNaN(date.getTime())) {
displayTime = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
}
} catch (e) {}
metadataBadges += `
<div style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between;">
<span style="color: #6b7280; font-size: 11px; font-weight: 600;">Edited At:</span>
<span style="color: #111827; font-size: 11px;">${escapeHtml(displayTime)}</span>
</div>
`;
}
Object.keys(attrs).forEach(key => {
if (key !== 'editedBy' && key !== 'editedAt') {
metadataBadges += `
<div style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between;">
<span style="color: #6b7280; font-size: 11px; font-weight: 600;">${escapeHtml(key)}:</span>
<span style="color: #111827; font-size: 11px; font-family: monospace;">${escapeHtml(attrs[key])}</span>
</div>
`;
}
});
}
// Add line range for block editor
if (!isInChat) {
metadataBadges += `
<div style="padding: 8px 12px; display: flex; justify-content: space-between;">
<span style="color: #6b7280; font-size: 11px; font-weight: 600;">Lines:</span>
<span style="color: #111827; font-size: 11px; font-family: monospace;">${lineRange}</span>
</div>
`;
}
// Build action button for chat snippets
let actionFooter = '';
if (isInChat && block.data.header) {
const h = block.data.header;
const btnText = h.action === 'new' ? '⨠INSERT INTO FILE' : 'âī¸ REPLACE IN FILE';
const btnId = `action-btn-${blockId}`;
actionFooter = `
<div style="padding: 12px; background: #ffffff; border-top: 2px solid ${style.color};">
<button id="${btnId}" class="scope-action-btn" style="
background: #ffffff;
color: #111827;
border: 2px solid #111827;
padding: 12px 24px;
border-radius: 6px;
cursor: pointer;
font-weight: 700;
font-size: 12px;
width: 100%;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: all 0.2s;
"
data-action="${h.action}"
data-container="${escapeHtml(h.container)}"
data-position="${h.position}"
data-scope-name="${escapeHtml(block.data.name)}"
data-language="${escapeHtml(h.language)}"
data-block-id="${blockId}"
onmouseover="this.style.background='#111827'; this.style.color='#ffffff'"
onmouseout="this.style.background='#ffffff'; this.style.color='#111827'"
>${btnText}</button>
</div>
`;
}
const metadataMenuId = `metadata-menu-${blockId}`;
return `
<div class="block-scope" data-block-id="${blockId}" style="
position: relative;
display: flex;
flex-direction: column;
margin-bottom: 16px;
border-radius: 6px;
overflow: hidden;
border: 2px solid ${style.color};
background: #ffffff;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
">
<!-- Wireframe Header -->
<div style="
background: #ffffff;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2px solid ${style.color};
">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">${style.icon}</span>
<div style="
font-weight: 700;
color: #111827;
font-family: monospace;
font-size: 13px;
letter-spacing: 0.3px;
">${escapeHtml(block.data.name)}</div>
<div style="
background: ${style.bg};
border: 1px solid ${style.color};
padding: 2px 8px;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
color: #111827;
text-transform: uppercase;
">${style.label}</div>
</div>
<button
onclick="(function(e) {
e.stopPropagation();
const menu = document.getElementById('${metadataMenuId}');
const isVisible = menu.style.display === 'block';
menu.style.display = isVisible ? 'none' : 'block';
})(event)"
style="
background: #ffffff;
border: 1px solid #111827;
color: #111827;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: 600;
transition: all 0.2s;
"
onmouseover="this.style.background='#f3f4f6'"
onmouseout="this.style.background='#ffffff'"
>âšī¸ Info</button>
</div>
<!-- Pull-up Metadata Menu -->
<div id="${metadataMenuId}" style="
display: none;
position: fixed;
bottom: ${isInChat ? '100px' : '20px'};
left: 50%;
transform: translateX(-50%);
width: 90%;
max-width: 500px;
background: #ffffff;
border: 2px solid ${style.color};
border-radius: 8px;
box-shadow: 0 20px 50px rgba(0,0,0,0.3);
z-index: 2147483647;
max-height: 400px;
overflow-y: auto;
">
${metadataBadges}
</div>
<!-- Content Area -->
<textarea class="block-content" data-block-id="${blockId}" style="
width: 100%;
height: 300px;
background: #ffffff;
color: #111827;
border: none;
padding: 16px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
line-height: 1.6;
resize: none;
outline: none;
overflow-y: auto;
box-sizing: border-box;
${isInChat ? 'pointer-events: none; user-select: text;' : ''}
">${escapeHtml(block.content)}</textarea>
<!-- Action Footer (chat only) -->
${actionFooter}
</div>
`;
}
// Render an unmarked block
function renderUnmarkedBlock(block, blockId) {
const lineRange = `Lines ${block.startLine + 1}-${block.endLine + 1}`;
const lineCount = block.endLine - block.startLine + 1;
return `
<div class="block-unmarked" data-block-id="${blockId}" style="
position: relative;
display: flex;
flex-direction: column;
margin-bottom: 16px;
border-radius: 6px;
overflow: hidden;
border: 2px dashed #6b7280;
background: #ffffff;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
opacity: 0.8;
">
<!-- Header -->
<div style="
background: #ffffff;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2px dashed #6b7280;
">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">đ</span>
<div style="
font-weight: 700;
color: #111827;
font-family: monospace;
font-size: 13px;
letter-spacing: 0.3px;
">UNMARKED BLOCK</div>
${block.container ? `
<div style="
background: rgba(139, 92, 246, 0.1);
border: 1px solid #8b5cf6;
padding: 2px 8px;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
color: #111827;
text-transform: uppercase;
">${escapeHtml(block.container)}</div>
` : ''}
</div>
<div style="
font-size: 11px;
color: #6b7280;
font-family: monospace;
">${lineCount} lines</div>
</div>
<!-- Content Area -->
<textarea class="block-content" data-block-id="${blockId}" style="
width: 100%;
height: 300px;
background: #ffffff;
color: #111827;
border: none;
padding: 16px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
line-height: 1.6;
resize: none;
outline: none;
overflow-y: auto;
box-sizing: border-box;
">${escapeHtml(block.content)}</textarea>
</div>
`;
}
// Render a container block
// Render a container block
function renderContainerBlock(block, blockId) {
const lineRange = `Lines ${block.startLine + 1}-${block.endLine + 1}`;
let childrenHtml = '';
block.children.forEach((child, idx) => {
const childId = `${blockId}-child-${idx}`;
if (child.type === 'scope') {
childrenHtml += renderScopeBlock(child, childId, false);
} else if (child.type === 'unmarked') {
childrenHtml += renderUnmarkedBlock(child, childId);
}
});
return `
<div class="block-container" data-block-id="${blockId}" style="
margin-bottom: 20px;
border-radius: 8px;
overflow: hidden;
border: 3px solid #8b5cf6;
background: #ffffff;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
">
<!-- Container Header -->
<div class="container-header" data-block-id="${blockId}" style="
background: #ffffff;
padding: 14px 20px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
border-bottom: 3px solid #8b5cf6;
">
<div style="font-weight: 700; color: #111827; display: flex; align-items: center; gap: 12px; font-size: 16px;">
<span class="container-toggle" style="font-size: 14px; color: #111827;">âŧ</span>
<span style="font-size: 20px;">đĻ</span>
<span style="font-family: monospace; text-transform: uppercase; letter-spacing: 0.5px;">
${escapeHtml(block.data.name)}
</span>
<span style="
background: rgba(139, 92, 246, 0.1);
border: 1px solid #8b5cf6;
padding: 3px 10px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
color: #111827;
">${block.children.length} blocks</span>
</div>
<div style="font-size: 11px; color: #6b7280; font-weight: 600; font-family: monospace;">
${lineRange}
</div>
</div>
<!-- Container Body -->
<div class="container-body" data-block-id="${blockId}" style="
padding: 16px;
background: #fafafa;
">
${childrenHtml}
</div>
</div>
`;
}
// Create main block editor HTML
function createBlockEditorHTML() {
// Check for dependencies
if (!window.StorageEditor) {
return `
<div style="padding: 40px; text-align: center; color: #ef4444;">
<h2>â ī¸ Storage Editor Not Loaded</h2>
<p>The core Storage Editor module must be loaded first.</p>
</div>
`;
}
const file = window.StorageEditor.getActiveFile();
if (!file) {
return '<div style="padding: 40px; text-align: center; color: #64748b;">đ No file open</div>';
}
const blocks = buildBlockStructure(file.content);
let html = `
<div style="
height: 100%;
display: flex;
flex-direction: column;
background: #0a0a0a;
">
<!-- Toolbar -->
<div style="
background: #1a1a1a;
padding: 12px 20px;
border-bottom: 2px solid #2a2a2a;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
">
<div style="display: flex; align-items: center; gap: 12px;">
<h2 style="margin: 0; color: #e6edf3; font-size: 18px; font-weight: 700;">
đĻ Block Editor
</h2>
<span style="color: #64748b; font-size: 14px;">
${blocks.length} top-level blocks
</span>
</div>
<button id="saveAllBlocks" style="
background: #10b981;
color: #fff;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 700;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
">đž Save All</button>
</div>
<!-- Blocks Container -->
<div id="blocksContainer" style="
flex: 1;
overflow-y: auto;
padding: 20px;
">
`;
blocks.forEach((block, idx) => {
const blockId = `block-${idx}`;
if (block.type === 'container') {
html += renderContainerBlock(block, blockId);
} else if (block.type === 'scope') {
html += renderScopeBlock(block, blockId);
} else if (block.type === 'unmarked') {
html += renderUnmarkedBlock(block, blockId);
}
});
html += `
</div>
</div>
`;
return html;
}
// Setup interactions
function setupBlockEditorInteractions(container) {
// Check for dependencies
if (!window.StorageEditor) {
console.error('StorageEditor not available');
return;
}
const file = window.StorageEditor.getActiveFile();
if (!file) return;
const blocks = buildBlockStructure(file.content);
// Container collapse/expand
container.querySelectorAll('.container-header').forEach(header => {
header.addEventListener('click', () => {
const blockId = header.dataset.blockId;
const body = container.querySelector(`.container-body[data-block-id="${blockId}"]`);
const toggle = header.querySelector('.container-toggle');
if (body.style.display === 'none') {
body.style.display = 'block';
toggle.textContent = 'âŧ';
} else {
body.style.display = 'none';
toggle.textContent = 'âļ';
}
});
});
// Save all button
const saveBtn = container.querySelector('#saveAllBlocks');
if (saveBtn) {
saveBtn.addEventListener('click', () => {
if (!confirm('Save all changes to file?')) return;
// Collect all textarea values with their block IDs
const textareas = container.querySelectorAll('.block-content');
const updates = new Map();
textareas.forEach(ta => {
const blockId = ta.dataset.blockId;
updates.set(blockId, ta.value);
});
// Reconstruct file content
const lines = [];
function processBlock(block, blockId) {
const updatedContent = updates.get(blockId);
if (block.type === 'container') {
lines.push(file.content.split('\n')[block.startLine]); // Opening marker
block.children.forEach((child, idx) => {
const childId = `${blockId}-child-${idx}`;
processBlock(child, childId);
});
lines.push(file.content.split('\n')[block.endLine]); // Closing marker
} else if (block.type === 'scope') {
lines.push(file.content.split('\n')[block.startLine]); // Opening marker
lines.push(updatedContent);
lines.push(file.content.split('\n')[block.endLine]); // Closing marker
} else if (block.type === 'unmarked') {
lines.push(updatedContent);
}
}
blocks.forEach((block, idx) => {
processBlock(block, `block-${idx}`);
});
// Save to storage
const files = window.StorageEditor.loadActiveFiles();
const activeIdx = files.findIndex(f => f.active);
if (activeIdx !== -1) {
files[activeIdx].content = lines.join('\n');
files[activeIdx].lastModified = new Date().toISOString();
window.StorageEditor.saveActiveFiles(files);
window.dispatchEvent(new Event('activeFilesUpdated'));
saveBtn.textContent = 'â
SAVED';
saveBtn.style.background = '#10b981';
setTimeout(() => {
saveBtn.textContent = 'đž SAVE ALL';
saveBtn.style.background = '#10b981';
}, 2000);
}
});
}
}
// Export
window.StorageEditorScopes = {
open: () => {
if (window.AppOverlay) {
AppOverlay.open([{
title: 'đĻ Block Editor',
html: createBlockEditorHTML(),
onRender: setupBlockEditorInteractions
}]);
}
},
// Core functions
parseScopes,
buildBlockStructure,
getLanguageStyle,
parseSnippetHeader,
parseScopeAttributes,
detectLanguage,
// Expose existing block renderer
renderScopeBlock,
// đĨ NEW: Find best matching scope using fuzzy name matching
findBestMatch(containerName, scopeName) {
if (!window.StorageEditor) {
throw new Error('StorageEditor not available');
}
const file = window.StorageEditor.getActiveFile();
if (!file) {
throw new Error('No active file');
}
const parsed = parseScopes(file.content);
const candidates = parsed.scopes.filter(s => s.container === containerName);
if (candidates.length === 0) {
return { match: null, score: 0 };
}
// Calculate similarity scores
const scores = candidates.map(scope => {
const targetName = scopeName.toLowerCase();
const candidateName = scope.name.toLowerCase();
// Exact match
if (targetName === candidateName) {
return { scope, score: 100 };
}
// Contains match
if (candidateName.includes(targetName) || targetName.includes(candidateName)) {
return { scope, score: 80 };
}
// Levenshtein distance (simple version)
const maxLen = Math.max(targetName.length, candidateName.length);
let matches = 0;
for (let i = 0; i < Math.min(targetName.length, candidateName.length); i++) {
if (targetName[i] === candidateName[i]) matches++;
}
const score = (matches / maxLen) * 60;
return { scope, score };
});
// Get best match
scores.sort((a, b) => b.score - a.score);
return { match: scores[0].scope, score: scores[0].score };
},
// đĨ NEW: Insert a new scope at specific position in container
insertAt(containerName, position, scopeData) {
if (!window.StorageEditor) {
throw new Error('StorageEditor not available');
}
const file = window.StorageEditor.getActiveFile();
if (!file) {
throw new Error('No active file');
}
const lines = file.content.split('\n');
const parsed = parseScopes(file.content);
// Find the target container
const container = parsed.containers.find(c => c.name === containerName);
if (!container) {
throw new Error(`Container "${containerName}" not found`);
}
// Get all scopes in this container, sorted by position
const containerScopes = parsed.scopes
.filter(s => s.container === containerName)
.sort((a, b) => a.startLine - b.startLine);
// Adjust position: 1-indexed, clamped to valid range
const adjustedPosition = Math.max(0, Math.min(position - 1, containerScopes.length));
// Determine insertion point
let insertLine;
if (adjustedPosition === 0 || containerScopes.length === 0) {
// Insert at start of container (after opening marker)
insertLine = container.startLine + 1;
} else if (adjustedPosition >= containerScopes.length) {
// Insert at end of container (before closing marker)
insertLine = container.endLine;
} else {
// Insert before the scope at this position
insertLine = containerScopes[adjustedPosition].startLine;
}
// Build scope markers based on language
const { name, language, content, attributes } = scopeData;
const attrString = attributes ? ' ' + Object.entries(attributes)
.map(([k, v]) => `@${k}:${v}@`)
.join(' ') : '';
let openMarker, closeMarker;
if (language === 'html') {
openMarker = `<!-- ${name}<${attrString} -->`;
closeMarker = `<!-- ${name}> -->`;
} else if (language === 'css') {
openMarker = `/* ${name}<${attrString} */`;
closeMarker = `/* ${name}> */`;
} else if (language === 'php') {
openMarker = `// ${name}<${attrString}`;
closeMarker = `// ${name}>`;
} else {
// Default to JS-style comments
openMarker = `// ${name}<${attrString}`;
closeMarker = `// ${name}>`;
}
// Insert the new scope
const newLines = [
'',
openMarker,
content,
closeMarker
];
lines.splice(insertLine, 0, ...newLines);
// Save updated file
const files = window.StorageEditor.loadActiveFiles();
const activeIdx = files.findIndex(f => f.active);
if (activeIdx !== -1) {
files[activeIdx].content = lines.join('\n');
files[activeIdx].lastModified = new Date().toISOString();
window.StorageEditor.saveActiveFiles(files);
window.dispatchEvent(new Event('activeFilesUpdated'));
}
return {
success: true,
message: `Inserted scope "${name}" at position ${position} in container "${containerName}"`,
insertedAt: insertLine
};
},
// đĨ NEW: Replace an existing scope's content with smart matching
replace(containerName, position, scopeName, newContent, attributes) {
if (!window.StorageEditor) {
throw new Error('StorageEditor not available');
}
const file = window.StorageEditor.getActiveFile();
if (!file) {
throw new Error('No active file');
}
const lines = file.content.split('\n');
const parsed = parseScopes(file.content);
// Get all scopes in this container
const containerScopes = parsed.scopes
.filter(s => s.container === containerName)
.sort((a, b) => a.startLine - b.startLine);
if (containerScopes.length === 0) {
throw new Error(`No scopes found in container "${containerName}"`);
}
let targetScope = null;
// If position is out of bounds, use name matching
if (position < 1 || position > containerScopes.length) {
const result = this.findBestMatch(containerName, scopeName);
if (result.score > 50) {
targetScope = result.match;
console.log(`[replace] Position ${position} out of bounds. Using name match: "${result.match.name}" (score: ${result.score})`);
} else {
// Insert at end if no good match
return this.insertAt(containerName, containerScopes.length + 1, {
name: scopeName,
language: parsed.scopes.find(s => s.container === containerName)?.language || 'javascript',
content: newContent,
attributes
});
}
} else {
// Get scope at position (1-indexed)
const scopeAtPosition = containerScopes[position - 1];
// Verify name similarity
const result = this.findBestMatch(containerName, scopeName);
if (result.match && result.match.name === scopeAtPosition.name && result.score > 70) {
targetScope = scopeAtPosition;
console.log(`[replace] Position ${position} matches scope "${scopeAtPosition.name}" (score: ${result.score})`);
} else if (result.score > 70) {
targetScope = result.match;
console.log(`[replace] Using best name match "${result.match.name}" instead of position ${position} (score: ${result.score})`);
} else {
targetScope = scopeAtPosition;
console.log(`[replace] Using scope at position ${position}: "${scopeAtPosition.name}" (low match score: ${result.score})`);
}
}
if (!targetScope) {
throw new Error(`Could not find scope to replace in container "${containerName}"`);
}
// Update attributes in opening marker if provided
if (attributes) {
const openLine = lines[targetScope.startLine];
const attrString = Object.entries(attributes)
.map(([k, v]) => `@${k}:${v}@`)
.join(' ');
// Remove old attributes and add new ones
const cleaned = openLine.replace(/@[a-zA-Z0-9_-]+:[^@]+@/g, '').trim();
lines[targetScope.startLine] = cleaned.replace(/(<\s*)/, `< ${attrString} `);
}
// Replace content (between opening and closing markers)
lines.splice(targetScope.startLine + 1, targetScope.endLine - targetScope.startLine - 1, newContent);
// Save updated file
const files = window.StorageEditor.loadActiveFiles();
const activeIdx = files.findIndex(f => f.active);
if (activeIdx !== -1) {
files[activeIdx].content = lines.join('\n');
files[activeIdx].lastModified = new Date().toISOString();
window.StorageEditor.saveActiveFiles(files);
window.dispatchEvent(new Event('activeFilesUpdated'));
}
return {
success: true,
message: `Replaced scope "${targetScope.name}" in container "${containerName}"`,
replacedScope: targetScope.name,
startLine: targetScope.startLine,
endLine: targetScope.endLine
};
},
// Helper: List all containers and their scopes
listStructure() {
if (!window.StorageEditor) {
throw new Error('StorageEditor not available');
}
const file = window.StorageEditor.getActiveFile();
if (!file) {
throw new Error('No active file');
}
const parsed = parseScopes(file.content);
return {
containers: parsed.containers.map(c => ({
name: c.name,
scopes: parsed.scopes
.filter(s => s.container === c.name)
.map(s => ({
name: s.name,
language: s.language,
position: parsed.scopes.filter(x => x.container === c.name && x.startLine < s.startLine).length,
attributes: s.attributes
}))
})),
topLevelScopes: parsed.scopes
.filter(s => !s.container)
.map(s => ({
name: s.name,
language: s.language,
attributes: s.attributes
}))
};
},
// đĨ Universal snippet renderer used by Chat.js
renderAnswer(answerText, parentElement) {
if (!answerText) return "<pre></pre>";
const renderBlock = window.StorageEditorScopes.renderScopeBlock;
// Only parse if scope markers exist
if (renderBlock && (answerText.includes("<!--") || answerText.includes("//") || answerText.includes("/*"))) {
try {
const parsed = window.StorageEditorScopes.parseScopes(answerText);
if (parsed.scopes && parsed.scopes.length > 0) {
let html = "";
const lines = answerText.split('\n');
parsed.scopes.forEach((scope, idx) => {
const scopeContent = lines.slice(scope.startLine + 1, scope.endLine).join('\n');
html += renderBlock(
{
data: {
language: scope.language,
name: scope.name,
header: scope.header,
container: scope.container,
attributes: scope.attributes
},
startLine: scope.startLine,
endLine: scope.endLine,
content: scopeContent
},
"scope-render-" + Date.now() + "-" + idx,
true // isInChat flag
);
});
// Add event listeners after rendering if parent element provided
if (parentElement) {
// Attach action button listeners
setTimeout(() => {
parentElement.querySelectorAll('.scope-action-btn').forEach(btn => {
btn.addEventListener('click', function() {
const action = this.dataset.action;
const container = this.dataset.container;
const position = parseInt(this.dataset.position);
const scopeName = this.dataset.scopeName;
const language = this.dataset.language;
const blockId = this.dataset.blockId;
// Get content from textarea (already cleaned)
const textarea = parentElement.querySelector(`.block-content[data-block-id="${blockId}"]`);
const content = textarea ? textarea.value : '';
try {
const attributes = {
editedBy: 'ai',
editedAt: Date.now().toString()
};
let result;
if (action === 'new') {
result = window.StorageEditorScopes.insertAt(container, position, {
name: scopeName,
language: language,
content: content,
attributes: attributes
});
} else if (action === 'edit') {
result = window.StorageEditorScopes.replace(container, position, scopeName, content, attributes);
}
this.style.background = '#10b981';
this.textContent = 'â
APPLIED';
this.disabled = true;
this.style.cursor = 'not-allowed';
console.log('[ScopeAction]', result);
setTimeout(() => {
this.style.transition = 'opacity 0.3s';
this.style.opacity = '0';
setTimeout(() => this.remove(), 300);
}, 2000);
} catch (error) {
console.error('[ScopeAction] Error:', error);
this.style.background = '#ef4444';
this.textContent = 'â ERROR';
alert('Error: ' + error.message);
}
});
});
}, 100);
// ------------------------------------------------------------
// CLEAN VISUAL MARKERS OUT OF TEXTAREAS AFTER RENDER
// ------------------------------------------------------------
setTimeout(() => {
parentElement.querySelectorAll("textarea.block-content").forEach(area => {
area.value = area.value
// HTML markers <!-- name< --> OR <!-- name> -->
.replace(/<!--\s*[a-z0-9_-]+<\s*-->/gi, "")
.replace(/<!--\s*[a-z0-9_-]+>\s*-->/gi, "")
// CSS markers /* name< */ /* name> */
.replace(/\/\*\s*[a-z0-9_-]+<\s*\*\//gi, "")
.replace(/\/\*\s*[a-z0-9_-]+>\s*\*\//gi, "")
// JS markers // name< // name>
.replace(/\/\/\s*[a-z0-9_-]+</gi, "")
.replace(/\/\/\s*[a-z0-9_-]+>/gi, "")
.trim();
});
}, 120); // slightly after button-setup
}
return html;
}
} catch (err) {
console.error("Snippet parsing failed:", err);
return `<pre>${escapeHtml(answerText)}</pre>`;
}
}
// Not a scope-formatted snippet â return plain
return `<pre>${escapeHtml(answerText)}</pre>`;
}
};
console.log('â
Scopes Block Editor v2 loaded');
})();