mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-15 07:28:06 -05:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72c9645044 | ||
|
|
16106aebb2 | ||
|
|
5332eafae6 | ||
|
|
80316b0564 | ||
|
|
395d35c287 | ||
|
|
33a40326f4 | ||
|
|
2584064c73 | ||
|
|
d4775ff927 | ||
|
|
fdfee8248c | ||
|
|
af0aedcd50 | ||
|
|
4b5f460ce4 | ||
|
|
386a3fad42 | ||
|
|
904d64e2a0 | ||
|
|
84e2c0d73c | ||
|
|
fa4e7752a1 | ||
|
|
59c62f5a3b | ||
|
|
dd71858faf | ||
|
|
fbbc7b12e1 |
@@ -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",
|
||||
|
||||
7
invokeai/frontend/web/pnpm-lock.yaml
generated
7
invokeai/frontend/web/pnpm-lock.yaml
generated
@@ -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==}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ToolViewButton } from './ToolViewButton';
|
||||
export const ToolChooser: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<ButtonGroup isAttached>
|
||||
<ButtonGroup isAttached orientation="vertical">
|
||||
<ToolBrushButton />
|
||||
<ToolEraserButton />
|
||||
<ToolRectButton />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "5.0.2"
|
||||
__version__ = "5.1.0rc1"
|
||||
|
||||
Reference in New Issue
Block a user