Improve autosave reliability and implement size limits for local draft storage
This commit is contained in:
@@ -4555,6 +4555,14 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
autosaveControllerRef.current = new EditorAutosaveController({
|
||||
saveDraft: () => store.saveDraft(),
|
||||
onComplete: (result) => {
|
||||
if (result.status === "skipped") {
|
||||
if (lastAutosaveErrorRef.current !== result.message) {
|
||||
lastAutosaveErrorRef.current = result.message;
|
||||
setStatusMessage(result.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === "error") {
|
||||
if (lastAutosaveErrorRef.current !== result.message) {
|
||||
lastAutosaveErrorRef.current = result.message;
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
|
||||
import {
|
||||
parseSceneDocumentJson,
|
||||
parseProjectDocumentJson,
|
||||
serializeProjectDocument
|
||||
parseProjectDocumentJson
|
||||
} from "./scene-document-json";
|
||||
import { assertProjectDocumentIsValid } from "../document/scene-document-validation";
|
||||
|
||||
export interface KeyValueStorage {
|
||||
getItem(key: string): string | null;
|
||||
@@ -31,6 +31,7 @@ export interface BrowserStorageAccessResult {
|
||||
|
||||
export type SaveSceneDocumentDraftResult =
|
||||
| { status: "saved"; message: string }
|
||||
| { status: "skipped"; message: string }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
export type LoadSceneDocumentDraftResult =
|
||||
@@ -45,8 +46,15 @@ export interface LoadOrCreateSceneDocumentResult {
|
||||
}
|
||||
|
||||
export const DEFAULT_SCENE_DRAFT_STORAGE_KEY = "webeditor3d.scene-document-draft";
|
||||
export const DEFAULT_SCENE_DRAFT_MAX_SERIALIZED_BYTES = 4 * 1024 * 1024;
|
||||
const ESTIMATED_TERRAIN_SAMPLE_JSON_BYTES = 8;
|
||||
const ESTIMATED_PROJECT_DRAFT_BASE_BYTES = 64 * 1024;
|
||||
const EDITOR_DRAFT_ENVELOPE_FORMAT = "webeditor3d.editor-draft.v1";
|
||||
|
||||
export interface SaveSceneDocumentDraftOptions {
|
||||
maxSerializedBytes?: number;
|
||||
}
|
||||
|
||||
interface StoredEditorDraftEnvelope {
|
||||
format: typeof EDITOR_DRAFT_ENVELOPE_FORMAT;
|
||||
document: unknown;
|
||||
@@ -65,6 +73,48 @@ function formatStorageDiagnostic(prefix: string, error: unknown): string {
|
||||
return `${prefix} ${getErrorDetail(error)}`;
|
||||
}
|
||||
|
||||
function formatByteSize(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function getAutosaveTooLargeMessage(sizeBytes: number, maxBytes: number): string {
|
||||
return `Autosave skipped because this project draft is about ${formatByteSize(sizeBytes)}, above the ${formatByteSize(maxBytes)} browser autosave limit. Use project save/export for terrain-heavy scenes.`;
|
||||
}
|
||||
|
||||
function getTerrainDraftSampleValueCount(document: ProjectDocument): number {
|
||||
let sampleValueCount = 0;
|
||||
|
||||
for (const scene of Object.values(document.scenes)) {
|
||||
for (const terrain of Object.values(scene.terrains)) {
|
||||
sampleValueCount += terrain.heights.length + terrain.paintWeights.length;
|
||||
}
|
||||
}
|
||||
|
||||
return sampleValueCount;
|
||||
}
|
||||
|
||||
export function estimateProjectDraftSerializedBytes(
|
||||
document: ProjectDocument,
|
||||
viewportLayoutState: ViewportLayoutState | null = null
|
||||
): number {
|
||||
const terrainSampleValueCount = getTerrainDraftSampleValueCount(document);
|
||||
const viewportBytes = viewportLayoutState === null ? 0 : 16 * 1024;
|
||||
|
||||
return (
|
||||
ESTIMATED_PROJECT_DRAFT_BASE_BYTES +
|
||||
viewportBytes +
|
||||
terrainSampleValueCount * ESTIMATED_TERRAIN_SAMPLE_JSON_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -199,19 +249,56 @@ export function saveSceneDocumentDraft(
|
||||
storage: KeyValueStorage,
|
||||
document: ProjectDocument,
|
||||
viewportLayoutState: ViewportLayoutState | null = null,
|
||||
key = DEFAULT_SCENE_DRAFT_STORAGE_KEY
|
||||
key = DEFAULT_SCENE_DRAFT_STORAGE_KEY,
|
||||
options: SaveSceneDocumentDraftOptions = {}
|
||||
): SaveSceneDocumentDraftResult {
|
||||
try {
|
||||
const rawDocument = serializeProjectDocument(document);
|
||||
storage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
format: EDITOR_DRAFT_ENVELOPE_FORMAT,
|
||||
document: JSON.parse(rawDocument),
|
||||
viewportLayoutState: viewportLayoutState === null ? null : cloneViewportLayoutState(viewportLayoutState)
|
||||
} satisfies StoredEditorDraftEnvelope)
|
||||
assertProjectDocumentIsValid(document);
|
||||
|
||||
const maxSerializedBytes =
|
||||
options.maxSerializedBytes ?? DEFAULT_SCENE_DRAFT_MAX_SERIALIZED_BYTES;
|
||||
const estimatedDraftBytes = estimateProjectDraftSerializedBytes(
|
||||
document,
|
||||
viewportLayoutState
|
||||
);
|
||||
|
||||
if (
|
||||
Number.isFinite(maxSerializedBytes) &&
|
||||
maxSerializedBytes > 0 &&
|
||||
estimatedDraftBytes > maxSerializedBytes
|
||||
) {
|
||||
storage.removeItem(key);
|
||||
|
||||
return {
|
||||
status: "skipped",
|
||||
message: getAutosaveTooLargeMessage(
|
||||
estimatedDraftBytes,
|
||||
maxSerializedBytes
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
const rawDraft = JSON.stringify({
|
||||
format: EDITOR_DRAFT_ENVELOPE_FORMAT,
|
||||
document,
|
||||
viewportLayoutState: viewportLayoutState === null ? null : cloneViewportLayoutState(viewportLayoutState)
|
||||
} satisfies StoredEditorDraftEnvelope);
|
||||
|
||||
if (
|
||||
Number.isFinite(maxSerializedBytes) &&
|
||||
maxSerializedBytes > 0 &&
|
||||
rawDraft.length > maxSerializedBytes
|
||||
) {
|
||||
storage.removeItem(key);
|
||||
|
||||
return {
|
||||
status: "skipped",
|
||||
message: getAutosaveTooLargeMessage(rawDraft.length, maxSerializedBytes)
|
||||
};
|
||||
}
|
||||
|
||||
storage.setItem(key, rawDraft);
|
||||
|
||||
return {
|
||||
status: "saved",
|
||||
message: "Autosave updated."
|
||||
|
||||
@@ -9531,8 +9531,9 @@ export class ViewportHost {
|
||||
this.currentTerrainBrushState.tool === "flatten"
|
||||
? hit.point.y - terrain.position.y
|
||||
: null;
|
||||
const previewTerrain = this.applyTerrainBrushPoint(
|
||||
terrain,
|
||||
const previewTerrain = cloneTerrain(terrain);
|
||||
const initialStamp = this.applyTerrainBrushPoint(
|
||||
previewTerrain,
|
||||
{
|
||||
x: hit.point.x,
|
||||
z: hit.point.z
|
||||
@@ -9544,6 +9545,7 @@ export class ViewportHost {
|
||||
this.activeTerrainBrushStroke = {
|
||||
pointerId: event.pointerId,
|
||||
previewTerrain,
|
||||
changed: initialStamp.changed,
|
||||
referenceHeight,
|
||||
lastAppliedPoint: {
|
||||
x: hit.point.x,
|
||||
@@ -9552,7 +9554,10 @@ export class ViewportHost {
|
||||
toolState: this.currentTerrainBrushState
|
||||
};
|
||||
this.renderer.domElement.setPointerCapture(event.pointerId);
|
||||
this.rebuildDisplayedTerrainState();
|
||||
this.refreshDisplayedTerrainDirtyBounds(
|
||||
previewTerrain.id,
|
||||
initialStamp.dirtyBounds
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -9586,10 +9591,7 @@ export class ViewportHost {
|
||||
);
|
||||
|
||||
if (
|
||||
!areTerrainsEqual(
|
||||
segmentResult.terrain,
|
||||
this.activeTerrainBrushStroke.previewTerrain
|
||||
) ||
|
||||
segmentResult.changed ||
|
||||
segmentResult.lastAppliedPoint.x !==
|
||||
this.activeTerrainBrushStroke.lastAppliedPoint.x ||
|
||||
segmentResult.lastAppliedPoint.z !==
|
||||
@@ -9597,10 +9599,17 @@ export class ViewportHost {
|
||||
) {
|
||||
this.activeTerrainBrushStroke = {
|
||||
...this.activeTerrainBrushStroke,
|
||||
previewTerrain: segmentResult.terrain,
|
||||
changed:
|
||||
this.activeTerrainBrushStroke.changed || segmentResult.changed,
|
||||
lastAppliedPoint: segmentResult.lastAppliedPoint
|
||||
};
|
||||
this.rebuildDisplayedTerrainState();
|
||||
|
||||
if (segmentResult.changed) {
|
||||
this.refreshDisplayedTerrainDirtyBounds(
|
||||
this.activeTerrainBrushStroke.previewTerrain.id,
|
||||
segmentResult.dirtyBounds
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -9634,6 +9643,7 @@ export class ViewportHost {
|
||||
|
||||
const cancelled = event.type === "pointercancel";
|
||||
let finalPreviewTerrain = this.activeTerrainBrushStroke.previewTerrain;
|
||||
let changed = this.activeTerrainBrushStroke.changed;
|
||||
|
||||
if (!cancelled) {
|
||||
const hit = this.getTerrainBrushHitAtClientPosition(
|
||||
@@ -9652,13 +9662,13 @@ export class ViewportHost {
|
||||
this.activeTerrainBrushStroke.toolState,
|
||||
this.activeTerrainBrushStroke.referenceHeight
|
||||
);
|
||||
finalPreviewTerrain = segmentResult.terrain;
|
||||
changed ||= segmentResult.changed;
|
||||
|
||||
if (
|
||||
segmentResult.lastAppliedPoint.x !== hit.point.x ||
|
||||
segmentResult.lastAppliedPoint.z !== hit.point.z
|
||||
) {
|
||||
finalPreviewTerrain = this.applyTerrainBrushPoint(
|
||||
const pointResult = this.applyTerrainBrushPoint(
|
||||
finalPreviewTerrain,
|
||||
{
|
||||
x: hit.point.x,
|
||||
@@ -9667,6 +9677,7 @@ export class ViewportHost {
|
||||
this.activeTerrainBrushStroke.toolState,
|
||||
this.activeTerrainBrushStroke.referenceHeight
|
||||
);
|
||||
changed ||= pointResult.changed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9678,7 +9689,7 @@ export class ViewportHost {
|
||||
const commit =
|
||||
!cancelled &&
|
||||
baseTerrain !== null &&
|
||||
!areTerrainsEqual(baseTerrain, finalPreviewTerrain);
|
||||
changed;
|
||||
const toolState = this.activeTerrainBrushStroke.toolState;
|
||||
this.activeTerrainBrushStroke = null;
|
||||
this.terrainBrushPreviewGroup.visible = false;
|
||||
|
||||
Reference in New Issue
Block a user