@xom11/whiteboard 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ import { lazy } from 'react';
5
5
  import { jsxs, jsx } from 'react/jsx-runtime';
6
6
 
7
7
  var GeometryStampHost = lazy(
8
- () => import('./host-HKMZSCIT.mjs').then((m) => ({ default: m.GeometryStampHost }))
8
+ () => import('./host-3UFGFMJ2.mjs').then((m) => ({ default: m.GeometryStampHost }))
9
9
  );
10
10
  var GeometryIcon = /* @__PURE__ */ jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
11
11
  /* @__PURE__ */ jsx("polygon", { points: "4,20 20,20 12,5" }),
@@ -38,5 +38,5 @@ var geometryStamp = {
38
38
  };
39
39
 
40
40
  export { geometryStamp };
41
- //# sourceMappingURL=chunk-GEC2D2EQ.mjs.map
42
- //# sourceMappingURL=chunk-GEC2D2EQ.mjs.map
41
+ //# sourceMappingURL=chunk-XVVLT6B3.mjs.map
42
+ //# sourceMappingURL=chunk-XVVLT6B3.mjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/stamps/geometry-2d/index.tsx"],"names":[],"mappings":";;;;;AAgBA,IAAM,iBAAA,GAAoB,IAAA;AAAA,EAAK,MAC7B,OAAO,qBAAQ,CAAA,CAAE,IAAA,CAAK,CAAC,CAAA,MAAO,EAAE,OAAA,EAAS,CAAA,CAAE,iBAAA,EAAkB,CAAE;AACjE,CAAA;AAEA,IAAM,YAAA,wBACH,KAAA,EAAA,EAAI,KAAA,EAAM,MAAK,MAAA,EAAO,IAAA,EAAK,SAAQ,WAAA,EAAY,IAAA,EAAK,QAAO,MAAA,EAAO,cAAA,EAAe,aAAY,KAAA,EAAM,aAAA,EAAc,SAAQ,cAAA,EAAe,OAAA,EAAQ,eAAY,MAAA,EAC3J,QAAA,EAAA;AAAA,kBAAA,GAAA,CAAC,SAAA,EAAA,EAAQ,QAAO,iBAAA,EAAkB,CAAA;AAAA,kBAClC,GAAA,CAAC,QAAA,EAAA,EAAO,EAAA,EAAG,GAAA,EAAI,EAAA,EAAG,IAAA,EAAK,CAAA,EAAE,KAAA,EAAM,IAAA,EAAK,cAAA,EAAe,MAAA,EAAO,MAAA,EAAO,CAAA;AAAA,kBACjE,GAAA,CAAC,QAAA,EAAA,EAAO,EAAA,EAAG,IAAA,EAAK,EAAA,EAAG,IAAA,EAAK,CAAA,EAAE,KAAA,EAAM,IAAA,EAAK,cAAA,EAAe,MAAA,EAAO,MAAA,EAAO,CAAA;AAAA,kBAClE,GAAA,CAAC,QAAA,EAAA,EAAO,EAAA,EAAG,IAAA,EAAK,EAAA,EAAG,GAAA,EAAI,CAAA,EAAE,KAAA,EAAM,IAAA,EAAK,cAAA,EAAe,MAAA,EAAO,MAAA,EAAO;AAAA,CAAA,EACnE,CAAA;AAGK,IAAM,aAAA,GAA+C;AAAA,EAC1D,IAAA,EAAM,UAAA;AAAA,EACN,WAAA,EAAa,GAAA;AAAA,EACb,YAAA,EAAc,GAAA;AAAA,EACd,YAAA,EAAc,8BAAA;AAAA,EACd,WAAA,EAAa,YAAA;AAAA,EACb,aAAA,EAAe,wBAAA;AAAA,EACf,iBAAA,EAAmB,oBAAA;AAAA,EACnB,MAAM,wBAAwB,IAAA,EAAM;AAClC,IAAA,IAAI,CAAC,oBAAA,CAAqB,IAAI,CAAA,EAAG;AAC/B,MAAA,MAAM,IAAI,MAAM,+EAAuE,CAAA;AAAA,IACzF;AACA,IAAA,OAAO,0BAAA,CAA2B,KAAK,SAAS,CAAA;AAAA,EAClD,CAAA;AAAA,EACA,MAAM,0BAA0B,OAAA,EAA4C;AAC1E,IAAA,MAAM,OAAO,OAAA,CAAQ,UAAA;AACrB,IAAA,MAAM,SAAU,OAAA,CAAuC,MAAA;AACvD,IAAA,IAAI,CAAC,QAAQ,CAAC,MAAA,IAAU,CAAC,oBAAA,CAAqB,IAAI,GAAG,OAAO,IAAA;AAC5D,IAAA,MAAM,SAAA,GAAY,MAAM,0BAAA,CAA2B,IAAA,CAAK,SAAS,CAAA;AACjE,IAAA,OAAO,cAAA,CAAe,WAAW,MAAM,CAAA;AAAA,EACzC,CAAA;AAAA,EACA,IAAA,EAAM;AACR","file":"chunk-GEC2D2EQ.mjs","sourcesContent":["'use client';\n\nimport { lazy, type ReactNode } from 'react';\nimport { renderGeometrySvgFromState } from './render';\nimport type {\n RestoredStampFile,\n StampType,\n} from '../shared/types';\nimport { svgToStampFile } from '../shared/svgToStampFile';\nimport {\n isGeometryCustomData,\n type GeometryCustomData,\n} from './types';\n\nexport type { GeometryCustomData };\n\nconst GeometryStampHost = lazy(() =>\n import('./host').then((m) => ({ default: m.GeometryStampHost })),\n);\n\nconst GeometryIcon: ReactNode = (\n <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n <polygon points=\"4,20 20,20 12,5\" />\n <circle cx=\"4\" cy=\"20\" r=\"1.4\" fill=\"currentColor\" stroke=\"none\" />\n <circle cx=\"20\" cy=\"20\" r=\"1.4\" fill=\"currentColor\" stroke=\"none\" />\n <circle cx=\"12\" cy=\"5\" r=\"1.4\" fill=\"currentColor\" stroke=\"none\" />\n </svg>\n);\n\nexport const geometryStamp: StampType<GeometryCustomData> = {\n kind: 'geometry',\n shortcutKey: 'g',\n toolbarLabel: 'G',\n toolbarTitle: 'Chèn hình học (G)',\n toolbarIcon: GeometryIcon,\n toolbarTestId: 'stamp-toolbar-geometry',\n matchesCustomData: isGeometryCustomData,\n async renderSvgFromCustomData(data) {\n if (!isGeometryCustomData(data)) {\n throw new Error('geometryStamp.renderSvgFromCustomData: customData không phải geometry');\n }\n return renderGeometrySvgFromState(data.jsonState);\n },\n async restoreFileFromCustomData(element): Promise<RestoredStampFile | null> {\n const data = element.customData as GeometryCustomData | undefined;\n const fileId = (element as { fileId?: string | null }).fileId;\n if (!data || !fileId || !isGeometryCustomData(data)) return null;\n const svgString = await renderGeometrySvgFromState(data.jsonState);\n return svgToStampFile(svgString, fileId);\n },\n Host: GeometryStampHost,\n};\n"]}
1
+ {"version":3,"sources":["../src/stamps/geometry-2d/index.tsx"],"names":[],"mappings":";;;;;AAgBA,IAAM,iBAAA,GAAoB,IAAA;AAAA,EAAK,MAC7B,OAAO,qBAAQ,CAAA,CAAE,IAAA,CAAK,CAAC,CAAA,MAAO,EAAE,OAAA,EAAS,CAAA,CAAE,iBAAA,EAAkB,CAAE;AACjE,CAAA;AAEA,IAAM,YAAA,wBACH,KAAA,EAAA,EAAI,KAAA,EAAM,MAAK,MAAA,EAAO,IAAA,EAAK,SAAQ,WAAA,EAAY,IAAA,EAAK,QAAO,MAAA,EAAO,cAAA,EAAe,aAAY,KAAA,EAAM,aAAA,EAAc,SAAQ,cAAA,EAAe,OAAA,EAAQ,eAAY,MAAA,EAC3J,QAAA,EAAA;AAAA,kBAAA,GAAA,CAAC,SAAA,EAAA,EAAQ,QAAO,iBAAA,EAAkB,CAAA;AAAA,kBAClC,GAAA,CAAC,QAAA,EAAA,EAAO,EAAA,EAAG,GAAA,EAAI,EAAA,EAAG,IAAA,EAAK,CAAA,EAAE,KAAA,EAAM,IAAA,EAAK,cAAA,EAAe,MAAA,EAAO,MAAA,EAAO,CAAA;AAAA,kBACjE,GAAA,CAAC,QAAA,EAAA,EAAO,EAAA,EAAG,IAAA,EAAK,EAAA,EAAG,IAAA,EAAK,CAAA,EAAE,KAAA,EAAM,IAAA,EAAK,cAAA,EAAe,MAAA,EAAO,MAAA,EAAO,CAAA;AAAA,kBAClE,GAAA,CAAC,QAAA,EAAA,EAAO,EAAA,EAAG,IAAA,EAAK,EAAA,EAAG,GAAA,EAAI,CAAA,EAAE,KAAA,EAAM,IAAA,EAAK,cAAA,EAAe,MAAA,EAAO,MAAA,EAAO;AAAA,CAAA,EACnE,CAAA;AAGK,IAAM,aAAA,GAA+C;AAAA,EAC1D,IAAA,EAAM,UAAA;AAAA,EACN,WAAA,EAAa,GAAA;AAAA,EACb,YAAA,EAAc,GAAA;AAAA,EACd,YAAA,EAAc,8BAAA;AAAA,EACd,WAAA,EAAa,YAAA;AAAA,EACb,aAAA,EAAe,wBAAA;AAAA,EACf,iBAAA,EAAmB,oBAAA;AAAA,EACnB,MAAM,wBAAwB,IAAA,EAAM;AAClC,IAAA,IAAI,CAAC,oBAAA,CAAqB,IAAI,CAAA,EAAG;AAC/B,MAAA,MAAM,IAAI,MAAM,+EAAuE,CAAA;AAAA,IACzF;AACA,IAAA,OAAO,0BAAA,CAA2B,KAAK,SAAS,CAAA;AAAA,EAClD,CAAA;AAAA,EACA,MAAM,0BAA0B,OAAA,EAA4C;AAC1E,IAAA,MAAM,OAAO,OAAA,CAAQ,UAAA;AACrB,IAAA,MAAM,SAAU,OAAA,CAAuC,MAAA;AACvD,IAAA,IAAI,CAAC,QAAQ,CAAC,MAAA,IAAU,CAAC,oBAAA,CAAqB,IAAI,GAAG,OAAO,IAAA;AAC5D,IAAA,MAAM,SAAA,GAAY,MAAM,0BAAA,CAA2B,IAAA,CAAK,SAAS,CAAA;AACjE,IAAA,OAAO,cAAA,CAAe,WAAW,MAAM,CAAA;AAAA,EACzC,CAAA;AAAA,EACA,IAAA,EAAM;AACR","file":"chunk-XVVLT6B3.mjs","sourcesContent":["'use client';\n\nimport { lazy, type ReactNode } from 'react';\nimport { renderGeometrySvgFromState } from './render';\nimport type {\n RestoredStampFile,\n StampType,\n} from '../shared/types';\nimport { svgToStampFile } from '../shared/svgToStampFile';\nimport {\n isGeometryCustomData,\n type GeometryCustomData,\n} from './types';\n\nexport type { GeometryCustomData };\n\nconst GeometryStampHost = lazy(() =>\n import('./host').then((m) => ({ default: m.GeometryStampHost })),\n);\n\nconst GeometryIcon: ReactNode = (\n <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n <polygon points=\"4,20 20,20 12,5\" />\n <circle cx=\"4\" cy=\"20\" r=\"1.4\" fill=\"currentColor\" stroke=\"none\" />\n <circle cx=\"20\" cy=\"20\" r=\"1.4\" fill=\"currentColor\" stroke=\"none\" />\n <circle cx=\"12\" cy=\"5\" r=\"1.4\" fill=\"currentColor\" stroke=\"none\" />\n </svg>\n);\n\nexport const geometryStamp: StampType<GeometryCustomData> = {\n kind: 'geometry',\n shortcutKey: 'g',\n toolbarLabel: 'G',\n toolbarTitle: 'Chèn hình học (G)',\n toolbarIcon: GeometryIcon,\n toolbarTestId: 'stamp-toolbar-geometry',\n matchesCustomData: isGeometryCustomData,\n async renderSvgFromCustomData(data) {\n if (!isGeometryCustomData(data)) {\n throw new Error('geometryStamp.renderSvgFromCustomData: customData không phải geometry');\n }\n return renderGeometrySvgFromState(data.jsonState);\n },\n async restoreFileFromCustomData(element): Promise<RestoredStampFile | null> {\n const data = element.customData as GeometryCustomData | undefined;\n const fileId = (element as { fileId?: string | null }).fileId;\n if (!data || !fileId || !isGeometryCustomData(data)) return null;\n const svgString = await renderGeometrySvgFromState(data.jsonState);\n return svgToStampFile(svgString, fileId);\n },\n Host: GeometryStampHost,\n};\n"]}
@@ -9328,6 +9328,200 @@ var init_useAiFigure = __esm({
9328
9328
  "use client";
9329
9329
  }
9330
9330
  });
9331
+
9332
+ // src/stamps/geometry-2d/ai/vision/tesseract.ts
9333
+ async function runTesseractOcr(image, opts = {}) {
9334
+ if (opts.signal?.aborted) {
9335
+ const err = new Error("Tesseract OCR aborted");
9336
+ err.name = "AbortError";
9337
+ throw err;
9338
+ }
9339
+ const { createWorker } = await import('tesseract.js');
9340
+ const lang = opts.lang ?? DEFAULT_LANG;
9341
+ const worker = await createWorker(lang);
9342
+ try {
9343
+ const dataUrl = `data:${image.mediaType};base64,${image.base64}`;
9344
+ const { data } = await worker.recognize(dataUrl);
9345
+ return { text: data.text, confidence: data.confidence };
9346
+ } finally {
9347
+ await worker.terminate();
9348
+ }
9349
+ }
9350
+ var DEFAULT_LANG;
9351
+ var init_tesseract = __esm({
9352
+ "src/stamps/geometry-2d/ai/vision/tesseract.ts"() {
9353
+ DEFAULT_LANG = "vie+eng";
9354
+ }
9355
+ });
9356
+
9357
+ // src/stamps/geometry-2d/ai/vision/extractProblem.ts
9358
+ async function extractProblemFromImage(image, opts = {}) {
9359
+ let raw;
9360
+ try {
9361
+ raw = await runTesseractOcr(image, {
9362
+ ...opts.tesseractLang ? { lang: opts.tesseractLang } : {},
9363
+ ...opts.signal ? { signal: opts.signal } : {}
9364
+ });
9365
+ } catch (e) {
9366
+ const err = e;
9367
+ return {
9368
+ ok: false,
9369
+ reason: "unreadable",
9370
+ message: "Tesseract OCR fail: " + (err.message ?? "?")
9371
+ };
9372
+ }
9373
+ const text = postProcess(raw.text);
9374
+ if (text.length === 0) {
9375
+ return { ok: false, reason: "empty", message: "Tesseract kh\xF4ng tr\xEDch \u0111\u01B0\u1EE3c text." };
9376
+ }
9377
+ const tooShort = text.length < MIN_HIGH_CONFIDENCE_CHARS;
9378
+ const lowConf = raw.confidence < TESSERACT_HIGH_CONFIDENCE_THRESHOLD;
9379
+ const confidence = tooShort || lowConf ? "low" : "high";
9380
+ return {
9381
+ ok: true,
9382
+ text,
9383
+ confidence,
9384
+ usage: { inputTokens: 0, outputTokens: 0 }
9385
+ };
9386
+ }
9387
+ function postProcess(raw) {
9388
+ let t = raw.trim();
9389
+ t = t.replace(/\*\*(.+?)\*\*/g, "$1");
9390
+ t = t.replace(/\*(.+?)\*/g, "$1");
9391
+ t = t.replace(/_(.+?)_/g, "$1");
9392
+ t = t.replace(/```[\s\S]*?```/g, "").replace(/`([^`]+)`/g, "$1");
9393
+ t = t.replace(/\s+/g, " ").trim();
9394
+ t = t.normalize("NFC");
9395
+ if (t.length > MAX_TEXT_CHARS) t = t.slice(0, MAX_TEXT_CHARS);
9396
+ return t;
9397
+ }
9398
+ var MIN_HIGH_CONFIDENCE_CHARS, MAX_TEXT_CHARS, TESSERACT_HIGH_CONFIDENCE_THRESHOLD;
9399
+ var init_extractProblem = __esm({
9400
+ "src/stamps/geometry-2d/ai/vision/extractProblem.ts"() {
9401
+ init_tesseract();
9402
+ MIN_HIGH_CONFIDENCE_CHARS = 10;
9403
+ MAX_TEXT_CHARS = 2e3;
9404
+ TESSERACT_HIGH_CONFIDENCE_THRESHOLD = 70;
9405
+ }
9406
+ });
9407
+
9408
+ // src/stamps/geometry-2d/ai/handleExtractProblem.ts
9409
+ async function handleExtractProblem(image, opts = {}) {
9410
+ try {
9411
+ const r = await extractProblemFromImage(image, opts);
9412
+ if (r.ok) {
9413
+ if (r.confidence === "low") {
9414
+ return {
9415
+ kind: "low-confidence",
9416
+ text: r.text,
9417
+ warning: "OCR c\xF3 th\u1EC3 kh\xF4ng ch\xEDnh x\xE1c, ki\u1EC3m tra tr\u01B0\u1EDBc khi v\u1EBD.",
9418
+ usage: r.usage
9419
+ };
9420
+ }
9421
+ return { kind: "success", text: r.text, usage: r.usage };
9422
+ }
9423
+ if (r.reason === "not-math") {
9424
+ return { kind: "refused", reason: "not-math", message: r.message };
9425
+ }
9426
+ if (r.reason === "unsupported") {
9427
+ return { kind: "error", code: "unsupported", message: r.message };
9428
+ }
9429
+ if (r.reason === "unreadable") {
9430
+ return { kind: "error", code: "network", message: r.message };
9431
+ }
9432
+ return { kind: "error", code: "empty", message: r.message };
9433
+ } catch (e) {
9434
+ return {
9435
+ kind: "error",
9436
+ code: "unexpected",
9437
+ message: e instanceof Error ? e.message : String(e)
9438
+ };
9439
+ }
9440
+ }
9441
+ var init_handleExtractProblem = __esm({
9442
+ "src/stamps/geometry-2d/ai/handleExtractProblem.ts"() {
9443
+ init_extractProblem();
9444
+ }
9445
+ });
9446
+
9447
+ // src/stamps/geometry-2d/ai/vision/preprocess.ts
9448
+ function inferMediaType(file) {
9449
+ const t = file.type.toLowerCase();
9450
+ if (ALLOWED_TYPES.includes(t)) return t;
9451
+ return null;
9452
+ }
9453
+ function validateFile(file) {
9454
+ const mt = inferMediaType(file);
9455
+ if (mt == null) {
9456
+ return {
9457
+ ok: false,
9458
+ code: "invalid-format",
9459
+ message: "Ch\u1EC9 h\u1ED7 tr\u1EE3 PNG, JPEG, WEBP."
9460
+ };
9461
+ }
9462
+ if (file.size > MAX_RAW_BYTES) {
9463
+ return {
9464
+ ok: false,
9465
+ code: "too-large",
9466
+ message: `\u1EA2nh qu\xE1 l\u1EDBn (> ${Math.round(MAX_RAW_BYTES / 1024 / 1024)} MB). Crop ho\u1EB7c resize tr\u01B0\u1EDBc.`
9467
+ };
9468
+ }
9469
+ return { ok: true, mediaType: mt };
9470
+ }
9471
+ async function fileToImagePart(file) {
9472
+ const v = validateFile(file);
9473
+ if (!v.ok) throw new Error(v.message);
9474
+ const bitmap = await createImageBitmap(file);
9475
+ const { width, height } = bitmap;
9476
+ const maxEdge = Math.max(width, height);
9477
+ const scale = maxEdge > MAX_EDGE_PX ? MAX_EDGE_PX / maxEdge : 1;
9478
+ const targetW = Math.round(width * scale);
9479
+ const targetH = Math.round(height * scale);
9480
+ const canvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(targetW, targetH) : Object.assign(document.createElement("canvas"), { width: targetW, height: targetH });
9481
+ const ctx = canvas.getContext("2d");
9482
+ if (!ctx) throw new Error("Kh\xF4ng t\u1EA1o \u0111\u01B0\u1EE3c canvas 2D context");
9483
+ ctx.drawImage(bitmap, 0, 0, targetW, targetH);
9484
+ bitmap.close();
9485
+ let outputType = v.mediaType === "image/png" ? "image/png" : "image/jpeg";
9486
+ let finalBlob = await canvasToBlob(canvas, outputType, 0.92);
9487
+ if (finalBlob.size > MAX_ENCODED_BYTES) {
9488
+ outputType = "image/jpeg";
9489
+ finalBlob = await canvasToBlob(canvas, "image/jpeg", 0.7);
9490
+ }
9491
+ const base64 = await blobToBase64(finalBlob);
9492
+ return { mediaType: outputType, base64 };
9493
+ }
9494
+ async function canvasToBlob(canvas, type, quality) {
9495
+ if ("convertToBlob" in canvas) {
9496
+ return canvas.convertToBlob({ type, quality });
9497
+ }
9498
+ return new Promise((resolve, reject) => {
9499
+ canvas.toBlob(
9500
+ (b) => b ? resolve(b) : reject(new Error("Canvas encode fail")),
9501
+ type,
9502
+ quality
9503
+ );
9504
+ });
9505
+ }
9506
+ async function blobToBase64(blob) {
9507
+ const buf = await blob.arrayBuffer();
9508
+ let binary = "";
9509
+ const bytes = new Uint8Array(buf);
9510
+ const chunk = 32768;
9511
+ for (let i = 0; i < bytes.length; i += chunk) {
9512
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
9513
+ }
9514
+ return typeof btoa === "function" ? btoa(binary) : Buffer.from(binary, "binary").toString("base64");
9515
+ }
9516
+ var MAX_EDGE_PX, MAX_RAW_BYTES, MAX_ENCODED_BYTES, ALLOWED_TYPES;
9517
+ var init_preprocess = __esm({
9518
+ "src/stamps/geometry-2d/ai/vision/preprocess.ts"() {
9519
+ MAX_EDGE_PX = 2048;
9520
+ MAX_RAW_BYTES = 10 * 1024 * 1024;
9521
+ MAX_ENCODED_BYTES = 3 * 1024 * 1024;
9522
+ ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp"];
9523
+ }
9524
+ });
9331
9525
  function AiFigurePrompt({ generator, onGenerated }) {
9332
9526
  const {
9333
9527
  prompt,
@@ -9347,20 +9541,132 @@ function AiFigurePrompt({ generator, onGenerated }) {
9347
9541
  const id = setInterval(() => setElapsed((s) => s + 1), 1e3);
9348
9542
  return () => clearInterval(id);
9349
9543
  }, [isLoading]);
9544
+ const [image, setImage] = React2.useState(null);
9545
+ const [ocrLoading, setOcrLoading] = React2.useState(false);
9546
+ const [ocrError, setOcrError] = React2.useState(null);
9547
+ const [ocrWarning, setOcrWarning] = React2.useState(null);
9548
+ const [isDragOver, setIsDragOver] = React2.useState(false);
9549
+ const fileInputRef = React2.useRef(null);
9350
9550
  const textareaRef = React2.useRef(null);
9551
+ const imagePreview = image ? `data:${image.mediaType};base64,${image.base64}` : null;
9552
+ React2.useEffect(() => {
9553
+ setOcrError(null);
9554
+ setOcrWarning(null);
9555
+ }, [image]);
9556
+ const handleFile = React2.useCallback(
9557
+ async (file) => {
9558
+ if (isLoading || ocrLoading) return;
9559
+ const v = validateFile(file);
9560
+ if (!v.ok) {
9561
+ setOcrError(v.message);
9562
+ return;
9563
+ }
9564
+ try {
9565
+ const part = await fileToImagePart(file);
9566
+ setImage(part);
9567
+ } catch (e) {
9568
+ setOcrError(e instanceof Error ? e.message : "Kh\xF4ng decode \u0111\u01B0\u1EE3c \u1EA3nh");
9569
+ }
9570
+ },
9571
+ [isLoading, ocrLoading]
9572
+ );
9573
+ const handleFileInput = React2.useCallback(
9574
+ (e) => {
9575
+ const file = e.target.files?.[0];
9576
+ if (file) void handleFile(file);
9577
+ e.target.value = "";
9578
+ },
9579
+ [handleFile]
9580
+ );
9581
+ const handlePaste = React2.useCallback(
9582
+ (e) => {
9583
+ const item = Array.from(e.clipboardData.items).find(
9584
+ (it) => it.kind === "file" && it.type.startsWith("image/")
9585
+ );
9586
+ if (!item) return;
9587
+ const file = item.getAsFile();
9588
+ if (!file) return;
9589
+ e.preventDefault();
9590
+ void handleFile(file);
9591
+ },
9592
+ [handleFile]
9593
+ );
9594
+ const handleDrop = React2.useCallback(
9595
+ (e) => {
9596
+ e.preventDefault();
9597
+ setIsDragOver(false);
9598
+ const file = Array.from(e.dataTransfer.files).find(
9599
+ (f) => f.type.startsWith("image/")
9600
+ );
9601
+ if (file) void handleFile(file);
9602
+ },
9603
+ [handleFile]
9604
+ );
9605
+ const runOcr = React2.useCallback(async () => {
9606
+ if (!image) return;
9607
+ setOcrLoading(true);
9608
+ setOcrError(null);
9609
+ setOcrWarning(null);
9610
+ try {
9611
+ const r = await handleExtractProblem(image);
9612
+ if (r.kind === "success" || r.kind === "low-confidence") {
9613
+ setPrompt(r.text);
9614
+ if (r.kind === "low-confidence") setOcrWarning(r.warning);
9615
+ requestAnimationFrame(() => textareaRef.current?.focus());
9616
+ } else {
9617
+ setOcrError(r.message);
9618
+ }
9619
+ } finally {
9620
+ setOcrLoading(false);
9621
+ }
9622
+ }, [image, setPrompt]);
9351
9623
  const handleSendClick = React2.useCallback(async () => {
9624
+ if (image && !prompt.trim() && !ocrLoading) {
9625
+ await runOcr();
9626
+ return;
9627
+ }
9352
9628
  const generated = await submit();
9353
9629
  if (generated) onGenerated(generated);
9354
- }, [submit, onGenerated]);
9630
+ }, [image, prompt, ocrLoading, runOcr, submit, onGenerated]);
9355
9631
  const promptEmpty = !prompt.trim();
9356
- const sendDisabled = promptEmpty || isLoading;
9632
+ const willOcr = image != null && promptEmpty;
9633
+ const sendDisabled = !image && promptEmpty || ocrLoading || isLoading && !willOcr;
9634
+ const placeholder = willOcr ? "B\u1EA5m g\u1EEDi \u0111\u1EC3 \u0111\u1ECDc \u0111\u1EC1 t\u1EEB \u1EA3nh \u2014 ho\u1EB7c t\u1EF1 g\xF5 \u1EDF \u0111\xE2y\u2026" : "M\xF4 t\u1EA3 \u0111\u1EC1 b\xE0i c\u1EA7n d\u1EF1ng \u2014 ho\u1EB7c d\xE1n/\u0111\xEDnh \u1EA3nh \u0111\u1EC1 (Ctrl+V).";
9357
9635
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "border-b border-slate-200 bg-slate-50 px-3 py-3", children: [
9358
9636
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "mb-2 flex items-center justify-between gap-2", children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs font-medium tracking-wide text-slate-600", children: "D\u1EF1ng h\xECnh b\u1EB1ng AI" }) }),
9359
9637
  /* @__PURE__ */ jsxRuntime.jsxs(
9360
9638
  "div",
9361
9639
  {
9362
- className: "group relative flex flex-col rounded-2xl bg-white shadow-sm transition-all duration-150 ring-1 ring-slate-200 focus-within:ring-2 focus-within:ring-emerald-400/70 focus-within:shadow-md",
9640
+ onDragOver: (e) => {
9641
+ e.preventDefault();
9642
+ setIsDragOver(true);
9643
+ },
9644
+ onDragLeave: () => setIsDragOver(false),
9645
+ onDrop: handleDrop,
9646
+ onPaste: handlePaste,
9647
+ className: "group relative flex flex-col rounded-2xl bg-white shadow-sm transition-all duration-150 ring-1 ring-slate-200 focus-within:ring-2 focus-within:ring-emerald-400/70 focus-within:shadow-md " + (isDragOver ? "ring-2 ring-emerald-500 bg-emerald-50/40" : ""),
9363
9648
  children: [
9649
+ image && imagePreview && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-wrap gap-2 px-3 pt-2.5", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "group/chip relative", children: [
9650
+ /* @__PURE__ */ jsxRuntime.jsx(
9651
+ "img",
9652
+ {
9653
+ src: imagePreview,
9654
+ alt: "\u1EA2nh \u0111\u1EC1 b\xE0i",
9655
+ className: "max-h-48 max-w-full h-auto w-auto rounded-lg border border-slate-200 shadow-sm"
9656
+ }
9657
+ ),
9658
+ /* @__PURE__ */ jsxRuntime.jsx(
9659
+ "button",
9660
+ {
9661
+ type: "button",
9662
+ onClick: () => setImage(null),
9663
+ disabled: ocrLoading || isLoading,
9664
+ "aria-label": "Xo\xE1 \u1EA3nh",
9665
+ className: "absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-slate-900/85 text-[11px] font-medium text-white shadow ring-2 ring-white transition hover:bg-slate-900 disabled:opacity-50",
9666
+ children: "\xD7"
9667
+ }
9668
+ )
9669
+ ] }) }),
9364
9670
  /* @__PURE__ */ jsxRuntime.jsx(
9365
9671
  "textarea",
9366
9672
  {
@@ -9378,48 +9684,88 @@ function AiFigurePrompt({ generator, onGenerated }) {
9378
9684
  },
9379
9685
  disabled: isLoading,
9380
9686
  rows: 2,
9381
- placeholder: "M\xF4 t\u1EA3 \u0111\u1EC1 b\xE0i c\u1EA7n d\u1EF1ng.",
9687
+ placeholder,
9382
9688
  className: "block w-full resize-none rounded-2xl bg-transparent px-3.5 pt-2.5 pb-1 text-sm leading-relaxed text-slate-800 placeholder:text-slate-400 outline-none disabled:opacity-60 field-sizing-content max-h-44"
9383
9689
  }
9384
9690
  ),
9385
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex items-center justify-end gap-2 px-2 pb-2 pt-1", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
9386
- isLoading && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-mono text-[10px] tabular-nums text-slate-500", children: tokens > 0 ? `${tokens}tok \xB7 ${elapsed}s` : `${elapsed}s` }),
9387
- isLoading ? /* @__PURE__ */ jsxRuntime.jsx(
9388
- "button",
9389
- {
9390
- type: "button",
9391
- onClick: cancel,
9392
- "aria-label": "Hu\u1EF7 d\u1EF1ng h\xECnh AI",
9393
- "data-testid": "geometry-ai-cancel",
9394
- title: `\u0110ang d\u1EF1ng\u2026 ${elapsed}s \u2014 b\u1EA5m \u0111\u1EC3 hu\u1EF7`,
9395
- className: "flex h-8 w-8 items-center justify-center rounded-full bg-amber-500 text-white shadow-sm transition hover:scale-105 hover:bg-amber-600 active:scale-95",
9396
- children: /* @__PURE__ */ jsxRuntime.jsx(StopIcon, { className: "h-3.5 w-3.5" })
9397
- }
9398
- ) : /* @__PURE__ */ jsxRuntime.jsx(
9399
- "button",
9400
- {
9401
- type: "button",
9402
- onClick: () => void handleSendClick(),
9403
- disabled: sendDisabled,
9404
- "aria-label": "D\u1EF1ng b\u1EB1ng AI",
9405
- title: "D\u1EF1ng b\u1EB1ng AI (Ctrl/\u2318+Enter)",
9406
- "data-testid": "geometry-ai-submit",
9407
- className: "flex h-8 w-8 items-center justify-center rounded-full bg-emerald-600 text-white shadow-sm transition hover:scale-105 hover:bg-emerald-700 active:scale-95 disabled:cursor-not-allowed disabled:bg-slate-300 disabled:hover:scale-100",
9408
- children: /* @__PURE__ */ jsxRuntime.jsx(ArrowUpIcon, { className: "h-[18px] w-[18px]" })
9409
- }
9410
- )
9411
- ] }) })
9691
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between gap-2 px-2 pb-2 pt-1", children: [
9692
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-1", children: [
9693
+ /* @__PURE__ */ jsxRuntime.jsx(
9694
+ "button",
9695
+ {
9696
+ type: "button",
9697
+ onClick: () => fileInputRef.current?.click(),
9698
+ disabled: isLoading || ocrLoading,
9699
+ "aria-label": "\u0110\xEDnh \u1EA3nh \u0111\u1EC1 b\xE0i",
9700
+ title: "\u0110\xEDnh \u1EA3nh (c\u0169ng c\xF3 th\u1EC3 d\xE1n b\u1EB1ng Ctrl+V ho\u1EB7c k\xE9o th\u1EA3)",
9701
+ className: "flex h-8 w-8 items-center justify-center rounded-full text-slate-500 transition hover:bg-slate-100 hover:text-emerald-700 disabled:opacity-40",
9702
+ children: /* @__PURE__ */ jsxRuntime.jsx(PaperclipIcon, { className: "h-[18px] w-[18px]" })
9703
+ }
9704
+ ),
9705
+ /* @__PURE__ */ jsxRuntime.jsx(
9706
+ "input",
9707
+ {
9708
+ ref: fileInputRef,
9709
+ type: "file",
9710
+ accept: "image/png,image/jpeg,image/webp",
9711
+ className: "sr-only",
9712
+ onChange: handleFileInput,
9713
+ disabled: isLoading || ocrLoading,
9714
+ "aria-label": "Ch\u1ECDn \u1EA3nh \u0111\u1EC1 b\xE0i"
9715
+ }
9716
+ )
9717
+ ] }),
9718
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
9719
+ (isLoading || ocrLoading) && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-mono text-[10px] tabular-nums text-slate-500", children: ocrLoading ? "\u0111\u1ECDc \u1EA3nh\u2026" : tokens > 0 ? `${tokens}tok \xB7 ${elapsed}s` : `${elapsed}s` }),
9720
+ isLoading ? /* @__PURE__ */ jsxRuntime.jsx(
9721
+ "button",
9722
+ {
9723
+ type: "button",
9724
+ onClick: cancel,
9725
+ "aria-label": "Hu\u1EF7 d\u1EF1ng h\xECnh AI",
9726
+ "data-testid": "geometry-ai-cancel",
9727
+ title: `\u0110ang d\u1EF1ng\u2026 ${elapsed}s \u2014 b\u1EA5m \u0111\u1EC3 hu\u1EF7`,
9728
+ className: "flex h-8 w-8 items-center justify-center rounded-full bg-amber-500 text-white shadow-sm transition hover:scale-105 hover:bg-amber-600 active:scale-95",
9729
+ children: /* @__PURE__ */ jsxRuntime.jsx(StopIcon, { className: "h-3.5 w-3.5" })
9730
+ }
9731
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
9732
+ "button",
9733
+ {
9734
+ type: "button",
9735
+ onClick: () => void handleSendClick(),
9736
+ disabled: sendDisabled,
9737
+ "aria-label": willOcr ? "\u0110\u1ECDc \u0111\u1EC1 t\u1EEB \u1EA3nh" : "D\u1EF1ng b\u1EB1ng AI",
9738
+ title: willOcr ? "\u0110\u1ECDc \u0111\u1EC1 t\u1EEB \u1EA3nh (s\u1EBD \u0111i\u1EC1n v\xE0o \xF4 chat)" : "D\u1EF1ng b\u1EB1ng AI (Ctrl/\u2318+Enter)",
9739
+ "data-testid": willOcr ? "geometry-ai-ocr" : "geometry-ai-submit",
9740
+ className: "flex h-8 w-8 items-center justify-center rounded-full bg-emerald-600 text-white shadow-sm transition hover:scale-105 hover:bg-emerald-700 active:scale-95 disabled:cursor-not-allowed disabled:bg-slate-300 disabled:hover:scale-100",
9741
+ children: /* @__PURE__ */ jsxRuntime.jsx(ArrowUpIcon, { className: "h-[18px] w-[18px]" })
9742
+ }
9743
+ )
9744
+ ] })
9745
+ ] }),
9746
+ isDragOver && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-emerald-50/60 text-xs font-medium text-emerald-700", children: "Th\u1EA3 \u1EA3nh v\xE0o \u0111\xE2y" })
9412
9747
  ]
9413
9748
  }
9414
9749
  ),
9750
+ ocrWarning && /* @__PURE__ */ jsxRuntime.jsx(
9751
+ "p",
9752
+ {
9753
+ className: "mt-1 px-1 text-xs text-amber-700",
9754
+ "data-testid": "geometry-ai-ocr-warning",
9755
+ children: ocrWarning
9756
+ }
9757
+ ),
9758
+ ocrError && /* @__PURE__ */ jsxRuntime.jsx("p", { role: "alert", className: "mt-1 px-1 text-xs text-red-600", children: ocrError }),
9415
9759
  error && /* @__PURE__ */ jsxRuntime.jsx("p", { role: "alert", className: "mt-1 px-1 text-xs text-red-600", children: error })
9416
9760
  ] });
9417
9761
  }
9418
- var ArrowUpIcon, StopIcon;
9762
+ var ArrowUpIcon, StopIcon, PaperclipIcon;
9419
9763
  var init_AiFigurePrompt = __esm({
9420
9764
  "src/stamps/geometry-2d/editor/AiFigurePrompt.tsx"() {
9421
9765
  "use client";
9422
9766
  init_useAiFigure();
9767
+ init_handleExtractProblem();
9768
+ init_preprocess();
9423
9769
  ArrowUpIcon = (props) => /* @__PURE__ */ jsxRuntime.jsxs(
9424
9770
  "svg",
9425
9771
  {
@@ -9438,6 +9784,20 @@ var init_AiFigurePrompt = __esm({
9438
9784
  }
9439
9785
  );
9440
9786
  StopIcon = (props) => /* @__PURE__ */ jsxRuntime.jsx("svg", { viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": true, ...props, children: /* @__PURE__ */ jsxRuntime.jsx("rect", { x: "6", y: "6", width: "12", height: "12", rx: "2" }) });
9787
+ PaperclipIcon = (props) => /* @__PURE__ */ jsxRuntime.jsx(
9788
+ "svg",
9789
+ {
9790
+ viewBox: "0 0 24 24",
9791
+ fill: "none",
9792
+ stroke: "currentColor",
9793
+ strokeWidth: 1.75,
9794
+ strokeLinecap: "round",
9795
+ strokeLinejoin: "round",
9796
+ "aria-hidden": true,
9797
+ ...props,
9798
+ children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" })
9799
+ }
9800
+ );
9441
9801
  }
9442
9802
  });
9443
9803