251 lines
8.0 KiB
TypeScript
251 lines
8.0 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { X, Calculator, Maximize2, Minimize2 } from "lucide-react";
|
|
|
|
// ─── GeoGebra type shim ───────────────────────────────────────────────────────
|
|
declare global {
|
|
interface Window {
|
|
GGBApplet: new (
|
|
params: Record<string, unknown>,
|
|
defer?: boolean,
|
|
) => {
|
|
inject: (containerId: string) => void;
|
|
};
|
|
ggbApplet?: {
|
|
reset: () => void;
|
|
setXML: (xml: string) => void;
|
|
};
|
|
}
|
|
}
|
|
|
|
// ─── Hook: load GeoGebra script once ─────────────────────────────────────────
|
|
const GEOGEBRA_SCRIPT = "https://www.geogebra.org/apps/deployggb.js";
|
|
|
|
const useGeoGebraScript = () => {
|
|
const [ready, setReady] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (document.querySelector(`script[src="${GEOGEBRA_SCRIPT}"]`)) {
|
|
if (window.GGBApplet) setReady(true);
|
|
return;
|
|
}
|
|
const script = document.createElement("script");
|
|
script.src = GEOGEBRA_SCRIPT;
|
|
script.async = true;
|
|
script.onload = () => setReady(true);
|
|
document.head.appendChild(script);
|
|
}, []);
|
|
|
|
return ready;
|
|
};
|
|
|
|
// ─── GeoGebra Calculator ──────────────────────────────────────────────────────
|
|
const GeoGebraCalculator = ({ containerId }: { containerId: string }) => {
|
|
const scriptReady = useGeoGebraScript();
|
|
const injected = useRef(false);
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
|
|
|
|
// Measure the wrapper first — GeoGebra needs explicit px dimensions
|
|
useEffect(() => {
|
|
const el = wrapperRef.current;
|
|
if (!el) return;
|
|
const ro = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const { width, height } = entry.contentRect;
|
|
if (width > 0 && height > 0) {
|
|
setDims({ w: Math.floor(width), h: Math.floor(height) });
|
|
}
|
|
}
|
|
});
|
|
ro.observe(el);
|
|
return () => ro.disconnect();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!scriptReady || !dims || injected.current) return;
|
|
injected.current = true;
|
|
|
|
const params = {
|
|
appName: "graphing",
|
|
width: dims.w,
|
|
height: dims.h,
|
|
showToolBar: true,
|
|
showAlgebraInput: true,
|
|
showMenuBar: false,
|
|
enableLabelDrags: true,
|
|
enableShiftDragZoom: true,
|
|
enableRightClick: true,
|
|
showZoomButtons: true,
|
|
capturingThreshold: null,
|
|
showFullscreenButton: false,
|
|
|
|
scale: 1,
|
|
disableAutoScale: false,
|
|
allowUpscale: false,
|
|
clickToLoad: false,
|
|
appletOnLoad: () => {},
|
|
useBrowserForJS: false,
|
|
showLogging: false,
|
|
errorDialogsActive: true,
|
|
showTutorialLink: false,
|
|
showSuggestionButtons: false,
|
|
language: "en",
|
|
id: "ggbApplet",
|
|
};
|
|
|
|
try {
|
|
const applet = new window.GGBApplet(params, true);
|
|
applet.inject(containerId);
|
|
} catch (e) {
|
|
console.error("GeoGebra init error:", e);
|
|
}
|
|
}, [scriptReady, dims, containerId]);
|
|
|
|
return (
|
|
<div ref={wrapperRef} className="w-full h-full">
|
|
{!dims && (
|
|
<div className="w-full h-full flex items-center justify-center text-gray-400 text-sm font-satoshi gap-2">
|
|
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
/>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
|
/>
|
|
</svg>
|
|
Loading calculator...
|
|
</div>
|
|
)}
|
|
<div id={containerId} style={{ width: dims?.w, height: dims?.h }} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── Modal ────────────────────────────────────────────────────────────────────
|
|
interface GraphCalculatorModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const GraphCalculatorModal = ({ open, onClose }: GraphCalculatorModalProps) => {
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
const containerId = "geogebra-container";
|
|
|
|
// Trap focus & keyboard dismiss
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [open, onClose]);
|
|
|
|
// Prevent body scroll while open
|
|
useEffect(() => {
|
|
document.body.style.overflow = open ? "hidden" : "";
|
|
return () => {
|
|
document.body.style.overflow = "";
|
|
};
|
|
}, [open]);
|
|
|
|
if (!open) return null;
|
|
|
|
return createPortal(
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Graph Calculator"
|
|
>
|
|
{/* Backdrop */}
|
|
<div
|
|
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
|
onClick={onClose}
|
|
/>
|
|
|
|
{/* Panel */}
|
|
<div
|
|
className={`
|
|
relative z-10 flex flex-col bg-white rounded-2xl shadow-2xl overflow-hidden
|
|
transition-all duration-300
|
|
${
|
|
fullscreen
|
|
? "w-screen h-screen rounded-none"
|
|
: "w-[95vw] h-[90vh] max-w-5xl"
|
|
}
|
|
`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-white shrink-0">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="p-1.5 rounded-lg bg-purple-50 border border-purple-100">
|
|
<Calculator size={16} className="text-purple-500" />
|
|
</div>
|
|
<span className="font-satoshi-bold text-gray-800 text-sm">
|
|
Graph Calculator
|
|
</span>
|
|
<span className="text-[10px] font-satoshi text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
|
|
Powered by GeoGebra
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
<button
|
|
onClick={() => setFullscreen((f) => !f)}
|
|
className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition"
|
|
title={fullscreen ? "Exit fullscreen" : "Fullscreen"}
|
|
>
|
|
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 transition"
|
|
title="Close"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* GeoGebra canvas area */}
|
|
<div className="flex-1 overflow-hidden">
|
|
<GeoGebraCalculator containerId={containerId} />
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
};
|
|
|
|
// ─── Trigger button + modal — drop this wherever you need it ──────────────────
|
|
export const GraphCalculatorButton = () => {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-full font-satoshi-medium text-sm"
|
|
>
|
|
<Calculator size={16} />
|
|
Calculator
|
|
</button>
|
|
|
|
<GraphCalculatorModal open={open} onClose={() => setOpen(false)} />
|
|
</>
|
|
);
|
|
};
|
|
|
|
// ─── Standalone modal export if you need to control it externally ─────────────
|
|
export { GraphCalculatorModal };
|