// Variaveis globais let hasUnsavedChanges = false; // --- Função para Mostrar Notificações Elegantes --- let toastTimeout; // Guarda o timer para não bugar se clicar várias vezes function showToast(message, isError = false) { const toast = document.getElementById('toast'); toast.innerText = message; // Muda a cor se for erro if (isError) { toast.classList.add('error'); } else { toast.classList.remove('error'); } // Tira o display: none e dá um micro-delay para a animação do CSS funcionar toast.classList.remove('hidden'); // Limpa qualquer timer antigo se o usuário "flodar" o botão clearTimeout(toastTimeout); setTimeout(() => { toast.classList.add('show'); }, 10); // Esconde automaticamente depois de 3 segundos toastTimeout = setTimeout(() => { toast.classList.remove('show'); // Espera a animação de saída terminar para dar display: none de novo setTimeout(() => toast.classList.add('hidden'), 400); }, 3000); }/** * ========================================== * GESTÃO DE ESTADO (DATA STORE) * ========================================== */ // Estado inicial da Árvore const initialData = { id: 'root', text: "Comece a digitar...", type: 'text', children: [], widthLevel: 2 // Tamanho padrão }; // Estado da Aplicação const appState = { serverId: null, lastSyncTime: null, projectName: 'Meu Mapa Mental', tree: structuredClone(initialData), // Deep copy nativo e otimizado view: { scale: 1, panX: window.innerWidth / 2 - 100, // Centralizar inicial panY: window.innerHeight / 2 - 50, autoCenter: true, // Controla se a câmera segue o foco automaticamente isLightMode: false // Modo de renderização simplificado para performance/mobile }, interaction: { isDragging: false, lastMouse: { x: 0, y: 0 }, nodePendingDeletion: null, cursorPosition: null, // Para salvar a posição do cursor newMapPendingConfirmation: false // Flag para o novo modal }, focusId: 'root', // Qual nó deve receber foco após render history: { undoStack: [], undoTimeout: null, isInitialPlaceholder: true, // Flag para controlar o texto inicial autoSaveTimeout: null } }; // Utilitário para gerar IDs únicos const generateId = () => '_' + Math.random().toString(36).substr(2, 9); /** * ========================================== * PERSISTÊNCIA (LOCAL STORAGE) * ========================================== */ const AUTO_SAVE_KEY = 'dashmap-autosave'; // Salva o estado atual no localStorage com um debounce function scheduleAutoSave() { clearTimeout(appState.history.autoSaveTimeout); appState.history.autoSaveTimeout = setTimeout(() => { const stateToSave = { serverId: appState.serverId, projectName: appState.projectName, tree: appState.tree, view: appState.view }; localStorage.setItem(AUTO_SAVE_KEY, JSON.stringify(stateToSave)); hasUnsavedChanges = true; // Se projeto vinculado à nuvem, salva no servidor if (appState.serverId) { saveMapToCloud(true); } }, 500); } // Carrega o estado do localStorage na inicialização function loadStateFromLocalStorage() { const savedStateJSON = localStorage.getItem(AUTO_SAVE_KEY); if (savedStateJSON) { try { const savedState = JSON.parse(savedStateJSON); // Validação simples para garantir que os dados não estão corrompidos if (savedState && savedState.tree && savedState.view) { appState.serverId = savedState.serverId || null; // <--- ADICIONADO: Restaura o ID se existir appState.projectName = savedState.projectName; appState.tree = savedState.tree; // Mescla as propriedades da view para não perder defaults (como autoCenter) appState.view = { ...appState.view, ...savedState.view }; appState.history.isInitialPlaceholder = false; // Dados carregados não são placeholder } } catch (e) { console.error("Falha ao carregar o estado do localStorage:", e); } } } /** * ========================================== * HISTÓRICO (UNDO/REDO) * ========================================== */ // Salva o estado atual na pilha de undo function saveStateForUndo() { const stateToSave = { projectName: appState.projectName, tree: structuredClone(appState.tree), // Deep copy nativo (mais rápido e seguro) focusId: appState.focusId // Salva o ID do foco ANTES da ação }; appState.history.isInitialPlaceholder = false; // Qualquer ação salva desativa o placeholder appState.history.undoStack.push(stateToSave); // Limita o tamanho do histórico para não consumir muita memória if (appState.history.undoStack.length > 50) { appState.history.undoStack.shift(); // Remove o estado mais antigo } } function undo() { if (appState.history.undoStack.length === 0) return; // Nada para desfazer const lastState = appState.history.undoStack.pop(); // Pega o último estado salvo appState.projectName = lastState.projectName; appState.tree = lastState.tree; // Restaura a árvore appState.focusId = lastState.focusId; // Restaura o foco para onde estava antes da ação appState.history.isInitialPlaceholder = false; // Desfazer também conta como uma ação do usuário document.getElementById('project-name-input').value = appState.projectName; // Atualiza o input do nome render(); // Re-renderiza a aplicação com o estado restaurado scheduleAutoSave(); } /** * ========================================== * ZERA QUANDO A PESSOA SAI DA CONTA * ========================================== */ // Função para zerar o projeto (útil para logout ou botão "Novo Mapa") function resetToNewProject() { // 1. Zera as referências da nuvem appState.serverId = null; appState.lastSyncTime = null; appState.projectName = 'Meu Mapa Mental'; // 2. Restaura a árvore inicial (usando a nossa nova melhoria!) appState.tree = structuredClone(initialData); // 3. Limpa o histórico e foca na raiz appState.focusId = 'root'; appState.history.undoStack = []; appState.history.isInitialPlaceholder = true; // 4. Centraliza a câmera novamente appState.view.scale = 1; appState.view.panX = window.innerWidth / 2 - 100; appState.view.panY = window.innerHeight / 2 - 50; // 5. Atualiza a interface Visual const projectNameInput = document.getElementById('project-name-input'); if (projectNameInput) projectNameInput.value = ''; // Fica vazio para mostrar o placeholder if (typeof originalProjectName !== 'undefined') originalProjectName = 'Meu Mapa Mental'; // 6. Refaz o desenho na tela render(); updateTransform(); } /** * ========================================== * INFINITE CANVAS (PAN & ZOOM) * ========================================== */ const app = document.getElementById('app'); // Estado temporario para distinguir clique de arrasto no mouseup let mouseDownTarget = null; let mouseDownPos = { x: 0, y: 0 }; let isMouseDragging = false; const DRAG_THRESHOLD = 5; // pixels de movimento para considerar arrasto function updateTransform() { const px = Math.round(appState.view.panX); const py = Math.round(appState.view.panY); const scale = appState.view.scale; world.style.transform = `translate(${px}px, ${py}px) scale(${scale})`; // Update zoom display if element exists const zoomLevelEl = document.getElementById('zoom-level'); if (zoomLevelEl) zoomLevelEl.textContent = Math.round(scale * 100) + '%'; scheduleAutoSave(); } app.addEventListener('wheel', (e) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); // Zoom const zoomIntensity = 0.1; const delta = -Math.sign(e.deltaY) * zoomIntensity; const newScale = Math.min(Math.max(0.1, appState.view.scale + delta), 5); // Zoom em direção ao mouse (math simplificado: zoom no centro por agora para não complicar) // Para zoom no mouse: // 1. Calcular mouse position relative to world // 2. Aplicar scale // 3. Ajustar pan para manter mouse position constante // (Implementação simplificada: Zoom no centro da tela ou apenas Scale direto) appState.view.scale = newScale; updateTransform(); } else { // Pan com roda do mouse (trackpad) appState.view.panX -= e.deltaX; appState.view.panY -= e.deltaY; updateTransform(); } }, { passive: false }); app.addEventListener('mousedown', (e) => { if (e.button !== 0) return; // Apenas botão esquerdo e.preventDefault(); // Previne foco nativo do contenteditable mouseDownTarget = e.target; mouseDownPos = { x: e.clientX, y: e.clientY }; isMouseDragging = false; const isOnNode = e.target.closest('.node-content'); // Inicia pan imediatamente se estiver no fundo, senao aguarda para ver se arrasta if (!isOnNode) { appState.interaction.isDragging = true; appState.interaction.lastMouse = { x: e.clientX, y: e.clientY }; app.style.cursor = 'grabbing'; } }); window.addEventListener('mousemove', (e) => { // Verifica se ultrapassou o threshold de arrasto if (!isMouseDragging && mouseDownTarget) { const dx = e.clientX - mouseDownPos.x; const dy = e.clientY - mouseDownPos.y; if (Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) { isMouseDragging = true; // Se estava em um node e comecou a arrastar, habilita pan do canvas appState.interaction.isDragging = true; appState.interaction.lastMouse = { x: e.clientX, y: e.clientY }; app.style.cursor = 'grabbing'; } } if (appState.interaction.isDragging) { const dx = e.clientX - appState.interaction.lastMouse.x; const dy = e.clientY - appState.interaction.lastMouse.y; appState.view.panX += dx; appState.view.panY += dy; appState.interaction.lastMouse = { x: e.clientX, y: e.clientY }; updateTransform(); } }); window.addEventListener('mouseup', (e) => { // Se nao arrastou (clique curto) e clicou em um node, seleciona ele if (!isMouseDragging && mouseDownTarget) { const nodeContent = mouseDownTarget.closest('.node-content'); if (nodeContent && !nodeContent.classList.contains('node-image-node')) { const nodeId = nodeContent.dataset.id; appState.focusId = nodeId; render(); focusForWriting(nodeId); } else if (!mouseDownTarget.closest('#app')) { // Clique fora do app limpa o foco document.activeElement?.blur(); } } mouseDownTarget = null; isMouseDragging = false; appState.interaction.isDragging = false; app.style.cursor = 'grab'; }); // --- Touch Events for Panning --- let touchStartPos = { x: 0, y: 0 }; let touchMoved = false; app.addEventListener('touchstart', (e) => { if (e.touches.length !== 1) return; const touch = e.touches[0]; touchStartPos = { x: touch.clientX, y: touch.clientY }; touchMoved = false; }, { passive: true }); window.addEventListener('touchmove', (e) => { if (e.touches.length !== 1) return; if (!touchMoved) { const touch = e.touches[0]; const dx = touch.clientX - touchStartPos.x; const dy = touch.clientY - touchStartPos.y; if (Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) { touchMoved = true; appState.interaction.isDragging = true; appState.interaction.lastMouse = { x: touch.clientX, y: touch.clientY }; } else { return; } } if (appState.interaction.isDragging) { e.preventDefault(); const touch = e.touches[0]; const dx = touch.clientX - appState.interaction.lastMouse.x; const dy = touch.clientY - appState.interaction.lastMouse.y; appState.view.panX += dx; appState.view.panY += dy; appState.interaction.lastMouse = { x: touch.clientX, y: touch.clientY }; updateTransform(); } }, { passive: false }); window.addEventListener('touchend', () => { touchMoved = false; appState.interaction.isDragging = false; app.style.cursor = 'grab'; }); // Resize Observer para atualizar linhas se a janela mudar const resizeObserver = new ResizeObserver(() => { drawConnections(); }); resizeObserver.observe(app); // Atualizar linhas periodicamente para animações CSS suaves de tamanho de texto setInterval(drawConnections, 100); /** * ========================================== * IMAGE LIGHTBOX * ========================================== */ const lightboxOverlay = document.getElementById('image-lightbox-overlay'); const lightboxImage = document.getElementById('lightbox-image'); const lightboxClose = document.getElementById('lightbox-close'); let lightboxState = { scale: 1, panX: 0, panY: 0, isDragging: false, lastMouse: { x: 0, y: 0 } }; function openImageLightbox(imageUrl) { // Reseta o estado antes de abrir lightboxState = { scale: 1, panX: 0, panY: 0, isDragging: false, lastMouse: { x: 0, y: 0 } }; lightboxImage.style.transform = `translate(0px, 0px) scale(1)`; lightboxImage.src = imageUrl; lightboxOverlay.classList.remove('hidden'); window.addEventListener('keydown', handleLightboxKeydown); // Adiciona listener de teclado } function closeImageLightbox() { lightboxOverlay.classList.add('hidden'); window.removeEventListener('keydown', handleLightboxKeydown); // Remove para não interferir } function updateLightboxTransform() { lightboxImage.style.transform = `translate(${lightboxState.panX}px, ${lightboxState.panY}px) scale(${lightboxState.scale})`; } lightboxClose.addEventListener('click', closeImageLightbox); lightboxOverlay.addEventListener('click', (e) => { // Fecha se clicar no fundo, mas não na imagem if (e.target === lightboxOverlay) { closeImageLightbox(); } }); lightboxImage.addEventListener('wheel', (e) => { e.preventDefault(); const zoomIntensity = 0.2; const delta = -Math.sign(e.deltaY) * zoomIntensity; const newScale = Math.max(1, lightboxState.scale + delta); // Não permite zoom menor que o original lightboxState.scale = newScale; updateLightboxTransform(); }); lightboxImage.addEventListener('mousedown', (e) => { if (lightboxState.scale > 1) { // Só permite arrastar se tiver zoom e.preventDefault(); lightboxState.isDragging = true; lightboxState.lastMouse = { x: e.clientX, y: e.clientY }; lightboxImage.classList.add('zoomed'); } }); window.addEventListener('mousemove', (e) => { if (lightboxState.isDragging) { const dx = e.clientX - lightboxState.lastMouse.x; const dy = e.clientY - lightboxState.lastMouse.y; lightboxState.panX += dx; lightboxState.panY += dy; lightboxState.lastMouse = { x: e.clientX, y: e.clientY }; updateLightboxTransform(); } }); window.addEventListener('mouseup', () => { lightboxState.isDragging = false; lightboxImage.classList.remove('zoomed'); }); /** * ========================================== * MANIPULAÇÃO DA ÁRVORE (LOGIC) * ========================================== */ // Encontra um nó e seu pai pelo ID function findNodeAndParent(node, id, parent = null) { if (node.id === id) return { node, parent }; for (let child of node.children) { const result = findNodeAndParent(child, id, node); if (result) return result; } return null; } // Adiciona filho function addChild(parentId) { saveStateForUndo(); const { node } = findNodeAndParent(appState.tree, parentId); if (!node) return; const newId = generateId(); node.children.push({ id: newId, text: "", type: "text", children: [], imageUrl: null, widthLevel: 2 }); appState.focusId = newId; render(); scheduleAutoSave(); focusForWriting(newId); } // Adiciona irmão function addSibling(currentId) { saveStateForUndo(); const { node, parent } = findNodeAndParent(appState.tree, currentId); // Se for a raiz, não tem irmão, adiciona filho if (!parent) { addChild(currentId); return; } const index = parent.children.findIndex(c => c.id === currentId); const newId = generateId(); // Insere logo após o nó atual parent.children.splice(index + 1, 0, { id: newId, text: "", type: "text", children: [], imageUrl: null, widthLevel: 2 }); appState.focusId = newId; render(); scheduleAutoSave(); focusForWriting(newId); } // Remove nó function deleteNode(id) { // Se o nó a ser deletado estava pendente, limpa o estado if (appState.interaction.nodePendingDeletion === id) { appState.interaction.nodePendingDeletion = null; } saveStateForUndo(); const { node, parent } = findNodeAndParent(appState.tree, id); if (!parent) return; // Não deleta a raiz // Se o nó que está sendo deletado for o pai de um nó pendente, cancela a pendência if (node.children.some(child => child.id === appState.interaction.nodePendingDeletion)) { appState.interaction.nodePendingDeletion = null; } // Se o nó é do tipo imagem e tem imageId, deleta do servidor deleteServerImage(node); const index = parent.children.findIndex(c => c.id === id); // Decide quem ganha foco: o irmão anterior, ou o pai let nextFocusId = parent.id; if (index > 0) nextFocusId = parent.children[index - 1].id; parent.children.splice(index, 1); appState.focusId = nextFocusId; render(); scheduleAutoSave(); focusForWriting(nextFocusId); } // Deleta imagem do servidor (se aplicável) function deleteServerImage(node) { if (node.type === 'image' && node.imageId && appState.serverId) { fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'delete_image', id: node.imageId }) }).catch(() => {}); } // Também deleta imagens dos filhos recursivamente node.children.forEach(child => deleteServerImage(child)); } // Atualiza texto function updateText(id, newText) { // Se o usuário começar a digitar em um nó pendente de exclusão, cancela a exclusão. if (appState.interaction.nodePendingDeletion === id) { appState.interaction.nodePendingDeletion = null; render(); // Re-renderiza para remover o estilo de aviso } // Se for a primeira digitação no nó inicial, limpa o texto e re-renderiza if (appState.history.isInitialPlaceholder && id === 'root') { const { node } = findNodeAndParent(appState.tree, id); if (node) { node.text = newText.slice(-1); // Pega só o último caractere digitado } appState.history.isInitialPlaceholder = false; // Desativa a flag render(); // Força a re-renderização para evitar o bug de caractere duplicado return; // Interrompe a execução para não continuar com a lógica antiga } // Debounce para salvar o estado no histórico. // Isso evita salvar um estado para cada caractere digitado. clearTimeout(appState.history.undoTimeout); appState.history.undoTimeout = setTimeout(() => { saveStateForUndo(); }, 300); // Salva 300ms depois que o usuário para de digitar const el = document.getElementById(`node-${id}`); const { node } = findNodeAndParent(appState.tree, id); if (node && el) { // Salva a posição do cursor ANTES de atualizar o estado e re-renderizar appState.interaction.cursorPosition = saveCursorPosition(el); node.text = el.innerHTML; // Pega o texto atual do DOM // Força a re-renderização para corrigir o desfoque do zoom render(); scheduleAutoSave(); } else { clearTimeout(appState.history.undoTimeout); // Se o nó não for encontrado, cancela o salvamento } } // Adiciona filho do tipo imagem function addImageChild(parentId, imageUrl) { saveStateForUndo(); const { node } = findNodeAndParent(appState.tree, parentId); if (!node) return; const newId = generateId(); // Extrai image_id da URL se for uma imagem do servidor let imageId = null; const urlMatch = imageUrl.match(/id=(\d+)/); if (urlMatch) imageId = parseInt(urlMatch[1]); node.children.push({ id: newId, text: "", type: "image", imageUrl: imageUrl, imageId: imageId, children: [], widthLevel: 2 }); appState.focusId = newId; render(); scheduleAutoSave(); } // Adiciona ou atualiza a imagem de um nó (mantido para compatibilidade via Ctrl+U) function updateNodeImage(id, imageUrl) { saveStateForUndo(); const { node } = findNodeAndParent(appState.tree, id); if (node) { node.type = "image"; node.imageUrl = imageUrl; appState.focusId = id; render(); scheduleAutoSave(); focusForWriting(id); } } // Função para atualizar a largura do nó function updateNodeWidth(id, sizeLevel) { saveStateForUndo(); const { node } = findNodeAndParent(appState.tree, id); if (node) { node.widthLevel = sizeLevel; appState.focusId = id; // Garante o foco render(); scheduleAutoSave(); focusForWriting(id); } } // Move um nó para cima na lista de irmãos function moveNodeUp(id) { saveStateForUndo(); const { node, parent } = findNodeAndParent(appState.tree, id); if (!parent) return; // Raiz não pode se mover const index = parent.children.findIndex(c => c.id === id); if (index > 0) { // Troca de posição com o irmão anterior [parent.children[index], parent.children[index - 1]] = [parent.children[index - 1], parent.children[index]]; appState.focusId = id; // Mantém o foco no nó movido render(); scheduleAutoSave(); focusForWriting(id); } } // Move um nó para baixo na lista de irmãos function moveNodeDown(id) { saveStateForUndo(); const { node, parent } = findNodeAndParent(appState.tree, id); if (!parent) return; // Raiz não pode se mover const index = parent.children.findIndex(c => c.id === id); if (index < parent.children.length - 1) { // Troca de posição com o próximo irmão [parent.children[index], parent.children[index + 1]] = [parent.children[index + 1], parent.children[index]]; appState.focusId = id; render(); scheduleAutoSave(); focusForWriting(id); } } // Promove o nó (sobe um nível, vira irmão do pai) function promoteNode(id) { saveStateForUndo(); const { node, parent } = findNodeAndParent(appState.tree, id); if (!parent) return; // Raiz não pode ser promovida if (!parent) return; // A própria raiz não pode ser promovida. const grandparentResult = findNodeAndParent(appState.tree, parent.id); if (!grandparentResult || !grandparentResult.parent) return; // Pai é a raiz, não pode promover mais // CASO ESPECIAL: O pai do nó é a raiz. Vamos inverter a relação. if (parent.id === 'root') { const nodeToPromote = node; const oldRoot = parent; const grandparent = grandparentResult.parent; // 1. Remove o nó da lista de filhos do pai const nodeIndexInParent = parent.children.findIndex(c => c.id === id); const [nodeToMove] = parent.children.splice(nodeIndexInParent, 1); // 1. Remove o nó a ser promovido da lista de filhos da raiz antiga. const nodeIndex = oldRoot.children.findIndex(c => c.id === id); oldRoot.children.splice(nodeIndex, 1); // 2. Encontra a posição do pai no avô para inserir o nó logo após const parentIndexInGrandparent = grandparent.children.findIndex(c => c.id === parent.id); grandparent.children.splice(parentIndexInGrandparent + 1, 0, nodeToMove); // 2. Os irmãos do nó promovido agora se tornam seus filhos. nodeToPromote.children.push(...oldRoot.children); // 3. A raiz antiga agora se torna filha do nó promovido. oldRoot.children = []; // Limpa os filhos da raiz antiga. nodeToPromote.children.unshift(oldRoot); // Adiciona a raiz antiga como primeiro filho. // 4. O nó promovido é a nova raiz da árvore. appState.tree = nodeToPromote; } else { // LÓGICA ORIGINAL: Promover para ser irmão do pai. const grandparentResult = findNodeAndParent(appState.tree, parent.id); if (!grandparentResult || !grandparentResult.parent) return; // Proteção extra const grandparent = grandparentResult.parent; const nodeIndexInParent = parent.children.findIndex(c => c.id === id); const [nodeToMove] = parent.children.splice(nodeIndexInParent, 1); const parentIndexInGrandparent = grandparent.children.findIndex(c => c.id === parent.id); grandparent.children.splice(parentIndexInGrandparent + 1, 0, nodeToMove); } appState.focusId = id; render(); scheduleAutoSave(); focusForWriting(id); } // Rebaixa o nó (desce um nível, vira filho do irmão anterior) function demoteNode(id) { saveStateForUndo(); const { node, parent } = findNodeAndParent(appState.tree, id); if (!parent) return; // Raiz não pode ser rebaixada const nodeIndex = parent.children.findIndex(c => c.id === id); if (nodeIndex === 0) return; // Não pode ser rebaixado se for o primeiro filho const newParent = parent.children[nodeIndex - 1]; // 1. Remove o nó da lista de filhos do pai atual const [nodeToMove] = parent.children.splice(nodeIndex, 1); // 2. Adiciona o nó à lista de filhos do novo pai (irmão anterior) newParent.children.push(nodeToMove); appState.focusId = id; render(); scheduleAutoSave(); focusForWriting(id); } function restructureNode(id, key) { switch (key) { case 'ArrowUp': return moveNodeUp(id); case 'ArrowDown': return moveNodeDown(id); case 'ArrowLeft': return promoteNode(id); case 'ArrowRight': return demoteNode(id); } } /** * ========================================== * LÓGICA DE ARQUIVOS (SALVAR/CARREGAR) * ========================================== */ function saveMapToFile() { const dataToSave = { projectName: appState.projectName, tree: appState.tree }; const jsonString = JSON.stringify(dataToSave, null, 2); // Formatação para legibilidade const blob = new Blob([jsonString], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; // ALTERADO: Salva com a extensão .dashmap a.download = `${appState.projectName.replace(/[^a-z0-9]/gi, '_').toLowerCase() || 'mapa_mental'}.dashmap`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); closeMainMenu(); } function loadMapFromFile() { const input = document.createElement('input'); input.type = 'file'; // ALTERADO: Aceita .dashmap e .json input.accept = '.dashmap,.json,application/json'; input.onchange = e => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = readerEvent => { try { const content = JSON.parse(readerEvent.target.result); // Validação simples if (content && content.tree && content.tree.id) { appState.tree = content.tree; appState.projectName = content.projectName || 'Mapa Carregado'; document.getElementById('project-name-input').value = appState.projectName; appState.history.isInitialPlaceholder = false; scheduleAutoSave(); // Salva o estado recém-carregado render(); } } catch (error) { console.error("Erro ao carregar o arquivo:", error); alert("Arquivo inválido."); } }; reader.readAsText(file); }; input.click(); closeMainMenu(); } /** * ========================================== * LÓGICA DE NAVEGAÇÃO DA VIEWPORT * ========================================== */ function centerOnNode(nodeId, smooth = true) { if (!nodeId) return; const nodeEl = document.getElementById(`node-${nodeId}`); if (!nodeEl) return; const nodeRect = nodeEl.getBoundingClientRect(); const viewportCenterX = window.innerWidth / 2; const viewportCenterY = window.innerHeight / 2; const nodeCenterX = nodeRect.left + nodeRect.width / 2; const nodeCenterY = nodeRect.top + nodeRect.height / 2; const dx = viewportCenterX - nodeCenterX; const dy = viewportCenterY - nodeCenterY; // Se o movimento for desprezível, ignoramos para evitar micro-ajustes if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return; appState.view.panX += dx; appState.view.panY += dy; if (smooth) { const world = document.getElementById('world'); const appBg = document.getElementById('app'); world.style.transition = 'transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1)'; if (appBg) appBg.style.transition = 'background-position 0.3s cubic-bezier(0.2, 0.8, 0.2, 1)'; updateTransform(); setTimeout(() => { world.style.transition = 'none'; if (appBg) appBg.style.transition = 'none'; }, 300); } else { updateTransform(); } scheduleAutoSave(); } function focusForWriting(nodeId) { // Só centraliza se o modo de auto-center estiver ativo if (!appState.view.autoCenter) return; // Usamos o timeout para garantir que o DOM já calculou as novas posições após um render() setTimeout(() => centerOnNode(nodeId, true), 10); } /** * ========================================== * RENDERIZAÇÃO DOM & SVG * ========================================== */ const nodesLayer = document.getElementById('nodes-layer'); const linksLayer = document.getElementById('links-layer'); const world = document.getElementById('world'); function createNodeElement(nodeData) { const isImage = nodeData.type === 'image'; // Wrapper const wrapper = document.createElement('div'); wrapper.className = 'node-wrapper'; wrapper.id = `wrapper-${nodeData.id}`; if (isImage) { // === NODE DO TIPO IMAGEM (apenas , sem contentEditable) === const img = document.createElement('img'); img.src = nodeData.imageUrl || 'data:image/svg+xml,'; img.className = 'node-content node-image-node'; img.id = `node-${nodeData.id}`; img.dataset.id = nodeData.id; img.tabIndex = 0; // Permite receber foco // OTIMIZAÇÃO: Lazy loading para evitar gargalo de memória no modo light/mobile img.loading = 'lazy'; let imageClickTimer = null; img.addEventListener('click', (e) => { e.stopPropagation(); // Delay para esperar possivel segundo clique if (imageClickTimer) { clearTimeout(imageClickTimer); imageClickTimer = null; // Segundo clique -> abre lightbox if (nodeData.imageUrl) openImageLightbox(nodeData.imageUrl); } else { imageClickTimer = setTimeout(() => { imageClickTimer = null; // Primeiro clique -> apenas seleciona appState.focusId = nodeData.id; render(); }, 250); } }); img.addEventListener('focus', () => { appState.focusId = nodeData.id; }); img.addEventListener('keydown', (e) => handleImageKeydown(e, nodeData.id)); // Container de filhos (para adicionar anotações após a imagem) const childrenContainer = document.createElement('div'); childrenContainer.className = 'node-children'; wrapper.appendChild(img); wrapper.appendChild(childrenContainer); nodeData.children.forEach(child => { childrenContainer.appendChild(createNodeElement(child)); }); return wrapper; } // === NODE DO TIPO TEXTO (comportamento original) === const content = document.createElement('div'); content.className = 'node-content'; content.contentEditable = true; content.innerHTML = nodeData.text; content.id = `node-${nodeData.id}`; content.dataset.id = nodeData.id; const widthLevel = nodeData.widthLevel || 2; content.classList.add(`node-width-${widthLevel}`); content.addEventListener('input', (e) => updateText(nodeData.id, e.target.innerHTML)); content.addEventListener('keydown', handleNodeKeydown); content.addEventListener('focus', () => { appState.focusId = nodeData.id; }); content.addEventListener('paste', (e) => { const items = (e.clipboardData || window.clipboardData).items; for (let index in items) { if (items[index].kind === 'file') return; } e.preventDefault(); const text = (e.clipboardData || window.clipboardData).getData('text/plain'); document.execCommand('insertText', false, text); }); content.addEventListener('paste', (e) => handleNodePaste(e, nodeData.id)); if (nodeData.id === appState.interaction.nodePendingDeletion) { content.classList.add('node-pending-deletion'); content.innerText = "Existem informações vinculadas. Pressione ENTER para apagar"; } const childrenContainer = document.createElement('div'); childrenContainer.className = 'node-children'; wrapper.appendChild(content); wrapper.appendChild(childrenContainer); nodeData.children.forEach(child => { childrenContainer.appendChild(createNodeElement(child)); }); return wrapper; } function render() { // 1. Limpa DOM nodesLayer.innerHTML = ''; // 2. Reconstrói Árvore const rootEl = createNodeElement(appState.tree); nodesLayer.appendChild(rootEl); // 3. Restaura Foco if (appState.focusId) { const el = document.getElementById(`node-${appState.focusId}`); if (el && document.activeElement !== el) { el.focus(); // Se não estivermos restaurando uma posição específica (ex: ao criar um novo nó), // movemos o cursor para o final. if (!appState.interaction.cursorPosition) { const range = document.createRange(); const sel = window.getSelection(); range.selectNodeContents(el); range.collapse(false); // Colapsa para o final sel.removeAllRanges(); sel.addRange(range); } } if (el && appState.interaction.cursorPosition) { restoreCursorPosition(el, appState.interaction.cursorPosition); appState.interaction.cursorPosition = null; // Limpa a posição salva } } // 4. Desenha Linhas (espera um tick para o layout calcular posições) if (!appState.view.isLightMode) { requestAnimationFrame(drawConnections); } } function drawConnections() { // Limpa linhas anteriores linksLayer.innerHTML = ''; // Adiciona a definição do marcador de seta (arrowhead) ao SVG // A definição do marcador será criada dinamicamente para cada linha agora. const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); // Container para os marcadores linksLayer.appendChild(defs); // Função recursiva para desenhar linhas de Pai -> Filhos function drawLinesForNode(node) { if (!node.children || node.children.length === 0) return; const parentEl = document.getElementById(`node-${node.id}`); if (!parentEl) return; const pRect = parentEl.getBoundingClientRect(); const worldRect = world.getBoundingClientRect(); const scale = appState.view.scale; // Ponto de saída X (Sempre na direita do pai) const pX = (pRect.right - worldRect.left) / scale; // --- NOVA LÓGICA PARA O PONTO Y (ALTURA) --- let pY; // Precisamos da referência do primeiro filho para saber a altura dele // (Assumindo que os filhos já estão ordenados visualmente, o que seu código já faz depois) // Vamos pegar o primeiro da lista de dados brutos mesmo, só para referência de altura. const targetChild = node.children[0]; const targetChildEl = document.getElementById(`node-${targetChild.id}`); if (targetChildEl) { const cRect = targetChildEl.getBoundingClientRect(); // Altura central do primeiro filho const targetCY = (cRect.top + cRect.height / 2 - worldRect.top) / scale; // Limites verticais do nó Pai const pTop = (pRect.top - worldRect.top) / scale; const pBottom = (pRect.bottom - worldRect.top) / scale; // Padding para a linha não sair exatamente na quina da borda const padding = 18; // O SEGUREDO: Tenta usar a altura do filho (targetCY). // Mas se targetCY for maior que o fundo do pai, usa o fundo com padding. // Se targetCY for menor que o topo do pai, usa o topo com padding. pY = Math.max(pTop + padding, Math.min(pBottom - padding, targetCY)); } else { // Fallback caso dê algo errado, usa o centro pY = (pRect.top + pRect.height / 2 - worldRect.top) / scale; } // 1. PREPARAÇÃO: Coletar e Ordenar os filhos visualmente (Y) // Isso garante que a linha desenhe de cima para baixo corretamente const childrenData = node.children.map(child => { const childEl = document.getElementById(`node-${child.id}`); if (!childEl) return null; const cRect = childEl.getBoundingClientRect(); return { child: child, id: child.id, cX: (cRect.left - worldRect.left) / scale, cY: (cRect.top + cRect.height / 2 - worldRect.top) / scale }; }).filter(Boolean).sort((a, b) => a.cY - b.cY); // Ordena pelo Y // Variável para rastrear onde a última linha "parou" verticalmente let previousBranchY = null; childrenData.forEach((item, index) => { const { child, id, cX, cY } = item; // --- EFEITO "FEITO À MÃO" (SEEDED RANDOMNESS) --- // (Mantive sua lógica original de randomização aqui) const seed = id; const seededRandom = (s) => { let h = 0; for(let i = 0; i < s.length; i++) { h = (Math.imul(31, h) + s.charCodeAt(i)) | 0; } return (h & 0x7fffffff) / 0x7fffffff; }; const randomId = `arrowhead-${id}`; const randomStrokeWidth = 1.0 + seededRandom(seed + 'sw') * 0.5; const randomArrowSize = 5 + seededRandom(seed + 'as') * 2; const roughness = 2; // Criação do Marker (Mantido igual) if (!document.getElementById(randomId)) { // Pequena otimização para não recriar se já existir const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker"); marker.id = randomId; marker.setAttribute("viewBox", "0 0 10 10"); marker.setAttribute("refX", "10"); marker.setAttribute("refY", "5"); marker.setAttribute("markerWidth", randomArrowSize); marker.setAttribute("markerHeight", randomArrowSize); marker.setAttribute("orient", "auto-start-reverse"); const arrowPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); arrowPath.setAttribute("d", "M 0 0 L 10 5 L 0 10"); arrowPath.setAttribute("fill", "none"); arrowPath.setAttribute("stroke", "var(--line-color)"); arrowPath.setAttribute("stroke-width", "1.5"); arrowPath.setAttribute("stroke-linecap", "round"); marker.appendChild(arrowPath); defs.appendChild(marker); } const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.classList.add('connection'); path.setAttribute('marker-end', `url(#${randomId})`); path.style.strokeWidth = `${randomStrokeWidth}px`; path.style.fill = "none"; // Importante para path não fechar // --- NOVA LÓGICA DE DESENHO EM CASCATA --- const cornerRadius = 10; const horizontalOffset = 13; const midX = pX + horizontalOffset; // O "Tronco" vertical fica aqui const roughen = (val, idx) => val + (seededRandom(seed + idx) - 0.5) * roughness; let d = ""; // Verificação: É o primeiro filho (topo) ou os seguintes? if (index === 0) { // --- PRIMEIRO FILHO: Conecta ao Pai --- // Desenha: Pai -> Direita -> Curva -> Baixo -> Curva -> Filho const midY1 = pY + (pY < cY ? cornerRadius : -cornerRadius); const midY2 = cY - (pY < cY ? cornerRadius : -cornerRadius); const sweepFlag1 = pY < cY ? 1 : 0; // Curva para baixo ou cima // Se estiver muito perto horizontalmente ou verticalmente, simplifica if (Math.abs(pY - cY) < cornerRadius * 2) { d = `M ${pX} ${pY} L ${midX} ${pY} L ${midX} ${cY} L ${cX} ${cY}`; } else { d = `M ${pX} ${pY} ` + // Sai do pai `L ${roughen(midX - cornerRadius, 1)} ${roughen(pY, 2)} ` + // Vai até o "cotovelo" `A ${cornerRadius} ${cornerRadius} 0 0 ${sweepFlag1} ${roughen(midX, 3)} ${roughen(midY1, 4)} ` + // Curva `L ${roughen(midX, 5)} ${roughen(midY2, 6)} ` + // Desce verticalmente `A ${cornerRadius} ${cornerRadius} 0 0 0 ${roughen(midX + cornerRadius, 7)} ${roughen(cY, 8)} ` + // Curva p/ direita `L ${cX} ${cY}`; // Vai pro filho } } else { // --- FILHOS SEGUINTES: Conecta ao Tronco Anterior --- // Começa no Tronco (midX) na altura do filho anterior (previousBranchY) // Ponto de início: midX, previousBranchY // A gente sobe um pouco (cornerRadius) para sobrepor a curva anterior e parecer contínuo const verticalOverlap = -23; // Valor positivo = sobrepõe linhas; Negativo = cria buraco entre linhas const startY = previousBranchY - verticalOverlap; const midY2 = cY - cornerRadius; d = `M ${roughen(midX, 10)} ${startY} ` + // Começa onde o anterior parou `L ${roughen(midX, 11)} ${roughen(midY2, 12)} ` + // Desce até a altura deste filho `A ${cornerRadius} ${cornerRadius} 0 0 0 ${roughen(midX + cornerRadius, 13)} ${roughen(cY, 14)} ` + // Curva p/ direita `L ${cX} ${cY}`; // Vai pro filho } path.setAttribute("d", d); linksLayer.appendChild(path); // Atualiza a referência Y para o próximo filho usar como ponto de partida previousBranchY = cY; // Recurse drawLinesForNode(child); }); } drawLinesForNode(appState.tree); } /** * ========================================== * INPUT & KEYBOARD (CORE FEATURE) * ========================================== */ /** * Faz upload de imagem para o servidor. * Se nao logado, mostra aviso e abre tela de login. */ function uploadImage(file, nodeId) { if (!appState.serverId) { showToast("Faça login para anexar imagens aos seus mapas.", true); openAuthModal(); return; } showToast("Enviando imagem..."); const formData = new FormData(); formData.append('file', file); formData.append('project_id', appState.serverId); fetch(`${API_URL}?action=upload_image`, { method: 'POST', body: formData }) .then(r => r.json()) .then(result => { if (result.status === 'success') { const imageUrl = `${API_URL}?action=download_image&id=${result.image_id}`; addImageChild(nodeId, imageUrl); } else { showToast(result.message || "Falha ao enviar imagem.", true); } }) .catch(() => { showToast("Erro de rede ao enviar imagem.", true); }); } /** * Abre o modal de login (expõe função privada do api.js) */ function openAuthModal() { const authModal = document.getElementById('auth-modal-overlay'); if (authModal) authModal.classList.remove('hidden'); } /** * Le arquivo como base64 (fallback para quando nao ha login) */ function readImageFile(file, callback) { const reader = new FileReader(); reader.onload = (e) => callback(e.target.result); reader.readAsDataURL(file); } function handleNodeKeydown(e) { const id = e.target.dataset.id; const { node } = findNodeAndParent(appState.tree, id); // **IMPORTANTE**: Salva o texto do nó atual no estado ANTES de qualquer outra ação. // Isso evita condições de corrida onde o foco é perdido antes do texto ser salvo. if (node) node.text = e.target.innerHTML; // Se um nó estava pendente e o usuário pressiona uma tecla que não seja ENTER, // cancelamos o estado de pendência antes de processar a nova ação. if (appState.interaction.nodePendingDeletion && e.key !== 'Enter') { const pendingId = appState.interaction.nodePendingDeletion; appState.interaction.nodePendingDeletion = null; // Se a tecla foi pressionada em um nó diferente, re-renderiza para limpar o aviso if (pendingId !== e.target.dataset.id) { render(); } } const isEmpty = e.target.innerText.trim() === ''; // TAB: Cria Filho if (e.key === 'Tab') { e.preventDefault(); addChild(id); } // ENTER: Cria Irmão OU Confirma Deleção else if (e.key === 'Enter') { e.preventDefault(); if (appState.interaction.nodePendingDeletion === id) { deleteNode(id); // Confirma e deleta } else { addSibling(id); // Cria irmão } } // BACKSPACE/DELETE: Remove se vazio else if (e.key === 'Backspace' || e.key === 'Delete') { if (isEmpty && node && node.children.length > 0) { e.preventDefault(); // Entra no modo de confirmação appState.interaction.nodePendingDeletion = id; render(); } else if (isEmpty) { e.preventDefault(); deleteNode(id); } } // ALT + SETAS: Reestrutura a árvore (caso mais específico primeiro) else if (e.altKey && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { // ALT + SETAS: Reestrutura a árvore e.preventDefault(); restructureNode(id, e.key); } // ALT + NÚMERO (1-6) PARA MUDAR TAMANHO else if (e.altKey && !isNaN(parseInt(e.key)) && parseInt(e.key) >= 1 && parseInt(e.key) <= 6) { e.preventDefault(); updateNodeWidth(id, parseInt(e.key)); } // CTRL/META + U: Adiciona Imagem else if ((e.ctrlKey || e.metaKey) && e.key === 'u') { e.preventDefault(); triggerImageUpload(id); } // CTRL/META + SETAS: Navegação de foco else if ((e.ctrlKey || e.metaKey) && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { // Não queremos prevenir o default se estiver editando texto (ex: mover cursor), // mas para a navegação entre nós, prevenimos. e.preventDefault(); navigateFocus(id, e.key); } } function handleNodePaste(e, id) { const items = (e.clipboardData || window.clipboardData).items; for (let index in items) { const item = items[index]; if (item.kind === 'file') { const blob = item.getAsFile(); if (blob.type.startsWith('image/')) { e.preventDefault(); uploadImage(blob, id); return; } } } } function handleImageKeydown(e, id) { // TAB: Cria Filho if (e.key === 'Tab') { e.preventDefault(); addChild(id); } // ENTER: Cria Irmão else if (e.key === 'Enter') { e.preventDefault(); addSibling(id); } // BACKSPACE/DELETE: Remove else if (e.key === 'Backspace' || e.key === 'Delete') { e.preventDefault(); deleteNode(id); } // ALT + SETAS: Reestrutura else if (e.altKey && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { e.preventDefault(); restructureNode(id, e.key); } // ALT + NÚMERO (1-6): Muda largura else if (e.altKey && !isNaN(parseInt(e.key)) && parseInt(e.key) >= 1 && parseInt(e.key) <= 6) { e.preventDefault(); updateNodeWidth(id, parseInt(e.key)); } // CTRL + SETAS: Navegação de foco else if ((e.ctrlKey || e.metaKey) && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { e.preventDefault(); navigateFocus(id, e.key); } } function triggerImageUpload(id) { const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.style.display = 'none'; input.onchange = (e) => { const file = e.target.files[0]; if (file) { uploadImage(file, id); } document.body.removeChild(input); }; document.body.appendChild(input); input.click(); } function navigateFocus(currentId, key) { const currentEl = document.getElementById(`node-${currentId}`); if (!currentEl) return; const allNodes = Array.from(document.querySelectorAll('.node-content')); const curRect = currentEl.getBoundingClientRect(); const curCx = curRect.left + curRect.width / 2; const curCy = curRect.top + curRect.height / 2; let bestCandidate = null; let minDist = Infinity; // Tolerância para erros de pixel ou bordas arredondadas const tolerance = 5; allNodes.forEach(node => { if (node.id === currentEl.id) return; const nRect = node.getBoundingClientRect(); const nCx = nRect.left + nRect.width / 2; const nCy = nRect.top + nRect.height / 2; let valid = false; let dist = Infinity; // Cálculos de Sobreposição (Overlap) // Isso verifica se "a sombra" de um cai sobre o outro const xOverlap = Math.max(0, Math.min(curRect.right, nRect.right) - Math.max(curRect.left, nRect.left)); const yOverlap = Math.max(0, Math.min(curRect.bottom, nRect.bottom) - Math.max(curRect.top, nRect.top)); switch (key) { case 'ArrowUp': if (nRect.bottom <= curRect.top + tolerance) { const vertDist = curRect.top - nRect.bottom; // Se houver overlap X, a distância horizontal é 0. // Se não, é a distância entre as bordas mais próximas. let horizDist = 0; if (xOverlap === 0) { horizDist = Math.min(Math.abs(curRect.left - nRect.right), Math.abs(curRect.right - nRect.left)); } // Adicionamos um pouquinho da diferença de centro como critério de desempate dist = vertDist + (horizDist * 1.5) + (Math.abs(nCx - curCx) * 0.1); valid = true; } break; case 'ArrowDown': if (nRect.top >= curRect.bottom - tolerance) { const vertDist = nRect.top - curRect.bottom; let horizDist = 0; if (xOverlap === 0) { // Sem sobreposição, calcula dist borda a borda horizDist = Math.min(Math.abs(curRect.left - nRect.right), Math.abs(curRect.right - nRect.left)); } // Se tiver overlap, horizDist é 0, então priorizamos puramente a proximidade vertical dist = vertDist + (horizDist * 1.5) + (Math.abs(nCx - curCx) * 0.1); valid = true; } break; case 'ArrowLeft': if (nRect.right <= curRect.left + tolerance) { const horizDist = curRect.left - nRect.right; let vertDist = 0; if (yOverlap === 0) { vertDist = Math.min(Math.abs(curRect.top - nRect.bottom), Math.abs(curRect.bottom - nRect.top)); } dist = horizDist + (vertDist * 1.5) + (Math.abs(nCy - curCy) * 0.1); valid = true; } break; case 'ArrowRight': if (nRect.left >= curRect.right - tolerance) { const horizDist = nRect.left - curRect.right; let vertDist = 0; if (yOverlap === 0) { vertDist = Math.min(Math.abs(curRect.top - nRect.bottom), Math.abs(curRect.bottom - nRect.top)); } dist = horizDist + (vertDist * 1.5) + (Math.abs(nCy - curCy) * 0.1); valid = true; } break; } if (valid && dist < minDist) { minDist = dist; bestCandidate = node; } }); if (bestCandidate) { bestCandidate.focus(); // --- CÓDIGO NOVO: Joga o cursor para o final --- const range = document.createRange(); const sel = window.getSelection(); // Seleciona todo o conteúdo do nó range.selectNodeContents(bestCandidate); // Colapsa o range para o final (false = fim, true = início) range.collapse(false); // Limpa seleções anteriores e aplica a nova sel.removeAllRanges(); sel.addRange(range); // ----------------------------------------------- appState.focusId = bestCandidate.dataset.id; focusForWriting(bestCandidate.dataset.id); } } // ========================================== // CAIXAS DE DIÁLOGO ELEGANTES // ========================================== function elegantPrompt(title, placeholder = "") { return new Promise((resolve) => { const overlay = document.getElementById('custom-prompt-overlay'); const titleEl = document.getElementById('custom-prompt-title'); const inputEl = document.getElementById('custom-prompt-input'); const btnConfirm = document.getElementById('custom-prompt-confirm'); const btnCancel = document.getElementById('custom-prompt-cancel'); titleEl.innerHTML = title; inputEl.placeholder = placeholder; inputEl.value = ""; overlay.classList.remove('hidden'); inputEl.focus(); const cleanup = () => { overlay.classList.add('hidden'); btnConfirm.removeEventListener('click', onConfirm); btnCancel.removeEventListener('click', onCancel); inputEl.removeEventListener('keydown', onKey); }; const onConfirm = () => { cleanup(); resolve(inputEl.value); }; const onCancel = () => { cleanup(); resolve(null); }; const onKey = (e) => { if(e.key === 'Enter') onConfirm(); if(e.key === 'Escape') onCancel(); }; btnConfirm.addEventListener('click', onConfirm); btnCancel.addEventListener('click', onCancel); inputEl.addEventListener('keydown', onKey); }); } function elegantConfirm(title, message, yesLabel = "Confirmar", cancelLabel = "Cancelar") { return new Promise((resolve) => { const overlay = document.getElementById('custom-confirm-overlay'); const titleEl = document.getElementById('custom-confirm-title'); const msgEl = document.getElementById('custom-confirm-message'); const btnYes = document.getElementById('custom-confirm-yes'); const btnCancel = document.getElementById('custom-confirm-cancel'); btnYes.innerText = yesLabel; btnCancel.innerText = cancelLabel; titleEl.innerText = title; msgEl.innerText = message; overlay.classList.remove('hidden'); const cleanup = () => { overlay.classList.add('hidden'); btnYes.removeEventListener('click', onYes); btnCancel.removeEventListener('click', onCancel); }; const onYes = () => { cleanup(); resolve(true); }; const onCancel = () => { cleanup(); resolve(false); }; btnYes.addEventListener('click', onYes); btnCancel.addEventListener('click', onCancel); }); } /** * Salva a posição atual do cursor (caret) dentro de um elemento contenteditable. */ function saveCursorPosition(element) { const selection = window.getSelection(); if (selection.rangeCount === 0) return null; const range = selection.getRangeAt(0); const preSelectionRange = range.cloneRange(); preSelectionRange.selectNodeContents(element); preSelectionRange.setEnd(range.startContainer, range.startOffset); return preSelectionRange.toString().length; } /** * Restaura a posição do cursor em um elemento contenteditable. * (Caso o seu updateText ou render precisem usar isso depois) */ function restoreCursorPosition(element, savedPosition) { if (savedPosition === null) return; const selection = window.getSelection(); const range = document.createRange(); range.setStart(element, 0); range.collapse(true); let nodeStack = [element]; let node, foundStart = false, stop = false; let charIndex = 0; while (!stop && (node = nodeStack.pop())) { if (node.nodeType === 3) { // Node de texto const nextCharIndex = charIndex + node.length; if (!foundStart && savedPosition >= charIndex && savedPosition <= nextCharIndex) { range.setStart(node, savedPosition - charIndex); range.collapse(true); stop = true; } charIndex = nextCharIndex; } else { let i = node.childNodes.length; while (i--) { nodeStack.push(node.childNodes[i]); } } } selection.removeAllRanges(); selection.addRange(range); } /** * ========================================== * SISTEMA DE AUTENTICAÇÃO (API) * ========================================== */ const API_URL = '/backend/api.php'; let isLoginMode = true; let isSyncWarningActive = false; let isCloudActionActive = false; // Bloqueia edição em nós enquanto overlay de sync está ativo window.addEventListener('keydown', (e) => { if (isSyncWarningActive && e.target.classList.contains('node-content')) { e.preventDefault(); e.stopPropagation(); } }, true); // Elementos const authModal = document.getElementById('auth-modal-overlay'); const btnMenuLogin = document.getElementById('menu-login'); const btnMenuLogout = document.getElementById('menu-logout'); const sidebarProfile = document.getElementById('sidebar-profile'); const sidebarLoginItem = document.getElementById('menu-login'); const userNameDisplay = document.getElementById('user-name-display'); const menuCloudDash = document.getElementById('menu-cloud-dashboard'); const btnMenuSaveCloud = document.getElementById('menu-save-cloud'); // Abrir Modal if (btnMenuLogin) { btnMenuLogin.addEventListener('click', () => { authModal.classList.remove('hidden'); }); } // Fechar Modal document.getElementById('auth-modal-close').addEventListener('click', () => authModal.classList.add('hidden')); authModal.addEventListener('click', (e) => { if (e.target === authModal) authModal.classList.add('hidden'); }); // Alternar entre Login e Registro document.getElementById('auth-toggle-link').addEventListener('click', (e) => { e.preventDefault(); isLoginMode = !isLoginMode; document.getElementById('auth-title').innerText = isLoginMode ? 'Acessar a Nuvem' : 'Criar Conta'; document.getElementById('auth-submit-btn').innerText = isLoginMode ? 'Entrar' : 'Registrar'; document.getElementById('auth-toggle-msg').innerText = isLoginMode ? 'Não tem uma conta?' : 'Já tem uma conta?'; document.getElementById('auth-toggle-link').innerText = isLoginMode ? 'Registre-se' : 'Faça Login'; const groupNome = document.getElementById('group-nome'); if (isLoginMode) { groupNome.classList.add('hidden'); document.getElementById('auth-nome').removeAttribute('required'); } else { groupNome.classList.remove('hidden'); document.getElementById('auth-nome').setAttribute('required', 'true'); } document.getElementById('auth-error').classList.add('hidden'); }); const sidebarCloudSection = document.getElementById('sidebar-cloud-section'); function updateAuthUI(isAuthenticated, userName = '') { if (isAuthenticated) { if (sidebarLoginItem) sidebarLoginItem.classList.add('hidden'); if (sidebarProfile) sidebarProfile.classList.remove('hidden'); if (btnMenuLogout) btnMenuLogout.classList.remove('hidden'); if (sidebarCloudSection) sidebarCloudSection.classList.remove('hidden'); userNameDisplay.innerText = userName; } else { if (sidebarLoginItem) sidebarLoginItem.classList.remove('hidden'); if (sidebarProfile) sidebarProfile.classList.add('hidden'); if (btnMenuLogout) btnMenuLogout.classList.add('hidden'); if (sidebarCloudSection) sidebarCloudSection.classList.add('hidden'); } } function getAuthHeaders() { const token = localStorage.getItem('dashmap_remember_token'); if (token) { return { 'Authorization': `Bearer ${token}` }; } return {}; } async function checkAuthStatus() { try { const response = await fetch(`${API_URL}?action=check_auth`, { headers: getAuthHeaders() }); const result = await response.json(); if (result.status === 'authenticated') { updateAuthUI(true, result.user.nome); } else { updateAuthUI(false); } } catch (error) { console.error("Erro ao verificar autenticação:", error); } } // Submit document.getElementById('auth-form').addEventListener('submit', async (e) => { e.preventDefault(); const email = document.getElementById('auth-email').value; const senha = document.getElementById('auth-senha').value; const nome = document.getElementById('auth-nome').value; const errorMsg = document.getElementById('auth-error'); const action = isLoginMode ? 'login' : 'register'; const payload = { action, email, senha }; const submitBtn = document.getElementById('auth-submit-btn'); const originalText = submitBtn.innerText; if (!isLoginMode) payload.nome = nome; // Adiciona opção "lembrar de mim" apenas no login if (isLoginMode) { const rememberCheckbox = document.getElementById('auth-remember'); payload.remember = rememberCheckbox ? rememberCheckbox.checked : true; } try { submitBtn.disabled = true; submitBtn.innerText = 'Processando...'; const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const result = await response.json(); if (result.status === 'success') { // Salva o token se veio na resposta (modo "lembrar de mim") if (result.token) { localStorage.setItem('dashmap_remember_token', result.token); } authModal.classList.add('hidden'); checkAuthStatus(); document.getElementById('auth-senha').value = ''; errorMsg.classList.add('hidden'); showToast(result.message); } else if (result.status === 'verification_required') { authModal.classList.add('hidden'); document.getElementById('auth-senha').value = ''; errorMsg.classList.add('hidden'); // Chama o prompt elegante para pedir o código handleVerificationFlow(result.email); } else { errorMsg.innerText = result.message; errorMsg.classList.remove('hidden'); } } catch (error) { errorMsg.innerText = "Erro ao conectar com o servidor."; errorMsg.classList.remove('hidden'); } finally { submitBtn.disabled = false; submitBtn.innerText = originalText; } }); /** * Lógica de UI para pedir o código de verificação */ async function handleVerificationFlow(email) { const promptTitle = `Verificação de E-mail
Digite o código enviado para ${email}`; const codigo = await elegantPrompt(promptTitle, "Código de 6 dígitos"); if (codigo) { try { const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'verify_email', email, codigo }) }); const result = await response.json(); if (result.status === 'success') { // Salva o token se veio na resposta (modo "lembrar de mim") if (result.token) { localStorage.setItem('dashmap_remember_token', result.token); } authModal.classList.add('hidden'); checkAuthStatus(); showToast(result.message); } else { showToast(result.message, true); handleVerificationFlow(email); // Tenta novamente se errar } } catch (e) { showToast("Erro ao verificar código.", true); } } else { // Se o usuário fechar o prompt ou cancelar, oferecemos a opção de reenviar const querReenviar = await elegantConfirm("Não recebeu o código?", "Deseja que enviamos um novo código de verificação para o seu e-mail?"); if (querReenviar) { showToast("Reenviando código..."); try { const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'resend_code', email }) }); const result = await response.json(); showToast(result.message, result.status !== 'success'); if (result.status === 'success') { handleVerificationFlow(email); // Reabre o prompt após o envio } } catch (e) { showToast("Erro ao processar reenvio.", true); } } } } // Logout btnMenuLogout.addEventListener('click', async () => { try { // Inclui o token no header para invalidar no backend const headers = { 'Content-Type': 'application/json', ...getAuthHeaders() }; const token = localStorage.getItem('dashmap_remember_token'); const response = await fetch(API_URL + '?action=logout', { method: 'POST', headers: headers, body: token ? JSON.stringify({ remember_token: token }) : '{}' }); // Limpa o token do localStorage localStorage.removeItem('dashmap_remember_token'); const result = await response.json(); if (result.status === 'success') { updateAuthUI(false); resetToNewProject(); showToast("Você saiu da conta com segurança."); } } catch (error) { showToast("Erro ao sair da conta.", true); } }); checkAuthStatus(); /** * ========================================== * SISTEMA DE ENVIO DE ARQUIVOS AO SERVER * ========================================== */ /** * Converte imagens base64 da árvore em uploads no servidor. */ async function convertBase64ImagesToCloud(tree, projectId) { let convertedCount = 0; async function convertNode(node) { if (node.type === 'image' && node.imageUrl && node.imageUrl.startsWith('data:')) { try { const mimeMatch = node.imageUrl.match(/^data:(image\/\w+);base64,/); const mimeType = mimeMatch ? mimeMatch[1] : 'image/jpeg'; const bytes = atob(node.imageUrl.split(',')[1]); const array = new Uint8Array(bytes.length); for (let i = 0; i < bytes.length; i++) array[i] = bytes.charCodeAt(i); const file = new File([array], `image_${convertedCount}.${mimeType.split('/')[1]}`, { type: mimeType }); const formData = new FormData(); formData.append('file', file); formData.append('project_id', projectId); const response = await fetch(`${API_URL}?action=upload_image`, { method: 'POST', body: formData }); const result = await response.json(); if (result.status === 'success') { node.imageUrl = `${API_URL}?action=download_image&id=${result.image_id}`; node.imageId = result.image_id; convertedCount++; } } catch (e) { console.warn('Falha ao converter imagem para cloud:', e); } } if (node.children) { for (const child of node.children) await convertNode(child); } } await convertNode(tree); return convertedCount; } // Salvar mapa na nuvem async function saveMapToCloud(isSilent = false) { if (!isSilent) showToast("Salvando na nuvem..."); const treeToSave = structuredClone(appState.tree); const payload = { action: 'save_project', id: appState.serverId, nome: appState.projectName, data_mapa: treeToSave, lastSyncTime: appState.lastSyncTime }; try { const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const result = await response.json(); if (result.status === 'conflict') { showSyncWarningModal(); return; } if (result.status === 'success') { const isNewProject = !appState.serverId; if (isNewProject) appState.serverId = result.id; if (result.newSyncTime) appState.lastSyncTime = result.newSyncTime; hasUnsavedChanges = false; if (isNewProject) { const converted = await convertBase64ImagesToCloud(appState.tree, appState.serverId); if (converted > 0) { showToast(`${converted} imagem(ns) convertida(s) para armazenamento na nuvem!`); scheduleAutoSave(); } } if (!isSilent) showToast("Projeto salvo na nuvem com sucesso!"); } else { if (!isSilent) showToast(result.message, true); } } catch (error) { if (!isSilent) showToast("Erro ao comunicar com o servidor.", true); } } btnMenuSaveCloud.addEventListener('click', () => { saveMapToCloud(); }); /** * ========================================== * SISTEMA DE DASHBOARD * ========================================== */ const cloudModal = document.getElementById('cloud-modal-overlay'); const cloudFileList = document.getElementById('cloud-file-list'); // Click dentro do modal não fecha const cloudModalContent = document.getElementById('cloud-modal-content'); if (cloudModalContent) cloudModalContent.addEventListener('click', (e) => e.stopPropagation()); document.getElementById('cloud-modal-close').addEventListener('click', () => cloudModal.classList.add('hidden')); cloudModal.addEventListener('click', (e) => { if (e.target === cloudModal) cloudModal.classList.add('hidden'); }); menuCloudDash.addEventListener('click', () => { cloudModal.classList.remove('hidden'); fetchCloudProjects(); }); // Busca const cloudSearch = document.getElementById('cloud-search'); let cloudProjectsCache = []; async function fetchCloudProjects() { cloudFileList.innerHTML = '
Carregando...
'; try { const response = await fetch(`${API_URL}?action=list_projects`); const result = await response.json(); if (result.status === 'success') { const items = result.items; if (items.length === 0) { cloudFileList.innerHTML = '
Nenhum projeto salvo ainda.
'; return; } const itemMap = {}; const tree = []; items.forEach(item => { item.children = []; itemMap[item.id] = item; }); items.forEach(item => { if (item.parent_id && itemMap[item.parent_id]) { itemMap[item.parent_id].children.push(item); } else { tree.push(item); } }); cloudProjectsCache = tree; cloudFileList.innerHTML = ''; renderCloudTree(tree, cloudFileList); } else { cloudFileList.innerHTML = `
Erro: ${result.message}
`; } } catch (error) { cloudFileList.innerHTML = '
Erro ao conectar com servidor.
'; } } // Busca por nome if (cloudSearch) { cloudSearch.addEventListener('input', (e) => { const query = e.target.value.toLowerCase().trim(); cloudFileList.innerHTML = ''; if (!query) { renderCloudTree(cloudProjectsCache, cloudFileList); return; } function filterTree(items) { const result = []; for (const item of items) { if (item.nome.toLowerCase().includes(query)) { result.push(item); } else { const filteredChildren = filterTree(item.children); if (filteredChildren.length > 0) { result.push({ ...item, children: filteredChildren }); } } } return result; } renderCloudTree(filterTree(cloudProjectsCache), cloudFileList); }); } // Ícones SVG const folderIconSvg = ``; const editIconSvg = ``; const fileIconSvg = ``; const trashIconSvg = ``; const chevronSvg = ``; function countDescendants(item) { if (!item.children || item.children.length === 0) return 0; let count = 0; for (const child of item.children) { count += 1 + countDescendants(child); } return count; } function renderCloudTree(items, container, level = 0) { items.forEach(item => { const row = document.createElement('div'); row.className = 'cloud-row'; row.draggable = true; if (level > 0) { row.style.paddingLeft = `${28 + level * 24}px`; } const dateObj = new Date(item.atualizado_em); const dateStr = dateObj.toLocaleDateString('pt-BR'); const isFolder = item.is_folder == 1; const iconSvg = isFolder ? folderIconSvg : fileIconSvg; const iconClass = isFolder ? 'folder-icon' : 'file-icon'; let sizeText = ''; if (isFolder) { const count = countDescendants(item); sizeText = `${count} ${count === 1 ? 'item' : 'itens'}`; } else { sizeText = '—'; } const arrowHtml = isFolder ? `${chevronSvg}` : ''; row.innerHTML = `
${arrowHtml} ${iconSvg} ${item.nome}
${dateStr}
${sizeText}
`; container.appendChild(row); // Drag row.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/plain', item.id); row.classList.add('dragging'); }); row.addEventListener('dragend', () => row.classList.remove('dragging')); if (isFolder) { const childrenContainer = document.createElement('div'); childrenContainer.className = 'cloud-children'; container.appendChild(childrenContainer); if (item.children.length > 0) { renderCloudTree(item.children, childrenContainer, level + 1); } row.addEventListener('click', (e) => { if (e.target.closest('.cloud-action-btn')) return; childrenContainer.classList.toggle('open'); const arrow = row.querySelector('.cloud-folder-toggle'); if (arrow) arrow.classList.toggle('expanded'); }); row.addEventListener('dragover', (e) => { e.preventDefault(); row.classList.add('drag-over'); }); row.addEventListener('dragleave', () => row.classList.remove('drag-over')); row.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); row.classList.remove('drag-over'); const draggedId = e.dataTransfer.getData('text/plain'); if (draggedId == item.id) return; moveCloudItem(draggedId, item.id); }); } else { row.addEventListener('click', (e) => { if (e.target.closest('.cloud-action-btn')) return; loadCloudProject(item.id); }); } // Deletar const deleteBtn = row.querySelector('.delete'); deleteBtn.addEventListener('click', async (e) => { e.stopPropagation(); const msg = isFolder ? `Tem certeza que deseja deletar a pasta "${item.nome}" e todo o seu conteúdo?` : `Tem certeza que deseja deletar "${item.nome}"?`; const confirmou = await elegantConfirm("Confirmar Exclusão", msg, "Deletar"); if (confirmou) deleteCloudItem(item.id); }); // Renomear const renameBtn = row.querySelector('.rename'); renameBtn.addEventListener('click', async (e) => { e.stopPropagation(); const tipo = isFolder ? 'pasta' : 'mapa'; const novoNome = await elegantPrompt(`Renomear ${tipo}:`, item.nome); if (novoNome && novoNome.trim() !== '' && novoNome.trim() !== item.nome) { renameCloudItem(item.id, novoNome.trim()); } }); async function renameCloudItem(itemId, newName) { isCloudActionActive = true; showToast("Renomeando..."); try { const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'rename_item', id: itemId, nome: newName }) }); const result = await response.json(); if (result.status === 'success') { showToast("Renomeado com sucesso!"); if (appState.serverId == itemId) { appState.projectName = newName; document.getElementById('project-name-input').value = newName; try { const resVer = await fetch(`${API_URL}?action=check_version&id=${itemId}`); const ver = await resVer.json(); if (ver.status === 'success') appState.lastSyncTime = ver.atualizado_em; } catch(e) {} } fetchCloudProjects(); } else { showToast(result.message, true); } } catch (error) { showToast("Erro ao renomear item.", true); } isCloudActionActive = false; } }); } async function loadCloudProject(projectId, isSilent = false) { if (!isSilent) showToast("Carregando mapa..."); cloudModal.classList.add('hidden'); try { const response = await fetch(`${API_URL}?action=load_project&id=${projectId}`); const result = await response.json(); if (result.status === 'success') { appState.serverId = result.project.id; appState.projectName = result.project.nome; appState.lastSyncTime = result.project.atualizado_em; let treeData = typeof result.project.data_mapa === 'string' ? JSON.parse(result.project.data_mapa) : result.project.data_mapa; function normalizeTree(node) { if (!node.type) node.type = node.imageUrl ? 'image' : 'text'; if (node.type === 'image' && !node.imageId) { const urlMatch = node.imageUrl && node.imageUrl.match(/id=(\d+)/); node.imageId = urlMatch ? parseInt(urlMatch[1]) : null; } if (node.children) node.children.forEach(normalizeTree); } normalizeTree(treeData); appState.tree = treeData; if (typeof originalProjectName !== 'undefined') originalProjectName = appState.projectName; document.getElementById('project-name-input').value = appState.projectName; scheduleAutoSave(); if (!isSilent) { appState.view = { scale: 1, panX: window.innerWidth / 2 - 100, panY: window.innerHeight / 2 - 50 }; appState.focusId = 'root'; render(); updateTransform(); showToast("Mapa carregado com sucesso!"); } else { render(); } } else { if (!isSilent) showToast(result.message, true); } } catch (error) { if (!isSilent) showToast("Erro ao carregar mapa.", true); } } // Nova Pasta document.getElementById('btn-new-folder').addEventListener('click', async () => { const folderName = await elegantPrompt("Nome da nova pasta:", "Ex: Faculdade, Trabalho..."); if (!folderName || folderName.trim() === "") return; showToast("Criando pasta..."); try { const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'save_project', nome: folderName.trim(), is_folder: 1, parent_id: null }) }); const result = await response.json(); if (result.status === 'success') { showToast("Pasta criada com sucesso!"); fetchCloudProjects(); } else { showToast(result.message, true); } } catch (error) { showToast("Erro ao criar pasta.", true); } }); // Novo Mapa na Nuvem document.getElementById('btn-new-map').addEventListener('click', async () => { const mapName = await elegantPrompt("Nome do novo projeto:", "Ex: Planejamento 2024..."); if (!mapName || mapName.trim() === "") return; showToast("Criando Projeto..."); const emptyMap = { id: 'root', text: "Comece a digitar...", type: 'text', children: [], widthLevel: 2 }; try { const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'save_project', nome: mapName.trim(), is_folder: 0, data_mapa: emptyMap, parent_id: null }) }); const result = await response.json(); if (result.status === 'success') { showToast("Mapa criado com sucesso!"); fetchCloudProjects(); } else { showToast(result.message, true); } } catch (error) { showToast("Erro ao criar mapa.", true); } }); // Drop na raiz (quando soltar em área vazia ou entre itens) cloudFileList.addEventListener('dragover', (e) => { e.preventDefault(); }); cloudFileList.addEventListener('drop', (e) => { // Se soltou em uma row, não trata aqui (ela já cuida do próprio drop) if (e.target.closest('.cloud-row')) return; if (e.target.closest('.cloud-children')) return; e.preventDefault(); const draggedId = e.dataTransfer.getData('text/plain'); if (draggedId) moveCloudItem(draggedId, null); }); async function moveCloudItem(itemId, newParentId) { isCloudActionActive = true; try { const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'move_item', id: itemId, parent_id: newParentId }) }); const result = await response.json(); if (result.status === 'success') { if (appState.serverId == itemId) { try { const resVer = await fetch(`${API_URL}?action=check_version&id=${itemId}`); const ver = await resVer.json(); if (ver.status === 'success') appState.lastSyncTime = ver.atualizado_em; } catch(e) {} } fetchCloudProjects(); } else { showToast(result.message, true); } } catch (error) { showToast("Erro ao mover arquivo.", true); } isCloudActionActive = false; } async function deleteCloudItem(itemId) { showToast("Deletando..."); try { const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'delete_item', id: itemId }) }); const result = await response.json(); if (result.status === 'success') { showToast("Deletado com sucesso!"); if (appState.serverId == itemId) { appState.serverId = null; document.getElementById('project-name-input').value = 'Mapa sem título'; showToast("Atenção: Você deletou o mapa atual da nuvem.", true); } fetchCloudProjects(); } else { showToast(result.message, true); } } catch (error) { showToast("Erro ao deletar item.", true); } } /** * ========================================== * MODO STANDBY E LIVE UPDATE * ========================================== */ function showSyncWarningModal() { if (isSyncWarningActive) return; isSyncWarningActive = true; let overlay = document.getElementById('sync-warning-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'sync-warning-overlay'; overlay.className = 'sync-lock-overlay'; overlay.style.cssText = "position:fixed;top:0;left:0;width:100%;height:100%;background:var(--bg-overlay);display:flex;justify-content:center;align-items:center;z-index:9999;"; overlay.innerHTML = ` `; document.body.appendChild(overlay); const handleConfirm = () => { overlay.remove(); overlay = null; isSyncWarningActive = false; window.removeEventListener('keydown', enterListener); loadCloudProject(appState.serverId, false); }; document.getElementById('btn-sync-reload').addEventListener('click', handleConfirm); const enterListener = (e) => { if (e.key === 'Enter') { e.preventDefault(); handleConfirm(); } }; window.addEventListener('keydown', enterListener); } } /** * ========================================== * PAINEL DE CONFIGURACOES * ========================================== */ // Variaveis globais do painel let currentSettingsUser = null; let allMediaList = []; // API Functions async function getProfile() { const response = await fetch(`${API_URL}?action=get_profile`); return response.json(); } async function updateProfile(nome) { const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'update_profile', nome }) }); return response.json(); } async function changePassword(currentPassword, newPassword) { const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'change_password', current_password: currentPassword, new_password: newPassword }) }); return response.json(); } async function getStorageInfo() { const response = await fetch(`${API_URL}?action=get_storage_info`); return response.json(); } async function listUserMedia() { const response = await fetch(`${API_URL}?action=list_user_media`); return response.json(); } async function deleteUserMedia(mediaId) { const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'delete_user_media', media_id: mediaId }) }); return response.json(); } async function uploadAvatar(file) { const formData = new FormData(); formData.append('action', 'upload_avatar'); formData.append('avatar', file); const response = await fetch(API_URL, { method: 'POST', body: formData }); return response.json(); } // Utilitarios function formatBytes(bytes, decimals = 2) { if (bytes === 0) return '0 Bytes'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } function getInitials(name) { if (!name) return 'US'; return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2); } // Abre o painel de configuracoes function openSettingsPanel(screen = 'profile') { const overlay = document.getElementById('settings-modal-overlay'); if (!overlay) return; overlay.classList.remove('hidden'); // Navega para a tela solicitada switchSettingsScreen(screen); // Carrega os dados loadSettingsData(); } // Navegacao entre telas function switchSettingsScreen(screenName) { // Atualiza navegacao document.querySelectorAll('.settings-nav-item').forEach(item => { item.classList.toggle('active', item.dataset.screen === screenName); }); // Atualiza conteudo document.querySelectorAll('.settings-screen').forEach(screen => { screen.classList.remove('active'); }); const targetScreen = document.getElementById(`settings-screen-${screenName}`); if (targetScreen) { targetScreen.classList.add('active'); } // Carrega dados especificos da tela if (screenName === 'storage') { loadStorageData(); } else if (screenName === 'media') { loadMediaData(); } } // Carrega dados do perfil async function loadSettingsData() { try { const result = await getProfile(); if (result.status === 'success') { currentSettingsUser = result.user; // Preenche formulario de perfil document.getElementById('settings-profile-name').value = result.user.nome; document.getElementById('settings-profile-email').value = result.user.email; // Atualiza avatares const initials = getInitials(result.user.nome); updateAvatarElements(initials, result.user.avatar); // Atualiza plano const planText = result.user.plano === 'pro' ? 'Plano Pro' : 'Plano Gratuito'; document.getElementById('settings-user-plan').textContent = planText; document.getElementById('user-plan-display').textContent = planText; // Atualiza nome na sidebar userNameDisplay.innerText = result.user.nome; document.getElementById('settings-user-name').textContent = result.user.nome; } } catch (e) { console.error('Erro ao carregar perfil:', e); } } // Atualiza elementos de avatar function updateAvatarElements(initials, avatarFile) { const avatarElements = [ document.getElementById('settings-avatar'), document.getElementById('avatar-preview') ]; avatarElements.forEach(el => { if (!el) return; if (avatarFile) { el.innerHTML = `Avatar`; } else { el.textContent = initials; } }); } // Carrega dados de armazenamento async function loadStorageData() { try { const result = await getStorageInfo(); if (result.status === 'success') { const storage = result.storage; // Texto de uso const usedText = storage.limit ? `${formatBytes(storage.total_used)} usados de ${formatBytes(storage.limit)}` : `${formatBytes(storage.total_used)} usados (Ilimitado)`; document.getElementById('storage-used-text').textContent = usedText; // Porcentagem const percentageText = storage.limit ? `${storage.percentage}%` : 'Ilimitado'; document.getElementById('storage-percentage').textContent = percentageText; // Barra de progresso if (storage.limit) { const mediaPercent = (storage.media_used / storage.limit) * 100; const mapsPercent = (storage.maps_used / storage.limit) * 100; const freePercent = 100 - mediaPercent - mapsPercent; document.getElementById('storage-bar-media').style.width = `${mediaPercent}%`; document.getElementById('storage-bar-maps').style.width = `${mapsPercent}%`; document.getElementById('storage-bar-free').style.width = `${Math.max(0, freePercent)}%`; } else { // Plano Pro - mostra apenas segmentos proporcionais const total = storage.total_used || 1; document.getElementById('storage-bar-media').style.width = `${(storage.media_used / total) * 50}%`; document.getElementById('storage-bar-maps').style.width = `${(storage.maps_used / total) * 50}%`; document.getElementById('storage-bar-free').style.width = '50%'; } // Legendas document.getElementById('legend-media-size').textContent = formatBytes(storage.media_used); document.getElementById('legend-maps-size').textContent = formatBytes(storage.maps_used); if (storage.limit) { const freeSpace = storage.limit - storage.total_used; document.getElementById('legend-free-size').textContent = formatBytes(Math.max(0, freeSpace)); } else { document.getElementById('legend-free-size').textContent = 'Ilimitado'; } // Mostra/oculta card de upsell const upsellCard = document.getElementById('storage-upsell'); if (storage.plan === 'pro') { upsellCard.classList.add('hidden'); } else { upsellCard.classList.remove('hidden'); } // Info de mapas (novo) const mapCountEl = document.getElementById('storage-map-count'); if (mapCountEl) { const mapLimit = storage.map_limit; if (mapLimit) { mapCountEl.textContent = `${storage.map_count} de ${mapLimit} mapas`; mapCountEl.className = storage.map_count >= mapLimit ? 'storage-map-count warning' : 'storage-map-count'; } else { mapCountEl.textContent = `${storage.map_count} mapas (Ilimitado)`; mapCountEl.className = 'storage-map-count'; } } } } catch (e) { console.error('Erro ao carregar armazenamento:', e); } } // Carrega dados de mídia async function loadMediaData() { try { const result = await listUserMedia(); if (result.status === 'success') { allMediaList = result.media; renderMediaTable(allMediaList); } } catch (e) { console.error('Erro ao carregar midias:', e); } } // Renderiza tabela de midias function renderMediaTable(mediaList) { const tbody = document.getElementById('media-table-body'); const emptyState = document.getElementById('media-empty-state'); if (mediaList.length === 0) { tbody.innerHTML = ''; emptyState.classList.remove('hidden'); return; } emptyState.classList.add('hidden'); tbody.innerHTML = mediaList.map(media => `
${escapeHtml(media.original_name)}
${escapeHtml(media.project_name)} ${formatBytes(media.file_size)}
`).join(''); } // Escape HTML function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Download de midia function downloadMedia(mediaId) { window.open(`${API_URL}?action=download_image&id=${mediaId}`, '_blank'); } // Confirmacao de delecao async function confirmDeleteMedia(mediaId) { const confirmed = await elegantConfirm( 'Deletar Midia', 'Tem certeza que deseja deletar esta midia? Esta acao nao pode ser desfeita.', 'Deletar', 'Cancelar' ); if (!confirmed) { return; } try { const result = await deleteUserMedia(mediaId); if (result.status === 'success') { showToast('Midia deletada com sucesso!'); loadMediaData(); // Recarrega a lista // Atualiza o armazenamento se estiver na tela if (document.getElementById('settings-screen-storage').classList.contains('active')) { loadStorageData(); } } else { showToast(result.message || 'Erro ao deletar midia', 'error'); } } catch (e) { showToast('Erro ao conectar com o servidor', 'error'); } } // Event Listeners do Painel de Configuracoes document.addEventListener('DOMContentLoaded', () => { // Overlay do painel const settingsOverlay = document.getElementById('settings-modal-overlay'); if (settingsOverlay) { // Fecha ao clicar fora settingsOverlay.addEventListener('click', (e) => { if (e.target === settingsOverlay) { settingsOverlay.classList.add('hidden'); } }); } // Botao de fechar const settingsClose = document.getElementById('settings-close'); if (settingsClose) { settingsClose.addEventListener('click', () => { settingsOverlay.classList.add('hidden'); }); } // Navegacao lateral document.querySelectorAll('.settings-nav-item').forEach(item => { item.addEventListener('click', () => { switchSettingsScreen(item.dataset.screen); }); }); // Abrir perfil do sidebar const profileTopBtn = document.getElementById('profile-top-btn'); if (profileTopBtn) { profileTopBtn.addEventListener('click', () => openSettingsPanel('profile')); } // Upload de avatar const btnChangeAvatar = document.getElementById('btn-change-avatar'); const avatarUpload = document.getElementById('avatar-upload'); if (btnChangeAvatar && avatarUpload) { btnChangeAvatar.addEventListener('click', () => avatarUpload.click()); avatarUpload.addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; try { const result = await uploadAvatar(file); if (result.status === 'success') { showToast('Avatar atualizado com sucesso!'); loadSettingsData(); } else { showToast(result.message || 'Erro ao fazer upload', 'error'); } } catch (err) { showToast('Erro ao conectar com o servidor', 'error'); } }); } // Formulario de perfil const profileForm = document.getElementById('settings-profile-form'); if (profileForm) { profileForm.addEventListener('submit', async (e) => { e.preventDefault(); const nome = document.getElementById('settings-profile-name').value.trim(); const errorEl = document.getElementById('settings-profile-error'); const successEl = document.getElementById('settings-profile-success'); errorEl.classList.add('hidden'); successEl.classList.add('hidden'); if (!nome) { errorEl.textContent = 'O nome nao pode ficar vazio.'; errorEl.classList.remove('hidden'); return; } try { const result = await updateProfile(nome); if (result.status === 'success') { userNameDisplay.innerText = result.user.nome; document.getElementById('settings-user-name').textContent = result.user.nome; const initials = getInitials(result.user.nome); updateAvatarElements(initials, currentSettingsUser?.avatar); successEl.textContent = 'Nome atualizado com sucesso!'; successEl.classList.remove('hidden'); setTimeout(() => successEl.classList.add('hidden'), 3000); } else { errorEl.textContent = result.message; errorEl.classList.remove('hidden'); } } catch (err) { errorEl.textContent = 'Erro ao conectar com o servidor.'; errorEl.classList.remove('hidden'); } }); } // Formulario de seguranca const securityForm = document.getElementById('settings-security-form'); if (securityForm) { securityForm.addEventListener('submit', async (e) => { e.preventDefault(); const currentPass = document.getElementById('settings-current-pass').value; const newPass = document.getElementById('settings-new-pass').value; const confirmPass = document.getElementById('settings-confirm-pass').value; const errorEl = document.getElementById('settings-security-error'); const successEl = document.getElementById('settings-security-success'); errorEl.classList.add('hidden'); successEl.classList.add('hidden'); if (newPass !== confirmPass) { errorEl.textContent = 'As senhas nao coincidem.'; errorEl.classList.remove('hidden'); return; } try { const result = await changePassword(currentPass, newPass); if (result.status === 'success') { successEl.textContent = 'Senha alterada com sucesso!'; successEl.classList.remove('hidden'); document.getElementById('settings-current-pass').value = ''; document.getElementById('settings-new-pass').value = ''; document.getElementById('settings-confirm-pass').value = ''; setTimeout(() => successEl.classList.add('hidden'), 3000); } else { errorEl.textContent = result.message; errorEl.classList.remove('hidden'); } } catch (err) { errorEl.textContent = 'Erro ao conectar com o servidor.'; errorEl.classList.remove('hidden'); } }); } // Busca de midias const mediaSearch = document.getElementById('media-search'); if (mediaSearch) { mediaSearch.addEventListener('input', (e) => { const search = e.target.value.toLowerCase(); const filtered = allMediaList.filter(m => m.original_name.toLowerCase().includes(search) || m.project_name.toLowerCase().includes(search) ); renderMediaTable(filtered); }); } // Botao ver planos const btnViewPlans = document.getElementById('btn-view-plans'); if (btnViewPlans) { btnViewPlans.addEventListener('click', () => { showToast('Em breve! Planos Pro estarao disponiveis.', 'info'); }); } }); setInterval(async () => { if (appState.serverId && !hasUnsavedChanges && !isSyncWarningActive && !isCloudActionActive) { try { const response = await fetch(`${API_URL}?action=check_version&id=${appState.serverId}`); const result = await response.json(); if (result.status === 'success' && result.atualizado_em > appState.lastSyncTime) { console.log("Live Update: Nova versão detectada!"); showSyncWarningModal(); } } catch (e) {} } }, 500); /** * ========================================== * EXPORTAÇÃO DO MAPA COMO PNG E PDF * ========================================== * html2canvas NÃO renderiza pseudo-elementos (::before), * então os dots são desenhados manualmente no canvas composto. */ const DOT_SPACING = 30; const DOT_RADIUS = 1.5; // ::before começa em (-10000, -10000), dots a cada 30px da sua origem // Dots visíveis em relação ao #world: -10, 20, 50, 80 ... // 10000 % 30 = 10 => primeiro dot após a origem = 30 - 10 = 20 const DOT_OFFSET = DOT_SPACING - (10000 % DOT_SPACING); // = 20 function getExportBackgroundColor() { return document.body.classList.contains('dark-theme') ? '#0F172A' : '#F8FAFC'; } function getExportDotColor() { return document.body.classList.contains('dark-theme') ? '#192535' : '#E5E7EB'; } function drawDots(ctx, width, height) { const dotColor = getExportDotColor(); ctx.fillStyle = dotColor; for (let x = DOT_OFFSET; x < width; x += DOT_SPACING) { for (let y = DOT_OFFSET; y < height; y += DOT_SPACING) { ctx.beginPath(); ctx.arc(x, y, DOT_RADIUS, 0, Math.PI * 2); ctx.fill(); } } } function getProjectFileName(extension) { const name = appState.projectName && appState.projectName.trim() ? appState.projectName.trim() : 'dashmap'; return name.replace(/[^a-zA-Z0-9àáâãéêíóôõúüçÀÁÂÃÉÊÍÓÔÕÚÜÇ _-]/g, '').replace(/\s+/g, '_') + '.' + extension; } function showBusyOverlay() { let overlay = document.getElementById('export-busy-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'export-busy-overlay'; overlay.innerHTML = '
Exportando...
'; document.body.appendChild(overlay); } overlay.classList.remove('hidden'); } function hideBusyOverlay() { const overlay = document.getElementById('export-busy-overlay'); if (overlay) overlay.classList.add('hidden'); } async function captureWorld() { const linksLayer = document.getElementById('links-layer'); const scale = appState.view.scale; const worldRect = world.getBoundingClientRect(); // Bounding box dos nós em world-coordinates let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; const wrappers = world.querySelectorAll('.node-wrapper'); wrappers.forEach(w => { const rect = w.getBoundingClientRect(); const x1 = (rect.left - worldRect.left) / scale; const y1 = (rect.top - worldRect.top) / scale; const x2 = (rect.right - worldRect.left) / scale; const y2 = (rect.bottom - worldRect.top) / scale; if (x1 < minX) minX = x1; if (y1 < minY) minY = y1; if (x2 > maxX) maxX = x2; if (y2 > maxY) maxY = y2; }); if (minX === Infinity) return null; const padding = 100; const cropX = Math.max(0, minX - padding); const cropY = Math.max(0, minY - padding); const cropW = maxX - minX + padding * 2; const cropH = maxY - minY + padding * 2; // Salva estado do SVG const saved = { svgWidth: linksLayer.style.width, svgHeight: linksLayer.style.height, svgAttrWidth: linksLayer.getAttribute('width'), svgAttrHeight: linksLayer.getAttribute('height'), svgViewBox: linksLayer.getAttribute('viewBox'), svgOverflow: linksLayer.style.overflow, }; // Expande SVG para cobrir conteúdo const svgSize = Math.max(maxX + padding, cropX + cropW); linksLayer.style.width = svgSize + 'px'; linksLayer.style.height = svgSize + 'px'; linksLayer.setAttribute('width', svgSize); linksLayer.setAttribute('height', svgSize); linksLayer.style.overflow = 'visible'; // Expande #world const worldSize = Math.max(svgSize, maxY + padding); const savedWorldWidth = world.style.width; const savedWorldHeight = world.style.height; world.style.width = worldSize + 'px'; world.style.height = worldSize + 'px'; render(); await new Promise(r => requestAnimationFrame(r)); await new Promise(r => requestAnimationFrame(r)); await new Promise(r => setTimeout(r, 500)); try { const canvas = await html2canvas(world, { width: worldSize, height: worldSize, scale: 1, useCORS: true, allowTaint: true, backgroundColor: getExportBackgroundColor(), logging: false, }); // Canvas final: conteúdo + padding const output = document.createElement('canvas'); output.width = cropW; output.height = cropH; const ctx = output.getContext('2d'); // 1) Fundo sólido (cor do canvas) ctx.fillStyle = getExportBackgroundColor(); ctx.fillRect(0, 0, cropW, cropH); // 2) Dots POR TRÁS — destination-over pinta só onde os pixels já // são transparentes. O conteúdo ainda não foi desenhado, mas o // fundo já pintou tudo. Então precisamos pintar dots ANTES do // fundo ou usar outra abordagem: // Solução: desenha dots primeiro, depois fundo com destination-over, // depois conteúdo com source-over. // Recomeça limpo: fundo -> dots -> conteúdo // Na verdade: destination-over só pinta onde alpha=0. // O fillRect pintou fundo sólido (alpha=1), então destination-over // não vai pintar mais nada. Precisamos de outra ordem. // Abordagem correta: // 1. Desenhar dots // 2. Desenhar fundo com destination-over (pinta nos espaços entre dots) // 3. Desenhar conteúdo com source-over ctx.clearRect(0, 0, cropW, cropH); // 1) Dots drawDots(ctx, cropW, cropH); // 2) Fundo POR TRÁS (destination-over pinta onde dots NÃO estão) ctx.globalCompositeOperation = 'destination-over'; ctx.fillStyle = getExportBackgroundColor(); ctx.fillRect(0, 0, cropW, cropH); ctx.globalCompositeOperation = 'source-over'; // 3) Conteúdo html2canvas recortado por cima ctx.drawImage(canvas, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH); return output; } finally { world.style.width = savedWorldWidth; world.style.height = savedWorldHeight; linksLayer.style.width = saved.svgWidth; linksLayer.style.height = saved.svgHeight; if (saved.svgAttrWidth) { linksLayer.setAttribute('width', saved.svgAttrWidth); } else { linksLayer.removeAttribute('width'); } if (saved.svgAttrHeight) { linksLayer.setAttribute('height', saved.svgAttrHeight); } else { linksLayer.removeAttribute('height'); } if (saved.svgViewBox) { linksLayer.setAttribute('viewBox', saved.svgViewBox); } else { linksLayer.removeAttribute('viewBox'); } linksLayer.style.overflow = saved.svgOverflow; render(); } } async function exportMapAsPNG() { if (typeof html2canvas === 'undefined') { showToast('Erro: biblioteca de exportação não carregada. Recarregue a página.'); return; } showBusyOverlay(); const exportElements = document.querySelectorAll('#title-container, #main-menu, #theme-toggle, #help-button, #toast, .export-submenu'); exportElements.forEach(el => el.style.visibility = 'hidden'); try { const canvas = await captureWorld(); if (!canvas) { showToast('Nenhum conteúdo para exportar.'); return; } const link = document.createElement('a'); link.download = getProjectFileName('png'); link.href = canvas.toDataURL('image/png'); link.click(); showToast('PNG exportado com sucesso!'); } catch (err) { console.error('Erro ao exportar PNG:', err); showToast('Erro ao exportar PNG.'); } finally { hideBusyOverlay(); exportElements.forEach(el => el.style.visibility = ''); } } async function exportMapAsPDF() { if (typeof html2canvas === 'undefined' || typeof window.jspdf === 'undefined') { showToast('Erro: bibliotecas de exportação não carregadas. Recarregue a página.'); return; } showBusyOverlay(); const exportElements = document.querySelectorAll('#title-container, #main-menu, #theme-toggle, #help-button, #toast, .export-submenu'); exportElements.forEach(el => el.style.visibility = 'hidden'); try { const canvas = await captureWorld(); if (!canvas) { showToast('Nenhum conteúdo para exportar.'); return; } const { jsPDF } = window.jspdf; const imgData = canvas.toDataURL('image/jpeg', 0.92); const imgWidthPx = canvas.width; const imgHeightPx = canvas.height; const isLandscape = imgWidthPx > imgHeightPx; const orientation = isLandscape ? 'landscape' : 'portrait'; const pdf = new jsPDF({ orientation, unit: 'px', format: [imgWidthPx, imgHeightPx] }); pdf.addImage(imgData, 'JPEG', 0, 0, imgWidthPx, imgHeightPx); pdf.save(getProjectFileName('pdf')); showToast('PDF exportado com sucesso!'); } catch (err) { console.error('Erro ao exportar PDF:', err); showToast('Erro ao exportar PDF.'); } finally { hideBusyOverlay(); exportElements.forEach(el => el.style.visibility = ''); } } /** * ========================================== * INICIALIZAÇÃO * ========================================== */ // --- Lógica da Sidebar --- const sidebar = document.getElementById('sidebar'); const sidebarCloseBtn = document.getElementById('sidebar-collapse-btn'); const sidebarExpandBtn = document.getElementById('sidebar-expand-btn'); const projectNameInput = document.getElementById('project-name-input'); function openSidebar() { sidebar.classList.remove('collapsed'); } function collapseSidebar() { sidebar.classList.add('collapsed'); } function toggleSidebar() { sidebar.classList.toggle('collapsed'); } if (sidebarCloseBtn) sidebarCloseBtn.addEventListener('click', collapseSidebar); if (sidebarExpandBtn) sidebarExpandBtn.addEventListener('click', openSidebar); // Mobile menu button (outside sidebar) const mobileMenuBtn = document.getElementById('mobile-menu-btn'); if (mobileMenuBtn) mobileMenuBtn.addEventListener('click', openSidebar); // Click outside collapses sidebar (ignores mobile menu button) document.addEventListener('click', (e) => { if (!sidebar.classList.contains('collapsed') && !sidebar.contains(e.target) && e.target !== mobileMenuBtn && !mobileMenuBtn?.contains(e.target)) { collapseSidebar(); } }); // Clicking on node content also collapses sidebar document.addEventListener('focusin', (e) => { if (e.target.closest('.node-content') && !sidebar.classList.contains('collapsed')) { collapseSidebar(); } }, true); // ESC collapses sidebar (doesn't hide it completely) window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !sidebar.classList.contains('collapsed')) collapseSidebar(); }); // --- Lógica de Tema --- const ctrlTheme = document.getElementById('ctrl-theme'); const menuThemeButton = document.getElementById('menu-theme'); const sunIconLight = document.getElementById('theme-icon-light'); const sunIconDark = document.getElementById('theme-icon-dark'); function toggleTheme() { document.body.classList.toggle('dark-theme'); updateLogoForTheme(); updateThemeIcons(); if (document.body.classList.contains('dark-theme')) { localStorage.setItem('mindmap-theme', 'dark'); } else { localStorage.removeItem('mindmap-theme'); } } function updateThemeIcons() { const isDark = document.body.classList.contains('dark-theme'); sunIconLight.style.display = isDark ? 'none' : 'inline'; sunIconDark.style.display = isDark ? 'inline' : 'none'; const themeText = document.getElementById('theme-text'); if (themeText) themeText.textContent = isDark ? 'Tema escuro' : 'Tema claro'; } function updateLogoForTheme() { const logoImage = document.querySelector('.sidebar-logo-img'); if (!logoImage) return; logoImage.src = document.body.classList.contains('dark-theme') ? 'ID/logodark.png' : 'ID/logo.png'; } if (ctrlTheme) ctrlTheme.addEventListener('click', toggleTheme); if (menuThemeButton) menuThemeButton.addEventListener('click', toggleTheme); // --- Lógica do Modal de Ajuda --- const helpModalOverlay = document.getElementById('help-modal-overlay'); const helpModalClose = document.getElementById('help-modal-close'); const ctrlHelp = document.getElementById('ctrl-help'); function openHelpModal() { helpModalOverlay.classList.remove('hidden'); } function closeHelpModal() { helpModalOverlay.classList.add('hidden'); } if (ctrlHelp) ctrlHelp.addEventListener('click', openHelpModal); helpModalClose.addEventListener('click', closeHelpModal); helpModalOverlay.addEventListener('click', (e) => { if (e.target === helpModalOverlay) closeHelpModal(); }); // --- Controles Flutuantes: Centralizar --- const ctrlCenter = document.getElementById('ctrl-center'); if (ctrlCenter) { ctrlCenter.addEventListener('click', () => centerOnNode(appState.focusId)); } // --- Controles de Zoom Horizontal --- const zoomInBtn = document.getElementById('zoom-in'); const zoomOutBtn = document.getElementById('zoom-out'); const zoomResetBtn = document.getElementById('zoom-reset'); if (zoomInBtn) { zoomInBtn.addEventListener('click', () => { appState.view.scale = Math.min(appState.view.scale + 0.15, 3); updateTransform(); }); } if (zoomOutBtn) { zoomOutBtn.addEventListener('click', () => { appState.view.scale = Math.max(appState.view.scale - 0.15, 0.2); updateTransform(); }); } if (zoomResetBtn) { zoomResetBtn.addEventListener('click', () => { appState.view.scale = 1; updateTransform(); }); } // --- Conexão dos botões da sidebar --- document.getElementById('menu-save').addEventListener('click', () => { saveMapToFile(); collapseSidebar(); }); document.getElementById('menu-open').addEventListener('click', () => { loadMapFromFile(); collapseSidebar(); }); function toggleLightMode() { appState.view.isLightMode = !appState.view.isLightMode; document.getElementById('world').classList.toggle('light-mode-active'); render(); updateTransform(); showToast(appState.view.isLightMode ? "Modo Light Ativado (Performance)" : "Modo Mapa Ativado"); } document.getElementById('menu-light-mode').addEventListener('click', () => { toggleLightMode(); collapseSidebar(); }); // --- Submenu de Exportação --- const exportButton = document.getElementById('menu-export'); const exportSubmenu = document.getElementById('export-submenu'); function toggleExportSubmenu() { if (exportSubmenu.classList.contains('visible')) { closeExportSubmenu(); } else { exportSubmenu.classList.remove('hidden'); exportSubmenu.classList.add('visible'); } } function closeExportSubmenu() { exportSubmenu.classList.remove('visible'); exportSubmenu.classList.add('hidden'); } document.getElementById('menu-export-png').addEventListener('click', () => { exportMapAsPNG(); closeExportSubmenu(); collapseSidebar(); }); document.getElementById('menu-export-pdf').addEventListener('click', () => { exportMapAsPDF(); closeExportSubmenu(); collapseSidebar(); }); // Toggle export submenu only by click on the button, not on wrapper exportButton ?.addEventListener('click', (e) => { e.stopPropagation(); toggleExportSubmenu(); }); window.addEventListener('click', (e) => { if (exportSubmenu.classList.contains('visible') && !exportSubmenu.contains(e.target) && e.target !== exportButton) { closeExportSubmenu(); } }); // --- Novo Mapa --- document.getElementById('menu-new').addEventListener('click', () => { appState.interaction.newMapPendingConfirmation = true; openConfirmModal(); collapseSidebar(); }); document.getElementById('menu-help').addEventListener('click', () => { openHelpModal(); collapseSidebar(); }); // --- Inicializa --- if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js') .then(reg => console.log('Service Worker registrado com sucesso!', reg)) .catch(err => console.error('Falha ao registrar Service Worker:', err)); }); } function updateThemeColor() { const isDark = document.body.classList.contains('dark-theme'); const color = isDark ? '#0F172A' : '#F8FAFC'; const metaThemeColor = document.querySelector('meta[name="theme-color"]'); if (metaThemeColor) { metaThemeColor.setAttribute('content', color); } else { const meta = document.createElement('meta'); meta.name = 'theme-color'; meta.content = color; document.getElementsByTagName('head')[0].appendChild(meta); } } // Observador para mudar a cor do tema quando a classe do body mudar const themeObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.attributeName === 'class') { updateThemeColor(); } }); }); themeObserver.observe(document.body, { attributes: true }); loadStateFromLocalStorage(); projectNameInput.value = appState.projectName === 'Meu Mapa Mental' ? '' : appState.projectName; // Track original name to detect actual changes on confirm let originalProjectName = appState.projectName; projectNameInput.addEventListener('input', (e) => { appState.projectName = e.target.value; scheduleAutoSave(); }); updateTransform(); render(); if (appState.view.autoCenter && appState.focusId) { setTimeout(() => centerOnNode(appState.focusId, false), 50); } // Verifica tema salvo ou preferência do sistema if (localStorage.getItem('mindmap-theme') === 'dark' || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches && !localStorage.getItem('mindmap-theme'))) { document.body.classList.add('dark-theme'); } updateLogoForTheme(); updateThemeIcons(); // --- Lógica do Modal de Confirmação --- const confirmModalOverlay = document.getElementById('confirm-modal-overlay'); function openConfirmModal() { confirmModalOverlay.classList.remove('hidden'); } function closeConfirmModal() { confirmModalOverlay.classList.add('hidden'); appState.interaction.newMapPendingConfirmation = false; } function executeNewMap() { localStorage.removeItem(AUTO_SAVE_KEY); window.location.reload(); } confirmModalOverlay.addEventListener('click', (e) => { if (e.target === confirmModalOverlay) closeConfirmModal(); }); // Teclado global window.addEventListener('keydown', (e) => { if (appState.interaction.newMapPendingConfirmation) { if (e.key === 'Enter') executeNewMap(); else if (e.key === 'Escape') closeConfirmModal(); return; } if (e.key === 'Escape' && !helpModalOverlay.classList.contains('hidden')) closeHelpModal(); if (e.key === 'Escape' && sidebar && !sidebar.classList.contains('collapsed')) collapseSidebar(); // CTRL+Z: Desfazer if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); undo(); } // CTRL+O ou CTRL+0: Centralizar if ((e.ctrlKey || e.metaKey) && (e.key.toLowerCase() === 'o' || e.key === '0')) { e.preventDefault(); centerOnNode(appState.focusId); } // CTRL+I: Câmera dinâmica if ((e.ctrlKey || e.metaKey) && e.key === 'i') { e.preventDefault(); appState.view.autoCenter = !appState.view.autoCenter; showToast(appState.view.autoCenter ? "Câmera: Dinâmica (Seguindo Foco)" : "Câmera: Travada (Modo Manual)"); } // Navegação com setas if ((e.ctrlKey || e.metaKey) && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { if (!document.activeElement || !document.activeElement.classList.contains('node-content')) { e.preventDefault(); if (appState.focusId) navigateFocus(appState.focusId, e.key); } } }); // Lightbox ESC function handleLightboxKeydown(e) { if (e.key === 'Escape') closeImageLightbox(); } // --- Mudança de nome do projeto --- if (projectNameInput) { function projectTitleConfirmed() { const novoNome = projectNameInput.value.trim(); if (novoNome && novoNome !== originalProjectName) { const oldName = originalProjectName; appState.projectName = novoNome; originalProjectName = novoNome; hasUnsavedChanges = true; scheduleAutoSave(); // Se estiver logado e com projeto na nuvem, renomeia no servidor if (appState.serverId) { fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'rename_item', id: appState.serverId, nome: novoNome }) }) .then(r => r.json()) .then(result => { if (result.status === 'success') { showToast("Nome atualizado na nuvem com sucesso!"); console.log("Nome sincronizado na nuvem com sucesso."); } else { appState.projectName = oldName; originalProjectName = oldName; projectNameInput.value = oldName; showToast(result.message || "Erro ao renomear na nuvem.", true); } }) .catch(() => { showToast("Erro ao conectar ao servidor para renomear.", true); }); } else { showToast("Nome do projeto atualizado!"); } // Feedback visual: borda + ícone check animado const checkIcon = document.getElementById('project-saved-icon'); if (checkIcon) { checkIcon.innerHTML = ``; checkIcon.classList.add('show'); } projectNameInput.classList.add('saved'); setTimeout(() => { projectNameInput.classList.remove('saved'); if (checkIcon) checkIcon.classList.remove('show'); }, 1500); } else if (!novoNome) { projectNameInput.value = originalProjectName; } } // Clicking outside (blur / change event) projectNameInput.addEventListener('change', projectTitleConfirmed); // Pressing Enter projectNameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { projectNameInput.blur(); projectTitleConfirmed(); } }); } console.log('DashMap Iniciado.');