@agent-native/core 0.146.1 → 0.146.3
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/corpus/README.md +1 -1
- package/corpus/templates/slides/actions/import-file.ts +144 -99
- package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +56 -23
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +1 -0
- package/corpus/templates/slides/app/components/editor/SlideOverflowWarning.tsx +4 -1
- package/corpus/templates/slides/app/context/DeckContext.tsx +91 -25
- package/corpus/templates/slides/app/hooks/use-deck-role.ts +14 -4
- package/corpus/templates/slides/app/i18n/en-US.ts +2 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +19 -7
- package/corpus/templates/slides/app/pages/Index.tsx +11 -2
- package/corpus/templates/slides/app/root.tsx +5 -1
- package/corpus/templates/slides/package.json +1 -0
- package/corpus/templates/slides/server/handlers/import/html-converter.ts +9 -0
- package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +960 -0
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/ingestion/pptx.d.ts +1 -0
- package/dist/ingestion/pptx.js +353 -28
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/docs/content/docs-components.mdx +122 -11
- package/package.json +2 -2
package/corpus/README.md
CHANGED
|
@@ -2,7 +2,6 @@ import path from "path";
|
|
|
2
2
|
|
|
3
3
|
import { defineAction } from "@agent-native/core";
|
|
4
4
|
import { writeAppState } from "@agent-native/core/application-state";
|
|
5
|
-
import { uploadFile } from "@agent-native/core/file-upload";
|
|
6
5
|
import { startBuilderDesignSystemIndex } from "@agent-native/core/server";
|
|
7
6
|
import {
|
|
8
7
|
getRequestOrgId,
|
|
@@ -273,21 +272,16 @@ export default defineAction({
|
|
|
273
272
|
const { PDFParse, canvasFactory } = await setupPdfParse();
|
|
274
273
|
const title = titleFromPath(filename);
|
|
275
274
|
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
275
|
+
// Reconstruct each page's real layout — positioned text blocks at
|
|
276
|
+
// their actual sizes plus every embedded image at its actual
|
|
277
|
+
// placement — instead of flattening the page to one guessed
|
|
278
|
+
// background photo and a canned text template. Image placement needs
|
|
279
|
+
// the optional canvas renderer; text positioning does not, so this
|
|
280
|
+
// still beats the old bullet-text fallback even when canvasFactory is
|
|
281
|
+
// unavailable in this runtime.
|
|
283
282
|
if (importIntoDeck) {
|
|
284
|
-
if (!canvasFactory) {
|
|
285
|
-
throw new Error(
|
|
286
|
-
"Source-faithful PDF import is unavailable in this runtime because the page renderer is not configured. Retry after connecting file storage or use a PDF export with a supported Slides deployment.",
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
283
|
if (!deckId) throw new Error("deckId is required to import into deck");
|
|
290
|
-
return
|
|
284
|
+
return importPdfPagesWithFidelity({
|
|
291
285
|
fileBuffer,
|
|
292
286
|
title,
|
|
293
287
|
deckId,
|
|
@@ -351,60 +345,61 @@ function nearestAspectRatio(width: number, height: number): AspectRatio {
|
|
|
351
345
|
return best;
|
|
352
346
|
}
|
|
353
347
|
|
|
354
|
-
async function
|
|
348
|
+
async function importPdfPagesWithFidelity(args: {
|
|
355
349
|
fileBuffer: Buffer;
|
|
356
350
|
title: string;
|
|
357
351
|
deckId: string;
|
|
358
352
|
PDFParse: Awaited<ReturnType<typeof setupPdfParse>>["PDFParse"];
|
|
359
|
-
canvasFactory: object;
|
|
353
|
+
canvasFactory: object | undefined;
|
|
360
354
|
}) {
|
|
361
355
|
const { fileBuffer, title, deckId, PDFParse, canvasFactory } = args;
|
|
362
|
-
const {
|
|
356
|
+
const { convertToSlideHtml, convertSectionsToSlides } =
|
|
363
357
|
await import("../server/handlers/import/html-converter.js");
|
|
358
|
+
const { parsePdfFidelity } =
|
|
359
|
+
await import("../server/handlers/import/pdf-fidelity-parser.js");
|
|
364
360
|
|
|
365
361
|
const pdf = new PDFParse({
|
|
366
362
|
data: new Uint8Array(fileBuffer),
|
|
367
363
|
CanvasFactory: canvasFactory,
|
|
368
364
|
});
|
|
369
365
|
let pages: { num: number; text: string }[];
|
|
370
|
-
let
|
|
371
|
-
number,
|
|
372
|
-
{ data: Uint8Array; width: number; height: number }
|
|
373
|
-
>;
|
|
366
|
+
let fidelityPages: Awaited<ReturnType<typeof parsePdfFidelity>>;
|
|
374
367
|
try {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
368
|
+
pages = normalizePdfPages(await pdf.getText());
|
|
369
|
+
// Image placement needs the optional canvas renderer to decode pixel
|
|
370
|
+
// data; skip it (text still gets real positions/sizes) when the native
|
|
371
|
+
// canvas binding isn't available in this runtime.
|
|
372
|
+
const imageResult = canvasFactory
|
|
373
|
+
? await pdf
|
|
374
|
+
.getImage({
|
|
375
|
+
imageBuffer: true,
|
|
376
|
+
imageDataUrl: false,
|
|
377
|
+
imageThreshold: 0,
|
|
378
|
+
})
|
|
379
|
+
.catch((err) => {
|
|
380
|
+
console.warn(
|
|
381
|
+
"[import-file] PDF image extraction failed, importing text-only fidelity:",
|
|
382
|
+
err instanceof Error ? err.message : String(err),
|
|
383
|
+
);
|
|
384
|
+
return undefined;
|
|
385
|
+
})
|
|
386
|
+
: undefined;
|
|
387
|
+
|
|
388
|
+
// `pdf-parse` memoizes the loaded pdfjs document behind `load()` (a
|
|
389
|
+
// TS-only `private` method — a real, callable runtime property).
|
|
390
|
+
// Reaching into it reuses the exact document `getText`/`getImage` above
|
|
391
|
+
// already parsed instead of parsing the file a second time.
|
|
392
|
+
const doc = await (
|
|
393
|
+
pdf as unknown as {
|
|
394
|
+
load(): Promise<
|
|
395
|
+
import("pdfjs-dist/legacy/build/pdf.mjs").PDFDocumentProxy
|
|
396
|
+
>;
|
|
397
|
+
}
|
|
398
|
+
).load();
|
|
399
|
+
// coercion-ok: undefined here means either canvasFactory was absent or
|
|
400
|
+
// getImage() already failed and logged a warning above — text-only
|
|
401
|
+
// fidelity is the intended degrade, not a swallowed failure.
|
|
402
|
+
fidelityPages = await parsePdfFidelity(doc, imageResult?.pages ?? []);
|
|
408
403
|
} finally {
|
|
409
404
|
await pdf.destroy();
|
|
410
405
|
}
|
|
@@ -413,60 +408,110 @@ async function importPdfPagesAsFullBleedSlides(args: {
|
|
|
413
408
|
throw new Error("The PDF renderer returned no importable pages.");
|
|
414
409
|
}
|
|
415
410
|
|
|
411
|
+
// A page with neither extracted text nor a fidelity element is only ever
|
|
412
|
+
// produced when nothing on it could be recovered (e.g. a scanned/image
|
|
413
|
+
// page and canvas rendering was unavailable or failed) — if that's true of
|
|
414
|
+
// every page, importing anyway would silently create a deck of blank
|
|
415
|
+
// placeholder slides and report success, matching the earlier
|
|
416
|
+
// text-extraction path's "needs OCR" failure keeps that lossy import from
|
|
417
|
+
// going unnoticed.
|
|
418
|
+
const hasRecoverableContent = pages.some((page) => {
|
|
419
|
+
const fidelity = fidelityPages.find((p) => p.pageNumber === page.num);
|
|
420
|
+
return page.text.trim().length > 0 || (fidelity?.elements.length ?? 0) > 0;
|
|
421
|
+
});
|
|
422
|
+
if (!hasRecoverableContent) {
|
|
423
|
+
throw new Error(
|
|
424
|
+
"No importable text or images found in this PDF. Scanned PDFs need OCR first.",
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
416
428
|
// Source decks (e.g. Instagram carousel exports) are commonly portrait or
|
|
417
429
|
// square, not the deck editor's 16:9 default — match the canvas to the
|
|
418
|
-
//
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
.
|
|
422
|
-
const aspectRatio = firstScreenshot
|
|
423
|
-
? nearestAspectRatio(firstScreenshot.width, firstScreenshot.height)
|
|
430
|
+
// PDF's own real page proportions instead of stretching/cropping it.
|
|
431
|
+
const firstSizedPage = fidelityPages.find((p) => p.widthEmu > 0);
|
|
432
|
+
const aspectRatio = firstSizedPage
|
|
433
|
+
? nearestAspectRatio(firstSizedPage.widthEmu, firstSizedPage.heightEmu)
|
|
424
434
|
: undefined;
|
|
425
435
|
|
|
426
436
|
const ownerEmail = getRequestUserEmail();
|
|
437
|
+
if (!ownerEmail) throw new Error("no authenticated user");
|
|
438
|
+
|
|
439
|
+
// Bounded the same way as the PPTX upload path below — an unbounded
|
|
440
|
+
// `Promise.all` here would fire one image-upload batch per page at once,
|
|
441
|
+
// and a large deck can be dozens of pages.
|
|
442
|
+
const uploadLimit = pLimit(4);
|
|
427
443
|
const imported = await Promise.all(
|
|
428
|
-
pages.map(
|
|
429
|
-
|
|
444
|
+
pages.map((page, index) =>
|
|
445
|
+
uploadLimit(async () => {
|
|
446
|
+
const fidelity = fidelityPages.find((p) => p.pageNumber === page.num);
|
|
447
|
+
|
|
448
|
+
if (!fidelity || fidelity.elements.length === 0) {
|
|
449
|
+
// Fidelity parsing failed or found nothing placeable on this page
|
|
450
|
+
// (e.g. a fully blank page) — fall back to plain extracted text
|
|
451
|
+
// instead of producing a silently blank slide.
|
|
452
|
+
const firstLine = page.text.split(/\r?\n/)[0]?.trim();
|
|
453
|
+
const [content] = convertSectionsToSlides([
|
|
454
|
+
{ heading: firstLine || `Page ${page.num}`, content: page.text },
|
|
455
|
+
]);
|
|
456
|
+
const id = newSlideId();
|
|
457
|
+
return {
|
|
458
|
+
slide: {
|
|
459
|
+
id,
|
|
460
|
+
content: content ?? '<div class="fmd-slide"></div>',
|
|
461
|
+
layout: "content",
|
|
462
|
+
notes: page.text,
|
|
463
|
+
},
|
|
464
|
+
snapshot: {
|
|
465
|
+
id,
|
|
466
|
+
text: page.text,
|
|
467
|
+
notes: page.text,
|
|
468
|
+
imageUrls: [],
|
|
469
|
+
editableText: true,
|
|
470
|
+
} satisfies SourceImportSlideSnapshot,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
430
473
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
474
|
+
const slideForUpload = {
|
|
475
|
+
texts: [],
|
|
476
|
+
images: [],
|
|
477
|
+
elements: fidelity.elements,
|
|
478
|
+
};
|
|
479
|
+
const uploaded = await uploadPptxSlideImages({
|
|
480
|
+
slide: slideForUpload,
|
|
481
|
+
slideIndex: index,
|
|
482
|
+
ownerEmail,
|
|
483
|
+
});
|
|
484
|
+
const content = convertToSlideHtml(
|
|
485
|
+
{
|
|
486
|
+
texts: [],
|
|
487
|
+
images: [],
|
|
488
|
+
elements: fidelity.elements,
|
|
489
|
+
widthEmu: fidelity.widthEmu,
|
|
490
|
+
heightEmu: fidelity.heightEmu,
|
|
491
|
+
// A page with no detected full-page fill is plain paper.
|
|
492
|
+
backgroundColor: fidelity.backgroundColor ?? "#ffffff", // guard:allow-raw-color - fallback plain-paper background, not a design-system token
|
|
493
|
+
},
|
|
494
|
+
uploaded.urls,
|
|
434
495
|
);
|
|
435
|
-
}
|
|
436
496
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
screenshot.width,
|
|
456
|
-
screenshot.height,
|
|
457
|
-
),
|
|
458
|
-
layout: "full-image",
|
|
459
|
-
notes: page.text,
|
|
460
|
-
},
|
|
461
|
-
snapshot: {
|
|
462
|
-
id,
|
|
463
|
-
text: page.text,
|
|
464
|
-
notes: page.text,
|
|
465
|
-
imageUrls: [uploadResult.url],
|
|
466
|
-
editableText: false,
|
|
467
|
-
} satisfies SourceImportSlideSnapshot,
|
|
468
|
-
};
|
|
469
|
-
}),
|
|
497
|
+
const id = newSlideId();
|
|
498
|
+
return {
|
|
499
|
+
slide: {
|
|
500
|
+
id,
|
|
501
|
+
content,
|
|
502
|
+
layout: "content",
|
|
503
|
+
notes: page.text,
|
|
504
|
+
},
|
|
505
|
+
snapshot: {
|
|
506
|
+
id,
|
|
507
|
+
text: page.text,
|
|
508
|
+
notes: page.text,
|
|
509
|
+
imageUrls: Object.values(uploaded.urls),
|
|
510
|
+
editableText: true,
|
|
511
|
+
} satisfies SourceImportSlideSnapshot,
|
|
512
|
+
};
|
|
513
|
+
}),
|
|
514
|
+
),
|
|
470
515
|
);
|
|
471
516
|
const slides = imported.map((entry) => entry.slide);
|
|
472
517
|
const sourceImport = buildSourceImportMetadata({
|
|
@@ -96,7 +96,10 @@ interface EditorToolbarProps {
|
|
|
96
96
|
onShowHistory: () => void;
|
|
97
97
|
historyButtonRef: React.RefObject<HTMLButtonElement | null>;
|
|
98
98
|
currentSlide?: Slide;
|
|
99
|
-
onUpdateSlide?: (
|
|
99
|
+
onUpdateSlide?: (
|
|
100
|
+
updates: Partial<Omit<Slide, "id">>,
|
|
101
|
+
slideIdOverride?: string,
|
|
102
|
+
) => void;
|
|
100
103
|
/** Active users on the current slide (from collab awareness) */
|
|
101
104
|
activeUsers?: CollabUser[];
|
|
102
105
|
/** Whether the agent has a durable presence entry on this slide */
|
|
@@ -352,7 +355,7 @@ export default function EditorToolbar({
|
|
|
352
355
|
// That row rides on SlideEditor, which only mounts for a real slide, so an
|
|
353
356
|
// empty deck must keep this fallback or it has no way to add one.
|
|
354
357
|
const contextToolbarVisible = canEdit && Boolean(currentSlide);
|
|
355
|
-
const { undo, redo, canUndo, canRedo } = useDecks();
|
|
358
|
+
const { undo, redo, canUndo, canRedo, getDeck } = useDecks();
|
|
356
359
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
357
360
|
const [importing, setImporting] = useState(false);
|
|
358
361
|
const { setTheme, resolvedTheme } = useTheme();
|
|
@@ -553,7 +556,7 @@ export default function EditorToolbar({
|
|
|
553
556
|
>
|
|
554
557
|
<div className="py-1.5">
|
|
555
558
|
{/* Layout section */}
|
|
556
|
-
<div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground
|
|
559
|
+
<div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
|
557
560
|
{t("editorToolbar.layout")}
|
|
558
561
|
</div>
|
|
559
562
|
{slideLayoutOptions.map((opt) => (
|
|
@@ -573,7 +576,7 @@ export default function EditorToolbar({
|
|
|
573
576
|
|
|
574
577
|
{/* Background section */}
|
|
575
578
|
<div className="mx-2 my-1.5 border-t border-border" />
|
|
576
|
-
<div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground
|
|
579
|
+
<div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
|
577
580
|
{t("editorToolbar.background")}
|
|
578
581
|
</div>
|
|
579
582
|
<div className="px-3 pb-2">
|
|
@@ -619,7 +622,7 @@ export default function EditorToolbar({
|
|
|
619
622
|
|
|
620
623
|
{/* Image & Assets section */}
|
|
621
624
|
<div className="mx-2 my-1.5 border-t border-border" />
|
|
622
|
-
<div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground
|
|
625
|
+
<div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
|
623
626
|
{t("editorToolbar.media")}
|
|
624
627
|
</div>
|
|
625
628
|
<button
|
|
@@ -647,27 +650,46 @@ export default function EditorToolbar({
|
|
|
647
650
|
|
|
648
651
|
{/* Diagrams section */}
|
|
649
652
|
<div className="mx-2 my-1.5 border-t border-border" />
|
|
650
|
-
<div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground
|
|
653
|
+
<div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
|
651
654
|
{t("editorToolbar.diagrams")}
|
|
652
655
|
</div>
|
|
653
656
|
<button
|
|
654
|
-
onClick={() => {
|
|
657
|
+
onClick={async () => {
|
|
655
658
|
if (!onUpdateSlide || !currentSlide) return;
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
+
// Pin the slide this insert targets before the async
|
|
660
|
+
// import/parse below, so navigating to another slide in the
|
|
661
|
+
// meantime can't redirect where the diagram lands.
|
|
662
|
+
const targetSlideId = currentSlide.id;
|
|
663
|
+
const defaultMermaidDefinition = `graph TD
|
|
659
664
|
A[Start] --> B{Decision}
|
|
660
665
|
B -->|Yes| C[Action A]
|
|
661
666
|
B -->|No| D[Action B]
|
|
662
667
|
C --> E[End]
|
|
663
|
-
D --> E
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
668
|
+
D --> E`;
|
|
669
|
+
try {
|
|
670
|
+
const { convertMermaidToExcalidraw } =
|
|
671
|
+
await import("./MermaidToExcalidrawPanel");
|
|
672
|
+
const data = await convertMermaidToExcalidraw(
|
|
673
|
+
defaultMermaidDefinition,
|
|
674
|
+
);
|
|
675
|
+
// The target slide may have been deleted while the import/
|
|
676
|
+
// parse above was in flight — patch-deck rejects the whole
|
|
677
|
+
// batch for a missing slide id, so re-check with the live
|
|
678
|
+
// deck (not the stale `currentSlide` this closure captured)
|
|
679
|
+
// and drop the result instead of enqueueing a dead op.
|
|
680
|
+
const stillExists = getDeck(deckId)?.slides.some(
|
|
681
|
+
(s) => s.id === targetSlideId,
|
|
682
|
+
);
|
|
683
|
+
if (!stillExists) return;
|
|
684
|
+
onUpdateSlide({ excalidrawData: data }, targetSlideId);
|
|
685
|
+
setLayoutOpen(false);
|
|
686
|
+
} catch (err) {
|
|
687
|
+
console.error("Insert Mermaid diagram failed:", err);
|
|
688
|
+
toast.error(t("editorToolbar.insertMermaidFailed"), {
|
|
689
|
+
description:
|
|
690
|
+
err instanceof Error ? err.message : undefined,
|
|
691
|
+
});
|
|
692
|
+
}
|
|
671
693
|
}}
|
|
672
694
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
|
|
673
695
|
>
|
|
@@ -696,6 +718,8 @@ graph TD
|
|
|
696
718
|
<button
|
|
697
719
|
onClick={async () => {
|
|
698
720
|
if (!onUpdateSlide || !currentSlide) return;
|
|
721
|
+
// Same pinning as the insert handler above.
|
|
722
|
+
const targetSlideId = currentSlide.id;
|
|
699
723
|
try {
|
|
700
724
|
const match = currentSlide.content.match(
|
|
701
725
|
/<div\s+class="mermaid"[^>]*>([\s\S]*?)<\/div>/i,
|
|
@@ -706,13 +730,22 @@ graph TD
|
|
|
706
730
|
const data = await convertMermaidToExcalidraw(
|
|
707
731
|
match[1].trim(),
|
|
708
732
|
);
|
|
709
|
-
|
|
733
|
+
// Same stale-target guard as the insert handler above.
|
|
734
|
+
const stillExists = getDeck(deckId)?.slides.some(
|
|
735
|
+
(s) => s.id === targetSlideId,
|
|
736
|
+
);
|
|
737
|
+
if (!stillExists) return;
|
|
738
|
+
onUpdateSlide({ excalidrawData: data }, targetSlideId);
|
|
710
739
|
setLayoutOpen(false);
|
|
711
|
-
} catch (err
|
|
740
|
+
} catch (err) {
|
|
712
741
|
console.error("Mermaid to Excalidraw failed:", err);
|
|
742
|
+
toast.error(t("editorToolbar.convertMermaidFailed"), {
|
|
743
|
+
description:
|
|
744
|
+
err instanceof Error ? err.message : undefined,
|
|
745
|
+
});
|
|
713
746
|
}
|
|
714
747
|
}}
|
|
715
|
-
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-[hsl(var(--accent-cyan))]
|
|
748
|
+
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-[hsl(var(--accent-cyan))] hover:bg-accent/50 transition-colors"
|
|
716
749
|
>
|
|
717
750
|
<IconTransform className="w-3 h-3" />
|
|
718
751
|
{t("editorToolbar.convertMermaidToExcalidraw")}
|
|
@@ -734,7 +767,7 @@ graph TD
|
|
|
734
767
|
|
|
735
768
|
{/* Transitions section */}
|
|
736
769
|
<div className="mx-2 my-1.5 border-t border-border" />
|
|
737
|
-
<div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground
|
|
770
|
+
<div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
|
738
771
|
{t("editorToolbar.transition")}
|
|
739
772
|
</div>
|
|
740
773
|
<div className="px-3 pb-2.5 grid grid-cols-4 gap-1">
|
|
@@ -767,7 +800,7 @@ graph TD
|
|
|
767
800
|
{onSetAspectRatio && (
|
|
768
801
|
<>
|
|
769
802
|
<div className="mx-2 my-1.5 border-t border-border" />
|
|
770
|
-
<div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground
|
|
803
|
+
<div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
|
771
804
|
{t("editorToolbar.aspectRatio")}
|
|
772
805
|
</div>
|
|
773
806
|
<div className="px-3 pb-2.5 grid grid-cols-4 gap-1">
|
|
@@ -4409,6 +4409,7 @@ export default function SlideEditor({
|
|
|
4409
4409
|
|
|
4410
4410
|
<DrawOverlay
|
|
4411
4411
|
visible={!!drawMode}
|
|
4412
|
+
scopeKey={slideId || slide.id}
|
|
4412
4413
|
onClose={() => onExitDrawMode?.()}
|
|
4413
4414
|
onSend={(annotations, instruction, canvasSize) => {
|
|
4414
4415
|
const summary = annotations
|
|
@@ -31,7 +31,10 @@ export function SlideOverflowWarning({
|
|
|
31
31
|
<div
|
|
32
32
|
role="status"
|
|
33
33
|
aria-live="polite"
|
|
34
|
-
|
|
34
|
+
// w-max/nowrap: the containing block is the zoomed slide canvas, so at
|
|
35
|
+
// low zoom the banner would otherwise wrap to a height taller than its
|
|
36
|
+
// own -top-12 offset and spill down over the slide.
|
|
37
|
+
className="absolute -top-12 left-0 z-20 flex w-max items-center gap-2 whitespace-nowrap rounded-md border border-foreground/40 px-2 py-1 text-xs text-foreground"
|
|
35
38
|
onPointerDown={(event) => event.stopPropagation()}
|
|
36
39
|
onClick={(event) => event.stopPropagation()}
|
|
37
40
|
>
|
|
@@ -143,6 +143,27 @@ export type DeckPersistenceResult =
|
|
|
143
143
|
| { persisted: false; reason: "request-failed"; error: unknown }
|
|
144
144
|
| { persisted: false; reason: "not-found" };
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Ask the server whether a deck row exists, keeping "absent" and "could not
|
|
148
|
+
* check" distinct — a rejected write is not by itself proof the row is missing.
|
|
149
|
+
*/
|
|
150
|
+
async function probeDeckPersisted(id: string): Promise<DeckPersistenceResult> {
|
|
151
|
+
try {
|
|
152
|
+
const result = await callAction<unknown>(
|
|
153
|
+
"get-deck",
|
|
154
|
+
{ id },
|
|
155
|
+
{
|
|
156
|
+
method: "GET",
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
return normalizeActionDeck(result)
|
|
160
|
+
? { persisted: true }
|
|
161
|
+
: { persisted: false, reason: "not-found" };
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return { persisted: false, reason: "request-failed", error };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
146
167
|
export function describeDeckPersistenceFailure(
|
|
147
168
|
result: DeckPersistenceResult,
|
|
148
169
|
fallback: string,
|
|
@@ -178,6 +199,7 @@ interface DeckContextType {
|
|
|
178
199
|
sourceDeckId: string,
|
|
179
200
|
newId: string,
|
|
180
201
|
title?: string,
|
|
202
|
+
onFailure?: () => void,
|
|
181
203
|
) => Deck | null;
|
|
182
204
|
deleteDeck: (id: string) => void;
|
|
183
205
|
updateDeck: (
|
|
@@ -851,18 +873,52 @@ async function fetchDeckListLightFromAPI(): Promise<{ id: string }[] | null> {
|
|
|
851
873
|
}
|
|
852
874
|
}
|
|
853
875
|
|
|
876
|
+
const DECK_FETCH_RETRY_ATTEMPTS = 3;
|
|
877
|
+
const DECK_FETCH_RETRY_DELAY_MS = 400;
|
|
878
|
+
|
|
879
|
+
// `get-deck` returns a real 404/403 only when the deck is genuinely gone or
|
|
880
|
+
// the caller genuinely lacks access (see actions/get-deck.ts). A network blip
|
|
881
|
+
// or 5xx is transient and must not be coerced into the same "not found" null
|
|
882
|
+
// the caller uses to show the owner-facing "deck unavailable" pane — that
|
|
883
|
+
// flashed a wrong message on brief server hiccups even though the deck still
|
|
884
|
+
// existed.
|
|
885
|
+
function isConfirmedDeckAbsence(err: unknown): boolean {
|
|
886
|
+
const status = (err as { status?: unknown } | null)?.status;
|
|
887
|
+
return status === 404 || status === 403;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// A timeout already made the caller wait the full action timeout window once
|
|
891
|
+
// (see DEFAULT_ACTION_TIMEOUT_MS in use-action.ts); retrying would multiply
|
|
892
|
+
// that wait instead of surfacing the failure — same reasoning as
|
|
893
|
+
// `isActionTimeout` in the shared action-query retry policy.
|
|
894
|
+
function isDeckFetchTimeout(err: unknown): boolean {
|
|
895
|
+
return (err as { timedOut?: unknown } | null)?.timedOut === true;
|
|
896
|
+
}
|
|
897
|
+
|
|
854
898
|
async function fetchDeckFromAPI(id: string): Promise<Deck | null> {
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
899
|
+
for (let attempt = 1; attempt <= DECK_FETCH_RETRY_ATTEMPTS; attempt++) {
|
|
900
|
+
try {
|
|
901
|
+
const result = await callAction<unknown>(
|
|
902
|
+
"get-deck",
|
|
903
|
+
{ id },
|
|
904
|
+
{ method: "GET" },
|
|
905
|
+
);
|
|
906
|
+
return normalizeActionDeck(result);
|
|
907
|
+
} catch (err) {
|
|
908
|
+
if (
|
|
909
|
+
isConfirmedDeckAbsence(err) ||
|
|
910
|
+
isDeckFetchTimeout(err) ||
|
|
911
|
+
attempt === DECK_FETCH_RETRY_ATTEMPTS
|
|
912
|
+
) {
|
|
913
|
+
console.error(`Failed to fetch deck ${id}:`, err);
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
await new Promise((resolve) =>
|
|
917
|
+
setTimeout(resolve, attempt * DECK_FETCH_RETRY_DELAY_MS),
|
|
918
|
+
);
|
|
919
|
+
}
|
|
865
920
|
}
|
|
921
|
+
return null;
|
|
866
922
|
}
|
|
867
923
|
|
|
868
924
|
export function deckIdFromPathname(pathname: string): string | null {
|
|
@@ -1776,24 +1832,18 @@ export function DeckProvider({ children }: { children: ReactNode }) {
|
|
|
1776
1832
|
}
|
|
1777
1833
|
}
|
|
1778
1834
|
|
|
1779
|
-
|
|
1780
|
-
const result = await callAction<unknown>(
|
|
1781
|
-
"get-deck",
|
|
1782
|
-
{ id },
|
|
1783
|
-
{ method: "GET" },
|
|
1784
|
-
);
|
|
1785
|
-
return normalizeActionDeck(result)
|
|
1786
|
-
? { persisted: true }
|
|
1787
|
-
: { persisted: false, reason: "not-found" };
|
|
1788
|
-
} catch (error) {
|
|
1789
|
-
return { persisted: false, reason: "request-failed", error };
|
|
1790
|
-
}
|
|
1835
|
+
return probeDeckPersisted(id);
|
|
1791
1836
|
},
|
|
1792
1837
|
[],
|
|
1793
1838
|
);
|
|
1794
1839
|
|
|
1795
1840
|
const duplicateDeck = useCallback(
|
|
1796
|
-
(
|
|
1841
|
+
(
|
|
1842
|
+
sourceDeckId: string,
|
|
1843
|
+
newId: string,
|
|
1844
|
+
title?: string,
|
|
1845
|
+
onFailure?: () => void,
|
|
1846
|
+
): Deck | null => {
|
|
1797
1847
|
if (pendingDuplicateSourceIdsRef.current.has(sourceDeckId)) return null;
|
|
1798
1848
|
const source = decks.find((d) => d.id === sourceDeckId);
|
|
1799
1849
|
if (!source) return null;
|
|
@@ -1843,10 +1893,26 @@ export function DeckProvider({ children }: { children: ReactNode }) {
|
|
|
1843
1893
|
).then(() => undefined);
|
|
1844
1894
|
pendingCreatePromisesRef.current.set(newId, duplicatePromise);
|
|
1845
1895
|
duplicatePromise
|
|
1846
|
-
.catch((err) => {
|
|
1896
|
+
.catch(async (err) => {
|
|
1897
|
+
// A rejected request is not proof the row is missing: a timeout or
|
|
1898
|
+
// dropped response can land after the server committed the insert.
|
|
1899
|
+
// Discarding the copy then would delete work that actually exists
|
|
1900
|
+
// and tell the user it failed, so confirm against the server first.
|
|
1901
|
+
const probe = await probeDeckPersisted(newId);
|
|
1902
|
+
if (probe.persisted) {
|
|
1903
|
+
console.warn(
|
|
1904
|
+
`Duplicate request for ${newId} failed but the deck persisted:`,
|
|
1905
|
+
err,
|
|
1906
|
+
);
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1847
1909
|
console.error("Duplicate failed:", err);
|
|
1848
|
-
// Roll back: drop the optimistic deck from local state.
|
|
1910
|
+
// Roll back: drop the optimistic deck from local state. The caller
|
|
1911
|
+
// (via onFailure) is responsible for navigating away if the user is
|
|
1912
|
+
// still sitting on this now-gone deck's route — otherwise they're
|
|
1913
|
+
// stranded on a "Deck unavailable" screen with no way back.
|
|
1849
1914
|
setDecks((prev) => prev.filter((d) => d.id !== newId));
|
|
1915
|
+
onFailure?.();
|
|
1850
1916
|
})
|
|
1851
1917
|
.finally(() => {
|
|
1852
1918
|
pendingCreateIdsRef.current.delete(newId);
|