perf(brush): reuse stroke session canvas
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user