Skip to content

Commit aa708b3

Browse files
committed
Better UX for screen loading of strokes
1 parent aa82f61 commit aa708b3

3 files changed

Lines changed: 178 additions & 21 deletions

File tree

frontend/src/components/Canvas.js

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ function Canvas({
164164
const lastDrawnStateRef = useRef(null); // Track last drawn state to avoid redundant redraws
165165
const isDrawingInProgressRef = useRef(false); // Prevent concurrent drawing operations
166166
const offscreenCanvasRef = useRef(null); // Offscreen canvas for flicker free rendering
167+
const cachedCanvasRef = useRef(null); // Cached canvas for incremental rendering
168+
const cachedDrawingIdsRef = useRef(new Set()); // Track which drawings are in the cache
167169
const forceNextRedrawRef = useRef(false); // Force next redraw even if signature matches for undo redo
168170
const [historyMode, setHistoryMode] = useState(false);
169171
const [historyRange, setHistoryRange] = useState(null); // {start, end} in epoch ms
@@ -888,6 +890,8 @@ function Canvas({
888890
drawingType: "stamp",
889891
stampData: stamp,
890892
stampSettings: settings,
893+
isPending: true, // Mark as pending for visual confirmation
894+
opacity: 0.5, // Reduced opacity for pending stamps
891895
}
892896
);
893897

@@ -919,6 +923,16 @@ function Canvas({
919923

920924
console.log("Stamp submitted successfully:", stampDrawing.drawingId);
921925

926+
// Mark stamp as confirmed
927+
stampDrawing.isPending = false;
928+
stampDrawing.opacity = 1.0;
929+
confirmedStrokesRef.current.add(stampDrawing.drawingId);
930+
931+
// Trigger a redraw to show the confirmed state
932+
requestAnimationFrame(() => {
933+
drawAllDrawings();
934+
});
935+
922936
if (currentRoomId) {
923937
checkUndoRedoAvailability(
924938
auth,
@@ -1712,6 +1726,38 @@ function Canvas({
17121726
return;
17131727
}
17141728

1729+
// Check if we can do incremental rendering (only new drawings added, no cuts/filters/etc)
1730+
const canUseIncrementalRendering = lastDrawnStateRef.current &&
1731+
cachedCanvasRef.current &&
1732+
cachedDrawingIdsRef.current.size > 0 &&
1733+
combined.length > cachedDrawingIdsRef.current.size &&
1734+
!combined.some(d => d.drawingType === "filter" || (d.pathData && d.pathData.tool === "cut")) &&
1735+
currentTemplateObjects?.length === 0;
1736+
1737+
let newDrawingsOnly = [];
1738+
if (canUseIncrementalRendering) {
1739+
// Find drawings that aren't in the cache
1740+
newDrawingsOnly = combined.filter(d => !cachedDrawingIdsRef.current.has(d.drawingId));
1741+
1742+
// Verify all cached drawings are still present
1743+
const currentIds = new Set(combined.map(d => d.drawingId));
1744+
const allCachedPresent = Array.from(cachedDrawingIdsRef.current).every(id => currentIds.has(id));
1745+
1746+
if (newDrawingsOnly.length > 0 && allCachedPresent && newDrawingsOnly.length <= 5) {
1747+
console.log(`Incremental rendering: adding ${newDrawingsOnly.length} new drawings`);
1748+
// We can use incremental rendering!
1749+
} else {
1750+
// Fall back to full redraw
1751+
newDrawingsOnly = [];
1752+
cachedCanvasRef.current = null;
1753+
cachedDrawingIdsRef.current.clear();
1754+
}
1755+
} else {
1756+
// Clear cache - we need full redraw
1757+
cachedCanvasRef.current = null;
1758+
cachedDrawingIdsRef.current.clear();
1759+
}
1760+
17151761
// Clear force flag after checking it
17161762
forceNextRedrawRef.current = false;
17171763
lastDrawnStateRef.current = stateSignature;
@@ -1728,7 +1774,16 @@ function Canvas({
17281774

17291775
const offscreenContext = offscreenCanvasRef.current.getContext("2d");
17301776
offscreenContext.imageSmoothingEnabled = false;
1731-
offscreenContext.clearRect(0, 0, canvasWidth, canvasHeight);
1777+
1778+
// If we can do incremental rendering, start from cached canvas
1779+
if (newDrawingsOnly.length > 0 && cachedCanvasRef.current) {
1780+
console.log("[drawAllDrawings] Using incremental rendering - copying from cache");
1781+
offscreenContext.clearRect(0, 0, canvasWidth, canvasHeight);
1782+
offscreenContext.drawImage(cachedCanvasRef.current, 0, 0);
1783+
} else {
1784+
// Full redraw
1785+
offscreenContext.clearRect(0, 0, canvasWidth, canvasHeight);
1786+
}
17321787

17331788
// This avoids async rendering issues with image stamps
17341789
const stampsToRender = [];
@@ -1881,7 +1936,11 @@ function Canvas({
18811936
const maskedOriginals = new Set();
18821937
let seenAnyCut = false;
18831938

1884-
for (const drawing of regularDrawings) {
1939+
// If doing incremental rendering, only draw new drawings
1940+
const drawingsToRender = newDrawingsOnly.length > 0 ? newDrawingsOnly : regularDrawings;
1941+
console.log(`[drawAllDrawings] Rendering ${drawingsToRender.length} drawings (incremental: ${newDrawingsOnly.length > 0})`);
1942+
1943+
for (const drawing of drawingsToRender) {
18851944
// If this is a cut record, apply the erase to the canvas now.
18861945
if (drawing && drawing.pathData && drawing.pathData.tool === "cut") {
18871946
seenAnyCut = true;
@@ -1976,6 +2035,13 @@ function Canvas({
19762035
offscreenContext.globalAlpha = 0.1;
19772036
}
19782037
}
2038+
2039+
// Apply opacity for pending strokes (visual confirmation)
2040+
if (drawing.isPending) {
2041+
offscreenContext.globalAlpha *= 0.5;
2042+
} else if (drawing.opacity !== undefined && drawing.opacity !== 1.0) {
2043+
offscreenContext.globalAlpha *= drawing.opacity;
2044+
}
19792045

19802046
// Stamps have pathData as array but need special rendering - render inline to preserve z-order
19812047
if (drawing.drawingType === "stamp" && drawing.stampData && drawing.stampSettings && Array.isArray(drawing.pathData) && drawing.pathData.length > 0) {
@@ -2228,10 +2294,27 @@ function Canvas({
22282294
}
22292295

22302296
// Copy offscreen canvas to visible canvas atomically
2231-
console.log("[drawAllDrawings] Copying offscreen canvas to visible canvas. Total strokes rendered:", regularDrawings.length, "filters:", filterDrawings.length);
2297+
console.log("[drawAllDrawings] Copying offscreen canvas to visible canvas. Total strokes rendered:", drawingsToRender.length, "filters:", filterDrawings.length);
22322298
context.imageSmoothingEnabled = false;
22332299
context.clearRect(0, 0, canvasWidth, canvasHeight);
22342300
context.drawImage(offscreenCanvasRef.current, 0, 0);
2301+
2302+
// Update cache after successful render (only if no filters/cuts and not in incremental mode)
2303+
if (filterDrawings.length === 0 && !combined.some(d => d.pathData && d.pathData.tool === "cut")) {
2304+
if (!cachedCanvasRef.current || cachedCanvasRef.current.width !== canvasWidth || cachedCanvasRef.current.height !== canvasHeight) {
2305+
cachedCanvasRef.current = document.createElement("canvas");
2306+
cachedCanvasRef.current.width = canvasWidth;
2307+
cachedCanvasRef.current.height = canvasHeight;
2308+
}
2309+
const cacheContext = cachedCanvasRef.current.getContext("2d");
2310+
cacheContext.clearRect(0, 0, canvasWidth, canvasHeight);
2311+
cacheContext.drawImage(offscreenCanvasRef.current, 0, 0);
2312+
2313+
// Update cached drawing IDs
2314+
cachedDrawingIdsRef.current = new Set(combined.map(d => d.drawingId));
2315+
console.log(`[drawAllDrawings] Cached ${cachedDrawingIdsRef.current.size} drawings for future incremental rendering`);
2316+
}
2317+
22352318
console.log("[drawAllDrawings] Canvas update complete");
22362319
} catch (e) {
22372320
console.error("Error in drawAllDrawings:", e);
@@ -3384,6 +3467,8 @@ function Canvas({
33843467
brushType: currentBrushType,
33853468
brushParams: brushParams,
33863469
drawingType: "stroke",
3470+
isPending: true, // Mark as pending for visual confirmation
3471+
opacity: 0.5, // Reduced opacity for pending strokes
33873472
}
33883473
);
33893474
newDrawing.roomId = currentRoomId;
@@ -3422,7 +3507,15 @@ function Canvas({
34223507
setRedoAvailable
34233508
);
34243509

3425-
// Don't remove from pending here - let mergedRefreshCanvas or socket confirmation handle it
3510+
// Mark stroke as confirmed by updating it in userData
3511+
newDrawing.isPending = false;
3512+
newDrawing.opacity = 1.0;
3513+
confirmedStrokesRef.current.add(newDrawing.drawingId);
3514+
3515+
// Trigger a redraw to show the confirmed state
3516+
requestAnimationFrame(() => {
3517+
drawAllDrawings();
3518+
});
34263519

34273520
if (currentRoomId) {
34283521
checkUndoRedoAvailability(
@@ -3526,6 +3619,8 @@ function Canvas({
35263619
brushType: currentBrushType,
35273620
brushParams: brushParams,
35283621
drawingType: "shape",
3622+
isPending: true, // Mark as pending for visual confirmation
3623+
opacity: 0.5, // Reduced opacity for pending strokes
35293624
}
35303625
);
35313626
newDrawing.roomId = currentRoomId;
@@ -3555,7 +3650,15 @@ function Canvas({
35553650
setRedoAvailable
35563651
);
35573652

3558-
// Don't remove from pending here - let mergedRefreshCanvas or socket confirmation handle it
3653+
// Mark stroke as confirmed
3654+
newDrawing.isPending = false;
3655+
newDrawing.opacity = 1.0;
3656+
confirmedStrokesRef.current.add(newDrawing.drawingId);
3657+
3658+
// Trigger a redraw to show the confirmed state
3659+
requestAnimationFrame(() => {
3660+
drawAllDrawings();
3661+
});
35593662

35603663
// Update undo/redo availability after shape submission
35613664
if (currentRoomId) {

frontend/src/lib/drawing.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export class Drawing {
1717
this.stampSettings = metadata.stampSettings || null;
1818
this.filterType = metadata.filterType || null;
1919
this.filterParams = metadata.filterParams || {};
20+
21+
// Pending state for visual confirmation
22+
this.isPending = metadata.isPending || false;
23+
this.opacity = metadata.opacity !== undefined ? metadata.opacity : 1.0;
2024
}
2125

2226
// Serialize metadata for backend storage
@@ -29,7 +33,9 @@ export class Drawing {
2933
stampData: this.stampData,
3034
stampSettings: this.stampSettings,
3135
filterType: this.filterType,
32-
filterParams: this.filterParams
36+
filterParams: this.filterParams,
37+
isPending: this.isPending,
38+
opacity: this.opacity
3339
};
3440
}
3541

@@ -48,6 +54,8 @@ export class Drawing {
4854
stampSettings: data.stampSettings || metadata.stampSettings || null,
4955
filterType: data.filterType || metadata.filterType || null,
5056
filterParams: data.filterParams || metadata.filterParams || {},
57+
isPending: data.isPending || metadata.isPending || false,
58+
opacity: data.opacity !== undefined ? data.opacity : (metadata.opacity !== undefined ? metadata.opacity : 1.0)
5159
};
5260

5361
return new Drawing(

frontend/src/services/canvasBackendJWT.js

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,38 @@ import { signStrokeForSecureRoom, isWalletConnected } from "../wallet/resvault";
1414
import { API_BASE } from "../config/apiConfig";
1515
import { Drawing } from "../lib/drawing";
1616

17+
// Retry with exponential backoff
18+
const retryWithBackoff = async (fn, maxRetries = 3, baseDelay = 1000) => {
19+
let lastError;
20+
21+
for (let attempt = 0; attempt < maxRetries; attempt++) {
22+
try {
23+
return await fn();
24+
} catch (error) {
25+
lastError = error;
26+
27+
// Don't retry on authentication errors or validation errors
28+
if (error.response?.status === 401 || error.response?.status === 403 || error.response?.status === 400) {
29+
throw error;
30+
}
31+
32+
// If this was the last attempt, throw the error
33+
if (attempt === maxRetries - 1) {
34+
break;
35+
}
36+
37+
// Calculate exponential backoff delay: baseDelay * 2^attempt
38+
const delay = baseDelay * Math.pow(2, attempt);
39+
console.log(`Retry attempt ${attempt + 1}/${maxRetries} after ${delay}ms delay`);
40+
41+
// Wait before retrying
42+
await new Promise(resolve => setTimeout(resolve, delay));
43+
}
44+
}
45+
46+
throw lastError;
47+
};
48+
1749
export const submitToDatabase = async (
1850
drawing,
1951
auth,
@@ -131,12 +163,19 @@ export const submitToDatabase = async (
131163
});
132164
console.log("Full strokeData object:", strokeData);
133165

134-
await postRoomStroke(
135-
token,
136-
options.roomId,
137-
strokeData,
138-
signature,
139-
signerPubKey
166+
// Submit with retry logic (max 3 attempts with exponential backoff)
167+
await retryWithBackoff(
168+
async () => {
169+
await postRoomStroke(
170+
token,
171+
options.roomId,
172+
strokeData,
173+
signature,
174+
signerPubKey
175+
);
176+
},
177+
3,
178+
1000
140179
);
141180

142181
if (!options.skipUndoCheck && setUndoAvailable && setRedoAvailable) {
@@ -266,15 +305,22 @@ export const submitBatchToDatabase = async (
266305

267306
console.log(`Submitting batch ${i / BATCH_SIZE + 1}: ${strokes.length} strokes`);
268307

269-
const result = await postRoomStrokesBatch(
270-
token,
271-
options.roomId,
272-
strokes,
273-
{
274-
skipUndoStack: options.skipUndoStack || false,
275-
signature,
276-
signerPubKey
277-
}
308+
// Submit with retry logic (max 3 attempts with exponential backoff)
309+
const result = await retryWithBackoff(
310+
async () => {
311+
return await postRoomStrokesBatch(
312+
token,
313+
options.roomId,
314+
strokes,
315+
{
316+
skipUndoStack: options.skipUndoStack || false,
317+
signature,
318+
signerPubKey
319+
}
320+
);
321+
},
322+
3,
323+
1000
278324
);
279325

280326
totalProcessed += result.processed || 0;

0 commit comments

Comments
 (0)