diff --git a/package-lock.json b/package-lock.json index 751b83efa..2e9c1597f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "imgtonum", "version": "0.0.0", "dependencies": { + "html-react-parser": "^5.2.11", "imagetracerjs": "^1.2.6", "lucide-react": "^0.562.0", "prop-types": "^15.8.1", @@ -2589,7 +2590,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3600,7 +3601,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/dargs": { @@ -3754,6 +3755,73 @@ "license": "MIT", "peer": true }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -3883,7 +3951,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -4712,6 +4779,16 @@ "dev": true, "license": "ISC" }, + "node_modules/html-dom-parser": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-5.1.2.tgz", + "integrity": "sha512-9nD3Rj3/FuQt83AgIa1Y3ruzspwFFA54AJbQnohXN+K6fL1/bhcDQJJY5Ne4L4A163ADQFVESd/0TLyNoV0mfg==", + "license": "MIT", + "dependencies": { + "domhandler": "5.0.3", + "htmlparser2": "10.0.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -4732,6 +4809,46 @@ "dev": true, "license": "MIT" }, + "node_modules/html-react-parser": { + "version": "5.2.11", + "resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-5.2.11.tgz", + "integrity": "sha512-WnSQVn/D1UTj64nSz5y8MriL+MrbsZH80Ytr1oqKqs8DGZnphWY1R1pl3t7TY3rpqTSu+FHA21P80lrsmrdNBA==", + "license": "MIT", + "dependencies": { + "domhandler": "5.0.3", + "html-dom-parser": "5.1.2", + "react-property": "2.0.2", + "style-to-js": "1.1.21" + }, + "peerDependencies": { + "@types/react": "0.14 || 15 || 16 || 17 || 18 || 19", + "react": "0.14 || 15 || 16 || 17 || 18 || 19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/htmlparser2": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -4849,6 +4966,12 @@ "dev": true, "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -6239,6 +6362,12 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.2.tgz", + "integrity": "sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==", + "license": "MIT" + }, "node_modules/react-router": { "version": "7.11.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz", @@ -7137,6 +7266,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index b831b3171..a0933a27e 100644 --- a/package.json +++ b/package.json @@ -161,6 +161,7 @@ "validate-scripts": "node scripts/validate-scripts.js" }, "dependencies": { + "html-react-parser": "^5.2.11", "imagetracerjs": "^1.2.6", "lucide-react": "^0.562.0", "prop-types": "^15.8.1", diff --git a/src/components/WasmImageProcessor.jsx b/src/components/WasmImageProcessor.jsx index 00889f906..bb0448010 100644 --- a/src/components/WasmImageProcessor.jsx +++ b/src/components/WasmImageProcessor.jsx @@ -123,7 +123,7 @@ const WasmImageProcessor = () => { step(100); navigate('/editor', { - state: { svg, imgData: { pixels: merged, width, height } }, + state: { svg }, }); } catch (err) { console.error(err); diff --git a/src/pages/Editor/Editor.module.css b/src/pages/Editor/Editor.module.css index b20fde10c..9baf9d011 100644 --- a/src/pages/Editor/Editor.module.css +++ b/src/pages/Editor/Editor.module.css @@ -1,19 +1,89 @@ -.svgContainer { +/* Viewport that handles pan/zoom (clipped) */ +.viewport { position: relative; - width: 100%; - aspect-ratio: 1 / 1; /* or width/height ratio of your image */ overflow: hidden; -} -.svgContainer svg { + transition: + transform 180ms ease, + box-shadow 180ms ease; + min-height: 360px; width: 100%; height: 100%; + max-height: 90vh; + cursor: grab; + background: transparent; } - /* Default path appearance */ -.svgContainer svg path:not(#svgContainerColouredPath) { - stroke: var(--color-text); - stroke-width: 0.25; +.colorMode .viewport svg path:not(.coloredRegion) { fill: white; cursor: pointer; transition: 150ms ease; } + +/* while dragging */ +.viewport.grabbing { + cursor: grabbing; +} + +/* Inner wrapper where we apply transform(scale/translate) */ +.inner { + transform-origin: 0 0; + will-change: transform; + transition: transform 50ms linear; + display: inline-block; +} + +/* Preserve the svg natural sizing — do not force fill/fit */ +.inner svg { + display: block; +} + +/* Enable interaction on primitives */ +/* Default path appearance: keep original fills untouched by CSS (we manipulate via JS) */ +.inner svg path, +.inner svg rect, +.inner svg circle, +.inner svg polygon, +.inner svg g { + pointer-events: all; + + stroke: #000; + stroke-width: 0.5; + cursor: pointer; + transition: + stroke 80ms ease, + stroke-width 80ms ease, + opacity 80ms ease; + opacity: 0.98; +} + +/* hover outline only (doesn't override fills) */ +.inner svg path:hover, +.inner svg rect:hover, +.inner svg circle:hover, +.inner svg polygon:hover, +.inner svg g:hover { + stroke: var(--color-primary); + stroke-width: 0.9; +} + +/* preview mode: disable interaction but keep visual state */ +.previewMode svg path, +.previewMode svg rect, +.previewMode svg circle, +.previewMode svg polygon, +.previewMode svg g { + pointer-events: none; +} + +.hint { + margin-top: 12px; + font-size: 0.85rem; + text-align: center; +} + +/* responsiveness */ +@media (max-width: 800px) { + .viewport { + min-height: 240px; + } +} diff --git a/src/pages/Editor/index.jsx b/src/pages/Editor/index.jsx index ccc0373f9..7fb289aad 100644 --- a/src/pages/Editor/index.jsx +++ b/src/pages/Editor/index.jsx @@ -1,12 +1,93 @@ +import { useRef, useState } from 'react'; +import parse from 'html-react-parser'; import { useLocation } from 'react-router-dom'; +import { Eye, Brush } from 'lucide-react'; import GlassCard from '@components/GlassCard'; import styles from './Editor.module.css'; import EditorHelmet from './EditorHelmet'; +import GlassSwitch from '@components/GlassSwitch'; + +// shape selector we care about +const SHAPE_SELECTOR = 'path,rect,circle,polygon,ellipse'; export default function Editor() { const { state } = useLocation(); - console.log(state); const { svg } = state || {}; + const [svgElements] = useState(() => (svg ? parse(svg) : null)); + const [isColorMode, setIsColorMode] = useState(true); + const viewportRef = useRef(null); + const innerRef = useRef(null); + const [transform, setTransform] = useState({ scale: 1, tx: 0, ty: 0 }); + const pointerState = useRef({ dragging: false, lastX: 0, lastY: 0, moving: false }); + + // Wheel zoom (keeps tx/ty; can be improved later to zoom to pointer) + const handleWheel = (e) => { + const delta = Math.sign(e.deltaY); + setTransform((t) => { + const step = 1.12; + let nextScale = delta > 0 ? t.scale / step : t.scale * step; + nextScale = Math.max(0.25, Math.min(6, nextScale)); + return { ...t, scale: nextScale }; + }); + }; + + // Panning handlers (pointer events) + const onPointerDown = (e) => { + if (!viewportRef.current) return; + pointerState.current.dragging = true; + pointerState.current.moved = false; + pointerState.current.lastX = e.clientX; + pointerState.current.lastY = e.clientY; + try { + viewportRef.current.setPointerCapture(e.pointerId); + } catch {} + viewportRef.current.classList.add(styles.grabbing); + }; + + const onPointerMove = (e) => { + if (!pointerState.current.dragging) return; + const dx = e.clientX - pointerState.current.lastX; + const dy = e.clientY - pointerState.current.lastY; + + // mark as moved if threshold exceeded + if (!pointerState.current.moved && Math.hypot(dx, dy) > 5) { + pointerState.current.moved = true; + } + + pointerState.current.lastX = e.clientX; + pointerState.current.lastY = e.clientY; + + if (pointerState.current.moved) { + setTransform((t) => ({ ...t, tx: t.tx + dx, ty: t.ty + dy })); + } + }; + + const onPointerUp = (e) => { + const moved = pointerState.current.moved; + pointerState.current.dragging = false; + try { + viewportRef.current.releasePointerCapture(e.pointerId); + } catch {} + viewportRef.current?.classList.remove(styles.grabbing); + + // Only treat as click if user didn't drag + if (!moved && isColorMode) { + // Find nearest shape inside SVG + const svgRoot = innerRef.current?.querySelector('svg'); + if (!svgRoot) return; + + const shape = document.elementFromPoint(e.clientX, e.clientY)?.closest(SHAPE_SELECTOR); + if (!shape || !svgRoot.contains(shape)) return; + + // Apply your color class + shape.classList.add(styles.coloredRegion); + } + }; + + const cardClass = isColorMode ? styles.colorMode : styles.previewMode; + const transformStyle = { + transform: `translate(${transform.tx}px, ${transform.ty}px) scale(${transform.scale})`, + }; if (!svg) { return ( @@ -21,16 +102,35 @@ export default function Editor() { <> - -
{ - if (e.target.tagName === 'path') { - e.target.id = styles.svgContainerColouredPath; - } - }} - /> + +
+ { + setIsColorMode((prev) => !prev); + }} + ariaLabel={`Switch to ${isColorMode ? 'preview' : 'color'} mode`} + thumbContent={isColorMode ? : } + /> +
+ + +
+ {svgElements} +
+
+ +
+ Click shapes to reveal their original colour. Use mouse wheel to zoom, drag to pan. Switching modes preserves + fills. +
);