Created
January 1, 2024 16:19
-
-
Save nitedani/35596b33b4c64caaf91b9d0139aa9556 to your computer and use it in GitHub Desktop.
React + Fabric image editor hook.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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