a quick try at a tool for painting
From yesterday night, just finished some final touches; should be enough for some cool things. Signed-off-by: Victor Seiji Hariki <victorseijih@gmail.com>
This commit is contained in:
parent
9dcef66c21
commit
c63003e1cf
12 changed files with 370 additions and 57 deletions
|
@ -210,16 +210,19 @@
|
|||
|
||||
<!-- Content -->
|
||||
<script src="js/index.js" type="text/javascript"></script>
|
||||
<script src="js/shortcuts.js" type="text/javascript"></script>
|
||||
<script src="js/ui/floating/history.js" type="text/javascript"></script>
|
||||
|
||||
<!-- Load Tools -->
|
||||
<script src="js/ui/tool/dream.js" type="text/javascript"></script>
|
||||
<script src="js/ui/tool/maskbrush.js" type="text/javascript"></script>
|
||||
<script src="js/ui/tool/colorbrush.js" type="text/javascript"></script>
|
||||
<script src="js/ui/tool/select.js" type="text/javascript"></script>
|
||||
<script src="js/ui/tool/stamp.js" type="text/javascript"></script>
|
||||
|
||||
<!-- Initialize -->
|
||||
<script
|
||||
src="js/initalize/shortcuts.populate.js"
|
||||
type="text/javascript"></script>
|
||||
<script
|
||||
src="js/initalize/toolbar.populate.js"
|
||||
type="text/javascript"></script>
|
||||
|
|
|
@ -48,6 +48,7 @@ var stableDiffusionData = {
|
|||
};
|
||||
|
||||
// stuff things use
|
||||
let debug = false;
|
||||
var returnedImages;
|
||||
var imageIndex = 0;
|
||||
var tmpImgXYWH = {};
|
||||
|
@ -126,6 +127,7 @@ function testHostConfiguration() {
|
|||
* Check host configuration
|
||||
*/
|
||||
const hostEl = document.getElementById("host");
|
||||
hostEl.value = localStorage.getItem("host");
|
||||
|
||||
const requestHost = (prompt, def = "http://127.0.0.1:7860") => {
|
||||
let value = window.prompt(prompt, def);
|
||||
|
@ -549,7 +551,9 @@ function changeSnapMode() {
|
|||
}
|
||||
|
||||
function changeMaskBlur() {
|
||||
stableDiffusionData.mask_blur = document.getElementById("maskBlur").value;
|
||||
stableDiffusionData.mask_blur = parseInt(
|
||||
document.getElementById("maskBlur").value
|
||||
);
|
||||
localStorage.setItem("mask_blur", stableDiffusionData.mask_blur);
|
||||
}
|
||||
|
||||
|
|
|
@ -24,6 +24,11 @@ mouse.listen.world.onmousemove.on((evn) => {
|
|||
*/
|
||||
const toggledebug = () => {
|
||||
const hidden = debugCanvas.style.display === "none";
|
||||
if (hidden) debugLayer.unhide();
|
||||
else debugLayer.hide();
|
||||
if (hidden) {
|
||||
debugLayer.unhide();
|
||||
debug = true;
|
||||
} else {
|
||||
debugLayer.hide();
|
||||
debug = false;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -54,7 +54,7 @@ mouse.registerContext(
|
|||
const target = evn.target;
|
||||
|
||||
// Get element bounding rect
|
||||
const bb = target.getBoundingClientRect();
|
||||
const bb = imageCollection.element.getBoundingClientRect();
|
||||
|
||||
// Get element width/height (css, cause I don't trust client sizes in chrome anymore)
|
||||
const w = imageCollection.size.w;
|
||||
|
@ -148,12 +148,14 @@ mouse.listen.window.onwheel.on((evn) => {
|
|||
|
||||
viewport.transform(imageCollection.element);
|
||||
|
||||
if (debug) {
|
||||
debugCtx.clearRect(0, 0, debugCanvas.width, debugCanvas.height);
|
||||
debugCtx.fillStyle = "#F0F";
|
||||
debugCtx.beginPath();
|
||||
debugCtx.arc(viewport.cx, viewport.cy, 5, 0, Math.PI * 2);
|
||||
debugCtx.fill();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mouse.listen.window.btn.middle.onpaintstart.on((evn) => {
|
||||
|
@ -173,11 +175,13 @@ mouse.listen.window.btn.middle.onpaint.on((evn) => {
|
|||
}
|
||||
|
||||
viewport.transform(imageCollection.element);
|
||||
if (debug) {
|
||||
debugCtx.clearRect(0, 0, debugCanvas.width, debugCanvas.height);
|
||||
debugCtx.fillStyle = "#F0F";
|
||||
debugCtx.beginPath();
|
||||
debugCtx.arc(viewport.cx, viewport.cy, 5, 0, Math.PI * 2);
|
||||
debugCtx.fill();
|
||||
}
|
||||
});
|
||||
|
||||
mouse.listen.window.btn.middle.onpaintend.on((evn) => {
|
||||
|
|
|
@ -14,6 +14,9 @@ keyboard.onShortcut({key: "KeyD"}, () => {
|
|||
keyboard.onShortcut({key: "KeyM"}, () => {
|
||||
tools.maskbrush.enable();
|
||||
});
|
||||
keyboard.onShortcut({key: "KeyC"}, () => {
|
||||
tools.colorbrush.enable();
|
||||
});
|
||||
keyboard.onShortcut({key: "KeyI"}, () => {
|
||||
tools.img2img.enable();
|
||||
});
|
|
@ -15,6 +15,7 @@ toolbar.addSeparator();
|
|||
* Mask Brush tool
|
||||
*/
|
||||
tools.maskbrush = maskBrushTool();
|
||||
tools.colorbrush = colorBrushTool();
|
||||
|
||||
/**
|
||||
* Image Editing tools
|
||||
|
|
|
@ -278,7 +278,30 @@ commands.createCommand(
|
|||
}
|
||||
|
||||
// Apply command
|
||||
state.context.clearRect(state.box.x, state.box.y, state.box.w, state.box.h);
|
||||
const style = state.context.fillStyle;
|
||||
state.context.fillStyle = "black";
|
||||
|
||||
const op = state.context.globalCompositeOperation;
|
||||
state.context.globalCompositeOperation = "destination-out";
|
||||
|
||||
if (options.mask)
|
||||
state.context.drawImage(
|
||||
options.mask,
|
||||
state.box.x,
|
||||
state.box.y,
|
||||
state.box.w,
|
||||
state.box.h
|
||||
);
|
||||
else
|
||||
state.context.fillRect(
|
||||
state.box.x,
|
||||
state.box.y,
|
||||
state.box.w,
|
||||
state.box.h
|
||||
);
|
||||
|
||||
state.context.fillStyle = style;
|
||||
state.context.globalCompositeOperation = op;
|
||||
},
|
||||
(title, state) => {
|
||||
// Clear destination area
|
||||
|
|
|
@ -288,7 +288,7 @@ window.addEventListener(
|
|||
window.addEventListener(
|
||||
"mousemove",
|
||||
(evn) => {
|
||||
mouse._contexts.forEach((context) => {
|
||||
mouse._contexts.forEach(async (context) => {
|
||||
const target = context.target;
|
||||
const name = context.name;
|
||||
|
||||
|
|
|
@ -172,13 +172,30 @@ const layers = {
|
|||
* Moves this layer to another location
|
||||
*
|
||||
* @param {number} x X coordinate of the top left of the canvas
|
||||
* @param {number} y X coordinate of the top left of the canvas
|
||||
* @param {number} y Y coordinate of the top left of the canvas
|
||||
*/
|
||||
moveTo(x, y) {
|
||||
canvas.style.left = `${x}px`;
|
||||
canvas.style.top = `${y}px`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resizes layer in place
|
||||
*
|
||||
* @param {number} w New width
|
||||
* @param {number} h New height
|
||||
*/
|
||||
resize(w, h) {
|
||||
canvas.width = Math.round(
|
||||
options.resolution.w * (w / options.bb.w)
|
||||
);
|
||||
canvas.height = Math.round(
|
||||
options.resolution.h * (h / options.bb.h)
|
||||
);
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
},
|
||||
|
||||
// Hides this layer (don't draw)
|
||||
hide() {
|
||||
this.canvas.style.display = "none";
|
||||
|
|
|
@ -197,57 +197,58 @@ function getBoundingBox(cx, cy, w, h, gridSnap = null) {
|
|||
};
|
||||
}
|
||||
|
||||
class NoContentError extends Error {}
|
||||
|
||||
/**
|
||||
* Crops a given canvas to content, returning a new canvas object with the content in it.
|
||||
*
|
||||
* @param {HTMLCanvasElement} sourceCanvas Canvas to get a content crop from
|
||||
* @returns {HTMLCanvasElement} A new canvas with the cropped part of the image
|
||||
* @param {object} options Extra options
|
||||
* @param {number} [options.border=0] Extra border around the content
|
||||
* @returns {{canvas: HTMLCanvasElement, bb: BoundingBox}} A new canvas with the cropped part of the image
|
||||
*/
|
||||
function cropCanvas(sourceCanvas) {
|
||||
var w = sourceCanvas.width;
|
||||
var h = sourceCanvas.height;
|
||||
var pix = {x: [], y: []};
|
||||
var imageData = sourceCanvas.getContext("2d").getImageData(0, 0, w, h);
|
||||
var x, y, index;
|
||||
function cropCanvas(sourceCanvas, options = {}) {
|
||||
defaultOpt(options, {border: 0});
|
||||
|
||||
for (y = 0; y < h; y++) {
|
||||
for (x = 0; x < w; x++) {
|
||||
const w = sourceCanvas.width;
|
||||
const h = sourceCanvas.height;
|
||||
var imageData = sourceCanvas.getContext("2d").getImageData(0, 0, w, h);
|
||||
/** @type {BoundingBox} */
|
||||
const bb = {x: 0, y: 0, w: 0, h: 0};
|
||||
|
||||
let minx = w;
|
||||
let maxx = -1;
|
||||
let miny = h;
|
||||
let maxy = -1;
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
// lol i need to learn what this part does
|
||||
index = (y * w + x) * 4; // OHHH OK this is setting the imagedata.data uint8clampeddataarray index for the specified x/y coords
|
||||
const index = (y * w + x) * 4; // OHHH OK this is setting the imagedata.data uint8clampeddataarray index for the specified x/y coords
|
||||
//this part i get, this is checking that 4th RGBA byte for opacity
|
||||
if (imageData.data[index + 3] > 0) {
|
||||
pix.x.push(x);
|
||||
pix.y.push(y);
|
||||
minx = Math.min(minx, x);
|
||||
maxx = Math.max(maxx, x);
|
||||
miny = Math.min(miny, y);
|
||||
maxy = Math.max(maxy, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ...need to learn what this part does too :badpokerface:
|
||||
// is this just determining the boundaries of non-transparent pixel data?
|
||||
pix.x.sort(function (a, b) {
|
||||
return a - b;
|
||||
});
|
||||
pix.y.sort(function (a, b) {
|
||||
return a - b;
|
||||
});
|
||||
var n = pix.x.length - 1;
|
||||
w = pix.x[n] - pix.x[0] + 1;
|
||||
h = pix.y[n] - pix.y[0] + 1;
|
||||
// yup sure looks like it
|
||||
|
||||
try {
|
||||
var cut = sourceCanvas
|
||||
.getContext("2d")
|
||||
.getImageData(pix.x[0], pix.y[0], w, h);
|
||||
bb.x = minx - options.border;
|
||||
bb.y = miny - options.border;
|
||||
bb.w = maxx - minx + 2 * options.border;
|
||||
bb.h = maxy - miny + 2 * options.border;
|
||||
|
||||
if (maxx < 0) throw new NoContentError("Canvas has no content to crop");
|
||||
|
||||
var cutCanvas = document.createElement("canvas");
|
||||
cutCanvas.width = w;
|
||||
cutCanvas.height = h;
|
||||
cutCanvas.getContext("2d").putImageData(cut, 0, 0);
|
||||
} catch (ex) {
|
||||
// probably empty image
|
||||
//TODO confirm edge cases?
|
||||
cutCanvas = null;
|
||||
}
|
||||
return cutCanvas;
|
||||
cutCanvas.width = bb.w;
|
||||
cutCanvas.height = bb.h;
|
||||
cutCanvas
|
||||
.getContext("2d")
|
||||
.drawImage(sourceCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h);
|
||||
return {canvas: cutCanvas, bb};
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -276,7 +277,7 @@ function downloadCanvas(options = {}) {
|
|||
if (options.filename) link.download = options.filename;
|
||||
|
||||
var croppedCanvas = options.cropToContent
|
||||
? cropCanvas(options.canvas)
|
||||
? cropCanvas(options.canvas).canvas
|
||||
: options.canvas;
|
||||
if (croppedCanvas != null) {
|
||||
link.href = croppedCanvas.toDataURL("image/png");
|
||||
|
|
247
js/ui/tool/colorbrush.js
Normal file
247
js/ui/tool/colorbrush.js
Normal file
|
@ -0,0 +1,247 @@
|
|||
const _color_brush_draw_callback = (evn, state) => {
|
||||
const ctx = state.drawLayer.ctx;
|
||||
|
||||
ctx.strokeStyle = state.color;
|
||||
|
||||
ctx.filter = "blur(" + state.brushBlur + "px)";
|
||||
ctx.lineWidth = state.brushSize;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(
|
||||
evn.px === undefined ? evn.x : evn.px,
|
||||
evn.py === undefined ? evn.y : evn.py
|
||||
);
|
||||
ctx.lineTo(evn.x, evn.y);
|
||||
ctx.lineJoin = ctx.lineCap = "round";
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const _color_brush_erase_callback = (evn, state, ctx) => {
|
||||
ctx.strokeStyle = "black";
|
||||
|
||||
ctx.lineWidth = state.brushSize;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(
|
||||
evn.px === undefined ? evn.x : evn.px,
|
||||
evn.py === undefined ? evn.y : evn.py
|
||||
);
|
||||
ctx.lineTo(evn.x, evn.y);
|
||||
ctx.lineJoin = ctx.lineCap = "round";
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const colorBrushTool = () =>
|
||||
toolbar.registerTool(
|
||||
"res/icons/brush.svg",
|
||||
"Color Brush",
|
||||
(state, opt) => {
|
||||
// Draw new cursor immediately
|
||||
ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height);
|
||||
state.movecb({...mouse.coords.world.pos});
|
||||
state.drawLayer = imageCollection.registerLayer(null, {
|
||||
after: imgLayer,
|
||||
});
|
||||
state.eraseLayer = imageCollection.registerLayer(null, {
|
||||
after: imgLayer,
|
||||
});
|
||||
state.eraseLayer.canvas.style.display = "none";
|
||||
state.eraseBackup = imageCollection.registerLayer(null, {
|
||||
after: imgLayer,
|
||||
});
|
||||
state.eraseBackup.canvas.style.display = "none";
|
||||
|
||||
// Start Listeners
|
||||
mouse.listen.world.onmousemove.on(state.movecb);
|
||||
mouse.listen.world.onwheel.on(state.wheelcb);
|
||||
|
||||
mouse.listen.world.btn.left.onpaintstart.on(state.drawstartcb);
|
||||
mouse.listen.world.btn.left.onpaint.on(state.drawcb);
|
||||
mouse.listen.world.btn.left.onpaintend.on(state.drawendcb);
|
||||
|
||||
mouse.listen.world.btn.right.onpaintstart.on(state.erasestartcb);
|
||||
mouse.listen.world.btn.right.onpaint.on(state.erasecb);
|
||||
mouse.listen.world.btn.right.onpaintend.on(state.eraseendcb);
|
||||
|
||||
// Display Color
|
||||
setMask("none");
|
||||
},
|
||||
(state, opt) => {
|
||||
// Clear Listeners
|
||||
mouse.listen.world.onmousemove.clear(state.movecb);
|
||||
mouse.listen.world.onwheel.clear(state.wheelcb);
|
||||
|
||||
mouse.listen.world.btn.left.onpaintstart.clear(state.drawstartcb);
|
||||
mouse.listen.world.btn.left.onpaint.clear(state.drawcb);
|
||||
mouse.listen.world.btn.left.onpaintend.clear(state.drawendcb);
|
||||
|
||||
mouse.listen.world.btn.right.onpaintstart.clear(state.erasestartcb);
|
||||
mouse.listen.world.btn.right.onpaint.clear(state.erasecb);
|
||||
mouse.listen.world.btn.right.onpaintend.clear(state.eraseendcb);
|
||||
|
||||
// Delete layer
|
||||
imageCollection.deleteLayer(state.drawLayer);
|
||||
imageCollection.deleteLayer(state.eraseBackup);
|
||||
imageCollection.deleteLayer(state.eraseLayer);
|
||||
},
|
||||
{
|
||||
init: (state) => {
|
||||
state.config = {
|
||||
brushScrollSpeed: 1 / 5,
|
||||
minBrushSize: 2,
|
||||
maxBrushSize: 500,
|
||||
minBlur: 0,
|
||||
maxBlur: 30,
|
||||
};
|
||||
|
||||
state.color = "#FFFFFF";
|
||||
state.brushSize = 32;
|
||||
state.brushBlur = 0;
|
||||
state.affectMask = true;
|
||||
state.setBrushSize = (size) => {
|
||||
state.brushSize = size;
|
||||
state.ctxmenu.brushSizeRange.value = size;
|
||||
state.ctxmenu.brushSizeText.value = size;
|
||||
};
|
||||
|
||||
state.movecb = (evn) => {
|
||||
// draw big translucent white blob cursor
|
||||
ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height);
|
||||
ovCtx.beginPath();
|
||||
ovCtx.arc(evn.x, evn.y, state.brushSize / 2, 0, 2 * Math.PI, true); // for some reason 4x on an arc is === to 8x on a line???
|
||||
ovCtx.fillStyle = state.color + "50";
|
||||
ovCtx.fill();
|
||||
};
|
||||
|
||||
state.wheelcb = (evn) => {
|
||||
if (!evn.evn.ctrlKey) {
|
||||
state.brushSize = state.setBrushSize(
|
||||
state.brushSize -
|
||||
Math.floor(state.config.brushScrollSpeed * evn.delta)
|
||||
);
|
||||
ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height);
|
||||
state.movecb(evn);
|
||||
}
|
||||
};
|
||||
|
||||
state.drawstartcb = (evn) => {
|
||||
if (state.affectMask) _mask_brush_draw_callback(evn, state);
|
||||
_color_brush_draw_callback(evn, state);
|
||||
};
|
||||
|
||||
state.drawcb = (evn) => {
|
||||
if (state.affectMask) _mask_brush_draw_callback(evn, state);
|
||||
_color_brush_draw_callback(evn, state);
|
||||
};
|
||||
|
||||
state.drawendcb = (evn) => {
|
||||
const canvas = state.drawLayer.canvas;
|
||||
const ctx = state.drawLayer.ctx;
|
||||
|
||||
const cropped = cropCanvas(canvas, {border: 10});
|
||||
const bb = cropped.bb;
|
||||
commands.runCommand("drawImage", "Color Brush Draw", {
|
||||
image: cropped.canvas,
|
||||
...bb,
|
||||
});
|
||||
|
||||
ctx.clearRect(bb.x, bb.y, bb.w, bb.h);
|
||||
};
|
||||
|
||||
state.erasestartcb = (evn) => {
|
||||
if (state.affectMask) _mask_brush_erase_callback(evn, state);
|
||||
|
||||
// Make a backup of the current image to apply erase later
|
||||
const bkpcanvas = state.eraseBackup.canvas;
|
||||
const bkpctx = state.eraseBackup.ctx;
|
||||
bkpctx.clearRect(0, 0, bkpcanvas.width, bkpcanvas.height);
|
||||
bkpctx.drawImage(imgCanvas, 0, 0);
|
||||
|
||||
imgCtx.globalCompositeOperation = "destination-out";
|
||||
_color_brush_erase_callback(evn, state, imgCtx);
|
||||
imgCtx.globalCompositeOperation = "source-over";
|
||||
_color_brush_erase_callback(evn, state, state.eraseLayer.ctx);
|
||||
};
|
||||
|
||||
state.erasecb = (evn) => {
|
||||
if (state.affectMask) _mask_brush_erase_callback(evn, state);
|
||||
imgCtx.globalCompositeOperation = "destination-out";
|
||||
_color_brush_erase_callback(evn, state, imgCtx);
|
||||
imgCtx.globalCompositeOperation = "source-over";
|
||||
_color_brush_erase_callback(evn, state, state.eraseLayer.ctx);
|
||||
};
|
||||
|
||||
state.eraseendcb = (evn) => {
|
||||
const canvas = state.eraseLayer.canvas;
|
||||
const ctx = state.eraseLayer.ctx;
|
||||
|
||||
const bkpcanvas = state.eraseBackup.canvas;
|
||||
|
||||
const cropped = cropCanvas(canvas, {border: 10});
|
||||
const bb = cropped.bb;
|
||||
|
||||
imgCtx.clearRect(0, 0, imgCanvas.width, imgCanvas.height);
|
||||
imgCtx.drawImage(bkpcanvas, 0, 0);
|
||||
|
||||
commands.runCommand("eraseImage", "Color Brush Erase", {
|
||||
mask: cropped.canvas,
|
||||
...bb,
|
||||
});
|
||||
|
||||
ctx.clearRect(bb.x, bb.y, bb.w, bb.h);
|
||||
};
|
||||
},
|
||||
populateContextMenu: (menu, state) => {
|
||||
if (!state.ctxmenu) {
|
||||
state.ctxmenu = {};
|
||||
|
||||
// Affects Mask Checkbox
|
||||
const affectMaskCheckbox = _toolbar_input.checkbox(
|
||||
state,
|
||||
"affectMask",
|
||||
"Affect Mask"
|
||||
).label;
|
||||
|
||||
state.ctxmenu.affectMaskCheckbox = affectMaskCheckbox;
|
||||
|
||||
// Brush size slider
|
||||
const brushSizeSlider = _toolbar_input.slider(
|
||||
state,
|
||||
"brushSize",
|
||||
"Brush Size",
|
||||
state.config.minBrushSize,
|
||||
state.config.maxBrushSize,
|
||||
1
|
||||
);
|
||||
state.ctxmenu.brushSizeSlider = brushSizeSlider.slider;
|
||||
state.setBrushSize = brushSizeSlider.setValue;
|
||||
|
||||
// Brush size slider
|
||||
const brushBlurSlider = _toolbar_input.slider(
|
||||
state,
|
||||
"brushBlur",
|
||||
"Brush Blur",
|
||||
state.config.minBlur,
|
||||
state.config.maxBlur,
|
||||
1
|
||||
);
|
||||
state.ctxmenu.brushBlurSlider = brushBlurSlider.slider;
|
||||
|
||||
// Brush color
|
||||
const brushColorPicker = document.createElement("input");
|
||||
brushColorPicker.type = "color";
|
||||
brushColorPicker.style.width = "100%";
|
||||
brushColorPicker.value = state.color;
|
||||
brushColorPicker.addEventListener("input", (evn) => {
|
||||
state.color = evn.target.value;
|
||||
});
|
||||
|
||||
state.ctxmenu.brushColorPicker = brushColorPicker;
|
||||
}
|
||||
|
||||
menu.appendChild(state.ctxmenu.affectMaskCheckbox);
|
||||
menu.appendChild(state.ctxmenu.brushSizeSlider);
|
||||
menu.appendChild(state.ctxmenu.brushBlurSlider);
|
||||
menu.appendChild(state.ctxmenu.brushColorPicker);
|
||||
},
|
||||
shortcut: "C",
|
||||
}
|
||||
);
|
5
res/icons/brush.svg
Normal file
5
res/icons/brush.svg
Normal file
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"></path>
|
||||
<path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"></path>
|
||||
|
||||
</svg>
|
After Width: | Height: | Size: 413 B |
Loading…
Reference in a new issue