// 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 = '
Outro dispositivo está editando este mapa na nuvem.
Pressione ENTER ou clique para carregar a versão mais recente.