All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 33s
- 🗂-knop in de woorden-editor opent de Windows-stijl verkenner met de thema's van de actieve taal (★-tegels met woordaantal); dubbelklik selecteert het thema in de editor - Geneste mappen via het folder-veld als pad; nieuwe lege mappen bestaan sessie-lokaal tot er een thema in wordt gezet (mappen zijn afgeleid van de themavelden) - Ankerthema's staan vast in de map "⚓": niet versleepbaar, hernoemen/ verwijderen geweigerd (anchor.js beheert ze) - Het 📁-knopje + inline map-invoer uit v0.3.21 zijn vervangen door de verkenner; de optgroup-weergave in de dropdowns blijft en toont het pad
278 lines
11 KiB
JavaScript
278 lines
11 KiB
JavaScript
/* teach - generieke mappenverkenner (Windows-stijl): één modal met een
|
||
broodkruimelpad, werkbalk (nieuwe map / nieuw item / hernoem / verwijder)
|
||
en een raster van map- en itemtegels. Klik = selecteren, dubbelklik =
|
||
openen, slepen = verplaatsen (pointer-events, werkt ook op touch).
|
||
|
||
Nesting is virtueel: paden zijn arrays van segmenten; de adapter bepaalt
|
||
hoe die op de onderliggende (platte) data worden afgebeeld. Mutaties in de
|
||
adapter geven null terug bij succes of een foutmelding (string) voor de
|
||
gebruiker.
|
||
|
||
Adapter-interface:
|
||
title() - venstertitel
|
||
list(path) - {folders:[{name,count}], items:[{key,name,tile()?,icon?,active?,movable?,deletable?}]}
|
||
createFolder(path, name) - nieuwe (lege) map
|
||
renameFolder(path, oldName, newName)
|
||
moveFolder(srcPath, targetPath)
|
||
deleteFolder(path, name) - alleen als leeg
|
||
createItem?(path) - optioneel: nieuw item in deze map
|
||
newItemLabel?() - label voor de nieuw-item-knop
|
||
allowRootItems - mogen items in de root staan (anders alleen mappen)
|
||
renameItem(key, name)
|
||
moveItem(key, targetPath)
|
||
deleteItem(key)
|
||
openItem(key) - dubbelklik op een item (modal sluit daarna)
|
||
*/
|
||
"use strict";
|
||
(function(){
|
||
const wrap = document.getElementById("explorerWrap");
|
||
const titleEl = document.getElementById("explorerTitle");
|
||
const crumbsEl = document.getElementById("exCrumbs");
|
||
const toolsEl = document.getElementById("exTools");
|
||
const msgEl = document.getElementById("exMsg");
|
||
const gridEl = document.getElementById("exGrid");
|
||
|
||
let AD = null; /* actieve adapter */
|
||
let PATH = []; /* huidig pad (segmenten) */
|
||
let SEL = null; /* selectie: {type:"folder"|"item", key} */
|
||
let onCloseCb = null;
|
||
|
||
window.openExplorer = function(adapter, onClose){
|
||
AD = adapter; PATH = []; SEL = null; onCloseCb = onClose || null;
|
||
render();
|
||
msg("");
|
||
wrap.classList.add("open");
|
||
};
|
||
function closeExplorer(){
|
||
wrap.classList.remove("open");
|
||
AD = null; SEL = null;
|
||
if(onCloseCb){ const cb = onCloseCb; onCloseCb = null; cb(); }
|
||
}
|
||
wrap.addEventListener("click", e=>{ if(e.target===wrap) closeExplorer(); });
|
||
document.querySelector(".explorer-close").addEventListener("click", closeExplorer);
|
||
|
||
const msg = t => { msgEl.textContent = t || ""; };
|
||
const validName = (name)=>{
|
||
const n = (name||"").trim();
|
||
if(!n) return null;
|
||
if(n.includes("/")){ msg(T("exNoSlash")); return null; }
|
||
if(n.length > 20) return n.slice(0,20);
|
||
return n;
|
||
};
|
||
|
||
/* inline invoer in een tegel (nieuwe map / hernoemen) - zelfde
|
||
Enter/blur-commit + Escape-annuleer als de bord-hernoem-inputs */
|
||
function inlineInput(host, initial, commit){
|
||
const inp = document.createElement("input");
|
||
inp.value = initial; inp.maxLength = 20;
|
||
host.innerHTML = "";
|
||
host.appendChild(inp);
|
||
inp.focus(); inp.select();
|
||
let done = false;
|
||
const finish = ok=>{
|
||
if(done) return; done = true;
|
||
if(ok){ const n = validName(inp.value); if(n){ commit(n); return; } }
|
||
render();
|
||
};
|
||
inp.addEventListener("keydown", ev=>{
|
||
ev.stopPropagation();
|
||
if(ev.key==="Enter") finish(true);
|
||
if(ev.key==="Escape") finish(false);
|
||
});
|
||
inp.addEventListener("blur", ()=>finish(true));
|
||
inp.addEventListener("pointerdown", ev=>ev.stopPropagation());
|
||
}
|
||
|
||
/* slepen: tegel volgt de aanwijzer (ghost), maptegels en broodkruimels
|
||
zijn drop-doelen ([data-drop]) en lichten op onder de aanwijzer */
|
||
function makeDraggable(el, payload){
|
||
el.addEventListener("pointerdown", e=>{
|
||
if(e.target.closest("input,button")) return;
|
||
const sx = e.clientX, sy = e.clientY;
|
||
let dragging = false, ghost = null, target = null;
|
||
const move = ev=>{
|
||
if(!dragging){
|
||
if(Math.hypot(ev.clientX-sx, ev.clientY-sy) < 8) return;
|
||
if(payload.movable === false) return;
|
||
dragging = true;
|
||
ghost = el.cloneNode(true);
|
||
ghost.classList.add("ex-ghost");
|
||
document.body.appendChild(ghost);
|
||
}
|
||
ev.preventDefault();
|
||
const zf = VZ();
|
||
ghost.style.left = (ev.clientX/zf + 10) + "px";
|
||
ghost.style.top = (ev.clientY/zf + 10) + "px";
|
||
const under = document.elementFromPoint(ev.clientX, ev.clientY);
|
||
const drop = under && under.closest("[data-drop]");
|
||
if(target && target !== drop) target.classList.remove("ex-drop");
|
||
target = (drop && drop !== el) ? drop : null;
|
||
if(target) target.classList.add("ex-drop");
|
||
};
|
||
const up = ()=>{
|
||
document.removeEventListener("pointermove", move);
|
||
document.removeEventListener("pointerup", up);
|
||
if(!dragging) return;
|
||
ghost.remove();
|
||
if(target){
|
||
target.classList.remove("ex-drop");
|
||
const dest = target.dataset.drop === "" ? [] : target.dataset.drop.split("/");
|
||
doMove(payload, dest);
|
||
}
|
||
};
|
||
document.addEventListener("pointermove", move);
|
||
document.addEventListener("pointerup", up);
|
||
});
|
||
}
|
||
function doMove(payload, dest){
|
||
msg("");
|
||
let err = null;
|
||
if(payload.type === "folder"){
|
||
const src = [...PATH, payload.key];
|
||
/* niet in zichzelf of een submap slepen */
|
||
if(dest.length >= src.length && src.every((s,i)=>dest[i]===s)){ msg(T("exIntoSelf")); return; }
|
||
err = AD.moveFolder(src, dest);
|
||
}else{
|
||
if(!dest.length && !AD.allowRootItems){ msg(T("exNoRootItems")); return; }
|
||
err = AD.moveItem(payload.key, dest);
|
||
}
|
||
SEL = null;
|
||
render();
|
||
if(err) msg(err);
|
||
}
|
||
|
||
function tileEl(cls){
|
||
const d = document.createElement("div");
|
||
d.className = "ex-tile " + cls;
|
||
return d;
|
||
}
|
||
const nameEl = (t)=>{ const n = document.createElement("div"); n.className = "ex-name"; n.textContent = t; return n; };
|
||
|
||
/* let op: render() wist de melding niet - mutatie-handlers zetten hun
|
||
foutmelding juist ná de her-render (anders verdween die meteen weer) */
|
||
function render(){
|
||
if(!AD) return;
|
||
titleEl.textContent = AD.title();
|
||
|
||
/* broodkruimels: 🏠-root + één knop per segment, allemaal drop-doel */
|
||
crumbsEl.innerHTML = "";
|
||
const crumb = (label, path)=>{
|
||
const b = document.createElement("button");
|
||
b.type = "button"; b.className = "ex-crumb";
|
||
b.textContent = label;
|
||
b.dataset.drop = path.join("/");
|
||
b.addEventListener("click", ()=>{ PATH = path; SEL = null; render(); msg(""); });
|
||
crumbsEl.appendChild(b);
|
||
return b;
|
||
};
|
||
crumb("🏠", []);
|
||
PATH.forEach((seg, i)=>{
|
||
const sep = document.createElement("span");
|
||
sep.className = "ex-sep"; sep.textContent = "›";
|
||
crumbsEl.appendChild(sep);
|
||
crumb(seg, PATH.slice(0, i+1));
|
||
});
|
||
|
||
/* werkbalk */
|
||
toolsEl.innerHTML = "";
|
||
const tool = (label, fn)=>{
|
||
const b = document.createElement("button");
|
||
b.type = "button"; b.className = "tbtn ghost";
|
||
b.textContent = label;
|
||
b.addEventListener("click", fn);
|
||
toolsEl.appendChild(b);
|
||
return b;
|
||
};
|
||
tool("+ " + T("exFolder"), ()=>{
|
||
const t = tileEl("folder");
|
||
const ico = document.createElement("div");
|
||
ico.className = "ex-ico"; ico.textContent = "📁";
|
||
t.appendChild(ico);
|
||
const holder = document.createElement("div");
|
||
t.appendChild(holder);
|
||
gridEl.prepend(t);
|
||
inlineInput(holder, "", (n)=>{
|
||
const err = AD.createFolder(PATH, n);
|
||
render();
|
||
if(err) msg(err);
|
||
});
|
||
});
|
||
if(AD.createItem && (PATH.length || AD.allowRootItems)){
|
||
tool("+ " + AD.newItemLabel(), ()=>{
|
||
const err = AD.createItem(PATH);
|
||
render();
|
||
if(err) msg(err);
|
||
});
|
||
}
|
||
tool("✏ " + T("exRename"), ()=>{
|
||
if(!SEL){ msg(T("exSelectFirst")); return; }
|
||
const t = gridEl.querySelector(".ex-tile.sel .ex-name");
|
||
if(!t) return;
|
||
const cur = SEL.type==="folder" ? SEL.key : t.textContent;
|
||
inlineInput(t, cur, (n)=>{
|
||
const err = SEL.type==="folder" ? AD.renameFolder(PATH, SEL.key, n) : AD.renameItem(SEL.key, n);
|
||
SEL = null;
|
||
render();
|
||
if(err) msg(err);
|
||
});
|
||
});
|
||
const delBtn = tool("🗑 " + T("exDelete"), ()=>{
|
||
if(!SEL){ msg(T("exSelectFirst")); return; }
|
||
if(!delBtn.classList.contains("danger")){
|
||
delBtn.classList.add("danger");
|
||
delBtn.textContent = "🗑 " + T("exConfirm");
|
||
return;
|
||
}
|
||
const err = SEL.type==="folder" ? AD.deleteFolder(PATH, SEL.key) : AD.deleteItem(SEL.key);
|
||
SEL = null;
|
||
render();
|
||
if(err) msg(err);
|
||
});
|
||
|
||
/* raster: eerst mappen, dan items */
|
||
gridEl.innerHTML = "";
|
||
const { folders, items } = AD.list(PATH);
|
||
folders.forEach(f=>{
|
||
const t = tileEl("folder" + (SEL && SEL.type==="folder" && SEL.key===f.name ? " sel" : ""));
|
||
t.dataset.drop = [...PATH, f.name].join("/");
|
||
const ico = document.createElement("div");
|
||
ico.className = "ex-ico"; ico.textContent = "📁";
|
||
t.appendChild(ico);
|
||
t.appendChild(nameEl(f.name));
|
||
const c = document.createElement("div");
|
||
c.className = "ex-count"; c.textContent = f.count;
|
||
t.appendChild(c);
|
||
t.addEventListener("click", ()=>{ SEL = {type:"folder", key:f.name}; markSel(t); });
|
||
t.addEventListener("dblclick", ()=>{ PATH = [...PATH, f.name]; SEL = null; render(); msg(""); });
|
||
makeDraggable(t, {type:"folder", key:f.name, movable: f.fixed !== true});
|
||
gridEl.appendChild(t);
|
||
});
|
||
items.forEach(it=>{
|
||
const t = tileEl("item" + (it.active ? " active-item" : "") + (SEL && SEL.type==="item" && SEL.key===it.key ? " sel" : ""));
|
||
if(it.tile){ t.appendChild(it.tile()); }
|
||
else{
|
||
const ico = document.createElement("div");
|
||
ico.className = "ex-ico"; ico.textContent = it.icon || "📄";
|
||
t.appendChild(ico);
|
||
}
|
||
t.appendChild(nameEl(it.name));
|
||
t.addEventListener("click", ()=>{ SEL = {type:"item", key:it.key}; markSel(t); });
|
||
t.addEventListener("dblclick", ()=>{ AD.openItem(it.key); closeExplorer(); });
|
||
makeDraggable(t, {type:"item", key:it.key, movable: it.movable !== false});
|
||
gridEl.appendChild(t);
|
||
});
|
||
if(!folders.length && !items.length){
|
||
const d = document.createElement("div");
|
||
d.className = "guestnote"; d.textContent = T("exEmpty");
|
||
gridEl.appendChild(d);
|
||
}
|
||
}
|
||
/* selectie visueel bijwerken zonder volledige her-render (behoudt dblclick) */
|
||
function markSel(tile){
|
||
gridEl.querySelectorAll(".ex-tile.sel").forEach(x=>x.classList.remove("sel"));
|
||
tile.classList.add("sel");
|
||
/* een nieuwe selectie ont-wapent de verwijderknop */
|
||
const db = [...toolsEl.children].find(b=>b.classList.contains("danger"));
|
||
if(db){ db.classList.remove("danger"); db.textContent = "🗑 " + T("exDelete"); }
|
||
}
|
||
})();
|