Compare commits

...
9 changed files with 435 additions and 49 deletions
@@ -25,6 +25,8 @@ import { ToolbarMainButtonNames } from "./enum";
import { dispatchUiEvents, uiEvents } from "~/signals/uiEvents";
import { paintColor } from "~/signals/uiEvents/toolbarMain/paintMode";
import { ColorPicker } from "~/components/ui/TextEditor/ColorPicker";
import { PaintModeMenu } from "~/components/ui/ToolbarMain/PaintModeMenu";
import { EraseModeMenu } from "~/components/ui/ToolbarMain/EraseModeMenu";
export const ToolbarMain = ({
disabled = false,
@@ -47,7 +49,7 @@ export const ToolbarMain = ({
paperWrapperStyles,
"glass",
disabled &&
"pointer-events-none cursor-default bg-ui-border shadow-md",
"pointer-events-none cursor-default bg-ui-border shadow-md",
)}
>
<div className="flex flex-col items-center gap-2">
@@ -155,9 +157,10 @@ export const ToolbarMain = ({
<div className="flex flex-col items-center gap-2">
<div className="flex flex-col items-center gap-2">
{ /* Conditionally show paint menu on mode selection */}
{buttonProps.PAINT.active ? (
<div className="relative">
<ColorPicker
<PaintModeMenu
color={paintColor.value}
onChange={dispatchUiEvents.toolbarMain.setPaintColor}
faIcon={faPaintbrush}
@@ -182,11 +185,25 @@ export const ToolbarMain = ({
tooltip="Paint Brush"
/>
)}
<ToolbarButton
icon={faEraser}
buttonProps={buttonProps.ERASER}
tooltip="Eraser"
/>
{ /* Conditionally show eraser menu on mode selection */}
{buttonProps.ERASER.active ? (
<EraseModeMenu
faIcon={faEraser}
borderStyle="border-2 bg-primary/30 border-2 border-primary hover:bg-primary/30 text-white"
defaultOpen={true}
anchor="right"
anchorGap={12}
closeOnMouseLeave={true}
/>
)
: (
<ToolbarButton
icon={faEraser}
buttonProps={buttonProps.ERASER}
tooltip="Eraser"
/>
)}
</div>
</div>
+67 -31
View File
@@ -1,6 +1,8 @@
import { Transition } from "@headlessui/react";
import React, { useState, useRef, useEffect, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import { Button } from "./Button";
import { faMinus, faPlus } from "@fortawesome/pro-solid-svg-icons";
interface SliderProps {
min: number;
@@ -13,6 +15,8 @@ interface SliderProps {
tooltipContent?: ReactNode;
suffix?: string;
innerLabel?: string;
showDecrement?: boolean,
showIncrement?: boolean,
}
export const Slider = ({
@@ -26,6 +30,8 @@ export const Slider = ({
tooltipContent,
suffix,
innerLabel,
showDecrement,
showIncrement
}: SliderProps) => {
const [localValue, setLocalValue] = useState(value);
const sliderRef = useRef<HTMLDivElement>(null);
@@ -36,15 +42,19 @@ export const Slider = ({
setLocalValue(value);
}, [value]);
const setClampedValue = (value: number) => {
const roundedValue = Math.round(value / step) * step;
const clampedValue = Math.min(Math.max(roundedValue, min), max);
setLocalValue(clampedValue);
onChange(clampedValue);
}
const updatePosition = (x: number) => {
if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect();
const fraction = (x - rect.left) / rect.width;
const newValue = fraction * (max - min) + min;
const roundedValue = Math.round(newValue / step) * step;
const clampedValue = Math.min(Math.max(roundedValue, min), max);
setLocalValue(clampedValue);
onChange(clampedValue);
setClampedValue(newValue);
};
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
@@ -81,49 +91,75 @@ export const Slider = ({
}
};
const handleDecrement = () => {
setClampedValue(localValue - step);
}
const handleIncrement = () => {
setClampedValue(localValue + step);
}
const percentage = ((localValue - min) / (max - min)) * 100;
const displayValue =
localValue.toFixed(step.toString().split(".")[1]?.length || 0) +
(suffix || "");
return (
<div
ref={sliderRef}
className={twMerge(
"glass group relative h-7 w-full cursor-pointer overflow-hidden rounded-lg border border-ui-border",
isDragging && "!bg-ui-controls/90",
className,
<div className="flex w-full">
{showDecrement && (
<Button
icon={faMinus}
className="size-6 text-white/80 bg-transparent rounded-full hover:bg-white/10 focus-visible:outline-primary active:bg-primary/30 my-auto mr-1"
onClick={handleDecrement}
/>
)}
onMouseDown={handleMouseDown}
>
<div
ref={sliderRef}
className={twMerge(
"absolute h-7 bg-primary/30 transition-colors duration-300 group-hover:bg-primary/50",
isDragging && "!bg-primary/50",
"glass group relative h-7 w-full cursor-pointer overflow-hidden rounded-lg border border-ui-border",
isDragging && "!bg-ui-controls/90",
className,
)}
style={{ width: `${percentage}%` }}
onMouseDown={handleMouseDown}
>
{innerLabel && (
<span
className={twMerge(
"absolute top-1/2 ml-2.5 -translate-y-1/2 text-nowrap text-sm font-medium text-white/60 transition-colors duration-300 group-hover:text-white",
isDragging && "!text-white",
)}
>
{innerLabel}
</span>
)}
<div
className={twMerge(
"absolute right-0 top-1/2 mr-1.5 h-3.5 w-0.5 -translate-y-1/2",
isDragging ? "bg-white" : "bg-white/50",
"absolute h-7 bg-primary/30 transition-colors duration-300 group-hover:bg-primary/50",
isDragging && "!bg-primary/50",
)}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
style={{ width: `${percentage}%` }}
>
{innerLabel && (
<span
className={twMerge(
"absolute top-1/2 ml-2.5 -translate-y-1/2 text-nowrap text-sm font-medium text-white/60 transition-colors duration-300 group-hover:text-white",
isDragging && "!text-white",
)}
>
{innerLabel}
</span>
)}
<div
className={twMerge(
"absolute right-0 top-1/2 mr-1.5 h-3.5 w-0.5 -translate-y-1/2",
isDragging ? "bg-white" : "bg-white/50",
)}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
</div>
</div>
{showIncrement && (
<Button
icon={faPlus}
className="size-6 text-white/80 bg-transparent rounded-full hover:bg-white/10 focus-visible:outline-primary active:bg-primary/30 my-auto ml-1"
onClick={handleIncrement}
/>
)}
{showTooltip && (
<Transition
as={"div"}
@@ -1,5 +1,3 @@
import { ChangeEvent, useState, useEffect } from "react";
import { twMerge } from "tailwind-merge";
import {
faFont,
faHashtag,
@@ -7,11 +5,13 @@ import {
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { HexColorPicker } from "react-colorful";
import { ChangeEvent, useEffect, useState } from "react";
import { HexAlphaColorPicker } from "react-colorful";
import { twMerge } from "tailwind-merge";
import { paperWrapperStyles } from "~/components/styles";
import { Button } from "../Button";
import { Input } from "../Input";
import { paperWrapperStyles } from "~/components/styles";
export const ColorPicker = ({
color: prevColor,
@@ -119,7 +119,7 @@ export const ColorPicker = ({
}
static
>
<HexColorPicker
<HexAlphaColorPicker
className="overflow-hidden"
color={currColor}
onChange={(color) => {
@@ -0,0 +1,102 @@
import {
faFont,
faHashtag,
IconDefinition,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { ChangeEvent, useEffect, useState } from "react";
import { HexColorPicker } from "react-colorful";
import { twMerge } from "tailwind-merge";
import { useSignals } from "@preact/signals-react/runtime";
import { paperWrapperStyles } from "~/components/styles";
import { BRUSH_MAX_SIZE, BRUSH_MIN_SIZE, paintBrushSize, setPaintBrushSize } from "~/signals/uiEvents/toolbarMain/paintMode";
import { Button } from "../Button";
import { Input } from "../Input";
import { Slider } from "../Slider";
import { eraseBrushSize, setEraseBrushSize } from "~/signals/uiEvents/toolbarMain/eraseMode";
export const EraseModeMenu = ({
faIcon = faFont,
borderStyle,
anchor = "bottom",
anchorGap = 0,
defaultOpen = false,
closeOnMouseLeave = false,
}: {
faIcon?: IconDefinition;
borderStyle?: string;
anchor?: "top" | "right" | "bottom" | "left";
anchorGap?: number;
defaultOpen?: boolean;
closeOnMouseLeave?: boolean;
}) => {
const [isOpen, setIsOpen] = useState(defaultOpen);
useEffect(() => {
setIsOpen(defaultOpen);
}, [defaultOpen]);
const handleMouseLeave = (close: () => void) => {
if (closeOnMouseLeave) {
setIsOpen(false);
close();
}
};
// Rerender the component when these signals change:
useSignals();
const brushSize = eraseBrushSize.value;
return (
<Popover className="relative">
{({ open, close }) => (
<>
<PopoverButton
className={twMerge(
"flex size-10 items-center justify-center gap-1 rounded-lg bg-ui-controls p-2",
borderStyle,
)}
onMouseEnter={() => defaultOpen && setIsOpen(true)}
>
<FontAwesomeIcon
icon={faIcon}
/>
</PopoverButton>
{(open || isOpen) && (
<div onMouseLeave={() => handleMouseLeave(close)}>
<PopoverPanel
anchor={anchor}
className={twMerge(
paperWrapperStyles,
"flex flex-col items-center gap-2 overflow-hidden w-[198px]",
)}
style={
{
"--anchor-gap": `${anchorGap}px`,
} as React.CSSProperties
}
static
>
<div className="flex flex-col w-full gap-2">
<p className="text-white font-medium text-sm w-full justify-start">Eraser Size:</p>
<Slider
min={BRUSH_MIN_SIZE}
max={BRUSH_MAX_SIZE}
value={brushSize}
onChange={setEraseBrushSize}
step={1}
innerLabel={brushSize.toString()}
showDecrement
showIncrement
/>
</div>
</PopoverPanel>
</div>
)}
</>
)}
</Popover>
);
};
@@ -0,0 +1,194 @@
import {
faFont,
faHashtag,
IconDefinition,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { ChangeEvent, useEffect, useState } from "react";
import { HexAlphaColorPicker } from "react-colorful";
import { twMerge } from "tailwind-merge";
import { useSignals } from "@preact/signals-react/runtime";
import { paperWrapperStyles } from "~/components/styles";
import { BRUSH_MAX_SIZE, BRUSH_MIN_SIZE, paintBrushSize, setPaintBrushSize } from "~/signals/uiEvents/toolbarMain/paintMode";
import { Button } from "../Button";
import { Input } from "../Input";
import { Slider } from "../Slider";
export const PaintModeMenu = ({
color: prevColor,
onChange,
faIcon = faFont,
borderStyle,
showBar = true,
anchor = "bottom",
anchorGap = 0,
fillBg = false,
staticIconColor,
streamChanges = false,
defaultOpen = false,
closeOnMouseLeave = false,
}: {
color: string;
onChange: (newColor: string) => void;
faIcon?: IconDefinition;
borderStyle?: string;
showBar?: boolean;
anchor?: "top" | "right" | "bottom" | "left";
anchorGap?: number;
fillBg?: boolean;
staticIconColor?: string;
streamChanges?: boolean;
defaultOpen?: boolean;
closeOnMouseLeave?: boolean;
}) => {
const [isOpen, setIsOpen] = useState(defaultOpen);
const [{ currColor, textInput }, setStates] = useState<{
currColor: string;
textInput: string;
}>({
currColor: prevColor,
textInput: prevColor.substring(1),
});
useEffect(() => {
setIsOpen(defaultOpen);
}, [defaultOpen]);
const handleMouseLeave = (close: () => void) => {
if (closeOnMouseLeave) {
onChange(currColor);
setIsOpen(false);
close();
}
};
const handleTextInput = (e: ChangeEvent<HTMLInputElement>) => {
if (isHexColorCode(e.target.value)) {
setStates({
currColor: `#${e.target.value}`,
textInput: e.target.value,
});
} else {
setStates((curr) => ({
...curr,
textInput: e.target.value,
}));
}
};
const isHexColorCode = (color: string) => {
const hexColorRegex = /^(?:[0-9a-fA-F]{3}){1,2}$/;
return hexColorRegex.test(color);
};
// Rerender the component when these signals change:
useSignals();
const brushSize = paintBrushSize.value;
return (
<Popover className="relative">
{({ open, close }) => (
<>
<PopoverButton
className={twMerge(
"flex size-10 items-center gap-1 rounded-lg bg-ui-controls p-2",
borderStyle,
showBar ? "flex-col" : "justify-center",
)}
style={fillBg ? { backgroundColor: prevColor } : {}}
onMouseEnter={() => defaultOpen && setIsOpen(true)}
>
<FontAwesomeIcon
icon={faIcon}
color={staticIconColor ?? (fillBg ? undefined : prevColor)}
/>
{showBar && (
<span
className="h-1 w-full"
style={{ backgroundColor: prevColor }}
/>
)}
</PopoverButton>
{(open || isOpen) && (
<div onMouseLeave={() => handleMouseLeave(close)}>
<PopoverPanel
anchor={anchor}
className={twMerge(
paperWrapperStyles,
"flex flex-col items-center gap-2 overflow-hidden",
)}
style={
{
"--anchor-gap": `${anchorGap}px`,
} as React.CSSProperties
}
static
>
<HexAlphaColorPicker
className="overflow-hidden"
color={currColor}
onChange={(color) => {
setStates({
currColor: color,
textInput: color.substring(1),
});
if (streamChanges) {
onChange(color);
}
}}
/>
<Input
style={{ width: "198px" }}
icon={faHashtag}
value={textInput}
onChange={handleTextInput}
/>
<div className="flex flex-col w-full mt-2 gap-2">
<p className="text-white font-medium text-sm w-full justify-start">Brush Size:</p>
<Slider
min={BRUSH_MIN_SIZE}
max={BRUSH_MAX_SIZE}
value={brushSize}
onChange={setPaintBrushSize}
step={1}
innerLabel={brushSize.toString()}
showDecrement
showIncrement
/>
</div>
{!streamChanges && (
<div className="flex w-full justify-center gap-2">
<Button
variant="secondary"
onClick={() => {
setStates({
currColor: prevColor,
textInput: prevColor.substring(1),
});
setIsOpen(false);
close();
}}
>
Cancel
</Button>
<Button
onClick={() => {
onChange(currColor);
setIsOpen(false);
close();
}}
>
OK
</Button>
</div>
)}
</PopoverPanel>
</div>
)}
</>
)}
</Popover>
);
};
@@ -0,0 +1,14 @@
import { effect, signal } from "@preact/signals-react";
import { clamp, normalize } from "~/utilities";
import { BRUSH_MAX_SIZE, BRUSH_MIN_SIZE, DEFAULT_PAINT_BRUSH_SIZE, paintBrushSize } from "./paintMode";
export const eraseBrushSize = signal<number>(DEFAULT_PAINT_BRUSH_SIZE);
export const setEraseBrushSize = (brushSize: number) => {
eraseBrushSize.value = clamp(brushSize, BRUSH_MIN_SIZE, BRUSH_MAX_SIZE);
}
export const onEraseBrushSizeChanged = (callback: (normalizedSize: number) => void) => {
effect(() => {
callback(normalize(paintBrushSize.value, BRUSH_MIN_SIZE, BRUSH_MAX_SIZE));
});
};
@@ -3,14 +3,18 @@ import {
loadingBarRetryDispatch,
loadingBarRetryEventHandler,
} from "./loadingBar";
import { onPaintColorChanged, setPaintColor } from "./paintMode";
import { onEraseBrushSizeChanged, setEraseBrushSize } from "./eraseMode";
import { onPaintColorChanged, setPaintBrushSize, setPaintColor } from "./paintMode";
export const dispatchers = {
...buttonDispatchers,
loadingBarRetry: loadingBarRetryDispatch,
setPaintColor,
setPaintBrushSize,
setEraseBrushSize
};
export const eventsHandlers = {
...buttonEventsHandlers,
loadingBarRetry: loadingBarRetryEventHandler,
onPaintColorChanged
onPaintColorChanged,
onEraseBrushSizeChanged
};
@@ -1,15 +1,22 @@
// Prompt settings
// - Prompt text
import { effect, signal } from "@preact/signals-react";
import { clamp, normalize } from "~/utilities";
// - Prompt strength
export const DEFAULT_PAINT_COLOR: string = "#ff0000";
export const paintColor = signal<string>(DEFAULT_PAINT_COLOR);
export const setPaintColor = (data: string) => {
paintColor.value = data;
};
export const BRUSH_MIN_SIZE: number = 0;
export const BRUSH_MAX_SIZE: number = 100;
// NOTE: Must be between MIN and MAX sizes - there's no runtime checks
export const DEFAULT_PAINT_BRUSH_SIZE: number = 50;
export const paintBrushSize = signal<number>(DEFAULT_PAINT_BRUSH_SIZE);
export const setPaintBrushSize = (brushSize: number) => {
paintBrushSize.value = clamp(brushSize, BRUSH_MIN_SIZE, BRUSH_MAX_SIZE);
console.debug(`Paint brush size set to ${paintBrushSize.value}`);
}
export const onPaintColorChanged = (callback: (data: string) => void) => {
effect(() => {
if (paintColor.value) {
@@ -17,3 +24,9 @@ export const onPaintColorChanged = (callback: (data: string) => void) => {
}
});
};
export const onPaintBrushSizeChanged = (callback: (normalizedSize: number) => void) => {
effect(() => {
callback(normalize(paintBrushSize.value, BRUSH_MIN_SIZE, BRUSH_MAX_SIZE));
});
};
+6
View File
@@ -10,3 +10,9 @@ export const getFileName = (file: File) => {
export const getFileExtension = (file: File) => {
return file.name.substring(file.name.lastIndexOf(".") + 1);
};
export const clamp = (num: number, min: number, max: number) => {
return Math.min(Math.max(num, min), max);
}
export const normalize = (num: number, min: number, max: number) => {
return (num - min) / (max - min);
}