diff --git a/public/css/teach.css b/public/css/teach.css new file mode 100644 index 0000000..f54678c --- /dev/null +++ b/public/css/teach.css @@ -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;}} diff --git a/public/index.html b/public/index.html index ecc074b..54028b4 100644 --- a/public/index.html +++ b/public/index.html @@ -5,945 +5,7 @@ teach - + @@ -1027,3811 +89,23 @@ - + + + + + + + + + + + + + + + + + + diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..09cd209 --- /dev/null +++ b/public/js/app.js @@ -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(); + } +})(); \ No newline at end of file diff --git a/public/js/board.js b/public/js/board.js new file mode 100644 index 0000000..f657bc4 --- /dev/null +++ b/public/js/board.js @@ -0,0 +1,1064 @@ +/* teach - bord: widgets, tekenen, gereedschappen, borden & mappen */ +/* ========================================================= + Widget framework +==========================================================*/ +const board = document.getElementById("board"); +const dock = document.getElementById("dock"); +let zTop = 10, widgetCount = 0; +const instances = new Set(); + +const REGISTRY = [ + { id:"letters", cat:"taal", icon:"🔤", w:760, h:640, minW:640, minH:540, mount:mountLetterGame }, + { id:"flits", cat:"taal", icon:"⚡", w:520, h:430, minW:400, minH:330, mount:mountFlits }, + { id:"hak", cat:"taal", icon:"✂️", w:540, h:440, minW:420, minH:340, mount:mountHak }, + { id:"ball", cat:"taal", icon:"🎈", w:620, h:560, minW:480, minH:440, mount:mountBall }, + { id:"anchor", cat:"taal", icon:"⚓", w:680, h:500, minW:440, minH:340, mount:mountAnchor }, + { id:"madd", cat:"rekenen", icon:"➕", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"add") }, + { id:"msub", cat:"rekenen", icon:"➖", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"sub") }, + { id:"mmul", cat:"rekenen", icon:"✖️", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"mul") }, + { id:"mdiv", cat:"rekenen", icon:"➗", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"div") }, + { id:"mpct", cat:"rekenen", icon:"💯", w:480, h:620, minW:400, minH:520, mount:(r,s)=>mountMath(r,s,"pct") }, + { id:"write", cat:"tools", icon:"✏️", w:560, h:440, minW:360, minH:280, mount:mountWrite }, + { id:"mind", cat:"tools", icon:"🕸️", w:640, h:480, minW:420, minH:320, mount:mountMind }, + { id:"notes", cat:"tools", icon:"📝", w:340, h:300, minW:240, minH:180, mount:mountNotes }, + { id:"timer", cat:"tools", icon:"⏱️", w:350, h:470, minW:300, minH:420, mount:mountTimer }, + { id:"dice", cat:"tools", icon:"🎲", w:400, h:340, minW:300, minH:270, mount:mountDice }, + { id:"names", cat:"tools", icon:"🎯", w:420, h:430, minW:320, minH:330, mount:mountNames }, + { id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia } +]; +const WIDGET_CATS = [["taal","catTaal"],["rekenen","catRekenen"],["tools","catTools"],["media","catMedia"]]; + +function makeWidget(def, saved){ + const win = document.createElement("div"); + win.className = "widget"; + win.style.width = (saved && saved.w ? saved.w : def.w)+"px"; + win.style.height = (saved && saved.h ? saved.h : def.h)+"px"; + if(def.minW) win.style.minWidth = def.minW+"px"; + if(def.minH) win.style.minHeight = def.minH+"px"; + win.style.left = (saved && saved.x!=null ? saved.x : 40 + (widgetCount*36)%240) + "px"; + win.style.top = (saved && saved.y!=null ? saved.y : 30 + (widgetCount*30)%160) + "px"; + win.style.zIndex = ++zTop; + widgetCount++; + if(saved && saved.bare){ win.classList.add("bare"); win.dataset.bare = "1"; } + + const head = document.createElement("div"); + head.className = "widget-head"; + head.innerHTML = `${def.icon}`; + const zBtn = document.createElement("button"); + zBtn.className = "wmin wz"; zBtn.textContent = "A+"; + head.appendChild(zBtn); + const minBtn = document.createElement("button"); + minBtn.className = "wmin"; minBtn.textContent = "–"; + head.appendChild(minBtn); + const maxBtn = document.createElement("button"); + maxBtn.className = "wmin"; maxBtn.textContent = "▢"; + head.appendChild(maxBtn); + const close = document.createElement("button"); + close.className = "wclose"; close.textContent = "✕"; + head.appendChild(close); + + const body = document.createElement("div"); + body.className = "widget-body"; + win.appendChild(head); win.appendChild(body); + + let pill = null; + function setMin(m){ + if(m && !pill){ + win.dataset.prevW = win.offsetWidth; + win.dataset.prevH = win.offsetHeight; + win.dataset.min = "1"; + win.style.display = "none"; + pill = document.createElement("button"); + pill.className = "dock-item"; + pill.innerHTML = `${def.icon}`; + pill.querySelector(".dl").textContent = T("wg_"+def.id); + pill.title = T("wMax"); + pill.addEventListener("click", ()=>setMin(false)); + dock.appendChild(pill); + }else if(!m && pill){ + pill.remove(); pill = null; + win.dataset.min = ""; + win.style.display = "flex"; + win.style.zIndex = ++zTop; + } + } + minBtn.addEventListener("click", ()=>setMin(true)); + /* per-widget content zoom for low-vision pupils */ + let wz = (saved && saved.wz) || 1; + function applyWZ(){ + body.style.zoom = wz; + win.dataset.wz = wz; + zBtn.textContent = wz===1 ? "A+" : Math.round(wz*100)+"%"; + zBtn.classList.toggle("on", wz!==1); + zBtn.title = T("wZoom"); + } + zBtn.addEventListener("click", ()=>{ + wz = wz===1 ? 1.25 : wz===1.25 ? 1.5 : 1; + applyWZ(); + }); + applyWZ(); + function setMax(m){ + if(m && win.dataset.max!=="1"){ + win.dataset.pmx = win.offsetLeft; win.dataset.pmy = win.offsetTop; + win.dataset.pmw = win.offsetWidth; win.dataset.pmh = win.offsetHeight; + win.dataset.max = "1"; + win.style.left = "0px"; win.style.top = "0px"; + win.style.width = board.clientWidth+"px"; + win.style.height = board.clientHeight+"px"; + win.style.zIndex = ++zTop; + maxBtn.textContent = "❐"; + setHudMin(true); /* keep the tools bar out of the way */ + }else if(!m && win.dataset.max==="1"){ + win.dataset.max = ""; + win.style.left = win.dataset.pmx+"px"; win.style.top = win.dataset.pmy+"px"; + win.style.width = win.dataset.pmw+"px"; win.style.height = win.dataset.pmh+"px"; + maxBtn.textContent = "▢"; + } + maxBtn.title = T(win.dataset.max==="1" ? "wUnfull" : "wFull"); + } + maxBtn.addEventListener("click", ()=>setMax(win.dataset.max!=="1")); + head.addEventListener("dblclick", e=>{ + if(e.target===close || e.target===minBtn || e.target===maxBtn) return; + setMin(true); + }); + + const setTitle = ()=>{ + head.querySelector(".wtitle").textContent = T("wg_"+def.id); + minBtn.title = T("wMin"); + maxBtn.title = T(win.dataset.max==="1" ? "wUnfull" : "wFull"); + zBtn.title = T("wZoom"); + if(pill){ pill.querySelector(".dl").textContent = T("wg_"+def.id); pill.title = T("wMax"); } + }; + setTitle(); + document.addEventListener("langchange", setTitle); + + win.addEventListener("pointerdown", ()=>{ win.style.zIndex = ++zTop; }); + head.addEventListener("pointerdown", e=>{ + if(e.target===close) return; + e.preventDefault(); + const zf = VZ(); + const sx = e.clientX/zf - win.offsetLeft, sy = e.clientY/zf - win.offsetTop; + const move = ev=>{ + win.style.left = Math.max(0, Math.min(board.clientWidth - 60, ev.clientX/zf - sx)) + "px"; + win.style.top = Math.max(0, Math.min(board.clientHeight - 40, ev.clientY/zf - sy)) + "px"; + }; + const up = ()=>{ document.removeEventListener("pointermove",move); document.removeEventListener("pointerup",up); }; + document.addEventListener("pointermove",move); document.addEventListener("pointerup",up); + }); + + board.appendChild(win); + const api = def.mount(body, saved ? saved.state : null) || {}; + if(saved && saved.min) setMin(true); + if(saved && saved.max) setMax(true); + const inst = { def, win, api }; + instances.add(inst); + close.addEventListener("click", ()=>{ + if(pill){ pill.remove(); pill = null; } + instances.delete(inst); win.remove(); updateEmptyHint(); + }); + updateEmptyHint(); + return inst; +} +function updateEmptyHint(){ + document.body.classList.toggle("board-empty", !board.querySelector(".widget")); +} + +function serializeBoard(){ + return { + ink: boardInkURL(), + bg: BOARD.bg, + widgets: [...instances].map(inst=>{ + const w = inst.win; + const min = w.dataset.min === "1"; + const max = w.dataset.max === "1"; + return { + id: inst.def.id, + x: max ? +w.dataset.pmx : w.offsetLeft, + y: max ? +w.dataset.pmy : w.offsetTop, + w: min ? (+w.dataset.prevW || inst.def.w) : max ? +w.dataset.pmw : w.offsetWidth, + h: min ? (+w.dataset.prevH || inst.def.h) : max ? +w.dataset.pmh : w.offsetHeight, + min, max, + wz: +w.dataset.wz || 1, + bare: w.dataset.bare === "1", + state: inst.api.getState ? inst.api.getState() : null + }; + }), + imgs: [...board.querySelectorAll(".bimg")].map(b=>({ + src: b.querySelector("img").src, + x: b.offsetLeft, y: b.offsetTop, w: b.offsetWidth + })) + }; +} +function restoreBoard(b){ + [...instances].forEach(inst=>inst.win.remove()); + instances.clear(); + dock.innerHTML = ""; + widgetCount = 0; + (b.widgets||[]).forEach(wd=>{ + const def = REGISTRY.find(d=>d.id===wd.id); + if(def) makeWidget(def, wd); + }); + setBoardInk(b.ink); + setBoardBg(b.bg || "dots"); + board.querySelectorAll(".bimg").forEach(x=>x.remove()); + (b.imgs||[]).forEach(im=>addBoardImage(im.src, im.x, im.y, im.w)); + updateEmptyHint(); +} + +/* ---------- board drawing layer (pen mode) ---------- */ +const bInk = document.createElement("canvas"); +bInk.id = "boardInk"; +board.insertBefore(bInk, board.firstChild); +const bCtx = bInk.getContext("2d"); +const bDpr = window.devicePixelRatio || 1; +function bResize(){ + const w = board.clientWidth, h = board.clientHeight; + if(w<2 || h<2) return; + const keep = document.createElement("canvas"); + keep.width = bInk.width; keep.height = bInk.height; + if(bInk.width) keep.getContext("2d").drawImage(bInk,0,0); + bInk.width = Math.round(w*bDpr); bInk.height = Math.round(h*bDpr); + if(keep.width) bCtx.drawImage(keep,0,0); + bCtx.lineCap = "round"; bCtx.lineJoin = "round"; +} +new ResizeObserver(bResize).observe(board); +bResize(); + +const PEN = { tool:"pen", color:"#26344a", size:4, nib:4 }; +const BOARD = { bg:"blank" }; +const penbar = document.getElementById("penbar"); +function setPen(on){ + document.body.classList.toggle("pen", on); + document.body.classList.toggle("pen-text", on && PEN.tool==="text"); + refreshPenbar(); +} +function setBoardBg(bg){ + BOARD.bg = bg; + board.className = (bg && bg!=="blank") ? "bg-"+bg : ""; + refreshPenbar(); +} +const PEN_TOOLS = [["hand","✋","toolHand"],["pen","✏️","toolPen"],["vulp","🖋️","toolFountain"],["mark","🖍️","toolMark"],["text","🔤","toolText"],["erase","🧽","eraser"]]; +PEN_TOOLS.forEach(([id,icon])=>{ + const b = document.createElement("button"); + b.dataset.tool = id; b.textContent = icon; + b.addEventListener("click", ()=>{ PEN.tool = id; setPen(true); refreshPenbar(); }); + penbar.querySelector(".pen-tools").appendChild(b); +}); +["#26344a","#3b7dd8","#e0554d","#3fae6a","#9b59b6","#f7c948"].forEach(c=>{ + const b = document.createElement("button"); + b.className = "wp-color"; b.dataset.c = c; + const dot = document.createElement("i"); dot.style.background = c; + b.appendChild(dot); + b.addEventListener("click", ()=>{ PEN.color = c; if(PEN.tool==="erase") PEN.tool = "pen"; refreshPenbar(); }); + penbar.querySelector(".pen-colors").appendChild(b); +}); +[3,5,8].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", ()=>{ PEN.size = s; refreshPenbar(); }); + penbar.querySelector(".pen-sizes").appendChild(b); +}); +const PEN_BGS = [["blank","bgBlank"],["dots","bgDots"],["lines","bgLines"],["write","bgWrite"],["grid-s","bgGridS"],["grid-b","bgGridB"]]; +PEN_BGS.forEach(([id])=>{ + const b = document.createElement("button"); + b.className = "wp-bg"; b.dataset.bg = id; + b.appendChild(document.createElement("i")); + b.addEventListener("click", ()=>setBoardBg(id)); + penbar.querySelector(".pen-bgs").appendChild(b); +}); +const PEN_EXTRAS = [["ruler","📏","toolRuler"],["geo","📐","toolGeo"],["lens","🔍","toolLens"],["light","🚦","wg_light"]]; +PEN_EXTRAS.forEach(([id,icon])=>{ + const b = document.createElement("button"); + b.dataset.extra = id; b.textContent = icon; + b.addEventListener("click", ()=>toggleDTool(id)); + penbar.querySelector(".pen-extras").appendChild(b); +}); +penbar.querySelector(".pen-clear").addEventListener("click", ()=>bCtx.clearRect(0,0,bInk.width,bInk.height)); +/* the ✍-button toggles drawing mode on/off (palette itself stays visible) */ +penbar.querySelector(".pen-close").addEventListener("click", ()=>setPen(!document.body.classList.contains("pen"))); +/* orientation + collapse of the whole bar */ +document.querySelector(".hud-orient").addEventListener("click", ()=>{ + document.getElementById("hud").classList.toggle("vert"); +}); +document.querySelector(".hud-min").addEventListener("click", function(){ + setHudMin(!document.getElementById("hud").classList.contains("min")); +}); +/* unified draggable bar (pen palette + dock) */ +const hud = document.getElementById("hud"); +hud.querySelector(".pen-grip").addEventListener("pointerdown", e=>{ + e.preventDefault(); + const zf = VZ(); + const r = hud.getBoundingClientRect(); + hud.style.transform = "none"; + hud.style.bottom = "auto"; + hud.style.left = (r.left/zf)+"px"; + hud.style.top = (r.top/zf)+"px"; + const ox = (e.clientX - r.left)/zf, oy = (e.clientY - r.top)/zf; + const mv = ev=>{ + hud.style.left = Math.max(4, Math.min(window.innerWidth/zf - r.width/zf - 4, ev.clientX/zf - ox))+"px"; + hud.style.top = Math.max(4, Math.min(window.innerHeight/zf - 44, ev.clientY/zf - oy))+"px"; + }; + const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; + document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); +}); +function setHudMin(m){ + hud.classList.toggle("min", m); + const b = document.querySelector(".hud-min"); + if(b) b.textContent = m ? "+" : "–"; +} +function updateHud(){ hud.classList.toggle("has-dock", dock.children.length > 0); } +new MutationObserver(updateHud).observe(dock, {childList:true}); +updateHud(); +function refreshPenbar(){ + penbar.querySelectorAll(".pen-tools button").forEach(b=>{ + b.classList.toggle("on", b.dataset.tool===PEN.tool); + b.title = T(PEN_TOOLS.find(x=>x[0]===b.dataset.tool)[2]); + }); + penbar.querySelectorAll(".wp-color").forEach(b=> + b.classList.toggle("on", b.dataset.c===PEN.color && PEN.tool!=="erase")); + penbar.querySelectorAll(".wp-size").forEach(b=>b.classList.toggle("on", +b.dataset.s===PEN.size)); + penbar.querySelectorAll(".wp-bg").forEach(b=>{ + b.classList.toggle("on", b.dataset.bg===BOARD.bg); + b.title = T(PEN_BGS.find(x=>x[0]===b.dataset.bg)[1]); + }); + penbar.querySelectorAll(".pen-extras button").forEach(b=>{ + b.classList.toggle("on", !!DTOOLS[b.dataset.extra]); + b.title = T(PEN_EXTRAS.find(x=>x[0]===b.dataset.extra)[2]); + }); + penbar.querySelector(".pen-clear").title = T("clearAll"); + const power = penbar.querySelector(".pen-close"); + const penOn = document.body.classList.contains("pen"); + power.textContent = "✍"; + power.title = T("penMode"); + power.classList.toggle("on", penOn); + document.querySelector(".hud-orient").title = T("hudOrient"); + document.querySelector(".hud-min").title = T("hudMin"); + document.body.classList.toggle("pen-text", penOn && PEN.tool==="text"); + document.body.classList.toggle("pen-hand", PEN.tool==="hand"); +} +document.addEventListener("langchange", refreshPenbar); + +/* ---------- physical tools: ruler, set square, magnifier ---------- */ +const DTOOLS = {}; +function makeMovable(t){ + let ang = 0; + t.addEventListener("pointerdown", e=>{ + if(e.target.classList.contains("dtool-x") || e.target.closest(".dtool-opts")) return; + e.preventDefault(); e.stopPropagation(); + if(e.target.classList.contains("dtool-rot")){ + const r = t.getBoundingClientRect(); + const cx = r.left+r.width/2, cy = r.top+r.height/2; + const start = Math.atan2(e.clientY-cy, e.clientX-cx)*180/Math.PI - ang; + const mv = ev=>{ + ang = Math.atan2(ev.clientY-cy, ev.clientX-cx)*180/Math.PI - start; + t.style.transform = `rotate(${ang}deg)`; + }; + const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; + document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); + return; + } + const zf = VZ(); + const sx = e.clientX/zf - t.offsetLeft, sy = e.clientY/zf - t.offsetTop; + const mv = ev=>{ + t.style.left = (ev.clientX/zf-sx)+"px"; + t.style.top = (ev.clientY/zf-sy)+"px"; + if(t.lensDraw) t.lensDraw(); + }; + const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; + document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); + }); +} +function toggleDTool(kind){ + if(DTOOLS[kind]){ + if(DTOOLS[kind]._iv) clearInterval(DTOOLS[kind]._iv); + DTOOLS[kind].remove(); delete DTOOLS[kind]; refreshPenbar(); return; + } + const t = document.createElement("div"); + t.className = "dtool dtool-"+kind; + if(kind==="ruler"){ + t._len = 600; t._hc = false; + const drawRuler = ()=>{ + const L = t._len, hc = t._hc; + const ink = hc ? "#000" : "#5a4a20"; + const bg = hc ? "rgba(255,214,0,.95)" : "rgba(255,243,205,.82)"; + let ticks = ""; + for(let i=5; i`; + if(cm) ticks += `${i/50}`; + } + t.style.width = L+"px"; + t.querySelector(".dtool-svg").innerHTML = ` + + + + + + + teach · cm + ${ticks}`; + }; + t.innerHTML = `
+
+ + +
`; + t.querySelector(".do-len").addEventListener("click", ()=>{ + t._len = t._len===600 ? 800 : t._len===800 ? 400 : 600; drawRuler(); + }); + t.querySelector(".do-hc").addEventListener("click", ()=>{ t._hc = !t._hc; drawRuler(); }); + t.style.height = "76px"; + drawRuler(); + }else if(kind==="geo"){ + t._sz = 340; t._hc = false; + const drawGeo = ()=>{ + const W = t._sz, H = W/2 + 22, hc = t._hc; + const ink = hc ? "#000" : "#1e4f74"; + const bg = hc ? "rgba(255,214,0,.9)" : "rgba(185,222,248,.6)"; + const halo = `stroke="${hc?"#ffd400":"#ffffff"}" stroke-width="3.5" paint-order="stroke" stroke-linejoin="round"`; + const ax = W/2, ay = 16, by = H-18; /* apex + baseline */ + const half = W/2 - 14; + let cmTicks = ""; + for(let i=25; i<=half-16; i+=25){ + const cm = i%50===0; + [ax-i, ax+i].forEach(x=>{ + cmTicks += ``; + }); + if(cm){ + [ax-i, ax+i].forEach(x=>{ + cmTicks += `${i/50}`; + }); + } + } + /* degree arc with labels INSIDE the arc so they stay readable */ + let degs = ""; + const R = half*0.60; + for(let d=10; d<180; d+=10){ + const rad = d*Math.PI/180; + const x1 = ax - Math.cos(rad)*(R-7), y1 = by - Math.sin(rad)*(R-7); + const x2 = ax - Math.cos(rad)*R, y2 = by - Math.sin(rad)*R; + degs += ``; + if(d%30===0 && d>=30 && d<=150){ + const xt = ax - Math.cos(rad)*(R-22), yt = by - Math.sin(rad)*(R-22); + degs += `${d}°`; + } + } + t.style.width = W+"px"; t.style.height = H+"px"; + t.querySelector(".dtool-svg").innerHTML = ` + + + + 0 + ${cmTicks}${degs}`; + }; + t.innerHTML = `
+
+ + +
`; + t.querySelector(".do-len").addEventListener("click", ()=>{ + t._sz = t._sz===340 ? 440 : t._sz===440 ? 280 : 340; drawGeo(); + }); + t.querySelector(".do-hc").addEventListener("click", ()=>{ t._hc = !t._hc; drawGeo(); }); + drawGeo(); + }else if(kind==="light"){ /* compact traffic light directly on the board */ + let on = "green"; + t.innerHTML = `
+ + + +
`; + const apply = ()=>t.querySelectorAll(".tl-lamp").forEach(l=>l.classList.toggle("on", l.dataset.c===on)); + t.querySelectorAll(".tl-lamp").forEach(l=>{ + l.addEventListener("pointerdown", e=>e.stopPropagation()); + l.addEventListener("click", ()=>{ on = l.dataset.c; apply(); }); + }); + apply(); + t.style.left = (board.clientWidth-140)+"px"; t.style.top = "20px"; + }else{ /* lens: magnifies background, pictures and ink (2x); resizable, round or square */ + const Z = 2, LMIN = 120, LMAX = 340; + t._d = 184; t._sq = false; + const c = document.createElement("canvas"); + t.appendChild(c); + t.lensDraw = ()=>{ + const d = t._d, S = Z*2; /* canvas is 2x for sharpness */ + const ctx2 = c.getContext("2d"); + const cx = t.offsetLeft + d/2 + 2, cy = t.offsetTop + d/2 + 2; /* board css px */ + const sw = d/Z; + const sx = cx - sw/2, sy = cy - sw/2; + ctx2.fillStyle = "#eef3f8"; + ctx2.fillRect(0, 0, d*2, d*2); + if(BOARD.bg === "dots"){ + ctx2.fillStyle = "#c9d6e5"; + const g = 26; + for(let gx = Math.floor(sx/g)*g; gx < sx+sw; gx += g){ + for(let gy = Math.floor(sy/g)*g; gy < sy+sw; gy += g){ + ctx2.beginPath(); + ctx2.arc((gx-sx)*S, (gy-sy)*S, 1.4*S, 0, 7); + ctx2.fill(); + } + } + } + board.querySelectorAll(".bimg").forEach(b=>{ + const im = b.querySelector("img"); + if(!im.complete || !im.naturalWidth) return; + try{ + ctx2.drawImage(im, (b.offsetLeft-sx)*S, (b.offsetTop-sy)*S, b.offsetWidth*S, b.offsetHeight*S); + }catch(e){} + }); + try{ + ctx2.drawImage(bInk, sx*bDpr, sy*bDpr, sw*bDpr, sw*bDpr, 0, 0, d*2, d*2); + }catch(e){} + }; + const lensSetup = ()=>{ + const d = t._d; + c.width = d*2; c.height = d*2; + c.style.width = d+"px"; c.style.height = d+"px"; + c.style.borderRadius = t._sq ? "16px" : "50%"; + t.style.width = (d+4)+"px"; t.style.height = (d+4)+"px"; + t.lensDraw(); + }; + const opts = document.createElement("div"); + opts.className = "dtool-opts"; + [["−","lensSmall",()=>{ t._d = Math.max(LMIN, t._d-40); lensSetup(); }], + ["+","lensBig", ()=>{ t._d = Math.min(LMAX, t._d+40); lensSetup(); }], + ["▢","lensShape",()=>{ t._sq = !t._sq; lensSetup(); }]].forEach(([txt,key,fn])=>{ + const b = document.createElement("button"); + b.textContent = txt; b.title = T(key); + b.addEventListener("click", fn); + opts.appendChild(b); + }); + t.appendChild(opts); + lensSetup(); + t._iv = setInterval(()=>t.lensDraw(), 350); + } + const x = document.createElement("button"); + x.className = "dtool-x"; x.textContent = "✕"; + x.addEventListener("click", ()=>{ if(t._iv) clearInterval(t._iv); t.remove(); delete DTOOLS[kind]; refreshPenbar(); }); + t.appendChild(x); + if(kind==="ruler" || kind==="geo"){ + const rot = document.createElement("button"); + rot.className = "dtool-rot"; rot.textContent = "↻"; + t.appendChild(rot); + } + makeMovable(t); + board.appendChild(t); + DTOOLS[kind] = t; + if(t.lensDraw) t.lensDraw(); + refreshPenbar(); +} + +/* board text tool */ +function startBoardText(e){ + if(board.querySelector(".wp-text-input")) return; + const zf = VZ(); + const r = board.getBoundingClientRect(); + const x = (e.clientX - r.left)/zf, y = (e.clientY - r.top)/zf; + const fontPx = PEN.size*6 + 12; + const inp = document.createElement("input"); + inp.type = "text"; inp.className = "wp-text-input"; + inp.style.left = Math.min(x, board.clientWidth-150)+"px"; + inp.style.top = Math.min(y, board.clientHeight-40)+"px"; + inp.style.color = PEN.color; + inp.style.fontSize = fontPx+"px"; + inp.style.zIndex = 4700; + board.appendChild(inp); + setTimeout(()=>inp.focus(), 0); + let done = false; + const commit = ()=>{ + if(done) return; done = true; + const v = inp.value.trim(); + if(v){ + bCtx.globalCompositeOperation = "source-over"; + bCtx.globalAlpha = 1; + bCtx.fillStyle = PEN.color; + bCtx.font = `700 ${fontPx*bDpr}px "Segoe UI Rounded","Segoe UI",Verdana,sans-serif`; + bCtx.textBaseline = "top"; + bCtx.fillText(v, x*bDpr, y*bDpr); + if(DTOOLS.lens) DTOOLS.lens.lensDraw(); + } + 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); +} + +let bDraw = false, bx = 0, by = 0; +const bPos = ev=>{ + const r = bInk.getBoundingClientRect(); + /* ratio-based: correct under any display zoom */ + return [(ev.clientX-r.left)*(bInk.width/r.width), (ev.clientY-r.top)*(bInk.height/r.height)]; +}; +bInk.addEventListener("pointerdown", e=>{ + if(e.pointerType==="mouse" && e.button!==0) return; + e.preventDefault(); + if(PEN.tool==="text"){ startBoardText(e); return; } + bInk.setPointerCapture(e.pointerId); + bDraw = true; + PEN.nib = PEN.size*0.8; + [bx,by] = bPos(e); + bStroke(bx+0.01, by+0.01, e.pressure, e.pointerType); +}); +bInk.addEventListener("pointermove", e=>{ + if(!bDraw) return; + (e.getCoalescedEvents ? e.getCoalescedEvents() : [e]).forEach(ev=>{ + const [x,y] = bPos(ev); bStroke(x, y, ev.pressure, ev.pointerType); + }); +}); +const bStop = ()=>{ + bDraw = false; + bCtx.globalAlpha = 1; + if(DTOOLS.lens) DTOOLS.lens.lensDraw(); +}; +bInk.addEventListener("pointerup", bStop); +bInk.addEventListener("pointercancel", bStop); +function bStroke(x,y,pressure,ptype){ + bCtx.globalCompositeOperation = PEN.tool==="erase" ? "destination-out" : "source-over"; + bCtx.globalAlpha = PEN.tool==="mark" ? 0.32 : 1; + bCtx.strokeStyle = PEN.color; + let w = PEN.size; + if(PEN.tool==="erase") w = PEN.size*6; + else if(PEN.tool==="mark") w = PEN.size*3.5; + else if(PEN.tool==="vulp"){ + let target; + if(ptype==="pen" && pressure > 0){ + target = PEN.size*(0.45 + pressure*1.9); + }else{ + const speed = Math.hypot(x-bx, y-by)/bDpr; + target = Math.max(PEN.size*0.5, Math.min(PEN.size*2.3, PEN.size*2.3 - speed*0.12)); + } + PEN.nib += (target - PEN.nib)*0.3; + w = PEN.nib; + } + bCtx.lineWidth = w*bDpr; + bCtx.beginPath(); bCtx.moveTo(bx,by); bCtx.lineTo(x,y); bCtx.stroke(); + bx = x; by = y; +} +function boardInkURL(){ try{ return bInk.toDataURL("image/png"); }catch(e){ return null; } } +function setBoardInk(url){ + bCtx.clearRect(0,0,bInk.width,bInk.height); + if(!url) return; + const im = new Image(); + im.onload = ()=>bCtx.drawImage(im,0,0); + im.src = url; +} +refreshPenbar(); + +/* gallery */ +const galleryWrap = document.getElementById("galleryWrap"); +const gallery = document.getElementById("gallery"); +function renderGallery(){ + gallery.innerHTML = ""; + WIDGET_CATS.forEach(([cat,key])=>{ + const defs = REGISTRY.filter(d=>d.cat===cat); + if(!defs.length) return; + const h = document.createElement("div"); + h.className = "gcat-title"; + h.textContent = T(key); + gallery.appendChild(h); + const grid = document.createElement("div"); + grid.className = "gallery"; + defs.forEach(def=>{ + const c = document.createElement("div"); + c.className = "gcard"; + c.innerHTML = `
${def.icon}
${T("wg_"+def.id)}
${T("wg_"+def.id+"_d")}
`; + c.addEventListener("click", ()=>{ galleryWrap.classList.remove("open"); makeWidget(def); }); + grid.appendChild(c); + }); + gallery.appendChild(grid); + }); +} +galleryWrap.addEventListener("click", e=>{ if(e.target===galleryWrap) galleryWrap.classList.remove("open"); }); + +/* ---------- floating add-button (FAB) ---------- */ +const fab = document.getElementById("fab"); +const fabMenu = document.getElementById("fabmenu"); +function refreshFab(){ + fabMenu.querySelector('[data-f="widgets"] .fl').textContent = T("fabWidgets"); + fabMenu.querySelector('[data-f="draw"] .fl').textContent = T("fabDraw"); + fabMenu.querySelector('[data-f="mind"] .fl').textContent = T("fabMind"); + fabMenu.querySelector('[data-f="img"] .fl').textContent = T("fabImg"); + fabMenu.querySelector('[data-f="draw"]').classList.toggle("on", document.body.classList.contains("pen")); +} +fab.addEventListener("click", ()=>{ + refreshFab(); + fabMenu.classList.toggle("open"); + const open = fabMenu.classList.contains("open"); + fab.classList.toggle("open", open); + if(open) document.getElementById("boardStrip").classList.remove("open"); +}); +document.addEventListener("pointerdown", e=>{ + if(!fabMenu.classList.contains("open")) return; + if(e.target.closest("#fab") || e.target.closest("#fabmenu")) return; + fabMenu.classList.remove("open"); + fab.classList.remove("open"); +}); +fabMenu.querySelector('[data-f="widgets"]').addEventListener("click", ()=>{ + fabMenu.classList.remove("open"); fab.classList.remove("open"); + renderGallery(); galleryWrap.classList.add("open"); +}); +fabMenu.querySelector('[data-f="draw"]').addEventListener("click", ()=>{ + fabMenu.classList.remove("open"); fab.classList.remove("open"); + setPen(!document.body.classList.contains("pen")); +}); +fabMenu.querySelector('[data-f="mind"]').addEventListener("click", ()=>{ + fabMenu.classList.remove("open"); fab.classList.remove("open"); + const def = REGISTRY.find(d=>d.id==="mind"); + makeWidget(def, { x:0, y:0, w:board.clientWidth, h:board.clientHeight, bare:true }); +}); +/* image upload onto the board */ +const imgInput = document.createElement("input"); +imgInput.type = "file"; imgInput.accept = "image/*"; imgInput.style.display = "none"; +document.body.appendChild(imgInput); +fabMenu.querySelector('[data-f="img"]').addEventListener("click", ()=>{ + fabMenu.classList.remove("open"); fab.classList.remove("open"); + imgInput.click(); +}); +function loadImgFile(f, x, y){ + const keepPng = /png|gif|svg/.test(f.type); + const img = new Image(); + img.onload = ()=>{ + const c = document.createElement("canvas"); + const s = Math.min(1, 900/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); + addBoardImage(keepPng ? c.toDataURL("image/png") : c.toDataURL("image/jpeg", 0.85), x, y, Math.min(360, c.width)); + URL.revokeObjectURL(img.src); + }; + img.src = URL.createObjectURL(f); +} +imgInput.addEventListener("change", ()=>{ + const f = imgInput.files[0]; if(!f) return; + loadImgFile(f, 80, 90); + imgInput.value = ""; +}); +/* drag & drop images from other websites or the computer onto the board */ +board.addEventListener("dragover", e=>{ e.preventDefault(); }); +board.addEventListener("drop", e=>{ + e.preventDefault(); + const zf = VZ(); + const r = board.getBoundingClientRect(); + const x = Math.max(4, (e.clientX - r.left)/zf - 100), y = Math.max(4, (e.clientY - r.top)/zf - 70); + const dt = e.dataTransfer; + if(dt.files && dt.files.length){ + [...dt.files].forEach((f,i)=>{ if(f.type.startsWith("image/")) loadImgFile(f, x+i*24, y+i*24); }); + return; + } + let url = null; + const html = dt.getData("text/html"); + if(html){ + const m = html.match(/]+src\s*=\s*["']([^"']+)["']/i); + if(m) url = m[1]; + } + if(!url){ + const uri = (dt.getData("text/uri-list") || dt.getData("text/plain") || "").trim().split("\n")[0]; + if(/^https?:\/\//i.test(uri) && (/\.(jpe?g|png|gif|webp|svg|avif)([?#]|$)/i.test(uri) || uri.includes("image"))) url = uri; + else if(/^data:image\//.test(uri)) url = uri; + else if(/^https?:\/\//i.test(uri)) url = uri; /* try anyway; broken links can be removed */ + } + if(url) addBoardImage(url, x, y, 320); +}); +function addBoardImage(src, x, y, w){ + const d = document.createElement("div"); + d.className = "bimg"; + d.style.left = x+"px"; d.style.top = y+"px"; d.style.width = w+"px"; + const im = document.createElement("img"); + im.src = src; im.draggable = false; + d.appendChild(im); + const xb = document.createElement("button"); + xb.className = "dtool-x"; xb.textContent = "✕"; + xb.addEventListener("click", ()=>d.remove()); + d.appendChild(xb); + const rs = document.createElement("div"); + rs.className = "bimg-rs"; + rs.addEventListener("pointerdown", e=>{ + e.preventDefault(); e.stopPropagation(); + const zf = VZ(); + const sw = d.offsetWidth, sx = e.clientX; + const mv = ev=>{ d.style.width = Math.max(80, sw + (ev.clientX - sx)/zf)+"px"; }; + const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; + document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); + }); + d.appendChild(rs); + d.addEventListener("pointerdown", e=>{ + if(e.target===xb || e.target===rs) return; + if(e.pointerType==="mouse" && e.button!==0) return; + e.preventDefault(); + const zf = VZ(); + const ox = e.clientX/zf - d.offsetLeft, oy = e.clientY/zf - d.offsetTop; + const mv = ev=>{ d.style.left = (ev.clientX/zf-ox)+"px"; d.style.top = (ev.clientY/zf-oy)+"px"; }; + const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; + document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); + }); + board.appendChild(d); + return d; +} +document.addEventListener("langchange", refreshFab); + +/* ========================================================= + Multiple boards, organised in folders (quick nav next to +) +==========================================================*/ +function freshBS(){ + return { folders:[{ name: LANG==="nl" ? "Map 1" : "Folder 1", + boards:[{ name: LANG==="nl" ? "Bord 1" : "Board 1", data:null }] }], + f:0, b:0 }; +} +let BS = freshBS(); +function boardsFromData(data){ + if(data && data.boards && data.boards.folders && data.boards.folders.length) return data.boards; + if(data && data.board){ + const bs = freshBS(); + bs.folders[0].boards[0].data = data.board; /* migrate the old single board */ + return bs; + } + return freshBS(); +} +function curSlot(){ + if(BS.f >= BS.folders.length) BS.f = 0; + const fo = BS.folders[BS.f]; + if(BS.b >= fo.boards.length) BS.b = 0; + return fo.boards[BS.b]; +} +function emptyBoardData(){ return { widgets:[], ink:null, bg:"blank", imgs:[] }; } +function stashCurrent(){ curSlot().data = serializeBoard(); } +function loadCurrentSlot(){ + restoreBoard(curSlot().data || emptyBoardData()); + updateBoardsUI(); +} +function switchTo(f, b){ + stashCurrent(); + BS.f = f; BS.b = b; + loadCurrentSlot(); +} +const boardsPanel = document.getElementById("boardsPanel"); +const btnFolders = document.getElementById("btnFolders"); +btnFolders.addEventListener("click", ()=>{ + renderBoardsPanel(); + boardsPanel.classList.toggle("open"); +}); +document.addEventListener("pointerdown", e=>{ + if(!boardsPanel.classList.contains("open")) return; + if(e.target.closest("#btnFolders") || e.target.closest("#boardsPanel")) return; + boardsPanel.classList.remove("open"); +}); +function updateBoardsUI(){ + btnFolders.title = `${T("boardsNav")} · ${BS.folders[BS.f].name} / ${curSlot().name}`; + fabBoardsBtn.title = `${BS.folders[BS.f].name} / ${curSlot().name}`; + if(boardsPanel.classList.contains("open")) renderBoardsPanel(); + if(boardStrip.classList.contains("open")) renderStrip(); +} +function flatBoards(){ + const out = []; + BS.folders.forEach((fo,fi)=>fo.boards.forEach((bo,bi)=>out.push({fi,bi,bo,fo}))); + return out; +} + +/* ---- quick switch: hover the 🗂 button → previews side by side ---- */ +const fabBoardsBtn = document.getElementById("fabBoardsBtn"); +const boardStrip = document.getElementById("boardStrip"); +let stripTimer = null; +function openStrip(){ clearTimeout(stripTimer); renderStrip(); boardStrip.classList.add("open"); } +function closeStrip(){ boardStrip.classList.remove("open"); } +fabBoardsBtn.addEventListener("click", ()=>{ + boardStrip.classList.contains("open") ? closeStrip() : openStrip(); +}); +fabBoardsBtn.addEventListener("mouseenter", openStrip); +fabBoardsBtn.addEventListener("mouseleave", ()=>{ stripTimer = setTimeout(closeStrip, 400); }); +boardStrip.addEventListener("mouseenter", ()=>clearTimeout(stripTimer)); +boardStrip.addEventListener("mouseleave", ()=>{ stripTimer = setTimeout(closeStrip, 400); }); +document.addEventListener("pointerdown", e=>{ + if(!boardStrip.classList.contains("open")) return; + if(e.target.closest("#boardStrip") || e.target.closest("#fabBoardsBtn")) return; + closeStrip(); +}); +boardStrip.addEventListener("wheel", e=>{ e.preventDefault(); boardStrip.scrollLeft += e.deltaY; }); + +/* mini preview of a board's contents */ +function boardThumb(data){ + const w = 168, h = 105; + const c = document.createElement("canvas"); + c.width = w; c.height = h; + const g = c.getContext("2d"); + g.fillStyle = "#fff"; g.fillRect(0, 0, w, h); + if(!data) return c; + const bw = Math.max(board.clientWidth, 1), bh = Math.max(board.clientHeight, 1); + (data.imgs||[]).forEach(im=>{ + g.fillStyle = "#d8e2ef"; + g.fillRect(im.x*w/bw, im.y*h/bh, Math.max(6, im.w*w/bw), Math.max(5, im.w*0.7*h/bh)); + }); + (data.widgets||[]).forEach(wd=>{ + const x = wd.x*w/bw, y = wd.y*h/bh, + ww = Math.max(12, wd.w*w/bw), wh = Math.max(9, wd.h*h/bh); + g.fillStyle = "rgba(59,125,216,.14)"; + g.fillRect(x, y, ww, wh); + g.strokeStyle = "rgba(59,125,216,.55)"; + g.strokeRect(x, y, ww, wh); + const def = REGISTRY.find(d=>d.id===wd.id); + if(def){ g.font = "11px sans-serif"; g.fillText(def.icon, x+2, y+12); } + }); + if(data.ink){ + const img = new Image(); + img.onload = ()=>{ try{ g.drawImage(img, 0, 0, w, h); }catch(e){} }; + img.src = data.ink; + } + return c; +} +function renderStrip(){ + boardStrip.innerHTML = ""; + const items = flatBoards(); + items.forEach(it=>{ + const isCur = it.fi===BS.f && it.bi===BS.b; + const card = document.createElement("div"); + card.className = "bs-card" + (isCur ? " active" : ""); + card.title = `${it.fo.name} / ${it.bo.name}`; + card.appendChild(boardThumb(isCur ? serializeBoard() : it.bo.data)); + const row = document.createElement("div"); + row.className = "bs-name"; + const nm = document.createElement("span"); + nm.textContent = it.bo.name; + row.appendChild(nm); + if(items.length > 1){ + const x = document.createElement("button"); + x.className = "bs-x"; x.textContent = "✕"; + x.title = T("delBoard"); + x.addEventListener("pointerdown", ev=>ev.stopPropagation()); + x.addEventListener("click", ev=>{ + ev.stopPropagation(); + if(!x.dataset.arm){ /* first click arms, second click deletes */ + clearTimeout(stripTimer); /* keep the strip open while confirming */ + x.dataset.arm = "1"; x.classList.add("arm"); x.textContent = "✓?"; + setTimeout(()=>{ x.dataset.arm=""; x.classList.remove("arm"); x.textContent="✕"; }, 3000); + return; + } + const fo = BS.folders[it.fi]; + fo.boards.splice(it.bi, 1); + if(!fo.boards.length) fo.boards.push({ name:T("boardName")+" 1", data:null }); + if(isCur){ BS.f = it.fi; BS.b = 0; loadCurrentSlot(); } + else if(it.fi===BS.f && it.bi < BS.b) BS.b--; + renderStrip(); + }); + row.appendChild(x); + } + card.appendChild(row); + card.addEventListener("click", ()=>{ + if(!isCur){ switchTo(it.fi, it.bi); renderStrip(); } + }); + boardStrip.appendChild(card); + }); + const add = document.createElement("div"); + add.className = "bs-card bs-new"; + add.textContent = "+"; + add.title = T("newBoard"); + add.addEventListener("click", ()=>{ + const fo = BS.folders[BS.f]; + fo.boards.push({ name:`${T("boardName")} ${fo.boards.length+1}`, data:null }); + switchTo(BS.f, fo.boards.length-1); + renderStrip(); + }); + boardStrip.appendChild(add); +} +function renderBoardsPanel(){ + boardsPanel.innerHTML = ""; + BS.folders.forEach((fo, fi)=>{ + const fr = document.createElement("div"); + fr.className = "bp-folder"; + const nm = document.createElement("span"); + nm.className = "bpf-name"; nm.textContent = "📁 " + fo.name; + fr.appendChild(nm); + const add = document.createElement("button"); + add.textContent = T("newBoard"); + add.addEventListener("click", ()=>{ + fo.boards.push({ name:`${T("boardName")} ${fo.boards.length+1}`, data:null }); + switchTo(fi, fo.boards.length-1); + renderBoardsPanel(); + }); + fr.appendChild(add); + if(BS.folders.length > 1){ + const del = document.createElement("button"); + del.textContent = "🗑"; + del.addEventListener("click", ()=>{ + if(fi === BS.f){ BS.folders.splice(fi,1); BS.f = 0; BS.b = 0; loadCurrentSlot(); } + else{ BS.folders.splice(fi,1); if(BS.f > fi) BS.f--; } + renderBoardsPanel(); + }); + fr.appendChild(del); + } + boardsPanel.appendChild(fr); + const row = document.createElement("div"); + row.className = "bp-boards"; + fo.boards.forEach((bo, bi)=>{ + const chip = document.createElement("div"); + chip.className = "bp-board" + (fi===BS.f && bi===BS.b ? " active" : ""); + const lbl = document.createElement("span"); + lbl.textContent = bo.name; + chip.appendChild(lbl); + chip.addEventListener("click", e=>{ + if(e.target.classList.contains("bpb-x") || e.target.tagName==="INPUT") return; + if(fi===BS.f && bi===BS.b) return; + switchTo(fi, bi); + renderBoardsPanel(); + }); + chip.addEventListener("dblclick", ()=>{ + if(chip.querySelector("input")) return; + const inp = document.createElement("input"); + inp.value = bo.name; inp.maxLength = 20; + chip.replaceChild(inp, lbl); + inp.focus(); inp.select(); + let done = false; + const commit = ()=>{ + if(done) return; done = true; + bo.name = inp.value.trim() || bo.name; + renderBoardsPanel(); updateBoardsUI(); + }; + inp.addEventListener("keydown", ev=>{ ev.stopPropagation(); if(ev.key==="Enter") commit(); }); + inp.addEventListener("blur", commit); + inp.addEventListener("pointerdown", ev=>ev.stopPropagation()); + }); + if(fo.boards.length > 1 || BS.folders.length > 1){ + const x = document.createElement("button"); + x.className = "bpb-x"; x.textContent = "✕"; + x.addEventListener("click", ev=>{ + ev.stopPropagation(); + const wasCur = (fi===BS.f && bi===BS.b); + fo.boards.splice(bi,1); + if(!fo.boards.length) fo.boards.push({ name:T("boardName")+" 1", data:null }); + if(wasCur){ BS.b = 0; loadCurrentSlot(); } + else if(fi===BS.f && bi < BS.b) BS.b--; + renderBoardsPanel(); + }); + chip.appendChild(x); + } + row.appendChild(chip); + }); + boardsPanel.appendChild(row); + }); + const nf = document.createElement("div"); + nf.className = "bp-new"; + const inp = document.createElement("input"); + inp.placeholder = T("folderPh"); inp.maxLength = 20; + const addFolder = ()=>{ + const name = inp.value.trim() || `${T("folderName")} ${BS.folders.length+1}`; + BS.folders.push({ name, boards:[{ name:T("boardName")+" 1", data:null }] }); + inp.value = ""; + renderBoardsPanel(); + }; + inp.addEventListener("keydown", e=>{ e.stopPropagation(); if(e.key==="Enter") addFolder(); }); + const nb = document.createElement("button"); + nb.className = "tbtn"; nb.textContent = T("newFolder"); + nb.addEventListener("click", addFolder); + nf.appendChild(inp); nf.appendChild(nb); + boardsPanel.appendChild(nf); + const hint = document.createElement("div"); + hint.className = "bp-hint"; hint.textContent = T("renameTip"); + boardsPanel.appendChild(hint); +} +document.addEventListener("langchange", updateBoardsUI); + diff --git a/public/js/core.js b/public/js/core.js new file mode 100644 index 0000000..1d3be71 --- /dev/null +++ b/public/js/core.js @@ -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 beginletter van het woord.", + instr2_mid:"Zoek de middelste letter van het woord.", + instr2_end:"Zoek de eindletter 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: ARASAAC (CC BY-NC-SA, Gobierno de Aragón). Ook leuk: sclera.be (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 beginning letter of the word.", + instr2_mid:"Find the middle letter of the word.", + instr2_end:"Find the ending letter 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: ARASAAC (CC BY-NC-SA, Gobierno de Aragón). Also nice: sclera.be (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)); }; + diff --git a/public/js/data.js b/public/js/data.js new file mode 100644 index 0000000..a3aa7d6 --- /dev/null +++ b/public/js/data.js @@ -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("✋👂👃👁️👄🦶😀😢😴🤧👶👦👧👵👴") +]; + diff --git a/public/js/widgets/anchor.js b/public/js/widgets/anchor.js new file mode 100644 index 0000000..7b6ce14 --- /dev/null +++ b/public/js/widgets/anchor.js @@ -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 = ` +
+
+
+
`; + 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 }) }; +} + diff --git a/public/js/widgets/ball.js b/public/js/widgets/ball.js new file mode 100644 index 0000000..8c4cf0e --- /dev/null +++ b/public/js/widgets/ball.js @@ -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 = ` +
+
+ + +
+
+
+
+
+
+ +
+
+
`; + 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}) }; +} + diff --git a/public/js/widgets/dice.js b/public/js/widgets/dice.js new file mode 100644 index 0000000..f563868 --- /dev/null +++ b/public/js/widgets/dice.js @@ -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 = ` +
+
+
+ +
+
+
+
+
+ +
`; + 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;id.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}) }; +} + diff --git a/public/js/widgets/flits.js b/public/js/widgets/flits.js new file mode 100644 index 0000000..9eb66a7 --- /dev/null +++ b/public/js/widgets/flits.js @@ -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 = ` +
+
+ + +
+
+
+
• • •
+
+
+
+ + + +
+
`; + 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}) }; +} + diff --git a/public/js/widgets/hak.js b/public/js/widgets/hak.js new file mode 100644 index 0000000..6b8e88a --- /dev/null +++ b/public/js/widgets/hak.js @@ -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 = ` +
+
+ +
+
+
+
+
+
+
+ + + + +
+
`; + 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}) }; +} + diff --git a/public/js/widgets/letters.js b/public/js/widgets/letters.js new file mode 100644 index 0000000..1f01130 --- /dev/null +++ b/public/js/widgets/letters.js @@ -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 = ` +
+
+ + +
+ +
+
+ +
+
+
+
+
+
+
🌟
+
+
+
+
+
+
+
+ + + + +
+
+ +
?
+ +
+
+
+ +
+
+
+
+
+
+ + +
+
+
+
`; + + 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 = `
${letter}
`; + 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 = `
${showGhost ? letter : ""}
`; + 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 = `
${T("noWords")}
`; + 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); + } +} + + diff --git a/public/js/widgets/light.js b/public/js/widgets/light.js new file mode 100644 index 0000000..e54310a --- /dev/null +++ b/public/js/widgets/light.js @@ -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 = ` +
+
+ + + +
+
`; + 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}) }; +} + diff --git a/public/js/widgets/math.js b/public/js/widgets/math.js new file mode 100644 index 0000000..db114b4 --- /dev/null +++ b/public/js/widgets/math.js @@ -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 = ` +
+
+ + +
+ +
+
+
+
+
+
+
+
+
+
🌟
+ +
+
+
+
+
`; + + 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 = `
${d}
`; + 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 = `
`; + 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(); } } + }; +} + diff --git a/public/js/widgets/media.js b/public/js/widgets/media.js new file mode 100644 index 0000000..2cde79b --- /dev/null +++ b/public/js/widgets/media.js @@ -0,0 +1,107 @@ +/* widget: Foto & video */ +/* ========================================================= + Media widget — photos and videos from external sites +==========================================================*/ +function mountMedia(root, initState){ + let cur = null; + root.innerHTML = ` +
+
+ + + +
+
+
`; + 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 || ""}) }; +} + diff --git a/public/js/widgets/mind.js b/public/js/widgets/mind.js new file mode 100644 index 0000000..bd94ac8 --- /dev/null +++ b/public/js/widgets/mind.js @@ -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 = ` +
+
+ +
+
+
+
`; + 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 += ``; + }else if(S.layout==="tree"){ + const my = (y1+y2)/2; + paths += ``; + }else{ + paths += ``; + } + }); + 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(); } } + }; +} + diff --git a/public/js/widgets/names.js b/public/js/widgets/names.js new file mode 100644 index 0000000..5652263 --- /dev/null +++ b/public/js/widgets/names.js @@ -0,0 +1,68 @@ +/* widget: Namenkiezer */ +/* ========================================================= + Name picker widget +==========================================================*/ +function mountNames(root, initState){ + let used = new Set(), spinning = false; + root.innerHTML = ` +
+
🎯
+ +
+ + +
+
`; + 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}) }; +} + diff --git a/public/js/widgets/notes.js b/public/js/widgets/notes.js new file mode 100644 index 0000000..4f1db0a --- /dev/null +++ b/public/js/widgets/notes.js @@ -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}) }; +} + diff --git a/public/js/widgets/timer.js b/public/js/widgets/timer.js new file mode 100644 index 0000000..78b7ad7 --- /dev/null +++ b/public/js/widgets/timer.js @@ -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 = ` +
+
+ +
+ + + +
+
+
+ + + + + + + +
05:00
+
+
+
+ + +
+
+
`; + 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}) }; +} + diff --git a/public/js/widgets/write.js b/public/js/widgets/write.js new file mode 100644 index 0000000..58b1016 --- /dev/null +++ b/public/js/widgets/write.js @@ -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 = ` +
+
+
+
+
+
+ +
+
+
`; + + 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 }; + } + }; +} +