diff --git a/package-lock.json b/package-lock.json index b4f5fc1..47e3607 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vectrace", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vectrace", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { "@stylistic/eslint-plugin": "^5.10.0", "@types/lodash": "^4.17.24", diff --git a/package.json b/package.json index 4829596..903d3c8 100644 --- a/package.json +++ b/package.json @@ -1,56 +1,56 @@ { - "name": "vectrace", - "private": true, - "version": "1.1.0", - "author": { - "name": "Terncode", - "email": "terncode@gmail.com", - "url": "https://terncode.dev" - }, - "type": "module", - "scripts": { - "dev": "vite --host", - "build": "tsc -b && vite build", - "lint": "eslint .", - "lint-fix": "eslint --fix \"src/**/*.{js,mjs,cjs,ts,jsx,tsx}\"", - "preview": "vite preview" - }, - "dependencies": { - "@stylistic/eslint-plugin": "^5.10.0", - "@types/lodash": "^4.17.24", - "eslint-plugin-react": "^7.37.5", - "express": "^5.2.1", - "fast-xml-builder": "^1.1.5", - "fast-xml-parser": "^5.7.1", - "imagetracerjs": "^1.2.6", - "jspdf": "^4.2.1", - "jszip": "^3.10.1", - "ldrs": "^1.1.9", - "localforage": "^1.10.0", - "lodash": "^4.18.1", - "pdf-lib": "^1.17.1", - "polygon-clipping": "^0.15.7", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-icons": "^5.6.0", - "styled-components": "^6.4.0", - "svg.js": "^2.7.1", - "svgson": "^5.3.1", - "uuid": "^14.0.0" - }, - "devDependencies": { - "@eslint/js": "^9.39.4", - "@types/node": "^24.12.2", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "eslint": "^9.39.4", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.2", - "eslint-plugin-styled-components-a11y": "^2.2.1", - "globals": "^17.4.0", - "typescript": "~6.0.2", - "typescript-eslint": "^8.58.0", - "vite": "^8.0.4" - } + "name": "vectrace", + "private": true, + "version": "1.1.q", + "author": { + "name": "Terncode", + "email": "terncode@gmail.com", + "url": "https://terncode.dev" + }, + "type": "module", + "scripts": { + "dev": "vite --host", + "build": "tsc -b && vite build", + "lint": "eslint .", + "lint-fix": "eslint --fix \"src/**/*.{js,mjs,cjs,ts,jsx,tsx}\"", + "preview": "vite preview" + }, + "dependencies": { + "@stylistic/eslint-plugin": "^5.10.0", + "@types/lodash": "^4.17.24", + "eslint-plugin-react": "^7.37.5", + "express": "^5.2.1", + "fast-xml-builder": "^1.1.5", + "fast-xml-parser": "^5.7.1", + "imagetracerjs": "^1.2.6", + "jspdf": "^4.2.1", + "jszip": "^3.10.1", + "ldrs": "^1.1.9", + "localforage": "^1.10.0", + "lodash": "^4.18.1", + "pdf-lib": "^1.17.1", + "polygon-clipping": "^0.15.7", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-icons": "^5.6.0", + "styled-components": "^6.4.0", + "svg.js": "^2.7.1", + "svgson": "^5.3.1", + "uuid": "^14.0.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "eslint-plugin-styled-components-a11y": "^2.2.1", + "globals": "^17.4.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.0", + "vite": "^8.0.4" + } } diff --git a/src/components/download-buttons.tsx b/src/components/download-buttons.tsx index e8fd9bc..f7ac90b 100644 --- a/src/components/download-buttons.tsx +++ b/src/components/download-buttons.tsx @@ -4,6 +4,7 @@ import { Button } from "../styles"; import styled from "styled-components"; import { createPdf, createSVGDoc, downloadBlob, downloadZip } from "../utils/download"; import type { ImageEditor } from "../handler/image-editor"; +import { Hint } from "./tooltip"; const Btn = styled(Button)` width: 100%; @@ -39,117 +40,132 @@ export function ImportExportButtons({ handler, selected }: CanvasHandlerProps & - { - downloadZip(handler); - }} - > - - ZIP - - { - createPdf(handler, true).save(`${handler.projectName}.pdf`); - }} - > - - PDF - - { - downloadBlob(await createSVGDoc(handler), `${handler.projectName}.svg`); - }} - > - - SVG - + + { + downloadZip(handler); + }} + > + + ZIP + + + + { + createPdf(handler, true).save(`${handler.projectName}.pdf`); + }} + > + + PDF + + + + { + downloadBlob(await createSVGDoc(handler), `${handler.projectName}.svg`); + }} + > + + SVG + + - { - 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"); - } - }} - > - - Image - - { - 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); + + { + 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"); } - downloadBlob(new Blob([v.svg.data], { type: "image/svg+xml" }), `${v.name}.svg`); - } else { - handler.popup.alert("Error", "Image not found"); - } - }} - > - {" "} - - SVG - + }} + > + + Image + + + + { + 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"); + } + }} + > + + SVG + + - { - handler.exportProject(); - }} - > - - Export - - { - handler.importProject(); - }} - > - - Import - + + { + handler.exportProject(); + }} + > + + Export + + + + { + handler.importProject(); + }} + > + + Import + + - { - { - if ( - await handler.popup.confirm( - "Clear", - "Are you sure you want to clear project? Any unsaved changes will be discarded", - ) - ) { - handler.clear(); + + { + { + if ( + await handler.popup.confirm( + "Clear", + "Are you sure you want to clear project? Any unsaved changes will be discarded", + ) + ) { + handler.clear(); + } } - } - }} - > - - Clear - + }} + > + + Clear + + diff --git a/src/components/number-input.tsx b/src/components/number-input.tsx index 50c5b4f..47f6f84 100644 --- a/src/components/number-input.tsx +++ b/src/components/number-input.tsx @@ -5,6 +5,7 @@ import { calculateMathExpression, canEvaluateMathExpression } from "../calc"; const StyledInput = styled.input<{ $isValid: boolean | null }>` padding: 1px; font-size: 1rem; + height: 15px; width: 80px; border: 1px solid ${({ $isValid }) => ($isValid === null ? "#888" : $isValid ? "#ffffff" : "#ef4444")}; outline: none; diff --git a/src/components/svg-tracer-options.tsx b/src/components/svg-tracer-options.tsx index e056609..9c9c166 100644 --- a/src/components/svg-tracer-options.tsx +++ b/src/components/svg-tracer-options.tsx @@ -9,9 +9,9 @@ import { VerticalSlider } from "./vertical-slider"; import { TRANSPARENCY_THRESHOLD_DEFAULT } from "../svg-tracer"; import type { VectraceHandler } from "../handler/vectrace-handler"; import { clamp } from "lodash"; +import { Hint } from "./tooltip"; const Table = styled.table` - margin: 2px; padding-right: 4px; `; @@ -31,7 +31,7 @@ const LayerPanel = styled.div` flex-direction: column; width: 100px; overflow: auto; - height: 100px; + height: 97px; `; const ToggleButton = styled.button<{ $enabled: boolean }>` @@ -67,60 +67,89 @@ export function SvgTracerOptions({ selected, handler }: { selected: ImageEditor[ return ( - { - const value = await handler.popup.prompt( - "Transparency threshold", - "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)); + + { + const value = await handler.popup.prompt( + "Transparency threshold", + "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) => { - setValue("transparencyThreshold", value); - }} - /> + }} + onChange={(value) => { + setValue("transparencyThreshold", value); + }} + /> + - setValue("ltres", value)} - /> - setValue("qtres", value)} - /> - setValue("pathomit", value)} - /> - setValue("roundcoords", value)} - /> + + setValue("ltres", value)} + /> + + + setValue("qtres", value)} + /> + + + setValue("pathomit", value)} + /> + + + setValue("roundcoords", value)} + /> +
diff --git a/src/components/tooltip.tsx b/src/components/tooltip.tsx new file mode 100644 index 0000000..ab2738d --- /dev/null +++ b/src/components/tooltip.tsx @@ -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(null); + const tooltipRef = useRef(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 ( +
{ + updatePos(); + setOpen(true); + }} + onMouseLeave={() => setOpen(false)} + > + {children} + {open && + createPortal( +
+ {hint} +
, + document.body, + )} +
+ ); +} diff --git a/src/components/vertical-slider.tsx b/src/components/vertical-slider.tsx index 547eab6..7fe0265 100644 --- a/src/components/vertical-slider.tsx +++ b/src/components/vertical-slider.tsx @@ -116,7 +116,7 @@ export function VerticalSlider({ min = 0, max = 100, step = 1, onLabelClick, val onLabelClick?.(); }} > - {value} + {Math.floor((value / max) * 100)}%