Inital commit
This commit is contained in:
+68
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { VectraceHandler } from "./handler/vectrace-handler";
|
||||
import { NavBar } from "./nav-bar";
|
||||
import { RenderPage } from "./render-page";
|
||||
import { DragDrop } from "./components/drag-drop";
|
||||
import { PopupRenderer } from "./components/popup";
|
||||
import { Grid } from "ldrs/react";
|
||||
import "ldrs/react/Grid.css";
|
||||
import styled from "styled-components";
|
||||
|
||||
const LoadingWrapper = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
const WrapperInner = styled.div`
|
||||
display: flex;
|
||||
margin: 20px auto;
|
||||
flex-direction: column;
|
||||
width: fit-content;
|
||||
font-size: 20pt;
|
||||
`;
|
||||
|
||||
function App() {
|
||||
const handler = useMemo(() => new VectraceHandler(), []);
|
||||
const [ready, setReady] = useState(handler.ready);
|
||||
useEffect(() => {
|
||||
handler.init("image_store");
|
||||
const unsub = handler.emitter.on("ready", () => setReady(true));
|
||||
const onSave = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
handler.exportProject();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onSave, true);
|
||||
return () => {
|
||||
unsub();
|
||||
document.removeEventListener("keydown", onSave);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!ready) {
|
||||
return <LoadingWrapper>
|
||||
<WrapperInner>
|
||||
<Grid
|
||||
size="60"
|
||||
speed="1.5"
|
||||
|
||||
color="white"
|
||||
/>
|
||||
<span>Loading</span>
|
||||
</WrapperInner>
|
||||
</LoadingWrapper>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<PopupRenderer popup={handler.popup} />
|
||||
<DragDrop handler={handler} />
|
||||
<NavBar handler={handler} />
|
||||
<RenderPage handler={handler} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Binary file not shown.
Binary file not shown.
+155
@@ -0,0 +1,155 @@
|
||||
import { last } from "lodash";
|
||||
|
||||
function isNumber(char: string) {
|
||||
const number = parseInt(char, 10);
|
||||
return !isNaN(number);
|
||||
}
|
||||
|
||||
const priorities = new Map<string, number>();
|
||||
priorities.set("(", 4);
|
||||
priorities.set(")", 4);
|
||||
priorities.set("^", 3);
|
||||
priorities.set("*", 2);
|
||||
priorities.set("/", 2);
|
||||
priorities.set("+", 1);
|
||||
priorities.set("-", 1);
|
||||
|
||||
function getPriority(char: string) {
|
||||
return priorities.get(char) || -1;
|
||||
}
|
||||
|
||||
function filterOutNumbers(expression: string) {
|
||||
const buffer: string[] = [];
|
||||
const lastReadNumberQueue: string[] = [];
|
||||
|
||||
const updateNumbers = () => {
|
||||
const number = lastReadNumberQueue.join("");
|
||||
lastReadNumberQueue.length = 0;
|
||||
if (number) {
|
||||
if (!isNaN(parseFloat(number))) {
|
||||
buffer.push(number);
|
||||
} else {
|
||||
throw new Error("malformed number");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < expression.length; i++) {
|
||||
const char = expression[i];
|
||||
if (isNumber(char) || char === "." || char === "," || (i === 0 && char === "-") ||
|
||||
(priorities.has(last(buffer) || "") && char === "-")) {
|
||||
lastReadNumberQueue.push(char);
|
||||
} else {
|
||||
updateNumbers();
|
||||
buffer.push(char);
|
||||
}
|
||||
}
|
||||
updateNumbers();
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function processExpression(expression: string) {
|
||||
const preExpression = filterOutNumbers(expression);
|
||||
const postExpression: string[] = [];
|
||||
const stack: string[] = [];
|
||||
while (preExpression.length) {
|
||||
const read = preExpression.shift()!;
|
||||
if (read !== ")") {
|
||||
if (isNumber(read)) {
|
||||
postExpression.push(read);
|
||||
}
|
||||
else if (read === "(") {
|
||||
stack.push(read);
|
||||
} else {
|
||||
while (stack.length && getPriority(read) <= getPriority(last(stack)!) &&
|
||||
last(stack) !== "(") {
|
||||
const op = last(stack)!;
|
||||
stack.pop();
|
||||
postExpression.push(op);
|
||||
}
|
||||
stack.push(read);
|
||||
}
|
||||
} else {
|
||||
let op = last(stack)!;
|
||||
stack.pop();
|
||||
while (op && op !== "(") {
|
||||
postExpression.push(op);
|
||||
op = last(stack)!;
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
while (stack.length) {
|
||||
const op = last(stack)!;
|
||||
stack.pop();
|
||||
postExpression.push(op);
|
||||
}
|
||||
return postExpression;
|
||||
}
|
||||
|
||||
function getResult(postExpressionQueue: string[]) {
|
||||
const stackCalc: number[] = [];
|
||||
while (postExpressionQueue.length) {
|
||||
const read = postExpressionQueue.shift()!;
|
||||
if (isNumber(read)) {
|
||||
const n = parseFloat(read);
|
||||
stackCalc.push(n);
|
||||
} else {
|
||||
const second = stackCalc.pop()!;
|
||||
const first = stackCalc.pop()!;
|
||||
let result = 0;
|
||||
|
||||
switch (read) {
|
||||
case "+":
|
||||
result = first + second;
|
||||
break;
|
||||
case "-":
|
||||
result = first - second;
|
||||
break;
|
||||
case "*":
|
||||
result = first * second;
|
||||
break;
|
||||
case "/":
|
||||
result = first / second;
|
||||
break;
|
||||
case "^":
|
||||
result = Math.pow(first, second);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
stackCalc.push(result);
|
||||
}
|
||||
}
|
||||
if (stackCalc.length === 1) {
|
||||
return stackCalc[0];
|
||||
}
|
||||
throw new Error("Failed to calculate");
|
||||
}
|
||||
|
||||
function validateNumber(number: number) {
|
||||
if (isNaN(number)) {
|
||||
throw new Error("Not a number");
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
export function fixStringForCalculation(expression: string) {
|
||||
return expression.replace(/[^0-9()^*/+-.,]/g, "");
|
||||
}
|
||||
|
||||
export function canEvaluateMathExpression(expression: string) {
|
||||
const res = /[()^*/+-]/g.test(expression.substring(1));
|
||||
return res;
|
||||
}
|
||||
|
||||
export function calculateMathExpression(expression: string) {
|
||||
expression = fixStringForCalculation(expression);
|
||||
|
||||
if (/[()^*/+-]/g.test(expression)) {
|
||||
const postFixed = processExpression(expression);
|
||||
return validateNumber(getResult(postFixed));
|
||||
} else {
|
||||
return validateNumber(parseFloat(expression));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import { useSettings } from "../use/use-settings";
|
||||
import type { CanvasHandlerProps } from "../handler/interfaces";
|
||||
import { TOOL_OBJECT_MOVE, TOOL_CANVAS_MOVE, TOOL_SELECT } from "../handler/tools";
|
||||
import { clamp } from "lodash";
|
||||
import { PaperView } from "./image-tools/paper-view";
|
||||
|
||||
const ZOOM_SENSITIVITY = 1000;
|
||||
|
||||
const Container = styled.div<{ $cursor?: string }>`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background-color: gray;
|
||||
|
||||
cursor: ${({ $cursor }) => $cursor || ""};
|
||||
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000;
|
||||
`;
|
||||
|
||||
const CanvasWrapper = styled.div`
|
||||
position: absolute;
|
||||
transform-origin: 0 0;
|
||||
will-change: transform;
|
||||
box-shadow: 20px 20px 20px black;
|
||||
`;
|
||||
|
||||
export default function A4Canvas({ handler }: CanvasHandlerProps) {
|
||||
const { settings, setSettings } = useSettings(handler);
|
||||
const [cursor, setCursor] = useState(handler.toolHandler.tool === TOOL_CANVAS_MOVE.key ? "grab" : "");
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const panRef = useRef({ offsetX: 0, offsetY: 0 });
|
||||
const isDragging = useRef(false);
|
||||
const dragStart = useRef({ x: 0, y: 0 });
|
||||
const hasMoved = useRef(false);
|
||||
|
||||
const applyTransform = () => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
const { offsetX, offsetY } = panRef.current;
|
||||
wrapper.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${settings.scale})`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
panRef.current.offsetX = (container.offsetWidth - (settings.landscape ? settings.paperHeight : settings.paperWidth) * settings.scale) / 2;
|
||||
panRef.current.offsetY = (container.offsetHeight - (settings.landscape ? settings.paperWidth : settings.paperHeight) * settings.scale) / 2;
|
||||
applyTransform();
|
||||
|
||||
const unsub = handler.toolHandler.emitter.on("select", (tool) => {
|
||||
setCursor(tool === TOOL_CANVAS_MOVE.key ? "grab" : "");
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyTransform();
|
||||
}, [settings.scale]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (!container) return;
|
||||
e.preventDefault();
|
||||
|
||||
const delta = (1 - e.deltaY) / ZOOM_SENSITIVITY;
|
||||
|
||||
const newScale = settings.scale + delta;
|
||||
const scale = clamp(newScale, 0.1, 10);
|
||||
setSettings({ scale });
|
||||
const rect = container.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
const currentScale = settings.scale;
|
||||
const scaleRatio = newScale / currentScale;
|
||||
|
||||
panRef.current.offsetX = mouseX - scaleRatio * (mouseX - panRef.current.offsetX);
|
||||
panRef.current.offsetY = mouseY - scaleRatio * (mouseY - panRef.current.offsetY);
|
||||
};
|
||||
|
||||
container.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => container.removeEventListener("wheel", handleWheel);
|
||||
}, [settings.scale]);
|
||||
|
||||
useEffect(() => {
|
||||
const update = async (ev: KeyboardEvent) => {
|
||||
const shift = ev.shiftKey ? 10 : 1;
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
switch (ev.key) {
|
||||
case "ArrowUp":
|
||||
case "w":
|
||||
case "W":
|
||||
y = -shift;
|
||||
break;
|
||||
case "ArrowDown":
|
||||
case "s":
|
||||
case "S":
|
||||
y = shift;
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
case "a":
|
||||
case "A":
|
||||
x = -shift;
|
||||
break;
|
||||
case "ArrowRight":
|
||||
case "d":
|
||||
case "D":
|
||||
x = shift;
|
||||
break;
|
||||
case "Delete": {
|
||||
const copy = [...handler.selected];
|
||||
if (copy.length && await handler.popup.confirm("Title", "Are you sure you want to delete")) {
|
||||
for (const id of copy) {
|
||||
handler.deleteImage(id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (x || y) {
|
||||
handler.selected.forEach((e) => {
|
||||
handler.move(e, x, y);
|
||||
});
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keyup", update);
|
||||
return () => {
|
||||
window.removeEventListener("keyup", update);
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (!isDragging.current && handler.toolHandler.isToolSelected(TOOL_CANVAS_MOVE)) {
|
||||
setCursor("grabbing");
|
||||
}
|
||||
isDragging.current = true;
|
||||
hasMoved.current = false;
|
||||
|
||||
dragStart.current = {
|
||||
x: e.clientX - panRef.current.offsetX,
|
||||
y: e.clientY - panRef.current.offsetY,
|
||||
};
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
hasMoved.current = true;
|
||||
|
||||
if (handler.toolHandler.isToolSelected(TOOL_CANVAS_MOVE) || e.buttons === 4) {
|
||||
panRef.current.offsetX = Math.round(e.clientX - dragStart.current.x);
|
||||
panRef.current.offsetY = Math.round(e.clientY - dragStart.current.y);
|
||||
} else if (handler.toolHandler.isToolSelected(TOOL_OBJECT_MOVE)) {
|
||||
// if (!handler.selected.length) {
|
||||
// handler.popup.alert("Drag", "None of the images are selected");
|
||||
// stopDragging();
|
||||
// return;
|
||||
// }
|
||||
// const mx = e.clientX - panRef.current.offsetX;
|
||||
// const my = e.clientY - panRef.current.offsetY;
|
||||
|
||||
// const dx = mx - dragStart.current.x;
|
||||
// const dy = my - dragStart.current.y;
|
||||
|
||||
// const x = dx / settings.scale;
|
||||
// const y = dy / settings.scale;
|
||||
|
||||
// dragStart.current.x = mx;
|
||||
// dragStart.current.y = my;
|
||||
|
||||
// handler.selected.forEach((e) => {
|
||||
// handler.move(e, x, y);
|
||||
// });
|
||||
}
|
||||
applyTransform();
|
||||
};
|
||||
|
||||
const stopDragging = () => {
|
||||
if (isDragging.current && handler.toolHandler.isToolSelected(TOOL_CANVAS_MOVE)) {
|
||||
setCursor("grab");
|
||||
}
|
||||
isDragging.current = false;
|
||||
};
|
||||
|
||||
const onClick = (ev: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (
|
||||
handler.toolHandler.isToolSelected(TOOL_SELECT) ||
|
||||
(handler.toolHandler.isToolSelected(TOOL_OBJECT_MOVE) && !hasMoved.current)
|
||||
) {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
|
||||
const screenX = ev.clientX - rect.left;
|
||||
const screenY = ev.clientY - rect.top;
|
||||
|
||||
const a4X = (screenX - panRef.current.offsetX) / settings.scale;
|
||||
const a4Y = (screenY - panRef.current.offsetY) / settings.scale;
|
||||
|
||||
handler.selectNext(a4X, a4Y, !ev.shiftKey, ev.ctrlKey);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container
|
||||
$cursor={cursor}
|
||||
ref={containerRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={stopDragging}
|
||||
onMouseLeave={stopDragging}
|
||||
>
|
||||
<CanvasWrapper onClick={onClick} ref={wrapperRef}>
|
||||
<PaperView settings={settings} handler={handler} />
|
||||
</CanvasWrapper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
const Button = styled.button`
|
||||
margin: 10px;
|
||||
padding: 8px;
|
||||
width: 75px;
|
||||
height: 75px;
|
||||
border: 1px solid white;
|
||||
border-radius: 0;
|
||||
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
`;
|
||||
|
||||
interface RibbonButtonProps {
|
||||
icon: () => React.ReactNode;
|
||||
name: string;
|
||||
onClick: (ev: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function RibbonButton(props: RibbonButtonProps) {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={props.onClick}>
|
||||
{props.icon()}
|
||||
{props.name}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { FaBroom, FaDrawPolygon, FaFileExport, FaFileImport, FaFilePdf, FaFileZipper, FaImage } from "react-icons/fa6";
|
||||
import type { CanvasHandlerProps } from "../handler/interfaces";
|
||||
import { Button } from "../styles";
|
||||
import styled from "styled-components";
|
||||
import { createPdf, createSVGDoc, downloadBlob, downloadZip } from "../utils/download";
|
||||
import type { ImageEditor } from "../handler/image-editor";
|
||||
|
||||
const Btn = styled(Button)`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0px;
|
||||
padding: 2px;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const Row = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
const Column = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const Box = styled.div<{ $width: number, $height: number }>`
|
||||
width: ${({ $width }) => $width}px;
|
||||
height: ${({ $height }) => $height}px;
|
||||
padding: 10px;
|
||||
`;
|
||||
|
||||
|
||||
export function ImportExportButtons({ handler, selected }: CanvasHandlerProps & { selected: ImageEditor[] }) {
|
||||
const boxSize = 90;
|
||||
return <>
|
||||
<Box $width={boxSize} $height={boxSize}>
|
||||
<Column>
|
||||
<Row>
|
||||
|
||||
<Btn onClick={() => { downloadZip(handler); }}>
|
||||
<FaFileZipper />ZIP
|
||||
</Btn>
|
||||
<Btn onClick={() => { createPdf(handler, true).save(`${handler.projectName}.pdf`); }}>
|
||||
<FaFilePdf />PDF
|
||||
</Btn>
|
||||
<Btn onClick={async () => { downloadBlob(await createSVGDoc(handler), `${handler.projectName}.svg`); }} >
|
||||
<FaDrawPolygon />SVG
|
||||
</Btn>
|
||||
</Row>
|
||||
<Row>
|
||||
<Btn disabled={selected.length !== 1} onClick={() => {
|
||||
const v = handler.imagesRenders.find(e => e.id === selected[0].id);
|
||||
if (v) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = v.image.naturalWidth * v.scale;
|
||||
canvas.height = v.image.naturalHeight * v.scale;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(v.image, 0, 0, canvas.width, canvas.height);
|
||||
canvas.toBlob(blob => {
|
||||
if (blob) {
|
||||
downloadBlob(blob, `${v.name}.png`);
|
||||
} else {
|
||||
handler.popup.alert("Error", "Cannot export image");
|
||||
}
|
||||
}, "image/png");
|
||||
} else {
|
||||
handler.popup.alert("Error", "Image not found");
|
||||
}
|
||||
}}><FaImage />Image</Btn>
|
||||
<Btn disabled={selected.length !== 1} onClick={() => {
|
||||
const v = handler.imagesRenders.find(e => e.id === selected[0].id);
|
||||
if (v) {
|
||||
if (v.svg.scale !== v.scale || v.svg.dirty) {
|
||||
handler.redrawSvg(v.id);
|
||||
}
|
||||
downloadBlob(new Blob([v.svg.data], { type: "image/svg+xml" }), `${v.name}.svg`);
|
||||
} else {
|
||||
handler.popup.alert("Error", "Image not found");
|
||||
}
|
||||
|
||||
}}> <FaDrawPolygon />SVG</Btn>
|
||||
</Row>
|
||||
</Column>
|
||||
</Box>
|
||||
<Box $width={boxSize} $height={boxSize}>
|
||||
<Column>
|
||||
<Row>
|
||||
<Btn onClick={() => { handler.exportProject(); }}><FaFileExport />Import</Btn>
|
||||
<Btn onClick={() => { handler.importProject(); }} ><FaFileImport />Export</Btn>
|
||||
</Row>
|
||||
<Btn style={{ backgroundColor: "#ff000073" }} disabled={selected.length !== 1} onClick={async () => {
|
||||
{
|
||||
if (await handler.popup.confirm("Clear", "Are you sure you want to clear project? Any unsaved changes will be discarded")) {
|
||||
handler.clear();
|
||||
}
|
||||
}
|
||||
}} ><FaBroom />Clear</Btn>
|
||||
</Column>
|
||||
</Box>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { CanvasHandlerProps } from "../handler/interfaces";
|
||||
import styled from "styled-components";
|
||||
|
||||
const DragOverlay = styled.div`
|
||||
position: absolute;
|
||||
inset: 16px;
|
||||
border-radius: 4px;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.6);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
z-index: 99999;
|
||||
`;
|
||||
|
||||
export function DragDrop({ handler }: CanvasHandlerProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(false);
|
||||
|
||||
const dt = e.dataTransfer;
|
||||
if (!dt) return;
|
||||
|
||||
const files = dt.files && dt.files.length ? [...dt.files] : [];
|
||||
for (const file of files) {
|
||||
handler.importFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("dragover", handleDragOver);
|
||||
window.addEventListener("dragleave", handleDragLeave);
|
||||
window.addEventListener("drop", handleDrop);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("dragover", handleDragOver);
|
||||
window.removeEventListener("dragleave", handleDragLeave);
|
||||
window.removeEventListener("drop", handleDrop);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return dragOver ? <DragOverlay>Drop PNG files to place on page</DragOverlay> : null;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import styled from "styled-components";
|
||||
import type { ImageEditor } from "../handler/image-editor";
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: block;
|
||||
padding: 5px;
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
width: 50px;
|
||||
height: 10px;
|
||||
background: #0d0d0d;
|
||||
border: 1px solid #444;
|
||||
border-top: none;
|
||||
color: #e0e0e0;
|
||||
font-size: 13px;
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
margin-top: 12px;
|
||||
|
||||
&::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: #666;
|
||||
}
|
||||
`;
|
||||
|
||||
export function ImagePropEditor({ propKey, selected }: { selected: ImageEditor[]; propKey: keyof ImageEditor }) {
|
||||
if (selected.length !== 1) return null;
|
||||
const item = selected[0];
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<span>{propKey}: </span>
|
||||
<Input
|
||||
type="number"
|
||||
value={item[propKey] as number}
|
||||
onChange={(ev) => {
|
||||
item.onPropsChange({ [propKey]: parseInt(ev.target.value, 10) });
|
||||
}}
|
||||
/>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import styled from "styled-components";
|
||||
import { FaMinus, FaPlus } from "react-icons/fa";
|
||||
import type { CanvasHandlerProps } from "../handler/interfaces";
|
||||
import { clamp } from "lodash";
|
||||
import type { ImageEditor } from "../handler/image-editor";
|
||||
|
||||
const SCALE_MIN = 0.1;
|
||||
const SCALE_MAX = 3;
|
||||
const SCALE_STEP = 0.01;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
width: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
font-family: monospace;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const IconBtn = styled.button`
|
||||
background: var(--primary-color);
|
||||
border: 1px solid #333;
|
||||
color: #aaa;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background 0.1s,
|
||||
color 0.1s;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
background: #2a2a2a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #0d0d0d;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const Track = styled.div`
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: #2e2e2e;
|
||||
min-width: 80px;
|
||||
`;
|
||||
|
||||
const Fill = styled.div<{ $pct: number }>`
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: ${({ $pct }) => $pct}%;
|
||||
background: #e0e0e0;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const Thumb = styled.input`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const ThumbDot = styled.div<{ $pct: number; $disabled: boolean }>`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: ${({ $pct }) => $pct}%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: ${({ $disabled }) => ($disabled ? "#444" : "#fff")};
|
||||
border: 2px solid ${({ $disabled }) => ($disabled ? "#333" : "#888")};
|
||||
pointer-events: none;
|
||||
transition: background 0.1s;
|
||||
`;
|
||||
|
||||
const Label = styled.span`
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
letter-spacing: 0.05em;
|
||||
`;
|
||||
|
||||
export function ImageScaler({ handler, selected }: CanvasHandlerProps & { selected: ImageEditor[] }) {
|
||||
const disabled = selected.length === 0;
|
||||
const isMixed = selected.length > 1;
|
||||
|
||||
const scale = disabled ? 1 : isMixed ? selected.reduce((acc, e) => acc + e.scale, 0) / selected.length : selected[0].scale;
|
||||
|
||||
const pct = ((clamp(scale, SCALE_MIN, SCALE_MAX) - SCALE_MIN) / (SCALE_MAX - SCALE_MIN)) * 100;
|
||||
|
||||
const setScale = (value: number, ignoreLimit = false) => {
|
||||
const clamped = ignoreLimit ? value : Math.min(SCALE_MAX, Math.max(SCALE_MIN, value));
|
||||
|
||||
selected.forEach((e) => {
|
||||
const oldScale = e.scale;
|
||||
const newScale = clamped;
|
||||
|
||||
const currentW = e.image.width * oldScale;
|
||||
const currentH = e.image.height * oldScale;
|
||||
|
||||
const newW = e.image.width * newScale;
|
||||
const newH = e.image.height * newScale;
|
||||
|
||||
const dx = (currentW - newW) / 2;
|
||||
const dy = (currentH - newH) / 2;
|
||||
|
||||
e.onPropsChange({
|
||||
scale: clamped,
|
||||
x: e.x + dx,
|
||||
y: e.y + dy,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const label = disabled ? "—" : isMixed ? "~" + scale.toFixed(2) : scale.toFixed(2);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<IconBtn disabled={disabled} onClick={() => setScale(scale - SCALE_STEP * 10)} title="Scale down">
|
||||
<FaMinus size={9} />
|
||||
</IconBtn>
|
||||
|
||||
<Track>
|
||||
<Fill $pct={pct} />
|
||||
<ThumbDot $pct={pct} $disabled={disabled} />
|
||||
<Thumb
|
||||
type="range"
|
||||
min={SCALE_MIN}
|
||||
max={SCALE_MAX}
|
||||
step={SCALE_STEP}
|
||||
value={scale}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setScale(parseFloat(e.target.value))}
|
||||
/>
|
||||
</Track>
|
||||
|
||||
<IconBtn disabled={disabled} onClick={() => setScale(scale + SCALE_STEP * 10, true)} title="Scale up">
|
||||
<FaPlus size={9} />
|
||||
</IconBtn>
|
||||
|
||||
<Label
|
||||
onClick={async () => {
|
||||
const size = await handler.popup.prompt("Custom size", "Enter custom size", scale.toString());
|
||||
if (size) {
|
||||
const float = parseFloat(size);
|
||||
if (!isNaN(float)) {
|
||||
setScale(float, true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={isMixed ? "Average of selected" : undefined}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import { drawCanvas } from "../../draw-canvas";
|
||||
import type { CanvasHandlerProps } from "../../handler/interfaces";
|
||||
import styled from "styled-components";
|
||||
|
||||
const CanvasEl = styled.canvas`
|
||||
background: white;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
display: block;
|
||||
`;
|
||||
|
||||
export function CanvasView({ handler }: CanvasHandlerProps) {
|
||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = ref.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const render = () => {
|
||||
drawCanvas(canvas!, handler);
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
const unsubs = [
|
||||
handler.emitter.on("update", render),
|
||||
handler.emitter.on("array-length", render),
|
||||
handler.emitter.on("select", render),
|
||||
handler.globalSettingsHandler.emitter.on("settings-update", render),
|
||||
];
|
||||
|
||||
return function () {
|
||||
unsubs.forEach((unsub) => unsub());
|
||||
};
|
||||
}, [handler]);
|
||||
|
||||
return <CanvasEl draggable={false} ref={ref} />;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { CanvasHandlerProps } from "../../handler/interfaces";
|
||||
import styled from "styled-components";
|
||||
import { MM_TO_INCH } from "../../constants";
|
||||
import { useImages } from "../../use/use-images";
|
||||
import { FreeTransform } from "./selection-box";
|
||||
import type { GlobalSettings, GridType } from "../../interface";
|
||||
import { SvgRenderer } from "./svg-view";
|
||||
|
||||
|
||||
const CanvasEl = styled.div`
|
||||
background: white;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const Img = styled.img<{ $selected: boolean }>`
|
||||
position: absolute;
|
||||
display: block;
|
||||
user-select: none;
|
||||
opacity: ${({ $selected }) => $selected ? 0.5 : 1};
|
||||
`;
|
||||
|
||||
|
||||
const Box = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #757575;
|
||||
`;
|
||||
|
||||
function getGridLines(settings: GridType, width: number, height: number, strokeWidth: number, strokeWidthHalf: number) {
|
||||
if (settings === "none") return null;
|
||||
const configs: Record<GridType, { vertical: number[]; horizontal: number[] }> = {
|
||||
"none": {
|
||||
horizontal: [1],
|
||||
vertical: [1]
|
||||
},
|
||||
"2x2": {
|
||||
vertical: [1 / 2],
|
||||
horizontal: [1 / 2],
|
||||
},
|
||||
"3x3": {
|
||||
vertical: [1 / 3, 2 / 3],
|
||||
horizontal: [1 / 3, 2 / 3],
|
||||
},
|
||||
};
|
||||
|
||||
const config = configs[settings];
|
||||
if (!config) return null;
|
||||
|
||||
return <>
|
||||
{config.vertical.map((fraction, i) => (
|
||||
<Box key={`v-${i}`} style={{ left: width * fraction - strokeWidthHalf, width: strokeWidth, height: "100%" }} />
|
||||
))}
|
||||
{config.horizontal.map((fraction, i) => (
|
||||
<Box key={`h-${i}`} style={{ top: height * fraction - strokeWidthHalf, height: strokeWidth, width: "100%" }} />
|
||||
))}
|
||||
</>;
|
||||
};
|
||||
|
||||
export function PaperView({ handler, settings }: CanvasHandlerProps & { settings: GlobalSettings }) {
|
||||
const { images } = useImages(handler);
|
||||
const dpi = settings.DPI;
|
||||
const width = Math.round(((settings.landscape ? settings.paperHeight : settings.paperWidth) / MM_TO_INCH) * dpi) * settings.scale;
|
||||
const height = Math.round(((settings.landscape ? settings.paperWidth : settings.paperHeight) / MM_TO_INCH) * dpi) * settings.scale;
|
||||
|
||||
const renderGuides = () => {
|
||||
const strokeWidth = 1;
|
||||
const strokeWidthHalf = strokeWidth / 2;
|
||||
return getGridLines(settings.grid, width, height, strokeWidth, strokeWidthHalf);
|
||||
};
|
||||
|
||||
return <CanvasEl style={{ width: `${width}px`, height: `${height}px` }} draggable={false}>
|
||||
{images.map(e => {
|
||||
if (!e.visible) return;
|
||||
const width = e.image.naturalWidth * e.scale * settings.scale;
|
||||
const height = e.image.naturalHeight * e.scale * settings.scale;
|
||||
const x = e.x * settings.scale;
|
||||
const y = e.y * settings.scale;
|
||||
return <div key={e.id}>
|
||||
<Img
|
||||
$selected={e.selected}
|
||||
draggable="false"
|
||||
src={e.url}
|
||||
alt={e.id}
|
||||
style={{ left: `${x}px`, top: `${y}px` }}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
<SvgRenderer x={x} y={y} width={width} height={height} svg={e.svg} />
|
||||
{e.selected ?
|
||||
<FreeTransform scale={settings.scale} transform={{
|
||||
x: x,
|
||||
y: y,
|
||||
height,
|
||||
width,
|
||||
rotation: 0
|
||||
}} onTransformChange={({ x, y, width }) => {
|
||||
const scale = Math.round(width / e.image.width / settings.scale * 100) / 100;
|
||||
e.onPropsChange({ x: Math.round(x / settings.scale), y: Math.round(y / settings.scale), scale });
|
||||
}} /> : null}
|
||||
</div>;
|
||||
})}
|
||||
{renderGuides()}
|
||||
</CanvasEl>;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import React, { useRef, useCallback } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Transform = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
};
|
||||
|
||||
type DragState =
|
||||
| { type: "none" }
|
||||
| { type: "move"; startX: number; startY: number; originX: number; originY: number }
|
||||
| { type: "resize"; handle: HandleKey; startX: number; startY: number; originTransform: Transform }
|
||||
| { type: "rotate"; startAngle: number; originRotation: number; cx: number; cy: number };
|
||||
|
||||
type HandleKey = "nw" | "ne" | "se" | "sw";
|
||||
|
||||
const HANDLE_SIZE = 10;
|
||||
const MIN_SIZE = 20;
|
||||
|
||||
const HANDLE_CURSORS: Record<HandleKey, string> = {
|
||||
nw: "nwse-resize",
|
||||
ne: "nesw-resize",
|
||||
se: "nwse-resize",
|
||||
sw: "nesw-resize",
|
||||
};
|
||||
|
||||
const HANDLE_POSITIONS: Record<HandleKey, { x: number; y: number }> = {
|
||||
nw: { x: 0, y: 0 },
|
||||
ne: { x: 1, y: 0 },
|
||||
se: { x: 1, y: 1 },
|
||||
sw: { x: 0, y: 1 },
|
||||
};
|
||||
|
||||
const Overlay = styled.div`
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const TransformBox = styled.div`
|
||||
position: absolute;
|
||||
pointer-events: auto;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
position: fixed;
|
||||
z-index: 999999;
|
||||
`;
|
||||
|
||||
const SelectionBorder = styled.div`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px dashed #00d4ff;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const ResizeHandle = styled.div<{ $x: number; $y: number; $cursor: string }>`
|
||||
position: absolute;
|
||||
left: calc(${({ $x }) => $x * 100}% - ${HANDLE_SIZE / 2}px);
|
||||
top: calc(${({ $y }) => $y * 100}% - ${HANDLE_SIZE / 2}px);
|
||||
width: ${HANDLE_SIZE}px;
|
||||
height: ${HANDLE_SIZE}px;
|
||||
background: white;
|
||||
border: 2px solid #00d4ff;
|
||||
border-radius: 2px;
|
||||
cursor: ${({ $cursor }) => $cursor};
|
||||
pointer-events: auto;
|
||||
z-index: 10;
|
||||
`;
|
||||
|
||||
// const RotateHandleWrapper = styled.div`
|
||||
// position: absolute;
|
||||
// left: 50%;
|
||||
// top: -36px;
|
||||
// transform: translateX(-50%);
|
||||
// display: flex;
|
||||
// flex-direction: column;
|
||||
// align-items: center;
|
||||
// pointer-events: auto;
|
||||
// cursor: grab;
|
||||
// z-index: 10;
|
||||
// `;
|
||||
|
||||
// const RotateStem = styled.div`
|
||||
// width: 2px;
|
||||
// height: 20px;
|
||||
// background: #00d4ff;
|
||||
// `;
|
||||
|
||||
// const RotateKnob = styled.div`
|
||||
// width: 14px;
|
||||
// height: 14px;
|
||||
// border-radius: 50%;
|
||||
// background: white;
|
||||
// border: 2px solid #00d4ff;
|
||||
// margin-top: -1px;
|
||||
// `;
|
||||
|
||||
export type FreeTransformProps = {
|
||||
transform: Transform;
|
||||
scale?: number;
|
||||
onTransformChange: (t: Transform) => void;
|
||||
};
|
||||
|
||||
export function FreeTransform({
|
||||
transform,
|
||||
scale = 1,
|
||||
onTransformChange,
|
||||
}: FreeTransformProps) {
|
||||
const dragRef = useRef<DragState>({ type: "none" });
|
||||
|
||||
const apply = useCallback(
|
||||
(updater: (prev: Transform) => Transform) => {
|
||||
onTransformChange(updater(transform));
|
||||
},
|
||||
[transform, onTransformChange]
|
||||
);
|
||||
|
||||
const onMovePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
type: "move",
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
originX: transform.x,
|
||||
originY: transform.y,
|
||||
};
|
||||
},
|
||||
[transform.x, transform.y]
|
||||
);
|
||||
|
||||
const onResizePointerDown = useCallback(
|
||||
(handle: HandleKey) => (e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
type: "resize",
|
||||
handle,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
originTransform: { ...transform },
|
||||
};
|
||||
},
|
||||
[transform]
|
||||
);
|
||||
|
||||
// const onRotatePointerDown = useCallback(
|
||||
// (e: React.PointerEvent) => {
|
||||
// e.stopPropagation();
|
||||
// e.currentTarget.setPointerCapture(e.pointerId);
|
||||
// const cx = transform.x * scale + (transform.width * scale) / 2;
|
||||
// const cy = transform.y * scale + (transform.height * scale) / 2;
|
||||
// const startAngle = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI);
|
||||
// dragRef.current = {
|
||||
// type: "rotate",
|
||||
// startAngle,
|
||||
// originRotation: transform.rotation,
|
||||
// cx,
|
||||
// cy,
|
||||
// };
|
||||
// },
|
||||
// [transform, scale]
|
||||
// );
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (e.buttons !== 1) return;
|
||||
const drag = dragRef.current;
|
||||
if (drag.type === "none") return;
|
||||
|
||||
if (drag.type === "move") {
|
||||
const dx = (e.clientX - drag.startX) / scale;
|
||||
const dy = (e.clientY - drag.startY) / scale;
|
||||
apply(() => ({
|
||||
...transform,
|
||||
x: drag.originX + dx,
|
||||
y: drag.originY + dy,
|
||||
}));
|
||||
}
|
||||
|
||||
if (drag.type === "rotate") {
|
||||
const currentAngle =
|
||||
Math.atan2(e.clientY - drag.cy, e.clientX - drag.cx) * (180 / Math.PI);
|
||||
apply(() => ({
|
||||
...transform,
|
||||
rotation: drag.originRotation + (currentAngle - drag.startAngle),
|
||||
}));
|
||||
}
|
||||
|
||||
if (drag.type === "resize") {
|
||||
const { handle, startX, startY, originTransform: o } = drag;
|
||||
const rad = (-o.rotation * Math.PI) / 180;
|
||||
const rawDx = (e.clientX - startX) / scale;
|
||||
const rawDy = (e.clientY - startY) / scale;
|
||||
const dx = rawDx * Math.cos(rad) - rawDy * Math.sin(rad);
|
||||
const dy = rawDx * Math.sin(rad) + rawDy * Math.cos(rad);
|
||||
|
||||
let { x, y, width, height } = o;
|
||||
|
||||
if (handle.includes("e")) width = Math.max(MIN_SIZE, o.width + dx);
|
||||
if (handle.includes("s")) height = Math.max(MIN_SIZE, o.height + dy);
|
||||
if (handle.includes("w")) {
|
||||
const newW = Math.max(MIN_SIZE, o.width - dx);
|
||||
x = o.x + o.width - newW;
|
||||
width = newW;
|
||||
}
|
||||
if (handle.includes("n")) {
|
||||
const newH = Math.max(MIN_SIZE, o.height - dy);
|
||||
y = o.y + o.height - newH;
|
||||
height = newH;
|
||||
}
|
||||
|
||||
apply(() => ({ ...transform, x, y, width, height }));
|
||||
}
|
||||
},
|
||||
[transform, scale, apply]
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
dragRef.current = { type: "none" };
|
||||
}, []);
|
||||
|
||||
const { x, y, width, height, rotation } = transform;
|
||||
|
||||
return (
|
||||
<Overlay>
|
||||
<TransformBox
|
||||
style={{
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
transform: `rotate(${rotation}deg)`
|
||||
}}
|
||||
onPointerDown={onMovePointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<SelectionBorder />
|
||||
|
||||
{(Object.keys(HANDLE_POSITIONS) as HandleKey[]).map((key) => (
|
||||
<ResizeHandle
|
||||
key={key}
|
||||
$x={HANDLE_POSITIONS[key].x}
|
||||
$y={HANDLE_POSITIONS[key].y}
|
||||
$cursor={HANDLE_CURSORS[key]}
|
||||
onPointerDown={onResizePointerDown(key)}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* <RotateHandleWrapper
|
||||
onPointerDown={onRotatePointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<RotateStem />
|
||||
<RotateKnob />
|
||||
</RotateHandleWrapper> */}
|
||||
</TransformBox>
|
||||
</Overlay>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
|
||||
import styled from "styled-components";
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
line-height: 0;
|
||||
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
svg: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
|
||||
export function SvgRenderer(props: Props) {
|
||||
const { svg, x, y, width, height } = props;
|
||||
|
||||
return (
|
||||
<Wrapper style={{
|
||||
left: x,
|
||||
top: y,
|
||||
width,
|
||||
height
|
||||
}} dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { styled } from "styled-components";
|
||||
import type { CanvasHandlerProps } from "../handler/interfaces";
|
||||
import { Icon2525 } from "../styles";
|
||||
import { FaMinus, FaPlus } from "react-icons/fa6";
|
||||
import { useSettings } from "../use/use-settings";
|
||||
import { clamp } from "lodash";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: var(--primary-color);
|
||||
border-top: 1px solid var(--secondary-color);
|
||||
`;
|
||||
|
||||
const ScaleText = styled.span`
|
||||
width: 50px;
|
||||
margin-top: 4px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const ProjectName = styled.span`
|
||||
margin: auto;
|
||||
`;
|
||||
|
||||
const DPI = [72, 96, 150, 300, 600];
|
||||
|
||||
export function InfoBar({ handler }: CanvasHandlerProps) {
|
||||
const { settings, setSettings } = useSettings(handler);
|
||||
const [projectName, setProjectName] = useState(handler.projectName);
|
||||
|
||||
useEffect(() => {
|
||||
return handler.emitter.on("name", name => {
|
||||
setProjectName(name);
|
||||
});
|
||||
}, [handler.projectName]);
|
||||
|
||||
const setScale = (increment: boolean) => {
|
||||
const amount = 0.05;
|
||||
setSettings({ scale: settings.scale + (increment ? amount : -amount) });
|
||||
};
|
||||
const setDPI = (increment: boolean) => {
|
||||
let index = DPI.indexOf(settings.DPI);
|
||||
|
||||
if (index === -1) {
|
||||
index = DPI.reduce((bestIdx, value, i) => {
|
||||
const bestDiff = Math.abs(DPI[bestIdx] - settings.DPI);
|
||||
const currentDiff = Math.abs(value - settings.DPI);
|
||||
return currentDiff < bestDiff ? i : bestIdx;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (increment) {
|
||||
if (index + 1 < DPI.length) index++;
|
||||
} else {
|
||||
if (index - 1 >= 0) index--;
|
||||
}
|
||||
|
||||
setSettings({ DPI: DPI[index] });
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Icon2525 onClick={() => setScale(false)}>
|
||||
<FaMinus />
|
||||
</Icon2525>
|
||||
<ScaleText
|
||||
onClick={async () => {
|
||||
const number = await handler.popup.prompt("DPI", "Enter your desired DPI", (settings.scale * 100).toString());
|
||||
if (number) {
|
||||
const int = parseInt(number, 10) / 100;
|
||||
if (!isNaN(int)) {
|
||||
setSettings({ scale: clamp(int, 0, 100) });
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Math.round(settings.scale * 100)}%
|
||||
</ScaleText>
|
||||
<Icon2525 onClick={() => setScale(true)}>
|
||||
<FaPlus />
|
||||
</Icon2525>
|
||||
<ProjectName>
|
||||
<span onClick={() => {
|
||||
handler.promptSetName();
|
||||
}}> {projectName}
|
||||
</span>
|
||||
</ProjectName>
|
||||
<Icon2525 onClick={() => setDPI(false)}>
|
||||
<FaMinus />
|
||||
</Icon2525>
|
||||
<ScaleText
|
||||
onClick={async () => {
|
||||
const number = await handler.popup.prompt("DPI", "Enter your desired DPI", settings.DPI.toString());
|
||||
if (number) {
|
||||
const int = parseInt(number, 10);
|
||||
if (!isNaN(int)) {
|
||||
setSettings({ DPI: clamp(int, DPI[0], DPI[DPI.length - 1]) });
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Math.round(settings.DPI)}DPI
|
||||
</ScaleText>
|
||||
<Icon2525 onClick={() => setDPI(true)}>
|
||||
<FaPlus />
|
||||
</Icon2525>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import styled from "styled-components";
|
||||
import type { CanvasHandleImageEditorProps } from "../handler/interfaces";
|
||||
import { FaEye, FaLock } from "react-icons/fa";
|
||||
import { NamePlate } from "./layer-name";
|
||||
import type { ImageEditor } from "../handler/image-editor";
|
||||
|
||||
const Item = styled.div<{ $selected: boolean }>`
|
||||
width: 240px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--secondary-color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
${({ $selected }) =>
|
||||
$selected &&
|
||||
`
|
||||
background-color: var(--secondary-color);
|
||||
`}
|
||||
`;
|
||||
|
||||
const Img = styled.img`
|
||||
margin: 5px;
|
||||
max-width: 25px;
|
||||
max-height: 25px;
|
||||
`;
|
||||
const Icon = styled.span`
|
||||
padding: 7px 5px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export function LayerItem({ image, handler }: CanvasHandleImageEditorProps) {
|
||||
const selectLayer = (image: ImageEditor, ctrl: boolean) => {
|
||||
if (!ctrl) {
|
||||
const selected = handler.selected.filter(e => e !== image.id);
|
||||
for (const sel of selected) {
|
||||
handler.setSelect(sel, false);
|
||||
}
|
||||
}
|
||||
image.toggleSelected();
|
||||
};
|
||||
return (
|
||||
<Item
|
||||
$selected={image.selected}
|
||||
onClick={ev => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
selectLayer(image, ev.ctrlKey);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
style={{ opacity: image.visible ? 1 : 0.1 }}
|
||||
onClick={ev => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
image.setVisible(!image.visible);
|
||||
}}
|
||||
>
|
||||
<FaEye />
|
||||
</Icon>
|
||||
|
||||
<Img
|
||||
draggable="false"
|
||||
onClick={ev => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
selectLayer(image, ev.ctrlKey);
|
||||
}}
|
||||
src={image.url}
|
||||
alt={image.id}
|
||||
/>
|
||||
<NamePlate image={image} />
|
||||
<Icon
|
||||
style={{ opacity: image.locked ? 1 : 0.1 }}
|
||||
onClick={ev => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
image.setLock(!image.locked);
|
||||
}}
|
||||
>
|
||||
<FaLock />
|
||||
</Icon>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import styled from "styled-components";
|
||||
import type { ImageEditorProps } from "../handler/interfaces";
|
||||
import { useState } from "react";
|
||||
|
||||
const NamePlateDiv = styled.span`
|
||||
width: 150px;
|
||||
display: inline-block;
|
||||
margin-top: 5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
width: 140px;
|
||||
background-color: var(--primary-color);
|
||||
border: 1px solid var(--secondary-color);
|
||||
border-radius: 0px;
|
||||
color: white;
|
||||
`;
|
||||
|
||||
export function NamePlate({ image }: ImageEditorProps) {
|
||||
const [name, setName] = useState(() => image.name);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const stopEditing = () => {
|
||||
image.setName(name);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<NamePlateDiv>
|
||||
{editing ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={stopEditing}
|
||||
onKeyUp={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
stopEditing();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span onDoubleClick={ev => {
|
||||
if (!image.locked) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
setEditing(true);
|
||||
}
|
||||
}}>{image.name}</span>
|
||||
)}
|
||||
</NamePlateDiv>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { CanvasHandlerProps } from "../handler/interfaces";
|
||||
import styled from "styled-components";
|
||||
import { LayerItem } from "./layer-item";
|
||||
import { FaFileImport, FaTrash } from "react-icons/fa";
|
||||
import { Icon2525 } from "../styles";
|
||||
import { useImages } from "../use/use-images";
|
||||
|
||||
const LayerPanelDiv = styled.div`
|
||||
border-top: 1px solid var(--secondary-color);
|
||||
border-left: 1px solid var(--secondary-color);
|
||||
background-color: var(--primary-color);
|
||||
display: flex;
|
||||
width: 242px;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const Gap = styled.div`
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const Version = styled.span`
|
||||
margin: 0 5px;
|
||||
`;
|
||||
|
||||
|
||||
const ToolBar = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-top: 1px solid var(--secondary-color);
|
||||
`;
|
||||
|
||||
|
||||
|
||||
const Wrapper = styled.div`
|
||||
width: 200px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const Title = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
opacity: 0.9;
|
||||
`;
|
||||
|
||||
const Row = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
opacity: 0.6;
|
||||
`;
|
||||
|
||||
const Value = styled.div`
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
const Link = styled.a`
|
||||
color: #4AF626;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
export function LayerPanel({ handler }: CanvasHandlerProps) {
|
||||
const { images } = useImages(handler);
|
||||
|
||||
return (
|
||||
<LayerPanelDiv>
|
||||
{images.map((e) => (
|
||||
<LayerItem key={e.id} handler={handler} image={e} />
|
||||
))}
|
||||
<Gap />
|
||||
<ToolBar>
|
||||
|
||||
<Version onDoubleClick={() => {
|
||||
handler.popup.custom("About", "", <Wrapper>
|
||||
<Title>Vectrace {handler.VERSION}v</Title>
|
||||
|
||||
<Row>
|
||||
<Label>Author</Label>
|
||||
<Value>{handler.AUTHOR.name}</Value>
|
||||
</Row>
|
||||
|
||||
|
||||
<Row>
|
||||
<Label>Email</Label>
|
||||
<Value>{handler.AUTHOR.email}</Value>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Label>URL</Label>
|
||||
<Value>
|
||||
<Link href={handler.AUTHOR.url} target="_blank" rel="noreferrer">
|
||||
{handler.AUTHOR.url}
|
||||
</Link>
|
||||
</Value>
|
||||
</Row>
|
||||
</Wrapper>);
|
||||
}}>
|
||||
{handler.VERSION}v
|
||||
|
||||
</Version>
|
||||
<Gap />
|
||||
<Icon2525
|
||||
onClick={() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.addEventListener("change", async () => {
|
||||
if (input.files) {
|
||||
for (const file of [...input.files]) {
|
||||
await handler.importFile(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
input.click();
|
||||
}}
|
||||
>
|
||||
<FaFileImport />
|
||||
</Icon2525>
|
||||
<Icon2525
|
||||
onClick={async () => {
|
||||
const c = [...handler.selected];
|
||||
if (c.length) {
|
||||
if (
|
||||
await handler.popup.confirm(
|
||||
`Delete (${c.length})`,
|
||||
`Are you sure you want to delete ${c.length} items?`,
|
||||
)
|
||||
) {
|
||||
for (const id of c) {
|
||||
handler.deleteImage(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FaTrash />
|
||||
</Icon2525>
|
||||
</ToolBar>
|
||||
</LayerPanelDiv>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import { calculateMathExpression, canEvaluateMathExpression } from "../calc";
|
||||
|
||||
const StyledInput = styled.input<{ $isValid: boolean | null }>`
|
||||
padding: 1px;
|
||||
font-size: 1rem;
|
||||
width: 80px;
|
||||
border: 1px solid
|
||||
${({ $isValid }) =>
|
||||
$isValid === null
|
||||
? "#888"
|
||||
: $isValid
|
||||
? "#ffffff"
|
||||
: "#ef4444"};
|
||||
outline: none;
|
||||
background-color: ${({ $isValid }) =>
|
||||
$isValid === null
|
||||
? "transparent"
|
||||
: $isValid
|
||||
? "#000000"
|
||||
: "#7e0000"};
|
||||
color: ${({ $isValid }) =>
|
||||
$isValid === null ? "inherit" : $isValid ? "#15803d" : "#b91c1c"};
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 3px
|
||||
${({ $isValid }) =>
|
||||
$isValid === null
|
||||
? "#88888844"
|
||||
: $isValid
|
||||
? "#22c55e44"
|
||||
: "#ef444444"};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
export interface NumberInputProps {
|
||||
value?: number;
|
||||
id?: string;
|
||||
onChange?: (value: number) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
|
||||
export function NumberInput({
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
placeholder = "Enter a number...",
|
||||
}: NumberInputProps) {
|
||||
const [raw, setRaw] = useState<string>(() => value !== undefined ? String(value) : ""
|
||||
);
|
||||
const [isValid, setIsValid] = useState<boolean | null>(
|
||||
value !== undefined ? true : null
|
||||
);
|
||||
|
||||
const isValidNumber = (val: string): boolean => {
|
||||
if (val.trim() === "") return false;
|
||||
const parsed = Number(val);
|
||||
|
||||
return isFinite(parsed) && !isNaN(parsed);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setRaw(val);
|
||||
|
||||
if (val.trim() === "") {
|
||||
|
||||
setIsValid(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isValidNumber(val)) {
|
||||
setIsValid(true);
|
||||
onChange?.(Number(val));
|
||||
} else {
|
||||
setIsValid(canEvaluateMathExpression(val));
|
||||
}
|
||||
};
|
||||
const increment = (increment: boolean) => {
|
||||
if (isValidNumber(raw)) {
|
||||
const value = Number(raw) + (increment ? 1 : -1);
|
||||
setRaw(value.toString());
|
||||
onChange?.(value);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const executeEvaluate = () => {
|
||||
if (canEvaluateMathExpression(raw)) {
|
||||
const value = calculateMathExpression(raw);
|
||||
|
||||
setRaw(value.toString());
|
||||
if (isValidNumber(value.toString())) {
|
||||
setIsValid(true);
|
||||
onChange?.(value);
|
||||
} else {
|
||||
setIsValid(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== undefined) {
|
||||
setRaw(value.toString());
|
||||
setIsValid(true);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<StyledInput
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={raw}
|
||||
onBlur={executeEvaluate}
|
||||
onKeyUp={ev => {
|
||||
switch (ev.key) {
|
||||
case "Enter":
|
||||
executeEvaluate();
|
||||
break;
|
||||
case "ArrowUp":
|
||||
ev.preventDefault();
|
||||
increment(true);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
ev.preventDefault();
|
||||
increment(false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}}
|
||||
onChange={handleChange}
|
||||
$isValid={isValid}
|
||||
placeholder={placeholder}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import styled from "styled-components";
|
||||
import { NumberInput, type NumberInputProps } from "./number-input";
|
||||
|
||||
const OptionDiv = styled.div`
|
||||
margin: 2px;
|
||||
`;
|
||||
|
||||
export function NumericInputWithLabel({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
name,
|
||||
id
|
||||
|
||||
}: NumberInputProps & { name: string }) {
|
||||
return <OptionDiv>
|
||||
<label htmlFor={id}>{name}:</label>
|
||||
<NumberInput id={id} placeholder={placeholder} value={value} onChange={onChange} />
|
||||
</OptionDiv>;
|
||||
}
|
||||
export function NumericInputWithLabelTable({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
name,
|
||||
id
|
||||
|
||||
}: NumberInputProps & { name: string }) {
|
||||
return <tr>
|
||||
<td>
|
||||
<label htmlFor={id}>{name}:</label>
|
||||
</td>
|
||||
<td>
|
||||
<NumberInput
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange} />
|
||||
|
||||
</td>
|
||||
</tr>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import styled from "styled-components";
|
||||
import { Button } from "../styles";
|
||||
import type { UseSettings } from "../use/use-settings";
|
||||
import { GRID } from "../interface";
|
||||
|
||||
const Wrapper = styled.div`
|
||||
height: calc(100% - 8px);
|
||||
border-right: 1px solid white;
|
||||
margin: 4px 0px;
|
||||
padding-right: 4px;
|
||||
width: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
button {
|
||||
flex-grow: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
export function PaperGuideSelector({ setSettings, settings }: UseSettings) {
|
||||
return <Wrapper>
|
||||
{GRID.map((e, i) => <Button
|
||||
$active={e === settings.grid}
|
||||
key={i}
|
||||
onClick={() => {
|
||||
setSettings({ grid: e });
|
||||
}}
|
||||
>{e}
|
||||
</Button>)}
|
||||
</Wrapper>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { FaFile } from "react-icons/fa6";
|
||||
import { RibbonButton } from "./buttons/button-ribbon";
|
||||
import type { UseSettings } from "../use/use-settings";
|
||||
|
||||
export function PaperOrientation({ setSettings, settings }: UseSettings) {
|
||||
|
||||
return <RibbonButton
|
||||
icon={() => <FaFile style={{ transform: `rotate(${settings.landscape ? "90" : "0"}deg)` }} />}
|
||||
onClick={() => {
|
||||
setSettings({ landscape: !settings.landscape });
|
||||
}}
|
||||
name={settings.landscape ? "Landscape" : "Portrait"} />;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import styled from "styled-components";
|
||||
import {
|
||||
A0_HEIGHT, A0_WIDTH,
|
||||
A1_HEIGHT, A1_WIDTH,
|
||||
A2_HEIGHT, A2_WIDTH,
|
||||
A3_HEIGHT, A3_WIDTH,
|
||||
A4_HEIGHT, A4_WIDTH,
|
||||
A5_HEIGHT, A5_WIDTH,
|
||||
A6_HEIGHT, A6_WIDTH,
|
||||
LETTER_HEIGHT, LETTER_WIDTH,
|
||||
LEGAL_HEIGHT, LEGAL_WIDTH,
|
||||
TABLOID_HEIGHT, TABLOID_WIDTH,
|
||||
LEDGER_HEIGHT, LEDGER_WIDTH,
|
||||
|
||||
} from "../constants";
|
||||
import { Button } from "../styles";
|
||||
import type { UseSettings } from "../use/use-settings";
|
||||
import { Popup } from "../handler/popup";
|
||||
|
||||
const Wrapper = styled.div`
|
||||
height: 100%;
|
||||
width: 130px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
button {
|
||||
flex-grow: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
interface Paper {
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function createPaper(name: string, width: number, height: number): Paper {
|
||||
return { name, width, height };
|
||||
}
|
||||
|
||||
const papers = [
|
||||
createPaper("A0", A0_WIDTH, A0_HEIGHT),
|
||||
createPaper("A1", A1_WIDTH, A1_HEIGHT),
|
||||
createPaper("A2", A2_WIDTH, A2_HEIGHT),
|
||||
createPaper("A3", A3_WIDTH, A3_HEIGHT),
|
||||
createPaper("A4", A4_WIDTH, A4_HEIGHT),
|
||||
createPaper("A5", A5_WIDTH, A5_HEIGHT),
|
||||
createPaper("A6", A6_WIDTH, A6_HEIGHT),
|
||||
|
||||
createPaper("Letter", LETTER_WIDTH, LETTER_HEIGHT),
|
||||
createPaper("Legal", LEGAL_WIDTH, LEGAL_HEIGHT),
|
||||
createPaper("Tabloid", TABLOID_WIDTH, TABLOID_HEIGHT),
|
||||
createPaper("Ledger", LEDGER_WIDTH, LEDGER_HEIGHT),
|
||||
];
|
||||
|
||||
|
||||
export function PaperSizeSelector({ setSettings, settings, popup }: UseSettings & { popup: Popup }) {
|
||||
const custom = papers.find(e => settings.paperWidth === e.width && settings.paperHeight === e.height);
|
||||
return <Wrapper>
|
||||
{papers.map((e, i) => <Button
|
||||
$active={settings.paperWidth === e.width && settings.paperHeight === e.height}
|
||||
key={i}
|
||||
onClick={() => {
|
||||
setSettings({ paperWidth: e.width, paperHeight: e.height });
|
||||
}}
|
||||
>{e.name}
|
||||
</Button>)}
|
||||
<Button
|
||||
$active={!custom}
|
||||
onClick={async () => {
|
||||
const result = await popup.prompt(
|
||||
"Paper size",
|
||||
`Enter paper size like <WIDTH>x<HEIGHT> (${A4_WIDTH}x${A4_HEIGHT})`,
|
||||
`${settings.paperWidth}x${settings.paperHeight}`
|
||||
);
|
||||
if (result) {
|
||||
const [width, height] = result.split("x").map(e => Math.max(parseInt(e, 10), 1));
|
||||
if (isNaN(width) || isNaN(height)) {
|
||||
popup.alert("Paper size", "Invalid size");
|
||||
} else {
|
||||
setSettings({
|
||||
paperWidth: width,
|
||||
paperHeight: height
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}>Custom</Button>
|
||||
</Wrapper>;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import { useState, type JSX } from "react";
|
||||
import { type PopupResolver, Popup } from "../handler/popup";
|
||||
|
||||
const fadeIn = keyframes`
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
`;
|
||||
|
||||
const slideDown = keyframes`
|
||||
from { transform: translateY(-24px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
`;
|
||||
|
||||
const Overlay = styled.div`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
animation: ${fadeIn} 0.15s ease-in-out;
|
||||
`;
|
||||
|
||||
const Dialog = styled.div`
|
||||
background-color: var(--primary-color);
|
||||
border: 1px solid var(--secondary-color);
|
||||
min-width: 360px;
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: ${slideDown} 0.18s ease-out;
|
||||
`;
|
||||
|
||||
const TitleBar = styled.div`
|
||||
border-bottom: 1px solid var(--secondary-color);
|
||||
padding: 12px 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #888;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const Body = styled.div`
|
||||
padding: 24px 20px;
|
||||
color: #d4d4d4;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const CustomBody = styled(Body)`
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
width: calc(100% - 24px);
|
||||
background: #0d0d0d;
|
||||
border: 1px solid #444;
|
||||
border-top: none;
|
||||
color: #e0e0e0;
|
||||
font-size: 13px;
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
margin-top: 12px;
|
||||
|
||||
&::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: #666;
|
||||
}
|
||||
`;
|
||||
|
||||
const Footer = styled.div`
|
||||
display: flex;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
`;
|
||||
|
||||
const Btn = styled.button<{ $primary?: boolean }>`
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: ${({ $primary }) => ($primary ? "#343434" : "#000000")};
|
||||
color: ${({ $primary }) => ($primary ? "#ffffff" : "#777")};
|
||||
border-right: 1px solid #2a2a2a;
|
||||
transition:
|
||||
background 0.1s,
|
||||
color 0.1s;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${({ $primary }) => ($primary ? "#6e6e6e" : "#2a2a2a")};
|
||||
color: ${({ $primary }) => ($primary ? "#000" : "#aaa")};
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: ${({ $primary }) => ($primary ? "#ccc" : "#111")};
|
||||
}
|
||||
`;
|
||||
|
||||
function AlertPopup({ popup }: { popup: PopupResolver }) {
|
||||
return (
|
||||
<>
|
||||
<Body>{popup.message}</Body>
|
||||
<Footer>
|
||||
<Btn $primary onClick={() => popup.resolve(undefined)}>
|
||||
OK
|
||||
</Btn>
|
||||
</Footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmPopup({ popup }: { popup: PopupResolver }) {
|
||||
return (
|
||||
<>
|
||||
<Body>{popup.message}</Body>
|
||||
<Footer>
|
||||
<Btn onClick={() => popup.resolve(false)}>Cancel</Btn>
|
||||
<Btn $primary onClick={() => popup.resolve(true)}>
|
||||
Confirm
|
||||
</Btn>
|
||||
</Footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptPopup({ popup }: { popup: PopupResolver }) {
|
||||
const typed = popup as typeof popup & { _default?: string };
|
||||
const [value, setValue] = useState(typed._default ?? "");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Body>
|
||||
{popup.message}
|
||||
<Input
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder={typed._default ?? ""}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") popup.resolve(value);
|
||||
if (e.key === "Escape") popup.resolve(null);
|
||||
}}
|
||||
/>
|
||||
</Body>
|
||||
<Footer>
|
||||
<Btn onClick={() => popup.resolve(null)}>Cancel</Btn>
|
||||
<Btn $primary onClick={() => popup.resolve(value)}>
|
||||
Submit
|
||||
</Btn>
|
||||
</Footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomPopup({ popup }: { popup: PopupResolver }) {
|
||||
const typed = popup as typeof popup & { element: JSX.Element };
|
||||
return (
|
||||
<>
|
||||
<CustomBody>{typed.element}</CustomBody>
|
||||
<Footer>
|
||||
<Btn $primary onClick={() => popup.resolve(undefined)}>
|
||||
Close
|
||||
</Btn>
|
||||
</Footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PopupBody({ popup }: { popup: PopupResolver }) {
|
||||
if (Popup.isAlert(popup)) return <AlertPopup popup={popup} />;
|
||||
if (Popup.isConfirm(popup)) return <ConfirmPopup popup={popup} />;
|
||||
if (Popup.isPrompt(popup)) return <PromptPopup popup={popup} />;
|
||||
if (Popup.isCustom(popup)) return <CustomPopup popup={popup} />;
|
||||
return null;
|
||||
}
|
||||
|
||||
interface PopupRendererProps {
|
||||
popup: Popup;
|
||||
}
|
||||
|
||||
export function PopupRenderer({ popup }: PopupRendererProps) {
|
||||
const { pendingPopups } = popup.use();
|
||||
|
||||
const current = pendingPopups[0];
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Overlay>
|
||||
<Dialog>
|
||||
<TitleBar>{current.title}</TitleBar>
|
||||
<PopupBody popup={current} />
|
||||
</Dialog>
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ImageEditor } from "../handler/image-editor";
|
||||
import type { TracerOptions } from "../interface";
|
||||
import { NumericInputWithLabelTable } from "./numeric-labeld-input";
|
||||
|
||||
|
||||
export function SvgTracerOptions({ selected }: { selected: ImageEditor[] }) {
|
||||
const [options, setOptions] = useState(selected[0]?.tracerOptions ?? {});
|
||||
|
||||
useEffect(() => {
|
||||
setOptions(selected[0]?.tracerOptions ?? {});
|
||||
}, [selected.map(e => e.id).join("")]);
|
||||
|
||||
const setValue = (key: keyof TracerOptions, value: number) => {
|
||||
setOptions({ ...options, [key]: value });
|
||||
selected.forEach(e => {
|
||||
e.setTracerSvg({ ...options, [key]: value });
|
||||
});
|
||||
};
|
||||
|
||||
return <tbody>
|
||||
<NumericInputWithLabelTable
|
||||
name="Ltres"
|
||||
value={options.ltres}
|
||||
onChange={value => setValue("ltres", value)}
|
||||
/>
|
||||
<NumericInputWithLabelTable
|
||||
name="Qtres"
|
||||
value={options.qtres}
|
||||
onChange={value => setValue("qtres", value)}
|
||||
/>
|
||||
<NumericInputWithLabelTable
|
||||
name="Path omit"
|
||||
value={options.pathomit}
|
||||
onChange={value => setValue("pathomit", value)}
|
||||
/>
|
||||
<NumericInputWithLabelTable
|
||||
name="Round corners"
|
||||
value={options.roundcoords}
|
||||
onChange={value => setValue("roundcoords", value)}
|
||||
/>
|
||||
</tbody>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { styled } from "styled-components";
|
||||
import type { CanvasHandlerProps } from "../handler/interfaces";
|
||||
import { useTool } from "../use/use-tool";
|
||||
import { Icon2525 } from "../styles";
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--primary-color);
|
||||
border-top: 1px solid var(--secondary-color);
|
||||
`;
|
||||
|
||||
export function Toolbar({ handler }: CanvasHandlerProps) {
|
||||
const { currentTool, tools, setTool } = useTool(handler);
|
||||
return (
|
||||
<Wrapper>
|
||||
{tools.map((tool) => (
|
||||
<Icon2525
|
||||
$selected={currentTool === tool.key}
|
||||
key={tool.key}
|
||||
onClick={() => setTool(tool.key)}
|
||||
className={currentTool === tool.key ? "active" : ""}
|
||||
>
|
||||
{tool.icon()}
|
||||
</Icon2525>
|
||||
))}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export const A0_WIDTH = 841;
|
||||
export const A0_HEIGHT = 1189;
|
||||
|
||||
export const A1_WIDTH = 594;
|
||||
export const A1_HEIGHT = 841;
|
||||
|
||||
export const A2_WIDTH = 420;
|
||||
export const A2_HEIGHT = 594;
|
||||
|
||||
export const A3_WIDTH = 297;
|
||||
export const A3_HEIGHT = 420;
|
||||
|
||||
export const A4_WIDTH = 210;
|
||||
export const A4_HEIGHT = 297;
|
||||
|
||||
export const A5_WIDTH = 148;
|
||||
export const A5_HEIGHT = 210;
|
||||
|
||||
export const A6_WIDTH = 105;
|
||||
export const A6_HEIGHT = 148;
|
||||
|
||||
export const LETTER_WIDTH = 216;
|
||||
export const LETTER_HEIGHT = 279;
|
||||
|
||||
export const LEGAL_WIDTH = 216;
|
||||
export const LEGAL_HEIGHT = 356;
|
||||
|
||||
export const TABLOID_WIDTH = 279;
|
||||
export const TABLOID_HEIGHT = 432;
|
||||
|
||||
export const LEDGER_WIDTH = 432;
|
||||
export const LEDGER_HEIGHT = 279;
|
||||
|
||||
export const MM_TO_INCH = 25.4;
|
||||
|
||||
export const VERSION = "1.0.0";
|
||||
@@ -0,0 +1,43 @@
|
||||
import { MM_TO_INCH } from "./constants";
|
||||
import type { VectraceHandler } from "./handler/vectrace-handler";
|
||||
|
||||
export function drawCanvas(canvas: HTMLCanvasElement, handler: VectraceHandler) {
|
||||
const settings = handler.globalSettingsHandler.settings;
|
||||
const dpi = settings.DPI;
|
||||
const width = Math.round((settings.paperWidth / MM_TO_INCH) * dpi);
|
||||
const height = Math.round((settings.paperHeight / MM_TO_INCH) * dpi);
|
||||
if (canvas.width !== width) {
|
||||
canvas.width = width;
|
||||
}
|
||||
if (canvas.height !== height) {
|
||||
canvas.height = height;
|
||||
}
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.lineWidth = 5;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.strokeStyle = "#ccc";
|
||||
ctx.strokeRect(0, 0, width, height);
|
||||
|
||||
ctx.strokeStyle = "#000000";
|
||||
//ctx.fillStyle = "#00bbff24";
|
||||
ctx.fillStyle = "#00bbffea";
|
||||
|
||||
for (let i = handler.imagesRenders.length - 1; i >= 0; i--) {
|
||||
const image = handler.imagesRenders[i];
|
||||
if (image.visible) {
|
||||
// const base64 = btoa(new TextEncoder().encode(image.svg).reduce((acc, byte) => acc + String.fromCharCode(byte), ""));
|
||||
// const dataUrl = `data:image/svg+xml;base64,${base64}`;
|
||||
// drawSVGOnCanvasScaled(image.svg, canvas);
|
||||
ctx.drawImage(image.image, image.x, image.y, image.image.width * image.scale, image.image.height * image.scale);
|
||||
//ctx.drawImage(image.image, image.x, image.y, image.image.naturalWidth * image.scale, image.image.naturalHeight * image.scale);
|
||||
// if (handler.isSelected(image.id)) {
|
||||
// ctx.strokeRect(image.x, image.y, image.image.width * image.scale, image.image.height * image.scale);
|
||||
// ctx.fillRect(image.x, image.y, image.image.width * image.scale, image.image.height * image.scale);
|
||||
// }
|
||||
// ctx.drawImage(image.svgCanvas.image, image.x, image.y, image.image.width * image.scale, image.image.height * image.scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { throttle } from "lodash";
|
||||
import type { GlobalSettings } from "../interface";
|
||||
import { BasicEventEmitter } from "../utils/eventEmitter";
|
||||
import { A4_HEIGHT, A4_WIDTH } from "../constants";
|
||||
|
||||
function defaultGlobalSetting(): GlobalSettings {
|
||||
return {
|
||||
DPI: 96,
|
||||
scale: 1,
|
||||
paperHeight: A4_HEIGHT,
|
||||
paperWidth: A4_WIDTH,
|
||||
grid: "none",
|
||||
landscape: false,
|
||||
};
|
||||
}
|
||||
|
||||
export class GlobalSettingsHandler {
|
||||
private STORAGE_SETTINGS_KEY = "settings";
|
||||
public readonly emitter = new BasicEventEmitter<{
|
||||
"settings-update": [];
|
||||
}>();
|
||||
private _settings: GlobalSettings = defaultGlobalSetting();
|
||||
private store!: LocalForage;
|
||||
|
||||
async init(store: LocalForage) {
|
||||
this.store = store;
|
||||
this._settings = (await this.store.getItem<GlobalSettings>(this.STORAGE_SETTINGS_KEY)) || this._settings;
|
||||
}
|
||||
setSettings(settings: Partial<GlobalSettings>) {
|
||||
const entries = Object.entries(settings);
|
||||
let emitUpdate = false;
|
||||
for (const [key, value] of entries) {
|
||||
const diff = (this._settings as any)[key] !== value;
|
||||
(this._settings as any)[key] = value;
|
||||
if (diff) {
|
||||
emitUpdate = true;
|
||||
}
|
||||
}
|
||||
if (emitUpdate) {
|
||||
this.emitter.emit("settings-update");
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
async clear() {
|
||||
this._settings = defaultGlobalSetting();
|
||||
await this.store.setItem(this.STORAGE_SETTINGS_KEY, this._settings);
|
||||
}
|
||||
get settings() {
|
||||
return { ...this._settings };
|
||||
}
|
||||
private save = throttle(async () => {
|
||||
await this.store.setItem(this.STORAGE_SETTINGS_KEY, this._settings);
|
||||
}, 1000);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BasicEventEmitter } from "../utils/eventEmitter";
|
||||
import type { DefinedTool } from "./interfaces";
|
||||
import { TOOL_OBJECT_MOVE, TOOLS } from "./tools";
|
||||
|
||||
export class ToolHandler {
|
||||
private STORAGE_TOOL_KEY = "tool";
|
||||
public readonly emitter = new BasicEventEmitter<{
|
||||
select: [string];
|
||||
}>();
|
||||
public readonly tools = TOOLS;
|
||||
private currentTool: string = TOOL_OBJECT_MOVE.key;
|
||||
private store!: LocalForage;
|
||||
|
||||
async init(store: LocalForage) {
|
||||
this.store = store;
|
||||
|
||||
const tool = await this.store.getItem<string>(this.STORAGE_TOOL_KEY);
|
||||
if (tool && this.tools.map((e) => e.key).includes(tool)) {
|
||||
this.currentTool = tool;
|
||||
}
|
||||
}
|
||||
|
||||
setTool(toolKey: string) {
|
||||
if (this.currentTool !== toolKey) {
|
||||
this.currentTool = toolKey;
|
||||
this.emitter.emit("select", toolKey);
|
||||
this.store.setItem<string>(this.STORAGE_TOOL_KEY, this.currentTool);
|
||||
}
|
||||
}
|
||||
isToolSelected(tool: DefinedTool<any>) {
|
||||
return this.currentTool === tool.key;
|
||||
}
|
||||
async clear() {
|
||||
this.setTool(TOOL_OBJECT_MOVE.key);
|
||||
}
|
||||
|
||||
get tool() {
|
||||
return this.currentTool;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { CanvasImageEx, TracerOptions } from "../interface";
|
||||
|
||||
export class ImageEditor {
|
||||
public readonly id: string;
|
||||
private query: () => CanvasImageEx;
|
||||
onPropsChange: (data: Partial<CanvasImageEx>) => void;
|
||||
private isSelected: () => boolean;
|
||||
private select: () => void;
|
||||
constructor(
|
||||
id: string,
|
||||
query: () => CanvasImageEx,
|
||||
onPropsChange: (data: Partial<CanvasImageEx>) => void,
|
||||
isSelected: () => boolean,
|
||||
select: () => void,
|
||||
) {
|
||||
this.id = id;
|
||||
this.query = query;
|
||||
this.onPropsChange = onPropsChange;
|
||||
this.isSelected = isSelected;
|
||||
this.select = select;
|
||||
}
|
||||
move(x: number, y: number) {
|
||||
this.onPropsChange({ x, y });
|
||||
}
|
||||
get selected() {
|
||||
return this.isSelected();
|
||||
}
|
||||
toggleSelected() {
|
||||
return this.select();
|
||||
}
|
||||
get image() {
|
||||
return this.query().image;
|
||||
}
|
||||
get url() {
|
||||
return this.query().url;
|
||||
}
|
||||
get canvas() {
|
||||
return this.query().canvas;
|
||||
}
|
||||
get blob() {
|
||||
return this.query().blob;
|
||||
}
|
||||
get name() {
|
||||
return this.query().name;
|
||||
}
|
||||
get svg() {
|
||||
return this.query().svg.data;
|
||||
}
|
||||
setName(name: string) {
|
||||
this.onPropsChange({ name });
|
||||
}
|
||||
get tracerOptions() {
|
||||
return this.query().svg.options;
|
||||
}
|
||||
setTracerSvg(options: TracerOptions) {
|
||||
const svg = this.query().svg;
|
||||
this.onPropsChange({
|
||||
svg: {
|
||||
options,
|
||||
data: svg.data,
|
||||
scale: svg.scale,
|
||||
dirty: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
get x() {
|
||||
return this.query().x;
|
||||
}
|
||||
set x(x: number) {
|
||||
this.onPropsChange({ x });
|
||||
}
|
||||
get y() {
|
||||
return this.query().y;
|
||||
}
|
||||
set y(y: number) {
|
||||
this.onPropsChange({ y });
|
||||
}
|
||||
get scale() {
|
||||
return this.query().scale;
|
||||
}
|
||||
setScale(scale: number) {
|
||||
this.onPropsChange({ scale });
|
||||
}
|
||||
get visible() {
|
||||
return this.query().visible;
|
||||
}
|
||||
setVisible(visible: boolean) {
|
||||
this.onPropsChange({ visible });
|
||||
}
|
||||
get locked() {
|
||||
return this.query().locked;
|
||||
}
|
||||
setLock(locked: boolean) {
|
||||
this.onPropsChange({ locked });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { JSX } from "react";
|
||||
import type { VectraceHandler } from "./vectrace-handler";
|
||||
import type { ImageEditor } from "./image-editor";
|
||||
|
||||
export interface CanvasHandlerProps {
|
||||
handler: VectraceHandler;
|
||||
}
|
||||
export interface ImageEditorProps {
|
||||
image: ImageEditor;
|
||||
}
|
||||
|
||||
export interface CanvasHandleImageEditorProps extends CanvasHandlerProps, ImageEditorProps { }
|
||||
|
||||
export type DefinedTool<T = any, K extends string = string> = {
|
||||
key: K;
|
||||
name: string;
|
||||
icon: () => JSX.Element;
|
||||
defaultSettings: () => T;
|
||||
};
|
||||
|
||||
export type ReturnTypeTool<T extends DefinedTool<any>> = T extends DefinedTool<infer R> ? R : never;
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useState, type JSX } from "react";
|
||||
import { BasicEventEmitter } from "../utils/eventEmitter";
|
||||
import { removeItem } from "../utils/collection";
|
||||
|
||||
interface PopupType {
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PopupAlert extends PopupType {
|
||||
type: "alert";
|
||||
}
|
||||
|
||||
export interface PopupConfirm extends PopupType {
|
||||
type: "confirm";
|
||||
}
|
||||
|
||||
export interface PopupPrompt extends PopupType {
|
||||
type: "prompt";
|
||||
_default?: string;
|
||||
}
|
||||
|
||||
export interface PopupCustom extends PopupType {
|
||||
type: "custom";
|
||||
element: JSX.Element;
|
||||
}
|
||||
|
||||
export type PopupTypes = PopupAlert | PopupConfirm | PopupPrompt | PopupCustom;
|
||||
|
||||
export interface PopupResolver extends PopupType {
|
||||
resolve: (value: any) => void;
|
||||
}
|
||||
|
||||
function promiseSpy<A = any>() {
|
||||
let _resolve!: (value: A | PromiseLike<A>) => void;
|
||||
let _reject!: (reason?: any) => void;
|
||||
const promise = new Promise<A>((resolve, reject) => {
|
||||
_resolve = resolve;
|
||||
_reject = reject;
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
resolve: _resolve,
|
||||
reject: _reject,
|
||||
};
|
||||
}
|
||||
|
||||
export class Popup {
|
||||
public readonly emitter = new BasicEventEmitter<{
|
||||
update: [PopupResolver[]];
|
||||
}>();
|
||||
|
||||
private list: PopupResolver[] = [];
|
||||
|
||||
private push(ref: PopupResolver) {
|
||||
this.list.push(ref);
|
||||
this.emitter.emit("update", [...this.list]);
|
||||
}
|
||||
|
||||
async alert(title: string, message: string) {
|
||||
const spy = promiseSpy<any>();
|
||||
const ref: PopupResolver = {
|
||||
type: "alert",
|
||||
title,
|
||||
message,
|
||||
resolve: (value: any) => {
|
||||
removeItem(this.list, ref);
|
||||
this.emitter.emit("update", [...this.list]);
|
||||
spy.resolve(value);
|
||||
},
|
||||
};
|
||||
this.push(ref);
|
||||
return spy.promise;
|
||||
}
|
||||
|
||||
async confirm(title: string, message: string) {
|
||||
const spy = promiseSpy<any>();
|
||||
const ref: PopupResolver = {
|
||||
type: "confirm",
|
||||
title,
|
||||
message,
|
||||
resolve: (value: any) => {
|
||||
removeItem(this.list, ref);
|
||||
this.emitter.emit("update", [...this.list]);
|
||||
spy.resolve(value);
|
||||
},
|
||||
};
|
||||
this.push(ref);
|
||||
return spy.promise;
|
||||
}
|
||||
|
||||
async prompt(title: string, message: string, _default?: string): Promise<string | null> {
|
||||
const spy = promiseSpy<string | null>();
|
||||
const ref = {
|
||||
type: "prompt",
|
||||
title,
|
||||
message,
|
||||
_default,
|
||||
resolve: (value: any) => {
|
||||
removeItem(this.list, ref);
|
||||
this.emitter.emit("update", [...this.list]);
|
||||
spy.resolve(value);
|
||||
},
|
||||
} as PopupResolver;
|
||||
this.push(ref);
|
||||
return spy.promise;
|
||||
}
|
||||
|
||||
async custom(title: string, message: string, element: JSX.Element) {
|
||||
const spy = promiseSpy<string | null>();
|
||||
const ref = {
|
||||
type: "custom",
|
||||
title,
|
||||
message,
|
||||
element,
|
||||
resolve: (value: any) => {
|
||||
removeItem(this.list, ref);
|
||||
this.emitter.emit("update", [...this.list]);
|
||||
spy.resolve(value);
|
||||
},
|
||||
} as PopupResolver;
|
||||
this.push(ref);
|
||||
return spy.promise;
|
||||
}
|
||||
|
||||
static isAlert(data: PopupType): data is PopupAlert {
|
||||
return data.type === "alert";
|
||||
}
|
||||
static isConfirm(data: PopupType): data is PopupConfirm {
|
||||
return data.type === "confirm";
|
||||
}
|
||||
static isPrompt(data: PopupType): data is PopupPrompt {
|
||||
return data.type === "prompt";
|
||||
}
|
||||
static isCustom(data: PopupType): data is PopupCustom {
|
||||
return data.type === "custom";
|
||||
}
|
||||
|
||||
use() {
|
||||
const [pendingPopups, setPendingPopups] = useState<PopupResolver[]>([...this.list]);
|
||||
useEffect(() => {
|
||||
return this.emitter.on("update", (items) => {
|
||||
setPendingPopups(items);
|
||||
});
|
||||
}, []);
|
||||
return { pendingPopups };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { FaArrowsUpDownLeftRight, FaExpand } from "react-icons/fa6";
|
||||
import type { DefinedTool } from "./interfaces";
|
||||
import type { JSX } from "react";
|
||||
import { FaMousePointer } from "react-icons/fa";
|
||||
|
||||
export const TOOLS: DefinedTool<any>[] = [];
|
||||
|
||||
export function defineTool<T = any, K extends string = string>(
|
||||
key: K,
|
||||
name: string,
|
||||
icon: () => JSX.Element,
|
||||
defaultSettings: () => T = () => null,
|
||||
): DefinedTool<T, K> {
|
||||
const tool: DefinedTool<T, K> = {
|
||||
key,
|
||||
name,
|
||||
icon,
|
||||
defaultSettings,
|
||||
};
|
||||
TOOLS.push(tool);
|
||||
return tool;
|
||||
}
|
||||
|
||||
export const TOOL_SELECT = defineTool<null>("select", "Select", () => <FaMousePointer />);
|
||||
export const TOOL_CANVAS_MOVE = defineTool<null>("move", "move-canvas", () => <FaArrowsUpDownLeftRight />);
|
||||
export const TOOL_OBJECT_MOVE = defineTool<null>("move-object", "Move object", () => <FaExpand />);
|
||||
@@ -0,0 +1,511 @@
|
||||
import localforage from "localforage";
|
||||
import { BasicEventEmitter } from "../utils/eventEmitter";
|
||||
import type { CanvasImage, CanvasImageEx, ProjectConfig, TracerOptions } from "../interface";
|
||||
import { pushUnique, removeItem } from "../utils/collection";
|
||||
import { cloneDeep, isEqual, last, throttle } from "lodash";
|
||||
import { ImageEditor } from "./image-editor";
|
||||
import { ToolHandler } from "./handler-tools";
|
||||
import { GlobalSettingsHandler } from "./handler-global-settings";
|
||||
import { Popup } from "./popup";
|
||||
import { imageToLaserSVG } from "../svg-tracer";
|
||||
import { urlToImage, canvasToBlob, imageToCanvas } from "../utils/image";
|
||||
import { loadFileAsDataUrl, makeFilenameSafe } from "../utils/generic";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import JSZip from "jszip";
|
||||
import { downloadBlob } from "../utils/download";
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
declare const __AUTHOR__: {
|
||||
name: string;
|
||||
email: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export class VectraceHandler {
|
||||
public readonly VERSION = __APP_VERSION__;
|
||||
public readonly AUTHOR = __AUTHOR__;
|
||||
private readonly STORAGE_IMAGE_KEY = "images";
|
||||
private readonly STORAGE_NAME_KEY = "NAME";
|
||||
private _projectName = "";
|
||||
private readonly STROKE_WIDTH = 2;
|
||||
public readonly popup = new Popup();
|
||||
private store!: LocalForage;
|
||||
public readonly emitter = new BasicEventEmitter<{
|
||||
update: [string, string[]];
|
||||
add: [string];
|
||||
remove: [string];
|
||||
name: [string];
|
||||
select: [string[], string | undefined | "*", string | undefined | "*"];
|
||||
"array-length": [number];
|
||||
ready: [];
|
||||
}>();
|
||||
|
||||
private _images: CanvasImage[] = [];
|
||||
private _renderedImages: CanvasImageEx[] = [];
|
||||
private _selected: string[] = [];
|
||||
private _ready = false;
|
||||
private _toolHandler = new ToolHandler();
|
||||
private _globalSettingsHandler = new GlobalSettingsHandler();
|
||||
private POOL_RATE = 250;
|
||||
private queue: { id: string, action: () => Promise<void> | void }[] = [];
|
||||
private frame!: number;
|
||||
private destroyed = false;
|
||||
|
||||
private tick = async () => {
|
||||
if (this.destroyed) return;
|
||||
const process = this.queue.shift();
|
||||
if (process) {
|
||||
await Promise.resolve(process.action()).catch(console.log);
|
||||
}
|
||||
this.frame = window.setTimeout(this.tick, this.POOL_RATE);
|
||||
};
|
||||
|
||||
async init(name: string) {
|
||||
if (this.store) return;
|
||||
this._projectName = localStorage.getItem(this.STORAGE_NAME_KEY) || "";
|
||||
if (!this._projectName) {
|
||||
await this.promptSetName();
|
||||
}
|
||||
(window as any).c = this;
|
||||
this.store = localforage.createInstance({
|
||||
name,
|
||||
});
|
||||
const [images] = await Promise.all([
|
||||
this.store.getItem<CanvasImage[]>(this.STORAGE_IMAGE_KEY),
|
||||
this._toolHandler.init(this.store),
|
||||
this._globalSettingsHandler.init(this.store),
|
||||
]);
|
||||
|
||||
this._images = images || [];
|
||||
this._renderedImages = await Promise.all(this._images.map((e) => this.renderImage(e)));
|
||||
this._ready = true;
|
||||
this.tick();
|
||||
this.emitter.emit("ready");
|
||||
}
|
||||
destroy() {
|
||||
clearTimeout(this.frame);
|
||||
this.destroyed = true;
|
||||
this.save();
|
||||
}
|
||||
|
||||
async promptSetName() {
|
||||
const fn = (message: string) => this._ready ? this.popup.prompt("Project name", message) : Promise.resolve(window.prompt(message));
|
||||
this.setName(await fn("Enter your project name here") || "");
|
||||
|
||||
}
|
||||
async setName(name: string) {
|
||||
this._projectName = makeFilenameSafe(name);
|
||||
this.emitter.emit("name", this._projectName);
|
||||
localStorage.setItem(this.STORAGE_NAME_KEY, this._projectName);
|
||||
|
||||
}
|
||||
|
||||
async clear() {
|
||||
for (const image of [...this._images]) {
|
||||
this.deleteImage(image.id);
|
||||
}
|
||||
this._images = [];
|
||||
this._renderedImages = [];
|
||||
this._toolHandler.clear();
|
||||
await Promise.all([
|
||||
await this.store.setItem(this.STORAGE_IMAGE_KEY, this._images),
|
||||
await this._globalSettingsHandler.clear()
|
||||
]);
|
||||
this._selected = [];
|
||||
localStorage.setItem(this.STORAGE_NAME_KEY, "");
|
||||
this.emitter.emit("select", [], "*", "*");
|
||||
}
|
||||
|
||||
async exportProject() {
|
||||
const zip = new JSZip();
|
||||
const images = zip.folder("images")!;
|
||||
|
||||
for (const image of this._renderedImages) {
|
||||
images.file(image.id, image.buffer);
|
||||
}
|
||||
const data: ProjectConfig = {
|
||||
version: this.VERSION,
|
||||
name: this._projectName,
|
||||
settings: this._globalSettingsHandler.settings,
|
||||
tool: this._toolHandler.tool,
|
||||
images: this._images
|
||||
};
|
||||
zip.file("config.json", new Blob([JSON.stringify(data)], { type: "application/json" }));
|
||||
const blob = await zip.generateAsync({ type: "blob" });
|
||||
downloadBlob(blob, `${this.projectName}.ppd`);
|
||||
}
|
||||
|
||||
importProject() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".ppd,application/octet-stream";
|
||||
input.addEventListener("change", async () => {
|
||||
const file = [...(input.files || [])][0];
|
||||
if (file) {
|
||||
const zip = await JSZip.loadAsync(file);
|
||||
const config = JSON.parse(await zip.files["config.json"].async("string")) as ProjectConfig;
|
||||
const map = new Map<string, string>();
|
||||
|
||||
const entries = Object.keys(zip.files);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const name = entries[i];
|
||||
|
||||
if (!name.startsWith("images/"))
|
||||
continue;
|
||||
|
||||
const entry = zip.files[name];
|
||||
if (entry.dir)
|
||||
continue;
|
||||
|
||||
const buffer = await entry.async("string");
|
||||
|
||||
map.set(last(name.split("/"))!, buffer);
|
||||
}
|
||||
|
||||
const images: CanvasImage[] = [];
|
||||
for (const img of config.images) {
|
||||
if (map.has(img.id)) {
|
||||
images.push({
|
||||
id: img.id,
|
||||
buffer: map.get(img.id)!,
|
||||
locked: img.locked,
|
||||
name: img.name,
|
||||
scale: img.scale,
|
||||
svg: img.svg,
|
||||
visible: img.visible,
|
||||
x: img.x,
|
||||
y: img.y
|
||||
});
|
||||
}
|
||||
}
|
||||
if (await this.popup.confirm("Import", "Are you sure you want to import. Any unsaved changes will be discarded")) {
|
||||
await this.clear();
|
||||
|
||||
const renderedImages = await Promise.all(images.map((e) => this.renderImage(e)));
|
||||
|
||||
this._images = images;
|
||||
this._renderedImages = renderedImages;
|
||||
for (const add of images) {
|
||||
this.emitter.emit("add", add.id);
|
||||
//await new Promise<void>(e => requestAnimationFrame(() => e()));
|
||||
}
|
||||
this.emitter.emit("array-length", images.length);
|
||||
this._globalSettingsHandler.setSettings(config.settings);
|
||||
this._toolHandler.setTool(config.tool);
|
||||
this.setName(config.name);
|
||||
this.save();
|
||||
}
|
||||
} else {
|
||||
this.popup.alert("Error", "File not selected");
|
||||
}
|
||||
});
|
||||
input.click();
|
||||
}
|
||||
|
||||
get projectName() {
|
||||
return this._projectName;
|
||||
}
|
||||
|
||||
private async renderImage(image: CanvasImage): Promise<CanvasImageEx> {
|
||||
const imageBuffer = await urlToImage(image.buffer);
|
||||
const canvas = imageToCanvas(imageBuffer);
|
||||
const blob = await canvasToBlob(canvas);
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
return {
|
||||
id: image.id,
|
||||
name: image.name,
|
||||
buffer: image.buffer,
|
||||
x: image.x,
|
||||
y: image.y,
|
||||
scale: image.scale,
|
||||
locked: image.locked,
|
||||
visible: image.visible,
|
||||
blob,
|
||||
url,
|
||||
canvas,
|
||||
svg: image.svg,
|
||||
|
||||
image: imageBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
move(id: string, x: number, y: number) {
|
||||
const { image, renderedImage } = this.getImagesEx(id);
|
||||
if (image.locked) return;
|
||||
renderedImage.x = image.x = Math.round(image.x + x);
|
||||
renderedImage.y = image.y = Math.round(image.y + y);
|
||||
this.emitter.emit("update", image.id, ["x", "y"]);
|
||||
this.save();
|
||||
}
|
||||
|
||||
async importFile(file: File) {
|
||||
const buffer = await loadFileAsDataUrl(file);
|
||||
const imageElement = await urlToImage(buffer);
|
||||
const options: TracerOptions = {};
|
||||
const svg = imageToLaserSVG(imageElement, {
|
||||
...options,
|
||||
scale: 1,
|
||||
strokewidth: this.STROKE_WIDTH
|
||||
});
|
||||
const f = file.name.split(".");
|
||||
f.pop();
|
||||
const fileName = f.join(".");
|
||||
|
||||
const image: CanvasImage = {
|
||||
id: uuidv4(),
|
||||
buffer,
|
||||
x: 0,
|
||||
y: 0,
|
||||
locked: false,
|
||||
visible: true,
|
||||
name: fileName,
|
||||
svg: {
|
||||
dirty: false,
|
||||
data: svg,
|
||||
scale: 1,
|
||||
options
|
||||
},
|
||||
scale: 1,
|
||||
};
|
||||
const rendered = await this.renderImage(image);
|
||||
this._images.push(image);
|
||||
this._renderedImages.push(rendered);
|
||||
|
||||
this.emitter.emit("array-length", this._images.length);
|
||||
this.emitter.emit("add", image.id);
|
||||
this.save();
|
||||
}
|
||||
selectNext(x: number, y: number, reverse: boolean, add: boolean) {
|
||||
const potentials = this._renderedImages.filter((e) => {
|
||||
return e.visible && !e.locked &&
|
||||
x >= e.x * this.globalSettingsHandler.settings.scale &&
|
||||
x <= e.x * this.globalSettingsHandler.settings.scale + e.image.width * e.scale * this.globalSettingsHandler.settings.scale &&
|
||||
y >= e.y * this.globalSettingsHandler.settings.scale &&
|
||||
y <= e.y * this.globalSettingsHandler.settings.scale + e.image.height * e.scale * this.globalSettingsHandler.settings.scale;
|
||||
});
|
||||
if (potentials.length === 0) {
|
||||
const copy = [...this._selected];
|
||||
this._selected.length = 0;
|
||||
if (copy.length) {
|
||||
this.emitter.emit("select", this._selected, "*", "*");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let lastSelectedIndex = -1;
|
||||
for (let i = 0; i < potentials.length; i++) {
|
||||
if (this._selected.includes(potentials[i].id)) {
|
||||
lastSelectedIndex = i;
|
||||
}
|
||||
}
|
||||
if (add) {
|
||||
potentials.forEach((p) => {
|
||||
if (!this._selected.includes(p.id)) {
|
||||
this._selected.push(p.id);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let nextIndex: number;
|
||||
if (lastSelectedIndex === -1) {
|
||||
nextIndex = reverse ? potentials.length - 1 : 0;
|
||||
} else {
|
||||
if (reverse) {
|
||||
nextIndex = (lastSelectedIndex - 1 + potentials.length) % potentials.length;
|
||||
} else {
|
||||
nextIndex = (lastSelectedIndex + 1) % potentials.length;
|
||||
}
|
||||
}
|
||||
|
||||
this._selected = [potentials[nextIndex].id];
|
||||
}
|
||||
|
||||
this.emitter.emit("select", this._selected, "*", "*");
|
||||
}
|
||||
|
||||
get imagesData() {
|
||||
return [...this._images];
|
||||
}
|
||||
get imagesRenders() {
|
||||
return [...this._renderedImages];
|
||||
}
|
||||
private getImageById(id: string) {
|
||||
return this._renderedImages.find((e) => e.id === id) || null;
|
||||
}
|
||||
private getImageByIdEx(id: string) {
|
||||
const image = this.getImageById(id);
|
||||
if (image) {
|
||||
return image;
|
||||
} else {
|
||||
throw new Error("Image does not exist!");
|
||||
}
|
||||
}
|
||||
private getImages(id: string) {
|
||||
const image = this._images.find((e) => e.id == id);
|
||||
const renderedImage = this._renderedImages.find((e) => e.id == id);
|
||||
if (image && renderedImage) {
|
||||
return { image, renderedImage };
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
private getImagesEx(id: string) {
|
||||
const obj = this.getImages(id);
|
||||
if (obj) {
|
||||
return obj;
|
||||
} else {
|
||||
throw new Error("Image does not exist!");
|
||||
}
|
||||
}
|
||||
enqueueForRedrawSvg(id: string) {
|
||||
this.queue = this.queue.filter(e => e.id !== id);
|
||||
|
||||
this.queue.push({
|
||||
id,
|
||||
action: async () => {
|
||||
this.redrawSvg(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
redrawSvg(id: string) {
|
||||
const images = this.getImages(id);
|
||||
if (!images) return;
|
||||
|
||||
const rendered = images.renderedImage;
|
||||
const base = images.image;
|
||||
|
||||
const scale = rendered.scale;
|
||||
const options: TracerOptions = {
|
||||
...rendered.svg.options,
|
||||
scale,
|
||||
strokewidth: this.STROKE_WIDTH
|
||||
};
|
||||
|
||||
const prevOptions = cloneDeep(options);
|
||||
|
||||
const svg = imageToLaserSVG(rendered.image, options);
|
||||
|
||||
const dirty = !isEqual(prevOptions, options);
|
||||
rendered.svg.dirty = dirty;
|
||||
base.svg.dirty = dirty;
|
||||
|
||||
base.svg.scale = scale;
|
||||
rendered.svg.scale = scale;
|
||||
|
||||
if (!dirty) {
|
||||
rendered.svg.options = options;
|
||||
base.svg.options = options;
|
||||
}
|
||||
|
||||
rendered.svg.data = svg;
|
||||
base.svg.data = svg;
|
||||
|
||||
this.emitter.emit(
|
||||
"update",
|
||||
id,
|
||||
["svg", "svgCanvas"],
|
||||
);
|
||||
this.save();
|
||||
}
|
||||
isSelected(id: string) {
|
||||
return this._selected.indexOf(id) !== -1;
|
||||
}
|
||||
setSelect(id: string, value: boolean) {
|
||||
if (this.isSelected(id)) {
|
||||
if (!value) {
|
||||
removeItem(this._selected, id);
|
||||
this.emitter.emit("select", this._selected, undefined, id);
|
||||
}
|
||||
} else {
|
||||
if (value) {
|
||||
if (this.getImageByIdEx(id).visible) {
|
||||
pushUnique(this._selected, id);
|
||||
this.emitter.emit("select", this._selected, id, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
get selected() {
|
||||
return this._selected;
|
||||
}
|
||||
deleteImage(id: string) {
|
||||
const images = this.getImages(id);
|
||||
if (images) {
|
||||
removeItem(this._images, images.image);
|
||||
removeItem(this._renderedImages, images.renderedImage);
|
||||
URL.revokeObjectURL(images.renderedImage.url);
|
||||
this.emitter.emit("remove", id);
|
||||
this.emitter.emit("array-length", this._images.length);
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
private save = throttle(async () => {
|
||||
await this.store.setItem(this.STORAGE_IMAGE_KEY, this._images);
|
||||
}, 1000);
|
||||
|
||||
createImagesWithEmit() {
|
||||
return this._images.map((e) => this.createImageWithEmit(e.id));
|
||||
}
|
||||
|
||||
createImageWithEmit(id: string) {
|
||||
if (this.getImageById(id)) {
|
||||
return new ImageEditor(
|
||||
id,
|
||||
() => this.getImageByIdEx(id),
|
||||
async (data) => {
|
||||
const { image, renderedImage } = this.getImagesEx(id);
|
||||
|
||||
if (image && renderedImage) {
|
||||
let emitUpdate = false;
|
||||
const entries = Object.entries(data);
|
||||
for (const [key, value] of entries) {
|
||||
|
||||
if (image.locked && key !== "locked") {
|
||||
continue;
|
||||
}
|
||||
let v = value;
|
||||
if (key === "x" || key === "y") {
|
||||
v = Math.round(v as number);
|
||||
}
|
||||
(image as any)[key] = value as any;
|
||||
const diff = (renderedImage as any)[key] !== v;
|
||||
(renderedImage as any)[key] = v as any;
|
||||
if ((key === "svg") && diff) {
|
||||
this.enqueueForRedrawSvg(id);
|
||||
}
|
||||
if (diff) {
|
||||
emitUpdate = true;
|
||||
}
|
||||
}
|
||||
if (emitUpdate) {
|
||||
this.emitter.emit(
|
||||
"update",
|
||||
id,
|
||||
entries.map((e) => e[0]),
|
||||
);
|
||||
}
|
||||
this.save();
|
||||
} else {
|
||||
throw new Error("Image does not exist!");
|
||||
}
|
||||
},
|
||||
() => this.isSelected(id),
|
||||
() => {
|
||||
this.setSelect(id, !this.isSelected(id));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
throw new Error(`Image ${id} does not exist!`);
|
||||
}
|
||||
}
|
||||
|
||||
get ready() {
|
||||
return this._ready;
|
||||
}
|
||||
get toolHandler() {
|
||||
return this._toolHandler;
|
||||
}
|
||||
get globalSettingsHandler() {
|
||||
return this._globalSettingsHandler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
|
||||
:root {
|
||||
--primary-color: #474747;
|
||||
--secondary-color: #252525;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'tiny5';
|
||||
src: url('./assets/fonts/Tiny5-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: tiny5;
|
||||
}
|
||||
|
||||
html,body,#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ImageTracerOptions } from "imagetracerjs";
|
||||
|
||||
export interface ObjectID {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface TracerOptions extends ImageTracerOptions {
|
||||
transparencyThreshold?: number;
|
||||
}
|
||||
|
||||
export interface SvgData {
|
||||
svg: string;
|
||||
normalizedSvg: string;
|
||||
}
|
||||
|
||||
export interface ProjectConfig {
|
||||
version: string;
|
||||
name: string;
|
||||
settings: GlobalSettings;
|
||||
tool: string;
|
||||
images: CanvasImage[];
|
||||
}
|
||||
|
||||
export interface GridCell {
|
||||
col: number;
|
||||
row: number;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface CanvasImage extends ObjectID {
|
||||
name: string;
|
||||
buffer: string;
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
svg: {
|
||||
options: TracerOptions;
|
||||
dirty: boolean;
|
||||
data: string;
|
||||
scale: number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CanvasImageEx extends CanvasImage {
|
||||
image: HTMLImageElement;
|
||||
blob: Blob;
|
||||
canvas: HTMLCanvasElement;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const GRID = [
|
||||
"none",
|
||||
"2x2",
|
||||
"3x3"
|
||||
] as const;
|
||||
|
||||
export type GridType = typeof GRID[number];
|
||||
export interface GlobalSettings {
|
||||
DPI: number;
|
||||
scale: number;
|
||||
paperWidth: number;
|
||||
paperHeight: number;
|
||||
landscape: boolean;
|
||||
grid: GridType;
|
||||
}
|
||||
|
||||
export interface SVGCanvas {
|
||||
svg: string;
|
||||
image: HTMLImageElement,
|
||||
url: string;
|
||||
blob: Blob;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<App />,
|
||||
);
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import styled from "styled-components";
|
||||
import type { CanvasHandlerProps } from "./handler/interfaces";
|
||||
import { useImages } from "./use/use-images";
|
||||
import { NumericInputWithLabelTable } from "./components/numeric-labeld-input";
|
||||
import { SvgTracerOptions } from "./components/svg-tracer-options";
|
||||
import { PaperSizeSelector } from "./components/paper-size-selector";
|
||||
import { useSettings } from "./use/use-settings";
|
||||
import { PaperOrientation } from "./components/paper-orentation";
|
||||
import { PaperGuideSelector } from "./components/paper-guide-selector";
|
||||
import { ImportExportButtons } from "./components/download-buttons";
|
||||
|
||||
|
||||
const Bar = styled.div`
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
height: 110px;
|
||||
background-color: var(--primary-color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const ImageBox = styled.table`
|
||||
margin: 2px;
|
||||
padding-right: 4px;
|
||||
`;
|
||||
|
||||
const SvgOption = styled.table`
|
||||
margin: 2px;
|
||||
padding-right: 4px;
|
||||
`;
|
||||
const Line = styled.div`
|
||||
margin: 4px;
|
||||
height: calc(100% - 8px);
|
||||
border-right: 1px solid white;
|
||||
`;
|
||||
|
||||
|
||||
|
||||
type NumericKeys<T> = {
|
||||
[K in keyof T]: T[K] extends number ? K : never
|
||||
}[keyof T]
|
||||
|
||||
function getNumericValue<T>(selected: T[], key: NumericKeys<T>): number {
|
||||
const disabled = selected.length === 0;
|
||||
const isMixed = selected.length > 1;
|
||||
|
||||
return disabled
|
||||
? 0
|
||||
: isMixed
|
||||
? selected.reduce((acc, e) => acc + (e[key] as number), 0) / selected.length
|
||||
: (selected[0][key] as number);
|
||||
}
|
||||
|
||||
export function NavBar({ handler }: CanvasHandlerProps) {
|
||||
const { selected } = useImages(handler);
|
||||
const { settings, setSettings } = useSettings(handler);
|
||||
|
||||
const setValue = (key: string, value: any) => {
|
||||
selected.forEach((e) => {
|
||||
e.onPropsChange({
|
||||
[key]: value
|
||||
});
|
||||
});
|
||||
};
|
||||
const style: React.CSSProperties = {
|
||||
opacity: selected.length ? 1 : 0.2,
|
||||
pointerEvents: selected.length ? "all" : "none"
|
||||
};
|
||||
|
||||
return (
|
||||
<Bar>
|
||||
<PaperSizeSelector settings={settings} setSettings={setSettings} popup={handler.popup} />
|
||||
<PaperOrientation settings={settings} setSettings={setSettings} />
|
||||
<PaperGuideSelector settings={settings} setSettings={setSettings} />
|
||||
<ImageBox style={style}>
|
||||
<tbody>
|
||||
<NumericInputWithLabelTable
|
||||
name="Size"
|
||||
value={getNumericValue(selected, "scale")}
|
||||
onChange={value => setValue("scale", value)}
|
||||
/>
|
||||
<NumericInputWithLabelTable
|
||||
name="X"
|
||||
value={getNumericValue(selected, "x")}
|
||||
onChange={value => setValue("x", value)}
|
||||
/>
|
||||
<NumericInputWithLabelTable
|
||||
name="Y"
|
||||
value={getNumericValue(selected, "y")}
|
||||
onChange={value => setValue("y", value)}
|
||||
/>
|
||||
</tbody>
|
||||
</ImageBox>
|
||||
<SvgOption style={style}>
|
||||
<SvgTracerOptions selected={selected} />
|
||||
</SvgOption>
|
||||
<Line />
|
||||
<ImportExportButtons handler={handler} selected={selected} />
|
||||
{/* <RibbonButton icon={() => <FaTrash />} name="Delete" onClick={async () => {
|
||||
if (await handler.popup.confirm("Delete project", "Are you sure you want to delete this project?")) {
|
||||
handler.clear();
|
||||
handler.promptSetName();
|
||||
}
|
||||
}} />
|
||||
<RibbonButton icon={() => <FaDownload />} name="Export" onClick={async () => {
|
||||
handler.exportProject();
|
||||
}} />
|
||||
|
||||
<RibbonButton icon={() => <FaFileImport />} name="Import" onClick={async () => {
|
||||
handler.importProject();
|
||||
}} /> */}
|
||||
|
||||
|
||||
</Bar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import styled from "styled-components";
|
||||
import type { CanvasHandlerProps } from "./handler/interfaces";
|
||||
import { LayerPanel } from "./components/layer-panel";
|
||||
import A4Canvas from "./components/a4-canvas";
|
||||
import { Toolbar } from "./components/toolbar";
|
||||
import { InfoBar } from "./components/info-bar";
|
||||
|
||||
|
||||
const Content = styled.div`
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const Left = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: auto;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const LeftInner = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
export function RenderPage({ handler }: CanvasHandlerProps) {
|
||||
return (
|
||||
<Content>
|
||||
<Left>
|
||||
<Toolbar handler={handler} />
|
||||
<LeftInner>
|
||||
<A4Canvas handler={handler} />
|
||||
<InfoBar handler={handler} />
|
||||
</LeftInner>
|
||||
</Left>
|
||||
<LayerPanel handler={handler} />
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
export const Icon2525 = styled.button<{ $selected?: boolean }>`
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: white;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
${(props) => props.$selected && "background: rgba(255, 255, 255, 0.25);"}
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
`;
|
||||
|
||||
export const Button = styled.button<{ $active?: boolean }>`
|
||||
margin: 2px;
|
||||
padding: 2px;
|
||||
border: 1px solid white;
|
||||
border-radius: 0;
|
||||
|
||||
cursor: pointer;
|
||||
background: ${({ $active }) => $active ? "white" : "var(--primary-color)"};
|
||||
color: ${({ $active }) => $active ? "var(--primary-color)" : "white"};;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
color: ${({ $active }) => $active ? "rgba(0, 0, 0, 0.25)" : "rgba(255, 255, 255, 0.50)"};
|
||||
background: ${({ $active }) => $active ? "rgba(255, 255, 255, 0.85)" : "rgba(255, 255, 255, 0.10)"};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,65 @@
|
||||
import ImageTracer from "imagetracerjs";
|
||||
import type { TracerOptions } from "./interface";
|
||||
import { last } from "lodash";
|
||||
import { VERSION } from "./constants";
|
||||
|
||||
|
||||
export function imageToBlackWhiteImageData(img: HTMLImageElement, transparencyThreshold: number = 50): ImageData {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const a = data[i + 3];
|
||||
|
||||
const value = a > transparencyThreshold ? 255 : 0;
|
||||
data[i] = 0;
|
||||
data[i + 1] = 0;
|
||||
data[i + 2] = 0;
|
||||
data[i + 3] = value;
|
||||
}
|
||||
|
||||
return imageData;
|
||||
}
|
||||
|
||||
export function imageToLaserSVG(img: HTMLImageElement, options?: TracerOptions): string {
|
||||
const bwData = imageToBlackWhiteImageData(img, options?.transparencyThreshold);
|
||||
|
||||
const rawSVG = ImageTracer.imagedataToSVG(bwData, {
|
||||
scale: 1,
|
||||
...options,
|
||||
viewbox: true,
|
||||
desc: false,
|
||||
layering: 0
|
||||
});
|
||||
const parser = new DOMParser();
|
||||
|
||||
const doc = parser.parseFromString(rawSVG, "image/svg+xml");
|
||||
const svg = doc.querySelectorAll("svg")[0];
|
||||
const [_, __, width, height] = svg.getAttribute("viewBox")!.split(" ");
|
||||
svg.setAttribute("width", width);
|
||||
svg.setAttribute("height", height);
|
||||
svg.setAttribute("desc", `Created with pathshop ${VERSION}v`);
|
||||
|
||||
const paths = [...doc.querySelectorAll("path")];
|
||||
|
||||
// getting global path. Removing it as it creates noise
|
||||
const longest = paths.sort((a, b) => (a.getAttribute("d") || "").length < (b.getAttribute("d") || "").length ? 1 : -1)[0];
|
||||
console.log(paths, longest);
|
||||
for (const path of paths) {
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("opacity", "1");
|
||||
path.setAttribute("stroke", "#000000");
|
||||
if (longest === path) {
|
||||
path.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return new XMLSerializer().serializeToString(doc);
|
||||
}
|
||||
Vendored
+154
@@ -0,0 +1,154 @@
|
||||
declare module "imagetracerjs" {
|
||||
export interface RGBAColor {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
export type Palette = RGBAColor[];
|
||||
|
||||
|
||||
export interface ImageDataLike {
|
||||
width: number;
|
||||
height: number;
|
||||
data: Uint8ClampedArray | number[];
|
||||
}
|
||||
|
||||
|
||||
export interface TracedSegment {
|
||||
type: "L" | "Q";
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2?: number;
|
||||
y2?: number;
|
||||
}
|
||||
|
||||
export interface TracedPath {
|
||||
isholepath: boolean;
|
||||
holechildren: number[];
|
||||
segments: TracedSegment[];
|
||||
boundingbox: [number, number, number, number];
|
||||
prestyle: string;
|
||||
}
|
||||
|
||||
export interface TracedLayer {
|
||||
pathnum: number;
|
||||
paths: TracedPath[];
|
||||
}
|
||||
|
||||
|
||||
export interface TraceData {
|
||||
layers: TracedLayer[];
|
||||
palette: Palette;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ImageTracerOptions {
|
||||
|
||||
colorsampling?: 0 | 1 | 2;
|
||||
|
||||
|
||||
layering?: 0 | 1;
|
||||
viewbox?: boolean;
|
||||
desc?: boolean;
|
||||
corsenabled?: boolean;
|
||||
|
||||
rightangleenhance?: boolean;
|
||||
|
||||
numberofcolors?: number;
|
||||
mincolorratio?: number;
|
||||
colorquantcycles?: number;
|
||||
ltres?: number;
|
||||
qtres?: number;
|
||||
pathomit?: number;
|
||||
scale?: number;
|
||||
roundcoords?: number;
|
||||
lcpr?: number;
|
||||
qcpr?: number;
|
||||
blurradius?: number;
|
||||
blurdelta?: number;
|
||||
strokewidth?: number;
|
||||
|
||||
|
||||
pal?: Palette;
|
||||
layercontainerid?: string;
|
||||
}
|
||||
|
||||
|
||||
export type OptionPreset =
|
||||
| "default"
|
||||
| "posterized1"
|
||||
| "posterized2"
|
||||
| "posterized3"
|
||||
| "curvy"
|
||||
| "sharp"
|
||||
| "detailed"
|
||||
| "smoothed"
|
||||
| "grayscale"
|
||||
| "fixedpalette"
|
||||
| "randomsampling1"
|
||||
| "randomsampling2"
|
||||
| "artistic1"
|
||||
| "artistic2"
|
||||
| "artistic3"
|
||||
| "artistic4";
|
||||
|
||||
export type OptionsOrPreset = ImageTracerOptions | OptionPreset;
|
||||
|
||||
|
||||
export function imageToSVG(
|
||||
imageUrl: string,
|
||||
callback: (svgString: string) => void,
|
||||
options?: OptionsOrPreset
|
||||
): void;
|
||||
|
||||
|
||||
export function imagedataToSVG(
|
||||
imageData: ImageDataLike,
|
||||
options?: OptionsOrPreset
|
||||
): string;
|
||||
|
||||
|
||||
export function imageToTracedata(
|
||||
imageUrl: string,
|
||||
callback: (traceData: TraceData) => void,
|
||||
options?: OptionsOrPreset
|
||||
): void;
|
||||
|
||||
export function imagedataToTracedata(
|
||||
imageData: ImageDataLike,
|
||||
options?: OptionsOrPreset
|
||||
): TraceData;
|
||||
|
||||
|
||||
export function appendSVGString(
|
||||
svgString: string,
|
||||
parentId: string
|
||||
): void;
|
||||
|
||||
|
||||
export function loadImage(
|
||||
url: string,
|
||||
callback: (canvas: HTMLCanvasElement) => void
|
||||
): void;
|
||||
|
||||
|
||||
export function getImgdata(
|
||||
canvas: HTMLCanvasElement
|
||||
): ImageData;
|
||||
|
||||
|
||||
declare const ImageTracer: {
|
||||
imageToSVG: typeof imageToSVG;
|
||||
imagedataToSVG: typeof imagedataToSVG;
|
||||
imageToTracedata: typeof imageToTracedata;
|
||||
imagedataToTracedata: typeof imagedataToTracedata;
|
||||
appendSVGString: typeof appendSVGString;
|
||||
loadImage: typeof loadImage;
|
||||
getImgdata: typeof getImgdata;
|
||||
};
|
||||
|
||||
export default ImageTracer;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { compact } from "lodash";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import type { VectraceHandler } from "../handler/vectrace-handler";
|
||||
import { removeItem } from "../utils/collection";
|
||||
|
||||
export function useImages(handler: VectraceHandler) {
|
||||
const [images, setImages] = useState(() => handler.createImagesWithEmit());
|
||||
|
||||
const selected = useMemo(
|
||||
() => images.filter((e) => handler.isSelected(e.id)),
|
||||
[images]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onAdd = (id: string) => {
|
||||
setImages((images) => [...images, handler.createImageWithEmit(id)]);
|
||||
};
|
||||
|
||||
const onRemove = (id: string) => {
|
||||
setImages((images) => {
|
||||
const clone = [...images];
|
||||
const image = clone.find((e) => e.id === id);
|
||||
if (image) removeItem(clone, image);
|
||||
return clone;
|
||||
});
|
||||
};
|
||||
|
||||
const onUpdate = (id: string) => {
|
||||
setImages((images) => {
|
||||
const clone = [...images];
|
||||
const index = clone.findIndex((e) => e.id === id);
|
||||
if (index !== -1) {
|
||||
clone[index] = handler.createImageWithEmit(id);
|
||||
}
|
||||
return clone;
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = (_ids: string[], added: string | undefined, remove: string | undefined) => {
|
||||
setImages((images) => {
|
||||
const clone = [...images];
|
||||
const indices = compact([added, remove]).map((a) =>
|
||||
clone.findIndex((e) => e.id === a)
|
||||
);
|
||||
for (const index of indices) {
|
||||
if (index !== -1) {
|
||||
clone[index] = handler.createImageWithEmit(clone[index].id);
|
||||
}
|
||||
}
|
||||
return clone;
|
||||
});
|
||||
};
|
||||
|
||||
handler.emitter.on("add", onAdd);
|
||||
handler.emitter.on("update", onUpdate);
|
||||
handler.emitter.on("remove", onRemove);
|
||||
handler.emitter.on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
handler.emitter.off("add", onAdd);
|
||||
handler.emitter.off("update", onUpdate);
|
||||
handler.emitter.off("remove", onRemove);
|
||||
handler.emitter.off("select", onSelect);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { images, selected };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { VectraceHandler } from "../handler/vectrace-handler";
|
||||
import type { GlobalSettings } from "../interface";
|
||||
|
||||
export type UseSettings = ReturnType<typeof useSettings>;
|
||||
|
||||
export function useSettings(handler: VectraceHandler) {
|
||||
const [settings, setSettings] = useState(handler.globalSettingsHandler.settings);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
setSettings(handler.globalSettingsHandler.settings);
|
||||
};
|
||||
handler.globalSettingsHandler.emitter.on("settings-update", update);
|
||||
return () => {
|
||||
handler.globalSettingsHandler.emitter.off("settings-update", update);
|
||||
};
|
||||
}, []);
|
||||
return {
|
||||
settings,
|
||||
setSettings: (settings: Partial<GlobalSettings>) => handler.globalSettingsHandler.setSettings(settings),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { VectraceHandler } from "../handler/vectrace-handler";
|
||||
|
||||
export function useTool(handler: VectraceHandler) {
|
||||
const [currentTool, setCurrentTool] = useState(handler.toolHandler.tool);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
setCurrentTool(handler.toolHandler.tool);
|
||||
};
|
||||
handler.toolHandler.emitter.on("select", update);
|
||||
return () => {
|
||||
handler.toolHandler.emitter.off("select", update);
|
||||
};
|
||||
}, []);
|
||||
return {
|
||||
currentTool,
|
||||
tools: handler.toolHandler.tools,
|
||||
setTool: (toolKey: string) => handler.toolHandler.setTool(toolKey),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function removeItem<T>(items: T[], item: T) {
|
||||
const index = items.indexOf(item);
|
||||
if (index !== -1) {
|
||||
items.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function pushUnique<T>(items: T[], item: T) {
|
||||
const index = items.indexOf(item);
|
||||
if (index === -1) {
|
||||
items.push(item);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import jsPDF from "jspdf";
|
||||
import { MM_TO_INCH } from "../constants";
|
||||
import type { VectraceHandler } from "../handler/vectrace-handler";
|
||||
import type { CanvasImageEx, GridCell } from "../interface";
|
||||
import JSZip from "jszip";
|
||||
import { parse, type INode } from "svgson";
|
||||
|
||||
export function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function prepareSVG(
|
||||
handler: VectraceHandler,
|
||||
imageRender: CanvasImageEx
|
||||
): Promise<Blob> {
|
||||
if (imageRender.svg.scale !== imageRender.scale || imageRender.svg.dirty) {
|
||||
handler.redrawSvg(imageRender.id);
|
||||
}
|
||||
return new Blob([imageRender.svg.data], { type: "image/svg+xml" });
|
||||
}
|
||||
|
||||
|
||||
|
||||
function getGridCells(
|
||||
paperWidth: number,
|
||||
paperHeight: number,
|
||||
grid: string
|
||||
): GridCell[] {
|
||||
const cols = grid === "3x3" ? 3 : grid === "2x2" ? 2 : 1;
|
||||
const rows = grid === "3x3" ? 3 : grid === "2x2" ? 2 : 1;
|
||||
const cellW = paperWidth / cols;
|
||||
const cellH = paperHeight / rows;
|
||||
const cells: GridCell[] = [];
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
cells.push({
|
||||
col,
|
||||
row,
|
||||
x: col * cellW,
|
||||
y: row * cellH,
|
||||
w: cellW,
|
||||
h: cellH,
|
||||
});
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
|
||||
function createCellPdf(
|
||||
handler: VectraceHandler,
|
||||
cell: GridCell
|
||||
): jsPDF {
|
||||
|
||||
|
||||
const doc = new jsPDF({
|
||||
orientation: cell.w > cell.h ? "landscape" : "portrait",
|
||||
unit: "mm",
|
||||
format: [cell.w, cell.h],
|
||||
});
|
||||
|
||||
for (const imageObject of handler.imagesRenders) {
|
||||
if (!imageObject.visible) continue;
|
||||
|
||||
const imgX = imageObject.x - cell.x;
|
||||
const imgY = imageObject.y - cell.y;
|
||||
const imgW = imageObject.image.naturalWidth * imageObject.scale;
|
||||
const imgH = imageObject.image.naturalHeight * imageObject.scale;
|
||||
|
||||
const overlapX = imgX + imgW > 0 && imgX < cell.w;
|
||||
const overlapY = imgY + imgH > 0 && imgY < cell.h;
|
||||
if (!overlapX || !overlapY) continue;
|
||||
|
||||
doc.addImage(
|
||||
imageObject.image,
|
||||
"PNG",
|
||||
imgX,
|
||||
imgY,
|
||||
imgW,
|
||||
imgH
|
||||
);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
const gridConfigs: Record<string, number[]> = {
|
||||
"2x2": [1 / 2],
|
||||
"3x3": [1 / 3, 2 / 3],
|
||||
};
|
||||
export function createPdf(handler: VectraceHandler, withGuides: boolean): jsPDF {
|
||||
const { DPI, paperHeight, paperWidth } = handler.globalSettingsHandler.settings;
|
||||
const width = (paperWidth / MM_TO_INCH) * DPI;
|
||||
const height = (paperHeight / MM_TO_INCH) * DPI;
|
||||
|
||||
const doc = new jsPDF({
|
||||
orientation: paperWidth > paperHeight ? "landscape" : "portrait",
|
||||
unit: "mm",
|
||||
format: [width, height],
|
||||
});
|
||||
|
||||
if (withGuides) {
|
||||
const setLine = () => {
|
||||
doc.setDrawColor(64, 64, 64);
|
||||
doc.setLineWidth(1);
|
||||
};
|
||||
const fractions = gridConfigs[handler.globalSettingsHandler.settings.grid];
|
||||
if (fractions) {
|
||||
setLine();
|
||||
fractions.forEach(f => {
|
||||
doc.line(width * f, 0, width * f, height);
|
||||
doc.line(0, height * f, width, height * f);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const imageObject of handler.imagesRenders) {
|
||||
if (imageObject.visible) {
|
||||
doc.addImage(
|
||||
imageObject.image,
|
||||
"PNG",
|
||||
imageObject.x,
|
||||
imageObject.y,
|
||||
imageObject.image.naturalWidth * imageObject.scale,
|
||||
imageObject.image.naturalHeight * imageObject.scale
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
|
||||
export function createSVGDoc(handler: VectraceHandler) {
|
||||
const { paperWidth, paperHeight, DPI } = handler.globalSettingsHandler.settings;
|
||||
const width = (paperWidth / MM_TO_INCH) * DPI;
|
||||
const height = (paperHeight / MM_TO_INCH) * DPI;
|
||||
return createCellSvg(handler, {
|
||||
col: 0,
|
||||
row: 0,
|
||||
h: height,
|
||||
w: width,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadZip(handler: VectraceHandler): Promise<void> {
|
||||
const zip = new JSZip();
|
||||
const { grid, paperWidth, paperHeight, DPI } = handler.globalSettingsHandler.settings;
|
||||
|
||||
const width = (paperWidth / MM_TO_INCH) * DPI;
|
||||
const height = (paperHeight / MM_TO_INCH) * DPI;
|
||||
|
||||
const mainFolder = zip.folder(handler.projectName)!;
|
||||
const images = mainFolder.folder("images")!;
|
||||
const svgs = mainFolder.folder("svgs")!;
|
||||
|
||||
for (let i = 0; i < handler.imagesRenders.length; i++) {
|
||||
const render = handler.imagesRenders[i];
|
||||
if (render.scale === 1) {
|
||||
images.file(`[${i + 1}]${render.name}.png`, render.blob);
|
||||
} else {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
canvas.width = render.image.naturalWidth * render.scale;
|
||||
canvas.height = render.image.naturalHeight * render.scale;
|
||||
ctx.drawImage(render.image, 0, 0, canvas.width, canvas.height);
|
||||
const blob = new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(data => {
|
||||
if (data) {
|
||||
resolve(data);
|
||||
} else {
|
||||
reject(new Error("Cannot create blob"));
|
||||
}
|
||||
}, "image/png");
|
||||
});
|
||||
images.file(`[${i + 1}]${render.name}.png`, blob);
|
||||
}
|
||||
|
||||
const svgBlob = await prepareSVG(handler, render);
|
||||
svgs.file(`[${i + 1}]${render.name}.svg`, svgBlob);
|
||||
}
|
||||
|
||||
mainFolder.file("document.pdf", createPdf(handler, false).output("blob"));
|
||||
if (grid !== "none") {
|
||||
mainFolder.file("document-grid.pdf", createPdf(handler, true).output("blob"));
|
||||
}
|
||||
|
||||
const svgBlob = await createCellSvg(handler, {
|
||||
col: 0,
|
||||
row: 0,
|
||||
h: height,
|
||||
w: width,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
mainFolder.file(`document.svg`, svgBlob);
|
||||
|
||||
if (grid !== "none") {
|
||||
const cells = getGridCells(width, height, grid);
|
||||
const cellPdfs = mainFolder.folder("cells/pdfs")!;
|
||||
const cellSvgs = mainFolder.folder("cells/svgs")!;
|
||||
|
||||
for (const cell of cells) {
|
||||
const label = `cell-r${cell.row}-c${cell.col}`;
|
||||
|
||||
const cellPdf = createCellPdf(handler, cell);
|
||||
cellPdfs.file(`${label}.pdf`, cellPdf.output("blob"));
|
||||
|
||||
const cellSvgBlob = await createCellSvg(handler, cell);
|
||||
cellSvgs.file(`${label}.svg`, cellSvgBlob);
|
||||
}
|
||||
}
|
||||
|
||||
const content = await zip.generateAsync({ type: "blob" });
|
||||
downloadBlob(content, `${handler.projectName}.zip`);
|
||||
}
|
||||
|
||||
function collectPaths(node: INode): INode[] {
|
||||
const results: INode[] = [];
|
||||
if (node.name === "path") results.push(node);
|
||||
for (const child of node.children ?? []) {
|
||||
results.push(...collectPaths(child));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function createCellSvg(
|
||||
handler: VectraceHandler,
|
||||
cell: GridCell
|
||||
) {
|
||||
const pathElements: string[] = [];
|
||||
for (const imageObject of handler.imagesRenders) {
|
||||
if (!imageObject.visible) continue;
|
||||
|
||||
if (imageObject.svg.scale !== imageObject.scale || imageObject.svg.dirty) {
|
||||
handler.redrawSvg(imageObject.id);
|
||||
}
|
||||
|
||||
const imgX = imageObject.x;
|
||||
const imgY = imageObject.y;
|
||||
const imgW = imageObject.image.naturalWidth * imageObject.scale;
|
||||
const imgH = imageObject.image.naturalHeight * imageObject.scale;
|
||||
|
||||
// Does this image even overlap the cell at all?
|
||||
const overlaps =
|
||||
imgX + imgW > cell.x &&
|
||||
imgX < cell.x + cell.w &&
|
||||
imgY + imgH > cell.y &&
|
||||
imgY < cell.y + cell.h;
|
||||
|
||||
if (!overlaps) continue;
|
||||
|
||||
const parsed = await parse(imageObject.svg.data);
|
||||
const paths = collectPaths(parsed);
|
||||
|
||||
for (const pathNode of paths) {
|
||||
const d = pathNode.attributes?.d;
|
||||
if (!d) continue;
|
||||
|
||||
const fill = pathNode.attributes?.fill ?? "none";
|
||||
const stroke = pathNode.attributes?.stroke ?? "#000000";
|
||||
const strokeWidth = pathNode.attributes?.["stroke-width"] ?? "1";
|
||||
|
||||
pathElements.push(
|
||||
`<g transform="translate(${imgX - cell.x} ${imgY - cell.y})">` +
|
||||
`<path d="${d}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"/>` +
|
||||
`</g>`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const svgString = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="${cell.w}"
|
||||
height="${cell.h}"
|
||||
viewBox="0 0 ${cell.w} ${cell.h}">
|
||||
${pathElements.join("\n ")}
|
||||
</svg>`;
|
||||
return new Blob([svgString], { type: "image/svg+xml" });
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { pushUnique, removeItem } from "./collection";
|
||||
|
||||
export type EventMap<T> = Record<keyof T, any[]> | never;
|
||||
export type DefaultEventMap = [never];
|
||||
export type AnyRest = [...args: any[]];
|
||||
export type Args<K, T> = T extends DefaultEventMap ? AnyRest : K extends keyof T ? T[K] : never;
|
||||
export type Key<K, T> = T extends DefaultEventMap ? number : K | keyof T;
|
||||
export type Listener<K, T, F> = T extends DefaultEventMap
|
||||
? F
|
||||
: K extends keyof T
|
||||
? T[K] extends unknown[]
|
||||
? (...args: T[K]) => void
|
||||
: never
|
||||
: never;
|
||||
export type Listener1<K, T> = Listener<K, T, (...args: any[]) => void>;
|
||||
|
||||
export class BasicEventEmitter<T extends EventMap<T>> {
|
||||
private listeners = new Map<Key<any, Listener1<any, any>>, any>();
|
||||
|
||||
on<K>(eventName: Key<K, T>, listener: Listener1<K, T>): () => void {
|
||||
const arr = this.listeners.get(eventName) || [];
|
||||
pushUnique(arr, listener);
|
||||
this.listeners.set(eventName, arr);
|
||||
return () => {
|
||||
this.off(eventName, listener);
|
||||
};
|
||||
}
|
||||
|
||||
off<K>(eventName: Key<K, T>, listener: Listener1<K, T>): boolean {
|
||||
const arr = this.listeners.get(eventName) || [];
|
||||
removeItem(arr, listener);
|
||||
if (!arr.length) {
|
||||
this.listeners.delete(eventName);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
emit<K>(eventName: Key<K, T>, ...args: Args<K, T>): Promise<unknown> {
|
||||
const listeners = this.listeners.get(eventName);
|
||||
const promises: Promise<unknown>[] = [];
|
||||
if (listeners) {
|
||||
for (let i = 0; i < listeners.length; i++) {
|
||||
try {
|
||||
const listener = listeners[i];
|
||||
if (listener) {
|
||||
const result = listener(...args);
|
||||
if (result instanceof Promise) {
|
||||
promises.push(result);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
listenersCount<K>(type: Key<K, T>) {
|
||||
const arr = this.listeners.get(type);
|
||||
return arr ? arr.length : 0;
|
||||
}
|
||||
|
||||
removeAllListeners() {
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
removeSpecificListeners<K>(type: Key<K, T>) {
|
||||
this.listeners.delete(type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export function loadFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => {
|
||||
resolve(reader.result as string);
|
||||
});
|
||||
reader.addEventListener("error", () => {
|
||||
reject(new Error("Failed to read file"));
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
export function makeFilenameSafe(input: string): string {
|
||||
return input
|
||||
.trim()
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "")
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/\.+$/, "")
|
||||
.replace(/^\.+/, "")
|
||||
.replace(/_{2,}/g, "_")
|
||||
.slice(0, 255)
|
||||
|| "unnamed";
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export function urlToImage(base64Str: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const imageSrc = new Image();
|
||||
imageSrc.addEventListener("load", () => {
|
||||
resolve(imageSrc);
|
||||
});
|
||||
imageSrc.addEventListener("error", () => {
|
||||
reject(new Error("Failed to load image"));
|
||||
});
|
||||
imageSrc.src = base64Str;
|
||||
});
|
||||
}
|
||||
|
||||
export function imageToCanvas(image: HTMLImageElement) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("No canvas context");
|
||||
|
||||
ctx.drawImage(image, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function canvasToBlob(canvas: HTMLCanvasElement) {
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((b) => {
|
||||
if (!b) reject(new Error("Failed to create blob"));
|
||||
else resolve(b);
|
||||
}, "image/png");
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user