Compare commits

...

18 Commits

Author SHA1 Message Date
psychedelicious
72c9645044 chore: bump version to v5.1.0rc1 2024-10-02 16:37:00 +10:00
psychedelicious
16106aebb2 chore(ui): lint 2024-10-02 16:35:31 +10:00
psychedelicious
5332eafae6 fix(ui): floating button tooltip orientations 2024-10-02 16:20:04 +10:00
psychedelicious
80316b0564 tweak(ui): left-hand panel buttons 2024-10-02 16:07:40 +10:00
psychedelicious
395d35c287 fix(ui): next/prev image buttons layout 2024-10-02 16:05:11 +10:00
psychedelicious
33a40326f4 feat(ui): add canvas setting for pressure sens 2024-10-02 15:53:35 +10:00
psychedelicious
2584064c73 tidy(ui): restore redux store checks 2024-10-02 15:47:03 +10:00
psychedelicious
d4775ff927 fix(ui): edge cases with tool rendering 2024-10-02 15:44:43 +10:00
psychedelicious
fdfee8248c feat(ui): updated layout for small screens
- Move color picker to floating buttons
- Always show floating buttons
- Minor layout tweaks for floating buttons
2024-10-02 15:44:43 +10:00
psychedelicious
af0aedcd50 tidy(ui): remove unused perfect-freehand options from brush state 2024-10-02 15:44:43 +10:00
psychedelicious
4b5f460ce4 feat(ui): hide brush preview when drawing with pen 2024-10-02 15:44:43 +10:00
psychedelicious
386a3fad42 feat(ui): hide brush fill circle on timeout 2024-10-02 15:44:43 +10:00
psychedelicious
904d64e2a0 feat(ui): initial pressure sensitivity implementation 2024-10-02 15:44:43 +10:00
psychedelicious
84e2c0d73c feat(ui): use touch-action: none instead of events to prevent pan/zoom 2024-10-02 15:44:43 +10:00
psychedelicious
fa4e7752a1 chore(ui): add perfect-freehand dep for tablet support 2024-10-02 15:44:43 +10:00
psychedelicious
59c62f5a3b feat(ui): use pointer events instead of mouse events
This gets touch input and tablet input working for basic drawing functions.
2024-10-02 15:44:43 +10:00
psychedelicious
dd71858faf feat(ui): prevent app from scrolling on touch events 2024-10-02 15:44:43 +10:00
psychedelicious
fbbc7b12e1 build(ui): vite dev server host: 0.0.0.0 2024-10-02 15:44:43 +10:00
38 changed files with 972 additions and 415 deletions

View File

@@ -81,6 +81,7 @@
"new-github-issue-url": "^1.0.0",
"overlayscrollbars": "^2.10.0",
"overlayscrollbars-react": "^0.5.6",
"perfect-freehand": "^1.2.2",
"query-string": "^9.1.0",
"react": "^18.3.1",
"react-colorful": "^5.6.1",

View File

@@ -92,6 +92,9 @@ dependencies:
overlayscrollbars-react:
specifier: ^0.5.6
version: 0.5.6(overlayscrollbars@2.10.0)(react@18.3.1)
perfect-freehand:
specifier: ^1.2.2
version: 1.2.2
query-string:
specifier: ^9.1.0
version: 9.1.0
@@ -9752,6 +9755,10 @@ packages:
resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==}
dev: true
/perfect-freehand@1.2.2:
resolution: {integrity: sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ==}
dev: false
/picocolors@1.0.0:
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}

View File

@@ -10,8 +10,8 @@
"previousImage": "Previous Image",
"reset": "Reset",
"resetUI": "$t(accessibility.reset) UI",
"showGalleryPanel": "Show Gallery Panel",
"showOptionsPanel": "Show Side Panel",
"toggleRightPanel": "Toggle Right Panel (T)",
"toggleLeftPanel": "Toggle Left Panel (G)",
"uploadImage": "Upload Image"
},
"boards": {
@@ -1813,7 +1813,8 @@
"isolatedStagingPreview": "Isolated Staging Preview",
"isolatedFilteringPreview": "Isolated Filtering Preview",
"isolatedTransformingPreview": "Isolated Transforming Preview",
"invertBrushSizeScrollDirection": "Invert Scroll for Brush Size"
"invertBrushSizeScrollDirection": "Invert Scroll for Brush Size",
"pressureSensitivity": "Pressure Sensitivity"
},
"HUD": {
"bbox": "Bbox",

View File

@@ -22,6 +22,7 @@ import { CanvasSettingsIsolatedTransformingPreviewSwitch } from 'features/contro
import { CanvasSettingsLogDebugInfoButton } from 'features/controlLayers/components/Settings/CanvasSettingsLogDebugInfo';
import { CanvasSettingsOutputOnlyMaskedRegionsCheckbox } from 'features/controlLayers/components/Settings/CanvasSettingsOutputOnlyMaskedRegionsCheckbox';
import { CanvasSettingsPreserveMaskCheckbox } from 'features/controlLayers/components/Settings/CanvasSettingsPreserveMaskCheckbox';
import { CanvasSettingsPressureSensitivityCheckbox } from 'features/controlLayers/components/Settings/CanvasSettingsPressureSensitivity';
import { CanvasSettingsRecalculateRectsButton } from 'features/controlLayers/components/Settings/CanvasSettingsRecalculateRectsButton';
import { CanvasSettingsShowHUDSwitch } from 'features/controlLayers/components/Settings/CanvasSettingsShowHUDSwitch';
import { CanvasSettingsShowProgressOnCanvas } from 'features/controlLayers/components/Settings/CanvasSettingsShowProgressOnCanvasSwitch';
@@ -50,6 +51,7 @@ export const CanvasSettingsPopover = memo(() => {
<CanvasSettingsClipToBboxCheckbox />
<CanvasSettingsOutputOnlyMaskedRegionsCheckbox />
<CanvasSettingsSnapToGridCheckbox />
<CanvasSettingsPressureSensitivityCheckbox />
<CanvasSettingsShowProgressOnCanvas />
<CanvasSettingsIsolatedStagingPreviewSwitch />
<CanvasSettingsIsolatedFilteringPreviewSwitch />

View File

@@ -0,0 +1,27 @@
import { Checkbox, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
selectPressureSensitivity,
settingsPressureSensitivityToggled,
} from 'features/controlLayers/store/canvasSettingsSlice';
import type { ChangeEventHandler } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const CanvasSettingsPressureSensitivityCheckbox = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const pressureSensitivity = useAppSelector(selectPressureSensitivity);
const onChange = useCallback<ChangeEventHandler<HTMLInputElement>>(() => {
dispatch(settingsPressureSensitivityToggled());
}, [dispatch]);
return (
<FormControl w="full">
<FormLabel flexGrow={1}>{t('controlLayers.settings.pressureSensitivity')}</FormLabel>
<Checkbox isChecked={pressureSensitivity} onChange={onChange} />
</FormControl>
);
});
CanvasSettingsPressureSensitivityCheckbox.displayName = 'CanvasSettingsPressureSensitivityCheckbox';

View File

@@ -1,4 +1,4 @@
import { IconButton } from '@invoke-ai/ui-library';
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
@@ -21,14 +21,15 @@ export const ToolBboxButton = memo(() => {
});
return (
<IconButton
aria-label={`${t('controlLayers.tool.bbox')} (C)`}
tooltip={`${t('controlLayers.tool.bbox')} (C)`}
icon={<PiBoundingBoxBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={selectBbox}
/>
<Tooltip label={`${t('controlLayers.tool.bbox')} (C)`} placement="end">
<IconButton
aria-label={`${t('controlLayers.tool.bbox')} (C)`}
icon={<PiBoundingBoxBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={selectBbox}
/>
</Tooltip>
);
});

View File

@@ -1,4 +1,4 @@
import { IconButton } from '@invoke-ai/ui-library';
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
@@ -21,14 +21,15 @@ export const ToolBrushButton = memo(() => {
});
return (
<IconButton
aria-label={`${t('controlLayers.tool.brush')} (B)`}
tooltip={`${t('controlLayers.tool.brush')} (B)`}
icon={<PiPaintBrushBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={selectBrush}
/>
<Tooltip label={`${t('controlLayers.tool.brush')} (B)`} placement="end">
<IconButton
aria-label={`${t('controlLayers.tool.brush')} (B)`}
icon={<PiPaintBrushBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={selectBrush}
/>
</Tooltip>
);
});

View File

@@ -11,7 +11,7 @@ import { ToolViewButton } from './ToolViewButton';
export const ToolChooser: React.FC = () => {
return (
<>
<ButtonGroup isAttached>
<ButtonGroup isAttached orientation="vertical">
<ToolBrushButton />
<ToolEraserButton />
<ToolRectButton />

View File

@@ -1,4 +1,4 @@
import { IconButton } from '@invoke-ai/ui-library';
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
@@ -21,14 +21,15 @@ export const ToolColorPickerButton = memo(() => {
});
return (
<IconButton
aria-label={`${t('controlLayers.tool.colorPicker')} (I)`}
tooltip={`${t('controlLayers.tool.colorPicker')} (I)`}
icon={<PiEyedropperBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={selectColorPicker}
/>
<Tooltip label={`${t('controlLayers.tool.colorPicker')} (I)`} placement="end">
<IconButton
aria-label={`${t('controlLayers.tool.colorPicker')} (I)`}
icon={<PiEyedropperBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={selectColorPicker}
/>
</Tooltip>
);
});

View File

@@ -1,4 +1,4 @@
import { IconButton } from '@invoke-ai/ui-library';
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
@@ -21,14 +21,15 @@ export const ToolEraserButton = memo(() => {
});
return (
<IconButton
aria-label={`${t('controlLayers.tool.eraser')} (E)`}
tooltip={`${t('controlLayers.tool.eraser')} (E)`}
icon={<PiEraserBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={selectEraser}
/>
<Tooltip label={`${t('controlLayers.tool.eraser')} (E)`} placement="end">
<IconButton
aria-label={`${t('controlLayers.tool.eraser')} (E)`}
icon={<PiEraserBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={selectEraser}
/>
</Tooltip>
);
});

View File

@@ -1,4 +1,4 @@
import { IconButton } from '@invoke-ai/ui-library';
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
@@ -21,14 +21,15 @@ export const ToolMoveButton = memo(() => {
});
return (
<IconButton
aria-label={`${t('controlLayers.tool.move')} (V)`}
tooltip={`${t('controlLayers.tool.move')} (V)`}
icon={<PiCursorBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={selectMove}
/>
<Tooltip label={`${t('controlLayers.tool.move')} (V)`} placement="end">
<IconButton
aria-label={`${t('controlLayers.tool.move')} (V)`}
icon={<PiCursorBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={selectMove}
/>
</Tooltip>
);
});

View File

@@ -1,4 +1,4 @@
import { IconButton } from '@invoke-ai/ui-library';
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
@@ -21,14 +21,15 @@ export const ToolRectButton = memo(() => {
});
return (
<IconButton
aria-label={`${t('controlLayers.tool.rectangle')} (U)`}
tooltip={`${t('controlLayers.tool.rectangle')} (U)`}
icon={<PiRectangleBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={selectRect}
/>
<Tooltip label={`${t('controlLayers.tool.rectangle')} (U)`} placement="end">
<IconButton
aria-label={`${t('controlLayers.tool.rectangle')} (U)`}
icon={<PiRectangleBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={selectRect}
/>
</Tooltip>
);
});

View File

@@ -1,4 +1,4 @@
import { IconButton } from '@invoke-ai/ui-library';
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
@@ -21,14 +21,15 @@ export const ToolViewButton = memo(() => {
});
return (
<IconButton
aria-label={`${t('controlLayers.tool.view')} (H)`}
tooltip={`${t('controlLayers.tool.view')} (H)`}
icon={<PiHandBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={selectView}
/>
<Tooltip label={`${t('controlLayers.tool.view')} (H)`} placement="end">
<IconButton
aria-label={`${t('controlLayers.tool.view')} (H)`}
icon={<PiHandBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={selectView}
/>
</Tooltip>
);
});

View File

@@ -1,7 +1,6 @@
/* eslint-disable i18next/no-literal-string */
import { Divider, Flex, Spacer } from '@invoke-ai/ui-library';
import { CanvasSettingsPopover } from 'features/controlLayers/components/Settings/CanvasSettingsPopover';
import { ToolChooser } from 'features/controlLayers/components/Tool/ToolChooser';
import { ToolColorPicker } from 'features/controlLayers/components/Tool/ToolFillColorPicker';
import { ToolSettings } from 'features/controlLayers/components/Tool/ToolSettings';
import { CanvasToolbarFitBboxToLayersButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarFitBboxToLayersButton';
@@ -31,7 +30,6 @@ export const CanvasToolbar = memo(() => {
return (
<Flex w="full" gap={2} alignItems="center">
<ToolChooser />
<ToolColorPicker />
<ToolSettings />
<Spacer />

View File

@@ -3,7 +3,9 @@ import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEnt
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasObjectBrushLine } from 'features/controlLayers/konva/CanvasObject/CanvasObjectBrushLine';
import { CanvasObjectBrushLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure';
import { CanvasObjectEraserLine } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLine';
import { CanvasObjectEraserLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLineWithPressure';
import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage';
import { CanvasObjectRect } from 'features/controlLayers/konva/CanvasObject/CanvasObjectRect';
import type { AnyObjectRenderer, AnyObjectState } from 'features/controlLayers/konva/CanvasObject/types';
@@ -113,6 +115,15 @@ export class CanvasEntityBufferObjectRenderer extends CanvasModuleBase {
this.konva.group.add(this.renderer.konva.group);
}
didRender = this.renderer.update(this.state, true);
} else if (this.state.type === 'brush_line_with_pressure') {
assert(this.renderer instanceof CanvasObjectBrushLineWithPressure || !this.renderer);
if (!this.renderer) {
this.renderer = new CanvasObjectBrushLineWithPressure(this.state, this);
this.konva.group.add(this.renderer.konva.group);
}
didRender = this.renderer.update(this.state, true);
} else if (this.state.type === 'eraser_line') {
assert(this.renderer instanceof CanvasObjectEraserLine || !this.renderer);
@@ -122,6 +133,15 @@ export class CanvasEntityBufferObjectRenderer extends CanvasModuleBase {
this.konva.group.add(this.renderer.konva.group);
}
didRender = this.renderer.update(this.state, true);
} else if (this.state.type === 'eraser_line_with_pressure') {
assert(this.renderer instanceof CanvasObjectEraserLineWithPressure || !this.renderer);
if (!this.renderer) {
this.renderer = new CanvasObjectEraserLineWithPressure(this.state, this);
this.konva.group.add(this.renderer.konva.group);
}
didRender = this.renderer.update(this.state, true);
} else if (this.state.type === 'rect') {
assert(this.renderer instanceof CanvasObjectRect || !this.renderer);
@@ -205,14 +225,18 @@ export class CanvasEntityBufferObjectRenderer extends CanvasModuleBase {
if (pushToState) {
const entityIdentifier = this.parent.entityIdentifier;
if (this.state.type === 'brush_line') {
this.manager.stateApi.addBrushLine({ entityIdentifier, brushLine: this.state });
} else if (this.state.type === 'eraser_line') {
this.manager.stateApi.addEraserLine({ entityIdentifier, eraserLine: this.state });
} else if (this.state.type === 'rect') {
this.manager.stateApi.addRect({ entityIdentifier, rect: this.state });
} else {
this.log.warn({ buffer: this.state }, 'Invalid buffer object type');
switch (this.state.type) {
case 'brush_line':
case 'brush_line_with_pressure':
this.manager.stateApi.addBrushLine({ entityIdentifier, brushLine: this.state });
break;
case 'eraser_line':
case 'eraser_line_with_pressure':
this.manager.stateApi.addEraserLine({ entityIdentifier, eraserLine: this.state });
break;
case 'rect':
this.manager.stateApi.addRect({ entityIdentifier, rect: this.state });
break;
}
}

View File

@@ -6,7 +6,9 @@ import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEnt
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasObjectBrushLine } from 'features/controlLayers/konva/CanvasObject/CanvasObjectBrushLine';
import { CanvasObjectBrushLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure';
import { CanvasObjectEraserLine } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLine';
import { CanvasObjectEraserLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLineWithPressure';
import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage';
import { CanvasObjectRect } from 'features/controlLayers/konva/CanvasObject/CanvasObjectRect';
import type { AnyObjectRenderer, AnyObjectState } from 'features/controlLayers/konva/CanvasObject/types';
@@ -285,6 +287,16 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase {
this.konva.objectGroup.add(renderer.konva.group);
}
didRender = renderer.update(objectState, force || isFirstRender);
} else if (objectState.type === 'brush_line_with_pressure') {
assert(renderer instanceof CanvasObjectBrushLineWithPressure || !renderer);
if (!renderer) {
renderer = new CanvasObjectBrushLineWithPressure(objectState, this);
this.renderers.set(renderer.id, renderer);
this.konva.objectGroup.add(renderer.konva.group);
}
didRender = renderer.update(objectState, force || isFirstRender);
} else if (objectState.type === 'eraser_line') {
assert(renderer instanceof CanvasObjectEraserLine || !renderer);
@@ -295,6 +307,16 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase {
this.konva.objectGroup.add(renderer.konva.group);
}
didRender = renderer.update(objectState, force || isFirstRender);
} else if (objectState.type === 'eraser_line_with_pressure') {
assert(renderer instanceof CanvasObjectEraserLineWithPressure || !renderer);
if (!renderer) {
renderer = new CanvasObjectEraserLineWithPressure(objectState, this);
this.renderers.set(renderer.id, renderer);
this.konva.objectGroup.add(renderer.konva.group);
}
didRender = renderer.update(objectState, force || isFirstRender);
} else if (objectState.type === 'rect') {
assert(renderer instanceof CanvasObjectRect || !renderer);

View File

@@ -0,0 +1,96 @@
import { rgbaColorToString } from 'common/util/colorCodeTransformers';
import { deepClone } from 'common/util/deepClone';
import type { CanvasEntityBufferObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer';
import type { CanvasEntityObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getSVGPathDataFromPoints } from 'features/controlLayers/konva/util';
import type { CanvasBrushLineWithPressureState } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { Logger } from 'roarr';
export class CanvasObjectBrushLineWithPressure extends CanvasModuleBase {
readonly type = 'object_brush_line_with_pressure';
readonly id: string;
readonly path: string[];
readonly parent: CanvasEntityObjectRenderer | CanvasEntityBufferObjectRenderer;
readonly manager: CanvasManager;
readonly log: Logger;
state: CanvasBrushLineWithPressureState;
konva: {
group: Konva.Group;
line: Konva.Path;
};
constructor(
state: CanvasBrushLineWithPressureState,
parent: CanvasEntityObjectRenderer | CanvasEntityBufferObjectRenderer
) {
super();
const { id, clip } = state;
this.id = id;
this.parent = parent;
this.manager = parent.manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug({ state }, 'Creating module');
this.konva = {
group: new Konva.Group({
name: `${this.type}:group`,
clip,
listening: false,
}),
line: new Konva.Path({
name: `${this.type}:path`,
listening: false,
shadowForStrokeEnabled: false,
globalCompositeOperation: 'source-over',
}),
};
this.konva.group.add(this.konva.line);
this.state = state;
}
update(state: CanvasBrushLineWithPressureState, force = false): boolean {
if (force || this.state !== state) {
this.log.trace({ state }, 'Updating brush line with pressure');
const { points, color, strokeWidth } = state;
this.konva.line.setAttrs({
data: getSVGPathDataFromPoints(points, {
size: strokeWidth / 2,
simulatePressure: false,
last: true,
thinning: 1,
}),
fill: rgbaColorToString(color),
});
this.state = state;
return true;
}
return false;
}
setVisibility(isVisible: boolean): void {
this.log.trace({ isVisible }, 'Setting brush line visibility');
this.konva.group.visible(isVisible);
}
destroy = () => {
this.log.debug('Destroying module');
this.konva.group.destroy();
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
parent: this.parent.id,
state: deepClone(this.state),
};
};
}

View File

@@ -0,0 +1,95 @@
import { deepClone } from 'common/util/deepClone';
import type { CanvasEntityBufferObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer';
import type { CanvasEntityObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getSVGPathDataFromPoints } from 'features/controlLayers/konva/util';
import type { CanvasEraserLineWithPressureState } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { Logger } from 'roarr';
export class CanvasObjectEraserLineWithPressure extends CanvasModuleBase {
readonly type = 'object_eraser_line_with_pressure';
readonly id: string;
readonly path: string[];
readonly parent: CanvasEntityObjectRenderer | CanvasEntityBufferObjectRenderer;
readonly manager: CanvasManager;
readonly log: Logger;
state: CanvasEraserLineWithPressureState;
konva: {
group: Konva.Group;
line: Konva.Path;
};
constructor(
state: CanvasEraserLineWithPressureState,
parent: CanvasEntityObjectRenderer | CanvasEntityBufferObjectRenderer
) {
super();
const { id, clip } = state;
this.id = id;
this.parent = parent;
this.manager = parent.manager;
this.path = this.manager.buildPath(this);
this.log = this.manager.buildLogger(this);
this.log.debug({ state }, 'Creating module');
this.konva = {
group: new Konva.Group({
name: `${this.type}:group`,
clip,
listening: false,
}),
line: new Konva.Path({
name: `${this.type}:path`,
listening: false,
fill: 'red', // Eraser lines use compositing, does not matter what color they have
shadowForStrokeEnabled: false,
globalCompositeOperation: 'destination-out',
}),
};
this.konva.group.add(this.konva.line);
this.state = state;
}
update(state: CanvasEraserLineWithPressureState, force = false): boolean {
if (force || this.state !== state) {
this.log.trace({ state }, 'Updating eraser line with pressure');
const { points, strokeWidth } = state;
this.konva.line.setAttrs({
data: getSVGPathDataFromPoints(points, {
size: strokeWidth / 2,
simulatePressure: false,
last: true,
thinning: 1,
}),
});
this.state = state;
return true;
}
return false;
}
setVisibility(isVisible: boolean): void {
this.log.trace({ isVisible }, 'Setting eraser line visibility');
this.konva.group.visible(isVisible);
}
destroy = () => {
this.log.debug('Destroying module');
this.konva.group.destroy();
};
repr = () => {
return {
id: this.id,
type: this.type,
path: this.path,
parent: this.parent.id,
state: deepClone(this.state),
};
};
}

View File

@@ -1,10 +1,14 @@
import type { CanvasObjectBrushLine } from 'features/controlLayers/konva/CanvasObject/CanvasObjectBrushLine';
import type { CanvasObjectBrushLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure';
import type { CanvasObjectEraserLine } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLine';
import type { CanvasObjectEraserLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLineWithPressure';
import type { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage';
import type { CanvasObjectRect } from 'features/controlLayers/konva/CanvasObject/CanvasObjectRect';
import type {
CanvasBrushLineState,
CanvasBrushLineWithPressureState,
CanvasEraserLineState,
CanvasEraserLineWithPressureState,
CanvasImageState,
CanvasRectState,
} from 'features/controlLayers/store/types';
@@ -15,9 +19,17 @@ import type {
export type AnyObjectRenderer =
| CanvasObjectBrushLine
| CanvasObjectBrushLineWithPressure
| CanvasObjectEraserLine
| CanvasObjectEraserLineWithPressure
| CanvasObjectRect
| CanvasObjectImage; /**
* Union of all object states.
*/
export type AnyObjectState = CanvasBrushLineState | CanvasEraserLineState | CanvasImageState | CanvasRectState;
export type AnyObjectState =
| CanvasBrushLineState
| CanvasBrushLineWithPressureState
| CanvasEraserLineState
| CanvasEraserLineWithPressureState
| CanvasImageState
| CanvasRectState;

View File

@@ -95,6 +95,7 @@ export class CanvasStageModule extends CanvasModuleBase {
initialize = () => {
this.log.debug('Initializing module');
this.container.style.touchAction = 'none';
this.konva.stage.container(this.container);
this.setResizeObserver();
this.fitStageToContainer();

View File

@@ -15,11 +15,16 @@ type CanvasToolBrushConfig = {
* The outer border color for the brush tool preview.
*/
BORDER_OUTER_COLOR: string;
/**
* The number of milliseconds to wait before hiding the brush preview's fill circle after the mouse is released.
*/
HIDE_FILL_TIMEOUT_MS: number;
};
const DEFAULT_CONFIG: CanvasToolBrushConfig = {
BORDER_INNER_COLOR: 'rgba(0,0,0,1)',
BORDER_OUTER_COLOR: 'rgba(255,255,255,0.8)',
HIDE_FILL_TIMEOUT_MS: 1500, // same as Affinity
};
/**
@@ -34,6 +39,7 @@ export class CanvasToolBrush extends CanvasModuleBase {
readonly log: Logger;
config: CanvasToolBrushConfig = DEFAULT_CONFIG;
hideFillTimeoutId: number | null = null;
/**
* The Konva objects that make up the brush tool preview:
@@ -85,15 +91,37 @@ export class CanvasToolBrush extends CanvasModuleBase {
};
this.konva.group.add(this.konva.fillCircle, this.konva.innerBorder, this.konva.outerBorder);
}
render = () => {
const cursorPos = this.manager.tool.$cursorPos.get();
const tool = this.parent.$tool.get();
// If the cursor position is not available, do not update the brush preview. The tool module will handle visiblity.
if (!cursorPos) {
if (tool !== 'brush') {
this.setVisibility(false);
return;
}
const cursorPos = this.parent.$cursorPos.get();
const canDraw = this.parent.getCanDraw();
if (!cursorPos || !canDraw) {
this.setVisibility(false);
return;
}
const isMouseDown = this.parent.$isMouseDown.get();
const lastPointerType = this.parent.$lastPointerType.get();
if (lastPointerType !== 'mouse' && isMouseDown) {
this.setVisibility(false);
return;
}
this.setVisibility(true);
if (this.hideFillTimeoutId !== null) {
window.clearTimeout(this.hideFillTimeoutId);
this.hideFillTimeoutId = null;
}
const settings = this.manager.stateApi.getSettings();
const brushPreviewFill = this.manager.stateApi.getBrushPreviewColor();
const alignedCursorPos = alignCoordForTool(cursorPos, settings.brushWidth);
@@ -105,6 +133,7 @@ export class CanvasToolBrush extends CanvasModuleBase {
y: alignedCursorPos.y,
radius,
fill: rgbaColorToString(brushPreviewFill),
visible: !isMouseDown && lastPointerType === 'mouse',
});
// But the borders are in screen-pixels
@@ -123,6 +152,11 @@ export class CanvasToolBrush extends CanvasModuleBase {
innerRadius: radius + onePixel,
outerRadius: radius + twoPixels,
});
this.hideFillTimeoutId = window.setTimeout(() => {
this.konva.fillCircle.visible(false);
this.hideFillTimeoutId = null;
}, this.config.HIDE_FILL_TIMEOUT_MS);
};
setVisibility = (visible: boolean) => {

View File

@@ -190,13 +190,31 @@ export class CanvasToolColorPicker extends CanvasModuleBase {
* Renders the color picker tool preview on the canvas.
*/
render = () => {
const cursorPos = this.manager.tool.$cursorPos.get();
const tool = this.parent.$tool.get();
// If the cursor position is not available, do not render the preview. The tool module will handle visibility.
if (!cursorPos) {
if (tool !== 'colorPicker') {
this.setVisibility(false);
return;
}
const cursorPos = this.parent.$cursorPos.get();
const canDraw = this.parent.getCanDraw();
if (!cursorPos || tool !== 'colorPicker' || !canDraw) {
this.setVisibility(false);
return;
}
const isMouseDown = this.parent.$isMouseDown.get();
const lastPointerType = this.parent.$lastPointerType.get();
if (lastPointerType !== 'mouse' && !isMouseDown) {
this.setVisibility(false);
return;
}
this.setVisibility(true);
const settings = this.manager.stateApi.getSettings();
const colorUnderCursor = this.parent.$colorUnderCursor.get();
const colorPickerInnerRadius = this.manager.stage.unscale(this.config.RING_INNER_RADIUS);

View File

@@ -78,12 +78,31 @@ export class CanvasToolEraser extends CanvasModuleBase {
}
render = () => {
const cursorPos = this.manager.tool.$cursorPos.get();
const tool = this.parent.$tool.get();
if (!cursorPos) {
if (tool !== 'eraser') {
this.setVisibility(false);
return;
}
const cursorPos = this.parent.$cursorPos.get();
const canDraw = this.parent.getCanDraw();
if (!cursorPos || !canDraw) {
this.setVisibility(false);
return;
}
const isMouseDown = this.parent.$isMouseDown.get();
const lastPointerType = this.parent.$lastPointerType.get();
if (lastPointerType !== 'mouse' && isMouseDown) {
this.setVisibility(false);
return;
}
this.setVisibility(true);
const settings = this.manager.stateApi.getSettings();
const alignedCursorPos = alignCoordForTool(cursorPos, settings.eraserWidth);
const radius = settings.eraserWidth / 2;

View File

@@ -9,6 +9,7 @@ import {
floorCoord,
getIsPrimaryMouseDown,
getLastPointOfLastLine,
getLastPointOfLastLineWithPressure,
getLastPointOfLine,
getPrefixedId,
getScaledCursorPosition,
@@ -26,7 +27,7 @@ import type {
RgbColor,
Tool,
} from 'features/controlLayers/store/types';
import { isRenderableEntity, RGBA_BLACK } from 'features/controlLayers/store/types';
import { RGBA_BLACK } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { atom } from 'nanostores';
@@ -76,6 +77,11 @@ export class CanvasToolModule extends CanvasModuleBase {
* The color currently under the cursor. Only has a value when the color picker tool is active.
*/
$colorUnderCursor = atom<RgbColor>(RGBA_BLACK);
/**
* The last pointer type that was used on the stage. This is used to determine if we should show a tool preview. For
* example, when using a pen, we should not show a brush preview.
*/
$lastPointerType = atom<string | null>(null);
konva: {
stage: Konva.Stage;
@@ -165,40 +171,23 @@ export class CanvasToolModule extends CanvasModuleBase {
};
render = () => {
const stage = this.manager.stage;
const renderedEntityCount = this.manager.stateApi.getRenderedEntityCount();
const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter();
const cursorPos = this.$cursorPos.get();
const tool = this.$tool.get();
const isFiltering = this.manager.stateApi.$isFiltering.get();
const isStaging = this.manager.stagingArea.$isStaging.get();
const isDrawable =
!!selectedEntity &&
selectedEntity.state.isEnabled &&
!selectedEntity.state.isLocked &&
isRenderableEntity(selectedEntity.state);
this.syncCursorStyle();
stage.setIsDraggable(tool === 'view');
this.manager.stage.setIsDraggable(tool === 'view');
if (!cursorPos || renderedEntityCount === 0 || isFiltering || isStaging) {
// We can bail early if the mouse isn't over the stage or there are no layers
if (!cursorPos || isFiltering || isStaging || renderedEntityCount === 0) {
this.konva.group.visible(false);
} else {
this.konva.group.visible(true);
// No need to render the brush preview if the cursor position or color is missing
if (cursorPos && tool === 'brush') {
this.brushToolPreview.render();
} else if (cursorPos && tool === 'eraser') {
this.eraserToolPreview.render();
} else if (cursorPos && tool === 'colorPicker') {
this.colorPickerToolPreview.render();
}
this.setToolVisibility(tool, isDrawable);
this.brushToolPreview.render();
this.eraserToolPreview.render();
this.colorPickerToolPreview.render();
}
};
@@ -257,11 +246,14 @@ export class CanvasToolModule extends CanvasModuleBase {
};
setEventListeners = (): (() => void) => {
this.konva.stage.on('mouseenter', this.onStageMouseEnter);
this.konva.stage.on('mousedown', this.onStageMouseDown);
this.konva.stage.on('mouseup', this.onStageMouseUp);
this.konva.stage.on('mousemove', this.onStageMouseMove);
this.konva.stage.on('mouseleave', this.onStageMouseLeave);
this.konva.stage.on('pointerenter', this.onStagePointerEnter);
this.konva.stage.on('pointerdown', this.onStagePointerDown);
this.konva.stage.on('pointerup', this.onStagePointerUp);
this.konva.stage.on('pointermove', this.onStagePointerMove);
// The Konva stage doesn't appear to handle pointerleave events, so we need to listen to the container instead
this.manager.stage.container.addEventListener('pointerleave', this.onStagePointerLeave);
this.konva.stage.on('wheel', this.onStageMouseWheel);
window.addEventListener('keydown', this.onKeyDown);
@@ -270,13 +262,15 @@ export class CanvasToolModule extends CanvasModuleBase {
window.addEventListener('blur', this.onWindowBlur);
return () => {
this.konva.stage.off('mouseenter', this.onStageMouseEnter);
this.konva.stage.off('mousedown', this.onStageMouseDown);
this.konva.stage.off('mouseup', this.onStageMouseUp);
this.konva.stage.off('mousemove', this.onStageMouseMove);
this.konva.stage.off('mouseleave', this.onStageMouseLeave);
this.konva.stage.off('pointerenter', this.onStagePointerEnter);
this.konva.stage.off('pointerdown', this.onStagePointerDown);
this.konva.stage.off('pointerup', this.onStagePointerUp);
this.konva.stage.off('pointermove', this.onStagePointerMove);
this.manager.stage.container.removeEventListener('pointerleave', this.onStagePointerLeave);
this.konva.stage.off('wheel', this.onStageMouseWheel);
window.removeEventListener('keydown', this.onKeyDown);
window.removeEventListener('keyup', this.onKeyUp);
window.removeEventListener('pointerup', this.onWindowPointerUp);
@@ -296,13 +290,14 @@ export class CanvasToolModule extends CanvasModuleBase {
}
};
onStageMouseEnter = async (_: KonvaEventObject<MouseEvent>) => {
if (!this.getCanDraw()) {
return;
}
const cursorPos = this.syncLastCursorPos();
onStagePointerEnter = async (e: KonvaEventObject<PointerEvent>) => {
try {
this.$lastPointerType.set(e.evt.pointerType);
if (!this.getCanDraw()) {
return;
}
const cursorPos = this.syncLastCursorPos();
const isMouseDown = this.$isMouseDown.get();
const settings = this.manager.stateApi.getSettings();
const tool = this.$tool.get();
@@ -320,14 +315,25 @@ export class CanvasToolModule extends CanvasModuleBase {
if (tool === 'brush') {
const normalizedPoint = offsetCoord(cursorPos, selectedEntity.state.position);
const alignedPoint = alignCoordForTool(normalizedPoint, settings.brushWidth);
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('brush_line'),
type: 'brush_line',
points: [alignedPoint.x, alignedPoint.y],
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
clip: this.getClip(selectedEntity.state),
});
if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) {
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('brush_line_with_pressure'),
type: 'brush_line_with_pressure',
points: [alignedPoint.x, alignedPoint.y, e.evt.pressure],
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
clip: this.getClip(selectedEntity.state),
});
} else {
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('brush_line'),
type: 'brush_line',
points: [alignedPoint.x, alignedPoint.y],
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
clip: this.getClip(selectedEntity.state),
});
}
return;
}
@@ -337,13 +343,23 @@ export class CanvasToolModule extends CanvasModuleBase {
if (selectedEntity.bufferRenderer.state && selectedEntity.bufferRenderer.hasBuffer()) {
selectedEntity.bufferRenderer.commitBuffer();
}
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('eraser_line'),
type: 'eraser_line',
points: [alignedPoint.x, alignedPoint.y],
strokeWidth: settings.eraserWidth,
clip: this.getClip(selectedEntity.state),
});
if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) {
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('eraser_line_with_pressure'),
type: 'eraser_line_with_pressure',
points: [alignedPoint.x, alignedPoint.y],
strokeWidth: settings.eraserWidth,
clip: this.getClip(selectedEntity.state),
});
} else {
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('eraser_line'),
type: 'eraser_line',
points: [alignedPoint.x, alignedPoint.y],
strokeWidth: settings.eraserWidth,
clip: this.getClip(selectedEntity.state),
});
}
return;
}
} finally {
@@ -351,26 +367,19 @@ export class CanvasToolModule extends CanvasModuleBase {
}
};
onStageMouseDown = async (e: KonvaEventObject<MouseEvent>) => {
if (!this.getCanDraw()) {
return;
}
this.$isMouseDown.set(getIsPrimaryMouseDown(e));
const cursorPos = this.syncLastCursorPos();
onStagePointerDown = async (e: KonvaEventObject<PointerEvent>) => {
try {
const tool = this.$tool.get();
const settings = this.manager.stateApi.getSettings();
this.$lastPointerType.set(e.evt.pointerType);
if (tool === 'colorPicker') {
const color = this.getColorUnderCursor();
if (color) {
this.manager.stateApi.setColor({ ...settings.color, ...color });
}
if (!this.getCanDraw()) {
return;
}
this.$isMouseDown.set(getIsPrimaryMouseDown(e));
const cursorPos = this.syncLastCursorPos();
const tool = this.$tool.get();
const settings = this.manager.stateApi.getSettings();
const isMouseDown = this.$isMouseDown.get();
const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter();
@@ -381,36 +390,57 @@ export class CanvasToolModule extends CanvasModuleBase {
const normalizedPoint = offsetCoord(cursorPos, selectedEntity.state.position);
if (tool === 'brush') {
const lastLinePoint = getLastPointOfLastLine(selectedEntity.state.objects, 'brush_line');
const alignedPoint = alignCoordForTool(normalizedPoint, settings.brushWidth);
if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point
if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) {
const lastLinePoint = getLastPointOfLastLineWithPressure(
selectedEntity.state.objects,
'brush_line_with_pressure'
);
const alignedPoint = alignCoordForTool(normalizedPoint, settings.brushWidth);
if (selectedEntity.bufferRenderer.hasBuffer()) {
selectedEntity.bufferRenderer.commitBuffer();
}
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('brush_line'),
type: 'brush_line',
points: [
// The last point of the last line is already normalized to the entity's coordinates
let points: number[];
if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point
points = [
lastLinePoint.x,
lastLinePoint.y,
lastLinePoint.pressure,
alignedPoint.x,
alignedPoint.y,
],
e.evt.pressure,
];
} else {
points = [alignedPoint.x, alignedPoint.y, e.evt.pressure];
}
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('brush_line_with_pressure'),
type: 'brush_line_with_pressure',
points,
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
clip: this.getClip(selectedEntity.state),
});
} else {
const lastLinePoint = getLastPointOfLastLine(selectedEntity.state.objects, 'brush_line');
const alignedPoint = alignCoordForTool(normalizedPoint, settings.brushWidth);
if (selectedEntity.bufferRenderer.hasBuffer()) {
selectedEntity.bufferRenderer.commitBuffer();
}
let points: number[];
if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point
points = [lastLinePoint.x, lastLinePoint.y, alignedPoint.x, alignedPoint.y];
} else {
points = [alignedPoint.x, alignedPoint.y];
}
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('brush_line'),
type: 'brush_line',
points: [alignedPoint.x, alignedPoint.y],
points,
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
clip: this.getClip(selectedEntity.state),
@@ -419,34 +449,56 @@ export class CanvasToolModule extends CanvasModuleBase {
}
if (tool === 'eraser') {
const lastLinePoint = getLastPointOfLastLine(selectedEntity.state.objects, 'eraser_line');
const alignedPoint = alignCoordForTool(normalizedPoint, settings.eraserWidth);
if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point
if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) {
const lastLinePoint = getLastPointOfLastLineWithPressure(
selectedEntity.state.objects,
'eraser_line_with_pressure'
);
const alignedPoint = alignCoordForTool(normalizedPoint, settings.eraserWidth);
if (selectedEntity.bufferRenderer.hasBuffer()) {
selectedEntity.bufferRenderer.commitBuffer();
}
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('eraser_line'),
type: 'eraser_line',
points: [
// The last point of the last line is already normalized to the entity's coordinates
let points: number[];
if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point
points = [
lastLinePoint.x,
lastLinePoint.y,
lastLinePoint.pressure,
alignedPoint.x,
alignedPoint.y,
],
e.evt.pressure,
];
} else {
points = [alignedPoint.x, alignedPoint.y, e.evt.pressure];
}
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('eraser_line_with_pressure'),
type: 'eraser_line_with_pressure',
points,
strokeWidth: settings.eraserWidth,
clip: this.getClip(selectedEntity.state),
});
} else {
const lastLinePoint = getLastPointOfLastLine(selectedEntity.state.objects, 'eraser_line');
const alignedPoint = alignCoordForTool(normalizedPoint, settings.eraserWidth);
if (selectedEntity.bufferRenderer.hasBuffer()) {
selectedEntity.bufferRenderer.commitBuffer();
}
let points: number[];
if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point
points = [lastLinePoint.x, lastLinePoint.y, alignedPoint.x, alignedPoint.y];
} else {
points = [alignedPoint.x, alignedPoint.y];
}
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('eraser_line'),
type: 'eraser_line',
points: [alignedPoint.x, alignedPoint.y],
points,
strokeWidth: settings.eraserWidth,
clip: this.getClip(selectedEntity.state),
});
@@ -469,26 +521,37 @@ export class CanvasToolModule extends CanvasModuleBase {
}
};
onStageMouseUp = (_: KonvaEventObject<MouseEvent>) => {
if (!this.getCanDraw()) {
return;
}
onStagePointerUp = (e: KonvaEventObject<PointerEvent>) => {
try {
this.$isMouseDown.set(false);
const cursorPos = this.syncLastCursorPos();
if (!cursorPos) {
this.$lastPointerType.set(e.evt.pointerType);
if (!this.getCanDraw()) {
return;
}
const tool = this.$tool.get();
const settings = this.manager.stateApi.getSettings();
if (tool === 'colorPicker') {
const color = this.getColorUnderCursor();
if (color) {
this.manager.stateApi.setColor({ ...settings.color, ...color });
}
return;
}
const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter();
const isDrawable = selectedEntity?.state.isEnabled && !selectedEntity.state.isLocked;
if (!isDrawable) {
return;
}
const tool = this.$tool.get();
if (tool === 'brush') {
if (selectedEntity.bufferRenderer.state?.type === 'brush_line' && selectedEntity.bufferRenderer.hasBuffer()) {
if (
(selectedEntity.bufferRenderer.state?.type === 'brush_line' ||
selectedEntity.bufferRenderer.state?.type === 'brush_line_with_pressure') &&
selectedEntity.bufferRenderer.hasBuffer()
) {
selectedEntity.bufferRenderer.commitBuffer();
} else {
selectedEntity.bufferRenderer.clearBuffer();
@@ -496,7 +559,11 @@ export class CanvasToolModule extends CanvasModuleBase {
}
if (tool === 'eraser') {
if (selectedEntity.bufferRenderer.state?.type === 'eraser_line' && selectedEntity.bufferRenderer.hasBuffer()) {
if (
(selectedEntity.bufferRenderer.state?.type === 'eraser_line' ||
selectedEntity.bufferRenderer.state?.type === 'eraser_line_with_pressure') &&
selectedEntity.bufferRenderer.hasBuffer()
) {
selectedEntity.bufferRenderer.commitBuffer();
} else {
selectedEntity.bufferRenderer.clearBuffer();
@@ -515,12 +582,14 @@ export class CanvasToolModule extends CanvasModuleBase {
}
};
onStageMouseMove = async (_: KonvaEventObject<MouseEvent>) => {
if (!this.getCanDraw()) {
return;
}
onStagePointerMove = async (e: KonvaEventObject<PointerEvent>) => {
try {
this.$lastPointerType.set(e.evt.pointerType);
if (!this.getCanDraw()) {
return;
}
const tool = this.$tool.get();
const cursorPos = this.syncLastCursorPos();
@@ -548,7 +617,7 @@ export class CanvasToolModule extends CanvasModuleBase {
const settings = this.manager.stateApi.getSettings();
if (tool === 'brush' && bufferState.type === 'brush_line') {
if (tool === 'brush' && (bufferState.type === 'brush_line' || bufferState.type === 'brush_line_with_pressure')) {
const lastPoint = getLastPointOfLine(bufferState.points);
const minDistance = settings.brushWidth * this.config.BRUSH_SPACING_TARGET_SCALE;
if (!lastPoint || !isDistanceMoreThanMin(cursorPos, lastPoint, minDistance)) {
@@ -564,8 +633,16 @@ export class CanvasToolModule extends CanvasModuleBase {
}
bufferState.points.push(alignedPoint.x, alignedPoint.y);
if (bufferState.type === 'brush_line_with_pressure') {
bufferState.points.push(e.evt.pressure);
}
await selectedEntity.bufferRenderer.setBuffer(bufferState);
} else if (tool === 'eraser' && bufferState.type === 'eraser_line') {
} else if (
tool === 'eraser' &&
(bufferState.type === 'eraser_line' || bufferState.type === 'eraser_line_with_pressure')
) {
const lastPoint = getLastPointOfLine(bufferState.points);
const minDistance = settings.eraserWidth * this.config.BRUSH_SPACING_TARGET_SCALE;
if (!lastPoint || !isDistanceMoreThanMin(cursorPos, lastPoint, minDistance)) {
@@ -581,6 +658,11 @@ export class CanvasToolModule extends CanvasModuleBase {
}
bufferState.points.push(alignedPoint.x, alignedPoint.y);
if (bufferState.type === 'eraser_line_with_pressure') {
bufferState.points.push(e.evt.pressure);
}
await selectedEntity.bufferRenderer.setBuffer(bufferState);
} else if (tool === 'rect' && bufferState.type === 'rect') {
const normalizedPoint = offsetCoord(cursorPos, selectedEntity.state.position);
@@ -596,23 +678,27 @@ export class CanvasToolModule extends CanvasModuleBase {
}
};
onStageMouseLeave = (_: KonvaEventObject<MouseEvent>) => {
if (!this.getCanDraw()) {
return;
onStagePointerLeave = (e: PointerEvent) => {
try {
this.$lastPointerType.set(e.pointerType);
this.$cursorPos.set(null);
if (!this.getCanDraw()) {
return;
}
const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter();
if (
selectedEntity &&
selectedEntity.bufferRenderer.state?.type !== 'rect' &&
selectedEntity.bufferRenderer.hasBuffer()
) {
selectedEntity.bufferRenderer.commitBuffer();
}
} finally {
this.render();
}
this.$cursorPos.set(null);
const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter();
if (
selectedEntity &&
selectedEntity.bufferRenderer.state?.type !== 'rect' &&
selectedEntity.bufferRenderer.hasBuffer()
) {
selectedEntity.bufferRenderer.commitBuffer();
}
this.render();
};
onStageMouseWheel = (e: KonvaEventObject<WheelEvent>) => {
@@ -652,12 +738,16 @@ export class CanvasToolModule extends CanvasModuleBase {
* whatever the user was drawing from being lost, or ending up with stale state, we need to commit the buffer
* on window pointer up.
*/
onWindowPointerUp = () => {
this.$isMouseDown.set(false);
const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter();
onWindowPointerUp = (_: PointerEvent) => {
try {
this.$isMouseDown.set(false);
const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter();
if (selectedEntity && selectedEntity.bufferRenderer.hasBuffer() && !this.manager.$isBusy.get()) {
selectedEntity.bufferRenderer.commitBuffer();
if (selectedEntity && selectedEntity.bufferRenderer.hasBuffer() && !this.manager.$isBusy.get()) {
selectedEntity.bufferRenderer.commitBuffer();
}
} finally {
this.render();
}
};

View File

@@ -6,6 +6,8 @@ import type { KonvaEventObject } from 'konva/lib/Node';
import type { Vector2d } from 'konva/lib/types';
import { clamp } from 'lodash-es';
import { customAlphabet } from 'nanoid';
import type { StrokeOptions } from 'perfect-freehand';
import getStroke from 'perfect-freehand';
import { assert } from 'tsafe';
/**
@@ -148,14 +150,32 @@ export const getLastPointOfLine = (points: number[]): Coordinate | null => {
if (points.length < 2) {
return null;
}
const x = points[points.length - 2];
const y = points[points.length - 1];
const x = points.at(-2);
const y = points.at(-1);
if (x === undefined || y === undefined) {
return null;
}
return { x, y };
};
/**
* Gets the last point of a line as a coordinate.
* @param points An array of numbers representing points as [x1, y1, x2, y2, ...]
* @returns The last point of the line as a coordinate, or null if the line has less than 1 point
*/
export const getLastPointOfLineWithPressure = (points: number[]): CoordinateWithPressure | null => {
if (points.length < 3) {
return null;
}
const x = points.at(-3);
const y = points.at(-2);
const pressure = points.at(-1);
if (x === undefined || y === undefined || pressure === undefined) {
return null;
}
return { x, y, pressure };
};
export function getIsPrimaryMouseDown(e: KonvaEventObject<MouseEvent>) {
return e.evt.buttons === 1;
}
@@ -436,7 +456,9 @@ export function loadImage(src: string): Promise<HTMLImageElement> {
*/
export const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 10);
export function getPrefixedId(prefix: CanvasEntityIdentifier['type'] | (string & Record<never, never>)): string {
export function getPrefixedId(
prefix: CanvasEntityIdentifier['type'] | CanvasObjectState['type'] | (string & Record<never, never>)
): string {
return `${prefix}:${nanoid()}`;
}
@@ -492,11 +514,32 @@ export const exhaustiveCheck = (value: never): never => {
assert(false, `Unhandled value: ${value}`);
};
type CoordinateWithPressure = {
x: number;
y: number;
pressure: number;
};
export const getLastPointOfLastLineWithPressure = (
objects: CanvasObjectState[],
type: 'brush_line_with_pressure' | 'eraser_line_with_pressure'
): CoordinateWithPressure | null => {
const lastObject = objects.at(-1);
if (!lastObject) {
return null;
}
if (lastObject.type === type) {
return getLastPointOfLineWithPressure(lastObject.points);
}
return null;
};
export const getLastPointOfLastLine = (
objects: CanvasObjectState[],
type: 'brush_line' | 'eraser_line'
): Coordinate | null => {
const lastObject = objects[objects.length - 1];
const lastObject = objects.at(-1);
if (!lastObject) {
return null;
}
@@ -540,3 +583,53 @@ export const getKonvaNodeDebugAttrs = (node: Konva.Node) => {
rotation: node.rotation(),
};
};
const average = (a: number, b: number) => (a + b) / 2;
function getSvgPathFromStroke(points: number[][], closed = true) {
const len = points.length;
if (len < 4) {
return '';
}
let a = points[0] as number[];
let b = points[1] as number[];
const c = points[2] as number[];
let result = `M${a[0]!.toFixed(2)},${a[1]!.toFixed(2)} Q${b[0]!.toFixed(
2
)},${b[1]!.toFixed(2)} ${average(b[0]!, c[0]!).toFixed(2)},${average(b[1]!, c[1]!).toFixed(2)} T`;
for (let i = 2, max = len - 1; i < max; i++) {
a = points[i]!;
b = points[i + 1]!;
result += `${average(a[0]!, b[0]!).toFixed(2)},${average(a[1]!, b[1]!).toFixed(2)} `;
}
if (closed) {
result += 'Z';
}
return result;
}
export const getSVGPathDataFromPoints = (points: number[], options?: StrokeOptions): string => {
const chunked: [number, number, number][] = [];
for (let i = 0; i < points.length; i += 3) {
chunked.push([points[i]!, points[i + 1]!, points[i + 2]!]);
}
return getSvgPathFromStroke(getStroke(chunked, options));
};
export const getPointerType = (e: KonvaEventObject<PointerEvent>): 'mouse' | 'pen' | 'touch' => {
if (e.evt.pointerType === 'mouse') {
return 'mouse';
}
if (e.evt.pointerType === 'pen') {
return 'pen';
}
return 'touch';
};

View File

@@ -78,6 +78,10 @@ type CanvasSettingsState = {
* Whether to show only the selected layer while transforming.
*/
isolatedTransformingPreview: boolean;
/**
* Whether to use pressure sensitivity for the brush and eraser tool when a pen device is used.
*/
pressureSensitivity: boolean;
};
const initialState: CanvasSettingsState = {
@@ -98,6 +102,7 @@ const initialState: CanvasSettingsState = {
isolatedStagingPreview: true,
isolatedFilteringPreview: true,
isolatedTransformingPreview: true,
pressureSensitivity: true,
};
export const canvasSettingsSlice = createSlice({
@@ -155,6 +160,9 @@ export const canvasSettingsSlice = createSlice({
settingsIsolatedTransformingPreviewToggled: (state) => {
state.isolatedTransformingPreview = !state.isolatedTransformingPreview;
},
settingsPressureSensitivityToggled: (state) => {
state.pressureSensitivity = !state.pressureSensitivity;
},
},
});
@@ -176,6 +184,7 @@ export const {
settingsIsolatedStagingPreviewToggled,
settingsIsolatedFilteringPreviewToggled,
settingsIsolatedTransformingPreviewToggled,
settingsPressureSensitivityToggled,
} = canvasSettingsSlice.actions;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
@@ -214,3 +223,4 @@ export const selectIsolatedFilteringPreview = createCanvasSettingsSelector(
export const selectIsolatedTransformingPreview = createCanvasSettingsSelector(
(settings) => settings.isolatedTransformingPreview
);
export const selectPressureSensitivity = createCanvasSettingsSelector((settings) => settings.pressureSensitivity);

View File

@@ -947,7 +947,11 @@ export const canvasSlice = createSlice({
// TODO(psyche): If we add the object without splatting, the renderer will see it as the same object and not
// re-render it (reference equality check). I don't like this behaviour.
entity.objects.push({ ...brushLine, points: simplifyFlatNumbersArray(brushLine.points) });
entity.objects.push({
...brushLine,
// If the brush line is not pressure sensitive, we simplify the points to reduce the size of the state
points: brushLine.type === 'brush_line' ? simplifyFlatNumbersArray(brushLine.points) : brushLine.points,
});
},
entityEraserLineAdded: (state, action: PayloadAction<EntityEraserLineAddedPayload>) => {
const { entityIdentifier, eraserLine } = action.payload;
@@ -962,7 +966,11 @@ export const canvasSlice = createSlice({
// TODO(psyche): If we add the object without splatting, the renderer will see it as the same object and not
// re-render it (reference equality check). I don't like this behaviour.
entity.objects.push({ ...eraserLine, points: simplifyFlatNumbersArray(eraserLine.points) });
entity.objects.push({
...eraserLine,
// If the brush line is not pressure sensitive, we simplify the points to reduce the size of the state
points: eraserLine.type === 'eraser_line' ? simplifyFlatNumbersArray(eraserLine.points) : eraserLine.points,
});
},
entityRectAdded: (state, action: PayloadAction<EntityRectAddedPayload>) => {
const { entityIdentifier, rect } = action.payload;

View File

@@ -58,7 +58,10 @@ const zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'view', 'bbox', 'colorP
export type Tool = z.infer<typeof zTool>;
const zPoints = z.array(z.number()).refine((points) => points.length % 2 === 0, {
message: 'Must have an even number of points',
message: 'Must have an even number of coordinate components',
});
const zPointsWithPressure = z.array(z.number()).refine((points) => points.length % 3 === 0, {
message: 'Must have a number of components divisible by 3',
});
const zRgbColor = z.object({
@@ -110,6 +113,16 @@ const zCanvasBrushLineState = z.object({
});
export type CanvasBrushLineState = z.infer<typeof zCanvasBrushLineState>;
const zCanvasBrushLineWithPressureState = z.object({
id: zId,
type: z.literal('brush_line_with_pressure'),
strokeWidth: z.number().min(1),
points: zPointsWithPressure,
color: zRgbaColor,
clip: zRect.nullable(),
});
export type CanvasBrushLineWithPressureState = z.infer<typeof zCanvasBrushLineWithPressureState>;
const zCanvasEraserLineState = z.object({
id: zId,
type: z.literal('eraser_line'),
@@ -119,6 +132,15 @@ const zCanvasEraserLineState = z.object({
});
export type CanvasEraserLineState = z.infer<typeof zCanvasEraserLineState>;
const zCanvasEraserLineWithPressureState = z.object({
id: zId,
type: z.literal('eraser_line_with_pressure'),
strokeWidth: z.number().min(1),
points: zPointsWithPressure,
clip: zRect.nullable(),
});
export type CanvasEraserLineWithPressureState = z.infer<typeof zCanvasEraserLineWithPressureState>;
const zCanvasRectState = z.object({
id: zId,
type: z.literal('rect'),
@@ -139,6 +161,8 @@ const zCanvasObjectState = z.union([
zCanvasBrushLineState,
zCanvasEraserLineState,
zCanvasRectState,
zCanvasBrushLineWithPressureState,
zCanvasEraserLineWithPressureState,
]);
export type CanvasObjectState = z.infer<typeof zCanvasObjectState>;
@@ -359,8 +383,12 @@ export type EntityIdentifierPayload<
} & T;
export type EntityMovedPayload = EntityIdentifierPayload<{ position: Coordinate }>;
export type EntityBrushLineAddedPayload = EntityIdentifierPayload<{ brushLine: CanvasBrushLineState }>;
export type EntityEraserLineAddedPayload = EntityIdentifierPayload<{ eraserLine: CanvasEraserLineState }>;
export type EntityBrushLineAddedPayload = EntityIdentifierPayload<{
brushLine: CanvasBrushLineState | CanvasBrushLineWithPressureState;
}>;
export type EntityEraserLineAddedPayload = EntityIdentifierPayload<{
eraserLine: CanvasEraserLineState | CanvasEraserLineWithPressureState;
}>;
export type EntityRectAddedPayload = EntityIdentifierPayload<{ rect: CanvasRectState }>;
export type EntityRasterizedPayload = EntityIdentifierPayload<{
imageObject: CanvasImageState;

View File

@@ -88,8 +88,9 @@ const CurrentImagePreview = () => {
exit={exit}
position="absolute"
top={0}
width="full"
height="full"
right={0}
bottom={0}
left={0}
pointerEvents="none"
>
<NextPrevImageButtons />

View File

@@ -7,12 +7,7 @@ import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
const nextPrevButtonStyles: ChakraProps['sx'] = {
color: 'base.100',
pointerEvents: 'auto',
};
const NextPrevImageButtons = () => {
const NextPrevImageButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineStart' | 'insetInlineEnd'] }) => {
const { t } = useTranslation();
const { prevImage, nextImage, isOnFirstImageOfView, isOnLastImageOfView } = useGalleryNavigation();
@@ -61,32 +56,36 @@ const NextPrevImageButtons = () => {
return (
<Box pos="relative" h="full" w="full">
<Box pos="absolute" top="50%" transform="translate(0, -50%)" insetInlineStart={1}>
{shouldShowLeftArrow && (
<IconButton
aria-label={t('accessibility.previousImage')}
icon={<PiCaretLeftBold size={64} />}
variant="unstyled"
onClick={onClickLeftArrow}
boxSize={16}
sx={nextPrevButtonStyles}
isDisabled={isFetching}
/>
)}
</Box>
<Box pos="absolute" top="50%" transform="translate(0, -50%)" insetInlineEnd={6}>
{shouldShowRightArrow && (
<IconButton
aria-label={t('accessibility.nextImage')}
icon={<PiCaretRightBold size={64} />}
variant="unstyled"
onClick={onClickRightArrow}
boxSize={16}
sx={nextPrevButtonStyles}
isDisabled={isFetching}
/>
)}
</Box>
{shouldShowLeftArrow && (
<IconButton
position="absolute"
top="50%"
transform="translate(0, -50%)"
aria-label={t('accessibility.previousImage')}
icon={<PiCaretLeftBold size={64} />}
variant="unstyled"
onClick={onClickLeftArrow}
isDisabled={isFetching}
color="base.100"
pointerEvents="auto"
insetInlineStart={inset}
/>
)}
{shouldShowRightArrow && (
<IconButton
position="absolute"
top="50%"
transform="translate(0, -50%)"
aria-label={t('accessibility.nextImage')}
icon={<PiCaretRightBold size={64} />}
variant="unstyled"
onClick={onClickRightArrow}
isDisabled={isFetching}
color="base.100"
pointerEvents="auto"
insetInlineEnd={inset}
/>
)}
</Box>
);
};

View File

@@ -73,7 +73,7 @@ const Wrapper = (props: PropsWithChildren<{ nodeProps: NodeProps }>) => {
{props.children}
{isHovering && (
<motion.div key="nextPrevButtons" initial={initial} animate={animate} exit={exit} style={styles}>
<NextPrevImageButtons />
<NextPrevImageButtons inset={2} />
</motion.div>
)}
</Flex>

View File

@@ -1,29 +0,0 @@
import type { ChakraProps } from '@invoke-ai/ui-library';
import { IconButton } from '@invoke-ai/ui-library';
import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
type Props = {
sx?: ChakraProps['sx'];
};
const CancelCurrentQueueItemIconButton = ({ sx }: Props) => {
const { t } = useTranslation();
const { cancelQueueItem, isLoading, isDisabled } = useCancelCurrentQueueItem();
return (
<IconButton
isDisabled={isDisabled}
isLoading={isLoading}
aria-label={t('queue.cancel')}
tooltip={t('queue.cancelTooltip')}
icon={<PiXBold size="16px" />}
onClick={cancelQueueItem}
colorScheme="error"
sx={sx}
/>
);
};
export default memo(CancelCurrentQueueItemIconButton);

View File

@@ -1,3 +1,4 @@
import type { TooltipProps } from '@invoke-ai/ui-library';
import { Divider, Flex, ListItem, Text, Tooltip, UnorderedList } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
@@ -20,19 +21,19 @@ const selectPromptsCount = createSelector(selectParamsSlice, selectDynamicPrompt
getShouldProcessPrompt(params.positivePrompt) ? dynamicPrompts.prompts.length : 1
);
type Props = {
type Props = TooltipProps & {
prepend?: boolean;
};
export const QueueButtonTooltip = (props: PropsWithChildren<Props>) => {
export const QueueButtonTooltip = ({ prepend, children, ...rest }: PropsWithChildren<Props>) => {
return (
<Tooltip label={<TooltipContent prepend={props.prepend} />} maxW={512}>
{props.children}
<Tooltip label={<TooltipContent prepend={prepend} />} maxW={512} {...rest}>
{children}
</Tooltip>
);
};
const TooltipContent = memo(({ prepend = false }: Props) => {
const TooltipContent = memo(({ prepend = false }: { prepend?: boolean }) => {
const { t } = useTranslation();
const { isReady, reasons } = useIsReadyToEnqueue();
const sendToCanvas = useAppSelector(selectSendToCanvas);

View File

@@ -33,7 +33,7 @@ import { Panel, PanelGroup } from 'react-resizable-panels';
import ParametersPanelUpscale from './ParametersPanels/ParametersPanelUpscale';
import ResizeHandle from './tabs/ResizeHandle';
const panelStyles: CSSProperties = { position: 'relative', height: '100%', width: '100%' };
const panelStyles: CSSProperties = { position: 'relative', height: '100%', width: '100%', minWidth: 0 };
const onLeftPanelCollapse = (isCollapsed: boolean) => $isLeftPanelOpen.set(!isCollapsed);
const onRightPanelCollapse = (isCollapsed: boolean) => $isRightPanelOpen.set(!isCollapsed);
@@ -117,42 +117,40 @@ export const AppContent = memo(() => {
return (
<Flex id="invoke-app-tabs" w="full" h="full" gap={4} p={4}>
<VerticalNavBar />
<Flex position="relative" w="full" h="full" gap={4} minW={0}>
<PanelGroup
ref={imperativePanelGroupRef}
id="app-panel-group"
autoSaveId="app-panel-group"
direction="horizontal"
style={panelStyles}
>
{withLeftPanel && (
<>
<Panel order={0} collapsible style={panelStyles} {...leftPanel.panelProps}>
<Flex flexDir="column" w="full" h="full" gap={2}>
<QueueControls />
<Box position="relative" w="full" h="full">
<LeftPanelContent />
</Box>
</Flex>
</Panel>
<ResizeHandle id="left-main-handle" {...leftPanel.resizeHandleProps} />
</>
)}
<Panel id="main-panel" order={1} minSize={20} style={panelStyles}>
<MainPanelContent />
</Panel>
{withRightPanel && (
<>
<ResizeHandle id="main-right-handle" {...rightPanel.resizeHandleProps} />
<Panel order={2} style={panelStyles} collapsible {...rightPanel.panelProps}>
<RightPanelContent />
</Panel>
</>
)}
</PanelGroup>
{withLeftPanel && <FloatingParametersPanelButtons panelApi={leftPanel} />}
{withRightPanel && <FloatingGalleryButton panelApi={rightPanel} />}
</Flex>
<PanelGroup
ref={imperativePanelGroupRef}
id="app-panel-group"
autoSaveId="app-panel-group"
direction="horizontal"
style={panelStyles}
>
{withLeftPanel && (
<>
<Panel order={0} collapsible style={panelStyles} {...leftPanel.panelProps}>
<Flex flexDir="column" w="full" h="full" gap={2}>
<QueueControls />
<Box position="relative" w="full" h="full">
<LeftPanelContent />
</Box>
</Flex>
</Panel>
<ResizeHandle id="left-main-handle" {...leftPanel.resizeHandleProps} />
</>
)}
<Panel id="main-panel" order={1} minSize={20} style={panelStyles}>
<MainPanelContent />
{withLeftPanel && <FloatingParametersPanelButtons panelApi={leftPanel} />}
{withRightPanel && <FloatingGalleryButton panelApi={rightPanel} />}
</Panel>
{withRightPanel && (
<>
<ResizeHandle id="main-right-handle" {...rightPanel.resizeHandleProps} />
<Panel order={2} style={panelStyles} collapsible {...rightPanel.panelProps}>
<RightPanelContent />
</Panel>
</>
)}
</PanelGroup>
</Flex>
);
});

View File

@@ -1,4 +1,4 @@
import { Flex, IconButton, Portal, Tooltip } from '@invoke-ai/ui-library';
import { Flex, IconButton, Tooltip } from '@invoke-ai/ui-library';
import type { UsePanelReturn } from 'features/ui/hooks/usePanel';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -11,25 +11,17 @@ type Props = {
const FloatingGalleryButton = (props: Props) => {
const { t } = useTranslation();
if (!props.panelApi.isCollapsed) {
return null;
}
return (
<Portal>
<Flex pos="absolute" transform="translate(0, -50%)" minW={8} top="50%" insetInlineEnd="21px" zIndex={11}>
<Tooltip label={t('accessibility.showGalleryPanel')} placement="start">
<IconButton
aria-label={t('accessibility.showGalleryPanel')}
onClick={props.panelApi.expand}
icon={<PiImagesSquareBold size="20px" />}
p={0}
h={48}
borderEndRadius={0}
/>
</Tooltip>
</Flex>
</Portal>
<Flex pos="absolute" transform="translate(0, -50%)" minW={8} top="50%" insetInlineEnd={2}>
<Tooltip label={t('accessibility.toggleRightPanel')} placement="start">
<IconButton
aria-label={t('accessibility.toggleRightPanel')}
onClick={props.panelApi.toggle}
icon={<PiImagesSquareBold />}
h={48}
/>
</Tooltip>
</Flex>
);
};

View File

@@ -1,10 +1,14 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { ButtonGroup, Flex, Icon, IconButton, Portal, spinAnimation, useShiftModifier } from '@invoke-ai/ui-library';
import CancelCurrentQueueItemIconButton from 'features/queue/components/CancelCurrentQueueItemIconButton';
import { ButtonGroup, Flex, Icon, IconButton, spinAnimation, Tooltip, useShiftModifier } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { ToolChooser } from 'features/controlLayers/components/Tool/ToolChooser';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useClearQueue } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
import { QueueButtonTooltip } from 'features/queue/components/QueueButtonTooltip';
import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem';
import { useInvoke } from 'features/queue/hooks/useInvoke';
import type { UsePanelReturn } from 'features/ui/hooks/usePanel';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -13,14 +17,10 @@ import {
PiSlidersHorizontalBold,
PiSparkleFill,
PiTrashSimpleBold,
PiXBold,
} from 'react-icons/pi';
import { useGetQueueStatusQuery } from 'services/api/endpoints/queue';
const floatingButtonStyles: SystemStyleObject = {
borderStartRadius: 0,
flexGrow: 1,
};
type Props = {
panelApi: UsePanelReturn;
};
@@ -29,8 +29,11 @@ const FloatingSidePanelButtons = (props: Props) => {
const { t } = useTranslation();
const queue = useInvoke();
const shift = useShiftModifier();
const tab = useAppSelector(selectActiveTab);
const imageViewer = useImageViewer();
const clearQueue = useClearQueue();
const { data: queueStatus } = useGetQueueStatusQuery();
const cancelCurrent = useCancelCurrentQueueItem();
const queueButtonIcon = useMemo(() => {
const isProcessing = (queueStatus?.queue.in_progress ?? 0) > 0;
@@ -38,62 +41,64 @@ const FloatingSidePanelButtons = (props: Props) => {
return <Icon boxSize={6} as={PiCircleNotchBold} animation={spinAnimation} />;
}
if (shift) {
return <PiLightningFill size="16px" />;
return <PiLightningFill />;
}
return <PiSparkleFill size="16px" />;
return <PiSparkleFill />;
}, [queue.isDisabled, queueStatus?.queue.in_progress, shift]);
if (!props.panelApi.isCollapsed) {
return null;
}
return (
<Portal>
<Flex
pos="absolute"
transform="translate(0, -50%)"
minW={8}
top="50%"
insetInlineStart="63px"
direction="column"
gap={2}
h={48}
zIndex={11}
>
<ButtonGroup orientation="vertical" flexGrow={3}>
<Flex pos="absolute" transform="translate(0, -50%)" top="50%" insetInlineStart={2} direction="column" gap={2}>
{tab === 'canvas' && !imageViewer.isOpen && (
<CanvasManagerProviderGate>
<ToolChooser />
</CanvasManagerProviderGate>
)}
<ButtonGroup orientation="vertical" h={48}>
<Tooltip label={t('accessibility.toggleLeftPanel')} placement="end">
<IconButton
tooltip={t('accessibility.showOptionsPanel')}
aria-label={t('accessibility.showOptionsPanel')}
onClick={props.panelApi.expand}
sx={floatingButtonStyles}
icon={<PiSlidersHorizontalBold size="16px" />}
aria-label={t('accessibility.toggleLeftPanel')}
onClick={props.panelApi.toggle}
icon={<PiSlidersHorizontalBold />}
flexGrow={1}
/>
<QueueButtonTooltip prepend={shift}>
<IconButton
aria-label={t('queue.queueBack')}
onClick={shift ? queue.queueFront : queue.queueBack}
isLoading={queue.isLoading}
isDisabled={queue.isDisabled}
icon={queueButtonIcon}
colorScheme="invokeYellow"
sx={floatingButtonStyles}
/>
</QueueButtonTooltip>
<CancelCurrentQueueItemIconButton sx={floatingButtonStyles} />
</ButtonGroup>
<IconButton
isDisabled={clearQueue.isDisabled}
isLoading={clearQueue.isLoading}
aria-label={t('queue.clear')}
tooltip={t('queue.clearTooltip')}
icon={<PiTrashSimpleBold />}
colorScheme="error"
onClick={clearQueue.openDialog}
data-testid={t('queue.clear')}
sx={floatingButtonStyles}
/>
</Flex>
</Portal>
</Tooltip>
<QueueButtonTooltip prepend={shift} placement="end">
<IconButton
aria-label={t('queue.queueBack')}
onClick={shift ? queue.queueFront : queue.queueBack}
isLoading={queue.isLoading}
isDisabled={queue.isDisabled}
icon={queueButtonIcon}
colorScheme="invokeYellow"
flexGrow={1}
/>
</QueueButtonTooltip>
<Tooltip label={t('queue.cancelTooltip')} placement="end">
<IconButton
isDisabled={cancelCurrent.isDisabled}
isLoading={cancelCurrent.isLoading}
aria-label={t('queue.cancelTooltip')}
icon={<PiXBold />}
onClick={cancelCurrent.cancelQueueItem}
colorScheme="error"
flexGrow={1}
/>
</Tooltip>
<Tooltip label={t('queue.clearTooltip')} placement="end">
<IconButton
isDisabled={clearQueue.isDisabled}
isLoading={clearQueue.isLoading}
aria-label={t('queue.clearTooltip')}
icon={<PiTrashSimpleBold />}
colorScheme="error"
onClick={clearQueue.openDialog}
data-testid={t('queue.clear')}
flexGrow={1}
/>
</Tooltip>
</ButtonGroup>
</Flex>
);
};

View File

@@ -67,26 +67,23 @@ export default defineConfig(({ mode }) => {
chunkSizeWarningLimit: 1500,
},
server: {
// Proxy HTTP requests to the flask server
proxy: {
// Proxy socket.io to the nodes socketio server
'/ws/socket.io': {
target: 'ws://127.0.0.1:9090',
ws: true,
},
// Proxy openapi schema definiton
'/openapi.json': {
target: 'http://127.0.0.1:9090/openapi.json',
rewrite: (path) => path.replace(/^\/openapi.json/, ''),
changeOrigin: true,
},
// proxy nodes api
'/api/': {
target: 'http://127.0.0.1:9090/api/',
rewrite: (path) => path.replace(/^\/api/, ''),
changeOrigin: true,
},
},
host: '0.0.0.0',
},
test: {
typecheck: {

View File

@@ -1 +1 @@
__version__ = "5.0.2"
__version__ = "5.1.0rc1"