3v324v23 commited on
Commit
79c65cf
·
1 Parent(s): af5d4de

Refactor code structure for improved readability and maintainability

Browse files
static/board-math.js ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Typed math on the sketch board (static/math-board.js).
2
+ The student types with MathLive (static/vendor/mathlive, MIT, loaded only when first needed) and its on-screen
3
+ math keyboard, set up here for ninth grade: digits, x and y, fractions, powers, roots, brackets, |x|, ± ≤ ≥ ≠, π,
4
+ degrees, angles and triangles, plus a letters tab. What is typed is kept as LaTeX and drawn on the board's
5
+ canvas by the small renderer below, so it moves, prints and is handed in with everything else. The renderer
6
+ knows the LaTeX that keyboard makes; anything else is drawn as its plain name. */
7
+ (()=>{
8
+ const VENDOR="/static/vendor/mathlive/";
9
+ const SERIF='"STIX Two Text","Cambria Math","Times New Roman",serif';
10
+
11
+ /* ---------- MathLive ---------- */
12
+ let loading=null;
13
+ function load(){
14
+ return loading||=new Promise((ok,no)=>{
15
+ if(window.MathfieldElement||window.MathLive) return ok();
16
+ const s=document.createElement("script"); s.src=VENDOR+"mathlive.min.js"; s.onload=ok; s.onerror=()=>{ loading=null; no(Error("mathlive")); };
17
+ document.head.append(s);
18
+ }).then(()=>{
19
+ const M=window.MathfieldElement||window.MathLive?.MathfieldElement;
20
+ M.fontsDirectory=VENDOR+"fonts"; M.soundsDirectory=null; M.keypressSound=null; M.plonkSound=null;
21
+ const k=window.mathVirtualKeyboard; if(k&&!k.__mathly){ k.__mathly=true; k.layouts=[NINTH,"alphabetic"]; k.editToolbar="none"; }
22
+ });
23
+ }
24
+ const key=(insert,label,tooltip,more={})=>({insert,label,tooltip,...more});
25
+ const NINTH={label:"123",tooltip:"מספרים ופעולות",rows:[
26
+ ["[7]","[8]","[9]",key("\\div","÷","חילוק"),key("\\frac{#@}{#?}","a⁄b","שבר"),"x","y",key("\\pi","π","פאי"),"[backspace]"],
27
+ ["[4]","[5]","[6]",key("\\times","×","כפל"),key("#@^2","x²","בריבוע"),key("#@^{#?}","xⁿ","חזקה"),"a","b",key("\\left(#0\\right)","( )","סוגריים")],
28
+ ["[1]","[2]","[3]","[-]",key("\\sqrt{#0}","√","שורש"),key("\\sqrt[#?]{#0}","ⁿ√","שורש n"),key("\\left|#0\\right|","|x|","ערך מוחלט"),"[left]","[right]"],
29
+ ["[0]","[.]","[=]","[+]",key("\\pm","±","פלוס מינוס"),key("\\le","≤","קטן או שווה"),key("\\ge","≥","גדול או שווה"),key("\\ne","≠","שונה"),"[return]"],
30
+ [key("^{\\circ}","°","מעלות"),key("\\angle","∠","זווית"),key("\\triangle","△","משולש"),"<",">",",",key("\\perp","⊥","מאונך"),key("\\parallel","∥","מקביל"),"[hide-keyboard]"]]};
31
+
32
+ /* ---------- reading LaTeX ---------- */
33
+ const SYM={cdot:["⋅","op"],times:["×","op"],div:["÷","op"],pm:["±","op"],mp:["∓","op"],le:["≤","rel"],leq:["≤","rel"],ge:["≥","rel"],geq:["≥","rel"],
34
+ ne:["≠","rel"],neq:["≠","rel"],approx:["≈","rel"],sim:["∼","rel"],cong:["≅","rel"],lt:["<","rel"],gt:[">","rel"],to:["→","rel"],
35
+ Rightarrow:["⇒","rel"],Leftrightarrow:["⇔","rel"],in:["∈","rel"],perp:["⊥","rel"],parallel:["∥","rel"],mid:["|","rel"],
36
+ pi:["π"],alpha:["α"],beta:["β"],gamma:["γ"],delta:["δ"],theta:["θ"],lambda:["λ"],mu:["μ"],sigma:["σ"],phi:["φ"],omega:["ω"],Delta:["Δ"],
37
+ angle:["∠"],measuredangle:["∡"],triangle:["△"],circ:["°"],degree:["°"],infty:["∞"],cdots:["⋯"],ldots:["…"],dots:["…"],prime:["′"],
38
+ vert:["|"],lvert:["|"],rvert:["|"],"%":["%"],"{":["{"],"}":["}"],lbrace:["{"],rbrace:["}"],colon:[":","punct"],",":[" ","space"],
39
+ ":":[" ","space"],";":[" ","space"]," ":[" ","space"],quad:[" ","space"],qquad:[" ","space"],"!":["","space"],
40
+ exponentialE:["e"],imaginaryI:["i"]};
41
+ const TEXT=new Set(["text","mathrm","textrm","operatorname","mbox","textit","mathit"]);
42
+ const WRAP=new Set(["mathbf","boldsymbol","mathsf","displaystyle","textstyle","mathnormal","bm"]);
43
+ const ANGLE_FREE=new Set(["(","[","|","{","−","+"]);
44
+
45
+ function tokens(s){
46
+ const out=[]; let i=0;
47
+ while(i<s.length){
48
+ const c=s[i];
49
+ if(c==="\\"){ let j=i+1; if(/[a-zA-Z]/.test(s[j]||"")) while(j<s.length&&/[a-zA-Z]/.test(s[j])) j++; else j++; out.push(s.slice(i,j)); i=j; }
50
+ else if(/\s/.test(c)) i++;
51
+ else{ out.push(c); i++; }
52
+ }
53
+ return out;
54
+ }
55
+ function parse(latex){
56
+ const t=tokens(String(latex||"")); let i=0;
57
+ const peek=()=>t[i], next=()=>t[i++];
58
+ function list(stop){
59
+ const items=[];
60
+ while(i<t.length&&!stop.includes(peek())){
61
+ const k=peek();
62
+ if(k==="^"||k==="_"){ next(); const base=items.pop()||{t:"row",items:[]}; const s=base.t==="scripts"?base:{t:"scripts",base};
63
+ s[k==="^"?"sup":"sub"]=arg(); items.push(s); continue; }
64
+ if(k==="'"){ next(); items.push({t:"sym",s:"′"}); continue; }
65
+ items.push(atom());
66
+ }
67
+ return items;
68
+ }
69
+ function group(close){ const items=list([close]); next(); return {t:"row",items}; }
70
+ function arg(){
71
+ if(peek()==="{"){ next(); return group("}"); }
72
+ return i<t.length?atom():{t:"row",items:[]};
73
+ }
74
+ function raw(){ // a text argument, as written
75
+ if(peek()!=="{") return next()||"";
76
+ next(); let depth=1, s="";
77
+ while(i<t.length){ const k=next(); if(k==="{") depth++; if(k==="}"&&!--depth) break;
78
+ s+=k.startsWith("\\")?(SYM[k.slice(1)]?.[0]??(k.length===2?k[1]:k.slice(1))):k; }
79
+ return s;
80
+ }
81
+ function delim(){ const k=next()||"."; if(k===".") return ""; if(k.startsWith("\\")) return SYM[k.slice(1)]?.[0]??k.slice(1); return k; }
82
+ function atom(){
83
+ const k=next();
84
+ if(k==="{") return group("}");
85
+ if(k==="}") return {t:"row",items:[]};
86
+ if(k.startsWith("\\")){
87
+ const c=k.slice(1);
88
+ if(c==="frac"||c==="dfrac"||c==="tfrac"||c==="cfrac") return {t:"frac",n:arg(),d:arg()};
89
+ if(c==="sqrt"){ let index=null; if(peek()==="["){ next(); index={t:"row",items:list(["]"])}; next(); } return {t:"sqrt",body:arg(),index}; }
90
+ if(c==="left"){ const l=delim(), body={t:"row",items:list(["\\right"])}; next(); return {t:"fence",l,r:delim(),body}; }
91
+ if(c==="right") return {t:"row",items:[]};
92
+ if(TEXT.has(c)) return {t:"text",s:raw(),italic:c==="textit"||c==="mathit"};
93
+ if(WRAP.has(c)) return peek()==="{"?arg():{t:"row",items:[]};
94
+ if(c==="overline"||c==="bar"||c==="overrightarrow"||c==="vec"||c==="widehat"||c==="hat") return {t:"over",body:arg(),arrow:c==="overrightarrow"||c==="vec"};
95
+ if(c==="placeholder"){ if(peek()==="["){ while(i<t.length&&next()!=="]"); } raw(); return {t:"ph"}; }
96
+ if(SYM[c]){ const [s,kind]=SYM[c]; return kind==="space"?{t:"space",w:s.length*.17}:{t:"sym",s,kind}; }
97
+ return {t:"text",s:c}; // unknown: its name, as text
98
+ }
99
+ if(/[a-zA-Z]/.test(k)) return {t:"sym",s:k,italic:true};
100
+ if(k==="-") return {t:"sym",s:"−",kind:"op"};
101
+ if(k==="+"||k==="*") return {t:"sym",s:k==="*"?"⋅":"+",kind:"op"};
102
+ if(k==="="||k==="<"||k===">") return {t:"sym",s:k,kind:"rel"};
103
+ if(k===","||k===";") return {t:"sym",s:k,kind:"punct"};
104
+ return {t:"sym",s:k};
105
+ }
106
+ return {t:"row",items:list([])};
107
+ }
108
+
109
+ /* ---------- laying it out: each box has a width, a height above the baseline (a) and below it (d) ---------- */
110
+ const meter=document.createElement("canvas").getContext("2d");
111
+ const fontOf=(size,italic)=>`${italic?"italic ":""}${size}px ${SERIF}`;
112
+ function width(s,size,italic){ meter.font=fontOf(size,italic); return meter.measureText(s).width; }
113
+
114
+ function box(node,z){ // z: the font size
115
+ switch(node.t){
116
+ case "row": return row(node.items,z);
117
+ case "sym":{ const w=width(node.s,z,node.italic);
118
+ return {w,a:.72*z,d:.22*z,kind:node.kind,paint:(c,x,y)=>{ c.font=fontOf(z,node.italic); c.fillText(node.s,x,y); }}; }
119
+ case "text":{ const w=width(node.s,z,node.italic);
120
+ return {w,a:.72*z,d:.22*z,paint:(c,x,y)=>{ c.font=fontOf(z,node.italic); c.fillText(node.s,x,y); }}; }
121
+ case "space": return {w:node.w*z,a:0,d:0,paint:()=>{}};
122
+ case "ph": return {w:.5*z,a:.6*z,d:.1*z,paint:(c,x,y)=>{ c.save(); c.globalAlpha=.35; c.lineWidth=1; c.strokeRect(x+.05*z,y-.6*z,.4*z,.7*z); c.restore(); }};
123
+ case "scripts": return scripts(node,z);
124
+ case "frac": return frac(node,z);
125
+ case "sqrt": return sqrt(node,z);
126
+ case "fence": return fence(node,z);
127
+ case "over":{ const b=box(node.body,z), lw=Math.max(1,.05*z);
128
+ return {w:b.w,a:b.a+.14*z,d:b.d,paint:(c,x,y)=>{ b.paint(c,x,y); c.lineWidth=lw; c.beginPath(); c.moveTo(x,y-b.a-.08*z); c.lineTo(x+b.w,y-b.a-.08*z); c.stroke();
129
+ if(node.arrow){ c.beginPath(); c.moveTo(x+b.w-.15*z,y-b.a-.15*z); c.lineTo(x+b.w,y-b.a-.08*z); c.lineTo(x+b.w-.15*z,y-b.a-.01*z); c.stroke(); } }}; }
130
+ }
131
+ return {w:0,a:0,d:0,paint:()=>{}};
132
+ }
133
+ function row(items,z){
134
+ const parts=[]; let prev=null;
135
+ for(const it of items){
136
+ const b=box(it,z);
137
+ // a little room around + − = and the like; a sign at the start, or after another sign or a bracket, is a unary one
138
+ const unary=b.kind==="op"&&(!prev||prev.kind==="op"||prev.kind==="rel"||prev.open);
139
+ const pad=b.kind==="rel"?.28*z:b.kind==="op"&&!unary?.2*z:b.kind==="punct"?0:0;
140
+ const after=b.kind==="punct"?.2*z:pad;
141
+ if(it.t==="frac"){ parts.push({b:{w:.08*z,a:0,d:0,paint:()=>{}}}); }
142
+ parts.push({b,pre:pad,post:after});
143
+ if(it.t==="frac") parts.push({b:{w:.08*z,a:0,d:0,paint:()=>{}}});
144
+ prev=it.t==="sym"&&ANGLE_FREE.has(it.s)&&b.kind!=="op"?{open:true}:b;
145
+ }
146
+ let w=0, a=.72*z*(items.length?0:1), d=.22*z*(items.length?0:1);
147
+ for(const p of parts){ w+=(p.pre||0)+p.b.w+(p.post||0); a=Math.max(a,p.b.a); d=Math.max(d,p.b.d); }
148
+ return {w,a,d,paint:(c,x,y)=>{ let at=x; for(const p of parts){ at+=p.pre||0; p.b.paint(c,at,y); at+=p.b.w+(p.post||0); } }};
149
+ }
150
+ function scripts(node,z){
151
+ const b=box(node.base,z), s=Math.max(9,.7*z);
152
+ const sup=node.sup?box(node.sup,s):null, sub=node.sub?box(node.sub,s):null;
153
+ const up=Math.max(.38*z,b.a-.38*z), down=Math.max(.2*z,b.d+.04*z-(sub?.a||0)*.2);
154
+ const gap=.04*z, w=b.w+gap+Math.max(sup?.w||0,sub?.w||0);
155
+ return {w,a:Math.max(b.a,sup?up+sup.a:0),d:Math.max(b.d,sub?down+sub.d:0),
156
+ paint:(c,x,y)=>{ b.paint(c,x,y); sup?.paint(c,x+b.w+gap,y-up); sub?.paint(c,x+b.w+gap,y+down); }};
157
+ }
158
+ function frac(node,z){
159
+ const s=Math.max(10,.85*z), n=box(node.n,s), d=box(node.d,s);
160
+ const axis=.28*z, gap=.12*z, lw=Math.max(1,.055*z), w=Math.max(n.w,d.w)+.24*z;
161
+ return {w,a:axis+gap+n.d+n.a,d:Math.max(0,d.a+d.d+gap-axis),
162
+ paint:(c,x,y)=>{ const bar=y-axis;
163
+ c.save(); c.lineWidth=lw; c.beginPath(); c.moveTo(x+.04*z,bar); c.lineTo(x+w-.04*z,bar); c.stroke(); c.restore();
164
+ n.paint(c,x+(w-n.w)/2,bar-gap-n.d); d.paint(c,x+(w-d.w)/2,bar+gap+d.a); }};
165
+ }
166
+ function sqrt(node,z){
167
+ const b=box(node.body,z), idx=node.index?.items?.length?box(node.index,Math.max(9,.55*z)):null;
168
+ const top=Math.max(b.a,.72*z)+.16*z, hook=.58*z, shift=idx?Math.max(0,idx.w-.28*z):0, lw=Math.max(1,.055*z);
169
+ return {w:shift+hook+b.w+.12*z,a:Math.max(top+.06*z,idx?.62*top+idx.a+idx.d:0),d:Math.max(b.d+.06*z,.22*z),
170
+ paint:(c,x,y)=>{ const x0=x+shift, bottom=y+Math.max(b.d,.05*z)+.04*z, t=y-top;
171
+ c.save(); c.lineWidth=lw; c.lineJoin="round"; c.beginPath();
172
+ c.moveTo(x0,y-.32*z); c.lineTo(x0+.12*z,y-.4*z); c.lineTo(x0+.3*z,bottom); c.lineTo(x0+hook,t); c.lineTo(x0+hook+b.w+.1*z,t); c.stroke(); c.restore();
173
+ b.paint(c,x0+hook+.04*z,y); idx?.paint(c,x0+.3*z-idx.w,y-.62*top); }};
174
+ }
175
+ function fence(node,z){
176
+ const b=box(node.body,z), tall=b.a+b.d>1.15*z;
177
+ const side=s=>{ if(!s) return {w:.05*z,paint:()=>{}};
178
+ const w=width(s,z,false);
179
+ return {w,paint:(c,x,y)=>{ c.save(); c.font=fontOf(z,false);
180
+ if(tall){ const h=b.a+b.d+.12*z, k=h/(.98*z); c.translate(x,y-b.a-.06*z); c.scale(1,k); c.fillText(s,0,.76*z); }
181
+ else c.fillText(s,x,y);
182
+ c.restore(); }}; };
183
+ const l=side(node.l), r=side(node.r), pad=.04*z;
184
+ return {w:l.w+pad+b.w+pad+r.w,a:Math.max(b.a+.08*z,.75*z),d:Math.max(b.d+.08*z,.25*z),
185
+ paint:(c,x,y)=>{ l.paint(c,x,y); b.paint(c,x+l.w+pad,y); r.paint(c,x+l.w+pad+b.w+pad,y); }};
186
+ }
187
+
188
+ const cache=new Map();
189
+ // the laid-out formula at this font size: w, a and d, and paint(context, x, baseline y) in the context's colors
190
+ function layout(latex,size){
191
+ const k=size+"|"+latex;
192
+ if(!cache.has(k)){ if(cache.size>500) cache.clear(); cache.set(k,box(parse(latex),size)); }
193
+ return cache.get(k);
194
+ }
195
+ // widths are measured with the math font: when it arrives, measure again
196
+ document.fonts?.addEventListener?.("loadingdone",()=>{ cache.clear(); window.MathlyBoardMath.onFonts?.(); });
197
+
198
+ window.MathlyBoardMath={load,layout,parse};
199
+ })();
static/index.html CHANGED
@@ -29,6 +29,7 @@
29
  <script src="/static/math-tex.js"></script>
30
  <script src="/static/math-sky.js"></script>
31
  <script src="/static/science.js"></script>
 
32
  <script src="/static/math-board.js"></script>
33
  <script src="/static/math.js"></script>
34
  </body>
 
29
  <script src="/static/math-tex.js"></script>
30
  <script src="/static/math-sky.js"></script>
31
  <script src="/static/science.js"></script>
32
+ <script src="/static/board-math.js"></script>
33
  <script src="/static/math-board.js"></script>
34
  <script src="/static/math.js"></script>
35
  </body>
static/math-board.js CHANGED
@@ -5,7 +5,8 @@
5
  as long as the page is open. Nothing is sent anywhere unless the student hands it in.
6
 
7
  Everything on the board is an element that can be moved or erased: a pen stroke (with a pen's pressure),
8
- a straight line, a circle (dragged from the center), and a copy of the question (its text and figures).
 
9
  Line ends and circle centers snap to the ends and centers already drawn, so a triangle closes neatly;
10
  lines that meet that way move together. The eraser removes the lines and circles it touches and cuts pen strokes;
11
  it leaves the question's copy alone (select it and delete it instead).
@@ -19,7 +20,8 @@ const setOn=on=>{ try{ localStorage.setItem(PREF,on?"on":"off"); }catch{} };
19
 
20
  const INKS=[["#1B2140","שחור"],["#2563D9","כחול"],["#D3455B","אדום"]];
21
  const ERASERS=[[16,"קטן"],[40,"בינוני"],[90,"גדול"]];
22
- const TOOLS=[["move","✋","הזזה"],["pen","✏️","עט"],["line","╱","קו ישר"],["circle","◯","עיגול"],["eraser","🧽","מחק"]];
 
23
  const PEN=2.6, SHAPE=2.4, SNAP=12, GRAB=9, STEP=2, HISTORY=100;
24
  const PAPER_INK="#1B2140", PAPER_FAINT="#C5CBDF", SELECT="#2563D9";
25
  const FONT='500 17px Rubik, system-ui, "Segoe UI", sans-serif', LINE_H=27, CARD_PAD=14;
@@ -29,6 +31,7 @@ const sketches=new Map(), images=new Map();
29
  let el=null, canvas=null, ctx=null, key="", base="", pages=0, page=0, layer=null, source=null, hand=null, onPage=null;
30
  let tool="pen", ink=INKS[0][0], eraser=ERASERS[1][0], sendLabel="";
31
  let draft=null, drag=null, rub=null, selection=[];
 
32
  let full=null; // the board open full screen: {saved: the context to go back to, done: called when it closes}
33
  const touches=new Map(); let pan=null, penSeen=false;
34
  const desk=()=>matchMedia("(min-width:1000px) and (any-pointer:fine)").matches; // the same test as math.css
@@ -80,7 +83,8 @@ function build(){
80
  canvas.addEventListener("pointermove",move);
81
  canvas.addEventListener("pointerup",up);
82
  canvas.addEventListener("pointercancel",up);
83
- canvas.addEventListener("contextmenu",e=>e.preventDefault()); // the pen's side button: no browser menu over the board
 
84
  canvas.addEventListener("wheel",e=>{ // endless paper: scrolls down as far as needed, never above the top
85
  if(e.ctrlKey) return; // the browser's zoom
86
  e.preventDefault(); const s=sketch(), dy=e.deltaMode===1?e.deltaY*24:e.deltaMode===2?e.deltaY*canvas.clientHeight:e.deltaY;
@@ -90,7 +94,7 @@ function build(){
90
  setTool(tool);
91
  }
92
  function pressed(sel,b){ el.querySelectorAll(sel).forEach(x=>x.setAttribute("aria-pressed",x===b)); }
93
- function reset(){ draft=drag=rub=null; select([]); }
94
  function setTool(t){
95
  tool=t; el.querySelectorAll("[data-tool]").forEach(x=>x.setAttribute("aria-pressed",x.dataset.tool===t));
96
  el.dataset.tool=t; if(t!=="move"&&selection.length){ select([]); paint(); }
@@ -99,7 +103,7 @@ function setTool(t){
99
  canvas.style.cursor=t==="eraser"?`url("data:image/svg+xml,${svg.replace(/</g,"%3C").replace(/>/g,"%3E")}") ${r+1} ${r+1}, cell`:t==="move"?"default":"";
100
  }
101
  function select(list){ selection=list; }
102
- function turn(p){ page=p; key=`${base}:${page}`; reset(); drawPages(); drawSend(); paint(); }
103
  function drawSend(){
104
  const b=el.querySelector(".mx-board-send"); b.hidden=!hand; el.querySelector(".mx-board-row").hidden=pages<2&&!hand; if(!hand) return;
105
  b.disabled=false; b.innerHTML=`<i aria-hidden="true">📤</i><span>${sendLabel||`הגשה לשאלה ${page+1}`}</span>`; b.title=sendLabel||`הגשת הלוח כתשובה לשאלה ${page+1}`;
@@ -134,6 +138,7 @@ function draw(m){
134
  if(m.kind==="line"){ ctx.lineWidth=SHAPE; ctx.beginPath(); ctx.moveTo(...m.a); ctx.lineTo(...m.b); ctx.stroke(); }
135
  else if(m.kind==="circle"){ ctx.lineWidth=SHAPE; ctx.beginPath(); ctx.arc(m.a[0],m.a[1],dist(m.a,m.b),0,Math.PI*2); ctx.stroke(); }
136
  else if(m.kind==="card") card(m);
 
137
  else{ for(let i=1;i<m.pts.length;i++) segment(m,i); if(m.pts.length===1) segment(m,0); }
138
  ctx.restore();
139
  }
@@ -160,8 +165,10 @@ function picture(src){
160
  if(!images.has(src)){ const img=new Image(); img.onload=()=>paint(); img.src=src; images.set(src,img); }
161
  return images.get(src);
162
  }
 
163
  function box(m){
164
  if(m.kind==="card") return [m.x,m.y,m.x+m.w,m.y+m.h];
 
165
  if(m.kind==="circle"){ const r=dist(m.a,m.b); return [m.a[0]-r,m.a[1]-r,m.a[0]+r,m.a[1]+r]; }
166
  const pts=m.kind==="line"?[m.a,m.b]:m.pts, xs=pts.map(p=>p[0]), ys=pts.map(p=>p[1]);
167
  return [Math.min(...xs),Math.min(...ys),Math.max(...xs),Math.max(...ys)];
@@ -182,7 +189,8 @@ function toSegment(p,a,b){
182
  function reach(m,p){
183
  if(m.kind==="line") return toSegment(p,m.a,m.b);
184
  if(m.kind==="circle") return Math.min(Math.abs(dist(p,m.a)-dist(m.a,m.b)),dist(p,m.a));
185
- if(m.kind==="card"){ const [x0,y0,x1,y1]=box(m); return p[0]>=x0&&p[0]<=x1&&p[1]>=y0&&p[1]<=y1?0:Infinity; }
 
186
  if(m.pts.length===1) return dist(p,m.pts[0]);
187
  let d=Infinity; for(let i=1;i<m.pts.length;i++) d=Math.min(d,toSegment(p,m.pts[i-1],m.pts[i])); return d;
188
  }
@@ -203,13 +211,13 @@ function snap(p,skip){
203
  }
204
  function shift(m,dx,dy){
205
  const by=p=>{ p[0]+=dx; p[1]+=dy; };
206
- if(m.kind==="card"){ m.x+=dx; m.y+=dy; } else if(m.kind==="free") m.pts.forEach(by); else { by(m.a); by(m.b); }
207
  }
208
- // the eraser at p: lines and circles it touches go; pen strokes are cut; the question's copy stays
209
  function rubOut(p){
210
  const r=eraser/2, all=marks(); let changed=false;
211
  for(let i=all.length-1;i>=0;i--){ const m=all[i];
212
- if(m.kind==="line"||m.kind==="circle"){ if(reach(m,p)<=r+SHAPE/2){ all.splice(i,1); changed=true; } continue; }
213
  if(m.kind!=="free") continue;
214
  const gone=m.pts.map(q=>dist(p,q)<=r+PEN); if(!gone.some(Boolean)) continue;
215
  const runs=[]; let run=[]; m.pts.forEach((q,k)=>{ if(gone[k]){ if(run.length) runs.push(run); run=[]; } else run.push(q); }); if(run.length) runs.push(run);
@@ -241,6 +249,8 @@ function down(e){
241
  }
242
  if(e.button!==0&&e.button!==5) return; // 5: the pen's eraser end
243
  const p=at(e), use=e.button===5||(e.buttons&32)>0?"eraser":tool;
 
 
244
  capture(e.pointerId); e.preventDefault();
245
  if(use==="move"){
246
  const m=hit(p);
@@ -356,6 +366,47 @@ function copyQuestion(){
356
  marks().push(m); setTool("move"); select([m]); paint(); if(pages>1) drawPages();
357
  }
358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  /* ---------- handing in ---------- */
360
  // the whole sketch as a picture on white paper: as wide as the board, as tall as what is drawn
361
  function snapshot(){
@@ -379,8 +430,9 @@ async function send(){
379
  }
380
 
381
  document.addEventListener("keydown",e=>{
 
382
  if(full&&e.key==="Escape"){ e.preventDefault(); e.stopImmediatePropagation(); return closeFull(); } // back to the question, not out of it
383
- if(!shown()||e.target.closest?.("input,textarea,[contenteditable]")) return;
384
  if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="z"&&!e.shiftKey){ e.preventDefault(); undo(); }
385
  else if((e.key==="Delete"||e.key==="Backspace")&&selection.length){ e.preventDefault(); removeSelection(); }
386
  });
@@ -429,7 +481,7 @@ function openFull({host,key:k,pages:n=1,page:p=0,question=null,submit=null,turne
429
  el.querySelector(".mx-board-back").focus({preventScroll:true});
430
  }
431
  function closeFull(){
432
- if(!full) return; const f=full; full=null;
433
  el.classList.remove("full"); el.removeAttribute("role"); el.removeAttribute("aria-modal"); el.remove();
434
  restore(f.saved); f.done?.();
435
  if(layer?.isConnected&&desk()) again();
 
5
  as long as the page is open. Nothing is sent anywhere unless the student hands it in.
6
 
7
  Everything on the board is an element that can be moved or erased: a pen stroke (with a pen's pressure),
8
+ a straight line, a circle (dragged from the center), typed math (the math keyboard, static/board-math.js), and a
9
+ copy of the question (its text and figures).
10
  Line ends and circle centers snap to the ends and centers already drawn, so a triangle closes neatly;
11
  lines that meet that way move together. The eraser removes the lines and circles it touches and cuts pen strokes;
12
  it leaves the question's copy alone (select it and delete it instead).
 
20
 
21
  const INKS=[["#1B2140","שחור"],["#2563D9","כחול"],["#D3455B","אדום"]];
22
  const ERASERS=[[16,"קטן"],[40,"בינוני"],[90,"גדול"]];
23
+ const TOOLS=[["move","✋","הזזה"],["pen","✏️","עט"],["line","╱","קו ישר"],["circle","◯","עיגול"],["math","∑","נוסחה"],["eraser","🧽","מחק"]];
24
+ const MATH_SIZE=26;
25
  const PEN=2.6, SHAPE=2.4, SNAP=12, GRAB=9, STEP=2, HISTORY=100;
26
  const PAPER_INK="#1B2140", PAPER_FAINT="#C5CBDF", SELECT="#2563D9";
27
  const FONT='500 17px Rubik, system-ui, "Segoe UI", sans-serif', LINE_H=27, CARD_PAD=14;
 
31
  let el=null, canvas=null, ctx=null, key="", base="", pages=0, page=0, layer=null, source=null, hand=null, onPage=null;
32
  let tool="pen", ink=INKS[0][0], eraser=ERASERS[1][0], sendLabel="";
33
  let draft=null, drag=null, rub=null, selection=[];
34
+ let editing=null; // the formula being typed: {mark (null for a new one), at, node, field, key}
35
  let full=null; // the board open full screen: {saved: the context to go back to, done: called when it closes}
36
  const touches=new Map(); let pan=null, penSeen=false;
37
  const desk=()=>matchMedia("(min-width:1000px) and (any-pointer:fine)").matches; // the same test as math.css
 
83
  canvas.addEventListener("pointermove",move);
84
  canvas.addEventListener("pointerup",up);
85
  canvas.addEventListener("pointercancel",up);
86
+ canvas.addEventListener("contextmenu",e=>e.preventDefault());
87
+ canvas.addEventListener("dblclick",e=>{ const m=hit(at(e)); if(m?.kind==="math") editMath(m,at(e)); }); // typed math is edited where it is // the pen's side button: no browser menu over the board
88
  canvas.addEventListener("wheel",e=>{ // endless paper: scrolls down as far as needed, never above the top
89
  if(e.ctrlKey) return; // the browser's zoom
90
  e.preventDefault(); const s=sketch(), dy=e.deltaMode===1?e.deltaY*24:e.deltaMode===2?e.deltaY*canvas.clientHeight:e.deltaY;
 
94
  setTool(tool);
95
  }
96
  function pressed(sel,b){ el.querySelectorAll(sel).forEach(x=>x.setAttribute("aria-pressed",x===b)); }
97
+ function reset(){ draft=drag=rub=null; select([]); if(editing) finishMath(false); }
98
  function setTool(t){
99
  tool=t; el.querySelectorAll("[data-tool]").forEach(x=>x.setAttribute("aria-pressed",x.dataset.tool===t));
100
  el.dataset.tool=t; if(t!=="move"&&selection.length){ select([]); paint(); }
 
103
  canvas.style.cursor=t==="eraser"?`url("data:image/svg+xml,${svg.replace(/</g,"%3C").replace(/>/g,"%3E")}") ${r+1} ${r+1}, cell`:t==="move"?"default":"";
104
  }
105
  function select(list){ selection=list; }
106
+ function turn(p){ if(editing) finishMath(true); page=p; key=`${base}:${page}`; reset(); drawPages(); drawSend(); paint(); }
107
  function drawSend(){
108
  const b=el.querySelector(".mx-board-send"); b.hidden=!hand; el.querySelector(".mx-board-row").hidden=pages<2&&!hand; if(!hand) return;
109
  b.disabled=false; b.innerHTML=`<i aria-hidden="true">📤</i><span>${sendLabel||`הגשה לשאלה ${page+1}`}</span>`; b.title=sendLabel||`הגשת הלוח כתשובה לשאלה ${page+1}`;
 
138
  if(m.kind==="line"){ ctx.lineWidth=SHAPE; ctx.beginPath(); ctx.moveTo(...m.a); ctx.lineTo(...m.b); ctx.stroke(); }
139
  else if(m.kind==="circle"){ ctx.lineWidth=SHAPE; ctx.beginPath(); ctx.arc(m.a[0],m.a[1],dist(m.a,m.b),0,Math.PI*2); ctx.stroke(); }
140
  else if(m.kind==="card") card(m);
141
+ else if(m.kind==="math"){ const L=mathBox(m); if(L){ ctx.fillStyle=m.color; ctx.direction="ltr"; ctx.textAlign="left"; ctx.textBaseline="alphabetic"; L.paint(ctx,m.x,m.y+L.a); } }
142
  else{ for(let i=1;i<m.pts.length;i++) segment(m,i); if(m.pts.length===1) segment(m,0); }
143
  ctx.restore();
144
  }
 
165
  if(!images.has(src)){ const img=new Image(); img.onload=()=>paint(); img.src=src; images.set(src,img); }
166
  return images.get(src);
167
  }
168
+ const mathBox=m=>window.MathlyBoardMath?.layout(m.latex,m.size||MATH_SIZE);
169
  function box(m){
170
  if(m.kind==="card") return [m.x,m.y,m.x+m.w,m.y+m.h];
171
+ if(m.kind==="math"){ const L=mathBox(m)||{w:0,a:0,d:0}; return [m.x,m.y,m.x+L.w,m.y+L.a+L.d]; }
172
  if(m.kind==="circle"){ const r=dist(m.a,m.b); return [m.a[0]-r,m.a[1]-r,m.a[0]+r,m.a[1]+r]; }
173
  const pts=m.kind==="line"?[m.a,m.b]:m.pts, xs=pts.map(p=>p[0]), ys=pts.map(p=>p[1]);
174
  return [Math.min(...xs),Math.min(...ys),Math.max(...xs),Math.max(...ys)];
 
189
  function reach(m,p){
190
  if(m.kind==="line") return toSegment(p,m.a,m.b);
191
  if(m.kind==="circle") return Math.min(Math.abs(dist(p,m.a)-dist(m.a,m.b)),dist(p,m.a));
192
+ if(m.kind==="card"||m.kind==="math"){ const [x0,y0,x1,y1]=box(m), dx=Math.max(x0-p[0],0,p[0]-x1), dy=Math.max(y0-p[1],0,p[1]-y1);
193
+ return m.kind==="card"?(dx||dy?Infinity:0):Math.hypot(dx,dy); }
194
  if(m.pts.length===1) return dist(p,m.pts[0]);
195
  let d=Infinity; for(let i=1;i<m.pts.length;i++) d=Math.min(d,toSegment(p,m.pts[i-1],m.pts[i])); return d;
196
  }
 
211
  }
212
  function shift(m,dx,dy){
213
  const by=p=>{ p[0]+=dx; p[1]+=dy; };
214
+ if(m.kind==="card"||m.kind==="math"){ m.x+=dx; m.y+=dy; } else if(m.kind==="free") m.pts.forEach(by); else { by(m.a); by(m.b); }
215
  }
216
+ // the eraser at p: lines, circles and typed math it touches go; pen strokes are cut; the question's copy stays
217
  function rubOut(p){
218
  const r=eraser/2, all=marks(); let changed=false;
219
  for(let i=all.length-1;i>=0;i--){ const m=all[i];
220
+ if(m.kind==="line"||m.kind==="circle"||m.kind==="math"){ if(reach(m,p)<=r+SHAPE/2){ all.splice(i,1); changed=true; } continue; }
221
  if(m.kind!=="free") continue;
222
  const gone=m.pts.map(q=>dist(p,q)<=r+PEN); if(!gone.some(Boolean)) continue;
223
  const runs=[]; let run=[]; m.pts.forEach((q,k)=>{ if(gone[k]){ if(run.length) runs.push(run); run=[]; } else run.push(q); }); if(run.length) runs.push(run);
 
249
  }
250
  if(e.button!==0&&e.button!==5) return; // 5: the pen's eraser end
251
  const p=at(e), use=e.button===5||(e.buttons&32)>0?"eraser":tool;
252
+ if(editing){ e.preventDefault(); return finishMath(true); } // a tap beside the formula being typed: it is done
253
+ if(use==="math"){ e.preventDefault(); const m=hit(p); return editMath(m?.kind==="math"?m:null,p); }
254
  capture(e.pointerId); e.preventDefault();
255
  if(use==="move"){
256
  const m=hit(p);
 
366
  marks().push(m); setTool("move"); select([m]); paint(); if(pages>1) drawPages();
367
  }
368
 
369
+ /* ---------- typed math ---------- */
370
+ // a bar over the board's top with the math field; the math keyboard opens with it
371
+ async function editMath(mark,p){
372
+ const M=window.MathlyBoardMath; if(!M||editing) return;
373
+ const node=document.createElement("div"); node.className="mx-board-mathedit"; node.dir="ltr";
374
+ node.innerHTML='<span class="mx-board-mathwait" dir="rtl">טוענים את מקלדת המתמטיקה…</span>';
375
+ el.querySelector(".mx-board-stage").append(node);
376
+ const e=editing={mark,at:p,node,field:null,key};
377
+ try{ await M.load(); }
378
+ catch{ node.querySelector("span").textContent="לא הצלחנו לטעון את מקלדת המתמטיקה. נסו שוב."; return setTimeout(()=>editing===e&&finishMath(false),2500); }
379
+ if(editing!==e) return;
380
+ node.innerHTML=`<math-field aria-label="נוסחה"></math-field>
381
+ <button type="button" data-math="ok" title="הוספה ללוח">✓</button><button type="button" data-math="cancel" title="ביטול">✕</button>`;
382
+ const f=e.field=node.querySelector("math-field");
383
+ f.mathVirtualKeyboardPolicy="manual";
384
+ f.value=mark?.latex||"";
385
+ f.addEventListener("change",()=>editing===e&&finishMath(true)); // the keyboard's return key, Enter, or leaving the field
386
+ f.addEventListener("focusin",()=>window.mathVirtualKeyboard?.show());
387
+ // the buttons keep the field's focus, so leaving it does not count as done before the tap is read
388
+ node.querySelectorAll("button").forEach(b=>b.addEventListener("pointerdown",ev=>{ ev.preventDefault(); finishMath(b.dataset.math==="ok"); }));
389
+ f.focus(); window.mathVirtualKeyboard?.show();
390
+ }
391
+ function finishMath(keep){
392
+ const e=editing; if(!e) return; editing=null;
393
+ const latex=keep&&e.field?String(e.field.getValue?.("latex")??e.field.value??"").trim():null;
394
+ try{ window.mathVirtualKeyboard?.hide(); }catch{}
395
+ e.node.remove();
396
+ if(latex===null||e.key!==key) return; // cancelled, or another page by now
397
+ const all=marks();
398
+ if(e.mark){
399
+ if(!all.includes(e.mark)||latex===e.mark.latex) return;
400
+ remember(); if(latex) e.mark.latex=latex; else all.splice(all.indexOf(e.mark),1); // emptied: gone
401
+ }else if(latex){
402
+ const L=window.MathlyBoardMath.layout(latex,MATH_SIZE), W=canvas.clientWidth;
403
+ remember();
404
+ all.push({kind:"math",latex,color:ink,size:MATH_SIZE,x:Math.max(8,Math.min(e.at[0],W-8-L.w)),y:Math.max(0,e.at[1]-(L.a+L.d)/2)});
405
+ }else return;
406
+ paint(); if(pages>1) drawPages();
407
+ }
408
+ if(window.MathlyBoardMath) window.MathlyBoardMath.onFonts=()=>el&&paint();
409
+
410
  /* ---------- handing in ---------- */
411
  // the whole sketch as a picture on white paper: as wide as the board, as tall as what is drawn
412
  function snapshot(){
 
430
  }
431
 
432
  document.addEventListener("keydown",e=>{
433
+ if(editing&&e.key==="Escape"){ e.preventDefault(); e.stopImmediatePropagation(); return finishMath(false); }
434
  if(full&&e.key==="Escape"){ e.preventDefault(); e.stopImmediatePropagation(); return closeFull(); } // back to the question, not out of it
435
+ if(!shown()||editing||e.target.closest?.("input,textarea,[contenteditable],math-field")) return;
436
  if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="z"&&!e.shiftKey){ e.preventDefault(); undo(); }
437
  else if((e.key==="Delete"||e.key==="Backspace")&&selection.length){ e.preventDefault(); removeSelection(); }
438
  });
 
481
  el.querySelector(".mx-board-back").focus({preventScroll:true});
482
  }
483
  function closeFull(){
484
+ if(!full) return; if(editing) finishMath(true); const f=full; full=null;
485
  el.classList.remove("full"); el.removeAttribute("role"); el.removeAttribute("aria-modal"); el.remove();
486
  restore(f.saved); f.done?.();
487
  if(layer?.isConnected&&desk()) again();
static/math.css CHANGED
@@ -675,7 +675,7 @@ button.sky-chip.flame{padding:6px 11px;color:var(--ink)}
675
  .mx-board-bar button:hover{color:var(--ink);background:var(--surface-2)}
676
  .mx-board-bar button[hidden]{display:none}
677
  .mx-board-bar button[aria-pressed="true"]{color:var(--ink);border-color:var(--mx-accent);background:var(--mx-accent-soft)}
678
- @media (max-width:1280px){.mx-board-bar button span{display:none}} /* a narrower board: icons only, names stay as tooltips */
679
  .mx-board-group{display:inline-flex;align-items:center;gap:3px}
680
  .mx-board-inks,.mx-board-sizes{padding-inline:6px;border-inline-start:1px solid var(--line)}
681
  .mx-board-inks button,.mx-board-sizes button{width:34px;padding:0;justify-content:center}
@@ -717,3 +717,13 @@ button.sky-chip.flame{padding:6px 11px;color:var(--ink)}
717
  .mx-board.full .mx-board-gap{flex:0}
718
  .mx-board.full .mx-board-bar [data-do="hide"]{display:none} /* the head's "back" does it */
719
  .mx-board.full .mx-board-bar button{min-height:40px;min-width:40px;justify-content:center}
 
 
 
 
 
 
 
 
 
 
 
675
  .mx-board-bar button:hover{color:var(--ink);background:var(--surface-2)}
676
  .mx-board-bar button[hidden]{display:none}
677
  .mx-board-bar button[aria-pressed="true"]{color:var(--ink);border-color:var(--mx-accent);background:var(--mx-accent-soft)}
678
+ @media (max-width:1600px){.mx-board-bar button span{display:none}} /* a narrower board: icons only, names stay as tooltips */
679
  .mx-board-group{display:inline-flex;align-items:center;gap:3px}
680
  .mx-board-inks,.mx-board-sizes{padding-inline:6px;border-inline-start:1px solid var(--line)}
681
  .mx-board-inks button,.mx-board-sizes button{width:34px;padding:0;justify-content:center}
 
717
  .mx-board.full .mx-board-gap{flex:0}
718
  .mx-board.full .mx-board-bar [data-do="hide"]{display:none} /* the head's "back" does it */
719
  .mx-board.full .mx-board-bar button{min-height:40px;min-width:40px;justify-content:center}
720
+
721
+ /* typing math on the board: a bar over the board's top; MathLive's keyboard sits above everything, the board too */
722
+ body{--keyboard-zindex:3000}
723
+ .mx-board-mathedit{position:absolute;z-index:2;top:8px;left:8px;right:8px;display:flex;align-items:center;gap:6px;padding:6px;
724
+ background:#fff;border:1.5px solid var(--mx-accent);border-radius:12px;box-shadow:0 4px 16px rgb(27 33 64/.18)}
725
+ .mx-board-mathedit math-field{flex:1;min-width:0;font-size:24px;border:0;background:none;color:#1B2140;--caret-color:#2563D9;--selection-background-color:#DCE6FF}
726
+ .mx-board-mathedit math-field::part(virtual-keyboard-toggle),.mx-board-mathedit math-field::part(menu-toggle){display:none}
727
+ .mx-board-mathedit button{flex:none;width:40px;height:40px;border-radius:10px;border:1px solid #C5CBDF;background:#F4F6FC;color:#1B2140;font:700 18px/1 var(--f-body);cursor:pointer}
728
+ .mx-board-mathedit button[data-math="ok"]{background:var(--mx-accent);border-color:var(--mx-accent);color:var(--mx-on-accent)}
729
+ .mx-board-mathwait{flex:1;color:#5B6694;font:500 14px/1.3 var(--f-body);padding:8px}
static/vendor/mathlive/LICENSE.txt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Copyright (c) 2017 - present Arno Gourdol. All rights reserved.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a
4
+ copy of this software and associated documentation files (the "Software"),
5
+ to deal in the Software without restriction, including without limitation
6
+ the rights to use, copy, modify, merge, publish, distribute, sublicense,
7
+ and/or sell copies of the Software, and to permit persons to whom the
8
+ Software is furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
18
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
19
+ DEALINGS IN THE SOFTWARE.
static/vendor/mathlive/fonts/KaTeX_AMS-Regular.woff2 ADDED
Binary file (28.1 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_Caligraphic-Bold.woff2 ADDED
Binary file (6.91 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_Caligraphic-Regular.woff2 ADDED
Binary file (6.91 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_Fraktur-Bold.woff2 ADDED
Binary file (11.3 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_Fraktur-Regular.woff2 ADDED
Binary file (11.3 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_Main-Bold.woff2 ADDED
Binary file (25.3 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_Main-BoldItalic.woff2 ADDED
Binary file (16.8 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_Main-Italic.woff2 ADDED
Binary file (17 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_Main-Regular.woff2 ADDED
Binary file (26.3 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_Math-BoldItalic.woff2 ADDED
Binary file (16.4 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_Math-Italic.woff2 ADDED
Binary file (16.4 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_SansSerif-Bold.woff2 ADDED
Binary file (12.2 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_SansSerif-Italic.woff2 ADDED
Binary file (12 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_SansSerif-Regular.woff2 ADDED
Binary file (10.3 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_Script-Regular.woff2 ADDED
Binary file (9.64 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_Size1-Regular.woff2 ADDED
Binary file (5.47 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_Size2-Regular.woff2 ADDED
Binary file (5.21 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_Size3-Regular.woff2 ADDED
Binary file (3.62 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_Size4-Regular.woff2 ADDED
Binary file (4.93 kB). View file
 
static/vendor/mathlive/fonts/KaTeX_Typewriter-Regular.woff2 ADDED
Binary file (13.6 kB). View file
 
static/vendor/mathlive/mathlive.min.js ADDED
The diff for this file is too large to render. See raw diff
 
tests/test_board_browser.py CHANGED
@@ -296,3 +296,60 @@ def test_the_wheel_scrolls_an_endless_paper(browser, server):
296
  assert alpha_near(page, 200, 300) > 0
297
  playwright.expect(page.locator(".mx-board-send")).to_be_hidden() # handing in is the unit quiz's
298
  assert errors == []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  assert alpha_near(page, 200, 300) > 0
297
  playwright.expect(page.locator(".mx-board-send")).to_be_hidden() # handing in is the unit quiz's
298
  assert errors == []
299
+
300
+
301
+ def type_math(page, at, latex):
302
+ """Taps the board with the math tool at `at`, types `latex` into the math field and adds it."""
303
+ box = page.locator(".mx-board canvas").bounding_box()
304
+ page.mouse.click(box["x"] + at[0], box["y"] + at[1])
305
+ field = page.locator(".mx-board-mathedit math-field")
306
+ playwright.expect(field).to_be_visible(timeout=10000)
307
+ page.evaluate("v=>{document.querySelector('.mx-board-mathedit math-field').value=v}", latex)
308
+ page.locator(".mx-board-mathedit [data-math=ok]").dispatch_event("pointerdown")
309
+ playwright.expect(page.locator(".mx-board-mathedit")).to_have_count(0)
310
+
311
+
312
+ def test_typed_math_is_an_element_like_any_other(browser, server):
313
+ page, errors = open_skill(browser, server, 1440, 900)
314
+ board_on(page)
315
+ page.locator(".mx-board [data-tool=math]").click()
316
+ box = page.locator(".mx-board canvas").bounding_box()
317
+ page.mouse.click(box["x"] + 100, box["y"] + 200)
318
+ playwright.expect(page.locator(".mx-board-mathedit math-field")).to_be_visible(timeout=10000)
319
+ assert page.evaluate("window.mathVirtualKeyboard.visible") # the math keyboard opens with it
320
+ page.keyboard.press("Escape") # cancels the formula, not the board
321
+ playwright.expect(page.locator(".mx-board-mathedit")).to_have_count(0)
322
+ playwright.expect(page.locator(".mx-board")).to_be_visible()
323
+ assert ink_pixels(page) == 0
324
+
325
+ type_math(page, (100, 200), r"\frac{x^2-9}{x+3}=x-3")
326
+ assert ink_pixels(page) > 100
327
+ assert alpha_near(page, 112, 200) > 0 or alpha_near(page, 130, 200) > 0
328
+
329
+ type_math(page, (100, 200), r"x^2") # tapping it with the math tool edits it, in place
330
+ assert ink_pixels(page) > 0
331
+ page.locator(".mx-board [data-do=undo]").click() # back to the fraction
332
+
333
+ page.locator(".mx-board [data-tool=move]").click()
334
+ drag(page, (120, 200), (120, 450))
335
+ assert alpha_near(page, 112, 200) + alpha_near(page, 130, 200) == 0
336
+ assert alpha_near(page, 112, 450) + alpha_near(page, 130, 450) > 0 # it went with the drag, whole
337
+
338
+ page.locator(".mx-board [data-tool=eraser]").click()
339
+ drag(page, (100, 440), (260, 460))
340
+ assert ink_pixels(page) == 0 # the eraser takes the whole formula
341
+ assert errors == []
342
+
343
+
344
+ def test_the_math_renderer_draws_what_the_keyboard_makes(browser, server):
345
+ page, errors = open_skill(browser, server, 1440, 900)
346
+ sizes = page.evaluate("""()=>[
347
+ 'x^2-5x+6=0', 'x_{1,2}=\\\\frac{-b\\\\pm\\\\sqrt{b^2-4ac}}{2a}', '\\\\sqrt[3]{8}', '\\\\left(\\\\frac{a}{b}\\\\right)^{3}',
348
+ '\\\\left|x-2\\\\right|\\\\le 5', '\\\\angle ABC=30^{\\\\circ}', '\\\\triangle ABC\\\\cong\\\\triangle DEF', 'a\\\\perp b\\\\parallel c',
349
+ '\\\\text{שטח}=\\\\pi r^2', '\\\\placeholder{}+1', '\\\\unknowncommand{x}', ''
350
+ ].map(l=>{ const L=window.MathlyBoardMath.layout(l,26); return [L.w,L.a,L.d]; })""")
351
+ assert all(w >= 0 and a >= 0 and d >= 0 for w, a, d in sizes)
352
+ assert all(w > 10 for w, _, _ in sizes[:-1])
353
+ frac, plain = sizes[1], sizes[0]
354
+ assert frac[0] > plain[0] and frac[1] > plain[1] # a fraction is wider and taller than a line of text
355
+ assert errors == []