fix(brush): stop painting after pointer release

This commit is contained in:
syntaxbullet
2026-07-03 17:43:13 +02:00
parent c13a6f5917
commit e75f8bc0a7
2 changed files with 68 additions and 28 deletions

View File

@@ -7,11 +7,17 @@ export type ImageTextureRenderer = {
dispose(): void; dispose(): void;
}; };
type TextureEntry = type TextureState =
| { status: "loading"; image: HTMLImageElement } | { status: "loading"; image: HTMLImageElement }
| { status: "ready"; texture: WebGLTexture } | { status: "ready"; texture: WebGLTexture }
| { status: "error" }; | { status: "error" };
type TextureEntry = {
source: string;
current: TextureState;
previousTexture?: WebGLTexture;
};
export function createImageTextureRenderer(context: WebGlRendererContext, invalidate: () => void): ImageTextureRenderer { export function createImageTextureRenderer(context: WebGlRendererContext, invalidate: () => void): ImageTextureRenderer {
const { gl } = context; const { gl } = context;
const program = createProgram(gl); const program = createProgram(gl);
@@ -30,11 +36,11 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
return { return {
syncAssets(assets) { syncAssets(assets) {
const activeKeys = new Set(assets.map(textureKey)); const activeIds = new Set(assets.map((asset) => asset.id));
for (const [key, entry] of textures) { for (const [assetId, entry] of textures) {
if (!activeKeys.has(key)) { if (!activeIds.has(assetId)) {
disposeEntry(gl, entry); disposeEntry(gl, entry);
textures.delete(key); textures.delete(assetId);
} }
} }
}, },
@@ -43,7 +49,8 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true; if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed); const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
if (entry.status !== "ready") return false; const texture = renderableTexture(entry);
if (!texture) return false;
gl.enable(gl.SCISSOR_TEST); gl.enable(gl.SCISSOR_TEST);
gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h); gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h);
@@ -52,7 +59,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.useProgram(program); gl.useProgram(program);
gl.activeTexture(gl.TEXTURE0); gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, entry.texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(samplerLocation, 0); gl.uniform1i(samplerLocation, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
@@ -85,42 +92,71 @@ function getTextureEntry(
invalidate: () => void, invalidate: () => void,
isDisposed: () => boolean, isDisposed: () => boolean,
): TextureEntry { ): TextureEntry {
const key = textureKey(asset); const cached = textures.get(asset.id);
const cached = textures.get(key); if (cached && cached.source === asset.source) return cached;
if (cached) return cached;
if (cached) {
if (cached.current.status === "ready") {
if (cached.previousTexture) context.gl.deleteTexture(cached.previousTexture);
cached.previousTexture = cached.current.texture;
} else {
disposeState(context.gl, cached.current);
}
cached.source = asset.source;
cached.current = loadTextureState(context, asset, cached, invalidate, isDisposed);
return cached;
}
const entry: TextureEntry = { source: asset.source, current: { status: "error" } };
entry.current = loadTextureState(context, asset, entry, invalidate, isDisposed);
textures.set(asset.id, entry);
return entry;
}
function loadTextureState(
context: WebGlRendererContext,
asset: Asset,
entry: TextureEntry,
invalidate: () => void,
isDisposed: () => boolean,
): TextureState {
const image = new Image(); const image = new Image();
textures.set(key, { status: "loading", image }); const source = asset.source;
image.onload = () => { image.onload = () => {
if (isDisposed()) return; if (isDisposed() || entry.source !== source) return;
const texture = createTexture(context.gl, image); const texture = createTexture(context.gl, image);
disposeEntry(context.gl, textures.get(key)); if (entry.previousTexture) context.gl.deleteTexture(entry.previousTexture);
textures.set(key, { status: "ready", texture }); entry.previousTexture = undefined;
entry.current = { status: "ready", texture };
invalidate(); invalidate();
}; };
image.onerror = () => { image.onerror = () => {
if (isDisposed()) return; if (isDisposed() || entry.source !== source) return;
textures.set(key, { status: "error" }); entry.current = { status: "error" };
invalidate(); invalidate();
}; };
image.src = asset.source; image.src = source;
return { status: "loading", image }; return { status: "loading", image };
} }
function textureKey(asset: Asset) { function renderableTexture(entry: TextureEntry): WebGLTexture | undefined {
return `${asset.id}:${asset.source}`; if (entry.current.status === "ready") return entry.current.texture;
return entry.previousTexture;
} }
function disposeEntry(gl: WebGL2RenderingContext, entry: TextureEntry | undefined) { function disposeEntry(gl: WebGL2RenderingContext, entry: TextureEntry) {
if (!entry) return; disposeState(gl, entry.current);
if (entry.status === "ready") { if (entry.previousTexture) gl.deleteTexture(entry.previousTexture);
gl.deleteTexture(entry.texture); }
function disposeState(gl: WebGL2RenderingContext, state: TextureState) {
if (state.status === "ready") {
gl.deleteTexture(state.texture);
return; return;
} }
if (entry.status === "loading") { if (state.status === "loading") {
entry.image.onload = null; state.image.onload = null;
entry.image.onerror = null; state.image.onerror = null;
} }
} }

View File

@@ -30,6 +30,7 @@ export function useCanvasInput(
): CanvasInputState { ): CanvasInputState {
const [isPanning, setIsPanning] = useState(false); const [isPanning, setIsPanning] = useState(false);
const brushSession = useRef<BrushSession>(); const brushSession = useRef<BrushSession>();
const brushSessionId = useRef(0);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
@@ -64,6 +65,7 @@ export function useCanvasInput(
const state = store.getState(); const state = store.getState();
const brush = beginBrushSession(state.document, state.editor, viewportPointToDocumentPoint(inputEvent.position, state.editor.viewport)); const brush = beginBrushSession(state.document, state.editor, viewportPointToDocumentPoint(inputEvent.position, state.editor.viewport));
if (brush) { if (brush) {
brushSessionId.current += 1;
brushSession.current = brush; brushSession.current = brush;
canvas.setPointerCapture(event.pointerId); canvas.setPointerCapture(event.pointerId);
event.preventDefault(); event.preventDefault();
@@ -99,9 +101,10 @@ export function useCanvasInput(
const handlePointerMove = (event: PointerEvent) => { const handlePointerMove = (event: PointerEvent) => {
const inputEvent = pointerInputEventFromPointerEvent(event); const inputEvent = pointerInputEventFromPointerEvent(event);
if (brushSession.current) { if (brushSession.current) {
const activeSessionId = brushSessionId.current;
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport); const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
void updateBrushSession({ store, session: brushSession.current, point }).then((nextSession) => { void updateBrushSession({ store, session: brushSession.current, point }).then((nextSession) => {
brushSession.current = nextSession; if (brushSessionId.current === activeSessionId) brushSession.current = nextSession;
}); });
event.preventDefault(); event.preventDefault();
return; return;
@@ -120,6 +123,7 @@ export function useCanvasInput(
const handlePointerUp = (event: PointerEvent) => { const handlePointerUp = (event: PointerEvent) => {
const inputEvent = pointerInputEventFromPointerEvent(event); const inputEvent = pointerInputEventFromPointerEvent(event);
if (brushSession.current) { if (brushSession.current) {
brushSessionId.current += 1;
brushSession.current = undefined; brushSession.current = undefined;
event.preventDefault(); event.preventDefault();
return; return;