Add more tools for Metallic canvases

This commit is contained in:
Brian Beck 2025-02-05 21:48:24 -08:00
parent 11b8a03ad9
commit 43a1aa89af
22 changed files with 518 additions and 422 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,2 +1,2 @@
!function(){"use strict";var e,r,_,t,n,i,u,c={},o={};function __webpack_require__(e){var r=o[e];if(void 0!==r)return r.exports;var _=o[e]={id:e,loaded:!1,exports:{}},t=!0;try{c[e].call(_.exports,_,_.exports,__webpack_require__),t=!1}finally{t&&delete o[e]}return _.loaded=!0,_.exports}__webpack_require__.m=c,e=[],__webpack_require__.O=function(r,_,t,n){if(_){n=n||0;for(var i=e.length;i>0&&e[i-1][2]>n;i--)e[i]=e[i-1];e[i]=[_,t,n];return}for(var u=1/0,i=0;i<e.length;i++){for(var _=e[i][0],t=e[i][1],n=e[i][2],c=!0,o=0;o<_.length;o++)u>=n&&Object.keys(__webpack_require__.O).every(function(e){return __webpack_require__.O[e](_[o])})?_.splice(o--,1):(c=!1,n<u&&(u=n));if(c){e.splice(i--,1);var a=t()}}return a},__webpack_require__.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return __webpack_require__.d(r,{a:r}),r},__webpack_require__.d=function(e,r){for(var _ in r)__webpack_require__.o(r,_)&&!__webpack_require__.o(e,_)&&Object.defineProperty(e,_,{enumerable:!0,get:r[_]})},__webpack_require__.f={},__webpack_require__.e=function(e){return Promise.all(Object.keys(__webpack_require__.f).reduce(function(r,_){return __webpack_require__.f[_](e,r),r},[]))},__webpack_require__.u=function(e){return"static/chunks/"+(737===e?"fb7d5399":e)+"."+({250:"10c119307e239c98",737:"bc4a70b34221e8c8",767:"0dd6b240996f3455",848:"fc0fe21cdc2e6431"})[e]+".js"},__webpack_require__.miniCssF=function(e){return"static/css/"+({214:"922e89893536f2f9",888:"fa467350f3c0ad7f"})[e]+".css"},__webpack_require__.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||Function("return this")()}catch(e){if("object"==typeof window)return window}}(),__webpack_require__.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},r={},_="_N_E:",__webpack_require__.l=function(e,t,n,i){if(r[e]){r[e].push(t);return}if(void 0!==n)for(var u,c,o=document.getElementsByTagName("script"),a=0;a<o.length;a++){var p=o[a];if(p.getAttribute("src")==e||p.getAttribute("data-webpack")==_+n){u=p;break}}u||(c=!0,(u=document.createElement("script")).charset="utf-8",u.timeout=120,__webpack_require__.nc&&u.setAttribute("nonce",__webpack_require__.nc),u.setAttribute("data-webpack",_+n),u.src=__webpack_require__.tu(e)),r[e]=[t];var onScriptComplete=function(_,t){u.onerror=u.onload=null,clearTimeout(f);var n=r[e];if(delete r[e],u.parentNode&&u.parentNode.removeChild(u),n&&n.forEach(function(e){return e(t)}),_)return _(t)},f=setTimeout(onScriptComplete.bind(null,void 0,{type:"timeout",target:u}),12e4);u.onerror=onScriptComplete.bind(null,u.onerror),u.onload=onScriptComplete.bind(null,u.onload),c&&document.head.appendChild(u)},__webpack_require__.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},__webpack_require__.nmd=function(e){return e.paths=[],e.children||(e.children=[]),e},__webpack_require__.tt=function(){return void 0===t&&(t={createScriptURL:function(e){return e}},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(t=trustedTypes.createPolicy("nextjs#bundler",t))),t},__webpack_require__.tu=function(e){return __webpack_require__.tt().createScriptURL(e)},__webpack_require__.p="/t2-model-skinner/_next/",n={272:0},__webpack_require__.f.j=function(e,r){var _=__webpack_require__.o(n,e)?n[e]:void 0;if(0!==_){if(_)r.push(_[2]);else if(272!=e){var t=new Promise(function(r,t){_=n[e]=[r,t]});r.push(_[2]=t);var i=__webpack_require__.p+__webpack_require__.u(e),u=Error();__webpack_require__.l(i,function(r){if(__webpack_require__.o(n,e)&&(0!==(_=n[e])&&(n[e]=void 0),_)){var t=r&&("load"===r.type?"missing":r.type),i=r&&r.target&&r.target.src;u.message="Loading chunk "+e+" failed.\n("+t+": "+i+")",u.name="ChunkLoadError",u.type=t,u.request=i,_[1](u)}},"chunk-"+e,e)}else n[e]=0}},__webpack_require__.O.j=function(e){return 0===n[e]},i=function(e,r){var _,t,i=r[0],u=r[1],c=r[2],o=0;if(i.some(function(e){return 0!==n[e]})){for(_ in u)__webpack_require__.o(u,_)&&(__webpack_require__.m[_]=u[_]);if(c)var a=c(__webpack_require__)}for(e&&e(r);o<i.length;o++)t=i[o],__webpack_require__.o(n,t)&&n[t]&&n[t][0](),n[t]=0;return __webpack_require__.O(a)},(u=self.webpackChunk_N_E=self.webpackChunk_N_E||[]).forEach(i.bind(null,0)),u.push=i.bind(null,u.push.bind(u))}();
//# sourceMappingURL=webpack-dd07d6e501d595e1.js.map
!function(){"use strict";var e,r,_,t,n,i,u,c={},o={};function __webpack_require__(e){var r=o[e];if(void 0!==r)return r.exports;var _=o[e]={id:e,loaded:!1,exports:{}},t=!0;try{c[e].call(_.exports,_,_.exports,__webpack_require__),t=!1}finally{t&&delete o[e]}return _.loaded=!0,_.exports}__webpack_require__.m=c,e=[],__webpack_require__.O=function(r,_,t,n){if(_){n=n||0;for(var i=e.length;i>0&&e[i-1][2]>n;i--)e[i]=e[i-1];e[i]=[_,t,n];return}for(var u=1/0,i=0;i<e.length;i++){for(var _=e[i][0],t=e[i][1],n=e[i][2],c=!0,o=0;o<_.length;o++)u>=n&&Object.keys(__webpack_require__.O).every(function(e){return __webpack_require__.O[e](_[o])})?_.splice(o--,1):(c=!1,n<u&&(u=n));if(c){e.splice(i--,1);var a=t()}}return a},__webpack_require__.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return __webpack_require__.d(r,{a:r}),r},__webpack_require__.d=function(e,r){for(var _ in r)__webpack_require__.o(r,_)&&!__webpack_require__.o(e,_)&&Object.defineProperty(e,_,{enumerable:!0,get:r[_]})},__webpack_require__.f={},__webpack_require__.e=function(e){return Promise.all(Object.keys(__webpack_require__.f).reduce(function(r,_){return __webpack_require__.f[_](e,r),r},[]))},__webpack_require__.u=function(e){return"static/chunks/"+(737===e?"fb7d5399":e)+"."+({250:"10c119307e239c98",737:"bc4a70b34221e8c8",767:"0dd6b240996f3455",848:"fc0fe21cdc2e6431"})[e]+".js"},__webpack_require__.miniCssF=function(e){return"static/css/"+({214:"922e89893536f2f9",888:"02ded4c5652f0944"})[e]+".css"},__webpack_require__.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||Function("return this")()}catch(e){if("object"==typeof window)return window}}(),__webpack_require__.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},r={},_="_N_E:",__webpack_require__.l=function(e,t,n,i){if(r[e]){r[e].push(t);return}if(void 0!==n)for(var u,c,o=document.getElementsByTagName("script"),a=0;a<o.length;a++){var p=o[a];if(p.getAttribute("src")==e||p.getAttribute("data-webpack")==_+n){u=p;break}}u||(c=!0,(u=document.createElement("script")).charset="utf-8",u.timeout=120,__webpack_require__.nc&&u.setAttribute("nonce",__webpack_require__.nc),u.setAttribute("data-webpack",_+n),u.src=__webpack_require__.tu(e)),r[e]=[t];var onScriptComplete=function(_,t){u.onerror=u.onload=null,clearTimeout(f);var n=r[e];if(delete r[e],u.parentNode&&u.parentNode.removeChild(u),n&&n.forEach(function(e){return e(t)}),_)return _(t)},f=setTimeout(onScriptComplete.bind(null,void 0,{type:"timeout",target:u}),12e4);u.onerror=onScriptComplete.bind(null,u.onerror),u.onload=onScriptComplete.bind(null,u.onload),c&&document.head.appendChild(u)},__webpack_require__.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},__webpack_require__.nmd=function(e){return e.paths=[],e.children||(e.children=[]),e},__webpack_require__.tt=function(){return void 0===t&&(t={createScriptURL:function(e){return e}},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(t=trustedTypes.createPolicy("nextjs#bundler",t))),t},__webpack_require__.tu=function(e){return __webpack_require__.tt().createScriptURL(e)},__webpack_require__.p="/t2-model-skinner/_next/",n={272:0},__webpack_require__.f.j=function(e,r){var _=__webpack_require__.o(n,e)?n[e]:void 0;if(0!==_){if(_)r.push(_[2]);else if(272!=e){var t=new Promise(function(r,t){_=n[e]=[r,t]});r.push(_[2]=t);var i=__webpack_require__.p+__webpack_require__.u(e),u=Error();__webpack_require__.l(i,function(r){if(__webpack_require__.o(n,e)&&(0!==(_=n[e])&&(n[e]=void 0),_)){var t=r&&("load"===r.type?"missing":r.type),i=r&&r.target&&r.target.src;u.message="Loading chunk "+e+" failed.\n("+t+": "+i+")",u.name="ChunkLoadError",u.type=t,u.request=i,_[1](u)}},"chunk-"+e,e)}else n[e]=0}},__webpack_require__.O.j=function(e){return 0===n[e]},i=function(e,r){var _,t,i=r[0],u=r[1],c=r[2],o=0;if(i.some(function(e){return 0!==n[e]})){for(_ in u)__webpack_require__.o(u,_)&&(__webpack_require__.m[_]=u[_]);if(c)var a=c(__webpack_require__)}for(e&&e(r);o<i.length;o++)t=i[o],__webpack_require__.o(n,t)&&n[t]&&n[t][0](),n[t]=0;return __webpack_require__.O(a)},(u=self.webpackChunk_N_E=self.webpackChunk_N_E||[]).forEach(i.bind(null,0)),u.push=i.bind(null,u.push.bind(u))}();
//# sourceMappingURL=webpack-3b398f57a6bbe648.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
self.__BUILD_MANIFEST=function(s){return{__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},"/":[s,"static/chunks/e21e5bbe-b28e0079b469d4e8.js","static/chunks/ebc70433-4eccd1cb3af29a3e.js","static/chunks/6eb5140f-31a2b2da7903b885.js","static/chunks/85d7bc83-1ca530d7d3f44153.js","static/chunks/3a17f596-9aeae038dfa51955.js","static/chunks/f580fadb-2911e2fbf64aae5a.js","static/chunks/515-13ff0773d41722ae.js","static/chunks/pages/index-1a2b7dc61d221db6.js"],"/_error":["static/chunks/pages/_error-54b9fcf45cb5bc62.js"],"/gallery":[s,"static/chunks/737a5600-aea383aaa2061cc6.js","static/chunks/918-3c6747f76df39072.js","static/css/922e89893536f2f9.css","static/chunks/pages/gallery-af1406fdc1af13f5.js"],sortedPages:["/","/_app","/_error","/gallery"]}}("static/chunks/cb355538-e538db8a1761f402.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();
self.__BUILD_MANIFEST=function(s){return{__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},"/":[s,"static/chunks/e21e5bbe-b28e0079b469d4e8.js","static/chunks/ebc70433-4eccd1cb3af29a3e.js","static/chunks/6eb5140f-31a2b2da7903b885.js","static/chunks/85d7bc83-1ca530d7d3f44153.js","static/chunks/3a17f596-9aeae038dfa51955.js","static/chunks/f580fadb-2911e2fbf64aae5a.js","static/chunks/515-13ff0773d41722ae.js","static/chunks/pages/index-ebc0557df1157d4e.js"],"/_error":["static/chunks/pages/_error-54b9fcf45cb5bc62.js"],"/gallery":[s,"static/chunks/737a5600-aea383aaa2061cc6.js","static/chunks/918-3c6747f76df39072.js","static/css/922e89893536f2f9.css","static/chunks/pages/gallery-af1406fdc1af13f5.js"],sortedPages:["/","/_app","/_error","/gallery"]}}("static/chunks/cb355538-e538db8a1761f402.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,7 +5,7 @@ import useTools from "./useTools";
import { fabric } from "fabric";
import { createFabricImage } from "./fabricUtils";
type JSONSnapshot = ReturnType<typeof Canvas.prototype["toJSON"]>;
type JSONSnapshot = ReturnType<(typeof Canvas.prototype)["toJSON"]>;
function updateObjectControlOptions() {
fabric.Object.prototype.set({
@ -165,6 +165,10 @@ export default function Canvas({
useEffect(() => {
if (canvas) {
canvas.isDrawingMode = isDrawingMode;
if (isDrawingMode) {
canvas.discardActiveObject();
canvas.requestRenderAll();
}
}
}, [canvas, isDrawingMode]);

View file

@ -10,6 +10,7 @@ export default function CanvasInteractions({
const ref = useRef<HTMLDivElement | null>(null);
const {
activeCanvas,
activeCanvasType,
bringForward,
sendBackward,
duplicate,
@ -143,14 +144,14 @@ export default function CanvasInteractions({
break;
}
case "p": {
if (activeCanvas === "metallic") {
if (activeCanvasType === "metallic") {
event.preventDefault();
setDrawingMode(true);
}
break;
}
case "s":
if (activeCanvas === "color") {
if (activeCanvasType === "metallic") {
event.preventDefault();
setDrawingMode(false);
}

View file

@ -1,4 +1,5 @@
import { InputHTMLAttributes, useEffect, useRef, useState } from "react";
import { fabric } from "fabric";
import getConfig from "next/config";
import useCanvas from "./useCanvas";
import useTools from "./useTools";
@ -52,6 +53,8 @@ export default function CanvasTools() {
setSaturation,
brightness,
setBrightness,
contrast,
setContrast,
layerMode,
setLayerMode,
activeCanvasType,
@ -108,6 +111,12 @@ export default function CanvasTools() {
? selectedObjects.every((object) => lockedObjects.has(object))
: false;
const hasSelection = selectedObjects.length > 0;
const selectionHasImages =
selectedObjects.filter((object) => object instanceof fabric.Image).length >
0;
const handleBackgroundColorChange: InputHTMLAttributes<HTMLInputElement>["onChange"] =
(event) => {
setBackgroundColor(event.target.value);
@ -168,359 +177,36 @@ export default function CanvasTools() {
</label>
</div>
<div className="Buttons">
{activeCanvasType === "color" ? (
<>
<input
ref={fileInputRef}
onChange={async (event) => {
const imageUrl = await new Promise<string>(
(resolve, reject) => {
const inputFile = event.target.files?.[0];
if (inputFile) {
const reader = new FileReader();
reader.addEventListener("load", (event) => {
resolve(event.target?.result as string);
});
reader.readAsDataURL(inputFile);
} else {
reject(new Error("No input file provided."));
}
}
);
addImages([imageUrl]);
}}
type="file"
accept=".png, image/png"
hidden
/>
<button
type="button"
aria-label="Add Image"
title="Add Image"
onClick={() => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}}
>
<ImPlus style={{ fontSize: 14 }} />
</button>
<button
type="button"
ref={setReferenceElement}
data-active={isFilterToolsOpen ? "" : undefined}
aria-label="Filters"
title="Filters"
onClick={() => {
setFilterToolsOpen((isOpen) => !isOpen);
}}
>
<ImContrast />
</button>
{isFilterToolsOpen ? (
<div
className="BrushToolsPopup"
ref={setPopperElement}
style={styles.popper}
tabIndex={-1}
onBlur={(event) => {
const newFocusElement = event.relatedTarget;
const isFocusLeaving =
!newFocusElement ||
!event.currentTarget.contains(newFocusElement);
if (isFocusLeaving) {
setFilterToolsOpen(false);
}
}}
{...attributes.popper}
>
<div className="Fields">
<div className="Field ApplyTo">
<label>Layer:</label>
<ul>
{selectedObjects.length ? (
<li>
<input
type="radio"
name="FilterLayer"
value="SelectedLayer"
id="FilterLayer-SelectedLayer"
checked={layerMode === "SelectedLayer"}
onChange={() => {
setLayerMode("SelectedLayer");
}}
/>
<label htmlFor="FilterLayer-SelectedLayer">
selected ({selectedObjects.length.toLocaleString()})
</label>
</li>
) : (
<>
<li>
<input
type="radio"
name="FilterLayer"
value="BaseLayer"
id="FilterLayer-BaseLayer"
checked={layerMode === "BaseLayer"}
onChange={() => {
setLayerMode("BaseLayer");
}}
/>{" "}
<label htmlFor="FilterLayer-BaseLayer">base</label>
</li>
<li>
<input
type="radio"
name="FilterLayer"
value="AllLayers"
id="FilterLayer-AllLayers"
checked={layerMode === "AllLayers"}
onChange={() => {
setLayerMode("AllLayers");
}}
/>
<label htmlFor="FilterLayer-AllLayers">
all (
{canvas?._objects.length.toLocaleString() ?? 0})
</label>
</li>
</>
)}
</ul>
</div>
<div className="Field">
<label>
Hue:{" "}
<strong>
{hueRotate == null ? (
"MULTIPLE VALUES"
) : (
<>{Math.round(hueRotate * 180)}&deg;</>
)}
</strong>
</label>
<div className="SliderContainer">
<Slider
min={-180}
max={180}
startPoint={0}
value={Math.round((hueRotate ?? 0) * 180)}
onChange={(value) => {
if (Array.isArray(value)) {
value = value[0];
}
setHueRotate(value / 180);
}}
trackStyle={{
height: 8,
background: "#03fccf",
}}
handleStyle={{
width: 20,
height: 20,
marginTop: -6,
borderColor: "#03fccf",
background: "rgb(5, 69, 76)",
// background: `rgb(${brushColor}, ${brushColor}, ${brushColor})`,
opacity: 1,
}}
railStyle={{
height: 8,
border: "1px solid #555",
background: "rgba(255, 255, 255, 0.3)",
}}
/>
</div>
</div>
<div className="Field">
<label>
Saturation:{" "}
<strong>
{saturation == null
? "MULTIPLE VALUES"
: `${Math.round(saturation * 100 + 100)}%`}
</strong>
</label>
<div className="SliderContainer">
<Slider
min={-100}
max={100}
startPoint={0}
value={Math.round((saturation ?? 0) * 100)}
onChange={(value) => {
if (Array.isArray(value)) {
value = value[0];
}
setSaturation(value / 100);
}}
trackStyle={{
height: 8,
background: "#03fccf",
}}
handleStyle={{
width: 20,
height: 20,
marginTop: -6,
borderColor: "#03fccf",
background: "rgb(5, 69, 76)",
// background: `rgb(${brushColor}, ${brushColor}, ${brushColor})`,
opacity: 1,
}}
railStyle={{
height: 8,
border: "1px solid #555",
background: "rgba(255, 255, 255, 0.3)",
}}
/>
</div>
</div>
<div className="Field">
<label>
Brightness:{" "}
<strong>
{brightness == null
? "MULTIPLE VALUES"
: `${Math.round(brightness * 100 + 100)}%`}
</strong>
</label>
<div className="SliderContainer">
<Slider
min={-100}
max={100}
startPoint={0}
value={Math.round((brightness ?? 0) * 100)}
onChange={(value) => {
if (Array.isArray(value)) {
value = value[0];
}
setBrightness(value / 100);
}}
trackStyle={{
height: 8,
background: "#03fccf",
}}
handleStyle={{
width: 20,
height: 20,
marginTop: -6,
borderColor: "#03fccf",
background: "rgb(5, 69, 76)",
// background: `rgb(${brushColor}, ${brushColor}, ${brushColor})`,
opacity: 1,
}}
railStyle={{
height: 8,
border: "1px solid #555",
background: "rgba(255, 255, 255, 0.3)",
}}
/>
</div>
</div>
</div>
</div>
) : null}
<button
type="button"
aria-label={isSelectionLocked ? "Unlock" : "Lock"}
title={isSelectionLocked ? "Unlock (L)" : "Lock (L)"}
onClick={isSelectionLocked ? unlockSelection : lockSelection}
data-locked={isSelectionLocked ? "" : undefined}
>
{isSelectionLocked ? (
<FaUnlock style={{ fontSize: 14 }} />
) : (
<FaLock style={{ fontSize: 14 }} />
)}
</button>
<button
type="button"
aria-label="Bring Forward"
title="Bring Forward (F)"
onClick={bringForward}
>
<FaArrowUp />
</button>
<button
type="button"
aria-label="Send Backward"
title="Send Backward (B)"
onClick={sendBackward}
>
<FaArrowDown />
</button>
<button
type="button"
aria-label="Duplicate"
title="Duplicate (D)"
onClick={duplicate}
>
<RiFileCopyFill />
</button>
<button
type="button"
aria-label="Delete"
title="Delete (Backspace)"
onClick={deleteSelection}
disabled={isSelectionLocked}
>
<FaTrashAlt />
</button>
<button
type="button"
aria-label="Undo"
title={`Undo (${commandKeyPrefix}Z)`}
onClick={undo}
disabled={!canUndo}
>
<ImUndo2 />
</button>
<button
type="button"
aria-label="Redo"
title={`Redo (${
isMac
? `${shiftKeySymbol}${commandKeyPrefix}Z)`
: `${commandKeyPrefix} Y`
})`}
onClick={redo}
disabled={!canRedo}
>
<ImRedo2 />
</button>
</>
) : null}
{activeCanvasType === "metallic" ? (
<>
<button
type="button"
data-active={isDrawingMode ? undefined : ""}
aria-label="Select"
title="Select (S)"
onClick={() => {
setDrawingMode(false);
}}
>
<GiArrowCursor />
</button>
<button
type="button"
ref={setReferenceElement}
data-active={isDrawingMode ? "" : undefined}
aria-label="Paint"
title="Paint (P)"
onClick={() => {
setDrawingMode(true);
setBrushToolsOpen((isOpen) => !isOpen);
}}
>
<IoMdBrush />
</button>
<div className="ButtonGroup">
<button
className="ButtonGroup"
type="button"
data-active={isDrawingMode ? undefined : ""}
aria-label="Select Mode"
title="Select Mode (S)"
onClick={() => {
setDrawingMode(false);
}}
>
<GiArrowCursor />
</button>
<button
className="ButtonGroup"
type="button"
ref={setReferenceElement}
data-active={isDrawingMode ? "" : undefined}
aria-label="Paint Mode"
title="Paint Mode (P)"
onClick={() => {
setDrawingMode(true);
setBrushToolsOpen((isOpen) => !isOpen);
}}
>
<IoMdBrush />
</button>
</div>
{isBrushToolsOpen ? (
<div
@ -613,6 +299,389 @@ export default function CanvasTools() {
) : null}
</>
) : null}
<>
<input
ref={fileInputRef}
onChange={async (event) => {
const imageUrl = await new Promise<string>((resolve, reject) => {
const inputFile = event.target.files?.[0];
if (inputFile) {
const reader = new FileReader();
reader.addEventListener("load", (event) => {
resolve(event.target?.result as string);
});
reader.readAsDataURL(inputFile);
} else {
reject(new Error("No input file provided."));
}
});
addImages([imageUrl]);
}}
type="file"
accept=".png, image/png"
hidden
/>
<button
type="button"
aria-label="Add Image"
title="Add Image"
onClick={() => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}}
>
<ImPlus style={{ fontSize: 14 }} />
</button>
<button
type="button"
ref={setReferenceElement}
data-active={isFilterToolsOpen ? "" : undefined}
aria-label="Filters"
title="Filters"
disabled={!selectionHasImages}
onClick={() => {
setFilterToolsOpen((isOpen) => !isOpen);
}}
>
<ImContrast />
</button>
{isFilterToolsOpen ? (
<div
className="BrushToolsPopup"
ref={setPopperElement}
style={styles.popper}
tabIndex={-1}
onBlur={(event) => {
const newFocusElement = event.relatedTarget;
const isFocusLeaving =
!newFocusElement ||
!event.currentTarget.contains(newFocusElement);
if (isFocusLeaving) {
setFilterToolsOpen(false);
}
}}
{...attributes.popper}
>
<div className="Fields">
<div className="Field ApplyTo">
<label>Layer:</label>
<ul>
{selectedObjects.length ? (
<li>
<input
type="radio"
name="FilterLayer"
value="SelectedLayer"
id="FilterLayer-SelectedLayer"
checked={layerMode === "SelectedLayer"}
onChange={() => {
setLayerMode("SelectedLayer");
}}
/>
<label htmlFor="FilterLayer-SelectedLayer">
selected ({selectedObjects.length.toLocaleString()})
</label>
</li>
) : (
<>
<li>
<input
type="radio"
name="FilterLayer"
value="BaseLayer"
id="FilterLayer-BaseLayer"
checked={layerMode === "BaseLayer"}
onChange={() => {
setLayerMode("BaseLayer");
}}
/>{" "}
<label htmlFor="FilterLayer-BaseLayer">base</label>
</li>
<li>
<input
type="radio"
name="FilterLayer"
value="AllLayers"
id="FilterLayer-AllLayers"
checked={layerMode === "AllLayers"}
onChange={() => {
setLayerMode("AllLayers");
}}
/>
<label htmlFor="FilterLayer-AllLayers">
all (
{canvas?._objects
.filter(
(object) => object instanceof fabric.Image
)
.length.toLocaleString() ?? 0}
)
</label>
</li>
</>
)}
</ul>
</div>
{activeCanvasType === "color" ? (
<>
<div className="Field">
<label>
Hue:{" "}
<strong>
{hueRotate == null ? (
"MULTIPLE VALUES"
) : (
<>{Math.round(hueRotate * 180)}&deg;</>
)}
</strong>
</label>
<div className="SliderContainer">
<Slider
min={-180}
max={180}
startPoint={0}
value={Math.round((hueRotate ?? 0) * 180)}
onChange={(value) => {
if (Array.isArray(value)) {
value = value[0];
}
setHueRotate(value / 180);
}}
trackStyle={{
height: 8,
background: "#03fccf",
}}
handleStyle={{
width: 20,
height: 20,
marginTop: -6,
borderColor: "#03fccf",
background: "rgb(5, 69, 76)",
// background: `rgb(${brushColor}, ${brushColor}, ${brushColor})`,
opacity: 1,
}}
railStyle={{
height: 8,
border: "1px solid #555",
background: "rgba(255, 255, 255, 0.3)",
}}
/>
</div>
</div>
<div className="Field">
<label>
Saturation:{" "}
<strong>
{saturation == null
? "MULTIPLE VALUES"
: `${Math.round(saturation * 100 + 100)}%`}
</strong>
</label>
<div className="SliderContainer">
<Slider
min={-100}
max={100}
startPoint={0}
value={Math.round((saturation ?? 0) * 100)}
onChange={(value) => {
if (Array.isArray(value)) {
value = value[0];
}
setSaturation(value / 100);
}}
trackStyle={{
height: 8,
background: "#03fccf",
}}
handleStyle={{
width: 20,
height: 20,
marginTop: -6,
borderColor: "#03fccf",
background: "rgb(5, 69, 76)",
// background: `rgb(${brushColor}, ${brushColor}, ${brushColor})`,
opacity: 1,
}}
railStyle={{
height: 8,
border: "1px solid #555",
background: "rgba(255, 255, 255, 0.3)",
}}
/>
</div>
</div>
</>
) : null}
<div className="Field">
<label>
Brightness:{" "}
<strong>
{brightness == null
? "MULTIPLE VALUES"
: `${Math.round(brightness * 100 + 100)}%`}
</strong>
</label>
<div className="SliderContainer">
<Slider
min={-100}
max={100}
startPoint={0}
value={Math.round((brightness ?? 0) * 100)}
onChange={(value) => {
if (Array.isArray(value)) {
value = value[0];
}
setBrightness(value / 100);
}}
trackStyle={{
height: 8,
background: "#03fccf",
}}
handleStyle={{
width: 20,
height: 20,
marginTop: -6,
borderColor: "#03fccf",
background: "rgb(5, 69, 76)",
// background: `rgb(${brushColor}, ${brushColor}, ${brushColor})`,
opacity: 1,
}}
railStyle={{
height: 8,
border: "1px solid #555",
background: "rgba(255, 255, 255, 0.3)",
}}
/>
</div>
</div>
<div className="Field">
<label>
Contrast:{" "}
<strong>
{contrast == null
? "MULTIPLE VALUES"
: `${Math.round(contrast * 100 + 100)}%`}
</strong>
</label>
<div className="SliderContainer">
<Slider
min={-100}
max={100}
startPoint={0}
value={Math.round((contrast ?? 0) * 100)}
onChange={(value) => {
if (Array.isArray(value)) {
value = value[0];
}
setContrast(value / 100);
}}
trackStyle={{
height: 8,
background: "#03fccf",
}}
handleStyle={{
width: 20,
height: 20,
marginTop: -6,
borderColor: "#03fccf",
background: "rgb(5, 69, 76)",
// background: `rgb(${brushColor}, ${brushColor}, ${brushColor})`,
opacity: 1,
}}
railStyle={{
height: 8,
border: "1px solid #555",
background: "rgba(255, 255, 255, 0.3)",
}}
/>
</div>
</div>
</div>
</div>
) : null}
<button
type="button"
aria-label={isSelectionLocked ? "Unlock" : "Lock"}
title={isSelectionLocked ? "Unlock (L)" : "Lock (L)"}
onClick={isSelectionLocked ? unlockSelection : lockSelection}
data-locked={isSelectionLocked ? "" : undefined}
disabled={!hasSelection}
>
{isSelectionLocked ? (
<FaUnlock style={{ fontSize: 14 }} />
) : (
<FaLock style={{ fontSize: 14 }} />
)}
</button>
<div className="ButtonGroup">
<button
type="button"
aria-label="Bring Forward"
title="Bring Forward (F)"
onClick={bringForward}
disabled={!hasSelection}
>
<FaArrowUp />
</button>
<button
type="button"
aria-label="Send Backward"
title="Send Backward (B)"
onClick={sendBackward}
disabled={!hasSelection}
>
<FaArrowDown />
</button>
</div>
<button
type="button"
aria-label="Duplicate"
title="Duplicate (D)"
onClick={duplicate}
disabled={!hasSelection}
>
<RiFileCopyFill />
</button>
<button
type="button"
aria-label="Delete"
title="Delete (Backspace)"
onClick={deleteSelection}
disabled={isSelectionLocked || !hasSelection}
>
<FaTrashAlt />
</button>
<div className="ButtonGroup">
<button
type="button"
aria-label="Undo"
title={`Undo (${commandKeyPrefix}Z)`}
onClick={undo}
disabled={!canUndo}
>
<ImUndo2 />
</button>
<button
type="button"
aria-label="Redo"
title={`Redo (${
isMac
? `${shiftKeySymbol}${commandKeyPrefix}Z)`
: `${commandKeyPrefix} Y`
})`}
onClick={redo}
disabled={!canRedo}
>
<ImRedo2 />
</button>
</div>
</>
</div>
<div className="Export">
<input

View file

@ -40,6 +40,7 @@ type ObjectFilters = {
HueRotation?: number;
Saturation?: number;
Brightness?: number;
Contrast?: number;
};
export default function ToolsProvider({ children }: { children: ReactNode }) {
@ -107,8 +108,8 @@ export default function ToolsProvider({ children }: { children: ReactNode }) {
const { canvases } = useCanvas();
const { canvas, notifyChange, undo, redo, canUndo, canRedo } =
useCanvas(activeCanvas);
const { canvas: metallicCanvas } = useCanvas(metallicCanvasId);
const [isDrawingMode, setDrawingMode] = useState(false);
const { canvas: metallicCanvas, setDrawingMode } =
useCanvas(metallicCanvasId);
const { combineColorAndAlphaImageUrls } = useImageWorker();
const { canvasPadding } = useSettings();
const [filterChanges, setFilterChanges] = useState<
@ -126,7 +127,33 @@ export default function ToolsProvider({ children }: { children: ReactNode }) {
}
}
const getFilter = (name: keyof ObjectFilters) => {
const setFilter = useCallback(
(name: keyof ObjectFilters, value: number) => {
const filterChanges: Array<[fabric.Object, ObjectFilters]> = [];
const newFilterMap = new Map(filterMap);
let applyObjects = selectedObjects;
if (layerMode === "AllLayers") {
applyObjects = canvas?._objects ?? [];
} else if (layerMode === "BaseLayer") {
applyObjects = canvas?._objects.slice(0, 1) ?? [];
}
for (const applyObject of applyObjects) {
if (applyObject instanceof fabric.Image) {
const existingFilters = filterMap.get(applyObject) ?? {};
const newFilters = { ...existingFilters, [name]: value };
newFilterMap.set(applyObject, newFilters);
filterChanges.push([applyObject, newFilters]);
}
}
setFilterMap(newFilterMap);
setFilterChanges(filterChanges);
},
[canvas, layerMode, filterMap, selectedObjects]
);
const getFilter = (
name: keyof ObjectFilters
): [number | null, (value: number) => void] => {
let applyObjects = selectedObjects;
if (layerMode === "AllLayers") {
applyObjects = canvas?._objects ?? [];
@ -142,54 +169,18 @@ export default function ToolsProvider({ children }: { children: ReactNode }) {
.slice(1)
.every((applyObject, i) => getValue(i + 1) === firstValue)
) {
return firstValue;
return [firstValue, (value: number) => setFilter(name, value)];
}
return null;
return [null, (value: number) => setFilter(name, value)];
} else {
return 0;
return [0, (value: number) => setFilter(name, value)];
}
};
const hueRotate = getFilter("HueRotation");
const saturation = getFilter("Saturation");
const brightness = getFilter("Brightness");
const setFilter = useCallback(
(name: keyof ObjectFilters, value: number) => {
const filterChanges: Array<[fabric.Object, ObjectFilters]> = [];
const newFilterMap = new Map(filterMap);
let applyObjects = selectedObjects;
if (layerMode === "AllLayers") {
applyObjects = canvas?._objects ?? [];
} else if (layerMode === "BaseLayer") {
applyObjects = canvas?._objects.slice(0, 1) ?? [];
}
for (const applyObject of applyObjects) {
const existingFilters = filterMap.get(applyObject) ?? {};
const newFilters = { ...existingFilters, [name]: value };
newFilterMap.set(applyObject, newFilters);
filterChanges.push([applyObject, newFilters]);
}
setFilterMap(newFilterMap);
setFilterChanges(filterChanges);
},
[canvas, layerMode, filterMap, selectedObjects]
);
const setHueRotate = useCallback(
(value: number) => setFilter("HueRotation", value),
[setFilter]
);
const setSaturation = useCallback(
(value: number) => setFilter("Saturation", value),
[setFilter]
);
const setBrightness = useCallback(
(value: number) => setFilter("Brightness", value),
[setFilter]
);
const [hueRotate, setHueRotate] = getFilter("HueRotation");
const [saturation, setSaturation] = getFilter("Saturation");
const [brightness, setBrightness] = getFilter("Brightness");
const [contrast, setContrast] = getFilter("Contrast");
useEffect(() => {
if (!filterChanges.length) {
@ -198,9 +189,13 @@ export default function ToolsProvider({ children }: { children: ReactNode }) {
for (const [selectedObject, newFilters] of filterChanges) {
if (selectedObject instanceof fabric.Image) {
selectedObject.filters = [];
if (activeCanvasType === "metallic") {
const grayscaleFilter = new fabric.Image.filters.Grayscale();
selectedObject.filters.push(grayscaleFilter);
}
for (const key in newFilters) {
const filterValue = newFilters[key as keyof ObjectFilters] ?? 0;
if (filterValue !== 0) {
const filterValue = newFilters[key as keyof ObjectFilters];
if (filterValue != null) {
switch (key) {
case "HueRotation":
selectedObject.filters.push(
@ -224,6 +219,13 @@ export default function ToolsProvider({ children }: { children: ReactNode }) {
})
);
break;
case "Contrast":
selectedObject.filters.push(
new fabric.Image.filters.Contrast({
contrast: filterValue,
})
);
break;
}
}
}
@ -234,7 +236,7 @@ export default function ToolsProvider({ children }: { children: ReactNode }) {
if (notifyChange) {
notifyChange();
}
}, [filterChanges, notifyChange]);
}, [filterChanges, activeCanvasType, notifyChange]);
const lockSelection = useCallback(() => {
if (selectedObjects.length) {
@ -319,7 +321,7 @@ export default function ToolsProvider({ children }: { children: ReactNode }) {
canvas.setActiveObject(lastAddedImage);
}
},
[canvas, activeCanvasType, textureSize]
[canvas, activeCanvasType, setDrawingMode, textureSize]
);
const duplicate = useCallback(async () => {
@ -509,6 +511,8 @@ export default function ToolsProvider({ children }: { children: ReactNode }) {
setSaturation,
brightness,
setBrightness,
contrast,
setContrast,
layerMode,
setLayerMode,
selectedObjects,
@ -524,8 +528,6 @@ export default function ToolsProvider({ children }: { children: ReactNode }) {
canUndo,
canRedo,
exportSkin,
isDrawingMode,
setDrawingMode,
selectedMaterialIndex,
setSelectedMaterialIndex,
textureSize,
@ -547,10 +549,12 @@ export default function ToolsProvider({ children }: { children: ReactNode }) {
hueRotate,
saturation,
brightness,
contrast,
layerMode,
setHueRotate,
setSaturation,
setBrightness,
setContrast,
selectedObjects,
lockSelection,
unlockSelection,
@ -564,7 +568,6 @@ export default function ToolsProvider({ children }: { children: ReactNode }) {
canUndo,
canRedo,
exportSkin,
isDrawingMode,
selectedMaterialIndex,
textureSize,
hasMetallic,

View file

@ -249,7 +249,7 @@ select {
place-content: center;
flex: 0 0 auto;
border: 1px solid rgb(23, 159, 138);
border-radius: 2px;
border-radius: 3px;
width: 28px;
height: 24px;
background: rgb(74, 193, 171);
@ -279,6 +279,23 @@ select {
color: white;
}
.ButtonGroup {
display: flex;
align-items: center;
gap: 0;
}
.ButtonGroup button:not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 0;
}
.ButtonGroup button:not(:last-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.CanvasToggle {
display: flex;
align-items: center;

View file

@ -16,6 +16,8 @@ interface ToolsContextValue {
setSaturation: (saturation: number) => void;
brightness: number | null;
setBrightness: (brightness: number) => void;
contrast: number | null;
setContrast: (contrast: number) => void;
layerMode: string;
setLayerMode: (layerMode: string) => void;
deleteSelection: () => void;