diff --git a/view/canvas/brush.ts b/view/canvas/brush.ts index bc98cfa..f5675a5 100644 --- a/view/canvas/brush.ts +++ b/view/canvas/brush.ts @@ -10,11 +10,21 @@ import type { AppStore } from "@editor/store"; export type BrushSession = { layerId: string; assetId: string; + width: number; + height: number; + canvas: HTMLCanvasElement; + context: CanvasRenderingContext2D; + ready: Promise; previousPoint: Vec2D; mode: "brush" | "eraser"; - source?: string; + changed?: boolean; pending?: Promise; cancelled?: boolean; + previewClosed?: boolean; + previewRequested?: boolean; + previewInFlight?: boolean; + previewFrame?: number; + previewSource?: string; }; export type BrushTargetEditorState = { @@ -26,7 +36,29 @@ export type BrushTargetEditorState = { export function beginBrushSession(document: ImageDocument, editor: BrushTargetEditorState, point: Vec2D): BrushSession | undefined { const layer = resolveBrushTargetLayer(document, editor); 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 { @@ -72,10 +104,7 @@ export function updateBrushSession(options: { }): BrushSession { const state = options.store.getState(); const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId); - if (!layer) return options.session; - - const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId); - if (!asset) return options.session; + if (!layer || layer.assetId !== options.session.assetId) return options.session; const from = options.session.previousPoint; const to = options.point; @@ -83,13 +112,12 @@ export function updateBrushSession(options: { options.session.pending = (options.session.pending ?? Promise.resolve()) .then(async () => { if (options.session.cancelled) return; + if (!(await options.session.ready) || options.session.cancelled) return; - const source = await drawStroke({ - source: options.session.source ?? asset.source, - width: asset.intrinsicSize.w, - height: asset.intrinsicSize.h, - from: documentPointToAssetPoint(from, layer, asset.intrinsicSize.w, asset.intrinsicSize.h), - to: documentPointToAssetPoint(to, layer, asset.intrinsicSize.w, asset.intrinsicSize.h), + drawStrokeSegment({ + context: options.session.context, + from: documentPointToAssetPoint(from, layer, options.session.width, options.session.height), + to: documentPointToAssetPoint(to, layer, options.session.width, options.session.height), color: state.editor.maskEdit ? "#ffffff" : options.color, size: options.size, hardness: options.hardness, @@ -97,8 +125,8 @@ export function updateBrushSession(options: { }); if (options.session.cancelled) return; - options.session.source = source; - options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, source }); + options.session.changed = true; + requestBrushStrokePreview({ store: options.store, session: options.session }); }) .catch(() => undefined); @@ -109,13 +137,16 @@ export async function commitBrushSession(options: { store: AppStore; session: Br await options.session.pending; 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); + closeBrushStrokePreview(options.session); } export function cancelBrushSession(options: { store: AppStore; session: BrushSession }) { options.session.cancelled = true; options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined); + closeBrushStrokePreview(options.session); } 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: { - source: string; - width: number; - height: number; +async function initializeBrushSession(session: BrushSession, source: string) { + const image = await loadImage(source); + if (session.cancelled) return false; + + 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; to: Vec2D; color: string; @@ -136,15 +174,9 @@ async function drawStroke(options: { hardness: number; mode: "brush" | "eraser"; }) { - const canvas = document.createElement("canvas"); - 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 { context } = options; const hardness = Math.max(0, Math.min(100, options.hardness)) / 100; + context.save(); context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over"; context.strokeStyle = 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.lineTo(options.to.x, options.to.y); 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((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) { diff --git a/view/canvas/useCanvasInput.ts b/view/canvas/useCanvasInput.ts index dcc8e22..2777799 100644 --- a/view/canvas/useCanvasInput.ts +++ b/view/canvas/useCanvasInput.ts @@ -12,7 +12,7 @@ import { pointerInputEventFromPointerEvent, wheelInputEventFromWheelEvent, } 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"; export type CanvasInputOptions = { @@ -31,12 +31,13 @@ export function useCanvasInput( options: CanvasInputOptions, ): CanvasInputState { const [isPanning, setIsPanning] = useState(false); - const brushSession = useRef(); + const brushSession = useRef(undefined); const brushSessionId = useRef(0); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; + let disposed = false; const transformHandler = createTransformControlsInputController({ getDocument: () => store.getState().document, @@ -71,6 +72,18 @@ export function useCanvasInput( 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) => { if (isEditableKeyboardTarget(event.target)) return; @@ -142,10 +155,7 @@ export function useCanvasInput( const inputEvent = pointerInputEventFromPointerEvent(event); if (brushSession.current) { if ((inputEvent.buttons & 1) !== 1 || isPanInteractionMode(store.getState().editor.tools.interactionMode)) { - const session = brushSession.current; - brushSessionId.current += 1; - brushSession.current = undefined; - void commitBrushSession({ store, session }).then(() => updateBrushPreview(inputEvent.position)); + commitActiveBrushSession(inputEvent.position); event.preventDefault(); return; } @@ -178,10 +188,7 @@ export function useCanvasInput( const handlePointerUp = (event: PointerEvent) => { const inputEvent = pointerInputEventFromPointerEvent(event); if (brushSession.current) { - const session = brushSession.current; - brushSessionId.current += 1; - brushSession.current = undefined; - void commitBrushSession({ store, session }).then(() => updateBrushPreview(inputEvent.position)); + commitActiveBrushSession(inputEvent.position); event.preventDefault(); return; } @@ -226,6 +233,12 @@ export function useCanvasInput( canvas.addEventListener("wheel", handleWheel, { passive: false }); return () => { + disposed = true; + brushSessionId.current += 1; + if (brushSession.current) { + cancelBrushSession({ store, session: brushSession.current }); + brushSession.current = undefined; + } window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); canvas.removeEventListener("pointerdown", handlePointerDown);