Add hints

This commit is contained in:
2026-06-29 09:41:02 +02:00
parent bfff0cc9fb
commit df39dda32d
7 changed files with 319 additions and 201 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "vectrace", "name": "vectrace",
"version": "1.0.0", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "vectrace", "name": "vectrace",
"version": "1.0.0", "version": "1.1.0",
"dependencies": { "dependencies": {
"@stylistic/eslint-plugin": "^5.10.0", "@stylistic/eslint-plugin": "^5.10.0",
"@types/lodash": "^4.17.24", "@types/lodash": "^4.17.24",
+54 -54
View File
@@ -1,56 +1,56 @@
{ {
"name": "vectrace", "name": "vectrace",
"private": true, "private": true,
"version": "1.1.0", "version": "1.1.q",
"author": { "author": {
"name": "Terncode", "name": "Terncode",
"email": "[email protected]", "email": "[email protected]",
"url": "https://terncode.dev" "url": "https://terncode.dev"
}, },
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"lint-fix": "eslint --fix \"src/**/*.{js,mjs,cjs,ts,jsx,tsx}\"", "lint-fix": "eslint --fix \"src/**/*.{js,mjs,cjs,ts,jsx,tsx}\"",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@stylistic/eslint-plugin": "^5.10.0", "@stylistic/eslint-plugin": "^5.10.0",
"@types/lodash": "^4.17.24", "@types/lodash": "^4.17.24",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"express": "^5.2.1", "express": "^5.2.1",
"fast-xml-builder": "^1.1.5", "fast-xml-builder": "^1.1.5",
"fast-xml-parser": "^5.7.1", "fast-xml-parser": "^5.7.1",
"imagetracerjs": "^1.2.6", "imagetracerjs": "^1.2.6",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"ldrs": "^1.1.9", "ldrs": "^1.1.9",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"polygon-clipping": "^0.15.7", "polygon-clipping": "^0.15.7",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
"styled-components": "^6.4.0", "styled-components": "^6.4.0",
"svg.js": "^2.7.1", "svg.js": "^2.7.1",
"svgson": "^5.3.1", "svgson": "^5.3.1",
"uuid": "^14.0.0" "uuid": "^14.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-styled-components-a11y": "^2.2.1", "eslint-plugin-styled-components-a11y": "^2.2.1",
"globals": "^17.4.0", "globals": "^17.4.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.58.0", "typescript-eslint": "^8.58.0",
"vite": "^8.0.4" "vite": "^8.0.4"
} }
} }
+116 -100
View File
@@ -4,6 +4,7 @@ import { Button } from "../styles";
import styled from "styled-components"; import styled from "styled-components";
import { createPdf, createSVGDoc, downloadBlob, downloadZip } from "../utils/download"; import { createPdf, createSVGDoc, downloadBlob, downloadZip } from "../utils/download";
import type { ImageEditor } from "../handler/image-editor"; import type { ImageEditor } from "../handler/image-editor";
import { Hint } from "./tooltip";
const Btn = styled(Button)` const Btn = styled(Button)`
width: 100%; width: 100%;
@@ -39,117 +40,132 @@ export function ImportExportButtons({ handler, selected }: CanvasHandlerProps &
<Box $width={boxSize} $height={boxSize}> <Box $width={boxSize} $height={boxSize}>
<Column> <Column>
<Row> <Row>
<Btn <Hint hint={"Download all project assets as a ZIP archive"}>
onClick={() => { <Btn
downloadZip(handler); onClick={() => {
}} downloadZip(handler);
> }}
<FaFileZipper /> >
ZIP <FaFileZipper />
</Btn> ZIP
<Btn </Btn>
onClick={() => { </Hint>
createPdf(handler, true).save(`${handler.projectName}.pdf`); <Hint hint={"Combine all assets into a single PDF"}>
}} <Btn
> onClick={() => {
<FaFilePdf /> createPdf(handler, true).save(`${handler.projectName}.pdf`);
PDF }}
</Btn> >
<Btn <FaFilePdf />
onClick={async () => { PDF
downloadBlob(await createSVGDoc(handler), `${handler.projectName}.svg`); </Btn>
}} </Hint>
> <Hint hint={"Merge all SVG paths into one .svg file"}>
<FaDrawPolygon /> <Btn
SVG onClick={async () => {
</Btn> downloadBlob(await createSVGDoc(handler), `${handler.projectName}.svg`);
}}
>
<FaDrawPolygon />
SVG
</Btn>
</Hint>
</Row> </Row>
<Row> <Row>
<Btn <Hint hint={"Download selected layer as PNG"}>
disabled={selected.length !== 1} <Btn
onClick={() => { disabled={selected.length !== 1}
const v = handler.imagesRenders.find((e) => e.id === selected[0].id); onClick={() => {
if (v) { const v = handler.imagesRenders.find((e) => e.id === selected[0].id);
const canvas = document.createElement("canvas"); if (v) {
canvas.width = v.image.naturalWidth * v.scale; const canvas = document.createElement("canvas");
canvas.height = v.image.naturalHeight * v.scale; canvas.width = v.image.naturalWidth * v.scale;
const ctx = canvas.getContext("2d")!; canvas.height = v.image.naturalHeight * v.scale;
ctx.drawImage(v.image, 0, 0, canvas.width, canvas.height); const ctx = canvas.getContext("2d")!;
canvas.toBlob((blob) => { ctx.drawImage(v.image, 0, 0, canvas.width, canvas.height);
if (blob) { canvas.toBlob((blob) => {
downloadBlob(blob, `${v.name}.png`); if (blob) {
} else { downloadBlob(blob, `${v.name}.png`);
handler.popup.alert("Error", "Cannot export image"); } else {
} handler.popup.alert("Error", "Cannot export image");
}, "image/png"); }
} else { }, "image/png");
handler.popup.alert("Error", "Image not found"); } 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"); <FaImage />
} Image
}} </Btn>
> </Hint>
{" "} <Hint hint={"Download SVG path for selected layer"}>
<FaDrawPolygon /> <Btn
SVG disabled={selected.length !== 1}
</Btn> 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>
</Hint>
</Row> </Row>
</Column> </Column>
</Box> </Box>
<Box $width={boxSize} $height={boxSize}> <Box $width={boxSize} $height={boxSize}>
<Column> <Column>
<Row> <Row>
<Btn <Hint hint={"Save entire project to a .ppd file"}>
onClick={() => { <Btn
handler.exportProject(); onClick={() => {
}} handler.exportProject();
> }}
<FaFileExport /> >
Export <FaFileExport />
</Btn> Export
<Btn </Btn>
onClick={() => { </Hint>
handler.importProject(); <Hint hint={"Load a project from a .ppd file"}>
}} <Btn
> onClick={() => {
<FaFileImport /> handler.importProject();
Import }}
</Btn> >
<FaFileImport />
Import
</Btn>
</Hint>
</Row> </Row>
<Btn <Hint hint={"Permanently wipe the canvas — cannot be undone"}>
style={{ backgroundColor: "#ff000073" }} <Btn
disabled={selected.length !== 1} style={{ backgroundColor: "#ff000073" }}
onClick={async () => { disabled={selected.length !== 1}
{ onClick={async () => {
if ( {
await handler.popup.confirm( if (
"Clear", await handler.popup.confirm(
"Are you sure you want to clear project? Any unsaved changes will be discarded", "Clear",
) "Are you sure you want to clear project? Any unsaved changes will be discarded",
) { )
handler.clear(); ) {
handler.clear();
}
} }
} }}
}} >
> <FaBroom />
<FaBroom /> Clear
Clear </Btn>
</Btn> </Hint>
</Column> </Column>
</Box> </Box>
</> </>
+1
View File
@@ -5,6 +5,7 @@ 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;
height: 15px;
width: 80px; width: 80px;
border: 1px solid ${({ $isValid }) => ($isValid === null ? "#888" : $isValid ? "#ffffff" : "#ef4444")}; border: 1px solid ${({ $isValid }) => ($isValid === null ? "#888" : $isValid ? "#ffffff" : "#ef4444")};
outline: none; outline: none;
+73 -44
View File
@@ -9,9 +9,9 @@ import { VerticalSlider } from "./vertical-slider";
import { TRANSPARENCY_THRESHOLD_DEFAULT } from "../svg-tracer"; import { TRANSPARENCY_THRESHOLD_DEFAULT } from "../svg-tracer";
import type { VectraceHandler } from "../handler/vectrace-handler"; import type { VectraceHandler } from "../handler/vectrace-handler";
import { clamp } from "lodash"; import { clamp } from "lodash";
import { Hint } from "./tooltip";
const Table = styled.table` const Table = styled.table`
margin: 2px;
padding-right: 4px; padding-right: 4px;
`; `;
@@ -31,7 +31,7 @@ const LayerPanel = styled.div`
flex-direction: column; flex-direction: column;
width: 100px; width: 100px;
overflow: auto; overflow: auto;
height: 100px; height: 97px;
`; `;
const ToggleButton = styled.button<{ $enabled: boolean }>` const ToggleButton = styled.button<{ $enabled: boolean }>`
@@ -67,60 +67,89 @@ export function SvgTracerOptions({ selected, handler }: { selected: ImageEditor[
return ( return (
<WrapperRow> <WrapperRow>
<VerticalSlider <Hint
max={255} hint={
min={0} "Transparency cutoff — pixels more transparent than this value are ignored when tracing. " +
value={options.transparencyThreshold ?? TRANSPARENCY_THRESHOLD_DEFAULT} "Lower = only opaque pixels traced, Higher = transparent pixels included in the path."
onLabelClick={async () => { }
const value = await handler.popup.prompt( >
"Transparency threshold", <VerticalSlider
"Enter transparency threshold between 0-255", max={255}
(options.transparencyThreshold ?? TRANSPARENCY_THRESHOLD_DEFAULT).toString(), min={0}
); value={options.transparencyThreshold ?? TRANSPARENCY_THRESHOLD_DEFAULT}
if (value) { onLabelClick={async () => {
const int = parseInt(value, 10); const value = await handler.popup.prompt(
if (!isNaN(int)) { "Transparency threshold",
setValue("transparencyThreshold", clamp(int, 0, 255)); "Enter transparency threshold between 0-255",
(options.transparencyThreshold ?? TRANSPARENCY_THRESHOLD_DEFAULT).toString(),
);
if (value) {
const int = parseInt(value, 10);
if (!isNaN(int)) {
setValue("transparencyThreshold", clamp(int, 0, 255));
}
} }
} }}
}} onChange={(value) => {
onChange={(value) => { setValue("transparencyThreshold", value);
setValue("transparencyThreshold", value); }}
}} />
/> </Hint>
<WrapperColumn> <WrapperColumn>
<Button <Button
$active={!updated} $active={!updated}
title="Draw"
onClick={() => { onClick={() => {
redraw(); redraw();
}} }}
> >
<FaBezierCurve /> <span>
<span>Redraw</span>
<FaBezierCurve style={{ paddingLeft: 10 }} />
</span>
</Button> </Button>
<WrapperRow> <WrapperRow>
<Table> <Table>
<tbody> <tbody>
<NumericInputWithLabelTable <Hint
name="Ltres" hint={
value={options.ltres} "Line threshold — how closely straight lines must follow the original shape. Lower = more accurate, more points."
onChange={(value) => setValue("ltres", value)} }
/> >
<NumericInputWithLabelTable <NumericInputWithLabelTable
name="Qtres" name="Ltres"
value={options.qtres} value={options.ltres}
onChange={(value) => setValue("qtres", value)} onChange={(value) => setValue("ltres", value)}
/> />
<NumericInputWithLabelTable </Hint>
name="Path omit" <Hint
value={options.pathomit} hint={
onChange={(value) => setValue("pathomit", value)} "Curve threshold — how closely curves must follow the original shape. Lower = smoother curves, more points."
/> }
<NumericInputWithLabelTable >
name="Round corners" <NumericInputWithLabelTable
value={options.roundcoords} name="Qtres"
onChange={(value) => setValue("roundcoords", value)} value={options.qtres}
/> onChange={(value) => setValue("qtres", value)}
/>
</Hint>
<Hint
hint={
"Ignore tiny paths smaller than this many pixels. Higher = cleaner output, but small details get dropped."
}
>
<NumericInputWithLabelTable
name="Path omit"
value={options.pathomit}
onChange={(value) => setValue("pathomit", value)}
/>
</Hint>
<Hint hint={"Decimal precision of path coordinates. Lower = smaller file, slightly less accurate."}>
<NumericInputWithLabelTable
name="Round corners"
value={options.roundcoords}
onChange={(value) => setValue("roundcoords", value)}
/>
</Hint>
</tbody> </tbody>
</Table> </Table>
<LayerPanel> <LayerPanel>
+72
View File
@@ -0,0 +1,72 @@
import { useState, useRef, useEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
interface HintProps {
hint: ReactNode;
children: ReactNode;
}
export function Hint({ hint, children }: HintProps) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0 });
const wrapperRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const updatePos = () => {
if (!wrapperRef.current?.children[0]) return;
const rect = wrapperRef.current.children[0].getBoundingClientRect();
setPos({
top: rect.bottom,
left: rect.left + rect.width / 2,
});
};
useEffect(() => {
if (!open || !tooltipRef.current) return;
const tip = tooltipRef.current.getBoundingClientRect();
const margin = 8;
setPos((prev) => ({
top: tip.top,
left: Math.min(Math.max(prev.left, tip.width / 2 + margin), window.innerWidth - tip.width / 2 - margin),
}));
}, [open]);
return (
<div
ref={wrapperRef}
style={{ display: "contents" }}
onMouseEnter={() => {
updatePos();
setOpen(true);
}}
onMouseLeave={() => setOpen(false)}
>
{children}
{open &&
createPortal(
<div
ref={tooltipRef}
style={{
position: "fixed",
top: pos.top,
left: pos.left,
transform: "translateX(-50%)",
background: "#141414",
border: "1px solid #4af626",
color: "#fff",
padding: "6px 10px",
fontSize: "12px",
maxWidth: "260px",
pointerEvents: "none",
zIndex: 9999,
whiteSpace: "pre-wrap",
}}
>
{hint}
</div>,
document.body,
)}
</div>
);
}
+1 -1
View File
@@ -116,7 +116,7 @@ export function VerticalSlider({ min = 0, max = 100, step = 1, onLabelClick, val
onLabelClick?.(); onLabelClick?.();
}} }}
> >
{value} {Math.floor((value / max) * 100)}%
</ValueLabel> </ValueLabel>
<Track min={min} max={max} step={step} value={value} $fillPercent={fillPercent} onChange={handleChange} /> <Track min={min} max={max} step={step} value={value} $fillPercent={fillPercent} onChange={handleChange} />
</SliderWrapper> </SliderWrapper>