Restaure et termine le lot tableur resté non committé. Il n'existait que dans un `git stash` (24 fichiers suivis) et en fichiers non suivis (menus.js, formula.js, color-picker.js, tests, fiches) : la 2.53.3 livrée ne le contenait donc pas. Le stash, créé avant le commit BUG-108, n'a jamais été restauré. #180 Redimensionnement au curseur (en-tête de bord miroité + classe de repaint), grille thémée sur les 15 thèmes et les modes contraste élevé / sépia, menu Fichier au niveau des onglets, barre épinglée pleine largeur. #181 Modèle de saisie Google Sheets : sélection ≠ édition (caret masqué sans quitter contenteditable, double-clic / F2 / première frappe qui remplace, Entrée contextuelle, Échap qui restaure) + inventaire priorisé des écarts. #182 Désélection fiable (clic simple sans dépendre du focus), contours de plage non empilés (sélecteur de classes sans point), couleurs texte/fond sur une plage, clic droit qui préserve la sélection multiple. #183 Panneau de couleurs façon Google Sheets : palette 8 × 10, STANDARD, PERSONNALISÉ, coche selon la luminance, sortie `#rrggbb` (une valeur HSL était rejetée par normHex). #184 Peinture de format complète (toutes propriétés, source sans format = réinitialisation), sélection multi-lignes/colonnes depuis les marges, grille étendue : colonnes A → Z d'emblée, lignes ajoutées PAR BLOCS DE 100 au défilement jusqu'à 1000 — matérialiser 1000 lignes d'un coup = ~26 000 cellules câblées par feuille, ce qui épuisait le tas de la suite JSDOM ; index de cellules `ref → td`, court-circuits formule/styles, marqueur data-wired. #185 Barre de menus : les 10 menus Google Sheets (161 entrées, 125 câblées, 36 annoncées indisponibles), ruban façon Sheets, grille unie 1 px dérivée du thème. #186 « Créer un fichier » propose .xlsx et construit un vrai classeur OPC (openpyxl) au lieu d'une charge utile texte illisible. Corrections trouvées en restaurant et en exerçant le lot : - fuite mémoire : écouteur `click` anonyme posé sur #content-area à chaque rendu, jamais retiré — sa fermeture retenait la grille précédente en entier ; - sorties Markdown / HTML / Imprimer de la barre de menus inertes (`data-xlsx-export` jamais réparti, seul `data-xlsx-action` l'était) ; - collision de classe `.xlsx-structure-menu` entre la barre de menus et le menu Structure de la barre d'outils (toute requête tombait sur un nœud masqué) ; - curseur col/row-resize absent quand le pointeur est sur la table elle-même ; - l'export emportait les lignes et colonnes vides du quadrillage (CSV, Markdown, HTML, impression) ; - clic extérieur avalé par la grâce de 250 ms du menu contextuel (destinée au seul appui long tactile) ; - une entrée indisponible laissait la barre de menus ouverte. Tests : pytest 1595 passés / 2 ignorés ; ruff et mypy 0 erreur ; bandit 0 ; 33 suites frontend vertes (xlsx-viewer 163/163, xlsx-menus 19/19, xlsx-formula 14/14) ; E2E complet 132 passés / 12 ignorés ; E2E xlsx-viewer 19/19.
8285 lines
342 KiB
JavaScript
8285 lines
342 KiB
JavaScript
/* ObsiGate — Viewer module */
|
||
import { api, AuthManager } from './auth.js';
|
||
import { state } from './state.js';
|
||
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard, activateInlineEditor, detachInlineEditor } from './utils.js';
|
||
import { isInlineEditorActive, queryEditor } from './editor-inline.js';
|
||
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard, showConfirm, showPrompt } from './ui.js';
|
||
import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js';
|
||
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
|
||
import { initDashboardTabs } from './sync.js';
|
||
import { renderMermaidBlocks } from './mermaid-viewer.js';
|
||
import { renderExcalidraw } from './excalidraw-viewer.js';
|
||
import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, DashboardSharedWidget, DashboardConflictsWidget } from './dashboard.js';
|
||
import { openShareDialog } from './config.js';
|
||
import { cacheViewedFile, getCachedFile } from './offline.js';
|
||
import { t, getLocale } from './i18n.js';
|
||
import { onFileRender } from './plugins.js';
|
||
import { NowPlaying } from './now-playing.js';
|
||
import { openAssistant } from './ai-fab.js';
|
||
import { parseRef, columnName, findTd, invalidateCellIndex, sheetOfRef, firstCellOfRange } from './xlsx/refs.js';
|
||
import { createColourPicker } from './xlsx/color-picker.js';
|
||
import { buildCommandBar } from './xlsx/command-bar.js';
|
||
import { buildMenuBar } from './xlsx/menus.js';
|
||
import { renderDashboardLoading, renderDashboardHtml } from './xlsx/dashboard.js';
|
||
import { openContextMenu, closeContextMenu, trackDismissable, enableArrowNav } from './xlsx/context-menu.js';
|
||
import {
|
||
isFormulaText, parseFormula, evaluate, createScope, displayValue,
|
||
colToIndex, indexToCol,
|
||
} from './xlsx/formula.js';
|
||
|
||
// ── Multi-format export ────────────────────────────────────────────────────
|
||
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
|
||
// export endpoints, showing a loading toast while the request is in flight.
|
||
async function downloadExport(vault, path, format, fallbackName) {
|
||
showToast(t("viewer.export_start"), "info");
|
||
const url = `/api/export/${format}?vault=${encodeURIComponent(vault)}&path=${encodeURIComponent(path)}`;
|
||
try {
|
||
const headers = AuthManager.getAuthHeaders() || {};
|
||
const res = await fetch(url, { credentials: "include", headers });
|
||
if (!res.ok) {
|
||
let detail = "";
|
||
try { detail = (await res.json()).detail || ""; } catch (_) { /* ignore */ }
|
||
throw new Error(detail || "HTTP " + res.status);
|
||
}
|
||
const blob = await res.blob();
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = fallbackName;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||
showToast(t("viewer.export_done"), "success");
|
||
} catch (err) {
|
||
console.error("Export error:", err);
|
||
showToast(t("viewer.export_error") + " " + err.message, "error");
|
||
}
|
||
}
|
||
|
||
// Close any open export dropdown when clicking elsewhere.
|
||
document.addEventListener("click", function (e) {
|
||
if (e.target && e.target.closest && e.target.closest(".export-dropdown")) return;
|
||
document.querySelectorAll(".export-menu").forEach(function (m) { m.style.display = "none"; });
|
||
});
|
||
|
||
// ── Spreadsheet header-edge resize: pointer routing (module level) ─────────
|
||
// One drag at a time; the context (area, panel, commit) is captured by the
|
||
// render that starts it, so re-renders never leave stale closures behind.
|
||
let activeResizeDrag = null;
|
||
if (typeof document !== "undefined" && !document.__xlsxResizeRouted) {
|
||
document.__xlsxResizeRouted = true;
|
||
const resizeTrackMove = (clientX, clientY) => {
|
||
const d = activeResizeDrag;
|
||
if (!d || typeof d.move !== "function") return;
|
||
d.move(clientX, clientY);
|
||
};
|
||
document.addEventListener("mousemove", (e) => resizeTrackMove(e.clientX, e.clientY));
|
||
document.addEventListener("mouseup", () => {
|
||
if (activeResizeDrag && typeof activeResizeDrag.commit === "function") {
|
||
const d = activeResizeDrag;
|
||
activeResizeDrag = null;
|
||
d.commit();
|
||
}
|
||
});
|
||
document.addEventListener("touchmove", (e) => {
|
||
if (!activeResizeDrag || !e.touches || !e.touches[0]) return;
|
||
e.preventDefault();
|
||
resizeTrackMove(e.touches[0].clientX, e.touches[0].clientY);
|
||
}, { passive: false });
|
||
document.addEventListener("touchend", () => {
|
||
if (activeResizeDrag && typeof activeResizeDrag.commit === "function") {
|
||
const d = activeResizeDrag;
|
||
activeResizeDrag = null;
|
||
d.commit();
|
||
}
|
||
});
|
||
document.addEventListener("touchcancel", () => {
|
||
if (activeResizeDrag && typeof activeResizeDrag.cancel === "function") {
|
||
const d = activeResizeDrag;
|
||
activeResizeDrag = null;
|
||
d.cancel();
|
||
}
|
||
});
|
||
// A drag that moved must not toggle the header selection on release.
|
||
document.addEventListener("click", (e) => {
|
||
const d = activeResizeDrag;
|
||
if (!d || !d.suppressClick) return;
|
||
const t = e.target && e.target.closest ? e.target.closest("thead th, th.xlsx-rownum") : null;
|
||
if (t) {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
}
|
||
d.suppressClick = false;
|
||
}, true);
|
||
}
|
||
// Hide the floating mini-bar when it loses its context (module level: pure DOM).
|
||
if (typeof document !== "undefined" && typeof window !== "undefined" && !document.__xlsxMinibarRouted) {
|
||
document.__xlsxMinibarRouted = true;
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape") {
|
||
document.querySelectorAll(".xlsx-minibar").forEach((m) => { m.hidden = true; });
|
||
}
|
||
});
|
||
window.addEventListener("scroll", () => {
|
||
document.querySelectorAll(".xlsx-minibar").forEach((m) => { m.hidden = true; });
|
||
}, true);
|
||
}
|
||
|
||
// Pane-aware content area helper — delegates to PaneManager when split
|
||
function getContentArea() {
|
||
// Override set by PaneTabManager._renderFileInPane for cross-module rendering
|
||
if (window._activePaneContentArea) return window._activePaneContentArea;
|
||
if (window.PaneManager && window.PaneManager.isSplit() && window.getActiveContentArea) {
|
||
return window.getActiveContentArea();
|
||
}
|
||
return document.getElementById('content-area');
|
||
}
|
||
// ---------------------------------------------------------------------------
|
||
// Outline/TOC Manager
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const OutlineManager = {
|
||
/**
|
||
* Slugify text to create valid IDs
|
||
*/
|
||
slugify(text) {
|
||
return (
|
||
text
|
||
.toLowerCase()
|
||
.normalize("NFD")
|
||
.replace(/[\u0300-\u036f]/g, "")
|
||
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
||
.replace(/\s+/g, "-")
|
||
.replace(/-+/g, "-")
|
||
.trim() || "heading"
|
||
);
|
||
},
|
||
|
||
/**
|
||
* Parse headings from markdown content
|
||
*/
|
||
parseHeadings() {
|
||
const contentArea = document.querySelector(".md-content");
|
||
if (!contentArea) return [];
|
||
|
||
const headings = [];
|
||
const h2s = contentArea.querySelectorAll("h2");
|
||
const h3s = contentArea.querySelectorAll("h3");
|
||
const allHeadings = [...h2s, ...h3s].sort((a, b) => {
|
||
return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
|
||
});
|
||
|
||
const usedIds = new Map();
|
||
|
||
allHeadings.forEach((heading) => {
|
||
const text = heading.textContent.trim();
|
||
if (!text) return;
|
||
|
||
const level = parseInt(heading.tagName[1]);
|
||
let id = this.slugify(text);
|
||
|
||
// Handle duplicate IDs
|
||
if (usedIds.has(id)) {
|
||
const count = usedIds.get(id) + 1;
|
||
usedIds.set(id, count);
|
||
id = `${id}-${count}`;
|
||
} else {
|
||
usedIds.set(id, 1);
|
||
}
|
||
|
||
// Inject ID into heading if not present
|
||
if (!heading.id) {
|
||
heading.id = id;
|
||
} else {
|
||
id = heading.id;
|
||
}
|
||
|
||
headings.push({
|
||
id,
|
||
level,
|
||
text,
|
||
element: heading,
|
||
});
|
||
});
|
||
|
||
return headings;
|
||
},
|
||
|
||
/**
|
||
* Render outline list
|
||
*/
|
||
renderOutline(headings) {
|
||
const outlineList = document.getElementById("outline-list");
|
||
const outlineEmpty = document.getElementById("outline-empty");
|
||
|
||
if (!outlineList) return;
|
||
|
||
outlineList.innerHTML = "";
|
||
|
||
if (!headings || headings.length === 0) {
|
||
outlineList.hidden = true;
|
||
if (outlineEmpty) {
|
||
outlineEmpty.hidden = false;
|
||
safeCreateIcons();
|
||
}
|
||
return;
|
||
}
|
||
|
||
outlineList.hidden = false;
|
||
if (outlineEmpty) outlineEmpty.hidden = true;
|
||
|
||
headings.forEach((heading) => {
|
||
const item = el(
|
||
"a",
|
||
{
|
||
class: `outline-item level-${heading.level}`,
|
||
href: `#${heading.id}`,
|
||
"data-heading-id": heading.id,
|
||
role: "link",
|
||
},
|
||
[document.createTextNode(heading.text)],
|
||
);
|
||
|
||
item.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
this.scrollToHeading(heading.id);
|
||
});
|
||
|
||
outlineList.appendChild(item);
|
||
});
|
||
|
||
state.headingsCache = headings;
|
||
},
|
||
|
||
/**
|
||
* Scroll to heading with smooth behavior
|
||
*/
|
||
scrollToHeading(headingId) {
|
||
const heading = document.getElementById(headingId);
|
||
if (!heading) return;
|
||
|
||
const contentArea = getContentArea();
|
||
if (!contentArea) return;
|
||
|
||
// Calculate offset for fixed header (if any)
|
||
const headerHeight = 80;
|
||
const headingTop = heading.offsetTop;
|
||
|
||
contentArea.scrollTo({
|
||
top: headingTop - headerHeight,
|
||
behavior: "smooth",
|
||
});
|
||
|
||
// Update active state immediately
|
||
this.setActiveHeading(headingId);
|
||
},
|
||
|
||
/**
|
||
* Set active heading in outline
|
||
*/
|
||
setActiveHeading(headingId) {
|
||
if (state.activeHeadingId === headingId) return;
|
||
|
||
state.activeHeadingId = headingId;
|
||
|
||
const items = document.querySelectorAll(".outline-item");
|
||
items.forEach((item) => {
|
||
if (item.getAttribute("data-heading-id") === headingId) {
|
||
item.classList.add("active");
|
||
item.setAttribute("aria-current", "location");
|
||
// Scroll outline item into view
|
||
item.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||
} else {
|
||
item.classList.remove("active");
|
||
item.removeAttribute("aria-current");
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Initialize outline for current document
|
||
*/
|
||
init() {
|
||
const headings = this.parseHeadings();
|
||
this.renderOutline(headings);
|
||
ScrollSpyManager.init(headings);
|
||
ReadingProgressManager.init();
|
||
},
|
||
|
||
/**
|
||
* Cleanup
|
||
*/
|
||
destroy() {
|
||
ScrollSpyManager.destroy();
|
||
ReadingProgressManager.destroy();
|
||
state.headingsCache = [];
|
||
state.activeHeadingId = null;
|
||
},
|
||
};
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Scroll Spy Manager
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const ScrollSpyManager = {
|
||
observer: null,
|
||
headings: [],
|
||
|
||
init(headings) {
|
||
this.destroy();
|
||
this.headings = headings;
|
||
|
||
if (!headings || headings.length === 0) return;
|
||
|
||
const contentArea = getContentArea();
|
||
if (!contentArea) return;
|
||
|
||
const options = {
|
||
root: contentArea,
|
||
rootMargin: "-20% 0px -70% 0px",
|
||
threshold: [0, 0.3, 0.5, 1.0],
|
||
};
|
||
|
||
this.observer = new IntersectionObserver((entries) => {
|
||
// Find the most visible heading
|
||
let mostVisible = null;
|
||
let maxRatio = 0;
|
||
|
||
entries.forEach((entry) => {
|
||
if (entry.isIntersecting && entry.intersectionRatio > maxRatio) {
|
||
maxRatio = entry.intersectionRatio;
|
||
mostVisible = entry.target;
|
||
}
|
||
});
|
||
|
||
if (mostVisible && mostVisible.id) {
|
||
OutlineManager.setActiveHeading(mostVisible.id);
|
||
}
|
||
}, options);
|
||
|
||
// Observe all headings
|
||
headings.forEach((heading) => {
|
||
if (heading.element) {
|
||
this.observer.observe(heading.element);
|
||
}
|
||
});
|
||
},
|
||
|
||
destroy() {
|
||
if (this.observer) {
|
||
this.observer.disconnect();
|
||
this.observer = null;
|
||
}
|
||
this.headings = [];
|
||
},
|
||
};
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Reading Progress Manager
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const ReadingProgressManager = {
|
||
scrollHandler: null,
|
||
|
||
init() {
|
||
this.destroy();
|
||
|
||
const contentArea = getContentArea();
|
||
if (!contentArea) return;
|
||
|
||
this.scrollHandler = this.throttle(() => {
|
||
this.updateProgress();
|
||
}, 100);
|
||
|
||
contentArea.addEventListener("scroll", this.scrollHandler);
|
||
this.updateProgress();
|
||
},
|
||
|
||
updateProgress() {
|
||
const contentArea = getContentArea();
|
||
const progressFill = document.getElementById("reading-progress-fill");
|
||
const progressText = document.getElementById("reading-progress-text");
|
||
|
||
if (!contentArea || !progressFill || !progressText) return;
|
||
|
||
const scrollTop = contentArea.scrollTop;
|
||
const scrollHeight = contentArea.scrollHeight;
|
||
const clientHeight = contentArea.clientHeight;
|
||
|
||
const maxScroll = scrollHeight - clientHeight;
|
||
const percentage = maxScroll > 0 ? Math.round((scrollTop / maxScroll) * 100) : 0;
|
||
|
||
progressFill.style.width = `${percentage}%`;
|
||
progressText.textContent = `${percentage}%`;
|
||
},
|
||
|
||
throttle(func, delay) {
|
||
let lastCall = 0;
|
||
return function (...args) {
|
||
const now = Date.now();
|
||
if (now - lastCall >= delay) {
|
||
lastCall = now;
|
||
func.apply(this, args);
|
||
}
|
||
};
|
||
},
|
||
|
||
destroy() {
|
||
const contentArea = getContentArea();
|
||
if (contentArea && this.scrollHandler) {
|
||
contentArea.removeEventListener("scroll", this.scrollHandler);
|
||
}
|
||
this.scrollHandler = null;
|
||
|
||
// Reset progress
|
||
const progressFill = document.getElementById("reading-progress-fill");
|
||
const progressText = document.getElementById("reading-progress-text");
|
||
if (progressFill) progressFill.style.width = "0%";
|
||
if (progressText) progressText.textContent = "0%";
|
||
},
|
||
};
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// File viewer
|
||
// ---------------------------------------------------------------------------
|
||
export async function openFile(vaultName, filePath) {
|
||
state.currentVault = vaultName;
|
||
state.currentPath = filePath;
|
||
state.showingSource = false;
|
||
state.cachedRawSource = null;
|
||
|
||
// Highlight active
|
||
syncActiveFileTreeItem(vaultName, filePath);
|
||
|
||
// Show loading state while fetching
|
||
const area = getContentArea();
|
||
area.innerHTML = '<div class="loading-indicator"><div class="loading-spinner"></div><div>Chargement...</div></div>';
|
||
|
||
try {
|
||
const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
|
||
const data = await api(url);
|
||
// Cache the file for offline use
|
||
if (data && !data.unsupported && data.content) {
|
||
cacheViewedFile(vaultName, filePath, data.content, data.modified);
|
||
}
|
||
renderFile(data);
|
||
} catch (err) {
|
||
// Try IndexedDB fallback
|
||
console.log('[Viewer] Network failed, trying offline cache:', err);
|
||
const cached = await getCachedFile(vaultName, filePath);
|
||
if (cached && cached.content) {
|
||
const fakeData = {
|
||
vault: vaultName,
|
||
path: filePath,
|
||
title: filePath.split('/').pop().replace(/\.md$/, ''),
|
||
content: cached.content,
|
||
html: null,
|
||
raw_size: cached.content.length,
|
||
modified: cached.modified || '',
|
||
tags: [],
|
||
offline: true
|
||
};
|
||
renderFile(fakeData);
|
||
showToast(t('viewer.loaded_from_cache'), 'info');
|
||
} else {
|
||
area.innerHTML = '<div class="welcome"><p style="color:var(--text-muted)">Impossible de charger le fichier — vous etes hors-ligne et ce fichier n\'est pas en cache.</p></div>';
|
||
}
|
||
}
|
||
}
|
||
|
||
async function renderBacklinksPanel(vault, path, container) {
|
||
try {
|
||
const data = await api(`/api/file/${encodeURIComponent(vault)}/backlinks?path=${encodeURIComponent(path)}`);
|
||
if (!data.backlinks || data.backlinks.length === 0) return;
|
||
|
||
const panel = el("div", { class: "backlinks-panel" });
|
||
const header = el("div", { class: "backlinks-header" }, [
|
||
icon("link", 14),
|
||
document.createTextNode(` ${data.total} lien(s) entrant(s)`),
|
||
]);
|
||
panel.appendChild(header);
|
||
|
||
const list = el("div", { class: "backlinks-list" });
|
||
data.backlinks.forEach((bl) => {
|
||
const item = el("div", { class: "backlink-item" });
|
||
const vaultBadge = el("span", { class: "backlink-vault" }, [document.createTextNode(bl.vault)]);
|
||
const titleEl = el("span", { class: "backlink-title" }, [document.createTextNode(bl.title || bl.path.split("/").pop().replace(/\.md$/i, ""))]);
|
||
item.appendChild(icon(getFileIcon(bl.path), 12));
|
||
item.appendChild(vaultBadge);
|
||
item.appendChild(titleEl);
|
||
item.addEventListener("click", () => TabManager.openPreview(bl.vault, bl.path));
|
||
item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(bl.vault, bl.path); });
|
||
list.appendChild(item);
|
||
});
|
||
panel.appendChild(list);
|
||
container.appendChild(panel);
|
||
} catch (err) {
|
||
// Silently ignore — backlinks are optional
|
||
console.debug("Backlinks fetch failed:", err);
|
||
}
|
||
}
|
||
|
||
// ── Pretty print (mode lecture enrichi pour fichiers texte non-markdown) ──
|
||
// Detecte le langage depuis le HTML deja rendu par le backend (classe
|
||
// language-xxx), formate le JSON, puis genere un bloc avec gouttiere de
|
||
// numeros de ligne. La coloration est appliquee ensuite via highlight.js.
|
||
function detectPrettyLang(data) {
|
||
if (data.is_json || data.extension === ".json") return "json";
|
||
const m = /class="language-([\w-]+)/.exec(data.html || "");
|
||
return m ? m[1] : "plaintext";
|
||
}
|
||
|
||
function buildPrettyHtml(data, raw) {
|
||
let text = raw;
|
||
const lang = detectPrettyLang(data);
|
||
if (lang === "json") {
|
||
try { text = JSON.stringify(JSON.parse(raw), null, 2); } catch (_) { /* JSON invalide: on affiche brut */ }
|
||
}
|
||
const lines = text.split("\n");
|
||
const gutter = lines.map((_, i) => `<span>${i + 1}</span>`).join("");
|
||
return `<div class="pretty-view"><div class="pp-gutter" aria-hidden="true">${gutter}</div><pre class="pp-pre"><code class="language-${lang}">${escapeHtml(text)}</code></pre></div>`;
|
||
}
|
||
|
||
// Colorateur générique de secours: utilisé quand highlight.js ne produit
|
||
// aucun token (langage plaintext — .txt/.log —, langue absente du bundle,
|
||
// ou CDN hljs indisponible). Réutilise les classes hljs existantes (le
|
||
// thème CDN colore string/comment/number/attr) + classes .pp-* maison
|
||
// pour les niveaux de log qui n'ont pas d'équivalent sémantique hljs.
|
||
const PP_TOKEN_RE = /("[^"\n]*"?|'[^'\n]*'?|`[^`\n]*`?)|(^\s*(?:#|\/\/|;)\s.*$|\s+(?:#|\/\/)\s.*$)|\b(\d+(?:\.\d+)?)\b|\b(ERROR|FATAL|CRITICAL|SEVERE|WARN(?:ING)?|INFO|DEBUG|TRACE|NOTICE)\b|^(\s*[\w.\-\/ ]+)=(?=\S)|\b(true|false|null|None|TRUE|FALSE|NULL)\b/gm;
|
||
|
||
function ppClassFor(m) {
|
||
if (m[1] !== undefined) return "hljs-string";
|
||
if (m[2] !== undefined) return "hljs-comment";
|
||
if (m[3] !== undefined) return "hljs-number";
|
||
if (m[4] !== undefined) {
|
||
const lvl = m[4];
|
||
if (/ERROR|FATAL|CRITICAL|SEVERE/.test(lvl)) return "pp-level pp-err";
|
||
if (/WARN/.test(lvl)) return "pp-level pp-warn";
|
||
if (/INFO|NOTICE/.test(lvl)) return "pp-level pp-info";
|
||
return "pp-level pp-debug";
|
||
}
|
||
if (m[5] !== undefined) return "hljs-attr";
|
||
if (m[6] !== undefined) return "hljs-literal";
|
||
return null;
|
||
}
|
||
|
||
function fallbackTokenize(text) {
|
||
let out = "";
|
||
let last = 0;
|
||
PP_TOKEN_RE.lastIndex = 0;
|
||
let m;
|
||
while ((m = PP_TOKEN_RE.exec(text)) !== null) {
|
||
if (m.index > last) out += escapeHtml(text.slice(last, m.index));
|
||
const cls = ppClassFor(m);
|
||
out += cls ? `<span class="${cls}">${escapeHtml(m[0])}</span>` : escapeHtml(m[0]);
|
||
last = m.index + (m[0].length || 1);
|
||
if (m[0].length === 0) PP_TOKEN_RE.lastIndex++; // garde anti-boucle infinie
|
||
}
|
||
out += escapeHtml(text.slice(last));
|
||
return out;
|
||
}
|
||
|
||
// Applique la coloration: hljs (avec aliases de languages) si des tokens
|
||
// sortent, sinon fallback regex generique.
|
||
function applyPrettyHighlight(codeEl, lang, text) {
|
||
safeHighlight(codeEl); // gerne les alias language-xxx inconnus de hljs
|
||
const tokens = codeEl.querySelectorAll('[class^="hljs-"]').length;
|
||
if (!tokens && text.trim()) {
|
||
codeEl.innerHTML = fallbackTokenize(text);
|
||
codeEl.classList.add("hljs");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Jump the inline PDF viewer to a specific page.
|
||
*
|
||
* The browser's built-in PDF viewer lives in an ``about:blank`` content window
|
||
* (so ``contentWindow.location.hash`` never reaches the document) **and** it
|
||
* ignores a same-document fragment navigation: changing only ``#page=N`` on the
|
||
* iframe ``src`` does not move the page. A changing query parameter forces a
|
||
* real reload, and the ``#page=N`` fragment is then honoured at load — the only
|
||
* reliable way to target a page with the native viewer.
|
||
*
|
||
* @param {HTMLElement} area - Content area containing the ``.pdf-iframe``.
|
||
* @param {string|number} page - 1-based page number from the PDF outline.
|
||
*/
|
||
export function navigatePdfToPage(area, page) {
|
||
const iframe = area && area.querySelector('.pdf-iframe');
|
||
if (!iframe || page === null || page === undefined || page === '') return;
|
||
const base = iframe.getAttribute('data-pdf-url') || iframe.src.split('#')[0];
|
||
iframe.setAttribute('data-pdf-url', base);
|
||
const sep = base.includes('?') ? '&' : '?';
|
||
iframe.src = `${base}${sep}_pdfpage=${Date.now()}#page=${page}`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Image viewer (roadmap #108-D)
|
||
// ---------------------------------------------------------------------------
|
||
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"]);
|
||
const IMAGE_ZOOM_MIN = 0.1;
|
||
const IMAGE_ZOOM_MAX = 8;
|
||
let _imageViewerCleanup = null;
|
||
// BUG-072 — lightbox / metadata-panel state, reset as soon as a non-image file
|
||
// is rendered. #111 — image→image navigation now happens **in place**, so the
|
||
// viewer (and these states) is no longer destroyed between siblings.
|
||
const _imageViewerState = { lightbox: false, meta: false };
|
||
|
||
/** Clamp a zoom factor into the supported [0.1, 8] range. */
|
||
export function clampImageZoom(value) {
|
||
if (!Number.isFinite(value)) return 1;
|
||
return Math.min(IMAGE_ZOOM_MAX, Math.max(IMAGE_ZOOM_MIN, value));
|
||
}
|
||
|
||
/** True when *p* has a viewable image extension. */
|
||
export function isImagePath(p) {
|
||
const lower = (p || "").toLowerCase();
|
||
const dot = lower.lastIndexOf(".");
|
||
return dot !== -1 && IMAGE_EXTS.has(lower.slice(dot));
|
||
}
|
||
|
||
/** Build the byte-serving URL used by <img> / thumbnails. */
|
||
export function buildImageUrl(vault, path) {
|
||
return `/api/image/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`;
|
||
}
|
||
|
||
function buildThumbUrl(vault, path, size) {
|
||
return `/api/media/${encodeURIComponent(vault)}/thumb?path=${encodeURIComponent(path)}&size=${size}`;
|
||
}
|
||
|
||
// #156-A10 — per-file undo/redo history. The stacks must outlive a re-render
|
||
// (a structure save rebuilds the whole viewer) without leaking across files, so
|
||
// they live in a small bounded module-level store keyed by vault + path.
|
||
const _xlsxHistory = new Map();
|
||
const XLSX_HISTORY_MAX_FILES = 8;
|
||
|
||
function xlsxHistory(key) {
|
||
let entry = _xlsxHistory.get(key);
|
||
if (!entry) {
|
||
entry = { undo: [], redo: [] };
|
||
_xlsxHistory.set(key, entry);
|
||
}
|
||
if (_xlsxHistory.size > XLSX_HISTORY_MAX_FILES) {
|
||
for (const k of _xlsxHistory.keys()) {
|
||
if (k !== key) { _xlsxHistory.delete(k); break; }
|
||
}
|
||
}
|
||
return entry;
|
||
}
|
||
|
||
/** Clear the per-file spreadsheet undo/redo history (tests, file close). */
|
||
export function resetXlsxHistory() {
|
||
_xlsxHistory.clear();
|
||
}
|
||
|
||
// #111 — directory listing cache. Opening / switching to another image must not
|
||
// hammer `/api/browse` (the filmstrip and the sibling list share one listing).
|
||
const _imageDirCache = new Map();
|
||
const IMAGE_DIR_CACHE_TTL = 15000;
|
||
|
||
/** List the viewable image siblings of *dir*, memoised for a short TTL. */
|
||
async function listImageSiblings(vault, dir) {
|
||
const key = `${vault}\u0000${dir}`;
|
||
const cached = _imageDirCache.get(key);
|
||
if (cached && Date.now() - cached.ts < IMAGE_DIR_CACHE_TTL) return cached.items;
|
||
const res = await api(`/api/browse/${encodeURIComponent(vault)}?path=${encodeURIComponent(dir)}`);
|
||
const items = (res.items || []).filter((it) => it.type === "file" && isImagePath(it.path));
|
||
_imageDirCache.set(key, { items, ts: Date.now() });
|
||
return items;
|
||
}
|
||
|
||
const IMAGE_MIME = {
|
||
".png": "image/png",
|
||
".jpg": "image/jpeg",
|
||
".jpeg": "image/jpeg",
|
||
".gif": "image/gif",
|
||
".svg": "image/svg+xml",
|
||
".webp": "image/webp",
|
||
".bmp": "image/bmp",
|
||
".ico": "image/x-icon",
|
||
};
|
||
|
||
/** Best-effort MIME type for an image path (metadata panel). */
|
||
function imageMimeFor(path) {
|
||
const lower = (path || "").toLowerCase();
|
||
const dot = lower.lastIndexOf(".");
|
||
return dot === -1 ? "" : IMAGE_MIME[lower.slice(dot)] || "";
|
||
}
|
||
|
||
function formatBytes(n) {
|
||
if (!n && n !== 0) return "";
|
||
if (n < 1024) return `${n} o`;
|
||
if (n < 1048576) return `${(n / 1024).toFixed(1)} Ko`;
|
||
return `${(n / 1048576).toFixed(1)} Mo`;
|
||
}
|
||
|
||
/**
|
||
* Render a full image viewer: centered image, wheel zoom (0.1×–8×), drag pan,
|
||
* double-click reset, ←/→ navigation between siblings, thumbnail filmstrip,
|
||
* collapsible metadata panel and a full-viewport lightbox.
|
||
*/
|
||
export function renderImageViewer(area, data) {
|
||
const vault = data.vault;
|
||
// #111 — mutable current-image state: navigating swaps these in place instead
|
||
// of re-rendering the whole viewer (which used to refetch the file + listing).
|
||
let currentPath = data.path;
|
||
let currentTitle = data.title || (currentPath || "").split("/").pop();
|
||
let imgUrl = buildImageUrl(vault, currentPath);
|
||
let currentMeta = {
|
||
image_mime: data.image_mime || imageMimeFor(currentPath),
|
||
size_bytes: data.size_bytes,
|
||
modified: data.modified,
|
||
};
|
||
|
||
area.innerHTML = "";
|
||
const container = el("div", { class: "image-viewer-container", tabindex: "0" });
|
||
|
||
// ── Toolbar ────────────────────────────────────────────────────────────
|
||
const toolbar = el("div", { class: "image-toolbar" });
|
||
const titleEl = el("span", { class: "image-title", title: currentPath }, [
|
||
document.createTextNode(currentTitle),
|
||
]);
|
||
toolbar.appendChild(titleEl);
|
||
toolbar.appendChild(el("div", { class: "image-toolbar-spacer" }));
|
||
|
||
const counter = el("span", { class: "image-counter", hidden: true }, [document.createTextNode("")]);
|
||
toolbar.appendChild(counter);
|
||
const zoomBadge = el("span", { class: "image-zoom-badge" }, [document.createTextNode("100%")]);
|
||
toolbar.appendChild(zoomBadge);
|
||
|
||
const mkBtn = (iconName, label, cls) => {
|
||
const b = el("button", { class: `btn-action${cls ? " " + cls : ""}`, type: "button", title: label, "aria-label": label }, [icon(iconName, 14)]);
|
||
return b;
|
||
};
|
||
|
||
const zoomOutBtn = mkBtn("zoom-out", t("viewer.image_zoom_out"));
|
||
const zoomInBtn = mkBtn("zoom-in", t("viewer.image_zoom_in"));
|
||
const zoomResetBtn = mkBtn("rotate-ccw", t("viewer.image_zoom_reset"));
|
||
const prevBtn = mkBtn("chevron-left", t("viewer.image_prev"));
|
||
const nextBtn = mkBtn("chevron-right", t("viewer.image_next"));
|
||
const originalBtn = mkBtn("external-link", t("viewer.image_open_original"));
|
||
const downloadBtn = mkBtn("download", t("viewer.download"));
|
||
const metaBtn = mkBtn("info", t("viewer.image_metadata"), "image-btn-metadata");
|
||
const lightboxBtn = mkBtn("maximize", t("viewer.image_fullscreen"), "image-btn-lightbox");
|
||
[prevBtn, nextBtn, zoomOutBtn, zoomInBtn, zoomResetBtn, metaBtn, originalBtn, downloadBtn, lightboxBtn]
|
||
.forEach((b) => toolbar.appendChild(b));
|
||
container.appendChild(toolbar);
|
||
|
||
// ── Body: image stage + metadata sidebar (right) ───────────────────────
|
||
const body = el("div", { class: "image-viewer-body" });
|
||
const stage = el("div", { class: "image-stage" });
|
||
const img = el("img", { class: "image-main", src: imgUrl, alt: currentTitle, draggable: "false" });
|
||
stage.appendChild(img);
|
||
|
||
// ── Overlay arrows along the frame edges (#111) ────────────────────────
|
||
const prevArrow = el("button", {
|
||
class: "image-nav-arrow image-nav-arrow-prev",
|
||
type: "button",
|
||
title: t("viewer.image_prev"),
|
||
"aria-label": t("viewer.image_prev"),
|
||
hidden: true,
|
||
}, [icon("chevron-left", 32)]);
|
||
const nextArrow = el("button", {
|
||
class: "image-nav-arrow image-nav-arrow-next",
|
||
type: "button",
|
||
title: t("viewer.image_next"),
|
||
"aria-label": t("viewer.image_next"),
|
||
hidden: true,
|
||
}, [icon("chevron-right", 32)]);
|
||
stage.appendChild(prevArrow);
|
||
stage.appendChild(nextArrow);
|
||
body.appendChild(stage);
|
||
|
||
// ── Metadata sidebar (kept open across ←/→ navigation) ─────────────────
|
||
const metaPanel = el("div", { class: "image-meta-panel" });
|
||
metaPanel.hidden = !_imageViewerState.meta;
|
||
body.appendChild(metaPanel);
|
||
container.appendChild(body);
|
||
|
||
// ── Thumbnail filmstrip (navigation) ───────────────────────────────────
|
||
// #112 — the film scrolls with the mouse wheel and two translucent edge
|
||
// arrows; the scrollable strip is wrapped so the arrows stay fixed.
|
||
const navWrap = el("div", { class: "image-nav", hidden: true });
|
||
const strip = el("div", { class: "image-nav-strip" });
|
||
const stripPrev = el("button", {
|
||
class: "image-strip-arrow image-strip-arrow-prev",
|
||
type: "button",
|
||
title: t("viewer.image_strip_prev"),
|
||
"aria-label": t("viewer.image_strip_prev"),
|
||
}, [icon("chevron-left", 20)]);
|
||
const stripNext = el("button", {
|
||
class: "image-strip-arrow image-strip-arrow-next",
|
||
type: "button",
|
||
title: t("viewer.image_strip_next"),
|
||
"aria-label": t("viewer.image_strip_next"),
|
||
}, [icon("chevron-right", 20)]);
|
||
navWrap.appendChild(strip);
|
||
navWrap.appendChild(stripPrev);
|
||
navWrap.appendChild(stripNext);
|
||
container.appendChild(navWrap);
|
||
|
||
let scale = 1;
|
||
let tx = 0;
|
||
let ty = 0;
|
||
|
||
const applyTransform = () => {
|
||
img.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
|
||
zoomBadge.textContent = `${Math.round(scale * 100)}%`;
|
||
};
|
||
const resetView = () => { scale = 1; tx = 0; ty = 0; applyTransform(); };
|
||
const setZoom = (next) => {
|
||
scale = clampImageZoom(next);
|
||
if (scale === 1) { tx = 0; ty = 0; }
|
||
applyTransform();
|
||
};
|
||
|
||
const renderMeta = () => {
|
||
const rows = [
|
||
[t("viewer.image_type"), currentMeta.image_mime || ""],
|
||
[t("viewer.image_dimensions"), img.naturalWidth ? `${img.naturalWidth} × ${img.naturalHeight}` : ""],
|
||
[t("viewer.metadata_size"), formatBytes(currentMeta.size_bytes)],
|
||
[t("viewer.metadata_path"), currentPath],
|
||
];
|
||
if (currentMeta.modified) rows.push([t("viewer.metadata_modified"), currentMeta.modified]);
|
||
metaPanel.innerHTML = "";
|
||
const dl = el("dl", {});
|
||
rows.forEach(([k, v]) => {
|
||
dl.appendChild(el("dt", {}, [document.createTextNode(k)]));
|
||
dl.appendChild(el("dd", {}, [document.createTextNode(v || "—")]));
|
||
});
|
||
metaPanel.appendChild(dl);
|
||
};
|
||
|
||
// ── Sibling navigation (in place, #111) ────────────────────────────────
|
||
let siblings = [];
|
||
let currentIndex = -1;
|
||
let thumbs = [];
|
||
const preloaded = new Set();
|
||
|
||
const preload = (i) => {
|
||
if (!siblings.length) return;
|
||
const it = siblings[(i % siblings.length + siblings.length) % siblings.length];
|
||
if (!it || preloaded.has(it.path)) return;
|
||
preloaded.add(it.path);
|
||
const pre = new Image();
|
||
pre.src = buildImageUrl(vault, it.path);
|
||
};
|
||
|
||
const updateStripActive = () => {
|
||
thumbs.forEach((thumb, i) => thumb.classList.toggle("active", i === currentIndex));
|
||
const active = thumbs[currentIndex];
|
||
if (!active) return;
|
||
// Scroll the filmstrip only (never the document) to reveal the active thumb.
|
||
const left = active.offsetLeft;
|
||
const right = left + active.offsetWidth;
|
||
if (left < strip.scrollLeft) strip.scrollLeft = Math.max(0, left - 8);
|
||
else if (right > strip.scrollLeft + strip.clientWidth) strip.scrollLeft = right - strip.clientWidth + 8;
|
||
};
|
||
|
||
const updateArrows = () => {
|
||
const multi = siblings.length > 1;
|
||
prevArrow.hidden = nextArrow.hidden = prevBtn.disabled = nextBtn.disabled = !multi;
|
||
prevArrow.disabled = nextArrow.disabled = !multi;
|
||
counter.hidden = !multi;
|
||
counter.textContent = multi ? `${currentIndex + 1} / ${siblings.length}` : "";
|
||
};
|
||
|
||
/** Swap to the sibling at *index* without rebuilding the viewer. */
|
||
const showSibling = (index) => {
|
||
const item = siblings[index];
|
||
if (!item || item.path === currentPath) return;
|
||
currentIndex = index;
|
||
currentPath = item.path;
|
||
currentTitle = item.name;
|
||
imgUrl = buildImageUrl(vault, currentPath);
|
||
currentMeta = {
|
||
image_mime: imageMimeFor(currentPath),
|
||
size_bytes: item.size,
|
||
modified: null,
|
||
};
|
||
scale = 1; tx = 0; ty = 0;
|
||
applyTransform();
|
||
img.style.display = "";
|
||
const errEl = stage.querySelector(".image-error");
|
||
if (errEl) errEl.remove();
|
||
img.alt = currentTitle;
|
||
img.src = imgUrl;
|
||
titleEl.textContent = currentTitle;
|
||
titleEl.title = currentPath;
|
||
updateStripActive();
|
||
updateArrows();
|
||
state.currentVault = vault;
|
||
state.currentPath = currentPath;
|
||
try { syncActiveFileTreeItem(vault, currentPath); } catch (_) { /* best-effort */ }
|
||
preload(index - 1);
|
||
preload(index + 1);
|
||
if (!metaPanel.hidden) renderMeta();
|
||
};
|
||
|
||
const go = (delta) => {
|
||
if (siblings.length < 2 || currentIndex < 0) return;
|
||
showSibling((currentIndex + delta + siblings.length) % siblings.length);
|
||
};
|
||
|
||
const renderStrip = () => {
|
||
if (siblings.length < 2) { navWrap.hidden = true; return; }
|
||
navWrap.hidden = false;
|
||
strip.innerHTML = "";
|
||
thumbs = siblings.map((s, i) => {
|
||
const thumb = el("img", {
|
||
class: `image-thumb${i === currentIndex ? " active" : ""}`,
|
||
src: buildThumbUrl(vault, s.path, 96),
|
||
alt: s.name,
|
||
title: s.name,
|
||
loading: "lazy",
|
||
decoding: "async",
|
||
});
|
||
thumb.addEventListener("click", () => showSibling(i));
|
||
strip.appendChild(thumb);
|
||
return thumb;
|
||
});
|
||
updateStripActive();
|
||
};
|
||
|
||
// #112 — scroll the film with the wheel (vertical → horizontal) and the
|
||
// two overlay arrows; the active image never changes, only the strip view.
|
||
const scrollStrip = (dir) => {
|
||
strip.scrollBy({ left: dir * Math.max(140, strip.clientWidth * 0.8), behavior: "smooth" });
|
||
};
|
||
stripPrev.addEventListener("click", () => scrollStrip(-1));
|
||
stripNext.addEventListener("click", () => scrollStrip(1));
|
||
strip.addEventListener("wheel", (e) => {
|
||
if (strip.scrollWidth <= strip.clientWidth) return;
|
||
const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
|
||
strip.scrollLeft += delta;
|
||
e.preventDefault();
|
||
}, { passive: false });
|
||
|
||
(async () => {
|
||
try {
|
||
const dir = currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
|
||
siblings = await listImageSiblings(vault, dir);
|
||
currentIndex = siblings.findIndex((it) => it.path === currentPath);
|
||
renderStrip();
|
||
updateArrows();
|
||
preload(currentIndex - 1);
|
||
preload(currentIndex + 1);
|
||
} catch (_) { /* navigation is best-effort */ }
|
||
})();
|
||
|
||
// ── Interactions ───────────────────────────────────────────────────────
|
||
stage.addEventListener("wheel", (e) => {
|
||
e.preventDefault();
|
||
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||
setZoom(scale * factor);
|
||
}, { passive: false });
|
||
|
||
const isArrow = (e) => !!(e.target && e.target.closest && e.target.closest(".image-nav-arrow"));
|
||
|
||
let dragging = false;
|
||
let startX = 0;
|
||
let startY = 0;
|
||
let startTx = 0;
|
||
let startTy = 0;
|
||
stage.addEventListener("pointerdown", (e) => {
|
||
if (e.button !== 0 || isArrow(e)) return;
|
||
dragging = true;
|
||
startX = e.clientX; startY = e.clientY; startTx = tx; startTy = ty;
|
||
stage.classList.add("panning");
|
||
if (stage.setPointerCapture) stage.setPointerCapture(e.pointerId);
|
||
});
|
||
stage.addEventListener("pointermove", (e) => {
|
||
if (!dragging) return;
|
||
tx = startTx + (e.clientX - startX);
|
||
ty = startTy + (e.clientY - startY);
|
||
applyTransform();
|
||
});
|
||
const endDrag = (e) => {
|
||
dragging = false;
|
||
stage.classList.remove("panning");
|
||
if (stage.releasePointerCapture && e.pointerId != null) {
|
||
try { stage.releasePointerCapture(e.pointerId); } catch (_) { /* already released */ }
|
||
}
|
||
};
|
||
stage.addEventListener("pointerup", endDrag);
|
||
stage.addEventListener("pointercancel", endDrag);
|
||
stage.addEventListener("dblclick", (e) => { if (!isArrow(e)) resetView(); });
|
||
|
||
const stopArrowPointer = (e) => e.stopPropagation();
|
||
prevArrow.addEventListener("pointerdown", stopArrowPointer);
|
||
nextArrow.addEventListener("pointerdown", stopArrowPointer);
|
||
prevArrow.addEventListener("dblclick", stopArrowPointer);
|
||
nextArrow.addEventListener("dblclick", stopArrowPointer);
|
||
prevArrow.addEventListener("click", (e) => { e.stopPropagation(); go(-1); });
|
||
nextArrow.addEventListener("click", (e) => { e.stopPropagation(); go(1); });
|
||
|
||
zoomInBtn.addEventListener("click", () => setZoom(scale * 1.25));
|
||
zoomOutBtn.addEventListener("click", () => setZoom(scale / 1.25));
|
||
zoomResetBtn.addEventListener("click", resetView);
|
||
prevBtn.addEventListener("click", () => go(-1));
|
||
nextBtn.addEventListener("click", () => go(1));
|
||
originalBtn.addEventListener("click", () => window.open(imgUrl, "_blank"));
|
||
downloadBtn.addEventListener("click", () => {
|
||
const dlUrl = `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(currentPath)}`;
|
||
window.open(dlUrl, "_blank");
|
||
});
|
||
metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false");
|
||
metaBtn.addEventListener("click", () => {
|
||
_imageViewerState.meta = !_imageViewerState.meta;
|
||
metaPanel.hidden = !_imageViewerState.meta;
|
||
metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false");
|
||
if (_imageViewerState.meta) renderMeta();
|
||
});
|
||
const syncLightbox = () => {
|
||
container.classList.toggle("lightbox", _imageViewerState.lightbox);
|
||
lightboxBtn.setAttribute("aria-pressed", _imageViewerState.lightbox ? "true" : "false");
|
||
};
|
||
syncLightbox();
|
||
lightboxBtn.addEventListener("click", () => {
|
||
_imageViewerState.lightbox = !_imageViewerState.lightbox;
|
||
syncLightbox();
|
||
});
|
||
|
||
img.addEventListener("load", () => {
|
||
img.style.display = "";
|
||
const errEl = stage.querySelector(".image-error");
|
||
if (errEl) errEl.remove();
|
||
if (!metaPanel.hidden) renderMeta();
|
||
});
|
||
img.addEventListener("error", () => {
|
||
img.style.display = "none";
|
||
if (!stage.querySelector(".image-error")) {
|
||
stage.appendChild(el("div", { class: "image-error" }, [document.createTextNode(t("viewer.image_error"))]));
|
||
}
|
||
});
|
||
|
||
const onKey = (e) => {
|
||
if (e.key === "ArrowLeft") { e.preventDefault(); go(-1); }
|
||
else if (e.key === "ArrowRight") { e.preventDefault(); go(1); }
|
||
else if (e.key === "+" || e.key === "=") { e.preventDefault(); setZoom(scale * 1.25); }
|
||
else if (e.key === "-") { e.preventDefault(); setZoom(scale / 1.25); }
|
||
else if (e.key === "0") { e.preventDefault(); resetView(); }
|
||
else if (e.key === "Escape") {
|
||
_imageViewerState.lightbox = false;
|
||
syncLightbox();
|
||
}
|
||
};
|
||
document.addEventListener("keydown", onKey);
|
||
_imageViewerCleanup = () => document.removeEventListener("keydown", onKey);
|
||
|
||
area.appendChild(container);
|
||
safeCreateIcons();
|
||
applyTransform();
|
||
if (_imageViewerState.meta) renderMeta();
|
||
try { container.focus({ preventScroll: true }); } catch (_) { /* non-fatal */ }
|
||
}
|
||
|
||
// #110 — Audio/video are handled by the global Now Playing controller
|
||
// (frontend/js/now-playing.js): a single shared media element is teleported
|
||
// between this inline view and a body-level dock, so playback survives tab,
|
||
// pane and page navigation. The inline branches below just hand over the
|
||
// surface; the legacy "stop on re-render" cleanup no longer applies.
|
||
export { formatMediaDuration, buildMediaUrl, renderFallback as renderMediaFallback } from "./now-playing.js";
|
||
|
||
/** Audio viewer (roadmap #109-B, made persistent by #110). */
|
||
export function renderAudioViewer(area, data) {
|
||
NowPlaying.attachInline(area, data);
|
||
}
|
||
|
||
/** Video viewer (roadmap #109-C, made persistent by #110). */
|
||
export function renderVideoViewer(area, data) {
|
||
NowPlaying.attachInline(area, data);
|
||
}
|
||
|
||
|
||
// ── Excel .xlsx — sheet tabs + editable cells ─────────────────────────────
|
||
// Cells are contenteditable; edits are collected per sheet and sent to
|
||
// PUT /api/file/{vault}/xlsx/save. Formula cells show their text and are
|
||
// never recalculated here.
|
||
//
|
||
// #153 A1/A4 — the read response carries `xlsx_lossy_features` (parts openpyxl
|
||
// drops on save): a banner lists them and the first save asks for an explicit
|
||
// confirmation before retrying with `force: true`. A value starting with "=" or
|
||
// "@" is stored as text unless the user turns the formula toggle on, so a typed
|
||
// `=cmd|…` cannot execute when the file is later opened in Excel.
|
||
// #153 A8 — a sheet bigger than the render caps used to be silently cut: the
|
||
// user saw a short table and no way to tell the rest of the workbook still
|
||
// existed. The backend now reports the real dimensions of every sheet, so the
|
||
// note states exactly what is hidden (and that those cells are not editable
|
||
// here — the workbook itself is untouched). A payload without those fields
|
||
// (older cache) simply shows no note.
|
||
function truncationNote(sheet) {
|
||
// The cap is the real "shown" figure, not `rows`/`cols`: those are post-trim
|
||
// (a sparse sheet renders 1x1) while the note must say how far the view
|
||
// reaches.
|
||
const cap = { rows: Number(sheet.max_rows) || 0, cols: Number(sheet.max_cols) || 0 };
|
||
const reasons = [];
|
||
if (Number(sheet.total_rows) > cap.rows) {
|
||
reasons.push(t("xlsx.truncated_rows", { shown: cap.rows, total: Number(sheet.total_rows) }));
|
||
}
|
||
if (Number(sheet.total_cols) > cap.cols) {
|
||
reasons.push(t("xlsx.truncated_cols", { shown: cap.cols, total: Number(sheet.total_cols) }));
|
||
}
|
||
if (!reasons.length) return "";
|
||
return `<div class="xlsx-truncated" role="note">
|
||
<i data-lucide="scissors" class="xlsx-truncated-icon"></i>
|
||
<div class="xlsx-warning-body">
|
||
<strong>${escapeHtml(t("xlsx.truncated_title"))}</strong>
|
||
<span>${escapeHtml(reasons.join(" "))}</span>
|
||
<span class="xlsx-warning-hint">${escapeHtml(t("xlsx.truncated_hint"))}</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
export function renderXlsxViewer(area, data) {
|
||
// #184 — `area` outlives a re-render, so every listener bound to it must be
|
||
// dropped first. The formula-link click handler below closes over the whole
|
||
// grid (panelEls, activeTd, selection): without this teardown each re-render
|
||
// kept the previous grid alive — opening or saving a spreadsheet a few times
|
||
// grew the tab's heap without bound.
|
||
if (typeof area._xlsxDropFormulaLinkClick === "function") {
|
||
try { area._xlsxDropFormulaLinkClick(); } catch (_) { /* ignore */ }
|
||
area._xlsxDropFormulaLinkClick = null;
|
||
}
|
||
const sheets = data.xlsx_sheets || [];
|
||
const lossy = data.xlsx_lossy_features || [];
|
||
// #153 A16 — mode flags: a .csv rides the same viewer with a flat
|
||
// structure (no tabs/merges/freeze to wire) and its own save endpoint;
|
||
// .xls/.ods are served read-only (no editing, no structure ops).
|
||
const isCsv = Boolean(data.is_csv);
|
||
const readOnly = Boolean(data.xlsx_readonly);
|
||
// Session-scoped state: once the lossy write is confirmed, the rest of the
|
||
// session saves without asking again (never persisted — a confirmation is
|
||
// per workbook, not a global preference).
|
||
let lossyConfirmed = false;
|
||
let allowFormula = false;
|
||
// #156-A12 — revision of the file as last read or written. It rides every
|
||
// write as `if_match`: a write on a file that changed elsewhere is refused
|
||
// with 409 `conflict` instead of silently overwriting the other writer.
|
||
let revision = data.xlsx_revision || null;
|
||
// #154-A1 — the command bar is grouped (Formules · Insertion · Vue · Fichier)
|
||
// and the sheet tabs are ALWAYS rendered (a single sheet used to show no tab
|
||
// at all, hiding the fact that a workbook can hold several). An editable
|
||
// workbook also gets an explicit “+” button next to the tabs.
|
||
const editable = !isCsv && !readOnly;
|
||
// #156-A10 — the undo/redo stacks are kept per file (module-level store).
|
||
const historyKey = `${data.vault || ""}\u0000${data.path || ""}`;
|
||
// #154-A5 — the shell markup (tabs + grouped command bar + status pills) is
|
||
// built by frontend/js/xlsx/command-bar.js.
|
||
const { tabs, actionsHtml, statusBar, fileHtml } = buildCommandBar({ sheets, isCsv, readOnly, editable });
|
||
// #185 — the Google Sheets menu bar (Fichier · Modifier · Affichage ·
|
||
// Insertion · Format · Données · Outils · Gemini · Extensions · Aide) sits
|
||
// ABOVE the ribbon, and every entry's action is resolved further down by
|
||
// `menuActions()`. Built here so it is a pure string like the ribbon.
|
||
const menuBarHtml = buildMenuBar({ editable, readOnly });
|
||
// #153 A15 — inline styles (bold/italic/colors from the file itself), merged
|
||
// ranges and the freeze anchor are applied AFTER the panels are in the DOM
|
||
// (the HTML string alone cannot carry them: col/row spans need the table).
|
||
/** Comment marker of one cell (empty span: text extraction unaffected). */
|
||
const decorateComments = (td) => {
|
||
const panel = td.closest ? td.closest(".xlsx-panel") : null;
|
||
const meta = sheets[Number(panel && panel.dataset ? panel.dataset.sheet : NaN)] || {};
|
||
const text = (meta.comments || {})[td.dataset.cell];
|
||
const has = td.querySelector ? td.querySelector(".xlsx-comment-dot") : null;
|
||
if (text && !has) {
|
||
const dot = document.createElement("span");
|
||
dot.className = "xlsx-comment-dot";
|
||
dot.title = text;
|
||
td.appendChild(dot);
|
||
} else if (!text && has) {
|
||
has.remove();
|
||
}
|
||
};
|
||
const applySheetMeta = (panel) => {
|
||
const meta = sheets[Number(panel.dataset.sheet)] || {};
|
||
const styleMap = meta.styles || {};
|
||
const alignMap = meta.aligns || {};
|
||
const comments = meta.comments || {};
|
||
// #184 — the padded grid holds ~26 000 cells; only the ones the workbook
|
||
// actually styles are worth touching (the clones/queries below are not).
|
||
panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => {
|
||
const ref = td.dataset.cell;
|
||
const style = styleMap[ref];
|
||
const align = alignMap[ref];
|
||
if (!style && !align && !comments[ref]) return;
|
||
if (style) td.setAttribute("style", style);
|
||
if (align) td.style.textAlign = align;
|
||
decorateComments(td);
|
||
});
|
||
// Explicit column widths / row heights (col_width & row_height ops):
|
||
// a colgroup carries the widths (units → px), rows take their height.
|
||
const table = panel.querySelector(".xlsx-table");
|
||
if (table) {
|
||
const widths = meta.colwidths || {};
|
||
let cg = table.querySelector("colgroup.xlsx-cols");
|
||
if (!cg) {
|
||
cg = document.createElement("colgroup");
|
||
cg.className = "xlsx-cols";
|
||
table.prepend(cg);
|
||
}
|
||
cg.replaceChildren();
|
||
const heads = table.querySelectorAll("thead th");
|
||
heads.forEach((th, i) => {
|
||
const col = document.createElement("col");
|
||
if (i === 0) {
|
||
col.style.width = "44px"; // row-number gutter (matches frozen offset)
|
||
} else {
|
||
const w = widths[columnName(i)];
|
||
if (w) col.style.width = `${Math.round(Number(w) * 7)}px`;
|
||
}
|
||
cg.appendChild(col);
|
||
});
|
||
const heights = meta.rowheights || {};
|
||
table.querySelectorAll("tbody tr").forEach((tr) => {
|
||
const h = heights[String(rowNumOf(tr))];
|
||
if (h) tr.style.height = `${Math.round(Number(h) * 96 / 72)}px`;
|
||
else tr.style.height = "";
|
||
});
|
||
}
|
||
// Merged ranges: span the anchor cell over the range and hide the covered
|
||
// cells (rowSpan/colSpan survive a re-render; hidden cells are skipped by
|
||
// the save collector because their text was already blank in openpyxl).
|
||
// Reset first: dynamic updates (unmerge, structure surgery) must also be
|
||
// able to REMOVE spans and reveals. Filtered rows hide TRs, never TDs, so
|
||
// clearing td-level display is safe.
|
||
panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => {
|
||
td.rowSpan = 1;
|
||
td.colSpan = 1;
|
||
if (td.style.display === "none") td.style.display = "";
|
||
});
|
||
(meta.merges || []).forEach((range) => {
|
||
const [from, to] = range.split(":");
|
||
const anchor = panel.querySelector(`td[data-cell="${from}"]`);
|
||
const last = panel.querySelector(`td[data-cell="${to}"]`);
|
||
if (!anchor || !last || anchor === last) return;
|
||
const a = parseRef(from);
|
||
const b = parseRef(to);
|
||
if (!a || !b) return;
|
||
anchor.rowSpan = Math.max(1, b.row - a.row + 1);
|
||
anchor.colSpan = Math.max(1, b.col - a.col + 1);
|
||
// Hide every covered cell of the block.
|
||
for (let r = a.row; r <= b.row; r++) {
|
||
for (let c = a.col; c <= b.col; c++) {
|
||
if (r === a.row && c === a.col) continue;
|
||
const covered = panel.querySelector(`td[data-cell="${columnName(c)}${r}"]`);
|
||
if (covered) covered.style.display = "none";
|
||
}
|
||
}
|
||
});
|
||
// Freeze panes: sticky the rows/cols left of and above the anchor.
|
||
// Reset first so unfreezing clears the classes without a re-render.
|
||
panel.querySelectorAll(".xlsx-frozen-row").forEach((tr) => tr.classList.remove("xlsx-frozen-row"));
|
||
panel.querySelectorAll(".xlsx-frozen-col").forEach((td) => td.classList.remove("xlsx-frozen-col"));
|
||
const freeze = meta.freeze;
|
||
if (freeze && freeze !== "A1") {
|
||
const f = parseRef(freeze);
|
||
if (f) {
|
||
panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => {
|
||
const ref = tr.querySelector("td")?.dataset.cell || "";
|
||
const p = parseRef(ref);
|
||
if (p && p.row < f.row) tr.classList.add("xlsx-frozen-row");
|
||
});
|
||
panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => {
|
||
const p = parseRef(td.dataset.cell);
|
||
if (p && p.col < f.col) td.classList.add("xlsx-frozen-col");
|
||
});
|
||
}
|
||
}
|
||
};
|
||
// #154-A4 — ARIA grid semantics (the backend renders a plain <table>).
|
||
const applyGridA11y = (panel) => {
|
||
const table = panel.querySelector(".xlsx-table");
|
||
if (!table) return;
|
||
table.setAttribute("role", "grid");
|
||
table.querySelectorAll("thead th").forEach((th) => th.setAttribute("role", "columnheader"));
|
||
table.querySelectorAll("tbody tr").forEach((tr) => {
|
||
tr.setAttribute("role", "row");
|
||
tr.querySelectorAll("th, td").forEach((cell) => {
|
||
cell.setAttribute("role", cell.tagName === "TH" ? "rowheader" : "gridcell");
|
||
});
|
||
});
|
||
};
|
||
// #184 — every sheet is laid out like a real spreadsheet: the grid always
|
||
// shows columns A → Z and 1000 rows, and the empty cells are KEPT (they are
|
||
// ordinary editable cells, not decoration). The grid is expanded for the sheet
|
||
// on screen only — expanding every tab at once would multiply this by the
|
||
// number of sheets, and the expansion is rebuilt when a tab is activated.
|
||
const GRID_MIN_COLS = 26; // A … Z — the opening column bound, always materialised
|
||
const GRID_MIN_ROWS = 1000; // #184 — how far the grid may grow, on demand
|
||
const GRID_CHUNK_ROWS = 100; // rows materialised per extension step
|
||
|
||
/**
|
||
* Pad a panel's table up to GRID_MIN_COLS (columns).
|
||
* Idempotent, and a no-op once the table already reaches that bound.
|
||
*/
|
||
const expandPanelGrid = (panel) => {
|
||
const table = panel.querySelector(".xlsx-table");
|
||
if (!table) return;
|
||
const headRow = table.querySelector("thead tr");
|
||
const tbody = table.querySelector("tbody");
|
||
if (!headRow || !tbody) return;
|
||
if (panel.dataset.gridColsPadded === "1") return;
|
||
panel.dataset.gridColsPadded = "1";
|
||
|
||
// Columns: add the missing letters after the last one.
|
||
const firstCol = columnName(1);
|
||
const lastLetter = (() => {
|
||
const ths = headRow.querySelectorAll("th:not(.xlsx-corner)");
|
||
return ths.length ? ths[ths.length - 1].textContent.trim() : "";
|
||
})();
|
||
const have = colToIndex(lastLetter) || 0;
|
||
if (have < GRID_MIN_COLS) {
|
||
for (let c = have + 1; c <= GRID_MIN_COLS; c++) {
|
||
const th = document.createElement("th");
|
||
th.textContent = columnName(c);
|
||
th.style.cursor = "pointer";
|
||
th.title = t("xlsx.select_column");
|
||
headRow.appendChild(th);
|
||
}
|
||
}
|
||
const cols = headRow.querySelectorAll("th:not(.xlsx-corner)").length;
|
||
|
||
// Pad the rows the server already rendered: a sparse/empty sheet arrives
|
||
// with fewer columns (ex. 8 pour une création) alors que l'en-tête vient
|
||
// d'être complété jusqu'à Z — sans ce comblement, les lignes 1..N
|
||
// n'affichent aucune cellule de I à Z.
|
||
tbody.querySelectorAll("tr").forEach((tr) => {
|
||
const existing = tr.querySelectorAll("td[data-cell]").length;
|
||
if (existing >= cols) return;
|
||
let rowNum = 0;
|
||
const firstTd = tr.querySelector("td[data-cell]");
|
||
if (firstTd) {
|
||
const p = parseRef(firstTd.dataset.cell);
|
||
if (p) rowNum = p.row;
|
||
}
|
||
if (!rowNum) {
|
||
const thNum = tr.querySelector("th.xlsx-rownum");
|
||
rowNum = Number(thNum ? thNum.textContent.trim() : NaN) || 0;
|
||
}
|
||
if (!(rowNum >= 1)) return;
|
||
const fragRow = document.createDocumentFragment();
|
||
for (let c = existing + 1; c <= cols; c++) {
|
||
const td = document.createElement("td");
|
||
td.dataset.cell = `${columnName(c)}${rowNum}`;
|
||
td.setAttribute("role", "gridcell");
|
||
fragRow.appendChild(td);
|
||
}
|
||
tr.appendChild(fragRow);
|
||
});
|
||
|
||
// Colgroup: the row-number gutter plus one entry per column, so the
|
||
// explicit widths still apply on the padded grid.
|
||
let cg = table.querySelector("colgroup.xlsx-cols");
|
||
if (cg) cg.replaceChildren();
|
||
else {
|
||
cg = document.createElement("colgroup");
|
||
cg.className = "xlsx-cols";
|
||
table.prepend(cg);
|
||
}
|
||
const gutter = document.createElement("col");
|
||
gutter.style.width = "44px";
|
||
cg.appendChild(gutter);
|
||
for (let c = 1; c <= cols; c++) {
|
||
const col = document.createElement("col");
|
||
cg.appendChild(col);
|
||
}
|
||
|
||
invalidateCellIndex(panel);
|
||
|
||
// Wire the fresh cells exactly like the ones the server sent.
|
||
if (!readOnly) {
|
||
tbody.querySelectorAll("td[data-cell]").forEach((td) => {
|
||
if (td.dataset.wired !== "1") setupCell(td);
|
||
});
|
||
}
|
||
applyGridA11y(panel);
|
||
};
|
||
|
||
/**
|
||
* #184 — materialise the next block of empty rows (GRID_CHUNK_ROWS at a
|
||
* time, up to GRID_MIN_ROWS). The grid may reach A → Z × 1000, but only the
|
||
* rows the reader actually reaches are created: materialising all 1000 up
|
||
* front means ~26 000 live editable cells per sheet, each one wired, which
|
||
* pinned ~0.3 GB per render and exhausted the heap in the JSDOM suite.
|
||
* Returns the number of rows added.
|
||
*/
|
||
const padMoreRows = (panel) => {
|
||
const table = panel.querySelector(".xlsx-table");
|
||
const tbody = table && table.querySelector("tbody");
|
||
const headRow = table && table.querySelector("thead tr");
|
||
if (!tbody || !headRow) return 0;
|
||
const cols = headRow.querySelectorAll("th:not(.xlsx-corner)").length;
|
||
const rows = tbody.querySelectorAll("tr").length;
|
||
if (!cols || rows >= GRID_MIN_ROWS) return 0;
|
||
const stop = Math.min(rows + GRID_CHUNK_ROWS, GRID_MIN_ROWS);
|
||
const frag = document.createDocumentFragment();
|
||
const added = [];
|
||
for (let r = rows + 1; r <= stop; r++) {
|
||
const tr = document.createElement("tr");
|
||
tr.setAttribute("role", "row");
|
||
const th = document.createElement("th");
|
||
th.className = "xlsx-rownum";
|
||
th.setAttribute("role", "rowheader");
|
||
th.textContent = String(r);
|
||
th.style.cursor = "pointer";
|
||
th.title = t("xlsx.select_row");
|
||
tr.appendChild(th);
|
||
for (let c = 1; c <= cols; c++) {
|
||
const td = document.createElement("td");
|
||
td.dataset.cell = `${columnName(c)}${r}`;
|
||
td.setAttribute("role", "gridcell");
|
||
tr.appendChild(td);
|
||
}
|
||
frag.appendChild(tr);
|
||
added.push(tr);
|
||
}
|
||
tbody.appendChild(frag);
|
||
invalidateCellIndex(panel);
|
||
if (!readOnly) {
|
||
added.forEach((tr) => tr.querySelectorAll("td[data-cell]").forEach(setupCell));
|
||
}
|
||
applyGridA11y(panel);
|
||
return stop - rows;
|
||
};
|
||
|
||
const panels = isCsv
|
||
? `<div class="xlsx-panel" data-sheet="0">${sheets[0]?.html || data.html || ""}</div>`
|
||
: sheets.map((s, i) =>
|
||
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${s.html}${truncationNote(s)}</div>`
|
||
).join("");
|
||
const lossWarning = lossy.length
|
||
? `<div class="xlsx-warning" role="note">
|
||
<i data-lucide="alert-triangle" class="xlsx-warning-icon"></i>
|
||
<div class="xlsx-warning-body">
|
||
<strong>${escapeHtml(t("xlsx.lossy_title"))}</strong>
|
||
<span class="xlsx-warning-list">${lossy
|
||
.map((f) => `<span class="xlsx-warning-tag">${escapeHtml(t("xlsx.feature_" + f))}</span>`)
|
||
.join("")}</span>
|
||
<span class="xlsx-warning-hint">${escapeHtml(t("xlsx.lossy_hint"))}</span>
|
||
</div>
|
||
</div>`
|
||
: "";
|
||
|
||
// #154-A1 — status pills and the grouped command bar are built by
|
||
// buildCommandBar() (frontend/js/xlsx/command-bar.js).
|
||
|
||
area.innerHTML = `
|
||
<div class="xlsx-viewer">
|
||
<div class="xlsx-toolbar">
|
||
${menuBarHtml}
|
||
<div class="xlsx-cmdbar" role="toolbar" aria-label="${escapeHtml(t("xlsx.cmdbar_label"))}">
|
||
<span class="xlsx-toolbar-actions">${actionsHtml}</span>
|
||
</div>
|
||
<div class="xlsx-sheetbar">
|
||
${tabs}
|
||
<span class="xlsx-sheetbar-end">${fileHtml}</span>
|
||
</div>
|
||
${statusBar}
|
||
<div class="xlsx-formula-bar" role="region" aria-label="${escapeHtml(t("xlsx.mi_show_formula"))}">
|
||
<span class="xlsx-editbar">
|
||
<input type="text" class="xlsx-active-cell" id="xlsx-active-cell" value=""
|
||
spellcheck="false" autocomplete="off" placeholder="A1"
|
||
aria-label="${escapeHtml(t("xlsx.name_box_label"))}"
|
||
title="${escapeHtml(t("xlsx.name_box_hint"))}" />
|
||
<span class="xlsx-edit-fx" aria-hidden="true">fx</span>
|
||
<input type="text" class="xlsx-formula-input" id="xlsx-formula-input"
|
||
spellcheck="false" list="xlsx-function-list" placeholder="${escapeHtml(t("xlsx.formula_bar_placeholder"))}"
|
||
aria-label="${escapeHtml(t("xlsx.formula_bar_placeholder"))}" />
|
||
<datalist id="xlsx-function-list"></datalist>
|
||
</span>
|
||
<span class="xlsx-find-group">
|
||
<input type="text" class="xlsx-find-input" id="xlsx-find-input"
|
||
spellcheck="false" placeholder="${escapeHtml(t("xlsx.find_placeholder"))}" />
|
||
<span class="xlsx-find-count" id="xlsx-find-count"></span>
|
||
<button class="btn-action xlsx-find-btn" id="xlsx-find-prev" title="${escapeHtml(t("xlsx.find_prev"))}">↑</button>
|
||
<button class="btn-action xlsx-find-btn" id="xlsx-find-next" title="${escapeHtml(t("xlsx.find_next"))}">↓</button>
|
||
<button class="btn-action xlsx-find-case" id="xlsx-find-case" aria-pressed="false" title="${escapeHtml(t("xlsx.find_case"))}">Aa</button>
|
||
<button class="btn-action xlsx-sort-reset" id="xlsx-sort-reset" title="${escapeHtml(t("xlsx.sort_reset"))}" style="display:none">
|
||
<i data-lucide="rotate-ccw" style="width:14px;height:14px"></i>
|
||
</button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
${lossWarning}
|
||
<div class="xlsx-body">
|
||
<div class="xlsx-main">
|
||
<div class="xlsx-panels">${panels}</div>
|
||
</div>
|
||
<aside class="xlsx-inspector" id="xlsx-inspector" aria-hidden="true">
|
||
<div class="xlsx-inspector-resize" id="xlsx-inspector-resize" role="separator" aria-orientation="vertical" title="${escapeHtml(t("xlsx.inspector_resize"))}"></div>
|
||
<div class="xlsx-inspector-head">
|
||
<span class="xlsx-inspector-title">${escapeHtml(t("xlsx.inspector_title"))}</span>
|
||
<span class="xlsx-inspector-actions">
|
||
<button class="btn-action xlsx-inspector-ai" id="xlsx-inspector-ai" type="button" title="${escapeHtml(t("xlsx.inspector_ai"))}">
|
||
<i data-lucide="bot" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.inspector_ai_short"))}
|
||
</button>
|
||
<button class="btn-action xlsx-inspector-close" id="xlsx-inspector-close" type="button" aria-label="${escapeHtml(t("common.cancel"))}">✕</button>
|
||
</span>
|
||
</div>
|
||
<div class="xlsx-inspector-body" id="xlsx-inspector-body"></div>
|
||
</aside>
|
||
</div>
|
||
</div>`;
|
||
|
||
// A re-render invalidates any open context menu (it targets the old DOM).
|
||
closeContextMenu();
|
||
|
||
// ── #153 A17 — dashboard panel: named ranges, objects, per-sheet KPI ──
|
||
// Fetched lazily on first open of the dashboard tab; a workbook without
|
||
// anything notable hides the button entirely (no empty panel).
|
||
// #185 — `dashBtn` is declared out here so Affichage ▸ Afficher ▸ Tableau de
|
||
// bord (and Données ▸ Plages nommées) can reach the very same panel.
|
||
let dashBtn = null;
|
||
if (!isCsv && !readOnly) {
|
||
dashBtn = el("button", {
|
||
class: "btn-action xlsx-dash-btn",
|
||
id: "xlsx-dashboard-btn",
|
||
type: "button",
|
||
title: t("xlsx.dashboard_btn"),
|
||
}, [icon("layout-dashboard", 14), document.createTextNode(t("xlsx.dashboard_btn"))]);
|
||
(area.querySelector("#xlsx-view-group") || area.querySelector(".xlsx-toolbar-actions")).appendChild(dashBtn);
|
||
const inspector = area.querySelector("#xlsx-inspector");
|
||
const inspectorBody = area.querySelector("#xlsx-inspector-body");
|
||
const closeInspector = () => {
|
||
const panel = inspectorBody.querySelector(".xlsx-dashboard");
|
||
if (panel) panel.remove();
|
||
inspector.classList.remove("open");
|
||
inspector.setAttribute("aria-hidden", "true");
|
||
dashBtn.classList.remove("active");
|
||
};
|
||
dashBtn.addEventListener("click", async () => {
|
||
// #154-A3 — the dashboard lives in a right-hand inspector so the grid
|
||
// stays visible beside it (it used to be prepended into the grid flow).
|
||
if (inspectorBody.querySelector(".xlsx-dashboard")) { closeInspector(); return; }
|
||
dashBtn.classList.add("active");
|
||
inspector.classList.add("open");
|
||
inspector.setAttribute("aria-hidden", "false");
|
||
const panel = document.createElement("div");
|
||
panel.className = "xlsx-dashboard";
|
||
panel.innerHTML = renderDashboardLoading();
|
||
inspectorBody.prepend(panel);
|
||
try {
|
||
const dash = await api(
|
||
`/api/file/${encodeURIComponent(data.vault)}/xlsx/dashboard?path=${encodeURIComponent(data.path)}`,
|
||
);
|
||
panel.innerHTML = renderDashboardHtml(dash);
|
||
safeCreateIcons();
|
||
// #154-A5 — clicking a named range reveals its first cell in the grid.
|
||
panel.querySelectorAll(".xlsx-range-row[data-ref]").forEach((row) => {
|
||
row.addEventListener("click", () => revealRange(row.dataset.ref));
|
||
});
|
||
} catch (err) {
|
||
panel.innerHTML = `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.load_error"))}: ${escapeHtml(String(err.message || err))}</p>`;
|
||
}
|
||
});
|
||
area.querySelector("#xlsx-inspector-close").addEventListener("click", closeInspector);
|
||
// #154-A3 — the assistant is the existing global side panel; the inspector
|
||
// just gives it a contextual entry point from the spreadsheet.
|
||
area.querySelector("#xlsx-inspector-ai").addEventListener("click", () => { openAssistant(); });
|
||
|
||
// #154-A5 — the inspector is resizable (width restored per session).
|
||
const resizeHandle = area.querySelector("#xlsx-inspector-resize");
|
||
const WIDTH_KEY = "obsigate_xlsx_inspector_width";
|
||
const clampWidth = (w) => Math.min(640, Math.max(260, w));
|
||
const savedWidth = Number(localStorage.getItem(WIDTH_KEY));
|
||
if (savedWidth >= 260 && savedWidth <= 640) {
|
||
inspector.style.flexBasis = `${savedWidth}px`;
|
||
inspector.style.width = `${savedWidth}px`;
|
||
}
|
||
let resizing = false;
|
||
const onMove = (e) => {
|
||
if (!resizing) return;
|
||
const width = clampWidth(inspector.getBoundingClientRect().right - e.clientX);
|
||
inspector.style.flexBasis = `${width}px`;
|
||
inspector.style.width = `${width}px`;
|
||
};
|
||
const onUp = () => {
|
||
if (!resizing) return;
|
||
resizing = false;
|
||
document.body.style.userSelect = "";
|
||
localStorage.setItem(WIDTH_KEY, String(Math.round(inspector.getBoundingClientRect().width)));
|
||
document.removeEventListener("mousemove", onMove);
|
||
document.removeEventListener("mouseup", onUp);
|
||
};
|
||
resizeHandle.addEventListener("mousedown", (e) => {
|
||
e.preventDefault();
|
||
resizing = true;
|
||
document.body.style.userSelect = "none";
|
||
document.addEventListener("mousemove", onMove);
|
||
document.addEventListener("mouseup", onUp);
|
||
});
|
||
}
|
||
|
||
const saveBtn = area.querySelector("#xlsx-save-btn");
|
||
const panelEls = [...area.querySelectorAll(".xlsx-panel")];
|
||
const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length;
|
||
const refreshSaveState = () => {
|
||
const dirty = dirtyCount() > 0;
|
||
saveBtn.disabled = !dirty;
|
||
saveBtn.classList.toggle("is-dirty", dirty);
|
||
// #154-A2 — star a sheet tab that holds unsaved edits.
|
||
panelEls.forEach((panel) => {
|
||
const tab = area.querySelector(`.xlsx-tab[data-sheet="${panel.dataset.sheet}"]`);
|
||
if (tab) tab.classList.toggle("xlsx-tab-dirty", panel.querySelectorAll("td.xlsx-dirty").length > 0);
|
||
});
|
||
};
|
||
|
||
// #154-A2 — non-blocking conflict banner (409 `conflict`): the edits are kept
|
||
// and the user can retry the save without losing their work.
|
||
const bannerHost = document.createElement("div");
|
||
bannerHost.className = "xlsx-banner-host";
|
||
area.querySelector(".xlsx-panels").before(bannerHost);
|
||
const showConflict = (msg) => {
|
||
bannerHost.innerHTML = "";
|
||
const banner = document.createElement("div");
|
||
banner.className = "xlsx-banner xlsx-banner-conflict";
|
||
banner.setAttribute("role", "alert");
|
||
banner.innerHTML = `<i data-lucide="git-merge" class="xlsx-banner-icon"></i>
|
||
<span class="xlsx-banner-text">${escapeHtml(msg)}</span>
|
||
<button class="btn-action xlsx-banner-retry" type="button">${escapeHtml(t("xlsx.conflict_retry"))}</button>
|
||
<button class="btn-action xlsx-banner-dismiss" type="button" aria-label="${escapeHtml(t("common.cancel"))}">✕</button>`;
|
||
// #156-A12 — the file changed elsewhere: re-read it first so the retry
|
||
// carries the CURRENT revision instead of failing on the same stale token.
|
||
banner.querySelector(".xlsx-banner-retry").addEventListener("click", async () => {
|
||
bannerHost.innerHTML = "";
|
||
try {
|
||
const fresh = await api(
|
||
`/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`,
|
||
);
|
||
if (fresh && fresh.xlsx_revision) revision = fresh.xlsx_revision;
|
||
} catch (_) { /* keep the previous token: the retry is refused again */ }
|
||
saveBtn.click();
|
||
});
|
||
banner.querySelector(".xlsx-banner-dismiss").addEventListener("click", () => {
|
||
bannerHost.innerHTML = "";
|
||
});
|
||
bannerHost.appendChild(banner);
|
||
safeCreateIcons();
|
||
};
|
||
|
||
// #153 A9bis — the first render stops at MAX_ROWS/MAX_COLS; the tail is
|
||
// fetched window by window from GET …/xlsx/sheet when the user reaches the
|
||
// end of a truncated sheet (scroll sentinel) or clicks « Charger la suite ».
|
||
// Appended rows reuse the exact same edit pipeline as the initial render.
|
||
const wireLazyRows = (panel) => {
|
||
const meta = sheets[Number(panel.dataset.sheet)] || {};
|
||
if (!meta.truncated) return;
|
||
const wrapper = panel.querySelector(".csv-table-wrapper");
|
||
const table = panel.querySelector(".xlsx-table tbody");
|
||
if (!wrapper || !table) return;
|
||
|
||
let offset = Number(meta.rows) || 0;
|
||
const total = Number(meta.total_rows) || 0;
|
||
let loading = false;
|
||
let done = offset >= total;
|
||
|
||
const foot = document.createElement("div");
|
||
foot.className = "xlsx-load-more";
|
||
const refreshFoot = () => {
|
||
foot.textContent = done
|
||
? ""
|
||
: `${t("xlsx.truncated_rows", { shown: offset, total })} · ${t("xlsx.load_more")}`;
|
||
foot.classList.toggle("done", done);
|
||
};
|
||
refreshFoot();
|
||
foot.addEventListener("click", () => { if (!done) loadMore(); });
|
||
wrapper.insertAdjacentElement("afterend", foot);
|
||
|
||
const appendWindow = (win) => {
|
||
const doc = new DOMParser().parseFromString(`<table>${win.html}</table>`, "text/html");
|
||
// No `tbody` selector: the fragment embeds its own wrapper div, so the
|
||
// parse yields bare `<tr>` inside `<table>` (the rows we want) — while
|
||
// any `tbody` in the fragment belongs to the *embedded* wrapper table.
|
||
doc.querySelectorAll("tr").forEach((tr) => table.appendChild(tr));
|
||
offset = win.offset + win.rows;
|
||
done = !win.has_more;
|
||
refreshFoot();
|
||
// The new rows must behave like the initial ones: contenteditable,
|
||
// dirty tracking, cached-value tooltip, icons in the fresh footnote.
|
||
panel.querySelectorAll("tbody tr:not([data-wired]) td").forEach(setupCell);
|
||
panel.querySelectorAll("tbody tr").forEach((tr) => tr.setAttribute("data-wired", "1"));
|
||
const cachedEls = panel.querySelectorAll(".xlsx-cached[data-cached-value]");
|
||
cachedEls.forEach((el) => { if (!el.title) el.title = t("xlsx.cached_value_title"); });
|
||
// #153 A15 — the window carries its own style/merge metadata: fold it
|
||
// into the sheet meta and re-apply, so lazy rows look like the initial
|
||
// render (applySheetMeta is idempotent over already-processed cells).
|
||
const sheetMeta = sheets[Number(panel.dataset.sheet)];
|
||
if (sheetMeta) {
|
||
Object.assign(sheetMeta.styles || (sheetMeta.styles = {}), win.styles || {});
|
||
Object.assign(sheetMeta.aligns || (sheetMeta.aligns = {}), win.aligns || {});
|
||
Object.assign(sheetMeta.comments || (sheetMeta.comments = {}), win.comments || {});
|
||
Object.assign(sheetMeta.formats || (sheetMeta.formats = {}), win.formats || {});
|
||
Object.assign(sheetMeta.colwidths || (sheetMeta.colwidths = {}), win.colwidths || {});
|
||
Object.assign(sheetMeta.rowheights || (sheetMeta.rowheights = {}), win.rowheights || {});
|
||
if (!sheetMeta.merges) sheetMeta.merges = [];
|
||
(win.merges || []).forEach((m) => {
|
||
if (!sheetMeta.merges.includes(m)) sheetMeta.merges.push(m);
|
||
});
|
||
if (win.freeze && !sheetMeta.freeze) sheetMeta.freeze = win.freeze;
|
||
applySheetMeta(panel);
|
||
refreshPanelFormulas(panel);
|
||
}
|
||
applyGridA11y(panel);
|
||
safeCreateIcons();
|
||
};
|
||
|
||
const loadMore = async () => {
|
||
if (loading || done) return;
|
||
loading = true;
|
||
const label = foot.textContent;
|
||
foot.textContent = t("xlsx.loading_more");
|
||
try {
|
||
const win = await api(
|
||
`/api/file/${encodeURIComponent(data.vault)}/xlsx/sheet?path=${encodeURIComponent(data.path)}` +
|
||
`&sheet=${encodeURIComponent(sheets[Number(panel.dataset.sheet)].name)}` +
|
||
`&offset=${offset}&limit=500`,
|
||
);
|
||
appendWindow(win);
|
||
} catch (err) {
|
||
foot.textContent = label; // restore: a failed fetch must not eat the button
|
||
showToast(`${t("xlsx.load_error")}: ${err.message || err}`, "error");
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
};
|
||
|
||
// #154-A4 — prefer IntersectionObserver (less main-thread work than a
|
||
// scroll listener); the scroll listener stays as a fallback for
|
||
// environments without IO (JSDOM).
|
||
if (typeof IntersectionObserver === "function") {
|
||
const io = new IntersectionObserver((entries) => {
|
||
if (entries.some((entry) => entry.isIntersecting)) loadMore();
|
||
}, { root: wrapper, rootMargin: "120px" });
|
||
io.observe(foot);
|
||
} else {
|
||
wrapper.addEventListener("scroll", () => {
|
||
if (loading || done) return;
|
||
if (wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 120) loadMore();
|
||
});
|
||
}
|
||
};
|
||
|
||
// ── Formula engine: local evaluation of `=…` cells ───────────────────
|
||
// The viewer computes formulas itself (frontend/js/xlsx/formula.js, no
|
||
// round-trip): display values update live while the raw formula stays in
|
||
// `td.dataset.formula` (what gets saved). Anything the parser rejects
|
||
// keeps its original rendering — Excel remains the source of truth.
|
||
const formulaLocale = () => {
|
||
try { return getLocale() === "fr" ? "fr" : "en"; } catch (_) { return "en"; }
|
||
};
|
||
/** User-typed text of a cell (helper spans excluded). */
|
||
const cleanCellText = (td) => {
|
||
if (!td) return "";
|
||
const clone = td.cloneNode(true);
|
||
clone.querySelectorAll(".xlsx-cached,.xlsx-comment-dot").forEach((el) => el.remove());
|
||
return clone.textContent;
|
||
};
|
||
/** Storable content: the formula when the engine manages the cell. */
|
||
const cellRaw = (td) => (td && td.dataset.formula) || cleanCellText(td);
|
||
const barTextOf = (td) => cellRaw(td);
|
||
const panelSheetName = (panel) => {
|
||
const idx = Number(panel && panel.dataset ? panel.dataset.sheet : NaN);
|
||
return (Number.isFinite(idx) && sheets[idx] ? sheets[idx].name : "") || "";
|
||
};
|
||
const panelBySheet = (name) => {
|
||
const idx = sheets.findIndex((s) => s && s.name === name);
|
||
if (idx < 0) return null;
|
||
return panelEls.find((p) => Number(p.dataset.sheet) === idx) || null;
|
||
};
|
||
const readRawCell = (sheetName, ref) => {
|
||
const panel = panelBySheet(sheetName);
|
||
if (!panel) return undefined; // unknown sheet → #REF!, not blank
|
||
const td = panel.querySelector(`td[data-cell="${String(ref).toUpperCase()}"]`);
|
||
if (!td) return null;
|
||
return cellRaw(td);
|
||
};
|
||
const decorateFormulaCell = (td, value, locale) => {
|
||
td.querySelectorAll(".xlsx-cached").forEach((el) => el.remove());
|
||
if (value && value.t === "link") {
|
||
const a = document.createElement("a");
|
||
a.href = value.url;
|
||
a.className = "xlsx-formula-link";
|
||
a.rel = "noopener";
|
||
a.textContent = value.label;
|
||
a.title = value.url;
|
||
td.textContent = "";
|
||
td.appendChild(a);
|
||
} else {
|
||
td.textContent = displayValue(value, locale);
|
||
}
|
||
td.dataset.display = td.textContent;
|
||
};
|
||
/**
|
||
* Adopt `raw` as the cell's content: manage formulas, release the rest.
|
||
* With `render=false` (keystrokes in flight) only the identity is adopted —
|
||
* dependents read the latest valid formula while the caret is untouched.
|
||
*/
|
||
const adoptCellRaw = (td, raw, scope, render = true) => {
|
||
const locale = scope.locale;
|
||
if (!isFormulaText(raw)) {
|
||
delete td.dataset.formula;
|
||
delete td.dataset.display;
|
||
return false;
|
||
}
|
||
let ast;
|
||
try {
|
||
ast = parseFormula(raw);
|
||
} catch (_) {
|
||
// Half-typed while editing: keep the previous identity so dependents
|
||
// stay stable; once committed, release so the text saves as typed.
|
||
if (render) {
|
||
delete td.dataset.formula;
|
||
delete td.dataset.display;
|
||
}
|
||
return false;
|
||
}
|
||
let value;
|
||
try {
|
||
value = evaluate(ast, scope);
|
||
} catch (_) {
|
||
if (render) {
|
||
delete td.dataset.formula;
|
||
delete td.dataset.display;
|
||
}
|
||
return false;
|
||
}
|
||
// Unknown function but Excel left a cached result: defer to it instead
|
||
// of showing #NOM? — the shadow stays the display (A12 fallback).
|
||
if (value && value.t === "e" && value.code === "NAME"
|
||
&& td.querySelector(".xlsx-cached[data-cached-value]")) {
|
||
delete td.dataset.formula;
|
||
delete td.dataset.display;
|
||
return false;
|
||
}
|
||
td.dataset.formula = raw.trim();
|
||
if (render) {
|
||
decorateFormulaCell(td, value, locale);
|
||
decorateComments(td);
|
||
}
|
||
return true;
|
||
};
|
||
const refreshPanelFormulas = (panel, opts = {}) => {
|
||
if (!panel || !panel.isConnected) return;
|
||
// Frappe en cours (`live`) : sans cellule gérée par le moteur, aucun
|
||
// affichage ne peut changer — les dépendants n'existent que via
|
||
// `dataset.formula`. On évite alors le balayage des ~26 000 cellules à
|
||
// chaque touche (le commit au `blur` reste, lui, un balayage complet qui
|
||
// adopte les nouvelles formules). Test natif, arrêté au premier succès.
|
||
// (`opts` peut recevoir l'index d'un `forEach` : accès défensif.)
|
||
if (opts && opts.live && !panel.querySelector("td[data-formula]")) return;
|
||
const locale = formulaLocale();
|
||
const sheet = panelSheetName(panel);
|
||
const scope = createScope({ readRaw: readRawCell, sheet, locale });
|
||
panel.querySelectorAll("td[data-cell]").forEach((td) => {
|
||
// #184 — a padded grid is mostly empty: an empty cell without a managed
|
||
// formula can never produce a value, so skip it BEFORE the expensive
|
||
// cellRaw()/cloneNode() path. Without this, one keystroke would clone
|
||
// 26 000 nodes.
|
||
if (!td.dataset.formula && !td.textContent) return;
|
||
// While the user is typing in the cell, adopt the identity (so
|
||
// dependents follow) but never rewrite its text under the caret.
|
||
// Programmatic writes carry editing=1 without focus: those render fully.
|
||
const typing = td === document.activeElement && td.dataset.editing === "1";
|
||
if (typing) {
|
||
const raw = cleanCellText(td);
|
||
if (isFormulaText(raw) || td.dataset.formula) adoptCellRaw(td, raw, scope, false);
|
||
return;
|
||
}
|
||
const managed = Boolean(td.dataset.formula);
|
||
if (managed && td.textContent === (td.dataset.display ?? "")) {
|
||
adoptCellRaw(td, td.dataset.formula, scope); // deps may have changed
|
||
} else if (managed) {
|
||
// The user overwrote the display: adopt the CURRENT text, never the
|
||
// stale dataset (cellRaw would hand back the old formula).
|
||
adoptCellRaw(td, cleanCellText(td), scope);
|
||
} else {
|
||
const raw = cellRaw(td);
|
||
if (isFormulaText(raw)) adoptCellRaw(td, raw, scope);
|
||
}
|
||
});
|
||
};
|
||
const refreshAllFormulas = () => {
|
||
panelEls.forEach(refreshPanelFormulas);
|
||
};
|
||
/** Reflect the active cell's toggles (bold/italic/strike/underline, font size). */
|
||
const updateSheetsToolbarStates = () => {
|
||
const td = activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null;
|
||
const setToggle = (id, on) => {
|
||
const b = area.querySelector(`#${id}`);
|
||
if (!b) return;
|
||
b.classList.toggle("active", Boolean(on));
|
||
b.setAttribute("aria-pressed", String(Boolean(on)));
|
||
};
|
||
setToggle("xs-b", td && (td.style.fontWeight === "600" || td.style.fontWeight === "bold" || td.style.fontWeight === "700"));
|
||
setToggle("xs-i", td && td.style.fontStyle === "italic");
|
||
setToggle("xs-u", td && (td.style.textDecoration || "").includes("underline"));
|
||
setToggle("xs-s", td && (td.style.textDecoration || "").includes("line-through"));
|
||
const sizeEl = area.querySelector("#xs-fssize");
|
||
if (sizeEl) {
|
||
const px = td && td.style.fontSize ? Number.parseFloat(td.style.fontSize) : NaN;
|
||
const size = String(Number.isFinite(px) ? Math.round(px) : 11);
|
||
if (![...sizeEl.options].some((o) => o.value === size)) {
|
||
const opt = document.createElement("option");
|
||
opt.value = size;
|
||
opt.textContent = size;
|
||
sizeEl.appendChild(opt);
|
||
}
|
||
sizeEl.value = size;
|
||
}
|
||
const wrapBtn = area.querySelector("#xs-wrap");
|
||
if (wrapBtn) {
|
||
const on = td && td.style.whiteSpace === "normal";
|
||
wrapBtn.classList.toggle("active", Boolean(on));
|
||
wrapBtn.setAttribute("aria-pressed", String(Boolean(on)));
|
||
}
|
||
};
|
||
|
||
// ── #153 A7 — keyboard navigation & formula bar ────────────────────────
|
||
// One active cell per viewer: clicking or arrowing into a cell shows its
|
||
// A1 name in the bar; the input mirrors the cell text and typing there
|
||
// edits the cell live (Enter commits, Escape reverts, then re-focuses).
|
||
let activeTd = null;
|
||
const activeCellEl = area.querySelector("#xlsx-active-cell");
|
||
const formulaInput = area.querySelector("#xlsx-formula-input");
|
||
const syncing = { value: false }; // guard against input-event feedback loops
|
||
// #155 — Excel-like range selection: { panel, anchor, focus } in (row, col).
|
||
let selection = null;
|
||
let suppressSelectionReset = false;
|
||
|
||
const cellName = (td) => td?.dataset.cell || "";
|
||
|
||
// #181-A2 / #182 — the selected block is outlined on its four real edges.
|
||
const SELECT_EDGE_CLASSES = [
|
||
"xlsx-sel-top", "xlsx-sel-bottom", "xlsx-sel-left", "xlsx-sel-right",
|
||
];
|
||
// #182 — class selectors need their leading dot: a bare `xlsx-sel-top` is a
|
||
// TYPE selector (an `<xlsx-sel-top>` element), so the query matched nothing
|
||
// and a previous outline was never cleared — the boxes piled up on every
|
||
// focus change. (Le balayage global est désormais remplacé par `clearMarks`
|
||
// ci-dessous : plus aucun `querySelectorAll` sur toute la grille.)
|
||
|
||
// Marques de sélection incrémentales : la grille compte ~26 000 cellules, et
|
||
// chaque changement de sélection (focus, flèche, survol pendant un glisser)
|
||
// balayait tout le panneau en `querySelectorAll` pour effacer les anciennes
|
||
// marques — d'où les saccades, et les indicateurs fantômes quand le balayage
|
||
// ratait sa cible (ex. `remove(".xlsx-hdr-selected")` avec un point : le
|
||
// sélecteur ne correspondait jamais). Seuls les nœuds précédemment marqués
|
||
// sont désormais retouchés : O(sélection) au lieu de O(grille).
|
||
let lastMarks = { cells: [], edges: [], headers: [] };
|
||
// Racine visuelle du viewer : l'état « sélection multiple » est porté par
|
||
// `.xlsx-viewer` (et non par `area` = #content-area, qui est le parent) —
|
||
// le sélecteur CSS `.xlsx-viewer.xlsx-range-multi` exige les deux classes
|
||
// sur le même élément. Cache résolu paresseusement : applySelection() tourne
|
||
// à chaque mousemove d'un glisser.
|
||
let viewerRootEl = null;
|
||
const viewerRoot = () => {
|
||
if (!viewerRootEl || !viewerRootEl.isConnected) {
|
||
viewerRootEl = area.querySelector(".xlsx-viewer") || area;
|
||
}
|
||
return viewerRootEl;
|
||
};
|
||
const clearMarks = () => {
|
||
for (const td of lastMarks.cells) {
|
||
if (td && td.classList) td.classList.remove("xlsx-selected");
|
||
}
|
||
for (const td of lastMarks.edges) {
|
||
if (td && td.classList) td.classList.remove(...SELECT_EDGE_CLASSES);
|
||
}
|
||
for (const el of lastMarks.headers) {
|
||
if (el && el.classList) el.classList.remove("xlsx-hdr-selected");
|
||
}
|
||
// Plus de sélection → plus de teinte multi-cellules.
|
||
viewerRoot().classList.remove("xlsx-range-multi");
|
||
lastMarks = { cells: [], edges: [], headers: [] };
|
||
};
|
||
|
||
const clearSelectionClasses = () => {
|
||
clearMarks();
|
||
};
|
||
|
||
const selectionBounds = () => {
|
||
if (!selection) return null;
|
||
const { anchor, focus } = selection;
|
||
return {
|
||
r1: Math.min(anchor.row, focus.row),
|
||
r2: Math.max(anchor.row, focus.row),
|
||
c1: Math.min(anchor.col, focus.col),
|
||
c2: Math.max(anchor.col, focus.col),
|
||
};
|
||
};
|
||
|
||
const rangeLabel = () => {
|
||
const b = selectionBounds();
|
||
if (!b) return "";
|
||
const first = `${columnName(b.c1)}${b.r1}`;
|
||
return b.r1 === b.r2 && b.c1 === b.c2 ? first : `${first}:${columnName(b.c2)}${b.r2}`;
|
||
};
|
||
|
||
// Paint the current selection and show its address in the Name Box.
|
||
// #181-A2 — the selected cells carry `xlsx-selected` (a tint) plus the
|
||
// `xlsx-sel-*` edge classes, which trace the block exactly the way
|
||
// Excel/Sheets draw it, instead of a flat wash over every cell.
|
||
const paintSelectionEdges = (panel, b) => {
|
||
for (let r = b.r1; r <= b.r2; r++) {
|
||
for (let c = b.c1; c <= b.c2; c++) {
|
||
const td = findTd(panel, r, c);
|
||
if (!td) continue;
|
||
let touched = false;
|
||
if (r === b.r1) { td.classList.add("xlsx-sel-top"); touched = true; }
|
||
if (r === b.r2) { td.classList.add("xlsx-sel-bottom"); touched = true; }
|
||
if (c === b.c1) { td.classList.add("xlsx-sel-left"); touched = true; }
|
||
if (c === b.c2) { td.classList.add("xlsx-sel-right"); touched = true; }
|
||
if (touched) lastMarks.edges.push(td);
|
||
}
|
||
}
|
||
};
|
||
|
||
const applySelection = () => {
|
||
if (!selection) return;
|
||
const { panel } = selection;
|
||
const b = selectionBounds();
|
||
clearMarks();
|
||
// Google Sheets : la teinte de fond n'apparaît qu'à partir de DEUX
|
||
// cellules (`.xlsx-range-multi` sur le viewer). Une sélection d'une seule
|
||
// cellule ne garde que son cadre accent (xlsx-sel-* + xlsx-active), sans
|
||
// changer le fond de la cellule.
|
||
viewerRoot().classList.toggle("xlsx-range-multi", b.r1 !== b.r2 || b.c1 !== b.c2);
|
||
for (let r = b.r1; r <= b.r2; r++) {
|
||
for (let c = b.c1; c <= b.c2; c++) {
|
||
const td = findTd(panel, r, c);
|
||
if (td) {
|
||
td.classList.add("xlsx-selected");
|
||
lastMarks.cells.push(td);
|
||
}
|
||
}
|
||
}
|
||
// #181-A2 — the range outline, plus the header highlight so the selected
|
||
// row numbers / column letters read as selected too.
|
||
paintSelectionEdges(panel, b);
|
||
paintSelectedHeaders(panel, b);
|
||
activeCellEl.value = rangeLabel();
|
||
updateMinibar();
|
||
};
|
||
|
||
// Highlight the headers covered by the current range (`A`, `B`… / `1`, `2`…).
|
||
const paintSelectedHeaders = (panel, b) => {
|
||
const head = panel.querySelector(".xlsx-table thead tr");
|
||
if (head) {
|
||
[...head.querySelectorAll("th:not(.xlsx-corner)")].forEach((th, i) => {
|
||
if (i + 1 >= b.c1 && i + 1 <= b.c2) {
|
||
th.classList.add("xlsx-hdr-selected");
|
||
lastMarks.headers.push(th);
|
||
}
|
||
});
|
||
}
|
||
panel.querySelectorAll("th.xlsx-rownum").forEach((th) => {
|
||
const row = Number(th.textContent);
|
||
if (row >= b.r1 && row <= b.r2) {
|
||
th.classList.add("xlsx-hdr-selected");
|
||
lastMarks.headers.push(th);
|
||
}
|
||
});
|
||
};
|
||
|
||
const setSelection = (anchorTd, focusTd = anchorTd) => {
|
||
if (!anchorTd) { clearSelectionClasses(); selection = null; return; }
|
||
const panel = anchorTd.closest(".xlsx-panel");
|
||
const anchor = parseRef(cellName(anchorTd));
|
||
const focus = parseRef(cellName(focusTd));
|
||
if (!panel || !anchor || !focus) return;
|
||
selection = { panel, anchor, focus };
|
||
applySelection();
|
||
};
|
||
|
||
const inSelection = (td) => {
|
||
const b = selectionBounds();
|
||
const p = parseRef(cellName(td));
|
||
if (!b || !p || !selection || selection.panel !== td.closest(".xlsx-panel")) return false;
|
||
return p.row >= b.r1 && p.row <= b.r2 && p.col >= b.c1 && p.col <= b.c2;
|
||
};
|
||
|
||
// La cellule portant le contour : suivie par référence plutôt que par un
|
||
// `querySelector` sur toute la grille à chaque déplacement. `markedActiveTd`
|
||
// (le contour) est distinct d'`activeTd` (le focus logique) : les deux ne
|
||
// divergent que sur les chemins d'extension de plage, et le contour reste
|
||
// nettoyé dans tous les cas, sans balayage.
|
||
let markedActiveTd = null;
|
||
const unmarkActive = () => {
|
||
if (markedActiveTd && markedActiveTd.classList) markedActiveTd.classList.remove("xlsx-active");
|
||
markedActiveTd = null;
|
||
};
|
||
const setActiveCell = (td) => {
|
||
unmarkActive();
|
||
activeTd = td || null;
|
||
if (!td) {
|
||
activeCellEl.value = "";
|
||
formulaInput.value = "";
|
||
formulaInput.disabled = true;
|
||
setSelection(null);
|
||
return;
|
||
}
|
||
td.classList.add("xlsx-active");
|
||
markedActiveTd = td;
|
||
activeCellEl.value = cellName(td);
|
||
formulaInput.disabled = false;
|
||
formulaInput.value = barTextOf(td);
|
||
setSelection(td);
|
||
updateSheetsToolbarStates();
|
||
};
|
||
|
||
const syncFormulaBar = (td) => {
|
||
if (td !== activeTd) return;
|
||
if (!syncing.value) formulaInput.value = barTextOf(td);
|
||
};
|
||
|
||
// #154-A5 — reveal a named range's first cell in the grid (dashboard → grid).
|
||
const revealRange = (ref) => {
|
||
const cellRef = firstCellOfRange(ref);
|
||
if (!cellRef) return;
|
||
const sheetName = sheetOfRef(ref);
|
||
let panel = visiblePanel();
|
||
if (sheetName) {
|
||
const idx = sheets.findIndex((s) => s.name === sheetName);
|
||
const tab = idx >= 0 ? area.querySelector(`.xlsx-tab[data-sheet="${idx}"]`) : null;
|
||
if (tab) { tab.click(); panel = panelEls[idx]; }
|
||
}
|
||
const td = panel.querySelector(`td[data-cell="${cellRef}"]`);
|
||
if (!td) { showToast(t("xlsx.range_not_visible"), "info"); return; }
|
||
setActiveCell(td);
|
||
td.focus();
|
||
if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "center", inline: "center" });
|
||
};
|
||
|
||
// #154-A5 — parseRef / columnName / findTd live in frontend/js/xlsx/refs.js.
|
||
|
||
// #181-A1/A2 — navigation from a SELECTED cell. Shift+arrow extends the range
|
||
// from the anchor (#181-A2); a plain arrow collapses to a single cell. In both
|
||
// cases the cell stays selected, never editing.
|
||
const moveActive = (td, key, forward = true, extend = false) => {
|
||
const ref = parseRef(cellName(td));
|
||
if (!ref) return;
|
||
let { row, col } = ref;
|
||
if (key === "Tab") col += forward ? 1 : -1;
|
||
else if (key === "ArrowRight") col += 1;
|
||
else if (key === "ArrowLeft") col -= 1;
|
||
else if (key === "ArrowDown") row += 1;
|
||
else if (key === "ArrowUp") row -= 1;
|
||
if (row < 1 || col < 1) return;
|
||
const panel = td.closest(".xlsx-panel");
|
||
const next = findTd(panel, row, col);
|
||
if (!next) return; // edge of the rendered window: no wrap
|
||
// Leaving edit mode: a move always commits whatever was being typed.
|
||
if (isTypingInCell(td)) {
|
||
commitEdit();
|
||
exitEditing(td);
|
||
}
|
||
// #155/#181-A2 — Shift+arrow extends the selection from the current anchor.
|
||
if (extend && selection && selection.panel === panel) {
|
||
suppressSelectionReset = true;
|
||
next.focus();
|
||
suppressSelectionReset = false;
|
||
selection.focus = { row, col };
|
||
applySelection();
|
||
activeTd = next;
|
||
formulaInput.value = barTextOf(next);
|
||
return;
|
||
}
|
||
next.focus();
|
||
setActiveCell(next);
|
||
};
|
||
|
||
formulaInput.addEventListener("input", () => {
|
||
if (!activeTd) return;
|
||
syncing.value = true;
|
||
activeTd.textContent = formulaInput.value;
|
||
syncing.value = false;
|
||
activeTd.classList.add("xlsx-dirty");
|
||
refreshSaveState();
|
||
refreshPanelFormulas(activeTd.closest(".xlsx-panel"));
|
||
});
|
||
formulaInput.addEventListener("keydown", (e) => {
|
||
if (!activeTd) return;
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
activeTd.blur();
|
||
activeTd.focus();
|
||
} else if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
activeTd.textContent = activeTd.dataset.orig;
|
||
activeTd.classList.remove("xlsx-dirty");
|
||
syncFormulaBar(activeTd);
|
||
refreshSaveState();
|
||
refreshPanelFormulas(activeTd.closest(".xlsx-panel"));
|
||
activeTd.focus();
|
||
}
|
||
});
|
||
|
||
// ── #156-A10 — undo/redo unifié ──────────────────────────
|
||
// One stack for everything the viewer can reverse: a cell edit (typed,
|
||
// pasted, cleared), a row sort, and the REVERSIBLE structure actions
|
||
// (insert, rename, duplicate, add sheet — a delete is not replayable, the
|
||
// `.bak` stays the safety net).
|
||
let pendingEdit = null;
|
||
const undoBtn = area.querySelector("#xlsx-undo-btn");
|
||
const redoBtn = area.querySelector("#xlsx-redo-btn");
|
||
const fileHistory = xlsxHistory(historyKey);
|
||
const undoStack = fileHistory.undo;
|
||
const redoStack = fileHistory.redo;
|
||
const updateUndoButtons = () => {
|
||
if (undoBtn) undoBtn.disabled = undoStack.length === 0;
|
||
if (redoBtn) redoBtn.disabled = redoStack.length === 0;
|
||
};
|
||
const pushHistory = (entry) => {
|
||
undoStack.push(entry);
|
||
redoStack.length = 0;
|
||
updateUndoButtons();
|
||
};
|
||
/** Sheet identity of a panel: its name when known (a CSV has none). */
|
||
const panelKey = (panel) => {
|
||
const idx = Number(panel?.dataset.sheet);
|
||
return sheets[idx]?.name || String(Number.isFinite(idx) ? idx : 0);
|
||
};
|
||
/** Reverse lookup: a panel by sheet name, then by rendered index. */
|
||
const panelForKey = (key) => {
|
||
const byName = sheets.findIndex((s) => s.name === key);
|
||
if (byName >= 0) return panelEls[byName];
|
||
return panelEls.find((p) => p.dataset.sheet === String(key)) || null;
|
||
};
|
||
const rowNumOf = (tr) => Number(tr.querySelector("th.xlsx-rownum")?.textContent);
|
||
const rowNums = (list) => list.map(rowNumOf);
|
||
|
||
// One history entry per changed cell: shared by the focus/blur edit cycle
|
||
// and by the range operations (paste, cut, clear) so they can all be undone.
|
||
const recordEdit = (td, before, after, beforeFormula = null, afterFormula = null) => {
|
||
if (before === after && (beforeFormula ?? null) === (afterFormula ?? null)) return;
|
||
pushHistory({
|
||
kind: "cell", sheet: panelKey(td.closest(".xlsx-panel")), ref: td.dataset.cell,
|
||
before, after, beforeFormula: beforeFormula ?? null, afterFormula: afterFormula ?? null,
|
||
});
|
||
};
|
||
const commitEdit = () => {
|
||
if (!pendingEdit) return;
|
||
const { td, before, beforeFormula } = pendingEdit;
|
||
pendingEdit = null;
|
||
recordEdit(td, before, td.textContent, beforeFormula ?? null, td.dataset.formula ?? null);
|
||
};
|
||
const beginEdit = (td) => {
|
||
if (pendingEdit && pendingEdit.td !== td) commitEdit();
|
||
if (!pendingEdit) pendingEdit = { td, before: td.textContent, beforeFormula: td.dataset.formula ?? null };
|
||
};
|
||
|
||
const applyCellHistory = (entry, value) => {
|
||
const panel = panelForKey(entry.sheet) || panelEls[0];
|
||
const td = panel?.querySelector(`td[data-cell="${entry.ref}"]`);
|
||
if (!td) return;
|
||
td.textContent = value;
|
||
const formulaKey = value === entry.after ? "afterFormula" : "beforeFormula";
|
||
if (entry[formulaKey] !== undefined) {
|
||
if (entry[formulaKey] == null) delete td.dataset.formula;
|
||
else td.dataset.formula = entry[formulaKey];
|
||
delete td.dataset.display;
|
||
}
|
||
td.classList.toggle("xlsx-dirty", value !== (td.dataset.orig ?? ""));
|
||
if (panel) refreshPanelFormulas(panel);
|
||
if (td === activeTd) syncFormulaBar(td);
|
||
refreshSaveState();
|
||
};
|
||
|
||
/** Replay a recorded row order (undo/redo of a sort). */
|
||
const applyRowOrder = (entry, order) => {
|
||
const tbody = panelForKey(entry.sheet)?.querySelector(".xlsx-table tbody");
|
||
if (!tbody || !order) return;
|
||
const byNum = new Map([...tbody.querySelectorAll("tr")].map((tr) => [rowNumOf(tr), tr]));
|
||
if (order.length !== byNum.size || order.some((n) => !byNum.has(n))) return;
|
||
order.forEach((n) => tbody.appendChild(byNum.get(n)));
|
||
};
|
||
|
||
/** Replay a recorded filter visibility (and its needle). */
|
||
const applyRowVisibility = (entry, side) => {
|
||
const panel = panelForKey(entry.sheet);
|
||
const hidden = new Set(entry[side]);
|
||
if (panel) {
|
||
panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => {
|
||
tr.style.display = hidden.has(rowNumOf(tr)) ? "none" : "";
|
||
});
|
||
}
|
||
findInput.value = entry[`${side}Needle`] || "";
|
||
};
|
||
|
||
/** Replay a structure batch through the same endpoint as the UI. */
|
||
const applyStructureHistory = async (entry, side) => {
|
||
const actions = entry[side];
|
||
if (!actions?.length) return;
|
||
try {
|
||
await putStructure(actions, false, { quiet: true, record: false });
|
||
} catch (err) {
|
||
await structureError(err, actions);
|
||
}
|
||
};
|
||
|
||
const applyEntry = (entry, side) => {
|
||
if (entry.kind === "cell") return applyCellHistory(entry, entry[side]);
|
||
if (entry.kind === "order") return applyRowOrder(entry, entry[side]);
|
||
if (entry.kind === "hidden") return applyRowVisibility(entry, side);
|
||
if (entry.kind === "structure") return applyStructureHistory(entry, side);
|
||
return undefined;
|
||
};
|
||
const undo = () => {
|
||
const entry = undoStack.pop();
|
||
if (!entry) return;
|
||
applyEntry(entry, "before");
|
||
redoStack.push(entry);
|
||
updateUndoButtons();
|
||
};
|
||
const redo = () => {
|
||
const entry = redoStack.pop();
|
||
if (!entry) return;
|
||
applyEntry(entry, "after");
|
||
undoStack.push(entry);
|
||
updateUndoButtons();
|
||
};
|
||
if (undoBtn) undoBtn.addEventListener("click", undo);
|
||
if (redoBtn) redoBtn.addEventListener("click", redo);
|
||
// The command bar always renders the buttons disabled: re-enable them when
|
||
// the history survived a re-render.
|
||
updateUndoButtons();
|
||
// #156-A6 — undo/redo, save, clipboard and grid navigation all live in the
|
||
// grid-level keydown handler registered below (one place, one priority
|
||
// order); the cell handler only keeps the in-cell keys.
|
||
|
||
// #181-A1 — set by enterEditing() right before it focuses the cell, so the
|
||
// cell's own `focus` handler knows the focus is part of entering edit mode
|
||
// (and does not reset the state we just installed).
|
||
let editingRequested = false;
|
||
|
||
const setupCell = (td) => {
|
||
td.contentEditable = "true";
|
||
td.spellcheck = false;
|
||
// Focusable without a pointing device: JSDOM requires it to fire focus
|
||
// events on contenteditable cells, and a keyboard user tabbing into the
|
||
// table from outside lands on the first cell thanks to it.
|
||
td.tabIndex = 0;
|
||
td.dataset.orig = td.textContent;
|
||
// #184 — explicit wiring marker. `dataset.orig` cannot serve: it is "" for
|
||
// an empty cell, so the padding pass would wire the server cells a second
|
||
// time and double every listener.
|
||
td.dataset.wired = "1";
|
||
// #181-A1 — every cell starts in SELECTION mode: no text caret, just the
|
||
// outline. Editing is a second, explicit state (double-click, F2, first
|
||
// printable keystroke) that reveals the caret and accepts typing.
|
||
td.dataset.editing = "";
|
||
td.classList.add("xlsx-cell");
|
||
td.addEventListener("input", () => {
|
||
// #156-A6 — typing puts the cell in “edit” mode: Delete then deletes a
|
||
// character instead of clearing the whole selection (spreadsheet rule).
|
||
enterEditing(td);
|
||
td.classList.add("xlsx-dirty");
|
||
syncFormulaBar(td);
|
||
refreshSaveState();
|
||
// `live` : la frappe reste fluide même sur une grande grille (voir
|
||
// refreshPanelFormulas — le commit au blur recalcule tout).
|
||
refreshPanelFormulas(td.closest(".xlsx-panel"), { live: true });
|
||
});
|
||
// #181-A1 — double-click is the pointer route into editing: the caret must
|
||
// appear where the user pointed (native caret placement, we only lift the
|
||
// caret colour).
|
||
td.addEventListener("dblclick", (e) => {
|
||
e.preventDefault();
|
||
enterEditing(td);
|
||
placeCaretAtPoint(td, e.clientX, e.clientY);
|
||
});
|
||
td.addEventListener("focus", () => {
|
||
// Focusing alone never starts editing: the cell is only SELECTED — unless
|
||
// enterEditing() is what asked for the focus (double-click, F2, first
|
||
// keystroke), otherwise this handler would undo the edit it just started.
|
||
if (!editingRequested) td.dataset.editing = "";
|
||
editingRequested = false;
|
||
beginEdit(td);
|
||
// #155 — a range extension keeps the selection; only the bar follows.
|
||
if (suppressSelectionReset) {
|
||
activeTd = td;
|
||
activeCellEl.value = cellName(td);
|
||
formulaInput.disabled = false;
|
||
formulaInput.value = barTextOf(td);
|
||
return;
|
||
}
|
||
setActiveCell(td);
|
||
});
|
||
td.addEventListener("blur", () => {
|
||
commitEdit();
|
||
exitEditing(td);
|
||
refreshPanelFormulas(td.closest(".xlsx-panel"));
|
||
});
|
||
td.addEventListener("keydown", (e) => {
|
||
const mod = e.ctrlKey || e.metaKey;
|
||
// #156-A6 — keys moved to the grid handler when combined with Ctrl
|
||
// (Ctrl+flèches = jump to the edge of a data run, Ctrl+Home/End…).
|
||
const move = e.key === "Tab" || e.key.startsWith("Arrow");
|
||
if (move && mod) return;
|
||
if (e.key === "Enter" && e.shiftKey) {
|
||
// #156-A6 — Maj+Entrée: line break inside the cell (plain text only).
|
||
e.preventDefault();
|
||
enterEditing(td);
|
||
insertTextAtCaret(td, "\n");
|
||
return;
|
||
}
|
||
if (e.key === "F2") { e.preventDefault(); startEditing(td); return; }
|
||
if (e.key === "Delete" && !mod && !isTypingInCell(td)) {
|
||
// #156-A6 — Suppr clears the selection when the cell is not edited.
|
||
e.preventDefault();
|
||
const ref = parseRef(cellName(td));
|
||
if (!ref) return;
|
||
const panel = td.closest(".xlsx-panel");
|
||
const b = selection && selection.panel === panel
|
||
? selectionBounds()
|
||
: { r1: ref.row, r2: ref.row, c1: ref.col, c2: ref.col };
|
||
clearRange(panel, b);
|
||
return;
|
||
}
|
||
// #181-A1 — the FIRST printable keystroke switches the selected cell to
|
||
// editing and REPLACES its content, exactly like Google Sheets / Excel
|
||
// (typing never appends to an existing value). `keydown` runs before the
|
||
// browser inserts, so the old content is dropped first; the incoming
|
||
// character is then inserted normally by the contenteditable.
|
||
if (e.key.length === 1 && !mod && !isTypingInCell(td)) {
|
||
enterEditing(td, { replace: true });
|
||
beginEdit(td);
|
||
return;
|
||
}
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
// #181-A3 — Enter is contextual, like Sheets/Excel:
|
||
// · while EDITING → commit and move to the cell BELOW;
|
||
// · while SELECTED → switch the current cell to editing mode.
|
||
e.preventDefault();
|
||
if (isTypingInCell(td)) {
|
||
const panel = td.closest(".xlsx-panel");
|
||
commitEdit();
|
||
exitEditing(td);
|
||
const ref = parseRef(cellName(td));
|
||
const below = ref ? findTd(panel, ref.row + 1, ref.col) : null;
|
||
if (below) goTo(below);
|
||
else {
|
||
td.blur();
|
||
td.focus();
|
||
}
|
||
} else {
|
||
startEditing(td);
|
||
}
|
||
return;
|
||
}
|
||
if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
// #181-A1 — Escape leaves editing and restores the value.
|
||
td.textContent = td.dataset.orig;
|
||
td.classList.remove("xlsx-dirty");
|
||
exitEditing(td);
|
||
syncFormulaBar(td);
|
||
refreshSaveState();
|
||
refreshPanelFormulas(td.closest(".xlsx-panel"));
|
||
return;
|
||
}
|
||
if (move) {
|
||
// #181-A1 — arrows/Tab only MOVE while the cell is selected; while
|
||
// editing they belong to the caret (except Tab, handled below).
|
||
if (isTypingInCell(td) && !e.key.startsWith("Arrow")) {
|
||
// Tab while editing: commit then advance, like Sheets.
|
||
e.preventDefault();
|
||
const panel = td.closest(".xlsx-panel");
|
||
commitEdit();
|
||
exitEditing(td);
|
||
const forward = !e.shiftKey;
|
||
const next = findTd(panel, parseRef(cellName(td))?.row, parseRef(cellName(td))?.col + (forward ? 1 : -1));
|
||
if (next) goTo(next);
|
||
return;
|
||
}
|
||
if (isTypingInCell(td) && e.key.startsWith("Arrow")) {
|
||
if (e.altKey) {
|
||
// #181 — Alt+flèche inserts a line break inside the cell (Sheets).
|
||
e.preventDefault();
|
||
insertTextAtCaret(td, "\n");
|
||
return;
|
||
}
|
||
// #182 — in Google Sheets / Excel an arrow key during editing
|
||
// COMMITS the cell and moves the selection (the caret is not walked
|
||
// through the text; Alt+flèche covers the line break). Leaving the
|
||
// caret here made the focused cell look frozen: the outline stayed
|
||
// on the cell being edited instead of following the navigation.
|
||
e.preventDefault();
|
||
moveActive(td, e.key, e.key === "ArrowRight" || e.key === "ArrowDown", false);
|
||
return;
|
||
}
|
||
// Excel-like: arrows and Tab move to the neighbour cell. Direction is
|
||
// the key's own (Left/Up = back, Right/Down = forward); Tab follows
|
||
// Shift. Prevented so Tab never leaves the table and arrows never
|
||
// move the caret (they move the SELECTION instead).
|
||
e.preventDefault();
|
||
const forward = e.key === "Tab"
|
||
? !e.shiftKey
|
||
: (e.key === "ArrowRight" || e.key === "ArrowDown");
|
||
moveActive(td, e.key, forward, e.shiftKey && e.key.startsWith("Arrow"));
|
||
}
|
||
});
|
||
td.addEventListener("paste", (e) => {
|
||
// #156-A5 — a multi-cell block fills a range starting at this cell; a
|
||
// single value replaces the cell (or is inserted at the caret when the
|
||
// user has a live text selection). Plain text only, never HTML (R4).
|
||
const payload = clipTextOf(e) || (rangeClipboard ? toTsv(rangeClipboard.rows) : "");
|
||
if (!payload) return;
|
||
e.preventDefault();
|
||
applyPaste(payload, td);
|
||
});
|
||
};
|
||
|
||
// #153 A12 — the backend marks the last value Excel computed; the wording is
|
||
// translated here so the tooltip follows the UI language.
|
||
area.querySelectorAll(".xlsx-cached[data-cached-value]").forEach((el) => {
|
||
el.title = t("xlsx.cached_value_title");
|
||
});
|
||
|
||
// Editable cells: Enter blurs, Escape reverts, arrows/Tab navigate.
|
||
// Read-only formats (.xls/.ods) skip the wiring entirely — the toolbar
|
||
// save button is disabled and the structure menu does not exist.
|
||
if (readOnly) {
|
||
saveBtn.disabled = true;
|
||
} else {
|
||
area.querySelectorAll(".xlsx-table td").forEach(setupCell);
|
||
}
|
||
panelEls.forEach(wireLazyRows);
|
||
panelEls.forEach(applySheetMeta);
|
||
refreshAllFormulas();
|
||
panelEls.forEach(applyGridA11y);
|
||
// The formula bar starts disabled: nothing is selected yet.
|
||
setActiveCell(null);
|
||
|
||
area.querySelectorAll(".xlsx-tab").forEach((tab) => {
|
||
tab.addEventListener("click", () => {
|
||
const idx = tab.dataset.span || tab.dataset.sheet;
|
||
area.querySelectorAll(".xlsx-tab").forEach((x) => x.classList.toggle("active", x === tab));
|
||
panelEls.forEach((p) => { p.style.display = p.dataset.sheet === idx ? "" : "none"; });
|
||
// #184 — the sheet coming on screen gets its A → Z × 1000 grid.
|
||
ensureGridPadded(visiblePanel());
|
||
refreshPanelFormulas(visiblePanel());
|
||
updateSheetsToolbarStates();
|
||
updateMinibar();
|
||
requestStick();
|
||
});
|
||
});
|
||
|
||
// =LIEN() cells render a real anchor: plain click selects the cell (the
|
||
// grid stays editable), Ctrl/Cmd+click follows the link like a spreadsheet.
|
||
const onFormulaLinkClick = (e) => {
|
||
const a = e.target && e.target.closest ? e.target.closest("a.xlsx-formula-link") : null;
|
||
if (!a) return;
|
||
e.preventDefault();
|
||
if (e.ctrlKey || e.metaKey) {
|
||
window.open(a.href, "_blank", "noopener");
|
||
} else {
|
||
const td = a.closest("td[data-cell]");
|
||
if (td) td.focus();
|
||
}
|
||
};
|
||
area.addEventListener("click", onFormulaLinkClick);
|
||
// Replaced on the next render — the teardown at the top of this function
|
||
// removes it so the grid it closes over can be collected.
|
||
area._xlsxDropFormulaLinkClick = () =>
|
||
area.removeEventListener("click", onFormulaLinkClick);
|
||
|
||
// Formula toggle (#153 A4) — opt-in for this viewing session only.
|
||
// #154-A1 — absent on a read-only workbook (nothing can be saved).
|
||
const formulaBtn = area.querySelector("#xlsx-formula-btn");
|
||
if (formulaBtn) formulaBtn.addEventListener("click", () => {
|
||
allowFormula = !allowFormula;
|
||
formulaBtn.setAttribute("aria-pressed", String(allowFormula));
|
||
formulaBtn.classList.toggle("active", allowFormula);
|
||
});
|
||
|
||
/** Remember the revision a write returned (chained writes stay in sync). */
|
||
const keepRevision = (res) => {
|
||
if (res && typeof res.revision === "string" && res.revision) revision = res.revision;
|
||
return res;
|
||
};
|
||
|
||
const putSheet = isCsv
|
||
? async (job, force) => keepRevision(await api(
|
||
`/api/file/${encodeURIComponent(data.vault)}/csv/save?path=${encodeURIComponent(data.path)}`,
|
||
{
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ cells: job.cells, if_match: revision }),
|
||
},
|
||
))
|
||
: async (job, force) => keepRevision(await api(
|
||
`/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`,
|
||
{
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ ...job, allow_formula: allowFormula, force, if_match: revision }),
|
||
},
|
||
));
|
||
|
||
saveBtn.addEventListener("click", async () => {
|
||
// Commit whatever is being typed (cell or formula bar) so the collector
|
||
// below reads reconciled content: a focused formula cell still carries
|
||
// its previous dataset.formula while its text already changed.
|
||
if (document.activeElement && typeof document.activeElement.blur === "function") {
|
||
document.activeElement.blur();
|
||
}
|
||
// An editing flag without a blur (programmatic write, missed event)
|
||
// would freeze the cell out of the recompute below: drop it, the blur
|
||
// above already committed genuine edits.
|
||
area.querySelectorAll('td[data-cell][data-editing="1"]').forEach((td) => {
|
||
delete td.dataset.editing;
|
||
});
|
||
panelEls.forEach(refreshPanelFormulas);
|
||
// One PUT per sheet (dirty cells can span tabs before a save).
|
||
// BUG-096 — a CSV is served as a single `html` table, with no
|
||
// `xlsx_sheets`: reading the sheet name from `sheets[…]` threw a
|
||
// TypeError before any request went out, so a CSV edit could never be
|
||
// saved. Fall back to the file name (the CSV endpoint ignores it).
|
||
const sheetNameOf = (panel) => {
|
||
const idx = Number(panel.dataset.sheet);
|
||
return sheets[idx]?.name || sheets[0]?.name || data.title || data.path || "";
|
||
};
|
||
const jobs = panelEls
|
||
.map((panel) => {
|
||
const cells = {};
|
||
panel.querySelectorAll("td.xlsx-dirty").forEach((td) => { cells[td.dataset.cell] = cellRaw(td); });
|
||
return { sheet: sheetNameOf(panel), cells };
|
||
})
|
||
.filter((job) => Object.keys(job.cells).length);
|
||
if (!jobs.length) return;
|
||
// #156-A9 — the viewer has no formula engine: a value starting with "=" or
|
||
// "@" is stored as TEXT unless the f(x) toggle is on. Say so at save time
|
||
// instead of letting the user believe a formula was written (the workbook
|
||
// keeps Excel's own computed values as the source of truth).
|
||
if (!allowFormula && !isCsv) {
|
||
const asText = jobs.reduce(
|
||
(n, job) => n + Object.values(job.cells).filter((v) => typeof v === "string" && /^[=@]/.test(v.trim())).length,
|
||
0,
|
||
);
|
||
if (asText) showToast(t("xlsx.formula_stored_as_text", { n: asText }), "info");
|
||
}
|
||
saveBtn.disabled = true;
|
||
// Our own write: the grid already reflects it (dynamic updates), so the
|
||
// watcher echo must not trigger a full reload with spinner (utils.js).
|
||
window.__xlsxLastOwnWrite = { vault: data.vault, path: data.path, at: Date.now() };
|
||
try {
|
||
for (const job of jobs) {
|
||
// 409 xlsx_lossy_content → confirm once, then retry with force: true.
|
||
// (Also covers a workbook that became lossy while it was open.)
|
||
let force = lossyConfirmed;
|
||
for (;;) {
|
||
try {
|
||
await putSheet(job, force);
|
||
break;
|
||
} catch (err) {
|
||
if (err && err.code === "xlsx_lossy_content" && !lossyConfirmed) {
|
||
const features = (err.details && err.details.features) || lossy;
|
||
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
|
||
const proceed = await showConfirm({
|
||
title: t("xlsx.lossy_title"),
|
||
message: t("xlsx.lossy_confirm", { features: labels }),
|
||
confirmLabel: t("xlsx.lossy_confirm_btn"),
|
||
danger: true,
|
||
});
|
||
if (!proceed) throw err;
|
||
lossyConfirmed = true;
|
||
force = true;
|
||
continue;
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
}
|
||
area.querySelectorAll("td.xlsx-dirty").forEach((td) => {
|
||
td.classList.remove("xlsx-dirty");
|
||
td.dataset.orig = td.textContent;
|
||
});
|
||
if (activeTd) syncFormulaBar(activeTd);
|
||
refreshSaveState();
|
||
showToast(t("editor.saved"), "success");
|
||
} catch (err) {
|
||
refreshSaveState();
|
||
// A refused confirmation is a decision, not a failure: neutral toast.
|
||
if (err && err.code === "xlsx_lossy_content") {
|
||
showToast(t("xlsx.lossy_cancelled"), "info");
|
||
} else if (err && err.code === "conflict") {
|
||
// #154-A2 — non-blocking: the edits are kept, the user may retry.
|
||
showConflict(t("xlsx.conflict_msg"));
|
||
} else {
|
||
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
|
||
}
|
||
}
|
||
});
|
||
|
||
// #185 — the Fichier ▸ Télécharger ▸ entry lives in the menu bar now; the
|
||
// button id is unchanged, so this stays the single Download implementation
|
||
// and the menu just closes.
|
||
const downloadOriginalFile = () => {
|
||
window.open(`/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`, "_blank");
|
||
};
|
||
|
||
// ── #153 A13 — sort / filter / find in the sheet + CSV export ──────────
|
||
// ALL of these act on the RENDERED grid only: the workbook is never
|
||
// rewritten by a sort or a filter (the save pipeline stays the only write
|
||
// path, and the note in xlsx.sort_applied says so).
|
||
const visiblePanel = () =>
|
||
panelEls.find((p) => p.style.display !== "none") || panelEls[0];
|
||
|
||
// Sorting reorders <tr> rows by the text of one column. Dirty cells travel
|
||
// with their row, so a sort never loses an unsaved edit.
|
||
let sortState = null; // { col, dir } on the visible panel
|
||
|
||
const applySort = (panel, col, dir) => {
|
||
const tbody = panel.querySelector(".xlsx-table tbody");
|
||
if (!tbody) return;
|
||
const rows = [...tbody.querySelectorAll("tr")];
|
||
const orderBefore = rowNums(rows);
|
||
const numeric = rows.every((tr) => {
|
||
const td = tr.querySelector(`td[data-cell^="${columnName(col)}"]`);
|
||
const v = td ? td.textContent.trim() : "";
|
||
return v === "" || !isNaN(Number(v));
|
||
});
|
||
rows.sort((a, b) => {
|
||
const ta = a.querySelector(`td[data-cell^="${columnName(col)}"]`);
|
||
const tb = b.querySelector(`td[data-cell^="${columnName(col)}"]`);
|
||
const va = ta ? ta.textContent.trim() : "";
|
||
const vb = tb ? tb.textContent.trim() : "";
|
||
const cmp = numeric
|
||
? (parseFloat(va) || 0) - (parseFloat(vb) || 0)
|
||
: va.localeCompare(vb, "fr");
|
||
return dir === "asc" ? cmp : -cmp;
|
||
});
|
||
rows.forEach((tr) => tbody.appendChild(tr));
|
||
sortState = { col, dir };
|
||
sortResetBtn.style.display = "";
|
||
// #156-A10 — the view change is undoable (the workbook stays untouched).
|
||
const orderAfter = rowNums(rows);
|
||
if (orderBefore.join() !== orderAfter.join()) {
|
||
pushHistory({ kind: "order", sheet: panelKey(panel), before: orderBefore, after: orderAfter });
|
||
}
|
||
refreshPanelFormulas(panel);
|
||
showToast(t("xlsx.sort_applied", { col: columnName(col) }), "info");
|
||
};
|
||
|
||
// #155 — Excel-like: clicking a column/row header selects the whole
|
||
// column/row. Sorting moved to the context menu (and the sort reset button).
|
||
const selectColumn = (panel, col) => {
|
||
const rows = panel.querySelectorAll("th.xlsx-rownum").length;
|
||
if (!rows || !col) return;
|
||
selection = { panel, anchor: { row: 1, col }, focus: { row: rows, col } };
|
||
applySelection();
|
||
const td = findTd(panel, 1, col);
|
||
if (td) { activeTd = td; formulaInput.disabled = false; formulaInput.value = barTextOf(td); }
|
||
};
|
||
const selectRow = (panel, row) => {
|
||
const cols = panel.querySelectorAll(".xlsx-table thead th:not(.xlsx-corner)").length;
|
||
if (!row || !cols) return;
|
||
selection = { panel, anchor: { row, col: 1 }, focus: { row, col: cols } };
|
||
applySelection();
|
||
const td = findTd(panel, row, 1);
|
||
if (td) { activeTd = td; formulaInput.disabled = false; formulaInput.value = barTextOf(td); }
|
||
};
|
||
|
||
const wireHeaderSelection = (panel) => {
|
||
const table = panel.querySelector(".xlsx-table");
|
||
if (!table || table.dataset.selectWired) return;
|
||
table.dataset.selectWired = "1";
|
||
// Delegated (not per-TH): headers created later by structure surgery
|
||
// resolve their live index, so no stale closure can select the wrong
|
||
// column after an insert or a delete.
|
||
const paintHeaders = () => {
|
||
table.querySelectorAll("thead th:not(.xlsx-corner)").forEach((th) => {
|
||
th.style.cursor = "pointer";
|
||
th.title = t("xlsx.select_column");
|
||
});
|
||
table.querySelectorAll("th.xlsx-rownum").forEach((th) => {
|
||
th.style.cursor = "pointer";
|
||
th.title = t("xlsx.select_row");
|
||
});
|
||
};
|
||
paintHeaders();
|
||
// #184 — click a header = whole row/column; DRAG over the headers (or
|
||
// Shift+click) extends to several rows / several columns, the way Sheets
|
||
// and Excel do. The selection is expressed with the same {anchor, focus}
|
||
// model as a range, so every downstream command (copier, effacer, menu)
|
||
// keeps working unchanged.
|
||
const colCount = () => table.querySelectorAll("thead th:not(.xlsx-corner)").length;
|
||
const rowCount = () => panel.querySelectorAll("th.xlsx-rownum").length;
|
||
const colIndexOf = (th) => {
|
||
const head = table.querySelector("thead tr");
|
||
if (!head) return 0;
|
||
return [...head.children].indexOf(th);
|
||
};
|
||
let headerAnchor = null; // { axis, from } — kept after the drag so Shift+click extends
|
||
let headerDragging = false;
|
||
// `true` entre le `mousedown` et le `click` d'une vraie souris : le clic
|
||
// ne refait alors rien (`mousedown` a déjà sélectionné, y compris le
|
||
// glisser et le Shift+clic). Sans `mousedown` préalable (clic synthétique,
|
||
// activation clavier, événement avalé en amont), le repli `click`
|
||
// ci-dessous sélectionne.
|
||
let headerPressed = false;
|
||
const selectAllFromMargin = () => {
|
||
const cols = colCount() || 1;
|
||
const rows = rowCount() || 1;
|
||
selection = { panel, anchor: { row: 1, col: 1 }, focus: { row: rows, col: cols } };
|
||
applySelection();
|
||
const head = findTd(panel, 1, 1);
|
||
activeTd = head;
|
||
if (head) {
|
||
formulaInput.disabled = false;
|
||
formulaInput.value = barTextOf(head);
|
||
}
|
||
updateSheetsToolbarStates();
|
||
updateMinibar();
|
||
};
|
||
const cornerFrom = (e) => {
|
||
const corner = e.target.closest ? e.target.closest(".xlsx-corner") : null;
|
||
return corner && table.contains(corner) ? corner : null;
|
||
};
|
||
const selectHeaderRange = (axis, from, to) => {
|
||
const cols = colCount() || 1;
|
||
const rows = rowCount() || 1;
|
||
const lo = Math.max(1, Math.min(from, to));
|
||
const hi = Math.max(from, to);
|
||
if (axis === "row") {
|
||
selection = { panel, anchor: { row: lo, col: 1 }, focus: { row: hi, col: cols } };
|
||
} else {
|
||
selection = { panel, anchor: { row: 1, col: lo }, focus: { row: rows, col: hi } };
|
||
}
|
||
applySelection();
|
||
// The active cell is the first cell of the selected block.
|
||
const head = axis === "row"
|
||
? findTd(panel, lo, 1)
|
||
: findTd(panel, 1, lo);
|
||
activeTd = head;
|
||
if (head) {
|
||
formulaInput.disabled = false;
|
||
formulaInput.value = barTextOf(head);
|
||
}
|
||
updateSheetsToolbarStates();
|
||
updateMinibar();
|
||
};
|
||
// A press on a header edge belongs to the resize gesture, not to the
|
||
// selection: the two would otherwise fight over the same mousedown.
|
||
// Synthetic events (tests, keyboard activation) carry no pointer position
|
||
// (`isTrusted === false`, coords at 0) : sans cette garde, elles tombent
|
||
// toujours « près du bord » et la sélection ne se fait jamais.
|
||
const nearResizeEdge = (th, axis, e) => {
|
||
if (e && e.isTrusted === false) return false;
|
||
const r = th.getBoundingClientRect();
|
||
const edge = 8; // RESIZE_EDGE_PX
|
||
return axis === "col"
|
||
? Math.abs(e.clientX - r.right) <= edge
|
||
: Math.abs(e.clientY - r.bottom) <= edge;
|
||
};
|
||
const headerFrom = (e) => {
|
||
const colTh = e.target.closest
|
||
? e.target.closest(".xlsx-table thead th:not(.xlsx-corner)") : null;
|
||
if (colTh && table.contains(colTh)) {
|
||
return nearResizeEdge(colTh, "col", e)
|
||
? null
|
||
: { axis: "col", index: colIndexOf(colTh), th: colTh };
|
||
}
|
||
const rowTh = e.target.closest ? e.target.closest("th.xlsx-rownum") : null;
|
||
if (rowTh && table.contains(rowTh)) {
|
||
const n = Number(rowTh.textContent);
|
||
return Number.isFinite(n) && n >= 1 && !nearResizeEdge(rowTh, "row", e)
|
||
? { axis: "row", index: n, th: rowTh } : null;
|
||
}
|
||
return null;
|
||
};
|
||
table.addEventListener("mousedown", (e) => {
|
||
if (e.button !== 0) return;
|
||
if (cornerFrom(e)) {
|
||
e.preventDefault();
|
||
headerPressed = true;
|
||
headerAnchor = null;
|
||
headerDragging = false;
|
||
selectAllFromMargin();
|
||
return;
|
||
}
|
||
const h = headerFrom(e);
|
||
if (!h) return;
|
||
e.preventDefault();
|
||
headerPressed = true;
|
||
// A cell under the pointer may still hold the focus: the selection is
|
||
// driven from here, not from the focus event.
|
||
if (activeTd && isTypingInCell(activeTd)) { commitEdit(); exitEditing(activeTd); }
|
||
if (e.shiftKey && headerAnchor && headerAnchor.axis === h.axis) {
|
||
selectHeaderRange(h.axis, headerAnchor.from, h.index);
|
||
headerDragging = false;
|
||
return;
|
||
}
|
||
headerAnchor = { axis: h.axis, from: h.index };
|
||
headerDragging = true;
|
||
selectHeaderRange(h.axis, h.index, h.index);
|
||
});
|
||
table.addEventListener("mouseover", (e) => {
|
||
if (!headerDragging || !headerAnchor) return;
|
||
const h = headerFrom(e);
|
||
if (!h || h.axis !== headerAnchor.axis) return;
|
||
selectHeaderRange(headerAnchor.axis, headerAnchor.from, h.index);
|
||
});
|
||
const endHeaderDrag = () => { headerDragging = false; };
|
||
table.addEventListener("mouseup", endHeaderDrag);
|
||
table.addEventListener("mouseleave", endHeaderDrag);
|
||
// Repli `click` : quand aucun `mousedown` ne l'a précédé (clic synthétique
|
||
// des tests, activation clavier, événement avalé en amont), le clic
|
||
// sélectionne — sinon il ne fait que consommer le drapeau, pour ne jamais
|
||
// réduire un glisser ou un Shift+clic qui vient d'avoir lieu.
|
||
table.addEventListener("click", (e) => {
|
||
if (headerPressed) { headerPressed = false; return; }
|
||
if (cornerFrom(e)) { selectAllFromMargin(); return; }
|
||
const h = headerFrom(e);
|
||
if (!h) return;
|
||
headerAnchor = { axis: h.axis, from: h.index };
|
||
selectHeaderRange(h.axis, h.index, h.index);
|
||
});
|
||
};
|
||
|
||
const hiddenRowsOf = (panel) =>
|
||
[...panel.querySelectorAll(".xlsx-table tbody tr")]
|
||
.filter((tr) => tr.style.display === "none")
|
||
.map(rowNumOf);
|
||
|
||
// Filter: rows whose cell text lacks the needle are hidden. Recorded as ONE
|
||
// coalesced undo entry per typing session (#156-A10), so Ctrl+Z clears the
|
||
// whole filter instead of replaying it keystroke by keystroke.
|
||
const applyFilter = (panel, needle) => {
|
||
const before = hiddenRowsOf(panel);
|
||
const beforeNeedle = panel.dataset.filterNeedle || "";
|
||
const n = needle.trim().toLowerCase();
|
||
panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => {
|
||
const text = tr.textContent.toLowerCase();
|
||
tr.style.display = !n || text.includes(n) ? "" : "none";
|
||
});
|
||
if (n) sortResetBtn.style.display = "";
|
||
panel.dataset.filterNeedle = needle;
|
||
if (panel !== visiblePanel()) return; // one entry for the sheet on screen
|
||
const after = hiddenRowsOf(panel);
|
||
const sheet = panelKey(panel);
|
||
const top = undoStack[undoStack.length - 1];
|
||
if (top && top.kind === "hidden" && top.sheet === sheet) {
|
||
top.after = after;
|
||
top.afterNeedle = needle;
|
||
redoStack.length = 0;
|
||
updateUndoButtons();
|
||
return;
|
||
}
|
||
if (!before.length && !after.length) return; // nothing hidden yet
|
||
pushHistory({ kind: "hidden", sheet, before, after, beforeNeedle, afterNeedle: needle });
|
||
};
|
||
|
||
panelEls.forEach(wireHeaderSelection);
|
||
|
||
// ── Column/row resize by dragging header edges (mouse + touch) ─────────
|
||
// Sizes persist through the same locked rewrite as the Mise en forme menu
|
||
// (PUT …/xlsx/style col_width/row_height); the re-render applies them from
|
||
// the colwidths/rowheights metadata. No undo entry (like the size prompts).
|
||
// The drag context is created by the render that starts it (fresh runStyle
|
||
// and revision) while pointer routing lives at module level, so re-renders
|
||
// never leave stale closures behind.
|
||
const RESIZE_EDGE_PX = 8;
|
||
const resizeGhost = (hostArea) => {
|
||
let g = hostArea.querySelector(".xlsx-resize-ghost");
|
||
if (!g) {
|
||
g = document.createElement("div");
|
||
g.className = "xlsx-resize-ghost";
|
||
g.hidden = true;
|
||
hostArea.appendChild(g);
|
||
}
|
||
return g;
|
||
};
|
||
const moveResizeGhost = (d, pos) => {
|
||
// Fixed overlay: client coordinates straight from the pointer.
|
||
const g = resizeGhost(d.area);
|
||
const grid = d.panel.querySelector(".csv-table-wrapper");
|
||
const r = grid ? grid.getBoundingClientRect() : { top: 0, left: 0, height: 0, width: 0 };
|
||
if (d.kind === "col") {
|
||
g.dataset.kind = "col";
|
||
g.style.left = `${pos}px`;
|
||
g.style.top = `${r.top}px`;
|
||
g.style.height = `${Math.max(0, r.height)}px`;
|
||
} else {
|
||
g.dataset.kind = "row";
|
||
g.style.top = `${pos}px`;
|
||
g.style.left = `${r.left}px`;
|
||
g.style.width = `${Math.max(0, r.width)}px`;
|
||
}
|
||
g.hidden = false;
|
||
};
|
||
const wireResize = (panel) => {
|
||
const table = panel.querySelector(".xlsx-table");
|
||
if (!table || table.dataset.resizeWired || !editable) return;
|
||
table.dataset.resizeWired = "1";
|
||
const headRow = table.querySelector("thead tr");
|
||
const colAtEdge = (clientX) => {
|
||
const ths = [...headRow.querySelectorAll("th:not(.xlsx-corner)")];
|
||
for (const th of ths) {
|
||
const r = th.getBoundingClientRect();
|
||
if (Math.abs(clientX - r.right) <= RESIZE_EDGE_PX) {
|
||
return { th, col: [...headRow.children].indexOf(th) };
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
const rowAtEdge = (clientY) => {
|
||
const nums = [...panel.querySelectorAll("th.xlsx-rownum")];
|
||
for (const th of nums) {
|
||
const r = th.getBoundingClientRect();
|
||
if (Math.abs(clientY - r.bottom) <= RESIZE_EDGE_PX) {
|
||
const row = Number(th.textContent);
|
||
if (row >= 1) return { th, row };
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
const beginDrag = (kind, el, startPx, index) => {
|
||
if (activeResizeDrag) return;
|
||
const rect = el.getBoundingClientRect();
|
||
const sheet = sheets[Number(panel.dataset.sheet)]?.name || "";
|
||
const d = {
|
||
kind, area, panel, index, sheet,
|
||
suppressClick: false,
|
||
startPx, startSize: kind === "col" ? rect.width : rect.height,
|
||
lastPx: startPx, moved: false,
|
||
move(clientX, clientY) {
|
||
const pos = this.kind === "col" ? clientX : clientY;
|
||
if (Math.abs(pos - this.startPx) > 3) this.moved = true;
|
||
this.lastPx = pos;
|
||
moveResizeGhost(this, pos);
|
||
},
|
||
commit() {
|
||
const g = this.area.querySelector(".xlsx-resize-ghost");
|
||
if (g) g.hidden = true;
|
||
if (!this.moved) return;
|
||
this.suppressClick = true;
|
||
const px = Math.max(1, this.startSize + ((this.lastPx ?? this.startPx) - this.startPx));
|
||
if (this.kind === "col") {
|
||
const width = Math.min(255, Math.max(3, Math.round((px / 7) * 10) / 10));
|
||
runStyle([{ op: "col_width", sheet: this.sheet, col: columnName(this.index), width }]);
|
||
} else {
|
||
const height = Math.min(409, Math.max(9, Math.round(px * 0.75 * 10) / 10));
|
||
runStyle([{ op: "row_height", sheet: this.sheet, row: this.index, height }]);
|
||
}
|
||
},
|
||
cancel() {
|
||
const g = this.area.querySelector(".xlsx-resize-ghost");
|
||
if (g) g.hidden = true;
|
||
},
|
||
};
|
||
activeResizeDrag = d;
|
||
moveResizeGhost(d, startPx);
|
||
};
|
||
// #180 — the edge header mirrors the resize cursor itself: the column
|
||
// headers carry an inline `cursor:pointer` (selection) and the cells a CSS
|
||
// `cursor:cell`, both of which would otherwise cover the table-level
|
||
// cursor exactly where the icon matters. The table classes below repaint
|
||
// the cells through the stylesheet (see `.xlsx-resize-col/row`).
|
||
let edgeTh = null;
|
||
const setEdgeTh = (th, cursor) => {
|
||
if (edgeTh && edgeTh !== th) {
|
||
edgeTh.style.cursor = edgeTh.dataset.prevCursor || "";
|
||
delete edgeTh.dataset.prevCursor;
|
||
edgeTh = null;
|
||
}
|
||
if (!th) return;
|
||
if (edgeTh !== th) {
|
||
edgeTh = th;
|
||
edgeTh.dataset.prevCursor = edgeTh.style.cursor || "";
|
||
}
|
||
if (edgeTh.style.cursor !== cursor) edgeTh.style.cursor = cursor;
|
||
};
|
||
const paintResizeCursor = (kind, th) => {
|
||
table.style.cursor = kind === "col" ? "col-resize" : kind === "row" ? "row-resize" : "";
|
||
table.classList.toggle("xlsx-resize-col", kind === "col");
|
||
table.classList.toggle("xlsx-resize-row", kind === "row");
|
||
setEdgeTh(th || null, kind === "col" ? "col-resize" : "row-resize");
|
||
};
|
||
// Excel / Sheets : on ne redimensionne qu'en prenant le BORD d'un
|
||
// en-tête (lettre de colonne / gutter des numéros). Le corps de la grille
|
||
// reste réservé à la sélection : auparavant une pression à 3 px d'une
|
||
// limite lançait un glisser (ghost + aucune cellule sélectionnée).
|
||
// The geometric probe needs a header under the pointer — except when the
|
||
// pointer is on the table itself (padding), which names no header; the
|
||
// hit test decides, as it does when the engine reports no layout.
|
||
const overColHeader = (target) => !!(target && (target === table
|
||
|| (target.closest && target.closest("thead th"))));
|
||
const overRowHeader = (target) => !!(target && (target === table
|
||
|| (target.closest && target.closest("th.xlsx-rownum"))));
|
||
table.addEventListener("mousemove", (e) => {
|
||
if (activeResizeDrag || !editable || e.buttons) return;
|
||
const c = overColHeader(e.target) ? colAtEdge(e.clientX) : null;
|
||
if (c) { paintResizeCursor("col", c.th); return; }
|
||
const rw = overRowHeader(e.target) ? rowAtEdge(e.clientY) : null;
|
||
paintResizeCursor(rw ? "row" : "", rw ? rw.th : null);
|
||
});
|
||
table.addEventListener("mouseleave", () => { if (!activeResizeDrag) paintResizeCursor("", null); });
|
||
table.addEventListener("mousedown", (e) => {
|
||
if (!editable || e.button !== 0 || activeResizeDrag) return;
|
||
const c = overColHeader(e.target) ? colAtEdge(e.clientX) : null;
|
||
const rw = !c && overRowHeader(e.target) ? rowAtEdge(e.clientY) : null;
|
||
if (!c && !rw) return;
|
||
e.preventDefault();
|
||
if (c) beginDrag("col", c.th, e.clientX, c.col);
|
||
else beginDrag("row", rw.th, e.clientY, rw.row);
|
||
});
|
||
table.addEventListener("touchstart", (e) => {
|
||
if (!editable || activeResizeDrag || !e.touches || !e.touches[0]) return;
|
||
const t0 = e.touches[0];
|
||
const c = overColHeader(e.target) ? colAtEdge(t0.clientX) : null;
|
||
const rw = !c && overRowHeader(e.target) ? rowAtEdge(t0.clientY) : null;
|
||
if (!c && !rw) return; // plain tap / long-press keep their behaviour
|
||
e.preventDefault();
|
||
e.stopPropagation(); // no long-press menu mid-drag
|
||
if (c) beginDrag("col", c.th, t0.clientX, c.col);
|
||
else beginDrag("row", rw.th, t0.clientY, rw.row);
|
||
}, { passive: false });
|
||
// Excel (AutoFit) et Sheets (« Fit to data ») : un double-clic sur le bord
|
||
// d'un en-tête ajuste la colonne / la ligne au contenu. Sur une cellule,
|
||
// le double-clic reste celui de l'édition (#181-A1) : le geste n'est pris
|
||
// ici que lorsqu'il part d'un en-tête.
|
||
table.addEventListener("dblclick", (e) => {
|
||
if (!editable) return;
|
||
const c = overColHeader(e.target) ? colAtEdge(e.clientX) : null;
|
||
if (c) { autoFitColumns(panel, [c.col]); return; }
|
||
const rw = overRowHeader(e.target) ? rowAtEdge(e.clientY) : null;
|
||
if (rw) autoFitRows(panel, [rw.row]);
|
||
});
|
||
};
|
||
panelEls.forEach(wireResize);
|
||
|
||
// #184 — lay the sheet out like a spreadsheet: columns A → Z, and rows that
|
||
// grow up to 1000 as the reader reaches the end (padMoreRows). Done for the
|
||
// visible sheet, on tab activation. A truncated sheet keeps its server-driven
|
||
// "Charger la suite" window: those rows are real data, not empty padding.
|
||
const ensureGridPadded = (panel) => {
|
||
if (!panel || isCsv) return;
|
||
expandPanelGrid(panel);
|
||
applySheetMeta(panel);
|
||
refreshPanelFormulas(panel);
|
||
const meta = sheets[Number(panel.dataset.sheet)] || {};
|
||
const wrapper = panel.querySelector(".csv-table-wrapper");
|
||
if (meta.truncated || !wrapper || panel.dataset.gridGrowth === "1") return;
|
||
panel.dataset.gridGrowth = "1";
|
||
wrapper.addEventListener("scroll", () => {
|
||
if (wrapper.scrollTop + wrapper.clientHeight < wrapper.scrollHeight - 120) return;
|
||
if (padMoreRows(panel)) applySheetMeta(panel);
|
||
}, { passive: true });
|
||
};
|
||
ensureGridPadded(visiblePanel());
|
||
|
||
// L'en-tête A–Z glissait sous la toolbar sticky (z-30 > z-3) dès que la page
|
||
// défilait — d'autant plus que la barre d'édition a agrandi la toolbar. Le
|
||
// `top` sticky de l'en-tête suit donc le recouvrement réel (`--xlsx-stick`),
|
||
// mesuré au scroll / redimensionnement et coalescé en rAF.
|
||
if (area._xlsxStickCleanup) {
|
||
try { area._xlsxStickCleanup(); } catch (_) { /* ignore */ }
|
||
area._xlsxStickCleanup = null;
|
||
}
|
||
const syncStick = () => {
|
||
const toolbar = area.querySelector(".xlsx-toolbar");
|
||
const panel = typeof visiblePanel === "function" ? visiblePanel() : null;
|
||
const wrapper = panel ? panel.querySelector(".csv-table-wrapper") : null;
|
||
if (!toolbar || !wrapper || !area.isConnected) return;
|
||
const overlap = toolbar.getBoundingClientRect().bottom - wrapper.getBoundingClientRect().top;
|
||
area.style.setProperty("--xlsx-stick", `${Math.max(0, Math.round(overlap))}px`);
|
||
};
|
||
let stickRaf = 0;
|
||
const raf = typeof requestAnimationFrame === "function"
|
||
? requestAnimationFrame.bind(window)
|
||
: (cb) => setTimeout(cb, 0);
|
||
const requestStick = () => {
|
||
if (stickRaf) return;
|
||
stickRaf = raf(() => { stickRaf = 0; syncStick(); });
|
||
};
|
||
const stickCleanups = [];
|
||
const scroller = getContentArea();
|
||
if (scroller && typeof scroller.addEventListener === "function") {
|
||
const onScroll = () => requestStick();
|
||
scroller.addEventListener("scroll", onScroll, { passive: true });
|
||
stickCleanups.push(() => scroller.removeEventListener("scroll", onScroll));
|
||
}
|
||
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
||
const onWinScroll = () => requestStick();
|
||
window.addEventListener("scroll", onWinScroll, { passive: true });
|
||
stickCleanups.push(() => window.removeEventListener("scroll", onWinScroll));
|
||
}
|
||
if (typeof ResizeObserver === "function") {
|
||
const toolbar = area.querySelector(".xlsx-toolbar");
|
||
if (toolbar) {
|
||
const ro = new ResizeObserver(() => requestStick());
|
||
ro.observe(toolbar);
|
||
stickCleanups.push(() => ro.disconnect());
|
||
}
|
||
}
|
||
area._xlsxStickCleanup = () => {
|
||
stickCleanups.forEach((fn) => { try { fn(); } catch (_) { /* ignore */ } });
|
||
if (stickRaf && typeof cancelAnimationFrame === "function") {
|
||
try { cancelAnimationFrame(stickRaf); } catch (_) { /* ignore */ }
|
||
}
|
||
stickRaf = 0;
|
||
};
|
||
requestStick();
|
||
|
||
const sortResetBtn = area.querySelector("#xlsx-sort-reset");
|
||
sortResetBtn.addEventListener("click", () => {
|
||
// Full reset: re-render the current file (server truth), clear find too.
|
||
sortResetBtn.style.display = "none";
|
||
renderXlsxViewer(area, data);
|
||
});
|
||
|
||
// Find-in-sheet: highlight matches, navigate with ↑/↓.
|
||
let findMatches = [];
|
||
let findIndex = -1;
|
||
const findInput = area.querySelector("#xlsx-find-input");
|
||
const findCount = area.querySelector("#xlsx-find-count");
|
||
let findCase = false;
|
||
|
||
const clearFind = () => {
|
||
findMatches.forEach(({ td, mark }) => {
|
||
mark.replaceWith(document.createTextNode(mark.textContent));
|
||
td.normalize();
|
||
});
|
||
findMatches = [];
|
||
findIndex = -1;
|
||
findCount.textContent = "";
|
||
};
|
||
|
||
// #156-A11 — the find scans EVERY sheet, not just the visible one: the
|
||
// counter adds the number of sheets holding a hit and stepping onto a match
|
||
// brings its tab forward (the match would otherwise stay invisible).
|
||
const runFind = () => {
|
||
clearFind();
|
||
const needle = findInput.value;
|
||
if (!needle.trim()) return;
|
||
const hay = findCase ? (s) => s : (s) => s.toLowerCase();
|
||
const n = hay(needle);
|
||
panelEls.forEach((panel) => {
|
||
panel.querySelectorAll(".xlsx-table td").forEach((td) => {
|
||
const text = td.textContent;
|
||
const pos = hay(text).indexOf(n);
|
||
if (pos === -1 || !text) return;
|
||
// Wrap the match in a <mark> by splitting the cell's first text node at
|
||
// the hit (cells are text-first; the cached-value span stays untouched).
|
||
const first = td.firstChild;
|
||
if (first && first.nodeType === 3) {
|
||
const mark = document.createElement("mark");
|
||
mark.className = "xlsx-find-hit";
|
||
mark.textContent = text.substr(pos, needle.length);
|
||
const tail = first.splitText(pos);
|
||
tail.splitText(needle.length);
|
||
td.replaceChild(mark, tail);
|
||
findMatches.push({ td, mark, panel });
|
||
}
|
||
});
|
||
});
|
||
if (!findMatches.length) {
|
||
findCount.textContent = t("xlsx.find_no_match");
|
||
return;
|
||
}
|
||
focusMatch(0);
|
||
};
|
||
|
||
/** Show the find counter, mentioning the sheets when hits span several. */
|
||
const updateFindCount = (index) => {
|
||
const count = findMatches.length;
|
||
const sheets = new Set(findMatches.map((m) => m.panel)).size;
|
||
findCount.textContent = sheets > 1
|
||
? t("xlsx.find_count_sheets", { index, count, sheets })
|
||
: t("xlsx.find_count", { index, count });
|
||
};
|
||
|
||
const focusMatch = (i) => {
|
||
findMatches.forEach(({ mark }) => mark.classList.remove("xlsx-find-current"));
|
||
findIndex = (i + findMatches.length) % findMatches.length;
|
||
const { td, mark, panel } = findMatches[findIndex];
|
||
mark.classList.add("xlsx-find-current");
|
||
// A hit in another sheet: activate its tab so the match becomes visible.
|
||
if (panel.style.display === "none") {
|
||
const idx = panel.dataset.sheet;
|
||
area.querySelectorAll(".xlsx-tab").forEach((x) => x.classList.toggle("active", x.dataset.sheet === idx));
|
||
panelEls.forEach((p) => { p.style.display = p.dataset.sheet === idx ? "" : "none"; });
|
||
}
|
||
// scrollIntoView is missing in JSDOM; guard it (real browsers have it).
|
||
if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "nearest" });
|
||
updateFindCount(findIndex + 1);
|
||
};
|
||
|
||
// One input drives both the highlight (find) and the row filter (A13):
|
||
// typing narrows the sheet to the matching rows AND highlights the hits.
|
||
findInput.addEventListener("input", () => {
|
||
panelEls.forEach((p) => applyFilter(p, findInput.value));
|
||
runFind();
|
||
});
|
||
findInput.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") { e.preventDefault(); focusMatch(findIndex + (e.shiftKey ? -1 : 1)); }
|
||
});
|
||
area.querySelector("#xlsx-find-prev").addEventListener("click", () => focusMatch(findIndex - 1));
|
||
area.querySelector("#xlsx-find-next").addEventListener("click", () => focusMatch(findIndex + 1));
|
||
const caseBtn = area.querySelector("#xlsx-find-case");
|
||
caseBtn.addEventListener("click", () => {
|
||
findCase = !findCase;
|
||
caseBtn.setAttribute("aria-pressed", String(findCase));
|
||
caseBtn.classList.toggle("active", findCase);
|
||
runFind();
|
||
});
|
||
|
||
// ── #153 A14 — workbook structure menu (sheets, rows, columns) ─────────
|
||
// Every action is an explicit user gesture (prompt/confirm) and goes to
|
||
// PUT …/xlsx/structure — one locked, atomic rewrite with a backup.
|
||
const visibleSheetIndex = () =>
|
||
Number((visiblePanel() || panelEls[0])?.dataset.sheet) || 0;
|
||
|
||
/** Inverse of a structure action, or null when it cannot be replayed. */
|
||
const inverseAction = (action) => {
|
||
switch (action.op) {
|
||
case "row_insert":
|
||
return { op: "row_delete", sheet: action.sheet, at: action.at, count: action.count || 1 };
|
||
case "col_insert":
|
||
return { op: "col_delete", sheet: action.sheet, at: action.at, count: action.count || 1 };
|
||
case "sheet_rename":
|
||
return { op: "sheet_rename", from: action.to, to: action.from };
|
||
case "sheet_add":
|
||
return String(action.name || "").length && String(action.name).length <= 31
|
||
? { op: "sheet_delete", name: action.name }
|
||
: null;
|
||
case "sheet_duplicate":
|
||
return String(action.as || "").length && String(action.as).length <= 31
|
||
? { op: "sheet_delete", name: action.as }
|
||
: null;
|
||
default:
|
||
return null; // row_delete / col_delete / sheet_delete drop data
|
||
}
|
||
};
|
||
|
||
// ── Dynamic structure application (no re-render) ───────────────────────
|
||
// Row/column inserts and deletes are replayed in the DOM (renumber refs,
|
||
// shift formula texts like Excel, patch the meta maps) so the grid, focus
|
||
// and selection survive. Sheet add/rename/delete/duplicate rebuild only
|
||
// their own tab and panel.
|
||
const shiftSingleCoord = (ref, op) => {
|
||
// Returns the shifted "A1" ref, or null when it falls into a deletion.
|
||
const m = /^(\$?)([A-Za-z]{1,3})(\$?)(\d+)$/.exec(String(ref || ""));
|
||
if (!m) return null;
|
||
let col = colToIndex(m[2]);
|
||
let row = Number(m[4]);
|
||
if (!col || !(row >= 1)) return null;
|
||
if (op.axis === "row") {
|
||
if (m[3]) return `${m[1]}${m[2]}${m[3]}${row}`;
|
||
if (op.insert) {
|
||
if (row >= op.at) row += op.count;
|
||
} else if (row >= op.at && row < op.at + op.count) {
|
||
return null;
|
||
} else if (row >= op.at + op.count) {
|
||
row -= op.count;
|
||
}
|
||
} else {
|
||
if (m[1]) return `${m[1]}${m[2]}${m[3]}${row}`;
|
||
if (op.insert) {
|
||
if (col >= op.at) col += op.count;
|
||
} else if (col >= op.at && col < op.at + op.count) {
|
||
return null;
|
||
} else if (col >= op.at + op.count) {
|
||
col -= op.count;
|
||
}
|
||
}
|
||
if (row < 1 || col < 1) return null;
|
||
return `${m[1]}${indexToCol(col)}${m[3]}${row}`;
|
||
};
|
||
const shiftRangeStr = (range, op) => {
|
||
// "A1:B2" → shifted range, or null when destroyed by a deletion.
|
||
const m = /^([^:]+):([^:]+)$/.exec(String(range || ""));
|
||
if (!m) return shiftSingleCoord(range, op);
|
||
const a = shiftSingleCoord(m[1], op);
|
||
const b = shiftSingleCoord(m[2], op);
|
||
if (!a || !b) return null;
|
||
// Normalize order (insert order is preserved, deletes clamp it).
|
||
const pa = parseRef(a);
|
||
const pb = parseRef(b);
|
||
if (!pa || !pb) return null;
|
||
const r1 = Math.min(pa.row, pb.row);
|
||
const r2 = Math.max(pa.row, pb.row);
|
||
const c1 = Math.min(pa.col, pb.col);
|
||
const c2 = Math.max(pa.col, pb.col);
|
||
return `${indexToCol(c1)}${r1}:${indexToCol(c2)}${r2}`;
|
||
};
|
||
const shiftFormulaRefs = (text, selfSheet, op) => {
|
||
// Shift the A1 references of a formula like Excel would, skipping string
|
||
// literals, $ locked parts and other sheets' qualifiers. Ranges whose end
|
||
// dies in a deletion collapse to #REF!.
|
||
const parts = String(text || "").split(/("[^"]*")/g);
|
||
for (let i = 0; i < parts.length; i += 2) {
|
||
parts[i] = parts[i].replace(
|
||
/((?:'[^']+'|[A-Za-z_][\w.]*)!)?(\$?)([A-Za-z]{1,3})(\$?)(\d+)(?!\s*\()/g,
|
||
(match, qualifier, colAbs, letters, rowAbs, rowNum) => {
|
||
if (qualifier) {
|
||
const q = qualifier.slice(0, -1).replace(/^'|'$/g, "");
|
||
if (q.toLowerCase() !== String(selfSheet).toLowerCase()) return match;
|
||
}
|
||
const shifted = shiftSingleCoord(`${colAbs}${letters}${rowAbs}${rowNum}`, op);
|
||
if (!shifted) return "#REF!";
|
||
return `${qualifier || ""}${shifted}`;
|
||
},
|
||
);
|
||
}
|
||
let out = parts.join("");
|
||
out = out.replace(/([A-Za-z$0-9]+:#REF!|#REF!:[A-Za-z$0-9]+)/g, "#REF!");
|
||
return out;
|
||
};
|
||
const adjustFormulaTexts = (sheetName, op) => {
|
||
panelEls.forEach((panel) => {
|
||
panel.querySelectorAll("td[data-cell]").forEach((td) => {
|
||
if (!td.dataset.formula) return;
|
||
const next = shiftFormulaRefs(td.dataset.formula, sheetName, op);
|
||
if (next !== td.dataset.formula) {
|
||
td.dataset.formula = next;
|
||
delete td.dataset.display;
|
||
}
|
||
});
|
||
});
|
||
};
|
||
const shiftMetaForOp = (sheetIdx, op) => {
|
||
const meta = sheets[sheetIdx];
|
||
if (!meta) return;
|
||
const shiftKey = (ref) => {
|
||
const p = parseRef(ref);
|
||
if (!p) return null;
|
||
if (op.axis === "row") {
|
||
if (op.insert) return p.row >= op.at ? `${indexToCol(p.col)}${p.row + op.count}` : ref;
|
||
if (p.row >= op.at && p.row < op.at + op.count) return null;
|
||
return p.row >= op.at + op.count ? `${indexToCol(p.col)}${p.row - op.count}` : ref;
|
||
}
|
||
if (op.insert) return p.col >= op.at ? `${indexToCol(p.col + op.count)}${p.row}` : ref;
|
||
if (p.col >= op.at && p.col < op.at + op.count) return null;
|
||
return p.col >= op.at + op.count ? `${indexToCol(p.col - op.count)}${p.row}` : ref;
|
||
};
|
||
["styles", "aligns", "comments", "formats"].forEach((key) => {
|
||
const map = meta[key];
|
||
if (!map) return;
|
||
Object.keys(map).forEach((ref) => {
|
||
const next = shiftKey(ref);
|
||
if (next === ref || next === null) {
|
||
if (next === null) delete map[ref];
|
||
return;
|
||
}
|
||
const value = map[ref];
|
||
delete map[ref];
|
||
map[next] = value;
|
||
});
|
||
});
|
||
if (op.axis === "col" && meta.colwidths) {
|
||
Object.keys(meta.colwidths).forEach((letter) => {
|
||
const c = colToIndex(letter);
|
||
if (!c) return;
|
||
const value = meta.colwidths[letter];
|
||
delete meta.colwidths[letter];
|
||
const shifted = shiftSingleCoord(`${letter}1`, op);
|
||
if (shifted) meta.colwidths[shifted.replace(/[0-9]+$/, "")] = value;
|
||
});
|
||
}
|
||
if (op.axis === "row" && meta.rowheights) {
|
||
Object.keys(meta.rowheights).forEach((r) => {
|
||
const row = Number(r);
|
||
if (!(row >= 1)) return;
|
||
const value = meta.rowheights[r];
|
||
delete meta.rowheights[r];
|
||
const shifted = shiftSingleCoord(`A${row}`, op);
|
||
if (shifted) meta.rowheights[String(parseRef(shifted).row)] = value;
|
||
});
|
||
}
|
||
if (meta.merges) {
|
||
meta.merges = meta.merges
|
||
.map((range) => shiftRangeStr(range, op))
|
||
.filter(Boolean);
|
||
}
|
||
if (meta.freeze && meta.freeze !== "A1") {
|
||
const shifted = shiftSingleCoord(meta.freeze, op);
|
||
meta.freeze = shifted || "A1";
|
||
}
|
||
};
|
||
const refreshTruncationNote = (panel) => {
|
||
const meta = sheets[Number(panel.dataset.sheet)] || {};
|
||
const html = truncationNote(meta);
|
||
const old = panel.querySelector(".xlsx-truncated");
|
||
if (html) {
|
||
if (old) {
|
||
old.outerHTML = html;
|
||
} else {
|
||
const tmp = document.createElement("div");
|
||
tmp.innerHTML = html;
|
||
const node = tmp.firstElementChild;
|
||
if (node) {
|
||
const wrap = panel.querySelector(".csv-table-wrapper");
|
||
if (wrap) wrap.after(node);
|
||
else panel.appendChild(node);
|
||
}
|
||
}
|
||
safeCreateIcons();
|
||
} else if (old) {
|
||
old.remove();
|
||
}
|
||
};
|
||
/** Shift a selection anchor through a structural op (delete-span collapses). */
|
||
const shiftSelPoint = (pt, op) => {
|
||
if (!pt) return null;
|
||
const num = op.axis === "row" ? pt.row : pt.col;
|
||
const fixed = op.axis === "row" ? pt.col : pt.row;
|
||
if (num === undefined || fixed === undefined) return null;
|
||
let n = num;
|
||
if (op.insert) {
|
||
if (n >= op.at) n += op.count;
|
||
} else if (n >= op.at && n < op.at + op.count) {
|
||
n = op.at;
|
||
} else if (n >= op.at + op.count) {
|
||
n -= op.count;
|
||
}
|
||
if (n < 1) n = 1;
|
||
return op.axis === "row" ? { row: n, col: fixed } : { row: fixed, col: n };
|
||
};
|
||
const blankTd = (ref) => {
|
||
const td = document.createElement("td");
|
||
td.dataset.cell = ref;
|
||
td.setAttribute("role", "gridcell");
|
||
setupCell(td);
|
||
return td;
|
||
};
|
||
const applyRowColOp = (sheetIdx, op) => {
|
||
const panel = panelEls.find((p) => Number(p.dataset.sheet) === sheetIdx);
|
||
const meta = sheets[sheetIdx];
|
||
if (!panel || !meta) return;
|
||
const tbody = panel.querySelector(".xlsx-table tbody");
|
||
const headRow = panel.querySelector(".xlsx-table thead tr");
|
||
const nCols = headRow ? headRow.querySelectorAll("th:not(.xlsx-corner)").length : 0;
|
||
const rendered = tbody ? tbody.querySelectorAll("tr").length : 0;
|
||
adjustFormulaTexts(meta.name, op);
|
||
shiftMetaForOp(sheetIdx, op);
|
||
const totalKey = op.axis === "row" ? "total_rows" : "total_cols";
|
||
meta[totalKey] = Math.max(0, (Number(meta[totalKey]) || 0) + (op.insert ? op.count : -op.count));
|
||
if (op.axis === "row" && op.insert && tbody && op.at <= rendered + 1 && nCols > 0) {
|
||
const pos = Math.min(op.at, rendered + 1);
|
||
const anchor = [...tbody.children].find((tr) => rowNumOf(tr) === pos) || null;
|
||
for (let k = 0; k < op.count; k++) {
|
||
const tr = document.createElement("tr");
|
||
tr.setAttribute("role", "row");
|
||
tr.dataset.fresh = "1";
|
||
const th = document.createElement("th");
|
||
th.className = "xlsx-rownum";
|
||
th.setAttribute("role", "rowheader");
|
||
th.textContent = String(op.at + k);
|
||
th.style.cursor = "pointer";
|
||
th.title = t("xlsx.select_row");
|
||
tr.appendChild(th);
|
||
for (let c = 1; c <= nCols; c++) {
|
||
const td = blankTd(`${columnName(c)}${op.at + k}`);
|
||
td.setAttribute("role", "gridcell");
|
||
tr.appendChild(td);
|
||
}
|
||
tbody.insertBefore(tr, anchor);
|
||
}
|
||
// Shift existing rows by reference (DOM order may differ after a sort,
|
||
// so recounting positions would corrupt references).
|
||
tbody.querySelectorAll("tr:not([data-fresh])").forEach((tr) => {
|
||
const rn = rowNumOf(tr);
|
||
if (!(rn >= op.at)) return;
|
||
const next = rn + op.count;
|
||
const th = tr.querySelector("th.xlsx-rownum");
|
||
if (th) th.textContent = String(next);
|
||
tr.querySelectorAll("td[data-cell]").forEach((td) => {
|
||
const p = parseRef(td.dataset.cell);
|
||
if (p && p.row >= op.at) td.dataset.cell = `${columnName(p.col)}${p.row + op.count}`;
|
||
});
|
||
});
|
||
tbody.querySelectorAll("tr[data-fresh]").forEach((tr) => { delete tr.dataset.fresh; });
|
||
} else if (op.axis === "row" && !op.insert && tbody) {
|
||
[...tbody.children].forEach((tr) => {
|
||
const rn = rowNumOf(tr);
|
||
if (rn >= op.at && rn < op.at + op.count) tr.remove();
|
||
});
|
||
tbody.querySelectorAll("tr").forEach((tr) => {
|
||
const rn = rowNumOf(tr);
|
||
if (!(rn >= 1)) return;
|
||
if (rn >= op.at + op.count) {
|
||
const next = rn - op.count;
|
||
const th = tr.querySelector("th.xlsx-rownum");
|
||
if (th) th.textContent = String(next);
|
||
tr.querySelectorAll("td[data-cell]").forEach((td) => {
|
||
const p = parseRef(td.dataset.cell);
|
||
if (p && p.row >= op.at + op.count) td.dataset.cell = `${columnName(p.col)}${p.row - op.count}`;
|
||
});
|
||
}
|
||
});
|
||
} else if (op.axis === "col" && op.insert && tbody && nCols > 0 && op.at <= nCols + 1) {
|
||
for (let k = 0; k < op.count; k++) {
|
||
const th = document.createElement("th");
|
||
th.textContent = columnName(op.at + k);
|
||
th.style.cursor = "pointer";
|
||
th.title = t("xlsx.select_column");
|
||
th.dataset.fresh = "1";
|
||
headRow.insertBefore(th, headRow.children[op.at + k] || null);
|
||
}
|
||
tbody.querySelectorAll("tr").forEach((tr) => {
|
||
const rn = rowNumOf(tr);
|
||
for (let k = 0; k < op.count; k++) {
|
||
const td = blankTd(`${columnName(op.at + k)}${rn}`);
|
||
td.setAttribute("role", "gridcell");
|
||
td.dataset.fresh = "1";
|
||
const after = [...tr.querySelectorAll("td[data-cell]")].find((cell) => {
|
||
const p = parseRef(cell.dataset.cell);
|
||
return p && p.col >= op.at && !cell.dataset.fresh;
|
||
});
|
||
tr.insertBefore(td, after || null);
|
||
}
|
||
});
|
||
// Shift existing references by value (never by position: sorts reorder).
|
||
headRow.querySelectorAll("th:not(.xlsx-corner):not([data-fresh])").forEach((th) => {
|
||
const idx = colToIndex(th.textContent.trim());
|
||
if (idx >= op.at) th.textContent = columnName(idx + op.count);
|
||
});
|
||
tbody.querySelectorAll("td[data-cell]:not([data-fresh])").forEach((td) => {
|
||
const p = parseRef(td.dataset.cell);
|
||
if (p && p.col >= op.at) td.dataset.cell = `${columnName(p.col + op.count)}${p.row}`;
|
||
});
|
||
panel.querySelectorAll("[data-fresh]").forEach((el) => { delete el.dataset.fresh; });
|
||
} else if (op.axis === "col" && !op.insert && tbody && nCols > 0) {
|
||
const kill = new Set();
|
||
for (let c = op.at; c < op.at + op.count; c++) kill.add(c);
|
||
[...headRow.children].forEach((th, i) => {
|
||
if (i > 0) {
|
||
const idx = colToIndex(th.textContent.trim());
|
||
if (kill.has(idx)) th.remove();
|
||
}
|
||
});
|
||
tbody.querySelectorAll("tr").forEach((tr) => {
|
||
[...tr.querySelectorAll("td[data-cell]")].forEach((td) => {
|
||
const p = parseRef(td.dataset.cell);
|
||
if (!p) return;
|
||
if (kill.has(p.col)) td.remove();
|
||
});
|
||
});
|
||
headRow.querySelectorAll("th:not(.xlsx-corner)").forEach((th) => {
|
||
const idx = colToIndex(th.textContent.trim());
|
||
if (idx >= op.at + op.count) th.textContent = columnName(idx - op.count);
|
||
});
|
||
tbody.querySelectorAll("td[data-cell]").forEach((td) => {
|
||
const p = parseRef(td.dataset.cell);
|
||
if (p && p.col >= op.at + op.count) td.dataset.cell = `${columnName(p.col - op.count)}${p.row}`;
|
||
});
|
||
}
|
||
// Rebuild spans/freeze/dims from the shifted meta, recompute, tidy UI.
|
||
invalidateCellIndex(panel);
|
||
applySheetMeta(panel);
|
||
applyGridA11y(panel);
|
||
refreshTruncationNote(panel);
|
||
refreshPanelFormulas(panel);
|
||
// The active cell may have been renumbered or removed: re-resolve it.
|
||
if (activeTd && !activeTd.isConnected) {
|
||
const p = parseRef(cellName(activeTd));
|
||
let next = null;
|
||
if (p) {
|
||
const s = shiftSelPoint({ row: p.row, col: p.col }, op);
|
||
if (s) next = findTd(panel, s.row, s.col);
|
||
}
|
||
setActiveCell(next);
|
||
}
|
||
if (selection && selection.panel === panel) {
|
||
const a = shiftSelPoint(selection.anchor, op);
|
||
const f = shiftSelPoint(selection.focus, op);
|
||
if (a && f) {
|
||
const aTd = findTd(panel, a.row, a.col);
|
||
const fTd = findTd(panel, f.row, f.col);
|
||
if (aTd && fTd) {
|
||
selection.anchor = a;
|
||
selection.focus = f;
|
||
applySelection();
|
||
} else {
|
||
setSelection(null);
|
||
}
|
||
} else {
|
||
setSelection(null);
|
||
}
|
||
if (!selection) {
|
||
setActiveCell(null);
|
||
const bar = area.querySelector("#xlsx-minibar");
|
||
if (bar) bar.hidden = true;
|
||
}
|
||
}
|
||
updateSheetsToolbarStates();
|
||
refreshSaveState();
|
||
};
|
||
|
||
/** Activate a sheet tab by index (tab strip + panels + states). */
|
||
const activateSheetTab = (idx) => {
|
||
area.querySelectorAll(".xlsx-tab").forEach((x) => {
|
||
const on = Number(x.dataset.sheet) === idx;
|
||
x.classList.toggle("active", on);
|
||
x.setAttribute("aria-selected", String(on));
|
||
});
|
||
panelEls.forEach((p) => { p.style.display = Number(p.dataset.sheet) === idx ? "" : "none"; });
|
||
// #184 — pad the sheet that just came on screen (A → Z, 1000 rows).
|
||
ensureGridPadded(visiblePanel());
|
||
refreshPanelFormulas(visiblePanel());
|
||
updateSheetsToolbarStates();
|
||
updateMinibar();
|
||
requestStick();
|
||
};
|
||
const reindexSheets = () => {
|
||
panelEls.forEach((p, i) => { p.dataset.sheet = String(i); });
|
||
area.querySelectorAll(".xlsx-tab").forEach((x, i) => { x.dataset.sheet = String(i); });
|
||
};
|
||
const blankPanelHtml = (cols, rows) => {
|
||
let thead = '<thead><tr><th class="xlsx-corner"></th>';
|
||
for (let c = 1; c <= cols; c++) thead += `<th>${columnName(c)}</th>`;
|
||
thead += "</tr></thead>";
|
||
let body = "<tbody>";
|
||
for (let r = 1; r <= rows; r++) {
|
||
body += `<tr><th class="xlsx-rownum">${r}</th>`;
|
||
for (let c = 1; c <= cols; c++) body += `<td data-cell="${columnName(c)}${r}"></td>`;
|
||
body += "</tr>";
|
||
}
|
||
return `<div class="csv-table-wrapper"><table class="csv-table xlsx-table">${thead}${body}</table></div>`;
|
||
};
|
||
const wireFreshPanel = (panel) => {
|
||
panel.querySelectorAll(".xlsx-table td").forEach(setupCell);
|
||
wireResize(panel);
|
||
wireLazyRows(panel);
|
||
applySheetMeta(panel);
|
||
applyGridA11y(panel);
|
||
};
|
||
const addSheetLocal = (name) => {
|
||
const clean = String(name || "").slice(0, 31);
|
||
if (!clean || sheets.some((s) => s && s.name === clean)) return;
|
||
// Same blank shape as the server (BUG-094): 8 cols × 20 rows.
|
||
const idx = sheets.length;
|
||
sheets.push({
|
||
name: clean, html: "", rows: 20, cols: 8, total_rows: 20, total_cols: 8,
|
||
max_rows: 500, max_cols: 40, truncated: false,
|
||
styles: {}, aligns: {}, comments: {}, formats: {},
|
||
colwidths: {}, rowheights: {}, merges: [], freeze: "",
|
||
});
|
||
const tabs = area.querySelector(".xlsx-tabs");
|
||
if (tabs) {
|
||
const tab = document.createElement("button");
|
||
tab.className = "xlsx-tab";
|
||
tab.dataset.sheet = String(idx);
|
||
tab.setAttribute("role", "tab");
|
||
tab.setAttribute("aria-selected", "false");
|
||
tab.textContent = clean;
|
||
tab.addEventListener("click", () => activateSheetTab(idx));
|
||
const addBtn = tabs.querySelector("#xlsx-tab-add");
|
||
tabs.insertBefore(tab, addBtn || null);
|
||
}
|
||
const host = area.querySelector(".xlsx-panels");
|
||
if (!host) return; // CSV mode has no sheet tabs
|
||
const panel = document.createElement("div");
|
||
panel.className = "xlsx-panel";
|
||
panel.dataset.sheet = String(idx);
|
||
panel.style.display = "none";
|
||
panel.innerHTML = blankPanelHtml(8, 20);
|
||
host.appendChild(panel);
|
||
panelEls.push(panel);
|
||
wireFreshPanel(panel);
|
||
activateSheetTab(idx);
|
||
setActiveCell(panel.querySelector('td[data-cell="A1"]'));
|
||
};
|
||
const renameSheetLocal = (from, to) => {
|
||
const clean = String(to || "").slice(0, 31);
|
||
const idx = sheets.findIndex((s) => s && s.name === from);
|
||
if (idx < 0 || !clean || clean === from) return;
|
||
sheets[idx].name = clean;
|
||
const tab = area.querySelector(`.xlsx-tab[data-sheet="${idx}"]`);
|
||
if (tab) tab.textContent = clean;
|
||
[undoStack, redoStack].forEach((stack) => {
|
||
stack.forEach((entry) => {
|
||
if (entry && entry.sheet === from) entry.sheet = clean;
|
||
});
|
||
});
|
||
// Cross-sheet qualifiers ('Old'!A1) follow the rename like Excel —
|
||
// outside string literals, which keep their text.
|
||
const esc = String(from).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
const both = new RegExp(`(^|[^\\w.$])((?:'${esc}'|${esc})!)`, "g");
|
||
const renameInText = (text) => text.split(/("[^"]*")/g).map((part, i) => {
|
||
if (i % 2 === 1) return part;
|
||
return part.replace(both, (m, pre, q) => {
|
||
const quoted = q.startsWith("'");
|
||
const needQuotes = /[^A-Za-z0-9_.$]/.test(clean);
|
||
const name = needQuotes || quoted ? `'${clean.replace(/'/g, "''")}'` : clean;
|
||
return `${pre}${name}!`;
|
||
});
|
||
}).join("");
|
||
panelEls.forEach((panel) => {
|
||
panel.querySelectorAll("td[data-cell]").forEach((td) => {
|
||
if (!td.dataset.formula) return;
|
||
const next = renameInText(td.dataset.formula);
|
||
if (next !== td.dataset.formula) {
|
||
td.dataset.formula = next;
|
||
delete td.dataset.display;
|
||
}
|
||
});
|
||
});
|
||
};
|
||
const deleteSheetLocal = (name) => {
|
||
const idx = sheets.findIndex((s) => s && s.name === name);
|
||
if (idx < 0 || sheets.length <= 1) return;
|
||
area.querySelector(`.xlsx-tab[data-sheet="${idx}"]`)?.remove();
|
||
const panel = panelEls[idx];
|
||
if (panel) panel.remove();
|
||
sheets.splice(idx, 1);
|
||
panelEls.splice(idx, 1);
|
||
reindexSheets();
|
||
[undoStack, redoStack].forEach((stack) => {
|
||
for (let i = stack.length - 1; i >= 0; i--) {
|
||
if (stack[i] && stack[i].sheet === name) stack.splice(i, 1);
|
||
}
|
||
});
|
||
updateUndoButtons();
|
||
if (activeTd && !activeTd.isConnected) setActiveCell(null);
|
||
if (selection && !selection.panel.isConnected) {
|
||
selection = null;
|
||
const bar = area.querySelector("#xlsx-minibar");
|
||
if (bar) bar.hidden = true;
|
||
}
|
||
activateSheetTab(0);
|
||
};
|
||
const duplicateSheetLocal = (name, as) => {
|
||
const clean = String(as || "").slice(0, 31);
|
||
const srcIdx = sheets.findIndex((s) => s && s.name === name);
|
||
if (srcIdx < 0 || !clean || sheets.some((s) => s && s.name === clean)) return;
|
||
const srcPanel = panelEls.find((p) => Number(p.dataset.sheet) === srcIdx);
|
||
if (!srcPanel) return;
|
||
const idx = sheets.length;
|
||
const meta = JSON.parse(JSON.stringify(sheets[srcIdx] || {}));
|
||
meta.name = clean;
|
||
sheets.push(meta);
|
||
const tabs = area.querySelector(".xlsx-tabs");
|
||
if (tabs) {
|
||
const tab = document.createElement("button");
|
||
tab.className = "xlsx-tab";
|
||
tab.dataset.sheet = String(idx);
|
||
tab.setAttribute("role", "tab");
|
||
tab.setAttribute("aria-selected", "false");
|
||
tab.textContent = clean;
|
||
tab.addEventListener("click", () => activateSheetTab(idx));
|
||
const addBtn = tabs.querySelector("#xlsx-tab-add");
|
||
tabs.insertBefore(tab, addBtn || null);
|
||
}
|
||
const clone = srcPanel.cloneNode(true);
|
||
clone.dataset.sheet = String(idx);
|
||
clone.style.display = "none";
|
||
clone.querySelector("table")?.removeAttribute("data-select-wired");
|
||
clone.querySelector("table")?.removeAttribute("data-resize-wired");
|
||
clone.querySelector(".xlsx-load-more")?.remove();
|
||
clone.querySelectorAll("td.xlsx-dirty").forEach((td) => {
|
||
td.classList.remove("xlsx-dirty");
|
||
td.dataset.orig = td.textContent;
|
||
});
|
||
clone.querySelectorAll("td.xlsx-active").forEach((td) => td.classList.remove("xlsx-active"));
|
||
clone.querySelectorAll("td.xlsx-selected").forEach((td) => td.classList.remove("xlsx-selected"));
|
||
const host = area.querySelector(".xlsx-panels");
|
||
if (!host) return;
|
||
host.appendChild(clone);
|
||
panelEls.push(clone);
|
||
wireFreshPanel(clone);
|
||
activateSheetTab(idx);
|
||
setActiveCell(clone.querySelector('td[data-cell="A1"]'));
|
||
};
|
||
const applyStructureLocal = (actions) => {
|
||
(actions || []).forEach((action) => {
|
||
if (!action || typeof action !== "object") return;
|
||
const op = action.op;
|
||
if (op === "row_insert" || op === "row_delete" || op === "col_insert" || op === "col_delete") {
|
||
const idx = sheets.findIndex((s) => s && s.name === action.sheet);
|
||
if (idx < 0) return;
|
||
const at = Number(action.at) || 1;
|
||
const count = Math.max(1, Number(action.count) || 1);
|
||
applyRowColOp(idx, {
|
||
axis: op.startsWith("row") ? "row" : "col",
|
||
at, count, insert: op.endsWith("insert"),
|
||
});
|
||
} else if (op === "sheet_add") {
|
||
addSheetLocal(action.name);
|
||
} else if (op === "sheet_rename") {
|
||
renameSheetLocal(action.from, action.to);
|
||
} else if (op === "sheet_delete") {
|
||
deleteSheetLocal(action.name);
|
||
} else if (op === "sheet_duplicate") {
|
||
duplicateSheetLocal(action.name, action.as);
|
||
}
|
||
});
|
||
refreshAllFormulas();
|
||
updateSheetsToolbarStates();
|
||
refreshSaveState();
|
||
};
|
||
|
||
const putStructure = async (actions, force = false, { quiet = false, record = true } = {}) => {
|
||
window.__xlsxLastOwnWrite = { vault: data.vault, path: data.path, at: Date.now() };
|
||
const res = await api(
|
||
`/api/file/${encodeURIComponent(data.vault)}/xlsx/structure?path=${encodeURIComponent(data.path)}`,
|
||
{
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ actions, force, if_match: revision }),
|
||
},
|
||
);
|
||
keepRevision(res);
|
||
if (!quiet) showToast(t("xlsx.structure_saved"), "success");
|
||
// #156-A10 — a fully reversible batch goes on the undo stack.
|
||
if (record) {
|
||
const inverses = actions.map(inverseAction);
|
||
if (inverses.every(Boolean)) {
|
||
pushHistory({ kind: "structure", before: inverses, after: actions });
|
||
}
|
||
}
|
||
// Dynamic: replay the batch in the DOM — grid, focus and selection
|
||
// survive (no full re-render).
|
||
applyStructureLocal(actions);
|
||
return res;
|
||
};
|
||
|
||
const structureError = async (err, retryActions) => {
|
||
if (err && err.code === "xlsx_lossy_content") {
|
||
const features = (err.details && err.details.features) || lossy;
|
||
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
|
||
const proceed = await showConfirm({
|
||
title: t("xlsx.lossy_title"),
|
||
message: t("xlsx.lossy_confirm", { features: labels }),
|
||
confirmLabel: t("xlsx.lossy_confirm_btn"),
|
||
danger: true,
|
||
});
|
||
if (proceed) return putStructure(retryActions, true); // re-emitted with force
|
||
showToast(t("xlsx.lossy_cancelled"), "info");
|
||
return null;
|
||
}
|
||
if (err && err.code === "conflict") {
|
||
// #156-A12 — a concurrent writer: keep the user's edits, offer a retry.
|
||
showConflict(t("xlsx.conflict_msg"));
|
||
return null;
|
||
}
|
||
showToast(`${t("xlsx.structure_error")}: ${err.message || err}`, "error");
|
||
return null;
|
||
};
|
||
|
||
// #154-A1/A2 — the “+” button mirrors the structure menu's « Ajouter une
|
||
// feuille » item, right where the tabs are (Excel-like).
|
||
const tabAddBtn = area.querySelector("#xlsx-tab-add");
|
||
if (tabAddBtn) tabAddBtn.addEventListener("click", async () => {
|
||
const name = await showPrompt({
|
||
title: t("xlsx.sheet_add"),
|
||
message: t("xlsx.structure_prompt_add"),
|
||
});
|
||
if (!name) return;
|
||
const actions = [{ op: "sheet_add", name }];
|
||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||
});
|
||
|
||
// Structure menu: built on demand, positioned under the button.
|
||
// (.csv / read-only workbooks have no such button — the menu block above
|
||
// is skipped for them.)
|
||
// Anchor a toolbar menu under its toggle button (clamped to the toolbar
|
||
// horizontally; dropped above the button when it would overflow the viewport).
|
||
const anchorUnder = (menu, toolbar, btn) => {
|
||
const toolRect = toolbar.getBoundingClientRect();
|
||
const btnRect = btn.getBoundingClientRect();
|
||
const vh = (typeof window !== "undefined" && window.innerHeight) || 768;
|
||
const w = menu.offsetWidth || 240;
|
||
const h = menu.offsetHeight || 0;
|
||
// With zeroed rects (JSDOM) this settles on 4px/4px — still assertable.
|
||
const left = Math.max(4, Math.min(btnRect.left - toolRect.left, Math.max(4, toolRect.width - w - 4)));
|
||
let top = btnRect.bottom - toolRect.top + 4;
|
||
if (h && toolRect.top + top + h > vh - 8) {
|
||
top = Math.max(4, btnRect.top - toolRect.top - h - 4);
|
||
}
|
||
menu.style.left = `${Math.round(left)}px`;
|
||
menu.style.top = `${Math.round(top)}px`;
|
||
};
|
||
// #179 — tracked dismissal of the structure menu (outside, focus loss…).
|
||
let dismissStructureMenu = null;
|
||
const structureBtn = area.querySelector("#xlsx-structure-btn");
|
||
if (structureBtn) structureBtn.addEventListener("click", (e) => {
|
||
if (dismissStructureMenu) { dismissStructureMenu(); dismissStructureMenu = null; return; }
|
||
const idx = visibleSheetIndex();
|
||
const sheetName = sheets[idx]?.name || "";
|
||
const activeRef = cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null);
|
||
const parsed = parseRef(activeRef) || { row: 1, col: 1 };
|
||
const menu = document.createElement("div");
|
||
menu.className = "xlsx-structure-menu";
|
||
menu.setAttribute("role", "menu");
|
||
// #179 — representative icon per entry; destructive ones read as danger.
|
||
const item = (iconName, label, fn, danger = false) => {
|
||
const b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = `btn-action xlsx-structure-item${danger ? " xlsx-context-danger" : ""}`;
|
||
b.setAttribute("role", "menuitem");
|
||
if (iconName) {
|
||
const ic = document.createElement("i");
|
||
ic.setAttribute("data-lucide", iconName);
|
||
ic.className = "xlsx-menu-icon";
|
||
ic.setAttribute("aria-hidden", "true");
|
||
b.appendChild(ic);
|
||
}
|
||
const lab = document.createElement("span");
|
||
lab.className = "xlsx-menu-label";
|
||
lab.textContent = label;
|
||
b.appendChild(lab);
|
||
b.addEventListener("click", () => {
|
||
if (dismissStructureMenu) { dismissStructureMenu(); dismissStructureMenu = null; }
|
||
fn();
|
||
});
|
||
menu.appendChild(b);
|
||
};
|
||
item("file-plus", t("xlsx.sheet_add"), async () => {
|
||
const name = await showPrompt({
|
||
title: t("xlsx.sheet_add"),
|
||
message: t("xlsx.structure_prompt_add"),
|
||
});
|
||
if (!name) return;
|
||
const actions = [{ op: "sheet_add", name }];
|
||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||
});
|
||
item("pencil", t("xlsx.sheet_rename"), async () => {
|
||
const to = await showPrompt({
|
||
title: t("xlsx.sheet_rename"),
|
||
message: t("xlsx.structure_prompt_rename"),
|
||
value: sheetName,
|
||
});
|
||
if (!to || to === sheetName) return;
|
||
const actions = [{ op: "sheet_rename", from: sheetName, to }];
|
||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||
});
|
||
item("copy", t("xlsx.sheet_duplicate"), async () => {
|
||
const as = await showPrompt({
|
||
title: t("xlsx.sheet_duplicate"),
|
||
message: t("xlsx.structure_prompt_add"),
|
||
value: `${sheetName} (copie)`,
|
||
});
|
||
if (!as) return;
|
||
const actions = [{ op: "sheet_duplicate", name: sheetName, as }];
|
||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||
});
|
||
item("trash-2", t("xlsx.sheet_delete"), async () => {
|
||
if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; }
|
||
const okDelete = await showConfirm({
|
||
title: t("xlsx.sheet_delete"),
|
||
message: t("xlsx.structure_confirm_delete_sheet", { name: sheetName }),
|
||
confirmLabel: t("xlsx.sheet_delete"),
|
||
danger: true,
|
||
});
|
||
if (!okDelete) return;
|
||
const actions = [{ op: "sheet_delete", name: sheetName }];
|
||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||
}, true);
|
||
if (parsed) {
|
||
menu.appendChild(Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" }));
|
||
item("rows", t("xlsx.row_insert"), async () => {
|
||
const actions = [{ op: "row_insert", sheet: sheetName, at: parsed.row }];
|
||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||
});
|
||
item("trash-2", t("xlsx.row_delete"), async () => {
|
||
const okRow = await showConfirm({
|
||
title: t("xlsx.row_delete"),
|
||
message: t("xlsx.structure_confirm_row", { n: parsed.row }),
|
||
confirmLabel: t("xlsx.row_delete"),
|
||
danger: true,
|
||
});
|
||
if (!okRow) return;
|
||
const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }];
|
||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||
}, true);
|
||
item("columns", t("xlsx.col_insert"), async () => {
|
||
const actions = [{ op: "col_insert", sheet: sheetName, at: parsed.col }];
|
||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||
});
|
||
item("trash-2", t("xlsx.col_delete"), async () => {
|
||
const okCol = await showConfirm({
|
||
title: t("xlsx.col_delete"),
|
||
message: t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }),
|
||
confirmLabel: t("xlsx.col_delete"),
|
||
danger: true,
|
||
});
|
||
if (!okCol) return;
|
||
const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }];
|
||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||
}, true);
|
||
}
|
||
enableArrowNav(menu, ".xlsx-structure-item");
|
||
const structureToolbar = e.target.closest(".xlsx-toolbar");
|
||
structureToolbar.appendChild(menu);
|
||
anchorUnder(menu, structureToolbar, structureBtn);
|
||
safeCreateIcons();
|
||
structureBtn.setAttribute("aria-expanded", "true");
|
||
// #179 — close on outside click, focus loss, Escape, scroll, resize.
|
||
dismissStructureMenu = trackDismissable(menu, {
|
||
toggle: structureBtn,
|
||
onClose: () => {
|
||
dismissStructureMenu = null;
|
||
structureBtn.setAttribute("aria-expanded", "false");
|
||
menu.remove();
|
||
},
|
||
});
|
||
});
|
||
|
||
// ── #156-A11 — sortie : CSV / Markdown / HTML / impression ────────────
|
||
// The four formats all read the SAME grid: the selection's block when a
|
||
// multi-cell range is active (this tranche l'écart doc/code de #153 A13,
|
||
// qui annonçait « export de la sélection »), the visible sheet otherwise.
|
||
// Values are the DISPLAYED ones — the cached formula shadow is a tooltip,
|
||
// never data. Rows follow the render (post-trim/post-lazy-load) so what is
|
||
// exported is exactly what the user sees.
|
||
|
||
/** The whole visible sheet as a matrix of displayed values. */
|
||
// #184 — the grid is laid out generously (columns A → Z, rows grown on
|
||
// demand), so the tail of empty rows/columns is LAYOUT, not data: the export
|
||
// carries the used block, the way a spreadsheet export does. Without this a
|
||
// two-column sheet exported 26 columns of blanks.
|
||
const isBlankCell = (v) => String(v == null ? "" : v).trim() === "";
|
||
const sheetMatrix = (panel) => {
|
||
const rows = [...panel.querySelectorAll(".xlsx-table tbody tr")].map((tr) =>
|
||
[...tr.querySelectorAll("td")].map((td) => cellTextOf(td)),
|
||
);
|
||
while (rows.length && rows[rows.length - 1].every(isBlankCell)) rows.pop();
|
||
let width = 0;
|
||
rows.forEach((r) => {
|
||
for (let c = r.length - 1; c >= 0; c--) {
|
||
if (!isBlankCell(r[c])) { if (c + 1 > width) width = c + 1; break; }
|
||
}
|
||
});
|
||
return rows.map((r) => r.slice(0, width));
|
||
};
|
||
|
||
/** What an export should carry, plus the scope/title to name the file. */
|
||
const exportPayload = () => {
|
||
const panel = visiblePanel();
|
||
const b = selectionBounds();
|
||
const selected = Boolean(
|
||
b && selection && selection.panel === panel && (b.r2 > b.r1 || b.c2 > b.c1),
|
||
);
|
||
const base = (sheets[Number(panel.dataset.sheet)]?.name || data.title || data.path || "feuille")
|
||
.replace(/\.[^.]+$/, "");
|
||
return {
|
||
rows: selected ? selectionMatrix() : sheetMatrix(panel),
|
||
selected,
|
||
// « Fruits-A1C3 » — a filename-safe version of the range address.
|
||
name: selected ? `${base}-${rangeLabel().replace(/[:$]/g, "")}` : base,
|
||
scope: t(selected ? "xlsx.export_scope_selection" : "xlsx.export_scope_sheet"),
|
||
};
|
||
};
|
||
|
||
const downloadText = (filename, text, mime) => {
|
||
const blob = new Blob([text], { type: mime });
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(a.href);
|
||
};
|
||
|
||
const toCsv = (rows) =>
|
||
rows
|
||
.map((r) => r.map((v) => (/[";\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v)).join(";"))
|
||
.join("\n");
|
||
|
||
const toMarkdown = (rows) => {
|
||
if (!rows.length) return "";
|
||
const head = rows[0];
|
||
return [
|
||
`| ${head.join(" | ")} |`,
|
||
`|${head.map(() => "---").join("|")}|`,
|
||
...rows.slice(1).map((r) => `| ${r.join(" | ")} |`),
|
||
].join("\n");
|
||
};
|
||
|
||
const toHtml = (rows, title) => `<!DOCTYPE html>
|
||
<html lang="${document.documentElement.lang || "fr"}">
|
||
<head><meta charset="utf-8"><title>${escapeHtml(title)}</title></head>
|
||
<body><table border="1"><tbody>${rows
|
||
.map((r) => `<tr>${r.map((v) => `<td>${escapeHtml(v)}</td>`).join("")}</tr>`)
|
||
.join("")}</tbody></table></body></html>`;
|
||
|
||
const exportSheetCsv = () => {
|
||
const { rows, name, scope } = exportPayload();
|
||
downloadText(`${name}.csv`, `\uFEFF${toCsv(rows)}`, "text/csv;charset=utf-8");
|
||
showToast(t("xlsx.export_done", { scope }), "success");
|
||
};
|
||
|
||
const exportDocument = (kind) => {
|
||
const { rows, name, scope } = exportPayload();
|
||
if (kind === "md") {
|
||
downloadText(`${name}.md`, toMarkdown(rows), "text/markdown;charset=utf-8");
|
||
} else {
|
||
downloadText(`${name}.html`, toHtml(rows, name), "text/html;charset=utf-8");
|
||
}
|
||
showToast(t("xlsx.export_done", { scope }), "success");
|
||
};
|
||
|
||
// Print renders the sheet in a hidden 0×0 iframe so the app chrome (toolbar,
|
||
// banners, tabs, inspector) never reaches the paper — the frame carries its
|
||
// own standalone document. The previous frame is dropped first: at most one
|
||
// ever lingers in the page.
|
||
const printSheet = () => {
|
||
const { rows, name } = exportPayload();
|
||
document.querySelectorAll("body > iframe.xlsx-print-frame").forEach((f) => f.remove());
|
||
document.body.classList.add("xlsx-print");
|
||
const frame = document.createElement("iframe");
|
||
frame.className = "xlsx-print-frame";
|
||
frame.setAttribute("aria-hidden", "true");
|
||
document.body.appendChild(frame);
|
||
try {
|
||
const doc = frame.contentDocument;
|
||
if (doc) {
|
||
doc.open();
|
||
doc.write(toHtml(rows, name));
|
||
doc.close();
|
||
const win = frame.contentWindow;
|
||
if (win && typeof win.print === "function") win.print();
|
||
} else {
|
||
// No same-origin contentDocument (older engines): keep the document
|
||
// reachable for a manual print instead of losing the export.
|
||
frame.setAttribute("srcdoc", toHtml(rows, name));
|
||
if (typeof window.print === "function") window.print();
|
||
}
|
||
} finally {
|
||
document.body.classList.remove("xlsx-print");
|
||
}
|
||
};
|
||
|
||
// #185 — the file menu moved to the menu bar (frontend/js/xlsx/menus.js);
|
||
// its `[data-xlsx-export]` entries and `#xlsx-download-btn` /
|
||
// `#xlsx-csv-btn` ids are wired by the generic menu handler below.
|
||
// ── #156-A8 — mise en forme en écriture ──────────────────────────────────
|
||
// Styles, fusions, largeurs, hauteurs et volets figés partent en une seule
|
||
// réécriture verrouillée/atomique (PUT …/xlsx/style), comme les éditions de
|
||
// cellules et la structure. Les couleurs sont **choisies** (sélecteur natif),
|
||
// jamais codées en dur : le champ `style` ne porte que des valeurs de l'appel.
|
||
const selectionRange = () => {
|
||
const b = selectionBounds();
|
||
if (b && selection && selection.panel === visiblePanel()) return rangeLabel();
|
||
return cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null) || "A1";
|
||
};
|
||
|
||
// ── Dynamic style application (no re-render) ──────────────────────────
|
||
// Mirrors the server fragment mapping so the grid matches the file without
|
||
// a round-trip: patch the sheet meta maps, then reuse applySheetMeta().
|
||
const splitDecls = (css) => {
|
||
const parts = [];
|
||
String(css || "").split(";").forEach((p) => {
|
||
const i = p.indexOf(":");
|
||
if (i > 0) parts.push([p.slice(0, i).trim(), p.slice(i + 1).trim()]);
|
||
});
|
||
return parts;
|
||
};
|
||
const setDecl = (parts, key, value) => {
|
||
const i = parts.findIndex((p) => p[0] === key);
|
||
if (value === null || value === undefined || value === "") {
|
||
if (i >= 0) parts.splice(i, 1);
|
||
} else if (i >= 0) {
|
||
parts[i][1] = value;
|
||
} else {
|
||
parts.push([key, value]);
|
||
}
|
||
};
|
||
const normHex = (v) => {
|
||
const s = String(v || "").trim().replace(/^#/, "");
|
||
return /^[0-9a-fA-F]{6}$/.test(s) ? `#${s.toLowerCase()}` : null;
|
||
};
|
||
const patchCellFragment = (meta, ref, style) => {
|
||
const styles = meta.styles || (meta.styles = {});
|
||
const aligns = meta.aligns || (meta.aligns = {});
|
||
const formats = meta.formats || (meta.formats = {});
|
||
const parts = splitDecls(styles[ref] || "");
|
||
if ("bold" in style) setDecl(parts, "font-weight", style.bold ? "600" : null);
|
||
if ("italic" in style) setDecl(parts, "font-style", style.italic ? "italic" : null);
|
||
if ("underline" in style || "strike" in style) {
|
||
const cur = new Set((((parts.find((p) => p[0] === "text-decoration") || [])[1]) || "").split(" ").filter(Boolean));
|
||
// Baseline from the td when the map has no entry yet (fresh styling).
|
||
if ("underline" in style) {
|
||
if (style.underline) cur.add("underline"); else cur.delete("underline");
|
||
}
|
||
if ("strike" in style) {
|
||
if (style.strike) cur.add("line-through"); else cur.delete("line-through");
|
||
}
|
||
setDecl(parts, "text-decoration", cur.size ? [...cur].join(" ") : null);
|
||
}
|
||
if ("font_color" in style) {
|
||
const hex = style.font_color ? normHex(style.font_color) : null;
|
||
setDecl(parts, "color", hex);
|
||
}
|
||
if ("fill_color" in style) {
|
||
const hex = style.fill_color ? normHex(style.fill_color) : null;
|
||
setDecl(parts, "background", hex);
|
||
}
|
||
if ("font_size" in style) {
|
||
const n = Number(style.font_size);
|
||
setDecl(parts, "font-size", Number.isFinite(n) && n !== 11 ? `${Number.isInteger(n) ? n : Math.round(n * 100) / 100}pt` : null);
|
||
}
|
||
if ("align" in style) {
|
||
if (style.align === "left") delete aligns[ref];
|
||
else aligns[ref] = style.align;
|
||
}
|
||
if ("valign" in style) {
|
||
setDecl(parts, "vertical-align", style.valign === "middle" ? "middle" : style.valign);
|
||
}
|
||
if ("wrap" in style) setDecl(parts, "white-space", style.wrap ? "normal" : "nowrap");
|
||
if ("rotation" in style) {
|
||
const r = Number(style.rotation) || 0;
|
||
if (r === 90) {
|
||
setDecl(parts, "writing-mode", "vertical-rl");
|
||
setDecl(parts, "transform", null);
|
||
} else if (r === 180) {
|
||
setDecl(parts, "writing-mode", "vertical-rl");
|
||
setDecl(parts, "transform", "scale(-1)");
|
||
} else if (r) {
|
||
// Diagonal (ex. ±45° du menu Rotation) : le CSS tourne en sens
|
||
// horaire, Excel en anti-horaire — signe inversé, comme le lecteur.
|
||
setDecl(parts, "writing-mode", null);
|
||
setDecl(parts, "transform", `rotate(${-r}deg)`);
|
||
} else {
|
||
setDecl(parts, "writing-mode", null);
|
||
setDecl(parts, "transform", null);
|
||
}
|
||
}
|
||
if ("border" in style || "border_style" in style || "border_color" in style) {
|
||
const which = style.border || "all";
|
||
if (which === "none") {
|
||
["border-left", "border-right", "border-top", "border-bottom"].forEach((k) => setDecl(parts, k, null));
|
||
} else {
|
||
const st = { medium: "2px", thick: "3px" }[style.border_style] || "1px";
|
||
const bs = ["dashed", "dotted", "double"].includes(style.border_style) ? style.border_style : "solid";
|
||
const hex = normHex(style.border_color) || "#000000";
|
||
["border-left", "border-right", "border-top", "border-bottom"].forEach((k) => {
|
||
setDecl(parts, k, `${st} ${bs} ${hex}`);
|
||
});
|
||
}
|
||
}
|
||
if ("number_format" in style) {
|
||
const fmt = style.number_format || "General";
|
||
if (fmt === "General" || fmt === "@") delete formats[ref];
|
||
else formats[ref] = fmt;
|
||
setDecl(parts, "font-family", (fmt === "General" || fmt === "@") ? null : "'JetBrains Mono',monospace");
|
||
}
|
||
const css = parts.map(([k, v]) => `${k}:${v}`).join(";");
|
||
if (css) styles[ref] = css;
|
||
else delete styles[ref];
|
||
};
|
||
const expandRangeRefs = (range) => {
|
||
const m = /^\$?([A-Za-z]{1,3})\$?(\d+)(?::\$?([A-Za-z]{1,3})\$?(\d+))?$/.exec(String(range || "").trim());
|
||
if (!m) return [];
|
||
const c1 = colToIndex(m[1]);
|
||
const r1 = Number(m[2]);
|
||
const c2 = m[3] ? colToIndex(m[3]) : c1;
|
||
const r2 = m[4] ? Number(m[4]) : r1;
|
||
if (!c1 || !c2 || !(r1 >= 1) || !(r2 >= 1)) return [];
|
||
const out = [];
|
||
for (let r = Math.min(r1, r2); r <= Math.max(r1, r2); r++) {
|
||
for (let c = Math.min(c1, c2); c <= Math.max(c1, c2); c++) {
|
||
out.push(`${indexToCol(c)}${r}`);
|
||
if (out.length >= 10000) return out;
|
||
}
|
||
}
|
||
return out;
|
||
};
|
||
/** Patch the sheet meta maps from style ops, then repaint (no re-render). */
|
||
const applyStyleOpsLocal = (sheetIdx, ops) => {
|
||
const meta = sheets[sheetIdx];
|
||
if (!meta) return;
|
||
const panel = panelEls.find((p) => Number(p.dataset.sheet) === sheetIdx);
|
||
ops.forEach((op) => {
|
||
if (!op || typeof op !== "object") return;
|
||
if (op.op === "cell") {
|
||
const refs = expandRangeRefs(op.range || op.cell || "");
|
||
refs.forEach((ref) => patchCellFragment(meta, ref, op.style || {}));
|
||
} else if (op.op === "merge") {
|
||
meta.merges = meta.merges || [];
|
||
if (op.range && !meta.merges.includes(op.range)) meta.merges.push(op.range);
|
||
} else if (op.op === "unmerge") {
|
||
meta.merges = (meta.merges || []).filter((m) => m !== op.range);
|
||
} else if (op.op === "col_width") {
|
||
const col = String(op.col || "").trim().toUpperCase();
|
||
meta.colwidths = meta.colwidths || {};
|
||
if (col) meta.colwidths[col] = Number(op.width);
|
||
} else if (op.op === "row_height") {
|
||
const row = Number(op.row);
|
||
meta.rowheights = meta.rowheights || {};
|
||
if (row >= 1) meta.rowheights[String(row)] = Number(op.height);
|
||
} else if (op.op === "freeze") {
|
||
meta.freeze = op.cell ? String(op.cell).toUpperCase() : "";
|
||
} else if (op.op === "comment_set") {
|
||
meta.comments = meta.comments || {};
|
||
if (op.ref && op.text) meta.comments[String(op.ref).toUpperCase()] = String(op.text).slice(0, 500);
|
||
} else if (op.op === "comment_clear") {
|
||
if (meta.comments) delete meta.comments[String(op.ref || "").toUpperCase()];
|
||
}
|
||
});
|
||
if (panel) {
|
||
applySheetMeta(panel);
|
||
refreshPanelFormulas(panel);
|
||
}
|
||
updateSheetsToolbarStates();
|
||
};
|
||
const putStyle = async (ops, force = false) => {
|
||
window.__xlsxLastOwnWrite = { vault: data.vault, path: data.path, at: Date.now() };
|
||
const res = await api(
|
||
`/api/file/${encodeURIComponent(data.vault)}/xlsx/style?path=${encodeURIComponent(data.path)}`,
|
||
{
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ ops, force, if_match: revision }),
|
||
},
|
||
);
|
||
keepRevision(res);
|
||
showToast(t("xlsx.format_applied"), "success");
|
||
// Dynamic: patch the meta maps and repaint in place — the grid, focus
|
||
// and selection survive (no full re-render).
|
||
const bySheet = new Map();
|
||
ops.forEach((op) => {
|
||
if (!op || typeof op !== "object") return;
|
||
const idx = sheets.findIndex((s) => s && s.name === op.sheet);
|
||
if (idx < 0) return;
|
||
if (!bySheet.has(idx)) bySheet.set(idx, []);
|
||
bySheet.get(idx).push(op);
|
||
});
|
||
bySheet.forEach((sheetOps, idx) => applyStyleOpsLocal(idx, sheetOps));
|
||
return res;
|
||
};
|
||
|
||
const styleError = async (err, retryOps) => {
|
||
if (err && err.code === "xlsx_lossy_content") {
|
||
const features = (err.details && err.details.features) || lossy;
|
||
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
|
||
const proceed = await showConfirm({
|
||
title: t("xlsx.lossy_title"),
|
||
message: t("xlsx.lossy_confirm", { features: labels }),
|
||
confirmLabel: t("xlsx.lossy_confirm_btn"),
|
||
danger: true,
|
||
});
|
||
if (proceed) return putStyle(retryOps, true); // re-emitted with force
|
||
showToast(t("xlsx.lossy_cancelled"), "info");
|
||
return null;
|
||
}
|
||
if (err && err.code === "conflict") {
|
||
showConflict(t("xlsx.conflict_msg"));
|
||
return null;
|
||
}
|
||
showToast(`${t("xlsx.format_error")}: ${err.message || err}`, "error");
|
||
return null;
|
||
};
|
||
|
||
const runStyle = async (ops) => {
|
||
try { await putStyle(ops); } catch (err) { await styleError(err, ops); }
|
||
};
|
||
|
||
// The bold/italic toggles read the style the reader applied (A15 keeps the
|
||
// font fragments inline), so a second click removes the effect.
|
||
const activeStyleCell = () =>
|
||
(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null);
|
||
const hasBold = () => {
|
||
const td = activeStyleCell();
|
||
return Boolean(td && (td.style.fontWeight === "600" || td.style.fontWeight === "bold"));
|
||
};
|
||
const hasItalic = () => {
|
||
const td = activeStyleCell();
|
||
return Boolean(td && td.style.fontStyle === "italic");
|
||
};
|
||
const hasUnderline = () => {
|
||
const td = activeStyleCell();
|
||
return Boolean(td && (td.style.textDecoration || "").includes("underline"));
|
||
};
|
||
const hasStrike = () => {
|
||
const td = activeStyleCell();
|
||
return Boolean(td && (td.style.textDecoration || "").includes("line-through"));
|
||
};
|
||
|
||
// #179 — tracked dismissal of the format menu (outside, focus loss…).
|
||
let dismissFormatMenu = null;
|
||
const formatBtn = area.querySelector("#xlsx-format-btn");
|
||
if (formatBtn) formatBtn.addEventListener("click", () => {
|
||
if (dismissFormatMenu) { dismissFormatMenu(); dismissFormatMenu = null; return; }
|
||
const sheetName = sheets[visibleSheetIndex()]?.name || "";
|
||
const menu = document.createElement("div");
|
||
menu.className = "xlsx-structure-menu xlsx-format-menu";
|
||
menu.setAttribute("role", "menu");
|
||
// #179 — representative icon per entry.
|
||
const item = (iconName, label, fn) => {
|
||
const b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "btn-action xlsx-structure-item";
|
||
b.setAttribute("role", "menuitem");
|
||
if (iconName) {
|
||
const ic = document.createElement("i");
|
||
ic.setAttribute("data-lucide", iconName);
|
||
ic.className = "xlsx-menu-icon";
|
||
ic.setAttribute("aria-hidden", "true");
|
||
b.appendChild(ic);
|
||
}
|
||
const lab = document.createElement("span");
|
||
lab.className = "xlsx-menu-label";
|
||
lab.textContent = label;
|
||
b.appendChild(lab);
|
||
b.addEventListener("click", () => {
|
||
if (dismissFormatMenu) { dismissFormatMenu(); dismissFormatMenu = null; }
|
||
fn();
|
||
});
|
||
menu.appendChild(b);
|
||
};
|
||
const sep = () => menu.appendChild(
|
||
Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" }),
|
||
);
|
||
const cellStyle = (style) => () => runStyle([
|
||
{ op: "cell", sheet: sheetName, range: selectionRange(), style },
|
||
]);
|
||
const numFormat = (fmt) => cellStyle({ number_format: fmt });
|
||
|
||
item("bold", t("xlsx.format_bold"), cellStyle({ bold: !hasBold() }));
|
||
item("italic", t("xlsx.format_italic"), cellStyle({ italic: !hasItalic() }));
|
||
item("underline", t("xlsx.format_underline"), cellStyle({ underline: !hasUnderline() }));
|
||
item("strikethrough", t("xlsx.format_strike"), cellStyle({ strike: !hasStrike() }));
|
||
item("remove-formatting", t("xlsx.format_clear"), cellStyle({
|
||
bold: false, italic: false, underline: false,
|
||
font_color: "", fill_color: "", align: "left", number_format: "General",
|
||
}));
|
||
sep();
|
||
item("align-left", t("xlsx.format_align_left"), cellStyle({ align: "left" }));
|
||
item("align-center", t("xlsx.format_align_center"), cellStyle({ align: "center" }));
|
||
item("align-right", t("xlsx.format_align_right"), cellStyle({ align: "right" }));
|
||
sep();
|
||
// #183 — colours open the same Sheets-like panel as the ribbon (the native
|
||
// input is only driven by its « + » / crayon buttons).
|
||
const colour = (iconName, label, kind) => {
|
||
const row = document.createElement("div");
|
||
row.className = "xlsx-format-colour";
|
||
if (iconName) {
|
||
const ic = document.createElement("i");
|
||
ic.setAttribute("data-lucide", iconName);
|
||
ic.className = "xlsx-menu-icon";
|
||
ic.setAttribute("aria-hidden", "true");
|
||
row.appendChild(ic);
|
||
}
|
||
const span = document.createElement("span");
|
||
span.textContent = label;
|
||
row.appendChild(span);
|
||
const trigger = document.createElement("button");
|
||
trigger.type = "button";
|
||
trigger.className = "xlsx-format-colour-btn";
|
||
trigger.dataset.colourMenu = kind;
|
||
trigger.title = label;
|
||
trigger.setAttribute("aria-label", label);
|
||
trigger.setAttribute("aria-haspopup", "dialog");
|
||
const dot = document.createElement("span");
|
||
dot.className = "xlsx-format-colour-dot";
|
||
dot.dataset.for = kind;
|
||
dot.setAttribute("aria-hidden", "true");
|
||
trigger.appendChild(dot);
|
||
row.appendChild(trigger);
|
||
menu.appendChild(row);
|
||
return trigger;
|
||
};
|
||
colour("palette", t("xlsx.format_font_color"), "font");
|
||
colour("paint-bucket", t("xlsx.format_fill_color"), "fill");
|
||
// #183 — the two colour rows open the same Sheets-like panel as the ribbon.
|
||
const openMenuColourPanel = (btn) => {
|
||
const kind = btn.dataset.colourMenu === "fill" ? "fill" : "font";
|
||
const td = activeStyleCell();
|
||
const value = normHex(kind === "fill"
|
||
? (td ? cssToHex(td.style.backgroundColor || td.style.background) : null)
|
||
: (td ? cssToHex(td.style.color) : null)) || "";
|
||
// Never two panels at once (the ribbon one may still be open).
|
||
area.querySelectorAll(".xlsx-colour-panel[data-xlsx-colour-panel]").forEach((p) => p.remove());
|
||
const { el, sync } = createColourPicker({
|
||
kind,
|
||
value,
|
||
custom: value || "",
|
||
onPick: (colour) => {
|
||
remove();
|
||
runStyle([{
|
||
op: "cell", sheet: sheetName, range: selectionRange(),
|
||
style: kind === "fill" ? { fill_color: colour } : { font_color: colour },
|
||
}]);
|
||
},
|
||
onReset: () => {
|
||
remove();
|
||
runStyle([{
|
||
op: "cell", sheet: sheetName, range: selectionRange(),
|
||
style: kind === "fill" ? { fill_color: "" } : { font_color: "" },
|
||
}]);
|
||
},
|
||
onCustom: () => {
|
||
// Arbitrary colours keep using the native picker: the panel offers
|
||
// the 80 curated swatches + the 8 standard ones.
|
||
const native = document.createElement("input");
|
||
native.type = "color";
|
||
native.value = value || "#000000";
|
||
native.setAttribute("data-xlsx-native", kind);
|
||
native.style.cssText = "position:absolute;opacity:0;pointer-events:none";
|
||
native.addEventListener("change", () => {
|
||
const picked = native.value;
|
||
const dot = menu.querySelector(`.xlsx-format-colour-dot[data-for="${kind}"]`);
|
||
if (dot) dot.style.background = picked;
|
||
native.remove();
|
||
remove();
|
||
runStyle([{
|
||
op: "cell", sheet: sheetName, range: selectionRange(),
|
||
style: kind === "fill" ? { fill_color: picked } : { font_color: picked },
|
||
}]);
|
||
});
|
||
menu.appendChild(native);
|
||
if (typeof native.click === "function") native.click();
|
||
},
|
||
onUnavailable: (label) => showToast(t("xlsx.colour_unavailable", { feature: label }), "info"),
|
||
});
|
||
const remove = () => el.remove();
|
||
menu.appendChild(el);
|
||
const dot = menu.querySelector(`.xlsx-format-colour-dot[data-for="${kind}"]`);
|
||
if (dot && value) dot.style.background = value;
|
||
safeCreateIcons();
|
||
sync(value);
|
||
};
|
||
menu.querySelectorAll("[data-colour-menu]").forEach((trigger) => {
|
||
trigger.addEventListener("click", (e) => { e.preventDefault(); openMenuColourPanel(trigger); });
|
||
});
|
||
sep();
|
||
item("type", t("xlsx.format_num_general"), numFormat("General"));
|
||
item("hash", t("xlsx.format_num_number"), numFormat("0.00"));
|
||
item("percent", t("xlsx.format_num_percent"), numFormat("0.0%"));
|
||
item("euro", t("xlsx.format_num_currency"), numFormat('#,##0.00 "€"'));
|
||
item("calendar", t("xlsx.format_num_date"), numFormat("DD/MM/YYYY"));
|
||
item("pilcrow", t("xlsx.format_num_text"), numFormat("@"));
|
||
sep();
|
||
item("combine", t("xlsx.format_merge"), () => {
|
||
const b = selectionBounds();
|
||
if (!b || !selection || selection.panel !== visiblePanel() || (b.r1 === b.r2 && b.c1 === b.c2)) {
|
||
showToast(t("xlsx.format_merge_needs_range"), "info");
|
||
return;
|
||
}
|
||
runStyle([{ op: "merge", sheet: sheetName, range: rangeLabel() }]);
|
||
});
|
||
item("ungroup", t("xlsx.format_unmerge"), () => {
|
||
runStyle([{ op: "unmerge", sheet: sheetName, range: selectionRange() }]);
|
||
});
|
||
sep();
|
||
item("snowflake", t("xlsx.format_freeze"), () => {
|
||
runStyle([{ op: "freeze", sheet: sheetName, cell: selectionRange().split(":")[0] }]);
|
||
});
|
||
item("sun", t("xlsx.format_unfreeze"), () => runStyle([{ op: "freeze", sheet: sheetName, cell: "" }]));
|
||
sep();
|
||
item("move-horizontal", t("xlsx.format_col_width"), () => promptColumnWidth());
|
||
item("maximize-2", t("xlsx.format_col_fit"), () => autoFitSelection("col"));
|
||
item("move-vertical", t("xlsx.format_row_height"), () => promptRowHeight());
|
||
item("maximize-2", t("xlsx.format_row_fit"), () => autoFitSelection("row"));
|
||
enableArrowNav(menu, ".xlsx-structure-item");
|
||
const formatToolbar = formatBtn.closest(".xlsx-toolbar");
|
||
formatToolbar.appendChild(menu);
|
||
anchorUnder(menu, formatToolbar, formatBtn);
|
||
safeCreateIcons();
|
||
formatBtn.setAttribute("aria-expanded", "true");
|
||
// #179 — close on outside click, focus loss, Escape, scroll, resize.
|
||
dismissFormatMenu = trackDismissable(menu, {
|
||
toggle: formatBtn,
|
||
onClose: () => {
|
||
dismissFormatMenu = null;
|
||
formatBtn.setAttribute("aria-expanded", "false");
|
||
menu.remove();
|
||
},
|
||
});
|
||
});
|
||
|
||
// ══ Sheets-like ribbon wiring ──────────────────────────────────────────
|
||
// Every control below reuses the locked PUT …/xlsx/style rewrite (runStyle)
|
||
// or existing viewer flows — no new backend route.
|
||
const styleSheetName = () => sheets[visibleSheetIndex()]?.name || "";
|
||
const runCellStyle = (style) => runStyle([
|
||
{ op: "cell", sheet: styleSheetName(), range: selectionRange(), style },
|
||
]);
|
||
const sheetFormatsOf = () => {
|
||
const meta = sheets[visibleSheetIndex()] || {};
|
||
return meta.formats || {};
|
||
};
|
||
/** Tracked dropdown menu for ribbon buttons (borders, aligns, rotation, ⋮). */
|
||
const trackedRibbonMenus = new Map();
|
||
const openRibbonMenu = (btn, items) => {
|
||
const prev = trackedRibbonMenus.get(btn);
|
||
if (prev) { prev(); trackedRibbonMenus.delete(btn); return; }
|
||
const menu = document.createElement("div");
|
||
menu.className = "xlsx-structure-menu xlsx-ribbon-menu";
|
||
menu.setAttribute("role", "menu");
|
||
items.forEach((entry) => {
|
||
if (entry.sep) {
|
||
menu.appendChild(Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" }));
|
||
return;
|
||
}
|
||
const b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = `btn-action xlsx-structure-item${entry.danger ? " xlsx-context-danger" : ""}`;
|
||
b.setAttribute("role", "menuitem");
|
||
if (entry.icon) {
|
||
const ic = document.createElement("i");
|
||
ic.setAttribute("data-lucide", entry.icon);
|
||
ic.className = "xlsx-menu-icon";
|
||
ic.setAttribute("aria-hidden", "true");
|
||
b.appendChild(ic);
|
||
}
|
||
const lab = document.createElement("span");
|
||
lab.className = "xlsx-menu-label";
|
||
lab.textContent = entry.label;
|
||
b.appendChild(lab);
|
||
b.addEventListener("click", () => {
|
||
const d = trackedRibbonMenus.get(btn);
|
||
if (d) { d(); trackedRibbonMenus.delete(btn); }
|
||
entry.onPick();
|
||
});
|
||
menu.appendChild(b);
|
||
});
|
||
enableArrowNav(menu, ".xlsx-structure-item");
|
||
const toolbar = btn.closest(".xlsx-toolbar");
|
||
toolbar.appendChild(menu);
|
||
anchorUnder(menu, toolbar, btn);
|
||
safeCreateIcons();
|
||
btn.setAttribute("aria-expanded", "true");
|
||
trackedRibbonMenus.set(btn, trackDismissable(menu, {
|
||
toggle: btn,
|
||
onClose: () => {
|
||
trackedRibbonMenus.delete(btn);
|
||
btn.setAttribute("aria-expanded", "false");
|
||
menu.remove();
|
||
},
|
||
}));
|
||
};
|
||
|
||
const searchBtn = area.querySelector("#xs-search");
|
||
if (searchBtn) searchBtn.addEventListener("click", () => {
|
||
const fi = area.querySelector("#xlsx-find-input");
|
||
if (!fi) return;
|
||
fi.scrollIntoView({ block: "nearest" });
|
||
fi.focus();
|
||
});
|
||
const printBtn = area.querySelector("#xs-print");
|
||
if (printBtn) printBtn.addEventListener("click", () => printSheet());
|
||
|
||
// Paint format: capture the active cell's style, apply on the next click.
|
||
// #184 — « Peindre le format » copies the WHOLE format of the source cell, not
|
||
// a subset of it, so the target ends up identical. This includes the case the
|
||
// tool used to refuse: a **plain** source onto a coloured/sized target now
|
||
// RESETS the target (empty values clear the declaration) instead of doing
|
||
// nothing at all.
|
||
const BORDER_STYLES = new Set(["dashed", "dotted", "double", "solid"]);
|
||
/** `rgb(…)` / `#rrggbb` → `#rrggbb` (null quand transparent/non-couleur). */
|
||
const cssToHex = (css) => {
|
||
const s = String(css || "").trim();
|
||
if (/^#[0-9a-fA-F]{6}$/.test(s)) return `#${s.slice(1).toLowerCase()}`;
|
||
const m = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/.exec(s);
|
||
if (!m) return null;
|
||
if (m[4] !== undefined && Number(m[4]) === 0) return null;
|
||
const hex = [m[1], m[2], m[3]].map((v) => Number(v).toString(16).padStart(2, "0")).join("");
|
||
return `#${hex}`;
|
||
};
|
||
/** Full format of a cell, as a `cell` style op payload. */
|
||
const captureFormat = (td) => {
|
||
const st = td.style;
|
||
const fontSize = st.fontSize ? Number.parseFloat(st.fontSize) : NaN;
|
||
const deco = st.textDecoration || "";
|
||
const borderStyle = [st.borderLeftStyle, st.borderTopStyle]
|
||
.find((v) => v && v !== "none") || "";
|
||
const style = {
|
||
bold: st.fontWeight === "600" || st.fontWeight === "bold" || st.fontWeight === "700",
|
||
italic: st.fontStyle === "italic",
|
||
underline: deco.includes("underline"),
|
||
strike: deco.includes("line-through"),
|
||
font_color: cssToHex(st.color) || "",
|
||
fill_color: cssToHex(st.backgroundColor || st.background) || "",
|
||
font_size: Number.isFinite(fontSize)
|
||
? Math.min(72, Math.max(6, Math.round(fontSize)))
|
||
: 11,
|
||
align: ["left", "center", "right", "justify"].includes(st.textAlign)
|
||
? st.textAlign
|
||
: "left",
|
||
wrap: st.whiteSpace === "normal",
|
||
// Vertical : 90/180. Diagonal : `rotate(Xdeg)` CSS (horaire) relu en
|
||
// angle Excel (anti-horaire, borné à ±90 comme le backend).
|
||
rotation: (() => {
|
||
if (st.writingMode === "vertical-rl") {
|
||
return (st.transform || "").includes("-1") ? 180 : 90;
|
||
}
|
||
const m = /rotate\(\s*(-?\d+(?:\.\d+)?)\s*deg\s*\)/.exec(st.transform || "");
|
||
return m ? Math.max(-90, Math.min(90, Math.round(-Number(m[1])))) : 0;
|
||
})(),
|
||
number_format: sheetFormatsOf()[cellName(td)] || "General",
|
||
};
|
||
// `valign` n'est envoyé que si la source en porte un valide (top/middle/
|
||
// bottom) : `""` faisait rejeter tout le PUT en 400 côté backend.
|
||
if (["top", "middle", "bottom"].includes(st.verticalAlign)) {
|
||
style.valign = st.verticalAlign;
|
||
}
|
||
if (borderStyle) {
|
||
style.border = "all";
|
||
// openpyxl ne connaît pas `solid` (CSS) : son équivalent est `thin`.
|
||
style.border_style = borderStyle === "solid" ? "thin"
|
||
: BORDER_STYLES.has(borderStyle) ? borderStyle : "thin";
|
||
style.border_color = cssToHex(st.borderLeftColor) || cssToHex(st.borderColor) || "#000000";
|
||
} else {
|
||
style.border = "none";
|
||
}
|
||
return style;
|
||
};
|
||
let paintStyle = null;
|
||
let paintOnceHandler = null;
|
||
const paintToggle = area.querySelector("#xs-paint");
|
||
const cancelPaintKeys = (e) => { if (e.key === "Escape" && paintStyle) stopPaint(); };
|
||
const stopPaint = () => {
|
||
paintStyle = null;
|
||
// #184 — cancelling (Escape, second click on the button) MUST also drop the
|
||
// pending "apply on next click" listener: it used to survive and repaint a
|
||
// stale style on the next grid click.
|
||
if (paintOnceHandler) {
|
||
area.removeEventListener("click", paintOnceHandler);
|
||
paintOnceHandler = null;
|
||
}
|
||
document.removeEventListener("keydown", cancelPaintKeys);
|
||
if (paintToggle) {
|
||
paintToggle.classList.remove("active");
|
||
paintToggle.setAttribute("aria-pressed", "false");
|
||
}
|
||
};
|
||
if (paintToggle) paintToggle.addEventListener("click", () => {
|
||
if (paintStyle) { stopPaint(); return; }
|
||
const td = activeStyleCell();
|
||
if (!td) {
|
||
showToast(t("xlsx.paint_pick_source"), "info");
|
||
return;
|
||
}
|
||
// A source with no explicit format is legitimate: it means "reset".
|
||
paintStyle = captureFormat(td);
|
||
paintToggle.classList.add("active");
|
||
paintToggle.setAttribute("aria-pressed", "true");
|
||
document.addEventListener("keydown", cancelPaintKeys);
|
||
showToast(t("xlsx.paint_pick_target"), "info");
|
||
paintOnceHandler = (e) => {
|
||
if (e.target && e.target.closest && e.target.closest("#xs-paint")) return;
|
||
// Toolbar/menu/modal/colour-panel clicks must neither paint nor disarm.
|
||
if (e.target && e.target.closest && e.target.closest(
|
||
".xlsx-toolbar,.xlsx-minibar,.xlsx-structure-menu,.xlsx-context-menu,"
|
||
+ ".xlsx-export-menu,.xlsx-colour-panel,.obsigate-modal-overlay"
|
||
)) return;
|
||
const captured = paintStyle;
|
||
stopPaint();
|
||
if (captured) runCellStyle(captured);
|
||
};
|
||
// Area bubble runs after the grid's own click handlers: the selection is
|
||
// already updated when this fires.
|
||
area.addEventListener("click", paintOnceHandler);
|
||
});
|
||
|
||
// Zoom: pure client-side scaling, persisted per browser.
|
||
const ZOOM_KEY = "obsigate_xlsx_zoom";
|
||
const applyZoom = (z) => {
|
||
const host = area.querySelector(".xlsx-panels");
|
||
if (host) host.style.zoom = z === 100 ? "" : `${z}%`;
|
||
const sel = area.querySelector("#xs-zoom");
|
||
if (sel) sel.value = String(z);
|
||
try { localStorage.setItem(ZOOM_KEY, String(z)); } catch (_) { /* private mode */ }
|
||
};
|
||
const zoomSel = area.querySelector("#xs-zoom");
|
||
if (zoomSel) {
|
||
let z = 100;
|
||
try { z = Number(localStorage.getItem(ZOOM_KEY)) || 100; } catch (_) { /* ignore */ }
|
||
if (![50, 75, 90, 100, 125, 150, 200].includes(z)) z = 100;
|
||
applyZoom(z);
|
||
zoomSel.addEventListener("change", () => applyZoom(Number(zoomSel.value) || 100));
|
||
}
|
||
|
||
// Quick number formats.
|
||
const quickFmt = (id, fmt) => {
|
||
const b = area.querySelector(id);
|
||
if (b) b.addEventListener("click", () => runCellStyle({ number_format: fmt }));
|
||
};
|
||
quickFmt("#xs-cur", '#,##0.00 "€"');
|
||
quickFmt("#xs-pct", "0.0%");
|
||
quickFmt("#xs-num123", "0.00");
|
||
const numSel = area.querySelector("#xs-numfmt");
|
||
if (numSel) numSel.addEventListener("change", () => {
|
||
if (!numSel.value) return;
|
||
runCellStyle({ number_format: numSel.value });
|
||
numSel.value = "";
|
||
});
|
||
const shiftDecimals = (delta) => {
|
||
const td = activeStyleCell();
|
||
if (!td) return;
|
||
let fmt = sheetFormatsOf()[cellName(td)] || "General";
|
||
if (fmt === "@") return;
|
||
if (fmt === "General") {
|
||
if (delta < 0) return;
|
||
runCellStyle({ number_format: "0.0" });
|
||
return;
|
||
}
|
||
const secs = fmt.split(";");
|
||
let head = secs[0];
|
||
if (/[yYmMdDhHsS]/.test(head) && !/\.\d/.test(head)) return; // dates: leave alone
|
||
const m = head.match(/\.0+/);
|
||
if (m) {
|
||
let zeros = m[0].slice(1);
|
||
zeros = delta > 0 ? `${zeros}0` : zeros.slice(0, -1);
|
||
head = zeros ? head.replace(/\.0+/, `.${zeros}`) : head.replace(/\.0+/, "");
|
||
} else if (delta > 0) {
|
||
head = head.replace(/(%?"[^"]*"?%?)$/, ".0$1");
|
||
if (!head.includes(".0")) head += ".0";
|
||
} else {
|
||
return;
|
||
}
|
||
secs[0] = head;
|
||
runCellStyle({ number_format: secs.join(";") });
|
||
};
|
||
const decMinus = area.querySelector("#xs-decminus");
|
||
if (decMinus) decMinus.addEventListener("click", () => shiftDecimals(-1));
|
||
const decPlus = area.querySelector("#xs-decplus");
|
||
if (decPlus) decPlus.addEventListener("click", () => shiftDecimals(1));
|
||
|
||
// Font size + B/I/S/U toggles (state reflected by updateSheetsToolbarStates).
|
||
const currentFontSize = () => {
|
||
const td = activeStyleCell();
|
||
if (!td) return 11;
|
||
const px = td.style.fontSize ? Number.parseFloat(td.style.fontSize) : NaN;
|
||
return Number.isFinite(px) ? Math.round(px) : 11;
|
||
};
|
||
const fsMinus = area.querySelector("#xs-fsminus");
|
||
if (fsMinus) fsMinus.addEventListener("click", () => {
|
||
runCellStyle({ font_size: Math.max(6, currentFontSize() - 1) });
|
||
});
|
||
const fsPlus = area.querySelector("#xs-fsplus");
|
||
if (fsPlus) fsPlus.addEventListener("click", () => {
|
||
runCellStyle({ font_size: Math.min(72, currentFontSize() + 1) });
|
||
});
|
||
const fsSize = area.querySelector("#xs-fssize");
|
||
if (fsSize) fsSize.addEventListener("change", () => {
|
||
const size = Number(fsSize.value);
|
||
if (Number.isFinite(size)) runCellStyle({ font_size: Math.min(72, Math.max(6, Math.round(size))) });
|
||
});
|
||
const styleToggle = (id, make) => {
|
||
const b = area.querySelector(id);
|
||
if (b) b.addEventListener("click", () => runCellStyle(make()));
|
||
};
|
||
styleToggle("#xs-b", () => ({ bold: !(activeStyleCell() && ["600", "bold", "700"].includes(activeStyleCell().style.fontWeight)) }));
|
||
styleToggle("#xs-i", () => ({ italic: !(activeStyleCell() && activeStyleCell().style.fontStyle === "italic") }));
|
||
styleToggle("#xs-s", () => ({ strike: !hasStrike() }));
|
||
styleToggle("#xs-u", () => ({ underline: !hasUnderline() }));
|
||
// #183 — the Sheets-like colour panel replaces the native colour input on the
|
||
// ribbon. Opening/positioning/dismissal reuse the viewer machinery
|
||
// (anchorUnder + trackDismissable); picking a colour applies it and closes.
|
||
let dismissColourPanel = null;
|
||
const closeColourPanel = () => {
|
||
if (dismissColourPanel) { dismissColourPanel(); dismissColourPanel = null; }
|
||
area.querySelectorAll("[data-xlsx-colour][aria-expanded='true']")
|
||
.forEach((b) => b.setAttribute("aria-expanded", "false"));
|
||
};
|
||
/** Open the panel for a ribbon colour button (`data-xlsx-colour="font|fill"`). */
|
||
const openColourPanel = (btn) => {
|
||
if (dismissColourPanel) { closeColourPanel(); return; }
|
||
// Ribbon buttons carry `data-xlsx-colour`; the Mise en forme rows carry
|
||
// `data-colour-menu`. Both open the same panel.
|
||
const kind = (btn.dataset.xlsxColour || btn.dataset.colourMenu) === "fill" ? "fill" : "font";
|
||
const native = area.querySelector(
|
||
kind === "fill" ? '[data-xlsx-native-for="xs-bgcolor"]' : '[data-xlsx-native-for="xs-fgcolor"]'
|
||
);
|
||
const swatchId = kind === "fill" ? "xs-bgcolor" : "xs-fgcolor";
|
||
const td = activeStyleCell();
|
||
// Reflect the colour already on the cell so the check mark starts in the
|
||
// right place (a cell with no explicit colour shows none selected).
|
||
const current = normHex(kind === "fill"
|
||
? (td ? cssToHex(td.style.backgroundColor || td.style.background) : null)
|
||
: (td ? cssToHex(td.style.color) : null)) || "";
|
||
const { el: panel, sync } = createColourPicker({
|
||
kind,
|
||
value: current,
|
||
custom: current || (native ? normHex(native.value) : "") || "",
|
||
onPick: (colour) => {
|
||
closeColourPanel();
|
||
// Mirror the colour on the button so the user sees the choice stick.
|
||
const sw = area.querySelector(`[data-swatch-for="${swatchId}"]`);
|
||
if (sw) sw.style.background = colour;
|
||
runCellStyle(kind === "fill" ? { fill_color: colour } : { font_color: colour });
|
||
},
|
||
onReset: () => {
|
||
closeColourPanel();
|
||
const sw = area.querySelector(`[data-swatch-for="${swatchId}"]`);
|
||
if (sw) sw.style.background = "";
|
||
runCellStyle(kind === "fill" ? { fill_color: "" } : { font_color: "" });
|
||
},
|
||
onCustom: () => {
|
||
// The native picker stays the only way to reach an arbitrary colour
|
||
// (the panel offers 80 + 8 curated ones).
|
||
if (native && typeof native.click === "function") native.click();
|
||
},
|
||
onUnavailable: (label) => showToast(t("xlsx.colour_unavailable", { feature: label }), "info"),
|
||
});
|
||
panel.dataset.xlsxColourPanel = kind;
|
||
const toolbar = btn.closest(".xlsx-toolbar") || area;
|
||
toolbar.appendChild(panel);
|
||
anchorUnder(panel, toolbar, btn);
|
||
safeCreateIcons();
|
||
btn.setAttribute("aria-expanded", "true");
|
||
dismissColourPanel = trackDismissable(panel, {
|
||
toggle: btn,
|
||
onClose: () => {
|
||
dismissColourPanel = null;
|
||
panel.remove();
|
||
btn.setAttribute("aria-expanded", "false");
|
||
},
|
||
});
|
||
sync(current);
|
||
};
|
||
area.querySelectorAll("[data-xlsx-colour]").forEach((btn) => {
|
||
btn.addEventListener("click", (e) => { e.preventDefault(); openColourPanel(btn); });
|
||
});
|
||
// Keep the ribbon swatch in step when the native picker (opened from the
|
||
// panel's « + ») yields an arbitrary colour.
|
||
area.querySelectorAll("[data-xlsx-native-for]").forEach((input) => {
|
||
input.addEventListener("change", (e) => {
|
||
const swatchId = input.dataset.xlsxNativeFor;
|
||
const sw = area.querySelector(`[data-swatch-for="${swatchId}"]`);
|
||
if (sw) sw.style.background = e.target.value;
|
||
const kind = swatchId === "xs-bgcolor" ? "fill" : "font";
|
||
runCellStyle(kind === "fill" ? { fill_color: e.target.value } : { font_color: e.target.value });
|
||
});
|
||
});
|
||
|
||
// Borders / merge / alignments / wrap / rotation.
|
||
const bordersBtn = area.querySelector("#xs-borders");
|
||
if (bordersBtn) bordersBtn.addEventListener("click", () => openRibbonMenu(bordersBtn, [
|
||
{ icon: "grid", label: t("xlsx.border_all"), onPick: () => runCellStyle({ border: "all" }) },
|
||
{ icon: "box-select", label: t("xlsx.border_outer"), onPick: () => runCellStyle({ border: "outer" }) },
|
||
{ icon: "ban", label: t("xlsx.border_none"), onPick: () => runCellStyle({ border: "none" }) },
|
||
]));
|
||
const mergeBtn = area.querySelector("#xs-merge");
|
||
if (mergeBtn) mergeBtn.addEventListener("click", () => {
|
||
const b = selectionBounds();
|
||
const panel = visiblePanel();
|
||
if (!b || !selection || selection.panel !== panel || (b.r1 === b.r2 && b.c1 === b.c2)) {
|
||
showToast(t("xlsx.format_merge_needs_range"), "info");
|
||
return;
|
||
}
|
||
const label = rangeLabel();
|
||
const merges = sheets[visibleSheetIndex()]?.merges || [];
|
||
const op = merges.includes(label) ? "unmerge" : "merge";
|
||
runStyle([{ op, sheet: styleSheetName(), range: label }]);
|
||
});
|
||
const alignhBtn = area.querySelector("#xs-alignh");
|
||
if (alignhBtn) alignhBtn.addEventListener("click", () => openRibbonMenu(alignhBtn, [
|
||
{ icon: "align-left", label: t("xlsx.format_align_left"), onPick: () => runCellStyle({ align: "left" }) },
|
||
{ icon: "align-center", label: t("xlsx.format_align_center"), onPick: () => runCellStyle({ align: "center" }) },
|
||
{ icon: "align-right", label: t("xlsx.format_align_right"), onPick: () => runCellStyle({ align: "right" }) },
|
||
{ icon: "align-justify", label: t("xlsx.format_align_justify"), onPick: () => runCellStyle({ align: "justify" }) },
|
||
]));
|
||
const alignvBtn = area.querySelector("#xs-alignv");
|
||
if (alignvBtn) alignvBtn.addEventListener("click", () => openRibbonMenu(alignvBtn, [
|
||
{ icon: "align-start-vertical", label: t("xlsx.valign_top"), onPick: () => runCellStyle({ valign: "top" }) },
|
||
{ icon: "align-center-vertical", label: t("xlsx.valign_middle"), onPick: () => runCellStyle({ valign: "middle" }) },
|
||
{ icon: "align-end-vertical", label: t("xlsx.valign_bottom"), onPick: () => runCellStyle({ valign: "bottom" }) },
|
||
]));
|
||
const wrapBtn = area.querySelector("#xs-wrap");
|
||
if (wrapBtn) wrapBtn.addEventListener("click", () => {
|
||
const td = activeStyleCell();
|
||
const on = !(td && td.style.whiteSpace === "normal");
|
||
runCellStyle({ wrap: on });
|
||
});
|
||
const rotBtn = area.querySelector("#xs-rot");
|
||
if (rotBtn) rotBtn.addEventListener("click", () => openRibbonMenu(rotBtn, [
|
||
{ icon: "minus", label: t("xlsx.rotation_0"), onPick: () => runCellStyle({ rotation: 0 }) },
|
||
{ icon: "trending-up", label: t("xlsx.rotation_45"), onPick: () => runCellStyle({ rotation: 45 }) },
|
||
{ icon: "trending-down", label: t("xlsx.rotation_315"), onPick: () => runCellStyle({ rotation: -45 }) },
|
||
{ icon: "arrow-up", label: t("xlsx.rotation_90"), onPick: () => runCellStyle({ rotation: 90 }) },
|
||
]));
|
||
|
||
// Shared flows: insert link, edit comment, insert SUM.
|
||
const listSeparator = () => (formulaLocale() === "fr" ? ";" : ",");
|
||
const insertLinkFlow = async () => {
|
||
const panel = visiblePanel();
|
||
const td = activeTd && activeTd.closest(".xlsx-panel") === panel ? activeTd : null;
|
||
if (!td) return;
|
||
const url = await showPrompt({ title: t("xlsx.link_add"), message: t("xlsx.link_url"), value: "https://" });
|
||
if (!url) return;
|
||
if (!/^https?:\/\//i.test(url.trim())) {
|
||
showToast(t("xlsx.link_invalid"), "error");
|
||
return;
|
||
}
|
||
const label = await showPrompt({ title: t("xlsx.link_add"), message: t("xlsx.link_label"), value: url.trim() });
|
||
if (label === null) return;
|
||
const clean = (s) => String(s).replace(/"/g, '""');
|
||
const sep = listSeparator();
|
||
const formula = label
|
||
? `=LIEN("${clean(url.trim())}"${sep}"${clean(label)}")`
|
||
: `=LIEN("${clean(url.trim())}")`;
|
||
setCellValue(td, formula);
|
||
refreshPanelFormulas(panel);
|
||
};
|
||
const editCommentFlow = async () => {
|
||
const panel = visiblePanel();
|
||
const td = activeTd && activeTd.closest(".xlsx-panel") === panel ? activeTd : null;
|
||
if (!td) return;
|
||
const sheet = styleSheetName();
|
||
const ref = cellName(td);
|
||
const meta = sheets[visibleSheetIndex()] || {};
|
||
const existing = (meta.comments || {})[ref] || "";
|
||
const text = await showPrompt({ title: t("xlsx.comment_add"), message: t("xlsx.comment_prompt"), value: existing });
|
||
if (text === null) return;
|
||
if (!text.trim()) runStyle([{ op: "comment_clear", sheet, ref }]);
|
||
else runStyle([{ op: "comment_set", sheet, ref, text: text.trim() }]);
|
||
};
|
||
const insertSumFlow = () => {
|
||
const panel = visiblePanel();
|
||
const td = activeTd && activeTd.closest(".xlsx-panel") === panel ? activeTd : null;
|
||
if (!td) return;
|
||
const here = parseRef(cellName(td));
|
||
if (!here) return;
|
||
const sep = listSeparator();
|
||
const numAt = (r, c) => {
|
||
const cell = findTd(panel, r, c);
|
||
if (!cell) return null;
|
||
const v = Number.parseFloat(String(cellTextOf(cell)).replace(",", "."));
|
||
return Number.isFinite(v) ? v : null;
|
||
};
|
||
// Prefer the numbers stacked directly above the active cell…
|
||
let top = null;
|
||
for (let r = here.row - 1; r >= Math.max(1, here.row - 100); r--) {
|
||
if (numAt(r, here.col) === null) break;
|
||
top = r;
|
||
}
|
||
const fn = formulaLocale() === "fr" ? "SOMME" : "SUM";
|
||
let formula = null;
|
||
if (top !== null) {
|
||
formula = `=${fn}(${columnName(here.col)}${top}:${columnName(here.col)}${here.row - 1})`;
|
||
} else {
|
||
const b = selectionBounds();
|
||
if (b && selection && selection.panel === panel && (b.r2 > b.r1 || b.c2 > b.c1)) {
|
||
formula = `=${fn}(${columnName(b.c1)}${b.r1}:${columnName(b.c2)}${b.r2})`;
|
||
}
|
||
}
|
||
if (!formula) {
|
||
showToast(t("xlsx.sum_empty"), "info");
|
||
return;
|
||
}
|
||
setCellValue(td, formula);
|
||
refreshPanelFormulas(panel);
|
||
};
|
||
|
||
// Overflow menu: singletons that deserve no permanent button.
|
||
const moreBtn = area.querySelector("#xs-more");
|
||
if (moreBtn) moreBtn.addEventListener("click", () => openRibbonMenu(moreBtn, [
|
||
{ icon: "snowflake", label: t("xlsx.format_freeze"), onPick: () => freezeAtSelection() },
|
||
{ icon: "sun", label: t("xlsx.format_unfreeze"), onPick: () => runStyle([{ op: "freeze", sheet: styleSheetName(), cell: "" }]) },
|
||
{ sep: true },
|
||
{ icon: "arrow-up", label: t("xlsx.sort_asc"), onPick: () => sortFromToolbar("asc") },
|
||
{ icon: "arrow-down", label: t("xlsx.sort_desc"), onPick: () => sortFromToolbar("desc") },
|
||
{ icon: "filter", label: t("xlsx.find_placeholder"), onPick: () => area.querySelector("#xlsx-find-input")?.focus() },
|
||
{ sep: true },
|
||
{ icon: "move-horizontal", label: t("xlsx.format_col_width"), onPick: () => promptColumnWidth() },
|
||
{ icon: "maximize-2", label: t("xlsx.format_col_fit"), onPick: () => autoFitSelection("col") },
|
||
{ icon: "move-vertical", label: t("xlsx.format_row_height"), onPick: () => promptRowHeight() },
|
||
{ icon: "maximize-2", label: t("xlsx.format_row_fit"), onPick: () => autoFitSelection("row") },
|
||
{ sep: true },
|
||
{ icon: "link", label: t("xlsx.link_add"), onPick: () => insertLinkFlow() },
|
||
{ icon: "message-square", label: t("xlsx.comment_add"), onPick: () => editCommentFlow() },
|
||
]));
|
||
const freezeAtSelection = () => {
|
||
const ref = selectionRange().split(":")[0];
|
||
runStyle([{ op: "freeze", sheet: styleSheetName(), cell: ref }]);
|
||
};
|
||
const sortFromToolbar = (dir) => {
|
||
const panel = visiblePanel();
|
||
const b = selection && selection.panel === panel ? selectionBounds() : null;
|
||
const ref = activeTd && activeTd.closest(".xlsx-panel") === panel
|
||
? parseRef(cellName(activeTd)) : null;
|
||
const col = b ? b.c1 : ref ? ref.col : 1;
|
||
applySort(panel, col, dir);
|
||
};
|
||
// ══ Tailles de colonnes / de lignes — manuel et automatique ═══════════
|
||
// Standards visés : Excel et Google Sheets.
|
||
// · unités : largeur en CARACTÈRES (`Column Width`, openpyxl
|
||
// `column_dimensions.width`), hauteur en POINTS (`Row Height`,
|
||
// openpyxl `row_dimensions.height`) ;
|
||
// · `0` = taille par défaut (le backend efface la mesure explicite —
|
||
// l'équivalent de « Default Width » / du bouton Reset de Sheets) ;
|
||
// · double-clic sur un bord d'en-tête = ajustement automatique au contenu
|
||
// (Excel AutoFit / Sheets « Fit to data »).
|
||
// Les facteurs de conversion sont exactement ceux d'applySheetMeta()
|
||
// (×7 px par caractère, ×96/72 px par point) : mesure → op → rendu ne
|
||
// perd jamais de pixels.
|
||
const metaOf = (panel) => sheets[Number(panel.dataset.sheet)] || {};
|
||
const pxToChars = (px) => Math.round((px / 7) * 10) / 10;
|
||
const pxToPts = (px) => Math.round((px * 72 / 96) * 10) / 10;
|
||
|
||
/** Lignes du panneau indexées par numéro (le DOM peut sauter des rangs). */
|
||
const rowTrIndex = (panel) => {
|
||
const map = new Map();
|
||
if (!panel) return map;
|
||
panel.querySelectorAll("tbody tr").forEach((tr) => {
|
||
const n = Number(tr.querySelector("th.xlsx-rownum")?.textContent);
|
||
if (n) map.set(n, tr);
|
||
});
|
||
return map;
|
||
};
|
||
|
||
/** Largeur courante d'une colonne, en caractères (métadonnée ou rendu). */
|
||
const currentColWidthChars = (panel, colIdx) => {
|
||
const stored = Number(metaOf(panel).colwidths?.[columnName(colIdx)]);
|
||
if (Number.isFinite(stored) && stored > 0) return stored;
|
||
const th = panel ? panel.querySelectorAll("thead th")[colIdx] : null;
|
||
const px = th ? th.getBoundingClientRect().width : 0;
|
||
return px ? pxToChars(px) : 8.43; // largeur par défaut d'Excel
|
||
};
|
||
|
||
/** Hauteur courante d'une ligne, en points (métadonnée ou rendu). */
|
||
const currentRowHeightPts = (panel, rowNum) => {
|
||
const stored = Number(metaOf(panel).rowheights?.[String(rowNum)]);
|
||
if (Number.isFinite(stored) && stored > 0) return stored;
|
||
const tr = rowTrIndex(panel).get(rowNum);
|
||
const px = tr ? tr.getBoundingClientRect().height : 0;
|
||
return px ? pxToPts(px) : 15; // hauteur par défaut d'Excel (15 pt)
|
||
};
|
||
|
||
let _fitCtx = null;
|
||
const textWidthPx = (text, cs) => {
|
||
if (!_fitCtx) _fitCtx = document.createElement("canvas").getContext("2d");
|
||
_fitCtx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
|
||
let w = 0;
|
||
for (const line of String(text).split("\n")) w = Math.max(w, _fitCtx.measureText(line).width);
|
||
return w;
|
||
};
|
||
|
||
/** Excel AutoFit / Sheets « Fit to data » : la colonne prend la largeur de
|
||
* sa ligne de contenu la plus large (padding et bordure compris). */
|
||
const autoFitColumns = (panel, cols) => {
|
||
if (!panel || !cols.length) return;
|
||
const wanted = new Set(cols);
|
||
const fits = new Map();
|
||
panel.querySelectorAll("tbody tr").forEach((tr) => {
|
||
if (tr.style.display === "none") return; // ligne masquée par un filtre
|
||
tr.querySelectorAll("td[data-cell]").forEach((td) => {
|
||
if (td.style.display === "none") return; // cellule couverte par une fusion
|
||
const ref = parseRef(td.dataset.cell);
|
||
if (!ref || !wanted.has(ref.col)) return;
|
||
const text = td.querySelector(".xlsx-cached") ? cellTextOf(td) : td.textContent;
|
||
if (!text) return;
|
||
const cs = getComputedStyle(td);
|
||
const px = Math.ceil(textWidthPx(text, cs))
|
||
+ (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0)
|
||
+ (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.borderRightWidth) || 0);
|
||
if (px > (fits.get(ref.col) || 0)) fits.set(ref.col, px);
|
||
});
|
||
});
|
||
const ops = [];
|
||
cols.forEach((c) => {
|
||
const px = fits.get(c);
|
||
if (!px) return; // colonne vide : on n'impose rien
|
||
const width = Math.min(255, Math.max(3, pxToChars(px)));
|
||
const stored = Number(metaOf(panel).colwidths?.[columnName(c)]);
|
||
if (Number.isFinite(stored) && Math.abs(stored - width) < 0.1) return;
|
||
ops.push({ op: "col_width", sheet: styleSheetName(), col: columnName(c), width });
|
||
});
|
||
if (!ops.length) { showToast(t("xlsx.fit_nothing"), "info"); return; }
|
||
runStyle(ops);
|
||
};
|
||
|
||
/** Excel AutoFit Row Height / Sheets « Fit to data » : la ligne prend la
|
||
* hauteur naturelle de son contenu (retours à la ligne et renvoi compris). */
|
||
const autoFitRows = (panel, rows) => {
|
||
if (!panel || !rows.length) return;
|
||
const index = rowTrIndex(panel);
|
||
const ops = [];
|
||
rows.forEach((rowNum) => {
|
||
const tr = index.get(rowNum);
|
||
if (!tr || tr.style.display === "none") return;
|
||
const prev = tr.style.height;
|
||
tr.style.height = "auto";
|
||
let need = 0;
|
||
tr.querySelectorAll("td[data-cell]").forEach((td) => {
|
||
if (td.style.display === "none") return;
|
||
const cs = getComputedStyle(td);
|
||
need = Math.max(need, (td.scrollHeight || 0)
|
||
+ (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.borderBottomWidth) || 0));
|
||
});
|
||
tr.style.height = prev;
|
||
if (!need) return;
|
||
const height = Math.min(409, Math.max(9, pxToPts(need)));
|
||
const stored = Number(metaOf(panel).rowheights?.[String(rowNum)]);
|
||
if (Number.isFinite(stored) && Math.abs(stored - height) < 0.1) return;
|
||
ops.push({ op: "row_height", sheet: styleSheetName(), row: rowNum, height });
|
||
});
|
||
if (!ops.length) { showToast(t("xlsx.fit_nothing"), "info"); return; }
|
||
runStyle(ops);
|
||
};
|
||
|
||
/** Ajustement automatique sur la sélection courante (ou la cellule active) :
|
||
* c'est l'entrée de menu, le geste et le clic droit d'en-tête qui l'appellent. */
|
||
const autoFitSelection = (axis) => {
|
||
const panel = visiblePanel();
|
||
if (!panel) return;
|
||
const b = selection && selection.panel === panel ? selectionBounds() : null;
|
||
const ref = parseRef(selectionRange().split(":")[0]) || {};
|
||
if (axis === "col") {
|
||
const cols = [];
|
||
if (b) { for (let c = b.c1; c <= b.c2; c++) cols.push(c); }
|
||
else cols.push(ref.col || 1);
|
||
autoFitColumns(panel, cols);
|
||
} else {
|
||
const rows = [];
|
||
if (b) { for (let r = b.r1; r <= b.r2; r++) rows.push(r); }
|
||
else rows.push(ref.row || 1);
|
||
autoFitRows(panel, rows);
|
||
}
|
||
};
|
||
|
||
const promptColumnWidth = async () => {
|
||
const panel = visiblePanel();
|
||
const b = selection && selection.panel === panel ? selectionBounds() : null;
|
||
const cols = [];
|
||
if (b) { for (let c = b.c1; c <= b.c2; c++) cols.push(c); }
|
||
else cols.push((parseRef(selectionRange().split(":")[0]) || { col: 1 }).col);
|
||
const letters = cols.map(columnName);
|
||
const answer = await showPrompt({
|
||
title: t("xlsx.format_col_width"),
|
||
message: t("xlsx.format_col_prompt", { col: letters.join(", ") }),
|
||
// Comme Excel / Sheets : la valeur courante est pré-remplie.
|
||
value: String(currentColWidthChars(panel, cols[0])),
|
||
});
|
||
if (answer === null || answer === undefined || String(answer).trim() === "") return;
|
||
const width = Number(String(answer).replace(",", "."));
|
||
if (!Number.isFinite(width) || width < 0 || width > 255) return;
|
||
runStyle(letters.map((col) => ({ op: "col_width", sheet: styleSheetName(), col, width })));
|
||
};
|
||
const promptRowHeight = async () => {
|
||
const panel = visiblePanel();
|
||
const b = selection && selection.panel === panel ? selectionBounds() : null;
|
||
const rows = [];
|
||
if (b) { for (let r = b.r1; r <= b.r2; r++) rows.push(r); }
|
||
else rows.push((parseRef(selectionRange().split(":")[0]) || { row: 1 }).row);
|
||
const answer = await showPrompt({
|
||
title: t("xlsx.format_row_height"),
|
||
message: t("xlsx.format_row_prompt", { row: rows.join(", ") }),
|
||
// Comme Excel / Sheets : la valeur courante est pré-remplie.
|
||
value: String(currentRowHeightPts(panel, rows[0])),
|
||
});
|
||
if (answer === null || answer === undefined || String(answer).trim() === "") return;
|
||
const height = Number(String(answer).replace(",", "."));
|
||
if (!Number.isFinite(height) || height < 0 || height > 409) return;
|
||
runStyle(rows.map((row) => ({ op: "row_height", sheet: styleSheetName(), row, height })));
|
||
};
|
||
|
||
// Floating mini-bar on multi-cell selections: link, comment, clear filter, SUM.
|
||
const minibar = document.createElement("div");
|
||
minibar.className = "xlsx-minibar";
|
||
minibar.id = "xlsx-minibar";
|
||
minibar.hidden = true;
|
||
const miniIcon = (name) => `<i data-lucide="${name}" style="width:15px;height:15px" aria-hidden="true"></i>`;
|
||
minibar.innerHTML =
|
||
`<button type="button" data-mini="link" title="${escapeHtml(t("xlsx.link_add"))}">${miniIcon("link")}</button>` +
|
||
`<button type="button" data-mini="comment" title="${escapeHtml(t("xlsx.comment_add"))}">${miniIcon("message-square")}</button>` +
|
||
`<button type="button" data-mini="clear" title="${escapeHtml(t("xlsx.sort_reset"))}">${miniIcon("filter")}</button>` +
|
||
`<button type="button" data-mini="sum" title="${escapeHtml(t("xlsx.sum_insert"))}">${miniIcon("sigma")}</button>`;
|
||
area.appendChild(minibar);
|
||
safeCreateIcons();
|
||
minibar.addEventListener("click", (e) => {
|
||
const btn = e.target && e.target.closest ? e.target.closest("[data-mini]") : null;
|
||
if (!btn) return;
|
||
const kind = btn.dataset.mini;
|
||
if (kind === "link") insertLinkFlow();
|
||
else if (kind === "comment") editCommentFlow();
|
||
else if (kind === "clear") sortResetBtn.click();
|
||
else if (kind === "sum") insertSumFlow();
|
||
});
|
||
const updateMinibar = () => {
|
||
const b = selection && selection.panel === visiblePanel() ? selectionBounds() : null;
|
||
if (!editable || !b || (b.r1 === b.r2 && b.c1 === b.c2)) {
|
||
minibar.hidden = true;
|
||
return;
|
||
}
|
||
const panel = visiblePanel();
|
||
const anchor = findTd(panel, b.r2, b.c2) || findTd(panel, b.r1, b.c1);
|
||
if (!anchor) { minibar.hidden = true; return; }
|
||
const r = anchor.getBoundingClientRect();
|
||
const vw = window.innerWidth || 1024;
|
||
const vh = window.innerHeight || 768;
|
||
minibar.hidden = false;
|
||
const mw = minibar.offsetWidth || 160;
|
||
const mh = minibar.offsetHeight || 36;
|
||
minibar.style.left = `${Math.max(4, Math.min(r.right - mw, vw - mw - 4))}px`;
|
||
minibar.style.top = `${Math.max(4, Math.min(r.bottom + 6, vh - mh - 4))}px`;
|
||
};
|
||
const hideMinibar = () => { minibar.hidden = true; };
|
||
panelEls.forEach((panel) => {
|
||
const wrap = panel.querySelector(".csv-table-wrapper");
|
||
if (wrap) wrap.addEventListener("scroll", hideMinibar, { passive: true });
|
||
});
|
||
|
||
// ══ #155 — Excel-like selection & context menu ═════════════════════════
|
||
const viewerEl = area.querySelector(".xlsx-viewer");
|
||
|
||
// ══ #156-A5 — presse-papiers de plage (copier / couper / coller un bloc) ══
|
||
// The grid keeps its own TSV clipboard so a copy → paste round-trip works
|
||
// even when the system clipboard is unavailable; the async Clipboard API is
|
||
// a best-effort mirror and the fallback source for a block that did not come
|
||
// from this grid (e.g. one copied in Excel). Values are always inserted as
|
||
// plain text — never HTML (risk R4).
|
||
let rangeClipboard = null; // { panel, r1, r2, c1, c2, rows, cut }
|
||
let pendingCut = null; // cut source range, cleared by the next paste
|
||
|
||
/** Displayed text of a cell (the cached-value shadow is a tooltip, not data). */
|
||
const cellTextOf = (td) => {
|
||
if (!td) return "";
|
||
const clone = td.cloneNode(true);
|
||
clone.querySelectorAll(".xlsx-cached").forEach((el) => el.remove());
|
||
return clone.textContent;
|
||
};
|
||
|
||
const toTsv = (rows) => rows.map((line) => line.join("\t")).join("\n");
|
||
|
||
/** The selected range as a matrix of displayed values. */
|
||
const selectionMatrix = () => {
|
||
const b = selectionBounds();
|
||
if (!b || !selection) return null;
|
||
const rows = [];
|
||
for (let r = b.r1; r <= b.r2; r++) {
|
||
const line = [];
|
||
for (let c = b.c1; c <= b.c2; c++) line.push(cellTextOf(findTd(selection.panel, r, c)));
|
||
rows.push(line);
|
||
}
|
||
return rows;
|
||
};
|
||
|
||
/** Best-effort system-clipboard write (silently ignored when unavailable). */
|
||
const writeClipboard = async (text) => {
|
||
try {
|
||
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
|
||
await navigator.clipboard.writeText(text);
|
||
return true;
|
||
}
|
||
} catch (_) { /* restricted clipboard: the internal one still works */ }
|
||
return false;
|
||
};
|
||
|
||
/** The selected range as a matrix of RAW values (formulas travel, Excel-like). */
|
||
const selectionRawMatrix = () => {
|
||
const b = selectionBounds();
|
||
if (!b || !selection) return null;
|
||
const rows = [];
|
||
for (let r = b.r1; r <= b.r2; r++) {
|
||
const line = [];
|
||
for (let c = b.c1; c <= b.c2; c++) {
|
||
const td = findTd(selection.panel, r, c);
|
||
line.push(td ? cellRaw(td) : "");
|
||
}
|
||
rows.push(line);
|
||
}
|
||
return rows;
|
||
};
|
||
/** Copy the selected range. Returns false when there is nothing to copy. */
|
||
const copySelection = () => {
|
||
const b = selectionBounds();
|
||
// Raw content (not display values): a copied formula is pasted as a
|
||
// formula and recomputed at its target, like a spreadsheet.
|
||
const rows = selectionRawMatrix();
|
||
if (!b || !rows || !selection) return false;
|
||
rangeClipboard = { panel: selection.panel, ...b, rows, cut: false };
|
||
pendingCut = null;
|
||
writeClipboard(toTsv(rows));
|
||
return true;
|
||
};
|
||
|
||
/** Cut the selected range: the source is cleared by the next paste (Excel). */
|
||
const cutSelection = () => {
|
||
if (!copySelection()) return false;
|
||
rangeClipboard.cut = true;
|
||
pendingCut = { panel: rangeClipboard.panel, ...selectionBounds() };
|
||
return true;
|
||
};
|
||
|
||
/** True when the user has a live text selection inside *td* (edit mode). */
|
||
const hasCellTextSelection = (td) => {
|
||
const sel = window.getSelection ? window.getSelection() : null;
|
||
return Boolean(sel && !sel.isCollapsed && sel.rangeCount && td && td.contains(sel.anchorNode));
|
||
};
|
||
|
||
/** Put the caret at the end of a cell's text (F2 / navigation → edit). */
|
||
const placeCaretAtEnd = (td) => {
|
||
const sel = window.getSelection ? window.getSelection() : null;
|
||
if (!sel || !document.createRange) return;
|
||
const range = document.createRange();
|
||
range.selectNodeContents(td);
|
||
range.collapse(false);
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
};
|
||
|
||
// #181-A1 — the two cell states. A SELECTED cell shows the outline and no
|
||
// caret; an EDITING cell shows the caret and accepts typing. `enterEditing`
|
||
// optionally empties the cell first (first keystroke on a selected cell, like
|
||
// Sheets/Excel); `exitEditing` is idempotent and safe on the blur path.
|
||
const enterEditing = (td, { replace = false } = {}) => {
|
||
if (!td) return;
|
||
if (replace) {
|
||
// Drop the value and the helper spans (cached shadow, comment dot,
|
||
// formula link) so the keystroke starts from an empty cell.
|
||
td.querySelectorAll(".xlsx-cached,.xlsx-comment-dot").forEach((el) => el.remove());
|
||
td.textContent = "";
|
||
delete td.dataset.formula;
|
||
delete td.dataset.display;
|
||
td.classList.add("xlsx-dirty");
|
||
}
|
||
td.dataset.editing = "1";
|
||
td.classList.add("xlsx-editing");
|
||
// Focus first, then let the caller place the caret. `editingRequested`
|
||
// survives the focus event (the cell handler would clear the state).
|
||
editingRequested = true;
|
||
if (document.activeElement !== td) td.focus();
|
||
editingRequested = false;
|
||
syncFormulaBar(td);
|
||
refreshSaveState();
|
||
};
|
||
|
||
const exitEditing = (td) => {
|
||
if (!td) return;
|
||
td.dataset.editing = "";
|
||
td.classList.remove("xlsx-editing");
|
||
};
|
||
|
||
const startEditing = (td) => {
|
||
enterEditing(td);
|
||
placeCaretAtEnd(td);
|
||
};
|
||
|
||
/**
|
||
* Put the caret where the user double-clicked, instead of at the end: a
|
||
* Range built from the click offset selects the word under the pointer, which
|
||
* is the Sheets/Excel behaviour (double-click edits in place).
|
||
*/
|
||
const placeCaretAtPoint = (td, clientX, clientY) => {
|
||
const sel = window.getSelection ? window.getSelection() : null;
|
||
if (!sel || typeof document.caretRangeFromPoint !== "function") {
|
||
placeCaretAtEnd(td);
|
||
return;
|
||
}
|
||
let range = null;
|
||
try {
|
||
range = document.caretRangeFromPoint(clientX, clientY);
|
||
} catch (_) {
|
||
range = null;
|
||
}
|
||
if (!range || !td.contains(range.startContainer)) {
|
||
placeCaretAtEnd(td);
|
||
return;
|
||
}
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
};
|
||
|
||
/** Insert plain text at the caret inside a cell (no HTML, no execCommand). */
|
||
const insertTextAtCaret = (td, text) => {
|
||
const sel = window.getSelection ? window.getSelection() : null;
|
||
if (sel && sel.rangeCount && td.contains(sel.anchorNode)) {
|
||
const range = sel.getRangeAt(0);
|
||
range.deleteContents();
|
||
const node = document.createTextNode(text);
|
||
range.insertNode(node);
|
||
range.setStartAfter(node);
|
||
range.collapse(true);
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
} else {
|
||
td.appendChild(document.createTextNode(text));
|
||
}
|
||
enterEditing(td);
|
||
td.classList.add("xlsx-dirty");
|
||
syncFormulaBar(td);
|
||
refreshSaveState();
|
||
};
|
||
|
||
/** Clear a range through the history stack (undoable), skipping *skip*. */
|
||
const clearRange = (panel, b, skip = null) => {
|
||
if (!panel || !b) return 0;
|
||
let cleared = 0;
|
||
for (let r = b.r1; r <= b.r2; r++) {
|
||
for (let c = b.c1; c <= b.c2; c++) {
|
||
if (skip && r >= skip.r1 && r <= skip.r2 && c >= skip.c1 && c <= skip.c2) continue;
|
||
const td = findTd(panel, r, c);
|
||
if (!td) continue;
|
||
const before = cellTextOf(td);
|
||
if (before === "") continue;
|
||
const beforeFormula = td.dataset.formula ?? null;
|
||
td.textContent = "";
|
||
delete td.dataset.formula;
|
||
delete td.dataset.display;
|
||
td.querySelectorAll(".xlsx-comment-dot").forEach((el) => el.remove());
|
||
td.classList.toggle("xlsx-dirty", (td.dataset.orig ?? "") !== "");
|
||
recordEdit(td, before, "", beforeFormula, null);
|
||
resignPending(td);
|
||
cleared++;
|
||
}
|
||
}
|
||
refreshSaveState();
|
||
return cleared;
|
||
};
|
||
|
||
/** Split pasted text into a matrix (TSV from a spreadsheet, or plain lines). */
|
||
const parseMatrix = (text) => {
|
||
const raw = String(text ?? "").replace(/\r\n?/g, "\n");
|
||
if (!raw) return [];
|
||
const lines = raw.split("\n");
|
||
// A spreadsheet copy ends with a newline: drop the empty row it produces.
|
||
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
||
return lines.map((line) => line.split("\t"));
|
||
};
|
||
|
||
/** Plain-text payload of a clipboard event ("" when unavailable). */
|
||
const clipTextOf = (e) => {
|
||
const data = (e && (e.clipboardData || window.clipboardData)) || null;
|
||
if (!data || typeof data.getData !== "function") return "";
|
||
try {
|
||
return data.getData("text/plain") || data.getData("text") || "";
|
||
} catch (_) {
|
||
return "";
|
||
}
|
||
};
|
||
|
||
// A range operation that rewrites the cell being edited must not produce a
|
||
// second history entry when that cell later loses focus (commitEdit).
|
||
const resignPending = (td) => {
|
||
if (pendingEdit && pendingEdit.td === td) pendingEdit.before = td.textContent;
|
||
};
|
||
|
||
/** Write one value into a cell through the history stack. */
|
||
const setCellValue = (td, value) => {
|
||
const before = cellTextOf(td);
|
||
if (before === value) return false;
|
||
const beforeFormula = td.dataset.formula ?? null;
|
||
td.textContent = value;
|
||
delete td.dataset.formula;
|
||
delete td.dataset.display;
|
||
td.classList.toggle("xlsx-dirty", value !== (td.dataset.orig ?? ""));
|
||
recordEdit(td, before, value, beforeFormula, isFormulaText(value) ? value.trim() : null);
|
||
resignPending(td);
|
||
return true;
|
||
};
|
||
|
||
/** Paste a matrix starting at *startTd*; keeps the pasted block selected. */
|
||
const fillFrom = (startTd, matrix) => {
|
||
const panel = startTd && startTd.closest(".xlsx-panel");
|
||
const start = startTd && parseRef(cellName(startTd));
|
||
if (!panel || !start || !matrix.length) return null;
|
||
const rows = matrix.length;
|
||
const cols = matrix.reduce((n, line) => Math.max(n, line.length), 0);
|
||
let missing = 0;
|
||
for (let i = 0; i < rows; i++) {
|
||
for (let j = 0; j < cols; j++) {
|
||
const td = findTd(panel, start.row + i, start.col + j);
|
||
if (!td) { missing++; continue; }
|
||
setCellValue(td, matrix[i][j] ?? "");
|
||
}
|
||
}
|
||
const target = {
|
||
r1: start.row, r2: start.row + rows - 1,
|
||
c1: start.col, c2: start.col + cols - 1,
|
||
};
|
||
// Excel keeps the pasted block selected: select the whole target range
|
||
// (cells outside the rendered window are simply not painted) and tell the
|
||
// user when part of the block could not be written.
|
||
selection = {
|
||
panel,
|
||
anchor: { row: target.r1, col: target.c1 },
|
||
focus: { row: target.r2, col: target.c2 },
|
||
};
|
||
applySelection();
|
||
activeTd = startTd;
|
||
formulaInput.disabled = false;
|
||
formulaInput.value = barTextOf(startTd);
|
||
refreshSaveState();
|
||
refreshPanelFormulas(panel);
|
||
if (missing) showToast(t("xlsx.paste_out_of_grid"), "info");
|
||
return target;
|
||
};
|
||
|
||
/** Paste one value: replace the cell, or insert at the caret mid-edit. */
|
||
const pasteSingleValue = (td, value) => {
|
||
if (hasCellTextSelection(td)) { insertTextAtCaret(td, value); return; }
|
||
if (setCellValue(td, value)) {
|
||
syncFormulaBar(td);
|
||
refreshSaveState();
|
||
}
|
||
};
|
||
|
||
/** Common paste path: 1×1 → single cell, otherwise fill the block. */
|
||
const applyPaste = (text, targetTd) => {
|
||
const td = targetTd || (activeTd && area.contains(activeTd) ? activeTd : null);
|
||
const matrix = parseMatrix(text);
|
||
if (!td || !matrix.length) return false;
|
||
if (matrix.length === 1 && matrix[0].length === 1) {
|
||
pasteSingleValue(td, matrix[0][0]);
|
||
} else {
|
||
const target = fillFrom(td, matrix);
|
||
// A cut is committed once its source is cleared; cells inside the target
|
||
// range are kept so cut → paste in place never erases the paste.
|
||
if (pendingCut && area.contains(pendingCut.panel)) clearRange(pendingCut.panel, pendingCut, target);
|
||
}
|
||
pendingCut = null;
|
||
if (rangeClipboard) rangeClipboard.cut = false;
|
||
return true;
|
||
};
|
||
|
||
/** Context-menu “Coller”: internal block first, system clipboard next. */
|
||
const pasteFromClipboard = async () => {
|
||
if (rangeClipboard && applyPaste(toTsv(rangeClipboard.rows))) return;
|
||
let text = "";
|
||
try {
|
||
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.readText) {
|
||
text = await navigator.clipboard.readText();
|
||
}
|
||
} catch (_) { /* permission denied / unavailable */ }
|
||
if (!text || !applyPaste(text)) showToast(t("xlsx.paste_empty"), "info");
|
||
};
|
||
|
||
// Ctrl+C / Ctrl+X are handled through the clipboard events: they also cover
|
||
// the browser's own Edit menu, and a live text selection inside the edited
|
||
// cell keeps its native behaviour.
|
||
viewerEl.addEventListener("copy", (e) => {
|
||
const td = e.target && e.target.closest ? e.target.closest("td[data-cell]") : null;
|
||
if (td && hasCellTextSelection(td)) return; // native in-cell copy
|
||
const inGrid = e.target && e.target.closest && e.target.closest(".xlsx-panel");
|
||
if (!inGrid || !copySelection()) return;
|
||
if (e.clipboardData && e.clipboardData.setData) e.clipboardData.setData("text/plain", toTsv(rangeClipboard.rows));
|
||
e.preventDefault();
|
||
});
|
||
viewerEl.addEventListener("cut", (e) => {
|
||
const td = e.target && e.target.closest ? e.target.closest("td[data-cell]") : null;
|
||
if (td && hasCellTextSelection(td)) return; // native in-cell cut
|
||
const inGrid = e.target && e.target.closest && e.target.closest(".xlsx-panel");
|
||
if (!inGrid || !cutSelection()) return;
|
||
if (e.clipboardData && e.clipboardData.setData) e.clipboardData.setData("text/plain", toTsv(rangeClipboard.rows));
|
||
e.preventDefault();
|
||
});
|
||
|
||
// ══ #156-A6 — clavier complet & #156-A7 — zone Nom ══════════════════
|
||
// A cell is in “navigation” mode until the user types (or presses F2); that
|
||
// single rule is what lets Delete clear the selection instead of deleting a
|
||
// character, and Home/End reach the row instead of the caret.
|
||
const isTypingInCell = (td) => Boolean(td && td.dataset.editing === "1");
|
||
|
||
const activeOrFirst = (panel) => {
|
||
if (panel && activeTd && panel.contains(activeTd)) return activeTd;
|
||
return panel ? panel.querySelector("td[data-cell]") : null;
|
||
};
|
||
|
||
const goTo = (td) => {
|
||
if (!td) return;
|
||
setActiveCell(td);
|
||
td.focus();
|
||
if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "nearest" });
|
||
};
|
||
|
||
/** Move the `xlsx-active` marker without touching the range selection. */
|
||
const markActive = (td) => {
|
||
const prev = area.querySelector("td.xlsx-active");
|
||
if (prev && prev !== td) prev.classList.remove("xlsx-active");
|
||
if (td) td.classList.add("xlsx-active");
|
||
};
|
||
|
||
const gridGeom = (panel) => ({
|
||
rows: panel.querySelectorAll("th.xlsx-rownum").length,
|
||
cols: panel.querySelectorAll(".xlsx-table thead th:not(.xlsx-corner)").length,
|
||
});
|
||
|
||
/** Ctrl+flèche — last cell of a data run (or the first non-empty after a gap). */
|
||
const jumpActive = (panel, key, extend) => {
|
||
const start = activeOrFirst(panel);
|
||
const ref = start && parseRef(cellName(start));
|
||
const step = {
|
||
ArrowRight: [0, 1], ArrowLeft: [0, -1], ArrowDown: [1, 0], ArrowUp: [-1, 0],
|
||
}[key];
|
||
if (!ref || !step) return;
|
||
const [dr, dc] = step;
|
||
const at = (i) => findTd(panel, ref.row + dr * i, ref.col + dc * i);
|
||
const blank = (td) => !td || cellTextOf(td).trim() === "";
|
||
let last = 0;
|
||
while (at(last + 1)) last++;
|
||
if (!last) return;
|
||
let i = 1;
|
||
if (blank(at(1))) {
|
||
while (i < last && blank(at(i))) i++; // skip the gap → next data
|
||
} else {
|
||
while (i < last && !blank(at(i + 1))) i++; // end of the data run
|
||
}
|
||
const target = at(i);
|
||
if (!target || target === start) return;
|
||
if (extend && selection && selection.panel === panel) {
|
||
suppressSelectionReset = true;
|
||
target.focus();
|
||
suppressSelectionReset = false;
|
||
selection.focus = parseRef(cellName(target)) || selection.focus;
|
||
applySelection();
|
||
activeTd = target;
|
||
formulaInput.value = barTextOf(target);
|
||
return;
|
||
}
|
||
goTo(target);
|
||
};
|
||
|
||
const selectAllCells = (panel) => {
|
||
const { rows, cols } = gridGeom(panel);
|
||
if (!rows || !cols) return;
|
||
selection = { panel, anchor: { row: 1, col: 1 }, focus: { row: rows, col: cols } };
|
||
applySelection();
|
||
};
|
||
|
||
const edgeOfRow = (panel, which) => {
|
||
const cur = activeOrFirst(panel);
|
||
const ref = cur && parseRef(cellName(cur));
|
||
const { cols } = gridGeom(panel);
|
||
if (!ref || !cols) return;
|
||
goTo(findTd(panel, ref.row, which === "first" ? 1 : cols));
|
||
};
|
||
|
||
const sheetCorner = (panel, key) => {
|
||
if (key === "Home") { goTo(panel.querySelector("td[data-cell]")); return; }
|
||
const { rows, cols } = gridGeom(panel);
|
||
if (rows && cols) goTo(findTd(panel, rows, cols));
|
||
};
|
||
|
||
const pageMove = (panel, dir) => {
|
||
const cur = activeOrFirst(panel);
|
||
const ref = cur && parseRef(cellName(cur));
|
||
const { rows } = gridGeom(panel);
|
||
if (!ref || !rows) return;
|
||
// One screenful: visible height ÷ row height, with a fallback when the
|
||
// layout is not measured (JSDOM reports 0 for every box).
|
||
const wrapper = panel.querySelector(".csv-table-wrapper");
|
||
const rowEl = panel.querySelector("tbody tr");
|
||
const h = (wrapper && wrapper.clientHeight) || 0;
|
||
const rh = (rowEl && rowEl.getBoundingClientRect().height) || 0;
|
||
const step = h && rh ? Math.max(1, Math.floor(h / rh) - 1) : 10;
|
||
const row = Math.min(rows, Math.max(1, ref.row + dir * step));
|
||
goTo(findTd(panel, row, ref.col));
|
||
};
|
||
|
||
/** #156-A7 — jump to a typed reference ("B12", "A1:B3", "Feuille!B12"). */
|
||
const goToRef = (raw) => {
|
||
const value = String(raw || "").trim().replace(/\$/g, "");
|
||
if (!value) return false;
|
||
const sheetName = sheetOfRef(value);
|
||
const body = value.includes("!") ? value.split("!").pop() : value;
|
||
const parts = body.toUpperCase().split(":");
|
||
const from = parseRef(parts[0]);
|
||
const to = parts[1] ? parseRef(parts[1]) : from;
|
||
if (!from || !to) return false;
|
||
let panel = visiblePanel();
|
||
if (sheetName) {
|
||
const idx = sheets.findIndex((s) => s.name === sheetName);
|
||
const tab = idx >= 0 ? area.querySelector(`.xlsx-tab[data-sheet="${idx}"]`) : null;
|
||
if (!tab) return false;
|
||
tab.click();
|
||
panel = panelEls[idx];
|
||
}
|
||
if (!panel) return false;
|
||
const anchorTd = findTd(panel, from.row, from.col);
|
||
if (!anchorTd) { showToast(t("xlsx.range_not_visible"), "info"); return false; }
|
||
const focusTd = findTd(panel, to.row, to.col) || anchorTd;
|
||
selection = {
|
||
panel,
|
||
anchor: { row: from.row, col: from.col },
|
||
focus: parseRef(cellName(focusTd)) || from,
|
||
};
|
||
activeTd = anchorTd;
|
||
markActive(anchorTd);
|
||
formulaInput.disabled = false;
|
||
formulaInput.value = barTextOf(anchorTd);
|
||
// Focusing the anchor must not collapse a typed range back to one cell:
|
||
// the focus handler skips the selection reset under this flag.
|
||
suppressSelectionReset = true;
|
||
anchorTd.focus();
|
||
suppressSelectionReset = false;
|
||
selection = {
|
||
panel,
|
||
anchor: { row: from.row, col: from.col },
|
||
focus: parseRef(cellName(focusTd)) || from,
|
||
};
|
||
applySelection();
|
||
if (typeof anchorTd.scrollIntoView === "function") anchorTd.scrollIntoView({ block: "center" });
|
||
return true;
|
||
};
|
||
|
||
activeCellEl.addEventListener("focus", () => { activeCellEl.select(); });
|
||
activeCellEl.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
const okRef = goToRef(activeCellEl.value);
|
||
if (!okRef) showToast(t("xlsx.name_box_invalid", { ref: activeCellEl.value }), "error");
|
||
activeCellEl.value = rangeLabel() || "";
|
||
if (activeTd) activeTd.focus();
|
||
} else if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
activeCellEl.value = rangeLabel() || "";
|
||
if (activeTd) activeTd.focus();
|
||
}
|
||
});
|
||
|
||
// #156-A7 — function-name suggestions for the formula bar. The names are
|
||
// localized through the catalog (Excel/Sheets each ship their own spelling).
|
||
const fnList = area.querySelector("#xlsx-function-list");
|
||
if (fnList) {
|
||
String(t("xlsx.fn_suggestions") || "")
|
||
.split(";")
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
.forEach((name) => {
|
||
const opt = document.createElement("option");
|
||
opt.value = name;
|
||
fnList.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
// #156-A6 — one grid-level keyboard handler, in priority order: save is
|
||
// global (the formula bar keeps it too), then undo/redo, then navigation.
|
||
// The formula/find inputs keep their native editing keys.
|
||
area.querySelector(".xlsx-viewer").addEventListener("keydown", (e) => {
|
||
const key = e.key;
|
||
const lower = key.length === 1 ? key.toLowerCase() : key;
|
||
const mod = e.ctrlKey || e.metaKey;
|
||
const inBar = e.target === formulaInput || e.target === activeCellEl;
|
||
const panel = (selection && selection.panel) || (e.target.closest && e.target.closest(".xlsx-panel")) || visiblePanel();
|
||
if (!panel) return;
|
||
if (mod && lower === "s") {
|
||
e.preventDefault();
|
||
if (!saveBtn.disabled) saveBtn.click();
|
||
return;
|
||
}
|
||
if (inBar) return; // formula/find inputs: native editing keys
|
||
if (mod && lower === "z") { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return; }
|
||
if (mod && lower === "y") { e.preventDefault(); redo(); return; }
|
||
if (readOnly) return; // navigation below would activate an uneditable grid
|
||
if (mod && lower === "a") { e.preventDefault(); selectAllCells(panel); return; }
|
||
if (mod && key.startsWith("Arrow")) { e.preventDefault(); jumpActive(panel, key, e.shiftKey); return; }
|
||
if (mod && (key === "Home" || key === "End")) { e.preventDefault(); sheetCorner(panel, key); return; }
|
||
if (key === "Delete" && !isTypingInCell(activeTd)) {
|
||
const b = selectionBounds();
|
||
if (!selection || !b) return;
|
||
e.preventDefault();
|
||
clearRange(selection.panel, b);
|
||
return;
|
||
}
|
||
if (!mod && (key === "Home" || key === "End") && !isTypingInCell(activeTd)) {
|
||
e.preventDefault();
|
||
edgeOfRow(panel, key === "Home" ? "first" : "last");
|
||
return;
|
||
}
|
||
if (!mod && (key === "PageUp" || key === "PageDown")) {
|
||
e.preventDefault();
|
||
pageMove(panel, key === "PageDown" ? 1 : -1);
|
||
}
|
||
});
|
||
|
||
const contextTargetFrom = (target) => {
|
||
if (!target || !target.closest) return null;
|
||
const panel = target.closest(".xlsx-panel");
|
||
if (!panel) return null;
|
||
const rownum = target.closest("th.xlsx-rownum");
|
||
if (rownum) return { panel, kind: "row", row: Number(rownum.textContent) };
|
||
const header = target.closest(".xlsx-table thead th:not(.xlsx-corner)");
|
||
if (header) return { panel, kind: "col", col: [...header.parentElement.children].indexOf(header) };
|
||
const td = target.closest("td[data-cell]");
|
||
if (td) return { panel, kind: "cell", td };
|
||
return null;
|
||
};
|
||
|
||
const selectForTarget = (target) => {
|
||
if (target.kind === "row") selectRow(target.panel, target.row);
|
||
else if (target.kind === "col") selectColumn(target.panel, target.col);
|
||
else if (target.kind === "cell"
|
||
&& (!selection || selection.panel !== target.panel || !inSelection(target.td))) {
|
||
setActiveCell(target.td);
|
||
}
|
||
};
|
||
|
||
// One structure write (insert/delete) with the usual lossy/error handling;
|
||
// destructive actions ask for confirmation first.
|
||
const structureAction = (action, confirmMsg) => {
|
||
const run = async () => {
|
||
try { await putStructure([action]); } catch (err) { structureError(err, [action]); }
|
||
};
|
||
if (confirmMsg) {
|
||
showConfirm({
|
||
title: t("xlsx.structure_title"),
|
||
message: confirmMsg,
|
||
confirmLabel: t("common.confirm"),
|
||
danger: true,
|
||
}).then((ok) => { if (ok) run(); });
|
||
} else {
|
||
run();
|
||
}
|
||
};
|
||
|
||
// #156-A5/A6 — clearing goes through clearRange() so "Effacer le contenu",
|
||
// Suppr and the cut source all share the same undoable path.
|
||
const clearContents = (panel, b) => clearRange(panel, b);
|
||
|
||
// ══ #185 — barre de menus façon Google Sheets ═══════════════════════════
|
||
// The ten menus are BUILT by frontend/js/xlsx/menus.js (pure data); this block
|
||
// only wires them: open/close with the #179 dismissal contract, accordion
|
||
// sections (Nombre, Thème, Zoom, …) and one registry mapping each entry's
|
||
// symbolic action to an existing flow. Entries ObsiGate does not implement
|
||
// say so out loud instead of doing nothing.
|
||
const menuBar = area.querySelector(".xlsx-menubar");
|
||
if (menuBar) {
|
||
const activeRefOf = () => parseRef(selectionRange().split(":")[0]) || { row: 1, col: 1 };
|
||
const sheetNameOf = () => sheets[visibleSheetIndex()]?.name || "";
|
||
const fileNameOf = () => String(data.path || "").split("/").pop();
|
||
/** A theme token as `#rrggbb` for a style payload (never hardcoded). */
|
||
const themeColour = (token, fallback) => {
|
||
let raw = "";
|
||
try {
|
||
raw = getComputedStyle(document.documentElement).getPropertyValue(token).trim();
|
||
} catch (_) { /* no layout engine */ }
|
||
return normHex(raw) || fallback;
|
||
};
|
||
|
||
// ── presentation toggles (Affichage ▸ Afficher, Outils ▸ Accessibilité) ──
|
||
const gridBtn = area.querySelector("#xs-grid");
|
||
const toggleGrid = () => {
|
||
const hidden = viewerEl.classList.toggle("xlsx-no-grid");
|
||
const on = !hidden;
|
||
if (gridBtn) {
|
||
gridBtn.setAttribute("aria-pressed", String(on));
|
||
gridBtn.classList.toggle("active", on);
|
||
}
|
||
return on;
|
||
};
|
||
if (gridBtn) gridBtn.addEventListener("click", toggleGrid);
|
||
|
||
const toggleFullscreen = () => {
|
||
const root = document.documentElement;
|
||
if (document.fullscreenElement) {
|
||
if (document.exitFullscreen) document.exitFullscreen();
|
||
return;
|
||
}
|
||
const ask = root.requestFullscreen || root.webkitRequestFullscreen;
|
||
if (!ask) { showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_fullscreen") }), "info"); return; }
|
||
try {
|
||
const p = ask.call(root);
|
||
if (p && typeof p.catch === "function") {
|
||
p.catch(() => showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_fullscreen") }), "info"));
|
||
}
|
||
} catch (_) {
|
||
showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_fullscreen") }), "info");
|
||
}
|
||
};
|
||
|
||
// ── Modifier ▸ Collage spécial ▸ valeurs uniquement ─────────────────────
|
||
const valuesOnlyMatrix = (rows, sheetName) => {
|
||
const locale = formulaLocale();
|
||
const scope = createScope({ readRaw: readRawCell, sheet: sheetName, locale });
|
||
return rows.map((line) => line.map((raw) => {
|
||
const text = String(raw === null || raw === undefined ? "" : raw);
|
||
if (!isFormulaText(text)) return text;
|
||
try {
|
||
return String(displayValue(evaluate(parseFormula(text), scope), locale));
|
||
} catch (_) {
|
||
return text;
|
||
}
|
||
}));
|
||
};
|
||
const pasteValuesOnly = async () => {
|
||
const td = activeTd && area.contains(activeTd) ? activeTd : null;
|
||
if (!td) return;
|
||
const name = sheetNameOf();
|
||
if (rangeClipboard && rangeClipboard.rows && rangeClipboard.rows.length) {
|
||
const tsv = toTsv(valuesOnlyMatrix(rangeClipboard.rows, name));
|
||
if (applyPaste(tsv, td)) return;
|
||
}
|
||
let text = "";
|
||
try {
|
||
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.readText) {
|
||
text = await navigator.clipboard.readText();
|
||
}
|
||
} catch (_) { /* permission denied */ }
|
||
const matrix = text ? parseMatrix(text) : [];
|
||
if (!matrix.length || !applyPaste(toTsv(valuesOnlyMatrix(matrix, name)), td)) {
|
||
showToast(t("xlsx.paste_empty"), "info");
|
||
}
|
||
};
|
||
|
||
// ── Insertion ▸ rows / columns / cells ─────────────────────────────────
|
||
const insertRows = (where) => {
|
||
const panel = visiblePanel();
|
||
const name = sheetNameOf();
|
||
const b = selectionBounds();
|
||
if (b && selection && selection.panel === panel) {
|
||
structureAction({
|
||
op: "row_insert", sheet: name, count: b.r2 - b.r1 + 1,
|
||
at: where < 0 ? b.r1 : b.r2 + 1,
|
||
});
|
||
return;
|
||
}
|
||
const ref = activeRefOf();
|
||
structureAction({ op: "row_insert", sheet: name, at: where < 0 ? ref.row : ref.row + 1 });
|
||
};
|
||
const insertCols = (where) => {
|
||
const panel = visiblePanel();
|
||
const name = sheetNameOf();
|
||
const b = selectionBounds();
|
||
if (b && selection && selection.panel === panel) {
|
||
structureAction({
|
||
op: "col_insert", sheet: name, count: b.c2 - b.c1 + 1,
|
||
at: where < 0 ? b.c1 : b.c2 + 1,
|
||
});
|
||
return;
|
||
}
|
||
const ref = activeRefOf();
|
||
structureAction({ op: "col_insert", sheet: name, at: where < 0 ? ref.col : ref.col + 1 });
|
||
};
|
||
|
||
// ── Insertion ▸ Fonction : insert the call, caret inside the parens ─────
|
||
const insertFunctionFlow = async () => {
|
||
const panel = visiblePanel();
|
||
const td = activeTd && activeTd.closest(".xlsx-panel") === panel ? activeTd : null;
|
||
if (!td) return;
|
||
const answer = await showPrompt({
|
||
title: t("xlsx.mi_function"),
|
||
message: t("xlsx.mi_function_hint"),
|
||
value: formulaLocale() === "fr" ? "SOMME" : "SUM",
|
||
});
|
||
if (answer === null) return;
|
||
const name = String(answer).trim().toUpperCase();
|
||
if (!name) return;
|
||
if (!/^[A-Z][A-Z0-9._]*$/.test(name)) {
|
||
showToast(t("xlsx.name_box_invalid", { ref: name }), "info");
|
||
return;
|
||
}
|
||
const text = `=${name}()`;
|
||
setCellValue(td, text);
|
||
enterEditing(td);
|
||
syncFormulaBar(td);
|
||
const sel = window.getSelection ? window.getSelection() : null;
|
||
if (sel && document.createRange && td.firstChild) {
|
||
const range = document.createRange();
|
||
range.setStart(td.firstChild, Math.max(0, text.length - 1));
|
||
range.collapse(true);
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
}
|
||
};
|
||
|
||
// ── Format ▸ Couleurs alternées (batchées : 50 ops par requête) ────────
|
||
const applyBandedRows = async () => {
|
||
const panel = visiblePanel();
|
||
const b = selectionBounds();
|
||
if (!b || !selection || selection.panel !== panel || b.r2 === b.r1) {
|
||
showToast(t("xlsx.format_merge_needs_range"), "info");
|
||
return;
|
||
}
|
||
const name = sheetNameOf();
|
||
const band = themeColour("--accent-bg", t("xlsx.mi_banded_default"));
|
||
const ops = [];
|
||
for (let r = b.r1; r <= b.r2; r++) {
|
||
const shaded = (r - b.r1) % 2 === 0;
|
||
ops.push({
|
||
op: "cell", sheet: name,
|
||
range: `${columnName(b.c1)}${r}:${columnName(b.c2)}${r}`,
|
||
style: { fill_color: shaded ? band : "" },
|
||
});
|
||
}
|
||
for (let i = 0; i < ops.length; i += 50) {
|
||
const chunk = ops.slice(i, i + 50);
|
||
try {
|
||
await runStyle(chunk);
|
||
} catch (_) {
|
||
showToast(t("xlsx.mi_banded_done", { n: Math.min(50, ops.length - i) }), "info");
|
||
return;
|
||
}
|
||
}
|
||
showToast(t("xlsx.mi_banded_done", { n: b.r2 - b.r1 + 1 }), "success");
|
||
};
|
||
|
||
// ── Format ▸ Thème : the theme list is built on demand ─────────────────
|
||
const fillThemeSection = async (row) => {
|
||
const sub = row.nextElementSibling;
|
||
if (!sub) return;
|
||
if (sub.dataset.filled !== "1") {
|
||
let entries = [];
|
||
try {
|
||
const mod = await import("./themes.js");
|
||
entries = typeof mod.listThemes === "function" ? mod.listThemes() : [];
|
||
} catch (_) { entries = []; }
|
||
sub.innerHTML = entries.length
|
||
? entries.map((theme) => (
|
||
`<button type="button" class="btn-action xlsx-structure-item xlsx-menu-item" role="menuitem"`
|
||
+ ` data-xlsx-action="apply-theme" data-xlsx-arg="${escapeHtml(theme.id)}">`
|
||
+ `<span class="xlsx-menu-label">${escapeHtml(theme.name || theme.id)}</span></button>`
|
||
)).join("")
|
||
: `<div class="xlsx-menu-empty">${escapeHtml(t("xlsx.menu_unavailable_hint"))}</div>`;
|
||
sub.dataset.filled = "1";
|
||
}
|
||
row.setAttribute("aria-expanded", "true");
|
||
sub.hidden = false;
|
||
};
|
||
|
||
// ── Données ▸ nettoyage / découpe ──────────────────────────────────────
|
||
const requireSelection = () => {
|
||
const panel = visiblePanel();
|
||
const b = selectionBounds();
|
||
if (!b || !selection || selection.panel !== panel) {
|
||
showToast(t("xlsx.format_merge_needs_range"), "info");
|
||
return null;
|
||
}
|
||
return { panel, b };
|
||
};
|
||
const removeDuplicateRows = async () => {
|
||
const sel = requireSelection();
|
||
if (!sel) return;
|
||
const { panel, b } = sel;
|
||
const seen = new Set();
|
||
const duplicates = [];
|
||
for (let r = b.r1; r <= b.r2; r++) {
|
||
const key = [];
|
||
let filled = false;
|
||
for (let c = b.c1; c <= b.c2; c++) {
|
||
const td = findTd(panel, r, c);
|
||
const text = td ? cellTextOf(td).trim() : "";
|
||
if (text) filled = true;
|
||
key.push(text);
|
||
}
|
||
// Blank rows are not duplicates of each other (Sheets ignores them too).
|
||
if (!filled) continue;
|
||
const signature = key.join("\u0000");
|
||
if (seen.has(signature)) duplicates.push(r);
|
||
else seen.add(signature);
|
||
}
|
||
if (!duplicates.length) { showToast(t("xlsx.mi_cleanup_dupes_none"), "info"); return; }
|
||
const ok = await showConfirm({
|
||
title: t("xlsx.mi_cleanup_dupes"),
|
||
message: t("xlsx.mi_cleanup_dupes_done", { n: duplicates.length }),
|
||
confirmLabel: t("common.confirm"),
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
// Bottom-up: deleting the last duplicate first keeps the earlier indexes valid.
|
||
const actions = duplicates.slice().sort((x, y) => y - x)
|
||
.map((row) => ({ op: "row_delete", sheet: sheetNameOf(), at: row, count: 1 }));
|
||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||
};
|
||
const trimSelection = () => {
|
||
const sel = requireSelection();
|
||
if (!sel) return;
|
||
const { panel, b } = sel;
|
||
let cleaned = 0;
|
||
for (let r = b.r1; r <= b.r2; r++) {
|
||
for (let c = b.c1; c <= b.c2; c++) {
|
||
const td = findTd(panel, r, c);
|
||
if (!td || td.dataset.editing === "1") continue;
|
||
const raw = cellRaw(td);
|
||
if (!raw) continue;
|
||
const next = isFormulaText(raw) ? raw : raw.trim().replace(/[ \t]+/g, " ");
|
||
if (next === raw) continue;
|
||
setCellValue(td, next);
|
||
cleaned++;
|
||
}
|
||
}
|
||
refreshPanelFormulas(panel);
|
||
refreshSaveState();
|
||
showToast(t("xlsx.mi_cleanup_trim_done", { n: cleaned }), "success");
|
||
};
|
||
const splitTextToColumns = async () => {
|
||
const panel = visiblePanel();
|
||
const td = activeTd && activeTd.closest(".xlsx-panel") === panel ? activeTd : null;
|
||
if (!td) return;
|
||
const source = cellTextOf(td).trim();
|
||
if (!source) return;
|
||
const separator = await showPrompt({
|
||
title: t("xlsx.mi_split"),
|
||
message: t("xlsx.mi_split_sep"),
|
||
value: ",",
|
||
});
|
||
if (separator === null || separator === "") return;
|
||
const start = parseRef(cellName(td));
|
||
if (!start) return;
|
||
const parts = source.split(separator).map((part) => part.trim());
|
||
let filled = 0;
|
||
parts.forEach((value, i) => {
|
||
const target = findTd(panel, start.row, start.col + i);
|
||
if (!target) return;
|
||
if (setCellValue(target, value)) filled++;
|
||
});
|
||
refreshPanelFormulas(panel);
|
||
refreshSaveState();
|
||
showToast(t("xlsx.mi_split_done", { n: filled }), "success");
|
||
};
|
||
|
||
// ── File actions that need their own endpoint ──────────────────────────
|
||
const fileRename = async () => {
|
||
const current = fileNameOf();
|
||
const next = await showPrompt({
|
||
title: t("xlsx.mi_rename"),
|
||
message: t("xlsx.rename_prompt"),
|
||
value: current,
|
||
});
|
||
if (!next || next === current) return;
|
||
try {
|
||
await api(`/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify({ path: data.path, new_name: next }),
|
||
});
|
||
showToast(t("xlsx.rename_done", { name: next }), "success");
|
||
} catch (err) {
|
||
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
|
||
}
|
||
};
|
||
const fileTrash = async () => {
|
||
const name = fileNameOf();
|
||
const ok = await showConfirm({
|
||
title: t("xlsx.mi_trash"),
|
||
message: t("xlsx.trash_confirm", { name }),
|
||
confirmLabel: t("xlsx.mi_trash"),
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api(`/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`, {
|
||
method: "DELETE",
|
||
});
|
||
showToast(t("xlsx.trash_done"), "success");
|
||
} catch (err) {
|
||
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
|
||
}
|
||
};
|
||
const fileHistory = async () => {
|
||
try {
|
||
const mod = await import("./backups.js");
|
||
await mod.openBackupViewer(data.vault, data.path);
|
||
} catch (_) {
|
||
showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_history") }), "info");
|
||
}
|
||
};
|
||
const fileDetails = () => {
|
||
const dirty = dirtyCount();
|
||
showConfirm({
|
||
title: t("xlsx.details_title"),
|
||
message: t("xlsx.details_body", {
|
||
vault: data.vault || "—",
|
||
path: data.path || "—",
|
||
sheets: sheets.length,
|
||
cells: area.querySelectorAll(".xlsx-table td[data-cell]").length,
|
||
revision: revision || "—",
|
||
dirty: String(dirty),
|
||
}),
|
||
confirmLabel: t("common.close"),
|
||
});
|
||
};
|
||
const openSettings = () => {
|
||
const btn = document.getElementById("config-open-btn");
|
||
if (btn) btn.click();
|
||
else showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_settings") }), "info");
|
||
};
|
||
const showWhatsNew = async () => {
|
||
try {
|
||
const health = await api("/api/health");
|
||
showConfirm({
|
||
title: t("xlsx.version_title"),
|
||
message: t("xlsx.version_body", {
|
||
version: health.version || "—",
|
||
date: health.git_commit ? String(health.git_commit).slice(0, 8) : "",
|
||
status: health.status || "ok",
|
||
}),
|
||
confirmLabel: t("common.close"),
|
||
});
|
||
} catch (_) {
|
||
showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_whatsnew") }), "info");
|
||
}
|
||
};
|
||
const reportProblem = async () => {
|
||
const report = [
|
||
`ObsiGate ${window.__OBSIGATE_VERSION || "?"}`,
|
||
navigator.userAgent,
|
||
`vault=${data.vault || ""} path=${data.path || ""}`,
|
||
`sheets=${sheets.length} revision=${revision || "-"} dirty=${dirtyCount()}`,
|
||
].join("\n");
|
||
try {
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
await navigator.clipboard.writeText(report);
|
||
showToast(t("xlsx.report_copied"), "success");
|
||
return;
|
||
}
|
||
} catch (_) { /* clipboard denied — fall through */ }
|
||
showConfirm({
|
||
title: t("xlsx.mi_report"),
|
||
message: report,
|
||
confirmLabel: t("common.close"),
|
||
});
|
||
};
|
||
|
||
/** action name -> implementation. Mirrors SHEETS_MENUS in menus.js. */
|
||
const menuActions = {
|
||
// Fichier
|
||
share: () => openShareDialog(data.vault, data.path),
|
||
download: downloadOriginalFile,
|
||
"export-csv": exportSheetCsv,
|
||
"export-md": () => exportDocument("md"),
|
||
"export-html": () => exportDocument("html"),
|
||
rename: fileRename,
|
||
trash: fileTrash,
|
||
history: fileHistory,
|
||
details: fileDetails,
|
||
settings: openSettings,
|
||
print: () => printSheet(),
|
||
// Modifier
|
||
undo: () => { undo(); },
|
||
redo: () => { redo(); },
|
||
cut: () => { if (editable) cutSelection(); },
|
||
copy: () => { copySelection(); },
|
||
paste: () => pasteFromClipboard(),
|
||
"paste-values": pasteValuesOnly,
|
||
"delete-rows": () => {
|
||
const b = selectionBounds();
|
||
if (!b) return;
|
||
structureAction(
|
||
{ op: "row_delete", sheet: sheetNameOf(), at: b.r1, count: b.r2 - b.r1 + 1 },
|
||
t("xlsx.confirm_delete_rows", { n: b.r2 - b.r1 + 1 }),
|
||
);
|
||
},
|
||
"delete-cols": () => {
|
||
const b = selectionBounds();
|
||
if (!b) return;
|
||
structureAction(
|
||
{ op: "col_delete", sheet: sheetNameOf(), at: b.c1, count: b.c2 - b.c1 + 1 },
|
||
t("xlsx.confirm_delete_cols", { n: b.c2 - b.c1 + 1 }),
|
||
);
|
||
},
|
||
"clear-contents": () => {
|
||
const b = selectionBounds();
|
||
if (b) clearContents(visiblePanel(), b);
|
||
},
|
||
"select-all": () => selectAllCells(visiblePanel()),
|
||
"find-replace": () => replaceFlow(),
|
||
// Affichage
|
||
freeze: () => freezeAtSelection(),
|
||
"freeze-row": () => {
|
||
const ref = activeRefOf();
|
||
runStyle([{ op: "freeze", sheet: sheetNameOf(), cell: `A${ref.row}` }]);
|
||
},
|
||
"freeze-col": () => {
|
||
const ref = activeRefOf();
|
||
runStyle([{ op: "freeze", sheet: sheetNameOf(), cell: `${columnName(ref.col)}1` }]);
|
||
},
|
||
unfreeze: () => runStyle([{ op: "freeze", sheet: sheetNameOf(), cell: "" }]),
|
||
"toggle-formula-bar": () => {
|
||
const bar = area.querySelector(".xlsx-formula-bar");
|
||
if (bar) bar.hidden = !bar.hidden;
|
||
},
|
||
"toggle-grid": toggleGrid,
|
||
"toggle-headers": () => viewerEl.classList.toggle("xlsx-no-headers"),
|
||
dashboard: () => { if (dashBtn) dashBtn.click(); },
|
||
zoom: (arg) => { const z = Number(arg); if (Number.isFinite(z)) applyZoom(z); },
|
||
fullscreen: toggleFullscreen,
|
||
// Insertion
|
||
"insert-rows-above": () => insertRows(-1),
|
||
"insert-rows-below": () => insertRows(1),
|
||
"insert-cols-left": () => insertCols(-1),
|
||
"insert-cols-right": () => insertCols(1),
|
||
"insert-cells": () => {
|
||
const ref = activeRefOf();
|
||
const name = sheetNameOf();
|
||
structureAction({ op: "row_insert", sheet: name, at: ref.row });
|
||
structureAction({ op: "col_insert", sheet: name, at: ref.col });
|
||
},
|
||
link: () => insertLinkFlow(),
|
||
comment: () => editCommentFlow(),
|
||
function: insertFunctionFlow,
|
||
// Format
|
||
numfmt: (arg) => runCellStyle({ number_format: arg }),
|
||
"theme-menu": (arg, item) => fillThemeSection(item),
|
||
"apply-theme": (arg) => {
|
||
import("./themes.js")
|
||
.then((mod) => mod.setTheme(arg))
|
||
.catch(() => showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_theme") }), "info"));
|
||
},
|
||
bold: () => runCellStyle({ bold: !hasBold() }),
|
||
italic: () => runCellStyle({ italic: !hasItalic() }),
|
||
underline: () => runCellStyle({ underline: !hasUnderline() }),
|
||
strike: () => runCellStyle({ strike: !hasStrike() }),
|
||
fontsize: (arg) => runCellStyle({ font_size: Number(arg) }),
|
||
align: (arg) => runCellStyle({ align: arg }),
|
||
valign: (arg) => runCellStyle({ valign: arg }),
|
||
"wrap-on": () => runCellStyle({ wrap: true }),
|
||
"wrap-off": () => runCellStyle({ wrap: false }),
|
||
rotate: (arg) => runCellStyle({ rotation: Number(arg) }),
|
||
"clear-format": () => runCellStyle({
|
||
bold: false, italic: false, underline: false, strike: false,
|
||
// Pas de `valign` : le backend ne connaît que top/middle/bottom et
|
||
// rejette `""` en 400 (même cause que le bug « Paint Format »).
|
||
font_color: "", fill_color: "", align: "left",
|
||
wrap: false, rotation: 0, number_format: "General",
|
||
}),
|
||
banded: applyBandedRows,
|
||
// Tailles (Format ▸ Redimensionner)
|
||
"col-width": () => promptColumnWidth(),
|
||
"row-height": () => promptRowHeight(),
|
||
"col-fit": () => autoFitSelection("col"),
|
||
"row-fit": () => autoFitSelection("row"),
|
||
// Données
|
||
"sort-asc": () => sortFromToolbar("asc"),
|
||
"sort-desc": () => sortFromToolbar("desc"),
|
||
"sort-reset": () => sortResetBtn.click(),
|
||
"filter-column": () => {
|
||
const panel = visiblePanel();
|
||
const td = activeTd && activeTd.closest(".xlsx-panel") === panel ? activeTd : null;
|
||
const value = td ? cellTextOf(td).trim() : "";
|
||
if (!value) { showToast(t("xlsx.find_no_match"), "info"); return; }
|
||
panelEls.forEach((p) => applyFilter(p, p === panel ? value : ""));
|
||
findInput.value = value;
|
||
runFind();
|
||
},
|
||
"named-ranges": () => { if (dashBtn) dashBtn.click(); },
|
||
"cleanup-dupes": removeDuplicateRows,
|
||
"cleanup-trim": trimSelection,
|
||
"split-text": splitTextToColumns,
|
||
// Outils
|
||
autocomplete: (arg) => {
|
||
const on = arg !== "false";
|
||
formulaInput.setAttribute("list", on ? "xlsx-function-list" : "");
|
||
showToast(on ? t("xlsx.mi_autocomplete_on") : t("xlsx.mi_autocomplete_off"), "info");
|
||
},
|
||
a11y: () => {
|
||
const on = viewerEl.classList.toggle("xlsx-a11y");
|
||
showToast(on ? t("xlsx.mi_a11y_on") : t("xlsx.mi_a11y_off"), "info");
|
||
},
|
||
// Gemini
|
||
gemini: () => { openAssistant(); },
|
||
// Aide
|
||
help: () => {
|
||
const btn = document.getElementById("help-open-btn");
|
||
if (btn) btn.click();
|
||
else showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_help_sheets") }), "info");
|
||
},
|
||
whatsnew: showWhatsNew,
|
||
shortcuts: () => showConfirm({
|
||
title: t("xlsx.shortcuts_title"),
|
||
message: t("xlsx.shortcuts_body"),
|
||
confirmLabel: t("common.close"),
|
||
}),
|
||
report: reportProblem,
|
||
};
|
||
|
||
/** Modifier ▸ Rechercher et remplacer. */
|
||
const replaceFlow = async () => {
|
||
const panel = visiblePanel();
|
||
if (!panel) return;
|
||
const needle = await showPrompt({
|
||
title: t("xlsx.mi_replace"),
|
||
message: t("xlsx.mi_replace_find"),
|
||
value: findInput.value,
|
||
});
|
||
if (needle === null || !String(needle).length) return;
|
||
const replacement = await showPrompt({
|
||
title: t("xlsx.mi_replace"),
|
||
message: t("xlsx.mi_replace_with"),
|
||
});
|
||
if (replacement === null) return;
|
||
const from = String(needle);
|
||
const to = String(replacement);
|
||
const scope = createScope({ readRaw: readRawCell, sheet: panelSheetName(panel), locale: formulaLocale() });
|
||
const matches = panelEls.reduce((acc, p) => acc.concat(
|
||
[...p.querySelectorAll(".xlsx-table td[data-cell]")].filter(
|
||
(td) => td.textContent.toLowerCase().includes(from.toLowerCase()),
|
||
),
|
||
), []);
|
||
if (!matches.length) { showToast(t("xlsx.mi_replace_none"), "info"); return; }
|
||
let replaced = 0;
|
||
matches.forEach((td) => {
|
||
const raw = cellRaw(td);
|
||
const next = isFormulaText(raw)
|
||
? raw.replace(new RegExp(from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), to)
|
||
: cellTextOf(td).replace(new RegExp(from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), to);
|
||
if (next !== raw && setCellValue(td, next)) replaced++;
|
||
});
|
||
panelEls.forEach(refreshPanelFormulas);
|
||
refreshSaveState();
|
||
if (replaced) showToast(t("xlsx.mi_replace_done", { n: replaced }), "success");
|
||
else showToast(t("xlsx.mi_replace_none"), "info");
|
||
};
|
||
|
||
// ── open / close (same dismissal contract as the other menus, #179) ─────
|
||
const openers = new Map();
|
||
const closeMenu = (btn) => {
|
||
const dismiss = openers.get(btn);
|
||
if (dismiss) { dismiss(); openers.delete(btn); }
|
||
};
|
||
const closeAllMenus = () => [...openers.keys()].forEach(closeMenu);
|
||
menuBar.querySelectorAll(".xlsx-menubtn").forEach((btn) => {
|
||
const drop = document.getElementById(btn.getAttribute("aria-controls"));
|
||
if (!drop) return;
|
||
enableArrowNav(drop, ".xlsx-menu-item");
|
||
btn.addEventListener("click", () => {
|
||
if (openers.has(btn)) { closeMenu(btn); return; }
|
||
closeAllMenus();
|
||
drop.style.display = "block";
|
||
btn.setAttribute("aria-expanded", "true");
|
||
const toolbar = btn.closest(".xlsx-toolbar") || area;
|
||
anchorUnder(drop, toolbar, btn);
|
||
const first = drop.querySelector(".xlsx-menu-item");
|
||
if (first && typeof first.focus === "function") first.focus({ preventScroll: true });
|
||
openers.set(btn, trackDismissable(drop, {
|
||
toggle: btn,
|
||
onClose: () => {
|
||
openers.delete(btn);
|
||
drop.style.display = "none";
|
||
btn.setAttribute("aria-expanded", "false");
|
||
},
|
||
}));
|
||
});
|
||
});
|
||
// Sheets accordion: one section open per level, the others collapse.
|
||
menuBar.addEventListener("click", (e) => {
|
||
const row = e.target.closest ? e.target.closest(".xlsx-menu-item[aria-haspopup='menu']") : null;
|
||
if (!row) return;
|
||
// stopImmediatePropagation, not stopPropagation: the dispatcher below
|
||
// listens on the SAME element, and a section row must not be treated as
|
||
// a plain command (it would close the menu it just filled).
|
||
e.stopImmediatePropagation();
|
||
// A section can also declare an action (the empty « Thème » one): run it
|
||
// instead of toggling, it fills the section itself.
|
||
const fill = row.dataset.xlsxAction ? menuActions[row.dataset.xlsxAction] : null;
|
||
if (fill) { fill(row.dataset.xlsxArg, row); return; }
|
||
const sub = row.nextElementSibling;
|
||
if (!sub || !sub.classList.contains("xlsx-menu-sub")) return;
|
||
const wasOpen = row.getAttribute("aria-expanded") === "true";
|
||
row.parentElement.querySelectorAll(".xlsx-menu-item[aria-haspopup='menu']").forEach((other) => {
|
||
if (other === row) return;
|
||
other.setAttribute("aria-expanded", "false");
|
||
const otherSub = other.nextElementSibling;
|
||
if (otherSub && otherSub.classList.contains("xlsx-menu-sub")) otherSub.hidden = true;
|
||
});
|
||
row.setAttribute("aria-expanded", String(!wasOpen));
|
||
sub.hidden = wasOpen;
|
||
});
|
||
// One delegated dispatcher for every entry of every menu.
|
||
menuBar.addEventListener("click", (e) => {
|
||
const item = e.target.closest ? e.target.closest("[data-xlsx-action], [data-xlsx-unavailable]") : null;
|
||
if (!item) return;
|
||
const labelNode = item.querySelector(".xlsx-menu-label");
|
||
const label = labelNode ? labelNode.textContent : "";
|
||
const kind = item.dataset.xlsxExport;
|
||
// Any pick dismisses the menu — including an entry whose only job is
|
||
// to announce it is unavailable (#185).
|
||
closeAllMenus();
|
||
if (!kind && item.hasAttribute("data-xlsx-unavailable")) {
|
||
showToast(t("xlsx.menu_unavailable", { item: label }), "info");
|
||
return;
|
||
}
|
||
if (kind) {
|
||
if (kind === "print") printSheet();
|
||
else exportDocument(kind);
|
||
return;
|
||
}
|
||
const run = menuActions[item.dataset.xlsxAction];
|
||
if (!run) { showToast(t("xlsx.menu_unavailable", { item: label }), "info"); return; }
|
||
run(item.dataset.xlsxArg, item);
|
||
});
|
||
}
|
||
|
||
const buildContextItems = (target) => {
|
||
const panel = target.panel;
|
||
const sheetName = sheets[Number(panel.dataset.sheet)]?.name || "";
|
||
const b = selectionBounds();
|
||
if (!b) return [];
|
||
const rowCount = b.r2 - b.r1 + 1;
|
||
const colCount = b.c2 - b.c1 + 1;
|
||
const items = [];
|
||
// Clic droit sur un EN-TÊTE (Excel / Sheets) : les commandes de taille
|
||
// d'abord — le dialogue et l'ajustement automatique portent sur la colonne
|
||
// ou la ligne cliquée (selectForTarget() en a déjà couvert toute la
|
||
// longueur / largeur).
|
||
if (editable && target.kind === "col") {
|
||
items.push({ icon: "move-horizontal", label: t("xlsx.format_col_width"), onClick: () => promptColumnWidth() });
|
||
items.push({ icon: "maximize-2", label: t("xlsx.format_col_fit"), onClick: () => autoFitSelection("col") });
|
||
items.push({ separator: true });
|
||
} else if (editable && target.kind === "row") {
|
||
items.push({ icon: "move-vertical", label: t("xlsx.format_row_height"), onClick: () => promptRowHeight() });
|
||
items.push({ icon: "maximize-2", label: t("xlsx.format_row_fit"), onClick: () => autoFitSelection("row") });
|
||
items.push({ separator: true });
|
||
}
|
||
// #156-A5 — clipboard entries first, in the spreadsheet order.
|
||
// #179 — every entry carries a representative Lucide icon.
|
||
items.push({ icon: "scissors", label: t("xlsx.cut"), disabled: !editable, onClick: () => cutSelection() });
|
||
items.push({ icon: "copy", label: t("xlsx.copy"), onClick: () => copySelection() });
|
||
items.push({ icon: "clipboard-paste", label: t("xlsx.paste"), disabled: !editable, onClick: () => pasteFromClipboard() });
|
||
items.push({ separator: true });
|
||
if (editable) {
|
||
items.push({ icon: "rows", label: t("xlsx.insert_row_above"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r1, count: rowCount }) });
|
||
items.push({ icon: "rows", label: t("xlsx.insert_row_below"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r2 + 1, count: rowCount }) });
|
||
items.push({ icon: "columns", label: t("xlsx.insert_col_left"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c1, count: colCount }) });
|
||
items.push({ icon: "columns", label: t("xlsx.insert_col_right"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c2 + 1, count: colCount }) });
|
||
items.push({ separator: true });
|
||
items.push({ icon: "trash-2", label: t("xlsx.delete_rows", { n: rowCount }), danger: true, onClick: () => structureAction({ op: "row_delete", sheet: sheetName, at: b.r1, count: rowCount }, t("xlsx.confirm_delete_rows", { n: rowCount })) });
|
||
items.push({ icon: "trash-2", label: t("xlsx.delete_cols", { n: colCount }), danger: true, onClick: () => structureAction({ op: "col_delete", sheet: sheetName, at: b.c1, count: colCount }, t("xlsx.confirm_delete_cols", { n: colCount })) });
|
||
items.push({ separator: true });
|
||
}
|
||
if (!isCsv) {
|
||
items.push({ icon: "arrow-up", label: t("xlsx.sort_asc"), onClick: () => applySort(panel, b.c1, "asc") });
|
||
items.push({ icon: "arrow-down", label: t("xlsx.sort_desc"), onClick: () => applySort(panel, b.c1, "desc") });
|
||
}
|
||
if (editable) {
|
||
items.push({ separator: true });
|
||
items.push({ icon: "eraser", label: t("xlsx.clear_contents"), onClick: () => clearContents(panel, b) });
|
||
}
|
||
return items;
|
||
};
|
||
|
||
const openGridMenu = (x, y, target, byTouch = false) => {
|
||
selectForTarget(target);
|
||
openContextMenu(x, y, buildContextItems(target), { byTouch });
|
||
};
|
||
|
||
viewerEl.addEventListener("contextmenu", (e) => {
|
||
const target = contextTargetFrom(e.target);
|
||
if (!target) return;
|
||
e.preventDefault();
|
||
openGridMenu(e.clientX, e.clientY, target);
|
||
});
|
||
|
||
// Long-press (touch) — the mobile equivalent of a right-click.
|
||
let longPressTimer = null;
|
||
const cancelLongPress = () => {
|
||
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
|
||
};
|
||
viewerEl.addEventListener("touchstart", (e) => {
|
||
const target = contextTargetFrom(e.target);
|
||
if (!target || !e.touches || !e.touches[0]) return;
|
||
const t0 = e.touches[0];
|
||
cancelLongPress();
|
||
longPressTimer = setTimeout(() => {
|
||
longPressTimer = null;
|
||
openGridMenu(t0.clientX, t0.clientY, target, true);
|
||
}, 500);
|
||
}, { passive: true });
|
||
viewerEl.addEventListener("touchmove", cancelLongPress, { passive: true });
|
||
viewerEl.addEventListener("touchend", cancelLongPress);
|
||
viewerEl.addEventListener("touchcancel", cancelLongPress);
|
||
|
||
// #182 — focus a cell WITHOUT letting its focus handler collapse the current
|
||
// range: the focus handler only syncs the formula bar, so the Name Box has to
|
||
// be restored to the range address afterwards (otherwise a drag or a
|
||
// Shift+click left `C3` in the box instead of `A1:C3`).
|
||
const focusKeepingRange = (td) => {
|
||
if (!td || !td.isConnected) return;
|
||
suppressSelectionReset = true;
|
||
td.focus();
|
||
suppressSelectionReset = false;
|
||
if (selection) activeCellEl.value = rangeLabel();
|
||
};
|
||
|
||
// Drag-to-select a rectangular range + Shift+click extension.
|
||
let dragAnchorTd = null;
|
||
let dragActive = false;
|
||
let dragExtended = false;
|
||
viewerEl.addEventListener("mousedown", (e) => {
|
||
// Clic droit sur la grille : le navigateur déplace le focus sur la cellule
|
||
// au moment du `mousedown`. Le handler `focus` de la cellule appellerait
|
||
// alors setActiveCell() et ÉCRASERAIT la sélection multiple AVANT que le
|
||
// menu contextuel ne s'ouvre — les entrées Copier / Coller / Effacer ne
|
||
// s'appliquaient plus qu'à la seule cellule sous le curseur. Le
|
||
// `preventDefault()` annule ce déplacement de focus : la plage survit
|
||
// jusqu'au choix d'une option (Google Sheets fait de même). Le
|
||
// déplacement de la sélection vers une cellule HORS plage reste géré par
|
||
// selectForTarget(), explicitement, à l'ouverture du menu.
|
||
if (e.button === 2) { e.preventDefault(); return; }
|
||
if (e.button !== 0) return;
|
||
const td = e.target.closest && e.target.closest("td[data-cell]");
|
||
if (!td) return;
|
||
const panel = td.closest(".xlsx-panel");
|
||
if (e.shiftKey && selection && selection.panel === panel) {
|
||
e.preventDefault();
|
||
selection.focus = parseRef(cellName(td)) || selection.focus;
|
||
applySelection();
|
||
// #182 — `preventDefault()` above suppresses the browser's own focus
|
||
// move, so `activeTd` and the real DOM focus used to diverge: the next
|
||
// click on the still-focused cell fired NO focus event and the range
|
||
// stayed stuck. Focusing here keeps both in step.
|
||
activeTd = td;
|
||
// #182 — `preventDefault()` above suppresses the browser's own focus
|
||
// move, so `activeTd` and the real DOM focus used to diverge: the next
|
||
// click on the still-focused cell fired NO focus event and the range
|
||
// stayed stuck. Focusing here keeps both in step — under
|
||
// `suppressSelectionReset`, so the focus handler does not collapse the
|
||
// range we just extended.
|
||
suppressSelectionReset = true;
|
||
td.focus();
|
||
suppressSelectionReset = false;
|
||
formulaInput.disabled = false;
|
||
formulaInput.value = barTextOf(td);
|
||
if (selection) activeCellEl.value = rangeLabel();
|
||
return;
|
||
}
|
||
// #181-A1 — a plain click SELECTS: drop any in-cell caret/text selection
|
||
// and leave edit mode, so no blinking cursor is left behind on the cell.
|
||
if (activeTd && isTypingInCell(activeTd)) {
|
||
commitEdit();
|
||
exitEditing(activeTd);
|
||
}
|
||
// #182 — a plain click ALWAYS collapses the selection to the clicked cell,
|
||
// even when that cell already had the focus (no focus event fires then,
|
||
// which is why a previously drawn range refused to clear). Doing it here
|
||
// rather than relying on `focus` makes deselection deterministic.
|
||
setActiveCell(td);
|
||
dragAnchorTd = td;
|
||
dragActive = true;
|
||
dragExtended = false;
|
||
});
|
||
viewerEl.addEventListener("mouseover", (e) => {
|
||
if (!dragActive || !dragAnchorTd) return;
|
||
const td = e.target.closest && e.target.closest("td[data-cell]");
|
||
if (!td || td === dragAnchorTd) return;
|
||
const panel = dragAnchorTd.closest(".xlsx-panel");
|
||
if (td.closest(".xlsx-panel") !== panel) return;
|
||
const a = parseRef(cellName(dragAnchorTd));
|
||
const f = parseRef(cellName(td));
|
||
if (!a || !f) return;
|
||
if (!dragExtended) {
|
||
dragExtended = true;
|
||
// Stop editing the anchor cell and drop any in-cell text selection.
|
||
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
|
||
if (document.getSelection) { const s = document.getSelection(); if (s && s.removeAllRanges) s.removeAllRanges(); }
|
||
viewerEl.classList.add("xlsx-selecting");
|
||
}
|
||
selection = { panel, anchor: a, focus: f };
|
||
applySelection();
|
||
});
|
||
const endDrag = () => {
|
||
if (dragActive && dragExtended && dragAnchorTd) {
|
||
activeTd = dragAnchorTd;
|
||
// #182 — no focus() here on purpose: a drag does not move the focus in
|
||
// a browser either, and stealing it would collapse the range we just
|
||
// drew (the cell's focus handler resets the selection to itself).
|
||
// Deselection no longer depends on focus events — the `mousedown`
|
||
// handler collapses the range explicitly.
|
||
formulaInput.disabled = false;
|
||
formulaInput.value = barTextOf(dragAnchorTd);
|
||
// #182 — the Name Box keeps showing the range, not just its anchor.
|
||
if (selection) activeCellEl.value = rangeLabel();
|
||
}
|
||
dragActive = false;
|
||
dragExtended = false;
|
||
dragAnchorTd = null;
|
||
viewerEl.classList.remove("xlsx-selecting");
|
||
};
|
||
viewerEl.addEventListener("mouseup", endDrag);
|
||
viewerEl.addEventListener("mouseleave", endDrag);
|
||
|
||
safeCreateIcons();
|
||
}
|
||
|
||
export function renderFile(data) {
|
||
// #93 — An inline edition session (#editor-container mounted in the content
|
||
// area) is destroyed by this very re-render: release it first so the editor
|
||
// state (CodeMirror view, Forge iframe, Yjs session) is torn down cleanly
|
||
// instead of being wiped mid-session by a tab switch / sidebar click.
|
||
if (isInlineEditorActive()) detachInlineEditor();
|
||
// #108 — release the image viewer's document-level shortcuts before swapping
|
||
// the content area (otherwise they leak on every re-render).
|
||
if (_imageViewerCleanup) { _imageViewerCleanup(); _imageViewerCleanup = null; }
|
||
// BUG-072 / #111 — any render that is not an image viewer starts from a clean
|
||
// viewer. Image→image navigation now happens in place (see renderImageViewer),
|
||
// so the viewer is never rebuilt between siblings.
|
||
if (!data.is_image) {
|
||
_imageViewerState.lightbox = false;
|
||
_imageViewerState.meta = false;
|
||
}
|
||
const area = getContentArea();
|
||
// #110 — if this render is about to replace the surface that currently hosts
|
||
// the shared media element, hand it back to the persistent dock first.
|
||
NowPlaying.handleRender(area, data);
|
||
|
||
// Handle PDF files — render in iframe with TOC sidebar
|
||
if (data.is_pdf) {
|
||
const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf/stream?path=${encodeURIComponent(data.path)}`;
|
||
const pages = data.pdf_metadata && data.pdf_metadata.pages ? data.pdf_metadata.pages : '?';
|
||
const toc = data.pdf_toc || [];
|
||
|
||
let tocHtml = '';
|
||
if (toc.length > 0) {
|
||
tocHtml = '<div class="pdf-toc"><h3>Table des matières</h3><ul>';
|
||
for (const item of toc) {
|
||
const indent = (item.level - 1) * 16;
|
||
tocHtml += `<li style="padding-left:${indent}px"><a href="#" data-page="${item.page}">${escapeHtml(item.title)}</a> <span class="toc-page">p.${item.page}</span></li>`;
|
||
}
|
||
tocHtml += '</ul></div>';
|
||
}
|
||
|
||
area.innerHTML = `
|
||
<div class="pdf-viewer-container">
|
||
<div class="pdf-toolbar">
|
||
<span class="pdf-info">PDF — ${pages} pages</span>
|
||
<button class="btn-action" data-pdf-url="${pdfUrl}">
|
||
<i data-lucide="external-link" style="width:14px;height:14px"></i> Plein écran
|
||
</button>
|
||
<button class="btn-action" data-download-url="/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}">
|
||
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
|
||
</button>
|
||
</div>
|
||
<div class="pdf-body">
|
||
${tocHtml}
|
||
<iframe src="${pdfUrl}" data-pdf-url="${pdfUrl}" class="pdf-iframe" title="${escapeHtml(data.title)}"></iframe>
|
||
</div>
|
||
</div>`;
|
||
area.querySelectorAll('.pdf-toolbar .btn-action').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
window.open(btn.dataset.pdfUrl || btn.dataset.downloadUrl, '_blank');
|
||
});
|
||
});
|
||
area.querySelectorAll('.pdf-toc a[data-page]').forEach((link) => {
|
||
link.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
navigatePdfToPage(area, link.getAttribute('data-page'));
|
||
});
|
||
});
|
||
lucide.createIcons();
|
||
return;
|
||
}
|
||
|
||
// Handle Excel .xlsx — editable table view (display / edit / download)
|
||
if (data.is_xlsx) {
|
||
renderXlsxViewer(area, data);
|
||
return;
|
||
}
|
||
|
||
// Handle CSV — same spreadsheet viewer, flat mode (#153 A16): the backend
|
||
// renders the grid with data-cell refs and PUT …/csv/save persists edits.
|
||
if (data.is_csv) {
|
||
renderXlsxViewer(area, data);
|
||
return;
|
||
}
|
||
|
||
// Handle Excalidraw — render in iframe editor
|
||
if (data.is_excalidraw) {
|
||
renderExcalidraw(area, data, data.vault, data.path);
|
||
return;
|
||
}
|
||
|
||
// Handle images — dedicated zoom/pan viewer (roadmap #108-D)
|
||
if (data.is_image) {
|
||
renderImageViewer(area, data);
|
||
return;
|
||
}
|
||
|
||
// Handle audio / video — native HTML5 players (roadmap #109)
|
||
if (data.is_audio) {
|
||
renderAudioViewer(area, data);
|
||
return;
|
||
}
|
||
if (data.is_video) {
|
||
renderVideoViewer(area, data);
|
||
return;
|
||
}
|
||
|
||
// Handle unsupported (binary) files
|
||
if (data.unsupported) {
|
||
const sizeStr = data.size_bytes
|
||
? data.size_bytes < 1024 ? `${data.size_bytes} o`
|
||
: data.size_bytes < 1048576 ? `${(data.size_bytes / 1024).toFixed(1)} Ko`
|
||
: `${(data.size_bytes / 1048576).toFixed(1)} Mo`
|
||
: "";
|
||
area.innerHTML = `
|
||
<div class="unsupported-file">
|
||
<i data-lucide="file" style="width:48px;height:48px"></i>
|
||
<div class="filename">${escapeHtml(data.path.split("/").pop())}</div>
|
||
<div>${data.media_too_large ? escapeHtml(t("viewer.media_too_large")) : "Ce fichier est binaire et ne peut pas être affiché."}</div>
|
||
${sizeStr ? `<div style="font-size:0.85rem;margin-top:4px">Taille : ${sizeStr}</div>` : ""}
|
||
<button class="btn-action" id="unsupported-download-btn">
|
||
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
|
||
</button>
|
||
</div>`;
|
||
lucide.createIcons();
|
||
document.getElementById("unsupported-download-btn").addEventListener("click", () => {
|
||
const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
|
||
window.open(dlUrl, "_blank");
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Breadcrumb
|
||
const parts = data.path.split("/");
|
||
const breadcrumbEls = [];
|
||
breadcrumbEls.push(
|
||
makeBreadcrumbSpan(data.vault, () => {
|
||
focusPathInSidebar(data.vault, "", { alignToTop: "center" });
|
||
}),
|
||
);
|
||
let accumulated = "";
|
||
parts.forEach((part, i) => {
|
||
breadcrumbEls.push(el("span", { class: "sep" }, [document.createTextNode(" / ")]));
|
||
accumulated += (accumulated ? "/" : "") + part;
|
||
const p = accumulated;
|
||
if (i < parts.length - 1) {
|
||
breadcrumbEls.push(
|
||
makeBreadcrumbSpan(part, () => {
|
||
focusPathInSidebar(data.vault, p, { alignToTop: "center" });
|
||
}),
|
||
);
|
||
} else {
|
||
breadcrumbEls.push(
|
||
makeBreadcrumbSpan(part.replace(/\.md$/i, ""), () => {
|
||
focusPathInSidebar(data.vault, data.path, { alignToTop: "center" });
|
||
}),
|
||
);
|
||
}
|
||
});
|
||
|
||
const breadcrumb = el("div", { class: "breadcrumb" }, breadcrumbEls);
|
||
|
||
// Tags
|
||
const tagsDiv = el("div", { class: "file-tags" });
|
||
(data.tags || []).forEach((tag) => {
|
||
if (!TagFilterService.isTagFiltered(tag)) {
|
||
const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]);
|
||
t.addEventListener("click", () => searchByTag(tag));
|
||
tagsDiv.appendChild(t);
|
||
}
|
||
});
|
||
|
||
// Action buttons
|
||
// Cache local au rendu courant: state.cachedRawSource global est fiable
|
||
// uniquement quand openFile() initialise le fichier (il le réinitialise).
|
||
// Or le contenu d'un onglet peut être rendu via renderFile() direct sans
|
||
// passer par openFile() — un cache partagé transporterait le contenu du
|
||
// fichier précédent (bug pretty print sur fichiers successifs).
|
||
let localRaw = null;
|
||
const getRaw = async () => {
|
||
if (localRaw != null) return localRaw;
|
||
if (state.currentVault === data.vault && state.currentPath === data.path && state.cachedRawSource) {
|
||
localRaw = state.cachedRawSource;
|
||
return localRaw;
|
||
}
|
||
const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
|
||
const rawData = await api(rawUrl);
|
||
localRaw = rawData.raw;
|
||
return localRaw;
|
||
};
|
||
const copyBtn = el("button", { class: "btn-action", title: "Copier la source" }, [icon("copy", 14), document.createTextNode("Copier")]);
|
||
copyBtn.addEventListener("click", async () => {
|
||
try {
|
||
const rawSrc = await getRaw();
|
||
if (navigator.clipboard) {
|
||
await navigator.clipboard.writeText(rawSrc);
|
||
} else {
|
||
// Fallback for non-secure contexts
|
||
const ta = document.createElement("textarea");
|
||
ta.value = rawSrc;
|
||
ta.style.position = "fixed"; ta.style.opacity = "0";
|
||
document.body.appendChild(ta); ta.select();
|
||
document.execCommand("copy");
|
||
document.body.removeChild(ta);
|
||
}
|
||
copyBtn.lastChild.textContent = t('viewer.copied');
|
||
setTimeout(() => (copyBtn.lastChild.textContent = t('viewer.copy')), 1500);
|
||
} catch (err) {
|
||
console.error("Copy error:", err);
|
||
showToast(t("viewer.copy_error"), "error");
|
||
}
|
||
});
|
||
|
||
const sourceBtn = el("button", { class: "btn-action", title: "Voir la source" }, [icon("code", 14), document.createTextNode("Source")]);
|
||
|
||
// Download raw file button (extension shown; .md label for markdown)
|
||
const mdBtn = el("button", { class: "btn-action", title: t("viewer.download_file") || t("viewer.download_md") }, [icon("file-text", 14), document.createTextNode(data.extension || ".md")]);
|
||
mdBtn.addEventListener("click", () => {
|
||
const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
|
||
const a = document.createElement("a");
|
||
a.href = dlUrl;
|
||
a.download = data.path.split("/").pop();
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
});
|
||
|
||
// PDF download button
|
||
const pdfBtn = el("button", { class: "btn-action", title: t("viewer.download_pdf") }, [icon("file", 14), document.createTextNode("PDF")]);
|
||
pdfBtn.addEventListener("click", () => {
|
||
const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf?path=${encodeURIComponent(data.path)}`;
|
||
window.open(pdfUrl, "_blank");
|
||
});
|
||
|
||
// Export dropdown button (HTML / MD bundle / ePub)
|
||
const exportWrap = el("div", { class: "export-dropdown", style: "position:relative;display:inline-block" });
|
||
const exportBtn = el("button", { class: "btn-action", title: t("viewer.export_title") }, [icon("download", 14), document.createTextNode(t("viewer.export"))]);
|
||
const exportMenu = el("div", { class: "export-menu", style: "display:none;position:absolute;right:0;top:100%;z-index:1000;min-width:200px;padding:4px;background:var(--bg,#fff);border:1px solid #ccc;border-radius:6px;box-shadow:0 6px 16px rgba(0,0,0,.18)" });
|
||
const exportOptions = [
|
||
{ format: "html", icon: "file-code", label: t("viewer.export_html"), ext: ".html" },
|
||
{ format: "md-bundle", icon: "archive", label: t("viewer.export_md_bundle"), ext: ".zip" },
|
||
{ format: "epub", icon: "book-open", label: t("viewer.export_epub"), ext: ".epub" },
|
||
];
|
||
exportOptions.forEach((opt) => {
|
||
const item = el("button", { class: "export-menu-item", style: "display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;border:none;background:none;cursor:pointer;border-radius:4px;text-align:left;font-size:13px" }, [
|
||
icon(opt.icon, 14),
|
||
document.createTextNode(opt.label),
|
||
]);
|
||
item.addEventListener("mouseenter", () => { item.style.background = "rgba(128,128,128,.15)"; });
|
||
item.addEventListener("mouseleave", () => { item.style.background = "none"; });
|
||
item.addEventListener("click", () => {
|
||
exportMenu.style.display = "none";
|
||
const baseName = data.path.split("/").pop().replace(/\.[^.]+$/, "") || "document";
|
||
downloadExport(data.vault, data.path, opt.format, baseName + opt.ext);
|
||
});
|
||
exportMenu.appendChild(item);
|
||
});
|
||
exportBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
exportMenu.style.display = exportMenu.style.display === "block" ? "none" : "block";
|
||
});
|
||
exportWrap.appendChild(exportBtn);
|
||
exportWrap.appendChild(exportMenu);
|
||
|
||
const editBtn = el("button", { class: "btn-action", title: "Editer" }, [icon("edit", 14), document.createTextNode("Editer")]);
|
||
editBtn.addEventListener("click", () => {
|
||
openEditor(data.vault, data.path);
|
||
});
|
||
|
||
const forgeBtn = el("button", { class: "btn-action btn-forge", title: t("viewer.forge_title") }, [icon("hammer", 14), document.createTextNode("Forge")]);
|
||
forgeBtn.addEventListener("click", () => {
|
||
var modal = document.getElementById("editor-modal");
|
||
var bodyEl = document.getElementById("editor-body");
|
||
// Set editor state so parent buttons work
|
||
state.editorVault = data.vault;
|
||
state.editorPath = data.path;
|
||
// #93 — Forge replaces the document read view when this file is the one
|
||
// currently displayed; otherwise it falls back to the overlay.
|
||
var inline = activateInlineEditor(data.vault, data.path);
|
||
if (!inline) {
|
||
// A stale inline session (another document) must release the area first.
|
||
detachInlineEditor();
|
||
state.editorVault = data.vault;
|
||
state.editorPath = data.path;
|
||
}
|
||
// Update header: show "Forge" brand + filename (the header travels with the
|
||
// container in inline mode — query it wherever it lives, #93)
|
||
var brand = queryEditor(".editor-brand");
|
||
var titleInp = document.getElementById("editor-title-input");
|
||
var fileInp = document.getElementById("editor-filename-input");
|
||
if (brand) brand.textContent = t("viewer.forge_brand");
|
||
if (titleInp) { titleInp.value = data.title || ''; titleInp.placeholder = t('forge.title_placeholder'); }
|
||
if (fileInp) fileInp.value = data.path.split('/').pop();
|
||
bodyEl.innerHTML = "";
|
||
bodyEl.style.overflow = "hidden";
|
||
var iframe = document.createElement("iframe");
|
||
iframe.id = "forge-iframe";
|
||
iframe.src = "/editor-poc?vault=" + encodeURIComponent(data.vault) + "&path=" + encodeURIComponent(data.path) + "&_ts=" + Date.now();
|
||
// #101 — allow the Forge editor's own Fullscreen button to work.
|
||
iframe.setAttribute("allow", "fullscreen");
|
||
iframe.setAttribute("allowfullscreen", "");
|
||
// Inline: the iframe fills the content area; overlay: the original 82vh.
|
||
iframe.style.cssText = "width:100%;height:" + (inline ? "100%" : "82vh") + ";border:none;display:block;";
|
||
bodyEl.appendChild(iframe);
|
||
modal.classList.add("active");
|
||
safeCreateIcons();
|
||
});
|
||
|
||
const openNewWindowBtn = el("button", { class: "btn-action", title: t("forge.popout") }, [icon("external-link", 14), document.createTextNode(t("forge.popout_label"))]);
|
||
openNewWindowBtn.addEventListener("click", () => {
|
||
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
|
||
const popoutUrl = `/popout/${encodeURIComponent(data.vault)}/${encodeURIComponent(data.path)}?theme=${theme}`;
|
||
window.open(popoutUrl, `popout_${data.vault}_${data.path.replace(/[^a-zA-Z0-9]/g, "_")}`, "width=1000,height=700,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no");
|
||
});
|
||
|
||
const tocBtn = el("button", { class: "btn-action", id: "toc-toggle-btn", title: "Afficher/Masquer le sommaire" }, [icon("list", 14), document.createTextNode("TOC")]);
|
||
tocBtn.addEventListener("click", () => {
|
||
RightSidebarManager.toggle();
|
||
});
|
||
|
||
// Share button — check if already shared
|
||
const shareBtn = el("button", { class: "btn-action btn-share", title: "Partager ce document" }, [icon("share-2", 14), document.createTextNode("Partager")]);
|
||
// Check if already shared and color the button
|
||
(async () => {
|
||
try {
|
||
const shares = await api("/api/shares");
|
||
if (shares.some(s => s.vault === data.vault && s.path === data.path)) {
|
||
shareBtn.classList.add("shared");
|
||
shareBtn.title = t('viewer.shared_doc_title');
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
})();
|
||
shareBtn.addEventListener("click", () => openShareDialog(data.vault, data.path));
|
||
|
||
// Bookmark button — check if already bookmarked
|
||
const bookmarkBtn = el("button", { class: "btn-action btn-bookmark", title: t("viewer.bookmark_toggle") }, [icon("bookmark-plus", 14), document.createTextNode("Bookmark")]);
|
||
// Check bookmark status and color the button
|
||
(async () => {
|
||
try {
|
||
const bms = await api("/api/bookmarks");
|
||
if (Array.isArray(bms) && bms.some(b => b.vault === data.vault && b.path === data.path)) {
|
||
bookmarkBtn.classList.add("active");
|
||
bookmarkBtn.title = t("viewer.bookmark_remove");
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
})();
|
||
bookmarkBtn.addEventListener("click", async () => {
|
||
try {
|
||
const res = await api("/api/bookmarks/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vault: data.vault, path: data.path, title: data.title }) });
|
||
bookmarkBtn.classList.toggle("active", res.bookmarked);
|
||
bookmarkBtn.title = res.bookmarked ? t("viewer.bookmark_remove") : t("viewer.bookmark_add");
|
||
showToast(res.bookmarked ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success");
|
||
if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load();
|
||
} catch (err) { showToast("Erreur: " + err.message, "error"); }
|
||
});
|
||
|
||
// Frontmatter — Accent Card
|
||
let fmSection = null;
|
||
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
|
||
fmSection = buildFrontmatterCard(data.frontmatter);
|
||
}
|
||
|
||
// Content container (rendered HTML)
|
||
const mdDiv = el("div", { class: "md-content", id: "file-rendered-content" });
|
||
mdDiv.innerHTML = data.html;
|
||
|
||
// Raw source container (hidden initially)
|
||
const rawDiv = el("div", { class: "raw-source-view", id: "file-raw-content", style: "display:none" });
|
||
|
||
// Source button toggle logic
|
||
sourceBtn.addEventListener("click", async () => {
|
||
const rendered = document.getElementById("file-rendered-content");
|
||
const raw = document.getElementById("file-raw-content");
|
||
if (!rendered || !raw) return;
|
||
|
||
state.showingSource = !state.showingSource;
|
||
if (state.showingSource) {
|
||
sourceBtn.classList.add("active");
|
||
raw.textContent = await getRaw();
|
||
rendered.style.display = "none";
|
||
raw.style.display = "block";
|
||
} else {
|
||
sourceBtn.classList.remove("active");
|
||
rendered.style.display = "block";
|
||
raw.style.display = "none";
|
||
}
|
||
});
|
||
|
||
// Pretty print — mode lecture enrichi (numeros de ligne + coloration)
|
||
// pour les fichiers texte non-markdown (code, JSON, configs, logs...)
|
||
let prettyBtn = null;
|
||
const isTextViewerFile = !data.is_markdown && !data.is_csv && !data.unsupported && !data.is_pdf && !data.is_image && !data.is_excalidraw;
|
||
if (isTextViewerFile) {
|
||
prettyBtn = el("button", { class: "btn-action", title: t("viewer.pretty_title") || "Mode formaté avec couleurs et numéros de ligne" }, [icon("sparkles", 14), document.createTextNode(t("viewer.pretty") || "Pretty")]);
|
||
prettyBtn.addEventListener("click", async () => {
|
||
const on = !prettyBtn.classList.contains("active");
|
||
if (on) {
|
||
let rawText = "";
|
||
try {
|
||
rawText = await getRaw();
|
||
} catch (_) {
|
||
rawText = mdDiv.textContent || "";
|
||
}
|
||
mdDiv.innerHTML = buildPrettyHtml(data, rawText);
|
||
const codeEl = mdDiv.querySelector("code");
|
||
applyPrettyHighlight(codeEl, detectPrettyLang(data), rawText);
|
||
prettyBtn.classList.add("active");
|
||
} else {
|
||
mdDiv.innerHTML = data.html;
|
||
mdDiv.querySelectorAll("pre code").forEach((block) => safeHighlight(block));
|
||
prettyBtn.classList.remove("active");
|
||
}
|
||
});
|
||
}
|
||
|
||
// Separateur visuel entre les groupes d'actions (#90)
|
||
const actionSep = () => el("span", { class: "action-sep" });
|
||
// Ordre des boutons par groupes fonctionnels (#90) :
|
||
// markdown : [TOC][pop-out][Bookmark] | [Editer][Source][.md][Forge] | [Copier][PDF][Export] | [Partager]
|
||
// texte autre : [pop-out][Bookmark] | [Editer][Source][.ext][Pretty] | [Copier] | [Partager]
|
||
// Boutons non applicables au type de fichier (TOC hors markdown, Pretty hors
|
||
// texte, PDF/Export hors markdown, ...) restent ancres a leur groupe ;
|
||
// spacer de groupe vide retire.
|
||
const navBtns = [];
|
||
if (data.is_markdown) navBtns.push(tocBtn);
|
||
navBtns.push(openNewWindowBtn, bookmarkBtn);
|
||
const editBtns = [];
|
||
if (!data.unsupported) editBtns.push(editBtn);
|
||
editBtns.push(sourceBtn);
|
||
if (!data.is_image) editBtns.push(mdBtn);
|
||
if (data.is_markdown) editBtns.push(forgeBtn);
|
||
if (prettyBtn) editBtns.push(prettyBtn);
|
||
const exportBtns = [copyBtn];
|
||
if (data.is_markdown) exportBtns.push(pdfBtn, exportWrap);
|
||
const shareBtns = [shareBtn];
|
||
const fileActions = [];
|
||
[navBtns, editBtns, exportBtns, shareBtns].forEach((group) => {
|
||
if (group.length === 0) return;
|
||
if (fileActions.length > 0) fileActions.push(actionSep());
|
||
group.forEach((b) => fileActions.push(b));
|
||
});
|
||
|
||
// Assemble
|
||
area.innerHTML = "";
|
||
area.appendChild(breadcrumb);
|
||
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv]));
|
||
// #115 — the action bar is a direct child of the scroll container (wrapped in
|
||
// `.file-toolbar`) so it can stay pinned while long documents scroll; nested
|
||
// inside `.file-header` it would stop sticking as soon as the header left the
|
||
// viewport.
|
||
area.appendChild(el("div", { class: "file-toolbar" }, [el("div", { class: "file-actions" }, fileActions)]));
|
||
if (fmSection) area.appendChild(fmSection);
|
||
area.appendChild(mdDiv);
|
||
area.appendChild(rawDiv);
|
||
|
||
// Backlinks panel
|
||
if (data.is_markdown) {
|
||
renderBacklinksPanel(data.vault, data.path, area);
|
||
}
|
||
|
||
// Highlight code blocks
|
||
area.querySelectorAll("pre code").forEach((block) => {
|
||
safeHighlight(block);
|
||
});
|
||
|
||
// Add copy buttons to code blocks
|
||
area.querySelectorAll("pre").forEach((pre) => {
|
||
if (pre.parentElement?.classList.contains("code-block-wrapper")) return;
|
||
const wrapper = el("div", { class: "code-block-wrapper" }, []);
|
||
wrapper.style.position = "relative";
|
||
pre.replaceWith(wrapper);
|
||
wrapper.appendChild(pre);
|
||
|
||
const btn = el("button", { class: "copy-code-btn", title: "Copier le code" }, [document.createTextNode("Copier")]);
|
||
btn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
const code = pre.querySelector("code")?.textContent || pre.textContent;
|
||
const copied = copyToClipboard(code);
|
||
if (copied) {
|
||
btn.textContent = "✓ Copié";
|
||
btn.classList.add("copied");
|
||
if (typeof showToast === "function") showToast("Code copié dans le presse-papiers", "success");
|
||
setTimeout(() => { btn.textContent = "Copier"; btn.classList.remove("copied"); }, 2000);
|
||
} else {
|
||
if (typeof showToast === "function") showToast("Erreur lors de la copie", "error");
|
||
}
|
||
});
|
||
wrapper.appendChild(btn);
|
||
});
|
||
|
||
// Render Mermaid diagrams
|
||
renderMermaidBlocks(area);
|
||
|
||
// Wire up wikilinks
|
||
area.querySelectorAll(".wikilink").forEach((link) => {
|
||
link.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
const v = link.getAttribute("data-vault");
|
||
const p = link.getAttribute("data-path");
|
||
if (v && p) TabManager.openPreview(v, p);
|
||
});
|
||
});
|
||
|
||
safeCreateIcons();
|
||
area.scrollTop = 0;
|
||
|
||
// Initialize outline/TOC for this document
|
||
OutlineManager.init();
|
||
|
||
// Plugin hooks: onFileRender — allow plugins to transform the rendered
|
||
// content or append custom widgets beneath it. Async + non-blocking so a
|
||
// slow/failed plugin never delays the normal file render.
|
||
if (data.content && data.is_markdown) {
|
||
onFileRender(data.vault, data.content, data.path).then((fr) => {
|
||
if (!fr) return;
|
||
if (typeof fr.replace === 'string' && fr.replace !== data.content) {
|
||
const rendered = document.getElementById('file-rendered-content');
|
||
if (rendered) rendered.innerHTML = fr.replace;
|
||
}
|
||
if (Array.isArray(fr.append) && fr.append.length > 0) {
|
||
const out = el('div', { class: 'plugin-render-output' });
|
||
fr.append.forEach((html) => {
|
||
const w = el('div', { class: 'plugin-widget' });
|
||
w.innerHTML = html;
|
||
out.appendChild(w);
|
||
});
|
||
area.appendChild(out);
|
||
safeCreateIcons();
|
||
}
|
||
}).catch(() => { /* plugins must never break rendering */ });
|
||
}
|
||
}
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers (escapeHtml imported from utils.js)
|
||
// ---------------------------------------------------------------------------
|
||
export function el(tag, attrs, children) {
|
||
const e = document.createElement(tag);
|
||
if (attrs) {
|
||
Object.entries(attrs).forEach(([k, v]) => {
|
||
// Skip boolean false for standard HTML boolean attributes to avoid setAttribute("checked", "false") bug
|
||
if (v === false && (k === "checked" || k === "disabled" || k === "hidden" || k === "required" || k === "readonly")) {
|
||
return;
|
||
}
|
||
e.setAttribute(k, v);
|
||
});
|
||
}
|
||
if (children) {
|
||
children.forEach((c) => {
|
||
if (c) e.appendChild(c);
|
||
});
|
||
}
|
||
return e;
|
||
}
|
||
|
||
export function icon(name, size) {
|
||
const i = document.createElement("i");
|
||
i.setAttribute("data-lucide", name);
|
||
i.style.width = size + "px";
|
||
i.style.height = size + "px";
|
||
i.classList.add("icon");
|
||
return i;
|
||
}
|
||
|
||
export function smallBadge(count) {
|
||
const s = document.createElement("span");
|
||
s.className = "badge-small";
|
||
s.style.cssText = "font-size:0.68rem;color:var(--text-muted);margin-left:4px";
|
||
s.textContent = `(${count})`;
|
||
return s;
|
||
}
|
||
|
||
function getContextMenuPositionFromElement(target) {
|
||
const rect = target.getBoundingClientRect();
|
||
return {
|
||
x: Math.min(rect.right - 8, window.innerWidth - 16),
|
||
y: Math.min(rect.top + rect.height / 2, window.innerHeight - 16),
|
||
};
|
||
}
|
||
|
||
export function attachTreeItemActionButton(itemEl, vault, path, type, isReadonly) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "tree-item-action-btn";
|
||
button.setAttribute("aria-label", "Afficher le menu d’actions");
|
||
button.setAttribute("title", "Actions");
|
||
const iconEl = icon("more-vertical", 16);
|
||
button.appendChild(iconEl);
|
||
button.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const pos = getContextMenuPositionFromElement(button);
|
||
ContextMenuManager.show(pos.x, pos.y, vault, path, type, isReadonly);
|
||
});
|
||
itemEl.appendChild(button);
|
||
// Ensure Lucide icons are rendered for the button
|
||
setTimeout(() => {
|
||
safeCreateIcons();
|
||
}, 0);
|
||
}
|
||
|
||
export function attachTreeItemLongPress(itemEl, getMenuData) {
|
||
let pressTimer = null;
|
||
let pressHandled = false;
|
||
let startX = 0;
|
||
let startY = 0;
|
||
const longPressDelay = 550;
|
||
const moveThreshold = 10;
|
||
|
||
const clearPressTimer = () => {
|
||
if (pressTimer) {
|
||
clearTimeout(pressTimer);
|
||
pressTimer = null;
|
||
}
|
||
};
|
||
|
||
itemEl.addEventListener("touchstart", (e) => {
|
||
if (!e.touches || e.touches.length !== 1) return;
|
||
pressHandled = false;
|
||
startX = e.touches[0].clientX;
|
||
startY = e.touches[0].clientY;
|
||
clearPressTimer();
|
||
pressTimer = setTimeout(() => {
|
||
const data = getMenuData();
|
||
if (!data) return;
|
||
pressHandled = true;
|
||
ContextMenuManager.show(startX, startY, data.vault, data.path, data.type, data.isReadonly);
|
||
}, longPressDelay);
|
||
}, { passive: true });
|
||
|
||
itemEl.addEventListener("touchmove", (e) => {
|
||
if (!e.touches || e.touches.length !== 1) return;
|
||
const dx = Math.abs(e.touches[0].clientX - startX);
|
||
const dy = Math.abs(e.touches[0].clientY - startY);
|
||
if (dx > moveThreshold || dy > moveThreshold) {
|
||
clearPressTimer();
|
||
}
|
||
}, { passive: true });
|
||
|
||
itemEl.addEventListener("touchend", () => {
|
||
clearPressTimer();
|
||
}, { passive: true });
|
||
|
||
itemEl.addEventListener("touchcancel", () => {
|
||
clearPressTimer();
|
||
}, { passive: true });
|
||
|
||
itemEl.addEventListener("click", (e) => {
|
||
if (pressHandled) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setTimeout(() => {
|
||
pressHandled = false;
|
||
}, 0);
|
||
}
|
||
}, true);
|
||
}
|
||
|
||
export function getVaultIcon(vaultName, size = 16) {
|
||
const v = state.allVaults.find((val) => val.name === vaultName);
|
||
const type = v ? v.type : "VAULT";
|
||
|
||
if (type === "DIR") {
|
||
const i = icon("folder", size);
|
||
i.style.color = "#eab308"; // yellow tint
|
||
return i;
|
||
} else {
|
||
const purple = "#8b5cf6";
|
||
const svgNS = "http://www.w3.org/2000/svg";
|
||
const svg = document.createElementNS(svgNS, "svg");
|
||
svg.setAttribute("xmlns", svgNS);
|
||
svg.setAttribute("width", size);
|
||
svg.setAttribute("height", size);
|
||
svg.setAttribute("viewBox", "0 0 24 24");
|
||
svg.setAttribute("fill", "none");
|
||
svg.setAttribute("stroke", purple);
|
||
svg.setAttribute("stroke-width", "2");
|
||
svg.setAttribute("stroke-linecap", "round");
|
||
svg.setAttribute("stroke-linejoin", "round");
|
||
svg.classList.add("icon");
|
||
|
||
const path1 = document.createElementNS(svgNS, "path");
|
||
path1.setAttribute("d", "M6 3h12l4 6-10 12L2 9z");
|
||
const path2 = document.createElementNS(svgNS, "path");
|
||
path2.setAttribute("d", "M11 3 8 9l4 12");
|
||
const path3 = document.createElementNS(svgNS, "path");
|
||
path3.setAttribute("d", "M12 21l4-12-3-6");
|
||
const path4 = document.createElementNS(svgNS, "path");
|
||
path4.setAttribute("d", "M2 9h20");
|
||
|
||
svg.appendChild(path1);
|
||
svg.appendChild(path2);
|
||
svg.appendChild(path3);
|
||
svg.appendChild(path4);
|
||
return svg;
|
||
}
|
||
}
|
||
|
||
function makeBreadcrumbSpan(text, onClick) {
|
||
const s = document.createElement("span");
|
||
s.textContent = text;
|
||
if (onClick) {
|
||
s.addEventListener("click", async (event) => {
|
||
event.preventDefault();
|
||
if (s.dataset.busy === "true") return;
|
||
s.dataset.busy = "true";
|
||
s.style.pointerEvents = "none";
|
||
try {
|
||
await onClick(event);
|
||
} finally {
|
||
s.dataset.busy = "false";
|
||
s.style.pointerEvents = "";
|
||
}
|
||
});
|
||
}
|
||
return s;
|
||
}
|
||
|
||
export function appendHighlightedText(container, text, query, caseSensitive) {
|
||
container.textContent = "";
|
||
if (!query) {
|
||
container.appendChild(document.createTextNode(text));
|
||
return;
|
||
}
|
||
|
||
const source = caseSensitive ? text : text.toLowerCase();
|
||
const needle = caseSensitive ? query : query.toLowerCase();
|
||
let start = 0;
|
||
let index = source.indexOf(needle, start);
|
||
|
||
if (index === -1) {
|
||
container.appendChild(document.createTextNode(text));
|
||
return;
|
||
}
|
||
|
||
while (index !== -1) {
|
||
if (index > start) {
|
||
container.appendChild(document.createTextNode(text.slice(start, index)));
|
||
}
|
||
const mark = el("mark", { class: "filter-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]);
|
||
container.appendChild(mark);
|
||
start = index + query.length;
|
||
index = source.indexOf(needle, start);
|
||
}
|
||
|
||
if (start < text.length) {
|
||
container.appendChild(document.createTextNode(text.slice(start)));
|
||
}
|
||
}
|
||
|
||
export function highlightSearchText(container, text, query, caseSensitive) {
|
||
container.textContent = "";
|
||
if (!query || !text) {
|
||
container.appendChild(document.createTextNode(text || ""));
|
||
return;
|
||
}
|
||
|
||
const source = caseSensitive ? text : text.toLowerCase();
|
||
const needle = caseSensitive ? query : query.toLowerCase();
|
||
let start = 0;
|
||
let index = source.indexOf(needle, start);
|
||
|
||
if (index === -1) {
|
||
container.appendChild(document.createTextNode(text));
|
||
return;
|
||
}
|
||
|
||
while (index !== -1) {
|
||
if (index > start) {
|
||
container.appendChild(document.createTextNode(text.slice(start, index)));
|
||
}
|
||
const mark = el("mark", { class: "search-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]);
|
||
container.appendChild(mark);
|
||
start = index + query.length;
|
||
index = source.indexOf(needle, start);
|
||
}
|
||
|
||
if (start < text.length) {
|
||
container.appendChild(document.createTextNode(text.slice(start)));
|
||
}
|
||
}
|
||
|
||
// #158 — the static « Raccourcis & Astuces » block lives in index.html inside
|
||
// #dashboard-panel-stats; showWelcome() rebuilds #dashboard-home from a
|
||
// template that omits it, so the block disappeared after the first rebuild.
|
||
// Capture it once, at module load (before any rebuild can destroy it).
|
||
const QUICK_HELP_HTML = (() => {
|
||
if (typeof document === "undefined") return "";
|
||
const qh = document.getElementById("quick-help");
|
||
return qh ? qh.outerHTML : "";
|
||
})();
|
||
|
||
export function showWelcome() {
|
||
hideProgressBar();
|
||
|
||
// #158 — the dashboard takes over the content area: drop the active-tab
|
||
// marker so clicking a tab re-renders it instead of early-returning.
|
||
if (window.TabManager && window.TabManager.deactivate) window.TabManager.deactivate();
|
||
|
||
// Restore or rebuild the dashboard with tabbed sections
|
||
const area = getContentArea();
|
||
// #110 — keep playing media alive if the dashboard replaces its surface.
|
||
NowPlaying.handleRender(area, null);
|
||
const home = document.getElementById("dashboard-home");
|
||
|
||
if (area && !home) {
|
||
area.innerHTML = `
|
||
<div id="dashboard-home" class="dashboard-home" role="region" aria-label="Tableau de bord">
|
||
<!-- Dashboard Tabs -->
|
||
<div class="dashboard-tabs">
|
||
<button class="dashboard-tab active" data-tab="stats" data-i18n="dashboard.stats">
|
||
<i data-lucide="bar-chart-3" style="width:14px;height:14px"></i> ${t('dashboard.stats')}
|
||
</button>
|
||
<button class="dashboard-tab" data-tab="bookmarks" data-i18n="dashboard.bookmarks">
|
||
<i data-lucide="bookmark" style="width:14px;height:14px"></i> ${t('dashboard.bookmarks')}
|
||
</button>
|
||
<button class="dashboard-tab" data-tab="recent" data-i18n="dashboard.tab_recent">
|
||
<i data-lucide="clock" style="width:14px;height:14px"></i> ${t('dashboard.tab_recent')}
|
||
</button>
|
||
<button class="dashboard-tab" data-tab="shared" data-i18n="dashboard.tab_shared">
|
||
<i data-lucide="share-2" style="width:14px;height:14px"></i> ${t('dashboard.tab_shared')}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Stats Panel -->
|
||
<div id="dashboard-panel-stats" class="dashboard-panel active">
|
||
<div id="dashboard-stats-grid" class="dashboard-stats-grid">
|
||
<div class="dashboard-stats-loading" data-i18n="common.loading">${t('common.loading')}</div>
|
||
</div>
|
||
<div id="dashboard-conflicts-container" style="margin-top:16px"></div>
|
||
${QUICK_HELP_HTML}
|
||
</div>
|
||
|
||
<!-- Bookmarks Panel -->
|
||
<div id="dashboard-panel-bookmarks" class="dashboard-panel">
|
||
<div id="dashboard-bookmarks-grid" class="dashboard-recent-grid"></div>
|
||
<div id="dashboard-bookmarks-empty" class="dashboard-recent-empty">
|
||
<i data-lucide="pin"></i>
|
||
<span data-i18n="dashboard.no_bookmarks">${t('dashboard.no_bookmarks')}</span>
|
||
<p data-i18n="dashboard.pin_hint">${t('dashboard.pin_hint')}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Recent Panel -->
|
||
<div id="dashboard-panel-recent" class="dashboard-panel">
|
||
<div class="dashboard-header">
|
||
<div class="dashboard-title-row">
|
||
<span id="dashboard-count" class="dashboard-badge"></span>
|
||
</div>
|
||
</div>
|
||
<div id="dashboard-recent-grid" class="dashboard-recent-grid"></div>
|
||
<div id="dashboard-loading" class="dashboard-loading">
|
||
<div class="skeleton-card"></div><div class="skeleton-card"></div><div class="skeleton-card"></div>
|
||
<div class="skeleton-card"></div><div class="skeleton-card"></div><div class="skeleton-card"></div>
|
||
</div>
|
||
<div id="dashboard-recent-empty" class="dashboard-recent-empty hidden">
|
||
<i data-lucide="inbox"></i>
|
||
<span data-i18n="dashboard.no_recent">${t('dashboard.no_recent')}</span>
|
||
<p data-i18n="dashboard.recent_hint">${t('dashboard.recent_hint')}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Shared Panel -->
|
||
<div id="dashboard-panel-shared" class="dashboard-panel">
|
||
<div id="dashboard-shared-grid" class="dashboard-recent-grid"></div>
|
||
<div id="dashboard-shared-empty" class="dashboard-recent-empty">
|
||
<i data-lucide="share-2"></i>
|
||
<span data-i18n="dashboard.no_shares">${t('dashboard.no_shares')}</span>
|
||
<p data-i18n="dashboard.shared_hint">${t('dashboard.shared_hint')}</p>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
// Re-initialize widgets and dashboard tabs
|
||
if (typeof DashboardRecentWidget !== "undefined") {
|
||
DashboardRecentWidget.init();
|
||
}
|
||
initDashboardTabs();
|
||
safeCreateIcons();
|
||
} else if (home) {
|
||
// Dashboard already exists, show it with default tab
|
||
home.style.display = "";
|
||
// Reset tabs to default
|
||
document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active"));
|
||
document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active"));
|
||
const defaultTab = document.querySelector('.dashboard-tab[data-tab="stats"]');
|
||
const defaultPanel = document.getElementById("dashboard-panel-stats");
|
||
if (defaultTab) defaultTab.classList.add("active");
|
||
if (defaultPanel) defaultPanel.classList.add("active");
|
||
}
|
||
|
||
// Load all widgets (they handle missing elements gracefully)
|
||
if (typeof DashboardStatsWidget !== "undefined") {
|
||
DashboardStatsWidget.load();
|
||
}
|
||
if (typeof DashboardConflictsWidget !== "undefined") {
|
||
DashboardConflictsWidget.load();
|
||
}
|
||
if (typeof DashboardRecentWidget !== "undefined") {
|
||
DashboardRecentWidget.load(state.selectedContextVault);
|
||
}
|
||
if (typeof DashboardBookmarkWidget !== "undefined") {
|
||
DashboardBookmarkWidget.load(state.selectedContextVault);
|
||
}
|
||
if (typeof DashboardSharedWidget !== "undefined") {
|
||
DashboardSharedWidget.load();
|
||
}
|
||
|
||
// Load saved searches sidebar
|
||
loadSavedSearches();
|
||
}
|
||
|
||
export async function loadSavedSearches() {
|
||
const list = document.getElementById("saved-searches-list");
|
||
const empty = document.getElementById("saved-searches-empty");
|
||
if (!list) return;
|
||
try {
|
||
const searches = await api("/api/saved-searches");
|
||
// Initialise la barre de filtres même quand la liste est vide
|
||
// (sinon les pills n'ont pas de listener au clic).
|
||
_initSavedFilter();
|
||
if (!searches.length) {
|
||
list.innerHTML = "";
|
||
if (empty) empty.style.display = "";
|
||
return;
|
||
}
|
||
if (empty) empty.style.display = "none";
|
||
list.innerHTML = searches.map(s => {
|
||
// Detect directory-based saved search (empty query + include_paths)
|
||
var isDirSearch = (!s.query || s.query.trim() === '') && s.include_paths;
|
||
var displayQuery = isDirSearch ? (s.include_paths || 'Dossier') : escapeHtml(s.query);
|
||
// Icon: folder-search for directories, search for regular queries
|
||
var typeIcon = isDirSearch
|
||
? '<i data-lucide="folder-search" style="width:14px;height:14px;color:var(--accent)" class="saved-search-type-icon"></i>'
|
||
: '<i data-lucide="search" style="width:14px;height:14px;color:var(--text-muted)" class="saved-search-type-icon"></i>';
|
||
const badges = [];
|
||
if (s.case_sensitive) badges.push('<span class="search-filter-badge">Aa</span>');
|
||
if (s.whole_word) badges.push('<span class="search-filter-badge">wd</span>');
|
||
if (s.regex) badges.push('<span class="search-filter-badge">.*</span>');
|
||
const pathFilters = [];
|
||
if (s.include_paths) pathFilters.push(`<span class="saved-search-path" title="Inclure: ${escapeHtml(s.include_paths)}">📥 ${escapeHtml(s.include_paths)}</span>`);
|
||
if (s.exclude_paths) pathFilters.push(`<span class="saved-search-path" title="Exclure: ${escapeHtml(s.exclude_paths)}">📤 ${escapeHtml(s.exclude_paths)}</span>`);
|
||
const vaultStr = s.vault && s.vault !== "all" ? `<span class="saved-search-vault">📁 ${escapeHtml(s.vault)}</span>` : "";
|
||
return `
|
||
<div class="saved-search-item${isDirSearch ? ' saved-search-dir' : ''}" data-type="${isDirSearch ? 'directory' : 'search'}">
|
||
<div class="saved-search-query">${displayQuery}</div>
|
||
<div class="saved-search-meta">
|
||
<span class="saved-search-badges">${badges.join("")} ${vaultStr}</span>
|
||
${typeIcon}
|
||
</div>
|
||
${pathFilters.length ? '<div class="saved-search-filters">' + pathFilters.join(" ") + '</div>' : ""}
|
||
<button class="saved-search-delete" data-id="${s.id}" title="Supprimer">✕</button>
|
||
</div>
|
||
`}).join("");
|
||
// Apply saved filter
|
||
_applySavedFilter();
|
||
// Set up filter bar (once)
|
||
_initSavedFilter();
|
||
list.querySelectorAll(".saved-search-item").forEach(item => {
|
||
item.addEventListener("click", (e) => {
|
||
if (e.target.classList.contains("saved-search-delete")) return;
|
||
const idx = Array.from(list.children).indexOf(item);
|
||
const s = searches[idx];
|
||
if (!s) return;
|
||
// Apply the saved search
|
||
const input = document.getElementById("search-input");
|
||
if (input) input.value = s.query;
|
||
state.searchCaseSensitive = s.case_sensitive || false;
|
||
state.searchWholeWord = s.whole_word || false;
|
||
state.searchRegex = s.regex || false;
|
||
if (typeof _updateToggleUI === "function") _updateToggleUI();
|
||
if (s.include_paths) {
|
||
const incl = document.getElementById("search-include-input");
|
||
if (incl) incl.value = s.include_paths;
|
||
}
|
||
if (s.exclude_paths) {
|
||
const excl = document.getElementById("search-exclude-input");
|
||
if (excl) excl.value = s.exclude_paths;
|
||
}
|
||
// Execute the search — suppress dropdown from appearing
|
||
AutocompleteDropdown.hide();
|
||
AutocompleteDropdown._suppressNext = true;
|
||
const vault = s.vault || "all";
|
||
if (input) { input.dispatchEvent(new Event("input")); }
|
||
clearTimeout(state.searchTimeout);
|
||
state.advancedSearchOffset = 0;
|
||
performAdvancedSearch(s.query, vault, null);
|
||
});
|
||
});
|
||
list.querySelectorAll(".saved-search-delete").forEach(b => b.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
await api(`/api/saved-searches/${b.dataset.id}`, { method: "DELETE" });
|
||
loadSavedSearches();
|
||
}));
|
||
safeCreateIcons();
|
||
} catch (err) { /* silently ignore */ }
|
||
}
|
||
|
||
export function showLoading() {
|
||
const area = getContentArea();
|
||
area.innerHTML = `
|
||
<div class="loading-indicator">
|
||
<div class="loading-spinner"></div>
|
||
<div>Recherche en cours...</div>
|
||
</div>`;
|
||
showProgressBar();
|
||
}
|
||
|
||
export function showProgressBar() {
|
||
const bar = document.getElementById("search-progress-bar");
|
||
if (bar) bar.classList.add("active");
|
||
}
|
||
|
||
export function hideProgressBar() {
|
||
const bar = document.getElementById("search-progress-bar");
|
||
if (bar) bar.classList.remove("active");
|
||
}
|
||
|
||
function goHome() {
|
||
const searchInput = document.getElementById("search-input");
|
||
if (searchInput) searchInput.value = "";
|
||
|
||
document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active"));
|
||
|
||
state.currentVault = null;
|
||
state.currentPath = null;
|
||
state.showingSource = false;
|
||
state.cachedRawSource = null;
|
||
|
||
closeMobileSidebar();
|
||
showWelcome();
|
||
}
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SSE Client — IndexUpdateManager
|
||
// ---------------------------------------------------------------------------
|
||
const IndexUpdateManager = (() => {
|
||
let eventSource = null;
|
||
let reconnectTimer = null;
|
||
let reconnectDelay = 1000;
|
||
const MAX_RECONNECT_DELAY = 30000;
|
||
let recentEvents = [];
|
||
const MAX_RECENT_EVENTS = 20;
|
||
let connectionState = "disconnected"; // disconnected | connecting | connected
|
||
|
||
function connect() {
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
}
|
||
connectionState = "connecting";
|
||
_updateBadge();
|
||
|
||
eventSource = new EventSource("/api/events");
|
||
|
||
eventSource.addEventListener("connected", (e) => {
|
||
connectionState = "connected";
|
||
reconnectDelay = 1000;
|
||
_updateBadge();
|
||
});
|
||
|
||
eventSource.addEventListener("index_updated", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_updated", data);
|
||
_onIndexUpdated(data);
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("index_reloaded", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_reloaded", data);
|
||
_onIndexReloaded(data);
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("vault_added", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("vault_added", data);
|
||
showToast(`Vault "${data.vault}" ajouté (${data.stats.file_count} fichiers)`, "info");
|
||
loadVaults();
|
||
loadTags();
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("vault_removed", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("vault_removed", data);
|
||
showToast(`Vault "${data.vault}" supprimé`, "info");
|
||
loadVaults();
|
||
loadTags();
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("index_start", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_start", data);
|
||
connectionState = "syncing";
|
||
_updateBadge();
|
||
showToast(`Indexation démarrée (${data.total_vaults} vaults)`, "info");
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("index_progress", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_progress", data);
|
||
connectionState = "syncing";
|
||
_updateBadge();
|
||
loadVaults();
|
||
loadTags();
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("index_complete", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_complete", data);
|
||
connectionState = "connected";
|
||
_updateBadge();
|
||
showToast(`Indexation terminée (${data.total_files} fichiers)`, "success");
|
||
loadVaults();
|
||
loadTags();
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.onerror = () => {
|
||
connectionState = "disconnected";
|
||
_updateBadge();
|
||
eventSource.close();
|
||
eventSource = null;
|
||
_scheduleReconnect();
|
||
};
|
||
}
|
||
|
||
function _scheduleReconnect() {
|
||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||
reconnectTimer = setTimeout(() => {
|
||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||
connect();
|
||
}, reconnectDelay);
|
||
}
|
||
|
||
function _addEvent(type, data) {
|
||
recentEvents.unshift({
|
||
type,
|
||
data,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
if (recentEvents.length > MAX_RECENT_EVENTS) {
|
||
recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS);
|
||
}
|
||
}
|
||
|
||
async function _onIndexUpdated(data) {
|
||
// Brief syncing state
|
||
connectionState = "syncing";
|
||
_updateBadge();
|
||
|
||
const n = data.total_changes || 0;
|
||
const vaults = (data.vaults || []).join(", ");
|
||
// Toast removed: silent auto-indexing — no notification needed
|
||
|
||
// Refresh sidebar and tags if affected vault matches current context
|
||
const affectsCurrentVault = state.selectedContextVault === "all" || (data.vaults || []).includes(state.selectedContextVault);
|
||
if (affectsCurrentVault) {
|
||
try {
|
||
await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]);
|
||
// Refresh current file if it was updated
|
||
if (state.currentVault && state.currentPath) {
|
||
const changed = (data.changes || []).some((c) => c.vault === state.currentVault && c.path === state.currentPath);
|
||
if (changed) {
|
||
openFile(state.currentVault, state.currentPath);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Error refreshing after index update:", err);
|
||
}
|
||
}
|
||
|
||
// Refresh recent tab if it is active
|
||
if (state.activeSidebarTab === "recent") {
|
||
const vaultFilter = document.getElementById("recent-vault-filter");
|
||
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
|
||
}
|
||
|
||
setTimeout(() => {
|
||
connectionState = "connected";
|
||
_updateBadge();
|
||
}, 1500);
|
||
}
|
||
|
||
async function _onIndexReloaded(data) {
|
||
connectionState = "syncing";
|
||
_updateBadge();
|
||
showToast(t("sync.index_reloaded"), "info");
|
||
try {
|
||
await Promise.all([loadVaults(), loadTags()]);
|
||
} catch (err) {
|
||
console.error("Error refreshing after full reload:", err);
|
||
}
|
||
setTimeout(() => {
|
||
connectionState = "connected";
|
||
_updateBadge();
|
||
}, 1500);
|
||
}
|
||
|
||
function _updateBadge() {
|
||
const badge = document.getElementById("sync-badge");
|
||
if (!badge) return;
|
||
badge.className = "sync-badge sync-badge--" + connectionState;
|
||
const labels = {
|
||
disconnected: "Déconnecté",
|
||
connecting: "Connexion...",
|
||
connected: "Synchronisé",
|
||
syncing: t("viewer.syncing"),
|
||
};
|
||
badge.title = labels[connectionState] || connectionState;
|
||
}
|
||
|
||
function disconnect() {
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
eventSource = null;
|
||
}
|
||
if (reconnectTimer) {
|
||
clearTimeout(reconnectTimer);
|
||
reconnectTimer = null;
|
||
}
|
||
connectionState = "disconnected";
|
||
_updateBadge();
|
||
}
|
||
|
||
function getState() {
|
||
return connectionState;
|
||
}
|
||
|
||
function getRecentEvents() {
|
||
return recentEvents;
|
||
}
|
||
|
||
return { connect, disconnect, getState, getRecentEvents };
|
||
})();
|
||
|
||
function initSyncStatus() {
|
||
const badge = document.getElementById("sync-badge");
|
||
if (!badge) return;
|
||
|
||
badge.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
toggleSyncPanel();
|
||
});
|
||
|
||
IndexUpdateManager.connect();
|
||
}
|
||
|
||
function toggleSyncPanel() {
|
||
let panel = document.getElementById("sync-panel");
|
||
if (panel) {
|
||
panel.remove();
|
||
return;
|
||
}
|
||
// Auto reconnect if disconnected when user opens the panel
|
||
if (IndexUpdateManager.getState() === "disconnected") {
|
||
IndexUpdateManager.connect();
|
||
}
|
||
panel = document.createElement("div");
|
||
panel.id = "sync-panel";
|
||
panel.className = "sync-panel";
|
||
_renderSyncPanel(panel);
|
||
document.body.appendChild(panel);
|
||
|
||
// Close on outside click
|
||
setTimeout(() => {
|
||
document.addEventListener("click", _closeSyncPanelOutside, { once: true });
|
||
}, 0);
|
||
}
|
||
|
||
function _closeSyncPanelOutside(e) {
|
||
const panel = document.getElementById("sync-panel");
|
||
if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") {
|
||
panel.remove();
|
||
}
|
||
}
|
||
|
||
function _renderSyncPanel(panel) {
|
||
const state = IndexUpdateManager.getState();
|
||
const events = IndexUpdateManager.getRecentEvents();
|
||
|
||
const stateLabels = {
|
||
disconnected: "Déconnecté",
|
||
connecting: "Connexion...",
|
||
connected: "Connecté",
|
||
syncing: "Synchronisation...",
|
||
};
|
||
|
||
let html = `<div class="sync-panel__header">
|
||
<span class="sync-panel__title">Synchronisation</span>
|
||
<span class="sync-panel__state sync-panel__state--${state}">${stateLabels[state] || state}</span>
|
||
</div>`;
|
||
|
||
if (events.length === 0) {
|
||
html += `<div class="sync-panel__empty">Aucun événement récent</div>`;
|
||
} else {
|
||
html += `<div class="sync-panel__events">`;
|
||
events.slice(0, 10).forEach((ev) => {
|
||
const time = new Date(ev.timestamp).toLocaleTimeString();
|
||
const typeLabels = {
|
||
index_updated: t("viewer.index_updated"),
|
||
index_reloaded: "Rechargement",
|
||
vault_added: t("sync.vault_added_label"),
|
||
vault_removed: t("sync.vault_removed_label"),
|
||
index_start: t("viewer.index_start"),
|
||
index_progress: t("sync.vault_indexed_label"),
|
||
index_complete: "Indexation tech.",
|
||
};
|
||
const label = typeLabels[ev.type] || ev.type;
|
||
let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || "";
|
||
if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`;
|
||
if (ev.type === "index_progress") detail = `${ev.data.vault} (${ev.data.files} fichiers)`;
|
||
if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`;
|
||
html += `<div class="sync-panel__event">
|
||
<span class="sync-panel__event-type">${label}</span>
|
||
<span class="sync-panel__event-detail">${detail}</span>
|
||
<span class="sync-panel__event-time">${time}</span>
|
||
</div>`;
|
||
});
|
||
html += `</div>`;
|
||
}
|
||
}
|
||
|
||
// ── Saved searches filter ──
|
||
var _savedFilterType = 'all';
|
||
var _savedFilterInitDone = false;
|
||
var _savedQuery = '';
|
||
|
||
function _savedNorm(value) {
|
||
return String(value || '')
|
||
.normalize('NFD')
|
||
.replace(/[\u0300-\u036f]/g, '')
|
||
.toLowerCase();
|
||
}
|
||
|
||
function _initSavedFilter() {
|
||
if (_savedFilterInitDone) return;
|
||
var bar = document.getElementById('saved-filter-bar');
|
||
if (!bar) return;
|
||
bar.querySelectorAll('.saved-filter-pill').forEach(function(btn) {
|
||
btn.addEventListener('click', function() {
|
||
bar.querySelectorAll('.saved-filter-pill').forEach(function(b) { b.classList.remove('active'); });
|
||
btn.classList.add('active');
|
||
_savedFilterType = btn.dataset.filter;
|
||
_applySavedFilter();
|
||
});
|
||
});
|
||
_savedFilterInitDone = true;
|
||
}
|
||
|
||
/** #99 — Filter the saved-searches list by the global sidebar filter input. */
|
||
export function filterSavedSearches(query) {
|
||
_savedQuery = (query || '').trim();
|
||
_applySavedFilter();
|
||
}
|
||
|
||
function _applySavedFilter() {
|
||
var items = document.querySelectorAll('.saved-search-item');
|
||
var list = document.getElementById('saved-searches-list');
|
||
var q = _savedNorm(_savedQuery);
|
||
var hasVisible = false;
|
||
items.forEach(function(item) {
|
||
var type = item.dataset.type;
|
||
var typeOk = _savedFilterType === 'all' || type === _savedFilterType;
|
||
var textOk = !q || _savedNorm(item.textContent).includes(q);
|
||
var show = typeOk && textOk;
|
||
item.style.display = show ? '' : 'none';
|
||
if (show) hasVisible = true;
|
||
});
|
||
// Remove a previous "no match" hint before deciding whether to add one.
|
||
if (list) {
|
||
var prev = list.querySelector('.sidebar-filter-empty');
|
||
if (prev) prev.remove();
|
||
}
|
||
// Show/hide empty state
|
||
var empty = document.getElementById('saved-searches-empty');
|
||
if (empty) empty.style.display = hasVisible ? 'none' : '';
|
||
// Explain an empty result caused by the global search rather than by an
|
||
// actually empty saved-searches list.
|
||
if (!hasVisible && q && list && items.length) {
|
||
var hint = document.createElement('div');
|
||
hint.className = 'sidebar-filter-empty';
|
||
hint.textContent = t('sidebar.no_results');
|
||
list.appendChild(hint);
|
||
}
|
||
}
|
||
|
||
|