Add more control
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user