Format code

This commit is contained in:
2026-06-29 09:41:02 +02:00
parent 42fcf34c5e
commit bfff0cc9fb
16 changed files with 538 additions and 495 deletions
+6 -10
View File
@@ -17,7 +17,7 @@ const Container = styled.div<{ $cursor?: string }>`
cursor: ${({ $cursor }) => $cursor || ""}; cursor: ${({ $cursor }) => $cursor || ""};
image-rendering: -webkit-optimize-contrast; image-rendering: -webkit-optimize-contrast;
backface-visibility: hidden; backface-visibility: hidden;
perspective: 1000; perspective: 1000;
`; `;
@@ -50,8 +50,10 @@ export default function A4Canvas({ handler }: CanvasHandlerProps) {
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
panRef.current.offsetX = (container.offsetWidth - (settings.landscape ? settings.paperHeight : settings.paperWidth) * settings.scale) / 2; panRef.current.offsetX =
panRef.current.offsetY = (container.offsetHeight - (settings.landscape ? settings.paperWidth : settings.paperHeight) * settings.scale) / 2; (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(); applyTransform();
const unsub = handler.toolHandler.emitter.on("select", (tool) => { const unsub = handler.toolHandler.emitter.on("select", (tool) => {
@@ -123,7 +125,7 @@ export default function A4Canvas({ handler }: CanvasHandlerProps) {
break; break;
case "Delete": { case "Delete": {
const copy = [...handler.selected]; const copy = [...handler.selected];
if (copy.length && await handler.popup.confirm("Title", "Are you sure you want to delete")) { if (copy.length && (await handler.popup.confirm("Title", "Are you sure you want to delete"))) {
for (const id of copy) { for (const id of copy) {
handler.deleteImage(id); handler.deleteImage(id);
} }
@@ -135,7 +137,6 @@ export default function A4Canvas({ handler }: CanvasHandlerProps) {
handler.selected.forEach((e) => { handler.selected.forEach((e) => {
handler.move(e, x, y); handler.move(e, x, y);
}); });
} }
}; };
@@ -145,7 +146,6 @@ export default function A4Canvas({ handler }: CanvasHandlerProps) {
}; };
}); });
const handleMouseDown = (e: React.MouseEvent) => { const handleMouseDown = (e: React.MouseEvent) => {
if (!isDragging.current && handler.toolHandler.isToolSelected(TOOL_CANVAS_MOVE)) { if (!isDragging.current && handler.toolHandler.isToolSelected(TOOL_CANVAS_MOVE)) {
setCursor("grabbing"); setCursor("grabbing");
@@ -174,16 +174,12 @@ export default function A4Canvas({ handler }: CanvasHandlerProps) {
// } // }
// const mx = e.clientX - panRef.current.offsetX; // const mx = e.clientX - panRef.current.offsetX;
// const my = e.clientY - panRef.current.offsetY; // const my = e.clientY - panRef.current.offsetY;
// const dx = mx - dragStart.current.x; // const dx = mx - dragStart.current.x;
// const dy = my - dragStart.current.y; // const dy = my - dragStart.current.y;
// const x = dx / settings.scale; // const x = dx / settings.scale;
// const y = dy / settings.scale; // const y = dy / settings.scale;
// dragStart.current.x = mx; // dragStart.current.x = mx;
// dragStart.current.y = my; // dragStart.current.y = my;
// handler.selected.forEach((e) => { // handler.selected.forEach((e) => {
// handler.move(e, x, y); // handler.move(e, x, y);
// }); // });
+118 -65
View File
@@ -26,79 +26,132 @@ const Column = styled.div`
height: 100%; height: 100%;
`; `;
const Box = styled.div<{ $width: number, $height: number }>` const Box = styled.div<{ $width: number; $height: number }>`
width: ${({ $width }) => $width}px; width: ${({ $width }) => $width}px;
height: ${({ $height }) => $height}px; height: ${({ $height }) => $height}px;
padding: 10px; padding: 10px;
`; `;
export function ImportExportButtons({ handler, selected }: CanvasHandlerProps & { selected: ImageEditor[] }) { export function ImportExportButtons({ handler, selected }: CanvasHandlerProps & { selected: ImageEditor[] }) {
const boxSize = 90; const boxSize = 90;
return <> return (
<Box $width={boxSize} $height={boxSize}> <>
<Column> <Box $width={boxSize} $height={boxSize}>
<Row> <Column>
<Row>
<Btn onClick={() => { downloadZip(handler); }}> <Btn
<FaFileZipper />ZIP onClick={() => {
</Btn> downloadZip(handler);
<Btn onClick={() => { createPdf(handler, true).save(`${handler.projectName}.pdf`); }}> }}
<FaFilePdf />PDF >
</Btn> <FaFileZipper />
<Btn onClick={async () => { downloadBlob(await createSVGDoc(handler), `${handler.projectName}.svg`); }} > ZIP
<FaDrawPolygon />SVG </Btn>
</Btn> <Btn
</Row> onClick={() => {
<Row> createPdf(handler, true).save(`${handler.projectName}.pdf`);
<Btn disabled={selected.length !== 1} onClick={() => { }}
const v = handler.imagesRenders.find(e => e.id === selected[0].id); >
if (v) { <FaFilePdf />
const canvas = document.createElement("canvas"); PDF
canvas.width = v.image.naturalWidth * v.scale; </Btn>
canvas.height = v.image.naturalHeight * v.scale; <Btn
const ctx = canvas.getContext("2d")!; onClick={async () => {
ctx.drawImage(v.image, 0, 0, canvas.width, canvas.height); downloadBlob(await createSVGDoc(handler), `${handler.projectName}.svg`);
canvas.toBlob(blob => { }}
if (blob) { >
downloadBlob(blob, `${v.name}.png`); <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 { } else {
handler.popup.alert("Error", "Cannot export image"); 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 />
Export
</Btn>
<Btn
onClick={() => {
handler.importProject();
}}
>
<FaFileImport />
Import
</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();
} }
}, "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"); <FaBroom />
} Clear
</Btn>
}}> <FaDrawPolygon />SVG</Btn> </Column>
</Row> </Box>
</Column> </>
</Box> );
<Box $width={boxSize} $height={boxSize}>
<Column>
<Row>
<Btn onClick={() => { handler.exportProject(); }}><FaFileExport />Export</Btn>
<Btn onClick={() => { handler.importProject(); }} ><FaFileImport />Import</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>
</>;
} }
+64 -49
View File
@@ -6,7 +6,6 @@ import { FreeTransform } from "./selection-box";
import type { GlobalSettings, GridType } from "../../interface"; import type { GlobalSettings, GridType } from "../../interface";
import { SvgRenderer } from "./svg-view"; import { SvgRenderer } from "./svg-view";
const CanvasEl = styled.div` const CanvasEl = styled.div`
background: white; background: white;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
@@ -17,10 +16,9 @@ const Img = styled.img<{ $selected: boolean }>`
position: absolute; position: absolute;
display: block; display: block;
user-select: none; user-select: none;
opacity: ${({ $selected }) => $selected ? 0.5 : 1}; opacity: ${({ $selected }) => ($selected ? 0.5 : 1)};
`; `;
const Box = styled.div` const Box = styled.div`
position: absolute; position: absolute;
top: 0; top: 0;
@@ -31,9 +29,9 @@ const Box = styled.div`
function getGridLines(settings: GridType, width: number, height: number, strokeWidth: number, strokeWidthHalf: number) { function getGridLines(settings: GridType, width: number, height: number, strokeWidth: number, strokeWidthHalf: number) {
if (settings === "none") return null; if (settings === "none") return null;
const configs: Record<GridType, { vertical: number[]; horizontal: number[] }> = { const configs: Record<GridType, { vertical: number[]; horizontal: number[] }> = {
"none": { none: {
horizontal: [1], horizontal: [1],
vertical: [1] vertical: [1],
}, },
"2x2": { "2x2": {
vertical: [1 / 2], vertical: [1 / 2],
@@ -48,21 +46,25 @@ function getGridLines(settings: GridType, width: number, height: number, strokeW
const config = configs[settings]; const config = configs[settings];
if (!config) return null; if (!config) return null;
return <> return (
{config.vertical.map((fraction, i) => ( <>
<Box key={`v-${i}`} style={{ left: width * fraction - strokeWidthHalf, width: strokeWidth, height: "100%" }} /> {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%" }} /> {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 }) { export function PaperView({ handler, settings }: CanvasHandlerProps & { settings: GlobalSettings }) {
const { images } = useImages(handler); const { images } = useImages(handler);
const dpi = settings.DPI; const dpi = settings.DPI;
const width = Math.round(((settings.landscape ? settings.paperHeight : settings.paperWidth) / MM_TO_INCH) * dpi) * settings.scale; const width =
const height = Math.round(((settings.landscape ? settings.paperWidth : settings.paperHeight) / MM_TO_INCH) * dpi) * settings.scale; 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 renderGuides = () => {
const strokeWidth = 1; const strokeWidth = 1;
@@ -70,37 +72,50 @@ export function PaperView({ handler, settings }: CanvasHandlerProps & { settings
return getGridLines(settings.grid, width, height, strokeWidth, strokeWidthHalf); return getGridLines(settings.grid, width, height, strokeWidth, strokeWidthHalf);
}; };
return <CanvasEl style={{ width: `${width}px`, height: `${height}px` }} draggable={false}> return (
{images.map(e => { <CanvasEl style={{ width: `${width}px`, height: `${height}px` }} draggable={false}>
if (!e.visible) return; {images.map((e) => {
const width = e.image.naturalWidth * e.scale * settings.scale; if (!e.visible) return;
const height = e.image.naturalHeight * e.scale * settings.scale; const width = e.image.naturalWidth * e.scale * settings.scale;
const x = e.x * settings.scale; const height = e.image.naturalHeight * e.scale * settings.scale;
const y = e.y * settings.scale; const x = e.x * settings.scale;
return <div key={e.id}> const y = e.y * settings.scale;
<Img return (
$selected={e.selected} <div key={e.id}>
draggable="false" <Img
src={e.url} $selected={e.selected}
alt={e.id} draggable="false"
style={{ left: `${x}px`, top: `${y}px` }} src={e.url}
width={width} alt={e.id}
height={height} style={{ left: `${x}px`, top: `${y}px` }}
/> width={width}
<SvgRenderer x={x} y={y} width={width} height={height} svg={e.svg} /> height={height}
{e.selected ? />
<FreeTransform scale={settings.scale} transform={{ <SvgRenderer x={x} y={y} width={width} height={height} svg={e.svg} />
x: x, {e.selected ? (
y: y, <FreeTransform
height, scale={settings.scale}
width, transform={{
rotation: 0 x: x,
}} onTransformChange={({ x, y, width }) => { y: y,
const scale = Math.round(width / e.image.width / settings.scale * 100) / 100; height,
e.onPropsChange({ x: Math.round(x / settings.scale), y: Math.round(y / settings.scale), scale }); width,
}} /> : null} rotation: 0,
</div>; }}
})} onTransformChange={({ x, y, width }) => {
{renderGuides()} const scale = Math.round((width / e.image.width / settings.scale) * 100) / 100;
</CanvasEl>; e.onPropsChange({
x: Math.round(x / settings.scale),
y: Math.round(y / settings.scale),
scale,
});
}}
/>
) : null}
</div>
);
})}
{renderGuides()}
</CanvasEl>
);
} }
+8 -13
View File
@@ -105,18 +105,14 @@ export type FreeTransformProps = {
onTransformChange: (t: Transform) => void; onTransformChange: (t: Transform) => void;
}; };
export function FreeTransform({ export function FreeTransform({ transform, scale = 1, onTransformChange }: FreeTransformProps) {
transform,
scale = 1,
onTransformChange,
}: FreeTransformProps) {
const dragRef = useRef<DragState>({ type: "none" }); const dragRef = useRef<DragState>({ type: "none" });
const apply = useCallback( const apply = useCallback(
(updater: (prev: Transform) => Transform) => { (updater: (prev: Transform) => Transform) => {
onTransformChange(updater(transform)); onTransformChange(updater(transform));
}, },
[transform, onTransformChange] [transform, onTransformChange],
); );
const onMovePointerDown = useCallback( const onMovePointerDown = useCallback(
@@ -131,7 +127,7 @@ export function FreeTransform({
originY: transform.y, originY: transform.y,
}; };
}, },
[transform.x, transform.y] [transform.x, transform.y],
); );
const onResizePointerDown = useCallback( const onResizePointerDown = useCallback(
@@ -146,7 +142,7 @@ export function FreeTransform({
originTransform: { ...transform }, originTransform: { ...transform },
}; };
}, },
[transform] [transform],
); );
// const onRotatePointerDown = useCallback( // const onRotatePointerDown = useCallback(
@@ -184,8 +180,7 @@ export function FreeTransform({
} }
if (drag.type === "rotate") { if (drag.type === "rotate") {
const currentAngle = const currentAngle = Math.atan2(e.clientY - drag.cy, e.clientX - drag.cx) * (180 / Math.PI);
Math.atan2(e.clientY - drag.cy, e.clientX - drag.cx) * (180 / Math.PI);
apply(() => ({ apply(() => ({
...transform, ...transform,
rotation: drag.originRotation + (currentAngle - drag.startAngle), rotation: drag.originRotation + (currentAngle - drag.startAngle),
@@ -218,7 +213,7 @@ export function FreeTransform({
apply(() => ({ ...transform, x, y, width, height })); apply(() => ({ ...transform, x, y, width, height }));
} }
}, },
[transform, scale, apply] [transform, scale, apply],
); );
const onPointerUp = useCallback(() => { const onPointerUp = useCallback(() => {
@@ -235,7 +230,7 @@ export function FreeTransform({
top: `${y}px`, top: `${y}px`,
width: `${width}px`, width: `${width}px`,
height: `${height}px`, height: `${height}px`,
transform: `rotate(${rotation}deg)` transform: `rotate(${rotation}deg)`,
}} }}
onPointerDown={onMovePointerDown} onPointerDown={onMovePointerDown}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
@@ -266,4 +261,4 @@ export function FreeTransform({
</TransformBox> </TransformBox>
</Overlay> </Overlay>
); );
}; }
+15 -16
View File
@@ -1,4 +1,3 @@
import styled from "styled-components"; import styled from "styled-components";
const Wrapper = styled.div` const Wrapper = styled.div`
@@ -6,12 +5,11 @@ const Wrapper = styled.div`
display: inline-block; display: inline-block;
line-height: 0; line-height: 0;
svg {
svg { width: 100%;
width: 100%; height: 100%;
height: 100%; display: block;
display: block; }
}
`; `;
type Props = { type Props = {
@@ -20,19 +18,20 @@ type Props = {
y: number; y: number;
width: number; width: number;
height: number; height: number;
} };
export function SvgRenderer(props: Props) { export function SvgRenderer(props: Props) {
const { svg, x, y, width, height } = props; const { svg, x, y, width, height } = props;
return ( return (
<Wrapper style={{ <Wrapper
left: x, style={{
top: y, left: x,
width, top: y,
height width,
}} dangerouslySetInnerHTML={{ __html: svg }} height,
}}
dangerouslySetInnerHTML={{ __html: svg }}
/> />
); );
} }
+8 -4
View File
@@ -30,7 +30,7 @@ export function InfoBar({ handler }: CanvasHandlerProps) {
const [projectName, setProjectName] = useState(handler.projectName); const [projectName, setProjectName] = useState(handler.projectName);
useEffect(() => { useEffect(() => {
return handler.emitter.on("name", name => { return handler.emitter.on("name", (name) => {
setProjectName(name); setProjectName(name);
}); });
}, [handler.projectName]); }, [handler.projectName]);
@@ -81,9 +81,13 @@ export function InfoBar({ handler }: CanvasHandlerProps) {
<FaPlus /> <FaPlus />
</Icon2525> </Icon2525>
<ProjectName> <ProjectName>
<span onClick={() => { <span
handler.promptSetName(); onClick={() => {
}}> {projectName} handler.promptSetName();
}}
>
{" "}
{projectName}
</span> </span>
</ProjectName> </ProjectName>
<Icon2525 onClick={() => setDPI(false)}> <Icon2525 onClick={() => setDPI(false)}>
+5 -5
View File
@@ -31,7 +31,7 @@ const Icon = styled.span`
export function LayerItem({ image, handler }: CanvasHandleImageEditorProps) { export function LayerItem({ image, handler }: CanvasHandleImageEditorProps) {
const selectLayer = (image: ImageEditor, ctrl: boolean) => { const selectLayer = (image: ImageEditor, ctrl: boolean) => {
if (!ctrl) { if (!ctrl) {
const selected = handler.selected.filter(e => e !== image.id); const selected = handler.selected.filter((e) => e !== image.id);
for (const sel of selected) { for (const sel of selected) {
handler.setSelect(sel, false); handler.setSelect(sel, false);
} }
@@ -41,7 +41,7 @@ export function LayerItem({ image, handler }: CanvasHandleImageEditorProps) {
return ( return (
<Item <Item
$selected={image.selected} $selected={image.selected}
onClick={ev => { onClick={(ev) => {
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
selectLayer(image, ev.ctrlKey); selectLayer(image, ev.ctrlKey);
@@ -49,7 +49,7 @@ export function LayerItem({ image, handler }: CanvasHandleImageEditorProps) {
> >
<Icon <Icon
style={{ opacity: image.visible ? 1 : 0.1 }} style={{ opacity: image.visible ? 1 : 0.1 }}
onClick={ev => { onClick={(ev) => {
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
image.setVisible(!image.visible); image.setVisible(!image.visible);
@@ -60,7 +60,7 @@ export function LayerItem({ image, handler }: CanvasHandleImageEditorProps) {
<Img <Img
draggable="false" draggable="false"
onClick={ev => { onClick={(ev) => {
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
selectLayer(image, ev.ctrlKey); selectLayer(image, ev.ctrlKey);
@@ -71,7 +71,7 @@ export function LayerItem({ image, handler }: CanvasHandleImageEditorProps) {
<NamePlate image={image} /> <NamePlate image={image} />
<Icon <Icon
style={{ opacity: image.locked ? 1 : 0.1 }} style={{ opacity: image.locked ? 1 : 0.1 }}
onClick={ev => { onClick={(ev) => {
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
image.setLock(!image.locked); image.setLock(!image.locked);
+11 -7
View File
@@ -44,13 +44,17 @@ export function NamePlate({ image }: ImageEditorProps) {
}} }}
/> />
) : ( ) : (
<span onDoubleClick={ev => { <span
if (!image.locked) { onDoubleClick={(ev) => {
ev.stopPropagation(); if (!image.locked) {
ev.preventDefault(); ev.stopPropagation();
setEditing(true); ev.preventDefault();
} setEditing(true);
}}>{image.name}</span> }
}}
>
{image.name}
</span>
)} )}
</NamePlateDiv> </NamePlateDiv>
); );
+28 -28
View File
@@ -22,15 +22,12 @@ const Version = styled.span`
margin: 0 5px; margin: 0 5px;
`; `;
const ToolBar = styled.div` const ToolBar = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
border-top: 1px solid var(--secondary-color); border-top: 1px solid var(--secondary-color);
`; `;
const Wrapper = styled.div` const Wrapper = styled.div`
width: 200px; width: 200px;
padding: 24px; padding: 24px;
@@ -62,7 +59,7 @@ const Value = styled.div`
`; `;
const Link = styled.a` const Link = styled.a`
color: #4AF626; color: #4af626;
text-decoration: none; text-decoration: none;
&:hover { &:hover {
@@ -80,34 +77,37 @@ export function LayerPanel({ handler }: CanvasHandlerProps) {
))} ))}
<Gap /> <Gap />
<ToolBar> <ToolBar>
<Version
onDoubleClick={() => {
handler.popup.custom(
"About",
"",
<Wrapper>
<Title>Vectrace {handler.VERSION}v</Title>
<Version onDoubleClick={() => { <Row>
handler.popup.custom("About", "", <Wrapper> <Label>Author</Label>
<Title>Vectrace {handler.VERSION}v</Title> <Value>{handler.AUTHOR.name}</Value>
</Row>
<Row> <Row>
<Label>Author</Label> <Label>Email</Label>
<Value>{handler.AUTHOR.name}</Value> <Value>{handler.AUTHOR.email}</Value>
</Row> </Row>
<Row>
<Row> <Label>URL</Label>
<Label>Email</Label> <Value>
<Value>{handler.AUTHOR.email}</Value> <Link href={handler.AUTHOR.url} target="_blank" rel="noreferrer">
</Row> {handler.AUTHOR.url}
</Link>
<Row> </Value>
<Label>URL</Label> </Row>
<Value> </Wrapper>,
<Link href={handler.AUTHOR.url} target="_blank" rel="noreferrer"> );
{handler.AUTHOR.url} }}
</Link> >
</Value>
</Row>
</Wrapper>);
}}>
{handler.VERSION}v {handler.VERSION}v
</Version> </Version>
<Gap /> <Gap />
<Icon2525 <Icon2525
+18 -47
View File
@@ -3,39 +3,22 @@ import styled from "styled-components";
import { calculateMathExpression, canEvaluateMathExpression } from "../calc"; import { calculateMathExpression, canEvaluateMathExpression } from "../calc";
const StyledInput = styled.input<{ $isValid: boolean | null }>` const StyledInput = styled.input<{ $isValid: boolean | null }>`
padding: 1px; padding: 1px;
font-size: 1rem; font-size: 1rem;
width: 80px; width: 80px;
border: 1px solid border: 1px solid ${({ $isValid }) => ($isValid === null ? "#888" : $isValid ? "#ffffff" : "#ef4444")};
${({ $isValid }) => outline: none;
$isValid === null background-color: ${({ $isValid }) => ($isValid === null ? "transparent" : $isValid ? "#000000" : "#7e0000")};
? "#888" color: ${({ $isValid }) => ($isValid === null ? "inherit" : $isValid ? "#15803d" : "#b91c1c")};
: $isValid transition:
? "#ffffff" border-color 0.2s ease,
: "#ef4444"}; background-color 0.2s ease;
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 { &:focus {
box-shadow: 0 0 0 3px box-shadow: 0 0 0 3px ${({ $isValid }) => ($isValid === null ? "#88888844" : $isValid ? "#22c55e44" : "#ef444444")};
${({ $isValid }) => }
$isValid === null
? "#88888844"
: $isValid
? "#22c55e44"
: "#ef444444"};
}
`; `;
export interface NumberInputProps { export interface NumberInputProps {
value?: number; value?: number;
id?: string; id?: string;
@@ -43,18 +26,9 @@ export interface NumberInputProps {
placeholder?: string; placeholder?: string;
} }
export function NumberInput({ value, onChange, id, placeholder = "Enter a number..." }: NumberInputProps) {
export function NumberInput({ const [raw, setRaw] = useState<string>(() => (value !== undefined ? String(value) : ""));
value, const [isValid, setIsValid] = useState<boolean | null>(value !== undefined ? true : null);
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 => { const isValidNumber = (val: string): boolean => {
if (val.trim() === "") return false; if (val.trim() === "") return false;
@@ -68,7 +42,6 @@ export function NumberInput({
setRaw(val); setRaw(val);
if (val.trim() === "") { if (val.trim() === "") {
setIsValid(null); setIsValid(null);
return; return;
} }
@@ -88,7 +61,6 @@ export function NumberInput({
} }
}; };
const executeEvaluate = () => { const executeEvaluate = () => {
if (canEvaluateMathExpression(raw)) { if (canEvaluateMathExpression(raw)) {
const value = calculateMathExpression(raw); const value = calculateMathExpression(raw);
@@ -116,7 +88,7 @@ export function NumberInput({
inputMode="numeric" inputMode="numeric"
value={raw} value={raw}
onBlur={executeEvaluate} onBlur={executeEvaluate}
onKeyUp={ev => { onKeyUp={(ev) => {
switch (ev.key) { switch (ev.key) {
case "Enter": case "Enter":
executeEvaluate(); executeEvaluate();
@@ -139,5 +111,4 @@ export function NumberInput({
id={id} id={id}
/> />
); );
}; }
+18 -33
View File
@@ -5,38 +5,23 @@ const OptionDiv = styled.div`
margin: 2px; margin: 2px;
`; `;
export function NumericInputWithLabel({ export function NumericInputWithLabel({ value, onChange, placeholder, name, id }: NumberInputProps & { name: string }) {
value, return (
onChange, <OptionDiv>
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> <label htmlFor={id}>{name}:</label>
</td> <NumberInput id={id} placeholder={placeholder} value={value} onChange={onChange} />
<td> </OptionDiv>
<NumberInput );
id={id} }
placeholder={placeholder} export function NumericInputWithLabelTable({ value, onChange, placeholder, name, id }: NumberInputProps & { name: string }) {
value={value} return (
onChange={onChange} /> <tr>
<td>
</td> <label htmlFor={id}>{name}:</label>
</tr>; </td>
<td>
<NumberInput id={id} placeholder={placeholder} value={value} onChange={onChange} />
</td>
</tr>
);
} }
+15 -11
View File
@@ -17,16 +17,20 @@ const Wrapper = styled.div`
} }
`; `;
export function PaperGuideSelector({ setSettings, settings }: UseSettings) { export function PaperGuideSelector({ setSettings, settings }: UseSettings) {
return <Wrapper> return (
{GRID.map((e, i) => <Button <Wrapper>
$active={e === settings.grid} {GRID.map((e, i) => (
key={i} <Button
onClick={() => { $active={e === settings.grid}
setSettings({ grid: e }); key={i}
}} onClick={() => {
>{e} setSettings({ grid: e });
</Button>)} }}
</Wrapper>; >
{e}
</Button>
))}
</Wrapper>
);
} }
+9 -7
View File
@@ -3,11 +3,13 @@ import { RibbonButton } from "./buttons/button-ribbon";
import type { UseSettings } from "../use/use-settings"; import type { UseSettings } from "../use/use-settings";
export function PaperOrientation({ setSettings, settings }: UseSettings) { export function PaperOrientation({ setSettings, settings }: UseSettings) {
return (
return <RibbonButton <RibbonButton
icon={() => <FaFile style={{ transform: `rotate(${settings.landscape ? "90" : "0"}deg)` }} />} icon={() => <FaFile style={{ transform: `rotate(${settings.landscape ? "90" : "0"}deg)` }} />}
onClick={() => { onClick={() => {
setSettings({ landscape: !settings.landscape }); setSettings({ landscape: !settings.landscape });
}} }}
name={settings.landscape ? "Landscape" : "Portrait"} />; name={settings.landscape ? "Landscape" : "Portrait"}
/>
);
} }
+60 -44
View File
@@ -1,17 +1,27 @@
import styled from "styled-components"; import styled from "styled-components";
import { import {
A0_HEIGHT, A0_WIDTH, A0_HEIGHT,
A1_HEIGHT, A1_WIDTH, A0_WIDTH,
A2_HEIGHT, A2_WIDTH, A1_HEIGHT,
A3_HEIGHT, A3_WIDTH, A1_WIDTH,
A4_HEIGHT, A4_WIDTH, A2_HEIGHT,
A5_HEIGHT, A5_WIDTH, A2_WIDTH,
A6_HEIGHT, A6_WIDTH, A3_HEIGHT,
LETTER_HEIGHT, LETTER_WIDTH, A3_WIDTH,
LEGAL_HEIGHT, LEGAL_WIDTH, A4_HEIGHT,
TABLOID_HEIGHT, TABLOID_WIDTH, A4_WIDTH,
LEDGER_HEIGHT, LEDGER_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"; } from "../constants";
import { Button } from "../styles"; import { Button } from "../styles";
import type { UseSettings } from "../use/use-settings"; import type { UseSettings } from "../use/use-settings";
@@ -52,38 +62,44 @@ const papers = [
createPaper("Ledger", LEDGER_WIDTH, LEDGER_HEIGHT), createPaper("Ledger", LEDGER_WIDTH, LEDGER_HEIGHT),
]; ];
export function PaperSizeSelector({ setSettings, settings, popup }: UseSettings & { popup: Popup }) { export function PaperSizeSelector({ setSettings, settings, popup }: UseSettings & { popup: Popup }) {
const custom = papers.find(e => settings.paperWidth === e.width && settings.paperHeight === e.height); const custom = papers.find((e) => settings.paperWidth === e.width && settings.paperHeight === e.height);
return <Wrapper> return (
{papers.map((e, i) => <Button <Wrapper>
$active={settings.paperWidth === e.width && settings.paperHeight === e.height} {papers.map((e, i) => (
key={i} <Button
onClick={() => { $active={settings.paperWidth === e.width && settings.paperHeight === e.height}
setSettings({ paperWidth: e.width, paperHeight: e.height }); key={i}
}} onClick={() => {
>{e.name} setSettings({ paperWidth: e.width, paperHeight: e.height });
</Button>)} }}
<Button >
$active={!custom} {e.name}
onClick={async () => { </Button>
const result = await popup.prompt( ))}
"Paper size", <Button
`Enter paper size like <WIDTH>x<HEIGHT> (${A4_WIDTH}x${A4_HEIGHT})`, $active={!custom}
`${settings.paperWidth}x${settings.paperHeight}` onClick={async () => {
); const result = await popup.prompt(
if (result) { "Paper size",
const [width, height] = result.split("x").map(e => Math.max(parseInt(e, 10), 1)); `Enter paper size like <WIDTH>x<HEIGHT> (${A4_WIDTH}x${A4_HEIGHT})`,
if (isNaN(width) || isNaN(height)) { `${settings.paperWidth}x${settings.paperHeight}`,
popup.alert("Paper size", "Invalid size"); );
} else { if (result) {
setSettings({ const [width, height] = result.split("x").map((e) => Math.max(parseInt(e, 10), 1));
paperWidth: width, if (isNaN(width) || isNaN(height)) {
paperHeight: height popup.alert("Paper size", "Invalid size");
}); } else {
setSettings({
paperWidth: width,
paperHeight: height,
});
}
} }
} }}
} >
}>Custom</Button> Custom
</Wrapper>; </Button>
</Wrapper>
);
} }
+85 -73
View File
@@ -18,13 +18,13 @@ const Table = styled.table`
const WrapperColumn = styled.div` const WrapperColumn = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
`; `;
const WrapperRow = styled.div` const WrapperRow = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
height: 100px; height: 100px;
`; `;
const LayerPanel = styled.div` const LayerPanel = styled.div`
display: flex; display: flex;
@@ -35,7 +35,7 @@ const LayerPanel = styled.div`
`; `;
const ToggleButton = styled.button<{ $enabled: boolean }>` const ToggleButton = styled.button<{ $enabled: boolean }>`
background-color: ${({ $enabled }) => $enabled ? "var(--secondary-color)" : "var(--primary-color)"}; background-color: ${({ $enabled }) => ($enabled ? "var(--secondary-color)" : "var(--primary-color)")};
color: white; color: white;
border: 1px solid white; border: 1px solid white;
&:hover { &:hover {
@@ -44,19 +44,17 @@ const ToggleButton = styled.button<{ $enabled: boolean }>`
} }
`; `;
export function SvgTracerOptions({ selected, handler }: { selected: ImageEditor[]; handler: VectraceHandler }) {
export function SvgTracerOptions({ selected, handler }: { selected: ImageEditor[], handler: VectraceHandler }) {
const [options, setOptions] = useState(selected[0]?.tracerOptions ?? {}); const [options, setOptions] = useState(selected[0]?.tracerOptions ?? {});
const [updated, setUpdated] = useState(false); const [updated, setUpdated] = useState(false);
useEffect(() => { useEffect(() => {
setOptions(selected[0]?.tracerOptions ?? {}); setOptions(selected[0]?.tracerOptions ?? {});
setUpdated(true); setUpdated(true);
}, [selected.map(e => `${e.id}${JSON.stringify(e.svgData.options)}`).join("")]); }, [selected.map((e) => `${e.id}${JSON.stringify(e.svgData.options)}`).join("")]);
const redraw = () => { const redraw = () => {
selected.forEach(e => { selected.forEach((e) => {
e.setTracerSvg({ ...options }); e.setTracerSvg({ ...options });
}); });
setUpdated(true); setUpdated(true);
@@ -67,69 +65,83 @@ export function SvgTracerOptions({ selected, handler }: { selected: ImageEditor[
setUpdated(false); setUpdated(false);
}; };
return <WrapperRow> return (
<VerticalSlider <WrapperRow>
max={255} <VerticalSlider
min={0} max={255}
value={options.transparencyThreshold ?? TRANSPARENCY_THRESHOLD_DEFAULT} min={0}
onLabelClick={async () => { value={options.transparencyThreshold ?? TRANSPARENCY_THRESHOLD_DEFAULT}
const value = await handler.popup.prompt( onLabelClick={async () => {
"Transparency threshold", const value = await handler.popup.prompt(
"Enter transparency threshold between 0-255", "Transparency threshold",
(options.transparencyThreshold ?? TRANSPARENCY_THRESHOLD_DEFAULT).toString()); "Enter transparency threshold between 0-255",
if (value) { (options.transparencyThreshold ?? TRANSPARENCY_THRESHOLD_DEFAULT).toString(),
const int = parseInt(value, 10); );
if (!isNaN(int)) { if (value) {
setValue("transparencyThreshold", clamp(int, 0, 255)); const int = parseInt(value, 10);
if (!isNaN(int)) {
setValue("transparencyThreshold", clamp(int, 0, 255));
}
} }
} }}
}} onChange={(value) => {
onChange={value => { setValue("transparencyThreshold", value);
setValue("transparencyThreshold", value); }}
}} /> />
<WrapperColumn> <WrapperColumn>
<Button $active={!updated} title="Draw" onClick={() => { redraw(); }}><FaBezierCurve /></Button> <Button
<WrapperRow> $active={!updated}
<Table> title="Draw"
<tbody> onClick={() => {
<NumericInputWithLabelTable redraw();
name="Ltres" }}
value={options.ltres} >
onChange={value => setValue("ltres", value)} <FaBezierCurve />
/> </Button>
<NumericInputWithLabelTable <WrapperRow>
name="Qtres" <Table>
value={options.qtres} <tbody>
onChange={value => setValue("qtres", value)} <NumericInputWithLabelTable
/> name="Ltres"
<NumericInputWithLabelTable value={options.ltres}
name="Path omit" onChange={(value) => setValue("ltres", value)}
value={options.pathomit} />
onChange={value => setValue("pathomit", value)} <NumericInputWithLabelTable
/> name="Qtres"
<NumericInputWithLabelTable value={options.qtres}
name="Round corners" onChange={(value) => setValue("qtres", value)}
value={options.roundcoords} />
onChange={value => setValue("roundcoords", value)} <NumericInputWithLabelTable
/> name="Path omit"
</tbody> value={options.pathomit}
</Table > onChange={(value) => setValue("pathomit", value)}
<LayerPanel> />
{(options.allowedPaths || []).map((e, i) => { <NumericInputWithLabelTable
return <ToggleButton name="Round corners"
key={i} value={options.roundcoords}
$enabled={e} onChange={(value) => setValue("roundcoords", value)}
onClick={() => { />
const array = options?.allowedPaths ? [...options.allowedPaths] : []; </tbody>
array[i] = !array[i]; </Table>
setValue("allowedPaths", array); <LayerPanel>
}} {(options.allowedPaths || []).map((e, i) => {
> return (
{`${i + 1} Layer`} <ToggleButton
</ToggleButton>; key={i}
})} $enabled={e}
</LayerPanel> onClick={() => {
const array = options?.allowedPaths ? [...options.allowedPaths] : [];
</WrapperRow> array[i] = !array[i];
</WrapperColumn></WrapperRow>; setValue("allowedPaths", array);
} }}
>
{`${i + 1} Layer`}
</ToggleButton>
);
})}
</LayerPanel>
</WrapperRow>
</WrapperColumn>
</WrapperRow>
);
}
+70 -83
View File
@@ -1,15 +1,14 @@
import React, { useState } from "react"; import React, { useState } from "react";
import styled from "styled-components"; import styled from "styled-components";
const SliderWrapper = styled.div` const SliderWrapper = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 120px; height: 120px;
width: 40px; width: 40px;
gap: 8px; gap: 8px;
`; `;
interface SliderProps { interface SliderProps {
@@ -20,73 +19,73 @@ interface SliderProps {
onChange?: (value: number) => void; onChange?: (value: number) => void;
} }
interface TrackTransientProps { interface TrackTransientProps {
$fillPercent: number; $fillPercent: number;
} }
const Track = styled.input.attrs({ type: "range" }) <TrackTransientProps>` const Track = styled.input.attrs({ type: "range" })<TrackTransientProps>`
appearance: none;
-webkit-appearance: none;
writing-mode: vertical-lr;
direction: rtl;
width: 6px;
height: 100%;
background: linear-gradient(
to top,
white ${({ $fillPercent }) => $fillPercent}%,
var(--secondary-color) ${({ $fillPercent }) => $fillPercent}%
);
border-radius: 0;
outline: none;
cursor: pointer;
transition: background 0.15s ease;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none; appearance: none;
width: 22px; -webkit-appearance: none;
height: 22px; writing-mode: vertical-lr;
direction: rtl;
width: 6px;
height: 100%;
background: linear-gradient(
to top,
white ${({ $fillPercent }) => $fillPercent}%,
var(--secondary-color) ${({ $fillPercent }) => $fillPercent}%
);
border-radius: 0; border-radius: 0;
background: #ffffff; outline: none;
border: 3px solid var(--secondary-color); cursor: pointer;
box-shadow: 0 0 6px rgba(255, 68, 68, 0.6); transition: background 0.15s ease;
cursor: grab;
transition: transform 0.1s ease, box-shadow 0.1s ease;
&:active { &::-webkit-slider-thumb {
cursor: grabbing; -webkit-appearance: none;
transform: scale(1.2); appearance: none;
box-shadow: 0 0 12px rgba(255, 68, 68, 0.9); width: 22px;
height: 22px;
border-radius: 0;
background: #ffffff;
border: 3px solid var(--secondary-color);
box-shadow: 0 0 6px rgba(255, 68, 68, 0.6);
cursor: grab;
transition:
transform 0.1s ease,
box-shadow 0.1s ease;
&:active {
cursor: grabbing;
transform: scale(1.2);
box-shadow: 0 0 12px rgba(255, 68, 68, 0.9);
}
} }
}
&::-moz-range-thumb { &::-moz-range-thumb {
width: 22px; width: 22px;
height: 22px; height: 22px;
border-radius: 0; border-radius: 0;
background: #ffffff; background: #ffffff;
border: 3px solid #ff4444; border: 3px solid #ff4444;
box-shadow: 0 0 6px rgba(255, 68, 68, 0.6); box-shadow: 0 0 6px rgba(255, 68, 68, 0.6);
cursor: grab; cursor: grab;
transition: transform 0.1s ease; transition: transform 0.1s ease;
&:active { &:active {
cursor: grabbing; cursor: grabbing;
transform: scale(1.2); transform: scale(1.2);
}
} }
}
&::-moz-range-track { &::-moz-range-track {
background: transparent; background: transparent;
} }
`; `;
const ValueLabel = styled.span` const ValueLabel = styled.span`
font-size: 13px; font-size: 13px;
color: white; color: white;
letter-spacing: 0.05em; letter-spacing: 0.05em;
`; `;
interface SliderProps { interface SliderProps {
@@ -98,17 +97,8 @@ interface SliderProps {
onChange?: (value: number) => void; onChange?: (value: number) => void;
} }
export function VerticalSlider({ export function VerticalSlider({ min = 0, max = 100, step = 1, onLabelClick, value: controlledValue, onChange }: SliderProps) {
min = 0, const [internalValue, setInternalValue] = useState(controlledValue ?? Math.floor((max - min) / 2));
max = 100,
step = 1,
onLabelClick,
value: controlledValue,
onChange,
}: SliderProps) {
const [internalValue, setInternalValue] = useState(
controlledValue ?? Math.floor((max - min) / 2)
);
const value = controlledValue ?? internalValue; const value = controlledValue ?? internalValue;
const fillPercent = ((value - min) / (max - min)) * 100; const fillPercent = ((value - min) / (max - min)) * 100;
@@ -121,17 +111,14 @@ export function VerticalSlider({
return ( return (
<SliderWrapper> <SliderWrapper>
<ValueLabel onDoubleClick={() => { <ValueLabel
onLabelClick?.(); onDoubleClick={() => {
}}>{value}</ValueLabel> onLabelClick?.();
<Track }}
min={min} >
max={max} {value}
step={step} </ValueLabel>
value={value} <Track min={min} max={max} step={step} value={value} $fillPercent={fillPercent} onChange={handleChange} />
$fillPercent={fillPercent}
onChange={handleChange}
/>
</SliderWrapper> </SliderWrapper>
); );
}; }