Files
canvas-label/src/components/canvas.tsx
2023-12-17 16:04:56 +01:00

451 lines
13 KiB
TypeScript

import { TransformableImage } from "@/components/transformable-image";
import type { KonvaEventObject } from "konva/lib/Node";
import { Layer, Rect, Stage } from "react-konva";
import { useAppDispatch, useAppSelector } from "@/hooks";
import {
appSlice,
deselectItem,
StageItemType,
updateImage,
updateText,
} from "@/store/app.slice";
import { useCallback, useEffect, useRef, useState } from "react";
import { Toolbar } from "@/components/toolbar";
import TransformableText from "@/components/transformable-text";
import LayerBorder from "@/components/layer-border";
import { CANVAS_PADDING_X, CANVAS_PADDING_Y } from "@/consts/canvas-params";
import Legacy from "@/components/layout/sidebar/legacy";
import { type Shape } from "konva/lib/Shape";
import Konva from "konva";
type Snap = "start" | "center" | "end";
type SnappingEdges = {
vertical: Array<{
guide: number;
offset: number;
snap: Snap;
}>;
horizontal: Array<{
guide: number;
offset: number;
snap: Snap;
}>;
};
const GUIDELINE_OFFSET = 5;
const Canvas = () => {
const dispatch = useAppDispatch();
const currentStep = useAppSelector((state) => state.app.currentStep);
const stage = useAppSelector(
(state) => state.app.history[currentStep]?.stage,
);
const stageRef = useRef<Konva.Stage>(null);
const layerRef = useRef<Konva.Layer>(null);
const selectedItemId = useAppSelector(
(state) => state.app.history[currentStep]?.selectedItemId,
);
const selectItem = (id: string) => dispatch(appSlice.actions.selectItem(id));
const items = useAppSelector(
(state) => state.app.history[currentStep]?.items,
);
const backgroundRect = useAppSelector(
(state) => state.app.history[currentStep]?.backgroundColor,
);
const backgroundId = "background";
const deselectHandler = (
e: KonvaEventObject<MouseEvent> | KonvaEventObject<TouchEvent>,
) => {
const target = e.target;
const clickedOnEmpty =
target === target.getStage() ||
(typeof target === "object" && target.attrs.id === backgroundId);
if (!clickedOnEmpty) {
return;
}
dispatch(deselectItem());
};
const [isEditing, setIsEditing] = useState(false);
const [isTransforming, setIsTransforming] = useState(false);
const [selected, setSelected] = useState(false);
useEffect(() => {
if (!selected && isEditing) {
setIsEditing(false);
} else if (!selected && isTransforming) {
setIsTransforming(false);
}
}, [selected, isEditing, isTransforming]);
function toggleEdit() {
setIsEditing(!isEditing);
setSelected(!isEditing);
}
/* Drag Drop logic */
const getObjectSnappingEdges = useCallback((node: Shape): SnappingEdges => {
const box = node.getClientRect();
const absPos = node.absolutePosition();
return {
vertical: [
{
guide: Math.round(box.x),
offset: Math.round(absPos.x - box.x),
snap: "start",
},
{
guide: Math.round(box.x + box.width / 2),
offset: Math.round(absPos.x - box.x - box.width / 2),
snap: "center",
},
{
guide: Math.round(box.x + box.width),
offset: Math.round(absPos.x - box.x - box.width),
snap: "end",
},
],
horizontal: [
{
guide: Math.round(box.y),
offset: Math.round(absPos.y - box.y),
snap: "start",
},
{
guide: Math.round(box.y + box.height / 2),
offset: Math.round(absPos.y - box.y - box.height / 2),
snap: "center",
},
{
guide: Math.round(box.y + box.height),
offset: Math.round(absPos.y - box.y - box.height),
snap: "end",
},
],
};
}, []);
const getLineGuideStops = (skipShape: Konva.Shape) => {
const stage = skipShape.getStage();
if (!stage) return { vertical: [], horizontal: [] };
// we can snap to playground-area borders and the center of the playground-area
const vertical = [0, stage.width() / 2, stage.width()];
const horizontal = [0, stage.height() / 2, stage.height()];
// and we snap over edges and center of each object on the canvas
stage.find(".object").forEach((guideItem) => {
if (guideItem === skipShape) {
return;
}
const box = guideItem.getClientRect();
// and we can snap to all edges of shapes
vertical.push(box.x, box.x + box.width, box.x + box.width / 2);
horizontal.push(box.y, box.y + box.height, box.y + box.height / 2);
});
return {
vertical,
horizontal,
};
};
const getGuides = useCallback(
(
lineGuideStops: ReturnType<typeof getLineGuideStops>,
itemBounds: ReturnType<typeof getObjectSnappingEdges>,
) => {
const resultV: Array<{
lineGuide: number;
diff: number;
snap: Snap;
offset: number;
}> = [];
const resultH: Array<{
lineGuide: number;
diff: number;
snap: Snap;
offset: number;
}> = [];
lineGuideStops.vertical.forEach((lineGuide) => {
itemBounds.vertical.forEach((itemBound) => {
const diff = Math.abs(lineGuide - itemBound.guide);
if (diff < GUIDELINE_OFFSET) {
resultV.push({
lineGuide: lineGuide,
diff: diff,
snap: itemBound.snap,
offset: itemBound.offset,
});
}
});
});
lineGuideStops.horizontal.forEach((lineGuide) => {
itemBounds.horizontal.forEach((itemBound) => {
const diff = Math.abs(lineGuide - itemBound.guide);
if (diff < GUIDELINE_OFFSET) {
resultH.push({
lineGuide: lineGuide,
diff: diff,
snap: itemBound.snap,
offset: itemBound.offset,
});
}
});
});
const guides: Array<{
lineGuide: number;
offset: number;
orientation: "V" | "H";
snap: "start" | "center" | "end";
}> = [];
const minV = resultV.sort((a, b) => a.diff - b.diff)[0];
const minH = resultH.sort((a, b) => a.diff - b.diff)[0];
if (minV) {
guides.push({
lineGuide: minV.lineGuide,
offset: minV.offset,
orientation: "V",
snap: minV.snap,
});
}
if (minH) {
guides.push({
lineGuide: minH.lineGuide,
offset: minH.offset,
orientation: "H",
snap: minH.snap,
});
}
return guides;
},
[],
);
const drawGuides = useCallback(
(guides: ReturnType<typeof getGuides>, layer: Konva.Layer) => {
guides.forEach((lg) => {
if (lg.orientation === "H") {
const line = new Konva.Line({
points: [-6000, 0, 6000, 0],
stroke: "rgb(0, 161, 255)",
strokeWidth: 1,
name: "guid-line",
dash: [4, 6],
});
layer.add(line);
line.absolutePosition({
x: 0,
y: lg.lineGuide,
});
} else if (lg.orientation === "V") {
const line = new Konva.Line({
points: [0, -6000, 0, 6000],
stroke: "rgb(0, 161, 255)",
strokeWidth: 1,
name: "guid-line",
dash: [4, 6],
});
layer.add(line);
line.absolutePosition({
x: lg.lineGuide,
y: 0,
});
}
});
},
[],
);
const onDragMove = useCallback(
(e: Konva.KonvaEventObject<DragEvent>) => {
const layer = e.target.getLayer();
if (!layer) {
return;
}
// clear all previous lines on the screen
layer.find(".guid-line").forEach((l) => l.destroy());
// find possible snapping lines
const lineGuideStops = getLineGuideStops(e.target as Konva.Shape);
// find snapping points of current object
const itemBounds = getObjectSnappingEdges(e.target as Konva.Shape);
// now find where can we snap current object
const guides = getGuides(lineGuideStops, itemBounds);
// do nothing if no snapping
if (!guides.length) {
return;
}
drawGuides(guides, layer);
const absPos = e.target.absolutePosition();
// now force object position
guides.forEach((lg) => {
switch (lg.snap) {
case "start": {
switch (lg.orientation) {
case "V": {
absPos.x = lg.lineGuide + lg.offset;
break;
}
case "H": {
absPos.y = lg.lineGuide + lg.offset;
break;
}
}
break;
}
case "center": {
switch (lg.orientation) {
case "V": {
absPos.x = lg.lineGuide + lg.offset;
break;
}
case "H": {
absPos.y = lg.lineGuide + lg.offset;
break;
}
}
break;
}
case "end": {
switch (lg.orientation) {
case "V": {
absPos.x = lg.lineGuide + lg.offset;
break;
}
case "H": {
absPos.y = lg.lineGuide + lg.offset;
break;
}
}
break;
}
}
});
e.target.absolutePosition(absPos);
},
[drawGuides, getGuides, getObjectSnappingEdges],
);
const onDragEnd = (e: Konva.KonvaEventObject<DragEvent>) => {
const layer = e.target.getLayer();
// clear all previous lines on the screen
layer?.find(".guid-line").forEach((l) => l.destroy());
};
if (!stage) return null;
return (
<div className="relative flex h-full w-full flex-col items-center">
<Toolbar />
<div
className={
"vertical-line horizontal-line flex h-full w-full items-center justify-center"
}
onClick={(e) => {
if (e.target !== e.currentTarget) {
return;
}
dispatch(deselectItem());
}}
>
<Stage
className="bg-white"
ref={stageRef}
width={stage.width}
height={stage.height}
onTouchStart={deselectHandler}
onMouseDown={deselectHandler}
x={stage.x}
y={stage.y}
scaleX={stage.scale}
scaleY={stage.scale}
>
<Layer>
{!!backgroundRect && (
<Rect
width={stage.width}
height={stage.height}
fill={backgroundRect}
onTouchStart={deselectHandler}
onClick={deselectHandler}
id={backgroundId}
/>
)}
</Layer>
<Layer
clipX={CANVAS_PADDING_X}
clipY={CANVAS_PADDING_Y}
clipWidth={stage.width - 2 * CANVAS_PADDING_X}
clipHeight={stage.height - 2 * CANVAS_PADDING_Y}
ref={layerRef}
>
{items?.toReversed().map((item) => {
if (item.type === StageItemType.Image) {
return (
<TransformableImage
id={item.id}
onSelect={() => selectItem(item.id)}
isSelected={item.id === selectedItemId}
onChange={(newAttrs) => {
dispatch(updateImage({ id: item.id, ...newAttrs }));
}}
imageProps={{
...item.params,
onDragMove,
onDragEnd,
name: "object",
}}
key={item.id}
isBlocked={item.isBlocked}
/>
);
}
if (item.type === StageItemType.Text) {
return (
<TransformableText
isEditing={isEditing}
isBlocked={item.isBlocked}
onEditChange={toggleEdit}
onSelect={() => selectItem(item.id)}
isSelected={item.id === selectedItemId}
onChange={(newAttrs) => {
dispatch(updateText({ id: item.id, ...newAttrs }));
}}
textProps={{
...item.params,
onDragMove,
onDragEnd,
name: "object",
}}
key={item.id}
id={item.id}
/>
);
}
})}
</Layer>
<Legacy stageParams={stage} />
<LayerBorder />
</Stage>
</div>
</div>
);
};
export default Canvas;