@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.
- package/README.md +51 -1
- package/dist/chunk-74VEEZBV.mjs +619 -0
- package/dist/chunk-74VEEZBV.mjs.map +1 -0
- package/dist/{chunk-BJX4YNA5.mjs → chunk-G7FR3AIV.mjs} +68 -12
- package/dist/chunk-G7FR3AIV.mjs.map +1 -0
- package/dist/{chunk-SHFOGORM.mjs → chunk-PDKKDZ4H.mjs} +4 -4
- package/dist/{chunk-SHFOGORM.mjs.map → chunk-PDKKDZ4H.mjs.map} +1 -1
- package/dist/chunk-PWIMZIB6.mjs +62 -0
- package/dist/chunk-PWIMZIB6.mjs.map +1 -0
- package/dist/{chunk-LPM4MM45.mjs → chunk-SBDMF4NQ.mjs} +3 -2
- package/dist/chunk-SBDMF4NQ.mjs.map +1 -0
- package/dist/chunk-WQOABS6N.mjs +197 -0
- package/dist/chunk-WQOABS6N.mjs.map +1 -0
- package/dist/{chunk-3SSQKRRO.mjs → chunk-ZVN356JZ.mjs} +4 -4
- package/dist/{chunk-3SSQKRRO.mjs.map → chunk-ZVN356JZ.mjs.map} +1 -1
- package/dist/geometry-2d.js +344 -228
- package/dist/geometry-2d.js.map +1 -1
- package/dist/geometry-2d.mjs +2 -2
- package/dist/geometry-3d.d.mts +1 -1
- package/dist/geometry-3d.d.ts +1 -1
- package/dist/geometry-3d.js +3411 -1277
- package/dist/geometry-3d.js.map +1 -1
- package/dist/geometry-3d.mjs +3 -2
- package/dist/graph-2d.js +360 -66
- package/dist/graph-2d.js.map +1 -1
- package/dist/graph-2d.mjs +2 -2
- package/dist/{host-T2W6R6SO.mjs → host-DJETSFCG.mjs} +272 -223
- package/dist/host-DJETSFCG.mjs.map +1 -0
- package/dist/{host-2QGKMGCT.mjs → host-LZH2FZ2N.mjs} +3 -3
- package/dist/{host-2QGKMGCT.mjs.map → host-LZH2FZ2N.mjs.map} +1 -1
- package/dist/host-N6ACNJKI.mjs +3226 -0
- package/dist/host-N6ACNJKI.mjs.map +1 -0
- package/dist/index.d.mts +133 -6
- package/dist/index.d.ts +133 -6
- package/dist/index.js +5634 -1999
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1231 -146
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -6
- package/dist/chunk-BJX4YNA5.mjs.map +0 -1
- package/dist/chunk-DJTBZEAR.mjs +0 -25
- package/dist/chunk-DJTBZEAR.mjs.map +0 -1
- package/dist/chunk-HM7RIXJE.mjs +0 -331
- package/dist/chunk-HM7RIXJE.mjs.map +0 -1
- package/dist/chunk-HYXFHEDJ.mjs +0 -129
- package/dist/chunk-HYXFHEDJ.mjs.map +0 -1
- package/dist/chunk-LPM4MM45.mjs.map +0 -1
- package/dist/host-T2W6R6SO.mjs.map +0 -1
- package/dist/host-XUFON6CQ.mjs +0 -1422
- 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-
|
|
4
|
-
export { geometryStamp } from './chunk-
|
|
5
|
-
import { geometry3dStamp } from './chunk-
|
|
6
|
-
export { geometry3dStamp } from './chunk-
|
|
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-
|
|
10
|
-
export { graph2dStamp } from './chunk-
|
|
11
|
-
export { isGraph2DCustomData } from './chunk-
|
|
12
|
-
export { isGeometryCustomData } from './chunk-
|
|
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-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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)
|
|
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) =>
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1303
|
+
const raw = window.localStorage.getItem(fullKey(validKey));
|
|
392
1304
|
if (!raw) return null;
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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,
|
|
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(
|
|
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
|
|
1786
|
+
}, [api, persistedInitial]);
|
|
817
1787
|
useEffect(
|
|
818
1788
|
() => () => {
|
|
819
|
-
if (sceneThrottleRef.current)
|
|
820
|
-
|
|
821
|
-
|
|
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
|