Add more control

This commit is contained in:
2026-06-29 09:41:02 +02:00
parent 2e62d2d810
commit 8207e84fa2
11 changed files with 890 additions and 56 deletions
+119 -27
View File
@@ -2,42 +2,134 @@ import { useEffect, useState } from "react";
import type { ImageEditor } from "../handler/image-editor";
import type { TracerOptions } from "../interface";
import { NumericInputWithLabelTable } from "./numeric-labeld-input";
import styled from "styled-components";
import { Button } from "../styles";
import { FaBezierCurve } from "react-icons/fa6";
import { VerticalSlider } from "./vertical-slider";
import { TRANSPARENCY_THRESHOLD_DEFAULT } from "../svg-tracer";
import type { VectraceHandler } from "../handler/vectrace-handler";
import { clamp } from "lodash";
const Table = styled.table`
margin: 2px;
padding-right: 4px;
`;
const WrapperColumn = styled.div`
display: flex;
flex-direction: column;
`;
const WrapperRow = styled.div`
display: flex;
flex-direction: row;
height: 100px;
`;
const LayerPanel = styled.div`
display: flex;
flex-direction: column;
width: 100px;
overflow: auto;
height: 100px;
`;
const ToggleButton = styled.button<{ $enabled: boolean }>`
background-color: ${({ $enabled }) => $enabled ? "var(--secondary-color)" : "var(--primary-color)"};
color: white;
border: 1px solid white;
&:hover {
color: var(--secondary-color);
background-color: white;
}
`;
export function SvgTracerOptions({ selected }: { selected: ImageEditor[] }) {
export function SvgTracerOptions({ selected, handler }: { selected: ImageEditor[], handler: VectraceHandler }) {
const [options, setOptions] = useState(selected[0]?.tracerOptions ?? {});
const [updated, setUpdated] = useState(false);
useEffect(() => {
setOptions(selected[0]?.tracerOptions ?? {});
}, [selected.map(e => e.id).join("")]);
setUpdated(true);
}, [selected.map(e => `${e.id}${JSON.stringify(e.svgData.options)}`).join("")]);
const setValue = (key: keyof TracerOptions, value: number) => {
setOptions({ ...options, [key]: value });
const redraw = () => {
selected.forEach(e => {
e.setTracerSvg({ ...options, [key]: value });
e.setTracerSvg({ ...options });
});
setUpdated(true);
};
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>;
const setValue = (key: keyof TracerOptions, value: any) => {
setOptions({ ...options, [key]: value });
setUpdated(false);
};
return <WrapperRow>
<VerticalSlider
max={255}
min={0}
value={options.transparencyThreshold ?? TRANSPARENCY_THRESHOLD_DEFAULT}
onLabelClick={async () => {
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);
}} />
<WrapperColumn>
<Button $active={!updated} title="Draw" onClick={() => { redraw(); }}><FaBezierCurve /></Button>
<WrapperRow>
<Table>
<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>
</Table >
<LayerPanel>
{(options.allowedPaths || []).map((e, i) => {
return <ToggleButton
key={i}
$enabled={e}
onClick={() => {
const array = options?.allowedPaths ? [...options.allowedPaths] : [];
array[i] = !array[i];
setValue("allowedPaths", array);
}}
>
{`${i + 1} Layer`}
</ToggleButton>;
})}
</LayerPanel>
</WrapperRow>
</WrapperColumn></WrapperRow>;
}
+137
View File
@@ -0,0 +1,137 @@
import React, { useState } from "react";
import styled from "styled-components";
const SliderWrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 120px;
width: 40px;
gap: 8px;
`;
interface SliderProps {
min?: number;
max?: number;
step?: number;
value?: number;
onChange?: (value: number) => void;
}
interface TrackTransientProps {
$fillPercent: number;
}
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;
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 {
width: 22px;
height: 22px;
border-radius: 0;
background: #ffffff;
border: 3px solid #ff4444;
box-shadow: 0 0 6px rgba(255, 68, 68, 0.6);
cursor: grab;
transition: transform 0.1s ease;
&:active {
cursor: grabbing;
transform: scale(1.2);
}
}
&::-moz-range-track {
background: transparent;
}
`;
const ValueLabel = styled.span`
font-size: 13px;
color: white;
letter-spacing: 0.05em;
`;
interface SliderProps {
min?: number;
max?: number;
step?: number;
value?: number;
onLabelClick?: () => void;
onChange?: (value: number) => void;
}
export function VerticalSlider({
min = 0,
max = 100,
step = 1,
onLabelClick,
value: controlledValue,
onChange,
}: SliderProps) {
const [internalValue, setInternalValue] = useState(
controlledValue ?? Math.floor((max - min) / 2)
);
const value = controlledValue ?? internalValue;
const fillPercent = ((value - min) / (max - min)) * 100;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const next = Number(e.target.value);
setInternalValue(next);
onChange?.(next);
};
return (
<SliderWrapper>
<ValueLabel onDoubleClick={() => {
onLabelClick?.();
}}>{value}</ValueLabel>
<Track
min={min}
max={max}
step={step}
value={value}
$fillPercent={fillPercent}
onChange={handleChange}
/>
</SliderWrapper>
);
};
+3
View File
@@ -46,6 +46,9 @@ export class ImageEditor {
get svg() {
return this.query().svg.data;
}
get svgData() {
return this.query().svg;
}
setName(name: string) {
this.onPropsChange({ name });
}
+6 -8
View File
@@ -7,7 +7,7 @@ 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 { imageToOutlineSVG } from "../svg-tracer";
import { urlToImage, canvasToBlob, imageToCanvas } from "../utils/image";
import { loadFileAsDataUrl, makeFilenameSafe } from "../utils/generic";
import { v4 as uuidv4 } from "uuid";
@@ -242,9 +242,7 @@ export class VectraceHandler {
async importFile(file: File) {
const buffer = await loadFileAsDataUrl(file);
const imageElement = await urlToImage(buffer);
const options: TracerOptions = {};
const svg = imageToLaserSVG(imageElement, {
...options,
const { svg, options } = imageToOutlineSVG(imageElement, {
scale: 1,
strokewidth: this.STROKE_WIDTH
});
@@ -375,17 +373,17 @@ export class VectraceHandler {
const base = images.image;
const scale = rendered.scale;
const options: TracerOptions = {
const oldOptions: TracerOptions = {
...rendered.svg.options,
scale,
strokewidth: this.STROKE_WIDTH
};
const prevOptions = cloneDeep(options);
const prevOptions = cloneDeep(oldOptions);
const svg = imageToLaserSVG(rendered.image, options);
const { svg, options } = imageToOutlineSVG(rendered.image, oldOptions);
const dirty = !isEqual(prevOptions, options);
const dirty = !isEqual(prevOptions, oldOptions);
rendered.svg.dirty = dirty;
base.svg.dirty = dirty;
+33 -1
View File
@@ -3,6 +3,7 @@
--primary-color: #474747;
--secondary-color: #252525;
}
@font-face {
font-family: 'tiny5';
src: url('./assets/fonts/Tiny5-Regular.ttf') format('truetype');
@@ -13,6 +14,8 @@
margin: 0;
padding: 0;
font-family: tiny5;
scrollbar-width: thin;
scrollbar-color: var(--primary-color) var(--secondary-color);
}
html,body,#root {
@@ -27,4 +30,33 @@ html,body,#root {
#root {
display: flex;
flex-direction: column;
}
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--secondary-color);
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--primary-color);
border-radius: 10px;
border: 2px solid var(--secondary-color);
transition: background 0.3s ease;
}
::-webkit-scrollbar-thumb:hover {
background: #6a6a6a;
}
::-webkit-scrollbar-thumb:active {
background: #888888;
}
::-webkit-scrollbar-corner {
background: var(--secondary-color);
}
+2 -1
View File
@@ -6,11 +6,12 @@ export interface ObjectID {
export interface TracerOptions extends ImageTracerOptions {
transparencyThreshold?: number;
allowedPaths?: boolean[];
}
export interface SvgData {
svg: string;
normalizedSvg: string;
options: TracerOptions;
}
export interface ProjectConfig {
+3 -3
View File
@@ -13,7 +13,7 @@ import { ImportExportButtons } from "./components/download-buttons";
const Bar = styled.div`
width: 100%;
overflow: auto;
height: 110px;
height: 125px;
background-color: var(--primary-color);
display: flex;
flex-direction: row;
@@ -24,7 +24,7 @@ const ImageBox = styled.table`
padding-right: 4px;
`;
const SvgOption = styled.table`
const SvgOption = styled.div`
margin: 2px;
padding-right: 4px;
`;
@@ -92,7 +92,7 @@ export function NavBar({ handler }: CanvasHandlerProps) {
</tbody>
</ImageBox>
<SvgOption style={style}>
<SvgTracerOptions selected={selected} />
<SvgTracerOptions selected={selected} handler={handler} />
</SvgOption>
<Line />
<ImportExportButtons handler={handler} selected={selected} />
+25 -15
View File
@@ -1,9 +1,9 @@
import ImageTracer from "imagetracerjs";
import type { TracerOptions } from "./interface";
import type { SvgData, TracerOptions } from "./interface";
import { VERSION } from "./constants";
export function imageToBlackWhiteImageData(img: HTMLImageElement, transparencyThreshold: number = 50): ImageData {
export const TRANSPARENCY_THRESHOLD_DEFAULT = 50;
export function imageToBlackWhiteImageData(img: HTMLImageElement, transparencyThreshold: number = TRANSPARENCY_THRESHOLD_DEFAULT): ImageData {
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
@@ -27,16 +27,19 @@ export function imageToBlackWhiteImageData(img: HTMLImageElement, transparencyTh
return imageData;
}
export function imageToLaserSVG(img: HTMLImageElement, options?: TracerOptions): string {
const bwData = imageToBlackWhiteImageData(img, options?.transparencyThreshold);
const rawSVG = ImageTracer.imagedataToSVG(bwData, {
export function imageToOutlineSVG(img: HTMLImageElement, options?: TracerOptions): SvgData {
const newOptions: TracerOptions = {
scale: 1,
...options,
viewbox: true,
desc: false,
layering: 0
});
};
const bwData = imageToBlackWhiteImageData(img, newOptions?.transparencyThreshold);
const rawSVG = ImageTracer.imagedataToSVG(bwData, newOptions);
const parser = new DOMParser();
const doc = parser.parseFromString(rawSVG, "image/svg+xml");
@@ -44,21 +47,28 @@ export function imageToLaserSVG(img: HTMLImageElement, options?: TracerOptions):
const [_, __, width, height] = svg.getAttribute("viewBox")!.split(" ");
svg.setAttribute("width", width);
svg.setAttribute("height", height);
svg.setAttribute("desc", `Created with pathshop ${VERSION}v`);
svg.setAttribute("desc", `Created with vectrace ${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) {
const allowedPaths = newOptions?.allowedPaths ? paths.map((_, i) => newOptions?.allowedPaths?.[i] ?? true) : paths.map(() => true);
for (let i = 0; i < paths.length; i++) {
const path = paths[i];
path.setAttribute("fill", "none");
path.setAttribute("opacity", "1");
path.setAttribute("stroke", "#000000");
if (longest === path) {
if (!allowedPaths[i]) {
path.remove();
}
}
return new XMLSerializer().serializeToString(doc);
if (newOptions) {
newOptions.allowedPaths = allowedPaths;
}
return {
svg: new XMLSerializer().serializeToString(doc),
options: newOptions,
};
}