Refactor code structure for improved readability and maintainability
Browse files- static/board-math.js +199 -0
- static/index.html +1 -0
- static/math-board.js +63 -11
- static/math.css +11 -1
- static/vendor/mathlive/LICENSE.txt +19 -0
- static/vendor/mathlive/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_Main-Bold.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_Main-Italic.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_Main-Regular.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_Math-Italic.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_Script-Regular.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- static/vendor/mathlive/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- static/vendor/mathlive/mathlive.min.js +0 -0
- tests/test_board_browser.py +57 -0
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),
|
|
|
|
| 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());
|
|
|
|
| 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)
|
|
|
|
| 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
|
| 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:
|
| 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 == []
|