From 6d5d4ef52004791dffd0192e29708fb09afc4f60 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 15 Jul 2026 04:12:43 +0200 Subject: [PATCH] v0.3.30-beta: ankerdiagrammen als vaste tak in de hoofdverkenner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nieuwe generieke withBranch-combinator (explorer.js): een tweede adapter verschijnt als vaste map in de root van de hoofdboom; item-sleutels worden geprefixt en slepen tussen de takken wordt geweigerd (ander soort inhoud) - De borden-verkenner (📁 in de menubalk) toont nu "⚓ Ankerdiagrammen" met daarin de eigen diagrammappen én de gedeelde 🌐/🏫-takken voor ankers - borden en ankerwoorden op één plek, met dezelfde regels (slepen, kopiëren, alleen-lezen, één-klik-mappen) - Diagram openen vanuit de hoofdverkenner plaatst een nieuw ankerwidget op het huidige bord met dat diagram geladen; het 🗂-knopje op een widget blijft in dát widget laden (adapter naar module-niveau getild met een onOpen-parameter, gedeeld door beide ingangen) --- VERSION | 2 +- public/js/board.js | 22 ++- public/js/core.js | 6 +- public/js/explorer.js | 76 ++++++++++ public/js/widgets/anchor.js | 292 ++++++++++++++++++------------------ 5 files changed, 250 insertions(+), 148 deletions(-) diff --git a/VERSION b/VERSION index f4b22a8..98e7f45 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.29-beta +0.3.30-beta diff --git a/public/js/board.js b/public/js/board.js index b77ff56..0eac7f9 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -1015,9 +1015,29 @@ function switchTo(f, b){ loadCurrentSlot(); } const btnFolders = document.getElementById("btnFolders"); +/* ankerdiagram uit de bibliotheek openen vanuit de hoofdverkenner: plaatst + een nieuw ankerwidget op het huidige bord met dat diagram erin geladen */ +function openAnchorOnBoard(entry){ + const def = REGISTRY.find(d=>d.id==="anchor"); + makeWidget(def, { state: { + cells: structuredClone(entry.cells || {}), + pics: structuredClone(entry.pics || {}), + }}); + markBoardChange(); + return null; +} btnFolders.addEventListener("click", ()=>{ stashCurrent(); /* verse thumbnails, ook van het actieve bord */ - openExplorer(withSharedBranches("board", boardsExplorerAdapter(), boardsSharedHooks())); + openExplorer(withBranch( + "⚓ " + T("anchorLibTitle"), + withSharedBranches("board", boardsExplorerAdapter(), boardsSharedHooks()), + withSharedBranches("anchor", anchorLibraryAdapter(openAnchorOnBoard), anchorLibrarySharedHooks(openAnchorOnBoard)), + { + wrongTypeMsg: T("exWrongBranch"), + branchCreateMsg: T("anchorLibCreateHint"), + branchCount: ()=>ANCHOR_LIB.length, + } + )); }); /* koppelstuk voor de gedeelde bibliotheek (zie withSharedBranches in explorer.js): borden exporteren/importeren als {name, data}. Een kopie diff --git a/public/js/core.js b/public/js/core.js index df48ed7..acd3fd3 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.3.29-beta"; +const VERSION = "0.3.30-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -90,6 +90,8 @@ const I18N = { shNoCreateHere:"Nieuw maken kan alleen in je eigen mappen - sleep het daarna hierheen om te delen.", shReadonly:"Alleen lezen - kopieer het naar je eigen mappen om te bewerken.", exLock:"Alleen lezen", shLockedOk:"Vergrendeld ✓", shUnlockedOk:"Ontgrendeld ✓", + exWrongBranch:"Dat hoort niet in deze map (borden en ankerdiagrammen zijn gescheiden).", + anchorLibCreateHint:"Nieuw diagram maken: gebruik 💾 op een ankerwidget.", hudMin:"Menu inklappen/uitklappen", hudOrient:"Horizontaal of verticaal", wFull:"Maximaliseren", wUnfull:"Terugzetten", wZoom:"Inhoud groter of kleiner (voor slechtzienden)", @@ -271,6 +273,8 @@ const I18N = { shNoCreateHere:"Create it in your own folders first - then drag it here to share.", shReadonly:"Read-only - copy it to your own folders to edit.", exLock:"Read-only", shLockedOk:"Locked ✓", shUnlockedOk:"Unlocked ✓", + exWrongBranch:"That doesn't belong in this folder (boards and anchor diagrams are separate).", + anchorLibCreateHint:"To create a new diagram: use 💾 on an anchor widget.", hudMin:"Collapse/expand menu", hudOrient:"Horizontal or vertical", wFull:"Maximize", wUnfull:"Restore", wZoom:"Content bigger or smaller (low vision)", diff --git a/public/js/explorer.js b/public/js/explorer.js index af09041..862f79a 100644 --- a/public/js/explorer.js +++ b/public/js/explorer.js @@ -333,6 +333,82 @@ } })(); +/* ========================================================= + Combineert twee adapters tot één boom: sub verschijnt als vaste map + (rootLabel) in de root van main. Slepen tussen de twee takken wordt + geweigerd (ander soort inhoud). Item-sleutels van sub worden geprefixt + zodat ze nooit botsen met die van main. + opts: { wrongTypeMsg, branchCreateMsg, branchCount() } +==========================================================*/ +function withBranch(rootLabel, main, sub, opts){ + const ENC = "br|"; + const enc = k => ENC + String(k); + const dec = k => { const raw = String(k).slice(ENC.length); return /^\d+$/.test(raw) ? +raw : raw; }; + const isEnc = k => typeof k === "string" && k.startsWith(ENC); + const inBranch = path => path.length > 0 && path[0] === rootLabel; + const wrongType = ()=>(opts && opts.wrongTypeMsg) || T("exExists"); + const shiftSel = sel => (sel && sel.type === "item" && isEnc(sel.key)) ? { type:"item", key: dec(sel.key) } : sel; + return { + title: main.title, + allowRootItems: main.allowRootItems, + newItemLabel: main.newItemLabel, + async init(){ + if(main.init) await main.init(); + if(sub.init) await sub.init(); + }, + list(path){ + if(inBranch(path)){ + const r = sub.list(path.slice(1)); + return { folders: r.folders, items: r.items.map(it=>({ ...it, key: enc(it.key) })) }; + } + const r = main.list(path); + if(path.length === 0){ + r.folders.push({ name: rootLabel, count: opts && opts.branchCount ? opts.branchCount() : "", fixed: true }); + } + return r; + }, + createItem: main.createItem ? (path)=>{ + if(inBranch(path)) return (opts && opts.branchCreateMsg) || T("shNoCreateHere"); + return main.createItem(path); + } : undefined, + createFolder(path, name){ + if(inBranch(path)) return sub.createFolder(path.slice(1), name); + if(path.length === 0 && name === rootLabel) return T("exExists"); + return main.createFolder(path, name); + }, + renameFolder(path, oldName, newName){ + if(path.length === 0 && oldName === rootLabel) return T("exFixedFolder"); + return inBranch(path) ? sub.renameFolder(path.slice(1), oldName, newName) : main.renameFolder(path, oldName, newName); + }, + moveFolder(srcPath, targetPath, newName){ + if(srcPath.length === 1 && srcPath[0] === rootLabel) return T("exFixedFolder"); + const s = inBranch(srcPath), t = inBranch(targetPath); + if(s !== t) return wrongType(); + return s ? sub.moveFolder(srcPath.slice(1), targetPath.slice(1), newName) : main.moveFolder(srcPath, targetPath, newName); + }, + deleteFolder(path, name){ + if(path.length === 0 && name === rootLabel) return T("exFixedFolder"); + return inBranch(path) ? sub.deleteFolder(path.slice(1), name) : main.deleteFolder(path, name); + }, + renameItem(k, name){ return isEnc(k) ? sub.renameItem(dec(k), name) : main.renameItem(k, name); }, + moveItem(k, dest){ + const s = isEnc(k), t = inBranch(dest); + if(s !== t) return wrongType(); + return s ? sub.moveItem(dec(k), dest.slice(1)) : main.moveItem(k, dest); + }, + deleteItem(k){ return isEnc(k) ? sub.deleteItem(dec(k)) : main.deleteItem(k); }, + openItem(k){ return isEnc(k) ? sub.openItem(dec(k)) : main.openItem(k); }, + lockLabel(path){ + if(inBranch(path)) return sub.lockLabel ? sub.lockLabel(path.slice(1)) : null; + return main.lockLabel ? main.lockLabel(path) : null; + }, + toggleLock(sel, path){ + if(inBranch(path)) return sub.toggleLock ? sub.toggleLock(shiftSel(sel), path.slice(1)) : null; + return main.toggleLock ? main.toggleLock(sel, path) : null; + }, + }; +} + /* ========================================================= Gedeelde bibliotheek in de verkenner: wikkelt een "eigen inhoud"-adapter en voegt in de root twee vaste takken toe: 🌐 Systeem (systeembreed, diff --git a/public/js/widgets/anchor.js b/public/js/widgets/anchor.js index a78e5c4..76b2124 100644 --- a/public/js/widgets/anchor.js +++ b/public/js/widgets/anchor.js @@ -228,105 +228,18 @@ function mountAnchor(root, initState, opts){ inp.addEventListener("blur", commit); inp.addEventListener("pointerdown", ev=>ev.stopPropagation()); } - /* ---- ankerbibliotheek: diagram los van het bord bewaren en hergebruiken. - Opslag in ANCHOR_LIB (eigen save-sectie "anchors"), beheer via dezelfde - Windows-stijl mappenverkenner als de borden en thema's. */ - function anchorLibAdapter(){ - const split = n => (n||"").split("/").map(s=>s.trim()).filter(Boolean); - const join = p => p.join("/"); - const wordCount = it => Object.keys(it.cells||{}).length; - const pending = new Set(); - const changed = ()=>scheduleSave("anchors"); - return { - title: ()=>T("anchorLibTitle"), - allowRootItems: true, - list(path){ - const p = join(path); - const folderMap = new Map(); - const items = []; - const seen = new Set(pending); - ANCHOR_LIB.forEach((it, i)=>{ - const j = join(split(it.folder)); - if(j === p) items.push({ key: i, name: `${it.name} (${wordCount(it)})`, icon: "⚓" }); - else if(!p || j.startsWith(p + "/")) seen.add(j); - }); - seen.forEach(j=>{ - if(j === p) return; - if(p && !j.startsWith(p + "/")) return; - if(!p && !j) return; - const seg = (p ? j.slice(p.length + 1) : j).split("/")[0]; - if(!seg) return; - const full = p ? p + "/" + seg : seg; - const under = ANCHOR_LIB.filter(it=>{ const ij = join(split(it.folder)); return ij === full || ij.startsWith(full + "/"); }).length; - folderMap.set(seg, under); - }); - const folders = [...folderMap].map(([name, count])=>({name, count})) - .sort((a,b)=>a.name.localeCompare(b.name)); - return { folders, items }; - }, - createFolder(path, name){ - if(this.list(path).folders.some(f=>f.name===name)) return T("exExists"); - pending.add(join([...path, name])); - return null; - }, - renameFolder(path, oldName, newName){ - return this.moveFolder([...path, oldName], path, newName); - }, - moveFolder(srcPath, targetPath, newName){ - const from = join(srcPath); - const to = join([...targetPath, newName || srcPath[srcPath.length-1]]); - if(from === to) return null; - if(ANCHOR_LIB.some(it=>{ const j = join(split(it.folder)); return j === to || j.startsWith(to + "/"); }) || pending.has(to)) return T("exExists"); - ANCHOR_LIB.forEach(it=>{ - const j = join(split(it.folder)); - if(j === from || j.startsWith(from + "/")) it.folder = to + j.slice(from.length); - }); - [...pending].forEach(j=>{ - if(j === from || j.startsWith(from + "/")){ pending.delete(j); pending.add(to + j.slice(from.length)); } - }); - changed(); - return null; - }, - deleteFolder(path, name){ - const full = join([...path, name]); - if(ANCHOR_LIB.some(it=>{ const j = join(split(it.folder)); return j === full || j.startsWith(full + "/"); })) return T("exFolderNotEmpty"); - [...pending].forEach(j=>{ if(j === full || j.startsWith(full + "/")) pending.delete(j); }); - return null; - }, - renameItem(i, name){ - if(!ANCHOR_LIB[i]) return null; - ANCHOR_LIB[i].name = name; - changed(); - return null; - }, - moveItem(i, targetPath){ - const it = ANCHOR_LIB[i]; - if(!it) return null; - if(targetPath.length) it.folder = join(targetPath); else delete it.folder; - pending.delete(join(targetPath)); - changed(); - return null; - }, - deleteItem(i){ - if(!ANCHOR_LIB[i]) return null; - ANCHOR_LIB.splice(i, 1); - changed(); - return null; - }, - /* dubbelklik: bewaard diagram in dít widget laden */ - openItem(i){ - const it = ANCHOR_LIB[i]; - if(!it) return; - S.cells = structuredClone(it.cells || {}); - S.pics = structuredClone(it.pics || {}); - S.pan = {x:0, y:0}; - syncSource(); - render(); - persistWords(); - /* programmatisch geladen: de bord-autosave-listeners zien dit niet */ - markBoardChange(); - }, - }; + /* een bewaard/gedeeld diagram in dít widget laden (dubbelklik in de + verkenner die via het 🗂-knopje van dit widget is geopend) */ + function loadDiagram(entry){ + S.cells = structuredClone(entry.cells || {}); + S.pics = structuredClone(entry.pics || {}); + S.pan = {x:0, y:0}; + syncSource(); + render(); + persistWords(); + /* programmatisch geladen: de bord-autosave-listeners zien dit niet */ + markBoardChange(); + return null; } if(!readonly){ saveBtn.title = T("anchorLibSave"); @@ -344,52 +257,8 @@ function mountAnchor(root, initState, opts){ saveBtn.textContent = "✓"; setTimeout(()=>{ saveBtn.textContent = orig; }, 1200); }); - /* koppelstuk voor de gedeelde bibliotheek: diagrammen als {name, data} */ - const anchorSharedHooks = ()=>{ - const split = n => (n||"").split("/").map(s=>s.trim()).filter(Boolean); - const join = p => p.join("/"); - return { - exportItem(i){ - const it = ANCHOR_LIB[i]; - return it ? { name: it.name, data: { cells: structuredClone(it.cells||{}), pics: structuredClone(it.pics||{}) } } : null; - }, - exportFolder(path){ - const p = join(path); - const out = []; - ANCHOR_LIB.forEach(it=>{ - const j = join(split(it.folder)); - if(j === p || j.startsWith(p + "/")){ - const rel = j === p ? [] : j.slice(p.length + 1).split("/"); - out.push({ name: it.name, rel, data: { cells: structuredClone(it.cells||{}), pics: structuredClone(it.pics||{}) } }); - } - }); - return out; - }, - copyIn(item, destPath){ - const entry = { - id: "al-"+Math.random().toString(36).slice(2,10), - name: item.name, - cells: structuredClone(item.data.cells || {}), - pics: structuredClone(item.data.pics || {}), - }; - if(destPath.length) entry.folder = join(destPath); - ANCHOR_LIB.push(entry); - scheduleSave("anchors"); - return null; - }, - openShared(item){ - S.cells = structuredClone(item.data.cells || {}); - S.pics = structuredClone(item.data.pics || {}); - S.pan = {x:0, y:0}; - syncSource(); - render(); - persistWords(); - markBoardChange(); - return null; - }, - }; - }; - libBtn.addEventListener("click", ()=>openExplorer(withSharedBranches("anchor", anchorLibAdapter(), anchorSharedHooks()))); + libBtn.addEventListener("click", ()=>openExplorer( + withSharedBranches("anchor", anchorLibraryAdapter(loadDiagram), anchorLibrarySharedHooks(loadDiagram)))); } new ResizeObserver(render).observe(stage); @@ -400,3 +269,136 @@ function mountAnchor(root, initState, opts){ return { getState: ()=>({ cells:S.cells, pan:S.pan, pics:S.pics, themeId:S.themeId }) }; } +/* ---- ankerbibliotheek: diagrammen los van een bord bewaren en hergebruiken. + Opslag in ANCHOR_LIB (eigen save-sectie "anchors"), beheer via de + Windows-stijl mappenverkenner. Op module-niveau zodat zowel het 🗂-knopje + op een widget (openen = in dát widget laden) als de vaste ⚓-tak in de + borden-verkenner (openen = nieuw widget op het huidige bord) dezelfde + adapter gebruiken - alleen de onOpen-actie verschilt. */ +function anchorLibraryAdapter(onOpen){ + const split = n => (n||"").split("/").map(s=>s.trim()).filter(Boolean); + const join = p => p.join("/"); + const wordCount = it => Object.keys(it.cells||{}).length; + const pending = new Set(); + const changed = ()=>scheduleSave("anchors"); + return { + title: ()=>T("anchorLibTitle"), + allowRootItems: true, + list(path){ + const p = join(path); + const folderMap = new Map(); + const items = []; + const seen = new Set(pending); + ANCHOR_LIB.forEach((it, i)=>{ + const j = join(split(it.folder)); + if(j === p) items.push({ key: i, name: `${it.name} (${wordCount(it)})`, icon: "⚓" }); + else if(!p || j.startsWith(p + "/")) seen.add(j); + }); + seen.forEach(j=>{ + if(j === p) return; + if(p && !j.startsWith(p + "/")) return; + if(!p && !j) return; + const seg = (p ? j.slice(p.length + 1) : j).split("/")[0]; + if(!seg) return; + const full = p ? p + "/" + seg : seg; + const under = ANCHOR_LIB.filter(it=>{ const ij = join(split(it.folder)); return ij === full || ij.startsWith(full + "/"); }).length; + folderMap.set(seg, under); + }); + const folders = [...folderMap].map(([name, count])=>({name, count})) + .sort((a,b)=>a.name.localeCompare(b.name)); + return { folders, items }; + }, + createFolder(path, name){ + if(this.list(path).folders.some(f=>f.name===name)) return T("exExists"); + pending.add(join([...path, name])); + return null; + }, + renameFolder(path, oldName, newName){ + return this.moveFolder([...path, oldName], path, newName); + }, + moveFolder(srcPath, targetPath, newName){ + const from = join(srcPath); + const to = join([...targetPath, newName || srcPath[srcPath.length-1]]); + if(from === to) return null; + if(ANCHOR_LIB.some(it=>{ const j = join(split(it.folder)); return j === to || j.startsWith(to + "/"); }) || pending.has(to)) return T("exExists"); + ANCHOR_LIB.forEach(it=>{ + const j = join(split(it.folder)); + if(j === from || j.startsWith(from + "/")) it.folder = to + j.slice(from.length); + }); + [...pending].forEach(j=>{ + if(j === from || j.startsWith(from + "/")){ pending.delete(j); pending.add(to + j.slice(from.length)); } + }); + changed(); + return null; + }, + deleteFolder(path, name){ + const full = join([...path, name]); + if(ANCHOR_LIB.some(it=>{ const j = join(split(it.folder)); return j === full || j.startsWith(full + "/"); })) return T("exFolderNotEmpty"); + [...pending].forEach(j=>{ if(j === full || j.startsWith(full + "/")) pending.delete(j); }); + return null; + }, + renameItem(i, name){ + if(!ANCHOR_LIB[i]) return null; + ANCHOR_LIB[i].name = name; + changed(); + return null; + }, + moveItem(i, targetPath){ + const it = ANCHOR_LIB[i]; + if(!it) return null; + if(targetPath.length) it.folder = join(targetPath); else delete it.folder; + pending.delete(join(targetPath)); + changed(); + return null; + }, + deleteItem(i){ + if(!ANCHOR_LIB[i]) return null; + ANCHOR_LIB.splice(i, 1); + changed(); + return null; + }, + openItem(i){ + const it = ANCHOR_LIB[i]; + return it ? onOpen(it) : null; + }, + }; +} +/* koppelstuk voor de gedeelde bibliotheek: diagrammen als {name, data} */ +function anchorLibrarySharedHooks(onOpen){ + const split = n => (n||"").split("/").map(s=>s.trim()).filter(Boolean); + const join = p => p.join("/"); + return { + exportItem(i){ + const it = ANCHOR_LIB[i]; + return it ? { name: it.name, data: { cells: structuredClone(it.cells||{}), pics: structuredClone(it.pics||{}) } } : null; + }, + exportFolder(path){ + const p = join(path); + const out = []; + ANCHOR_LIB.forEach(it=>{ + const j = join(split(it.folder)); + if(j === p || j.startsWith(p + "/")){ + const rel = j === p ? [] : j.slice(p.length + 1).split("/"); + out.push({ name: it.name, rel, data: { cells: structuredClone(it.cells||{}), pics: structuredClone(it.pics||{}) } }); + } + }); + return out; + }, + copyIn(item, destPath){ + const entry = { + id: "al-"+Math.random().toString(36).slice(2,10), + name: item.name, + cells: structuredClone(item.data.cells || {}), + pics: structuredClone(item.data.pics || {}), + }; + if(destPath.length) entry.folder = join(destPath); + ANCHOR_LIB.push(entry); + scheduleSave("anchors"); + return null; + }, + openShared(item){ + return onOpen({ name: item.name, cells: item.data.cells || {}, pics: item.data.pics || {} }); + }, + }; +} +