Commit caab2435 authored by Kantz's avatar Kantz
Browse files

removed example

parent 2ae5fa25
import { Editor, Node, mergeAttributes } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import katex from "katex";
import "mathlive";
const editorElement = document.querySelector("#editor");
const dialog = document.querySelector("#mathDialog");
const mathField = document.querySelector("#mathField");
const status = document.querySelector("#status");
const jsonOutput = document.querySelector("#json");
const markdownOutput = document.querySelector("#markdown");
let saveMath = null;
function openMathEditor(latex, onSave) {
mathField.value = latex || "";
saveMath = onSave;
dialog.showModal();
requestAnimationFrame(() => mathField.focus());
}
dialog.addEventListener("close", () => {
if (dialog.returnValue === "save" && saveMath) saveMath(mathField.value);
saveMath = null;
});
function renderLatex(dom, latex) {
try {
katex.render(latex || "\\square", dom, { throwOnError: false });
} catch {
dom.textContent = latex || "\\square";
}
}
const MathNode = Node.create({
name: "math",
group: "inline",
inline: true,
atom: true,
selectable: true,
addAttributes() {
return {
latex: { default: "" },
display: { default: "inline" },
};
},
parseHTML() {
return [{ tag: "span[data-math]" }];
},
renderHTML({ HTMLAttributes }) {
return ["span", mergeAttributes(HTMLAttributes, { "data-math": "" })];
},
addCommands() {
return {
insertMath:
(latex = "") =>
({ commands }) =>
commands.insertContent({ type: this.name, attrs: { latex } }),
};
},
addNodeView() {
return ({ node, editor, getPos }) => {
const dom = document.createElement("span");
dom.className = "math-chip";
dom.tabIndex = 0;
dom.dataset.math = "";
const edit = () => {
openMathEditor(node.attrs.latex, (latex) => {
editor
.chain()
.focus()
.command(({ tr }) => {
tr.setNodeMarkup(getPos(), undefined, { ...node.attrs, latex });
return true;
})
.run();
});
};
dom.addEventListener("click", edit);
dom.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
edit();
});
renderLatex(dom, node.attrs.latex);
return {
dom,
update(updatedNode) {
if (updatedNode.type.name !== node.type.name) return false;
node = updatedNode;
renderLatex(dom, node.attrs.latex);
return true;
},
};
};
},
});
const editor = new Editor({
element: editorElement,
extensions: [StarterKit.configure({ heading: false }), MathNode],
content: "<p>Explain why <span data-math latex=\"\\frac{d}{dx}x^2 = 2x\"></span> is true.</p>",
editorProps: {
attributes: {
class: "tiptap",
},
handleKeyDown(view, event) {
if (event.key !== "$") return false;
event.preventDefault();
openMathEditor("", (latex) => editor.chain().focus().insertMath(latex).run());
return true;
},
},
onUpdate: updatePreview,
});
document.querySelector("#insertMath").addEventListener("click", () => {
openMathEditor("", (latex) => editor.chain().focus().insertMath(latex).run());
});
document.querySelector("#imageUpload").addEventListener("change", async (event) => {
const file = event.target.files[0];
if (!file) return;
status.textContent = "Reading image...";
try {
const form = new FormData();
form.append("image", file);
// ponytail: static demo expects the app backend to proxy Mathpix at this endpoint.
const response = await fetch("/api/mathpix", { method: "POST", body: form });
const result = await response.json();
if (!response.ok) throw new Error(result.error || `Mathpix failed: ${response.status}`);
const { latex } = result;
editor.chain().focus().insertMath(latex || "").run();
status.textContent = "Formula inserted";
} catch (error) {
status.textContent = error.message;
} finally {
event.target.value = "";
}
});
document.querySelector("#send").addEventListener("click", () => {
updatePreview();
status.textContent = "Serialized";
});
function toChatContent(doc) {
const content = [];
function walk(node) {
if (node.type === "text") content.push({ type: "text", text: node.text });
if (node.type === "math") {
content.push({
type: "math",
latex: node.attrs.latex,
display: node.attrs.display || "inline",
});
}
(node.content || []).forEach(walk);
}
walk(doc);
return content;
}
function toMarkdown(parts) {
return parts
.map((part) => (part.type === "math" ? `$${part.latex}$` : part.text))
.join("");
}
function updatePreview() {
const parts = toChatContent(editor.getJSON());
jsonOutput.textContent = JSON.stringify(parts, null, 2);
markdownOutput.textContent = toMarkdown(parts);
}
updatePreview();
const sample = toMarkdown([
{ type: "text", text: "x = " },
{ type: "math", latex: "1", display: "inline" },
]);
console.assert(sample === "x = $1$", "Markdown math serialization failed");
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tiptap MathLive Chat Input</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css"
/>
<link rel="stylesheet" href="./styles.css" />
<script type="importmap">
{
"imports": {
"@tiptap/core": "https://esm.sh/@tiptap/core@2.11.5",
"@tiptap/starter-kit": "https://esm.sh/@tiptap/starter-kit@2.11.5",
"katex": "https://esm.sh/katex@0.16.10",
"mathlive": "https://esm.sh/mathlive@0.101.1"
}
}
</script>
<script type="module" src="./app.js"></script>
</head>
<body>
<main class="app">
<section class="composer" aria-label="Chat composer">
<div class="toolbar">
<button id="insertMath" type="button" title="Insert formula">f(x)</button>
<label class="upload" title="Read handwriting with Mathpix">
<input id="imageUpload" type="file" accept="image/*" />
Image
</label>
<button id="send" type="button">Send</button>
<span id="status" class="status" role="status"></span>
</div>
<div id="editor" class="editor" aria-label="Message input"></div>
<output id="json" class="output" aria-label="Structured JSON"></output>
<output id="markdown" class="output" aria-label="Markdown"></output>
</section>
</main>
<dialog id="mathDialog" class="math-dialog">
<form method="dialog">
<math-field id="mathField"></math-field>
<div class="dialog-actions">
<button id="cancelMath" value="cancel" type="submit">Cancel</button>
<button id="saveMath" value="save" type="submit">Save</button>
</div>
</form>
</dialog>
</body>
</html>
const http = require("http");
const fs = require("fs");
const path = require("path");
const root = __dirname;
const types = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
};
function loadEnv(file = path.join(root, ".env")) {
if (!fs.existsSync(file)) return;
for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)\s*$/);
if (!match || process.env[match[1]]) continue;
process.env[match[1]] = match[2].replace(/^['"]|['"]$/g, "");
}
}
function readBody(req, maxBytes = 10 * 1024 * 1024) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
req.on("data", (chunk) => {
size += chunk.length;
if (size > maxBytes) {
req.destroy();
reject(new Error("Image is too large"));
return;
}
chunks.push(chunk);
});
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
function fileFromMultipart(contentType, body) {
const boundary = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/)?.slice(1).find(Boolean);
if (!boundary) throw new Error("Missing multipart boundary");
const raw = body.toString("binary");
const part = raw
.split(`--${boundary}`)
.find((chunk) => /name="image"/.test(chunk) && /filename=/.test(chunk));
if (!part) throw new Error("Missing image file");
const splitAt = part.indexOf("\r\n\r\n");
if (splitAt === -1) throw new Error("Invalid image upload");
const headers = part.slice(0, splitAt);
const data = part.slice(splitAt + 4).replace(/\r\n$/, "");
const mime = headers.match(/content-type:\s*([^\r\n]+)/i)?.[1] || "image/jpeg";
return { mime, buffer: Buffer.from(data, "binary") };
}
async function callMathpix(file) {
const response = await fetch("https://api.mathpix.com/v3/latex", {
method: "POST",
headers: {
"content-type": "application/json",
app_id: process.env.MATHPIX_APP_ID || "",
app_key: process.env.MATHPIX_APP_KEY || "",
},
body: JSON.stringify({
src: `data:${file.mime};base64,${file.buffer.toString("base64")}`,
formats: ["latex_normal"],
}),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || `Mathpix failed: ${response.status}`);
return result;
}
function sendJson(res, status, data) {
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data));
}
async function handleMathpix(req, res) {
if (!process.env.MATHPIX_APP_ID || !process.env.MATHPIX_APP_KEY) {
sendJson(res, 500, { error: "Missing MATHPIX_APP_ID or MATHPIX_APP_KEY" });
return;
}
try {
const file = fileFromMultipart(req.headers["content-type"] || "", await readBody(req));
const raw = await callMathpix(file);
sendJson(res, 200, { latex: raw.latex || raw.latex_normal || "", raw });
} catch (error) {
sendJson(res, 400, { error: error.message });
}
}
function serveStatic(req, res) {
const urlPath = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
const file = path.join(root, urlPath === "/" ? "index.html" : urlPath);
const relative = path.relative(root, file);
if (relative.startsWith("..") || path.isAbsolute(relative) || path.basename(file) === ".env") {
res.writeHead(404);
res.end("Not found");
return;
}
fs.readFile(file, (error, data) => {
if (error) {
res.writeHead(404);
res.end("Not found");
return;
}
res.writeHead(200, { "content-type": types[path.extname(file)] || "application/octet-stream" });
res.end(data);
});
}
function createServer() {
loadEnv();
return http.createServer((req, res) => {
if (req.method === "POST" && req.url === "/api/mathpix") {
handleMathpix(req, res);
return;
}
if (req.method === "GET" || req.method === "HEAD") {
serveStatic(req, res);
return;
}
res.writeHead(405);
res.end("Method not allowed");
});
}
function selfTest() {
const body = Buffer.from(
'--x\r\ncontent-disposition: form-data; name="image"; filename="a.png"\r\ncontent-type: image/png\r\n\r\nabc\r\n--x--\r\n',
"binary"
);
const file = fileFromMultipart("multipart/form-data; boundary=x", body);
console.assert(file.mime === "image/png", "multipart mime parse failed");
console.assert(file.buffer.toString() === "abc", "multipart body parse failed");
}
if (process.argv.includes("--self-test")) {
selfTest();
} else {
const port = Number(process.env.PORT || 5174);
createServer().listen(port, "127.0.0.1", () => {
console.log(`Listening on http://127.0.0.1:${port}`);
});
}
:root {
color-scheme: light;
font-family: Inter, "Segoe UI", Arial, sans-serif;
background: #f4f6f8;
color: #17202a;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
button,
.upload {
min-height: 40px;
border: 1px solid #c8d0d8;
border-radius: 8px;
padding: 0 14px;
background: #fff;
color: #17202a;
font: inherit;
font-weight: 650;
cursor: pointer;
}
button:hover,
.upload:hover {
border-color: #576b80;
}
.app {
display: grid;
min-height: 100vh;
align-items: center;
padding: 24px;
}
.composer {
width: min(860px, 100%);
margin: 0 auto;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
#send {
margin-left: auto;
border-color: #145c47;
background: #17785b;
color: #fff;
}
.upload {
display: inline-flex;
align-items: center;
}
.upload input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.status {
min-width: 140px;
color: #526170;
font-size: 0.9rem;
}
.editor {
min-height: 132px;
border: 1px solid #b9c3cd;
border-radius: 8px;
background: #fff;
}
.tiptap {
min-height: 132px;
padding: 16px;
font-size: 1.08rem;
line-height: 1.65;
outline: none;
}
.tiptap p {
margin: 0;
}
.math-chip {
display: inline-block;
min-width: 1.4em;
margin: 0 0.12em;
padding: 1px 5px;
border: 1px solid #b9d4cc;
border-radius: 6px;
background: #edf8f4;
vertical-align: baseline;
cursor: text;
}
.math-chip:focus {
outline: 3px solid rgba(23, 120, 91, 0.22);
outline-offset: 2px;
}
.output {
display: block;
width: 100%;
margin-top: 12px;
padding: 12px;
border: 1px solid #d8dee4;
border-radius: 8px;
background: #101820;
color: #d8f3dc;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: 0.86rem/1.45 Consolas, "Liberation Mono", monospace;
}
.math-dialog {
width: min(560px, calc(100vw - 32px));
border: 1px solid #b9c3cd;
border-radius: 8px;
padding: 16px;
}
.math-dialog::backdrop {
background: rgba(9, 20, 31, 0.44);
}
math-field {
width: 100%;
min-height: 72px;
padding: 10px;
border: 1px solid #b9c3cd;
border-radius: 8px;
font-size: 1.4rem;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
@media (max-width: 640px) {
.app {
padding: 14px;
}
.toolbar {
flex-wrap: wrap;
}
#send {
margin-left: 0;
}
.status {
flex-basis: 100%;
}
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment