Files
ComfyBox/src/lib/components/MaskCanvas.svelte
2023-05-28 00:47:48 -05:00

670 lines
17 KiB
Svelte

<script context="module" lang="ts">
export type MaskCanvasData = {
hasMask: boolean,
maskCanvas: HTMLCanvasElement | null,
curLineGroup: LineGroup,
redoCurLines: LineGroup,
}
export type LinePoint = {
x: number,
y: number
}
export interface Line {
size?: number,
points: LinePoint[]
}
export type LineGroup = Line[];
</script>
<script lang="ts">
import { loadImage } from "$lib/widgets/utils"
import { tick, createEventDispatcher } from "svelte";
import { ArrowClockwise, ArrowCounterclockwise, XSquare, Exclude, Circle, Grid3x3Gap, ArrowsFullscreen, FullscreenExit } from "svelte-bootstrap-icons";
export let fileURL: string | null = null;
export let fullscreen: boolean = false;
const dispatch = createEventDispatcher<{
change: MaskCanvasData;
release: MaskCanvasData;
loaded: MaskCanvasData
}>();
let canvasCursor: string | undefined = undefined;
let container: HTMLDivElement | null;
let canvas: HTMLCanvasElement | null;
let maskCanvas: HTMLCanvasElement | null;
let renders: HTMLImageElement[] = [];
let context: CanvasRenderingContext2D | null;
let maskContext: CanvasRenderingContext2D | null;
let curLineGroup: LineGroup = [];
let redoCurLines: LineGroup = []
let original: HTMLImageElement | null;
let isImageLoaded: boolean = false;
let imageWidth: number = 512;
let imageHeight: number = 512;
let scale: number = 1.0;
let minScale: number = 1.0;
let brushSize: number = 100;
let maskBlur: number = 0;
let clipMask: boolean = false;
let hasMask: boolean = false;
let isDrawing: boolean = false;
let isPanning: boolean = false;
let isBrushShowing: boolean = false;
let transform = ""
$: transform = `translate(${imx}px, ${imy}px) scale(${scale})`
let imx: number = 0;
let imy: number = 0;
let x: number = 0;
let y: number = 0;
let panX: number = 0;
let panY: number = 0;
const BRUSH_COLOR = "#000"
enum MouseButton {
Left = 0,
Middle = 1,
Right = 2,
Back = 3,
Forward = 4
}
$: if (isPanning) {
canvasCursor = "grab";
}
else if (isImageLoaded && isBrushShowing) {
canvasCursor = "none";
}
else {
canvasCursor = undefined;
}
$: {
context = canvas ? canvas.getContext("2d") : null
}
function clearState() {
hasMask = false;
maskCanvas = null;
maskContext = null;
isImageLoaded = false;
original = null;
renders = []
// curLineGroup = [];
// redoCurLines = []
imageWidth = 512;
imageHeight = 512;
scale = 1.0;
minScale = 0.5;
}
let loadedFileURL: string | null = null
let dispatchLoaded: boolean = false;
$: if (fileURL !== loadedFileURL) {
clearState();
if (fileURL) {
loadImage(fileURL).then(i => {
original = i;
isImageLoaded = true;
dispatchLoaded = true;
})
.catch(i => {
isImageLoaded = false;
})
}
else {
isImageLoaded = false;
original = null;
}
loadedFileURL = fileURL
}
$: {
// initSizeAndScale(isImageLoaded, original);
[imageWidth, imageHeight] = getCurrentWidthAndHeight(isImageLoaded, original)
scale = initScale(imageWidth, imageHeight)
initImagePos()
// in case mask strokes were preserved after new image load
// (use case: sending an inpainted image back while reusing the same mask)
tick().then(() => {
loaded();
})
}
function loaded() {
if (!dispatchLoaded)
return;
dispatchLoaded = false;
redrawCurLines()
hasMask = curLineGroup.length > 0;
console.warn("[MaskCanvas] LOADED", maskCanvas, hasMask)
dispatch("loaded", {
hasMask,
maskCanvas,
curLineGroup,
redoCurLines
})
}
$: hasMask = curLineGroup.length > 0;
function initImagePos() {
if (!container)
return
const rect = container.getBoundingClientRect();
imx = rect.width / 2 - (imageWidth / 2) * scale
imy = rect.height / 2 - (imageHeight / 2) * scale
}
function initScale(width: number, height: number): number {
const s = getScale(width, height);
minScale = s / 2;
return s;
}
function initSizeAndScale(isImageLoaded: boolean, original: HTMLImageElement | null) {
[imageWidth, imageHeight] = getCurrentWidthAndHeight(isImageLoaded, original)
scale = getScale(imageWidth, imageHeight)
minScale = scale;
}
function drawOnCurrentRender(lineGroup: LineGroup) {
draw(lineGroup)
dispatch("change", {
hasMask,
maskCanvas,
curLineGroup,
redoCurLines
})
}
function draw(lineGroup: LineGroup) {
if (!context || !maskContext)
return
context.clearRect(0, 0, context.canvas.width, context.canvas.height)
maskContext.clearRect(0, 0, maskContext.canvas.width, maskContext.canvas.height)
const color = BRUSH_COLOR
const drawMask = (ctx: CanvasRenderingContext2D) => {
ctx.save();
ctx.filter = `blur(${maskBlur}px)`
drawLines(ctx, lineGroup, color)
ctx.restore();
}
drawMask(maskContext);
if (clipMask) {
context.save();
context.filter = `blur(${maskBlur}px)`
drawLines(context, lineGroup, color)
context.restore();
context.globalCompositeOperation = "source-in"
context.drawImage(original!!, 0, 0, imageWidth, imageHeight)
context.globalCompositeOperation = "source-over";
}
else {
drawMask(context);
}
}
function updateMaskImage() {
drawOnCurrentRender(curLineGroup);
}
function drawLines(ctx: CanvasRenderingContext2D, lines: LineGroup, color: string) {
ctx.strokeStyle = color
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
lines.forEach(line => {
if (!line?.points.length || !line.size) {
return
}
ctx.lineWidth = line.size
ctx.beginPath()
ctx.moveTo(line.points[0].x, line.points[0].y)
line.points.forEach(point => ctx.lineTo(point.x, point.y))
ctx.stroke()
})
}
function redrawCurLines() {
drawOnCurrentRender(curLineGroup || [])
}
$: if (canvas && original) {
console.warn("INITCANVAS", imageWidth, imageHeight, original.src)
maskCanvas = document.createElement("canvas");
maskContext = maskCanvas.getContext("2d")!;
maskCanvas.width = imageWidth;
maskCanvas.height = imageHeight;
canvas.width = imageWidth;
canvas.height = imageHeight;
redrawCurLines() // no react on curLineGroup
}
function getCurrentWidthAndHeight(isImageLoaded: boolean, original: HTMLImageElement | null) {
if (isImageLoaded && original){
return [original.naturalWidth, original.naturalHeight]
}
return [512, 512]
}
function getScale(width: number, height: number): number {
const size = container?.getBoundingClientRect();
if (!size) {
return 1.0
}
const ratioWidth = size.width / width
const ratioHeight = (size.height) / height
let scale: number = 1.0
if (ratioWidth < 1 || ratioHeight < 1) {
scale = Math.min(ratioWidth, ratioHeight)
}
return scale
}
function undoStroke() {
if (curLineGroup.length === 0) {
return
}
const lastLine = curLineGroup.pop()!
const newRedoCurLines = [...redoCurLines, lastLine]
redoCurLines = newRedoCurLines
const newLineGroup = [...curLineGroup]
curLineGroup = newLineGroup
drawOnCurrentRender(newLineGroup)
}
function redoStroke() {
if (redoCurLines.length === 0) {
return
}
const line = redoCurLines.pop()!
redoCurLines = [...redoCurLines]
const newLineGroup = [...curLineGroup, line]
curLineGroup = newLineGroup
drawOnCurrentRender(newLineGroup)
}
export function clearStrokes() {
redoCurLines = []
const newLineGroup: LineGroup = []
curLineGroup = newLineGroup
drawOnCurrentRender(newLineGroup)
}
export function recenterImage() {
scale = initScale(imageWidth, imageHeight)
initImagePos();
}
async function toggleFullscreen() {
fullscreen = !fullscreen;
updateMaskImage();
await tick();
recenterImage();
}
function onCanvasMouseOver() {
isBrushShowing = true;
}
function onCanvasFocus() {
isBrushShowing = true;
}
function onCanvasMouseLeave() {
isBrushShowing = false;
}
function mouseXY(e: MouseEvent): LinePoint {
return { x: e.offsetX, y: e.offsetY }
}
function onCanvasMouseDown(e: MouseEvent) {
if (!original?.src)
return;
if (isPanning)
return;
if (canvas == null)
return;
switch (e.button) {
case MouseButton.Right:
return;
case MouseButton.Middle:
isPanning = true;
panX = e.offsetX * scale;
panY = e.offsetY * scale;
return;
}
isDrawing = true;
redoCurLines = []
let lineGroup: LineGroup = [...curLineGroup]
lineGroup.push({size: brushSize, points: [mouseXY(e)] })
curLineGroup = lineGroup
drawOnCurrentRender(curLineGroup);
}
function onCanvasMouseUp() {
}
function onCanvasMouseMove(e: MouseEvent) {
if (isPanning)
return;
if (!isDrawing)
return;
if (curLineGroup.length === 0)
return
curLineGroup[curLineGroup.length-1].points.push(mouseXY(e))
curLineGroup = curLineGroup; // react
drawOnCurrentRender(curLineGroup)
}
function onCanvasMouseWheel(e: WheelEvent) {
e.preventDefault();
if (!container || e.target != canvas)
return;
const bound = container.getBoundingClientRect()
// coodinates on the image that were zoomed
const x_ = e.clientX - bound.x
const y_ = e.clientY - bound.y
e.preventDefault();
var delta = e.deltaY * -0.001
delta = Math.max(-1,Math.min(1,delta)) // cap the delta to [-1,1] for cross browser consistency
const zx = (x_ - imx)/scale
const zy = (y_ - imy)/scale
scale += delta * scale
scale = Math.max(minScale,scale)
imx = -zx * scale + x_
imy = -zy * scale + y_
x = e.offsetX * scale;
y = e.offsetY * scale;
}
function onMouseMove(e: MouseEvent) {
if (e.target != canvas)
return;
x = e.offsetX * scale;
y = e.offsetY * scale;
if (isPanning) {
imx += x - panX;
imy += y - panY;
}
}
function onMouseUp(e: MouseEvent) {
if (e.button === MouseButton.Middle) {
isPanning = false
panX = 0
panY = 0
}
if (isPanning)
return;
if (!original?.src)
return;
if (!canvas)
return;
if (!isDrawing)
return;
isDrawing = false;
dispatch("release", { hasMask, maskCanvas, curLineGroup, redoCurLines })
}
function dispatchRelease() {
updateMaskImage()
dispatch("release", { hasMask, maskCanvas, curLineGroup, redoCurLines })
}
</script>
<svelte:window on:mouseup={onMouseUp} />
<div class="me-container" class:fullscreen bind:this={container} on:mousemove={onMouseMove}>
{#if !isImageLoaded}
<div>
(empty)
</div>
{:else}
<div class="me-transform" style:transform={transform} style:--scale={scale}>
<div class="me-canvas-container">
<div class="me-original-image-container"
style:width="{imageWidth}px"
style:height="{imageHeight}px">
{#if original}
{@const showOriginal = !clipMask}
<img class="me-original-image"
src={original.src}
style:width={imageWidth}
style:height={imageHeight}
style:display={showOriginal ? "block" : "none"}
/>
{/if}
</div>
<canvas class="me-canvas"
bind:this={canvas}
style:cursor={canvasCursor}
on:mouseover={onCanvasMouseOver}
on:focus={onCanvasFocus}
on:wheel={onCanvasMouseWheel}
on:mouseleave={onCanvasMouseLeave}
on:mousedown|preventDefault={onCanvasMouseDown}
on:mouseup|preventDefault={onCanvasMouseUp}
on:mousemove={onCanvasMouseMove}
/>
</div>
</div>
{/if}
{#if isImageLoaded && isBrushShowing && !isPanning}
<div class="me-brush-cursor"
style:width="{brushSize * scale}px"
style:height="{brushSize * scale}px"
style:left="{x + imx}px"
style:top="{y + imy}px"
style:transform="translate(-50%, -50%)"
/>
{/if}
<div class="me-toolkit-bar">
<button disabled={curLineGroup.length === 0} on:click={undoStroke}>
<ArrowCounterclockwise />
</button>
<button disabled={redoCurLines.length === 0} on:click={redoStroke}>
<ArrowClockwise />
</button>
<button on:click={clearStrokes} disabled={curLineGroup.length === 0 && redoCurLines.length === 0}>
<XSquare/>
</button>
<label>
<Circle />
<input type="range" min="1" max="200" bind:value={brushSize} step="0.1"
on:change={updateMaskImage}
on:pointerup={dispatchRelease}/>
</label>
<label>
<Grid3x3Gap/>
<input type="range" min="1" max="100" bind:value={maskBlur} step="0.1"
on:change={updateMaskImage}
on:pointerup={dispatchRelease}/>
</label>
<div class="toggle-button" class:toggled={clipMask} on:click={() => {clipMask = !clipMask; updateMaskImage()}}>
<Exclude />
</div>
<div class="toggle-button" class:toggled={fullscreen} on:click={() => {toggleFullscreen()}}>
{#if fullscreen}
<FullscreenExit />
{:else}
<ArrowsFullscreen />
{/if}
</div>
</div>
</div>
<style lang="scss">
$bg-color: #a0a0a0;
.me-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background-color: white;
background-image:
linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(135deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(135deg, transparent 75%, #ccc 75%);
background-size:25px 25px; /* Must be a square */
background-position:0 0, 12.5px 0, 12.5px -12.5px, 0px 12.5px; /* Must be half of one side of the square */
&.fullscreen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
margin: auto;
z-index: var(--layer-top);
}
}
.me-transform {
--scale: 1;
display: flex;
flex-wrap: wrap;
width: -moz-fit-content;
width: fit-content;
height: -moz-fit-content;
height: fit-content;
margin: 0;
padding: 0;
transform-origin: 0% 0%;
}
.me-original-image-container {
position: absolute;
top: 0;
left: 0;
grid-area: editor-content;
pointer-events: none;
user-select: none;
display: grid;
grid-template-areas: 'original-image-content';
border: calc((1 / var(--scale)) * 5px) dashed grey;
img.me-original-image {
grid-area: original-image-content;
}
}
.me-canvas {
position: absolute;
z-index: 1;
}
.me-brush-cursor {
position: absolute;
border-radius: 50%;
background-color: #000;
border: 1px solid var(--yellow-accent);
pointer-events: none;
}
.me-toolkit-bar {
position: absolute;
bottom: 0.5rem;
border-radius: 3rem;
padding: 0.4rem 24px;
display: flex;
margin: 0.5rem auto;
gap: 16px;
left: 0;
right: 0;
width: 80%;
height: 3rem;
align-items: center;
justify-content: space-evenly;
backdrop-filter: blur(12px);
background-color: white;
animation: slideUp 0.2s ease-out;
border: var(--editor-toolkit-panel-border);
box-shadow: 0 0 0 4px #0000001a, 0 3px 16px #00000014, 0 2px 6px 1px #00000017;
label {
display: flex;
flex-direction: row;
gap: 4px;
input {
width: 5rem;
}
}
button {
&:not(:disabled) {
cursor: pointer;
}
&:hover:not(:disabled) {
color: var(--secondary-600);
}
&:disabled {
opacity: 40%;
}
}
.toggle-button {
&:hover:not(:disabled) {
color: var(--secondary-600);
}
&:not(:disabled) {
cursor: pointer;
}
&.toggled {
color: var(--secondary-400);
}
}
}
</style>