perf(brush): reuse stroke session canvas

This commit is contained in:
syntaxbullet
2026-07-04 16:09:44 +02:00
parent 1a43d1f0e5
commit 6e5d58a638
2 changed files with 148 additions and 39 deletions

View File

@@ -10,11 +10,21 @@ import type { AppStore } from "@editor/store";
export type BrushSession = { export type BrushSession = {
layerId: string; layerId: string;
assetId: string; assetId: string;
width: number;
height: number;
canvas: HTMLCanvasElement;
context: CanvasRenderingContext2D;
ready: Promise<boolean>;
previousPoint: Vec2D; previousPoint: Vec2D;
mode: "brush" | "eraser"; mode: "brush" | "eraser";
source?: string; changed?: boolean;
pending?: Promise<void>; pending?: Promise<void>;
cancelled?: boolean; cancelled?: boolean;
previewClosed?: boolean;
previewRequested?: boolean;
previewInFlight?: boolean;
previewFrame?: number;
previewSource?: string;
}; };
export type BrushTargetEditorState = { export type BrushTargetEditorState = {
@@ -26,7 +36,29 @@ export type BrushTargetEditorState = {
export function beginBrushSession(document: ImageDocument, editor: BrushTargetEditorState, point: Vec2D): BrushSession | undefined { export function beginBrushSession(document: ImageDocument, editor: BrushTargetEditorState, point: Vec2D): BrushSession | undefined {
const layer = resolveBrushTargetLayer(document, editor); const layer = resolveBrushTargetLayer(document, editor);
if (!layer || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined; if (!layer || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
return { layerId: layer.id, assetId: layer.assetId, previousPoint: point, mode: editor.tools.activeTool };
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return undefined;
const canvas = globalThis.document.createElement("canvas");
canvas.width = Math.max(1, Math.round(asset.intrinsicSize.w));
canvas.height = Math.max(1, Math.round(asset.intrinsicSize.h));
const context = canvas.getContext("2d");
if (!context) return undefined;
const session: BrushSession = {
layerId: layer.id,
assetId: layer.assetId,
width: canvas.width,
height: canvas.height,
canvas,
context,
ready: Promise.resolve(false),
previousPoint: point,
mode: editor.tools.activeTool,
};
session.ready = initializeBrushSession(session, asset.source).catch(() => false);
return session;
} }
export function canPreviewBrush(document: ImageDocument, editor: BrushTargetEditorState): boolean { export function canPreviewBrush(document: ImageDocument, editor: BrushTargetEditorState): boolean {
@@ -72,10 +104,7 @@ export function updateBrushSession(options: {
}): BrushSession { }): BrushSession {
const state = options.store.getState(); const state = options.store.getState();
const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId); const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId);
if (!layer) return options.session; if (!layer || layer.assetId !== options.session.assetId) return options.session;
const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return options.session;
const from = options.session.previousPoint; const from = options.session.previousPoint;
const to = options.point; const to = options.point;
@@ -83,13 +112,12 @@ export function updateBrushSession(options: {
options.session.pending = (options.session.pending ?? Promise.resolve()) options.session.pending = (options.session.pending ?? Promise.resolve())
.then(async () => { .then(async () => {
if (options.session.cancelled) return; if (options.session.cancelled) return;
if (!(await options.session.ready) || options.session.cancelled) return;
const source = await drawStroke({ drawStrokeSegment({
source: options.session.source ?? asset.source, context: options.session.context,
width: asset.intrinsicSize.w, from: documentPointToAssetPoint(from, layer, options.session.width, options.session.height),
height: asset.intrinsicSize.h, to: documentPointToAssetPoint(to, layer, options.session.width, options.session.height),
from: documentPointToAssetPoint(from, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
to: documentPointToAssetPoint(to, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
color: state.editor.maskEdit ? "#ffffff" : options.color, color: state.editor.maskEdit ? "#ffffff" : options.color,
size: options.size, size: options.size,
hardness: options.hardness, hardness: options.hardness,
@@ -97,8 +125,8 @@ export function updateBrushSession(options: {
}); });
if (options.session.cancelled) return; if (options.session.cancelled) return;
options.session.source = source; options.session.changed = true;
options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, source }); requestBrushStrokePreview({ store: options.store, session: options.session });
}) })
.catch(() => undefined); .catch(() => undefined);
@@ -109,13 +137,16 @@ export async function commitBrushSession(options: { store: AppStore; session: Br
await options.session.pending; await options.session.pending;
if (options.session.cancelled) return; if (options.session.cancelled) return;
if (options.session.source) options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: options.session.assetId, source: options.session.source }); const source = options.session.changed ? canvasToDataUrl(options.session.canvas) : undefined;
if (source) options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: options.session.assetId, source });
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined); options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
closeBrushStrokePreview(options.session);
} }
export function cancelBrushSession(options: { store: AppStore; session: BrushSession }) { export function cancelBrushSession(options: { store: AppStore; session: BrushSession }) {
options.session.cancelled = true; options.session.cancelled = true;
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined); options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
closeBrushStrokePreview(options.session);
} }
function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: number, height: number): Vec2D { function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: number, height: number): Vec2D {
@@ -125,10 +156,17 @@ function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: numb
}; };
} }
async function drawStroke(options: { async function initializeBrushSession(session: BrushSession, source: string) {
source: string; const image = await loadImage(source);
width: number; if (session.cancelled) return false;
height: number;
session.context.clearRect(0, 0, session.width, session.height);
session.context.drawImage(image, 0, 0, session.width, session.height);
return true;
}
function drawStrokeSegment(options: {
context: CanvasRenderingContext2D;
from: Vec2D; from: Vec2D;
to: Vec2D; to: Vec2D;
color: string; color: string;
@@ -136,15 +174,9 @@ async function drawStroke(options: {
hardness: number; hardness: number;
mode: "brush" | "eraser"; mode: "brush" | "eraser";
}) { }) {
const canvas = document.createElement("canvas"); const { context } = options;
canvas.width = Math.max(1, Math.round(options.width));
canvas.height = Math.max(1, Math.round(options.height));
const context = canvas.getContext("2d");
if (!context) return options.source;
const image = await loadImage(options.source);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
const hardness = Math.max(0, Math.min(100, options.hardness)) / 100; const hardness = Math.max(0, Math.min(100, options.hardness)) / 100;
context.save();
context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over"; context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over";
context.strokeStyle = options.color; context.strokeStyle = options.color;
context.shadowColor = options.mode === "eraser" ? "rgba(0,0,0,1)" : options.color; context.shadowColor = options.mode === "eraser" ? "rgba(0,0,0,1)" : options.color;
@@ -156,9 +188,73 @@ async function drawStroke(options: {
context.moveTo(options.from.x, options.from.y); context.moveTo(options.from.x, options.from.y);
context.lineTo(options.to.x, options.to.y); context.lineTo(options.to.x, options.to.y);
context.stroke(); context.stroke();
context.globalCompositeOperation = "source-over"; context.restore();
}
return canvas.toDataURL("image/png"); function requestBrushStrokePreview(options: { store: AppStore; session: BrushSession }) {
if (options.session.cancelled || options.session.previewClosed) return;
options.session.previewRequested = true;
if (options.session.previewFrame !== undefined || options.session.previewInFlight) return;
options.session.previewFrame = requestAnimationFrame(() => {
options.session.previewFrame = undefined;
void publishBrushStrokePreview(options);
});
}
async function publishBrushStrokePreview(options: { store: AppStore; session: BrushSession }) {
if (options.session.cancelled || options.session.previewClosed || !options.session.previewRequested) return;
options.session.previewRequested = false;
options.session.previewInFlight = true;
const source = await canvasToObjectUrl(options.session.canvas).catch(() => undefined);
options.session.previewInFlight = false;
if (!source) {
if (options.session.previewRequested) requestBrushStrokePreview(options);
return;
}
if (options.session.cancelled || options.session.previewClosed) {
URL.revokeObjectURL(source);
return;
}
const previousSource = options.session.previewSource;
options.session.previewSource = source;
options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, source });
if (previousSource) URL.revokeObjectURL(previousSource);
if (options.session.previewRequested) requestBrushStrokePreview(options);
}
function closeBrushStrokePreview(session: BrushSession) {
session.previewClosed = true;
if (session.previewFrame !== undefined) {
cancelAnimationFrame(session.previewFrame);
session.previewFrame = undefined;
}
if (session.previewSource) {
URL.revokeObjectURL(session.previewSource);
session.previewSource = undefined;
}
}
function canvasToObjectUrl(canvas: HTMLCanvasElement) {
return new Promise<string | undefined>((resolve) => {
canvas.toBlob((blob) => {
resolve(blob ? URL.createObjectURL(blob) : undefined);
}, "image/png");
});
}
function canvasToDataUrl(canvas: HTMLCanvasElement) {
try {
return canvas.toDataURL("image/png");
} catch {
return undefined;
}
} }
function loadImage(source: string) { function loadImage(source: string) {

View File

@@ -12,7 +12,7 @@ import {
pointerInputEventFromPointerEvent, pointerInputEventFromPointerEvent,
wheelInputEventFromWheelEvent, wheelInputEventFromWheelEvent,
} from "@input/index"; } from "@input/index";
import { beginBrushSession, canPreviewBrush, commitBrushSession, updateBrushSession, type BrushSession } from "./brush"; import { beginBrushSession, canPreviewBrush, cancelBrushSession, commitBrushSession, updateBrushSession, type BrushSession } from "./brush";
import { applyMagicWandAt } from "./magic-wand"; import { applyMagicWandAt } from "./magic-wand";
export type CanvasInputOptions = { export type CanvasInputOptions = {
@@ -31,12 +31,13 @@ export function useCanvasInput(
options: CanvasInputOptions, options: CanvasInputOptions,
): CanvasInputState { ): CanvasInputState {
const [isPanning, setIsPanning] = useState(false); const [isPanning, setIsPanning] = useState(false);
const brushSession = useRef<BrushSession>(); const brushSession = useRef<BrushSession | undefined>(undefined);
const brushSessionId = useRef(0); const brushSessionId = useRef(0);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
let disposed = false;
const transformHandler = createTransformControlsInputController({ const transformHandler = createTransformControlsInputController({
getDocument: () => store.getState().document, getDocument: () => store.getState().document,
@@ -71,6 +72,18 @@ export function useCanvasInput(
store.dispatch(commandIds.toolSetBrushPreview, { position: viewportPointToDocumentPoint(position, state.editor.viewport) }); store.dispatch(commandIds.toolSetBrushPreview, { position: viewportPointToDocumentPoint(position, state.editor.viewport) });
}; };
const commitActiveBrushSession = (position: { x: number; y: number }) => {
const session = brushSession.current;
if (!session) return;
const sessionId = brushSessionId.current + 1;
brushSessionId.current = sessionId;
brushSession.current = undefined;
void commitBrushSession({ store, session }).then(() => {
if (!disposed && brushSessionId.current === sessionId && !brushSession.current) updateBrushPreview(position);
});
};
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (isEditableKeyboardTarget(event.target)) return; if (isEditableKeyboardTarget(event.target)) return;
@@ -142,10 +155,7 @@ export function useCanvasInput(
const inputEvent = pointerInputEventFromPointerEvent(event); const inputEvent = pointerInputEventFromPointerEvent(event);
if (brushSession.current) { if (brushSession.current) {
if ((inputEvent.buttons & 1) !== 1 || isPanInteractionMode(store.getState().editor.tools.interactionMode)) { if ((inputEvent.buttons & 1) !== 1 || isPanInteractionMode(store.getState().editor.tools.interactionMode)) {
const session = brushSession.current; commitActiveBrushSession(inputEvent.position);
brushSessionId.current += 1;
brushSession.current = undefined;
void commitBrushSession({ store, session }).then(() => updateBrushPreview(inputEvent.position));
event.preventDefault(); event.preventDefault();
return; return;
} }
@@ -178,10 +188,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) {
const session = brushSession.current; commitActiveBrushSession(inputEvent.position);
brushSessionId.current += 1;
brushSession.current = undefined;
void commitBrushSession({ store, session }).then(() => updateBrushPreview(inputEvent.position));
event.preventDefault(); event.preventDefault();
return; return;
} }
@@ -226,6 +233,12 @@ export function useCanvasInput(
canvas.addEventListener("wheel", handleWheel, { passive: false }); canvas.addEventListener("wheel", handleWheel, { passive: false });
return () => { return () => {
disposed = true;
brushSessionId.current += 1;
if (brushSession.current) {
cancelBrushSession({ store, session: brushSession.current });
brushSession.current = undefined;
}
window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("keyup", handleKeyUp);
canvas.removeEventListener("pointerdown", handlePointerDown); canvas.removeEventListener("pointerdown", handlePointerDown);