修复旧浏览器更新提醒及画布离开保存体验
This commit is contained in:
parent
50ffc8d648
commit
ebb0d1514b
@ -5120,7 +5120,30 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const canvasPersistenceScope = useRef(id);
|
const canvasPersistenceScope = useRef(id);
|
||||||
const canvasRemoteOperationActive = useRef(false);
|
const canvasRemoteOperationActive = useRef(false);
|
||||||
|
const canvasLeaveApprovedRef = useRef(false);
|
||||||
|
const [canvasLeaveUrl, setCanvasLeaveUrl] = useState("");
|
||||||
|
const [isSavingBeforeLeave, setIsSavingBeforeLeave] = useState(false);
|
||||||
|
const [canvasLeaveError, setCanvasLeaveError] = useState("");
|
||||||
canvasPersistenceScope.current = id;
|
canvasPersistenceScope.current = id;
|
||||||
|
const hasUnsavedCanvasChanges = useCallback(() => {
|
||||||
|
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || savedCanvasSnapshotRef.current?.id !== id) return false;
|
||||||
|
const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData as CanvasState;
|
||||||
|
const current = buildPersistedCanvasData();
|
||||||
|
const content = (value: CanvasState) => [value.nodes.map(node => {
|
||||||
|
const { updatedAt: _updatedAt, ...nodeData } = node;
|
||||||
|
return { ...nodeData, connections: node.connections || [] };
|
||||||
|
}), value.edges, value.suppressedMediaKeys || [], normalizeStyleProPersistedState(value.styleProState)];
|
||||||
|
return JSON.stringify(content(saved)) !== JSON.stringify(content(current));
|
||||||
|
}, [buildPersistedCanvasData, id]);
|
||||||
|
const requestCanvasLeave = useCallback((url: string) => {
|
||||||
|
if (hasUnsavedCanvasChanges()) {
|
||||||
|
setCanvasLeaveError("");
|
||||||
|
setCanvasLeaveUrl(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
canvasLeaveApprovedRef.current = true;
|
||||||
|
window.location.assign(url);
|
||||||
|
}, [hasUnsavedCanvasChanges]);
|
||||||
const applyCanvasRemoteSnapshot = useCallback((result: CanvasRemoteOperationResult) => {
|
const applyCanvasRemoteSnapshot = useCallback((result: CanvasRemoteOperationResult) => {
|
||||||
if (result.projectId !== id || canvasPersistenceScope.current !== id || useCanvasStore.getState().projectId !== id) return false;
|
if (result.projectId !== id || canvasPersistenceScope.current !== id || useCanvasStore.getState().projectId !== id) return false;
|
||||||
const remoteSnapshot = JSON.stringify({ canvasData: result.canvasData });
|
const remoteSnapshot = JSON.stringify({ canvasData: result.canvasData });
|
||||||
@ -5147,7 +5170,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}, [buildPersistedCanvasData, id]);
|
}, [buildPersistedCanvasData, id]);
|
||||||
const persistCanvasStateNow = useCallback(
|
const persistCanvasStateNow = useCallback(
|
||||||
async (reason: "autosave" | "delete" | "beforeunload") => {
|
async (reason: "autosave" | "delete" | "beforeunload") => {
|
||||||
if (canvasPersistenceScope.current !== id || !canvasReadyRef.current || useCanvasStore.getState().projectId !== id) return false;
|
if (canvasLeaveApprovedRef.current || canvasPersistenceScope.current !== id || !canvasReadyRef.current || useCanvasStore.getState().projectId !== id) return false;
|
||||||
if (externalCanvasConflictRef.current) return false;
|
if (externalCanvasConflictRef.current) return false;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
@ -5232,6 +5255,30 @@ export default function CanvasEditorLegacyPage({
|
|||||||
[persistCanvasStateNow]
|
[persistCanvasStateNow]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const finishCanvasLeave = useCallback(async (save: boolean) => {
|
||||||
|
if (!canvasLeaveUrl || isSavingBeforeLeave) return;
|
||||||
|
setCanvasLeaveError("");
|
||||||
|
if (canvasAutosaveTimer.current) {
|
||||||
|
clearTimeout(canvasAutosaveTimer.current);
|
||||||
|
canvasAutosaveTimer.current = null;
|
||||||
|
}
|
||||||
|
if (save) {
|
||||||
|
setIsSavingBeforeLeave(true);
|
||||||
|
try {
|
||||||
|
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current || "画布暂时无法同步,请保留此页并稍后重试。");
|
||||||
|
if (hasUnsavedCanvasChanges() && !(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current);
|
||||||
|
if (hasUnsavedCanvasChanges()) throw new Error("还有改动正在同步,请稍后重试离开。");
|
||||||
|
} catch (error) {
|
||||||
|
setCanvasLeaveError(error instanceof Error ? error.message : "画布暂时无法同步,请保留此页并稍后重试。");
|
||||||
|
setIsSavingBeforeLeave(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvasLeaveApprovedRef.current = true;
|
||||||
|
clearCanvasDraft(id);
|
||||||
|
window.location.assign(canvasLeaveUrl);
|
||||||
|
}, [canvasLeaveUrl, hasUnsavedCanvasChanges, id, isSavingBeforeLeave, persistCanvasState]);
|
||||||
|
|
||||||
const resolveCanvasSyncConflict = useCallback((choice: "local" | "remote") => {
|
const resolveCanvasSyncConflict = useCallback((choice: "local" | "remote") => {
|
||||||
return canvasSaveQueue.current!.enqueue(async () => {
|
return canvasSaveQueue.current!.enqueue(async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
@ -5355,7 +5402,33 @@ export default function CanvasEditorLegacyPage({
|
|||||||
clearTimeout(saveTimer);
|
clearTimeout(saveTimer);
|
||||||
if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null;
|
if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null;
|
||||||
};
|
};
|
||||||
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
}, [isAgentMode, isAgentOperationRefreshActive, nodes, edges, isLoading, persistCanvasState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading || isAgentMode) return;
|
||||||
|
const retrySync = () => {
|
||||||
|
if (canvasLeaveApprovedRef.current || externalCanvasConflictRef.current || canvasRemoteOperationActive.current || isAgentOperationRefreshActive() || !hasUnsavedCanvasChanges()) return;
|
||||||
|
void persistCanvasState("autosave").catch(error => console.error("[Canvas] background sync failed:", error));
|
||||||
|
};
|
||||||
|
const onVisibilityChange = () => {
|
||||||
|
if (!canvasLeaveApprovedRef.current && document.visibilityState === "hidden" && hasUnsavedCanvasChanges()) {
|
||||||
|
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current!.snapshot, snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }) });
|
||||||
|
retrySync();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const retryTimer = window.setInterval(() => {
|
||||||
|
if (document.visibilityState === "visible" && navigator.onLine && canvasSyncErrorRef.current) retrySync();
|
||||||
|
}, 20_000);
|
||||||
|
window.addEventListener("online", retrySync);
|
||||||
|
window.addEventListener("focus", retrySync);
|
||||||
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(retryTimer);
|
||||||
|
window.removeEventListener("online", retrySync);
|
||||||
|
window.removeEventListener("focus", retrySync);
|
||||||
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
};
|
||||||
|
}, [buildPersistedCanvasData, hasUnsavedCanvasChanges, id, isAgentMode, isAgentOperationRefreshActive, isLoading, persistCanvasState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isAgentOperationRefreshActive()) return;
|
if (isLoading || isAgentOperationRefreshActive()) return;
|
||||||
@ -5458,18 +5531,25 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
||||||
|
|
||||||
// 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率
|
// 浏览器离开确认只保护尚未同步的内容;真正离开时保留草稿并发送一次保存。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isAgentMode) return;
|
if (isLoading || isAgentMode) return;
|
||||||
|
|
||||||
const handleBeforeUnload = () => {
|
const retainDraft = () => {
|
||||||
|
if (canvasLeaveApprovedRef.current || !hasUnsavedCanvasChanges()) return null;
|
||||||
|
const canvasData = buildPersistedCanvasData();
|
||||||
|
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current!.snapshot, snapshot: JSON.stringify({ canvasData }) });
|
||||||
|
return canvasData;
|
||||||
|
};
|
||||||
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
if (!retainDraft()) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = "";
|
||||||
|
};
|
||||||
|
const handlePageHide = () => {
|
||||||
try {
|
try {
|
||||||
if (!canvasReadyRef.current || isAgentOperationRefreshActive()) return;
|
const canvasData = retainDraft();
|
||||||
const canvasData = buildPersistedCanvasData();
|
if (!canvasData || isSaving || canvasRemoteOperationActive.current || isAgentOperationRefreshActive() || externalCanvasConflictRef.current) return;
|
||||||
writeCanvasDraft(id, {
|
|
||||||
base: savedCanvasSnapshotRef.current?.id === id ? savedCanvasSnapshotRef.current.snapshot : JSON.stringify({ canvasData: { nodes: [], edges: [] } }),
|
|
||||||
snapshot: JSON.stringify({ canvasData }),
|
|
||||||
});
|
|
||||||
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
||||||
void fetch(`/api/canvas/${id}`, {
|
void fetch(`/api/canvas/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@ -5488,18 +5568,20 @@ export default function CanvasEditorLegacyPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
window.addEventListener("pagehide", handleBeforeUnload);
|
window.addEventListener("pagehide", handlePageHide);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
window.removeEventListener("pagehide", handleBeforeUnload);
|
window.removeEventListener("pagehide", handlePageHide);
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
activeCanvasWorkspaceType,
|
activeCanvasWorkspaceType,
|
||||||
buildPersistedCanvasData,
|
buildPersistedCanvasData,
|
||||||
|
hasUnsavedCanvasChanges,
|
||||||
id,
|
id,
|
||||||
isAgentOperationRefreshActive,
|
isAgentOperationRefreshActive,
|
||||||
isAgentMode,
|
isAgentMode,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isSaving,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 智能命名与缩略图逻辑
|
// 智能命名与缩略图逻辑
|
||||||
@ -10509,6 +10591,20 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CanvasAgentUiListener projectId={id} workspace="canvas" />
|
<CanvasAgentUiListener projectId={id} workspace="canvas" />
|
||||||
|
<Dialog open={Boolean(canvasLeaveUrl)} onOpenChange={open => { if (!open && !isSavingBeforeLeave) setCanvasLeaveUrl(""); }}>
|
||||||
|
<DialogContent onEscapeKeyDown={event => { if (isSavingBeforeLeave) event.preventDefault(); }} onPointerDownOutside={event => event.preventDefault()}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>离开前保存画布?</DialogTitle>
|
||||||
|
<DialogDescription>平时会自动保存。当前还有改动尚未同步到云端,可以保存后离开,或放弃这些未同步的改动。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{canvasLeaveError ? <p role="alert" className="text-sm text-destructive">{canvasLeaveError}</p> : null}
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button disabled={isSavingBeforeLeave} variant="ghost" onClick={() => setCanvasLeaveUrl("")}>继续编辑</Button>
|
||||||
|
<Button disabled={isSavingBeforeLeave} variant="outline" onClick={() => void finishCanvasLeave(false)}>不保存并离开</Button>
|
||||||
|
<Button disabled={isSavingBeforeLeave} onClick={() => void finishCanvasLeave(true)}>{isSavingBeforeLeave ? "正在保存…" : "保存并离开"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
{canvasSyncConflicts.length > 0 ? (
|
{canvasSyncConflicts.length > 0 ? (
|
||||||
<div role="alert" className="absolute inset-x-3 top-28 z-[60] mx-auto flex max-w-2xl flex-wrap items-center gap-3 rounded-lg border bg-background p-4 text-sm shadow-md">
|
<div role="alert" className="absolute inset-x-3 top-28 z-[60] mx-auto flex max-w-2xl flex-wrap items-center gap-3 rounded-lg border bg-background p-4 text-sm shadow-md">
|
||||||
<div className="min-w-0 flex-1 basis-64">
|
<div className="min-w-0 flex-1 basis-64">
|
||||||
@ -11228,14 +11324,12 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
{isVideoMode ? brandEntry : (
|
{isVideoMode ? brandEntry : (
|
||||||
<CanvasSurfaceHeader
|
<CanvasSurfaceHeader
|
||||||
isSaving={isSaving}
|
|
||||||
onAddImage={handleAddImageNode}
|
onAddImage={handleAddImageNode}
|
||||||
onHome={goHome}
|
onHome={() => requestCanvasLeave("/")}
|
||||||
onOpenFiles={() => {
|
onOpenFiles={() => {
|
||||||
setIsPanelOpen(true);
|
setIsPanelOpen(true);
|
||||||
setPanelMode("fileList");
|
setPanelMode("fileList");
|
||||||
}}
|
}}
|
||||||
onSave={() => void handleSave()}
|
|
||||||
projectTitle={projectTitle}
|
projectTitle={projectTitle}
|
||||||
rightInset={panelOcclusionRight}
|
rightInset={panelOcclusionRight}
|
||||||
/>
|
/>
|
||||||
@ -11340,6 +11434,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
createUrl={createNewCanvasUrl}
|
createUrl={createNewCanvasUrl}
|
||||||
currentId={id}
|
currentId={id}
|
||||||
onClose={() => setPanelMode("chat")}
|
onClose={() => setPanelMode("chat")}
|
||||||
|
onNavigate={requestCanvasLeave}
|
||||||
workspaceLabel={workspaceLabel}
|
workspaceLabel={workspaceLabel}
|
||||||
workspaceType={activeCanvasWorkspaceType}
|
workspaceType={activeCanvasWorkspaceType}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -10,31 +10,39 @@ export function AppUpdateNotice() {
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
let disposed = false;
|
||||||
|
let controller: AbortController | undefined;
|
||||||
let checking = false;
|
let checking = false;
|
||||||
const check = async () => {
|
const check = async () => {
|
||||||
if (checking || document.visibilityState !== "visible") return;
|
if (disposed || checking || document.visibilityState !== "visible") return;
|
||||||
checking = true;
|
checking = true;
|
||||||
|
controller = new AbortController();
|
||||||
|
const current = controller;
|
||||||
|
const timeout = window.setTimeout(() => current.abort(), 8000);
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/app-version", { cache: "no-store", signal: AbortSignal.any([controller.signal, AbortSignal.timeout(8000)]) });
|
const response = await fetch("/api/app-version", { cache: "no-store", signal: current.signal });
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!controller.signal.aborted && typeof data.version === "string" && data.version)
|
if (!disposed && !current.signal.aborted && typeof data.version === "string" && data.version)
|
||||||
setAvailableVersion(data.version !== (process.env.NEXT_PUBLIC_APP_RELEASE || "development") ? data.version : "");
|
setAvailableVersion(data.version !== (process.env.NEXT_PUBLIC_APP_RELEASE || "development") ? data.version : "");
|
||||||
} catch {
|
} catch {
|
||||||
// A failed version read leaves the current workspace available.
|
// A failed version read leaves the current workspace available.
|
||||||
} finally {
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
checking = false;
|
checking = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void check();
|
void check();
|
||||||
const interval = window.setInterval(check, 60_000);
|
const interval = window.setInterval(check, 60_000);
|
||||||
window.addEventListener("focus", check);
|
window.addEventListener("focus", check);
|
||||||
|
window.addEventListener("online", check);
|
||||||
document.addEventListener("visibilitychange", check);
|
document.addEventListener("visibilitychange", check);
|
||||||
return () => {
|
return () => {
|
||||||
controller.abort();
|
disposed = true;
|
||||||
|
controller?.abort();
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
window.removeEventListener("focus", check);
|
window.removeEventListener("focus", check);
|
||||||
|
window.removeEventListener("online", check);
|
||||||
document.removeEventListener("visibilitychange", check);
|
document.removeEventListener("visibilitychange", check);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@ -19,6 +19,7 @@ const PROJECT_LIST_PAGE_SIZE = 24;
|
|||||||
interface CanvasFileListPanelProps {
|
interface CanvasFileListPanelProps {
|
||||||
currentId: string;
|
currentId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onNavigate?: (url: string) => void;
|
||||||
workspaceType?: CanvasWorkspaceType;
|
workspaceType?: CanvasWorkspaceType;
|
||||||
workspaceLabel?: string;
|
workspaceLabel?: string;
|
||||||
createUrl?: string;
|
createUrl?: string;
|
||||||
@ -28,6 +29,7 @@ interface CanvasFileListPanelProps {
|
|||||||
export function CanvasFileListPanel({
|
export function CanvasFileListPanel({
|
||||||
currentId,
|
currentId,
|
||||||
onClose,
|
onClose,
|
||||||
|
onNavigate,
|
||||||
workspaceType = "canvas",
|
workspaceType = "canvas",
|
||||||
workspaceLabel = "画布",
|
workspaceLabel = "画布",
|
||||||
createUrl = "/canvas?new=1",
|
createUrl = "/canvas?new=1",
|
||||||
@ -88,6 +90,10 @@ export function CanvasFileListPanel({
|
|||||||
|
|
||||||
// 创建新画布
|
// 创建新画布
|
||||||
const handleCreateNew = async () => {
|
const handleCreateNew = async () => {
|
||||||
|
if (onNavigate) {
|
||||||
|
onNavigate(createUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
router.push(createUrl);
|
router.push(createUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -100,6 +106,10 @@ export function CanvasFileListPanel({
|
|||||||
}
|
}
|
||||||
const targetUrl = buildProjectUrl(id);
|
const targetUrl = buildProjectUrl(id);
|
||||||
onClose(); // 关闭文件列表面板
|
onClose(); // 关闭文件列表面板
|
||||||
|
if (onNavigate) {
|
||||||
|
onNavigate(targetUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 强制整页跳转,避免仅更新 URL 不刷新
|
// 强制整页跳转,避免仅更新 URL 不刷新
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.assign(targetUrl);
|
window.location.assign(targetUrl);
|
||||||
|
|||||||
@ -2,14 +2,10 @@
|
|||||||
|
|
||||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
||||||
|
|
||||||
import { Loader2, Save } from "lucide-react";
|
|
||||||
|
|
||||||
type CanvasSurfaceHeaderProps = {
|
type CanvasSurfaceHeaderProps = {
|
||||||
isSaving: boolean;
|
|
||||||
onAddImage: () => void;
|
onAddImage: () => void;
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
onOpenFiles: () => void;
|
onOpenFiles: () => void;
|
||||||
onSave: () => void;
|
|
||||||
projectTitle: string;
|
projectTitle: string;
|
||||||
rightInset: number;
|
rightInset: number;
|
||||||
};
|
};
|
||||||
@ -18,11 +14,9 @@ const iconButtonClass =
|
|||||||
"inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] p-0 text-[#858585] transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/10";
|
"inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] p-0 text-[#858585] transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/10";
|
||||||
|
|
||||||
export function CanvasSurfaceHeader({
|
export function CanvasSurfaceHeader({
|
||||||
isSaving,
|
|
||||||
onAddImage,
|
onAddImage,
|
||||||
onHome,
|
onHome,
|
||||||
onOpenFiles,
|
onOpenFiles,
|
||||||
onSave,
|
|
||||||
projectTitle,
|
projectTitle,
|
||||||
rightInset,
|
rightInset,
|
||||||
}: CanvasSurfaceHeaderProps) {
|
}: CanvasSurfaceHeaderProps) {
|
||||||
@ -77,19 +71,6 @@ export function CanvasSurfaceHeader({
|
|||||||
<ReferenceIcon name="plus" size={16} />
|
<ReferenceIcon name="plus" size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[8px] border border-[#e5e5e5] px-3 text-[12px] text-[#333333] transition-colors hover:bg-black/5 disabled:opacity-50 dark:border-white/10 dark:text-white"
|
|
||||||
disabled={isSaving}
|
|
||||||
onClick={onSave}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
保存
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -106,6 +106,112 @@ async function moveImage(page: Page) {
|
|||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unloadWouldBeBlocked = (page: Page) => page.evaluate(() => {
|
||||||
|
const event = new Event("beforeunload", { cancelable: true });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
return event.defaultPrevented;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("background saving recovers when the network returns without a manual save", async ({ page }) => {
|
||||||
|
const { state, errors } = await setup(page);
|
||||||
|
await expect(page.getByRole("button", { name: "保存", exact: true })).toHaveCount(0);
|
||||||
|
state.failSave = true;
|
||||||
|
await moveImage(page);
|
||||||
|
await expect.poll(() => state.writes.filter(body => body.canvasData).length).toBeGreaterThan(0);
|
||||||
|
expect(state.canvasData.nodes[0].position.x).toBe(160);
|
||||||
|
state.failSave = false;
|
||||||
|
await page.evaluate(() => window.dispatchEvent(new Event("online")));
|
||||||
|
await expect.poll(() => state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||||||
|
await expect.poll(() => unloadWouldBeBlocked(page)).toBe(false);
|
||||||
|
expect(state.submissions).toHaveLength(0);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only unsaved content blocks closing and beforeunload does not issue duplicate saves", async ({ page }) => {
|
||||||
|
const { state, errors } = await setup(page);
|
||||||
|
expect(await unloadWouldBeBlocked(page)).toBe(false);
|
||||||
|
await page.locator('[data-canvas-node-id="source"]').click();
|
||||||
|
expect(await unloadWouldBeBlocked(page)).toBe(false);
|
||||||
|
state.failSave = true;
|
||||||
|
await moveImage(page);
|
||||||
|
const writes = state.writes.length;
|
||||||
|
expect(await unloadWouldBeBlocked(page)).toBe(true);
|
||||||
|
expect(state.writes).toHaveLength(writes);
|
||||||
|
const draft = await page.evaluate(id => localStorage.getItem(`canvas-unsynced:${id}`), projectId);
|
||||||
|
expect(JSON.parse(JSON.parse(draft!).snapshot).canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||||||
|
expect(state.submissions).toHaveLength(0);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaving with pending changes can continue editing or save before navigating", async ({ page }) => {
|
||||||
|
const { state, errors } = await setup(page);
|
||||||
|
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
|
||||||
|
await moveImage(page);
|
||||||
|
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await page.screenshot({ path: test.info().outputPath("leave-dialog-desktop.png"), animations: "disabled" });
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.screenshot({ path: test.info().outputPath("leave-dialog-mobile.png"), animations: "disabled" });
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await dialog.getByRole("button", { name: "继续编辑", exact: true }).click();
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
await moveImage(page);
|
||||||
|
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
||||||
|
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
|
||||||
|
await expect(page.getByText("已返回首页")).toBeVisible();
|
||||||
|
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||||||
|
expect(state.submissions).toHaveLength(0);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed saving keeps the leave dialog and canvas until a successful retry", async ({ page }) => {
|
||||||
|
const { state, errors } = await setup(page);
|
||||||
|
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
|
||||||
|
state.failSave = true;
|
||||||
|
await moveImage(page);
|
||||||
|
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
|
||||||
|
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
|
||||||
|
await expect(dialog.getByRole("alert")).toContainText("HTTP 503");
|
||||||
|
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
|
||||||
|
expect(state.submissions).toHaveLength(0);
|
||||||
|
state.failSave = false;
|
||||||
|
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
|
||||||
|
await expect(page.getByText("已返回首页")).toBeVisible();
|
||||||
|
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("discarding pending changes leaves without saving or restoring the discarded draft", async ({ page }) => {
|
||||||
|
const { state, errors } = await setup(page);
|
||||||
|
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
|
||||||
|
state.failSave = true;
|
||||||
|
await moveImage(page);
|
||||||
|
expect(await unloadWouldBeBlocked(page)).toBe(true);
|
||||||
|
const writes = state.writes.length;
|
||||||
|
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "不保存并离开", exact: true }).click();
|
||||||
|
await expect(page.getByText("已返回首页")).toBeVisible();
|
||||||
|
expect(state.writes).toHaveLength(writes);
|
||||||
|
expect(state.canvasData.nodes[0].position.x).toBe(160);
|
||||||
|
expect(await page.evaluate(id => localStorage.getItem(`canvas-unsynced:${id}`), projectId)).toBeNull();
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creating another project also offers to preserve pending canvas changes", async ({ page }) => {
|
||||||
|
const { state, errors } = await setup(page);
|
||||||
|
state.failSave = true;
|
||||||
|
await moveImage(page);
|
||||||
|
await page.getByRole("button", { name: "查看项目文件", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "新建画布", exact: true }).first().click();
|
||||||
|
await expect(page.getByRole("dialog", { name: "离开前保存画布?" })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "继续编辑", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/canvas/${projectId}$`));
|
||||||
|
expect(state.submissions).toHaveLength(0);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test("an enhanced result can be moved and archived before the next save without blocking Agent", async ({ page }) => {
|
test("an enhanced result can be moved and archived before the next save without blocking Agent", async ({ page }) => {
|
||||||
const { state, errors } = await setup(page);
|
const { state, errors } = await setup(page);
|
||||||
await page.locator('[data-canvas-node-id="source"]').click();
|
await page.locator('[data-canvas-node-id="source"]').click();
|
||||||
@ -309,6 +415,28 @@ async function countRefreshes(page: Page, state: { appVersion: string }) {
|
|||||||
return () => navigations;
|
return () => navigations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("an older browser without AbortSignal static helpers receives the release notice", async ({ page }) => {
|
||||||
|
await page.context().addInitScript(() => {
|
||||||
|
Object.defineProperty(AbortSignal, "any", { value: undefined, configurable: true });
|
||||||
|
Object.defineProperty(AbortSignal, "timeout", { value: undefined, configurable: true });
|
||||||
|
});
|
||||||
|
const { state, errors } = await setup(page);
|
||||||
|
await showUpdate(page, state);
|
||||||
|
await expect(page.getByRole("button", { name: "同步并刷新", exact: true })).toBeVisible();
|
||||||
|
expect(state.submissions).toHaveLength(0);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a release is detected by the minute timer without requiring a focus change", async ({ page }) => {
|
||||||
|
await page.clock.install();
|
||||||
|
const { state, errors } = await setup(page);
|
||||||
|
state.appVersion = "20261106T120000Z-nextrelease1";
|
||||||
|
await page.clock.fastForward(60_001);
|
||||||
|
await expect(page.getByRole("alertdialog")).toContainText("平台已更新");
|
||||||
|
expect(state.submissions).toHaveLength(0);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test("a new release requires confirmation and refreshes after preserving canvas and input", async ({ page }) => {
|
test("a new release requires confirmation and refreshes after preserving canvas and input", async ({ page }) => {
|
||||||
const { state, errors } = await setup(page);
|
const { state, errors } = await setup(page);
|
||||||
const refreshes = await countRefreshes(page, state);
|
const refreshes = await countRefreshes(page, state);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user