📜
blocks.js
Back
📝 Javascript ⚡ Executable Ctrl+S: Save • Ctrl+R: Run • Ctrl+F: Find
// ===== Blocks Module - Hierarchical Block Layout with Folding // - Inline footer (closing line stays in same div as opener) // - Double-click to focus a subtree // - In focused view: no ellipsis; wrap long code so blocks expand; show input boxes // - In full view: condensed single-line with ellipsis; plain text only // - Tight indent (10px per depth) // - Full-height colored strip tied to block height and content type // - Folding: Containers can be collapsed/expanded with a toggle button // - String/Number inputs with colored underlines (ONLY in focused mode) // ================================================= document.addEventListener('DOMContentLoaded', () => { const { editor } = window.editorAPI || {}; if (!editor) { console.warn('Editor not found, blocks functionality may be limited'); return; } // DOM const blocksBtn = document.getElementById("blocksBtn"); const blocksOverlay = document.getElementById("blocksOverlay"); const blocksClose = document.getElementById("blocksClose"); const blocksCanvas = document.getElementById("blocksCanvas"); // ===== Config ===== const BASE_TAB_PX = 10; // tighter indentation const BASE_FONT_PX = 12; const CONTENT_TYPE_COLORS = { script: '#ff6b6b', // Red for <script> tags div: '#4CAF50', // Green for <div> tags function: '#2196F3', // Blue for functions variable: '#9C27B0', // Purple for variables line: '#007acc', // Default blue for other lines container: '#007acc' // Default for containers }; // ===== View state ===== let rootModel = null; // full document tree let currentRoot = null; // current subtree being displayed const viewStack = []; // for Back // ===== Overlay ===== function openOverlay() { blocksOverlay.classList.add("open"); parseAndRenderBlocks(); } function closeOverlay() { blocksOverlay.classList.remove("open"); if (blocksBtn) blocksBtn.focus(); } if (blocksBtn) blocksBtn.addEventListener("click", openOverlay); if (blocksClose) blocksClose.addEventListener("click", closeOverlay); window.addEventListener("keydown", (e) => { if (blocksOverlay.classList.contains("open") && e.key === "Escape") closeOverlay(); }); // ===== Parse & Render Entrypoint ===== function forceFoldComputation(session) { const foldWidgets = session.foldWidgets || {}; const lines = session.getDocument().getAllLines(); let computedCount = 0; // Iterate through all lines to compute fold widgets for (let row = 0; row < lines.length; row++) { if (!(row in foldWidgets) || foldWidgets[row] == null) { const widget = session.getFoldWidget(row); foldWidgets[row] = widget; // Update foldWidgets directly if (widget) computedCount++; } } // Log the number of computed fold widgets console.log(`Computed ${computedCount} fold widgets for ${lines.length} lines`, foldWidgets); // Warn if fold widgets are still incomplete const missingFolds = lines.length - Object.keys(foldWidgets).length; if (missingFolds > 0) { console.warn(`${missingFolds} lines missing fold widgets, may fall back to indentation parsing`); } session.foldWidgets = foldWidgets; // Ensure session.foldWidgets is updated // Force editor re-render to ensure fold widgets are applied session.bgTokenizer.start(0); } function parseAndRenderBlocks() { const session = editor.getSession(); const lines = session.getDocument().getAllLines(); // Force fold computation forceFoldComputation(session); const foldWidgets = session.foldWidgets; rootModel = foldWidgets && Object.keys(foldWidgets).length ? buildTreeWithFolds(lines, foldWidgets, session) : buildTreeWithIndentation(lines); // Debug: Log the parsed tree console.log('Parsed tree:', JSON.stringify(rootModel, (key, value) => { if (key === 'children') return value.map(child => ({ id: child.id, text: child.text, depth: child.depth, type: child.type, footerText: child.footerText })); return value; }, 2)); currentRoot = rootModel; renderTree(currentRoot); } // ===== Tree model ===== // Node: { id, text, depth, type:'root'|'container'|'line', contentType:'script'|'div'|'function'|'variable'|'line', children:[], startRow?, endRow?, footerText?, collapsed? } let nextId = 1; function makeNode(props) { return Object.assign( { id: nextId++, text: '', depth: 0, type: 'line', contentType: detectContentType(props.text), children: [], collapsed: false }, props ); } // Helper to detect content type function detectContentType(text) { if (!text) return 'line'; const trimmed = text.trim(); if (trimmed.startsWith('<script') || trimmed.includes('<script>')) return 'script'; if (trimmed.startsWith('<div') || trimmed.includes('<div>')) return 'div'; if (trimmed.includes('function ') || trimmed.match(/function\s+\w+\s*\(/)) return 'function'; if (trimmed.match(/^(let|const|var)\s+\w+/)) return 'variable'; return 'line'; } // --- Fold-based parser (Ace) --- function buildTreeWithFolds(lines, foldWidgets, session) { nextId = 1; const root = makeNode({ type: 'root', depth: 0, text: 'ROOT' }); const stack = [{ node: root, endRow: null }]; for (let row = 0; row < lines.length; row++) { const raw = lines[row]; const trimmed = raw.trim(); if (!trimmed) continue; // Skip comments unless they contain @editIdentifiers@ if (trimmed.startsWith('<!--') && !trimmed.includes('@editIdentifiers@')) { console.log(`Ignoring comment at row ${row}: ${trimmed}`); continue; } // Clean up stack for containers that have ended while (stack.length > 1 && stack[stack.length - 1].endRow !== null && stack[stack.length - 1].endRow < row) { console.log(`Popping node at row ${row}:`, stack[stack.length - 1]); stack.pop(); } // Check if this row is the closing tag for the current container if (stack.length > 1 && stack[stack.length - 1].endRow === row) { const level = stack[stack.length - 1]; const parent = stack[stack.length - 2].node; const opener = level.node.text.trim(); // Validate closing tag matches opening tag if ((opener.match(/^<(div|script|style|body|html|head)(?:\s|>)/) && trimmed.match(new RegExp(`^</${opener.match(/^<(div|script|style|body|html|head)/)?.[1]}>`))) || (opener.includes('{') && trimmed.includes('}'))) { level.node.footerText = trimmed; console.log(`Assigned footerText at row ${row}: ${trimmed} to node ID ${level.node.id}`); } else { console.warn(`Mismatch at row ${row}: expected closing tag for ${opener}, got ${trimmed}`); parent.children.push(makeNode({ text: trimmed, depth: parent.depth + 1, type: 'line' })); } stack.pop(); continue; } const fw = foldWidgets[row]; if (fw === 'start') { const range = session.getFoldWidgetRange(row); if (!range) { console.warn(`No fold range for start at row ${row}: ${trimmed}`); const parent = stack[stack.length - 1].node; parent.children.push(makeNode({ text: trimmed, depth: parent.depth + 1, type: 'line' })); continue; } const endRow = range.end.row; // Handle single-line blocks if (endRow === row) { const closingMatch = trimmed.match(/<\/(div|script|style|body|html|head)>$/) || trimmed.match(/}$/); if (closingMatch) { const parent = stack[stack.length - 1].node; const containerNode = makeNode({ text: trimmed.replace(closingMatch[0], '').trim(), depth: parent.depth + 1, type: 'container', startRow: row, endRow, footerText: closingMatch[0], collapsed: false }); parent.children.push(containerNode); console.log(`Single-line container at row ${row}:`, containerNode); continue; } } const parent = stack[stack.length - 1].node; const containerNode = makeNode({ text: trimmed, depth: parent.depth + 1, type: 'container', startRow: row, endRow, collapsed: false }); parent.children.push(containerNode); stack.push({ node: containerNode, endRow }); console.log(`Pushed container at row ${row}:`, containerNode); } else { const parent = stack[stack.length - 1].node; parent.children.push(makeNode({ text: trimmed, depth: parent.depth + 1, type: 'line' })); } } // Ensure no unclosed containers remain in the stack while (stack.length > 1) { console.warn('Unclosed container detected:', stack[stack.length - 1]); stack.pop(); } return root; } // --- Indentation fallback parser --- function buildTreeWithIndentation(lines) { nextId = 1; const root = makeNode({ type: 'root', depth: 0, text: 'ROOT' }); const stack = [{ node: root, indent: -1 }]; lines.forEach((raw, row) => { const trimmed = raw.trim(); if (!trimmed) return; // Skip comments unless they contain @editIdentifiers@ if (trimmed.startsWith('<!--') && !trimmed.includes('@editIdentifiers@')) { console.log(`Ignoring comment at row ${row}: ${trimmed}`); return; } const indentWidth = raw.length - raw.replace(/^\s*/, '').length; const currentIndent = Math.floor(indentWidth / 2); // Clean up stack based on indentation while (stack.length > 1 && stack[stack.length - 1].indent >= currentIndent) { console.log(`Popping node at row ${row} due to indentation ${currentIndent}:`, stack[stack.length - 1]); stack.pop(); } const parent = stack[stack.length - 1].node; const isClosingTag = trimmed.match(/^<\/(div|script|style|body|html|head)>/); const lastContainer = stack[stack.length - 1].node.type === 'container' ? stack[stack.length - 1].node : null; if (lastContainer && isClosingTag) { const opener = lastContainer.text.trim(); const closingTagName = isClosingTag[1]; if (opener.match(new RegExp(`^<${closingTagName}(?:\\s|>|$)`)) || (opener.includes('{') && trimmed.includes('}'))) { lastContainer.footerText = trimmed; console.log(`Assigned footerText at row ${row}: ${trimmed} to node ID ${lastContainer.id}`); stack.pop(); return; } } const looksOpen = trimmed.includes('{') && !trimmed.includes('}') || trimmed.match(/^<(div|script|style|body|html|head)(?:\s|>)/); if (looksOpen) { const containerNode = makeNode({ text: trimmed, depth: parent.depth + 1, type: 'container', collapsed: false }); parent.children.push(containerNode); stack.push({ node: containerNode, indent: currentIndent }); console.log(`Pushed container at row ${row}:`, containerNode); } else { parent.children.push(makeNode({ text: trimmed, depth: parent.depth + 1, type: 'line' })); } }); return root; } // ===== String/Number/Text/Identifier Input Detection ===== function parseTextWithInputs(text) { const container = document.createDocumentFragment(); console.log('parseTextWithInputs called with:', text); const editIdentifiersMatch = text.match(/(?:<!--.*?@editIdentifiers(?:\.([^@\s]+))?@.*?-->|\/\/.*?@editIdentifiers(?:\.([^@\s]+))?@)/); const shouldEditIdentifiers = editIdentifiersMatch !== null; const specificIdentifiers = editIdentifiersMatch && (editIdentifiersMatch[1] || editIdentifiersMatch[2]) ? (editIdentifiersMatch[1] || editIdentifiersMatch[2]).split('.') : null; console.log('Edit identifiers:', shouldEditIdentifiers, 'Specific:', specificIdentifiers); const cleanText = text .replace(/<!--.*?@editIdentifiers(?:\.[^@\s]+)?@.*?-->/g, '') .replace(/\/\/.*?@editIdentifiers(?:\.[^@\s]+)?@.*$/gm, ''); if (!cleanText.trim()) { return container; } const workingText = cleanText; const stringRegex = /(['"`])((?:\\.|(?!\1)[^\\])*?)\1/g; const numberRegex = /\b\d+\.?\d*\b/g; const htmlTextRegex = />([^<]+)</g; const identifierRegex = /\b[a-zA-Z_$][a-zA-Z0-9_$]*\b/g; let lastIndex = 0; const matches = []; let match; while ((match = stringRegex.exec(workingText)) !== null) { matches.push({ type: 'string', value: match[0], start: match.index, end: match.index + match[0].length }); } htmlTextRegex.lastIndex = 0; while ((match = htmlTextRegex.exec(workingText)) !== null) { const textContent = match[1].trim(); if (textContent && textContent.length > 0) { const textStart = match.index + 1; matches.push({ type: 'text', value: textContent, start: textStart, end: textStart + textContent.length }); } } numberRegex.lastIndex = 0; while ((match = numberRegex.exec(workingText)) !== null) { const isInsideOther = matches.some(token => match.index >= token.start && match.index < token.end ); if (!isInsideOther) { matches.push({ type: 'number', value: match[0], start: match.index, end: match.index + match[0].length }); } } if (shouldEditIdentifiers) { identifierRegex.lastIndex = 0; const identifierCounts = new Map(); while ((match = identifierRegex.exec(workingText)) !== null) { const identifier = match[0]; const reservedWords = ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'true', 'false', 'null', 'undefined', 'this', 'new', 'class', 'extends', 'import', 'export', 'from', 'default', 'async', 'await', 'try', 'catch', 'throw', 'typeof', 'instanceof', 'in', 'of', 'delete', 'void', 'break', 'continue', 'case', 'switch', 'do', 'with']; if (reservedWords.includes(identifier)) { continue; } const isInsideOther = matches.some(token => match.index >= token.start && match.index < token.end ); if (isInsideOther) { continue; } const currentCount = identifierCounts.get(identifier) || 0; identifierCounts.set(identifier, currentCount + 1); let shouldInclude = false; if (specificIdentifiers) { const targetIndex = specificIdentifiers.findIndex((spec, idx) => { const [targetName, targetOccurrence] = spec.split('.'); const occurrenceNum = targetOccurrence ? parseInt(targetOccurrence, 10) : 0; return targetName === identifier && currentCount === occurrenceNum; }); shouldInclude = targetIndex !== -1; } else { shouldInclude = true; } if (shouldInclude) { matches.push({ type: 'identifier', value: identifier, start: match.index, end: match.index + identifier.length }); } } } console.log('Found matches:', matches); matches.sort((a, b) => a.start - b.start); if (matches.length === 0) { container.appendChild(document.createTextNode(workingText)); return container; } matches.forEach(token => { if (token.start > lastIndex) { const textBefore = workingText.substring(lastIndex, token.start); container.appendChild(document.createTextNode(textBefore)); } let inputElement; if (token.type === 'text') { inputElement = document.createElement('textarea'); inputElement.value = token.value; inputElement.style.resize = 'none'; inputElement.style.overflow = 'hidden'; inputElement.style.minHeight = '20px'; inputElement.style.height = '20px'; inputElement.style.verticalAlign = 'top'; inputElement.rows = 1; const autoResize = () => { inputElement.style.height = 'auto'; inputElement.style.height = `${inputElement.scrollHeight}px`; }; setTimeout(autoResize, 0); inputElement.addEventListener('input', autoResize); inputElement.style.borderBottom = '2px solid #2196F3'; inputElement.style.backgroundColor = 'rgba(33, 150, 243, 0.1)'; console.log('Created textarea for:', token.value); } else { inputElement = document.createElement('input'); inputElement.type = 'text'; inputElement.value = token.value; inputElement.style.width = `${Math.max(token.value.length + 1, 3)}ch`; if (token.type === 'string') { inputElement.style.borderBottom = '2px solid #4CAF50'; inputElement.style.backgroundColor = 'rgba(76, 175, 80, 0.1)'; console.log('Created string input for:', token.value); } else if (token.type === 'number') { inputElement.style.borderBottom = '2px solid #FF9800'; inputElement.style.backgroundColor = 'rgba(255, 152, 0, 0.1)'; console.log('Created number input for:', token.value); } else if (token.type === 'identifier') { inputElement.style.borderBottom = '2px solid #9C27B0'; inputElement.style.backgroundColor = 'rgba(156, 39, 176, 0.1)'; console.log('Created identifier input for:', token.value); } inputElement.addEventListener('input', (e) => { e.target.style.width = `${Math.max(e.target.value.length + 1, 3)}ch`; }); } inputElement.style.border = 'none'; inputElement.style.outline = 'none'; inputElement.style.fontFamily = 'inherit'; inputElement.style.fontSize = 'inherit'; inputElement.style.color = 'inherit'; inputElement.style.padding = '0'; inputElement.style.margin = '0'; inputElement.addEventListener('focus', (e) => { e.stopPropagation(); }); container.appendChild(inputElement); lastIndex = token.end; }); if (lastIndex < workingText.length) { const remainingText = workingText.substring(lastIndex); container.appendChild(document.createTextNode(remainingText)); } return container; } // ===== Rendering ===== function renderTree(rootNode) { blocksCanvas.innerHTML = ''; const isFocused = rootNode !== rootModel; const headerRow = document.createElement('div'); headerRow.style.display = 'flex'; headerRow.style.alignItems = 'center'; headerRow.style.gap = '8px'; headerRow.style.padding = '8px 0'; if (isFocused) { const backBtn = document.createElement('button'); backBtn.textContent = '← Back'; backBtn.style.padding = '6px 10px'; backBtn.style.border = '1px solid #bbb'; backBtn.style.borderRadius = '6px'; backBtn.style.background = '#fff'; backBtn.style.cursor = 'pointer'; backBtn.addEventListener('click', () => { if (viewStack.length) { currentRoot = viewStack.pop(); renderTree(currentRoot); } }); headerRow.appendChild(backBtn); const crumb = document.createElement('div'); crumb.textContent = displayPath(rootNode); crumb.style.fontFamily = 'system-ui, sans-serif'; crumb.style.fontSize = '12px'; crumb.style.color = '#666'; headerRow.appendChild(crumb); } blocksCanvas.appendChild(headerRow); const container = document.createElement('div'); container.className = 'blocks-container'; blocksCanvas.appendChild(container); rootNode.children.forEach(child => { container.appendChild(renderNode(child, isFocused)); }); } function displayPath(node) { const names = []; let n = node; while (n && n.type !== 'root') { names.push(n.text); n = findParent(rootModel, n.id); } return names.reverse().join(' / '); } function findParent(root, childId) { const stack = [root]; while (stack.length) { const cur = stack.pop(); if (!cur.children) continue; for (const c of cur.children) { if (c.id === childId) return cur; stack.push(c); } } return null; } function renderNode(node, isFocused) { if (node.type === 'container') { const { blockEl, childContainer } = createContainerBlock(node.text, node.depth, node.id, isFocused, node.collapsed); if (!node.collapsed) { node.children.forEach(ch => { childContainer.appendChild(renderNode(ch, isFocused)); }); } if (node.footerText && !node.collapsed) { const footer = document.createElement('div'); footer.className = 'block-footer'; footer.style.marginTop = '4px'; footer.style.opacity = '0.9'; footer.style.fontStyle = 'italic'; footer.style.marginLeft = '0'; // Align with header if (isFocused) { footer.appendChild(parseTextWithInputs(node.footerText)); } else { footer.appendChild(document.createTextNode(node.footerText)); } applyTextStyles(footer, isFocused); blockEl.appendChild(footer); } return blockEl; } else { return createLineBlock(node.text, node.depth, node.id, isFocused); } } // ===== Focus (double-click) ===== function focusOn(nodeId) { const node = findById(rootModel, nodeId); if (!node) return; viewStack.push(currentRoot); currentRoot = cloneAsFocusedRoot(node); renderTree(currentRoot); } function findById(root, id) { const stack = [root]; while (stack.length) { const n = stack.pop(); if (n.id === id) return n; if (n.children) stack.push(...n.children); } return null; } function cloneAsFocusedRoot(node) { const focused = makeNode({ id: node.id, text: node.text, depth: 0, type: 'root', contentType: node.contentType, children: [], collapsed: false }); if (node.type === 'container') { const cloneContainer = deepCloneNode(node, node.depth - 1); focused.children.push(cloneContainer); } else { focused.children.push(deepCloneNode(node, node.depth - 1)); } return focused; } function deepCloneNode(node, depthOffset) { const cloned = makeNode({ id: node.id, text: node.text, depth: Math.max(1, node.depth - depthOffset), type: node.type, contentType: node.contentType, children: [], collapsed: node.collapsed }); if (node.footerText) cloned.footerText = node.footerText; if (node.children && node.children.length) { cloned.children = node.children.map(ch => deepCloneNode(ch, depthOffset)); } return cloned; } // ===== Block constructors ===== function createContainerBlock(headerText, depth, nodeId, isFocused, collapsed) { const node = findById(rootModel, nodeId); const blockEl = document.createElement('div'); blockEl.className = 'code-block container-block'; applyBlockBaseStyles(blockEl, depth, node ? node.contentType : 'container'); blockEl.dataset.nodeId = String(nodeId); const headerWrapper = document.createElement('div'); headerWrapper.style.display = 'flex'; headerWrapper.style.alignItems = 'center'; headerWrapper.style.gap = '6px'; const toggleBtn = document.createElement('button'); toggleBtn.textContent = collapsed ? '+' : '−'; toggleBtn.style.width = '20px'; toggleBtn.style.height = '20px'; toggleBtn.style.padding = '0'; toggleBtn.style.border = '1px solid #bbb'; toggleBtn.style.borderRadius = '4px'; toggleBtn.style.background = '#fff'; toggleBtn.style.cursor = 'pointer'; toggleBtn.style.fontFamily = 'monospace'; toggleBtn.style.fontSize = '14px'; toggleBtn.style.lineHeight = '20px'; toggleBtn.style.textAlign = 'center'; toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); const node = findById(rootModel, nodeId); if (node) { node.collapsed = !node.collapsed; renderTree(currentRoot); } }); headerWrapper.appendChild(toggleBtn); const header = document.createElement('div'); header.className = 'block-header'; if (isFocused) { header.appendChild(parseTextWithInputs(headerText)); } else { header.appendChild(document.createTextNode(headerText)); } header.style.fontWeight = '600'; applyTextStyles(header, isFocused); headerWrapper.appendChild(header); blockEl.appendChild(headerWrapper); const childContainer = document.createElement('div'); childContainer.className = 'fold-children'; childContainer.style.marginTop = '4px'; blockEl.appendChild(childContainer); blockEl.addEventListener('dblclick', (e) => { e.stopPropagation(); focusOn(Number(blockEl.dataset.nodeId)); }); return { blockEl, childContainer }; } function createLineBlock(text, depth, nodeId, isFocused) { const node = findById(rootModel, nodeId); const block = document.createElement('div'); block.className = 'code-block line-block'; applyBlockBaseStyles(block, depth, node ? node.contentType : 'line'); block.dataset.nodeId = String(nodeId); const span = document.createElement('span'); if (isFocused) { span.appendChild(parseTextWithInputs(text)); } else { span.appendChild(document.createTextNode(text)); } applyTextStyles(span, isFocused); block.appendChild(span); block.addEventListener('dblclick', (e) => { e.stopPropagation(); focusOn(Number(block.dataset.nodeId)); }); return block; } // ===== Shared styling (white blocks + full-height colored strip) ===== function applyBlockBaseStyles(el, depth, contentType) { el.style.position = 'relative'; el.style.border = '1px solid #ddd'; el.style.margin = '2px 0'; el.style.padding = '6px 10px 6px 16px'; el.style.fontFamily = "'Courier New', monospace"; el.style.fontSize = `${BASE_FONT_PX}px`; el.style.lineHeight = '1.35'; el.style.marginLeft = `${depth * BASE_TAB_PX}px`; el.style.minHeight = '20px'; el.style.boxSizing = 'border-box'; el.style.borderRadius = '4px'; el.style.backgroundColor = '#ffffff'; el.style.cursor = 'zoom-in'; const strip = document.createElement('div'); strip.className = 'block-strip'; strip.style.position = 'absolute'; strip.style.left = '10px'; strip.style.top = '4px'; strip.style.bottom = '4px'; strip.style.width = '4px'; strip.style.backgroundColor = CONTENT_TYPE_COLORS[contentType] || CONTENT_TYPE_COLORS.line; strip.style.borderRadius = '2px'; el.appendChild(strip); el.addEventListener('mouseenter', () => { el.style.backgroundColor = '#f9fbff'; }); el.addEventListener('mouseleave', () => { el.style.backgroundColor = '#ffffff'; }); } function applyTextStyles(el, isFocused) { el.style.color = '#2d2d2d'; el.style.display = 'block'; if (isFocused) { el.style.whiteSpace = 'pre-wrap'; el.style.overflow = 'visible'; el.style.textOverflow = 'clip'; el.style.wordBreak = 'break-word'; } else { el.style.whiteSpace = 'nowrap'; el.style.overflow = 'hidden'; el.style.textOverflow = 'ellipsis'; } } // ===== Auto-refresh while open ===== let refreshTimeout; editor.on('change', () => { clearTimeout(refreshTimeout); refreshTimeout = setTimeout(() => { if (blocksOverlay.classList.contains('open')) { const wasFocused = currentRoot && currentRoot !== rootModel; const focusId = wasFocused ? currentRoot.children?.[0]?.id : null; const collapsedStates = extractCollapsedStates(rootModel); // Force fold computation before parsing forceFoldComputation(editor.getSession()); parseAndRenderBlocks(); applyCollapsedStates(rootModel, collapsedStates); if (wasFocused && focusId != null) { const node = findById(rootModel, focusId); if (node) { currentRoot = cloneAsFocusedRoot(node); renderTree(currentRoot); } } } }, 350); }); // Helper to extract collapsed states before re-parsing function extractCollapsedStates(root) { const states = {}; const stack = [root]; while (stack.length) { const node = stack.pop(); if (node.type === 'container' && node.collapsed) { states[node.id] = true; } if (node.children) stack.push(...node.children); } return states; } // Helper to apply collapsed states after re-parsing function applyCollapsedStates(root, states) { const stack = [root]; while (stack.length) { const node = stack.pop(); if (node.type === 'container' && states[node.id]) { node.collapsed = true; } if (node.children) stack.push(...node.children); } } // ===== Shortcut to open ===== document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'B') { e.preventDefault(); openOverlay(); } }); // ===== Export API ===== window.blocksAPI = { openOverlay, closeOverlay, parseAndRenderBlocks }; console.log("Blocks module (focused unwrap with folding and color-coded tags) loaded"); });