Skip to content

Instantly share code, notes, and snippets.

@nitedani
Created January 1, 2024 16:19
Show Gist options
  • Save nitedani/35596b33b4c64caaf91b9d0139aa9556 to your computer and use it in GitHub Desktop.
Save nitedani/35596b33b4c64caaf91b9d0139aa9556 to your computer and use it in GitHub Desktop.
React + Fabric image editor hook.
import { useMantineTheme } from "@mantine/core";
import {
Canvas,
Group as FabricGroup,
Object as FabricObject,
Image,
Path,
PencilBrush,
Point,
} from "fabric";
import { useEffect, useRef, useState } from "react";
Path.prototype.selectable = false;
Path.prototype.evented = false;
Image.prototype.selectable = false;
Image.prototype.evented = false;
export type ImageEditorProps = {
src: string;
};
export type Api = Canvas & {
toImage: () => string;
toggleDrawingMode: () => void;
setDrawingMode: (value: boolean) => void;
setBrushWidth(width: number): void;
getBrushWidth(): number;
eraseAll(): void;
isDirty: boolean;
};
export const useImageEditor = (props: {
src?: string;
defaultObjects?: FabricObject[];
isDrawingMode?: boolean;
defaultBrushWidth?: number;
}) => {
const apiRef = useRef<Api | null>(null);
const [canvas, setCanvas] = useState<HTMLCanvasElement | null>(null);
const [, rerender] = useState({});
const theme = useMantineTheme();
const apiState = useRef({
brushWidth: props.defaultBrushWidth ?? 4,
drawingMode: props.isDrawingMode ?? false,
});
useEffect(() => {
const parent = canvas?.parentElement;
if (!canvas || !parent || !props.src) {
return;
}
const _api = new Canvas(canvas, {
width: parent.clientWidth,
height: parent.clientHeight,
uniScaleKey: "shiftKey",
uniformScaling: false,
selection: false,
selectionKey: "altKey",
imageSmoothingEnabled: false,
preserveObjectStacking: true,
backgroundColor:
theme.colorScheme === "dark"
? theme.colors.dark[7]
: theme.colors.gray[1],
}) as Api;
_api.isDirty = false;
_api.toImage = () => {
const imageObject = _api.getObjects("image")[0] as Image;
const scaledWidth = imageObject.getScaledWidth();
const scaledHeight = imageObject.getScaledHeight();
const scale = Math.min(
imageObject.width / scaledWidth,
imageObject.height / scaledHeight
);
const paths = _api.getObjects("path") as Path[];
const group = new FabricGroup([imageObject, ...paths]);
return group.toDataURL({
multiplier: scale,
top: -group.getRelativeY(),
left: -group.getRelativeX(),
width: scaledWidth,
height: scaledHeight,
format: "jpeg",
quality: 0.9,
});
};
let isDragging = false;
let lastPosX = 0;
let lastPosY = 0;
_api.on("mouse:down", (opt) => {
const evt = opt.e as PointerEvent;
const activeObj = _api.getActiveObject();
if (!_api.isDrawingMode && !evt.altKey && !activeObj) {
isDragging = true;
_api.selection = false;
lastPosX = evt.clientX;
lastPosY = evt.clientY;
}
});
_api.on("path:created", (_path) => {
_api.isDirty = true;
});
_api.on("mouse:move", (opt) => {
if (isDragging) {
const e = opt.e as PointerEvent;
const vpt = _api.viewportTransform;
vpt[4] += e.clientX - lastPosX;
vpt[5] += e.clientY - lastPosY;
_api.requestRenderAll();
lastPosX = e.clientX;
lastPosY = e.clientY;
}
});
_api.on("mouse:up", () => {
_api.setViewportTransform(_api.viewportTransform);
isDragging = false;
_api.selection = true;
});
_api.freeDrawingBrush = new PencilBrush(_api);
_api.setBrushWidth = (value: number) => {
const api = apiRef.current;
if (!api || !api.freeDrawingBrush) return;
api.freeDrawingBrush.width = value * 2;
apiState.current.brushWidth = value;
};
_api.toggleDrawingMode = () => {
const api = apiRef.current;
if (!api) return;
api.isDrawingMode = !api.isDrawingMode;
apiState.current.drawingMode = api.isDrawingMode;
rerender({});
};
_api.setDrawingMode = (value: boolean) => {
const api = apiRef.current;
if (!api) return;
api.isDrawingMode = value;
apiState.current.drawingMode = api.isDrawingMode;
rerender({});
};
_api.getBrushWidth = () => {
const api = apiRef.current;
if (!api || !api.freeDrawingBrush) return 0;
return api.freeDrawingBrush.width / 2;
};
_api.eraseAll = () => {
const api = apiRef.current;
if (!api) return;
api.getObjects("path").forEach((path) => {
api.remove(path);
});
api.isDirty = true;
};
_api.on("mouse:wheel", (opt) => {
const delta = opt.e.deltaY;
let zoom = _api.getZoom();
zoom *= 0.999 ** delta;
if (zoom > 10) zoom = 10;
if (zoom < 0.5) zoom = 0.5;
_api.zoomToPoint({ x: opt.e.offsetX, y: opt.e.offsetY } as Point, zoom);
opt.e.preventDefault();
opt.e.stopPropagation();
});
const onResize = () => {
_api.setDimensions({
width: parent.clientWidth,
height: parent.clientHeight,
});
};
window.addEventListener("resize", onResize);
apiRef.current = _api;
(async () => {
const img = await Image.fromURL(props.src!);
const cWidth = _api.getWidth();
const cHeight = _api.getHeight();
// scale to fit
const scale = Math.min(cWidth / img.width, cHeight / img.height);
img.scale(scale);
img.top = 0;
img.left = 0;
_api.setZoom(1);
// center
const remainingX = _api.getWidth() - img.getScaledWidth();
const remainingY = _api.getHeight() - img.getScaledHeight();
_api.absolutePan({ x: -remainingX / 2, y: -remainingY / 2 } as Point);
_api.add(img);
if (props.defaultObjects) {
props.defaultObjects.forEach((obj) => {
_api.add(obj);
});
}
_api.setBrushWidth(apiState.current.brushWidth);
_api.isDrawingMode = apiState.current.drawingMode;
rerender({});
})();
return () => {
_api.dispose();
window.removeEventListener("resize", onResize);
};
}, [canvas, props.src, props.defaultObjects]);
const api = apiRef.current;
return {
canvas: (
<canvas
ref={(e) => {
setCanvas(e);
}}
></canvas>
),
api,
};
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment