Verbeter anatomie en plaatsing van avataraccessoires (v0.4.46-beta) #1
20 changed files with 4862 additions and 4745 deletions
946
public/css/teach.css
Normal file
946
public/css/teach.css
Normal file
|
|
@ -0,0 +1,946 @@
|
||||||
|
/* teach - alle opmaak */
|
||||||
|
:root{
|
||||||
|
--bg:#eef3f8; --panel:#ffffff; --ink:#26344a; --muted:#7b8aa3;
|
||||||
|
--blue:#3b7dd8; --blue-d:#2b62b0; --red:#e0554d; --green:#3fae6a;
|
||||||
|
--yellow:#f7c948; --purple:#9b59b6; --orange:#f28c38;
|
||||||
|
--wood:#f6e7c9; --wood-d:#e8d2a4;
|
||||||
|
--radius:16px; --shadow:0 10px 30px rgba(30,50,90,.18);
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box; -webkit-tap-highlight-color:transparent;}
|
||||||
|
*::-webkit-scrollbar{width:8px; height:8px;}
|
||||||
|
*::-webkit-scrollbar-thumb{background:#c3cfde; border-radius:4px;}
|
||||||
|
*::-webkit-scrollbar-track{background:transparent;}
|
||||||
|
html,body{height:100%; margin:0; overflow:hidden;}
|
||||||
|
body{
|
||||||
|
font-family:"Segoe UI Rounded","Arial Rounded MT Bold",ui-rounded,"Segoe UI",Verdana,sans-serif;
|
||||||
|
background:var(--bg); color:var(--ink); user-select:none;
|
||||||
|
}
|
||||||
|
input,textarea{user-select:text;}
|
||||||
|
/* ---------- Toolbar ---------- */
|
||||||
|
#toolbar{
|
||||||
|
position:fixed; top:0; left:0; right:0; height:58px; z-index:5000;
|
||||||
|
background:var(--panel); box-shadow:0 2px 10px rgba(30,50,90,.10);
|
||||||
|
display:flex; align-items:center; gap:10px; padding:0 16px;
|
||||||
|
overflow-x:auto; overflow-y:hidden; scrollbar-width:none;
|
||||||
|
}
|
||||||
|
#toolbar::-webkit-scrollbar{display:none;}
|
||||||
|
#zoomPct{min-width:58px; padding:9px 8px; font-size:13px; text-align:center;}
|
||||||
|
#zoomPct.on{background:var(--yellow); color:#6b5200;}
|
||||||
|
#btnZoomIn:disabled,#btnZoomOut:disabled{opacity:.35; cursor:default;}
|
||||||
|
.logo{display:flex; align-items:baseline; gap:1px; font-size:30px; font-weight:900; line-height:1;}
|
||||||
|
.logo span{display:inline-block; transform-origin:bottom center;}
|
||||||
|
.logo .l1{color:var(--red); transform:rotate(-6deg);}
|
||||||
|
.logo .l2{color:var(--orange); transform:rotate(4deg) translateY(-2px);}
|
||||||
|
.logo .l3{color:var(--green); transform:rotate(-3deg);}
|
||||||
|
.logo .l4{color:var(--blue); transform:rotate(5deg) translateY(-1px);}
|
||||||
|
.logo .l5{color:var(--purple); transform:rotate(-4deg);}
|
||||||
|
.logo .crayon{font-size:24px; margin-left:6px; transform:rotate(10deg);}
|
||||||
|
#verTag{font-size:10.5px; font-weight:700; color:var(--muted); align-self:flex-end; margin-left:4px; transform:none;}
|
||||||
|
#verTag.beta{color:#fff; background:var(--orange); padding:1px 7px; border-radius:8px;}
|
||||||
|
.logo:hover span:not(.crayon){animation:bounce .5s;}
|
||||||
|
.logo .l2{animation-delay:.05s;} .logo .l3{animation-delay:.1s;}
|
||||||
|
.logo .l4{animation-delay:.15s;} .logo .l5{animation-delay:.2s;}
|
||||||
|
@keyframes bounce{0%,100%{transform:translateY(0);} 40%{transform:translateY(-8px);}}
|
||||||
|
#toolbar .spacer{flex:1;}
|
||||||
|
.tbtn{
|
||||||
|
border:none; border-radius:12px; padding:9px 14px; font:inherit; font-weight:700;
|
||||||
|
background:var(--blue); color:#fff; cursor:pointer; font-size:14.5px;
|
||||||
|
}
|
||||||
|
.tbtn:hover{background:var(--blue-d);}
|
||||||
|
.tbtn.ghost{background:#e8eef6; color:var(--ink);}
|
||||||
|
.tbtn.ghost:hover{background:#dbe5f1;}
|
||||||
|
#btnSave{min-width:158px; text-align:center;}
|
||||||
|
#btnSave.ok{background:var(--green); color:#fff;}
|
||||||
|
#btnUser.on{background:var(--green); color:#fff;}
|
||||||
|
#langToggle{display:flex; background:#e8eef6; border-radius:12px; overflow:hidden;}
|
||||||
|
#langToggle button{
|
||||||
|
border:none; padding:9px 13px; font:inherit; font-weight:700; cursor:pointer;
|
||||||
|
background:transparent; color:var(--muted); font-size:13.5px;
|
||||||
|
}
|
||||||
|
#langToggle button.active{background:var(--blue); color:#fff;}
|
||||||
|
/* ---------- Board ---------- */
|
||||||
|
#board{position:absolute; inset:58px 0 0 0; overflow:hidden;}
|
||||||
|
#board.bg-dots{
|
||||||
|
background-image:radial-gradient(circle,#c9d6e5 1.4px,transparent 1.4px);
|
||||||
|
background-size:26px 26px;
|
||||||
|
}
|
||||||
|
/* board background line options */
|
||||||
|
#board.bg-blank{background-image:none;}
|
||||||
|
#board.bg-lines{background-image:repeating-linear-gradient(#0000 0 39px, #b9cbe3 39px 40px);}
|
||||||
|
#board.bg-write{background-image:repeating-linear-gradient(
|
||||||
|
#0000 0 19px,#cddcf0 19px 20px,#0000 20px 39px,#8aa8cf 39px 40.5px,#0000 40.5px 59px,#cddcf0 59px 60px,#0000 60px 79px);}
|
||||||
|
#board.bg-grid-s{background-image:
|
||||||
|
repeating-linear-gradient(#0000 0 24px,#cddcf0 24px 25px),
|
||||||
|
repeating-linear-gradient(90deg,#0000 0 24px,#cddcf0 24px 25px);}
|
||||||
|
#board.bg-grid-b{background-image:
|
||||||
|
repeating-linear-gradient(#0000 0 49px,#b9cbe3 49px 50px),
|
||||||
|
repeating-linear-gradient(90deg,#0000 0 49px,#b9cbe3 49px 50px);}
|
||||||
|
/* drawing layer on the board: sits ABOVE widgets like a transparency sheet,
|
||||||
|
so you can annotate over everything; clicks pass through when pen is off */
|
||||||
|
#boardInk{position:absolute; inset:0; pointer-events:none; z-index:4000;}
|
||||||
|
body.pen #boardInk{pointer-events:auto; cursor:crosshair;}
|
||||||
|
body.pen.pen-text #boardInk{cursor:text;}
|
||||||
|
body.pen.pen-hand #boardInk{pointer-events:none;} /* hand tool: interact with everything */
|
||||||
|
/* floating add-button (FAB) */
|
||||||
|
#fab{
|
||||||
|
position:fixed; right:24px; bottom:24px; z-index:5600; width:60px; height:60px;
|
||||||
|
border-radius:50%; border:none; background:var(--blue); color:#fff; font-size:28px;
|
||||||
|
cursor:pointer; box-shadow:0 8px 24px rgba(59,125,216,.45); opacity:.8;
|
||||||
|
transition:all .2s; line-height:1;
|
||||||
|
}
|
||||||
|
#fab:hover{opacity:1; transform:scale(1.1);}
|
||||||
|
#fab.open{transform:rotate(45deg); opacity:1;}
|
||||||
|
body.board-empty #fab{opacity:1; animation:fabpulse 2s infinite;}
|
||||||
|
@keyframes fabpulse{
|
||||||
|
0%,100%{box-shadow:0 8px 24px rgba(59,125,216,.45);}
|
||||||
|
50%{box-shadow:0 8px 38px rgba(59,125,216,.9); transform:scale(1.07);}
|
||||||
|
}
|
||||||
|
#fabmenu{
|
||||||
|
position:fixed; right:24px; bottom:96px; z-index:5600; display:none;
|
||||||
|
flex-direction:column; gap:10px; align-items:flex-end;
|
||||||
|
}
|
||||||
|
/* board quick-switch: hover strip with live previews */
|
||||||
|
#fabBoardsBtn{
|
||||||
|
position:fixed; right:96px; bottom:31px; z-index:5590; width:46px; height:46px;
|
||||||
|
border-radius:50%; border:none; background:var(--purple); color:#fff; font-size:19px;
|
||||||
|
cursor:pointer; box-shadow:0 6px 18px rgba(155,89,182,.45); opacity:.85;
|
||||||
|
transition:all .2s; line-height:1;
|
||||||
|
}
|
||||||
|
#fabBoardsBtn:hover{opacity:1; transform:scale(1.1);}
|
||||||
|
#boardStrip{
|
||||||
|
position:fixed; right:16px; bottom:92px; z-index:5590; display:none; gap:10px;
|
||||||
|
padding:10px; border-radius:16px; background:rgba(255,255,255,.93);
|
||||||
|
backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
|
||||||
|
box-shadow:0 10px 28px rgba(30,50,90,.3); border:1px solid rgba(255,255,255,.7);
|
||||||
|
max-width:calc(100vw - 40px); overflow-x:auto; overflow-y:hidden;
|
||||||
|
}
|
||||||
|
#boardStrip.open{display:flex;}
|
||||||
|
.bs-card{
|
||||||
|
width:168px; flex:none; background:#fff; border-radius:12px;
|
||||||
|
border:2.5px solid #e3eaf3; cursor:pointer; overflow:hidden;
|
||||||
|
}
|
||||||
|
.bs-card:hover{border-color:var(--blue);}
|
||||||
|
.bs-card.active{border-color:var(--yellow);}
|
||||||
|
.bs-card canvas{display:block; width:168px; height:105px; background:#fff;}
|
||||||
|
.bs-name{
|
||||||
|
font-size:12px; font-weight:800; padding:5px 8px; display:flex;
|
||||||
|
justify-content:space-between; align-items:center; gap:6px;
|
||||||
|
}
|
||||||
|
.bs-name span{overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
|
||||||
|
.bs-x{
|
||||||
|
border:none; background:#fbe3e1; color:var(--red); border-radius:7px;
|
||||||
|
min-width:26px; height:24px; font-size:11px; font-weight:900; cursor:pointer; flex:none;
|
||||||
|
}
|
||||||
|
.bs-x:hover{background:var(--red); color:#fff;}
|
||||||
|
.bs-x.arm{background:var(--red); color:#fff; animation:pop .3s;}
|
||||||
|
.bs-new{
|
||||||
|
display:flex; align-items:center; justify-content:center; font-size:30px;
|
||||||
|
color:#fff; background:var(--green); border-color:var(--green);
|
||||||
|
width:90px; min-height:130px; font-weight:900;
|
||||||
|
}
|
||||||
|
/* folder management panel (save menu) */
|
||||||
|
#boardsPanel{
|
||||||
|
position:fixed; right:16px; top:64px; z-index:5600; width:300px; max-height:66vh;
|
||||||
|
overflow:auto; display:none; flex-direction:column; gap:8px; padding:12px;
|
||||||
|
border-radius:16px; background:rgba(255,255,255,.96); backdrop-filter:blur(12px);
|
||||||
|
box-shadow:0 10px 30px rgba(30,50,90,.3); border:1px solid rgba(255,255,255,.7);
|
||||||
|
}
|
||||||
|
#boardsPanel.open{display:flex;}
|
||||||
|
.bp-folder{display:flex; align-items:center; gap:6px; font-weight:800; font-size:14px; margin-top:4px;}
|
||||||
|
.bp-folder .bpf-name{flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
|
||||||
|
.bp-folder button{
|
||||||
|
border:none; background:#e8eef6; border-radius:8px; padding:4px 8px; font:inherit;
|
||||||
|
font-weight:800; font-size:11.5px; cursor:pointer; color:var(--muted); flex:none;
|
||||||
|
}
|
||||||
|
.bp-folder button:hover{background:#dbe5f1; color:var(--ink);}
|
||||||
|
.bp-boards{display:flex; flex-wrap:wrap; gap:6px;}
|
||||||
|
.bp-board{
|
||||||
|
display:flex; align-items:center; gap:5px; border:2px solid #e3eaf3; background:#fff;
|
||||||
|
border-radius:10px; padding:5px 10px; font:inherit; font-weight:700; font-size:12.5px;
|
||||||
|
cursor:pointer; color:var(--muted);
|
||||||
|
}
|
||||||
|
.bp-board.active{border-color:var(--yellow); background:var(--yellow); color:#6b5200;}
|
||||||
|
.bp-board .bpb-x{border:none; background:transparent; color:var(--red); font-weight:900;
|
||||||
|
cursor:pointer; padding:0; font-size:11px; opacity:.8;}
|
||||||
|
.bp-board:hover .bpb-x{opacity:1;}
|
||||||
|
.bp-board input{border:none; outline:none; font:inherit; width:80px; background:transparent;}
|
||||||
|
.bp-new{display:flex; gap:6px; margin-top:6px;}
|
||||||
|
.bp-new input{
|
||||||
|
flex:1; min-width:0; border:2px solid #cdd9e8; border-radius:10px; padding:7px 9px;
|
||||||
|
font:inherit; font-size:13px; outline:none;
|
||||||
|
}
|
||||||
|
.bp-hint{font-size:11px; color:var(--muted); font-weight:700;}
|
||||||
|
#fabmenu.open{display:flex;}
|
||||||
|
.fab-item{
|
||||||
|
display:flex; align-items:center; gap:10px; border:none;
|
||||||
|
background:rgba(255,255,255,.92); backdrop-filter:blur(10px);
|
||||||
|
border-radius:14px; padding:11px 16px; font:inherit; font-weight:800; font-size:14px;
|
||||||
|
color:var(--ink); cursor:pointer; box-shadow:0 6px 18px rgba(30,50,90,.22);
|
||||||
|
}
|
||||||
|
.fab-item:hover{background:#fff; transform:translateX(-4px);}
|
||||||
|
.fab-item.on{background:var(--yellow);}
|
||||||
|
.fab-item .fi{font-size:18px;}
|
||||||
|
/* pen palette (section inside #hud, always visible) */
|
||||||
|
#penbar{display:flex; gap:6px; align-items:center; flex-wrap:wrap;}
|
||||||
|
.pen-close{background:#e8eef6 !important; color:var(--ink) !important;}
|
||||||
|
.pen-close.on{background:var(--yellow) !important; color:#6b5200 !important;}
|
||||||
|
body:not(.pen) .pen-tools button.on{background:transparent; box-shadow:none;}
|
||||||
|
.pen-grip{cursor:grab; font-size:15px; color:var(--muted); padding:0 2px; touch-action:none;}
|
||||||
|
.pen-grip:active{cursor:grabbing;}
|
||||||
|
#penbar .wp-group{padding:2px;}
|
||||||
|
#penbar .wp-group button{width:28px; height:28px; font-size:14px;}
|
||||||
|
#penbar .wp-color i{width:15px; height:15px;}
|
||||||
|
#penbar .wp-bg i{width:17px; height:17px;}
|
||||||
|
#penbar .wp-clear{width:28px; height:28px; font-size:13px;}
|
||||||
|
/* images on the board */
|
||||||
|
.bimg{
|
||||||
|
position:absolute; z-index:0; border-radius:10px; touch-action:none; cursor:grab;
|
||||||
|
box-shadow:0 6px 18px rgba(30,50,90,.25);
|
||||||
|
}
|
||||||
|
.bimg:active{cursor:grabbing;}
|
||||||
|
.bimg img{width:100%; display:block; border-radius:10px; pointer-events:none;}
|
||||||
|
.bimg .dtool-x,.bimg .bimg-rs{opacity:0; transition:opacity .15s;}
|
||||||
|
.bimg:hover .dtool-x,.bimg:hover .bimg-rs{opacity:1;}
|
||||||
|
.bimg-rs{
|
||||||
|
position:absolute; right:-9px; bottom:-9px; width:20px; height:20px; border-radius:50%;
|
||||||
|
background:var(--blue); border:2.5px solid #fff; cursor:nwse-resize;
|
||||||
|
box-shadow:0 2px 6px rgba(0,0,0,.3);
|
||||||
|
}
|
||||||
|
body.pen .bimg{pointer-events:none;}
|
||||||
|
/* frameless (bare) widget for mindmap directly on the board */
|
||||||
|
.widget.bare{
|
||||||
|
background:transparent !important; border:none; box-shadow:none !important;
|
||||||
|
backdrop-filter:none; -webkit-backdrop-filter:none; resize:none; border-radius:0;
|
||||||
|
pointer-events:none; /* let clicks pass through to widgets/board below */
|
||||||
|
}
|
||||||
|
.widget.bare .widget-head,.widget.bare .mm-top,.widget.bare .mm-node,
|
||||||
|
.widget.bare .mm-pop{pointer-events:auto;}
|
||||||
|
.widget.bare .widget-head{
|
||||||
|
position:absolute; top:8px; right:8px; left:auto; width:auto; z-index:6;
|
||||||
|
background:rgba(255,255,255,.78); border:none; border-radius:12px; padding:5px 8px; gap:6px;
|
||||||
|
box-shadow:0 4px 12px rgba(30,50,90,.15);
|
||||||
|
}
|
||||||
|
.widget.bare .widget-head .wtitle{display:none;}
|
||||||
|
.widget.bare .widget-body,.widget.bare .mm-stage{background:transparent;}
|
||||||
|
/* mindmap node settings popover */
|
||||||
|
.mm-cog{
|
||||||
|
position:absolute; right:-11px; bottom:-11px; width:22px; height:22px; border-radius:50%;
|
||||||
|
border:none; background:var(--blue); color:#fff; font-size:11px; cursor:pointer;
|
||||||
|
line-height:1; display:none; padding:0;
|
||||||
|
}
|
||||||
|
.mm-node:hover .mm-cog{display:block;}
|
||||||
|
.mm-pop{
|
||||||
|
position:absolute; z-index:20; background:#fff; border-radius:12px; padding:9px 10px;
|
||||||
|
box-shadow:0 10px 26px rgba(30,50,90,.3); display:flex; flex-direction:column; gap:7px;
|
||||||
|
}
|
||||||
|
.mm-pop .mp-row{display:flex; gap:5px; align-items:center;}
|
||||||
|
.mm-pop .mp-lbl{font-size:11px; font-weight:800; color:var(--muted); width:16px; text-align:center;}
|
||||||
|
.mm-pop button{border:none; cursor:pointer; padding:0; background:transparent;}
|
||||||
|
.mm-pop .mp-sw{width:19px; height:19px; border-radius:50%; border:2px solid #fff; box-shadow:0 0 0 1.5px #cdd9e8;}
|
||||||
|
.mm-pop .mp-sw.on{box-shadow:0 0 0 2.5px var(--ink);}
|
||||||
|
.mm-pop .mp-fs{font-weight:900; color:var(--ink); width:24px; height:24px; border-radius:7px; background:#e8eef6;}
|
||||||
|
.mm-pop .mp-fs.on{background:var(--yellow);}
|
||||||
|
.mm-pop .mp-ls{width:34px; height:24px; border-radius:7px; background:#e8eef6; position:relative;}
|
||||||
|
.mm-pop .mp-ls.on{background:var(--yellow);}
|
||||||
|
.mm-pop .mp-ls i{display:block; border-top:2.5px solid var(--ink); width:24px; margin:0 auto;}
|
||||||
|
.mm-pop .mp-ls[data-ls=dash] i{border-top-style:dashed;}
|
||||||
|
.mm-pop .mp-ls[data-ls=dot] i{border-top-style:dotted;}
|
||||||
|
.mm-pop .mp-x{margin-left:auto; font-weight:900; color:var(--red); font-size:12px;}
|
||||||
|
/* physical tools: ruler, set square, magnifier */
|
||||||
|
.dtool{position:absolute; z-index:3; touch-action:none; user-select:none; cursor:grab; left:120px; top:120px;}
|
||||||
|
.dtool:active{cursor:grabbing;}
|
||||||
|
.dtool .dtool-x{
|
||||||
|
position:absolute; top:-10px; right:-10px; width:24px; height:24px; border-radius:50%;
|
||||||
|
border:none; background:#fbe3e1; color:var(--red); font-weight:900; cursor:pointer;
|
||||||
|
font-size:12px; line-height:1; box-shadow:0 2px 6px rgba(0,0,0,.2); z-index:2;
|
||||||
|
}
|
||||||
|
.dtool .dtool-rot{
|
||||||
|
position:absolute; bottom:-10px; right:-10px; width:26px; height:26px; border-radius:50%;
|
||||||
|
border:none; background:var(--blue); color:#fff; cursor:grab; font-size:13px;
|
||||||
|
line-height:1; box-shadow:0 2px 6px rgba(0,0,0,.25); z-index:2;
|
||||||
|
}
|
||||||
|
.dtool-ruler{width:600px; height:76px;}
|
||||||
|
.dtool-geo{width:340px; height:184px;}
|
||||||
|
.dtool-opts{position:absolute; top:-12px; left:8px; display:flex; gap:5px;}
|
||||||
|
.dtool-opts button{
|
||||||
|
width:24px; height:24px; border-radius:50%; border:none; background:#fff;
|
||||||
|
color:var(--ink); font-size:12px; font-weight:900; cursor:pointer; line-height:1;
|
||||||
|
box-shadow:0 2px 6px rgba(0,0,0,.25);
|
||||||
|
}
|
||||||
|
.dtool-opts button:hover{background:var(--yellow);}
|
||||||
|
.dtool-lens{z-index:4500;} /* the glass sits above the ink sheet */
|
||||||
|
.dtool-lens canvas{
|
||||||
|
border:5px solid #5a6c85; box-shadow:0 8px 22px rgba(0,0,0,.3);
|
||||||
|
background:#fff; display:block;
|
||||||
|
}
|
||||||
|
/* =========== Mindmap widget =========== */
|
||||||
|
.mm{flex:1; min-height:0; display:flex; flex-direction:column; position:relative;}
|
||||||
|
.mm-top{
|
||||||
|
position:absolute; top:8px; left:8px; z-index:7; display:flex; gap:8px; align-items:center;
|
||||||
|
padding:6px 8px; background:rgba(255,255,255,.85); backdrop-filter:blur(8px);
|
||||||
|
border-radius:12px; box-shadow:0 4px 12px rgba(30,50,90,.15); cursor:default;
|
||||||
|
}
|
||||||
|
.mm-grip{cursor:grab; color:var(--muted); font-size:14px; padding:0 2px; touch-action:none; user-select:none;}
|
||||||
|
.mm-grip:active{cursor:grabbing;}
|
||||||
|
.mm-hint{font-size:12px; font-weight:700; color:var(--muted); max-width:180px;}
|
||||||
|
.mm-stage{flex:1; min-height:0; position:relative; overflow:hidden; background:#fff;}
|
||||||
|
.mm-stage svg{position:absolute; inset:0; width:100%; height:100%; pointer-events:none;}
|
||||||
|
.mm-node{
|
||||||
|
position:absolute; transform:translate(-50%,-50%); background:#fff;
|
||||||
|
border:2.5px solid var(--blue); padding:8px 16px; font-weight:800; font-size:15px;
|
||||||
|
cursor:grab; touch-action:none; min-width:64px; text-align:center;
|
||||||
|
box-shadow:0 3px 10px rgba(30,50,90,.15); border-radius:50px;
|
||||||
|
}
|
||||||
|
.mm-node:active{cursor:grabbing;}
|
||||||
|
.mm-node span{outline:none; cursor:text; display:inline-block; min-width:34px; user-select:text;}
|
||||||
|
.mm-node span:empty::before{content:"…"; color:#c2cddc;}
|
||||||
|
.mm-node.root{background:var(--yellow); border-color:#d9a400; font-size:17px; z-index:2;}
|
||||||
|
.mm.tree .mm-node{border-radius:10px;}
|
||||||
|
.mm.web .mm-node{border-radius:16px; border-style:dashed;}
|
||||||
|
.mm-node .mm-add,.mm-node .mm-del{
|
||||||
|
position:absolute; width:22px; height:22px; border-radius:50%; border:none;
|
||||||
|
font-size:13px; font-weight:900; cursor:pointer; line-height:1; display:none; padding:0;
|
||||||
|
}
|
||||||
|
.mm-node:hover .mm-add,.mm-node:hover .mm-del{display:block;}
|
||||||
|
.mm-add{right:-11px; top:50%; transform:translateY(-50%); background:var(--green); color:#fff;}
|
||||||
|
.mm-del{left:-9px; top:-9px; background:#fbe3e1; color:var(--red);}
|
||||||
|
/* ---------- Widget windows ---------- */
|
||||||
|
.widget{
|
||||||
|
position:absolute; background:rgba(255,255,255,.55); border-radius:var(--radius);
|
||||||
|
backdrop-filter:blur(14px) saturate(1.15); -webkit-backdrop-filter:blur(14px) saturate(1.15);
|
||||||
|
box-shadow:0 4px 14px rgba(30,50,90,.08); border:1px solid rgba(255,255,255,.6);
|
||||||
|
display:flex; flex-direction:column;
|
||||||
|
min-width:280px; min-height:200px; resize:both; overflow:hidden;
|
||||||
|
transition:background .2s, box-shadow .2s, border-color .2s;
|
||||||
|
}
|
||||||
|
.widget:hover,.widget:focus-within{
|
||||||
|
background:rgba(255,255,255,.92);
|
||||||
|
box-shadow:0 12px 30px rgba(30,50,90,.2);
|
||||||
|
border-color:rgba(255,255,255,.9);
|
||||||
|
}
|
||||||
|
.widget .wmin,.widget .wclose{opacity:.4; transition:opacity .15s;}
|
||||||
|
.widget:hover .wmin,.widget:hover .wclose,
|
||||||
|
.widget:focus-within .wmin,.widget:focus-within .wclose{opacity:1;}
|
||||||
|
/* unified floating bar: pen palette + dock, draggable, combined when both active */
|
||||||
|
#hud{
|
||||||
|
position:fixed; left:50%; bottom:16px; transform:translateX(-50%); z-index:5500;
|
||||||
|
display:none; gap:8px; align-items:center; flex-wrap:wrap; max-width:94vw;
|
||||||
|
padding:5px 8px; border-radius:14px;
|
||||||
|
background:rgba(255,255,255,.72); backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
|
||||||
|
box-shadow:0 6px 18px rgba(30,50,90,.18); border:1px solid rgba(255,255,255,.7);
|
||||||
|
}
|
||||||
|
#hud{display:flex;}
|
||||||
|
.hud-div{display:none; width:1.5px; height:26px; background:#c3cfde; border-radius:2px; flex:none;}
|
||||||
|
#hud.has-dock .hud-div{display:block;}
|
||||||
|
#dock{display:none; gap:8px; align-items:center; flex-wrap:wrap;}
|
||||||
|
#hud.has-dock #dock{display:flex;}
|
||||||
|
/* collapse + orientation */
|
||||||
|
.hud-btn{
|
||||||
|
border:none; background:#e8eef6; border-radius:8px; width:26px; height:26px;
|
||||||
|
cursor:pointer; font-size:13px; line-height:1; padding:0; flex:none; font-weight:900; color:var(--ink);
|
||||||
|
}
|
||||||
|
.hud-btn:hover{background:#dbe5f1;}
|
||||||
|
#hud.min #penbar,#hud.min #dock,#hud.min .hud-div{display:none !important;}
|
||||||
|
#hud.vert{
|
||||||
|
flex-direction:column; align-items:center; flex-wrap:nowrap;
|
||||||
|
max-height:calc(100vh - 84px); overflow-y:auto; overflow-x:hidden;
|
||||||
|
max-width:none; width:92px;
|
||||||
|
}
|
||||||
|
#hud.vert #penbar{flex-direction:column; flex-wrap:nowrap; align-items:center;}
|
||||||
|
#hud.vert #penbar .wp-group{display:grid; grid-template-columns:repeat(2,30px); justify-items:center; align-items:center;}
|
||||||
|
#hud.vert .hud-div{width:26px; height:1.5px;}
|
||||||
|
#hud.vert #dock{flex-direction:column; flex-wrap:nowrap;}
|
||||||
|
.dock-item{
|
||||||
|
border:none; display:flex; align-items:center; gap:7px; background:#fff;
|
||||||
|
border-radius:10px; padding:6px 12px; font:inherit; font-weight:700; font-size:13px;
|
||||||
|
color:var(--ink); cursor:pointer; box-shadow:0 2px 6px rgba(30,50,90,.12);
|
||||||
|
}
|
||||||
|
.dock-item:hover{background:#eef3f9; transform:translateY(-2px);}
|
||||||
|
.dock-item .di{font-size:16px; line-height:1;}
|
||||||
|
.widget-head{
|
||||||
|
display:flex; align-items:center; gap:10px; padding:10px 14px;
|
||||||
|
background:rgba(255,255,255,.4); border-bottom:1px solid rgba(150,170,200,.28);
|
||||||
|
cursor:grab; flex:none;
|
||||||
|
}
|
||||||
|
.widget-head:active{cursor:grabbing;}
|
||||||
|
.widget-head .wicon{font-size:20px;}
|
||||||
|
.widget-head .wtitle{font-weight:800; font-size:15px; flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
|
||||||
|
.wclose{
|
||||||
|
border:none; background:#fbe3e1; color:var(--red); width:28px; height:28px;
|
||||||
|
border-radius:9px; font-weight:900; cursor:pointer; font-size:14px; line-height:1; flex:none;
|
||||||
|
}
|
||||||
|
.wclose:hover{background:var(--red); color:#fff;}
|
||||||
|
.wmin{
|
||||||
|
border:none; background:#e8eef6; color:var(--ink); width:28px; height:28px;
|
||||||
|
border-radius:9px; font-weight:900; cursor:pointer; font-size:15px; line-height:1; flex:none;
|
||||||
|
}
|
||||||
|
.wmin:hover{background:#dbe5f1;}
|
||||||
|
.wz{font-size:10.5px; width:auto; min-width:30px; padding:0 5px;}
|
||||||
|
.wz.on{background:var(--yellow); color:#6b5200;}
|
||||||
|
.widget-body{flex:1; overflow:hidden; position:relative; display:flex; flex-direction:column; min-height:0;}
|
||||||
|
/* ---------- Modals ---------- */
|
||||||
|
.modalwrap{
|
||||||
|
position:fixed; inset:0; z-index:6000; background:rgba(30,45,70,.45);
|
||||||
|
display:none; align-items:center; justify-content:center;
|
||||||
|
}
|
||||||
|
.modalwrap.open{display:flex;}
|
||||||
|
.modal{
|
||||||
|
background:var(--panel); border-radius:20px; padding:26px; width:min(680px,92vw);
|
||||||
|
max-height:88vh; overflow:auto; box-shadow:var(--shadow);
|
||||||
|
}
|
||||||
|
.modal h2{margin:0 0 18px; font-size:21px;}
|
||||||
|
.gallery{display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:14px;}
|
||||||
|
.gcard{
|
||||||
|
border:2px solid #e3eaf3; border-radius:16px; padding:18px 14px; text-align:center;
|
||||||
|
cursor:pointer; transition:all .15s; background:#fbfdff;
|
||||||
|
}
|
||||||
|
.gcard:hover{border-color:var(--blue); transform:translateY(-2px); box-shadow:0 6px 18px rgba(59,125,216,.18);}
|
||||||
|
.gcard .gi{font-size:38px;}
|
||||||
|
.gcard .gn{font-weight:800; margin-top:8px; font-size:15px;}
|
||||||
|
.gcard .gd{font-size:12.5px; color:var(--muted); margin-top:4px; line-height:1.35;}
|
||||||
|
/* login modal */
|
||||||
|
#loginModal{width:min(400px,92vw);}
|
||||||
|
#loginModal input{
|
||||||
|
width:100%; border:2px solid #cdd9e8; border-radius:12px; padding:11px 12px;
|
||||||
|
font:inherit; font-size:16px; outline:none; margin-bottom:10px;
|
||||||
|
}
|
||||||
|
#loginModal input:focus{border-color:var(--blue);}
|
||||||
|
.lrow{display:flex; gap:8px;}
|
||||||
|
.lrow .tbtn{flex:1;}
|
||||||
|
#loginMsg{min-height:20px; font-size:13.5px; font-weight:700; color:var(--red); margin:6px 0;}
|
||||||
|
#loginMsg.ok{color:var(--green);}
|
||||||
|
.guestnote{font-size:13px; color:var(--muted); margin-top:12px; line-height:1.45;}
|
||||||
|
#loggedBox{display:none;}
|
||||||
|
#loggedBox .hello{font-size:18px; font-weight:800; margin-bottom:14px;}
|
||||||
|
/* =========================================================
|
||||||
|
Letter game widget
|
||||||
|
==========================================================*/
|
||||||
|
.lg{display:flex; flex-direction:column; flex:1; min-height:0; padding:12px 14px; gap:10px;}
|
||||||
|
.lg-top{display:flex; gap:8px; align-items:center; flex-wrap:wrap;}
|
||||||
|
.lg-levels{display:flex; background:#e8eef6; border-radius:12px; overflow:hidden;}
|
||||||
|
.lg-levels button{
|
||||||
|
border:none; background:transparent; padding:8px 13px; font:inherit; font-weight:800;
|
||||||
|
font-size:13.5px; color:var(--muted); cursor:pointer;
|
||||||
|
}
|
||||||
|
.lg-levels button.active{background:var(--green); color:#fff;}
|
||||||
|
.lg-cards{display:flex; gap:5px; flex-wrap:wrap;}
|
||||||
|
.lg-cards button{
|
||||||
|
border:2px solid #e3eaf3; background:#fff; width:34px; height:34px; border-radius:10px;
|
||||||
|
font:inherit; font-weight:800; color:var(--muted); cursor:pointer; font-size:14px;
|
||||||
|
}
|
||||||
|
.lg-cards button.active{border-color:var(--yellow); background:var(--yellow); color:#6b5200;}
|
||||||
|
.lg-cards .lg-theme{
|
||||||
|
width:auto; padding:0 11px; max-width:120px; overflow:hidden;
|
||||||
|
text-overflow:ellipsis; white-space:nowrap;
|
||||||
|
}
|
||||||
|
.lg-num{
|
||||||
|
border:2px solid #e3eaf3; border-radius:10px; height:34px; font:inherit;
|
||||||
|
font-weight:800; font-size:13px; color:var(--muted); background:#fff;
|
||||||
|
padding:0 4px; outline:none; cursor:pointer;
|
||||||
|
}
|
||||||
|
.lg-snd,.lg-mode{
|
||||||
|
border:2px solid #e3eaf3; border-radius:10px; width:34px; height:34px;
|
||||||
|
background:#fff; cursor:pointer; font-size:15px; line-height:1; padding:0; flex:none;
|
||||||
|
}
|
||||||
|
.lg-snd.off{opacity:.55;}
|
||||||
|
/* small digit blocks/slots for the maths widgets */
|
||||||
|
.blok.sm{width:46px; height:74px; border-radius:10px; padding-top:4px;}
|
||||||
|
.blok.sm .bl{font-size:24px; height:30px; color:var(--purple);}
|
||||||
|
.blok.sm .dots,.slot.sm .dots{grid-template-columns:repeat(2,7px); grid-template-rows:repeat(3,7px); gap:2.5px;}
|
||||||
|
.blok.sm .dots i,.slot.sm .dots i{width:7px; height:7px;}
|
||||||
|
.blok.sm .blok-dots{margin-bottom:7px;}
|
||||||
|
.slot.sm{width:46px; height:74px; padding-top:4px;}
|
||||||
|
.slot.sm .ghost{font-size:22px; height:26px;}
|
||||||
|
.slot.sm .slot-dots{margin-bottom:6px;}
|
||||||
|
.slot.sm .dots i.off{width:6px; height:6px;}
|
||||||
|
.mt-slots{display:flex; gap:8px; justify-content:center;}
|
||||||
|
.mt-tray{gap:8px; padding:10px 8px; min-height:96px;}
|
||||||
|
.mt.blocks .mt-a,.mt.blocks .mt-pad{display:none;}
|
||||||
|
.mt:not(.blocks) .mt-slots,.mt:not(.blocks) .mt-tray{display:none;}
|
||||||
|
/* on-screen letter keyboard (letters widget, type mode) */
|
||||||
|
.lg-kbd{gap:6px; padding:10px;}
|
||||||
|
.lg-kbd button{
|
||||||
|
width:40px; height:46px; border-radius:10px; border:2px solid var(--wood-d);
|
||||||
|
background:linear-gradient(#fdf3dd,var(--wood)); font:inherit; font-weight:900;
|
||||||
|
font-size:20px; cursor:pointer; padding:0;
|
||||||
|
}
|
||||||
|
.lg-kbd button.v{color:var(--red);}
|
||||||
|
.lg-kbd button.c{color:var(--blue);}
|
||||||
|
.lg.typemode .lg-tray.blocks-tray{display:none;}
|
||||||
|
.lg:not(.typemode) .lg-kbd{display:none;}
|
||||||
|
.lg-ed-themerow{display:flex; gap:8px; align-items:center;}
|
||||||
|
.ed-theme-sel{
|
||||||
|
flex:1; min-width:0; border:2px solid #cdd9e8; border-radius:10px; padding:9px 10px;
|
||||||
|
font:inherit; font-weight:700; font-size:15px; outline:none; background:#fff; cursor:pointer;
|
||||||
|
}
|
||||||
|
.ed-theme-name{
|
||||||
|
border:2px solid var(--blue); border-radius:10px; padding:9px 10px;
|
||||||
|
font:inherit; font-size:15px; outline:none; width:180px;
|
||||||
|
}
|
||||||
|
.ed-theme-del{
|
||||||
|
border:none; background:#fbe3e1; color:var(--red); border-radius:10px;
|
||||||
|
width:40px; height:40px; cursor:pointer; font-size:15px; flex:none;
|
||||||
|
}
|
||||||
|
.ed-theme-del:hover{background:var(--red); color:#fff;}
|
||||||
|
.lg-progress{margin-left:auto; font-size:13.5px; font-weight:700; color:var(--muted); display:flex; align-items:center; gap:8px; white-space:nowrap;}
|
||||||
|
.lg-stars{color:#d9a400; font-size:14px; min-width:34px; text-align:right;}
|
||||||
|
.lg-instr{
|
||||||
|
background:#f1f7ff; border:1.5px dashed #bcd4f2;
|
||||||
|
border-radius:12px; padding:6px 14px; font-size:14.5px; font-weight:700; color:#37588a;
|
||||||
|
text-align:center; min-height:42px; display:flex; align-items:center; justify-content:center;
|
||||||
|
flex:none;
|
||||||
|
}
|
||||||
|
.lg-card{
|
||||||
|
flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||||
|
gap:16px; background:linear-gradient(#ffffff,#f6fafe); border:2px solid #e3eaf3;
|
||||||
|
border-radius:18px; padding:12px; min-height:0; position:relative; overflow:hidden;
|
||||||
|
}
|
||||||
|
.lg-pic{font-size:64px; line-height:1; filter:drop-shadow(0 4px 6px rgba(0,0,0,.12));}
|
||||||
|
.lg-pic img{width:80px; height:80px; object-fit:contain; border-radius:12px; vertical-align:middle;}
|
||||||
|
.lg-slots{display:flex; gap:10px;}
|
||||||
|
.slot{
|
||||||
|
width:64px; height:98px; border-radius:12px; position:relative;
|
||||||
|
background:#e9eef5; border:2.5px dashed #b9c7d9;
|
||||||
|
display:flex; flex-direction:column; align-items:center; justify-content:flex-start;
|
||||||
|
padding-top:6px; transition:all .12s;
|
||||||
|
}
|
||||||
|
.slot.droptarget{border-color:var(--green); background:#e3f6ea; transform:scale(1.06);}
|
||||||
|
.slot .ghost{font-size:30px; font-weight:800; color:#c2cddc; height:40px; display:flex; align-items:center;}
|
||||||
|
.slot.filled{border-style:solid; border-color:transparent; background:transparent; padding:0;}
|
||||||
|
.slot.filled .ghost,.slot.filled .slot-dots{display:none;}
|
||||||
|
.slot.shake{animation:shake .35s;}
|
||||||
|
@keyframes shake{
|
||||||
|
0%,100%{transform:translateX(0);} 20%{transform:translateX(-7px);}
|
||||||
|
40%{transform:translateX(7px);} 60%{transform:translateX(-5px);} 80%{transform:translateX(5px);}
|
||||||
|
}
|
||||||
|
.blok{
|
||||||
|
width:64px; height:98px; border-radius:12px; cursor:grab; touch-action:none;
|
||||||
|
display:flex; flex-direction:column; align-items:center; padding-top:6px;
|
||||||
|
background:linear-gradient(#fdf3dd,var(--wood)); border:2.5px solid var(--wood-d);
|
||||||
|
box-shadow:0 4px 0 var(--wood-d), 0 7px 14px rgba(60,40,0,.16);
|
||||||
|
position:relative; flex:none;
|
||||||
|
}
|
||||||
|
.blok:active{cursor:grabbing;}
|
||||||
|
.blok .bl{font-size:34px; font-weight:900; height:44px; display:flex; align-items:center;}
|
||||||
|
.blok.vowel .bl{color:var(--red);}
|
||||||
|
.blok.cons .bl{color:var(--blue);}
|
||||||
|
.blok .blok-dots{margin-top:auto; margin-bottom:10px;}
|
||||||
|
.blok.in-slot{cursor:pointer; animation:pop .3s;}
|
||||||
|
.blok.dragging{opacity:.25;}
|
||||||
|
@keyframes pop{0%{transform:scale(.6);} 60%{transform:scale(1.12);} 100%{transform:scale(1);}}
|
||||||
|
.slot .slot-dots{margin-top:auto; margin-bottom:9px;}
|
||||||
|
.dots{display:grid; grid-template-columns:repeat(2,9px); grid-template-rows:repeat(3,9px); gap:3px;}
|
||||||
|
.dots i{width:9px; height:9px; border-radius:50%; background:transparent;}
|
||||||
|
.blok .dots i.on{background:#b98a3c; box-shadow:inset 0 -1.5px 2px rgba(0,0,0,.35), 0 1px 0 rgba(255,255,255,.5);}
|
||||||
|
.blok .dots i.off{background:rgba(0,0,0,.05);}
|
||||||
|
.slot .dots i.on{background:#aebcce; box-shadow:inset 0 2px 3px rgba(30,50,80,.4);}
|
||||||
|
.slot .dots i.off{background:transparent; border:1px dotted #ccd7e4; width:7px; height:7px;}
|
||||||
|
.lg-tray{
|
||||||
|
display:flex; gap:12px; justify-content:center; flex-wrap:wrap;
|
||||||
|
background:#eef3f9; border-radius:16px; padding:14px 12px; min-height:130px; flex:none;
|
||||||
|
}
|
||||||
|
.lg-bottom{display:flex; align-items:center; justify-content:center; gap:12px; min-height:46px; flex:none;}
|
||||||
|
.lg-next{
|
||||||
|
border:none; border-radius:12px; padding:10px 22px; font:inherit; font-weight:800;
|
||||||
|
background:var(--green); color:#fff; cursor:pointer; font-size:15.5px; display:none;
|
||||||
|
}
|
||||||
|
.lg-next.show{display:inline-block; animation:pop .3s;}
|
||||||
|
.lg-done{
|
||||||
|
position:absolute; inset:0; border-radius:18px; display:none; align-items:center;
|
||||||
|
justify-content:center; flex-direction:column; gap:6px;
|
||||||
|
background:rgba(255,255,255,.88); backdrop-filter:blur(2px); z-index:5;
|
||||||
|
}
|
||||||
|
.lg-done.show{display:flex;}
|
||||||
|
.lg-done .dg{font-size:52px;}
|
||||||
|
.lg-done .dt{font-size:24px; font-weight:900; color:var(--green);}
|
||||||
|
.confetti{position:absolute; width:10px; height:10px; border-radius:3px; top:-12px; z-index:6; pointer-events:none; animation:fall linear forwards;}
|
||||||
|
@keyframes fall{to{transform:translateY(110vh) rotate(720deg);}}
|
||||||
|
#dragClone{
|
||||||
|
position:fixed; z-index:9000; pointer-events:none; transform:translate(-50%,-60%) scale(1.08);
|
||||||
|
filter:drop-shadow(0 12px 16px rgba(0,0,0,.28));
|
||||||
|
}
|
||||||
|
/* ---------- custom words editor ---------- */
|
||||||
|
.lg-editor{
|
||||||
|
position:absolute; inset:0; z-index:20; background:#fff; display:none;
|
||||||
|
flex-direction:column; gap:10px; padding:14px 16px; overflow:auto;
|
||||||
|
}
|
||||||
|
.lg-editor.open{display:flex;}
|
||||||
|
.lg-ed-head{display:flex; align-items:center; justify-content:space-between;}
|
||||||
|
.lg-ed-head b{font-size:17px;}
|
||||||
|
.lg-ed-form{
|
||||||
|
display:flex; gap:10px; align-items:center; flex-wrap:wrap;
|
||||||
|
background:#f1f7ff; border-radius:14px; padding:12px;
|
||||||
|
}
|
||||||
|
.lg-ed-form input[type=text]{
|
||||||
|
border:2px solid #cdd9e8; border-radius:10px; padding:9px 10px; font:inherit;
|
||||||
|
font-size:16px; outline:none;
|
||||||
|
}
|
||||||
|
.lg-ed-form input.ed-word{width:170px; text-transform:lowercase;}
|
||||||
|
.ed-preview{width:52px; height:52px; display:flex; align-items:center; justify-content:center;
|
||||||
|
font-size:34px; background:#fff; border-radius:10px; border:2px dashed #cdd9e8; overflow:hidden; flex:none;}
|
||||||
|
.ed-preview img{width:100%; height:100%; object-fit:contain;}
|
||||||
|
.ed-add{border:none; border-radius:10px; padding:11px 20px; font:inherit; font-weight:800;
|
||||||
|
background:var(--green); color:#fff; cursor:pointer; font-size:14.5px;}
|
||||||
|
.ed-msg{width:100%; color:var(--red); font-weight:700; font-size:13.5px; min-height:16px;}
|
||||||
|
/* picture picker tabs */
|
||||||
|
.ed-tabs{display:flex; gap:6px;}
|
||||||
|
.ed-tabs button{
|
||||||
|
border:none; border-radius:10px 10px 0 0; padding:9px 16px; font:inherit; font-weight:800;
|
||||||
|
font-size:13.5px; background:#e8eef6; color:var(--muted); cursor:pointer;
|
||||||
|
}
|
||||||
|
.ed-tabs button.active{background:var(--yellow); color:#6b5200;}
|
||||||
|
.ed-pane{background:#fbfdff; border:2px solid #e3eaf3; border-radius:0 12px 12px 12px; padding:12px; min-height:120px;}
|
||||||
|
.ed-cats{display:flex; gap:5px; flex-wrap:wrap; margin-bottom:10px;}
|
||||||
|
.ed-cats button{
|
||||||
|
border:none; background:#e8eef6; border-radius:9px; padding:6px 11px; font:inherit;
|
||||||
|
font-weight:800; font-size:12.5px; color:var(--muted); cursor:pointer;
|
||||||
|
}
|
||||||
|
.ed-cats button.active{background:var(--blue); color:#fff;}
|
||||||
|
.ed-grid{display:flex; gap:4px; flex-wrap:wrap; max-height:170px; overflow:auto;}
|
||||||
|
.ed-grid button{
|
||||||
|
border:none; background:transparent; border-radius:9px; font-size:26px;
|
||||||
|
width:42px; height:42px; cursor:pointer; padding:0; line-height:1;
|
||||||
|
}
|
||||||
|
.ed-grid button:hover{background:#dbe8f8;}
|
||||||
|
.ed-grid button.sel{background:var(--yellow);}
|
||||||
|
.ed-pictorow{display:flex; gap:8px; margin-bottom:10px;}
|
||||||
|
.ed-pictorow input{flex:1; border:2px solid #cdd9e8; border-radius:10px; padding:9px 10px; font:inherit; font-size:15px; outline:none;}
|
||||||
|
.ed-pictorow .tbtn{flex:none;}
|
||||||
|
.ed-pgrid{display:flex; gap:8px; flex-wrap:wrap; max-height:180px; overflow:auto;}
|
||||||
|
.ed-pgrid button{
|
||||||
|
border:2px solid #e3eaf3; background:#fff; border-radius:12px; width:74px; height:74px;
|
||||||
|
cursor:pointer; padding:5px;
|
||||||
|
}
|
||||||
|
.ed-pgrid button:hover{border-color:var(--blue);}
|
||||||
|
.ed-pgrid button.sel{border-color:var(--yellow); background:#fff8e1;}
|
||||||
|
.ed-pgrid img{width:100%; height:100%; object-fit:contain;}
|
||||||
|
.ed-credit{font-size:11.5px; color:var(--muted); margin-top:8px; line-height:1.4;}
|
||||||
|
.ed-credit a{color:var(--blue);}
|
||||||
|
.ed-status{font-size:13.5px; font-weight:700; color:var(--muted); padding:6px 0;}
|
||||||
|
.ed-upload{background:#e8eef6; border-radius:10px; padding:11px 16px; font-weight:700;
|
||||||
|
font-size:14px; cursor:pointer; display:inline-block;}
|
||||||
|
.ed-upload input{display:none;}
|
||||||
|
.ed-uphint{font-size:12.5px; color:var(--muted); margin-top:10px; line-height:1.45;}
|
||||||
|
.lg-ed-list{display:flex; flex-direction:column; gap:6px;}
|
||||||
|
.lg-ed-list .empty{color:var(--muted); font-weight:700; text-align:center; padding:18px 0;}
|
||||||
|
.ed-row{display:flex; align-items:center; gap:12px; background:#f7fafd; border-radius:12px; padding:7px 12px;}
|
||||||
|
.ed-row .ep{width:40px; height:40px; font-size:28px; display:flex; align-items:center; justify-content:center;}
|
||||||
|
.ed-row .ep img{width:100%; height:100%; object-fit:contain; border-radius:8px;}
|
||||||
|
.ed-row .ew{font-weight:800; font-size:17px; letter-spacing:2px; flex:1;}
|
||||||
|
.ed-row .edel{border:none; background:#fbe3e1; color:var(--red); border-radius:8px;
|
||||||
|
width:30px; height:30px; cursor:pointer; font-weight:900;}
|
||||||
|
.ed-row .edel:hover{background:var(--red); color:#fff;}
|
||||||
|
.ed-row.base{opacity:.65;}
|
||||||
|
/* =========== Notes widget =========== */
|
||||||
|
.notes{flex:1; width:100%; min-height:0; border:none; resize:none; font:inherit; font-size:16px;
|
||||||
|
padding:14px; outline:none; background:#fffdf2; color:var(--ink);}
|
||||||
|
/* =========== Writing pad widget =========== */
|
||||||
|
.wp{flex:1; min-height:0; display:flex; flex-direction:column;}
|
||||||
|
.wp-bar{
|
||||||
|
display:flex; gap:10px; padding:8px 10px; flex-wrap:wrap; align-items:center;
|
||||||
|
flex:none; background:#f7fafd; border-bottom:1px solid #e3eaf3;
|
||||||
|
}
|
||||||
|
.wp-group{display:flex; gap:4px; align-items:center; background:#e8eef6; border-radius:10px; padding:3px;}
|
||||||
|
.wp-group button{
|
||||||
|
border:none; background:transparent; border-radius:8px; width:32px; height:32px;
|
||||||
|
cursor:pointer; font-size:16px; line-height:1; padding:0;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
}
|
||||||
|
.wp-group button.on{background:#fff; box-shadow:0 1px 4px rgba(30,50,90,.2);}
|
||||||
|
.wp-color{border-radius:50% !important; position:relative;}
|
||||||
|
.wp-color i{width:18px; height:18px; border-radius:50%; display:block;}
|
||||||
|
.wp-color.on{outline:2.5px solid var(--ink); outline-offset:-3px;}
|
||||||
|
.wp-size i{border-radius:50%; background:var(--ink); display:block;}
|
||||||
|
.wp-bg{font-size:0 !important;}
|
||||||
|
.wp-bg i{
|
||||||
|
width:20px; height:20px; display:block; border-radius:4px; border:1.5px solid #9db6d8;
|
||||||
|
background-color:#fff; background-size:100% 25%;
|
||||||
|
}
|
||||||
|
.wp-bg[data-bg=blank] i{background-image:none;}
|
||||||
|
.wp-bg[data-bg=dots] i{background-image:radial-gradient(circle,#9db6d8 1px,transparent 1px); background-size:5px 5px;}
|
||||||
|
.wp-bg[data-bg=lines] i{background-image:repeating-linear-gradient(#0000 0 4px, #9db6d8 4px 5px);}
|
||||||
|
.wp-bg[data-bg=write] i{background-image:repeating-linear-gradient(#0000 0 2px, #c3d5ec 2px 3px, #0000 3px 5px, #6d92c4 5px 6px);}
|
||||||
|
.wp-bg[data-bg=grid-s] i{background-image:repeating-linear-gradient(#0000 0 4px, #9db6d8 4px 5px),repeating-linear-gradient(90deg,#0000 0 4px, #9db6d8 4px 5px);}
|
||||||
|
.wp-bg[data-bg=grid-b] i{background-image:repeating-linear-gradient(#0000 0 8px, #9db6d8 8px 9px),repeating-linear-gradient(90deg,#0000 0 8px, #9db6d8 8px 9px);}
|
||||||
|
.wp-clear{border:none; background:#fbe3e1; color:var(--red); border-radius:10px;
|
||||||
|
width:34px; height:34px; cursor:pointer; font-size:15px; margin-left:auto;}
|
||||||
|
.wp-clear:hover{background:var(--red); color:#fff;}
|
||||||
|
.wp-stage{flex:1; min-height:0; position:relative; background:#fff;}
|
||||||
|
.wp-stage canvas{position:absolute; inset:0; touch-action:none; cursor:crosshair;}
|
||||||
|
.wp-stage.t-text canvas{cursor:text;}
|
||||||
|
.wp-text-input{
|
||||||
|
position:absolute; z-index:5; background:rgba(255,255,255,.85);
|
||||||
|
border:1.5px dashed var(--blue); border-radius:6px; padding:2px 6px;
|
||||||
|
font-family:inherit; font-weight:700; outline:none; min-width:130px;
|
||||||
|
}
|
||||||
|
/* paper backgrounds (CSS, so the eraser only removes ink) */
|
||||||
|
.wp-stage.bg-lines{background-image:repeating-linear-gradient(#0000 0 31px, #aec5e4 31px 32px);}
|
||||||
|
.wp-stage.bg-write{background-image:repeating-linear-gradient(
|
||||||
|
#0000 0 15px, #c3d5ec 15px 16px, #0000 16px 31px, #6d92c4 31px 32.5px, #0000 32.5px 47px, #c3d5ec 47px 48px, #0000 48px 63px);}
|
||||||
|
.wp-stage.bg-grid-s{background-image:
|
||||||
|
repeating-linear-gradient(#0000 0 19px, #c9d9ee 19px 20px),
|
||||||
|
repeating-linear-gradient(90deg,#0000 0 19px, #c9d9ee 19px 20px);}
|
||||||
|
.wp-stage.bg-grid-b{background-image:
|
||||||
|
repeating-linear-gradient(#0000 0 39px, #aec5e4 39px 40px),
|
||||||
|
repeating-linear-gradient(90deg,#0000 0 39px, #aec5e4 39px 40px);}
|
||||||
|
/* =========== Media widget =========== */
|
||||||
|
.media{flex:1; min-height:0; display:flex; flex-direction:column;}
|
||||||
|
.media-bar{
|
||||||
|
display:flex; gap:8px; padding:8px 10px; flex:none;
|
||||||
|
background:#f7fafd; border-bottom:1px solid #e3eaf3;
|
||||||
|
}
|
||||||
|
.media-bar input{
|
||||||
|
flex:1; min-width:0; border:2px solid #cdd9e8; border-radius:10px;
|
||||||
|
padding:8px 10px; font:inherit; font-size:14px; outline:none;
|
||||||
|
}
|
||||||
|
.media-bar input:focus{border-color:var(--blue);}
|
||||||
|
.media-stage{
|
||||||
|
flex:1; min-height:0; background:#141a24; display:flex;
|
||||||
|
align-items:center; justify-content:center; overflow:hidden;
|
||||||
|
}
|
||||||
|
.media-stage iframe{width:100%; height:100%; border:none;}
|
||||||
|
.media-stage img,.media-stage video{max-width:100%; max-height:100%;}
|
||||||
|
.media-msg{color:#93a4bd; font-weight:700; font-size:14px; text-align:center; padding:20px; line-height:1.5;}
|
||||||
|
.media-yt{position:relative; width:100%; height:100%; display:flex; align-items:center; justify-content:center;}
|
||||||
|
.media-yt img{max-width:100%; max-height:100%;}
|
||||||
|
.media-play{
|
||||||
|
position:absolute; border:none; width:76px; height:54px; border-radius:14px;
|
||||||
|
background:rgba(224,60,50,.95); color:#fff; font-size:24px; cursor:pointer;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
box-shadow:0 6px 18px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
.media-play:hover{transform:scale(1.08);}
|
||||||
|
/* =========== Math widgets =========== */
|
||||||
|
.mt{flex:1; min-height:0; display:flex; flex-direction:column; gap:10px; padding:12px 14px;}
|
||||||
|
.mt-top{display:flex; gap:8px; align-items:center; flex:none;}
|
||||||
|
.mt-score{margin-left:auto; font-size:13.5px; font-weight:700; color:var(--muted); white-space:nowrap;}
|
||||||
|
.mt-card{
|
||||||
|
flex:1; min-height:0; display:flex; flex-direction:column; align-items:center;
|
||||||
|
justify-content:center; gap:12px; background:linear-gradient(#fff,#f6fafe);
|
||||||
|
border:2px solid #e3eaf3; border-radius:18px; position:relative; padding:10px; overflow:hidden;
|
||||||
|
}
|
||||||
|
.mt-q{font-size:40px; font-weight:900; letter-spacing:1px; text-align:center;}
|
||||||
|
.mt-a{
|
||||||
|
min-width:110px; height:56px; border-radius:14px; border:2.5px dashed #b9c7d9;
|
||||||
|
background:#eef3f9; font-size:32px; font-weight:900; display:flex; align-items:center;
|
||||||
|
justify-content:center; color:var(--blue); padding:0 16px;
|
||||||
|
}
|
||||||
|
.mt-a.ok{border-style:solid; border-color:var(--green); background:#e3f6ea; color:var(--green);}
|
||||||
|
.mt-a.shake{animation:shake .35s;}
|
||||||
|
.mt-fb{min-height:24px; font-size:16px; font-weight:800; color:var(--green); text-align:center;}
|
||||||
|
.mt-fb.ans{color:var(--red);}
|
||||||
|
.mt-pad{display:grid; grid-template-columns:repeat(4,1fr); gap:8px; flex:none;}
|
||||||
|
.mt-pad button{
|
||||||
|
border:none; border-radius:12px; background:#e8eef6; font:inherit; font-weight:900;
|
||||||
|
font-size:22px; padding:12px 0; cursor:pointer; color:var(--ink);
|
||||||
|
}
|
||||||
|
.mt-pad button:hover{background:#dbe5f1;}
|
||||||
|
.mt-pad .ok{background:var(--green); color:#fff;}
|
||||||
|
.mt-done{
|
||||||
|
position:absolute; inset:0; border-radius:18px; display:none; flex-direction:column;
|
||||||
|
align-items:center; justify-content:center; gap:8px; background:rgba(255,255,255,.9);
|
||||||
|
backdrop-filter:blur(2px); z-index:5;
|
||||||
|
}
|
||||||
|
.mt-done.show{display:flex;}
|
||||||
|
.mt-done .dg{font-size:46px;}
|
||||||
|
.mt-done .dt{font-size:22px; font-weight:900; color:var(--green);}
|
||||||
|
.mt-done .ds{font-size:16px; font-weight:800; color:var(--muted);}
|
||||||
|
.mt-again{
|
||||||
|
border:none; border-radius:12px; padding:10px 22px; background:var(--green);
|
||||||
|
color:#fff; font:inherit; font-weight:800; font-size:15px; cursor:pointer; margin-top:6px;
|
||||||
|
}
|
||||||
|
/* =========== high-contrast accessibility mode =========== */
|
||||||
|
body.hc{--bg:#fff; --ink:#000; --muted:#2b2b2b; --blue:#0047ab; --red:#c40000;
|
||||||
|
--green:#006b2d; --yellow:#ffd400; --wood:#ffe9b0; --wood-d:#8a6a00;}
|
||||||
|
body.hc #board.bg-dots{background-image:radial-gradient(circle,#7d8ea3 1.7px,transparent 1.7px);}
|
||||||
|
body.hc .widget{background:#fff; border:2.5px solid #000; box-shadow:0 4px 14px rgba(0,0,0,.35); backdrop-filter:none;}
|
||||||
|
body.hc .widget:hover,.body.hc .widget:focus-within{background:#fff;}
|
||||||
|
body.hc .widget .wmin,body.hc .widget .wclose{opacity:1;}
|
||||||
|
body.hc .widget-head{background:#f2f2f2; border-bottom:2px solid #000;}
|
||||||
|
body.hc .slot{border-color:#000; background:#fff;}
|
||||||
|
body.hc .slot .ghost{color:#666;}
|
||||||
|
body.hc .slot .dots i.on{background:#333;}
|
||||||
|
body.hc .blok .dots i.on{background:#6a4a00;}
|
||||||
|
body.hc .lg-instr{background:#fff; border-color:#000; color:#000;}
|
||||||
|
body.hc #hud,body.hc .dock-item,body.hc .fab-item,body.hc #penbar .wp-group{border:2px solid #000; background:#fff;}
|
||||||
|
body.hc #fab{background:#0047ab;}
|
||||||
|
body.hc :focus-visible{outline:3px solid #0047ab; outline-offset:2px;}
|
||||||
|
body.hc .mm-node{box-shadow:0 3px 10px rgba(0,0,0,.4); border-width:3px;}
|
||||||
|
#btnHC.on{background:var(--yellow); color:#000;}
|
||||||
|
/* high contrast inside every widget */
|
||||||
|
body.hc .lg-levels,body.hc .tseg{background:#fff; border:2px solid #000; border-radius:12px;}
|
||||||
|
body.hc .lg-levels button,body.hc .tseg button{color:#000;}
|
||||||
|
body.hc .lg-levels button.active{background:#006b2d; color:#fff;}
|
||||||
|
body.hc .lg-cards button,body.hc .lg-num,body.hc .fl-sel,body.hc .lg-snd,body.hc .lg-mode{
|
||||||
|
border-color:#000; color:#000; background:#fff;
|
||||||
|
}
|
||||||
|
body.hc .lg-cards button.active{background:#ffd400; border-color:#000; color:#000;}
|
||||||
|
body.hc .lg-tray{background:#fff; border:2px dashed #000;}
|
||||||
|
body.hc .lg-card,body.hc .mt-card{background:#fff; border-color:#000;}
|
||||||
|
body.hc .lg-progress,body.hc .mt-score,body.hc .mm-hint,body.hc .dc-sum{color:#000;}
|
||||||
|
body.hc .lg-stars{color:#7a5d00;}
|
||||||
|
body.hc .mt-a{border-color:#000; background:#fff;}
|
||||||
|
body.hc .mt-pad button{background:#fff; border:2px solid #000; color:#000;}
|
||||||
|
body.hc .mt-pad .ok{background:#006b2d; border-color:#006b2d; color:#fff;}
|
||||||
|
body.hc .mt-fb{color:#006b2d;}
|
||||||
|
body.hc .mt-fb.ans{color:#c40000;}
|
||||||
|
body.hc .lg-kbd button,body.hc .blok{border-color:#6a4a00;}
|
||||||
|
body.hc .fl-word.dim{color:#8a8a8a;}
|
||||||
|
body.hc .slot .dots i.off{border-color:#666;}
|
||||||
|
body.hc .np textarea,body.hc .media-bar input,body.hc .ed-theme-sel,
|
||||||
|
body.hc .lg-ed-form input[type=text],body.hc .ed-pictorow input{border-color:#000; color:#000;}
|
||||||
|
body.hc .np-result{background:#0047ab;}
|
||||||
|
body.hc .np-row label,body.hc .thint,body.hc .guestnote,body.hc .ed-credit,
|
||||||
|
body.hc .bp-hint,body.hc .gcard .gd{color:#222;}
|
||||||
|
body.hc .gcard{border-color:#000; background:#fff;}
|
||||||
|
body.hc .gcat-title{color:#000;}
|
||||||
|
body.hc .tbtn.ghost{background:#fff; border:2px solid #000; color:#000;}
|
||||||
|
body.hc .wmin,body.hc .wclose{border:1.5px solid #000; background:#fff;}
|
||||||
|
body.hc .wclose{color:#c40000;}
|
||||||
|
body.hc .wz.on,body.hc #zoomPct.on{background:#ffd400; color:#000;}
|
||||||
|
body.hc .ed-tabs button,body.hc .ed-cats button{color:#000; background:#fff; border:1.5px solid #000;}
|
||||||
|
body.hc .ed-tabs button.active,body.hc .ed-cats button.active{background:#ffd400;}
|
||||||
|
body.hc .ed-pane{border-color:#000; background:#fff;}
|
||||||
|
body.hc .timer .presets button,body.hc .timer .main button,body.hc .ticon,
|
||||||
|
body.hc .hud-btn,body.hc .mm-seg button{background:#fff; border:1.5px solid #000; color:#000;}
|
||||||
|
body.hc .timer .main .bstart{background:#006b2d; border-color:#006b2d; color:#fff;}
|
||||||
|
body.hc .bp-board{border-color:#000; color:#000;}
|
||||||
|
body.hc .bp-board.active{background:#ffd400;}
|
||||||
|
body.hc .dock-item{color:#000;}
|
||||||
|
body.hc .media-msg{color:#ddd;}
|
||||||
|
/* =========== Flash words widget =========== */
|
||||||
|
.fl-word{font-size:58px; font-weight:900; letter-spacing:3px; min-height:70px; display:flex; align-items:center;}
|
||||||
|
.fl-word.dim{color:#c2cddc;}
|
||||||
|
.fl-pic{font-size:46px; min-height:56px; display:flex; align-items:center;}
|
||||||
|
.fl-pic img{width:56px; height:56px; object-fit:contain;}
|
||||||
|
.fl-sel{
|
||||||
|
border:2px solid #e3eaf3; border-radius:10px; height:34px; font:inherit;
|
||||||
|
font-weight:800; font-size:13px; color:var(--muted); background:#fff;
|
||||||
|
padding:0 6px; outline:none; cursor:pointer;
|
||||||
|
}
|
||||||
|
/* =========== Chop & blend widget =========== */
|
||||||
|
.hk-word{min-height:70px; display:flex; align-items:center; gap:4px; flex-wrap:wrap; justify-content:center;}
|
||||||
|
.hk-whole{font-size:52px; font-weight:900; letter-spacing:3px;}
|
||||||
|
.hk-l{font-size:46px; font-weight:900; animation:pop .4s both;}
|
||||||
|
.hk-l.v{color:var(--red);}
|
||||||
|
.hk-l.c{color:var(--blue);}
|
||||||
|
.hk-sep{font-size:30px; color:var(--muted); font-weight:700; margin:0 3px;}
|
||||||
|
/* =========== Balloon word widget =========== */
|
||||||
|
.bl-balloons{font-size:34px; letter-spacing:4px; min-height:44px;}
|
||||||
|
.bl-word{font-size:44px; font-weight:900; letter-spacing:6px; min-height:56px; display:flex; align-items:center;}
|
||||||
|
.bl-msg{min-height:26px; font-size:17px; font-weight:800;}
|
||||||
|
.bl-msg.win{color:var(--green);}
|
||||||
|
.bl-msg.lose{color:var(--red);}
|
||||||
|
.bl-kbd button:disabled{cursor:default;}
|
||||||
|
/* =========== Anchor words widget =========== */
|
||||||
|
.an{flex:1; min-height:0; display:flex; flex-direction:column;}
|
||||||
|
.an-hint{
|
||||||
|
flex:none; font-size:11.5px; font-weight:700; color:var(--muted);
|
||||||
|
padding:6px 10px; background:#f7fafd; border-bottom:1px solid #e3eaf3;
|
||||||
|
}
|
||||||
|
.an-stage{flex:1; min-height:0; position:relative; overflow:hidden; background:#fff; cursor:grab; touch-action:none;}
|
||||||
|
.an-stage:active{cursor:grabbing;}
|
||||||
|
.an-layer{position:absolute; inset:0;}
|
||||||
|
.an-cell{
|
||||||
|
position:absolute; width:128px; height:56px; border-radius:12px; border:2px dashed #dbe4f0;
|
||||||
|
display:flex; align-items:center; justify-content:center; font-weight:800; font-size:15px;
|
||||||
|
color:var(--ink); padding:2px 8px; overflow:hidden; text-align:center; cursor:pointer;
|
||||||
|
}
|
||||||
|
.an-cell:hover{border-color:#b6c6dc;}
|
||||||
|
.an-cell.filled{border-style:solid; border-color:#cdd7e4; background:#fff; box-shadow:0 3px 8px rgba(30,50,90,.12);}
|
||||||
|
.an-cell.root{border:2.5px solid #d9a400; background:#fff8dd;}
|
||||||
|
.an-cell.root.filled{background:var(--yellow); font-size:16px;}
|
||||||
|
.an-cell input{width:100%; border:none; outline:none; font:inherit; text-align:center; background:transparent;}
|
||||||
|
.an-word{overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:100%;}
|
||||||
|
.an-dot{
|
||||||
|
position:absolute; right:4px; bottom:4px; width:15px; height:15px; border-radius:50%;
|
||||||
|
border:1.5px solid #fff; box-shadow:0 1px 3px rgba(0,0,0,.3); cursor:pointer; padding:0;
|
||||||
|
opacity:.55; transition:opacity .15s;
|
||||||
|
}
|
||||||
|
.an-cell:hover .an-dot{opacity:1;}
|
||||||
|
body.hc .an-cell{border-color:#555; color:#000;}
|
||||||
|
/* =========== Dice widget =========== */
|
||||||
|
.dc{flex:1; min-height:0; display:flex; flex-direction:column; gap:10px; padding:12px; align-items:center;}
|
||||||
|
.dc-row{flex:1; display:flex; gap:14px; align-items:center; justify-content:center; flex-wrap:wrap; cursor:pointer;}
|
||||||
|
.die{
|
||||||
|
width:92px; height:92px; background:#fff; border:3.5px solid var(--ink); border-radius:18px;
|
||||||
|
display:grid; grid-template-columns:repeat(3,1fr); grid-template-rows:repeat(3,1fr);
|
||||||
|
padding:10px; box-shadow:0 5px 0 rgba(0,0,0,.15);
|
||||||
|
}
|
||||||
|
.die.rolling{animation:shake .3s infinite;}
|
||||||
|
.die i{width:16px; height:16px; border-radius:50%; background:var(--ink); place-self:center; visibility:hidden;}
|
||||||
|
.die i.on{visibility:visible;}
|
||||||
|
.dc-sum{font-size:22px; font-weight:900; min-height:28px;}
|
||||||
|
/* =========== Traffic light widget =========== */
|
||||||
|
.tl{flex:1; min-height:0; display:flex; align-items:center; justify-content:center; padding:14px;}
|
||||||
|
.tl-house{background:#22272f; border-radius:26px; padding:20px 18px; display:flex; flex-direction:column; gap:16px; box-shadow:0 10px 26px rgba(0,0,0,.35);}
|
||||||
|
.tl-lamp{
|
||||||
|
width:100px; height:100px; border-radius:50%; border:none; cursor:pointer;
|
||||||
|
opacity:.22; transition:all .2s; padding:0;
|
||||||
|
}
|
||||||
|
.tl-lamp.red{background:#e33; color:#e33;}
|
||||||
|
.tl-lamp.orange{background:#f5a623; color:#f5a623;}
|
||||||
|
.tl-lamp.green{background:#2ecc71; color:#2ecc71;}
|
||||||
|
.tl-lamp.on{opacity:1; box-shadow:0 0 46px currentColor;}
|
||||||
|
.tl-mini{padding:12px 10px; gap:10px; border-radius:20px;}
|
||||||
|
.tl-mini .tl-lamp{width:62px; height:62px;}
|
||||||
|
/* =========== Name picker widget =========== */
|
||||||
|
.np{flex:1; min-height:0; display:flex; flex-direction:column; gap:10px; padding:12px;}
|
||||||
|
.np textarea{
|
||||||
|
flex:1; min-height:0; border:2px solid #cdd9e8; border-radius:12px; padding:10px;
|
||||||
|
font:inherit; font-size:14.5px; resize:none; outline:none;
|
||||||
|
}
|
||||||
|
.np-result{
|
||||||
|
min-height:72px; display:flex; align-items:center; justify-content:center;
|
||||||
|
font-size:32px; font-weight:900; color:#fff; text-align:center; padding:8px 12px;
|
||||||
|
background:linear-gradient(135deg,var(--blue),var(--purple));
|
||||||
|
border-radius:18px; position:relative; overflow:hidden;
|
||||||
|
text-shadow:0 2px 6px rgba(0,0,0,.3); letter-spacing:1px;
|
||||||
|
}
|
||||||
|
.np-result.spin{animation:npspin .25s infinite; background:linear-gradient(135deg,var(--orange),var(--red));}
|
||||||
|
.np-result.winner{animation:pop .45s;}
|
||||||
|
@keyframes npspin{0%,100%{transform:scale(1) rotate(-1deg);} 50%{transform:scale(1.04) rotate(1deg);}}
|
||||||
|
.np-row{display:flex; gap:10px; align-items:center; flex:none;}
|
||||||
|
.np-row label{font-size:13px; font-weight:700; color:var(--muted); display:flex; gap:5px; align-items:center; margin-left:auto;}
|
||||||
|
/* gallery categories */
|
||||||
|
.gcat-title{font-size:13.5px; font-weight:800; color:var(--muted); margin:16px 0 8px; text-transform:uppercase; letter-spacing:.5px;}
|
||||||
|
.gcat-title:first-child{margin-top:0;}
|
||||||
|
/* =========== Timer widget =========== */
|
||||||
|
.timer{display:flex; flex-direction:column; align-items:center; flex:1; min-height:0; gap:10px; padding:14px;}
|
||||||
|
.tstage{
|
||||||
|
flex:1; min-height:0; width:100%; display:flex; flex-direction:column;
|
||||||
|
align-items:center; justify-content:center; gap:8px;
|
||||||
|
}
|
||||||
|
.topts{display:flex; justify-content:space-between; align-items:center; width:100%; flex:none;}
|
||||||
|
.tseg{display:flex; background:#e8eef6; border-radius:10px; overflow:hidden;}
|
||||||
|
.tseg button{border:none; background:transparent; padding:7px 13px; font-size:15px; cursor:pointer; line-height:1;}
|
||||||
|
.tseg button.on{background:var(--blue); color:#fff;}
|
||||||
|
.mm-seg button{font:inherit; font-weight:800; font-size:13px;}
|
||||||
|
.ticon{border:none; background:#e8eef6; border-radius:10px; padding:7px 11px; font-size:15px; cursor:pointer; line-height:1;}
|
||||||
|
.ticon.on{background:var(--yellow);}
|
||||||
|
.timer .tpie{
|
||||||
|
flex:1 1 0; min-height:80px; aspect-ratio:1/1; width:auto; max-width:100%; max-height:220px;
|
||||||
|
display:none; cursor:grab; touch-action:none;
|
||||||
|
}
|
||||||
|
.timer .tpie:active{cursor:grabbing;}
|
||||||
|
.timer.m-pie .tpie,.timer.m-both .tpie{display:block;}
|
||||||
|
.timer .tdisp{font-size:46px; font-weight:900; font-variant-numeric:tabular-nums; flex:none;}
|
||||||
|
.timer.m-both .tdisp{font-size:26px;}
|
||||||
|
.timer.m-pie .tdisp{font-size:17px; color:var(--muted);}
|
||||||
|
.timer .tdisp.warn{color:var(--red); animation:blink 1s infinite;}
|
||||||
|
@keyframes blink{50%{opacity:.35;}}
|
||||||
|
.timer .trow{display:flex; gap:8px; flex-wrap:wrap; justify-content:center; flex:none;}
|
||||||
|
.thint{flex:none;}
|
||||||
|
.timer .presets button{
|
||||||
|
border:none; border-radius:999px; padding:6px 13px; background:#e8eef6; font:inherit;
|
||||||
|
font-weight:800; font-size:12.5px; color:var(--muted); cursor:pointer;
|
||||||
|
}
|
||||||
|
.timer .presets button:hover{background:#dbe5f1; color:var(--ink);}
|
||||||
|
.timer .main button{
|
||||||
|
border:none; border-radius:12px; padding:10px 26px; font:inherit; font-weight:800;
|
||||||
|
font-size:15.5px; cursor:pointer; background:#e8eef6; color:var(--ink);
|
||||||
|
}
|
||||||
|
.timer .main .bstart{background:var(--green); color:#fff; min-width:110px;}
|
||||||
|
.thint{font-size:11.5px; color:var(--muted); text-align:center; line-height:1.4;}
|
||||||
|
.timer.flash{animation:tflash .5s 4;}
|
||||||
|
@keyframes tflash{50%{background:#fdeaa0;}}
|
||||||
4764
public/index.html
4764
public/index.html
File diff suppressed because it is too large
Load diff
55
public/js/app.js
Normal file
55
public/js/app.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/* teach - start: beeldgrootte, contrast, sessie-herstel */
|
||||||
|
/* =========================================================
|
||||||
|
init
|
||||||
|
==========================================================*/
|
||||||
|
/* display size: scale the whole app up for pupils who need it (device setting) */
|
||||||
|
let ZOOM = 100;
|
||||||
|
const btnZoomIn = document.getElementById("btnZoomIn");
|
||||||
|
const btnZoomOut = document.getElementById("btnZoomOut");
|
||||||
|
const zoomPct = document.getElementById("zoomPct");
|
||||||
|
function setZoom(z){
|
||||||
|
ZOOM = Math.max(100, Math.min(200, Math.round(z/10)*10));
|
||||||
|
document.body.style.zoom = ZOOM/100;
|
||||||
|
zoomPct.textContent = ZOOM+"%";
|
||||||
|
zoomPct.classList.toggle("on", ZOOM!==100);
|
||||||
|
btnZoomIn.title = btnZoomOut.title = zoomPct.title = T("zoomUI");
|
||||||
|
btnZoomIn.disabled = ZOOM>=200;
|
||||||
|
btnZoomOut.disabled = ZOOM<=100;
|
||||||
|
try{ localStorage.setItem("teach.zoom", ZOOM); }catch(e){}
|
||||||
|
}
|
||||||
|
btnZoomIn.addEventListener("click", ()=>setZoom(ZOOM+10));
|
||||||
|
btnZoomOut.addEventListener("click", ()=>setZoom(ZOOM-10));
|
||||||
|
zoomPct.addEventListener("click", ()=>setZoom(100)); /* tap the % to reset */
|
||||||
|
document.addEventListener("langchange", ()=>{ btnZoomIn.title = btnZoomOut.title = zoomPct.title = T("zoomUI"); });
|
||||||
|
try{ const z = +localStorage.getItem("teach.zoom"); if(z>100) setZoom(z); else setZoom(100); }catch(e){ setZoom(100); }
|
||||||
|
|
||||||
|
/* high-contrast accessibility mode (device setting, persists) */
|
||||||
|
const btnHC = document.getElementById("btnHC");
|
||||||
|
function setHC(on){
|
||||||
|
document.body.classList.toggle("hc", on);
|
||||||
|
btnHC.classList.toggle("on", on);
|
||||||
|
btnHC.title = T("a11y");
|
||||||
|
try{ localStorage.setItem("teach.hc", on ? "1" : ""); }catch(e){}
|
||||||
|
}
|
||||||
|
btnHC.addEventListener("click", ()=>setHC(!document.body.classList.contains("hc")));
|
||||||
|
document.addEventListener("langchange", ()=>{ btnHC.title = T("a11y"); });
|
||||||
|
try{ if(localStorage.getItem("teach.hc")==="1") setHC(true); }catch(e){}
|
||||||
|
|
||||||
|
applyI18n();
|
||||||
|
updateEmptyHint();
|
||||||
|
/* restore session (same browser tab session only) */
|
||||||
|
(function(){
|
||||||
|
let name = null;
|
||||||
|
try{ name = sessionStorage.getItem("teach.session"); }catch(e){}
|
||||||
|
const users = loadUsers();
|
||||||
|
if(name && users[name]){
|
||||||
|
currentUser = { name, data: users[name].data };
|
||||||
|
THEMES = themesFromData(currentUser.data);
|
||||||
|
GENERAL_EXTRA = generalExtraFromData(currentUser.data);
|
||||||
|
updateUserUI();
|
||||||
|
BS = boardsFromData(currentUser.data);
|
||||||
|
loadCurrentSlot();
|
||||||
|
}else{
|
||||||
|
updateBoardsUI();
|
||||||
|
}
|
||||||
|
})();
|
||||||
1064
public/js/board.js
Normal file
1064
public/js/board.js
Normal file
File diff suppressed because it is too large
Load diff
392
public/js/core.js
Normal file
392
public/js/core.js
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
/* teach - kern: versie, i18n, accounts, opslag, geluid */
|
||||||
|
"use strict";
|
||||||
|
/* version — bump on every change (form 0.0.00) */
|
||||||
|
const VERSION = "0.1.02";
|
||||||
|
(function(){
|
||||||
|
const tag = document.getElementById("verTag");
|
||||||
|
tag.textContent = "v"+VERSION;
|
||||||
|
/* dev branch deploys report a dev-… build via /api/version → mark as beta;
|
||||||
|
main/release builds show only the version number */
|
||||||
|
fetch("/api/version")
|
||||||
|
.then(r=>r.ok ? r.json() : null)
|
||||||
|
.then(v=>{
|
||||||
|
if(v && typeof v.version==="string" && v.version.startsWith("dev")){
|
||||||
|
tag.textContent = "v"+VERSION+" · beta";
|
||||||
|
tag.classList.add("beta");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(()=>{});
|
||||||
|
})();
|
||||||
|
/* =========================================================
|
||||||
|
i18n
|
||||||
|
==========================================================*/
|
||||||
|
const I18N = {
|
||||||
|
nl:{
|
||||||
|
addWidget:"Widget", chooseWidget:"Kies een widget", saveBoard:"Bord opslaan",
|
||||||
|
emptyBoard:"Het bord is leeg. Klik op “Widget” om te beginnen.",
|
||||||
|
wg_letters:"Letterblokken", wg_letters_d:"Leer lezen en spellen met letterblokken – 3 niveaus, zoals ‘Ik leer lezen’.",
|
||||||
|
wg_notes:"Notities", wg_notes_d:"Een vel om op te schrijven.",
|
||||||
|
wg_timer:"Timer", wg_timer_d:"Afteltimer met kijkklok voor de klas.",
|
||||||
|
wg_write:"Schrijfblad", wg_write_d:"Schrijven en rekenen: pen, gum en papier met schrijflijnen of rekenblokjes.",
|
||||||
|
bgBlank:"Leeg blad", bgLines:"Lijntjes", bgWrite:"Schrijflijnen",
|
||||||
|
bgGridS:"Kleine rekenblokjes", bgGridB:"Grote rekenblokjes",
|
||||||
|
penSize:"Dikte", eraser:"Gum", clearAll:"Alles wissen",
|
||||||
|
toolPen:"Pen", toolFountain:"Vulpen (dun en dik, reageert op druk of snelheid)",
|
||||||
|
toolMark:"Markeerstift", toolText:"Tekst typen (klik op het blad)",
|
||||||
|
wMin:"Minimaliseren", wMax:"Herstellen",
|
||||||
|
lvl1:"Niveau 1", lvl2:"Niveau 2", lvl3:"Niveau 3",
|
||||||
|
instr1:"Leg de letterblokken op de goede plek. Kijk goed naar de letters!",
|
||||||
|
instr2_begin:"Zoek de <b>beginletter</b> van het woord.",
|
||||||
|
instr2_mid:"Zoek de <b>middelste letter</b> van het woord.",
|
||||||
|
instr2_end:"Zoek de <b>eindletter</b> van het woord.",
|
||||||
|
instr3:"Spel het woord bij het plaatje. De nopjes helpen je!",
|
||||||
|
card:"Kaart", cardGeneral:"Algemeen", word:"Woord", welldone:"Goed zo!", next:"Volgende woord ▸",
|
||||||
|
cardDone:"Kaart klaar! 🎉", pickCard:"Kies een nieuwe kaart of een ander niveau.",
|
||||||
|
myWords:"Mijn woorden", manage:"Thema's en woorden beheren", addWord:"+ Toevoegen",
|
||||||
|
newTheme:"Nieuw thema", themeNamePh:"naam van het thema…", delTheme:"Thema verwijderen",
|
||||||
|
numWords:"Woorden per spel", all:"Alle", noThemes:"— nog geen thema's —",
|
||||||
|
muteAll:"Alle geluiden aan/uit", sndWidget:"Geluid van deze widget aan/uit",
|
||||||
|
catTaal:"Taal", catRekenen:"Rekenen", catTools:"Hulpmiddelen", catMedia:"Media",
|
||||||
|
wg_madd:"Plussommen", wg_madd_d:"Optellen tot 10, 20 of 100.",
|
||||||
|
wg_msub:"Minsommen", wg_msub_d:"Aftrekken tot 10, 20 of 100.",
|
||||||
|
wg_mmul:"Keersommen", wg_mmul_d:"De tafels van 1 t/m 12 oefenen.",
|
||||||
|
wg_mdiv:"Deelsommen", wg_mdiv_d:"Delen met de tafels van 1 t/m 12.",
|
||||||
|
wg_mpct:"Procenten", wg_mpct_d:"Procenten uitrekenen met mooie getallen.",
|
||||||
|
mGood:"Goed zo!", mAnsWas:"Het goede antwoord is", mAgain:"Nog een keer ▸",
|
||||||
|
mGoed:"goed", mOf:"van", mDoneT:"Klaar!",
|
||||||
|
amode:"Blokjes slepen of typen", penMode:"Tekenen op het bord",
|
||||||
|
fabWidgets:"Widget toevoegen", fabDraw:"Tekenen & schrijven", fabMind:"Mindmap op het bord",
|
||||||
|
fabImg:"Afbeelding op het bord",
|
||||||
|
nodeColor:"Kleur", nodeFont:"Lettergrootte", lineColor:"Lijnkleur", lineStyle:"Lijnstijl",
|
||||||
|
a11y:"Hoog contrast (voor slechtzienden)",
|
||||||
|
zoomUI:"Beeldgrootte: alles groter of kleiner tonen",
|
||||||
|
lensBig:"Loep groter", lensSmall:"Loep kleiner", lensShape:"Rond of vierkant",
|
||||||
|
toolHand:"Handje: verplaatsen en bedienen",
|
||||||
|
boardsNav:"Borden en mappen", newBoard:"+ Bord", newFolder:"+ Map",
|
||||||
|
folderPh:"naam nieuwe map…", boardName:"Bord", folderName:"Map",
|
||||||
|
delBoard:"Bord verwijderen (2× tikken)",
|
||||||
|
renameTip:"Dubbelklik op een bord om het te hernoemen",
|
||||||
|
hudMin:"Menu inklappen/uitklappen", hudOrient:"Horizontaal of verticaal",
|
||||||
|
wFull:"Maximaliseren", wUnfull:"Terugzetten",
|
||||||
|
wZoom:"Inhoud groter of kleiner (voor slechtzienden)",
|
||||||
|
wg_flits:"Flitswoorden", wg_flits_d:"Een woord flitst kort in beeld – lezen voordat het verdwijnt!",
|
||||||
|
flFlash:"⚡ Flits", flShow:"👁 Toon", flNext:"Volgende ▸", flSec:"sec",
|
||||||
|
wg_dice:"Dobbelstenen", wg_dice_d:"Gooi 1, 2 of 3 grote dobbelstenen.",
|
||||||
|
diceRoll:"🎲 Gooi!", diceSum:"Samen",
|
||||||
|
wg_light:"Stoplicht", wg_light_d:"Rood, oranje of groen – bijvoorbeeld voor het werkrumoer.",
|
||||||
|
wg_names:"Namenkiezer", wg_names_d:"Kies willekeurig een naam uit de klas.",
|
||||||
|
namesPh:"Eén naam per regel…", namesPick:"🎯 Kies!", namesNoRep:"Zonder herhaling",
|
||||||
|
namesDone:"Iedereen is geweest! 🎉",
|
||||||
|
wg_hak:"Hakken & plakken", wg_hak_d:"Woorden in klanken hakken en weer plakken, met voorlezen.",
|
||||||
|
hakChop:"✂️ Hak", hakGlue:"🔗 Plak", hakSay:"🔊 Lees voor",
|
||||||
|
wg_ball:"Ballonnenwoord", wg_ball_d:"Raad het woord letter voor letter voordat de ballonnen knappen.",
|
||||||
|
wg_anchor:"Ankerwoorden", wg_anchor_d:"Onbeperkt woordraster rond een thema: elk woord hoort bij de woorden eromheen.",
|
||||||
|
anchorPh:"thema…", anchorHint:"Tik op een vak en typ · sleep de achtergrond om te schuiven",
|
||||||
|
anchorColor:"Kleur: klik om te wisselen (verbind woorden met dezelfde kleur)",
|
||||||
|
ballWin:"Geraden! 🎉", ballLose:"Helaas! Het woord was:",
|
||||||
|
bgDots:"Stippen", toolRuler:"Liniaal", toolGeo:"Geodriehoek", toolLens:"Vergrootglas",
|
||||||
|
wg_mind:"Mindmap", wg_mind_d:"Woordweb, spin of boomdiagram om samen ideeën te ordenen.",
|
||||||
|
mmSpider:"Spin", mmTree:"Boom", mmWeb:"Woordweb", mmRoot:"Onderwerp",
|
||||||
|
mmHint:"Klik op tekst om te typen · sleep aan de rand",
|
||||||
|
wg_media:"Foto & video", wg_media_d:"Toon foto's en video's van internet, zoals YouTube of Vimeo.",
|
||||||
|
mediaPh:"Plak een link (YouTube, Vimeo, foto of video)…", mediaShow:"Tonen",
|
||||||
|
mediaEmpty:"Plak hierboven een link en klik op Tonen.",
|
||||||
|
mediaErr:"Deze link kan niet worden getoond. Probeer een YouTube-, Vimeo-, foto- of videolink, of open hem met ↗ in een nieuw tabblad.",
|
||||||
|
mediaOpen:"Open in nieuw tabblad",
|
||||||
|
wordPh:"woord (2–5 letters)", close:"Sluiten",
|
||||||
|
badWord:"Gebruik 2 t/m 5 letters (a-z).", needPic:"Kies eerst een plaatje.",
|
||||||
|
noWords:"Nog geen eigen woorden. Voeg er hierboven een toe!",
|
||||||
|
tabEmoji:"😀 Emoji", tabPicto:"🖼️ Picto's", tabUpload:"📷 Upload",
|
||||||
|
pictoSearch:"Zoeken", pictoPh:"zoekwoord…", pictoNone:"Niets gevonden. Probeer een ander woord.",
|
||||||
|
pictoLoad:"Zoeken…", pictoErr:"Kon picto's niet laden (internet nodig).",
|
||||||
|
pictoCredit:"Picto's: <a href='https://arasaac.org' target='_blank'>ARASAAC</a> (CC BY-NC-SA, Gobierno de Aragón). Ook leuk: <a href='https://www.sclera.be' target='_blank'>sclera.be</a> (gratis download, dan via Upload toevoegen).",
|
||||||
|
upload:"📷 Kies een afbeelding…",
|
||||||
|
upHint:"Tip: vierkant plaatje, witte of doorzichtige achtergrond, één duidelijk voorwerp, géén tekst in het plaatje.",
|
||||||
|
cats:["Dieren","Eten","Natuur","Vervoer","Spullen","Lichaam"],
|
||||||
|
notesPh:"Schrijf hier…",
|
||||||
|
start:"Start", pause:"Pauze", reset:"Reset", min:"min",
|
||||||
|
tmDigits:"Cijfers", tmVisual:"Kijkklok", tmBoth:"Allebei", tmSound:"Geluid aan/uit",
|
||||||
|
tmHint:"Sleep aan de rode schijf om de tijd in te stellen.",
|
||||||
|
login:"Inloggen", loginBtn:"Inloggen", registerBtn:"Account maken", logout:"Uitloggen",
|
||||||
|
namePh:"naam", passPh:"wachtwoord",
|
||||||
|
guestNote:"Zonder inloggen wordt niets bewaard: eigen woorden en het bord zijn weg als je de pagina sluit. Log in om alles op dit apparaat te bewaren.",
|
||||||
|
storedNote:"Je eigen woorden worden automatisch bewaard. Met “Bord opslaan” bewaar je ook de widgets op het bord.",
|
||||||
|
errLogin:"Onbekende naam of verkeerd wachtwoord.",
|
||||||
|
errExists:"Deze naam bestaat al.",
|
||||||
|
errName:"Naam: 2–20 letters of cijfers.",
|
||||||
|
errPass:"Wachtwoord: minimaal 4 tekens.",
|
||||||
|
errCrypto:"Inloggen werkt niet in deze browser.",
|
||||||
|
hello:"Hoi", boardSaved:"Opgeslagen ✓", loginFirst:"Eerst inloggen"
|
||||||
|
},
|
||||||
|
en:{
|
||||||
|
addWidget:"Widget", chooseWidget:"Choose a widget", saveBoard:"Save board",
|
||||||
|
emptyBoard:"The board is empty. Click “Widget” to get started.",
|
||||||
|
wg_letters:"Letter blocks", wg_letters_d:"Learn to read and spell with letter blocks – 3 levels, like ‘Ik leer lezen’.",
|
||||||
|
wg_notes:"Notes", wg_notes_d:"A sheet to write on.",
|
||||||
|
wg_timer:"Timer", wg_timer_d:"Countdown timer with a visual clock for class.",
|
||||||
|
wg_write:"Writing pad", wg_write_d:"Writing and maths: pen, eraser and paper with handwriting lines or maths squares.",
|
||||||
|
bgBlank:"Blank sheet", bgLines:"Lines", bgWrite:"Handwriting lines",
|
||||||
|
bgGridS:"Small maths squares", bgGridB:"Big maths squares",
|
||||||
|
penSize:"Thickness", eraser:"Eraser", clearAll:"Clear all",
|
||||||
|
toolPen:"Pen", toolFountain:"Fountain pen (thin and thick, follows pressure or speed)",
|
||||||
|
toolMark:"Highlighter", toolText:"Type text (click the sheet)",
|
||||||
|
wMin:"Minimize", wMax:"Restore",
|
||||||
|
lvl1:"Level 1", lvl2:"Level 2", lvl3:"Level 3",
|
||||||
|
instr1:"Place the letter blocks in the right spot. Look closely at the letters!",
|
||||||
|
instr2_begin:"Find the <b>beginning letter</b> of the word.",
|
||||||
|
instr2_mid:"Find the <b>middle letter</b> of the word.",
|
||||||
|
instr2_end:"Find the <b>ending letter</b> of the word.",
|
||||||
|
instr3:"Spell the word for the picture. The little studs will help you!",
|
||||||
|
card:"Card", cardGeneral:"General", word:"Word", welldone:"Well done!", next:"Next word ▸",
|
||||||
|
cardDone:"Card complete! 🎉", pickCard:"Pick a new card or another level.",
|
||||||
|
myWords:"My words", manage:"Manage themes and words", addWord:"+ Add",
|
||||||
|
newTheme:"New theme", themeNamePh:"name of the theme…", delTheme:"Delete theme",
|
||||||
|
numWords:"Words per game", all:"All", noThemes:"— no themes yet —",
|
||||||
|
muteAll:"All sounds on/off", sndWidget:"This widget's sound on/off",
|
||||||
|
catTaal:"Language", catRekenen:"Maths", catTools:"Tools", catMedia:"Media",
|
||||||
|
wg_madd:"Addition", wg_madd_d:"Adding up to 10, 20 or 100.",
|
||||||
|
wg_msub:"Subtraction", wg_msub_d:"Subtracting up to 10, 20 or 100.",
|
||||||
|
wg_mmul:"Multiplication", wg_mmul_d:"Practise the times tables 1–12.",
|
||||||
|
wg_mdiv:"Division", wg_mdiv_d:"Dividing with the tables 1–12.",
|
||||||
|
wg_mpct:"Percentages", wg_mpct_d:"Working out percentages with friendly numbers.",
|
||||||
|
mGood:"Well done!", mAnsWas:"The right answer is", mAgain:"Again ▸",
|
||||||
|
mGoed:"correct", mOf:"of", mDoneT:"Done!",
|
||||||
|
amode:"Drag blocks or type", penMode:"Draw on the board",
|
||||||
|
fabWidgets:"Add widget", fabDraw:"Draw & write", fabMind:"Mindmap on the board",
|
||||||
|
fabImg:"Picture on the board",
|
||||||
|
nodeColor:"Colour", nodeFont:"Font size", lineColor:"Line colour", lineStyle:"Line style",
|
||||||
|
a11y:"High contrast (for low vision)",
|
||||||
|
zoomUI:"Display size: show everything bigger or smaller",
|
||||||
|
lensBig:"Larger magnifier", lensSmall:"Smaller magnifier", lensShape:"Round or square",
|
||||||
|
toolHand:"Hand: move and interact",
|
||||||
|
boardsNav:"Boards and folders", newBoard:"+ Board", newFolder:"+ Folder",
|
||||||
|
folderPh:"name of new folder…", boardName:"Board", folderName:"Folder",
|
||||||
|
delBoard:"Delete board (tap twice)",
|
||||||
|
renameTip:"Double-click a board to rename it",
|
||||||
|
hudMin:"Collapse/expand menu", hudOrient:"Horizontal or vertical",
|
||||||
|
wFull:"Maximize", wUnfull:"Restore",
|
||||||
|
wZoom:"Content bigger or smaller (low vision)",
|
||||||
|
wg_flits:"Flash words", wg_flits_d:"A word flashes briefly – read it before it disappears!",
|
||||||
|
flFlash:"⚡ Flash", flShow:"👁 Show", flNext:"Next ▸", flSec:"sec",
|
||||||
|
wg_dice:"Dice", wg_dice_d:"Roll 1, 2 or 3 big dice.",
|
||||||
|
diceRoll:"🎲 Roll!", diceSum:"Total",
|
||||||
|
wg_light:"Traffic light", wg_light_d:"Red, orange or green – e.g. for classroom noise.",
|
||||||
|
wg_names:"Name picker", wg_names_d:"Pick a random name from the class.",
|
||||||
|
namesPh:"One name per line…", namesPick:"🎯 Pick!", namesNoRep:"No repeats",
|
||||||
|
namesDone:"Everyone has had a turn! 🎉",
|
||||||
|
wg_hak:"Chop & blend", wg_hak_d:"Chop words into sounds and blend them back, with read-aloud.",
|
||||||
|
hakChop:"✂️ Chop", hakGlue:"🔗 Blend", hakSay:"🔊 Read aloud",
|
||||||
|
wg_ball:"Balloon word", wg_ball_d:"Guess the word letter by letter before the balloons pop.",
|
||||||
|
wg_anchor:"Anchor words", wg_anchor_d:"Unlimited word grid around a theme: every word relates to its neighbours.",
|
||||||
|
anchorPh:"theme…", anchorHint:"Tap a cell and type · drag the background to pan",
|
||||||
|
anchorColor:"Colour: click to cycle (connect words with the same colour)",
|
||||||
|
ballWin:"You got it! 🎉", ballLose:"Oh no! The word was:",
|
||||||
|
bgDots:"Dots", toolRuler:"Ruler", toolGeo:"Set square", toolLens:"Magnifier",
|
||||||
|
wg_mind:"Mind map", wg_mind_d:"Word web, spider or tree diagram to organise ideas together.",
|
||||||
|
mmSpider:"Spider", mmTree:"Tree", mmWeb:"Word web", mmRoot:"Topic",
|
||||||
|
mmHint:"Click text to type · drag by the edge",
|
||||||
|
wg_media:"Photo & video", wg_media_d:"Show photos and videos from the internet, such as YouTube or Vimeo.",
|
||||||
|
mediaPh:"Paste a link (YouTube, Vimeo, photo or video)…", mediaShow:"Show",
|
||||||
|
mediaEmpty:"Paste a link above and click Show.",
|
||||||
|
mediaErr:"This link cannot be shown. Try a YouTube, Vimeo, photo or video link, or open it with ↗ in a new tab.",
|
||||||
|
mediaOpen:"Open in new tab",
|
||||||
|
wordPh:"word (2–5 letters)", close:"Close",
|
||||||
|
badWord:"Use 2 to 5 letters (a-z).", needPic:"Pick a picture first.",
|
||||||
|
noWords:"No custom words yet. Add one above!",
|
||||||
|
tabEmoji:"😀 Emoji", tabPicto:"🖼️ Pictos", tabUpload:"📷 Upload",
|
||||||
|
pictoSearch:"Search", pictoPh:"search word…", pictoNone:"Nothing found. Try another word.",
|
||||||
|
pictoLoad:"Searching…", pictoErr:"Could not load pictos (internet required).",
|
||||||
|
pictoCredit:"Pictos: <a href='https://arasaac.org' target='_blank'>ARASAAC</a> (CC BY-NC-SA, Gobierno de Aragón). Also nice: <a href='https://www.sclera.be' target='_blank'>sclera.be</a> (free download, then add via Upload).",
|
||||||
|
upload:"📷 Choose an image…",
|
||||||
|
upHint:"Tip: square picture, white or transparent background, one clear object, no text in the picture.",
|
||||||
|
cats:["Animals","Food","Nature","Transport","Things","Body"],
|
||||||
|
notesPh:"Write here…",
|
||||||
|
start:"Start", pause:"Pause", reset:"Reset", min:"min",
|
||||||
|
tmDigits:"Digits", tmVisual:"Visual clock", tmBoth:"Both", tmSound:"Sound on/off",
|
||||||
|
tmHint:"Drag the red disc to set the time.",
|
||||||
|
login:"Log in", loginBtn:"Log in", registerBtn:"Create account", logout:"Log out",
|
||||||
|
namePh:"name", passPh:"password",
|
||||||
|
guestNote:"Without logging in nothing is saved: custom words and the board are gone when you close the page. Log in to keep everything on this device.",
|
||||||
|
storedNote:"Your custom words are saved automatically. “Save board” also stores the widgets on the board.",
|
||||||
|
errLogin:"Unknown name or wrong password.",
|
||||||
|
errExists:"This name already exists.",
|
||||||
|
errName:"Name: 2–20 letters or digits.",
|
||||||
|
errPass:"Password: at least 4 characters.",
|
||||||
|
errCrypto:"Login does not work in this browser.",
|
||||||
|
hello:"Hi", boardSaved:"Saved ✓", loginFirst:"Log in first"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let LANG = "nl";
|
||||||
|
const T = k => I18N[LANG][k];
|
||||||
|
/* effective CSS zoom of the app (display-size setting); pointer coordinates
|
||||||
|
are visual pixels, absolute positions are layout pixels → divide by VZ() */
|
||||||
|
const VZ = ()=>{ const z = parseFloat(document.body.style.zoom); return z>0 ? z : 1; };
|
||||||
|
|
||||||
|
function applyI18n(){
|
||||||
|
document.querySelectorAll("[data-i18n]").forEach(el=>{ el.textContent = T(el.dataset.i18n); });
|
||||||
|
document.querySelectorAll("#langToggle button").forEach(b=>b.classList.toggle("active", b.dataset.lang===LANG));
|
||||||
|
document.getElementById("liName").placeholder = T("namePh");
|
||||||
|
document.getElementById("liPass").placeholder = T("passPh");
|
||||||
|
document.getElementById("btnMute").title = T("muteAll");
|
||||||
|
updateUserUI();
|
||||||
|
document.dispatchEvent(new CustomEvent("langchange"));
|
||||||
|
}
|
||||||
|
document.querySelectorAll("#langToggle button").forEach(b=>{
|
||||||
|
b.addEventListener("click", ()=>{ LANG=b.dataset.lang; applyI18n(); });
|
||||||
|
});
|
||||||
|
document.getElementById("btnFs").addEventListener("click", ()=>{
|
||||||
|
if(document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
Accounts & storage
|
||||||
|
- Guest: everything in memory only, gone on close.
|
||||||
|
- Logged in: PBKDF2-SHA256 hashed password (Web Crypto),
|
||||||
|
per-user data (custom words + saved board) in localStorage.
|
||||||
|
Note: this protects the password itself; anyone with physical
|
||||||
|
access to this device/browser profile can still clear or read
|
||||||
|
stored data. For real multi-device security a server is needed.
|
||||||
|
==========================================================*/
|
||||||
|
const USERS_KEY = "teach.users";
|
||||||
|
let currentUser = null; // {name, data}
|
||||||
|
|
||||||
|
function loadUsers(){ try{ return JSON.parse(localStorage.getItem(USERS_KEY)) || {}; }catch(e){ return {}; } }
|
||||||
|
function saveUsers(u){ try{ localStorage.setItem(USERS_KEY, JSON.stringify(u)); }catch(e){} }
|
||||||
|
|
||||||
|
const bufToHex = b => [...new Uint8Array(b)].map(x=>x.toString(16).padStart(2,"0")).join("");
|
||||||
|
const hexToBuf = h => new Uint8Array(h.match(/.{2}/g).map(x=>parseInt(x,16)));
|
||||||
|
async function hashPw(pw, saltHex, iterations){
|
||||||
|
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(pw), "PBKDF2", false, ["deriveBits"]);
|
||||||
|
const bits = await crypto.subtle.deriveBits(
|
||||||
|
{name:"PBKDF2", hash:"SHA-256", salt:hexToBuf(saltHex), iterations}, key, 256);
|
||||||
|
return bufToHex(bits);
|
||||||
|
}
|
||||||
|
const cryptoOK = !!(window.crypto && crypto.subtle && crypto.getRandomValues);
|
||||||
|
|
||||||
|
function persistUser(){
|
||||||
|
if(!currentUser) return;
|
||||||
|
const users = loadUsers();
|
||||||
|
if(users[currentUser.name]){ users[currentUser.name].data = currentUser.data; saveUsers(users); }
|
||||||
|
}
|
||||||
|
function persistWords(){
|
||||||
|
if(currentUser){
|
||||||
|
currentUser.data.themes = THEMES;
|
||||||
|
currentUser.data.generalExtra = GENERAL_EXTRA;
|
||||||
|
persistUser();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function generalExtraFromData(data){
|
||||||
|
return (data && data.generalExtra) ? data.generalExtra : {nl:[], en:[]};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* login UI */
|
||||||
|
const loginWrap = document.getElementById("loginWrap");
|
||||||
|
const loginMsg = document.getElementById("loginMsg");
|
||||||
|
function updateUserUI(){
|
||||||
|
document.getElementById("userLbl").textContent = currentUser ? currentUser.name : T("login");
|
||||||
|
document.getElementById("btnUser").classList.toggle("on", !!currentUser);
|
||||||
|
document.getElementById("loginBox").style.display = currentUser ? "none" : "block";
|
||||||
|
document.getElementById("loggedBox").style.display = currentUser ? "block" : "none";
|
||||||
|
if(currentUser) document.getElementById("helloLbl").textContent = `${T("hello")} ${currentUser.name}! 👋`;
|
||||||
|
}
|
||||||
|
document.getElementById("btnUser").addEventListener("click", ()=>{
|
||||||
|
loginMsg.textContent = ""; loginMsg.classList.remove("ok");
|
||||||
|
loginWrap.classList.add("open");
|
||||||
|
});
|
||||||
|
loginWrap.addEventListener("click", e=>{ if(e.target===loginWrap) loginWrap.classList.remove("open"); });
|
||||||
|
|
||||||
|
async function doLogin(register){
|
||||||
|
const name = document.getElementById("liName").value.trim();
|
||||||
|
const pass = document.getElementById("liPass").value;
|
||||||
|
loginMsg.classList.remove("ok");
|
||||||
|
if(!cryptoOK){ loginMsg.textContent = T("errCrypto"); return; }
|
||||||
|
if(!/^[a-zA-Z0-9_-]{2,20}$/.test(name)){ loginMsg.textContent = T("errName"); return; }
|
||||||
|
if(pass.length < 4){ loginMsg.textContent = T("errPass"); return; }
|
||||||
|
const users = loadUsers();
|
||||||
|
try{
|
||||||
|
if(register){
|
||||||
|
if(users[name]){ loginMsg.textContent = T("errExists"); return; }
|
||||||
|
const salt = bufToHex(crypto.getRandomValues(new Uint8Array(16)));
|
||||||
|
const iterations = 150000;
|
||||||
|
const hash = await hashPw(pass, salt, iterations);
|
||||||
|
users[name] = { salt, iterations, hash, data:{ themes:{nl:[],en:[]}, board:null } };
|
||||||
|
saveUsers(users);
|
||||||
|
}else{
|
||||||
|
const u = users[name];
|
||||||
|
if(!u){ loginMsg.textContent = T("errLogin"); return; }
|
||||||
|
const hash = await hashPw(pass, u.salt, u.iterations);
|
||||||
|
if(hash !== u.hash){ loginMsg.textContent = T("errLogin"); return; }
|
||||||
|
}
|
||||||
|
}catch(e){ loginMsg.textContent = T("errCrypto"); return; }
|
||||||
|
currentUser = { name, data: loadUsers()[name].data };
|
||||||
|
THEMES = themesFromData(currentUser.data);
|
||||||
|
GENERAL_EXTRA = generalExtraFromData(currentUser.data);
|
||||||
|
document.getElementById("liPass").value = "";
|
||||||
|
try{ sessionStorage.setItem("teach.session", name); }catch(e){}
|
||||||
|
updateUserUI();
|
||||||
|
loginWrap.classList.remove("open");
|
||||||
|
document.dispatchEvent(new CustomEvent("wordschange"));
|
||||||
|
BS = boardsFromData(currentUser.data);
|
||||||
|
loadCurrentSlot();
|
||||||
|
}
|
||||||
|
document.getElementById("btnLogin").addEventListener("click", ()=>doLogin(false));
|
||||||
|
document.getElementById("btnRegister").addEventListener("click", ()=>doLogin(true));
|
||||||
|
document.getElementById("liPass").addEventListener("keydown", e=>{ if(e.key==="Enter") doLogin(false); });
|
||||||
|
document.getElementById("btnLogout").addEventListener("click", ()=>{
|
||||||
|
persistUser();
|
||||||
|
currentUser = null;
|
||||||
|
THEMES = {nl:[],en:[]};
|
||||||
|
GENERAL_EXTRA = {nl:[],en:[]};
|
||||||
|
try{ sessionStorage.removeItem("teach.session"); }catch(e){}
|
||||||
|
updateUserUI();
|
||||||
|
loginWrap.classList.remove("open");
|
||||||
|
document.dispatchEvent(new CustomEvent("wordschange"));
|
||||||
|
BS = freshBS();
|
||||||
|
loadCurrentSlot();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* save board */
|
||||||
|
document.getElementById("btnSave").addEventListener("click", ()=>{
|
||||||
|
const btn = document.getElementById("btnSave");
|
||||||
|
const lbl = btn.querySelector("span");
|
||||||
|
if(!currentUser){
|
||||||
|
lbl.textContent = T("loginFirst");
|
||||||
|
setTimeout(()=>{ lbl.textContent = T("saveBoard"); }, 2200);
|
||||||
|
loginWrap.classList.add("open");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stashCurrent();
|
||||||
|
currentUser.data.boards = BS;
|
||||||
|
delete currentUser.data.board;
|
||||||
|
persistUser();
|
||||||
|
lbl.textContent = T("boardSaved");
|
||||||
|
btn.classList.add("ok");
|
||||||
|
setTimeout(()=>{ lbl.textContent = T("saveBoard"); btn.classList.remove("ok"); }, 1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
Sounds
|
||||||
|
==========================================================*/
|
||||||
|
let AC=null, MUTED=false;
|
||||||
|
const btnMute = document.getElementById("btnMute");
|
||||||
|
btnMute.addEventListener("click", ()=>{
|
||||||
|
MUTED = !MUTED;
|
||||||
|
btnMute.textContent = MUTED ? "🔇" : "🔊";
|
||||||
|
btnMute.title = T("muteAll");
|
||||||
|
});
|
||||||
|
function tone(freq, dur=0.12, delay=0, type="sine", vol=0.18){
|
||||||
|
if(MUTED) return;
|
||||||
|
try{
|
||||||
|
AC = AC || new (window.AudioContext||window.webkitAudioContext)();
|
||||||
|
const o=AC.createOscillator(), g=AC.createGain();
|
||||||
|
o.type=type; o.frequency.value=freq;
|
||||||
|
g.gain.setValueAtTime(vol, AC.currentTime+delay);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.001, AC.currentTime+delay+dur);
|
||||||
|
o.connect(g); g.connect(AC.destination);
|
||||||
|
o.start(AC.currentTime+delay); o.stop(AC.currentTime+delay+dur+0.02);
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
const sndGood = ()=> tone(880,0.1);
|
||||||
|
const sndBad = ()=> tone(180,0.18,0,"square",0.10);
|
||||||
|
const sndWin = ()=>{ [523,659,784,1047].forEach((f,i)=>tone(f,0.16,i*0.12,"triangle",0.2)); };
|
||||||
|
|
||||||
132
public/js/data.js
Normal file
132
public/js/data.js
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
/* teach - woorddata: algemene kaart, thema's, gedeelde woordbronnen */
|
||||||
|
/* =========================================================
|
||||||
|
Letter game data
|
||||||
|
==========================================================*/
|
||||||
|
const DOTCODE = {
|
||||||
|
a:[1], b:[1,2], c:[1,4], d:[1,4,5], e:[1,5], f:[1,2,4], g:[1,2,4,5], h:[1,2,5],
|
||||||
|
i:[2,4], j:[2,4,5], k:[1,3], l:[1,2,3], m:[1,3,4], n:[1,3,4,5], o:[1,3,5],
|
||||||
|
p:[1,2,3,4], q:[1,2,3,4,5], r:[1,2,3,5], s:[2,3,4], t:[2,3,4,5], u:[1,3,6],
|
||||||
|
v:[1,2,3,6], w:[2,4,5,6], x:[1,3,4,6], y:[1,3,4,5,6], z:[1,3,5,6],
|
||||||
|
/* digits use the braille number patterns (a–j) */
|
||||||
|
"1":[1], "2":[1,2], "3":[1,4], "4":[1,4,5], "5":[1,5],
|
||||||
|
"6":[1,2,4], "7":[1,2,4,5], "8":[1,2,5], "9":[2,4], "0":[2,4,5]
|
||||||
|
};
|
||||||
|
const VOWELS = "aeiou";
|
||||||
|
function dotGrid(letter){
|
||||||
|
const code = DOTCODE[letter] || [];
|
||||||
|
const grid = document.createElement("div");
|
||||||
|
grid.className = "dots";
|
||||||
|
[1,4,2,5,3,6].forEach(p=>{
|
||||||
|
const d = document.createElement("i");
|
||||||
|
d.className = code.includes(p) ? "on" : "off";
|
||||||
|
grid.appendChild(d);
|
||||||
|
});
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* built-in general words: ONE card per language (extendable via the editor) */
|
||||||
|
const WORDS = {
|
||||||
|
nl:[
|
||||||
|
["aap","🐵"],["vis","🐟"],["zon","☀️"],["kat","🐈"],["bal","⚽"],["ei","🥚"],
|
||||||
|
["boom","🌳"],["huis","🏠"],["maan","🌙"],["boot","⛵"],["kip","🐔"],["muis","🐭"],
|
||||||
|
["boek","📕"],["ster","⭐"],["roos","🌹"],["vuur","🔥"],["fiets","🚲"],["taart","🎂"],
|
||||||
|
["jas","🧥"],["bril","👓"],["klok","⏰"],["peer","🍐"],["kaas","🧀"],["eend","🦆"],
|
||||||
|
["hand","✋"],["voet","🦶"],["neus","👃"],["oog","👁️"],["oor","👂"],["mond","👄"]
|
||||||
|
],
|
||||||
|
en:[
|
||||||
|
["cat","🐈"],["dog","🐕"],["sun","☀️"],["egg","🥚"],["ball","⚽"],["fish","🐟"],
|
||||||
|
["tree","🌳"],["moon","🌙"],["boat","⛵"],["star","⭐"],["book","📕"],["cake","🎂"],
|
||||||
|
["house","🏠"],["mouse","🐭"],["apple","🍎"],["train","🚂"],["fire","🔥"],["rose","🌹"],
|
||||||
|
["bear","🐻"],["duck","🦆"],["frog","🐸"],["bee","🐝"],["cow","🐄"],["pig","🐷"],
|
||||||
|
["hand","✋"],["foot","🦶"],["nose","👃"],["eye","👁️"],["ear","👂"],["milk","🥛"]
|
||||||
|
]
|
||||||
|
};
|
||||||
|
/* teacher's own additions to the general card (persisted per user) */
|
||||||
|
let GENERAL_EXTRA = {nl:[], en:[]};
|
||||||
|
function generalList(){ return WORDS[LANG].concat(GENERAL_EXTRA[LANG]); }
|
||||||
|
const shuffle = a => { a=a.slice(); for(let i=a.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1)); [a[i],a[j]]=[a[j],a[i]];} return a; };
|
||||||
|
|
||||||
|
/* custom themes: folders of own words.
|
||||||
|
{nl:[{name:"Herfst", words:[[word,pic],...]}], en:[...]} */
|
||||||
|
let THEMES = {nl:[], en:[]};
|
||||||
|
function themesFromData(data){
|
||||||
|
if(!data) return {nl:[],en:[]};
|
||||||
|
if(data.themes) return data.themes;
|
||||||
|
/* migrate old flat word lists into one theme */
|
||||||
|
const t = {nl:[],en:[]};
|
||||||
|
if(data.words) ["nl","en"].forEach(l=>{
|
||||||
|
if(Array.isArray(data.words[l]) && data.words[l].length)
|
||||||
|
t[l] = [{name: l==="nl" ? "Mijn woorden" : "My words", words: data.words[l]}];
|
||||||
|
});
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
const isImg = p => typeof p==="string" && (p.startsWith("data:") || p.startsWith("http"));
|
||||||
|
function setPic(el, pic){
|
||||||
|
if(isImg(pic)){ el.innerHTML = ""; const im = document.createElement("img"); im.src = pic; im.alt=""; el.appendChild(im); }
|
||||||
|
else el.textContent = pic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- shared word source (general card + themes + anchor grids) ---- */
|
||||||
|
/* anchor-word widgets register their words here so every language widget
|
||||||
|
can use them as a theme automatically */
|
||||||
|
let ANCHORS = []; /* [{el, name, words:[[w,pic],...]}] */
|
||||||
|
function anchorSources(){
|
||||||
|
ANCHORS = ANCHORS.filter(a=>a.el && document.contains(a.el));
|
||||||
|
return ANCHORS.filter(a=>a.words.length);
|
||||||
|
}
|
||||||
|
function normWordSource(card){
|
||||||
|
if(typeof card==="string"){
|
||||||
|
if(card.startsWith("t")){
|
||||||
|
const th = THEMES[LANG][+card.slice(1)];
|
||||||
|
if(th && th.words.length) return card;
|
||||||
|
}else if(card.startsWith("a")){
|
||||||
|
if(anchorSources()[+card.slice(1)]) return card;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "g";
|
||||||
|
}
|
||||||
|
function wordSourceList(card){
|
||||||
|
card = normWordSource(card);
|
||||||
|
if(card.startsWith("t")) return THEMES[LANG][+card.slice(1)].words;
|
||||||
|
if(card.startsWith("a")) return anchorSources()[+card.slice(1)].words;
|
||||||
|
return generalList();
|
||||||
|
}
|
||||||
|
function fillWordSourceSel(sel, card){
|
||||||
|
sel.innerHTML = "";
|
||||||
|
const og = document.createElement("option");
|
||||||
|
og.value = "g"; og.textContent = T("cardGeneral");
|
||||||
|
sel.appendChild(og);
|
||||||
|
THEMES[LANG].forEach((th,i)=>{
|
||||||
|
if(!th.words.length) return;
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = "t"+i; o.textContent = "★ "+th.name;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
anchorSources().forEach((a,i)=>{
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = "a"+i; o.textContent = "⚓ "+a.name;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
sel.value = normWordSource(card);
|
||||||
|
if(sel.selectedIndex < 0) sel.selectedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* emoji palette (grouped) */
|
||||||
|
const splitEmoji = s => {
|
||||||
|
const out = [];
|
||||||
|
for(const ch of s){
|
||||||
|
if(out.length && (ch==="️" || ch==="")) out[out.length-1]+=ch;
|
||||||
|
else if(out.length && out[out.length-1].endsWith("")) out[out.length-1]+=ch;
|
||||||
|
else out.push(ch);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
const EMOJI_CATS = [
|
||||||
|
splitEmoji("🐵🐶🐱🐭🐹🐰🦊🐻🐼🐨🐯🦁🐮🐷🐸🐔🐧🐦🦆🦉🐴🦄🐝🐛🦋🐌🐞🐠🐟🐬🐳🐢🐍🦎🐘🦒"),
|
||||||
|
splitEmoji("🍎🍐🍊🍋🍌🍉🍇🍓🍒🍑🥝🍅🥕🌽🥔🧀🥚🍞🥐🥞🍕🍟🍦🍪🎂🍫🍬🥛🧃🍯"),
|
||||||
|
splitEmoji("🌳🌲🌴🌵🌷🌹🌻🌸💐🍄⭐🌙☀️⛅🌈❄️🔥💧🌊🪨"),
|
||||||
|
splitEmoji("🚗🚕🚌🚑🚒🚜🚲🛴🏍️🚂✈️🚁⛵🚀🛶🚢"),
|
||||||
|
splitEmoji("⚽🏀🎈🎁🧸📕✏️🖍️✂️🎨🥁🎸👓⌚⏰💡🔑🧤🧢👟👗🧥🛏️🪑🚪🏠"),
|
||||||
|
splitEmoji("✋👂👃👁️👄🦶😀😢😴🤧👶👦👧👵👴")
|
||||||
|
];
|
||||||
|
|
||||||
159
public/js/widgets/anchor.js
Normal file
159
public/js/widgets/anchor.js
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
/* widget: Ankerwoorden */
|
||||||
|
/* =========================================================
|
||||||
|
Anchor words — unlimited word grid around a theme
|
||||||
|
==========================================================*/
|
||||||
|
function mountAnchor(root, initState){
|
||||||
|
const CW = 128, CH = 56, G = 10;
|
||||||
|
/* colour cycle for connecting words: none → green → blue → red → purple → orange → yellow */
|
||||||
|
const COLS = [null,"#3fae6a","#3b7dd8","#e0554d","#9b59b6","#f28c38","#d9a400"];
|
||||||
|
const S = { cells:{}, pan:{x:0, y:0} };
|
||||||
|
if(initState){
|
||||||
|
const src = initState.cells || {};
|
||||||
|
Object.keys(src).forEach(k=>{
|
||||||
|
const v = src[k];
|
||||||
|
/* migrate: old format stored plain strings */
|
||||||
|
S.cells[k] = (typeof v==="string") ? {t:v, c:null} : {t:v.t||"", c:v.c||null};
|
||||||
|
});
|
||||||
|
S.pan = initState.pan || {x:0, y:0};
|
||||||
|
}
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="an">
|
||||||
|
<div class="an-hint"></div>
|
||||||
|
<div class="an-stage"><div class="an-layer"></div></div>
|
||||||
|
</div>`;
|
||||||
|
const stage = root.querySelector(".an-stage"),
|
||||||
|
layer = root.querySelector(".an-layer"),
|
||||||
|
hint = root.querySelector(".an-hint");
|
||||||
|
const key = (x,y)=>x+","+y;
|
||||||
|
|
||||||
|
/* register this grid as a word source for the language widgets */
|
||||||
|
const reg = { el: root, name: T("wg_anchor"), words: [] };
|
||||||
|
ANCHORS.push(reg);
|
||||||
|
function syncSource(quiet){
|
||||||
|
const center = S.cells["0,0"];
|
||||||
|
reg.name = (center && center.t) ? center.t : T("wg_anchor");
|
||||||
|
const seen = new Set();
|
||||||
|
reg.words = [];
|
||||||
|
Object.keys(S.cells).forEach(k=>{
|
||||||
|
const w = (S.cells[k].t||"").trim().toLowerCase();
|
||||||
|
if(/^[a-zà-ÿ]{2,12}$/.test(w) && !seen.has(w)){
|
||||||
|
seen.add(w);
|
||||||
|
reg.words.push([w, "⚓"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if(!quiet) document.dispatchEvent(new CustomEvent("wordschange"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPan(){ layer.style.transform = `translate(${-S.pan.x}px,${-S.pan.y}px)`; }
|
||||||
|
function render(){
|
||||||
|
layer.innerHTML = "";
|
||||||
|
const w = stage.offsetWidth, h = stage.offsetHeight;
|
||||||
|
if(w < 2) return;
|
||||||
|
const cx = w/2, cy = h/2;
|
||||||
|
const x0 = Math.floor((S.pan.x - cx - CW)/(CW+G)), x1 = Math.ceil((S.pan.x + w - cx + CW)/(CW+G));
|
||||||
|
const y0 = Math.floor((S.pan.y - cy - CH)/(CH+G)), y1 = Math.ceil((S.pan.y + h - cy + CH)/(CH+G));
|
||||||
|
for(let gx=x0; gx<=x1; gx++){
|
||||||
|
for(let gy=y0; gy<=y1; gy++){
|
||||||
|
const c = document.createElement("div");
|
||||||
|
const cell = S.cells[key(gx,gy)];
|
||||||
|
const word = cell && cell.t;
|
||||||
|
const isRoot = gx===0 && gy===0;
|
||||||
|
c.className = "an-cell" + (word ? " filled" : "") + (isRoot ? " root" : "");
|
||||||
|
c.style.left = (cx + gx*(CW+G) - CW/2)+"px";
|
||||||
|
c.style.top = (cy + gy*(CH+G) - CH/2)+"px";
|
||||||
|
if(word){
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "an-word";
|
||||||
|
span.textContent = word;
|
||||||
|
c.appendChild(span);
|
||||||
|
/* only the theme word is styled by default; other words are plain
|
||||||
|
until the teacher gives them a colour to show a connection */
|
||||||
|
if(!isRoot){
|
||||||
|
if(cell.c){
|
||||||
|
c.style.borderColor = cell.c;
|
||||||
|
c.style.background = cell.c + "1c";
|
||||||
|
}
|
||||||
|
const dot = document.createElement("button");
|
||||||
|
dot.className = "an-dot";
|
||||||
|
dot.style.background = cell.c || "#cdd7e4";
|
||||||
|
dot.title = T("anchorColor");
|
||||||
|
dot.addEventListener("pointerdown", ev=>ev.stopPropagation());
|
||||||
|
dot.addEventListener("click", ev=>{
|
||||||
|
ev.stopPropagation();
|
||||||
|
cell.c = COLS[(COLS.indexOf(cell.c)+1) % COLS.length];
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
c.appendChild(dot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.dataset.gx = gx; c.dataset.gy = gy;
|
||||||
|
layer.appendChild(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyPan();
|
||||||
|
hint.textContent = T("anchorHint");
|
||||||
|
}
|
||||||
|
/* pan by dragging, click (without moving) to type */
|
||||||
|
let pd = null;
|
||||||
|
stage.addEventListener("pointerdown", e=>{
|
||||||
|
if(e.target.tagName==="INPUT") return;
|
||||||
|
if(e.pointerType==="mouse" && e.button!==0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
stage.setPointerCapture(e.pointerId);
|
||||||
|
const sc = stage.getBoundingClientRect().width / stage.offsetWidth;
|
||||||
|
pd = { x:e.clientX/sc, y:e.clientY/sc, px:S.pan.x, py:S.pan.y,
|
||||||
|
moved:false, target:e.target.closest(".an-cell"), sc };
|
||||||
|
});
|
||||||
|
stage.addEventListener("pointermove", e=>{
|
||||||
|
if(!pd) return;
|
||||||
|
const dx = e.clientX/pd.sc - pd.x, dy = e.clientY/pd.sc - pd.y;
|
||||||
|
if(Math.abs(dx) > 6 || Math.abs(dy) > 6) pd.moved = true;
|
||||||
|
if(pd.moved){ S.pan.x = pd.px - dx; S.pan.y = pd.py - dy; applyPan(); }
|
||||||
|
});
|
||||||
|
stage.addEventListener("pointerup", ()=>{
|
||||||
|
if(!pd) return;
|
||||||
|
const {moved, target} = pd;
|
||||||
|
pd = null;
|
||||||
|
if(moved){ render(); return; }
|
||||||
|
if(target) editCell(target);
|
||||||
|
});
|
||||||
|
stage.addEventListener("pointercancel", ()=>{
|
||||||
|
const moved = pd && pd.moved;
|
||||||
|
pd = null;
|
||||||
|
if(moved) render();
|
||||||
|
});
|
||||||
|
function editCell(cell){
|
||||||
|
if(cell.querySelector("input")) return;
|
||||||
|
const gx = +cell.dataset.gx, gy = +cell.dataset.gy, k = key(gx,gy);
|
||||||
|
const inp = document.createElement("input");
|
||||||
|
inp.maxLength = 24;
|
||||||
|
inp.value = S.cells[k] ? S.cells[k].t : "";
|
||||||
|
if(gx===0 && gy===0) inp.placeholder = T("anchorPh");
|
||||||
|
cell.textContent = "";
|
||||||
|
cell.appendChild(inp);
|
||||||
|
setTimeout(()=>{ inp.focus(); inp.select(); }, 0);
|
||||||
|
let done = false;
|
||||||
|
const commit = ()=>{
|
||||||
|
if(done) return; done = true;
|
||||||
|
const v = inp.value.trim();
|
||||||
|
if(v) S.cells[k] = { t:v, c:(S.cells[k] && S.cells[k].c) || null };
|
||||||
|
else delete S.cells[k];
|
||||||
|
syncSource();
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
inp.addEventListener("keydown", ev=>{
|
||||||
|
ev.stopPropagation();
|
||||||
|
if(ev.key==="Enter") commit();
|
||||||
|
if(ev.key==="Escape"){ done = true; render(); }
|
||||||
|
});
|
||||||
|
inp.addEventListener("blur", commit);
|
||||||
|
inp.addEventListener("pointerdown", ev=>ev.stopPropagation());
|
||||||
|
}
|
||||||
|
new ResizeObserver(render).observe(stage);
|
||||||
|
document.addEventListener("langchange", render);
|
||||||
|
syncSource(true);
|
||||||
|
render();
|
||||||
|
if(reg.words.length) document.dispatchEvent(new CustomEvent("wordschange"));
|
||||||
|
return { getState: ()=>({ cells:S.cells, pan:S.pan }) };
|
||||||
|
}
|
||||||
|
|
||||||
109
public/js/widgets/ball.js
Normal file
109
public/js/widgets/ball.js
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
/* widget: Ballonnenwoord */
|
||||||
|
/* =========================================================
|
||||||
|
Balloon word widget — guess before the balloons pop
|
||||||
|
==========================================================*/
|
||||||
|
function mountBall(root, initState){
|
||||||
|
const S = { card:0, snd:true, lives:6, guessed:new Set(), over:false, order:[], idx:0, stars:0 };
|
||||||
|
if(initState){ S.card = initState.card ?? 0; S.snd = initState.snd !== false; }
|
||||||
|
const play = f=>{ if(S.snd) f(); };
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="mt">
|
||||||
|
<div class="mt-top">
|
||||||
|
<button class="lg-snd"></button>
|
||||||
|
<select class="fl-sel bl-src"></select>
|
||||||
|
<div class="mt-score bl-score"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-card">
|
||||||
|
<div class="bl-balloons"></div>
|
||||||
|
<div class="bl-word"></div>
|
||||||
|
<div class="bl-msg"></div>
|
||||||
|
<button class="lg-next bl-next"></button>
|
||||||
|
</div>
|
||||||
|
<div class="lg-tray bl-kbd lg-kbd" style="display:flex;"></div>
|
||||||
|
</div>`;
|
||||||
|
const el = s => root.querySelector(s);
|
||||||
|
const srcSel = el(".bl-src"), balloonsEl = el(".bl-balloons"),
|
||||||
|
wordEl = el(".bl-word"), msgEl = el(".bl-msg"),
|
||||||
|
nextBtn = el(".bl-next"), kbdEl = el(".bl-kbd"),
|
||||||
|
scoreEl = el(".bl-score"), sndBtn = el(".lg-snd");
|
||||||
|
|
||||||
|
function list(){ S.card = normWordSource(S.card); return wordSourceList(S.card); }
|
||||||
|
function buildSrc(){ fillWordSourceSel(srcSel, S.card); }
|
||||||
|
"abcdefghijklmnopqrstuvwxyz".split("").forEach(l=>{
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.textContent = l; b.dataset.l = l;
|
||||||
|
b.className = VOWELS.includes(l) ? "v" : "c";
|
||||||
|
b.addEventListener("click", ()=>guess(l, b));
|
||||||
|
kbdEl.appendChild(b);
|
||||||
|
});
|
||||||
|
function word(){ return list()[S.order[S.idx]][0]; }
|
||||||
|
function drawWord(reveal){
|
||||||
|
wordEl.textContent = word().split("").map(l=>(reveal || S.guessed.has(l)) ? l : "_").join(" ");
|
||||||
|
}
|
||||||
|
function drawBalloons(){
|
||||||
|
balloonsEl.textContent = "🎈".repeat(S.lives) + "💥".repeat(6-S.lives);
|
||||||
|
}
|
||||||
|
function newWord(){
|
||||||
|
if(!S.order.length || S.idx >= S.order.length){
|
||||||
|
S.order = shuffle(list().map((_,i)=>i));
|
||||||
|
S.idx = 0;
|
||||||
|
}
|
||||||
|
S.lives = 6; S.guessed = new Set(); S.over = false;
|
||||||
|
msgEl.textContent = ""; msgEl.className = "bl-msg";
|
||||||
|
nextBtn.classList.remove("show");
|
||||||
|
kbdEl.querySelectorAll("button").forEach(b=>{ b.disabled = false; b.style.opacity = 1; });
|
||||||
|
drawWord(false); drawBalloons();
|
||||||
|
scoreEl.textContent = S.stars ? S.stars+" ★" : "";
|
||||||
|
}
|
||||||
|
function guess(l, btn){
|
||||||
|
if(S.over || btn.disabled) return;
|
||||||
|
btn.disabled = true; btn.style.opacity = .3;
|
||||||
|
if(word().includes(l)){
|
||||||
|
S.guessed.add(l);
|
||||||
|
play(sndGood);
|
||||||
|
drawWord(false);
|
||||||
|
if(word().split("").every(x=>S.guessed.has(x))){
|
||||||
|
S.over = true; S.stars++;
|
||||||
|
msgEl.textContent = T("ballWin");
|
||||||
|
msgEl.classList.add("win");
|
||||||
|
play(sndWin); confetti(el(".mt-card"));
|
||||||
|
nextBtn.classList.add("show");
|
||||||
|
scoreEl.textContent = S.stars+" ★";
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
S.lives--;
|
||||||
|
play(sndBad);
|
||||||
|
drawBalloons();
|
||||||
|
if(S.lives <= 0){
|
||||||
|
S.over = true;
|
||||||
|
drawWord(true);
|
||||||
|
msgEl.textContent = `${T("ballLose")} ${word()}`;
|
||||||
|
msgEl.classList.add("lose");
|
||||||
|
nextBtn.classList.add("show");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextBtn.addEventListener("click", ()=>{ S.idx++; newWord(); });
|
||||||
|
srcSel.addEventListener("change", ()=>{
|
||||||
|
S.card = srcSel.value.startsWith("t") ? srcSel.value : +srcSel.value;
|
||||||
|
S.order = []; newWord();
|
||||||
|
});
|
||||||
|
sndBtn.addEventListener("click", ()=>{
|
||||||
|
S.snd = !S.snd;
|
||||||
|
sndBtn.textContent = S.snd ? "🔔" : "🔕";
|
||||||
|
sndBtn.classList.toggle("off", !S.snd);
|
||||||
|
});
|
||||||
|
function labels(){
|
||||||
|
sndBtn.textContent = S.snd ? "🔔" : "🔕";
|
||||||
|
sndBtn.classList.toggle("off", !S.snd);
|
||||||
|
sndBtn.title = T("sndWidget");
|
||||||
|
nextBtn.textContent = T("next");
|
||||||
|
buildSrc();
|
||||||
|
}
|
||||||
|
document.addEventListener("langchange", ()=>{ S.card=0; S.order=[]; labels(); newWord(); });
|
||||||
|
document.addEventListener("wordschange", ()=>{ buildSrc(); S.order=[]; newWord(); });
|
||||||
|
labels(); newWord();
|
||||||
|
return { getState: ()=>({card:S.card, snd:S.snd}) };
|
||||||
|
}
|
||||||
|
|
||||||
74
public/js/widgets/dice.js
Normal file
74
public/js/widgets/dice.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
/* widget: Dobbelstenen */
|
||||||
|
/* =========================================================
|
||||||
|
Dice widget
|
||||||
|
==========================================================*/
|
||||||
|
function mountDice(root, initState){
|
||||||
|
let count = (initState && initState.count) || 2, rolling = false;
|
||||||
|
const PIPS = {1:[4],2:[0,8],3:[0,4,8],4:[0,2,6,8],5:[0,2,4,6,8],6:[0,2,3,5,6,8]};
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="dc">
|
||||||
|
<div class="mt-top" style="width:100%;">
|
||||||
|
<div class="tseg dc-seg">
|
||||||
|
<button data-n="1">1</button><button data-n="2">2</button><button data-n="3">3</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-score dc-sumlbl"></div>
|
||||||
|
</div>
|
||||||
|
<div class="dc-row"></div>
|
||||||
|
<div class="dc-sum"></div>
|
||||||
|
<button class="tbtn dc-roll"></button>
|
||||||
|
</div>`;
|
||||||
|
const row = root.querySelector(".dc-row"),
|
||||||
|
sumEl = root.querySelector(".dc-sum"),
|
||||||
|
rollBtn = root.querySelector(".dc-roll");
|
||||||
|
function face(die, v){
|
||||||
|
die.querySelectorAll("i").forEach((p,i)=>p.classList.toggle("on", PIPS[v].includes(i)));
|
||||||
|
die.dataset.v = v;
|
||||||
|
}
|
||||||
|
function build(){
|
||||||
|
row.innerHTML = "";
|
||||||
|
for(let i=0;i<count;i++){
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.className = "die";
|
||||||
|
for(let j=0;j<9;j++) d.appendChild(document.createElement("i"));
|
||||||
|
face(d, 1+Math.floor(Math.random()*6));
|
||||||
|
row.appendChild(d);
|
||||||
|
}
|
||||||
|
sumEl.textContent = "";
|
||||||
|
}
|
||||||
|
function roll(){
|
||||||
|
if(rolling) return;
|
||||||
|
rolling = true;
|
||||||
|
sumEl.textContent = "";
|
||||||
|
const dice = [...row.querySelectorAll(".die")];
|
||||||
|
dice.forEach(d=>d.classList.add("rolling"));
|
||||||
|
let n = 0;
|
||||||
|
const iv = setInterval(()=>{
|
||||||
|
dice.forEach(d=>face(d, 1+Math.floor(Math.random()*6)));
|
||||||
|
tone(300+Math.random()*250, 0.04, 0, "square", 0.05);
|
||||||
|
if(++n >= 9){
|
||||||
|
clearInterval(iv);
|
||||||
|
dice.forEach(d=>d.classList.remove("rolling"));
|
||||||
|
const total = dice.reduce((s,d)=>s + +d.dataset.v, 0);
|
||||||
|
if(count>1) sumEl.textContent = `${T("diceSum")}: ${total}`;
|
||||||
|
rolling = false;
|
||||||
|
}
|
||||||
|
}, 90);
|
||||||
|
}
|
||||||
|
row.addEventListener("click", roll);
|
||||||
|
rollBtn.addEventListener("click", roll);
|
||||||
|
root.querySelectorAll(".dc-seg button").forEach(b=>{
|
||||||
|
b.addEventListener("click", ()=>{
|
||||||
|
count = +b.dataset.n;
|
||||||
|
root.querySelectorAll(".dc-seg button").forEach(x=>x.classList.toggle("on", x===b));
|
||||||
|
build();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
function labels(){
|
||||||
|
rollBtn.textContent = T("diceRoll");
|
||||||
|
root.querySelectorAll(".dc-seg button").forEach(x=>x.classList.toggle("on", +x.dataset.n===count));
|
||||||
|
}
|
||||||
|
document.addEventListener("langchange", labels);
|
||||||
|
labels(); build();
|
||||||
|
return { getState: ()=>({count}) };
|
||||||
|
}
|
||||||
|
|
||||||
90
public/js/widgets/flits.js
Normal file
90
public/js/widgets/flits.js
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
/* widget: Flitswoorden */
|
||||||
|
/* =========================================================
|
||||||
|
Flash words widget
|
||||||
|
==========================================================*/
|
||||||
|
function mountFlits(root, initState){
|
||||||
|
const S = { card:0, dur:2, idx:0, order:[] };
|
||||||
|
if(initState){ S.card = initState.card ?? 0; S.dur = initState.dur || 2; }
|
||||||
|
let flashTimer = null;
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="mt">
|
||||||
|
<div class="mt-top">
|
||||||
|
<select class="fl-sel fl-src"></select>
|
||||||
|
<select class="fl-sel fl-dur"></select>
|
||||||
|
<div class="mt-score fl-cnt"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-card">
|
||||||
|
<div class="fl-word dim">• • •</div>
|
||||||
|
<div class="fl-pic"></div>
|
||||||
|
</div>
|
||||||
|
<div class="trow" style="display:flex;gap:10px;justify-content:center;flex:none;">
|
||||||
|
<button class="tbtn fl-flash"></button>
|
||||||
|
<button class="tbtn ghost fl-show"></button>
|
||||||
|
<button class="tbtn ghost fl-next"></button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
const el = s => root.querySelector(s);
|
||||||
|
const srcSel = el(".fl-src"), durSel = el(".fl-dur"),
|
||||||
|
wordEl = el(".fl-word"), picEl = el(".fl-pic"), cntEl = el(".fl-cnt");
|
||||||
|
|
||||||
|
function list(){ S.card = normWordSource(S.card); return wordSourceList(S.card); }
|
||||||
|
function buildSrc(){
|
||||||
|
fillWordSourceSel(srcSel, S.card);
|
||||||
|
durSel.innerHTML = "";
|
||||||
|
[0.5,1,2,3,5].forEach(d=>{
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = d; o.textContent = d+" "+T("flSec");
|
||||||
|
durSel.appendChild(o);
|
||||||
|
});
|
||||||
|
durSel.value = S.dur;
|
||||||
|
}
|
||||||
|
function newRound(){
|
||||||
|
S.order = shuffle(list().map((_,i)=>i));
|
||||||
|
S.idx = 0;
|
||||||
|
}
|
||||||
|
function cur(){ return list()[S.order[S.idx]]; }
|
||||||
|
function hide(){
|
||||||
|
wordEl.textContent = "• • •";
|
||||||
|
wordEl.classList.add("dim");
|
||||||
|
picEl.textContent = "";
|
||||||
|
}
|
||||||
|
function flash(){
|
||||||
|
clearTimeout(flashTimer);
|
||||||
|
wordEl.textContent = cur()[0];
|
||||||
|
wordEl.classList.remove("dim");
|
||||||
|
picEl.textContent = "";
|
||||||
|
cntEl.textContent = `${T("word")} ${S.idx+1}/${S.order.length}`;
|
||||||
|
flashTimer = setTimeout(hide, S.dur*1000);
|
||||||
|
}
|
||||||
|
function show(){
|
||||||
|
clearTimeout(flashTimer);
|
||||||
|
wordEl.textContent = cur()[0];
|
||||||
|
wordEl.classList.remove("dim");
|
||||||
|
setPic(picEl, cur()[1]);
|
||||||
|
}
|
||||||
|
el(".fl-flash").addEventListener("click", flash);
|
||||||
|
el(".fl-show").addEventListener("click", show);
|
||||||
|
el(".fl-next").addEventListener("click", ()=>{
|
||||||
|
S.idx++;
|
||||||
|
if(S.idx >= S.order.length) newRound();
|
||||||
|
flash();
|
||||||
|
});
|
||||||
|
srcSel.addEventListener("change", ()=>{
|
||||||
|
S.card = srcSel.value.startsWith("t") ? srcSel.value : +srcSel.value;
|
||||||
|
newRound(); hide();
|
||||||
|
cntEl.textContent = "";
|
||||||
|
});
|
||||||
|
durSel.addEventListener("change", ()=>{ S.dur = +durSel.value; });
|
||||||
|
function labels(){
|
||||||
|
el(".fl-flash").textContent = T("flFlash");
|
||||||
|
el(".fl-show").textContent = T("flShow");
|
||||||
|
el(".fl-next").textContent = T("flNext");
|
||||||
|
buildSrc();
|
||||||
|
}
|
||||||
|
document.addEventListener("langchange", ()=>{ S.card=0; labels(); newRound(); hide(); });
|
||||||
|
document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); hide(); });
|
||||||
|
labels(); newRound();
|
||||||
|
return { getState: ()=>({card:S.card, dur:S.dur}) };
|
||||||
|
}
|
||||||
|
|
||||||
94
public/js/widgets/hak.js
Normal file
94
public/js/widgets/hak.js
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
/* widget: Hakken & plakken */
|
||||||
|
/* =========================================================
|
||||||
|
Chop & blend widget — words in sounds, with read-aloud
|
||||||
|
==========================================================*/
|
||||||
|
function mountHak(root, initState){
|
||||||
|
const S = { card:0, idx:0, order:[], chopped:false };
|
||||||
|
if(initState) S.card = initState.card ?? 0;
|
||||||
|
const canSpeak = "speechSynthesis" in window;
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="mt">
|
||||||
|
<div class="mt-top">
|
||||||
|
<select class="fl-sel hk-src"></select>
|
||||||
|
<div class="mt-score hk-cnt"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-card">
|
||||||
|
<div class="fl-pic hk-pic"></div>
|
||||||
|
<div class="hk-word"></div>
|
||||||
|
</div>
|
||||||
|
<div class="trow" style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap;flex:none;">
|
||||||
|
<button class="tbtn hk-say" ${canSpeak?"":'style="display:none"'}></button>
|
||||||
|
<button class="tbtn ghost hk-chop"></button>
|
||||||
|
<button class="tbtn ghost hk-glue"></button>
|
||||||
|
<button class="tbtn ghost hk-next">▸</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
const el = s => root.querySelector(s);
|
||||||
|
const srcSel = el(".hk-src"), wordEl = el(".hk-word"),
|
||||||
|
picEl = el(".hk-pic"), cntEl = el(".hk-cnt");
|
||||||
|
|
||||||
|
function list(){ S.card = normWordSource(S.card); return wordSourceList(S.card); }
|
||||||
|
function buildSrc(){ fillWordSourceSel(srcSel, S.card); }
|
||||||
|
function cur(){ return list()[S.order[S.idx]]; }
|
||||||
|
function newRound(){ S.order = shuffle(list().map((_,i)=>i)); S.idx = 0; }
|
||||||
|
function display(){
|
||||||
|
const [w, pic] = cur();
|
||||||
|
wordEl.innerHTML = "";
|
||||||
|
if(S.chopped){
|
||||||
|
w.split("").forEach((l,i)=>{
|
||||||
|
if(i>0){
|
||||||
|
const sep = document.createElement("span");
|
||||||
|
sep.className = "hk-sep"; sep.textContent = "–";
|
||||||
|
wordEl.appendChild(sep);
|
||||||
|
}
|
||||||
|
const s = document.createElement("span");
|
||||||
|
s.className = "hk-l " + (VOWELS.includes(l) ? "v" : "c");
|
||||||
|
s.textContent = l;
|
||||||
|
s.style.animationDelay = (i*0.08)+"s";
|
||||||
|
wordEl.appendChild(s);
|
||||||
|
});
|
||||||
|
}else{
|
||||||
|
const s = document.createElement("span");
|
||||||
|
s.className = "hk-whole";
|
||||||
|
s.textContent = w;
|
||||||
|
wordEl.appendChild(s);
|
||||||
|
}
|
||||||
|
setPic(picEl, pic);
|
||||||
|
cntEl.textContent = `${T("word")} ${S.idx+1}/${S.order.length}`;
|
||||||
|
}
|
||||||
|
function speak(text, rate){
|
||||||
|
if(!canSpeak) return;
|
||||||
|
speechSynthesis.cancel();
|
||||||
|
const u = new SpeechSynthesisUtterance(text);
|
||||||
|
u.lang = LANG==="nl" ? "nl-NL" : "en-GB";
|
||||||
|
u.rate = rate;
|
||||||
|
speechSynthesis.speak(u);
|
||||||
|
}
|
||||||
|
el(".hk-say").addEventListener("click", ()=>{
|
||||||
|
if(S.chopped) speak(cur()[0].split("").join(", "), 0.7);
|
||||||
|
else speak(cur()[0], 0.85);
|
||||||
|
});
|
||||||
|
el(".hk-chop").addEventListener("click", ()=>{ S.chopped = true; display(); play(sndGood); });
|
||||||
|
el(".hk-glue").addEventListener("click", ()=>{ S.chopped = false; display(); play(sndGood); });
|
||||||
|
el(".hk-next").addEventListener("click", ()=>{
|
||||||
|
S.idx++; if(S.idx >= S.order.length) newRound();
|
||||||
|
S.chopped = false; display();
|
||||||
|
});
|
||||||
|
const play = f=>f();
|
||||||
|
srcSel.addEventListener("change", ()=>{
|
||||||
|
S.card = srcSel.value.startsWith("t") ? srcSel.value : +srcSel.value;
|
||||||
|
newRound(); S.chopped = false; display();
|
||||||
|
});
|
||||||
|
function labels(){
|
||||||
|
el(".hk-say").textContent = T("hakSay");
|
||||||
|
el(".hk-chop").textContent = T("hakChop");
|
||||||
|
el(".hk-glue").textContent = T("hakGlue");
|
||||||
|
buildSrc();
|
||||||
|
}
|
||||||
|
document.addEventListener("langchange", ()=>{ S.card=0; labels(); newRound(); S.chopped=false; display(); });
|
||||||
|
document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); S.chopped=false; display(); });
|
||||||
|
labels(); newRound(); display();
|
||||||
|
return { getState: ()=>({card:S.card}) };
|
||||||
|
}
|
||||||
|
|
||||||
615
public/js/widgets/letters.js
Normal file
615
public/js/widgets/letters.js
Normal file
|
|
@ -0,0 +1,615 @@
|
||||||
|
/* widget: Letterblokken (Ik leer lezen) */
|
||||||
|
function mountLetterGame(root, initState){
|
||||||
|
const S = { level:1, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks" };
|
||||||
|
if(initState){
|
||||||
|
S.level = initState.level||1;
|
||||||
|
S.card = initState.card ?? "g";
|
||||||
|
S.count = initState.count||6;
|
||||||
|
S.snd = initState.snd !== false;
|
||||||
|
S.amode = initState.amode||"blocks";
|
||||||
|
}
|
||||||
|
const play = f=>{ if(S.snd) f(); };
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="lg">
|
||||||
|
<div class="lg-top">
|
||||||
|
<button class="lg-snd"></button>
|
||||||
|
<button class="lg-mode"></button>
|
||||||
|
<div class="lg-levels">
|
||||||
|
<button data-l="1"></button><button data-l="2"></button><button data-l="3"></button>
|
||||||
|
</div>
|
||||||
|
<div class="lg-cards"></div>
|
||||||
|
<select class="lg-num"></select>
|
||||||
|
<div class="lg-progress"><span class="lg-count"></span><span class="lg-stars"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="lg-instr"></div>
|
||||||
|
<div class="lg-card">
|
||||||
|
<div class="lg-pic"></div>
|
||||||
|
<div class="lg-slots"></div>
|
||||||
|
<div class="lg-done"><div class="dg">🌟</div><div class="dt"></div><div class="dp" style="color:var(--muted);font-weight:700;"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="lg-tray blocks-tray"></div>
|
||||||
|
<div class="lg-tray lg-kbd"></div>
|
||||||
|
<div class="lg-bottom"><button class="lg-next"></button></div>
|
||||||
|
<div class="lg-editor">
|
||||||
|
<div class="lg-ed-head"><b class="ed-title"></b><button class="wclose lg-ed-close">✕</button></div>
|
||||||
|
<div class="lg-ed-themerow">
|
||||||
|
<select class="ed-theme-sel"></select>
|
||||||
|
<button class="tbtn ed-theme-add">+</button>
|
||||||
|
<input type="text" class="ed-theme-name" maxlength="16" style="display:none;">
|
||||||
|
<button class="ed-theme-del">🗑</button>
|
||||||
|
</div>
|
||||||
|
<div class="lg-ed-form">
|
||||||
|
<input type="text" class="ed-word" maxlength="5">
|
||||||
|
<div class="ed-preview">?</div>
|
||||||
|
<button class="ed-add"></button>
|
||||||
|
<div class="ed-msg"></div>
|
||||||
|
</div>
|
||||||
|
<div class="ed-tabs">
|
||||||
|
<button data-t="emoji"></button><button data-t="picto"></button><button data-t="upload"></button>
|
||||||
|
</div>
|
||||||
|
<div class="ed-pane">
|
||||||
|
<div class="ed-p-emoji">
|
||||||
|
<div class="ed-cats"></div>
|
||||||
|
<div class="ed-grid"></div>
|
||||||
|
</div>
|
||||||
|
<div class="ed-p-picto" style="display:none;">
|
||||||
|
<div class="ed-pictorow">
|
||||||
|
<input type="text" class="ed-psearch" maxlength="30">
|
||||||
|
<button class="tbtn ed-pgo"></button>
|
||||||
|
</div>
|
||||||
|
<div class="ed-status"></div>
|
||||||
|
<div class="ed-pgrid"></div>
|
||||||
|
<div class="ed-credit"></div>
|
||||||
|
</div>
|
||||||
|
<div class="ed-p-upload" style="display:none;">
|
||||||
|
<label class="ed-upload"><span class="ed-uplbl"></span><input type="file" accept="image/*"></label>
|
||||||
|
<div class="ed-uphint"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lg-ed-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const el = s => root.querySelector(s);
|
||||||
|
const slotsEl = el(".lg-slots"), trayEl = el(".blocks-tray"), picEl = el(".lg-pic"),
|
||||||
|
instrEl = el(".lg-instr"), nextBtn = el(".lg-next"), doneEl = el(".lg-done"),
|
||||||
|
countEl = el(".lg-count"), starsEl = el(".lg-stars"), cardsEl = el(".lg-cards");
|
||||||
|
|
||||||
|
/* card buttons (built dynamically: N cards + star + edit) */
|
||||||
|
const numSel = root.querySelector(".lg-num");
|
||||||
|
function buildNumSel(){
|
||||||
|
numSel.innerHTML = "";
|
||||||
|
[4,6,8,10,"all"].forEach(v=>{
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = v;
|
||||||
|
o.textContent = v==="all" ? T("all") : v;
|
||||||
|
numSel.appendChild(o);
|
||||||
|
});
|
||||||
|
numSel.value = S.count;
|
||||||
|
numSel.title = T("numWords");
|
||||||
|
}
|
||||||
|
numSel.addEventListener("change", ()=>{
|
||||||
|
S.count = numSel.value==="all" ? "all" : +numSel.value;
|
||||||
|
newRound(); render();
|
||||||
|
});
|
||||||
|
const sndBtn = root.querySelector(".lg-snd");
|
||||||
|
sndBtn.addEventListener("click", ()=>{
|
||||||
|
S.snd = !S.snd;
|
||||||
|
updateSndBtn();
|
||||||
|
});
|
||||||
|
function updateSndBtn(){
|
||||||
|
sndBtn.textContent = S.snd ? "🔔" : "🔕";
|
||||||
|
sndBtn.classList.toggle("off", !S.snd);
|
||||||
|
sndBtn.title = T("sndWidget");
|
||||||
|
modeBtn.textContent = S.amode==="blocks" ? "🧱" : "⌨️";
|
||||||
|
modeBtn.title = T("amode");
|
||||||
|
root.querySelector(".lg").classList.toggle("typemode", S.amode==="type");
|
||||||
|
}
|
||||||
|
const modeBtn = root.querySelector(".lg-mode");
|
||||||
|
modeBtn.addEventListener("click", ()=>{
|
||||||
|
S.amode = S.amode==="blocks" ? "type" : "blocks";
|
||||||
|
updateSndBtn();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* on-screen letter keyboard for type mode */
|
||||||
|
const kbdEl = root.querySelector(".lg-kbd");
|
||||||
|
"abcdefghijklmnopqrstuvwxyz".split("").forEach(l=>{
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.textContent = l;
|
||||||
|
b.className = VOWELS.includes(l) ? "v" : "c";
|
||||||
|
b.addEventListener("click", ()=>typeLetter(l));
|
||||||
|
kbdEl.appendChild(b);
|
||||||
|
});
|
||||||
|
function typeLetter(l){
|
||||||
|
if(doneEl.classList.contains("show") || edEl.classList.contains("open")) return;
|
||||||
|
const slot = slotsEl.querySelector(".slot:not(.filled)");
|
||||||
|
if(!slot) return;
|
||||||
|
if(slot.dataset.letter === l){
|
||||||
|
const b = makeBlock(l);
|
||||||
|
b.classList.add("in-slot");
|
||||||
|
b.dataset.placedAt = Date.now();
|
||||||
|
slot.appendChild(b);
|
||||||
|
slot.classList.add("filled");
|
||||||
|
play(sndGood);
|
||||||
|
checkComplete();
|
||||||
|
}else{
|
||||||
|
slot.classList.add("shake");
|
||||||
|
setTimeout(()=>slot.classList.remove("shake"), 400);
|
||||||
|
play(sndBad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* word source: one compact dropdown (general card + themes + anchor grids) */
|
||||||
|
let srcSel = null;
|
||||||
|
function buildCardButtons(){
|
||||||
|
cardsEl.innerHTML = "";
|
||||||
|
srcSel = document.createElement("select");
|
||||||
|
srcSel.className = "fl-sel";
|
||||||
|
fillWordSourceSel(srcSel, S.card);
|
||||||
|
srcSel.addEventListener("change", ()=>{ S.card = srcSel.value; newRound(); render(); });
|
||||||
|
cardsEl.appendChild(srcSel);
|
||||||
|
const edit = document.createElement("button");
|
||||||
|
edit.className = "lg-edit"; edit.textContent = "✎";
|
||||||
|
edit.addEventListener("click", openEditor);
|
||||||
|
cardsEl.appendChild(edit);
|
||||||
|
}
|
||||||
|
buildCardButtons();
|
||||||
|
|
||||||
|
root.querySelectorAll(".lg-levels button").forEach(b=>{
|
||||||
|
b.addEventListener("click", ()=>{ S.level = +b.dataset.l; newRound(); render(); });
|
||||||
|
});
|
||||||
|
nextBtn.addEventListener("click", ()=>{
|
||||||
|
S.wordIdx++;
|
||||||
|
if(S.wordIdx >= S.order.length){ showCardDone(); } else render();
|
||||||
|
});
|
||||||
|
document.addEventListener("langchange", ()=>{
|
||||||
|
S.lang=LANG; S.card="g"; buildCardButtons(); buildNumSel(); newRound(); render();
|
||||||
|
});
|
||||||
|
document.addEventListener("wordschange", ()=>{
|
||||||
|
buildCardButtons(); newRound(); render();
|
||||||
|
if(edEl.classList.contains("open")){ renderThemeSel(); renderEdList(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function fullList(){
|
||||||
|
S.card = normWordSource(S.card);
|
||||||
|
return wordSourceList(S.card);
|
||||||
|
}
|
||||||
|
function newRound(){
|
||||||
|
const list = fullList();
|
||||||
|
const n = S.count==="all" ? list.length : Math.min(+S.count, list.length);
|
||||||
|
S.order = shuffle(list.map((_,i)=>i)).slice(0,n);
|
||||||
|
S.wordIdx = 0; S.stars = 0;
|
||||||
|
}
|
||||||
|
function currentWord(){ return fullList()[S.order[S.wordIdx]]; }
|
||||||
|
|
||||||
|
function makeBlock(letter){
|
||||||
|
const b = document.createElement("div");
|
||||||
|
b.className = "blok " + (VOWELS.includes(letter) ? "vowel" : "cons");
|
||||||
|
b.dataset.letter = letter;
|
||||||
|
b.innerHTML = `<div class="bl">${letter}</div>`;
|
||||||
|
const dg = dotGrid(letter); dg.classList.add("blok-dots");
|
||||||
|
b.appendChild(dg);
|
||||||
|
attachDrag(b);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
function makeSlot(letter, showGhost){
|
||||||
|
const s = document.createElement("div");
|
||||||
|
s.className = "slot";
|
||||||
|
s.dataset.letter = letter;
|
||||||
|
s.innerHTML = `<div class="ghost">${showGhost ? letter : ""}</div>`;
|
||||||
|
const dg = dotGrid(letter); dg.classList.add("slot-dots");
|
||||||
|
s.appendChild(dg);
|
||||||
|
s.addEventListener("click", ()=>{
|
||||||
|
const placed = s.querySelector(".blok");
|
||||||
|
/* ignore the synthetic click that follows a successful drop */
|
||||||
|
if(placed && !placed.classList.contains("fixed") &&
|
||||||
|
Date.now() - (+placed.dataset.placedAt || 0) > 400){
|
||||||
|
placed.classList.remove("in-slot");
|
||||||
|
trayEl.appendChild(placed);
|
||||||
|
s.classList.remove("filled");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function distractors(word, n){
|
||||||
|
const pool = "abcdefghijklmnoprstuvwz".split("").filter(c=>!word.includes(c));
|
||||||
|
return shuffle(pool).slice(0,n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(){
|
||||||
|
if(!S.order.length || S.wordIdx >= S.order.length) newRound();
|
||||||
|
doneEl.classList.remove("show");
|
||||||
|
nextBtn.classList.remove("show");
|
||||||
|
nextBtn.textContent = T("next");
|
||||||
|
root.querySelectorAll(".lg-levels button").forEach(b=>{
|
||||||
|
b.textContent = T("lvl"+b.dataset.l);
|
||||||
|
b.classList.toggle("active", +b.dataset.l===S.level);
|
||||||
|
});
|
||||||
|
cardsEl.querySelector(".lg-edit").title = T("manage");
|
||||||
|
numSel.title = T("numWords");
|
||||||
|
const allOpt = numSel.querySelector('option[value="all"]');
|
||||||
|
if(allOpt) allOpt.textContent = T("all");
|
||||||
|
|
||||||
|
const [word, pic] = currentWord();
|
||||||
|
setPic(picEl, pic);
|
||||||
|
countEl.textContent = `${T("word")} ${S.wordIdx+1}/${S.order.length}`;
|
||||||
|
starsEl.textContent = S.stars ? S.stars+" ★" : "";
|
||||||
|
slotsEl.innerHTML = ""; trayEl.innerHTML = "";
|
||||||
|
|
||||||
|
const letters = word.split("");
|
||||||
|
if(S.level===1){
|
||||||
|
instrEl.innerHTML = T("instr1");
|
||||||
|
letters.forEach(l=>slotsEl.appendChild(makeSlot(l,true)));
|
||||||
|
shuffle(letters.concat(distractors(word,3))).forEach(l=>trayEl.appendChild(makeBlock(l)));
|
||||||
|
}
|
||||||
|
else if(S.level===2){
|
||||||
|
const types = ["begin","end","mid"];
|
||||||
|
const posType = types[S.wordIdx % 3];
|
||||||
|
let idx = posType==="begin" ? 0 : posType==="end" ? letters.length-1 : Math.floor(letters.length/2);
|
||||||
|
instrEl.innerHTML = T("instr2_"+posType);
|
||||||
|
letters.forEach((l,i)=>{
|
||||||
|
if(i===idx){ slotsEl.appendChild(makeSlot(l,false)); }
|
||||||
|
else{
|
||||||
|
const s = makeSlot(l,true);
|
||||||
|
s.classList.add("filled");
|
||||||
|
const b = makeBlock(l); b.classList.add("in-slot","fixed");
|
||||||
|
b.style.pointerEvents = "none";
|
||||||
|
s.appendChild(b);
|
||||||
|
slotsEl.appendChild(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
shuffle([letters[idx]].concat(distractors(word,3))).forEach(l=>trayEl.appendChild(makeBlock(l)));
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
instrEl.innerHTML = T("instr3");
|
||||||
|
letters.forEach(l=>slotsEl.appendChild(makeSlot(l,false)));
|
||||||
|
shuffle(letters.concat(distractors(word,3))).forEach(l=>trayEl.appendChild(makeBlock(l)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkComplete(){
|
||||||
|
const open = slotsEl.querySelectorAll(".slot:not(.filled)").length;
|
||||||
|
if(open===0){
|
||||||
|
S.stars++;
|
||||||
|
starsEl.textContent = S.stars ? S.stars+" ★" : "";
|
||||||
|
play(sndWin); confetti(el(".lg-card"));
|
||||||
|
doneEl.querySelector(".dt").textContent = T("welldone");
|
||||||
|
doneEl.querySelector(".dp").textContent = currentWord()[0];
|
||||||
|
doneEl.classList.add("show");
|
||||||
|
nextBtn.classList.add("show");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function showCardDone(){
|
||||||
|
doneEl.querySelector(".dt").textContent = T("cardDone");
|
||||||
|
doneEl.querySelector(".dp").textContent = T("pickCard");
|
||||||
|
doneEl.classList.add("show");
|
||||||
|
nextBtn.classList.remove("show");
|
||||||
|
newRound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- custom words editor ---------- */
|
||||||
|
const edEl = el(".lg-editor"), edWord = el(".ed-word"),
|
||||||
|
edPrev = el(".ed-preview"), edMsg = el(".ed-msg"), edList = el(".lg-ed-list"),
|
||||||
|
edFile = el(".ed-p-upload input"), edPSearch = el(".ed-psearch"),
|
||||||
|
edPGrid = el(".ed-pgrid"), edStatus = el(".ed-status"),
|
||||||
|
edThemeSel = el(".ed-theme-sel"), edThemeAdd = el(".ed-theme-add"),
|
||||||
|
edThemeName = el(".ed-theme-name"), edThemeDel = el(".ed-theme-del");
|
||||||
|
let edPic = "", edTab = "emoji", edCat = 0, edTheme = "g";
|
||||||
|
|
||||||
|
/* ---- word target: the general card or one of the themes ---- */
|
||||||
|
function renderThemeSel(){
|
||||||
|
const list = THEMES[S.lang];
|
||||||
|
if(edTheme !== "g" && edTheme >= list.length) edTheme = "g";
|
||||||
|
edThemeSel.innerHTML = "";
|
||||||
|
const og = document.createElement("option");
|
||||||
|
og.value = "g";
|
||||||
|
og.textContent = `${T("cardGeneral")} (${generalList().length})`;
|
||||||
|
edThemeSel.appendChild(og);
|
||||||
|
list.forEach((th,i)=>{
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = i; o.textContent = `★ ${th.name} (${th.words.length})`;
|
||||||
|
edThemeSel.appendChild(o);
|
||||||
|
});
|
||||||
|
edThemeSel.value = edTheme;
|
||||||
|
edThemeAdd.title = T("newTheme");
|
||||||
|
edThemeDel.title = T("delTheme");
|
||||||
|
edThemeName.placeholder = T("themeNamePh");
|
||||||
|
}
|
||||||
|
edThemeSel.addEventListener("change", ()=>{
|
||||||
|
edTheme = edThemeSel.value==="g" ? "g" : +edThemeSel.value;
|
||||||
|
renderEdList();
|
||||||
|
});
|
||||||
|
edThemeAdd.addEventListener("click", ()=>{
|
||||||
|
edThemeName.style.display = "block";
|
||||||
|
edThemeName.value = "";
|
||||||
|
edThemeName.focus();
|
||||||
|
});
|
||||||
|
let themeDone = false;
|
||||||
|
function commitTheme(){
|
||||||
|
if(themeDone) return; themeDone = true;
|
||||||
|
const nm = edThemeName.value.trim();
|
||||||
|
if(nm){
|
||||||
|
THEMES[S.lang].push({name:nm, words:[]});
|
||||||
|
edTheme = THEMES[S.lang].length-1;
|
||||||
|
persistWords();
|
||||||
|
renderThemeSel(); renderEdList();
|
||||||
|
document.dispatchEvent(new CustomEvent("wordschange"));
|
||||||
|
}
|
||||||
|
edThemeName.style.display = "none";
|
||||||
|
setTimeout(()=>{ themeDone = false; }, 100);
|
||||||
|
}
|
||||||
|
edThemeName.addEventListener("keydown", ev=>{
|
||||||
|
ev.stopPropagation();
|
||||||
|
if(ev.key==="Enter") commitTheme();
|
||||||
|
if(ev.key==="Escape"){ themeDone=true; edThemeName.style.display="none"; setTimeout(()=>{themeDone=false;},100); }
|
||||||
|
});
|
||||||
|
edThemeName.addEventListener("blur", commitTheme);
|
||||||
|
edThemeDel.addEventListener("click", ()=>{
|
||||||
|
if(edTheme==="g" || !THEMES[S.lang].length) return;
|
||||||
|
THEMES[S.lang].splice(edTheme,1);
|
||||||
|
edTheme = "g";
|
||||||
|
persistWords();
|
||||||
|
renderThemeSel(); renderEdList();
|
||||||
|
document.dispatchEvent(new CustomEvent("wordschange"));
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectPic(p, btn){
|
||||||
|
edPic = p; setPic(edPrev, p);
|
||||||
|
root.querySelectorAll(".ed-grid button.sel,.ed-pgrid button.sel").forEach(b=>b.classList.remove("sel"));
|
||||||
|
if(btn) btn.classList.add("sel");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* tabs */
|
||||||
|
root.querySelectorAll(".ed-tabs button").forEach(b=>{
|
||||||
|
b.addEventListener("click", ()=>{ edTab = b.dataset.t; refreshTabs(); });
|
||||||
|
});
|
||||||
|
function refreshTabs(){
|
||||||
|
root.querySelectorAll(".ed-tabs button").forEach(b=>{
|
||||||
|
b.textContent = T("tab"+b.dataset.t[0].toUpperCase()+b.dataset.t.slice(1));
|
||||||
|
b.classList.toggle("active", b.dataset.t===edTab);
|
||||||
|
});
|
||||||
|
el(".ed-p-emoji").style.display = edTab==="emoji" ? "block" : "none";
|
||||||
|
el(".ed-p-picto").style.display = edTab==="picto" ? "block" : "none";
|
||||||
|
el(".ed-p-upload").style.display = edTab==="upload" ? "block" : "none";
|
||||||
|
if(edTab==="picto" && !edPSearch.value) edPSearch.value = edWord.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* emoji tab */
|
||||||
|
function renderEmojiCats(){
|
||||||
|
const cats = el(".ed-cats");
|
||||||
|
cats.innerHTML = "";
|
||||||
|
T("cats").forEach((name,i)=>{
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.textContent = name;
|
||||||
|
b.classList.toggle("active", i===edCat);
|
||||||
|
b.addEventListener("click", ()=>{ edCat=i; renderEmojiCats(); });
|
||||||
|
cats.appendChild(b);
|
||||||
|
});
|
||||||
|
const grid = el(".ed-grid");
|
||||||
|
grid.innerHTML = "";
|
||||||
|
EMOJI_CATS[edCat].forEach(em=>{
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.type="button"; b.textContent = em;
|
||||||
|
b.classList.toggle("sel", em===edPic);
|
||||||
|
b.addEventListener("click", ()=>selectPic(em, b));
|
||||||
|
grid.appendChild(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* picto tab (ARASAAC) */
|
||||||
|
async function searchPictos(){
|
||||||
|
const term = edPSearch.value.trim();
|
||||||
|
if(!term) return;
|
||||||
|
edStatus.textContent = T("pictoLoad");
|
||||||
|
edPGrid.innerHTML = "";
|
||||||
|
try{
|
||||||
|
const resp = await fetch(`https://api.arasaac.org/api/pictograms/${S.lang}/search/${encodeURIComponent(term)}`);
|
||||||
|
if(!resp.ok) throw new Error("http "+resp.status);
|
||||||
|
const arr = await resp.json();
|
||||||
|
if(!arr.length){ edStatus.textContent = T("pictoNone"); return; }
|
||||||
|
edStatus.textContent = "";
|
||||||
|
arr.slice(0,12).forEach(p=>{
|
||||||
|
const url = `https://static.arasaac.org/pictograms/${p._id}/${p._id}_300.png`;
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.type="button";
|
||||||
|
const im = document.createElement("img");
|
||||||
|
im.src = url; im.loading = "lazy"; im.alt = term;
|
||||||
|
b.appendChild(im);
|
||||||
|
b.addEventListener("click", ()=>selectPic(url, b));
|
||||||
|
edPGrid.appendChild(b);
|
||||||
|
});
|
||||||
|
}catch(e){ edStatus.textContent = T("pictoErr"); }
|
||||||
|
}
|
||||||
|
el(".ed-pgo").addEventListener("click", searchPictos);
|
||||||
|
edPSearch.addEventListener("keydown", e=>{ if(e.key==="Enter") searchPictos(); });
|
||||||
|
/* typing a word auto-fills picto search */
|
||||||
|
edWord.addEventListener("input", ()=>{ edPSearch.value = edWord.value.trim(); });
|
||||||
|
|
||||||
|
/* upload tab */
|
||||||
|
edFile.addEventListener("change", ()=>{
|
||||||
|
const f = edFile.files[0]; if(!f) return;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = ()=>{
|
||||||
|
const c = document.createElement("canvas");
|
||||||
|
const s = Math.min(1, 128/Math.max(img.width, img.height));
|
||||||
|
c.width = Math.round(img.width*s); c.height = Math.round(img.height*s);
|
||||||
|
c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
|
||||||
|
selectPic(c.toDataURL("image/png"));
|
||||||
|
URL.revokeObjectURL(img.src);
|
||||||
|
};
|
||||||
|
img.src = URL.createObjectURL(f);
|
||||||
|
edFile.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
el(".ed-add").addEventListener("click", ()=>{
|
||||||
|
const w = edWord.value.trim().toLowerCase();
|
||||||
|
if(!/^[a-z]{2,5}$/.test(w)){ edMsg.textContent = T("badWord"); return; }
|
||||||
|
if(!edPic){ edMsg.textContent = T("needPic"); return; }
|
||||||
|
edMsg.textContent = "";
|
||||||
|
if(edTheme==="g"){
|
||||||
|
GENERAL_EXTRA[S.lang].push([w, edPic]); /* extend the general card */
|
||||||
|
}else{
|
||||||
|
THEMES[S.lang][edTheme].words.push([w, edPic]);
|
||||||
|
}
|
||||||
|
persistWords();
|
||||||
|
renderThemeSel();
|
||||||
|
edWord.value = ""; edPSearch.value = ""; edPic = ""; edPrev.textContent = "?";
|
||||||
|
root.querySelectorAll(".ed-grid button.sel,.ed-pgrid button.sel").forEach(b=>b.classList.remove("sel"));
|
||||||
|
renderEdList();
|
||||||
|
play(sndGood);
|
||||||
|
document.dispatchEvent(new CustomEvent("wordschange"));
|
||||||
|
});
|
||||||
|
el(".lg-ed-close").addEventListener("click", ()=>{
|
||||||
|
edEl.classList.remove("open");
|
||||||
|
buildCardButtons();
|
||||||
|
newRound();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
function openEditor(){
|
||||||
|
el(".ed-title").textContent = T("manage");
|
||||||
|
el(".ed-uplbl").textContent = T("upload");
|
||||||
|
el(".ed-uphint").textContent = T("upHint");
|
||||||
|
el(".ed-credit").innerHTML = T("pictoCredit");
|
||||||
|
el(".ed-pgo").textContent = T("pictoSearch");
|
||||||
|
edPSearch.placeholder = T("pictoPh");
|
||||||
|
edWord.placeholder = T("wordPh");
|
||||||
|
el(".ed-add").textContent = T("addWord");
|
||||||
|
el(".lg-ed-close").title = T("close");
|
||||||
|
edMsg.textContent = ""; edStatus.textContent = "";
|
||||||
|
refreshTabs();
|
||||||
|
renderEmojiCats();
|
||||||
|
renderThemeSel();
|
||||||
|
renderEdList();
|
||||||
|
edEl.classList.add("open");
|
||||||
|
}
|
||||||
|
function renderEdList(){
|
||||||
|
edList.innerHTML = "";
|
||||||
|
const isGen = edTheme==="g";
|
||||||
|
const base = isGen ? WORDS[S.lang] : [];
|
||||||
|
const list = isGen ? GENERAL_EXTRA[S.lang] : (THEMES[S.lang][edTheme] ? THEMES[S.lang][edTheme].words : []);
|
||||||
|
if(!base.length && !list.length){
|
||||||
|
edList.innerHTML = `<div class="empty">${T("noWords")}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const addRow = (entry, delFn)=>{
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ed-row" + (delFn ? "" : " base");
|
||||||
|
const p = document.createElement("div"); p.className = "ep"; setPic(p, entry[1]);
|
||||||
|
const wv = document.createElement("div"); wv.className = "ew"; wv.textContent = entry[0];
|
||||||
|
row.appendChild(p); row.appendChild(wv);
|
||||||
|
if(delFn){
|
||||||
|
const del = document.createElement("button"); del.className = "edel"; del.textContent = "✕";
|
||||||
|
del.addEventListener("click", delFn);
|
||||||
|
row.appendChild(del);
|
||||||
|
}
|
||||||
|
edList.appendChild(row);
|
||||||
|
};
|
||||||
|
/* own words first (deletable), built-in general words below (fixed) */
|
||||||
|
list.forEach((entry, i)=>addRow(entry, ()=>{
|
||||||
|
list.splice(i,1);
|
||||||
|
persistWords();
|
||||||
|
renderThemeSel(); renderEdList();
|
||||||
|
document.dispatchEvent(new CustomEvent("wordschange"));
|
||||||
|
}));
|
||||||
|
base.forEach(entry=>addRow(entry, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- drag & drop ----------
|
||||||
|
Hit-testing is geometric (cursor vs slot rectangles) instead of
|
||||||
|
elementFromPoint, so overlays/transforms can never break drops. */
|
||||||
|
function attachDrag(block){
|
||||||
|
block.addEventListener("pointerdown", e=>{
|
||||||
|
if(block.classList.contains("in-slot")) return;
|
||||||
|
if(e.pointerType==="mouse" && e.button!==0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
/* avoid implicit touch capture retargeting the follow-up click */
|
||||||
|
try{ block.releasePointerCapture(e.pointerId); }catch(_){}
|
||||||
|
const clone = block.cloneNode(true);
|
||||||
|
clone.id = "dragClone";
|
||||||
|
document.body.appendChild(clone);
|
||||||
|
block.classList.add("dragging");
|
||||||
|
const moveTo = ev=>{ const zf = VZ(); clone.style.left = (ev.clientX/zf)+"px"; clone.style.top = (ev.clientY/zf)+"px"; };
|
||||||
|
moveTo(e);
|
||||||
|
let hovered = null;
|
||||||
|
const hitSlot = ev=>{
|
||||||
|
const pad = 8;
|
||||||
|
let found = null;
|
||||||
|
slotsEl.querySelectorAll(".slot:not(.filled)").forEach(s=>{
|
||||||
|
const r = s.getBoundingClientRect();
|
||||||
|
if(ev.clientX >= r.left-pad && ev.clientX <= r.right+pad &&
|
||||||
|
ev.clientY >= r.top-pad && ev.clientY <= r.bottom+pad) found = s;
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
};
|
||||||
|
const move = ev=>{
|
||||||
|
moveTo(ev);
|
||||||
|
const slot = hitSlot(ev);
|
||||||
|
if(hovered && hovered!==slot) hovered.classList.remove("droptarget");
|
||||||
|
hovered = slot;
|
||||||
|
if(hovered) hovered.classList.add("droptarget");
|
||||||
|
};
|
||||||
|
const up = ev=>{
|
||||||
|
document.removeEventListener("pointermove",move);
|
||||||
|
document.removeEventListener("pointerup",up);
|
||||||
|
document.removeEventListener("pointercancel",cancel);
|
||||||
|
clone.remove();
|
||||||
|
block.classList.remove("dragging");
|
||||||
|
if(hovered) hovered.classList.remove("droptarget");
|
||||||
|
const target = hitSlot(ev);
|
||||||
|
if(target){
|
||||||
|
if(target.dataset.letter === block.dataset.letter){
|
||||||
|
target.classList.add("filled");
|
||||||
|
block.classList.add("in-slot");
|
||||||
|
block.dataset.placedAt = Date.now();
|
||||||
|
target.appendChild(block);
|
||||||
|
play(sndGood);
|
||||||
|
checkComplete();
|
||||||
|
}else{
|
||||||
|
target.classList.add("shake");
|
||||||
|
setTimeout(()=>target.classList.remove("shake"), 400);
|
||||||
|
play(sndBad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const cancel = ()=>{
|
||||||
|
document.removeEventListener("pointermove",move);
|
||||||
|
document.removeEventListener("pointerup",up);
|
||||||
|
document.removeEventListener("pointercancel",cancel);
|
||||||
|
clone.remove();
|
||||||
|
block.classList.remove("dragging");
|
||||||
|
if(hovered) hovered.classList.remove("droptarget");
|
||||||
|
};
|
||||||
|
document.addEventListener("pointermove",move);
|
||||||
|
document.addEventListener("pointerup",up);
|
||||||
|
document.addEventListener("pointercancel",cancel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
buildNumSel();
|
||||||
|
updateSndBtn();
|
||||||
|
newRound();
|
||||||
|
render();
|
||||||
|
document.addEventListener("langchange", updateSndBtn);
|
||||||
|
return {
|
||||||
|
getState: ()=>({ level:S.level, card:S.card, count:S.count, snd:S.snd, amode:S.amode }),
|
||||||
|
setState: st=>{ if(st){ S.level=st.level||1; S.card=st.card??0; S.count=st.count||6; S.snd=st.snd!==false; S.amode=st.amode||"blocks"; buildNumSel(); updateSndBtn(); newRound(); render(); } }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function confetti(container){
|
||||||
|
const colors = ["#e0554d","#3b7dd8","#3fae6a","#f7c948","#9b59b6"];
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
for(let i=0;i<28;i++){
|
||||||
|
const c = document.createElement("div");
|
||||||
|
c.className = "confetti";
|
||||||
|
c.style.left = Math.random()*rect.width + "px";
|
||||||
|
c.style.background = colors[i%colors.length];
|
||||||
|
c.style.animationDuration = (1.2+Math.random()*1.4)+"s";
|
||||||
|
c.style.animationDelay = (Math.random()*0.3)+"s";
|
||||||
|
container.appendChild(c);
|
||||||
|
setTimeout(()=>c.remove(), 3200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
24
public/js/widgets/light.js
Normal file
24
public/js/widgets/light.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
/* stoplicht (bordgereedschap-variant zit in board.js) */
|
||||||
|
/* =========================================================
|
||||||
|
Traffic light widget
|
||||||
|
==========================================================*/
|
||||||
|
function mountLight(root, initState){
|
||||||
|
let on = (initState && initState.on) || "green";
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="tl">
|
||||||
|
<div class="tl-house">
|
||||||
|
<button class="tl-lamp red" data-c="red"></button>
|
||||||
|
<button class="tl-lamp orange" data-c="orange"></button>
|
||||||
|
<button class="tl-lamp green" data-c="green"></button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
function apply(){
|
||||||
|
root.querySelectorAll(".tl-lamp").forEach(l=>l.classList.toggle("on", l.dataset.c===on));
|
||||||
|
}
|
||||||
|
root.querySelectorAll(".tl-lamp").forEach(l=>{
|
||||||
|
l.addEventListener("click", ()=>{ on = l.dataset.c; apply(); });
|
||||||
|
});
|
||||||
|
apply();
|
||||||
|
return { getState: ()=>({on}) };
|
||||||
|
}
|
||||||
|
|
||||||
292
public/js/widgets/math.js
Normal file
292
public/js/widgets/math.js
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
/* widgets: Rekenen (+ - x : %) */
|
||||||
|
/* =========================================================
|
||||||
|
Math practice engine — add / sub / mul / div / percentages
|
||||||
|
==========================================================*/
|
||||||
|
function mountMath(root, initState, mode){
|
||||||
|
const ROUND = 10;
|
||||||
|
const S = { level:1, snd:true, amode:"blocks", q:null, tries:0, done:0, good:0, input:"", busy:false };
|
||||||
|
if(initState){
|
||||||
|
S.level = initState.level||1;
|
||||||
|
S.snd = initState.snd!==false;
|
||||||
|
S.amode = initState.amode||"blocks";
|
||||||
|
}
|
||||||
|
const play = f=>{ if(S.snd) f(); };
|
||||||
|
const rnd = n => Math.floor(Math.random()*n);
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="mt blocks">
|
||||||
|
<div class="mt-top">
|
||||||
|
<button class="lg-snd"></button>
|
||||||
|
<button class="lg-mode"></button>
|
||||||
|
<div class="lg-levels">
|
||||||
|
<button data-l="1"></button><button data-l="2"></button><button data-l="3"></button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-score"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-card">
|
||||||
|
<div class="mt-q"></div>
|
||||||
|
<div class="mt-a"></div>
|
||||||
|
<div class="mt-slots"></div>
|
||||||
|
<div class="mt-fb"></div>
|
||||||
|
<div class="mt-done">
|
||||||
|
<div class="dg">🌟</div><div class="dt"></div><div class="ds"></div>
|
||||||
|
<button class="mt-again"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lg-tray mt-tray"></div>
|
||||||
|
<div class="mt-pad"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const el = s => root.querySelector(s);
|
||||||
|
const mtEl = el(".mt"), qEl = el(".mt-q"), aEl = el(".mt-a"), fbEl = el(".mt-fb"),
|
||||||
|
scoreEl = el(".mt-score"), doneEl = el(".mt-done"),
|
||||||
|
sndBtn = el(".lg-snd"), modeBtn = el(".lg-mode"),
|
||||||
|
padEl = el(".mt-pad"), slotsEl = el(".mt-slots"), trayEl = el(".mt-tray");
|
||||||
|
|
||||||
|
/* ---- digit blocks with dot patterns (like the letter blocks) ---- */
|
||||||
|
function makeDigitBlock(d){
|
||||||
|
const b = document.createElement("div");
|
||||||
|
b.className = "blok sm";
|
||||||
|
b.dataset.digit = d;
|
||||||
|
b.innerHTML = `<div class="bl">${d}</div>`;
|
||||||
|
const dg = dotGrid(d); dg.classList.add("blok-dots");
|
||||||
|
b.appendChild(dg);
|
||||||
|
attachDigitDrag(b);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
"0123456789".split("").forEach(d=>trayEl.appendChild(makeDigitBlock(d)));
|
||||||
|
|
||||||
|
function makeDigitSlot(d){
|
||||||
|
const s = document.createElement("div");
|
||||||
|
s.className = "slot sm";
|
||||||
|
s.dataset.digit = d;
|
||||||
|
s.innerHTML = `<div class="ghost"></div>`;
|
||||||
|
const dg = dotGrid(d); dg.classList.add("slot-dots");
|
||||||
|
s.appendChild(dg);
|
||||||
|
s.addEventListener("click", ()=>{
|
||||||
|
const placed = s.querySelector(".blok");
|
||||||
|
if(placed && Date.now() - (+placed.dataset.placedAt||0) > 400){
|
||||||
|
placed.remove();
|
||||||
|
s.classList.remove("filled");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function attachDigitDrag(block){
|
||||||
|
block.addEventListener("pointerdown", e=>{
|
||||||
|
if(e.pointerType==="mouse" && e.button!==0) return;
|
||||||
|
if(S.busy || doneEl.classList.contains("show")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
try{ block.releasePointerCapture(e.pointerId); }catch(_){}
|
||||||
|
const clone = block.cloneNode(true);
|
||||||
|
clone.id = "dragClone";
|
||||||
|
document.body.appendChild(clone);
|
||||||
|
const moveTo = ev=>{ const zf = VZ(); clone.style.left = (ev.clientX/zf)+"px"; clone.style.top = (ev.clientY/zf)+"px"; };
|
||||||
|
moveTo(e);
|
||||||
|
let hovered = null;
|
||||||
|
const hit = ev=>{
|
||||||
|
const pad = 8;
|
||||||
|
let found = null;
|
||||||
|
slotsEl.querySelectorAll(".slot:not(.filled)").forEach(s=>{
|
||||||
|
const r = s.getBoundingClientRect();
|
||||||
|
if(ev.clientX>=r.left-pad && ev.clientX<=r.right+pad &&
|
||||||
|
ev.clientY>=r.top-pad && ev.clientY<=r.bottom+pad) found = s;
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
};
|
||||||
|
const move = ev=>{
|
||||||
|
moveTo(ev);
|
||||||
|
const s = hit(ev);
|
||||||
|
if(hovered && hovered!==s) hovered.classList.remove("droptarget");
|
||||||
|
hovered = s;
|
||||||
|
if(hovered) hovered.classList.add("droptarget");
|
||||||
|
};
|
||||||
|
const up = ev=>{
|
||||||
|
document.removeEventListener("pointermove",move);
|
||||||
|
document.removeEventListener("pointerup",up);
|
||||||
|
document.removeEventListener("pointercancel",cancel);
|
||||||
|
clone.remove();
|
||||||
|
if(hovered) hovered.classList.remove("droptarget");
|
||||||
|
const target = hit(ev);
|
||||||
|
if(target){
|
||||||
|
if(target.dataset.digit === block.dataset.digit){
|
||||||
|
const copy = block.cloneNode(true);
|
||||||
|
copy.classList.add("in-slot");
|
||||||
|
copy.dataset.placedAt = Date.now();
|
||||||
|
target.appendChild(copy);
|
||||||
|
target.classList.add("filled");
|
||||||
|
play(sndGood);
|
||||||
|
if(!slotsEl.querySelector(".slot:not(.filled)")) blocksSolved();
|
||||||
|
}else{
|
||||||
|
target.classList.add("shake");
|
||||||
|
setTimeout(()=>target.classList.remove("shake"), 400);
|
||||||
|
play(sndBad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const cancel = ()=>{
|
||||||
|
document.removeEventListener("pointermove",move);
|
||||||
|
document.removeEventListener("pointerup",up);
|
||||||
|
document.removeEventListener("pointercancel",cancel);
|
||||||
|
clone.remove();
|
||||||
|
if(hovered) hovered.classList.remove("droptarget");
|
||||||
|
};
|
||||||
|
document.addEventListener("pointermove",move);
|
||||||
|
document.addEventListener("pointerup",up);
|
||||||
|
document.addEventListener("pointercancel",cancel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function blocksSolved(){
|
||||||
|
S.busy = true;
|
||||||
|
S.good++;
|
||||||
|
fbEl.textContent = T("mGood");
|
||||||
|
play(sndWin);
|
||||||
|
confetti(el(".mt-card"));
|
||||||
|
setTimeout(next, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gen(){
|
||||||
|
const L = S.level;
|
||||||
|
let a, b, ans, txt;
|
||||||
|
if(mode==="add"){
|
||||||
|
const max = L===1 ? 10 : L===2 ? 20 : 100;
|
||||||
|
a = rnd(max+1); b = rnd(max-a+1); ans = a+b;
|
||||||
|
txt = `${a} + ${b} =`;
|
||||||
|
}else if(mode==="sub"){
|
||||||
|
const max = L===1 ? 10 : L===2 ? 20 : 100;
|
||||||
|
a = rnd(max+1); b = rnd(a+1); ans = a-b;
|
||||||
|
txt = `${a} − ${b} =`;
|
||||||
|
}else if(mode==="mul"){
|
||||||
|
const tmax = L===1 ? 5 : L===2 ? 10 : 12;
|
||||||
|
a = 1+rnd(tmax); b = 1+rnd(L===3 ? 12 : 10); ans = a*b;
|
||||||
|
txt = `${a} × ${b} =`;
|
||||||
|
}else if(mode==="div"){
|
||||||
|
const tmax = L===1 ? 5 : L===2 ? 10 : 12;
|
||||||
|
const t = 1+rnd(tmax), q = 1+rnd(L===3 ? 12 : 10);
|
||||||
|
a = t*q; ans = q;
|
||||||
|
txt = `${a} ${S.lang==="nl" || LANG==="nl" ? ":" : "÷"} ${t} =`;
|
||||||
|
}else{ /* pct */
|
||||||
|
const sets = L===1 ? [10,50,100] : L===2 ? [10,20,25,50,75,100] : [5,10,20,25,30,40,50,60,70,75,80,90];
|
||||||
|
const p = sets[rnd(sets.length)];
|
||||||
|
const step = {5:20,10:10,20:5,25:4,30:10,40:5,50:2,60:5,70:10,75:4,80:5,90:10,100:1}[p];
|
||||||
|
const base = step*(1+rnd(12));
|
||||||
|
ans = p*base/100;
|
||||||
|
txt = `${p}% ${T("mOf")} ${base} =`;
|
||||||
|
}
|
||||||
|
return { txt, ans };
|
||||||
|
}
|
||||||
|
|
||||||
|
function newQuestion(){
|
||||||
|
let q = gen();
|
||||||
|
if(S.q && q.txt===S.q.txt) q = gen(); /* avoid the same sum twice in a row */
|
||||||
|
S.q = q; S.tries = 0; S.input = ""; S.busy = false;
|
||||||
|
qEl.textContent = q.txt;
|
||||||
|
aEl.textContent = "";
|
||||||
|
aEl.classList.remove("ok");
|
||||||
|
fbEl.textContent = ""; fbEl.classList.remove("ans");
|
||||||
|
slotsEl.innerHTML = "";
|
||||||
|
if(S.amode==="blocks"){
|
||||||
|
String(q.ans).split("").forEach(d=>slotsEl.appendChild(makeDigitSlot(d)));
|
||||||
|
}
|
||||||
|
updateScore();
|
||||||
|
}
|
||||||
|
function updateScore(){
|
||||||
|
scoreEl.textContent = `${S.good} ${T("mGoed")} · ${Math.min(S.done+1,ROUND)}/${ROUND}`;
|
||||||
|
}
|
||||||
|
function endRound(){
|
||||||
|
doneEl.querySelector(".dt").textContent = T("mDoneT");
|
||||||
|
doneEl.querySelector(".ds").textContent = `${S.good}/${ROUND} ${T("mGoed")}`;
|
||||||
|
doneEl.querySelector(".dg").textContent = S.good>=8 ? "🌟" : S.good>=5 ? "👍" : "💪";
|
||||||
|
doneEl.classList.add("show");
|
||||||
|
if(S.good>=8) play(sndWin);
|
||||||
|
}
|
||||||
|
function next(){
|
||||||
|
S.done++;
|
||||||
|
if(S.done >= ROUND){ endRound(); }
|
||||||
|
else newQuestion();
|
||||||
|
}
|
||||||
|
function check(){
|
||||||
|
if(S.busy || S.input==="") return;
|
||||||
|
if(+S.input === S.q.ans){
|
||||||
|
S.busy = true;
|
||||||
|
S.good++;
|
||||||
|
aEl.classList.add("ok");
|
||||||
|
fbEl.textContent = T("mGood");
|
||||||
|
play(sndGood);
|
||||||
|
confetti(el(".mt-card"));
|
||||||
|
setTimeout(next, 900);
|
||||||
|
}else{
|
||||||
|
S.tries++;
|
||||||
|
aEl.classList.add("shake");
|
||||||
|
setTimeout(()=>aEl.classList.remove("shake"), 400);
|
||||||
|
play(sndBad);
|
||||||
|
S.input = ""; aEl.textContent = "";
|
||||||
|
if(S.tries >= 3){
|
||||||
|
S.busy = true;
|
||||||
|
fbEl.textContent = `${T("mAnsWas")} ${S.q.ans}`;
|
||||||
|
fbEl.classList.add("ans");
|
||||||
|
setTimeout(next, 1800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
["7","8","9","⌫","4","5","6","0","1","2","3","✓"].forEach(k=>{
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.textContent = k;
|
||||||
|
if(k==="✓") b.className = "ok";
|
||||||
|
b.addEventListener("click", ()=>{
|
||||||
|
if(doneEl.classList.contains("show")) return;
|
||||||
|
if(k==="⌫"){ if(!S.busy){ S.input = S.input.slice(0,-1); aEl.textContent = S.input; } }
|
||||||
|
else if(k==="✓") check();
|
||||||
|
else if(!S.busy && S.input.length<4){ S.input += k; aEl.textContent = S.input; }
|
||||||
|
});
|
||||||
|
padEl.appendChild(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
el(".mt-again").addEventListener("click", ()=>{
|
||||||
|
S.done = 0; S.good = 0;
|
||||||
|
doneEl.classList.remove("show");
|
||||||
|
newQuestion();
|
||||||
|
});
|
||||||
|
root.querySelectorAll(".lg-levels button").forEach(b=>{
|
||||||
|
b.addEventListener("click", ()=>{
|
||||||
|
S.level = +b.dataset.l;
|
||||||
|
S.done = 0; S.good = 0;
|
||||||
|
doneEl.classList.remove("show");
|
||||||
|
labels(); newQuestion();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
sndBtn.addEventListener("click", ()=>{
|
||||||
|
S.snd = !S.snd;
|
||||||
|
sndBtn.textContent = S.snd ? "🔔" : "🔕";
|
||||||
|
sndBtn.classList.toggle("off", !S.snd);
|
||||||
|
});
|
||||||
|
modeBtn.addEventListener("click", ()=>{
|
||||||
|
S.amode = S.amode==="blocks" ? "type" : "blocks";
|
||||||
|
labels(); newQuestion();
|
||||||
|
});
|
||||||
|
|
||||||
|
function labels(){
|
||||||
|
root.querySelectorAll(".lg-levels button").forEach(b=>{
|
||||||
|
b.textContent = T("lvl"+b.dataset.l);
|
||||||
|
b.classList.toggle("active", +b.dataset.l===S.level);
|
||||||
|
});
|
||||||
|
sndBtn.textContent = S.snd ? "🔔" : "🔕";
|
||||||
|
sndBtn.classList.toggle("off", !S.snd);
|
||||||
|
sndBtn.title = T("sndWidget");
|
||||||
|
modeBtn.textContent = S.amode==="blocks" ? "🧱" : "⌨️";
|
||||||
|
modeBtn.title = T("amode");
|
||||||
|
mtEl.classList.toggle("blocks", S.amode==="blocks");
|
||||||
|
el(".mt-again").textContent = T("mAgain");
|
||||||
|
updateScore();
|
||||||
|
}
|
||||||
|
document.addEventListener("langchange", ()=>{ labels(); newQuestion(); });
|
||||||
|
|
||||||
|
labels();
|
||||||
|
newQuestion();
|
||||||
|
return {
|
||||||
|
getState: ()=>({ level:S.level, snd:S.snd, amode:S.amode }),
|
||||||
|
setState: st=>{ if(st){ S.level=st.level||1; S.snd=st.snd!==false; S.amode=st.amode||"blocks"; labels(); newQuestion(); } }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
107
public/js/widgets/media.js
Normal file
107
public/js/widgets/media.js
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
/* widget: Foto & video */
|
||||||
|
/* =========================================================
|
||||||
|
Media widget — photos and videos from external sites
|
||||||
|
==========================================================*/
|
||||||
|
function mountMedia(root, initState){
|
||||||
|
let cur = null;
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="media">
|
||||||
|
<div class="media-bar">
|
||||||
|
<input type="text" class="m-url" spellcheck="false">
|
||||||
|
<button class="tbtn m-go"></button>
|
||||||
|
<button class="tbtn ghost m-ext">↗</button>
|
||||||
|
</div>
|
||||||
|
<div class="media-stage"><div class="media-msg"></div></div>
|
||||||
|
</div>`;
|
||||||
|
const urlInp = root.querySelector(".m-url"),
|
||||||
|
goBtn = root.querySelector(".m-go"),
|
||||||
|
extBtn = root.querySelector(".m-ext"),
|
||||||
|
stage = root.querySelector(".media-stage");
|
||||||
|
|
||||||
|
function labels(){
|
||||||
|
urlInp.placeholder = T("mediaPh");
|
||||||
|
goBtn.textContent = T("mediaShow");
|
||||||
|
extBtn.title = T("mediaOpen");
|
||||||
|
const msg = stage.querySelector(".media-msg");
|
||||||
|
if(msg) msg.textContent = T(msg.dataset.err ? "mediaErr" : "mediaEmpty");
|
||||||
|
}
|
||||||
|
function showMsg(err){
|
||||||
|
stage.innerHTML = "";
|
||||||
|
const m = document.createElement("div");
|
||||||
|
m.className = "media-msg";
|
||||||
|
if(err) m.dataset.err = "1";
|
||||||
|
m.textContent = T(err ? "mediaErr" : "mediaEmpty");
|
||||||
|
stage.appendChild(m);
|
||||||
|
}
|
||||||
|
function show(raw){
|
||||||
|
let url = raw.trim();
|
||||||
|
if(!url){ showMsg(false); return; }
|
||||||
|
if(!/^https?:\/\//i.test(url)) url = "https://"+url;
|
||||||
|
let node = null;
|
||||||
|
const yt = url.match(/(?:youtube\.com\/(?:watch\?[^#]*v=|shorts\/|embed\/|live\/)|youtu\.be\/)([\w-]{6,})/);
|
||||||
|
const vi = url.match(/vimeo\.com\/(?:video\/)?(\d+)/);
|
||||||
|
if(yt){
|
||||||
|
/* show the thumbnail first (always works); play attempts the embed */
|
||||||
|
const id = yt[1];
|
||||||
|
stage.innerHTML = "";
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "media-yt";
|
||||||
|
const im = document.createElement("img");
|
||||||
|
im.src = "https://i.ytimg.com/vi/"+id+"/hqdefault.jpg"; im.alt = "";
|
||||||
|
const pb = document.createElement("button");
|
||||||
|
pb.className = "media-play"; pb.textContent = "▶";
|
||||||
|
pb.addEventListener("click", ()=>{
|
||||||
|
const f = document.createElement("iframe");
|
||||||
|
f.src = "https://www.youtube.com/embed/"+id+"?rel=0&autoplay=1";
|
||||||
|
f.referrerPolicy = "strict-origin-when-cross-origin";
|
||||||
|
f.allow = "autoplay; encrypted-media; picture-in-picture";
|
||||||
|
f.allowFullscreen = true;
|
||||||
|
stage.innerHTML = "";
|
||||||
|
stage.appendChild(f);
|
||||||
|
});
|
||||||
|
wrap.appendChild(im); wrap.appendChild(pb);
|
||||||
|
stage.appendChild(wrap);
|
||||||
|
cur = url; urlInp.value = url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(vi){
|
||||||
|
node = document.createElement("iframe");
|
||||||
|
node.src = "https://player.vimeo.com/video/"+vi[1];
|
||||||
|
node.allow = "autoplay; fullscreen; picture-in-picture";
|
||||||
|
node.allowFullscreen = true;
|
||||||
|
}else if(/\.(jpe?g|png|gif|webp|svg|avif)([?#]|$)/i.test(url)){
|
||||||
|
node = document.createElement("img");
|
||||||
|
node.src = url; node.alt = "";
|
||||||
|
node.addEventListener("error", ()=>showMsg(true));
|
||||||
|
}else if(/\.(mp4|webm|ogg|mov)([?#]|$)/i.test(url)){
|
||||||
|
node = document.createElement("video");
|
||||||
|
node.src = url; node.controls = true;
|
||||||
|
node.addEventListener("error", ()=>showMsg(true));
|
||||||
|
}
|
||||||
|
if(node){
|
||||||
|
stage.innerHTML = "";
|
||||||
|
stage.appendChild(node);
|
||||||
|
cur = url;
|
||||||
|
urlInp.value = url;
|
||||||
|
}else{
|
||||||
|
showMsg(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
goBtn.addEventListener("click", ()=>show(urlInp.value));
|
||||||
|
extBtn.addEventListener("click", ()=>{
|
||||||
|
let u = (cur || urlInp.value).trim();
|
||||||
|
if(!u) return;
|
||||||
|
if(!/^https?:\/\//i.test(u)) u = "https://"+u;
|
||||||
|
window.open(u, "_blank");
|
||||||
|
});
|
||||||
|
urlInp.addEventListener("keydown", e=>{
|
||||||
|
e.stopPropagation();
|
||||||
|
if(e.key==="Enter") show(urlInp.value);
|
||||||
|
});
|
||||||
|
document.addEventListener("langchange", labels);
|
||||||
|
if(initState && initState.url){ urlInp.value = initState.url; show(initState.url); }
|
||||||
|
else showMsg(false);
|
||||||
|
labels();
|
||||||
|
return { getState: ()=>({url: cur || ""}) };
|
||||||
|
}
|
||||||
|
|
||||||
252
public/js/widgets/mind.js
Normal file
252
public/js/widgets/mind.js
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
/* widget: Mindmap */
|
||||||
|
/* =========================================================
|
||||||
|
Mindmap widget — spider, tree or word web
|
||||||
|
==========================================================*/
|
||||||
|
function mountMind(root, initState){
|
||||||
|
const S = { layout:"spider", nodes:[{id:1, x:0.5, y:0.45, t:"", parent:null}], nextId:2 };
|
||||||
|
if(initState){
|
||||||
|
S.layout = initState.layout || "spider";
|
||||||
|
if(Array.isArray(initState.nodes) && initState.nodes.length) S.nodes = initState.nodes;
|
||||||
|
S.nextId = initState.nextId || (Math.max(...S.nodes.map(n=>n.id))+1);
|
||||||
|
}
|
||||||
|
const PALETTE = ["#3b7dd8","#3fae6a","#e0554d","#9b59b6","#f28c38"];
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="mm spider">
|
||||||
|
<div class="tseg mm-seg">
|
||||||
|
<button data-m="spider"></button><button data-m="tree"></button><button data-m="web"></button>
|
||||||
|
</div>
|
||||||
|
<div class="mm-hint"></div>
|
||||||
|
<div class="mm-stage"><svg></svg></div>
|
||||||
|
</div>`;
|
||||||
|
const mmEl = root.querySelector(".mm");
|
||||||
|
/* toolbar row wrapper */
|
||||||
|
const topRow = document.createElement("div");
|
||||||
|
topRow.className = "mm-top";
|
||||||
|
mmEl.insertBefore(topRow, mmEl.firstChild);
|
||||||
|
const mmGrip = document.createElement("span");
|
||||||
|
mmGrip.className = "mm-grip"; mmGrip.textContent = "⠿";
|
||||||
|
topRow.appendChild(mmGrip);
|
||||||
|
topRow.appendChild(root.querySelector(".mm-seg"));
|
||||||
|
topRow.appendChild(root.querySelector(".mm-hint"));
|
||||||
|
mmGrip.addEventListener("pointerdown", e=>{
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
const mr = mmEl.getBoundingClientRect();
|
||||||
|
const sc = mr.width / mmEl.offsetWidth; /* effective zoom on this widget */
|
||||||
|
const ox = e.clientX/sc - topRow.offsetLeft, oy = e.clientY/sc - topRow.offsetTop;
|
||||||
|
const mv = ev=>{
|
||||||
|
topRow.style.left = Math.max(0, Math.min(mmEl.offsetWidth - topRow.offsetWidth, ev.clientX/sc - ox))+"px";
|
||||||
|
topRow.style.top = Math.max(0, Math.min(mmEl.offsetHeight - topRow.offsetHeight, ev.clientY/sc - oy))+"px";
|
||||||
|
};
|
||||||
|
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); };
|
||||||
|
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
|
||||||
|
});
|
||||||
|
|
||||||
|
const stage = root.querySelector(".mm-stage"),
|
||||||
|
svg = stage.querySelector("svg");
|
||||||
|
|
||||||
|
function nodeById(id){ return S.nodes.find(n=>n.id===id); }
|
||||||
|
function depth(n){ let d=0, c=n; while(c && c.parent!=null){ c = nodeById(c.parent); d++; } return d; }
|
||||||
|
|
||||||
|
function drawLines(){
|
||||||
|
const w = stage.clientWidth, h = stage.clientHeight;
|
||||||
|
let paths = "";
|
||||||
|
S.nodes.forEach(n=>{
|
||||||
|
if(n.parent==null) return;
|
||||||
|
const p = nodeById(n.parent);
|
||||||
|
if(!p) return;
|
||||||
|
const x1 = p.x*w, y1 = p.y*h, x2 = n.x*w, y2 = n.y*h;
|
||||||
|
const col = n.lc || PALETTE[(depth(n)-1) % PALETTE.length];
|
||||||
|
const dash = n.ls==="dash" ? ' stroke-dasharray="11 8"' : n.ls==="dot" ? ' stroke-dasharray="2.5 7"' : "";
|
||||||
|
if(S.layout==="spider"){
|
||||||
|
const mx = (x1+x2)/2 + (y2-y1)*0.18, my = (y1+y2)/2 - (x2-x1)*0.18;
|
||||||
|
paths += `<path d="M${x1},${y1} Q${mx},${my} ${x2},${y2}" fill="none" stroke="${col}" stroke-width="3"${dash}/>`;
|
||||||
|
}else if(S.layout==="tree"){
|
||||||
|
const my = (y1+y2)/2;
|
||||||
|
paths += `<path d="M${x1},${y1} L${x1},${my} L${x2},${my} L${x2},${y2}" fill="none" stroke="${col}" stroke-width="3"${dash}/>`;
|
||||||
|
}else{
|
||||||
|
paths += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${col}" stroke-width="3"${dash}/>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
svg.innerHTML = paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNodeStyle(n, d){
|
||||||
|
if(n.parent!=null) d.style.borderColor = n.c || PALETTE[(depth(n)-1) % PALETTE.length];
|
||||||
|
else if(n.c) d.style.borderColor = n.c;
|
||||||
|
d.style.fontSize = (n.fs || (n.parent==null ? 17 : 15))+"px";
|
||||||
|
}
|
||||||
|
function buildNode(n){
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.className = "mm-node" + (n.parent==null ? " root" : "");
|
||||||
|
applyNodeStyle(n, d);
|
||||||
|
d.dataset.id = n.id;
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.contentEditable = "true";
|
||||||
|
span.spellcheck = false;
|
||||||
|
span.textContent = n.t || (n.parent==null ? T("mmRoot") : "");
|
||||||
|
span.addEventListener("input", ()=>{ n.t = span.textContent; });
|
||||||
|
span.addEventListener("keydown", e=>e.stopPropagation());
|
||||||
|
span.addEventListener("pointerdown", e=>e.stopPropagation());
|
||||||
|
d.appendChild(span);
|
||||||
|
const add = document.createElement("button");
|
||||||
|
add.className = "mm-add"; add.textContent = "+";
|
||||||
|
add.addEventListener("pointerdown", e=>e.stopPropagation());
|
||||||
|
add.addEventListener("click", ()=>{
|
||||||
|
const a = Math.random()*Math.PI*2;
|
||||||
|
S.nodes.push({
|
||||||
|
id: S.nextId++, parent: n.id,
|
||||||
|
x: Math.min(.95, Math.max(.05, n.x + Math.cos(a)*0.17)),
|
||||||
|
y: Math.min(.92, Math.max(.06, n.y + Math.sin(a)*0.2)),
|
||||||
|
t: ""
|
||||||
|
});
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
d.appendChild(add);
|
||||||
|
const cog = document.createElement("button");
|
||||||
|
cog.className = "mm-cog"; cog.textContent = "🎨";
|
||||||
|
cog.addEventListener("pointerdown", e=>e.stopPropagation());
|
||||||
|
cog.addEventListener("click", ()=>openNodePop(n, d));
|
||||||
|
d.appendChild(cog);
|
||||||
|
if(n.parent!=null){
|
||||||
|
const del = document.createElement("button");
|
||||||
|
del.className = "mm-del"; del.textContent = "✕";
|
||||||
|
del.addEventListener("pointerdown", e=>e.stopPropagation());
|
||||||
|
del.addEventListener("click", ()=>{
|
||||||
|
const doomed = new Set([n.id]);
|
||||||
|
let grew = true;
|
||||||
|
while(grew){
|
||||||
|
grew = false;
|
||||||
|
S.nodes.forEach(m=>{
|
||||||
|
if(m.parent!=null && doomed.has(m.parent) && !doomed.has(m.id)){ doomed.add(m.id); grew = true; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
S.nodes = S.nodes.filter(m=>!doomed.has(m.id));
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
d.appendChild(del);
|
||||||
|
}
|
||||||
|
/* drag */
|
||||||
|
d.addEventListener("pointerdown", e=>{
|
||||||
|
if(e.pointerType==="mouse" && e.button!==0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const r = stage.getBoundingClientRect();
|
||||||
|
const mv = ev=>{
|
||||||
|
n.x = Math.min(.98, Math.max(.02, (ev.clientX-r.left)/r.width));
|
||||||
|
n.y = Math.min(.96, Math.max(.04, (ev.clientY-r.top)/r.height));
|
||||||
|
d.style.left = (n.x*100)+"%";
|
||||||
|
d.style.top = (n.y*100)+"%";
|
||||||
|
drawLines();
|
||||||
|
};
|
||||||
|
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); };
|
||||||
|
document.addEventListener("pointermove",mv);
|
||||||
|
document.addEventListener("pointerup",up);
|
||||||
|
});
|
||||||
|
d.style.left = (n.x*100)+"%";
|
||||||
|
d.style.top = (n.y*100)+"%";
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* per-node settings popover: colour, font size, line colour & style */
|
||||||
|
function closeNodePop(){ stage.querySelectorAll(".mm-pop").forEach(x=>x.remove()); }
|
||||||
|
function openNodePop(n, nodeEl){
|
||||||
|
closeNodePop();
|
||||||
|
const pop = document.createElement("div");
|
||||||
|
pop.className = "mm-pop";
|
||||||
|
const w = stage.clientWidth, h = stage.clientHeight;
|
||||||
|
pop.style.left = Math.min(n.x*w + 30, w-190)+"px";
|
||||||
|
pop.style.top = Math.min(n.y*h + 24, h-150)+"px";
|
||||||
|
pop.addEventListener("pointerdown", e=>e.stopPropagation());
|
||||||
|
|
||||||
|
const restyle = ()=>{ applyNodeStyle(n, nodeEl); drawLines(); refreshPop(); };
|
||||||
|
const row = (lbl, title)=>{
|
||||||
|
const r = document.createElement("div");
|
||||||
|
r.className = "mp-row"; r.title = title;
|
||||||
|
const l = document.createElement("span");
|
||||||
|
l.className = "mp-lbl"; l.textContent = lbl;
|
||||||
|
r.appendChild(l);
|
||||||
|
pop.appendChild(r);
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
/* node colour */
|
||||||
|
const r1 = row("⬤", T("nodeColor"));
|
||||||
|
PALETTE.concat(["#26344a"]).forEach(c=>{
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.className = "mp-sw"; b.dataset.v = c; b.style.background = c;
|
||||||
|
b.addEventListener("click", ()=>{ n.c = (n.c===c ? null : c); restyle(); });
|
||||||
|
r1.appendChild(b);
|
||||||
|
});
|
||||||
|
const xBtn = document.createElement("button");
|
||||||
|
xBtn.className = "mp-x"; xBtn.textContent = "✕";
|
||||||
|
xBtn.addEventListener("click", closeNodePop);
|
||||||
|
r1.appendChild(xBtn);
|
||||||
|
/* font size */
|
||||||
|
const r2 = row("A", T("nodeFont"));
|
||||||
|
[["A",13],["A",16],["A",20]].forEach(([txt,fs],i)=>{
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.className = "mp-fs"; b.dataset.v = fs;
|
||||||
|
b.textContent = txt; b.style.fontSize = (11+i*3)+"px";
|
||||||
|
b.addEventListener("click", ()=>{ n.fs = fs; restyle(); });
|
||||||
|
r2.appendChild(b);
|
||||||
|
});
|
||||||
|
if(n.parent!=null){
|
||||||
|
/* line colour */
|
||||||
|
const r3 = row("╱", T("lineColor"));
|
||||||
|
PALETTE.concat(["#26344a"]).forEach(c=>{
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.className = "mp-sw"; b.dataset.v = "l"+c; b.style.background = c;
|
||||||
|
b.addEventListener("click", ()=>{ n.lc = (n.lc===c ? null : c); restyle(); });
|
||||||
|
r3.appendChild(b);
|
||||||
|
});
|
||||||
|
/* line style */
|
||||||
|
const r4 = row("┄", T("lineStyle"));
|
||||||
|
[["solid",""],["dash","dash"],["dot","dot"]].forEach(([id,val])=>{
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.className = "mp-ls"; b.dataset.ls = id; b.dataset.v = "s"+id;
|
||||||
|
b.appendChild(document.createElement("i"));
|
||||||
|
b.addEventListener("click", ()=>{ n.ls = val || null; restyle(); });
|
||||||
|
r4.appendChild(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function refreshPop(){
|
||||||
|
pop.querySelectorAll(".mp-sw").forEach(b=>{
|
||||||
|
const v = b.dataset.v;
|
||||||
|
b.classList.toggle("on", v.startsWith("l") ? n.lc===v.slice(1) : n.c===v);
|
||||||
|
});
|
||||||
|
pop.querySelectorAll(".mp-fs").forEach(b=>
|
||||||
|
b.classList.toggle("on", +b.dataset.v === (n.fs || (n.parent==null?17:15))));
|
||||||
|
pop.querySelectorAll(".mp-ls").forEach(b=>
|
||||||
|
b.classList.toggle("on", (n.ls||"") === (b.dataset.ls==="solid" ? "" : b.dataset.ls)));
|
||||||
|
}
|
||||||
|
refreshPop();
|
||||||
|
stage.appendChild(pop);
|
||||||
|
}
|
||||||
|
document.addEventListener("pointerdown", e=>{
|
||||||
|
if(!stage.querySelector(".mm-pop")) return;
|
||||||
|
if(!e.target.closest(".mm-pop") && !e.target.closest(".mm-cog")) closeNodePop();
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(){
|
||||||
|
mmEl.className = "mm "+S.layout;
|
||||||
|
root.querySelectorAll(".mm-seg button").forEach(b=>{
|
||||||
|
b.textContent = T(b.dataset.m==="spider" ? "mmSpider" : b.dataset.m==="tree" ? "mmTree" : "mmWeb");
|
||||||
|
b.classList.toggle("on", b.dataset.m===S.layout);
|
||||||
|
});
|
||||||
|
root.querySelector(".mm-hint").textContent = T("mmHint");
|
||||||
|
closeNodePop();
|
||||||
|
stage.querySelectorAll(".mm-node").forEach(x=>x.remove());
|
||||||
|
S.nodes.forEach(n=>stage.appendChild(buildNode(n)));
|
||||||
|
drawLines();
|
||||||
|
}
|
||||||
|
root.querySelectorAll(".mm-seg button").forEach(b=>{
|
||||||
|
b.addEventListener("click", ()=>{ S.layout = b.dataset.m; render(); });
|
||||||
|
});
|
||||||
|
new ResizeObserver(drawLines).observe(stage);
|
||||||
|
document.addEventListener("langchange", render);
|
||||||
|
render();
|
||||||
|
return {
|
||||||
|
getState: ()=>({ layout:S.layout, nodes:S.nodes, nextId:S.nextId }),
|
||||||
|
setState: st=>{ if(st && st.nodes){ S.layout=st.layout||"spider"; S.nodes=st.nodes; S.nextId=st.nextId||S.nextId; render(); } }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
68
public/js/widgets/names.js
Normal file
68
public/js/widgets/names.js
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
/* widget: Namenkiezer */
|
||||||
|
/* =========================================================
|
||||||
|
Name picker widget
|
||||||
|
==========================================================*/
|
||||||
|
function mountNames(root, initState){
|
||||||
|
let used = new Set(), spinning = false;
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="np">
|
||||||
|
<div class="np-result">🎯</div>
|
||||||
|
<textarea class="np-list" spellcheck="false"></textarea>
|
||||||
|
<div class="np-row">
|
||||||
|
<button class="tbtn np-pick"></button>
|
||||||
|
<label><input type="checkbox" class="np-nr"><span class="np-nrl"></span></label>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
const ta = root.querySelector(".np-list"),
|
||||||
|
result = root.querySelector(".np-result"),
|
||||||
|
pickBtn = root.querySelector(".np-pick"),
|
||||||
|
nr = root.querySelector(".np-nr");
|
||||||
|
if(initState){
|
||||||
|
ta.value = initState.names || "";
|
||||||
|
nr.checked = !!initState.nr;
|
||||||
|
}
|
||||||
|
ta.addEventListener("input", ()=>used.clear());
|
||||||
|
ta.addEventListener("keydown", e=>e.stopPropagation());
|
||||||
|
function names(){ return ta.value.split("\n").map(s=>s.trim()).filter(Boolean); }
|
||||||
|
pickBtn.addEventListener("click", ()=>{
|
||||||
|
if(spinning) return;
|
||||||
|
const all = names();
|
||||||
|
if(!all.length){ ta.focus(); return; }
|
||||||
|
let pool = nr.checked ? all.filter(n=>!used.has(n)) : all;
|
||||||
|
if(!pool.length){
|
||||||
|
result.textContent = T("namesDone");
|
||||||
|
used.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spinning = true;
|
||||||
|
result.classList.remove("winner");
|
||||||
|
result.classList.add("spin");
|
||||||
|
const FUN = ["🌟","🎉","🦄","🚀","🐸","🎈","🍀","⚡"];
|
||||||
|
let n = 0;
|
||||||
|
const iv = setInterval(()=>{
|
||||||
|
result.textContent = all[Math.floor(Math.random()*all.length)];
|
||||||
|
tone(400+Math.random()*300, 0.03, 0, "square", 0.04);
|
||||||
|
if(++n >= 16){
|
||||||
|
clearInterval(iv);
|
||||||
|
const winner = pool[Math.floor(Math.random()*pool.length)];
|
||||||
|
const em = FUN[Math.floor(Math.random()*8)];
|
||||||
|
result.textContent = `${em} ${winner} ${em}`;
|
||||||
|
result.classList.remove("spin");
|
||||||
|
result.classList.add("winner");
|
||||||
|
confetti(result.parentElement);
|
||||||
|
if(nr.checked) used.add(winner);
|
||||||
|
sndWin();
|
||||||
|
spinning = false;
|
||||||
|
}
|
||||||
|
}, 80);
|
||||||
|
});
|
||||||
|
function labels(){
|
||||||
|
ta.placeholder = T("namesPh");
|
||||||
|
pickBtn.textContent = T("namesPick");
|
||||||
|
root.querySelector(".np-nrl").textContent = T("namesNoRep");
|
||||||
|
}
|
||||||
|
document.addEventListener("langchange", labels);
|
||||||
|
labels();
|
||||||
|
return { getState: ()=>({names:ta.value, nr:nr.checked}) };
|
||||||
|
}
|
||||||
|
|
||||||
14
public/js/widgets/notes.js
Normal file
14
public/js/widgets/notes.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
/* widget: Notities */
|
||||||
|
/* =========================================================
|
||||||
|
Notes widget
|
||||||
|
==========================================================*/
|
||||||
|
function mountNotes(root, initState){
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.className = "notes";
|
||||||
|
ta.placeholder = T("notesPh");
|
||||||
|
if(initState && typeof initState.text==="string") ta.value = initState.text;
|
||||||
|
document.addEventListener("langchange", ()=>{ ta.placeholder = T("notesPh"); });
|
||||||
|
root.appendChild(ta);
|
||||||
|
return { getState: ()=>({text: ta.value}) };
|
||||||
|
}
|
||||||
|
|
||||||
151
public/js/widgets/timer.js
Normal file
151
public/js/widgets/timer.js
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
/* widget: Timer */
|
||||||
|
/* =========================================================
|
||||||
|
Timer widget — digits, visual "kijkklok" disc, or both
|
||||||
|
==========================================================*/
|
||||||
|
function mountTimer(root, initState){
|
||||||
|
const DIAL = 3600; // the disc is a 60-minute dial, like a real kijkklok
|
||||||
|
let total = 300, left = 300, iv = null,
|
||||||
|
mode = "both", // dig | pie | both
|
||||||
|
snd = true;
|
||||||
|
if(initState){
|
||||||
|
total = initState.total || 300; left = total;
|
||||||
|
mode = initState.mode || "both";
|
||||||
|
snd = initState.snd !== false;
|
||||||
|
}
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="timer m-both">
|
||||||
|
<div class="topts">
|
||||||
|
<button class="bsnd ticon"></button>
|
||||||
|
<div class="tseg">
|
||||||
|
<button data-m="dig">🔢</button>
|
||||||
|
<button data-m="pie">🔴</button>
|
||||||
|
<button data-m="both">🔢🔴</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tstage">
|
||||||
|
<svg class="tpie" viewBox="0 0 150 150">
|
||||||
|
<circle cx="75" cy="75" r="70" fill="#eef3f9" stroke="#d5dfeb" stroke-width="2"/>
|
||||||
|
<g class="ticks"></g>
|
||||||
|
<path class="parc" fill="var(--red)"/>
|
||||||
|
<circle class="knob" r="7" fill="#fff" stroke="var(--red)" stroke-width="3"/>
|
||||||
|
<circle cx="75" cy="75" r="6" fill="#fff" stroke="#b9c7d9" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
<div class="tdisp">05:00</div>
|
||||||
|
</div>
|
||||||
|
<div class="trow presets"></div>
|
||||||
|
<div class="trow main">
|
||||||
|
<button class="bstart"></button>
|
||||||
|
<button class="breset"></button>
|
||||||
|
</div>
|
||||||
|
<div class="thint"></div>
|
||||||
|
</div>`;
|
||||||
|
const box = root.querySelector(".timer"),
|
||||||
|
svg = root.querySelector(".tpie"),
|
||||||
|
disp = root.querySelector(".tdisp"),
|
||||||
|
arc = root.querySelector(".parc"),
|
||||||
|
knob = root.querySelector(".knob"),
|
||||||
|
bstart = root.querySelector(".bstart"),
|
||||||
|
breset = root.querySelector(".breset"),
|
||||||
|
bsnd = root.querySelector(".bsnd"),
|
||||||
|
presets = root.querySelector(".presets"),
|
||||||
|
hint = root.querySelector(".thint");
|
||||||
|
const fmt = s => String(Math.floor(s/60)).padStart(2,"0")+":"+String(s%60).padStart(2,"0");
|
||||||
|
|
||||||
|
/* minute tick marks (every 5 min) */
|
||||||
|
const ticks = root.querySelector(".ticks");
|
||||||
|
for(let i=0;i<12;i++){
|
||||||
|
const a = i*30*Math.PI/180;
|
||||||
|
const l = document.createElementNS("http://www.w3.org/2000/svg","line");
|
||||||
|
l.setAttribute("x1", 75+62*Math.sin(a)); l.setAttribute("y1", 75-62*Math.cos(a));
|
||||||
|
l.setAttribute("x2", 75+69*Math.sin(a)); l.setAttribute("y2", 75-69*Math.cos(a));
|
||||||
|
l.setAttribute("stroke", "#b9c7d9"); l.setAttribute("stroke-width", i%3===0?2.5:1.5);
|
||||||
|
ticks.appendChild(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPie(){
|
||||||
|
const a = Math.min(360, left/DIAL*360);
|
||||||
|
const rad = a*Math.PI/180;
|
||||||
|
const kx = 75 + 63*Math.sin(rad), ky = 75 - 63*Math.cos(rad);
|
||||||
|
knob.setAttribute("cx", kx.toFixed(2)); knob.setAttribute("cy", ky.toFixed(2));
|
||||||
|
if(a<=0){ arc.setAttribute("d",""); return; }
|
||||||
|
if(a>=359.99){ arc.setAttribute("d","M75,5 A70,70 0 1 1 74.99,5 Z"); return; }
|
||||||
|
const x = 75 + 70*Math.sin(rad), y = 75 - 70*Math.cos(rad);
|
||||||
|
arc.setAttribute("d",`M75,75 L75,5 A70,70 0 ${a>180?1:0} 1 ${x.toFixed(2)},${y.toFixed(2)} Z`);
|
||||||
|
}
|
||||||
|
function draw(){
|
||||||
|
disp.textContent = fmt(left);
|
||||||
|
disp.classList.toggle("warn", left<=10 && left>0 && iv!==null);
|
||||||
|
drawPie();
|
||||||
|
}
|
||||||
|
function labels(){
|
||||||
|
bstart.textContent = (iv ? "⏸ "+T("pause") : "▶ "+T("start"));
|
||||||
|
breset.textContent = "↺ "+T("reset");
|
||||||
|
presets.querySelectorAll("button").forEach(b=>b.textContent = b.dataset.m+" "+T("min"));
|
||||||
|
root.querySelectorAll(".tseg button").forEach(b=>{
|
||||||
|
const key = b.dataset.m==="dig" ? "tmDigits" : b.dataset.m==="pie" ? "tmVisual" : "tmBoth";
|
||||||
|
b.title = T(key);
|
||||||
|
b.classList.toggle("on", b.dataset.m===mode);
|
||||||
|
});
|
||||||
|
bsnd.textContent = snd ? "🔔" : "🔕";
|
||||||
|
bsnd.title = T("tmSound");
|
||||||
|
bsnd.classList.toggle("on", snd);
|
||||||
|
box.className = "timer m-"+mode + (box.classList.contains("flash")?" flash":"");
|
||||||
|
hint.textContent = mode==="dig" ? "" : T("tmHint");
|
||||||
|
}
|
||||||
|
[1,5,10,15,30].forEach(m=>{
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.dataset.m = m;
|
||||||
|
b.addEventListener("click", ()=>{ stop(); total=left=m*60; draw(); labels(); });
|
||||||
|
presets.appendChild(b);
|
||||||
|
});
|
||||||
|
root.querySelectorAll(".tseg button").forEach(b=>{
|
||||||
|
b.addEventListener("click", ()=>{ mode = b.dataset.m; labels(); });
|
||||||
|
});
|
||||||
|
bsnd.addEventListener("click", ()=>{ snd=!snd; labels(); });
|
||||||
|
|
||||||
|
/* drag on the disc to set the time */
|
||||||
|
function angleFromEvent(ev){
|
||||||
|
const r = svg.getBoundingClientRect();
|
||||||
|
const px = (ev.clientX - r.left)*150/r.width - 75;
|
||||||
|
const py = (ev.clientY - r.top)*150/r.height - 75;
|
||||||
|
return (Math.atan2(px, -py)*180/Math.PI + 360) % 360;
|
||||||
|
}
|
||||||
|
svg.addEventListener("pointerdown", e=>{
|
||||||
|
e.preventDefault();
|
||||||
|
svg.setPointerCapture(e.pointerId);
|
||||||
|
const setFrom = ev=>{
|
||||||
|
const mins = Math.min(60, Math.max(1, Math.round(angleFromEvent(ev)/6)));
|
||||||
|
total = left = mins*60;
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
setFrom(e);
|
||||||
|
const move = ev=>setFrom(ev);
|
||||||
|
const up = ()=>{ svg.removeEventListener("pointermove",move); svg.removeEventListener("pointerup",up); };
|
||||||
|
svg.addEventListener("pointermove",move);
|
||||||
|
svg.addEventListener("pointerup",up);
|
||||||
|
});
|
||||||
|
|
||||||
|
function stop(){ if(iv){ clearInterval(iv); iv=null; } }
|
||||||
|
bstart.addEventListener("click", ()=>{
|
||||||
|
if(iv){ stop(); }
|
||||||
|
else if(left>0){
|
||||||
|
iv = setInterval(()=>{
|
||||||
|
left--;
|
||||||
|
if(left<=0){
|
||||||
|
left=0; stop();
|
||||||
|
if(snd) sndWin();
|
||||||
|
box.classList.add("flash");
|
||||||
|
setTimeout(()=>box.classList.remove("flash"), 2200);
|
||||||
|
labels();
|
||||||
|
}
|
||||||
|
draw();
|
||||||
|
},1000);
|
||||||
|
}
|
||||||
|
labels(); draw();
|
||||||
|
});
|
||||||
|
breset.addEventListener("click", ()=>{ stop(); left=total; draw(); labels(); });
|
||||||
|
document.addEventListener("langchange", labels);
|
||||||
|
labels(); draw();
|
||||||
|
return { getState: ()=>({total, mode, snd}) };
|
||||||
|
}
|
||||||
|
|
||||||
205
public/js/widgets/write.js
Normal file
205
public/js/widgets/write.js
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
/* widget: Schrijfblad */
|
||||||
|
/* =========================================================
|
||||||
|
Writing pad — pen on paper with lines or maths squares
|
||||||
|
==========================================================*/
|
||||||
|
function mountWrite(root, initState){
|
||||||
|
const COLORS = ["#26344a","#3b7dd8","#e0554d","#3fae6a","#f7c948"];
|
||||||
|
const SIZES = [3,6,10];
|
||||||
|
let color = COLORS[0], size = SIZES[1], tool = "pen", // pen | mark | text | erase
|
||||||
|
bg = (initState && initState.bg) || "write";
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="wp">
|
||||||
|
<div class="wp-bar">
|
||||||
|
<div class="wp-group wp-tools"></div>
|
||||||
|
<div class="wp-group wp-bgs"></div>
|
||||||
|
<div class="wp-group wp-colors"></div>
|
||||||
|
<div class="wp-group wp-sizes"></div>
|
||||||
|
<button class="wp-clear">🗑</button>
|
||||||
|
</div>
|
||||||
|
<div class="wp-stage"><canvas></canvas></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const stage = root.querySelector(".wp-stage"),
|
||||||
|
canvas = root.querySelector("canvas"),
|
||||||
|
ctx = canvas.getContext("2d"),
|
||||||
|
toolsEl = root.querySelector(".wp-tools"),
|
||||||
|
bgsEl = root.querySelector(".wp-bgs"),
|
||||||
|
colorsEl = root.querySelector(".wp-colors"),
|
||||||
|
sizesEl = root.querySelector(".wp-sizes"),
|
||||||
|
clearBtn = root.querySelector(".wp-clear");
|
||||||
|
|
||||||
|
const TOOLS = [["pen","✏️","toolPen"],["vulp","🖋️","toolFountain"],["mark","🖍️","toolMark"],["text","🔤","toolText"],["erase","🧽","eraser"]];
|
||||||
|
TOOLS.forEach(([id,icon,key])=>{
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.dataset.tool = id; b.textContent = icon;
|
||||||
|
b.addEventListener("click", ()=>{ tool = id; refresh(); });
|
||||||
|
toolsEl.appendChild(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* background choices */
|
||||||
|
const BGS = [["blank","bgBlank"],["lines","bgLines"],["write","bgWrite"],["grid-s","bgGridS"],["grid-b","bgGridB"]];
|
||||||
|
BGS.forEach(([id,key])=>{
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.className = "wp-bg"; b.dataset.bg = id;
|
||||||
|
b.appendChild(document.createElement("i"));
|
||||||
|
b.addEventListener("click", ()=>{ bg = id; refresh(); });
|
||||||
|
bgsEl.appendChild(b);
|
||||||
|
});
|
||||||
|
COLORS.forEach(c=>{
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.className = "wp-color"; b.dataset.c = c;
|
||||||
|
const i = document.createElement("i"); i.style.background = c;
|
||||||
|
b.appendChild(i);
|
||||||
|
b.addEventListener("click", ()=>{ color = c; if(tool==="erase") tool = "pen"; refresh(); });
|
||||||
|
colorsEl.appendChild(b);
|
||||||
|
});
|
||||||
|
SIZES.forEach(s=>{
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.className = "wp-size"; b.dataset.s = s;
|
||||||
|
const i = document.createElement("i");
|
||||||
|
i.style.width = i.style.height = (s+4)+"px";
|
||||||
|
b.appendChild(i);
|
||||||
|
b.addEventListener("click", ()=>{ size = s; refresh(); });
|
||||||
|
sizesEl.appendChild(b);
|
||||||
|
});
|
||||||
|
clearBtn.addEventListener("click", ()=>{ ctx.clearRect(0,0,canvas.width,canvas.height); });
|
||||||
|
|
||||||
|
function refresh(){
|
||||||
|
stage.className = "wp-stage" + (bg==="blank" ? "" : " bg-"+bg) + (tool==="text" ? " t-text" : "");
|
||||||
|
toolsEl.querySelectorAll("button").forEach(b=>{
|
||||||
|
b.classList.toggle("on", b.dataset.tool===tool);
|
||||||
|
b.title = T(TOOLS.find(x=>x[0]===b.dataset.tool)[2]);
|
||||||
|
});
|
||||||
|
bgsEl.querySelectorAll("button").forEach(b=>{
|
||||||
|
b.classList.toggle("on", b.dataset.bg===bg);
|
||||||
|
b.title = T(BGS.find(x=>x[0]===b.dataset.bg)[1]);
|
||||||
|
});
|
||||||
|
colorsEl.querySelectorAll("button").forEach(b=>b.classList.toggle("on", b.dataset.c===color && tool!=="erase"));
|
||||||
|
sizesEl.querySelectorAll("button").forEach(b=>{ b.classList.toggle("on", +b.dataset.s===size); b.title = T("penSize"); });
|
||||||
|
clearBtn.title = T("clearAll");
|
||||||
|
}
|
||||||
|
document.addEventListener("langchange", refresh);
|
||||||
|
|
||||||
|
/* canvas sizing (preserves ink on resize) */
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
function resize(){
|
||||||
|
const w = stage.clientWidth, h = stage.clientHeight;
|
||||||
|
if(w<2 || h<2) return;
|
||||||
|
const keep = document.createElement("canvas");
|
||||||
|
keep.width = canvas.width; keep.height = canvas.height;
|
||||||
|
if(canvas.width) keep.getContext("2d").drawImage(canvas,0,0);
|
||||||
|
canvas.width = Math.round(w*dpr); canvas.height = Math.round(h*dpr);
|
||||||
|
canvas.style.width = w+"px"; canvas.style.height = h+"px";
|
||||||
|
if(keep.width) ctx.drawImage(keep,0,0);
|
||||||
|
ctx.lineCap = "round"; ctx.lineJoin = "round";
|
||||||
|
}
|
||||||
|
new ResizeObserver(resize).observe(stage);
|
||||||
|
resize();
|
||||||
|
|
||||||
|
/* restore saved ink */
|
||||||
|
if(initState && initState.ink){
|
||||||
|
const im = new Image();
|
||||||
|
im.onload = ()=>{ resize(); ctx.drawImage(im,0,0); };
|
||||||
|
im.src = initState.ink;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* drawing */
|
||||||
|
let drawing = false, lx=0, ly=0, nibW = size;
|
||||||
|
const pos = ev=>{
|
||||||
|
const r = canvas.getBoundingClientRect();
|
||||||
|
/* ratio-based: correct under app zoom and widget zoom */
|
||||||
|
return [(ev.clientX-r.left)*(canvas.width/r.width), (ev.clientY-r.top)*(canvas.height/r.height)];
|
||||||
|
};
|
||||||
|
canvas.addEventListener("pointerdown", e=>{
|
||||||
|
if(e.pointerType==="mouse" && e.button!==0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if(tool==="text"){ startText(e); return; }
|
||||||
|
canvas.setPointerCapture(e.pointerId);
|
||||||
|
drawing = true;
|
||||||
|
nibW = size*0.8;
|
||||||
|
[lx,ly] = pos(e);
|
||||||
|
strokeTo(lx+0.01, ly+0.01, e.pressure, e.pointerType);
|
||||||
|
});
|
||||||
|
canvas.addEventListener("pointermove", e=>{
|
||||||
|
if(!drawing) return;
|
||||||
|
const evs = e.getCoalescedEvents ? e.getCoalescedEvents() : [e];
|
||||||
|
evs.forEach(ev=>{ const [x,y] = pos(ev); strokeTo(x, y, ev.pressure, ev.pointerType); });
|
||||||
|
});
|
||||||
|
const stopDraw = ()=>{ drawing = false; ctx.globalAlpha = 1; };
|
||||||
|
canvas.addEventListener("pointerup", stopDraw);
|
||||||
|
canvas.addEventListener("pointercancel", stopDraw);
|
||||||
|
function strokeTo(x,y,pressure,ptype){
|
||||||
|
ctx.globalCompositeOperation = tool==="erase" ? "destination-out" : "source-over";
|
||||||
|
ctx.globalAlpha = tool==="mark" ? 0.32 : 1;
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
let w = size;
|
||||||
|
if(tool==="erase") w = size*5;
|
||||||
|
else if(tool==="mark") w = size*3.5;
|
||||||
|
else if(tool==="vulp"){
|
||||||
|
/* nib: real pen pressure when available, otherwise slower = thicker */
|
||||||
|
let target;
|
||||||
|
if(ptype==="pen" && pressure > 0){
|
||||||
|
target = size*(0.45 + pressure*1.9);
|
||||||
|
}else{
|
||||||
|
const speed = Math.hypot(x-lx, y-ly)/dpr;
|
||||||
|
target = Math.max(size*0.5, Math.min(size*2.3, size*2.3 - speed*0.12));
|
||||||
|
}
|
||||||
|
nibW += (target - nibW)*0.3; /* smooth transitions between thin and thick */
|
||||||
|
w = nibW;
|
||||||
|
}
|
||||||
|
ctx.lineWidth = w*dpr;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(lx,ly);
|
||||||
|
ctx.lineTo(x,y);
|
||||||
|
ctx.stroke();
|
||||||
|
lx = x; ly = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* text tool: click the sheet, type, Enter (or click away) to place */
|
||||||
|
function startText(e){
|
||||||
|
if(stage.querySelector(".wp-text-input")) return;
|
||||||
|
const r = stage.getBoundingClientRect();
|
||||||
|
const sc = r.width / stage.offsetWidth; /* effective zoom on this widget */
|
||||||
|
const x = (e.clientX - r.left)/sc, y = (e.clientY - r.top)/sc;
|
||||||
|
const fontPx = size*6 + 10;
|
||||||
|
const inp = document.createElement("input");
|
||||||
|
inp.type = "text"; inp.className = "wp-text-input";
|
||||||
|
inp.style.left = Math.min(x, r.width-140)+"px";
|
||||||
|
inp.style.top = Math.min(y, r.height-34)+"px";
|
||||||
|
inp.style.color = color;
|
||||||
|
inp.style.fontSize = fontPx+"px";
|
||||||
|
stage.appendChild(inp);
|
||||||
|
setTimeout(()=>inp.focus(), 0);
|
||||||
|
let done = false;
|
||||||
|
const commit = ()=>{
|
||||||
|
if(done) return; done = true;
|
||||||
|
const v = inp.value.trim();
|
||||||
|
if(v){
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.font = `700 ${fontPx*dpr}px "Segoe UI Rounded","Segoe UI",Verdana,sans-serif`;
|
||||||
|
ctx.textBaseline = "top";
|
||||||
|
ctx.fillText(v, x*dpr, y*dpr);
|
||||||
|
}
|
||||||
|
inp.remove();
|
||||||
|
};
|
||||||
|
inp.addEventListener("keydown", ev=>{
|
||||||
|
ev.stopPropagation();
|
||||||
|
if(ev.key==="Enter") commit();
|
||||||
|
if(ev.key==="Escape"){ done = true; inp.remove(); }
|
||||||
|
});
|
||||||
|
inp.addEventListener("blur", commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
return {
|
||||||
|
getState: ()=>{
|
||||||
|
let ink = null;
|
||||||
|
try{ ink = canvas.toDataURL("image/png"); }catch(e){}
|
||||||
|
return { bg, ink };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in a new issue