Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/api/classroom/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export async function POST(request: NextRequest) {
);
}

const id = stage.id || randomUUID();
const rawId = stage.id || randomUUID();
const id = isValidClassroomId(rawId) ? rawId : randomUUID();
const baseUrl = buildRequestOrigin(request);

const persisted = await persistClassroom({ id, stage: { ...stage, id }, scenes }, baseUrl);
Expand Down
34 changes: 25 additions & 9 deletions app/classroom/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Stage } from '@/components/stage';
import { ThemeProvider } from '@/lib/hooks/use-theme';
import { useStageStore } from '@/lib/store';
import { loadImageMapping } from '@/lib/utils/image-storage';
import { db } from '@/lib/utils/database';
import type { GenerationParamsData } from '@/lib/utils/database';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { useSceneGenerator } from '@/lib/hooks/use-scene-generator';
Expand Down Expand Up @@ -114,16 +116,28 @@ export default function ClassroomDetailPage() {
if (hasPending && stage) {
generationStartedRef.current = true;

// Load generation params from sessionStorage (stored by generation-preview before navigating)
const genParamsStr = sessionStorage.getItem('generationParams');
const params = genParamsStr ? JSON.parse(genParamsStr) : {};
void (async () => {
let params: GenerationParamsData = {};
try {
// Load generation params from IndexedDB (persisted by generation-preview)
const record = await db.stageOutlines.get(stage.id);
params = record?.generationParams || {};
} catch (err) {
log.warn('[Classroom] Failed to load persisted generation params:', err);
}

// Reconstruct imageMapping from IndexedDB using pdfImages storageIds
const storageIds = (params.pdfImages || [])
.map((img: { storageId?: string }) => img.storageId)
.filter(Boolean) as string[];

// Reconstruct imageMapping from IndexedDB using pdfImages storageIds
const storageIds = (params.pdfImages || [])
.map((img: { storageId?: string }) => img.storageId)
.filter(Boolean);
let imageMapping: Record<string, string> = {};
try {
imageMapping = await loadImageMapping(storageIds);
} catch (err) {
log.warn('[Classroom] Failed to rebuild PDF image mapping for resume:', err);
}

loadImageMapping(storageIds).then((imageMapping) => {
generateRemaining({
pdfImages: params.pdfImages,
imageMapping,
Expand All @@ -135,8 +149,10 @@ export default function ClassroomDetailPage() {
},
agents: params.agents,
userProfile: params.userProfile,
}).catch((err) => {
log.warn('[Classroom] Scene generation resume error:', err);
});
});
})();
} else if (outlines.length > 0 && stage) {
// All scenes are generated, but some media may not have finished.
// Resume media generation for any tasks not yet in IndexedDB.
Expand Down
21 changes: 12 additions & 9 deletions app/generation-preview/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -711,15 +711,18 @@ function GenerationPreviewContent() {
const remaining = outlines.filter((o) => o.order !== data.scene.order);
store.setGeneratingOutlines(remaining);

// Store generation params for classroom to continue generation
sessionStorage.setItem(
'generationParams',
JSON.stringify({
pdfImages: currentSession.pdfImages,
agents,
userProfile,
}),
);
// Persist generation params to IndexedDB so generation can resume
// even if the tab is closed and reopened (sessionStorage is ephemeral)
const genParams = {
pdfImages: currentSession.pdfImages,
agents,
userProfile,
};

await db.stageOutlines.update(stage.id, {
generationParams: genParams,
updatedAt: Date.now(),
});

sessionStorage.removeItem('generationSession');
await store.saveToStorage();
Expand Down
2 changes: 1 addition & 1 deletion components/scene-renderers/interactive-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function InteractiveRenderer({ content, mode: _mode, sceneId }: Interacti
src={patchedHtml ? undefined : content.url}
className="absolute inset-0 w-full h-full border-0"
title={`Interactive Scene ${sceneId}`}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
sandbox="allow-scripts allow-forms allow-popups"
/>
</div>
);
Expand Down
66 changes: 66 additions & 0 deletions components/slide-renderer/Editor/Canvas/LinkDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
'use client';

import { useEffect, useState } from 'react';
import type { PPTElementLink } from '@/lib/types/slides';

interface LinkDialogProps {
currentLink?: PPTElementLink;
onConfirm: (link: PPTElementLink) => void;
onClose: () => void;
}

export function LinkDialog({ currentLink, onConfirm, onClose }: LinkDialogProps) {
const [url, setUrl] = useState(currentLink?.target || '');

useEffect(() => {
setUrl(currentLink?.target || '');
}, [currentLink]);

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = url.trim();
if (!trimmed) return;
onConfirm({ type: 'web', target: trimmed });
};

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
onClick={onClose}
>
<form
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-5 w-[380px] space-y-4"
onClick={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{currentLink ? 'Edit Link' : 'Add Link'}
</h3>
<input
type="url"
placeholder="https://example.com"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="w-full rounded border border-gray-300 dark:border-gray-600 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
disabled={!url.trim()}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
Confirm
</button>
</div>
</form>
</div>
);
}
31 changes: 27 additions & 4 deletions components/slide-renderer/Editor/Canvas/hooks/useDrop.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { useEffect, type RefObject } from 'react';
import { useCanvasStore } from '@/lib/store';
import { useCanvasOperations } from '@/lib/hooks/use-canvas-operations';
import { escapeHtml } from '@/lib/utils/sanitize-html';
import type { PPTTextElement } from '@/lib/types/slides';
import { nanoid } from 'nanoid';

export function useDrop(elementRef: RefObject<HTMLElement | null>) {
const disableHotkeys = useCanvasStore.use.disableHotkeys();
const canvasScale = useCanvasStore.use.canvasScale();
const { addElement } = useCanvasOperations();

useEffect(() => {
const element = elementRef.current;
Expand All @@ -13,9 +19,26 @@ export function useDrop(elementRef: RefObject<HTMLElement | null>) {

const firstItem = e.dataTransfer.items[0];
if (firstItem && firstItem.kind === 'string' && firstItem.type === 'text/plain') {
firstItem.getAsString((_text) => {
if (disableHotkeys) return;
// TODO: implement createTextElement
firstItem.getAsString((text) => {
if (disableHotkeys || !text.trim() || !element) return;

const rect = element.getBoundingClientRect();
const left = (e.clientX - rect.left) / canvasScale;
const top = (e.clientY - rect.top) / canvasScale;

const newElement: PPTTextElement = {
id: nanoid(10),
type: 'text',
left,
top,
width: 300,
height: 50,
rotate: 0,
content: `<p>${escapeHtml(text)}</p>`,
defaultFontName: 'Microsoft YaHei',
defaultColor: '#333333',
};
addElement(newElement);
});
}
};
Expand All @@ -41,5 +64,5 @@ export function useDrop(elementRef: RefObject<HTMLElement | null>) {
document.removeEventListener('dragenter', preventDefault);
document.removeEventListener('dragover', preventDefault);
};
}, [elementRef, disableHotkeys]);
}, [elementRef, disableHotkeys, canvasScale, addElement]);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { useCallback, type RefObject } from 'react';
import { useCanvasStore } from '@/lib/store';
import type { CreateElementSelectionData } from '@/lib/types/edit';
import { useCanvasOperations } from '@/lib/hooks/use-canvas-operations';
import type { CreateElementSelectionData, CreatingTextElement, CreatingShapeElement, CreatingLineElement } from '@/lib/types/edit';
import type { PPTTextElement, PPTShapeElement, PPTLineElement } from '@/lib/types/slides';
import { nanoid } from 'nanoid';

export function useInsertFromCreateSelection(viewportRef: RefObject<HTMLElement | null>) {
const canvasScale = useCanvasStore.use.canvasScale();
const creatingElement = useCanvasStore.use.creatingElement();
const setCreatingElement = useCanvasStore.use.setCreatingElement();
const { addElement } = useCanvasOperations();

// Calculate selection position and size from the start and end points of mouse drag selection
const formatCreateSelection = useCallback(
Expand Down Expand Up @@ -65,6 +69,69 @@ export function useInsertFromCreateSelection(viewportRef: RefObject<HTMLElement
[viewportRef, canvasScale],
);

const createTextElement = useCallback(
(position: { left: number; top: number; width: number; height: number }, creating: CreatingTextElement) => {
const element: PPTTextElement = {
id: nanoid(10),
type: 'text',
left: position.left,
top: position.top,
width: Math.max(position.width, 50),
height: Math.max(position.height, 30),
rotate: 0,
content: '<p><br></p>',
defaultFontName: 'Microsoft YaHei',
defaultColor: '#333333',
vertical: creating.vertical,
};
addElement(element);
},
[addElement],
);

const createShapeElement = useCallback(
(position: { left: number; top: number; width: number; height: number }, creating: CreatingShapeElement) => {
const { data } = creating;
const element: PPTShapeElement = {
id: nanoid(10),
type: 'shape',
left: position.left,
top: position.top,
width: Math.max(position.width, 30),
height: Math.max(position.height, 30),
rotate: 0,
viewBox: data.viewBox,
path: data.path,
fill: '#5b9bd5',
fixedRatio: false,
special: data.special,
pathFormula: data.pathFormula,
};
addElement(element);
},
[addElement],
);

const createLineElement = useCallback(
(position: { left: number; top: number; start: [number, number]; end: [number, number] }, creating: CreatingLineElement) => {
const { data } = creating;
const element: PPTLineElement = {
id: nanoid(10),
type: 'line',
left: position.left,
top: position.top,
width: 2,
start: position.start,
end: position.end,
style: data.style,
color: '#333333',
points: data.points,
};
addElement(element);
},
[addElement],
);

// Insert element based on mouse selection position and size
const insertElementFromCreateSelection = useCallback(
(selectionData: CreateElementSelectionData) => {
Expand All @@ -74,22 +141,22 @@ export function useInsertFromCreateSelection(viewportRef: RefObject<HTMLElement
if (type === 'text') {
const position = formatCreateSelection(selectionData);
if (position) {
// TODO: Implement createTextElement
createTextElement(position, creatingElement as CreatingTextElement);
}
} else if (type === 'shape') {
const position = formatCreateSelection(selectionData);
if (position) {
// TODO: Implement createShapeElement
createShapeElement(position, creatingElement as CreatingShapeElement);
}
} else if (type === 'line') {
const position = formatCreateSelectionForLine(selectionData);
if (position) {
// TODO: Implement createLineElement
createLineElement(position, creatingElement as CreatingLineElement);
}
}
setCreatingElement(null);
},
[creatingElement, formatCreateSelection, formatCreateSelectionForLine, setCreatingElement],
[creatingElement, formatCreateSelection, formatCreateSelectionForLine, setCreatingElement, createTextElement, createShapeElement, createLineElement],
);

return {
Expand Down
Loading