Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, useCallback, useMemo, CSSProperties, MouseEvent } from "react";
import React, { useState, useCallback, useMemo, CSSProperties, MouseEvent } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useTabsStore } from "@/stores/useTabsStore";
Expand Down Expand Up @@ -79,7 +79,7 @@ export interface PageTreeItemProps {
};
}

export function PageTreeItem({
export const PageTreeItem = React.memo(function PageTreeItem({
item,
depth,
isActive,
Expand Down Expand Up @@ -493,4 +493,4 @@ export function PageTreeItem({
/>
</>
);
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { toast } from 'sonner';
import { usePageTree } from '@/hooks/usePageTree';
import { useParams } from 'next/navigation';
import { patch } from '@/lib/auth/auth-fetch';
import { useOpenTabsStore } from '@/stores/useOpenTabsStore';
import { useTabsStore } from '@/stores/useTabsStore';

export function EditableTitle({ pageId: propPageId }: { pageId?: string | null } = {}) {
const storePageId = usePageStore((state) => state.pageId);
Expand Down Expand Up @@ -56,6 +58,10 @@ export function EditableTitle({ pageId: propPageId }: { pageId?: string | null }
const updatedPage = await patch<{ id: string; title: string }>(`/api/pages/${page.id}`, { title });
updateNode(updatedPage.id, { title: updatedPage.title });
mutate(`/api/pages/${page.id}/breadcrumbs`);

// Update tab titles in both tab stores (single batched update each)
useOpenTabsStore.getState().updateTabTitle(updatedPage.id, updatedPage.title);
useTabsStore.getState().updateTabMetaByPageId(updatedPage.id, { title: updatedPage.title });
} catch (error) {
console.error(error);
toast.error('Failed to update title');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { useRouter } from 'next/navigation';
import { ShadowCanvas } from '@/components/canvas/ShadowCanvas';
import { ErrorBoundary } from '@/components/ai/shared';
import { TreePage } from '@/hooks/usePageTree';
import { useDocumentStore } from '@/stores/useDocumentStore';
import { useDocumentManagerStore } from '@/stores/useDocumentManagerStore';
import { useEditingStore } from '@/stores/useEditingStore';
import { useAuth } from '@/hooks/useAuth';
import { useSocket } from '@/hooks/useSocket';
import { PageEventPayload } from '@/lib/websocket';
Expand All @@ -21,39 +22,156 @@ interface CanvasPageViewProps {
const MonacoEditor = dynamic(() => import('@/components/editors/MonacoEditor'), { ssr: false });

const CanvasPageView = ({ page }: CanvasPageViewProps) => {
const content = useDocumentStore((state) => state.content);
const setContent = useDocumentStore((state) => state.setContent);
const updateContentFromServer = useDocumentStore((state) => state.updateContentFromServer);
const setDocument = useDocumentStore((state) => state.setDocument);
const setSaveCallback = useDocumentStore((state) => state.setSaveCallback);
const documentState = useDocumentManagerStore((state) => state.documents.get(page.id));
const content = documentState?.content ?? (typeof page.content === 'string' ? page.content : '');
const [activeTab, setActiveTab] = useState('view');
const containerRef = useRef<HTMLDivElement>(null);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const saveVersionRef = useRef(0);
const router = useRouter();
const { user } = useAuth();
const socket = useSocket();

const saveContent = useCallback(async (pageId: string, newValue: string) => {
console.log(`--- Saving Page ${pageId} ---`);
console.log('Content:', newValue);
const saveContent = useCallback(async (pageId: string, newValue: string, expectedRevision?: number) => {
try {
// Include socket ID so server can exclude this client from broadcast
const headers: Record<string, string> = {};
if (socket?.id) {
headers['X-Socket-ID'] = socket.id;
}
await patch(`/api/pages/${pageId}`, { content: newValue }, { headers });
console.log('Save successful');
const body: Record<string, unknown> = { content: newValue };
if (expectedRevision !== undefined) {
body.expectedRevision = expectedRevision;
}
const savedPage = await patch<{ revision?: number }>(`/api/pages/${pageId}`, body, { headers });
return savedPage;
} catch (error) {
console.error('Failed to save page content:', error);
toast.error('Failed to save page content.');
throw error;
}
}, [socket]);

// Keep refs in sync for unmount cleanup (avoids stale closures in empty-deps effects)
const saveContentRef = useRef(saveContent);
const pageIdRef = useRef(page.id);
useEffect(() => { saveContentRef.current = saveContent; }, [saveContent]);
useEffect(() => { pageIdRef.current = page.id; }, [page.id]);

const setContent = useCallback((newContent: string) => {
const version = ++saveVersionRef.current;
useDocumentManagerStore.getState().updateDocument(page.id, {
content: newContent,
isDirty: true,
lastUpdateTime: Date.now(),
});

if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(async () => {
// Timer has fired; clear ref so clean docs can accept server updates again
saveTimeoutRef.current = null;
try {
const doc = useDocumentManagerStore.getState().getDocument(page.id);
const savedPage = await saveContent(page.id, newContent, doc?.revision);
// Only clear isDirty if no newer edits arrived while saving
if (saveVersionRef.current === version) {
const updates: Record<string, unknown> = {
isDirty: false,
lastSaved: Date.now(),
};
if (savedPage?.revision !== undefined) {
updates.revision = savedPage.revision;
}
useDocumentManagerStore.getState().updateDocument(page.id, updates);
} else if (savedPage?.revision !== undefined) {
// Newer edits pending, but still update revision to latest server value
useDocumentManagerStore.getState().updateDocument(page.id, {
revision: savedPage.revision,
});
}
} catch {
// saveContent already logged and toasted - isDirty stays true for retry/unmount-save
}
}, 1000);
}, [page.id, saveContent]);

const updateContentFromServer = useCallback((newContent: string, revision?: number) => {
const doc = useDocumentManagerStore.getState().getDocument(page.id);
// Don't overwrite local edits or in-flight saves
if (doc?.isDirty || saveTimeoutRef.current) return;

const updates: Partial<{ content: string; isDirty: boolean; lastSaved: number; lastUpdateTime: number; revision: number }> = {
content: newContent,
isDirty: false,
lastSaved: Date.now(),
lastUpdateTime: Date.now(),
};
if (revision !== undefined) {
updates.revision = revision;
}
useDocumentManagerStore.getState().updateDocument(page.id, updates);
}, [page.id]);

// Initialize or refresh document in manager store
useEffect(() => {
const initialText = typeof page.content === 'string' ? page.content : '';
setDocument(page.id, initialText);
setSaveCallback(saveContent);
}, [page.id, page.content, setDocument, setSaveCallback, saveContent]);
const store = useDocumentManagerStore.getState();
const existing = store.getDocument(page.id);
if (!existing) {
store.createDocument(page.id, initialText, 'html');
if (page.revision !== undefined) {
store.updateDocument(page.id, { revision: page.revision });
}
} else if (!existing.isDirty && existing.content !== initialText) {
// Refresh from prop if doc exists but isn't dirty (e.g. out-of-band server update)
store.updateDocument(page.id, {
content: initialText,
lastUpdateTime: Date.now(),
...(page.revision !== undefined ? { revision: page.revision } : {}),
});
}
}, [page.id, page.content, page.revision]);

// Register editing state to prevent SWR revalidation during edits
useEffect(() => {
if (documentState?.isDirty) {
useEditingStore.getState().startEditing(page.id, 'document');
} else {
useEditingStore.getState().endEditing(page.id);
}
return () => useEditingStore.getState().endEditing(page.id);
}, [documentState?.isDirty, page.id]);

// Force-save on unmount and clean up cached document
// Empty deps — parent renders with key={page.id} so this only runs on TRUE unmount
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
const id = pageIdRef.current;
const store = useDocumentManagerStore.getState();
const doc = store.getDocument(id);
if (doc?.isDirty) {
const snapshotLastUpdateTime = doc.lastUpdateTime;
saveContentRef.current(id, doc.content, doc.revision)
.then(() => {
const latest = useDocumentManagerStore.getState().getDocument(id);
// Only clear if no remount created a newer document for this page
if (!latest || latest.lastUpdateTime === snapshotLastUpdateTime) {
useDocumentManagerStore.getState().clearDocument(id);
}
})
.catch(() => {
// Save failed — keep document in store so it can be recovered on remount
});
} else {
store.clearDocument(id);
}
useEditingStore.getState().endEditing(id);
};
}, []);

// Listen for real-time content updates from AI tools
useEffect(() => {
Expand All @@ -76,7 +194,7 @@ const CanvasPageView = ({ page }: CanvasPageViewProps) => {
const updatedPage = await response.json();
const newContent = typeof updatedPage.content === 'string' ? updatedPage.content : '';
// Use updateContentFromServer to avoid triggering auto-save loop
updateContentFromServer(newContent);
updateContentFromServer(newContent, updatedPage.revision);
}
} catch (error) {
console.error('Failed to fetch updated canvas content:', error);
Expand Down Expand Up @@ -179,5 +297,6 @@ export default React.memo(
CanvasPageView,
(prevProps, nextProps) =>
prevProps.page.id === nextProps.page.id &&
prevProps.page.content === nextProps.page.content
prevProps.page.content === nextProps.page.content &&
prevProps.page.revision === nextProps.page.revision
);
Loading