@xom11/whiteboard 0.7.0 → 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.
Files changed (50) hide show
  1. package/README.md +51 -1
  2. package/dist/chunk-74VEEZBV.mjs +619 -0
  3. package/dist/chunk-74VEEZBV.mjs.map +1 -0
  4. package/dist/{chunk-BJX4YNA5.mjs → chunk-G7FR3AIV.mjs} +68 -12
  5. package/dist/chunk-G7FR3AIV.mjs.map +1 -0
  6. package/dist/{chunk-SHFOGORM.mjs → chunk-PDKKDZ4H.mjs} +4 -4
  7. package/dist/{chunk-SHFOGORM.mjs.map → chunk-PDKKDZ4H.mjs.map} +1 -1
  8. package/dist/chunk-PWIMZIB6.mjs +62 -0
  9. package/dist/chunk-PWIMZIB6.mjs.map +1 -0
  10. package/dist/{chunk-LPM4MM45.mjs → chunk-SBDMF4NQ.mjs} +3 -2
  11. package/dist/chunk-SBDMF4NQ.mjs.map +1 -0
  12. package/dist/chunk-WQOABS6N.mjs +197 -0
  13. package/dist/chunk-WQOABS6N.mjs.map +1 -0
  14. package/dist/{chunk-3SSQKRRO.mjs → chunk-ZVN356JZ.mjs} +4 -4
  15. package/dist/{chunk-3SSQKRRO.mjs.map → chunk-ZVN356JZ.mjs.map} +1 -1
  16. package/dist/geometry-2d.js +344 -228
  17. package/dist/geometry-2d.js.map +1 -1
  18. package/dist/geometry-2d.mjs +2 -2
  19. package/dist/geometry-3d.d.mts +1 -1
  20. package/dist/geometry-3d.d.ts +1 -1
  21. package/dist/geometry-3d.js +3411 -1277
  22. package/dist/geometry-3d.js.map +1 -1
  23. package/dist/geometry-3d.mjs +3 -2
  24. package/dist/graph-2d.js +360 -66
  25. package/dist/graph-2d.js.map +1 -1
  26. package/dist/graph-2d.mjs +2 -2
  27. package/dist/{host-T2W6R6SO.mjs → host-DJETSFCG.mjs} +272 -223
  28. package/dist/host-DJETSFCG.mjs.map +1 -0
  29. package/dist/{host-2QGKMGCT.mjs → host-LZH2FZ2N.mjs} +3 -3
  30. package/dist/{host-2QGKMGCT.mjs.map → host-LZH2FZ2N.mjs.map} +1 -1
  31. package/dist/host-N6ACNJKI.mjs +3226 -0
  32. package/dist/host-N6ACNJKI.mjs.map +1 -0
  33. package/dist/index.d.mts +133 -6
  34. package/dist/index.d.ts +133 -6
  35. package/dist/index.js +5634 -1999
  36. package/dist/index.js.map +1 -1
  37. package/dist/index.mjs +1231 -146
  38. package/dist/index.mjs.map +1 -1
  39. package/package.json +9 -6
  40. package/dist/chunk-BJX4YNA5.mjs.map +0 -1
  41. package/dist/chunk-DJTBZEAR.mjs +0 -25
  42. package/dist/chunk-DJTBZEAR.mjs.map +0 -1
  43. package/dist/chunk-HM7RIXJE.mjs +0 -331
  44. package/dist/chunk-HM7RIXJE.mjs.map +0 -1
  45. package/dist/chunk-HYXFHEDJ.mjs +0 -129
  46. package/dist/chunk-HYXFHEDJ.mjs.map +0 -1
  47. package/dist/chunk-LPM4MM45.mjs.map +0 -1
  48. package/dist/host-T2W6R6SO.mjs.map +0 -1
  49. package/dist/host-XUFON6CQ.mjs +0 -1422
  50. package/dist/host-XUFON6CQ.mjs.map +0 -1
package/dist/index.mjs CHANGED
@@ -1,17 +1,17 @@
1
1
  "use client";
2
2
  import './index.css';
3
- import { geometryStamp } from './chunk-SHFOGORM.mjs';
4
- export { geometryStamp } from './chunk-SHFOGORM.mjs';
5
- import { geometry3dStamp } from './chunk-HYXFHEDJ.mjs';
6
- export { geometry3dStamp } from './chunk-HYXFHEDJ.mjs';
3
+ import { geometryStamp } from './chunk-PDKKDZ4H.mjs';
4
+ export { geometryStamp } from './chunk-PDKKDZ4H.mjs';
5
+ import { geometry3dStamp } from './chunk-PWIMZIB6.mjs';
6
+ export { geometry3dStamp } from './chunk-PWIMZIB6.mjs';
7
7
  import { latexStamp } from './chunk-7P7SQFOW.mjs';
8
8
  export { latexStamp } from './chunk-7P7SQFOW.mjs';
9
- import { graph2dStamp } from './chunk-3SSQKRRO.mjs';
10
- export { graph2dStamp } from './chunk-3SSQKRRO.mjs';
11
- export { isGraph2DCustomData } from './chunk-HM7RIXJE.mjs';
12
- export { isGeometryCustomData } from './chunk-BJX4YNA5.mjs';
9
+ import { graph2dStamp } from './chunk-ZVN356JZ.mjs';
10
+ export { graph2dStamp } from './chunk-ZVN356JZ.mjs';
11
+ export { isGraph2DCustomData } from './chunk-74VEEZBV.mjs';
12
+ export { isGeometryCustomData } from './chunk-G7FR3AIV.mjs';
13
13
  export { isLatexCustomData } from './chunk-X5R72SSJ.mjs';
14
- export { isGeometry3DCustomData } from './chunk-DJTBZEAR.mjs';
14
+ export { isGeometry3DCustomData } from './chunk-WQOABS6N.mjs';
15
15
  import './chunk-HTBLO5JO.mjs';
16
16
  import './chunk-C6SCVOMC.mjs';
17
17
  import './chunk-BJTO5JO5.mjs';
@@ -45,7 +45,7 @@ var ALL_STAMPS = Object.freeze([
45
45
  ...STABLE_STAMPS,
46
46
  ...EXPERIMENTAL_STAMPS
47
47
  ]);
48
- var DEFAULT_STAMPS = STABLE_STAMPS;
48
+ var DEFAULT_STAMPS = ALL_STAMPS;
49
49
  function findStampForCustomData(data, stamps = DEFAULT_STAMPS) {
50
50
  for (const s of stamps) {
51
51
  if (s.matchesCustomData(data)) return s;
@@ -77,6 +77,7 @@ function ToolbarInjector({
77
77
  let cancelled = false;
78
78
  let observer = null;
79
79
  let rafId = null;
80
+ let observedRoot = null;
80
81
  const apply = (next) => {
81
82
  if (cancelled || menuMountRef.current === next) return;
82
83
  menuMountRef.current = next;
@@ -102,21 +103,38 @@ function ToolbarInjector({
102
103
  }
103
104
  apply(wrapper);
104
105
  };
105
- const schedule = () => {
106
+ const attachObserver = () => {
107
+ if (cancelled) return;
108
+ const excalidraw = document.querySelector(".excalidraw");
109
+ const nextRoot = excalidraw ?? document.body;
110
+ if (observedRoot === nextRoot) return;
111
+ observer?.disconnect();
112
+ observedRoot = nextRoot;
113
+ observer = new MutationObserver(onMutation);
114
+ observer.observe(nextRoot, { childList: true, subtree: true });
115
+ };
116
+ const onMutation = () => {
106
117
  if (rafId != null) return;
107
118
  rafId = requestAnimationFrame(() => {
108
119
  rafId = null;
120
+ if (cancelled) return;
121
+ if (observedRoot !== document.querySelector(".excalidraw")) {
122
+ attachObserver();
123
+ }
109
124
  findMenu();
110
125
  });
111
126
  };
112
127
  findMenu();
113
- const root = document.querySelector(".excalidraw") ?? document.body;
114
- observer = new MutationObserver(schedule);
115
- observer.observe(root, { childList: true, subtree: true });
128
+ attachObserver();
116
129
  return () => {
117
130
  cancelled = true;
118
- if (rafId != null) cancelAnimationFrame(rafId);
131
+ if (rafId != null) {
132
+ cancelAnimationFrame(rafId);
133
+ rafId = null;
134
+ }
119
135
  observer?.disconnect();
136
+ observer = null;
137
+ observedRoot = null;
120
138
  document.getElementById(MENU_WRAPPER_ID)?.remove();
121
139
  };
122
140
  }, [enabled]);
@@ -129,20 +147,28 @@ function ToolbarInjector({
129
147
  };
130
148
  return createPortal(
131
149
  /* @__PURE__ */ jsxs(Fragment, { children: [
132
- stamps.map((stamp) => /* @__PURE__ */ jsx(
133
- StampMenuItem,
134
- {
135
- icon: stamp.toolbarIcon,
136
- label: stamp.toolbarTitle,
137
- active: activeStampKind === stamp.kind,
138
- onClick: () => {
139
- onToggle(stamp.kind);
140
- closePopover();
150
+ stamps.map((stamp) => {
151
+ const { displayLabel, shortcut } = splitTitleAndShortcut(
152
+ stamp.toolbarTitle,
153
+ stamp.toolbarLabel
154
+ );
155
+ return /* @__PURE__ */ jsx(
156
+ StampMenuItem,
157
+ {
158
+ icon: stamp.toolbarIcon,
159
+ label: displayLabel,
160
+ ariaLabel: stamp.toolbarTitle,
161
+ shortcut,
162
+ active: activeStampKind === stamp.kind,
163
+ onClick: () => {
164
+ onToggle(stamp.kind);
165
+ closePopover();
166
+ },
167
+ dataTestId: stamp.toolbarTestId
141
168
  },
142
- dataTestId: stamp.toolbarTestId
143
- },
144
- stamp.kind
145
- )),
169
+ stamp.kind
170
+ );
171
+ }),
146
172
  /* @__PURE__ */ jsx(
147
173
  "div",
148
174
  {
@@ -158,7 +184,22 @@ function ToolbarInjector({
158
184
  menuMount
159
185
  );
160
186
  }
161
- function StampMenuItem({ icon, label, active, onClick, dataTestId }) {
187
+ function splitTitleAndShortcut(title, fallbackShortcut) {
188
+ const match = title.match(/^(.*?)\s*\(([^()]+)\)\s*$/);
189
+ if (match) {
190
+ return { displayLabel: match[1].trim(), shortcut: match[2].trim() };
191
+ }
192
+ return { displayLabel: title, shortcut: fallbackShortcut };
193
+ }
194
+ function StampMenuItem({
195
+ icon,
196
+ label,
197
+ ariaLabel,
198
+ shortcut,
199
+ active,
200
+ onClick,
201
+ dataTestId
202
+ }) {
162
203
  const className = [
163
204
  "dropdown-menu-item",
164
205
  "dropdown-menu-item-base",
@@ -169,39 +210,15 @@ function StampMenuItem({ icon, label, active, onClick, dataTestId }) {
169
210
  {
170
211
  type: "button",
171
212
  onClick,
172
- "aria-label": label,
213
+ title: ariaLabel,
214
+ "aria-label": ariaLabel,
173
215
  "aria-pressed": active,
174
216
  "data-testid": dataTestId,
175
217
  className,
176
- style: {
177
- display: "flex",
178
- alignItems: "center",
179
- columnGap: "0.625rem",
180
- width: "100%",
181
- boxSizing: "border-box",
182
- background: "transparent",
183
- border: "1px solid transparent",
184
- cursor: "pointer",
185
- fontFamily: "inherit",
186
- fontSize: "0.875rem",
187
- color: "var(--color-on-surface)"
188
- },
189
218
  children: [
190
- /* @__PURE__ */ jsx(
191
- "span",
192
- {
193
- "aria-hidden": "true",
194
- style: {
195
- display: "inline-flex",
196
- alignItems: "center",
197
- justifyContent: "center",
198
- width: "1rem",
199
- height: "1rem"
200
- },
201
- children: icon
202
- }
203
- ),
204
- /* @__PURE__ */ jsx("span", { children: label })
219
+ /* @__PURE__ */ jsx("div", { className: "dropdown-menu-item__icon", "aria-hidden": "true", children: icon }),
220
+ /* @__PURE__ */ jsx("div", { className: "dropdown-menu-item__text", children: label }),
221
+ shortcut ? /* @__PURE__ */ jsx("div", { className: "dropdown-menu-item__shortcut", children: shortcut }) : null
205
222
  ]
206
223
  }
207
224
  );
@@ -235,6 +252,843 @@ function useShortcuts({
235
252
  return () => window.removeEventListener("keydown", handler, { capture: true });
236
253
  }, [enabled, onToggle, stamps]);
237
254
  }
255
+ var WRAPPER_ID = "pdf-import-portal-wrapper";
256
+ var POPOVER_SELECTOR2 = ".App-toolbar__extra-tools-dropdown .dropdown-menu-container";
257
+ function PdfImporterButton({ enabled, onPick }) {
258
+ const [mount, setMount] = useState(null);
259
+ const mountRef = useRef(null);
260
+ const inputRef = useRef(null);
261
+ useEffect(() => {
262
+ if (!enabled) {
263
+ mountRef.current = null;
264
+ setMount(null);
265
+ document.getElementById(WRAPPER_ID)?.remove();
266
+ return;
267
+ }
268
+ let cancelled = false;
269
+ let observer = null;
270
+ let rafId = null;
271
+ let observedRoot = null;
272
+ const apply = (next) => {
273
+ if (cancelled || mountRef.current === next) return;
274
+ mountRef.current = next;
275
+ queueMicrotask(() => {
276
+ if (!cancelled) setMount(next);
277
+ });
278
+ };
279
+ const findMenu = () => {
280
+ if (cancelled) return;
281
+ const container = document.querySelector(POPOVER_SELECTOR2);
282
+ if (!container) {
283
+ apply(null);
284
+ return;
285
+ }
286
+ let wrapper = container.querySelector("#" + WRAPPER_ID);
287
+ if (!wrapper) {
288
+ wrapper = document.createElement("div");
289
+ wrapper.id = WRAPPER_ID;
290
+ wrapper.setAttribute("data-pdf-import", "true");
291
+ wrapper.style.display = "contents";
292
+ container.appendChild(wrapper);
293
+ }
294
+ apply(wrapper);
295
+ };
296
+ const attachObserver = () => {
297
+ if (cancelled) return;
298
+ const excalidraw = document.querySelector(".excalidraw");
299
+ const nextRoot = excalidraw ?? document.body;
300
+ if (observedRoot === nextRoot) return;
301
+ observer?.disconnect();
302
+ observedRoot = nextRoot;
303
+ observer = new MutationObserver(onMutation);
304
+ observer.observe(nextRoot, { childList: true, subtree: true });
305
+ };
306
+ const onMutation = () => {
307
+ if (rafId != null) return;
308
+ rafId = requestAnimationFrame(() => {
309
+ rafId = null;
310
+ if (cancelled) return;
311
+ if (observedRoot !== document.querySelector(".excalidraw")) {
312
+ attachObserver();
313
+ }
314
+ findMenu();
315
+ });
316
+ };
317
+ findMenu();
318
+ attachObserver();
319
+ return () => {
320
+ cancelled = true;
321
+ if (rafId != null) cancelAnimationFrame(rafId);
322
+ observer?.disconnect();
323
+ document.getElementById(WRAPPER_ID)?.remove();
324
+ };
325
+ }, [enabled]);
326
+ const closePopover = () => {
327
+ const trigger = document.querySelector(
328
+ ".App-toolbar__extra-tools-trigger"
329
+ );
330
+ trigger?.click();
331
+ };
332
+ const handleClick = () => {
333
+ inputRef.current?.click();
334
+ };
335
+ const handleFileChange = (e) => {
336
+ const file = e.target.files?.[0];
337
+ if (file) onPick(file);
338
+ e.target.value = "";
339
+ closePopover();
340
+ };
341
+ if (!enabled || !mount) {
342
+ return /* @__PURE__ */ jsx(
343
+ "input",
344
+ {
345
+ ref: inputRef,
346
+ type: "file",
347
+ accept: "application/pdf,.pdf",
348
+ style: { display: "none" },
349
+ onChange: handleFileChange
350
+ }
351
+ );
352
+ }
353
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
354
+ /* @__PURE__ */ jsx(
355
+ "input",
356
+ {
357
+ ref: inputRef,
358
+ type: "file",
359
+ accept: "application/pdf,.pdf",
360
+ style: { display: "none" },
361
+ onChange: handleFileChange
362
+ }
363
+ ),
364
+ createPortal(
365
+ /* @__PURE__ */ jsxs(
366
+ "button",
367
+ {
368
+ type: "button",
369
+ onClick: handleClick,
370
+ title: "Ch\xE8n PDF (P)",
371
+ "aria-label": "Ch\xE8n PDF",
372
+ "data-testid": "pdf-import-button",
373
+ className: "dropdown-menu-item dropdown-menu-item-base",
374
+ children: [
375
+ /* @__PURE__ */ jsx("div", { className: "dropdown-menu-item__icon", "aria-hidden": "true", children: /* @__PURE__ */ jsx(PdfIcon, {}) }),
376
+ /* @__PURE__ */ jsx("div", { className: "dropdown-menu-item__text", children: "Ch\xE8n PDF" }),
377
+ /* @__PURE__ */ jsx("div", { className: "dropdown-menu-item__shortcut", children: "P" })
378
+ ]
379
+ }
380
+ ),
381
+ mount
382
+ )
383
+ ] });
384
+ }
385
+ function PdfIcon() {
386
+ return /* @__PURE__ */ jsxs(
387
+ "svg",
388
+ {
389
+ width: "18",
390
+ height: "18",
391
+ viewBox: "0 0 24 24",
392
+ fill: "none",
393
+ stroke: "currentColor",
394
+ strokeWidth: "1.6",
395
+ strokeLinecap: "round",
396
+ strokeLinejoin: "round",
397
+ "aria-hidden": "true",
398
+ children: [
399
+ /* @__PURE__ */ jsx("path", { d: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" }),
400
+ /* @__PURE__ */ jsx("path", { d: "M14 3v5h5" }),
401
+ /* @__PURE__ */ jsx("text", { x: "7.5", y: "17", fontSize: "6", fontFamily: "sans-serif", fontWeight: "700", stroke: "none", fill: "currentColor", children: "PDF" })
402
+ ]
403
+ }
404
+ );
405
+ }
406
+
407
+ // src/pdf/parseRange.ts
408
+ function parsePageRange(input, totalPages) {
409
+ if (!Number.isInteger(totalPages) || totalPages <= 0) {
410
+ throw new Error("S\u1ED1 trang ph\u1EA3i l\xE0 s\u1ED1 nguy\xEAn d\u01B0\u01A1ng.");
411
+ }
412
+ const trimmed = input.trim();
413
+ if (trimmed === "") return [];
414
+ const tokens = trimmed.split(/[,\s]+/).map((t) => t.trim()).filter((t) => t.length > 0);
415
+ const set = /* @__PURE__ */ new Set();
416
+ for (const token of tokens) {
417
+ if (token.includes("-")) {
418
+ const parts = token.split("-");
419
+ if (parts.length !== 2) {
420
+ throw new Error(`Kho\u1EA3ng trang kh\xF4ng h\u1EE3p l\u1EC7: "${token}".`);
421
+ }
422
+ const start = parseStrictInt(parts[0]);
423
+ const end = parseStrictInt(parts[1]);
424
+ if (start === null || end === null) {
425
+ throw new Error(`Kho\u1EA3ng trang kh\xF4ng h\u1EE3p l\u1EC7: "${token}".`);
426
+ }
427
+ if (start > end) {
428
+ throw new Error(`Kho\u1EA3ng trang ng\u01B0\u1EE3c: "${token}" (\u0111\u1EA7u > cu\u1ED1i).`);
429
+ }
430
+ if (start < 1 || end > totalPages) {
431
+ throw new Error(
432
+ `Kho\u1EA3ng trang v\u01B0\u1EE3t gi\u1EDBi h\u1EA1n: "${token}". PDF c\xF3 ${totalPages} trang.`
433
+ );
434
+ }
435
+ for (let i = start; i <= end; i++) set.add(i);
436
+ } else {
437
+ const n = parseStrictInt(token);
438
+ if (n === null) {
439
+ throw new Error(`S\u1ED1 trang kh\xF4ng h\u1EE3p l\u1EC7: "${token}".`);
440
+ }
441
+ if (n < 1 || n > totalPages) {
442
+ throw new Error(
443
+ `S\u1ED1 trang v\u01B0\u1EE3t gi\u1EDBi h\u1EA1n: ${n}. PDF c\xF3 ${totalPages} trang.`
444
+ );
445
+ }
446
+ set.add(n);
447
+ }
448
+ }
449
+ return Array.from(set).sort((a, b) => a - b);
450
+ }
451
+ function parseStrictInt(s) {
452
+ if (!/^-?\d+$/.test(s)) return null;
453
+ const n = Number(s);
454
+ return Number.isInteger(n) ? n : null;
455
+ }
456
+
457
+ // src/pdf/rasterize.ts
458
+ var workerSrcOverride = null;
459
+ var pdfjsCache = null;
460
+ function configurePdfWorker(workerSrc) {
461
+ workerSrcOverride = workerSrc;
462
+ if (pdfjsCache) {
463
+ pdfjsCache.GlobalWorkerOptions.workerSrc = workerSrc;
464
+ }
465
+ }
466
+ async function loadPdfjs() {
467
+ if (pdfjsCache) return pdfjsCache;
468
+ const mod = await import('pdfjs-dist');
469
+ const workerSrc = workerSrcOverride ?? `https://cdn.jsdelivr.net/npm/pdfjs-dist@${mod.version}/build/pdf.worker.min.mjs`;
470
+ mod.GlobalWorkerOptions.workerSrc = workerSrc;
471
+ pdfjsCache = mod;
472
+ return mod;
473
+ }
474
+ async function loadPdfDocument(source) {
475
+ const pdfjs = await loadPdfjs();
476
+ const data = source instanceof ArrayBuffer ? source : await source.arrayBuffer();
477
+ const task = pdfjs.getDocument({ data: new Uint8Array(data) });
478
+ return task.promise;
479
+ }
480
+ async function closePdfDocument(doc) {
481
+ try {
482
+ await doc.cleanup();
483
+ await doc.destroy();
484
+ } catch {
485
+ }
486
+ }
487
+ async function rasterizePdf(doc, options = {}) {
488
+ const scale = options.scale ?? 2;
489
+ const total = doc.numPages;
490
+ const pages = options.pages ?? Array.from({ length: total }, (_, i) => i + 1);
491
+ const signal = options.signal;
492
+ const result = [];
493
+ for (let i = 0; i < pages.length; i++) {
494
+ if (signal?.aborted) {
495
+ throw new DOMException("Rasterize PDF b\u1ECB hu\u1EF7.", "AbortError");
496
+ }
497
+ const pageNum = pages[i];
498
+ const page = await doc.getPage(pageNum);
499
+ try {
500
+ const rendered = await renderPageToPng(page, scale);
501
+ result.push({ pageNumber: pageNum, mimeType: "image/png", ...rendered });
502
+ } finally {
503
+ page.cleanup();
504
+ }
505
+ options.onProgress?.(i + 1, pages.length);
506
+ }
507
+ return result;
508
+ }
509
+ async function renderPageToPng(page, scale) {
510
+ const viewport = page.getViewport({ scale });
511
+ const width = Math.ceil(viewport.width);
512
+ const height = Math.ceil(viewport.height);
513
+ const canvas = document.createElement("canvas");
514
+ canvas.width = width;
515
+ canvas.height = height;
516
+ const ctx = canvas.getContext("2d");
517
+ if (!ctx) throw new Error("Kh\xF4ng l\u1EA5y \u0111\u01B0\u1EE3c 2D context c\u1EE7a canvas.");
518
+ await page.render({ canvasContext: ctx, viewport, canvas }).promise;
519
+ const dataURL = canvas.toDataURL("image/png");
520
+ return { dataURL, width, height };
521
+ }
522
+ async function renderPageThumbnail(page, scale = 0.3, quality = 0.7) {
523
+ const viewport = page.getViewport({ scale });
524
+ const width = Math.ceil(viewport.width);
525
+ const height = Math.ceil(viewport.height);
526
+ const canvas = document.createElement("canvas");
527
+ canvas.width = width;
528
+ canvas.height = height;
529
+ const ctx = canvas.getContext("2d");
530
+ if (!ctx) throw new Error("Kh\xF4ng l\u1EA5y \u0111\u01B0\u1EE3c 2D context c\u1EE7a canvas.");
531
+ ctx.fillStyle = "#ffffff";
532
+ ctx.fillRect(0, 0, width, height);
533
+ await page.render({ canvasContext: ctx, viewport, canvas }).promise;
534
+ const dataURL = canvas.toDataURL("image/jpeg", quality);
535
+ return { dataURL, width, height };
536
+ }
537
+ async function renderAllThumbnails(doc, onEach, options = {}) {
538
+ const total = doc.numPages;
539
+ const scale = options.scale ?? 0.3;
540
+ const quality = options.quality ?? 0.7;
541
+ const concurrency = Math.max(1, options.concurrency ?? 3);
542
+ const signal = options.signal;
543
+ let next = 1;
544
+ async function worker() {
545
+ while (true) {
546
+ if (signal?.aborted) return;
547
+ const pageNum = next++;
548
+ if (pageNum > total) return;
549
+ const page = await doc.getPage(pageNum);
550
+ try {
551
+ if (signal?.aborted) return;
552
+ const { dataURL, width, height } = await renderPageThumbnail(page, scale, quality);
553
+ if (signal?.aborted) return;
554
+ onEach(pageNum, dataURL, width, height);
555
+ } finally {
556
+ page.cleanup();
557
+ }
558
+ }
559
+ }
560
+ await Promise.all(
561
+ Array.from({ length: Math.min(concurrency, total) }, () => worker())
562
+ );
563
+ }
564
+ function serializeSelection(pages) {
565
+ if (pages.length === 0) return "";
566
+ const sorted = [...pages].sort((a, b) => a - b);
567
+ const groups = [];
568
+ let start = sorted[0];
569
+ let prev = start;
570
+ for (let i = 1; i < sorted.length; i++) {
571
+ const n = sorted[i];
572
+ if (n === prev + 1) {
573
+ prev = n;
574
+ } else {
575
+ groups.push(start === prev ? `${start}` : `${start}-${prev}`);
576
+ start = n;
577
+ prev = n;
578
+ }
579
+ }
580
+ groups.push(start === prev ? `${start}` : `${start}-${prev}`);
581
+ return groups.join(",");
582
+ }
583
+ function PageRangeDialog({ doc, fileName, onConfirm, onCancel }) {
584
+ const totalPages = doc.numPages;
585
+ const defaultPages = useMemo(
586
+ () => Array.from({ length: totalPages }, (_, i) => i + 1),
587
+ [totalPages]
588
+ );
589
+ const [selectedSet, setSelectedSet] = useState(
590
+ () => new Set(defaultPages)
591
+ );
592
+ const [inputValue, setInputValue] = useState(serializeSelection(defaultPages));
593
+ const [inputError, setInputError] = useState(null);
594
+ const [thumbs, setThumbs] = useState({});
595
+ const [thumbProgress, setThumbProgress] = useState(0);
596
+ const inputRef = useRef(null);
597
+ useEffect(() => {
598
+ const ctrl = new AbortController();
599
+ void renderAllThumbnails(
600
+ doc,
601
+ (pageNum, dataURL, width, height) => {
602
+ setThumbs((prev) => ({ ...prev, [pageNum]: { dataURL, width, height } }));
603
+ setThumbProgress((prev) => prev + 1);
604
+ },
605
+ { scale: 0.3, quality: 0.7, concurrency: 3, signal: ctrl.signal }
606
+ ).catch((err) => {
607
+ if (ctrl.signal.aborted) return;
608
+ console.warn("[PageRangeDialog] render thumbnails l\u1ED7i:", err);
609
+ });
610
+ return () => ctrl.abort();
611
+ }, [doc]);
612
+ useEffect(() => {
613
+ const onKey = (e) => {
614
+ if (e.key === "Escape") {
615
+ e.preventDefault();
616
+ e.stopPropagation();
617
+ onCancel();
618
+ }
619
+ };
620
+ window.addEventListener("keydown", onKey, { capture: true });
621
+ return () => window.removeEventListener("keydown", onKey, { capture: true });
622
+ }, [onCancel]);
623
+ const handleInputChange = (next) => {
624
+ setInputValue(next);
625
+ try {
626
+ const pages = parsePageRange(next, totalPages);
627
+ setInputError(null);
628
+ setSelectedSet(new Set(pages));
629
+ } catch (e) {
630
+ setInputError(e.message);
631
+ }
632
+ };
633
+ const toggleThumb = (pageNum) => {
634
+ setSelectedSet((prev) => {
635
+ const next = new Set(prev);
636
+ if (next.has(pageNum)) next.delete(pageNum);
637
+ else next.add(pageNum);
638
+ const serialized = serializeSelection([...next]);
639
+ setInputValue(serialized);
640
+ setInputError(null);
641
+ return next;
642
+ });
643
+ };
644
+ const selectAll = () => {
645
+ setSelectedSet(new Set(defaultPages));
646
+ setInputValue(serializeSelection(defaultPages));
647
+ setInputError(null);
648
+ };
649
+ const clearAll = () => {
650
+ setSelectedSet(/* @__PURE__ */ new Set());
651
+ setInputValue("");
652
+ setInputError(null);
653
+ };
654
+ const canSubmit = inputError === null && selectedSet.size > 0;
655
+ const sortedSelected = useMemo(
656
+ () => [...selectedSet].sort((a, b) => a - b),
657
+ [selectedSet]
658
+ );
659
+ const handleSubmit = () => {
660
+ if (!canSubmit) return;
661
+ onConfirm(sortedSelected);
662
+ };
663
+ return createPortal(
664
+ /* @__PURE__ */ jsx(
665
+ "div",
666
+ {
667
+ role: "dialog",
668
+ "aria-modal": "true",
669
+ "aria-labelledby": "pdf-range-title",
670
+ style: {
671
+ position: "fixed",
672
+ inset: 0,
673
+ background: "rgba(0,0,0,0.55)",
674
+ display: "flex",
675
+ alignItems: "center",
676
+ justifyContent: "center",
677
+ zIndex: 1e4
678
+ },
679
+ onClick: (e) => {
680
+ if (e.target === e.currentTarget) onCancel();
681
+ },
682
+ children: /* @__PURE__ */ jsxs(
683
+ "div",
684
+ {
685
+ style: {
686
+ background: "var(--popup-bg-color, #fff)",
687
+ color: "var(--text-primary-color, #1b1b1f)",
688
+ borderRadius: 12,
689
+ padding: "20px 22px",
690
+ width: "min(880px, 92vw)",
691
+ maxHeight: "88vh",
692
+ boxShadow: "0 12px 40px rgba(0,0,0,0.3)",
693
+ fontFamily: "inherit",
694
+ display: "flex",
695
+ flexDirection: "column",
696
+ gap: 12
697
+ },
698
+ onClick: (e) => e.stopPropagation(),
699
+ children: [
700
+ /* @__PURE__ */ jsxs("div", { children: [
701
+ /* @__PURE__ */ jsx(
702
+ "h2",
703
+ {
704
+ id: "pdf-range-title",
705
+ style: { margin: 0, fontSize: 16, fontWeight: 600, lineHeight: 1.3 },
706
+ children: "Ch\xE8n PDF"
707
+ }
708
+ ),
709
+ /* @__PURE__ */ jsxs("p", { style: { margin: "4px 0 0", fontSize: 12, opacity: 0.7 }, children: [
710
+ fileName,
711
+ " \u2014 ",
712
+ totalPages,
713
+ " trang",
714
+ thumbProgress < totalPages && /* @__PURE__ */ jsxs(Fragment, { children: [
715
+ " \xB7 \u0111ang t\u1EA3i preview ",
716
+ thumbProgress,
717
+ "/",
718
+ totalPages,
719
+ "\u2026"
720
+ ] })
721
+ ] })
722
+ ] }),
723
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", alignItems: "flex-start", gap: 10 }, children: [
724
+ /* @__PURE__ */ jsxs("div", { style: { flex: 1 }, children: [
725
+ /* @__PURE__ */ jsx(
726
+ "label",
727
+ {
728
+ style: { display: "block", fontSize: 12, marginBottom: 4, opacity: 0.75 },
729
+ children: "Trang c\u1EA7n ch\xE8n (vd: 1,3,5-10) \u2014 ho\u1EB7c click thumbnail b\xEAn d\u01B0\u1EDBi"
730
+ }
731
+ ),
732
+ /* @__PURE__ */ jsx(
733
+ "input",
734
+ {
735
+ ref: inputRef,
736
+ type: "text",
737
+ value: inputValue,
738
+ onChange: (e) => handleInputChange(e.target.value),
739
+ onKeyDown: (e) => {
740
+ if (e.key === "Enter") {
741
+ e.preventDefault();
742
+ handleSubmit();
743
+ }
744
+ },
745
+ style: {
746
+ width: "100%",
747
+ boxSizing: "border-box",
748
+ padding: "8px 10px",
749
+ fontSize: 14,
750
+ borderRadius: 6,
751
+ border: `1px solid ${inputError ? "#dc2626" : "rgba(0,0,0,0.2)"}`,
752
+ outline: "none",
753
+ background: "var(--input-bg-color, #fff)",
754
+ color: "inherit",
755
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace"
756
+ }
757
+ }
758
+ )
759
+ ] }),
760
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 6, paddingTop: 18 }, children: [
761
+ /* @__PURE__ */ jsx(
762
+ "button",
763
+ {
764
+ type: "button",
765
+ onClick: selectAll,
766
+ style: quickBtnStyle,
767
+ title: "Ch\u1ECDn t\u1EA5t c\u1EA3 trang",
768
+ children: "T\u1EA5t c\u1EA3"
769
+ }
770
+ ),
771
+ /* @__PURE__ */ jsx(
772
+ "button",
773
+ {
774
+ type: "button",
775
+ onClick: clearAll,
776
+ style: quickBtnStyle,
777
+ title: "B\u1ECF ch\u1ECDn t\u1EA5t c\u1EA3",
778
+ children: "B\u1ECF h\u1EBFt"
779
+ }
780
+ )
781
+ ] })
782
+ ] }),
783
+ /* @__PURE__ */ jsx("div", { style: { minHeight: 18, fontSize: 12 }, "data-testid": "pdf-range-status", children: inputError ? /* @__PURE__ */ jsx("span", { style: { color: "#dc2626" }, children: inputError }) : /* @__PURE__ */ jsxs("span", { style: { opacity: 0.75 }, children: [
784
+ "\u0110\xE3 ch\u1ECDn ",
785
+ /* @__PURE__ */ jsx("strong", { children: selectedSet.size }),
786
+ " / ",
787
+ totalPages,
788
+ " trang"
789
+ ] }) }),
790
+ /* @__PURE__ */ jsx(
791
+ "div",
792
+ {
793
+ style: {
794
+ flex: 1,
795
+ minHeight: 240,
796
+ maxHeight: "60vh",
797
+ overflow: "auto",
798
+ padding: 8,
799
+ background: "rgba(0,0,0,0.04)",
800
+ borderRadius: 8,
801
+ display: "grid",
802
+ gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))",
803
+ gap: 10,
804
+ alignContent: "start"
805
+ },
806
+ children: Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => {
807
+ const thumb = thumbs[pageNum];
808
+ const selected = selectedSet.has(pageNum);
809
+ return /* @__PURE__ */ jsx(
810
+ ThumbnailItem,
811
+ {
812
+ pageNum,
813
+ thumb,
814
+ selected,
815
+ onToggle: () => toggleThumb(pageNum)
816
+ },
817
+ pageNum
818
+ );
819
+ })
820
+ }
821
+ ),
822
+ /* @__PURE__ */ jsxs(
823
+ "div",
824
+ {
825
+ style: {
826
+ display: "flex",
827
+ justifyContent: "flex-end",
828
+ gap: 8,
829
+ paddingTop: 4
830
+ },
831
+ children: [
832
+ /* @__PURE__ */ jsx(
833
+ "button",
834
+ {
835
+ type: "button",
836
+ onClick: onCancel,
837
+ style: {
838
+ padding: "8px 14px",
839
+ fontSize: 13,
840
+ borderRadius: 6,
841
+ border: "1px solid rgba(0,0,0,0.15)",
842
+ background: "transparent",
843
+ color: "inherit",
844
+ cursor: "pointer"
845
+ },
846
+ children: "Hu\u1EF7"
847
+ }
848
+ ),
849
+ /* @__PURE__ */ jsxs(
850
+ "button",
851
+ {
852
+ type: "button",
853
+ onClick: handleSubmit,
854
+ disabled: !canSubmit,
855
+ style: {
856
+ padding: "8px 16px",
857
+ fontSize: 13,
858
+ borderRadius: 6,
859
+ border: "none",
860
+ background: canSubmit ? "#4f46e5" : "rgba(0,0,0,0.15)",
861
+ color: "#fff",
862
+ cursor: canSubmit ? "pointer" : "not-allowed",
863
+ fontWeight: 500
864
+ },
865
+ children: [
866
+ "Ch\xE8n ",
867
+ selectedSet.size > 0 ? `${selectedSet.size} trang` : ""
868
+ ]
869
+ }
870
+ )
871
+ ]
872
+ }
873
+ )
874
+ ]
875
+ }
876
+ )
877
+ }
878
+ ),
879
+ document.body
880
+ );
881
+ }
882
+ var quickBtnStyle = {
883
+ padding: "7px 10px",
884
+ fontSize: 12,
885
+ borderRadius: 6,
886
+ border: "1px solid rgba(0,0,0,0.15)",
887
+ background: "transparent",
888
+ color: "inherit",
889
+ cursor: "pointer",
890
+ whiteSpace: "nowrap"
891
+ };
892
+ function ThumbnailItem({ pageNum, thumb, selected, onToggle }) {
893
+ const aspect = thumb ? thumb.width / thumb.height : 0.77;
894
+ return /* @__PURE__ */ jsxs(
895
+ "button",
896
+ {
897
+ type: "button",
898
+ onClick: onToggle,
899
+ "aria-pressed": selected,
900
+ "aria-label": `Trang ${pageNum}${selected ? " (\u0111\xE3 ch\u1ECDn)" : ""}`,
901
+ title: `Trang ${pageNum}`,
902
+ style: {
903
+ position: "relative",
904
+ padding: 0,
905
+ background: "#fff",
906
+ border: `2px solid ${selected ? "#4f46e5" : "rgba(0,0,0,0.12)"}`,
907
+ borderRadius: 6,
908
+ overflow: "hidden",
909
+ cursor: "pointer",
910
+ boxShadow: selected ? "0 0 0 3px rgba(79,70,229,0.18)" : "none",
911
+ transition: "border-color 80ms ease, box-shadow 80ms ease"
912
+ },
913
+ children: [
914
+ /* @__PURE__ */ jsx(
915
+ "div",
916
+ {
917
+ style: {
918
+ width: "100%",
919
+ aspectRatio: aspect.toString(),
920
+ background: "#f5f5f5",
921
+ display: "flex",
922
+ alignItems: "center",
923
+ justifyContent: "center"
924
+ },
925
+ children: thumb ? (
926
+ // eslint-disable-next-line @next/next/no-img-element
927
+ /* @__PURE__ */ jsx(
928
+ "img",
929
+ {
930
+ src: thumb.dataURL,
931
+ alt: "",
932
+ style: { width: "100%", height: "100%", display: "block", objectFit: "contain" },
933
+ draggable: false
934
+ }
935
+ )
936
+ ) : /* @__PURE__ */ jsx("div", { style: { fontSize: 11, opacity: 0.5 }, children: "\u2026" })
937
+ }
938
+ ),
939
+ /* @__PURE__ */ jsx(
940
+ "div",
941
+ {
942
+ style: {
943
+ position: "absolute",
944
+ bottom: 4,
945
+ left: 4,
946
+ fontSize: 10,
947
+ fontWeight: 600,
948
+ padding: "2px 6px",
949
+ borderRadius: 4,
950
+ background: selected ? "#4f46e5" : "rgba(0,0,0,0.6)",
951
+ color: "#fff"
952
+ },
953
+ children: pageNum
954
+ }
955
+ ),
956
+ selected && /* @__PURE__ */ jsx(
957
+ "div",
958
+ {
959
+ "aria-hidden": "true",
960
+ style: {
961
+ position: "absolute",
962
+ top: 4,
963
+ right: 4,
964
+ width: 18,
965
+ height: 18,
966
+ borderRadius: "50%",
967
+ background: "#4f46e5",
968
+ color: "#fff",
969
+ display: "flex",
970
+ alignItems: "center",
971
+ justifyContent: "center",
972
+ fontSize: 11,
973
+ fontWeight: 700,
974
+ boxShadow: "0 1px 3px rgba(0,0,0,0.3)"
975
+ },
976
+ children: "\u2713"
977
+ }
978
+ )
979
+ ]
980
+ }
981
+ );
982
+ }
983
+
984
+ // src/pdf/insertPdfPages.ts
985
+ var PAGE_GAP = 24;
986
+ var DEFAULT_SCALE = 2;
987
+ function insertRasterizedPagesIntoScene(api, rendered, options) {
988
+ if (!api) throw new Error("Excalidraw API ch\u01B0a s\u1EB5n s\xE0ng.");
989
+ if (rendered.length === 0) return { insertedElementIds: [], fileIds: [] };
990
+ const { scale } = options;
991
+ const filesPayload = rendered.map((p) => ({
992
+ id: generateFileId(),
993
+ dataURL: p.dataURL,
994
+ mimeType: p.mimeType,
995
+ created: Date.now()
996
+ }));
997
+ api.addFiles(filesPayload);
998
+ const origin = options.origin ?? getViewportCenter(api);
999
+ const sceneSizes = rendered.map((p) => pixelsToSceneSize(p.width, p.height, scale));
1000
+ const maxSceneWidth = Math.max(...sceneSizes.map((s) => s.width));
1001
+ const baseX = origin.x - maxSceneWidth / 2;
1002
+ let cursorY = origin.y - sceneSizes[0].height / 2;
1003
+ const newElements = rendered.map((_, i) => {
1004
+ const { width, height } = sceneSizes[i];
1005
+ const x = baseX + (maxSceneWidth - width) / 2;
1006
+ const y = cursorY;
1007
+ cursorY = y + height + PAGE_GAP;
1008
+ return buildPdfImageElement(filesPayload[i].id, x, y, width, height);
1009
+ });
1010
+ const existing = api.getSceneElements();
1011
+ api.updateScene({
1012
+ elements: [...existing, ...newElements],
1013
+ appState: { selectedElementIds: {}, croppingElementId: null }
1014
+ });
1015
+ return {
1016
+ insertedElementIds: newElements.map((e) => e.id),
1017
+ fileIds: filesPayload.map((f) => f.id)
1018
+ };
1019
+ }
1020
+ function pixelsToSceneSize(pxWidth, pxHeight, scale) {
1021
+ return { width: pxWidth / scale, height: pxHeight / scale };
1022
+ }
1023
+ function buildPdfImageElement(fileId, x, y, width, height) {
1024
+ return {
1025
+ type: "image",
1026
+ id: "pdf_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8),
1027
+ x,
1028
+ y,
1029
+ width,
1030
+ height,
1031
+ fileId,
1032
+ angle: 0,
1033
+ strokeColor: "transparent",
1034
+ backgroundColor: "transparent",
1035
+ fillStyle: "solid",
1036
+ strokeWidth: 1,
1037
+ strokeStyle: "solid",
1038
+ roughness: 0,
1039
+ opacity: 100,
1040
+ groupIds: [],
1041
+ roundness: null,
1042
+ seed: Math.floor(Math.random() * 1e9),
1043
+ versionNonce: 0,
1044
+ version: 1,
1045
+ isDeleted: false,
1046
+ boundElements: null,
1047
+ updated: Date.now(),
1048
+ link: null,
1049
+ locked: false,
1050
+ status: "saved",
1051
+ scale: [1, 1]
1052
+ };
1053
+ }
1054
+ function generateFileId() {
1055
+ return "pdf_" + Date.now() + "_" + Math.random().toString(36).slice(2, 10);
1056
+ }
1057
+ function getViewportCenter(api) {
1058
+ const appState = api?.getAppState?.() ?? {
1059
+ scrollX: 0,
1060
+ scrollY: 0,
1061
+ width: 800,
1062
+ height: 600,
1063
+ zoom: { value: 1 }
1064
+ };
1065
+ const zoom = appState.zoom?.value ?? 1;
1066
+ return {
1067
+ x: appState.scrollX + (appState.width ?? 800) / 2 / zoom,
1068
+ y: appState.scrollY + (appState.height ?? 600) / 2 / zoom
1069
+ };
1070
+ }
1071
+ async function insertPdfPages(api, source, options = {}) {
1072
+ if (!api) throw new Error("Excalidraw API ch\u01B0a s\u1EB5n s\xE0ng.");
1073
+ const scale = options.scale ?? DEFAULT_SCALE;
1074
+ const doc = await loadPdfDocument(source);
1075
+ let rendered;
1076
+ try {
1077
+ rendered = await rasterizePdf(doc, {
1078
+ pages: options.pages,
1079
+ scale,
1080
+ onProgress: options.onProgress,
1081
+ signal: options.signal
1082
+ });
1083
+ } finally {
1084
+ void closePdfDocument(doc);
1085
+ }
1086
+ const { insertedElementIds } = insertRasterizedPagesIntoScene(api, rendered, {
1087
+ scale,
1088
+ origin: options.origin
1089
+ });
1090
+ return { insertedElementIds, pages: rendered };
1091
+ }
238
1092
  var DOUBLE_CLICK_MS = 400;
239
1093
  function useStampDoubleClick({ enabled, stamps, onOpen }) {
240
1094
  const lastClickRef = useRef({
@@ -380,6 +1234,63 @@ async function restoreMissingStampFiles(api, elements, stamps = DEFAULT_STAMPS)
380
1234
  }
381
1235
  }
382
1236
 
1237
+ // src/core/persistence/validation.ts
1238
+ var STORAGE_KEY_RE = /^[a-zA-Z0-9_-]{1,128}$/;
1239
+ function validateStorageKey(key) {
1240
+ if (typeof key !== "string" || !STORAGE_KEY_RE.test(key)) {
1241
+ const sample = key === void 0 ? "undefined" : String(key).slice(0, 32);
1242
+ throw new Error(
1243
+ `[whiteboard] Invalid storageKey: must match ${STORAGE_KEY_RE} (got: ${sample})`
1244
+ );
1245
+ }
1246
+ return key;
1247
+ }
1248
+ var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
1249
+ function sanitizingReviver(_key, value) {
1250
+ if (DANGEROUS_KEYS.has(_key)) return void 0;
1251
+ return value;
1252
+ }
1253
+ var MAX_NESTED_DEPTH = 64;
1254
+ function depthExceeds(v, max, depth = 0) {
1255
+ if (depth > max) return true;
1256
+ if (v === null || typeof v !== "object") return false;
1257
+ const children = Array.isArray(v) ? v : Object.values(v);
1258
+ for (const child of children) {
1259
+ if (depthExceeds(child, max, depth + 1)) return true;
1260
+ }
1261
+ return false;
1262
+ }
1263
+ var ALLOWED_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "elements", "appState", "savedAt"]);
1264
+ function isPlainObject(v) {
1265
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1266
+ }
1267
+ function safeParseScene(raw) {
1268
+ let parsed;
1269
+ try {
1270
+ parsed = JSON.parse(raw, sanitizingReviver);
1271
+ } catch {
1272
+ return null;
1273
+ }
1274
+ if (!isPlainObject(parsed)) return null;
1275
+ if (depthExceeds(parsed, MAX_NESTED_DEPTH)) return null;
1276
+ const safe = {};
1277
+ for (const k of Object.keys(parsed)) {
1278
+ if (ALLOWED_TOP_LEVEL_KEYS.has(k)) safe[k] = parsed[k];
1279
+ }
1280
+ if (!Array.isArray(safe.elements)) return null;
1281
+ for (const el of safe.elements) {
1282
+ if (!isPlainObject(el)) return null;
1283
+ if (typeof el.id !== "string" || typeof el.type !== "string") return null;
1284
+ }
1285
+ const appState = isPlainObject(safe.appState) ? safe.appState : {};
1286
+ return {
1287
+ version: safe.version,
1288
+ elements: safe.elements,
1289
+ appState,
1290
+ savedAt: safe.savedAt
1291
+ };
1292
+ }
1293
+
383
1294
  // src/core/persistence/sceneStore.ts
384
1295
  var PREFIX = "whiteboard:scene:";
385
1296
  var SCHEMA_VERSION = 1;
@@ -387,35 +1298,34 @@ function fullKey(key) {
387
1298
  return PREFIX + key;
388
1299
  }
389
1300
  function readScene(key) {
1301
+ const validKey = validateStorageKey(key);
390
1302
  if (typeof window === "undefined") return null;
391
- const raw = window.localStorage.getItem(fullKey(key));
1303
+ const raw = window.localStorage.getItem(fullKey(validKey));
392
1304
  if (!raw) return null;
393
- try {
394
- const parsed = JSON.parse(raw);
395
- if (!parsed || typeof parsed !== "object") return null;
396
- if (parsed.version !== SCHEMA_VERSION) {
397
- console.warn(
398
- `[whiteboard] scene version ${parsed.version} kh\xF4ng kh\u1EDBp ${SCHEMA_VERSION}, b\u1ECF qua.`
399
- );
400
- return null;
401
- }
402
- if (!Array.isArray(parsed.elements)) return null;
403
- return {
404
- version: SCHEMA_VERSION,
405
- elements: parsed.elements,
406
- appState: parsed.appState ?? {},
407
- savedAt: typeof parsed.savedAt === "number" ? parsed.savedAt : Date.now()
408
- };
409
- } catch (err) {
410
- console.warn("[whiteboard] scene parse error, clear:", err);
1305
+ const parsed = safeParseScene(raw);
1306
+ if (!parsed) {
1307
+ console.warn("[whiteboard] scene parse/validation failed, clear:", validKey);
411
1308
  try {
412
- window.localStorage.removeItem(fullKey(key));
1309
+ window.localStorage.removeItem(fullKey(validKey));
413
1310
  } catch {
414
1311
  }
415
1312
  return null;
416
1313
  }
1314
+ if (parsed.version !== SCHEMA_VERSION) {
1315
+ console.warn(
1316
+ `[whiteboard] scene version ${parsed.version} kh\xF4ng kh\u1EDBp ${SCHEMA_VERSION}, b\u1ECF qua.`
1317
+ );
1318
+ return null;
1319
+ }
1320
+ return {
1321
+ version: SCHEMA_VERSION,
1322
+ elements: parsed.elements,
1323
+ appState: parsed.appState,
1324
+ savedAt: typeof parsed.savedAt === "number" ? parsed.savedAt : Date.now()
1325
+ };
417
1326
  }
418
1327
  function writeScene(key, payload) {
1328
+ const validKey = validateStorageKey(key);
419
1329
  if (typeof window === "undefined") return;
420
1330
  const record = {
421
1331
  version: SCHEMA_VERSION,
@@ -424,7 +1334,7 @@ function writeScene(key, payload) {
424
1334
  savedAt: Date.now()
425
1335
  };
426
1336
  try {
427
- window.localStorage.setItem(fullKey(key), JSON.stringify(record));
1337
+ window.localStorage.setItem(fullKey(validKey), JSON.stringify(record));
428
1338
  } catch (err) {
429
1339
  console.warn("[whiteboard] scene write failed:", err);
430
1340
  }
@@ -493,12 +1403,13 @@ async function withStore(mode, fn, fallback) {
493
1403
  });
494
1404
  }
495
1405
  async function readFiles(storageKey) {
1406
+ const validKey = validateStorageKey(storageKey);
496
1407
  try {
497
1408
  return await withStore(
498
1409
  "readonly",
499
1410
  (store, setResult, fail) => {
500
1411
  const out = {};
501
- const req = store.index("storageKey").openCursor(IDBKeyRange.only(storageKey));
1412
+ const req = store.index("storageKey").openCursor(IDBKeyRange.only(validKey));
502
1413
  req.onsuccess = () => {
503
1414
  const cursor = req.result;
504
1415
  if (!cursor) {
@@ -523,6 +1434,7 @@ async function readFiles(storageKey) {
523
1434
  }
524
1435
  }
525
1436
  async function writeFiles(storageKey, files) {
1437
+ const validKey = validateStorageKey(storageKey);
526
1438
  const entries = Object.entries(files);
527
1439
  if (entries.length === 0) return;
528
1440
  try {
@@ -545,7 +1457,7 @@ async function writeFiles(storageKey, files) {
545
1457
  }
546
1458
  const rec = {
547
1459
  id,
548
- storageKey,
1460
+ storageKey: validKey,
549
1461
  dataURL: ff.dataURL,
550
1462
  mimeType: ff.mimeType,
551
1463
  created: ff.created ?? now,
@@ -566,11 +1478,12 @@ async function writeFiles(storageKey, files) {
566
1478
  }
567
1479
  }
568
1480
  async function pruneFiles(storageKey, keepIds) {
1481
+ const validKey = validateStorageKey(storageKey);
569
1482
  try {
570
1483
  await withStore(
571
1484
  "readwrite",
572
1485
  (store, setResult, fail) => {
573
- const req = store.index("storageKey").openCursor(IDBKeyRange.only(storageKey));
1486
+ const req = store.index("storageKey").openCursor(IDBKeyRange.only(validKey));
574
1487
  req.onsuccess = () => {
575
1488
  const cursor = req.result;
576
1489
  if (!cursor) {
@@ -619,9 +1532,18 @@ function Whiteboard({
619
1532
  const pruneThrottleRef = useRef(null);
620
1533
  const latestSceneRef = useRef(null);
621
1534
  const pendingFilesRef = useRef({});
1535
+ const hashElementsVersionRef = useRef(null);
1536
+ const stampsRef = useRef(stamps);
1537
+ stampsRef.current = stamps;
622
1538
  const persistEnabled = typeof storageKey === "string" && storageKey.length > 0;
623
1539
  const persistKeyRef = useRef(storageKey);
624
1540
  persistKeyRef.current = storageKey;
1541
+ const onSceneChangeRef = useRef(onSceneChange);
1542
+ onSceneChangeRef.current = onSceneChange;
1543
+ const onFilesChangeRef = useRef(onFilesChange);
1544
+ onFilesChangeRef.current = onFilesChange;
1545
+ const persistEnabledRef = useRef(persistEnabled);
1546
+ persistEnabledRef.current = persistEnabled;
625
1547
  const persistedInitial = useMemo(
626
1548
  () => persistEnabled ? readScene(storageKey) : null,
627
1549
  [persistEnabled, storageKey]
@@ -635,6 +1557,8 @@ function Whiteboard({
635
1557
  activeStampRef.current = activeStamp;
636
1558
  const [editingElement, setEditingElement] = useState(null);
637
1559
  const hostRef = useRef(null);
1560
+ const [pdfPending, setPdfPending] = useState(null);
1561
+ const [pdfBusy, setPdfBusy] = useState(false);
638
1562
  const handledCropIdRef = useRef(null);
639
1563
  const prevExcalidrawToolRef = useRef("selection");
640
1564
  const stampByKind = useMemo(() => {
@@ -709,21 +1633,14 @@ function Whiteboard({
709
1633
  if (!sceneThrottleRef.current) {
710
1634
  sceneThrottleRef.current = setTimeout(async () => {
711
1635
  sceneThrottleRef.current = null;
712
- const mod = await import('@excalidraw/excalidraw');
713
- const latestScene = latestSceneRef.current ?? { elements, appState };
714
- const liveElements = latestScene.elements.filter((e) => !e.isDeleted);
715
- const liveAppState = pickSyncableAppState(latestScene.appState);
716
- const elementHash = mod.hashElementsVersion(liveElements);
717
- const sceneHash = `${elementHash}:${JSON.stringify(liveAppState)}`;
718
- if (sceneHash === lastSceneHashRef.current) return;
719
- lastSceneHashRef.current = sceneHash;
720
- onSceneChange?.({ elements: liveElements, appState: liveAppState });
721
- if (persistEnabled) {
722
- writeScene(storageKey, {
723
- elements: liveElements,
724
- appState: liveAppState
725
- });
1636
+ try {
1637
+ const mod = await import('@excalidraw/excalidraw');
1638
+ hashElementsVersionRef.current = mod.hashElementsVersion;
1639
+ } catch (err) {
1640
+ console.warn("[whiteboard] import excalidraw \u0111\u1EC3 flush scene th\u1EA5t b\u1EA1i:", err);
1641
+ return;
726
1642
  }
1643
+ flushSceneRef.current();
727
1644
  }, SYNC_THROTTLE_MS);
728
1645
  }
729
1646
  if (persistEnabled && newIds.length > 0) {
@@ -733,63 +1650,112 @@ function Whiteboard({
733
1650
  if (!fileThrottleRef.current) {
734
1651
  fileThrottleRef.current = setTimeout(() => {
735
1652
  fileThrottleRef.current = null;
736
- const pending = pendingFilesRef.current;
737
- pendingFilesRef.current = {};
738
- const currentElements = api?.getSceneElements?.() ?? elements;
739
- const stampIds = /* @__PURE__ */ new Set();
740
- for (const el of currentElements) {
741
- const fid = el.fileId;
742
- if (fid && isStampElement(el)) stampIds.add(fid);
743
- }
744
- const raster = {};
745
- for (const [id, f] of Object.entries(pending)) {
746
- if (!stampIds.has(id)) raster[id] = f;
747
- }
748
- if (Object.keys(raster).length > 0) {
749
- void writeFiles(persistKeyRef.current, raster);
750
- }
1653
+ flushFilesRef.current();
751
1654
  }, 1e3);
752
1655
  }
753
1656
  }
754
1657
  if (persistEnabled && !pruneThrottleRef.current) {
755
1658
  pruneThrottleRef.current = setTimeout(() => {
756
1659
  pruneThrottleRef.current = null;
757
- const currentElements = api?.getSceneElements?.() ?? elements;
758
- const keep = /* @__PURE__ */ new Set();
759
- for (const el of currentElements) {
760
- const fid = el.fileId;
761
- if (fid && !isStampElement(el)) keep.add(fid);
762
- }
763
- void pruneFiles(persistKeyRef.current, keep);
1660
+ flushPruneRef.current();
764
1661
  }, 2e3);
765
1662
  }
766
1663
  },
767
1664
  [readOnly, api, onSceneChange, onFilesChange, persistEnabled, storageKey, stamps, openStamp]
768
1665
  );
1666
+ const flushSceneRef = useRef(() => void 0);
1667
+ flushSceneRef.current = () => {
1668
+ try {
1669
+ const latestScene = latestSceneRef.current;
1670
+ if (!latestScene) return;
1671
+ const liveElements = latestScene.elements.filter((e) => !e.isDeleted);
1672
+ const liveAppState = pickSyncableAppState(latestScene.appState);
1673
+ const hashFn = hashElementsVersionRef.current;
1674
+ const elementHash = hashFn ? hashFn(liveElements) : liveElements.map((e) => e.id).join("|");
1675
+ const sceneHash = `${elementHash}:${JSON.stringify(liveAppState)}`;
1676
+ if (sceneHash === lastSceneHashRef.current) return;
1677
+ lastSceneHashRef.current = sceneHash;
1678
+ onSceneChangeRef.current?.({ elements: liveElements, appState: liveAppState });
1679
+ if (persistEnabledRef.current) {
1680
+ writeScene(persistKeyRef.current, {
1681
+ elements: liveElements,
1682
+ appState: liveAppState
1683
+ });
1684
+ }
1685
+ } catch (err) {
1686
+ console.warn("[whiteboard] flushScene th\u1EA5t b\u1EA1i:", err);
1687
+ }
1688
+ };
1689
+ const flushFilesRef = useRef(() => void 0);
1690
+ flushFilesRef.current = () => {
1691
+ try {
1692
+ const pending = pendingFilesRef.current;
1693
+ pendingFilesRef.current = {};
1694
+ if (Object.keys(pending).length === 0) return;
1695
+ const currentElements = apiRef.current?.getSceneElements?.() ?? latestSceneRef.current?.elements ?? [];
1696
+ const stampIds = /* @__PURE__ */ new Set();
1697
+ for (const el of currentElements) {
1698
+ const fid = el.fileId;
1699
+ if (fid && isStampElement(el)) stampIds.add(fid);
1700
+ }
1701
+ const raster = {};
1702
+ for (const [id, f] of Object.entries(pending)) {
1703
+ if (!stampIds.has(id)) raster[id] = f;
1704
+ }
1705
+ if (Object.keys(raster).length > 0) {
1706
+ void writeFiles(persistKeyRef.current, raster);
1707
+ }
1708
+ } catch (err) {
1709
+ console.warn("[whiteboard] flushFiles th\u1EA5t b\u1EA1i:", err);
1710
+ }
1711
+ };
1712
+ const flushPruneRef = useRef(() => void 0);
1713
+ flushPruneRef.current = () => {
1714
+ try {
1715
+ const currentElements = apiRef.current?.getSceneElements?.() ?? latestSceneRef.current?.elements ?? [];
1716
+ const keep = /* @__PURE__ */ new Set();
1717
+ for (const el of currentElements) {
1718
+ const fid = el.fileId;
1719
+ if (fid && !isStampElement(el)) keep.add(fid);
1720
+ }
1721
+ void pruneFiles(persistKeyRef.current, keep);
1722
+ } catch (err) {
1723
+ console.warn("[whiteboard] flushPrune th\u1EA5t b\u1EA1i:", err);
1724
+ }
1725
+ };
769
1726
  useEffect(() => {
770
1727
  if (!api || !persistEnabled) return;
771
1728
  let cancelled = false;
772
- void readFiles(storageKey).then((files) => {
773
- if (cancelled) return;
774
- const entries = Object.entries(files);
775
- if (entries.length === 0) return;
776
- try {
777
- api.addFiles(
778
- entries.map(([id, f]) => ({
779
- id,
780
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
781
- dataURL: f.dataURL,
782
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
783
- mimeType: f.mimeType,
784
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
785
- created: f.created ?? Date.now()
786
- }))
787
- );
788
- entries.forEach(([id]) => knownFileIdsRef.current.add(id));
789
- } catch (err) {
790
- console.warn("[whiteboard] addFiles t\u1EEB IDB th\u1EA5t b\u1EA1i:", err);
1729
+ void readFiles(storageKey).then(
1730
+ (files) => {
1731
+ if (cancelled) return;
1732
+ const entries = Object.entries(files);
1733
+ if (entries.length === 0) return;
1734
+ if (cancelled) return;
1735
+ try {
1736
+ api.addFiles(
1737
+ entries.map(([id, f]) => ({
1738
+ id,
1739
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1740
+ dataURL: f.dataURL,
1741
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1742
+ mimeType: f.mimeType,
1743
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1744
+ created: f.created ?? Date.now()
1745
+ }))
1746
+ );
1747
+ if (cancelled) return;
1748
+ entries.forEach(([id]) => knownFileIdsRef.current.add(id));
1749
+ } catch (err) {
1750
+ if (cancelled) return;
1751
+ console.warn("[whiteboard] addFiles t\u1EEB IDB th\u1EA5t b\u1EA1i:", err);
1752
+ }
1753
+ },
1754
+ (err) => {
1755
+ if (cancelled) return;
1756
+ console.warn("[whiteboard] readFiles th\u1EA5t b\u1EA1i:", err);
791
1757
  }
792
- });
1758
+ );
793
1759
  return () => {
794
1760
  cancelled = true;
795
1761
  };
@@ -798,27 +1764,43 @@ function Whiteboard({
798
1764
  if (!api) return;
799
1765
  let cancelled = false;
800
1766
  const run = async () => {
1767
+ if (cancelled) return;
801
1768
  try {
802
1769
  const elements = api.getSceneElements();
803
1770
  if (!elements || elements.length === 0) return;
804
1771
  if (cancelled) return;
805
- await restoreMissingStampFiles(api, elements, stamps);
1772
+ await restoreMissingStampFiles(api, elements, stampsRef.current);
806
1773
  } catch (err) {
1774
+ if (cancelled) return;
807
1775
  console.warn("Math stamp restore pass failed:", err);
808
1776
  }
809
1777
  };
810
- run();
811
- const t = setTimeout(run, 400);
1778
+ void run();
1779
+ const t = setTimeout(() => {
1780
+ void run();
1781
+ }, 400);
812
1782
  return () => {
813
1783
  cancelled = true;
814
1784
  clearTimeout(t);
815
1785
  };
816
- }, [api, persistedInitial, stamps]);
1786
+ }, [api, persistedInitial]);
817
1787
  useEffect(
818
1788
  () => () => {
819
- if (sceneThrottleRef.current) clearTimeout(sceneThrottleRef.current);
820
- if (fileThrottleRef.current) clearTimeout(fileThrottleRef.current);
821
- if (pruneThrottleRef.current) clearTimeout(pruneThrottleRef.current);
1789
+ if (sceneThrottleRef.current) {
1790
+ clearTimeout(sceneThrottleRef.current);
1791
+ sceneThrottleRef.current = null;
1792
+ flushSceneRef.current();
1793
+ }
1794
+ if (fileThrottleRef.current) {
1795
+ clearTimeout(fileThrottleRef.current);
1796
+ fileThrottleRef.current = null;
1797
+ flushFilesRef.current();
1798
+ }
1799
+ if (pruneThrottleRef.current) {
1800
+ clearTimeout(pruneThrottleRef.current);
1801
+ pruneThrottleRef.current = null;
1802
+ flushPruneRef.current();
1803
+ }
822
1804
  },
823
1805
  []
824
1806
  );
@@ -865,6 +1847,80 @@ function Whiteboard({
865
1847
  return () => window.removeEventListener("keydown", onKey, { capture: true });
866
1848
  }, [activeStamp, closeStamp]);
867
1849
  useStampClickOutside({ activeStamp, hostRef, onClose: closeStamp });
1850
+ const handlePdfPick = useCallback(
1851
+ async (file) => {
1852
+ if (readOnly || pdfBusy) return;
1853
+ setPdfBusy(true);
1854
+ try {
1855
+ const doc = await loadPdfDocument(file);
1856
+ setPdfPending({ doc, fileName: file.name, totalPages: doc.numPages });
1857
+ } catch (err) {
1858
+ console.warn("[whiteboard] \u0110\u1ECDc PDF th\u1EA5t b\u1EA1i:", err);
1859
+ 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.");
1860
+ } finally {
1861
+ setPdfBusy(false);
1862
+ }
1863
+ },
1864
+ [readOnly, pdfBusy]
1865
+ );
1866
+ const handlePdfConfirm = useCallback(
1867
+ async (pages) => {
1868
+ if (!pdfPending || !api) return;
1869
+ const { doc } = pdfPending;
1870
+ setPdfPending(null);
1871
+ setPdfBusy(true);
1872
+ const scale = 2;
1873
+ try {
1874
+ const rendered = await rasterizePdf(doc, { pages, scale });
1875
+ await closePdfDocument(doc);
1876
+ insertRasterizedPagesIntoScene(api, rendered, { scale });
1877
+ } catch (err) {
1878
+ console.warn("[whiteboard] Ch\xE8n PDF th\u1EA5t b\u1EA1i:", err);
1879
+ window.alert("Ch\xE8n PDF th\u1EA5t b\u1EA1i. Xem console \u0111\u1EC3 bi\u1EBFt chi ti\u1EBFt.");
1880
+ } finally {
1881
+ setPdfBusy(false);
1882
+ }
1883
+ },
1884
+ [pdfPending, api]
1885
+ );
1886
+ const handlePdfCancel = useCallback(() => {
1887
+ if (pdfPending) {
1888
+ void closePdfDocument(pdfPending.doc);
1889
+ }
1890
+ setPdfPending(null);
1891
+ }, [pdfPending]);
1892
+ useEffect(() => {
1893
+ if (readOnly) return;
1894
+ const root = document.querySelector(".excalidraw");
1895
+ if (!root) return;
1896
+ const onDragOver = (e) => {
1897
+ const items = e.dataTransfer?.items;
1898
+ if (!items) return;
1899
+ for (let i = 0; i < items.length; i++) {
1900
+ if (items[i].kind === "file" && items[i].type === "application/pdf") {
1901
+ e.preventDefault();
1902
+ e.stopPropagation();
1903
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
1904
+ return;
1905
+ }
1906
+ }
1907
+ };
1908
+ const onDrop = (e) => {
1909
+ const files = e.dataTransfer?.files;
1910
+ if (!files || files.length === 0) return;
1911
+ const pdf = Array.from(files).find((f) => f.type === "application/pdf");
1912
+ if (!pdf) return;
1913
+ e.preventDefault();
1914
+ e.stopPropagation();
1915
+ void handlePdfPick(pdf);
1916
+ };
1917
+ root.addEventListener("dragover", onDragOver, { capture: true });
1918
+ root.addEventListener("drop", onDrop, { capture: true });
1919
+ return () => {
1920
+ root.removeEventListener("dragover", onDragOver, { capture: true });
1921
+ root.removeEventListener("drop", onDrop, { capture: true });
1922
+ };
1923
+ }, [readOnly, handlePdfPick, api]);
868
1924
  return /* @__PURE__ */ jsxs("div", { className: `relative h-full w-full${isDarkTheme ? " theme--dark" : ""}`, children: [
869
1925
  /* @__PURE__ */ jsx(Suspense, { fallback: /* @__PURE__ */ jsx(ExcalidrawLoadingFallback, {}), children: /* @__PURE__ */ jsx(
870
1926
  Excalidraw,
@@ -899,6 +1955,35 @@ function Whiteboard({
899
1955
  stamps
900
1956
  }
901
1957
  ),
1958
+ /* @__PURE__ */ jsx(PdfImporterButton, { enabled: !readOnly, onPick: handlePdfPick }),
1959
+ pdfPending && /* @__PURE__ */ jsx(
1960
+ PageRangeDialog,
1961
+ {
1962
+ doc: pdfPending.doc,
1963
+ fileName: pdfPending.fileName,
1964
+ onConfirm: handlePdfConfirm,
1965
+ onCancel: handlePdfCancel
1966
+ }
1967
+ ),
1968
+ pdfBusy && !pdfPending && /* @__PURE__ */ jsx(
1969
+ "div",
1970
+ {
1971
+ "aria-live": "polite",
1972
+ role: "status",
1973
+ style: {
1974
+ position: "fixed",
1975
+ bottom: 16,
1976
+ right: 16,
1977
+ padding: "8px 14px",
1978
+ background: "rgba(0,0,0,0.75)",
1979
+ color: "#fff",
1980
+ borderRadius: 6,
1981
+ fontSize: 12,
1982
+ zIndex: 1e4
1983
+ },
1984
+ children: "\u0110ang x\u1EED l\xFD PDF\u2026"
1985
+ }
1986
+ ),
902
1987
  HostComponent && /* @__PURE__ */ jsx(
903
1988
  HostComponent,
904
1989
  {
@@ -912,6 +1997,6 @@ function Whiteboard({
912
1997
  ] });
913
1998
  }
914
1999
 
915
- export { ALL_STAMPS, DEFAULT_STAMPS, EXPERIMENTAL_STAMPS, STABLE_STAMPS, Whiteboard, findStampForCustomData, isStampElement, pickSyncableAppState, restoreMissingStampFiles };
2000
+ export { ALL_STAMPS, DEFAULT_STAMPS, EXPERIMENTAL_STAMPS, STABLE_STAMPS, Whiteboard, closePdfDocument, configurePdfWorker, findStampForCustomData, insertPdfPages, insertRasterizedPagesIntoScene, isStampElement, loadPdfDocument, parsePageRange, pickSyncableAppState, rasterizePdf, restoreMissingStampFiles };
916
2001
  //# sourceMappingURL=index.mjs.map
917
2002
  //# sourceMappingURL=index.mjs.map