From ab64078b7684fa815349c77c7e1c2f2241509ff6 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 16 Aug 2024 20:59:32 +1000
Subject: [PATCH] feat(ui): add canvas background style

---
 invokeai/frontend/web/public/locales/en.json  |  6 +++
 .../CanvasSettingsBackgroundStyle.tsx         | 50 +++++++++++++++++++
 .../ControlLayersSettingsPopover.tsx          |  2 +
 .../components/StageComponent.tsx             | 15 ++++++
 .../controlLayers/konva/CanvasBackground.ts   |  9 ++++
 .../controlLayers/konva/CanvasManager.ts      |  7 +++
 .../controlLayers/store/canvasV2Slice.ts      |  2 +
 .../controlLayers/store/settingsReducers.ts   |  5 +-
 .../src/features/controlLayers/store/types.ts |  6 +++
 9 files changed, 101 insertions(+), 1 deletion(-)
 create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/CanvasSettingsBackgroundStyle.tsx

diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 9b743b4bd9..7a8c3f2f31 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1717,6 +1717,12 @@
             "view": "View",
             "transform": "Transform",
             "eyeDropper": "Eye Dropper"
+        },
+        "background": {
+            "backgroundStyle": "Background Style",
+            "solid": "Solid",
+            "checkerboard": "Checkerboard",
+            "dynamicGrid": "Dynamic Grid"
         }
     },
     "upscaling": {
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasSettingsBackgroundStyle.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasSettingsBackgroundStyle.tsx
new file mode 100644
index 0000000000..20ea323680
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasSettingsBackgroundStyle.tsx
@@ -0,0 +1,50 @@
+import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
+import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { canvasBackgroundStyleChanged } from 'features/controlLayers/store/canvasV2Slice';
+import { isCanvasBackgroundStyle } from 'features/controlLayers/store/types';
+import { memo, useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+export const CanvasSettingsBackgroundStyle = memo(() => {
+  const { t } = useTranslation();
+  const dispatch = useAppDispatch();
+  const canvasBackgroundStyle = useAppSelector((s) => s.canvasV2.settings.canvasBackgroundStyle);
+  const onChange = useCallback<ComboboxOnChange>(
+    (v) => {
+      if (!isCanvasBackgroundStyle(v?.value)) {
+        return;
+      }
+      dispatch(canvasBackgroundStyleChanged(v.value));
+    },
+    [dispatch]
+  );
+
+  const options = useMemo<ComboboxOption[]>(() => {
+    return [
+      {
+        value: 'solid',
+        label: t('controlLayers.background.solid'),
+      },
+      {
+        value: 'checkerboard',
+        label: t('controlLayers.background.checkerboard'),
+      },
+      {
+        value: 'dynamicGrid',
+        label: t('controlLayers.background.dynamicGrid'),
+      },
+    ];
+  }, [t]);
+
+  const value = useMemo(() => options.find((o) => o.value === canvasBackgroundStyle), [options, canvasBackgroundStyle]);
+
+  return (
+    <FormControl orientation="vertical">
+      <FormLabel m={0}>{t('controlLayers.background.backgroundStyle')}</FormLabel>
+      <Combobox value={value} options={options} onChange={onChange} isSearchable={false} />
+    </FormControl>
+  );
+});
+
+CanvasSettingsBackgroundStyle.displayName = 'CanvasSettingsBackgroundStyle';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersSettingsPopover.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersSettingsPopover.tsx
index 8a7eb0b98e..a6dca5eb90 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersSettingsPopover.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersSettingsPopover.tsx
@@ -12,6 +12,7 @@ import {
 } from '@invoke-ai/ui-library';
 import { useStore } from '@nanostores/react';
 import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { CanvasSettingsBackgroundStyle } from 'features/controlLayers/components/CanvasSettingsBackgroundStyle';
 import { $canvasManager } from 'features/controlLayers/konva/CanvasManager';
 import {
   clipToBboxChanged,
@@ -71,6 +72,7 @@ const ControlLayersSettingsPopover = () => {
               <FormLabel flexGrow={1}>{t('unifiedCanvas.clipToBbox')}</FormLabel>
               <Checkbox isChecked={clipToBbox} onChange={onChangeClipToBbox} />
             </FormControl>
+            <CanvasSettingsBackgroundStyle />
             <Button onClick={invalidateRasterizationCaches} size="sm">
               Invalidate Rasterization Caches
             </Button>
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx
index ae8e217df8..9870e3c72d 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx
@@ -3,8 +3,10 @@ import { useStore } from '@nanostores/react';
 import { $socket } from 'app/hooks/useSocketIO';
 import { logger } from 'app/logging/logger';
 import { useAppStore } from 'app/store/nanostores/store';
+import { useAppSelector } from 'app/store/storeHooks';
 import { HeadsUpDisplay } from 'features/controlLayers/components/HeadsUpDisplay';
 import { $canvasManager, CanvasManager } from 'features/controlLayers/konva/CanvasManager';
+import { TRANSPARENCY_CHECKER_PATTERN } from 'features/controlLayers/konva/constants';
 import Konva from 'konva';
 import { memo, useCallback, useEffect, useLayoutEffect, useState } from 'react';
 import { useDevicePixelRatio } from 'use-device-pixel-ratio';
@@ -52,6 +54,8 @@ type Props = {
 };
 
 export const StageComponent = memo(({ asPreview = false }: Props) => {
+  const canvasBackgroundStyle = useAppSelector((s) => s.canvasV2.settings.canvasBackgroundStyle);
+
   const [stage] = useState(
     () =>
       new Konva.Stage({
@@ -77,6 +81,17 @@ export const StageComponent = memo(({ asPreview = false }: Props) => {
 
   return (
     <Flex position="relative" w="full" h="full">
+      {canvasBackgroundStyle === 'checkerboard' && (
+        <Flex
+          position="absolute"
+          bgImage={TRANSPARENCY_CHECKER_PATTERN}
+          top={0}
+          right={0}
+          bottom={0}
+          left={0}
+          opacity={0.1}
+        />
+      )}
       <Flex
         position="absolute"
         top={0}
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackground.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackground.ts
index 6dc5462ff9..7e268a64f8 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackground.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackground.ts
@@ -34,6 +34,15 @@ export class CanvasBackground {
   }
 
   render() {
+    const settings = this.manager.stateApi.getSettings();
+
+    if (settings.canvasBackgroundStyle !== 'dynamicGrid') {
+      this.konva.layer.visible(false);
+      return;
+    }
+
+    this.konva.layer.visible(true);
+
     this.konva.layer.zIndex(0);
     const scale = this.manager.stage.scaleX();
     const gridSpacing = CanvasBackground.getGridSpacing(scale);
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts
index ef0e8fe781..4adc5c80e3 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts
@@ -270,6 +270,13 @@ export class CanvasManager {
       return;
     }
 
+    if (
+      this._isFirstRender ||
+      state.settings.canvasBackgroundStyle !== this._prevState.settings.canvasBackgroundStyle
+    ) {
+      this.background.render();
+    }
+
     if (this._isFirstRender || state.rasterLayers.entities !== this._prevState.rasterLayers.entities) {
       this.log.debug('Rendering raster layers');
 
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts
index 55a6fdb2ca..bc939f0894 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts
@@ -94,6 +94,7 @@ const initialState: CanvasV2State = {
     showHUD: true,
     clipToBbox: false,
     cropToBboxOnSave: false,
+    canvasBackgroundStyle: 'dynamicGrid',
   },
   compositing: {
     maskBlur: 16,
@@ -474,6 +475,7 @@ export const {
   clipToBboxChanged,
   canvasReset,
   rasterizationCachesInvalidated,
+  canvasBackgroundStyleChanged,
   // All entities
   entitySelected,
   entityNameChanged,
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/settingsReducers.ts b/invokeai/frontend/web/src/features/controlLayers/store/settingsReducers.ts
index cecdd38135..50870bfa4f 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/settingsReducers.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/settingsReducers.ts
@@ -1,8 +1,11 @@
 import type { PayloadAction, SliceCaseReducers } from '@reduxjs/toolkit';
-import type { CanvasV2State } from 'features/controlLayers/store/types';
+import type { CanvasBackgroundStyle, CanvasV2State } from 'features/controlLayers/store/types';
 
 export const settingsReducers = {
   clipToBboxChanged: (state, action: PayloadAction<boolean>) => {
     state.settings.clipToBbox = action.payload;
   },
+  canvasBackgroundStyleChanged: (state, action: PayloadAction<CanvasBackgroundStyle>) => {
+    state.settings.canvasBackgroundStyle = action.payload;
+  },
 } satisfies SliceCaseReducers<CanvasV2State>;
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
index 3d37342a07..ea010ff06b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
@@ -839,6 +839,11 @@ export type StagingAreaImage = {
   offsetY: number;
 };
 
+const zCanvasBackgroundStyle = z.enum(['checkerboard', 'dynamicGrid', 'solid']);
+export type CanvasBackgroundStyle = z.infer<typeof zCanvasBackgroundStyle>;
+export const isCanvasBackgroundStyle = (v: unknown): v is CanvasBackgroundStyle =>
+  zCanvasBackgroundStyle.safeParse(v).success;
+
 export type CanvasV2State = {
   _version: 3;
   selectedEntityIdentifier: CanvasEntityIdentifier | null;
@@ -863,6 +868,7 @@ export type CanvasV2State = {
     preserveMaskedArea: boolean;
     cropToBboxOnSave: boolean;
     clipToBbox: boolean;
+    canvasBackgroundStyle: CanvasBackgroundStyle;
   };
   bbox: {
     rect: {