Merge pull request #37 from seijihariki/img2img

Add tool for img2img

Former-commit-id: 4e6f088cd99e9274b7e0ca9413ba7e8c3503540e
This commit is contained in:
tim h 2022-11-23 17:36:02 -06:00 committed by GitHub
commit 42adf68610
21 changed files with 1437 additions and 676 deletions

View file

@ -0,0 +1,16 @@
name: Prettier Autoformatting
on:
pull_request:
branches: [main]
types: [opened, synchronize, closed]
jobs:
prettier:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Prettify
uses: creyD/prettier_action@v4.2
with:
prettier_options: --write **/*.{js,html,css,md}

8
css/colors.css Normal file
View file

@ -0,0 +1,8 @@
:root {
--c-primary: #2c3333;
--c-hover: hsl(180, 7%, 30%);
--c-active: hsl(180, 7%, 25%);
--c-secondary: #395b64;
--c-accent: #a5c9ca;
--c-text: #e7f6f2;
}

View file

@ -14,44 +14,6 @@ body {
background-color: #ccc;
}
#historyContainer > .info {
padding: 0;
}
#history.history {
height: 200px;
overflow: scroll;
}
#history.history > .history-item {
cursor: pointer;
padding: 5px;
padding-top: 2px;
padding-bottom: 2px;
}
#history.history > .history-item {
background-color: #0000;
}
#history.history > .history-item:hover {
background-color: #fff5;
}
#history.history > .history-item.current {
background-color: #66f5;
}
#history.history > .history-item.current:hover {
background-color: #66f5;
}
#history.history > .history-item.future {
background-color: #4445;
}
#history.history > .history-item.future:hover {
background-color: #ddd5;
}
.mainHSplit {
display: grid;
grid-template-columns: 1fr;
@ -68,40 +30,6 @@ body {
grid-row-gap: 5px;
}
.uiContainer {
position: fixed;
width: 250px;
height: auto;
z-index: 999;
}
.uiTitleBar {
z-index: 999;
cursor: move;
background-color: rgba(104, 104, 104, 0.75);
z-index: 999;
user-select: none;
padding-left: 5px;
padding-right: 5px;
padding-top: 5px;
padding-bottom: 5px;
margin-bottom: auto;
font-size: 1.5em;
color: black;
text-align: center;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
border: solid;
border-bottom: none;
border-color: black;
}
.draggable {
cursor: move;
}
.toolbar {
display: flex;
justify-content: space-between;
@ -158,7 +86,7 @@ button.tool:hover {
transition: max-height 0.2s ease-out;
}
.info {
.menu-container {
background-color: rgba(255, 255, 255, 0.5);
padding-left: 10px;
padding-right: 10px;

32
css/ui/generic.css Normal file
View file

@ -0,0 +1,32 @@
/* UI Floating Windows */
.floating-window {
position: fixed;
width: 250px;
height: auto;
z-index: 999;
}
.floating-window-title {
cursor: move;
background-color: rgba(104, 104, 104, 0.75);
user-select: none;
padding-left: 5px;
padding-right: 5px;
padding-top: 5px;
padding-bottom: 5px;
margin-bottom: auto;
font-size: 1.5em;
color: black;
text-align: center;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
border: solid;
border-bottom: none;
border-color: black;
}
.draggable {
cursor: move;
}

38
css/ui/history.css Normal file
View file

@ -0,0 +1,38 @@
#historyContainer > .info {
padding: 0;
}
#history.history {
height: 200px;
overflow-y: scroll;
overflow-x: hidden;
}
#history.history > .history-item {
cursor: pointer;
padding: 5px;
padding-top: 2px;
padding-bottom: 2px;
}
#history.history > .history-item {
background-color: #0000;
}
#history.history > .history-item:hover {
background-color: #fff5;
}
#history.history > .history-item.current {
background-color: #66f5;
}
#history.history > .history-item.current:hover {
background-color: #66f5;
}
#history.history > .history-item.future {
background-color: #4445;
}
#history.history > .history-item.future:hover {
background-color: #ddd5;
}

62
css/ui/toolbar.css Normal file
View file

@ -0,0 +1,62 @@
#ui-toolbar {
align-content: center;
width: 60px;
border-radius: 5px;
color: var(--c-text);
background-color: var(--c-primary);
}
#ui-toolbar .handle {
display: flex;
align-items: center;
justify-content: center;
height: 10px;
}
#ui-toolbar .handle > .line {
width: 80%;
border-top: 2px #777 dotted;
}
#ui-toolbar .tool .tool-icon {
filter: invert(60%);
}
#ui-toolbar .tool.using .tool-icon {
filter: invert(80%);
}
#ui-toolbar .tool:hover .tool-icon {
filter: invert(90%);
}
/* The separator */
#ui-toolbar .separator {
width: 80%;
margin: auto;
align-self: center;
border-top: 1px var(--c-hover) solid;
}
/* Styles for the tool buttons */
#ui-toolbar .tool {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
margin: 5px;
border-radius: 5px;
cursor: pointer;
}
#ui-toolbar .tool.using {
background-color: var(--c-active);
}
#ui-toolbar .tool:hover {
background-color: var(--c-hover);
}

View file

@ -1,188 +1,337 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<title>openOutpaint 🐠</title>
<!-- CSS Variables -->
<link href="css/colors.css" rel="stylesheet" />
<head>
<meta charset="utf-8" />
<title>openOutpaint 🐠</title>
<link href="css/index.css" rel="stylesheet" />
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<link href="css/index.css" rel="stylesheet" />
<link href="css/ui/generic.css" rel="stylesheet" />
<body>
<!-- Main Toolbar -->
<div id="infoContainer" class="uiContainer">
<div id="infoTitleBar" class="draggable uiTitleBar">openOutpaint 🐠</div>
<div id="info" class="info" style="min-width:200px;">
<link href="css/ui/history.css" rel="stylesheet" />
<link href="css/ui/toolbar.css" rel="stylesheet" />
<label for="host">Host</label>
<input id="host" value="http://127.0.0.1:7860"><br />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<!-- Prompts section -->
<button type="button" class="collapsible">Prompts</button>
<div class="content">
<label for="prompt">Prompt:</label> <br>
<textarea id="prompt">oceanographic study, underwater wildlife, award winning</textarea><br />
<label for="negPrompt">Negative prompt:</label> <br>
<textarea
id="negPrompt">people, person, humans, human, divers, diver, glitch, error, text, watermark, bad quality, blurry</textarea><br />
<hr>
</div>
<!-- SD section -->
<button type="button" class="collapsible">Stable Diffusion settings</button>
<div class="content">
<label for="models">Model:</label>
<select id="models" onchange="changeModel()"></select><br>
<label for="samplerSelect">Sampler:</label>
<select id="samplerSelect" onchange="changeSampler()"></select><br />
<label for="seed">Seed (-1 for random):</label> <br>
<input type="number" id="seed" onchange="changeSeed()" min="1" max="9999999999" value="-1"
step="1" /><br />
<label for="steps">Steps: <input type="number" id="stepsTxt"></label><br />
<input type="range" id="steps" name="steps" min="1" max="50" /><br />
<label for="cfgScale">CFG scale: <input type="number" id="cfgScaleTxt"></label>
<br />
<input type="range" id="cfgScale" name="cfgScale" min="-1" max="25" step="0.5" /><br />
<label for="batchSize">Batch size: <input type="number" id="batchSizeText"></label><br />
<input type="range" id="batchSize" name="batchSize" min="1" max="8" step="1" /><br />
<label for="batchCount">Batch count: <input type="number" id="batchCountText"></label><br />
<input type="range" id="batchCount" name="batchCount" min="1" max="8" step="1" /><br />
<hr>
</div>
<!-- Unsectioned -->
<label for="scaleFactor">Scale factor: <input type="number" id="scaleFactorTxt"></label><br />
<input type="range" id="scaleFactor" name="scaleFactor" min="1" max="16" /><br />
<label for="cbxSnap">Snap to grid?</label>
<input type="checkbox" id="cbxSnap" onchange="changeSnapMode()" checked="checked"><br />
<label for="cbxEnableErasing">Right-click erase?</label>
<input type="checkbox" id="cbxEnableErasing" onchange="changeEnableErasing()"><br />
<label for="cbxPaint">Mask mode?</label>
<input type="checkbox" id="cbxPaint" onchange="changePaintMode()"><br />
<body>
<!-- Main Toolbar -->
<div
id="infoContainer"
class="floating-window"
style="left: 10px; top: 10px">
<div id="infoTitleBar" class="draggable floating-window-title">
openOutpaint 🐠
</div>
<div id="info" class="menu-container" style="min-width: 200px">
<label for="host">Host</label>
<input id="host" value="http://127.0.0.1:7860" /><br />
<label for="cbxHRFix">Auto txt2img HRfix?</label>
<input type="checkbox" id="cbxHRFix" onchange="changeHiResFix()"><br />
<label for="overMaskPx">Overmask px (0 to disable):</label>
<input type="number" id="overMaskPx" onchange="changeOverMaskPx()" min="0" max="128" value="16"
step="1" /><br />
<label for="maskBlur">Mask blur:</label>
<span id="maskBlurText"></span><br />
<input type="number" id="maskBlur" name="maskBlur" min="0" max="256" value="0" step="1"
onchange="changeMaskBlur()" /><br />
<!-- Save/load image section -->
<button type="button" class="collapsible">Save/Load/New image</button>
<div class="content">
<label for="preloadImage">Load image:</label>
<input type="file" id="preloadImage" onchange="preloadImage()" accept="image/*" /
style="width: 200px;"><br />
<button onclick="downloadCanvas()">Save canvas</button><br />
<label for="upscalers">Choose upscaler</label>
<select id="upscalers"></select>
<button onclick="upscaleAndDownload()">Upscale (might take a sec)</button><br />
<!-- Prompts section -->
<button type="button" class="collapsible">Prompts</button>
<div class="content">
<label for="prompt">Prompt:</label> <br />
<textarea id="prompt">
oceanographic study, underwater wildlife, award winning</textarea
><br />
<label for="negPrompt">Negative prompt:</label> <br />
<textarea id="negPrompt">
people, person, humans, human, divers, diver, glitch, error, text, watermark, bad quality, blurry</textarea
><br />
<hr />
</div>
<!-- SD section -->
<button type="button" class="collapsible">
Stable Diffusion settings
</button>
<div class="content">
<label for="models">Model:</label>
<select id="models" onchange="changeModel()"></select
><br />
<label for="samplerSelect">Sampler:</label>
<select id="samplerSelect" onchange="changeSampler()"></select
><br />
<label for="seed">Seed (-1 for random):</label> <br />
<input
type="number"
id="seed"
onchange="changeSeed()"
min="1"
max="9999999999"
value="-1"
step="1" /><br />
<label for="steps">Steps: <input type="number" id="stepsTxt" /></label
><br />
<input type="range" id="steps" name="steps" min="1" max="50" /><br />
<label for="cfgScale"
>CFG scale: <input type="number" id="cfgScaleTxt"
/></label>
<br />
<input
type="range"
id="cfgScale"
name="cfgScale"
min="-1"
max="25"
step="0.5" /><br />
<label for="batchSize"
>Batch size: <input type="number" id="batchSizeText" /></label
><br />
<input
type="range"
id="batchSize"
name="batchSize"
min="1"
max="8"
step="1" /><br />
<label for="batchCount"
>Batch count: <input type="number" id="batchCountText" /></label
><br />
<input
type="range"
id="batchCount"
name="batchCount"
min="1"
max="8"
step="1" /><br />
<hr />
</div>
<!-- Unsectioned -->
<label for="scaleFactor"
>Scale factor: <input type="number" id="scaleFactorTxt" /></label
><br />
<input
type="range"
id="scaleFactor"
name="scaleFactor"
min="1"
max="16" /><br />
<label for="cbxSnap">Snap to grid?</label>
<input
type="checkbox"
id="cbxSnap"
onchange="changeSnapMode()"
checked="checked" /><br />
<label for="cbxHRFix">Auto txt2img HRfix?</label>
<input
type="checkbox"
id="cbxHRFix"
onchange="changeHiResFix()" /><br />
<label for="overMaskPx">Overmask px (0 to disable):</label>
<input
type="number"
id="overMaskPx"
onchange="changeOverMaskPx()"
min="0"
max="128"
value="16"
step="1" /><br />
<label for="maskBlur">Mask blur:</label>
<span id="maskBlurText"></span><br />
<input
type="number"
id="maskBlur"
name="maskBlur"
min="0"
max="256"
value="0"
step="1"
onchange="changeMaskBlur()" /><br />
<!-- Save/load image section -->
<button type="button" class="collapsible">Save/Load/New image</button>
<div class="content">
<label for="preloadImage">Load image:</label>
<input type="file" id="preloadImage" onchange="preloadImage()"
accept="image/*" / style="width: 200px;"><br />
<button onclick="downloadCanvas()">Save canvas</button><br />
<label for="upscalers">Choose upscaler</label>
<select id="upscalers"></select>
<button onclick="upscaleAndDownload()">
Upscale (might take a sec)</button
><br />
<button onclick="newImage()">Clear canvas</button>
</div>
<!-- Degub info -->
<button type="button" class="collapsible">Debug info</button>
<div id="coords" class="content">
<label for="mouseX">mouseX:</label>
<span id="mouseX"></span>
<br />
<label for="mouseY">mouseY:</label>
<span id="mouseY"></span>
<br />
<label for="canvasX">canvasX:</label>
<span id="canvasX"></span>
<br />
<label for="canvasY">canvasY:</label>
<span id="canvasY"></span>
<br />
<label for="snapX">snapX:</label>
<span id="snapX"></span>
<br />
<label for="snapY">snapY:</label>
<span id="snapY"></span><br />
<label for="heldButton">Mouse button:</label>
<span id="heldButton"></span><br />
<span id="version">Alpha release v0.0.6.9</span> <!-- 𝓃𝒾𝒸𝑒 -->
<br />
<hr>
</div>
</div>
<button onclick="newImage()">Clear canvas</button>
</div>
<!-- Debug info -->
<button type="button" class="collapsible">Debug info</button>
<div id="coords" class="content">
<label for="mouseX">mouseX:</label>
<span id="mouseX"></span>
<br />
<label for="mouseY">mouseY:</label>
<span id="mouseY"></span>
<br />
<label for="canvasX">canvasX:</label>
<span id="canvasX"></span>
<br />
<label for="canvasY">canvasY:</label>
<span id="canvasY"></span>
<br />
<label for="snapX">snapX:</label>
<span id="snapX"></span>
<br />
<label for="snapY">snapY:</label>
<span id="snapY"></span><br />
<label for="heldButton">Mouse button:</label>
<span id="heldButton"></span><br />
<span id="version">Alpha release v0.0.6.6</span>
<br />
<hr />
</div>
<div style="display: flex; align-items: center">
<div
style="
flex: 1;
border-top: 1px black solid;
margin-right: 10px;
"></div>
Context Menu
<div
style="
flex: 1;
border-top: 1px black solid;
margin-left: 10px;
"></div>
</div>
<div id="tool-context" class="context-menu"></div>
</div>
</div>
</div>
<!-- History -->
<div id="ui-history" class="floating-window" style="right: 10px; top: 10px">
<div class="draggable floating-window-title">History</div>
<div class="menu-container" style="min-width: 200px">
<div id="history" class="history"></div>
<div class="toolbar" style="padding: 10px">
<button type="button" onclick="commands.undo()" class="tool">
undo
</button>
<button type="button" onclick="commands.redo()" class="tool">
redo
</button>
</div>
</div>
</div>
<!-- History Toolbar -->
<div id="historyContainer" class="uiContainer" style="right: 0;">
<div id="historyTitleBar" class="draggable uiTitleBar">History</div>
<div class="info" style="min-width:200px;">
<div id="history" class="history"></div>
<div class="toolbar" style="padding: 10px;">
<button type="button" onclick="commands.undo()" class="tool">undo</button>
<button type="button" onclick="commands.redo()" class="tool">redo</button>
</div>
</div>
<!-- Toolbar -->
<div
id="ui-toolbar"
class="floating-window"
style="right: 10px; top: 350px">
<div class="draggable handle">
<span class="line"></span>
</div>
<div class="toolbar-section"></div>
</div>
</div>
<div id="mainHSplit" class="mainHSplit">
<div id="uiWrapper" class="uiWrapper">
<div id="canvasHolder" class="canvasHolder" oncontextmenu="return false;">
<canvas id="backgroundCanvas" class="mainCanvases backgroundCanvas" width="2560" height="1440"
style="z-index: 0;">
<!-- gray grid bg canvas -->
<p>lol ur browser sucks</p>
</canvas>
<canvas id="canvas" class="mainCanvases canvas" width="2560" height="1440" style="z-index: 1;">
<!-- normal canvas on which images are drawn -->
<p>lol ur browser sucks</p>
</canvas>
<canvas id="tempCanvas" class="mainCanvases tempCanvas" width="2560" height="1440" style="z-index: 2;">
<!-- temporary canvas on which images being selected/rejected or imported arbitrary images are superimposed -->
<p>lol ur browser sucks</p>
</canvas>
<canvas id="targetCanvas" class="mainCanvases targetCanvas" width="2560" height="1440"
style="z-index: 3;">
<!-- canvas on which "targeting" squares are drawn -->
<p>lol ur browser sucks</p>
</canvas>
<canvas id="maskPaintCanvas" class="mainCanvases maskPaintCanvas" width="2560" height="1440"
style="z-index: 4;">
<!-- canvas on which masking brush is "painted" -->
<p>lol ur browser sucks</p>
</canvas>
<canvas id="overlayCanvas" class="mainCanvases overlayCanvas" width="2560" height="1440"
style="z-index: 5;">
<!-- canvas on which "cursor" reticle or arc is drawn -->
<p>lol ur browser sucks</p>
</canvas>
<div id="tempDiv" style="position: relative; z-index: 6;">
<!-- where popup buttons go -->
</div>
</div>
</div>
<div id="masks" class="masks">
<div>
<!-- <canvas id="maskCanvasMonitor" class="maskCanvasMonitor" width="512" height="512">
<!-- Canvases -->
<div
id="mainHSplit"
class="mainHSplit"
onmouseover="document.activeElement.blur()">
<div id="uiWrapper" class="uiWrapper">
<div
id="canvasHolder"
class="canvasHolder"
oncontextmenu="return false;">
<canvas
id="backgroundCanvas"
class="mainCanvases backgroundCanvas"
width="2560"
height="1440"
style="z-index: 0">
<!-- gray grid bg canvas -->
<p>lol ur browser sucks</p>
</canvas>
<canvas
id="canvas"
class="mainCanvases canvas"
width="2560"
height="1440"
style="z-index: 1">
<!-- normal canvas on which images are drawn -->
<p>lol ur browser sucks</p>
</canvas>
<canvas
id="tempCanvas"
class="mainCanvases tempCanvas"
width="2560"
height="1440"
style="z-index: 2">
<!-- temporary canvas on which images being selected/rejected or imported arbitrary images are superimposed -->
<p>lol ur browser sucks</p>
</canvas>
<canvas
id="targetCanvas"
class="mainCanvases targetCanvas"
width="2560"
height="1440"
style="z-index: 3">
<!-- canvas on which "targeting" squares are drawn -->
<p>lol ur browser sucks</p>
</canvas>
<canvas
id="maskPaintCanvas"
class="mainCanvases maskPaintCanvas"
width="2560"
height="1440"
style="z-index: 4">
<!-- canvas on which masking brush is "painted" -->
<p>lol ur browser sucks</p>
</canvas>
<canvas
id="overlayCanvas"
class="mainCanvases overlayCanvas"
width="2560"
height="1440"
style="z-index: 5">
<!-- canvas on which "cursor" reticle or arc is drawn -->
<p>lol ur browser sucks</p>
</canvas>
<div id="tempDiv" style="position: relative; z-index: 6">
<!-- where popup buttons go -->
</div>
</div>
</div>
<div id="masks" class="masks">
<div>
<!-- <canvas id="maskCanvasMonitor" class="maskCanvasMonitor" width="512" height="512">
<p>lol ur browser sucks</p>
</canvas><br /> -->
<canvas id="overMaskCanvasMonitor" class="overMaskCanvasMonitor" width="512" height="512">
<p>lol ur browser sucks</p>
</canvas><br />
<canvas id="initImgCanvasMonitor" class="initImgCanvasMonitor" width="512" height="512">
<p>lol ur browser sucks</p>
</canvas><br />
</div>
</div>
</div>
<canvas
id="overMaskCanvasMonitor"
class="overMaskCanvasMonitor"
width="512"
height="512">
<p>lol ur browser sucks</p> </canvas
><br />
<canvas
id="initImgCanvasMonitor"
class="initImgCanvasMonitor"
width="512"
height="512">
<p>lol ur browser sucks</p> </canvas
><br />
</div>
</div>
</div>
<!-- Base Libs -->
<script src="js/util.js" type="text/javascript"></script>
<script src="js/input.js" type="text/javascript"></script>
<script src="js/commands.js" type="text/javascript"></script>
<script src="js/ui/history.js" type="text/javascript"></script>
<script src="js/settingsbar.js" type="text/javascript"></script>
<script src="js/util.js" type="text/javascript"></script>
<script src="js/input.js" type="text/javascript"></script>
<script src="js/commands.js" type="text/javascript"></script>
<script src="js/index.js" type="text/javascript"></script>
<script src="js/settingsbar.js" type="text/javascript"></script>
<script src="js/shortcuts.js" type="text/javascript"></script>
<script src="js/ui/history.js" type="text/javascript"></script>
</body>
<!-- Content -->
<script src="js/index.js" type="text/javascript"></script>
<script src="js/shortcuts.js" type="text/javascript"></script>
</html>
<!-- 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/toolbar.js" type="text/javascript"></script>
</body>
</html>

View file

@ -39,6 +39,12 @@ const commands = {
Object.assign(copy, options);
const state = {};
const entry = {
id: guid(),
title,
state,
};
// Attempt to run command
try {
run(title, copy, state);
@ -53,6 +59,7 @@ const commands = {
console.debug(`Undoing ${name}, currently ${commands.current}`);
undo(title, state);
_commands_events.emit({
id: entry.id,
name,
action: "undo",
state,
@ -63,6 +70,7 @@ const commands = {
console.debug(`Redoing ${name}, currently ${commands.current}`);
redo(title, copy, state);
_commands_events.emit({
id: entry.id,
name,
action: "redo",
state,
@ -71,21 +79,29 @@ const commands = {
};
// Add to history
if (commands.history.length > commands.current + 1)
commands.history.splice(commands.current + 1);
if (commands.history.length > commands.current + 1) {
commands.history.forEach((entry, index) => {
if (index >= commands.current + 1)
_commands_events.emit({
id: entry.id,
name,
action: "deleted",
state,
current: commands.current,
});
});
const entry = {
id: guid(),
title,
undo: undoWrapper,
redo: redoWrapper,
state,
};
commands.history.splice(commands.current + 1);
}
commands.history.push(entry);
commands.current++;
entry.undo = undoWrapper;
entry.redo = redoWrapper;
_commands_events.emit({
id: entry.id,
name,
action: "run",
state,
@ -156,3 +172,53 @@ commands.createCommand(
state.context.drawImage(state.original, state.box.x, state.box.y);
}
);
commands.createCommand(
"eraseImage",
(title, options, state) => {
if (
!options ||
options.x === undefined ||
options.y === undefined ||
options.w === undefined ||
options.h === undefined
)
throw "Command eraseImage requires options in the format: {x, y, w, h, ctx?}";
// Check if we have state
if (!state.context) {
const context = options.ctx || imgCtx;
state.context = context;
// Saving what was in the canvas before the command
const imgData = context.getImageData(
options.x,
options.y,
options.w,
options.h
);
state.box = {
x: options.x,
y: options.y,
w: options.w,
h: options.h,
};
// Create Image
const cutout = document.createElement("canvas");
cutout.width = state.box.w;
cutout.height = state.box.h;
cutout.getContext("2d").putImageData(imgData, 0, 0);
state.original = new Image();
state.original.src = cutout.toDataURL();
}
// Apply command
state.context.clearRect(state.box.x, state.box.y, state.box.w, state.box.h);
},
(title, state) => {
// Clear destination area
state.context.clearRect(state.box.x, state.box.y, state.box.w, state.box.h);
// Undo
state.context.drawImage(state.original, state.box.x, state.box.y);
}
);

View file

@ -55,12 +55,31 @@ function sliderChangeHandlerFactory(
textBoxId,
dataKey,
defaultV,
save = true,
setter = (k, v) => (stableDiffusionData[k] = v),
getter = (k) => stableDiffusionData[k]
) {
const sliderEl = document.getElementById(sliderId);
const textBoxEl = document.getElementById(textBoxId);
const savedValue = localStorage.getItem(dataKey);
return sliderChangeHandlerFactoryEl(
document.getElementById(sliderId),
document.getElementById(textBoxId),
dataKey,
defaultV,
save,
setter,
getter
);
}
function sliderChangeHandlerFactoryEl(
sliderEl,
textBoxEl,
dataKey,
defaultV,
save = true,
setter = (k, v) => (stableDiffusionData[k] = v),
getter = (k) => stableDiffusionData[k]
) {
const savedValue = save && localStorage.getItem(dataKey);
if (savedValue) setter(dataKey, savedValue || defaultV);
@ -70,12 +89,12 @@ function sliderChangeHandlerFactory(
if (value) setter(dataKey, value);
if (!eventSource || eventSource.id === textBoxId)
if (!eventSource || eventSource === textBoxEl)
sliderEl.value = getter(dataKey);
setter(dataKey, Number(sliderEl.value));
textBoxEl.value = getter(dataKey);
localStorage.setItem(dataKey, getter(dataKey));
if (save) localStorage.setItem(dataKey, getter(dataKey));
}
textBoxEl.onchange = changeHandler;
@ -104,11 +123,9 @@ var heldButton = 0;
var snapX = 0;
var snapY = 0;
var drawThis = {};
var clicked = false;
const basePixelCount = 64; //64 px - ALWAYS 64 PX
var scaleFactor = 8; //x64 px
var snapToGrid = true;
var paintMode = false;
var backupMaskPaintCanvas; //???
var backupMaskPaintCtx; //...? look i am bad at this
var backupMaskChunk = null;
@ -123,9 +140,8 @@ var arbitraryImageData;
var arbitraryImageBitmap;
var arbitraryImageBase64; // seriously js cmon work with me here
var placingArbitraryImage = false; // for when the user has loaded an existing image from their computer
var enableErasing = false; // accidental right-click erase if the user isn't trying to erase is a bad thing
var marchOffset = 0;
var marching = false;
var stopMarching = null;
var inProgress = false;
var marchCoords = {};
@ -160,7 +176,6 @@ function startup() {
getModels();
drawBackground();
changeScaleFactor();
changePaintMode();
changeSampler();
changeSteps();
changeCfgScale();
@ -171,7 +186,6 @@ function startup() {
changeSeed();
changeOverMaskPx();
changeHiResFix();
changeEnableErasing();
document.getElementById("overlayCanvas").onmousemove = mouseMove;
document.getElementById("overlayCanvas").onmousedown = mouseDown;
document.getElementById("overlayCanvas").onmouseup = mouseUp;
@ -197,44 +211,67 @@ function writeArbitraryImage(img, x, y) {
document.getElementById("preloadImage").files = null;
}
function dream(x, y, prompt) {
function dream(
x,
y,
prompt,
extra = {
method: endpoint,
stopMarching: () => {},
bb: {x, y, w: prompt.width, h: prompt.height},
}
) {
tmpImgXYWH.x = x;
tmpImgXYWH.y = y;
tmpImgXYWH.w = prompt.width;
tmpImgXYWH.h = prompt.height;
console.log(
"dreaming to " + host + url + endpoint + ":\r\n" + JSON.stringify(prompt)
"dreaming to " +
host +
url +
(extra.method || endpoint) +
":\r\n" +
JSON.stringify(prompt)
);
postData(prompt).then((data) => {
returnedImages = data.images;
totalImagesReturned = data.images.length;
blockNewImages = true;
//console.log(data); // JSON data parsed by `data.json()` call
imageAcceptReject(x, y, data);
});
checkProgress();
console.info(`dreaming "${prompt.prompt}"`);
console.debug(prompt);
// Start checking for progress
const progressCheck = checkProgress(extra.bb);
postData(prompt, extra)
.then((data) => {
returnedImages = data.images;
totalImagesReturned = data.images.length;
blockNewImages = true;
//console.log(data); // JSON data parsed by `data.json()` call
imageAcceptReject(x, y, data, extra);
})
.finally(() => clearInterval(progressCheck));
}
async function postData(promptData) {
async function postData(promptData, extra = null) {
this.host = document.getElementById("host").value;
// Default options are marked with *
const response = await fetch(this.host + this.url + this.endpoint, {
method: "POST", // *GET, POST, PUT, DELETE, etc.
mode: "cors", // no-cors, *cors, same-origin
cache: "default", // *default, no-cache, reload, force-cache, only-if-cached
credentials: "same-origin", // include, *same-origin, omit
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
redirect: "follow", // manual, *follow, error
referrerPolicy: "no-referrer", // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url
body: JSON.stringify(promptData), // body data type must match "Content-Type" header
});
const response = await fetch(
this.host + this.url + extra.method || endpoint,
{
method: "POST", // *GET, POST, PUT, DELETE, etc.
mode: "cors", // no-cors, *cors, same-origin
cache: "default", // *default, no-cache, reload, force-cache, only-if-cached
credentials: "same-origin", // include, *same-origin, omit
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
redirect: "follow", // manual, *follow, error
referrerPolicy: "no-referrer", // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url
body: JSON.stringify(promptData), // body data type must match "Content-Type" header
}
);
return response.json(); // parses JSON response into native JavaScript objects
}
function imageAcceptReject(x, y, data) {
function imageAcceptReject(x, y, data, extra = null) {
inProgress = false;
document.getElementById("progressDiv").remove();
const img = new Image();
@ -261,7 +298,8 @@ function imageAcceptReject(x, y, data) {
function accept(evt) {
// write image to imgcanvas
marching = false;
stopMarching && stopMarching();
stopMarching = null;
clearBackupMask();
placeImage();
removeChoiceButtons();
@ -271,7 +309,8 @@ function accept(evt) {
function reject(evt) {
// remove image entirely
marching = false;
stopMarching && stopMarching();
stopMarching = null;
restoreBackupMask();
clearBackupMask();
clearTargetMask();
@ -375,59 +414,40 @@ function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function snap(i, scaled = true) {
// very cheap test proof of concept but it works surprisingly well
var scaleOffset = 0;
if (scaled) {
if (scaleFactor % 2 != 0) {
// odd number, snaps to center of cell, oops
scaleOffset = basePixelCount / 2;
}
}
var snapOffset = (i % basePixelCount) - scaleOffset;
if (snapOffset == 0) {
return snapOffset;
}
return -snapOffset;
function march(bb) {
let offset = 0;
const interval = setInterval(() => {
drawMarchingAnts(bb, offset++);
offset %= 16;
}, 20);
return () => clearInterval(interval);
}
function march() {
if (marching) {
marchOffset++;
if (marchOffset > 16) {
marchOffset = 0;
}
drawMarchingAnts();
setTimeout(march, 20);
}
}
function drawMarchingAnts() {
function drawMarchingAnts(bb, offset) {
clearTargetMask();
tgtCtx.strokeStyle = "#FFFFFFFF"; //"#55000077";
tgtCtx.setLineDash([4, 2]);
tgtCtx.lineDashOffset = -marchOffset;
tgtCtx.strokeRect(marchCoords.x, marchCoords.y, marchCoords.w, marchCoords.h);
tgtCtx.lineDashOffset = -offset;
tgtCtx.strokeRect(bb.x, bb.y, bb.w, bb.h);
}
function checkProgress() {
function checkProgress(bb) {
document.getElementById("progressDiv") &&
document.getElementById("progressDiv").remove();
endpoint = "progress?skip_current_image=false";
// Skip image to stop using a ton of networking resources
endpoint = "progress?skip_current_image=true";
var div = document.createElement("div");
div.id = "progressDiv";
div.style.position = "absolute";
div.style.width = "200px";
div.style.height = "70px";
div.style.left = parseInt(marchCoords.x + marchCoords.w - 100) + "px";
div.style.top = parseInt(marchCoords.y + marchCoords.h) + "px";
div.style.left = parseInt(bb.x + bb.w - 100) + "px";
div.style.top = parseInt(bb.y + bb.h) + "px";
div.innerHTML = '<span class="strokeText" id="estRemaining"></span>';
document.getElementById("tempDiv").appendChild(div);
updateProgress();
}
function updateProgress() {
if (inProgress) {
return setInterval(() => {
fetch(host + url + endpoint)
.then((response) => response.json())
.then((data) => {
@ -439,8 +459,7 @@ function updateProgress() {
document.getElementById("estRemaining").innerText = estimate;
});
setTimeout(updateProgress, 500);
}
}, 1500);
}
function mouseMove(evt) {
@ -467,67 +486,9 @@ function mouseMove(evt) {
finalX = snapOffsetX + canvasX;
finalY = snapOffsetY + canvasY;
ovCtx.drawImage(arbitraryImage, finalX, finalY);
} else if (!paintMode) {
// draw targeting square reticle thingy cursor
ovCtx.strokeStyle = "#FFFFFF";
snapOffsetX = 0;
snapOffsetY = 0;
if (snapToGrid) {
snapOffsetX = snap(canvasX);
snapOffsetY = snap(canvasY);
}
finalX = snapOffsetX + canvasX;
finalY = snapOffsetY + canvasY;
ovCtx.strokeRect(
parseInt(finalX - (basePixelCount * scaleFactor) / 2),
parseInt(finalY - (basePixelCount * scaleFactor) / 2),
basePixelCount * scaleFactor,
basePixelCount * scaleFactor
); //origin is middle of the frame
}
}
/**
* Mask implementation
*/
mouse.listen.canvas.onmousemove.on((evn) => {
if (paintMode && evn.target.id === "overlayCanvas") {
// draw big translucent red blob cursor
ovCtx.beginPath();
ovCtx.arc(evn.x, evn.y, 4 * scaleFactor, 0, 2 * Math.PI, true); // for some reason 4x on an arc is === to 8x on a line???
ovCtx.fillStyle = "#FF6A6A50";
ovCtx.fill();
}
});
mouse.listen.canvas.left.onpaint.on((evn) => {
if (paintMode && evn.initialTarget.id === "overlayCanvas") {
maskPaintCtx.globalCompositeOperation = "source-over";
maskPaintCtx.strokeStyle = "#FF6A6A";
maskPaintCtx.lineWidth = 8 * scaleFactor;
maskPaintCtx.beginPath();
maskPaintCtx.moveTo(evn.px, evn.py);
maskPaintCtx.lineTo(evn.x, evn.y);
maskPaintCtx.lineJoin = maskPaintCtx.lineCap = "round";
maskPaintCtx.stroke();
}
});
mouse.listen.canvas.right.onpaint.on((evn) => {
if (paintMode && evn.initialTarget.id === "overlayCanvas") {
maskPaintCtx.globalCompositeOperation = "destination-out";
maskPaintCtx.strokeStyle = "#FFFFFFFF";
maskPaintCtx.lineWidth = 8 * scaleFactor;
maskPaintCtx.beginPath();
maskPaintCtx.moveTo(evn.px, evn.py);
maskPaintCtx.lineTo(evn.x, evn.y);
maskPaintCtx.lineJoin = maskPaintCtx.lineCap = "round";
maskPaintCtx.stroke();
}
});
function mouseDown(evt) {
const rect = ovCanvas.getBoundingClientRect();
var oddOffset = 0;
@ -543,39 +504,6 @@ function mouseDown(evt) {
nextBox.w = arbitraryImageData.width;
nextBox.h = arbitraryImageData.height;
dropTargets.push(nextBox);
} else if (!paintMode) {
//const rect = ovCanvas.getBoundingClientRect()
var nextBox = {};
nextBox.x =
evt.clientX -
(basePixelCount * scaleFactor) / 2 -
rect.left +
oddOffset; //origin is middle of the frame
nextBox.y =
evt.clientY - (basePixelCount * scaleFactor) / 2 - rect.top + oddOffset; //TODO make a way to set the origin to numpad dirs?
nextBox.w = basePixelCount * scaleFactor;
nextBox.h = basePixelCount * scaleFactor;
drawTargets.push(nextBox);
}
} else if (evt.button == 2) {
if (enableErasing && !paintMode) {
// right click, also gotta make sure mask blob isn't being used as it's visually inconsistent with behavior of erased region
ctx = imgCanvas.getContext("2d");
if (snapToGrid) {
ctx.clearRect(
canvasX + snap(canvasX) - (basePixelCount * scaleFactor) / 2,
canvasY + snap(canvasY) - (basePixelCount * scaleFactor) / 2,
basePixelCount * scaleFactor,
basePixelCount * scaleFactor
);
} else {
ctx.clearRect(
canvasX - (basePixelCount * scaleFactor) / 2,
canvasY - (basePixelCount * scaleFactor) / 2,
basePixelCount * scaleFactor,
basePixelCount * scaleFactor
);
}
}
}
}
@ -601,188 +529,10 @@ function mouseUp(evt) {
drawThis.h = target.h;
drawIt = drawThis; // i still think this is really stupid and redundant and unnecessary and redundant
drop(drawIt);
} else if (paintMode) {
clicked = false;
return;
} else {
if (!blockNewImages) {
//TODO seriously, refactor this
blockNewImages = true;
marching = inProgress = true;
var drawIt = {}; //why am i doing this????
var target = drawTargets[drawTargets.length - 1]; //get the last one... why am i storing all of them?
var oddOffset = 0;
if (scaleFactor % 2 != 0) {
oddOffset = basePixelCount / 2;
}
snapOffsetX = 0;
snapOffsetY = 0;
if (snapToGrid) {
snapOffsetX = snap(target.x);
snapOffsetY = snap(target.y);
}
finalX = snapOffsetX + target.x - oddOffset;
finalY = snapOffsetY + target.y - oddOffset;
drawThis.x = marchCoords.x = finalX;
drawThis.y = marchCoords.y = finalY;
drawThis.w = marchCoords.w = target.w;
drawThis.h = marchCoords.h = target.h;
march(finalX, finalY, target.w, target.h);
drawIt = drawThis; //TODO this is WRONG but also explicitly only draws the last image ... i think
//check if there's image data already there
// console.log(downX + ":" + downY + " :: " + this.isCanvasBlank(downX, downY));
if (!isCanvasBlank(drawIt.x, drawIt.y, drawIt.w, drawIt.h, imgCanvas)) {
// image exists, set up for img2img
var mainCanvasCtx = document
.getElementById("canvas")
.getContext("2d");
const imgChunk = mainCanvasCtx.getImageData(
drawIt.x,
drawIt.y,
drawIt.w,
drawIt.h
); // imagedata object of the image being outpainted
const imgChunkData = imgChunk.data; // imagedata.data object, a big inconvenient uint8clampedarray
// these are the 3 mask monitors on the bottom of the page
var initImgCanvas = document.getElementById("initImgCanvasMonitor");
var overMaskCanvas = document.getElementById("overMaskCanvasMonitor");
overMaskCanvas.width = initImgCanvas.width = target.w; //maskCanvas.width = target.w;
overMaskCanvas.height = initImgCanvas.height = target.h; //maskCanvas.height = target.h;
var initImgCanvasCtx = initImgCanvas.getContext("2d");
var overMaskCanvasCtx = overMaskCanvas.getContext("2d");
// get blank pixels to use as mask
const initImgData = mainCanvasCtx.createImageData(drawIt.w, drawIt.h);
let overMaskImgData = overMaskCanvasCtx.createImageData(
drawIt.w,
drawIt.h
);
// cover entire masks in black before adding masked areas
for (let i = 0; i < imgChunkData.length; i += 4) {
// l->r, top->bottom, R G B A pixel values in a big ol array
// make a simple mask
if (imgChunkData[i + 3] == 0) {
// rgba pixel values, 4th one is alpha, if it's 0 there's "nothing there" in the image display canvas and its time to outpaint
overMaskImgData.data[i] = 255; // white mask gets painted over
overMaskImgData.data[i + 1] = 255;
overMaskImgData.data[i + 2] = 255;
overMaskImgData.data[i + 3] = 255;
initImgData.data[i] = 0; // null area on initial image becomes opaque black pixels
initImgData.data[i + 1] = 0;
initImgData.data[i + 2] = 0;
initImgData.data[i + 3] = 255;
} else {
// leave these pixels alone
overMaskImgData.data[i] = 0; // black mask gets ignored for in/outpainting
overMaskImgData.data[i + 1] = 0;
overMaskImgData.data[i + 2] = 0;
overMaskImgData.data[i + 3] = 255; // but it still needs an opaque alpha channel
initImgData.data[i] = imgChunkData[i]; // put the original picture back in the painted area
initImgData.data[i + 1] = imgChunkData[i + 1];
initImgData.data[i + 2] = imgChunkData[i + 2];
initImgData.data[i + 3] = imgChunkData[i + 3]; //it's still RGBA so we can handily do this in nice chunks'o'4
}
}
if (overMaskPx > 0) {
// https://stackoverflow.com/a/30204783 ???? !!!!!!!!
overMaskCanvasCtx.fillStyle = "black";
overMaskCanvasCtx.fillRect(0, 0, drawIt.w, drawIt.h); // fill with black instead of null to start
for (i = 0; i < overMaskImgData.data.length; i += 4) {
if (overMaskImgData.data[i] == 255) {
// white pixel?
// just blotch all over the thing
var rando = Math.floor(Math.random() * overMaskPx);
overMaskCanvasCtx.beginPath();
overMaskCanvasCtx.arc(
(i / 4) % overMaskCanvas.width,
Math.floor(i / 4 / overMaskCanvas.width),
scaleFactor + rando, // was 4 * sf + rando, too big
0,
2 * Math.PI,
true
);
overMaskCanvasCtx.fillStyle = "#FFFFFFFF";
overMaskCanvasCtx.fill();
}
}
overMaskImgData = overMaskCanvasCtx.getImageData(
0,
0,
overMaskCanvas.width,
overMaskCanvas.height
);
overMaskCanvasCtx.putImageData(overMaskImgData, 0, 0);
}
// also check for painted masks in region, add them as white pixels to mask canvas
const maskChunk = maskPaintCtx.getImageData(
drawIt.x,
drawIt.y,
drawIt.w,
drawIt.h
);
const maskChunkData = maskChunk.data;
for (let i = 0; i < maskChunkData.length; i += 4) {
if (maskChunkData[i + 3] != 0) {
overMaskImgData.data[i] = 255;
overMaskImgData.data[i + 1] = 255;
overMaskImgData.data[i + 2] = 255;
overMaskImgData.data[i + 3] = 255;
}
}
// backup any painted masks ingested then them, replacable if user doesn't like resultant image
var clearArea = maskPaintCtx.createImageData(drawIt.w, drawIt.h);
backupMaskChunk = maskChunk;
backupMaskX = drawIt.x;
backupMaskY = drawIt.y;
var clearD = clearArea.data;
for (let i = 0; i < clearD.length; i++) {
clearD[i] = 0; // just null it all out
}
maskPaintCtx.putImageData(clearArea, drawIt.x, drawIt.y);
// mask monitors
overMaskCanvasCtx.putImageData(overMaskImgData, 0, 0); // :pray:
var overMaskBase64 = overMaskCanvas.toDataURL();
initImgCanvasCtx.putImageData(initImgData, 0, 0);
var initImgBase64 = initImgCanvas.toDataURL();
// anyway all that to say NOW let's run img2img
endpoint = "img2img";
stableDiffusionData.mask = overMaskBase64;
stableDiffusionData.init_images = [initImgBase64];
// slightly more involved than txt2img
} else {
// time to run txt2img
endpoint = "txt2img";
// easy enough
}
stableDiffusionData.prompt = document.getElementById("prompt").value;
stableDiffusionData.negative_prompt =
document.getElementById("negPrompt").value;
stableDiffusionData.width = drawIt.w;
stableDiffusionData.height = drawIt.h;
stableDiffusionData.firstphase_height = drawIt.h / 2;
stableDiffusionData.firstphase_width = drawIt.w / 2;
dream(drawIt.x, drawIt.y, stableDiffusionData);
}
}
}
}
function changePaintMode() {
paintMode = document.getElementById("cbxPaint").checked;
clearTargetMask();
ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height);
}
function changeEnableErasing() {
// yeah because this is for the image layer
enableErasing = document.getElementById("cbxEnableErasing").checked;
localStorage.setItem("enable_erase", enableErasing);
}
function changeSampler() {
if (!document.getElementById("samplerSelect").value == "") {
// must be done, since before getSamplers is done, the options are empty
@ -816,6 +566,7 @@ const changeScaleFactor = sliderChangeHandlerFactory(
"scaleFactorTxt",
"scaleFactor",
8,
true,
(k, v) => (scaleFactor = v),
(k) => scaleFactor
);
@ -1247,6 +998,5 @@ function loadSettings() {
document.getElementById("maskBlur").value = Number(_mask_blur);
document.getElementById("seed").value = Number(_seed);
document.getElementById("cbxHRFix").checked = Boolean(_enable_hr);
document.getElementById("cbxEnableErasing").checked = Boolean(_enable_erase);
document.getElementById("overMaskPx").value = Number(_overmask_px);
}

View file

@ -31,9 +31,9 @@ function _context_coords() {
}
function _mouse_observers() {
return {
// Simple click handlers
// Simple click handler
onclick: new Observer(),
// Double click handlers (will still trigger simple click handler as well)
// Double click handler (will still trigger simple click handler as well)
ondclick: new Observer(),
// Drag handler
ondragstart: new Observer(),
@ -48,6 +48,7 @@ function _mouse_observers() {
function _context_observers() {
return {
onwheel: new Observer(),
onmousemove: new Observer(),
left: _mouse_observers(),
middle: _mouse_observers(),
@ -270,36 +271,27 @@ window.onmousemove = (evn) => {
});
});
};
/** MOUSE DEBUG */
/*
mouse.listen.window.right.onclick.on(() =>
console.debug('mouse.listen.window.right.onclick')
);
mouse.listen.window.right.ondclick.on(() =>
console.debug('mouse.listen.window.right.ondclick')
window.addEventListener(
"wheel",
(evn) => {
evn.preventDefault();
["window", "canvas", "world"].forEach((ctx) => {
mouse.listen[ctx].onwheel.emit({
target: evn.target,
delta: evn.deltaY,
deltaX: evn.deltaX,
deltaY: evn.deltaY,
deltaZ: evn.deltaZ,
mode: evn.deltaMode,
x: mouse[ctx].pos.x,
y: mouse[ctx].pos.y,
timestamp: new Date(),
});
});
},
{passive: false}
);
mouse.listen.window.right.ondragstart.on(() =>
console.debug('mouse.listen.window.right.ondragstart')
);
mouse.listen.window.right.ondrag.on(() =>
console.debug('mouse.listen.window.right.ondrag')
);
mouse.listen.window.right.ondragend.on(() =>
console.debug('mouse.listen.window.right.ondragend')
);
mouse.listen.window.right.onpaintstart.on(() =>
console.debug('mouse.listen.window.right.onpaintstart')
);
mouse.listen.window.right.onpaint.on(() =>
console.debug('mouse.listen.window.right.onpaint')
);
mouse.listen.window.right.onpaintend.on(() =>
console.debug('mouse.listen.window.right.onpaintend')
);
*/
/**
* Keyboard input processing
*/
@ -370,7 +362,18 @@ window.onkeydown = (evn) => {
}, inputConfig.keyboardHoldTiming),
};
// Process shortcuts
// Process shortcuts if input target is not a text field
switch (evn.target.tagName.toLowerCase()) {
case "input":
case "textarea":
case "select":
case "button":
return; // If in an input field, do not process shortcuts
default:
// Do nothing
break;
}
const callbacks = keyboard.shortcuts[evn.code];
if (callbacks)

View file

@ -1,40 +1,4 @@
//dragElement(document.getElementById("infoContainer"));
//dragElement(document.getElementById("historyContainer"));
function dragElement(elmnt) {
var p3 = 0,
p4 = 0;
var draggableElements = elmnt.getElementsByClassName("draggable");
for (var i = 0; i < draggableElements.length; i++) {
draggableElements[i].onmousedown = dragMouseDown;
}
function dragMouseDown(e) {
e.preventDefault();
p3 = e.clientX;
p4 = e.clientY;
document.onmouseup = closeDragElement;
document.onmousemove = elementDrag;
}
function elementDrag(e) {
e.preventDefault();
elmnt.style.bottom = null;
elmnt.style.right = null;
elmnt.style.top = elmnt.offsetTop - (p4 - e.clientY) + "px";
elmnt.style.left = elmnt.offsetLeft - (p3 - e.clientX) + "px";
p3 = e.clientX;
p4 = e.clientY;
}
function closeDragElement() {
document.onmouseup = null;
document.onmousemove = null;
}
}
function makeDraggable(id) {
const element = document.getElementById(id);
function makeDraggable(element) {
const startbb = element.getBoundingClientRect();
let dragging = false;
let offset = {x: 0, y: 0};
@ -66,7 +30,9 @@ function makeDraggable(id) {
});
}
makeDraggable("infoContainer");
document.querySelectorAll(".floating-window").forEach((w) => {
makeDraggable(w);
});
var coll = document.getElementsByClassName("collapsible");
for (var i = 0; i < coll.length; i++) {

View file

@ -6,3 +6,14 @@ keyboard.onShortcut({ctrl: true, key: "KeyZ"}, () => {
keyboard.onShortcut({ctrl: true, key: "KeyY"}, () => {
commands.redo();
});
// Tool shortcuts
keyboard.onShortcut({key: "KeyD"}, () => {
tools.dream.enable();
});
keyboard.onShortcut({key: "KeyM"}, () => {
tools.maskbrush.enable();
});
keyboard.onShortcut({key: "KeyI"}, () => {
tools.img2img.enable();
});

View file

@ -1,6 +1,4 @@
(() => {
makeDraggable("historyContainer");
const historyView = document.getElementById("history");
const makeHistoryEntry = (index, id, title) => {
@ -27,18 +25,26 @@
};
_commands_events.on((message) => {
if (message.action === "run") {
Array.from(historyView.children).forEach((child) => {
if (
!commands.history.find((entry) => `hist-${entry.id}` === child.id)
) {
console.log("Removing " + child.id);
historyView.removeChild(child);
}
});
}
commands.history.forEach((entry, index) => {
console.log("Inserting " + entry.id);
if (!document.getElementById(`hist-${entry.id}`)) {
console.log("Inserting " + entry.id);
historyView.appendChild(
makeHistoryEntry(index, `hist-${entry.id}`, entry.title)
);
}
});
while (historyView.children.length > commands.history.length)
historyView.removeChild(historyView.lastElementChild);
Array.from(historyView.children).forEach((child, index) => {
if (index === commands.current) {
child.classList.remove(["past"]);

178
js/ui/tool/dream.js Normal file
View file

@ -0,0 +1,178 @@
const dream_generate_callback = (evn, state) => {
if (evn.target.id === "overlayCanvas" && !blockNewImages) {
const bb = getBoundingBox(
evn.x,
evn.y,
basePixelCount * scaleFactor,
basePixelCount * scaleFactor,
state.snapToGrid && basePixelCount
);
// Build request to the API
const request = {};
Object.assign(request, stableDiffusionData);
// Load prompt (maybe we should add some events so we don't have to do this)
request.prompt = document.getElementById("prompt").value;
request.negative_prompt = document.getElementById("negPrompt").value;
// Don't allow another image until is finished
blockNewImages = true;
// Setup marching ants
stopMarching = march(bb);
// Setup some basic information for SD
request.width = bb.w;
request.height = bb.h;
request.firstphase_width = bb.w / 2;
request.firstphase_height = bb.h / 2;
// Use txt2img if canvas is blank
if (isCanvasBlank(bb.x, bb.y, bb.w, bb.h, imgCanvas)) {
// Dream
dream(bb.x, bb.y, request, {method: "txt2img", stopMarching, bb});
} else {
// Use img2img if not
// Temporary canvas for init image and mask generation
const auxCanvas = document.createElement("canvas");
auxCanvas.width = request.width;
auxCanvas.height = request.height;
const auxCtx = auxCanvas.getContext("2d");
auxCtx.fillStyle = "#000F";
// Get init image
auxCtx.fillRect(0, 0, bb.w, bb.h);
auxCtx.drawImage(imgCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h);
request.init_images = [auxCanvas.toDataURL()];
// Get mask image
auxCtx.fillRect(0, 0, bb.w, bb.h);
auxCtx.globalCompositeOperation = "destination-in";
auxCtx.drawImage(imgCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h);
auxCtx.globalCompositeOperation = "destination-out";
auxCtx.drawImage(
maskPaintCanvas,
bb.x,
bb.y,
bb.w,
bb.h,
0,
0,
bb.w,
bb.h
);
auxCtx.globalCompositeOperation = "destination-atop";
auxCtx.fillStyle = "#FFFF";
auxCtx.fillRect(0, 0, bb.w, bb.h);
request.mask = auxCanvas.toDataURL();
// Dream
dream(bb.x, bb.y, request, {method: "img2img", stopMarching, bb});
}
}
};
const dream_erase_callback = (evn, state) => {
const bb = getBoundingBox(
evn.x,
evn.y,
basePixelCount * scaleFactor,
basePixelCount * scaleFactor,
state.snapToGrid && basePixelCount
);
commands.runCommand("eraseImage", "Erase Area", bb);
};
/**
* Image to Image
*/
const dream_img2img_callback = (evn, state) => {
if (evn.target.id === "overlayCanvas" && !blockNewImages) {
const bb = getBoundingBox(
evn.x,
evn.y,
basePixelCount * scaleFactor,
basePixelCount * scaleFactor,
state.snapToGrid && basePixelCount
);
// Do nothing if no image exists
if (isCanvasBlank(bb.x, bb.y, bb.w, bb.h, imgCanvas)) return;
// Build request to the API
const request = {};
Object.assign(request, stableDiffusionData);
request.denoising_strength = state.denoisingStrength;
request.inpainting_fill = 1; // For img2img use original
// Load prompt (maybe we should add some events so we don't have to do this)
request.prompt = document.getElementById("prompt").value;
request.negative_prompt = document.getElementById("negPrompt").value;
// Don't allow another image until is finished
blockNewImages = true;
// Setup marching ants
stopMarching = march(bb);
// Setup some basic information for SD
request.width = bb.w;
request.height = bb.h;
request.firstphase_width = bb.w / 2;
request.firstphase_height = bb.h / 2;
// Use img2img
// Temporary canvas for init image and mask generation
const auxCanvas = document.createElement("canvas");
auxCanvas.width = request.width;
auxCanvas.height = request.height;
const auxCtx = auxCanvas.getContext("2d");
auxCtx.fillStyle = "#000F";
// Get init image
auxCtx.fillRect(0, 0, bb.w, bb.h);
auxCtx.drawImage(imgCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h);
request.init_images = [auxCanvas.toDataURL()];
// Get mask image
auxCtx.fillRect(0, 0, bb.w, bb.h);
auxCtx.globalCompositeOperation = "destination-out";
auxCtx.drawImage(maskPaintCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h);
// Border Mask
if (state.useBorderMask) {
auxCtx.fillStyle = "#000F";
auxCtx.fillRect(0, 0, state.borderMaskSize, bb.h);
auxCtx.fillRect(0, 0, bb.w, state.borderMaskSize);
auxCtx.fillRect(
bb.w - state.borderMaskSize,
0,
state.borderMaskSize,
bb.h
);
auxCtx.fillRect(
0,
bb.h - state.borderMaskSize,
bb.w,
state.borderMaskSize
);
}
auxCtx.globalCompositeOperation = "destination-atop";
auxCtx.fillStyle = "#FFFF";
auxCtx.fillRect(0, 0, bb.w, bb.h);
request.mask = auxCanvas.toDataURL();
request.inpainting_mask_invert = true;
// Dream
dream(bb.x, bb.y, request, {method: "img2img", stopMarching, bb});
}
};

27
js/ui/tool/maskbrush.js Normal file
View file

@ -0,0 +1,27 @@
const mask_brush_draw_callback = (evn, state) => {
if (evn.initialTarget.id === "overlayCanvas") {
maskPaintCtx.globalCompositeOperation = "source-over";
maskPaintCtx.strokeStyle = "#FF6A6A";
maskPaintCtx.lineWidth = state.brushSize;
maskPaintCtx.beginPath();
maskPaintCtx.moveTo(evn.px, evn.py);
maskPaintCtx.lineTo(evn.x, evn.y);
maskPaintCtx.lineJoin = maskPaintCtx.lineCap = "round";
maskPaintCtx.stroke();
}
};
const mask_brush_erase_callback = (evn, state) => {
if (evn.initialTarget.id === "overlayCanvas") {
maskPaintCtx.globalCompositeOperation = "destination-out";
maskPaintCtx.strokeStyle = "#FFFFFFFF";
maskPaintCtx.lineWidth = state.brushSize;
maskPaintCtx.beginPath();
maskPaintCtx.moveTo(evn.px, evn.py);
maskPaintCtx.lineTo(evn.x, evn.y);
maskPaintCtx.lineJoin = maskPaintCtx.lineCap = "round";
maskPaintCtx.stroke();
}
};

456
js/ui/toolbar.js Normal file
View file

@ -0,0 +1,456 @@
/**
* Toolbar
*/
const toolbar = {
_toolbar: document.getElementById("ui-toolbar"),
tools: [],
_makeToolbarEntry: (tool) => {
const toolTitle = document.createElement("img");
toolTitle.classList.add("tool-icon");
toolTitle.src = tool.icon;
const toolEl = document.createElement("div");
toolEl.id = `tool-${tool.id}`;
toolEl.classList.add("tool");
toolEl.title = tool.name;
if (tool.options.shortcut) toolEl.title += ` (${tool.options.shortcut})`;
toolEl.onclick = () => tool.enable();
toolEl.appendChild(toolTitle);
return toolEl;
},
registerTool(
icon,
toolname,
enable,
disable,
options = {
/**
* Runs on tool creation. It receives the tool state.
*
* Can be used to setup callback functions, for example.
*/
init: null,
/**
* Function to populate the state menu.
*
* It receives a div element (that is the menu) and the current tool state.
*/
populateContextMenu: null,
/**
* Help description of the tool; for now used for nothing
*/
description: "",
/**
* Shortcut; Text describing this tool's shortcut access
*/
shortcut: "",
}
) {
// Set some defaults
if (!options.init)
options.init = (state) => console.debug(`Initialized tool '${toolname}'`);
if (!options.populateContextMenu)
options.populateContextMenu = (menu, state) => {
const span = document.createElement("span");
span.textContent = "Tool has no context menu";
menu.appendChild(span);
return;
};
// Create tool
const id = guid();
const contextMenuEl = document.getElementById("tool-context");
const tool = {
id,
icon,
name: toolname,
enabled: false,
_element: null,
state: {},
options,
enable: (opt = null) => {
this.tools.filter((t) => t.enabled).forEach((t) => t.disable());
while (contextMenuEl.lastChild) {
contextMenuEl.removeChild(contextMenuEl.lastChild);
}
options.populateContextMenu(contextMenuEl, tool.state);
tool._element && tool._element.classList.add("using");
tool.enabled = true;
enable(tool.state, opt);
},
disable: (opt = null) => {
tool._element && tool._element.classList.remove("using");
disable(tool.state, opt);
tool.enabled = false;
},
};
// Initalize
options.init && options.init(tool.state);
this.tools.push(tool);
// Add tool to toolbar
tool._element = this._makeToolbarEntry(tool);
this._toolbar.appendChild(tool._element);
return tool;
},
addSeparator() {
const separator = document.createElement("div");
separator.classList.add("separator");
this._toolbar.appendChild(separator);
},
};
/**
* Premade inputs for populating the context menus
*/
const _toolbar_input = {
checkbox: (state, dataKey, text) => {
if (state[dataKey] === undefined) state[dataKey] = false;
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = state[dataKey];
checkbox.onchange = () => (state[dataKey] = checkbox.checked);
const label = document.createElement("label");
label.appendChild(checkbox);
label.appendChild(new Text(text));
return {checkbox, label};
},
slider: (state, dataKey, text, min = 0, max = 1, step = 0.1) => {
const slider = document.createElement("input");
slider.type = "range";
slider.max = max;
slider.step = step;
slider.min = min;
slider.value = state[dataKey];
const textEl = document.createElement("input");
textEl.type = "number";
textEl.value = state[dataKey];
console.log(state[dataKey]);
sliderChangeHandlerFactoryEl(
slider,
textEl,
dataKey,
state[dataKey],
false,
(k, v) => (state[dataKey] = v),
(k) => state[dataKey]
);
const label = document.createElement("label");
label.appendChild(new Text(text));
label.appendChild(textEl);
label.appendChild(slider);
return {
slider,
text: textEl,
label,
setValue(v) {
slider.value = v;
textEl.value = slider.value;
return parseInt(slider.value);
},
};
},
};
/**
* Dream and img2img tools
*/
const _reticle_draw = (evn, snapToGrid = true) => {
if (evn.target.id === "overlayCanvas") {
const bb = getBoundingBox(
evn.x,
evn.y,
basePixelCount * scaleFactor,
basePixelCount * scaleFactor,
snapToGrid && basePixelCount
);
// draw targeting square reticle thingy cursor
ovCtx.strokeStyle = "#FFF";
ovCtx.strokeRect(bb.x, bb.y, bb.w, bb.h); //origin is middle of the frame
}
};
const tools = {};
/**
* Dream tool
*/
tools.dream = toolbar.registerTool(
"res/icons/image-plus.svg",
"Dream",
(state, opt) => {
// Draw new cursor immediately
ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height);
state.mousemovecb({...mouse.canvas.pos, target: {id: "overlayCanvas"}});
// Start Listeners
mouse.listen.canvas.onmousemove.on(state.mousemovecb);
mouse.listen.canvas.left.onclick.on(state.dreamcb);
mouse.listen.canvas.right.onclick.on(state.erasecb);
},
(state, opt) => {
// Clear Listeners
mouse.listen.canvas.onmousemove.clear(state.mousemovecb);
mouse.listen.canvas.left.onclick.clear(state.dreamcb);
mouse.listen.canvas.right.onclick.clear(state.erasecb);
},
{
init: (state) => {
state.snapToGrid = true;
state.mousemovecb = (evn) => _reticle_draw(evn, state.snapToGrid);
state.dreamcb = (evn) => {
dream_generate_callback(evn, state);
};
state.erasecb = (evn) => dream_erase_callback(evn, state);
},
populateContextMenu: (menu, state) => {
if (!state.ctxmenu) {
state.ctxmenu = {};
state.ctxmenu.snapToGridLabel = _toolbar_input.checkbox(
state,
"snapToGrid",
"Snap To Grid"
).label;
}
menu.appendChild(state.ctxmenu.snapToGridLabel);
},
shortcut: "D",
}
);
tools.img2img = toolbar.registerTool(
"res/icons/image.svg",
"Img2Img",
(state, opt) => {
// Draw new cursor immediately
ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height);
state.mousemovecb({...mouse.canvas.pos, target: {id: "overlayCanvas"}});
// Start Listeners
mouse.listen.canvas.onmousemove.on(state.mousemovecb);
mouse.listen.canvas.left.onclick.on(state.dreamcb);
mouse.listen.canvas.right.onclick.on(state.erasecb);
},
(state, opt) => {
// Clear Listeners
mouse.listen.canvas.onmousemove.clear(state.mousemovecb);
mouse.listen.canvas.left.onclick.clear(state.dreamcb);
mouse.listen.canvas.right.onclick.clear(state.erasecb);
},
{
init: (state) => {
state.snapToGrid = true;
state.denoisingStrength = 0.7;
state.useBorderMask = true;
state.borderMaskSize = 64;
state.mousemovecb = (evn) => {
_reticle_draw(evn, state.snapToGrid);
const bb = getBoundingBox(
evn.x,
evn.y,
basePixelCount * scaleFactor,
basePixelCount * scaleFactor,
state.snapToGrid && basePixelCount
);
// For displaying border mask
const auxCanvas = document.createElement("canvas");
auxCanvas.width = bb.w;
auxCanvas.height = bb.h;
const auxCtx = auxCanvas.getContext("2d");
if (state.useBorderMask) {
auxCtx.fillStyle = "#FF6A6A50";
auxCtx.fillRect(0, 0, state.borderMaskSize, bb.h);
auxCtx.fillRect(0, 0, bb.w, state.borderMaskSize);
auxCtx.fillRect(
bb.w - state.borderMaskSize,
0,
state.borderMaskSize,
bb.h
);
auxCtx.fillRect(
0,
bb.h - state.borderMaskSize,
bb.w,
state.borderMaskSize
);
}
const tmp = ovCtx.globalAlpha;
ovCtx.globalAlpha = 0.4;
ovCtx.drawImage(auxCanvas, bb.x, bb.y);
ovCtx.globalAlpha = tmp;
};
state.dreamcb = (evn) => {
dream_img2img_callback(evn, state);
};
state.erasecb = (evn) => dream_erase_callback(evn, state);
},
populateContextMenu: (menu, state) => {
if (!state.ctxmenu) {
state.ctxmenu = {};
// Snap To Grid Checkbox
state.ctxmenu.snapToGridLabel = _toolbar_input.checkbox(
state,
"snapToGrid",
"Snap To Grid"
).label;
// Denoising Strength Slider
state.ctxmenu.denoisingStrengthLabel = _toolbar_input.slider(
state,
"denoisingStrength",
"Denoising Strength",
0,
1,
0.05
).label;
// Use Border Mask Checkbox
state.ctxmenu.useBorderMaskLabel = _toolbar_input.checkbox(
state,
"useBorderMask",
"Use Border Mask"
).label;
// Border Mask Size Slider
state.ctxmenu.borderMaskSize = _toolbar_input.slider(
state,
"borderMaskSize",
"Border Mask Size",
0,
128,
1
).label;
}
menu.appendChild(state.ctxmenu.snapToGridLabel);
menu.appendChild(document.createElement("br"));
menu.appendChild(state.ctxmenu.denoisingStrengthLabel);
menu.appendChild(document.createElement("br"));
menu.appendChild(state.ctxmenu.useBorderMaskLabel);
menu.appendChild(document.createElement("br"));
menu.appendChild(state.ctxmenu.borderMaskSize);
},
shortcut: "I",
}
);
/**
* Mask Editing tools
*/
toolbar.addSeparator();
/**
* Mask Brush tool
*/
tools.maskbrush = toolbar.registerTool(
"res/icons/paintbrush.svg",
"Mask Brush",
(state, opt) => {
// Draw new cursor immediately
ovCtx.clearRect(0, 0, ovCanvas.width, ovCanvas.height);
state.movecb({...mouse.canvas.pos, target: {id: "overlayCanvas"}});
// Start Listeners
mouse.listen.canvas.onmousemove.on(state.movecb);
mouse.listen.canvas.onwheel.on(state.wheelcb);
mouse.listen.canvas.left.onpaint.on(state.drawcb);
mouse.listen.canvas.right.onpaint.on(state.erasecb);
},
(state, opt) => {
// Clear Listeners
mouse.listen.canvas.onmousemove.clear(state.movecb);
mouse.listen.canvas.onwheel.on(state.wheelcb);
mouse.listen.canvas.left.onpaint.clear(state.drawcb);
mouse.listen.canvas.right.onpaint.clear(state.erasecb);
},
{
init: (state) => {
state.config = {
brushScrollSpeed: 1 / 4,
minBrushSize: 10,
maxBrushSize: 500,
};
state.brushSize = 64;
state.setBrushSize = (size) => {
state.brushSize = size;
state.ctxmenu.brushSizeRange.value = size;
state.ctxmenu.brushSizeText.value = size;
};
state.movecb = (evn) => {
if (evn.target.id === "overlayCanvas") {
// draw big translucent red blob cursor
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 = "#FF6A6A50";
ovCtx.fill();
}
};
state.wheelcb = (evn) => {
if (evn.target.id === "overlayCanvas") {
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.drawcb = (evn) => mask_brush_draw_callback(evn, state);
state.erasecb = (evn) => mask_brush_erase_callback(evn, state);
},
populateContextMenu: (menu, state) => {
if (!state.ctxmenu) {
state.ctxmenu = {};
const brushSizeSlider = _toolbar_input.slider(
state,
"brushSize",
"Brush Size",
state.config.minBrushSize,
state.config.maxBrushSize,
1
);
state.ctxmenu.brushSizeLabel = brushSizeSlider.label;
state.setBrushSize = brushSizeSlider.setValue;
}
menu.appendChild(state.ctxmenu.brushSizeLabel);
},
shortcut: "M",
}
);
toolbar.tools[0].enable();

View file

@ -41,3 +41,41 @@ const guid = (size = 3) => {
id += s4();
return id;
};
/**
* Bounding box Calculation
*/
function snap(i, scaled = true, gridSize = 64) {
// very cheap test proof of concept but it works surprisingly well
var scaleOffset = 0;
if (scaled) {
if (scaleFactor % 2 != 0) {
// odd number, snaps to center of cell, oops
scaleOffset = gridSize / 2;
}
}
var snapOffset = (i % gridSize) - scaleOffset;
if (snapOffset == 0) {
return snapOffset;
}
return -snapOffset;
}
function getBoundingBox(cx, cy, w, h, gridSnap = null) {
const offset = {x: 0, y: 0};
const box = {x: 0, y: 0};
if (gridSnap) {
offset.x = snap(cx, true, gridSnap);
offset.y = snap(cy, true, gridSnap);
}
box.x = offset.x + cx;
box.y = offset.y + cy;
return {
x: Math.floor(box.x - w / 2),
y: Math.floor(box.y - h / 2),
w,
h,
};
}

7
res/icons/LICENSE Normal file
View file

@ -0,0 +1,7 @@
ISC License
Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022.
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

8
res/icons/image-plus.svg Normal file
View file

@ -0,0 +1,8 @@
<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="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7"></path>
<line x1="16" y1="5" x2="22" y2="5"></line>
<line x1="19" y1="2" x2="19" y2="8"></line>
<circle cx="9" cy="9" r="2"></circle>
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"></path>
</svg>

After

Width:  |  Height:  |  Size: 460 B

6
res/icons/image.svg Normal file
View file

@ -0,0 +1,6 @@
<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">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="9" cy="9" r="2"></circle>
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"></path>
</svg>

After

Width:  |  Height:  |  Size: 356 B

6
res/icons/paintbrush.svg Normal file
View file

@ -0,0 +1,6 @@
<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="M18.37 2.63 14 7l-1.59-1.59a2 2 0 0 0-2.82 0L8 7l9 9 1.59-1.59a2 2 0 0 0 0-2.82L17 10l4.37-4.37a2.12 2.12 0 1 0-3-3Z"></path>
<path d="M9 8c-2 3-4 3.5-7 4l8 10c2-1 6-5 6-7"></path>
<path d="M14.5 17.5 4.5 15"></path>
</svg>

After

Width:  |  Height:  |  Size: 421 B