@xom11/whiteboard 0.9.1 → 0.10.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.
package/dist/index.js CHANGED
@@ -145,7 +145,17 @@ function deserializeIntoBoard(board, serialized, options = {}) {
145
145
  const palette = options.palette ?? paletteFor(false);
146
146
  const idMap = /* @__PURE__ */ new Map();
147
147
  const resolve = (a) => {
148
- if (typeof a === "string" && idMap.has(a)) return idMap.get(a);
148
+ if (typeof a === "string") {
149
+ if (idMap.has(a)) return idMap.get(a);
150
+ const m = /^(.+):border:(\d+)$/.exec(a);
151
+ if (m) {
152
+ const poly = idMap.get(m[1]);
153
+ const idx = parseInt(m[2], 10);
154
+ if (poly && Array.isArray(poly.borders) && poly.borders[idx]) {
155
+ return poly.borders[idx];
156
+ }
157
+ }
158
+ }
149
159
  if (Array.isArray(a)) return a.map(resolve);
150
160
  return a;
151
161
  };
@@ -193,6 +203,29 @@ var init_safeJsx = __esm({
193
203
  });
194
204
 
195
205
  // src/stamps/geometry-2d/render.ts
206
+ function containerDimsForBbox(bbox) {
207
+ const [xmin, ymax, xmax, ymin] = bbox;
208
+ const w = Math.abs(xmax - xmin);
209
+ const h = Math.abs(ymax - ymin);
210
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
211
+ return { width: FALLBACK_W, height: FALLBACK_H };
212
+ }
213
+ let width = w * PIXELS_PER_UNIT;
214
+ let height = h * PIXELS_PER_UNIT;
215
+ const maxAxis = Math.max(width, height);
216
+ if (maxAxis > MAX_DIM) {
217
+ const ratio = MAX_DIM / maxAxis;
218
+ width *= ratio;
219
+ height *= ratio;
220
+ }
221
+ const minAxis = Math.min(width, height);
222
+ if (minAxis < MIN_DIM) {
223
+ const ratio = MIN_DIM / minAxis;
224
+ width *= ratio;
225
+ height *= ratio;
226
+ }
227
+ return { width: Math.round(width), height: Math.round(height) };
228
+ }
196
229
  async function renderGeometrySvgFromState(jsonState) {
197
230
  const parsed = JSON.parse(jsonState);
198
231
  const palette = paletteFor(false);
@@ -215,10 +248,11 @@ async function renderGeometrySvgFromState(jsonState) {
215
248
  opts.grid.strokeColor = palette.grid;
216
249
  }
217
250
  });
251
+ const { width, height } = containerDimsForBbox(parsed.bbox);
218
252
  const container = document.createElement("div");
219
253
  const containerId = "jxg_offscreen_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8);
220
254
  container.id = containerId;
221
- container.style.cssText = "position:absolute;top:-99999px;left:-99999px;width:400px;height:300px;visibility:hidden;pointer-events:none;";
255
+ container.style.cssText = `position:absolute;top:-99999px;left:-99999px;width:${width}px;height:${height}px;visibility:hidden;pointer-events:none;`;
222
256
  document.body.appendChild(container);
223
257
  let board = null;
224
258
  try {
@@ -228,7 +262,7 @@ async function renderGeometrySvgFromState(jsonState) {
228
262
  grid: !!parsed.showGrid,
229
263
  showCopyright: false,
230
264
  showNavigation: false,
231
- keepAspectRatio: false
265
+ keepAspectRatio: true
232
266
  });
233
267
  deserializeIntoBoard(board, parsed, { palette });
234
268
  board.update();
@@ -240,12 +274,18 @@ async function renderGeometrySvgFromState(jsonState) {
240
274
  if (container.parentNode) container.parentNode.removeChild(container);
241
275
  }
242
276
  }
277
+ var PIXELS_PER_UNIT, MIN_DIM, MAX_DIM, FALLBACK_W, FALLBACK_H;
243
278
  var init_render = __esm({
244
279
  "src/stamps/geometry-2d/render.ts"() {
245
280
  init_renderInline();
246
281
  init_serialize();
247
282
  init_theme();
248
283
  init_safeJsx();
284
+ PIXELS_PER_UNIT = 20;
285
+ MIN_DIM = 100;
286
+ MAX_DIM = 1200;
287
+ FALLBACK_W = 400;
288
+ FALLBACK_H = 300;
249
289
  }
250
290
  });
251
291
 
@@ -342,8 +382,12 @@ function letterForGroup(g) {
342
382
  }
343
383
  function objKind(obj) {
344
384
  if (!obj) return "other";
385
+ const ec = typeof obj.elementClass === "number" ? obj.elementClass : null;
386
+ if (ec === 1) return "point";
387
+ if (ec === 2) return "line";
388
+ if (ec === 3) return "circle";
345
389
  const e = (obj.elType || obj.type || "").toString().toLowerCase();
346
- if (e === "point" || e === "glider" || e === "midpoint") return "point";
390
+ if (e === "point" || e === "glider" || e === "midpoint" || e === "intersection" || e === "otherintersection" || e === "reflection" || e === "mirrorpoint" || e === "mirrorelement" || e === "orthogonalprojection" || e === "parallelpoint") return "point";
347
391
  if (e === "line" || e === "segment" || e === "arrow" || e === "axis" || e === "normal" || e === "parallel" || e === "perpendicular" || e === "tangent" || e === "bisector" || e === "perpendicularsegment") return "line";
348
392
  if (e === "circle" || e === "circumcircle") return "circle";
349
393
  return "other";
@@ -543,7 +587,7 @@ function handleDown(ctx, e) {
543
587
  if (!sc) return;
544
588
  const [sx, sy] = sc;
545
589
  const hits2 = ctx.objectsAt(e).map(ctx.promoteLabel).filter((o) => o !== ctx.axisObjsRef.current.x && o !== ctx.axisObjsRef.current.y);
546
- const obj = hits2.find((o) => objKind(o) === "point") ?? hits2[0] ?? ctx.findNearestPoint(e, 12);
590
+ const obj = hits2.find((o) => objKind(o) === "point") ?? ctx.findNearestPoint(e, 12) ?? hits2[0];
547
591
  if (obj) {
548
592
  const shift = !!(e.shiftKey || e.altKey);
549
593
  ctx.toggleSelect(obj, shift);
@@ -786,7 +830,7 @@ function handleUp(ctx, e) {
786
830
  const moved = Math.hypot(sx - start.sx, sy - start.sy);
787
831
  if (moved > 4) return;
788
832
  const hits = ctx.objectsAt(e).map(ctx.promoteLabel).filter((o) => o !== ctx.axisObjsRef.current.x && o !== ctx.axisObjsRef.current.y);
789
- const best = hits.find((o) => objKind(o) === "point") ?? hits[0] ?? ctx.findNearestPoint(e, 12);
833
+ const best = hits.find((o) => objKind(o) === "point") ?? ctx.findNearestPoint(e, 12) ?? hits[0];
790
834
  if (!best) {
791
835
  ctx.lastMoveClickRef.current = { obj: null, time: 0 };
792
836
  return;
@@ -925,8 +969,16 @@ var init_MiniBoard = __esm({
925
969
  const nextLocalId = React8.useCallback(() => "j" + creationLogRef.current.length, []);
926
970
  const resolveArgs = React8.useCallback((args) => {
927
971
  return args.map((a) => {
928
- if (typeof a === "string" && objMapRef.current.has(a)) {
929
- return objMapRef.current.get(a);
972
+ if (typeof a === "string") {
973
+ if (objMapRef.current.has(a)) return objMapRef.current.get(a);
974
+ const m = /^(.+):border:(\d+)$/.exec(a);
975
+ if (m) {
976
+ const poly = objMapRef.current.get(m[1]);
977
+ const idx = parseInt(m[2], 10);
978
+ if (poly && Array.isArray(poly.borders) && poly.borders[idx]) {
979
+ return poly.borders[idx];
980
+ }
981
+ }
930
982
  }
931
983
  return a;
932
984
  });
@@ -956,15 +1008,27 @@ var init_MiniBoard = __esm({
956
1008
  [nextLocalId, resolveArgs, pushLog]
957
1009
  );
958
1010
  const localIdOf = React8.useCallback((obj) => {
1011
+ if (!obj) return null;
959
1012
  for (const [id, o] of objMapRef.current.entries()) {
960
1013
  if (o === obj) return id;
961
1014
  }
1015
+ for (const [id, o] of objMapRef.current.entries()) {
1016
+ const borders = o?.borders;
1017
+ if (Array.isArray(borders)) {
1018
+ const idx = borders.indexOf(obj);
1019
+ if (idx >= 0) return `${id}:border:${idx}`;
1020
+ }
1021
+ }
962
1022
  return null;
963
1023
  }, []);
964
1024
  const snapshotObject = React8.useCallback((obj, anchorScreen) => {
965
1025
  const o = obj;
966
1026
  const k = objKind(o);
967
1027
  if (k !== "point" && k !== "line" && k !== "circle") return null;
1028
+ for (const owner of objMapRef.current.values()) {
1029
+ const borders = owner?.borders;
1030
+ if (Array.isArray(borders) && borders.indexOf(o) >= 0) return null;
1031
+ }
968
1032
  const v = o.visProp ?? {};
969
1033
  const showLabel = v.withlabel !== false;
970
1034
  const showValue = valueLabelsRef.current.has(o);
@@ -1483,7 +1547,22 @@ var init_MiniBoard = __esm({
1483
1547
  const board = boardRef.current;
1484
1548
  if (!board) return false;
1485
1549
  const idMap = objMapRef.current;
1486
- const resolved = el.args.map((a) => typeof a === "string" && idMap.has(a) ? idMap.get(a) : a);
1550
+ const resolve = (a) => {
1551
+ if (typeof a === "string") {
1552
+ if (idMap.has(a)) return idMap.get(a);
1553
+ const m = /^(.+):border:(\d+)$/.exec(a);
1554
+ if (m) {
1555
+ const poly = idMap.get(m[1]);
1556
+ const idx = parseInt(m[2], 10);
1557
+ if (poly && Array.isArray(poly.borders) && poly.borders[idx]) {
1558
+ return poly.borders[idx];
1559
+ }
1560
+ }
1561
+ }
1562
+ if (Array.isArray(a)) return a.map(resolve);
1563
+ return a;
1564
+ };
1565
+ const resolved = el.args.map(resolve);
1487
1566
  try {
1488
1567
  if (el.type === "valueLabel") {
1489
1568
  const target = resolved[0];
@@ -2997,6 +3076,7 @@ var init_EditorPanel = __esm({
2997
3076
  function GeometryEditorPanel2({ initialState, onInsert, onClose, withLeftPanel = false, onStateChange, isDark, isMobile = false, onOpenDrawer, onUndo, onRedo, canUndo, canRedo }, ref) {
2998
3077
  const handleRef = React8.useRef(null);
2999
3078
  const [ready, setReady] = React8.useState(false);
3079
+ const [hasContent, setHasContent] = React8.useState(false);
3000
3080
  const [propsPopover, setPropsPopover] = React8.useState(null);
3001
3081
  const [transformPopover, setTransformPopover] = React8.useState(null);
3002
3082
  const onStateChangeRef = React8.useRef(onStateChange);
@@ -3005,8 +3085,10 @@ var init_EditorPanel = __esm({
3005
3085
  }, [onStateChange]);
3006
3086
  const emitState = React8.useCallback(() => {
3007
3087
  const h = handleRef.current;
3088
+ if (!h) return;
3089
+ setHasContent(h.getCreationLog().length > 0);
3008
3090
  const cb = onStateChangeRef.current;
3009
- if (!h || !cb) return;
3091
+ if (!cb) return;
3010
3092
  cb({
3011
3093
  tool: h.getTool(),
3012
3094
  showAxis: h.getShowAxis(),
@@ -3136,7 +3218,8 @@ var init_EditorPanel = __esm({
3136
3218
  {
3137
3219
  type: "button",
3138
3220
  onClick: handleInsert,
3139
- disabled: !ready,
3221
+ disabled: !ready || !hasContent,
3222
+ title: !hasContent ? "V\u1EBD \xEDt nh\u1EA5t m\u1ED9t \u0111\u1ED1i t\u01B0\u1EE3ng tr\u01B0\u1EDBc khi ch\xE8n" : void 0,
3140
3223
  "data-testid": "geometry-insert-btn-mobile",
3141
3224
  className: "rounded bg-white/15 px-3 py-1.5 text-xs font-semibold transition hover:bg-white/25 disabled:opacity-50",
3142
3225
  children: "Ch\xE8n"
@@ -3236,7 +3319,8 @@ var init_EditorPanel = __esm({
3236
3319
  "button",
3237
3320
  {
3238
3321
  onClick: handleInsert,
3239
- disabled: !ready,
3322
+ disabled: !ready || !hasContent,
3323
+ title: !hasContent ? "V\u1EBD \xEDt nh\u1EA5t m\u1ED9t \u0111\u1ED1i t\u01B0\u1EE3ng tr\u01B0\u1EDBc khi ch\xE8n" : void 0,
3240
3324
  "data-testid": "geometry-insert-btn",
3241
3325
  className: "rounded bg-emerald-600 px-3 py-1 text-xs font-medium text-white transition hover:bg-emerald-700 disabled:opacity-50",
3242
3326
  children: "Ch\xE8n"
@@ -4213,6 +4297,108 @@ var init_theme2 = __esm({
4213
4297
  }
4214
4298
  });
4215
4299
 
4300
+ // src/stamps/geometry-3d/render.ts
4301
+ async function renderGeometry3DSvgFromState(jsonState) {
4302
+ const state = parseSerializedBoard3D(jsonState);
4303
+ const JXG = (await import('jsxgraph')).default;
4304
+ const div = document.createElement("div");
4305
+ div.style.cssText = `position:absolute;left:-9999px;top:-9999px;width:${OUTPUT_WIDTH}px;height:${OUTPUT_HEIGHT}px;`;
4306
+ document.body.appendChild(div);
4307
+ try {
4308
+ JXG.Options.text.display = "internal";
4309
+ const board = JXG.JSXGraph.initBoard(div, {
4310
+ boundingbox: state.bbox,
4311
+ keepaspectratio: true,
4312
+ axis: false,
4313
+ showCopyright: false,
4314
+ showNavigation: false,
4315
+ renderer: "svg"
4316
+ });
4317
+ const baseAttrs = VIEW3D_ATTRS(false);
4318
+ const view = board.create(
4319
+ "view3d",
4320
+ [
4321
+ [-5, -5],
4322
+ [10, 10],
4323
+ [
4324
+ [state.view.bbox3D[0], state.view.bbox3D[3]],
4325
+ [state.view.bbox3D[1], state.view.bbox3D[4]],
4326
+ [state.view.bbox3D[2], state.view.bbox3D[5]]
4327
+ ]
4328
+ ],
4329
+ {
4330
+ ...baseAttrs,
4331
+ // JSXGraph view3d đọc azimuth/elevation từ az.slider.start (không phải
4332
+ // az.value). Nếu pass `value` → JSXGraph bỏ qua → render rơi về default
4333
+ // (1.0 rad / 0.3 rad), không khớp góc user xoay trong editor.
4334
+ az: { ...baseAttrs.az, slider: { ...baseAttrs.az.slider, start: state.view.azimuth } },
4335
+ el: { ...baseAttrs.el, slider: { ...baseAttrs.el.slider, start: state.view.elevation } }
4336
+ }
4337
+ );
4338
+ try {
4339
+ const v = view;
4340
+ v?.az_slide?.setValue?.(state.view.azimuth);
4341
+ v?.el_slide?.setValue?.(state.view.elevation);
4342
+ v?.board?.update?.();
4343
+ } catch {
4344
+ }
4345
+ if (!state.showAxes) {
4346
+ view.defaultAxes = [];
4347
+ }
4348
+ try {
4349
+ view.create(
4350
+ "plane3d",
4351
+ [
4352
+ [0, 0, 0],
4353
+ [1, 0, 0],
4354
+ [0, 1, 0],
4355
+ GROUND_PLANE_RANGE,
4356
+ GROUND_PLANE_RANGE
4357
+ ],
4358
+ GROUND_PLANE_ATTRS(false)
4359
+ );
4360
+ } catch {
4361
+ }
4362
+ const idMap = /* @__PURE__ */ new Map();
4363
+ for (const el of state.elements) {
4364
+ const parents = el.parents.map(
4365
+ (p) => typeof p === "string" && p.startsWith("@id:") ? idMap.get(p.slice(4)) : p
4366
+ );
4367
+ const obj = view.create(el.type, parents, {
4368
+ ...el.attributes,
4369
+ id: el.id,
4370
+ name: el.label
4371
+ });
4372
+ idMap.set(el.id, obj);
4373
+ }
4374
+ const svg = div.querySelector("svg");
4375
+ if (!svg) {
4376
+ throw new Error("renderGeometry3DSvgFromState: SVG not produced");
4377
+ }
4378
+ const clone = svg.cloneNode(true);
4379
+ clone.setAttribute("width", String(OUTPUT_WIDTH));
4380
+ clone.setAttribute("height", String(OUTPUT_HEIGHT));
4381
+ const svgString = new XMLSerializer().serializeToString(clone);
4382
+ try {
4383
+ JXG.JSXGraph.freeBoard(board);
4384
+ } catch {
4385
+ }
4386
+ return { svgString, width: OUTPUT_WIDTH, height: OUTPUT_HEIGHT };
4387
+ } finally {
4388
+ document.body.removeChild(div);
4389
+ }
4390
+ }
4391
+ var OUTPUT_WIDTH, OUTPUT_HEIGHT;
4392
+ var init_render3 = __esm({
4393
+ "src/stamps/geometry-3d/render.ts"() {
4394
+ "use client";
4395
+ init_serialize2();
4396
+ init_theme2();
4397
+ OUTPUT_WIDTH = 1024;
4398
+ OUTPUT_HEIGHT = 768;
4399
+ }
4400
+ });
4401
+
4216
4402
  // src/stamps/geometry-3d/editor/tools/handlers/_ensurePoint.ts
4217
4403
  function hitToConstraint(hit) {
4218
4404
  switch (hit.kind) {
@@ -5614,8 +5800,11 @@ var init_MiniBoard3D = __esm({
5614
5800
  ],
5615
5801
  {
5616
5802
  ...baseAttrs,
5617
- az: { ...baseAttrs.az, value: DEFAULT_VIEW3D.azimuth },
5618
- el: { ...baseAttrs.el, value: DEFAULT_VIEW3D.elevation }
5803
+ // JSXGraph view3d đọc giá trị khởi tạo từ az.slider.start (không
5804
+ // phải az.value). Pass nhầm `value` JSXGraph dùng default
5805
+ // 1.0/0.3, khiến DEFAULT_VIEW3D bị bỏ qua.
5806
+ az: { ...baseAttrs.az, slider: { ...baseAttrs.az.slider, start: DEFAULT_VIEW3D.azimuth } },
5807
+ el: { ...baseAttrs.el, slider: { ...baseAttrs.el.slider, start: DEFAULT_VIEW3D.elevation } }
5619
5808
  }
5620
5809
  );
5621
5810
  } catch {
@@ -6252,6 +6441,7 @@ var init_EditorPanel2 = __esm({
6252
6441
  init_ensurePoint();
6253
6442
  init_MiniBoard3D();
6254
6443
  init_StatusHint();
6444
+ init_theme2();
6255
6445
  init_persistence();
6256
6446
  EditorPanel = React8__namespace.forwardRef(
6257
6447
  function EditorPanel2(props, ref) {
@@ -6357,8 +6547,17 @@ var init_EditorPanel2 = __esm({
6357
6547
  }, [showAxis, showGrid]);
6358
6548
  const handleView3DReady = React8__namespace.useCallback((view) => {
6359
6549
  rendererRef.current = new JxgRenderer(scene, view);
6550
+ if (initialState) {
6551
+ try {
6552
+ const v = view;
6553
+ v?.az_slide?.setValue?.(initialState.view.azimuth);
6554
+ v?.el_slide?.setValue?.(initialState.view.elevation);
6555
+ v?.board?.update?.();
6556
+ } catch {
6557
+ }
6558
+ }
6360
6559
  onReadyChange?.(true);
6361
- }, [onReadyChange, scene]);
6560
+ }, [onReadyChange, scene, initialState]);
6362
6561
  const handleClick = React8__namespace.useCallback((screen) => {
6363
6562
  const board = boardRef.current;
6364
6563
  if (!board) return;
@@ -6496,8 +6695,10 @@ var init_EditorPanel2 = __esm({
6496
6695
  const elevation = typeof elSlider?.Value === "function" ? elSlider.Value() : 0;
6497
6696
  return sceneToBoard(
6498
6697
  scene,
6499
- { azimuth, elevation, bbox3D: [-5, -5, -5, 5, 5, 5] },
6500
- [-6, -6, 6, 6]
6698
+ { azimuth, elevation, bbox3D: [...DEFAULT_VIEW3D.bbox3D] },
6699
+ // JSXGraph boundingbox order: [xmin, ymax, xmax, ymin]. Must match
6700
+ // MiniBoard3D.initBoard so render reproduces the editor's view.
6701
+ [-6, 6, 6, -6]
6501
6702
  );
6502
6703
  },
6503
6704
  setTool: (k) => controllerRef.current.selectTool(k),
@@ -7406,6 +7607,7 @@ var init_host3 = __esm({
7406
7607
  init_useChordShortcut();
7407
7608
  init_insertImage();
7408
7609
  init_useIsMobile();
7610
+ init_render3();
7409
7611
  init_serialize2();
7410
7612
  Geometry3DStampHost = React8.forwardRef(
7411
7613
  function Geometry3DStampHost2({ api, editingElement, onClose, isDark }, ref) {
@@ -7420,10 +7622,25 @@ var init_host3 = __esm({
7420
7622
  const [showGrid, setShowGrid] = React8.useState(true);
7421
7623
  const [canUndo, setCanUndo] = React8.useState(false);
7422
7624
  const [canRedo, setCanRedo] = React8.useState(false);
7625
+ const [hasContent, setHasContent] = React8.useState(false);
7423
7626
  const handleHistoryChange = React8.useCallback((u, r) => {
7424
7627
  setCanUndo(u);
7425
7628
  setCanRedo(r);
7426
7629
  }, []);
7630
+ React8.useEffect(() => {
7631
+ const scene = sceneRef.current;
7632
+ if (!scene) return;
7633
+ const sync = () => setHasContent(scene.list().length > 0);
7634
+ sync();
7635
+ const unsubs = [
7636
+ scene.on("add", sync),
7637
+ scene.on("delete", sync),
7638
+ scene.on("reset", sync)
7639
+ ];
7640
+ return () => {
7641
+ for (const u of unsubs) u();
7642
+ };
7643
+ }, []);
7427
7644
  const handleUndo = React8.useCallback(() => {
7428
7645
  editorRef.current?.undo();
7429
7646
  }, []);
@@ -7471,7 +7688,15 @@ var init_host3 = __esm({
7471
7688
  if (!editorRef.current.hasContent()) return false;
7472
7689
  const board = editorRef.current.serialize();
7473
7690
  if (board.elements.length === 0) return false;
7474
- void performInsert(board, 0, 0, "");
7691
+ void (async () => {
7692
+ try {
7693
+ const jsonState = serializeBoard3D(board);
7694
+ const { svgString, width, height } = await renderGeometry3DSvgFromState(jsonState);
7695
+ await performInsert(board, width, height, svgString);
7696
+ } catch (err) {
7697
+ console.error("Geometry3D insert failed:", err);
7698
+ }
7699
+ })();
7475
7700
  return true;
7476
7701
  }, [performInsert]);
7477
7702
  React8.useImperativeHandle(
@@ -7553,7 +7778,8 @@ var init_host3 = __esm({
7553
7778
  {
7554
7779
  type: "button",
7555
7780
  onClick: tryInsert,
7556
- disabled: !ready,
7781
+ disabled: !ready || !hasContent,
7782
+ title: !hasContent ? "V\u1EBD \xEDt nh\u1EA5t m\u1ED9t \u0111\u1ED1i t\u01B0\u1EE3ng tr\u01B0\u1EDBc khi ch\xE8n" : void 0,
7557
7783
  "data-testid": "geom3d-insert-btn-mobile",
7558
7784
  className: "rounded bg-white/15 px-3 py-1.5 text-xs font-semibold transition hover:bg-white/25 disabled:opacity-50",
7559
7785
  children: "Ch\xE8n"
@@ -7603,7 +7829,8 @@ var init_host3 = __esm({
7603
7829
  "button",
7604
7830
  {
7605
7831
  onClick: tryInsert,
7606
- disabled: !ready,
7832
+ disabled: !ready || !hasContent,
7833
+ title: !hasContent ? "V\u1EBD \xEDt nh\u1EA5t m\u1ED9t \u0111\u1ED1i t\u01B0\u1EE3ng tr\u01B0\u1EDBc khi ch\xE8n" : void 0,
7607
7834
  "data-testid": "geom3d-insert-btn",
7608
7835
  className: "rounded bg-emerald-600 px-3 py-1 text-xs font-medium text-white transition hover:bg-emerald-700 disabled:opacity-50",
7609
7836
  children: "Ch\xE8n"
@@ -8276,7 +8503,7 @@ async function renderGraph2dSvgFromState(jsonState) {
8276
8503
  if (container.parentNode) container.parentNode.removeChild(container);
8277
8504
  }
8278
8505
  }
8279
- var init_render3 = __esm({
8506
+ var init_render4 = __esm({
8280
8507
  "src/stamps/graph-2d/render.ts"() {
8281
8508
  init_serialize3();
8282
8509
  init_renderObjects();
@@ -8964,7 +9191,7 @@ var init_EditorPanel3 = __esm({
8964
9191
  init_MiniBoard2();
8965
9192
  init_serialize3();
8966
9193
  init_parser();
8967
- init_render3();
9194
+ init_render4();
8968
9195
  init_colors();
8969
9196
  init_handlers2();
8970
9197
  GraphEditorPanel = React8.forwardRef(function GraphEditorPanel2(props, ref) {
@@ -9539,91 +9766,7 @@ var latexStamp = {
9539
9766
 
9540
9767
  // src/stamps/geometry-3d/index.tsx
9541
9768
  init_serialize2();
9542
-
9543
- // src/stamps/geometry-3d/render.ts
9544
- init_serialize2();
9545
- init_theme2();
9546
- var OUTPUT_WIDTH = 1024;
9547
- var OUTPUT_HEIGHT = 768;
9548
- async function renderGeometry3DSvgFromState(jsonState) {
9549
- const state = parseSerializedBoard3D(jsonState);
9550
- const JXG = (await import('jsxgraph')).default;
9551
- const div = document.createElement("div");
9552
- div.style.cssText = `position:absolute;left:-9999px;top:-9999px;width:${OUTPUT_WIDTH}px;height:${OUTPUT_HEIGHT}px;`;
9553
- document.body.appendChild(div);
9554
- try {
9555
- JXG.Options.text.display = "internal";
9556
- const board = JXG.JSXGraph.initBoard(div, {
9557
- boundingbox: state.bbox,
9558
- axis: false,
9559
- showCopyright: false,
9560
- showNavigation: false,
9561
- renderer: "svg"
9562
- });
9563
- const baseAttrs = VIEW3D_ATTRS(false);
9564
- const view = board.create(
9565
- "view3d",
9566
- [
9567
- [-5, -5],
9568
- [10, 10],
9569
- [
9570
- [state.view.bbox3D[0], state.view.bbox3D[3]],
9571
- [state.view.bbox3D[1], state.view.bbox3D[4]],
9572
- [state.view.bbox3D[2], state.view.bbox3D[5]]
9573
- ]
9574
- ],
9575
- {
9576
- ...baseAttrs,
9577
- az: { ...baseAttrs.az, value: state.view.azimuth },
9578
- el: { ...baseAttrs.el, value: state.view.elevation }
9579
- }
9580
- );
9581
- if (!state.showAxes) {
9582
- view.defaultAxes = [];
9583
- }
9584
- try {
9585
- view.create(
9586
- "plane3d",
9587
- [
9588
- [0, 0, 0],
9589
- [1, 0, 0],
9590
- [0, 1, 0],
9591
- GROUND_PLANE_RANGE,
9592
- GROUND_PLANE_RANGE
9593
- ],
9594
- GROUND_PLANE_ATTRS(false)
9595
- );
9596
- } catch {
9597
- }
9598
- const idMap = /* @__PURE__ */ new Map();
9599
- for (const el of state.elements) {
9600
- const parents = el.parents.map(
9601
- (p) => typeof p === "string" && p.startsWith("@id:") ? idMap.get(p.slice(4)) : p
9602
- );
9603
- const obj = view.create(el.type, parents, {
9604
- ...el.attributes,
9605
- id: el.id,
9606
- name: el.label
9607
- });
9608
- idMap.set(el.id, obj);
9609
- }
9610
- const svg = div.querySelector("svg");
9611
- if (!svg) {
9612
- throw new Error("renderGeometry3DSvgFromState: SVG not produced");
9613
- }
9614
- const clone = svg.cloneNode(true);
9615
- clone.setAttribute("width", String(OUTPUT_WIDTH));
9616
- clone.setAttribute("height", String(OUTPUT_HEIGHT));
9617
- const svgString = new XMLSerializer().serializeToString(clone);
9618
- try {
9619
- JXG.JSXGraph.freeBoard(board);
9620
- } catch {
9621
- }
9622
- return { svgString, width: OUTPUT_WIDTH, height: OUTPUT_HEIGHT };
9623
- } finally {
9624
- document.body.removeChild(div);
9625
- }
9626
- }
9769
+ init_render3();
9627
9770
  var Geometry3DStampHost3 = React8.lazy(
9628
9771
  () => Promise.resolve().then(() => (init_host3(), host_exports3)).then((m) => ({ default: m.Geometry3DStampHost }))
9629
9772
  );
@@ -9679,7 +9822,7 @@ var geometry3dStamp = {
9679
9822
  };
9680
9823
 
9681
9824
  // src/stamps/graph-2d/index.tsx
9682
- init_render3();
9825
+ init_render4();
9683
9826
  init_types3();
9684
9827
  var Graph2DStampHost3 = React8.lazy(
9685
9828
  () => Promise.resolve().then(() => (init_host4(), host_exports4)).then((m) => ({ default: m.Graph2DStampHost }))
@@ -9846,20 +9989,28 @@ function ToolbarInjector({
9846
9989
  };
9847
9990
  return reactDom.createPortal(
9848
9991
  /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
9849
- stamps.map((stamp) => /* @__PURE__ */ jsxRuntime.jsx(
9850
- StampMenuItem,
9851
- {
9852
- icon: stamp.toolbarIcon,
9853
- label: stamp.toolbarTitle,
9854
- active: activeStampKind === stamp.kind,
9855
- onClick: () => {
9856
- onToggle(stamp.kind);
9857
- closePopover();
9992
+ stamps.map((stamp) => {
9993
+ const { displayLabel, shortcut } = splitTitleAndShortcut(
9994
+ stamp.toolbarTitle,
9995
+ stamp.toolbarLabel
9996
+ );
9997
+ return /* @__PURE__ */ jsxRuntime.jsx(
9998
+ StampMenuItem,
9999
+ {
10000
+ icon: stamp.toolbarIcon,
10001
+ label: displayLabel,
10002
+ ariaLabel: stamp.toolbarTitle,
10003
+ shortcut,
10004
+ active: activeStampKind === stamp.kind,
10005
+ onClick: () => {
10006
+ onToggle(stamp.kind);
10007
+ closePopover();
10008
+ },
10009
+ dataTestId: stamp.toolbarTestId
9858
10010
  },
9859
- dataTestId: stamp.toolbarTestId
9860
- },
9861
- stamp.kind
9862
- )),
10011
+ stamp.kind
10012
+ );
10013
+ }),
9863
10014
  /* @__PURE__ */ jsxRuntime.jsx(
9864
10015
  "div",
9865
10016
  {
@@ -9875,7 +10026,22 @@ function ToolbarInjector({
9875
10026
  menuMount
9876
10027
  );
9877
10028
  }
9878
- function StampMenuItem({ icon, label, active, onClick, dataTestId }) {
10029
+ function splitTitleAndShortcut(title, fallbackShortcut) {
10030
+ const match = title.match(/^(.*?)\s*\(([^()]+)\)\s*$/);
10031
+ if (match) {
10032
+ return { displayLabel: match[1].trim(), shortcut: match[2].trim() };
10033
+ }
10034
+ return { displayLabel: title, shortcut: fallbackShortcut };
10035
+ }
10036
+ function StampMenuItem({
10037
+ icon,
10038
+ label,
10039
+ ariaLabel,
10040
+ shortcut,
10041
+ active,
10042
+ onClick,
10043
+ dataTestId
10044
+ }) {
9879
10045
  const className = [
9880
10046
  "dropdown-menu-item",
9881
10047
  "dropdown-menu-item-base",
@@ -9886,39 +10052,15 @@ function StampMenuItem({ icon, label, active, onClick, dataTestId }) {
9886
10052
  {
9887
10053
  type: "button",
9888
10054
  onClick,
9889
- "aria-label": label,
10055
+ title: ariaLabel,
10056
+ "aria-label": ariaLabel,
9890
10057
  "aria-pressed": active,
9891
10058
  "data-testid": dataTestId,
9892
10059
  className,
9893
- style: {
9894
- display: "flex",
9895
- alignItems: "center",
9896
- columnGap: "0.625rem",
9897
- width: "100%",
9898
- boxSizing: "border-box",
9899
- background: "transparent",
9900
- border: "1px solid transparent",
9901
- cursor: "pointer",
9902
- fontFamily: "inherit",
9903
- fontSize: "0.875rem",
9904
- color: "var(--color-on-surface)"
9905
- },
9906
10060
  children: [
9907
- /* @__PURE__ */ jsxRuntime.jsx(
9908
- "span",
9909
- {
9910
- "aria-hidden": "true",
9911
- style: {
9912
- display: "inline-flex",
9913
- alignItems: "center",
9914
- justifyContent: "center",
9915
- width: "1rem",
9916
- height: "1rem"
9917
- },
9918
- children: icon
9919
- }
9920
- ),
9921
- /* @__PURE__ */ jsxRuntime.jsx("span", { children: label })
10061
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "dropdown-menu-item__icon", "aria-hidden": "true", children: icon }),
10062
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "dropdown-menu-item__text", children: label }),
10063
+ shortcut ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "dropdown-menu-item__shortcut", children: shortcut }) : null
9922
10064
  ]
9923
10065
  }
9924
10066
  );
@@ -9952,29 +10094,866 @@ function useShortcuts({
9952
10094
  return () => window.removeEventListener("keydown", handler, { capture: true });
9953
10095
  }, [enabled, onToggle, stamps]);
9954
10096
  }
9955
- var DOUBLE_CLICK_MS = 400;
9956
- function useStampDoubleClick({ enabled, stamps, onOpen }) {
9957
- const lastClickRef = React8.useRef({
9958
- time: 0,
9959
- elementId: null
9960
- });
9961
- return React8.useCallback(
9962
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
9963
- (_activeTool, pointerDownState) => {
9964
- if (!enabled) return;
9965
- const hitElement = pointerDownState?.hit?.element;
9966
- if (!hitElement || hitElement.type !== "image") return;
9967
- const stamp = findStampForCustomData(hitElement.customData, stamps);
9968
- if (!stamp) return;
9969
- const now = Date.now();
9970
- const isDouble = lastClickRef.current.elementId === hitElement.id && now - lastClickRef.current.time < DOUBLE_CLICK_MS;
9971
- lastClickRef.current = { time: now, elementId: hitElement.id };
9972
- if (!isDouble) return;
9973
- onOpen(stamp.kind, {
9974
- id: hitElement.id,
9975
- customData: hitElement.customData
9976
- });
9977
- },
10097
+ var WRAPPER_ID = "pdf-import-portal-wrapper";
10098
+ var POPOVER_SELECTOR2 = ".App-toolbar__extra-tools-dropdown .dropdown-menu-container";
10099
+ function PdfImporterButton({ enabled, onPick }) {
10100
+ const [mount, setMount] = React8.useState(null);
10101
+ const mountRef = React8.useRef(null);
10102
+ const inputRef = React8.useRef(null);
10103
+ React8.useEffect(() => {
10104
+ if (!enabled) {
10105
+ mountRef.current = null;
10106
+ setMount(null);
10107
+ document.getElementById(WRAPPER_ID)?.remove();
10108
+ return;
10109
+ }
10110
+ let cancelled = false;
10111
+ let observer = null;
10112
+ let rafId = null;
10113
+ let observedRoot = null;
10114
+ const apply = (next) => {
10115
+ if (cancelled || mountRef.current === next) return;
10116
+ mountRef.current = next;
10117
+ queueMicrotask(() => {
10118
+ if (!cancelled) setMount(next);
10119
+ });
10120
+ };
10121
+ const findMenu = () => {
10122
+ if (cancelled) return;
10123
+ const container = document.querySelector(POPOVER_SELECTOR2);
10124
+ if (!container) {
10125
+ apply(null);
10126
+ return;
10127
+ }
10128
+ let wrapper = container.querySelector("#" + WRAPPER_ID);
10129
+ if (!wrapper) {
10130
+ wrapper = document.createElement("div");
10131
+ wrapper.id = WRAPPER_ID;
10132
+ wrapper.setAttribute("data-pdf-import", "true");
10133
+ wrapper.style.display = "contents";
10134
+ container.appendChild(wrapper);
10135
+ }
10136
+ apply(wrapper);
10137
+ };
10138
+ const attachObserver = () => {
10139
+ if (cancelled) return;
10140
+ const excalidraw = document.querySelector(".excalidraw");
10141
+ const nextRoot = excalidraw ?? document.body;
10142
+ if (observedRoot === nextRoot) return;
10143
+ observer?.disconnect();
10144
+ observedRoot = nextRoot;
10145
+ observer = new MutationObserver(onMutation);
10146
+ observer.observe(nextRoot, { childList: true, subtree: true });
10147
+ };
10148
+ const onMutation = () => {
10149
+ if (rafId != null) return;
10150
+ rafId = requestAnimationFrame(() => {
10151
+ rafId = null;
10152
+ if (cancelled) return;
10153
+ if (observedRoot !== document.querySelector(".excalidraw")) {
10154
+ attachObserver();
10155
+ }
10156
+ findMenu();
10157
+ });
10158
+ };
10159
+ findMenu();
10160
+ attachObserver();
10161
+ return () => {
10162
+ cancelled = true;
10163
+ if (rafId != null) cancelAnimationFrame(rafId);
10164
+ observer?.disconnect();
10165
+ document.getElementById(WRAPPER_ID)?.remove();
10166
+ };
10167
+ }, [enabled]);
10168
+ const closePopover = () => {
10169
+ const trigger = document.querySelector(
10170
+ ".App-toolbar__extra-tools-trigger"
10171
+ );
10172
+ trigger?.click();
10173
+ };
10174
+ const handleClick = () => {
10175
+ inputRef.current?.click();
10176
+ };
10177
+ const handleFileChange = (e) => {
10178
+ const file = e.target.files?.[0];
10179
+ if (file) onPick(file);
10180
+ e.target.value = "";
10181
+ closePopover();
10182
+ };
10183
+ if (!enabled || !mount) {
10184
+ return /* @__PURE__ */ jsxRuntime.jsx(
10185
+ "input",
10186
+ {
10187
+ ref: inputRef,
10188
+ type: "file",
10189
+ accept: "application/pdf,.pdf",
10190
+ style: { display: "none" },
10191
+ onChange: handleFileChange
10192
+ }
10193
+ );
10194
+ }
10195
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
10196
+ /* @__PURE__ */ jsxRuntime.jsx(
10197
+ "input",
10198
+ {
10199
+ ref: inputRef,
10200
+ type: "file",
10201
+ accept: "application/pdf,.pdf",
10202
+ style: { display: "none" },
10203
+ onChange: handleFileChange
10204
+ }
10205
+ ),
10206
+ reactDom.createPortal(
10207
+ /* @__PURE__ */ jsxRuntime.jsxs(
10208
+ "button",
10209
+ {
10210
+ type: "button",
10211
+ onClick: handleClick,
10212
+ title: "Ch\xE8n PDF (P)",
10213
+ "aria-label": "Ch\xE8n PDF",
10214
+ "data-testid": "pdf-import-button",
10215
+ className: "dropdown-menu-item dropdown-menu-item-base",
10216
+ children: [
10217
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "dropdown-menu-item__icon", "aria-hidden": "true", children: /* @__PURE__ */ jsxRuntime.jsx(PdfIcon, {}) }),
10218
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "dropdown-menu-item__text", children: "Ch\xE8n PDF" }),
10219
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "dropdown-menu-item__shortcut", children: "P" })
10220
+ ]
10221
+ }
10222
+ ),
10223
+ mount
10224
+ )
10225
+ ] });
10226
+ }
10227
+ function PdfIcon() {
10228
+ return /* @__PURE__ */ jsxRuntime.jsxs(
10229
+ "svg",
10230
+ {
10231
+ width: "18",
10232
+ height: "18",
10233
+ viewBox: "0 0 24 24",
10234
+ fill: "none",
10235
+ stroke: "currentColor",
10236
+ strokeWidth: "1.6",
10237
+ strokeLinecap: "round",
10238
+ strokeLinejoin: "round",
10239
+ "aria-hidden": "true",
10240
+ children: [
10241
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" }),
10242
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M14 3v5h5" }),
10243
+ /* @__PURE__ */ jsxRuntime.jsx("text", { x: "7.5", y: "17", fontSize: "6", fontFamily: "sans-serif", fontWeight: "700", stroke: "none", fill: "currentColor", children: "PDF" })
10244
+ ]
10245
+ }
10246
+ );
10247
+ }
10248
+
10249
+ // src/pdf/parseRange.ts
10250
+ function parsePageRange(input, totalPages) {
10251
+ if (!Number.isInteger(totalPages) || totalPages <= 0) {
10252
+ throw new Error("S\u1ED1 trang ph\u1EA3i l\xE0 s\u1ED1 nguy\xEAn d\u01B0\u01A1ng.");
10253
+ }
10254
+ const trimmed = input.trim();
10255
+ if (trimmed === "") return [];
10256
+ const tokens = trimmed.split(/[,\s]+/).map((t) => t.trim()).filter((t) => t.length > 0);
10257
+ const set = /* @__PURE__ */ new Set();
10258
+ for (const token of tokens) {
10259
+ if (token.includes("-")) {
10260
+ const parts = token.split("-");
10261
+ if (parts.length !== 2) {
10262
+ throw new Error(`Kho\u1EA3ng trang kh\xF4ng h\u1EE3p l\u1EC7: "${token}".`);
10263
+ }
10264
+ const start = parseStrictInt(parts[0]);
10265
+ const end = parseStrictInt(parts[1]);
10266
+ if (start === null || end === null) {
10267
+ throw new Error(`Kho\u1EA3ng trang kh\xF4ng h\u1EE3p l\u1EC7: "${token}".`);
10268
+ }
10269
+ if (start > end) {
10270
+ throw new Error(`Kho\u1EA3ng trang ng\u01B0\u1EE3c: "${token}" (\u0111\u1EA7u > cu\u1ED1i).`);
10271
+ }
10272
+ if (start < 1 || end > totalPages) {
10273
+ throw new Error(
10274
+ `Kho\u1EA3ng trang v\u01B0\u1EE3t gi\u1EDBi h\u1EA1n: "${token}". PDF c\xF3 ${totalPages} trang.`
10275
+ );
10276
+ }
10277
+ for (let i = start; i <= end; i++) set.add(i);
10278
+ } else {
10279
+ const n = parseStrictInt(token);
10280
+ if (n === null) {
10281
+ throw new Error(`S\u1ED1 trang kh\xF4ng h\u1EE3p l\u1EC7: "${token}".`);
10282
+ }
10283
+ if (n < 1 || n > totalPages) {
10284
+ throw new Error(
10285
+ `S\u1ED1 trang v\u01B0\u1EE3t gi\u1EDBi h\u1EA1n: ${n}. PDF c\xF3 ${totalPages} trang.`
10286
+ );
10287
+ }
10288
+ set.add(n);
10289
+ }
10290
+ }
10291
+ return Array.from(set).sort((a, b) => a - b);
10292
+ }
10293
+ function parseStrictInt(s) {
10294
+ if (!/^-?\d+$/.test(s)) return null;
10295
+ const n = Number(s);
10296
+ return Number.isInteger(n) ? n : null;
10297
+ }
10298
+
10299
+ // src/pdf/rasterize.ts
10300
+ var workerSrcOverride = null;
10301
+ var pdfjsCache = null;
10302
+ function configurePdfWorker(workerSrc) {
10303
+ workerSrcOverride = workerSrc;
10304
+ if (pdfjsCache) {
10305
+ pdfjsCache.GlobalWorkerOptions.workerSrc = workerSrc;
10306
+ }
10307
+ }
10308
+ async function loadPdfjs() {
10309
+ if (pdfjsCache) return pdfjsCache;
10310
+ const mod = await import('pdfjs-dist');
10311
+ const workerSrc = workerSrcOverride ?? `https://cdn.jsdelivr.net/npm/pdfjs-dist@${mod.version}/build/pdf.worker.min.mjs`;
10312
+ mod.GlobalWorkerOptions.workerSrc = workerSrc;
10313
+ pdfjsCache = mod;
10314
+ return mod;
10315
+ }
10316
+ async function loadPdfDocument(source) {
10317
+ const pdfjs = await loadPdfjs();
10318
+ const data = source instanceof ArrayBuffer ? source : await source.arrayBuffer();
10319
+ const task = pdfjs.getDocument({ data: new Uint8Array(data) });
10320
+ return task.promise;
10321
+ }
10322
+ async function closePdfDocument(doc) {
10323
+ try {
10324
+ await doc.cleanup();
10325
+ await doc.destroy();
10326
+ } catch {
10327
+ }
10328
+ }
10329
+ async function rasterizePdf(doc, options = {}) {
10330
+ const scale3 = options.scale ?? 2;
10331
+ const total = doc.numPages;
10332
+ const pages = options.pages ?? Array.from({ length: total }, (_, i) => i + 1);
10333
+ const signal = options.signal;
10334
+ const result = [];
10335
+ for (let i = 0; i < pages.length; i++) {
10336
+ if (signal?.aborted) {
10337
+ throw new DOMException("Rasterize PDF b\u1ECB hu\u1EF7.", "AbortError");
10338
+ }
10339
+ const pageNum = pages[i];
10340
+ const page = await doc.getPage(pageNum);
10341
+ try {
10342
+ const rendered = await renderPageToPng(page, scale3);
10343
+ result.push({ pageNumber: pageNum, mimeType: "image/png", ...rendered });
10344
+ } finally {
10345
+ page.cleanup();
10346
+ }
10347
+ options.onProgress?.(i + 1, pages.length);
10348
+ }
10349
+ return result;
10350
+ }
10351
+ async function renderPageToPng(page, scale3) {
10352
+ const viewport = page.getViewport({ scale: scale3 });
10353
+ const width = Math.ceil(viewport.width);
10354
+ const height = Math.ceil(viewport.height);
10355
+ const canvas = document.createElement("canvas");
10356
+ canvas.width = width;
10357
+ canvas.height = height;
10358
+ const ctx = canvas.getContext("2d");
10359
+ if (!ctx) throw new Error("Kh\xF4ng l\u1EA5y \u0111\u01B0\u1EE3c 2D context c\u1EE7a canvas.");
10360
+ await page.render({ canvasContext: ctx, viewport, canvas }).promise;
10361
+ const dataURL = canvas.toDataURL("image/png");
10362
+ return { dataURL, width, height };
10363
+ }
10364
+ async function renderPageThumbnail(page, scale3 = 0.3, quality = 0.7) {
10365
+ const viewport = page.getViewport({ scale: scale3 });
10366
+ const width = Math.ceil(viewport.width);
10367
+ const height = Math.ceil(viewport.height);
10368
+ const canvas = document.createElement("canvas");
10369
+ canvas.width = width;
10370
+ canvas.height = height;
10371
+ const ctx = canvas.getContext("2d");
10372
+ if (!ctx) throw new Error("Kh\xF4ng l\u1EA5y \u0111\u01B0\u1EE3c 2D context c\u1EE7a canvas.");
10373
+ ctx.fillStyle = "#ffffff";
10374
+ ctx.fillRect(0, 0, width, height);
10375
+ await page.render({ canvasContext: ctx, viewport, canvas }).promise;
10376
+ const dataURL = canvas.toDataURL("image/jpeg", quality);
10377
+ return { dataURL, width, height };
10378
+ }
10379
+ async function renderAllThumbnails(doc, onEach, options = {}) {
10380
+ const total = doc.numPages;
10381
+ const scale3 = options.scale ?? 0.3;
10382
+ const quality = options.quality ?? 0.7;
10383
+ const concurrency = Math.max(1, options.concurrency ?? 3);
10384
+ const signal = options.signal;
10385
+ let next = 1;
10386
+ async function worker() {
10387
+ while (true) {
10388
+ if (signal?.aborted) return;
10389
+ const pageNum = next++;
10390
+ if (pageNum > total) return;
10391
+ const page = await doc.getPage(pageNum);
10392
+ try {
10393
+ if (signal?.aborted) return;
10394
+ const { dataURL, width, height } = await renderPageThumbnail(page, scale3, quality);
10395
+ if (signal?.aborted) return;
10396
+ onEach(pageNum, dataURL, width, height);
10397
+ } finally {
10398
+ page.cleanup();
10399
+ }
10400
+ }
10401
+ }
10402
+ await Promise.all(
10403
+ Array.from({ length: Math.min(concurrency, total) }, () => worker())
10404
+ );
10405
+ }
10406
+ function serializeSelection(pages) {
10407
+ if (pages.length === 0) return "";
10408
+ const sorted = [...pages].sort((a, b) => a - b);
10409
+ const groups = [];
10410
+ let start = sorted[0];
10411
+ let prev = start;
10412
+ for (let i = 1; i < sorted.length; i++) {
10413
+ const n = sorted[i];
10414
+ if (n === prev + 1) {
10415
+ prev = n;
10416
+ } else {
10417
+ groups.push(start === prev ? `${start}` : `${start}-${prev}`);
10418
+ start = n;
10419
+ prev = n;
10420
+ }
10421
+ }
10422
+ groups.push(start === prev ? `${start}` : `${start}-${prev}`);
10423
+ return groups.join(",");
10424
+ }
10425
+ function PageRangeDialog({ doc, fileName, onConfirm, onCancel }) {
10426
+ const totalPages = doc.numPages;
10427
+ const defaultPages = React8.useMemo(
10428
+ () => Array.from({ length: totalPages }, (_, i) => i + 1),
10429
+ [totalPages]
10430
+ );
10431
+ const [selectedSet, setSelectedSet] = React8.useState(
10432
+ () => new Set(defaultPages)
10433
+ );
10434
+ const [inputValue, setInputValue] = React8.useState(serializeSelection(defaultPages));
10435
+ const [inputError, setInputError] = React8.useState(null);
10436
+ const [thumbs, setThumbs] = React8.useState({});
10437
+ const [thumbProgress, setThumbProgress] = React8.useState(0);
10438
+ const inputRef = React8.useRef(null);
10439
+ React8.useEffect(() => {
10440
+ const ctrl = new AbortController();
10441
+ void renderAllThumbnails(
10442
+ doc,
10443
+ (pageNum, dataURL, width, height) => {
10444
+ setThumbs((prev) => ({ ...prev, [pageNum]: { dataURL, width, height } }));
10445
+ setThumbProgress((prev) => prev + 1);
10446
+ },
10447
+ { scale: 0.3, quality: 0.7, concurrency: 3, signal: ctrl.signal }
10448
+ ).catch((err) => {
10449
+ if (ctrl.signal.aborted) return;
10450
+ console.warn("[PageRangeDialog] render thumbnails l\u1ED7i:", err);
10451
+ });
10452
+ return () => ctrl.abort();
10453
+ }, [doc]);
10454
+ React8.useEffect(() => {
10455
+ const onKey = (e) => {
10456
+ if (e.key === "Escape") {
10457
+ e.preventDefault();
10458
+ e.stopPropagation();
10459
+ onCancel();
10460
+ }
10461
+ };
10462
+ window.addEventListener("keydown", onKey, { capture: true });
10463
+ return () => window.removeEventListener("keydown", onKey, { capture: true });
10464
+ }, [onCancel]);
10465
+ const handleInputChange = (next) => {
10466
+ setInputValue(next);
10467
+ try {
10468
+ const pages = parsePageRange(next, totalPages);
10469
+ setInputError(null);
10470
+ setSelectedSet(new Set(pages));
10471
+ } catch (e) {
10472
+ setInputError(e.message);
10473
+ }
10474
+ };
10475
+ const toggleThumb = (pageNum) => {
10476
+ setSelectedSet((prev) => {
10477
+ const next = new Set(prev);
10478
+ if (next.has(pageNum)) next.delete(pageNum);
10479
+ else next.add(pageNum);
10480
+ const serialized = serializeSelection([...next]);
10481
+ setInputValue(serialized);
10482
+ setInputError(null);
10483
+ return next;
10484
+ });
10485
+ };
10486
+ const selectAll = () => {
10487
+ setSelectedSet(new Set(defaultPages));
10488
+ setInputValue(serializeSelection(defaultPages));
10489
+ setInputError(null);
10490
+ };
10491
+ const clearAll = () => {
10492
+ setSelectedSet(/* @__PURE__ */ new Set());
10493
+ setInputValue("");
10494
+ setInputError(null);
10495
+ };
10496
+ const canSubmit = inputError === null && selectedSet.size > 0;
10497
+ const sortedSelected = React8.useMemo(
10498
+ () => [...selectedSet].sort((a, b) => a - b),
10499
+ [selectedSet]
10500
+ );
10501
+ const handleSubmit = () => {
10502
+ if (!canSubmit) return;
10503
+ onConfirm(sortedSelected);
10504
+ };
10505
+ return reactDom.createPortal(
10506
+ /* @__PURE__ */ jsxRuntime.jsx(
10507
+ "div",
10508
+ {
10509
+ role: "dialog",
10510
+ "aria-modal": "true",
10511
+ "aria-labelledby": "pdf-range-title",
10512
+ style: {
10513
+ position: "fixed",
10514
+ inset: 0,
10515
+ background: "rgba(0,0,0,0.55)",
10516
+ display: "flex",
10517
+ alignItems: "center",
10518
+ justifyContent: "center",
10519
+ zIndex: 1e4
10520
+ },
10521
+ onClick: (e) => {
10522
+ if (e.target === e.currentTarget) onCancel();
10523
+ },
10524
+ children: /* @__PURE__ */ jsxRuntime.jsxs(
10525
+ "div",
10526
+ {
10527
+ style: {
10528
+ background: "var(--popup-bg-color, #fff)",
10529
+ color: "var(--text-primary-color, #1b1b1f)",
10530
+ borderRadius: 12,
10531
+ padding: "20px 22px",
10532
+ width: "min(880px, 92vw)",
10533
+ maxHeight: "88vh",
10534
+ boxShadow: "0 12px 40px rgba(0,0,0,0.3)",
10535
+ fontFamily: "inherit",
10536
+ display: "flex",
10537
+ flexDirection: "column",
10538
+ gap: 12
10539
+ },
10540
+ onClick: (e) => e.stopPropagation(),
10541
+ children: [
10542
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
10543
+ /* @__PURE__ */ jsxRuntime.jsx(
10544
+ "h2",
10545
+ {
10546
+ id: "pdf-range-title",
10547
+ style: { margin: 0, fontSize: 16, fontWeight: 600, lineHeight: 1.3 },
10548
+ children: "Ch\xE8n PDF"
10549
+ }
10550
+ ),
10551
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { style: { margin: "4px 0 0", fontSize: 12, opacity: 0.7 }, children: [
10552
+ fileName,
10553
+ " \u2014 ",
10554
+ totalPages,
10555
+ " trang",
10556
+ thumbProgress < totalPages && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
10557
+ " \xB7 \u0111ang t\u1EA3i preview ",
10558
+ thumbProgress,
10559
+ "/",
10560
+ totalPages,
10561
+ "\u2026"
10562
+ ] })
10563
+ ] })
10564
+ ] }),
10565
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", alignItems: "flex-start", gap: 10 }, children: [
10566
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { flex: 1 }, children: [
10567
+ /* @__PURE__ */ jsxRuntime.jsx(
10568
+ "label",
10569
+ {
10570
+ style: { display: "block", fontSize: 12, marginBottom: 4, opacity: 0.75 },
10571
+ children: "Trang c\u1EA7n ch\xE8n (vd: 1,3,5-10) \u2014 ho\u1EB7c click thumbnail b\xEAn d\u01B0\u1EDBi"
10572
+ }
10573
+ ),
10574
+ /* @__PURE__ */ jsxRuntime.jsx(
10575
+ "input",
10576
+ {
10577
+ ref: inputRef,
10578
+ type: "text",
10579
+ value: inputValue,
10580
+ onChange: (e) => handleInputChange(e.target.value),
10581
+ onKeyDown: (e) => {
10582
+ if (e.key === "Enter") {
10583
+ e.preventDefault();
10584
+ handleSubmit();
10585
+ }
10586
+ },
10587
+ style: {
10588
+ width: "100%",
10589
+ boxSizing: "border-box",
10590
+ padding: "8px 10px",
10591
+ fontSize: 14,
10592
+ borderRadius: 6,
10593
+ border: `1px solid ${inputError ? "#dc2626" : "rgba(0,0,0,0.2)"}`,
10594
+ outline: "none",
10595
+ background: "var(--input-bg-color, #fff)",
10596
+ color: "inherit",
10597
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace"
10598
+ }
10599
+ }
10600
+ )
10601
+ ] }),
10602
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", gap: 6, paddingTop: 18 }, children: [
10603
+ /* @__PURE__ */ jsxRuntime.jsx(
10604
+ "button",
10605
+ {
10606
+ type: "button",
10607
+ onClick: selectAll,
10608
+ style: quickBtnStyle,
10609
+ title: "Ch\u1ECDn t\u1EA5t c\u1EA3 trang",
10610
+ children: "T\u1EA5t c\u1EA3"
10611
+ }
10612
+ ),
10613
+ /* @__PURE__ */ jsxRuntime.jsx(
10614
+ "button",
10615
+ {
10616
+ type: "button",
10617
+ onClick: clearAll,
10618
+ style: quickBtnStyle,
10619
+ title: "B\u1ECF ch\u1ECDn t\u1EA5t c\u1EA3",
10620
+ children: "B\u1ECF h\u1EBFt"
10621
+ }
10622
+ )
10623
+ ] })
10624
+ ] }),
10625
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { minHeight: 18, fontSize: 12 }, "data-testid": "pdf-range-status", children: inputError ? /* @__PURE__ */ jsxRuntime.jsx("span", { style: { color: "#dc2626" }, children: inputError }) : /* @__PURE__ */ jsxRuntime.jsxs("span", { style: { opacity: 0.75 }, children: [
10626
+ "\u0110\xE3 ch\u1ECDn ",
10627
+ /* @__PURE__ */ jsxRuntime.jsx("strong", { children: selectedSet.size }),
10628
+ " / ",
10629
+ totalPages,
10630
+ " trang"
10631
+ ] }) }),
10632
+ /* @__PURE__ */ jsxRuntime.jsx(
10633
+ "div",
10634
+ {
10635
+ style: {
10636
+ flex: 1,
10637
+ minHeight: 240,
10638
+ maxHeight: "60vh",
10639
+ overflow: "auto",
10640
+ padding: 8,
10641
+ background: "rgba(0,0,0,0.04)",
10642
+ borderRadius: 8,
10643
+ display: "grid",
10644
+ gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))",
10645
+ gap: 10,
10646
+ alignContent: "start"
10647
+ },
10648
+ children: Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => {
10649
+ const thumb = thumbs[pageNum];
10650
+ const selected = selectedSet.has(pageNum);
10651
+ return /* @__PURE__ */ jsxRuntime.jsx(
10652
+ ThumbnailItem,
10653
+ {
10654
+ pageNum,
10655
+ thumb,
10656
+ selected,
10657
+ onToggle: () => toggleThumb(pageNum)
10658
+ },
10659
+ pageNum
10660
+ );
10661
+ })
10662
+ }
10663
+ ),
10664
+ /* @__PURE__ */ jsxRuntime.jsxs(
10665
+ "div",
10666
+ {
10667
+ style: {
10668
+ display: "flex",
10669
+ justifyContent: "flex-end",
10670
+ gap: 8,
10671
+ paddingTop: 4
10672
+ },
10673
+ children: [
10674
+ /* @__PURE__ */ jsxRuntime.jsx(
10675
+ "button",
10676
+ {
10677
+ type: "button",
10678
+ onClick: onCancel,
10679
+ style: {
10680
+ padding: "8px 14px",
10681
+ fontSize: 13,
10682
+ borderRadius: 6,
10683
+ border: "1px solid rgba(0,0,0,0.15)",
10684
+ background: "transparent",
10685
+ color: "inherit",
10686
+ cursor: "pointer"
10687
+ },
10688
+ children: "Hu\u1EF7"
10689
+ }
10690
+ ),
10691
+ /* @__PURE__ */ jsxRuntime.jsxs(
10692
+ "button",
10693
+ {
10694
+ type: "button",
10695
+ onClick: handleSubmit,
10696
+ disabled: !canSubmit,
10697
+ style: {
10698
+ padding: "8px 16px",
10699
+ fontSize: 13,
10700
+ borderRadius: 6,
10701
+ border: "none",
10702
+ background: canSubmit ? "#4f46e5" : "rgba(0,0,0,0.15)",
10703
+ color: "#fff",
10704
+ cursor: canSubmit ? "pointer" : "not-allowed",
10705
+ fontWeight: 500
10706
+ },
10707
+ children: [
10708
+ "Ch\xE8n ",
10709
+ selectedSet.size > 0 ? `${selectedSet.size} trang` : ""
10710
+ ]
10711
+ }
10712
+ )
10713
+ ]
10714
+ }
10715
+ )
10716
+ ]
10717
+ }
10718
+ )
10719
+ }
10720
+ ),
10721
+ document.body
10722
+ );
10723
+ }
10724
+ var quickBtnStyle = {
10725
+ padding: "7px 10px",
10726
+ fontSize: 12,
10727
+ borderRadius: 6,
10728
+ border: "1px solid rgba(0,0,0,0.15)",
10729
+ background: "transparent",
10730
+ color: "inherit",
10731
+ cursor: "pointer",
10732
+ whiteSpace: "nowrap"
10733
+ };
10734
+ function ThumbnailItem({ pageNum, thumb, selected, onToggle }) {
10735
+ const aspect = thumb ? thumb.width / thumb.height : 0.77;
10736
+ return /* @__PURE__ */ jsxRuntime.jsxs(
10737
+ "button",
10738
+ {
10739
+ type: "button",
10740
+ onClick: onToggle,
10741
+ "aria-pressed": selected,
10742
+ "aria-label": `Trang ${pageNum}${selected ? " (\u0111\xE3 ch\u1ECDn)" : ""}`,
10743
+ title: `Trang ${pageNum}`,
10744
+ style: {
10745
+ position: "relative",
10746
+ padding: 0,
10747
+ background: "#fff",
10748
+ border: `2px solid ${selected ? "#4f46e5" : "rgba(0,0,0,0.12)"}`,
10749
+ borderRadius: 6,
10750
+ overflow: "hidden",
10751
+ cursor: "pointer",
10752
+ boxShadow: selected ? "0 0 0 3px rgba(79,70,229,0.18)" : "none",
10753
+ transition: "border-color 80ms ease, box-shadow 80ms ease"
10754
+ },
10755
+ children: [
10756
+ /* @__PURE__ */ jsxRuntime.jsx(
10757
+ "div",
10758
+ {
10759
+ style: {
10760
+ width: "100%",
10761
+ aspectRatio: aspect.toString(),
10762
+ background: "#f5f5f5",
10763
+ display: "flex",
10764
+ alignItems: "center",
10765
+ justifyContent: "center"
10766
+ },
10767
+ children: thumb ? (
10768
+ // eslint-disable-next-line @next/next/no-img-element
10769
+ /* @__PURE__ */ jsxRuntime.jsx(
10770
+ "img",
10771
+ {
10772
+ src: thumb.dataURL,
10773
+ alt: "",
10774
+ style: { width: "100%", height: "100%", display: "block", objectFit: "contain" },
10775
+ draggable: false
10776
+ }
10777
+ )
10778
+ ) : /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 11, opacity: 0.5 }, children: "\u2026" })
10779
+ }
10780
+ ),
10781
+ /* @__PURE__ */ jsxRuntime.jsx(
10782
+ "div",
10783
+ {
10784
+ style: {
10785
+ position: "absolute",
10786
+ bottom: 4,
10787
+ left: 4,
10788
+ fontSize: 10,
10789
+ fontWeight: 600,
10790
+ padding: "2px 6px",
10791
+ borderRadius: 4,
10792
+ background: selected ? "#4f46e5" : "rgba(0,0,0,0.6)",
10793
+ color: "#fff"
10794
+ },
10795
+ children: pageNum
10796
+ }
10797
+ ),
10798
+ selected && /* @__PURE__ */ jsxRuntime.jsx(
10799
+ "div",
10800
+ {
10801
+ "aria-hidden": "true",
10802
+ style: {
10803
+ position: "absolute",
10804
+ top: 4,
10805
+ right: 4,
10806
+ width: 18,
10807
+ height: 18,
10808
+ borderRadius: "50%",
10809
+ background: "#4f46e5",
10810
+ color: "#fff",
10811
+ display: "flex",
10812
+ alignItems: "center",
10813
+ justifyContent: "center",
10814
+ fontSize: 11,
10815
+ fontWeight: 700,
10816
+ boxShadow: "0 1px 3px rgba(0,0,0,0.3)"
10817
+ },
10818
+ children: "\u2713"
10819
+ }
10820
+ )
10821
+ ]
10822
+ }
10823
+ );
10824
+ }
10825
+
10826
+ // src/pdf/insertPdfPages.ts
10827
+ var PAGE_GAP = 24;
10828
+ var DEFAULT_SCALE = 2;
10829
+ function insertRasterizedPagesIntoScene(api, rendered, options) {
10830
+ if (!api) throw new Error("Excalidraw API ch\u01B0a s\u1EB5n s\xE0ng.");
10831
+ if (rendered.length === 0) return { insertedElementIds: [], fileIds: [] };
10832
+ const { scale: scale3 } = options;
10833
+ const filesPayload = rendered.map((p) => ({
10834
+ id: generateFileId(),
10835
+ dataURL: p.dataURL,
10836
+ mimeType: p.mimeType,
10837
+ created: Date.now()
10838
+ }));
10839
+ api.addFiles(filesPayload);
10840
+ const origin = options.origin ?? getViewportCenter(api);
10841
+ const sceneSizes = rendered.map((p) => pixelsToSceneSize(p.width, p.height, scale3));
10842
+ const maxSceneWidth = Math.max(...sceneSizes.map((s) => s.width));
10843
+ const baseX = origin.x - maxSceneWidth / 2;
10844
+ let cursorY = origin.y - sceneSizes[0].height / 2;
10845
+ const newElements = rendered.map((_, i) => {
10846
+ const { width, height } = sceneSizes[i];
10847
+ const x = baseX + (maxSceneWidth - width) / 2;
10848
+ const y = cursorY;
10849
+ cursorY = y + height + PAGE_GAP;
10850
+ return buildPdfImageElement(filesPayload[i].id, x, y, width, height);
10851
+ });
10852
+ const existing = api.getSceneElements();
10853
+ api.updateScene({
10854
+ elements: [...existing, ...newElements],
10855
+ appState: { selectedElementIds: {}, croppingElementId: null }
10856
+ });
10857
+ return {
10858
+ insertedElementIds: newElements.map((e) => e.id),
10859
+ fileIds: filesPayload.map((f) => f.id)
10860
+ };
10861
+ }
10862
+ function pixelsToSceneSize(pxWidth, pxHeight, scale3) {
10863
+ return { width: pxWidth / scale3, height: pxHeight / scale3 };
10864
+ }
10865
+ function buildPdfImageElement(fileId, x, y, width, height) {
10866
+ return {
10867
+ type: "image",
10868
+ id: "pdf_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8),
10869
+ x,
10870
+ y,
10871
+ width,
10872
+ height,
10873
+ fileId,
10874
+ angle: 0,
10875
+ strokeColor: "transparent",
10876
+ backgroundColor: "transparent",
10877
+ fillStyle: "solid",
10878
+ strokeWidth: 1,
10879
+ strokeStyle: "solid",
10880
+ roughness: 0,
10881
+ opacity: 100,
10882
+ groupIds: [],
10883
+ roundness: null,
10884
+ seed: Math.floor(Math.random() * 1e9),
10885
+ versionNonce: 0,
10886
+ version: 1,
10887
+ isDeleted: false,
10888
+ boundElements: null,
10889
+ updated: Date.now(),
10890
+ link: null,
10891
+ locked: false,
10892
+ status: "saved",
10893
+ scale: [1, 1]
10894
+ };
10895
+ }
10896
+ function generateFileId() {
10897
+ return "pdf_" + Date.now() + "_" + Math.random().toString(36).slice(2, 10);
10898
+ }
10899
+ function getViewportCenter(api) {
10900
+ const appState = api?.getAppState?.() ?? {
10901
+ scrollX: 0,
10902
+ scrollY: 0,
10903
+ width: 800,
10904
+ height: 600,
10905
+ zoom: { value: 1 }
10906
+ };
10907
+ const zoom = appState.zoom?.value ?? 1;
10908
+ return {
10909
+ x: appState.scrollX + (appState.width ?? 800) / 2 / zoom,
10910
+ y: appState.scrollY + (appState.height ?? 600) / 2 / zoom
10911
+ };
10912
+ }
10913
+ async function insertPdfPages(api, source, options = {}) {
10914
+ if (!api) throw new Error("Excalidraw API ch\u01B0a s\u1EB5n s\xE0ng.");
10915
+ const scale3 = options.scale ?? DEFAULT_SCALE;
10916
+ const doc = await loadPdfDocument(source);
10917
+ let rendered;
10918
+ try {
10919
+ rendered = await rasterizePdf(doc, {
10920
+ pages: options.pages,
10921
+ scale: scale3,
10922
+ onProgress: options.onProgress,
10923
+ signal: options.signal
10924
+ });
10925
+ } finally {
10926
+ void closePdfDocument(doc);
10927
+ }
10928
+ const { insertedElementIds } = insertRasterizedPagesIntoScene(api, rendered, {
10929
+ scale: scale3,
10930
+ origin: options.origin
10931
+ });
10932
+ return { insertedElementIds, pages: rendered };
10933
+ }
10934
+ var DOUBLE_CLICK_MS = 400;
10935
+ function useStampDoubleClick({ enabled, stamps, onOpen }) {
10936
+ const lastClickRef = React8.useRef({
10937
+ time: 0,
10938
+ elementId: null
10939
+ });
10940
+ return React8.useCallback(
10941
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10942
+ (_activeTool, pointerDownState) => {
10943
+ if (!enabled) return;
10944
+ const hitElement = pointerDownState?.hit?.element;
10945
+ if (!hitElement || hitElement.type !== "image") return;
10946
+ const stamp = findStampForCustomData(hitElement.customData, stamps);
10947
+ if (!stamp) return;
10948
+ const now = Date.now();
10949
+ const isDouble = lastClickRef.current.elementId === hitElement.id && now - lastClickRef.current.time < DOUBLE_CLICK_MS;
10950
+ lastClickRef.current = { time: now, elementId: hitElement.id };
10951
+ if (!isDouble) return;
10952
+ onOpen(stamp.kind, {
10953
+ id: hitElement.id,
10954
+ customData: hitElement.customData
10955
+ });
10956
+ },
9978
10957
  [enabled, stamps, onOpen]
9979
10958
  );
9980
10959
  }
@@ -10420,6 +11399,8 @@ function Whiteboard({
10420
11399
  activeStampRef.current = activeStamp;
10421
11400
  const [editingElement, setEditingElement] = React8.useState(null);
10422
11401
  const hostRef = React8.useRef(null);
11402
+ const [pdfPending, setPdfPending] = React8.useState(null);
11403
+ const [pdfBusy, setPdfBusy] = React8.useState(false);
10423
11404
  const handledCropIdRef = React8.useRef(null);
10424
11405
  const prevExcalidrawToolRef = React8.useRef("selection");
10425
11406
  const stampByKind = React8.useMemo(() => {
@@ -10708,6 +11689,80 @@ function Whiteboard({
10708
11689
  return () => window.removeEventListener("keydown", onKey, { capture: true });
10709
11690
  }, [activeStamp, closeStamp]);
10710
11691
  useStampClickOutside({ activeStamp, hostRef, onClose: closeStamp });
11692
+ const handlePdfPick = React8.useCallback(
11693
+ async (file) => {
11694
+ if (readOnly || pdfBusy) return;
11695
+ setPdfBusy(true);
11696
+ try {
11697
+ const doc = await loadPdfDocument(file);
11698
+ setPdfPending({ doc, fileName: file.name, totalPages: doc.numPages });
11699
+ } catch (err) {
11700
+ console.warn("[whiteboard] \u0110\u1ECDc PDF th\u1EA5t b\u1EA1i:", err);
11701
+ window.alert("Kh\xF4ng \u0111\u1ECDc \u0111\u01B0\u1EE3c PDF. File c\xF3 th\u1EC3 \u0111\xE3 h\u1ECFng ho\u1EB7c b\u1ECB m\u1EADt kh\u1EA9u b\u1EA3o v\u1EC7.");
11702
+ } finally {
11703
+ setPdfBusy(false);
11704
+ }
11705
+ },
11706
+ [readOnly, pdfBusy]
11707
+ );
11708
+ const handlePdfConfirm = React8.useCallback(
11709
+ async (pages) => {
11710
+ if (!pdfPending || !api) return;
11711
+ const { doc } = pdfPending;
11712
+ setPdfPending(null);
11713
+ setPdfBusy(true);
11714
+ const scale3 = 2;
11715
+ try {
11716
+ const rendered = await rasterizePdf(doc, { pages, scale: scale3 });
11717
+ await closePdfDocument(doc);
11718
+ insertRasterizedPagesIntoScene(api, rendered, { scale: scale3 });
11719
+ } catch (err) {
11720
+ console.warn("[whiteboard] Ch\xE8n PDF th\u1EA5t b\u1EA1i:", err);
11721
+ window.alert("Ch\xE8n PDF th\u1EA5t b\u1EA1i. Xem console \u0111\u1EC3 bi\u1EBFt chi ti\u1EBFt.");
11722
+ } finally {
11723
+ setPdfBusy(false);
11724
+ }
11725
+ },
11726
+ [pdfPending, api]
11727
+ );
11728
+ const handlePdfCancel = React8.useCallback(() => {
11729
+ if (pdfPending) {
11730
+ void closePdfDocument(pdfPending.doc);
11731
+ }
11732
+ setPdfPending(null);
11733
+ }, [pdfPending]);
11734
+ React8.useEffect(() => {
11735
+ if (readOnly) return;
11736
+ const root = document.querySelector(".excalidraw");
11737
+ if (!root) return;
11738
+ const onDragOver = (e) => {
11739
+ const items = e.dataTransfer?.items;
11740
+ if (!items) return;
11741
+ for (let i = 0; i < items.length; i++) {
11742
+ if (items[i].kind === "file" && items[i].type === "application/pdf") {
11743
+ e.preventDefault();
11744
+ e.stopPropagation();
11745
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
11746
+ return;
11747
+ }
11748
+ }
11749
+ };
11750
+ const onDrop = (e) => {
11751
+ const files = e.dataTransfer?.files;
11752
+ if (!files || files.length === 0) return;
11753
+ const pdf = Array.from(files).find((f) => f.type === "application/pdf");
11754
+ if (!pdf) return;
11755
+ e.preventDefault();
11756
+ e.stopPropagation();
11757
+ void handlePdfPick(pdf);
11758
+ };
11759
+ root.addEventListener("dragover", onDragOver, { capture: true });
11760
+ root.addEventListener("drop", onDrop, { capture: true });
11761
+ return () => {
11762
+ root.removeEventListener("dragover", onDragOver, { capture: true });
11763
+ root.removeEventListener("drop", onDrop, { capture: true });
11764
+ };
11765
+ }, [readOnly, handlePdfPick, api]);
10711
11766
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `relative h-full w-full${isDarkTheme ? " theme--dark" : ""}`, children: [
10712
11767
  /* @__PURE__ */ jsxRuntime.jsx(React8.Suspense, { fallback: /* @__PURE__ */ jsxRuntime.jsx(ExcalidrawLoadingFallback, {}), children: /* @__PURE__ */ jsxRuntime.jsx(
10713
11768
  Excalidraw2,
@@ -10742,6 +11797,35 @@ function Whiteboard({
10742
11797
  stamps
10743
11798
  }
10744
11799
  ),
11800
+ /* @__PURE__ */ jsxRuntime.jsx(PdfImporterButton, { enabled: !readOnly, onPick: handlePdfPick }),
11801
+ pdfPending && /* @__PURE__ */ jsxRuntime.jsx(
11802
+ PageRangeDialog,
11803
+ {
11804
+ doc: pdfPending.doc,
11805
+ fileName: pdfPending.fileName,
11806
+ onConfirm: handlePdfConfirm,
11807
+ onCancel: handlePdfCancel
11808
+ }
11809
+ ),
11810
+ pdfBusy && !pdfPending && /* @__PURE__ */ jsxRuntime.jsx(
11811
+ "div",
11812
+ {
11813
+ "aria-live": "polite",
11814
+ role: "status",
11815
+ style: {
11816
+ position: "fixed",
11817
+ bottom: 16,
11818
+ right: 16,
11819
+ padding: "8px 14px",
11820
+ background: "rgba(0,0,0,0.75)",
11821
+ color: "#fff",
11822
+ borderRadius: 6,
11823
+ fontSize: 12,
11824
+ zIndex: 1e4
11825
+ },
11826
+ children: "\u0110ang x\u1EED l\xFD PDF\u2026"
11827
+ }
11828
+ ),
10745
11829
  HostComponent && /* @__PURE__ */ jsxRuntime.jsx(
10746
11830
  HostComponent,
10747
11831
  {
@@ -10760,17 +11844,24 @@ exports.DEFAULT_STAMPS = DEFAULT_STAMPS;
10760
11844
  exports.EXPERIMENTAL_STAMPS = EXPERIMENTAL_STAMPS;
10761
11845
  exports.STABLE_STAMPS = STABLE_STAMPS;
10762
11846
  exports.Whiteboard = Whiteboard;
11847
+ exports.closePdfDocument = closePdfDocument;
11848
+ exports.configurePdfWorker = configurePdfWorker;
10763
11849
  exports.findStampForCustomData = findStampForCustomData;
10764
11850
  exports.geometry3dStamp = geometry3dStamp;
10765
11851
  exports.geometryStamp = geometryStamp;
10766
11852
  exports.graph2dStamp = graph2dStamp;
11853
+ exports.insertPdfPages = insertPdfPages;
11854
+ exports.insertRasterizedPagesIntoScene = insertRasterizedPagesIntoScene;
10767
11855
  exports.isGeometry3DCustomData = isGeometry3DCustomData;
10768
11856
  exports.isGeometryCustomData = isGeometryCustomData;
10769
11857
  exports.isGraph2DCustomData = isGraph2DCustomData;
10770
11858
  exports.isLatexCustomData = isLatexCustomData;
10771
11859
  exports.isStampElement = isStampElement;
10772
11860
  exports.latexStamp = latexStamp;
11861
+ exports.loadPdfDocument = loadPdfDocument;
11862
+ exports.parsePageRange = parsePageRange;
10773
11863
  exports.pickSyncableAppState = pickSyncableAppState;
11864
+ exports.rasterizePdf = rasterizePdf;
10774
11865
  exports.restoreMissingStampFiles = restoreMissingStampFiles;
10775
11866
  //# sourceMappingURL=index.js.map
10776
11867
  //# sourceMappingURL=index.js.map