Inital commit

This commit is contained in:
2026-06-29 09:40:34 +02:00
commit 3ee804a543
68 changed files with 10924 additions and 0 deletions
+68
View File
@@ -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
View File
@@ -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));
}
}
+235
View File
@@ -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>
);
}
+41
View File
@@ -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>
</>
);
}
+104
View File
@@ -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>
</>;
}
+62
View File
@@ -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;
}
+46
View File
@@ -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>
);
}
+179
View File
@@ -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} />;
}
+106
View File
@@ -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>
);
};
+38
View File
@@ -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 }}
/>
);
}
+110
View File
@@ -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>
);
}
+84
View File
@@ -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>
);
}
+57
View File
@@ -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>
);
}
+152
View File
@@ -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>
);
}
+143
View File
@@ -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}
/>
);
};
+42
View File
@@ -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>;
}
+32
View File
@@ -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>;
}
+13
View File
@@ -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"} />;
}
+89
View File
@@ -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>;
}
+213
View File
@@ -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>
</>
);
}
+43
View File
@@ -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>;
}
+29
View File
@@ -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>
);
}
+36
View File
@@ -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";
+43
View File
@@ -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);
}
}
}
+54
View File
@@ -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);
}
+40
View File
@@ -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;
}
}
+96
View File
@@ -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 });
}
}
+21
View File
@@ -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;
+149
View File
@@ -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 };
}
}
+26
View File
@@ -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 />);
+511
View File
@@ -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;
}
}
+30
View File
@@ -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;
}
+77
View File
@@ -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;
}
+7
View File
@@ -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
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+50
View File
@@ -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;
}
`;
+65
View File
@@ -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);
}
+154
View File
@@ -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;
}
+68
View File
@@ -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 };
}
+23
View File
@@ -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),
};
}
+21
View File
@@ -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),
};
}
+17
View File
@@ -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;
}
+287
View File
@@ -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" });
}
+72
View File
@@ -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);
}
}
+24
View File
@@ -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";
}
+33
View File
@@ -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");
});
}