From e21bc177e1e4d4b17c148a340e16347fd1a2bcd6 Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 14 Jul 2026 22:34:54 +0200 Subject: [PATCH] v0.3.27-beta: gedeelde bibliotheek in de mappenverkenner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - withSharedBranches (explorer.js): wikkelt een eigen-inhoud-adapter en voegt in de root de vaste takken "🌐 Systeem" (systeembreed) en "🏫 School" (binnen de eigen school) toe - Slepen tussen bomen = kopiëren: eigen bord/diagram of hele map naar een gedeelde tak publiceert (POST /shared, kopie - origineel blijft); gedeeld item of map naar de eigen boom kopieert (borden krijgen nieuw id + verse wids); binnen een tak is slepen echt verplaatsen (PATCH, server bewaakt) - Dubbelklik op een gedeeld bord kopieert en opent het; op een gedeeld ankerdiagram laadt het in het widget - Verkenner ondersteunt nu async adapters (init/mutaties), groene ok-meldingen ("Gedeeld ✓"/"Gekopieerd ✓") en een eigenaar-hint per tegel - Gasten en leerlingen zien alleen de eigen boom --- VERSION | 2 +- public/css/teach.css | 2 + public/js/board.js | 71 ++++++++-- public/js/core.js | 8 +- public/js/explorer.js | 276 +++++++++++++++++++++++++++++++++--- public/js/widgets/anchor.js | 47 +++++- 6 files changed, 375 insertions(+), 31 deletions(-) diff --git a/VERSION b/VERSION index 9683ce5..9c64e37 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.26-beta +0.3.27-beta diff --git a/public/css/teach.css b/public/css/teach.css index 23ac311..ce87f43 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -221,6 +221,8 @@ .ex-sep{color:var(--muted); font-weight:800;} .ex-tools{display:flex; gap:8px; flex-wrap:wrap;} .ex-msg{min-height:18px; color:var(--red); font-size:13px; font-weight:700;} + .ex-msg.ok{color:var(--green, #2c8c4b);} + .ex-hint{font-size:11px; color:var(--muted); font-weight:600;} .ex-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:12px; margin-top:8px; diff --git a/public/js/board.js b/public/js/board.js index 4ec3daf..b77ff56 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -1017,8 +1017,62 @@ function switchTo(f, b){ const btnFolders = document.getElementById("btnFolders"); btnFolders.addEventListener("click", ()=>{ stashCurrent(); /* verse thumbnails, ook van het actieve bord */ - openExplorer(boardsExplorerAdapter()); + openExplorer(withSharedBranches("board", boardsExplorerAdapter(), boardsSharedHooks())); }); +/* koppelstuk voor de gedeelde bibliotheek (zie withSharedBranches in + explorer.js): borden exporteren/importeren als {name, data}. Een kopie + krijgt altijd een nieuw bord-id en verse wids, anders zouden toewijzingen + en voortgang van verschillende gebruikers naar hetzelfde id verwijzen. */ +function freshBoardCopy(name, data){ + const d = data ? structuredClone(data) : null; + if(d && Array.isArray(d.widgets)) d.widgets.forEach(w=>{ w.wid = genWidgetId(); }); + return { id: genBoardId(), name, data: d }; +} +function boardsSharedHooks(){ + const entryAtPath = path => BS.folders.find(f=>joinBoardPath(splitBoardPath(f.name))===joinBoardPath(path)); + const ensureEntry = path => { + let e = entryAtPath(path); + if(!e){ e = { name: joinBoardPath(path), boards: [] }; BS.folders.push(e); } + return e; + }; + const boardData = hit => (hit.fi===BS.f && hit.bi===BS.b) ? serializeBoard() : (hit.bo.data || emptyBoardData()); + return { + exportItem(id){ + const hit = findBoardEntry(id); + return hit ? { name: hit.bo.name, data: boardData(hit) } : null; + }, + exportFolder(path){ + const p = joinBoardPath(path); + const out = []; + BS.folders.forEach((f, fi)=>{ + const j = joinBoardPath(splitBoardPath(f.name)); + if(j === p || j.startsWith(p + "/")){ + const rel = j === p ? [] : j.slice(p.length + 1).split("/"); + f.boards.forEach((bo, bi)=>out.push({ + name: bo.name, rel, + data: (fi===BS.f && bi===BS.b) ? serializeBoard() : (bo.data || emptyBoardData()), + })); + } + }); + return out; + }, + copyIn(item, destPath){ + if(!destPath.length) return T("exNoRootItems"); + ensureEntry(destPath).boards.push(freshBoardCopy(item.name, item.data)); + markBoardChange(); + return null; + }, + openShared(item, relPath){ + const entry = ensureEntry(relPath.length ? relPath : [T("shGlobal")]); + const copy = freshBoardCopy(item.name, item.data); + entry.boards.push(copy); + markBoardChange(); + const hit = findBoardEntry(copy.id); + if(hit) switchTo(hit.fi, hit.bi); + return null; + }, + }; +} function updateBoardsUI(){ btnFolders.title = `${T("boardsNav")} · ${BS.folders[BS.f].name} / ${curSlot().name}`; fabBoardsBtn.title = `${BS.folders[BS.f].name} / ${curSlot().name}`; @@ -1030,16 +1084,17 @@ function updateBoardsUI(){ dus bestaande data en de server (findBoardById op bord-id) blijven werken */ const splitBoardPath = n => n.split("/").map(s=>s.trim()).filter(Boolean); const joinBoardPath = p => p.join("/"); +function findBoardEntry(id){ + for(let fi=0; fib.id===id); + if(bi >= 0) return { fi, bi, fo: BS.folders[fi], bo: BS.folders[fi].boards[bi] }; + } + return null; +} function boardsExplorerAdapter(){ const norm = f => joinBoardPath(splitBoardPath(f.name)); const entryAt = path => BS.folders.find(f=>norm(f)===joinBoardPath(path)); - const findBoard = id => { - for(let fi=0; fib.id===id); - if(bi >= 0) return { fi, bi, fo: BS.folders[fi], bo: BS.folders[fi].boards[bi] }; - } - return null; - }; + const findBoard = findBoardEntry; const activeId = ()=>curSlot().id; /* indices herstellen nadat entries verschoven/verwijderd zijn */ const relocate = (id)=>{ diff --git a/public/js/core.js b/public/js/core.js index a00de88..6887104 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.26-beta"; +const VERSION = "0.3.27-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -85,6 +85,9 @@ const I18N = { exFolderNotEmpty:"Map is niet leeg - verplaats of verwijder eerst de inhoud.", exIntoSelf:"Een map kan niet in zichzelf.", exNoRootItems:"Borden horen in een map.", exLastBoard:"Het laatste bord kan niet weg.", + shGlobal:"Systeem", shSchool:"School", + shPublished:"Gedeeld ✓", shCopied:"Gekopieerd naar je eigen mappen ✓", + shNoCreateHere:"Nieuw maken kan alleen in je eigen mappen - sleep het daarna hierheen om te delen.", hudMin:"Menu inklappen/uitklappen", hudOrient:"Horizontaal of verticaal", wFull:"Maximaliseren", wUnfull:"Terugzetten", wZoom:"Inhoud groter of kleiner (voor slechtzienden)", @@ -261,6 +264,9 @@ const I18N = { exFolderNotEmpty:"Folder is not empty - move or delete its contents first.", exIntoSelf:"A folder cannot go inside itself.", exNoRootItems:"Boards belong in a folder.", exLastBoard:"The last board cannot be deleted.", + shGlobal:"System", shSchool:"School", + shPublished:"Shared ✓", shCopied:"Copied to your own folders ✓", + shNoCreateHere:"Create it in your own folders first - then drag it here to share.", 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 b73f668..2f9d2f4 100644 --- a/public/js/explorer.js +++ b/public/js/explorer.js @@ -37,8 +37,10 @@ let SEL = null; /* selectie: {type:"folder"|"item", key} */ let onCloseCb = null; - window.openExplorer = function(adapter, onClose){ + window.openExplorer = async function(adapter, onClose){ AD = adapter; PATH = []; SEL = null; onCloseCb = onClose || null; + /* adapters met externe data (gedeelde bibliotheek) laden die eerst */ + if(AD.init){ try{ await AD.init(); }catch(e){} } render(); msg(""); wrap.classList.add("open"); @@ -51,7 +53,13 @@ wrap.addEventListener("click", e=>{ if(e.target===wrap) closeExplorer(); }); document.querySelector(".explorer-close").addEventListener("click", closeExplorer); - const msg = t => { msgEl.textContent = t || ""; }; + const msg = t => { msgEl.classList.remove("ok"); msgEl.textContent = t || ""; }; + const msgOk = t => { msgEl.classList.add("ok"); msgEl.textContent = t || ""; }; + /* mutaties geven null (ok), een foutstring, of {ok:"..."} (groene melding) */ + function showResult(res){ + if(res && res.ok) msgOk(res.ok); + else if(res) msg(res); + } const validName = (name)=>{ const n = (name||"").trim(); if(!n) return null; @@ -124,21 +132,21 @@ document.addEventListener("pointerup", up); }); } - function doMove(payload, dest){ + async function doMove(payload, dest){ msg(""); - let err = null; + let res = 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); + res = await AD.moveFolder(src, dest); }else{ if(!dest.length && !AD.allowRootItems){ msg(T("exNoRootItems")); return; } - err = AD.moveItem(payload.key, dest); + res = await AD.moveItem(payload.key, dest); } SEL = null; render(); - if(err) msg(err); + showResult(res); } function tileEl(cls){ @@ -191,17 +199,17 @@ const holder = document.createElement("div"); t.appendChild(holder); gridEl.prepend(t); - inlineInput(holder, "", (n)=>{ - const err = AD.createFolder(PATH, n); + inlineInput(holder, "", async (n)=>{ + const res = await AD.createFolder(PATH, n); render(); - if(err) msg(err); + showResult(res); }); }); if(AD.createItem && (PATH.length || AD.allowRootItems)){ - tool("+ " + AD.newItemLabel(), ()=>{ - const err = AD.createItem(PATH); + tool("+ " + AD.newItemLabel(), async ()=>{ + const res = await AD.createItem(PATH); render(); - if(err) msg(err); + showResult(res); }); } tool("✏ " + T("exRename"), ()=>{ @@ -209,24 +217,24 @@ 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); + inlineInput(t, cur, async (n)=>{ + const res = SEL.type==="folder" ? await AD.renameFolder(PATH, SEL.key, n) : await AD.renameItem(SEL.key, n); SEL = null; render(); - if(err) msg(err); + showResult(res); }); }); - const delBtn = tool("🗑 " + T("exDelete"), ()=>{ + const delBtn = tool("🗑 " + T("exDelete"), async ()=>{ 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); + const res = SEL.type==="folder" ? await AD.deleteFolder(PATH, SEL.key) : await AD.deleteItem(SEL.key); SEL = null; render(); - if(err) msg(err); + showResult(res); }); /* raster: eerst mappen, dan items */ @@ -256,8 +264,17 @@ t.appendChild(ico); } t.appendChild(nameEl(it.name)); + if(it.hint){ + const hn = document.createElement("div"); + hn.className = "ex-hint"; hn.textContent = it.hint; + t.appendChild(hn); + } t.addEventListener("click", ()=>{ SEL = {type:"item", key:it.key}; markSel(t); }); - t.addEventListener("dblclick", ()=>{ AD.openItem(it.key); closeExplorer(); }); + t.addEventListener("dblclick", async ()=>{ + const res = await AD.openItem(it.key); + if(typeof res === "string"){ render(); msg(res); return; } + closeExplorer(); + }); makeDraggable(t, {type:"item", key:it.key, movable: it.movable !== false}); gridEl.appendChild(t); }); @@ -276,3 +293,222 @@ if(db){ db.classList.remove("danger"); db.textContent = "🗑 " + T("exDelete"); } } })(); + +/* ========================================================= + Gedeelde bibliotheek in de verkenner: wikkelt een "eigen inhoud"-adapter + en voegt in de root twee vaste takken toe: 🌐 Systeem (systeembreed, + gepubliceerd door de systeemmanager) en 🏫 School (gedeeld binnen de + eigen school). Slepen tussen de bomen = kopiëren: + eigen item/map → gedeelde tak = publiceren (POST /shared) + gedeeld item/map → eigen boom = kopiëren naar eigen collectie + Binnen een gedeelde tak = echt verplaatsen (PATCH, server bewaakt rechten). + hooks (per soort inhoud): + exportItem(key) -> {name, data} | null + exportFolder(path) -> [{name, rel:[...], data}] (alles onder het pad) + copyIn(sharedItem, destPath) -> err|null (gedeeld item in eigen boom zetten) + openShared(sharedItem, relPath)-> err|null (dubbelklik op gedeeld item) +==========================================================*/ +function withSharedBranches(kind, own, hooks){ + /* gasten en leerlingen zien alleen de eigen boom */ + if(!currentUser || currentUser.role === "pupil") return own; + const GLOBAL = "🌐 " + T("shGlobal"); + const SCHOOL = "🏫 " + T("shSchool"); + const branches = [GLOBAL]; + if(currentUser.schoolId != null) branches.push(SCHOOL); + const scopeOf = seg => seg === GLOBAL ? "global" : "school"; + const split = n => (n||"").split("/").map(s=>s.trim()).filter(Boolean); + const join = p => p.join("/"); + let cache = []; + const pending = { global: new Set(), school: new Set() }; + const inScope = scope => cache.filter(it=>it.scope===scope); + const isShared = path => path.length > 0 && branches.includes(path[0]); + const refresh = async ()=>{ cache = (await api("/shared/"+kind)).items || []; }; + const wrapErr = e => e && e.message ? e.message : String(e); + + async function publishOne(scope, folder, name, data){ + await api("/shared/"+kind, { method:"POST", body:{ name, folder, data, scope } }); + } + async function fetchShared(id){ + return (await api(`/shared/${kind}/${id}`)).item; + } + + return { + title: own.title, + allowRootItems: own.allowRootItems, + newItemLabel: own.newItemLabel, + async init(){ try{ await refresh(); }catch(e){ cache = []; } }, + list(path){ + if(!isShared(path)){ + const r = own.list(path); + if(path.length === 0){ + branches.forEach(b=>r.folders.push({ name:b, count: inScope(scopeOf(b)).length, fixed:true })); + } + return r; + } + const scope = scopeOf(path[0]); + const sub = path.slice(1); + const p = join(sub); + const folderMap = new Map(); + const items = []; + const seen = new Set(pending[scope]); + inScope(scope).forEach(it=>{ + const j = join(split(it.folder)); + if(j === p){ + items.push({ + key: "sh:"+it.id, name: it.name, + icon: kind === "board" ? "▦" : "⚓", + hint: it.ownerName || undefined, + }); + }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 count = inScope(scope).filter(it=>{ const ij = join(split(it.folder)); return ij === full || ij.startsWith(full + "/"); }).length; + folderMap.set(seg, count); + }); + const folders = [...folderMap].map(([name, count])=>({name, count})) + .sort((a,b)=>a.name.localeCompare(b.name)); + return { folders, items }; + }, + createItem: own.createItem ? (path)=>{ + if(isShared(path)) return T("shNoCreateHere"); + return own.createItem(path); + } : undefined, + createFolder(path, name){ + if(!isShared(path)){ + if(path.length === 0 && branches.includes(name)) return T("exExists"); + return own.createFolder(path, name); + } + const scope = scopeOf(path[0]); + if(this.list(path).folders.some(f=>f.name===name)) return T("exExists"); + pending[scope].add(join([...path.slice(1), name])); + return null; + }, + async renameFolder(path, oldName, newName){ + if(!isShared(path) && !(path.length === 0 && branches.includes(oldName))) + return own.renameFolder(path, oldName, newName); + return this.moveFolder([...path, oldName], path, newName); + }, + async moveFolder(srcPath, targetPath, newName){ + const srcShared = isShared(srcPath), tgtShared = isShared(targetPath); + if(srcPath.length === 1 && branches.includes(srcPath[0])) return T("exFixedFolder"); + if(!srcShared && !tgtShared) return own.moveFolder(srcPath, targetPath, newName); + const last = newName || srcPath[srcPath.length-1]; + try{ + if(srcShared && tgtShared && scopeOf(srcPath[0]) === scopeOf(targetPath[0])){ + /* binnen dezelfde tak: echte verplaatsing via prefix-rewrite */ + const scope = scopeOf(srcPath[0]); + const from = join(srcPath.slice(1)); + const to = join([...targetPath.slice(1), last]); + if(from === to) return null; + await api(`/shared/${kind}/folder`, { method:"PATCH", body:{ scope, from, to } }); + [...pending[scope]].forEach(j=>{ + if(j === from || j.startsWith(from + "/")){ pending[scope].delete(j); pending[scope].add(to + j.slice(from.length)); } + }); + await refresh(); + return null; + } + if(!srcShared && tgtShared){ + /* hele eigen map publiceren (kopie; het origineel blijft staan) */ + const scope = scopeOf(targetPath[0]); + const base = [...targetPath.slice(1), last]; + const list = hooks.exportFolder(srcPath); + for(const it of list) await publishOne(scope, join([...base, ...it.rel]), it.name, it.data); + await refresh(); + return { ok: T("shPublished") }; + } + /* gedeelde map → eigen boom (of andere tak): itemsgewijs kopiëren */ + const scope = scopeOf(srcPath[0]); + const from = join(srcPath.slice(1)); + const under = inScope(scope).filter(it=>{ const j = join(split(it.folder)); return j === from || j.startsWith(from + "/"); }); + for(const it of under){ + const full = await fetchShared(it.id); + const rel = split(it.folder).slice(srcPath.length - 1); + if(tgtShared){ + await publishOne(scopeOf(targetPath[0]), join([...targetPath.slice(1), last, ...rel]), full.name, full.data); + }else{ + const err = hooks.copyIn(full, [...targetPath, last, ...rel]); + if(err) return err; + } + } + if(tgtShared) await refresh(); + return { ok: tgtShared ? T("shPublished") : T("shCopied") }; + }catch(e){ return wrapErr(e); } + }, + async deleteFolder(path, name){ + if(!isShared(path)){ + if(path.length === 0 && branches.includes(name)) return T("exFixedFolder"); + return own.deleteFolder(path, name); + } + const scope = scopeOf(path[0]); + const full = join([...path.slice(1), name]); + if(inScope(scope).some(it=>{ const j = join(split(it.folder)); return j === full || j.startsWith(full + "/"); })) + return T("exFolderNotEmpty"); + [...pending[scope]].forEach(j=>{ if(j === full || j.startsWith(full + "/")) pending[scope].delete(j); }); + return null; + }, + async renameItem(key, name){ + if(typeof key !== "string" || !key.startsWith("sh:")) return own.renameItem(key, name); + try{ + await api(`/shared/${kind}/${key.slice(3)}`, { method:"PATCH", body:{ name } }); + await refresh(); + return null; + }catch(e){ return wrapErr(e); } + }, + async moveItem(key, dest){ + const srcShared = typeof key === "string" && key.startsWith("sh:"); + const tgtShared = isShared(dest); + try{ + if(!srcShared && !tgtShared) return own.moveItem(key, dest); + if(!srcShared && tgtShared){ + /* eigen item publiceren (kopie) */ + const exp = hooks.exportItem(key); + if(!exp) return null; + const scope = scopeOf(dest[0]); + await publishOne(scope, join(dest.slice(1)), exp.name, exp.data); + pending[scope].delete(join(dest.slice(1))); + await refresh(); + return { ok: T("shPublished") }; + } + const id = key.slice(3); + if(srcShared && tgtShared){ + const meta = cache.find(it=>"sh:"+it.id === key); + if(meta && scopeOf(dest[0]) === meta.scope){ + await api(`/shared/${kind}/${id}`, { method:"PATCH", body:{ folder: join(dest.slice(1)) } }); + pending[meta.scope].delete(join(dest.slice(1))); + await refresh(); + return null; + } + const full = await fetchShared(id); + await publishOne(scopeOf(dest[0]), join(dest.slice(1)), full.name, full.data); + await refresh(); + return { ok: T("shPublished") }; + } + /* gedeeld item → eigen boom: kopiëren */ + const full = await fetchShared(id); + const err = hooks.copyIn(full, dest); + return err || { ok: T("shCopied") }; + }catch(e){ return wrapErr(e); } + }, + async deleteItem(key){ + if(typeof key !== "string" || !key.startsWith("sh:")) return own.deleteItem(key); + try{ + await api(`/shared/${kind}/${key.slice(3)}`, { method:"DELETE" }); + await refresh(); + return null; + }catch(e){ return wrapErr(e); } + }, + async openItem(key){ + if(typeof key !== "string" || !key.startsWith("sh:")) return own.openItem(key); + try{ + const full = await fetchShared(key.slice(3)); + return hooks.openShared(full, split(full.folder)); + }catch(e){ return wrapErr(e); } + }, + }; +} diff --git a/public/js/widgets/anchor.js b/public/js/widgets/anchor.js index 10f1c59..a78e5c4 100644 --- a/public/js/widgets/anchor.js +++ b/public/js/widgets/anchor.js @@ -344,7 +344,52 @@ function mountAnchor(root, initState, opts){ saveBtn.textContent = "✓"; setTimeout(()=>{ saveBtn.textContent = orig; }, 1200); }); - libBtn.addEventListener("click", ()=>openExplorer(anchorLibAdapter())); + /* 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()))); } new ResizeObserver(render).observe(stage);