Compare commits
18 Commits
luke-mino-
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54a8d913f8 | ||
|
|
c43a4990a9 | ||
|
|
8dd5a9900b | ||
|
|
7a11dc59b6 | ||
|
|
ba768c32f3 | ||
|
|
e3f19ab856 | ||
|
|
a9f416233d | ||
|
|
b23a92b442 | ||
|
|
8c3caa77d6 | ||
|
|
ad6dda4435 | ||
|
|
c43cd287bb | ||
|
|
b347dd1734 | ||
|
|
1a6913c466 | ||
|
|
f80fc4cf9a | ||
|
|
8b8f3538bf | ||
|
|
ecd87ae0f4 | ||
|
|
693d408c4a | ||
|
|
1feee48284 |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 115 KiB |
@@ -1346,3 +1346,466 @@ audio.comfy-audio.empty-audio-widget {
|
||||
border-radius: 0;
|
||||
}
|
||||
/* END LOD specific styles */
|
||||
|
||||
/* ===================== Mask Editor Styles ===================== */
|
||||
/* To be migrated to Tailwind later */
|
||||
#maskEditor_brush {
|
||||
position: absolute;
|
||||
backgroundColor: transparent;
|
||||
z-index: 8889;
|
||||
pointer-events: none;
|
||||
border-radius: 50%;
|
||||
overflow: visible;
|
||||
outline: 1px dashed black;
|
||||
box-shadow: 0 0 0 1px white;
|
||||
}
|
||||
#maskEditor_brushPreviewGradient {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
display: none;
|
||||
}
|
||||
.maskEditor_sidePanelTitle {
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
font-family: sans-serif;
|
||||
color: var(--descrip-text);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.maskEditor_sidePanelBrushShapeCircle {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-color);
|
||||
pointer-events: auto;
|
||||
transition: background 0.1s;
|
||||
margin-left: 7.5px;
|
||||
}
|
||||
.maskEditor_sidePanelBrushRange {
|
||||
width: 180px;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.maskEditor_sidePanelBrushRange::-webkit-slider-thumb {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
margin-top: -8px;
|
||||
background: var(--p-surface-700);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.maskEditor_sidePanelBrushRange::-moz-range-thumb {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
background: var(--p-surface-800);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.maskEditor_sidePanelBrushRange::-webkit-slider-runnable-track {
|
||||
background: var(--p-surface-700);
|
||||
height: 3px;
|
||||
}
|
||||
.maskEditor_sidePanelBrushRange::-moz-range-track {
|
||||
background: var(--p-surface-700);
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelBrushShapeSquare {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
margin: 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
pointer-events: auto;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.maskEditor_brushShape_dark {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.maskEditor_brushShape_dark:hover {
|
||||
background: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.maskEditor_brushShape_light {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.maskEditor_brushShape_light:hover {
|
||||
background: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelLayer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
}
|
||||
.maskEditor_sidePanelLayerVisibilityContainer {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.maskEditor_sidePanelVisibilityToggle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.maskEditor_sidePanelLayerIconContainer {
|
||||
width: 60px;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
fill: var(--input-text);
|
||||
}
|
||||
.maskEditor_sidePanelLayerIconContainer svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
.maskEditor_sidePanelBigButton {
|
||||
width: 85px;
|
||||
height: 30px;
|
||||
background: rgb(0 0 0 / 0.2);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--input-text);
|
||||
font-family: sans-serif;
|
||||
font-size: 15px;
|
||||
pointer-events: auto;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
.maskEditor_sidePanelBigButton:hover {
|
||||
background-color: var(--p-overlaybadge-outline-color);
|
||||
border: none;
|
||||
}
|
||||
.maskEditor_toolPanelContainer {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.maskEditor_toolPanelContainerSelected svg {
|
||||
fill: var(--p-button-text-primary-color) !important;
|
||||
}
|
||||
.maskEditor_toolPanelContainerSelected .maskEditor_toolPanelIndicator {
|
||||
display: block;
|
||||
}
|
||||
.maskEditor_toolPanelContainer svg {
|
||||
width: 75%;
|
||||
aspect-ratio: 1/1;
|
||||
fill: var(--p-button-text-secondary-color);
|
||||
}
|
||||
|
||||
.maskEditor_toolPanelContainerDark:hover {
|
||||
background-color: var(--p-surface-800);
|
||||
}
|
||||
|
||||
.maskEditor_toolPanelContainerLight:hover {
|
||||
background-color: var(--p-surface-300);
|
||||
}
|
||||
|
||||
.maskEditor_toolPanelIndicator {
|
||||
display: none;
|
||||
height: 100%;
|
||||
width: 4px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
background: var(--p-button-text-primary-color);
|
||||
}
|
||||
.maskEditor_sidePanelSeparator {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--border-color);
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
#maskEditorCanvasContainer {
|
||||
position: absolute;
|
||||
width: 1000px;
|
||||
height: 667px;
|
||||
left: 359px;
|
||||
top: 280px;
|
||||
}
|
||||
|
||||
.maskEditor_topPanelIconButton_dark {
|
||||
width: 50px;
|
||||
height: 30px;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: background-color 0.1s;
|
||||
background: var(--p-surface-800);
|
||||
border: 1px solid var(--p-form-field-border-color);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.maskEditor_topPanelIconButton_dark:hover {
|
||||
background-color: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.maskEditor_topPanelIconButton_dark svg {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
pointer-events: none;
|
||||
fill: var(--input-text);
|
||||
}
|
||||
|
||||
.maskEditor_topPanelIconButton_light {
|
||||
width: 50px;
|
||||
height: 30px;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: background-color 0.1s;
|
||||
background: var(--comfy-menu-bg);
|
||||
border: 1px solid var(--p-form-field-border-color);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.maskEditor_topPanelIconButton_light:hover {
|
||||
background-color: var(--p-surface-300);
|
||||
}
|
||||
|
||||
.maskEditor_topPanelIconButton_light svg {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
pointer-events: none;
|
||||
fill: var(--input-text);
|
||||
}
|
||||
|
||||
.maskEditor_topPanelButton_dark {
|
||||
height: 30px;
|
||||
background: var(--p-surface-800);
|
||||
border: 1px solid var(--p-form-field-border-color);
|
||||
border-radius: 10px;
|
||||
color: var(--input-text);
|
||||
font-family: sans-serif;
|
||||
pointer-events: auto;
|
||||
transition: 0.1s;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.maskEditor_topPanelButton_dark:hover {
|
||||
background-color: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.maskEditor_topPanelButton_light {
|
||||
height: 30px;
|
||||
background: var(--comfy-menu-bg);
|
||||
border: 1px solid var(--p-form-field-border-color);
|
||||
border-radius: 10px;
|
||||
color: var(--input-text);
|
||||
font-family: sans-serif;
|
||||
pointer-events: auto;
|
||||
transition: 0.1s;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.maskEditor_topPanelButton_light:hover {
|
||||
background-color: var(--p-surface-300);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanel_paintBucket_Container {
|
||||
width: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanel_colorSelect_Container {
|
||||
display: flex;
|
||||
width: 180px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanel_colorSelect_tolerance_container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelContainerColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelContainerRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.maskEditor_accent_bg_dark {
|
||||
background: var(--p-surface-800);
|
||||
}
|
||||
|
||||
.maskEditor_accent_bg_very_dark {
|
||||
background: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.maskEditor_accent_bg_light {
|
||||
background: var(--p-surface-300);
|
||||
}
|
||||
|
||||
.maskEditor_accent_bg_very_light {
|
||||
background: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
|
||||
.maskEditor_sidePanelToggleContainer {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelToggleSwitch {
|
||||
display: inline-block;
|
||||
border-radius: 16px;
|
||||
width: 40px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
transition: background 0.25s;
|
||||
background: var(--p-surface-300);
|
||||
}
|
||||
|
||||
.dark-theme .maskEditor_sidePanelToggleSwitch {
|
||||
background: var(--p-surface-700);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelToggleSwitch::before, .maskEditor_sidePanelToggleSwitch::after {
|
||||
content: "";
|
||||
}
|
||||
.maskEditor_sidePanelToggleSwitch::before {
|
||||
display: block;
|
||||
background: linear-gradient(to bottom, #fff 0%, #eee 100%);
|
||||
border-radius: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
transition: ease 0.2s;
|
||||
}
|
||||
.maskEditor_sidePanelToggleContainer:hover .maskEditor_sidePanelToggleSwitch::before {
|
||||
background: linear-gradient(to bottom, #fff 0%, #fff 100%);
|
||||
}
|
||||
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch {
|
||||
background: var(--p-button-text-primary-color);
|
||||
}
|
||||
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch::before {
|
||||
background: var(--comfy-menu-bg);
|
||||
left: 20px;
|
||||
}
|
||||
.dark-theme .maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch::before {
|
||||
background: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelToggleCheckbox {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelDropdown {
|
||||
border: 1px solid var(--p-form-field-border-color);
|
||||
background: var(--comfy-menu-bg);
|
||||
height: 24px;
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelDropdown option {
|
||||
background: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelDropdown:focus {
|
||||
outline: 1px solid var(--p-surface-300);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelDropdown option:hover {
|
||||
background: white;
|
||||
}
|
||||
.maskEditor_sidePanelDropdown option:active {
|
||||
background: var(--p-surface-300);
|
||||
}
|
||||
|
||||
.dark-theme .maskEditor_sidePanelDropdown {
|
||||
background: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.dark-theme .maskEditor_sidePanelDropdown option {
|
||||
background: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.dark-theme .maskEditor_sidePanelDropdown:focus {
|
||||
outline: 1px solid var(--p-button-text-primary-color);
|
||||
}
|
||||
|
||||
.dark-theme .maskEditor_sidePanelDropdown option:active {
|
||||
background: var(--p-highlight-background);
|
||||
}
|
||||
|
||||
.maskEditor_layerRow {
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelLayerPreviewContainer {
|
||||
width: 40px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelLayerPreviewContainer > svg{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
fill: var(--p-surface-100);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelImageLayerImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelSubTitle {
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-family: sans-serif;
|
||||
color: var(--descrip-text);
|
||||
}
|
||||
|
||||
.maskEditor_containerDropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelLayerCheckbox {
|
||||
margin-left: 15px;
|
||||
}
|
||||
/* ===================== End of Mask Editor Styles ===================== */
|
||||
@@ -32,7 +32,7 @@ defineOptions({
|
||||
interface IconTextButtonProps extends BaseButtonProps {
|
||||
iconPosition?: 'left' | 'right'
|
||||
label: string
|
||||
onClick: () => void
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const {
|
||||
|
||||
@@ -35,7 +35,6 @@ import { ValidationState } from '@/utils/validationUtil'
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
validateUrlFn?: (url: string) => Promise<boolean>
|
||||
disableValidation?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -102,8 +101,6 @@ const defaultValidateUrl = async (url: string): Promise<boolean> => {
|
||||
}
|
||||
|
||||
const validateUrl = async (value: string) => {
|
||||
if (props.disableValidation) return
|
||||
|
||||
if (validationState.value === ValidationState.LOADING) return
|
||||
|
||||
const url = cleanInput(value)
|
||||
|
||||
@@ -29,7 +29,10 @@
|
||||
import Button from 'primevue/button'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
|
||||
const { apiNodeNames, onLogin, onCancel } = defineProps<{
|
||||
apiNodeNames: string[]
|
||||
@@ -38,6 +41,9 @@ const { apiNodeNames, onLogin, onCancel } = defineProps<{
|
||||
}>()
|
||||
|
||||
const handleLearnMoreClick = () => {
|
||||
window.open('https://docs.comfy.org/tutorials/api-nodes/faq', '_blank')
|
||||
window.open(
|
||||
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between gap-2 py-2 px-4">
|
||||
<IconTextButton
|
||||
:label="$t('cloud.missingNodes.learnMore')"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
icon-position="left"
|
||||
@click="handleLearnMoreClick"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--info]"></i>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<TextButton
|
||||
:label="$t('cloud.missingNodes.gotIt')"
|
||||
type="secondary"
|
||||
size="md"
|
||||
@click="handleGotItClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const handleLearnMoreClick = () => {
|
||||
window.open('https://www.comfy.org/cloud', '_blank')
|
||||
}
|
||||
|
||||
const handleGotItClick = () => {
|
||||
dialogStore.closeDialog({ key: 'global-cloud-missing-nodes' })
|
||||
}
|
||||
</script>
|
||||
@@ -49,14 +49,14 @@ import { computed } from 'vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
const props = defineProps<{
|
||||
const { missingCoreNodes } = defineProps<{
|
||||
missingCoreNodes: Record<string, LGraphNode[]>
|
||||
}>()
|
||||
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
|
||||
const hasMissingCoreNodes = computed(() => {
|
||||
return Object.keys(props.missingCoreNodes).length > 0
|
||||
return Object.keys(missingCoreNodes).length > 0
|
||||
})
|
||||
|
||||
// Use computed for reactive version tracking
|
||||
@@ -66,7 +66,7 @@ const currentComfyUIVersion = computed<string | null>(() => {
|
||||
})
|
||||
|
||||
const sortedMissingCoreNodes = computed(() => {
|
||||
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {
|
||||
return Object.entries(missingCoreNodes).sort(([a], [b]) => {
|
||||
// Sort by version in descending order (newest first)
|
||||
return compare(b, a) // Reversed for descending order
|
||||
})
|
||||
|
||||
@@ -6,15 +6,18 @@
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<p class="m-0 text-sm leading-4 text-muted-foreground">
|
||||
{{ $t('cloud.missingNodes.description') }}
|
||||
<br /><br />
|
||||
{{ $t('cloud.missingNodes.priorityMessage') }}
|
||||
{{
|
||||
isCloud
|
||||
? $t('missingNodes.cloud.description')
|
||||
: $t('missingNodes.oss.description')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<MissingCoreNodesMessage v-if="!isCloud" :missing-core-nodes />
|
||||
|
||||
<!-- Missing Nodes List Wrapper -->
|
||||
<div
|
||||
class="flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-secondary-background"
|
||||
class="comfy-missing-nodes flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-secondary-background"
|
||||
>
|
||||
<div
|
||||
v-for="(node, i) in uniqueNodes"
|
||||
@@ -24,13 +27,18 @@
|
||||
<span class="text-xs">
|
||||
{{ node.label }}
|
||||
</span>
|
||||
<span v-if="node.hint" class="text-xs">{{ node.hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom instruction -->
|
||||
<div>
|
||||
<p class="m-0 text-sm leading-4 text-muted-foreground">
|
||||
{{ $t('cloud.missingNodes.replacementInstruction') }}
|
||||
{{
|
||||
isCloud
|
||||
? $t('missingNodes.cloud.replacementInstruction')
|
||||
: $t('missingNodes.oss.replacementInstruction')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,12 +48,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
|
||||
const props = defineProps<{
|
||||
missingNodeTypes: MissingNodeType[]
|
||||
}>()
|
||||
|
||||
// Get missing core nodes for OSS mode
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
|
||||
const uniqueNodes = computed(() => {
|
||||
const seenTypes = new Set()
|
||||
return props.missingNodeTypes
|
||||
@@ -1,39 +1,42 @@
|
||||
<template>
|
||||
<NoResultsPlaceholder
|
||||
class="pb-0"
|
||||
icon="pi pi-exclamation-circle"
|
||||
:title="$t('loadWorkflowWarning.missingNodesTitle')"
|
||||
:message="$t('loadWorkflowWarning.missingNodesDescription')"
|
||||
/>
|
||||
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
|
||||
<ListBox
|
||||
:options="uniqueNodes"
|
||||
option-label="label"
|
||||
scroll-height="100%"
|
||||
class="comfy-missing-nodes"
|
||||
:pt="{
|
||||
list: { class: 'border-none' }
|
||||
}"
|
||||
<!-- Cloud mode: Learn More + Got It buttons -->
|
||||
<div
|
||||
v-if="isCloud"
|
||||
class="flex w-full items-center justify-between gap-2 py-2 px-4"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="align-items-center flex">
|
||||
<span class="node-type">{{ slotProps.option.label }}</span>
|
||||
<span v-if="slotProps.option.hint" class="node-hint">{{
|
||||
slotProps.option.hint
|
||||
}}</span>
|
||||
<Button
|
||||
v-if="slotProps.option.action"
|
||||
:label="slotProps.option.action.text"
|
||||
size="small"
|
||||
outlined
|
||||
@click="slotProps.option.action.callback"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ListBox>
|
||||
<div v-if="showManagerButtons" class="flex justify-end py-3">
|
||||
<IconTextButton
|
||||
:label="$t('missingNodes.cloud.learnMore')"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
icon-position="left"
|
||||
as="a"
|
||||
href="https://www.comfy.org/cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--info]"></i>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<TextButton
|
||||
:label="$t('missingNodes.cloud.gotIt')"
|
||||
type="secondary"
|
||||
size="md"
|
||||
@click="handleGotItClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- OSS mode: Open Manager + Install All buttons -->
|
||||
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
|
||||
<TextButton
|
||||
:label="$t('g.openManager')"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
@click="openManager"
|
||||
/>
|
||||
<PackInstallButton
|
||||
v-if="showInstallAllButton"
|
||||
type="secondary"
|
||||
size="md"
|
||||
:disabled="
|
||||
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
|
||||
@@ -46,40 +49,32 @@
|
||||
: $t('manager.installAllMissingNodes')
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('g.openManager')"
|
||||
size="small"
|
||||
outlined
|
||||
@click="openManager"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import ListBox from 'primevue/listbox'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
missingNodeTypes: MissingNodeType[]
|
||||
}>()
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Get missing node packs from workflow with loading and error states
|
||||
const { missingNodePacks, isLoading, error, missingCoreNodes } =
|
||||
useMissingNodes()
|
||||
const handleGotItClick = () => {
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
}
|
||||
|
||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const managerState = useManagerState()
|
||||
|
||||
@@ -91,27 +86,6 @@ const isInstalling = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const uniqueNodes = computed(() => {
|
||||
const seenTypes = new Set()
|
||||
return props.missingNodeTypes
|
||||
.filter((node) => {
|
||||
const type = typeof node === 'object' ? node.type : node
|
||||
if (seenTypes.has(type)) return false
|
||||
seenTypes.add(type)
|
||||
return true
|
||||
})
|
||||
.map((node) => {
|
||||
if (typeof node === 'object') {
|
||||
return {
|
||||
label: node.type,
|
||||
hint: node.hint,
|
||||
action: node.action
|
||||
}
|
||||
}
|
||||
return { label: node }
|
||||
})
|
||||
})
|
||||
|
||||
// Show manager buttons unless manager is disabled
|
||||
const showManagerButtons = computed(() => {
|
||||
return managerState.shouldShowManagerButtons.value
|
||||
@@ -129,9 +103,6 @@ const openManager = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
// Computed to check if all missing nodes have been installed
|
||||
const allMissingNodesInstalled = computed(() => {
|
||||
return (
|
||||
@@ -140,13 +111,14 @@ const allMissingNodesInstalled = computed(() => {
|
||||
missingNodePacks.value?.length === 0
|
||||
)
|
||||
})
|
||||
// Watch for completion and close dialog
|
||||
|
||||
// Watch for completion and close dialog (OSS mode only)
|
||||
watch(allMissingNodesInstalled, async (allInstalled) => {
|
||||
if (allInstalled && showInstallAllButton.value) {
|
||||
if (!isCloud && allInstalled && showInstallAllButton.value) {
|
||||
// Use nextTick to ensure state updates are complete
|
||||
await nextTick()
|
||||
|
||||
dialogStore.closeDialog({ key: 'global-load-workflow-warning' })
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
|
||||
// Show success toast
|
||||
useToastStore().add({
|
||||
@@ -158,20 +130,3 @@ watch(allMissingNodesInstalled, async (allInstalled) => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comfy-missing-nodes {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.node-hint {
|
||||
margin-left: 0.5rem;
|
||||
font-style: italic;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
:deep(.p-button) {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -3,8 +3,16 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--triangle-alert] text-gold-600"></i>
|
||||
<p class="m-0 text-sm">
|
||||
{{ $t('cloud.missingNodes.title') }}
|
||||
{{
|
||||
isCloud
|
||||
? $t('missingNodes.cloud.title')
|
||||
: $t('missingNodes.oss.title')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
</script>
|
||||
@@ -123,6 +123,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -137,6 +138,7 @@ interface CreditHistoryItemData {
|
||||
isPositive: boolean
|
||||
}
|
||||
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
const dialogService = useDialogService()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
@@ -183,12 +185,17 @@ const handleMessageSupport = async () => {
|
||||
}
|
||||
|
||||
const handleFaqClick = () => {
|
||||
window.open('https://docs.comfy.org/tutorials/api-nodes/faq', '_blank')
|
||||
window.open(
|
||||
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
|
||||
buildDocsUrl('/tutorials/api-nodes/overview#api-nodes', {
|
||||
includeLocale: true
|
||||
}),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ import type { CSSProperties, Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -168,15 +169,6 @@ interface MenuItem {
|
||||
}
|
||||
|
||||
// Constants
|
||||
const EXTERNAL_LINKS = {
|
||||
DOCS: 'https://docs.comfy.org/',
|
||||
DISCORD: 'https://www.comfy.org/discord',
|
||||
GITHUB: 'https://github.com/comfyanonymous/ComfyUI',
|
||||
DESKTOP_GUIDE_WINDOWS: 'https://docs.comfy.org/installation/desktop/windows',
|
||||
DESKTOP_GUIDE_MACOS: 'https://docs.comfy.org/installation/desktop/macos',
|
||||
UPDATE_GUIDE: 'https://docs.comfy.org/installation/update_comfyui'
|
||||
} as const
|
||||
|
||||
const TIME_UNITS = {
|
||||
MINUTE: 60 * 1000,
|
||||
HOUR: 60 * 60 * 1000,
|
||||
@@ -193,7 +185,8 @@ const SUBMENU_CONFIG = {
|
||||
} as const
|
||||
|
||||
// Composables
|
||||
const { t, locale } = useI18n()
|
||||
const { t } = useI18n()
|
||||
const { staticUrls, buildDocsUrl } = useExternalLink()
|
||||
const releaseStore = useReleaseStore()
|
||||
const commandStore = useCommandStore()
|
||||
const settingStore = useSettingStore()
|
||||
@@ -230,11 +223,12 @@ const moreItems = computed<MenuItem[]>(() => {
|
||||
visible: isElectron(),
|
||||
action: () => {
|
||||
trackResourceClick('docs', true)
|
||||
const docsUrl =
|
||||
electronAPI().getPlatform() === 'darwin'
|
||||
? EXTERNAL_LINKS.DESKTOP_GUIDE_MACOS
|
||||
: EXTERNAL_LINKS.DESKTOP_GUIDE_WINDOWS
|
||||
openExternalLink(docsUrl)
|
||||
openExternalLink(
|
||||
buildDocsUrl('/installation/desktop', {
|
||||
includeLocale: true,
|
||||
platform: true
|
||||
})
|
||||
)
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
@@ -286,7 +280,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
label: t('helpCenter.docs'),
|
||||
action: () => {
|
||||
trackResourceClick('docs', true)
|
||||
openExternalLink(EXTERNAL_LINKS.DOCS)
|
||||
openExternalLink(buildDocsUrl('/', { includeLocale: true }))
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
@@ -297,7 +291,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
label: 'Discord',
|
||||
action: () => {
|
||||
trackResourceClick('discord', true)
|
||||
openExternalLink(EXTERNAL_LINKS.DISCORD)
|
||||
openExternalLink(staticUrls.discord)
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
@@ -308,7 +302,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
label: t('helpCenter.github'),
|
||||
action: () => {
|
||||
trackResourceClick('github', true)
|
||||
openExternalLink(EXTERNAL_LINKS.GITHUB)
|
||||
openExternalLink(staticUrls.github)
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
@@ -533,25 +527,19 @@ const onReleaseClick = (release: ReleaseNote): void => {
|
||||
trackResourceClick('release_notes', true)
|
||||
void releaseStore.handleShowChangelog(release.version)
|
||||
const versionAnchor = formatVersionAnchor(release.version)
|
||||
const changelogUrl = `${getChangelogUrl()}#${versionAnchor}`
|
||||
const changelogUrl = `${buildDocsUrl('/changelog', { includeLocale: true })}#${versionAnchor}`
|
||||
openExternalLink(changelogUrl)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const onUpdate = (_: ReleaseNote): void => {
|
||||
trackResourceClick('docs', true)
|
||||
openExternalLink(EXTERNAL_LINKS.UPDATE_GUIDE)
|
||||
openExternalLink(
|
||||
buildDocsUrl('/installation/update_comfyui', { includeLocale: true })
|
||||
)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Generate language-aware changelog URL
|
||||
const getChangelogUrl = (): string => {
|
||||
const isChineseLocale = locale.value === 'zh'
|
||||
return isChineseLocale
|
||||
? 'https://docs.comfy.org/zh-CN/changelog'
|
||||
: 'https://docs.comfy.org/changelog'
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
telemetry?.trackHelpCenterOpened({ source: 'sidebar' })
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
v-model:background-render-mode="viewer.backgroundRenderMode.value"
|
||||
v-model:fov="viewer.fov.value"
|
||||
:has-background-image="viewer.hasBackgroundImage.value"
|
||||
:disable-background-upload="viewer.isStandaloneMode.value"
|
||||
@update-background-image="viewer.handleBackgroundImageUpdate"
|
||||
/>
|
||||
</div>
|
||||
@@ -91,13 +92,15 @@ import LightControls from '@/components/load3d/controls/viewer/ViewerLightContro
|
||||
import ModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue'
|
||||
import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue'
|
||||
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
|
||||
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const props = defineProps<{
|
||||
node: LGraphNode
|
||||
node?: LGraphNode
|
||||
modelUrl?: string
|
||||
}>()
|
||||
|
||||
const viewerContentRef = ref<HTMLDivElement>()
|
||||
@@ -106,20 +109,30 @@ const mainContentRef = ref<HTMLDivElement>()
|
||||
const maximized = ref(false)
|
||||
const mutationObserver = ref<MutationObserver | null>(null)
|
||||
|
||||
const viewer = useLoad3dService().getOrCreateViewer(toRaw(props.node))
|
||||
const isStandaloneMode = !props.node && props.modelUrl
|
||||
|
||||
const viewer = props.node
|
||||
? useLoad3dService().getOrCreateViewer(toRaw(props.node))
|
||||
: useLoad3dViewer()
|
||||
|
||||
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
|
||||
useLoad3dDrag({
|
||||
onModelDrop: async (file) => {
|
||||
await viewer.handleModelDrop(file)
|
||||
},
|
||||
disabled: viewer.isPreview
|
||||
disabled: viewer.isPreview.value || isStandaloneMode
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const source = useLoad3dService().getLoad3d(props.node)
|
||||
if (source && containerRef.value) {
|
||||
await viewer.initializeViewer(containerRef.value, source)
|
||||
if (!containerRef.value) return
|
||||
|
||||
if (isStandaloneMode && props.modelUrl) {
|
||||
await viewer.initializeStandaloneViewer(containerRef.value, props.modelUrl)
|
||||
} else if (props.node) {
|
||||
const source = useLoad3dService().getLoad3d(props.node)
|
||||
if (source) {
|
||||
await viewer.initializeViewer(containerRef.value, source)
|
||||
}
|
||||
}
|
||||
|
||||
if (viewerContentRef.value) {
|
||||
@@ -150,7 +163,9 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
const handleCancel = () => {
|
||||
viewer.restoreInitialState()
|
||||
if (!isStandaloneMode) {
|
||||
viewer.restoreInitialState()
|
||||
}
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<div v-if="!hasBackgroundImage && !disableBackgroundUpload">
|
||||
<Button
|
||||
severity="secondary"
|
||||
:label="$t('load3d.uploadBackgroundImage')"
|
||||
@@ -74,6 +74,7 @@ const backgroundRenderMode = defineModel<'tiled' | 'panorama'>(
|
||||
|
||||
defineProps<{
|
||||
hasBackgroundImage?: boolean
|
||||
disableBackgroundUpload?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
97
src/components/maskeditor/BrushCursor.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div
|
||||
id="maskEditor_brush"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
opacity: brushOpacity,
|
||||
width: `${brushSize}px`,
|
||||
height: `${brushSize}px`,
|
||||
left: `${brushLeft}px`,
|
||||
top: `${brushTop}px`,
|
||||
borderRadius: borderRadius,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1000
|
||||
}"
|
||||
>
|
||||
<div
|
||||
id="maskEditor_brushPreviewGradient"
|
||||
:style="{
|
||||
display: gradientVisible ? 'block' : 'none',
|
||||
background: gradientBackground
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { BrushShape } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const { containerRef } = defineProps<{
|
||||
containerRef?: HTMLElement
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const brushOpacity = computed(() => {
|
||||
return store.brushVisible ? '1' : '0'
|
||||
})
|
||||
|
||||
const brushRadius = computed(() => {
|
||||
return store.brushSettings.size * store.zoomRatio
|
||||
})
|
||||
|
||||
const brushSize = computed(() => {
|
||||
return brushRadius.value * 2
|
||||
})
|
||||
|
||||
const brushLeft = computed(() => {
|
||||
const dialogRect = containerRef?.getBoundingClientRect()
|
||||
const dialogOffsetLeft = dialogRect?.left || 0
|
||||
return (
|
||||
store.cursorPoint.x +
|
||||
store.panOffset.x -
|
||||
brushRadius.value -
|
||||
dialogOffsetLeft
|
||||
)
|
||||
})
|
||||
|
||||
const brushTop = computed(() => {
|
||||
const dialogRect = containerRef?.getBoundingClientRect()
|
||||
const dialogOffsetTop = dialogRect?.top || 0
|
||||
return (
|
||||
store.cursorPoint.y +
|
||||
store.panOffset.y -
|
||||
brushRadius.value -
|
||||
dialogOffsetTop
|
||||
)
|
||||
})
|
||||
|
||||
const borderRadius = computed(() => {
|
||||
return store.brushSettings.type === BrushShape.Rect ? '0%' : '50%'
|
||||
})
|
||||
|
||||
const gradientVisible = computed(() => {
|
||||
return store.brushPreviewGradientVisible
|
||||
})
|
||||
|
||||
const gradientBackground = computed(() => {
|
||||
const hardness = store.brushSettings.hardness
|
||||
|
||||
if (hardness === 1) {
|
||||
return 'rgba(255, 0, 0, 0.5)'
|
||||
}
|
||||
|
||||
const midStop = hardness * 100
|
||||
const outerStop = 100
|
||||
|
||||
return `radial-gradient(
|
||||
circle,
|
||||
rgba(255, 0, 0, 0.5) 0%,
|
||||
rgba(255, 0, 0, 0.25) ${midStop}%,
|
||||
rgba(255, 0, 0, 0) ${outerStop}%
|
||||
)`
|
||||
})
|
||||
</script>
|
||||
129
src/components/maskeditor/BrushSettingsPanel.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<h3
|
||||
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
|
||||
>
|
||||
{{ t('maskEditor.brushSettings') }}
|
||||
</h3>
|
||||
|
||||
<button
|
||||
class="w-45 h-7.5 border-none bg-black/20 border border-[var(--border-color)] text-[var(--input-text)] font-sans text-[15px] pointer-events-auto transition-colors duration-100 hover:bg-[var(--p-overlaybadge-outline-color)] hover:border-none"
|
||||
@click="resetToDefault"
|
||||
>
|
||||
{{ t('maskEditor.resetToDefault') }}
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.brushShape')
|
||||
}}</span>
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
|
||||
>
|
||||
<div
|
||||
class="maskEditor_sidePanelBrushShapeCircle bg-transparent hover:bg-[var(--comfy-menu-bg)] dark-theme:hover:bg-[var(--p-surface-900)]"
|
||||
:class="{ active: store.brushSettings.type === BrushShape.Arc }"
|
||||
:style="{
|
||||
background:
|
||||
store.brushSettings.type === BrushShape.Arc
|
||||
? 'var(--p-button-text-primary-color)'
|
||||
: ''
|
||||
}"
|
||||
@click="setBrushShape(BrushShape.Arc)"
|
||||
></div>
|
||||
<div
|
||||
class="maskEditor_sidePanelBrushShapeSquare bg-transparent hover:bg-[var(--comfy-menu-bg)] dark-theme:hover:bg-[var(--p-surface-900)]"
|
||||
:class="{ active: store.brushSettings.type === BrushShape.Rect }"
|
||||
:style="{
|
||||
background:
|
||||
store.brushSettings.type === BrushShape.Rect
|
||||
? 'var(--p-button-text-primary-color)'
|
||||
: ''
|
||||
}"
|
||||
@click="setBrushShape(BrushShape.Rect)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.colorSelector')
|
||||
}}</span>
|
||||
<input type="color" :value="store.rgbColor" @input="onColorChange" />
|
||||
</div>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.thickness')"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:model-value="store.brushSettings.size"
|
||||
@update:model-value="onThicknessChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.opacity')"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:model-value="store.brushSettings.opacity"
|
||||
@update:model-value="onOpacityChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.hardness')"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:model-value="store.brushSettings.hardness"
|
||||
@update:model-value="onHardnessChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.smoothingPrecision')"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:model-value="store.brushSettings.smoothingPrecision"
|
||||
@update:model-value="onSmoothingPrecisionChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BrushShape } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const setBrushShape = (shape: BrushShape) => {
|
||||
store.brushSettings.type = shape
|
||||
}
|
||||
|
||||
const onColorChange = (event: Event) => {
|
||||
store.rgbColor = (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
const onThicknessChange = (value: number) => {
|
||||
store.setBrushSize(value)
|
||||
}
|
||||
|
||||
const onOpacityChange = (value: number) => {
|
||||
store.setBrushOpacity(value)
|
||||
}
|
||||
|
||||
const onHardnessChange = (value: number) => {
|
||||
store.setBrushHardness(value)
|
||||
}
|
||||
|
||||
const onSmoothingPrecisionChange = (value: number) => {
|
||||
store.setBrushSmoothingPrecision(value)
|
||||
}
|
||||
|
||||
const resetToDefault = () => {
|
||||
store.resetBrushToDefault()
|
||||
}
|
||||
</script>
|
||||
103
src/components/maskeditor/ColorSelectSettingsPanel.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<h3
|
||||
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
|
||||
>
|
||||
{{ t('maskEditor.colorSelectSettings') }}
|
||||
</h3>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.tolerance')"
|
||||
:min="0"
|
||||
:max="255"
|
||||
:step="1"
|
||||
:model-value="store.colorSelectTolerance"
|
||||
@update:model-value="onToleranceChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.selectionOpacity')"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:model-value="store.selectionOpacity"
|
||||
@update:model-value="onSelectionOpacityChange"
|
||||
/>
|
||||
|
||||
<ToggleControl
|
||||
:label="t('maskEditor.livePreview')"
|
||||
:model-value="store.colorSelectLivePreview"
|
||||
@update:model-value="onLivePreviewChange"
|
||||
/>
|
||||
|
||||
<ToggleControl
|
||||
:label="t('maskEditor.applyToWholeImage')"
|
||||
:model-value="store.applyWholeImage"
|
||||
@update:model-value="onWholeImageChange"
|
||||
/>
|
||||
|
||||
<DropdownControl
|
||||
:label="t('maskEditor.method')"
|
||||
:options="methodOptions"
|
||||
:model-value="store.colorComparisonMethod"
|
||||
@update:model-value="onMethodChange"
|
||||
/>
|
||||
|
||||
<ToggleControl
|
||||
:label="t('maskEditor.stopAtMask')"
|
||||
:model-value="store.maskBoundary"
|
||||
@update:model-value="onMaskBoundaryChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.maskTolerance')"
|
||||
:min="0"
|
||||
:max="255"
|
||||
:step="1"
|
||||
:model-value="store.maskTolerance"
|
||||
@update:model-value="onMaskToleranceChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import DropdownControl from './controls/DropdownControl.vue'
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
import ToggleControl from './controls/ToggleControl.vue'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const methodOptions = Object.values(ColorComparisonMethod)
|
||||
|
||||
const onToleranceChange = (value: number) => {
|
||||
store.setColorSelectTolerance(value)
|
||||
}
|
||||
|
||||
const onSelectionOpacityChange = (value: number) => {
|
||||
store.setSelectionOpacity(value)
|
||||
}
|
||||
|
||||
const onLivePreviewChange = (value: boolean) => {
|
||||
store.colorSelectLivePreview = value
|
||||
}
|
||||
|
||||
const onWholeImageChange = (value: boolean) => {
|
||||
store.applyWholeImage = value
|
||||
}
|
||||
|
||||
const onMethodChange = (value: string | number) => {
|
||||
store.colorComparisonMethod = value as ColorComparisonMethod
|
||||
}
|
||||
|
||||
const onMaskBoundaryChange = (value: boolean) => {
|
||||
store.maskBoundary = value
|
||||
}
|
||||
|
||||
const onMaskToleranceChange = (value: number) => {
|
||||
store.setMaskTolerance(value)
|
||||
}
|
||||
</script>
|
||||
227
src/components/maskeditor/ImageLayerSettingsPanel.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<h3
|
||||
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
|
||||
>
|
||||
{{ t('maskEditor.layers') }}
|
||||
</h3>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.maskOpacity')"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:model-value="store.maskOpacity"
|
||||
@update:model-value="onMaskOpacityChange"
|
||||
/>
|
||||
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.maskBlendingOptions')
|
||||
}}</span>
|
||||
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] -mt-2 -mb-1.5"
|
||||
>
|
||||
<select
|
||||
class="maskEditor_sidePanelDropdown"
|
||||
:value="store.maskBlendMode"
|
||||
@change="onBlendModeChange"
|
||||
>
|
||||
<option value="black">{{ t('maskEditor.black') }}</option>
|
||||
<option value="white">{{ t('maskEditor.white') }}</option>
|
||||
<option value="negative">{{ t('maskEditor.negative') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.maskLayer')
|
||||
}}</span>
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
|
||||
:style="{
|
||||
border: store.activeLayer === 'mask' ? '2px solid #007acc' : 'none'
|
||||
}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="maskEditor_sidePanelLayerCheckbox"
|
||||
:checked="maskLayerVisible"
|
||||
@change="onMaskLayerVisibilityChange"
|
||||
/>
|
||||
<div class="maskEditor_sidePanelLayerPreviewContainer">
|
||||
<svg viewBox="0 0 20 20" style="">
|
||||
<path
|
||||
class="cls-1"
|
||||
d="M1.31,5.32v9.36c0,.55.45,1,1,1h15.38c.55,0,1-.45,1-1V5.32c0-.55-.45-1-1-1H2.31c-.55,0-1,.45-1,1ZM11.19,13.44c-2.91.94-5.57-1.72-4.63-4.63.34-1.05,1.19-1.9,2.24-2.24,2.91-.94,5.57,1.72,4.63,4.63-.34,1.05-1.19-1.9-2.24,2.24Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
style="font-size: 12px"
|
||||
:style="{ opacity: store.activeLayer === 'mask' ? '0.5' : '1' }"
|
||||
:disabled="store.activeLayer === 'mask'"
|
||||
@click="setActiveLayer('mask')"
|
||||
>
|
||||
{{ t('maskEditor.activateLayer') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.paintLayer')
|
||||
}}</span>
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
|
||||
:style="{
|
||||
border: store.activeLayer === 'rgb' ? '2px solid #007acc' : 'none'
|
||||
}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="maskEditor_sidePanelLayerCheckbox"
|
||||
:checked="paintLayerVisible"
|
||||
@change="onPaintLayerVisibilityChange"
|
||||
/>
|
||||
<div class="maskEditor_sidePanelLayerPreviewContainer">
|
||||
<svg viewBox="0 0 20 20">
|
||||
<path
|
||||
class="cls-1"
|
||||
d="M 17 6.965 c 0 0.235 -0.095 0.47 -0.275 0.655 l -6.51 6.52 c -0.045 0.035 -0.09 0.075 -0.135 0.11 c -0.035 -0.695 -0.605 -1.24 -1.305 -1.245 c 0.035 -0.06 0.08 -0.12 0.135 -0.17 l 6.52 -6.52 c 0.36 -0.36 0.945 -0.36 1.3 0 c 0.175 0.175 0.275 0.415 0.275 0.65 Z"
|
||||
/>
|
||||
<path
|
||||
class="cls-1"
|
||||
d="M 9.82 14.515 c 0 2.23 -3.23 1.59 -4.82 0 c 1.65 -0.235 2.375 -1.29 3.53 -1.29 c 0.715 0 1.29 0.58 1.29 1.29 Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
style="font-size: 12px"
|
||||
:style="{
|
||||
opacity: store.activeLayer === 'rgb' ? '0.5' : '1',
|
||||
display: showLayerButtons ? 'block' : 'none'
|
||||
}"
|
||||
:disabled="store.activeLayer === 'rgb'"
|
||||
@click="setActiveLayer('rgb')"
|
||||
>
|
||||
{{ t('maskEditor.activateLayer') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.baseImageLayer')
|
||||
}}</span>
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="maskEditor_sidePanelLayerCheckbox"
|
||||
:checked="baseImageLayerVisible"
|
||||
@change="onBaseImageLayerVisibilityChange"
|
||||
/>
|
||||
<div class="maskEditor_sidePanelLayerPreviewContainer">
|
||||
<img
|
||||
class="maskEditor_sidePanelImageLayerImage"
|
||||
:src="baseImageSrc"
|
||||
:alt="t('maskEditor.baseLayerPreview')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import type { ImageLayer } from '@/extensions/core/maskeditor/types'
|
||||
import { MaskBlendMode, Tools } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const { toolManager } = defineProps<{
|
||||
toolManager?: ReturnType<typeof useToolManager>
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const canvasManager = useCanvasManager()
|
||||
|
||||
const maskLayerVisible = ref(true)
|
||||
const paintLayerVisible = ref(true)
|
||||
const baseImageLayerVisible = ref(true)
|
||||
|
||||
const baseImageSrc = computed(() => {
|
||||
return store.image?.src ?? ''
|
||||
})
|
||||
|
||||
const showLayerButtons = computed(() => {
|
||||
return store.currentTool === Tools.Eraser
|
||||
})
|
||||
|
||||
const onMaskLayerVisibilityChange = (event: Event) => {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
maskLayerVisible.value = checked
|
||||
|
||||
const maskCanvas = store.maskCanvas
|
||||
if (maskCanvas) {
|
||||
maskCanvas.style.opacity = checked ? String(store.maskOpacity) : '0'
|
||||
}
|
||||
}
|
||||
|
||||
const onPaintLayerVisibilityChange = (event: Event) => {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
paintLayerVisible.value = checked
|
||||
|
||||
const rgbCanvas = store.rgbCanvas
|
||||
if (rgbCanvas) {
|
||||
rgbCanvas.style.opacity = checked ? '1' : '0'
|
||||
}
|
||||
}
|
||||
|
||||
const onBaseImageLayerVisibilityChange = (event: Event) => {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
baseImageLayerVisible.value = checked
|
||||
|
||||
const imgCanvas = store.imgCanvas
|
||||
if (imgCanvas) {
|
||||
imgCanvas.style.opacity = checked ? '1' : '0'
|
||||
}
|
||||
}
|
||||
|
||||
const onMaskOpacityChange = (value: number) => {
|
||||
store.setMaskOpacity(value)
|
||||
|
||||
const maskCanvas = store.maskCanvas
|
||||
if (maskCanvas) {
|
||||
maskCanvas.style.opacity = String(value)
|
||||
}
|
||||
|
||||
maskLayerVisible.value = value !== 0
|
||||
}
|
||||
|
||||
const onBlendModeChange = async (event: Event) => {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
let blendMode: MaskBlendMode
|
||||
|
||||
switch (value) {
|
||||
case 'white':
|
||||
blendMode = MaskBlendMode.White
|
||||
break
|
||||
case 'negative':
|
||||
blendMode = MaskBlendMode.Negative
|
||||
break
|
||||
default:
|
||||
blendMode = MaskBlendMode.Black
|
||||
}
|
||||
|
||||
store.maskBlendMode = blendMode
|
||||
|
||||
await canvasManager.updateMaskColor()
|
||||
}
|
||||
|
||||
const setActiveLayer = (layer: ImageLayer) => {
|
||||
toolManager?.setActiveLayer(layer)
|
||||
}
|
||||
</script>
|
||||
209
src/components/maskeditor/MaskEditorContent.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="maskEditor-dialog-root flex h-full w-full flex-col"
|
||||
@contextmenu.prevent
|
||||
@dragstart="handleDragStart"
|
||||
>
|
||||
<div
|
||||
id="maskEditorCanvasContainer"
|
||||
ref="canvasContainerRef"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<canvas
|
||||
ref="imgCanvasRef"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
<canvas
|
||||
ref="rgbCanvasRef"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
<canvas
|
||||
ref="maskCanvasRef"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
<div ref="canvasBackgroundRef" class="bg-white w-full h-full" />
|
||||
</div>
|
||||
|
||||
<div class="maskEditor-ui-container flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex min-h-0 flex-1 overflow-hidden">
|
||||
<ToolPanel
|
||||
v-if="initialized"
|
||||
ref="toolPanelRef"
|
||||
:tool-manager="toolManager!"
|
||||
/>
|
||||
|
||||
<PointerZone
|
||||
v-if="initialized"
|
||||
:tool-manager="toolManager!"
|
||||
:pan-zoom="panZoom!"
|
||||
/>
|
||||
|
||||
<SidePanel
|
||||
v-if="initialized"
|
||||
ref="sidePanelRef"
|
||||
:tool-manager="toolManager!"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BrushCursor v-if="initialized" :container-ref="containerRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { useImageLoader } from '@/composables/maskeditor/useImageLoader'
|
||||
import { useKeyboard } from '@/composables/maskeditor/useKeyboard'
|
||||
import { useMaskEditorLoader } from '@/composables/maskeditor/useMaskEditorLoader'
|
||||
import { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
|
||||
import { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import BrushCursor from './BrushCursor.vue'
|
||||
import PointerZone from './PointerZone.vue'
|
||||
import SidePanel from './SidePanel.vue'
|
||||
import ToolPanel from './ToolPanel.vue'
|
||||
|
||||
const { node } = defineProps<{
|
||||
node: LGraphNode
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const dataStore = useMaskEditorDataStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const loader = useMaskEditorLoader()
|
||||
|
||||
const containerRef = ref<HTMLElement>()
|
||||
const canvasContainerRef = ref<HTMLDivElement>()
|
||||
const imgCanvasRef = ref<HTMLCanvasElement>()
|
||||
const maskCanvasRef = ref<HTMLCanvasElement>()
|
||||
const rgbCanvasRef = ref<HTMLCanvasElement>()
|
||||
const canvasBackgroundRef = ref<HTMLDivElement>()
|
||||
|
||||
const toolPanelRef = ref<InstanceType<typeof ToolPanel>>()
|
||||
const sidePanelRef = ref<InstanceType<typeof SidePanel>>()
|
||||
|
||||
const initialized = ref(false)
|
||||
|
||||
const keyboard = useKeyboard()
|
||||
const panZoom = usePanAndZoom()
|
||||
|
||||
let toolManager: ReturnType<typeof useToolManager> | null = null
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const handleDragStart = (event: DragEvent) => {
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const initUI = async () => {
|
||||
if (!containerRef.value) {
|
||||
console.error(
|
||||
'[MaskEditorContent] Cannot initialize - missing required refs'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!imgCanvasRef.value ||
|
||||
!maskCanvasRef.value ||
|
||||
!rgbCanvasRef.value ||
|
||||
!canvasContainerRef.value ||
|
||||
!canvasBackgroundRef.value
|
||||
) {
|
||||
console.error('[MaskEditorContent] Cannot initialize - missing canvas refs')
|
||||
return
|
||||
}
|
||||
|
||||
store.maskCanvas = maskCanvasRef.value
|
||||
store.rgbCanvas = rgbCanvasRef.value
|
||||
store.imgCanvas = imgCanvasRef.value
|
||||
store.canvasContainer = canvasContainerRef.value
|
||||
store.canvasBackground = canvasBackgroundRef.value
|
||||
|
||||
try {
|
||||
await loader.loadFromNode(node)
|
||||
|
||||
toolManager = useToolManager(keyboard, panZoom)
|
||||
|
||||
const imageLoader = useImageLoader()
|
||||
const image = await imageLoader.loadImages()
|
||||
|
||||
await panZoom.initializeCanvasPanZoom(
|
||||
image,
|
||||
containerRef.value,
|
||||
toolPanelRef.value?.$el as HTMLElement | undefined,
|
||||
sidePanelRef.value?.$el as HTMLElement | undefined
|
||||
)
|
||||
|
||||
store.canvasHistory.saveInitialState()
|
||||
|
||||
initialized.value = true
|
||||
} catch (error) {
|
||||
console.error('[MaskEditorContent] Initialization failed:', error)
|
||||
dialogStore.closeDialog()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
keyboard.addListeners()
|
||||
|
||||
if (containerRef.value) {
|
||||
resizeObserver = new ResizeObserver(async () => {
|
||||
if (panZoom) {
|
||||
await panZoom.invalidatePanZoom()
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(containerRef.value)
|
||||
}
|
||||
|
||||
void initUI()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
toolManager?.brushDrawing.saveBrushSettings()
|
||||
|
||||
keyboard?.removeListeners()
|
||||
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
|
||||
store.canvasHistory.clearStates()
|
||||
store.resetState()
|
||||
dataStore.reset()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.maskEditor-dialog-root {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.maskEditor-ui-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
:deep(#maskEditorCanvasContainer) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
</style>
|
||||
44
src/components/maskeditor/PaintBucketSettingsPanel.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<h3
|
||||
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
|
||||
>
|
||||
{{ t('maskEditor.paintBucketSettings') }}
|
||||
</h3>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.tolerance')"
|
||||
:min="0"
|
||||
:max="255"
|
||||
:step="1"
|
||||
:model-value="store.paintBucketTolerance"
|
||||
@update:model-value="onToleranceChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.fillOpacity')"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:model-value="store.fillOpacity"
|
||||
@update:model-value="onFillOpacityChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const onToleranceChange = (value: number) => {
|
||||
store.setPaintBucketTolerance(value)
|
||||
}
|
||||
|
||||
const onFillOpacityChange = (value: number) => {
|
||||
store.setFillOpacity(value)
|
||||
}
|
||||
</script>
|
||||
95
src/components/maskeditor/PointerZone.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div
|
||||
ref="pointerZoneRef"
|
||||
class="w-[calc(100%-4rem-220px)] h-full"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointerleave="handlePointerLeave"
|
||||
@pointerenter="handlePointerEnter"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="handleTouchEnd"
|
||||
@wheel="handleWheel"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const { toolManager, panZoom } = defineProps<{
|
||||
toolManager: ReturnType<typeof useToolManager>
|
||||
panZoom: ReturnType<typeof usePanAndZoom>
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const pointerZoneRef = ref<HTMLDivElement>()
|
||||
|
||||
onMounted(() => {
|
||||
if (!pointerZoneRef.value) {
|
||||
console.error('[PointerZone] Pointer zone ref not initialized')
|
||||
return
|
||||
}
|
||||
|
||||
store.pointerZone = pointerZoneRef.value
|
||||
})
|
||||
|
||||
watch(
|
||||
() => store.isPanning,
|
||||
(isPanning) => {
|
||||
if (!pointerZoneRef.value) return
|
||||
|
||||
if (isPanning) {
|
||||
pointerZoneRef.value.style.cursor = 'grabbing'
|
||||
} else {
|
||||
toolManager.updateCursor()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handlePointerDown = async (event: PointerEvent) => {
|
||||
await toolManager.handlePointerDown(event)
|
||||
}
|
||||
|
||||
const handlePointerMove = async (event: PointerEvent) => {
|
||||
await toolManager.handlePointerMove(event)
|
||||
}
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
void toolManager.handlePointerUp(event)
|
||||
}
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
store.brushVisible = false
|
||||
if (pointerZoneRef.value) {
|
||||
pointerZoneRef.value.style.cursor = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerEnter = () => {
|
||||
toolManager.updateCursor()
|
||||
}
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
panZoom.handleTouchStart(event)
|
||||
}
|
||||
|
||||
const handleTouchMove = async (event: TouchEvent) => {
|
||||
await panZoom.handleTouchMove(event)
|
||||
}
|
||||
|
||||
const handleTouchEnd = (event: TouchEvent) => {
|
||||
panZoom.handleTouchEnd(event)
|
||||
}
|
||||
|
||||
const handleWheel = async (event: WheelEvent) => {
|
||||
await panZoom.zoom(event)
|
||||
const newCursorPoint = { x: event.clientX, y: event.clientY }
|
||||
panZoom.updateCursorPosition(newCursorPoint)
|
||||
}
|
||||
</script>
|
||||
31
src/components/maskeditor/SettingsPanelContainer.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="maskEditor_sidePanel">
|
||||
<div class="maskEditor_sidePanelContainer">
|
||||
<component :is="currentPanelComponent" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import { Tools } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import BrushSettingsPanel from './BrushSettingsPanel.vue'
|
||||
import ColorSelectSettingsPanel from './ColorSelectSettingsPanel.vue'
|
||||
import PaintBucketSettingsPanel from './PaintBucketSettingsPanel.vue'
|
||||
|
||||
const currentPanelComponent = computed<Component>(() => {
|
||||
const tool = useMaskEditorStore().currentTool
|
||||
|
||||
if (tool === Tools.MaskBucket) {
|
||||
return PaintBucketSettingsPanel
|
||||
} else if (tool === Tools.MaskColorFill) {
|
||||
return ColorSelectSettingsPanel
|
||||
} else {
|
||||
return BrushSettingsPanel
|
||||
}
|
||||
})
|
||||
</script>
|
||||
24
src/components/maskeditor/SidePanel.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-3 pb-3 h-full !items-stretch bg-[var(--comfy-menu-bg)] overflow-y-auto w-55 px-2.5"
|
||||
>
|
||||
<div class="w-full min-h-full">
|
||||
<SettingsPanelContainer />
|
||||
|
||||
<div class="w-full h-0.5 bg-[var(--border-color)] mt-6 mb-1.5" />
|
||||
|
||||
<ImageLayerSettingsPanel :tool-manager="toolManager" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
|
||||
import ImageLayerSettingsPanel from './ImageLayerSettingsPanel.vue'
|
||||
import SettingsPanelContainer from './SettingsPanelContainer.vue'
|
||||
|
||||
const { toolManager } = defineProps<{
|
||||
toolManager?: ReturnType<typeof useToolManager>
|
||||
}>()
|
||||
</script>
|
||||
69
src/components/maskeditor/ToolPanel.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div
|
||||
class="h-full z-[8888] flex flex-col justify-between bg-[var(--comfy-menu-bg)]"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
v-for="tool in allTools"
|
||||
:key="tool"
|
||||
:class="[
|
||||
'maskEditor_toolPanelContainer hover:bg-[var(--p-surface-300)] dark-theme:hover:bg-[var(--p-surface-800)]',
|
||||
{ maskEditor_toolPanelContainerSelected: currentTool === tool }
|
||||
]"
|
||||
@click="onToolSelect(tool)"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center"
|
||||
v-html="iconsHtml[tool]"
|
||||
></div>
|
||||
<div class="maskEditor_toolPanelIndicator"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center cursor-pointer rounded-md mb-2 transition-colors duration-200 hover:bg-[var(--p-surface-300)] dark-theme:hover:bg-[var(--p-surface-800)]"
|
||||
:title="t('maskEditor.clickToResetZoom')"
|
||||
@click="onResetZoom"
|
||||
>
|
||||
<span class="text-sm text-[var(--p-button-text-secondary-color)]">{{
|
||||
zoomText
|
||||
}}</span>
|
||||
<span class="text-xs text-[var(--p-button-text-secondary-color)]">{{
|
||||
dimensionsText
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import { iconsHtml } from '@/extensions/core/maskeditor/constants'
|
||||
import type { Tools } from '@/extensions/core/maskeditor/types'
|
||||
import { allTools } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const { toolManager } = defineProps<{
|
||||
toolManager: ReturnType<typeof useToolManager>
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const onToolSelect = (tool: Tools) => {
|
||||
toolManager.switchTool(tool)
|
||||
}
|
||||
|
||||
const currentTool = computed(() => store.currentTool)
|
||||
|
||||
const zoomText = computed(() => `${Math.round(store.displayZoomRatio * 100)}%`)
|
||||
const dimensionsText = computed(() => {
|
||||
const img = store.image
|
||||
return img ? `${img.width}x${img.height}` : ' '
|
||||
})
|
||||
|
||||
const onResetZoom = () => {
|
||||
store.resetZoom()
|
||||
}
|
||||
</script>
|
||||
55
src/components/maskeditor/controls/DropdownControl.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="flex flex-row gap-2.5 items-center min-h-6 relative">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
label
|
||||
}}</span>
|
||||
<select
|
||||
class="absolute right-0 h-6 px-1.5 rounded-md border border-[var(--p-form-field-border-color)] transition-colors duration-100 bg-[var(--comfy-menu-bg)] focus:outline focus:outline-1 focus:outline-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-900)] dark-theme:focus:outline-[var(--p-button-text-primary-color)]"
|
||||
:value="modelValue"
|
||||
@change="onChange"
|
||||
>
|
||||
<option
|
||||
v-for="option in normalizedOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface DropdownOption {
|
||||
label: string
|
||||
value: string | number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
options: string[] | DropdownOption[]
|
||||
modelValue: string | number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number]
|
||||
}>()
|
||||
|
||||
const normalizedOptions = computed((): DropdownOption[] => {
|
||||
return props.options.map((option) => {
|
||||
if (typeof option === 'string') {
|
||||
return { label: option, value: option }
|
||||
}
|
||||
return option
|
||||
})
|
||||
})
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
39
src/components/maskeditor/controls/SliderControl.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
label
|
||||
}}</span>
|
||||
<input
|
||||
type="range"
|
||||
class="maskEditor_sidePanelBrushRange"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:value="modelValue"
|
||||
@input="onInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
label: string
|
||||
min: number
|
||||
max: number
|
||||
step?: number
|
||||
modelValue: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
step: 1
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number]
|
||||
}>()
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const value = Number((event.target as HTMLInputElement).value)
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
34
src/components/maskeditor/controls/ToggleControl.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="flex flex-row gap-2.5 items-center min-h-6 relative">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
label
|
||||
}}</span>
|
||||
<label class="maskEditor_sidePanelToggleContainer">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="maskEditor_sidePanelToggleCheckbox"
|
||||
:checked="modelValue"
|
||||
@change="onChange"
|
||||
/>
|
||||
<div class="maskEditor_sidePanelToggleSwitch"></div>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
label: string
|
||||
modelValue: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
emit('update:modelValue', checked)
|
||||
}
|
||||
</script>
|
||||
126
src/components/maskeditor/dialog/TopBarHeader.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="m-0 text-lg font-semibold">{{ t('maskEditor.title') }}</h3>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.undo')"
|
||||
@click="onUndo"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<path
|
||||
d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.redo')"
|
||||
@click="onRedo"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<path
|
||||
class="cls-1"
|
||||
d="M6.23,12.18c-1.97,0-3.58-1.61-3.58-3.58,0-2.11,1.71-3.58,4.17-3.58h3.98l-1.43-1.43c-.18-.18-.18-.47,0-.64.18-.18.46-.18.64,0l2.21,2.21c.09.09.13.2.13.32s-.05.24-.13.32l-2.21,2.21c-.18.18-.47.18-.64,0-.18-.18-.18-.47,0-.64l1.43-1.43h-3.98c-1.92,0-3.26,1.1-3.26,2.67,0,1.47,1.2,2.67,2.67,2.67.25,0,.46.2.46.46s-.2.46-.46.46Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button :class="textButtonClass" @click="onInvert">
|
||||
{{ t('maskEditor.invert') }}
|
||||
</button>
|
||||
|
||||
<button :class="textButtonClass" @click="onClear">
|
||||
{{ t('maskEditor.clear') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button
|
||||
:label="saveButtonText"
|
||||
icon="pi pi-check"
|
||||
size="small"
|
||||
:disabled="!saveEnabled"
|
||||
@click="handleSave"
|
||||
/>
|
||||
<Button
|
||||
:label="t('g.cancel')"
|
||||
icon="pi pi-times"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
|
||||
import { useMaskEditorSaver } from '@/composables/maskeditor/useMaskEditorSaver'
|
||||
import { t } from '@/i18n'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const canvasTools = useCanvasTools()
|
||||
const saver = useMaskEditorSaver()
|
||||
|
||||
const saveButtonText = ref(t('g.save'))
|
||||
const saveEnabled = ref(true)
|
||||
|
||||
const iconButtonClass =
|
||||
'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-[var(--p-form-field-border-color)] pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)] dark-theme:hover:bg-[var(--p-surface-900)]'
|
||||
|
||||
const textButtonClass =
|
||||
'h-7.5 w-15 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)] dark-theme:hover:bg-[var(--p-surface-900)]'
|
||||
|
||||
const onUndo = () => {
|
||||
store.canvasHistory.undo()
|
||||
}
|
||||
|
||||
const onRedo = () => {
|
||||
store.canvasHistory.redo()
|
||||
}
|
||||
|
||||
const onInvert = () => {
|
||||
canvasTools.invertMask()
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
canvasTools.clearMask()
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saveButtonText.value = t('g.saving')
|
||||
saveEnabled.value = false
|
||||
|
||||
try {
|
||||
store.brushVisible = false
|
||||
await saver.save()
|
||||
dialogStore.closeDialog()
|
||||
} catch (error) {
|
||||
console.error('[TopBarHeader] Save failed:', error)
|
||||
store.brushVisible = true
|
||||
saveButtonText.value = t('g.save')
|
||||
saveEnabled.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
dialogStore.closeDialog({ key: 'global-mask-editor' })
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
ref="menuButtonRef"
|
||||
v-tooltip="{
|
||||
value: t('sideToolbar.labels.menu'),
|
||||
showDelay: 300,
|
||||
@@ -29,6 +30,7 @@
|
||||
>
|
||||
<template #item="{ item, props }">
|
||||
<a
|
||||
v-if="item.key !== 'nodes-2.0-toggle'"
|
||||
class="p-menubar-item-link px-4 py-2"
|
||||
v-bind="props.action"
|
||||
:href="item.url"
|
||||
@@ -65,6 +67,34 @@
|
||||
</span>
|
||||
<i v-if="item.items" class="pi pi-angle-right ml-auto" />
|
||||
</a>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-between px-4 py-2"
|
||||
@click.stop="handleNodes2ToggleClick"
|
||||
>
|
||||
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
|
||||
<ToggleSwitch
|
||||
v-model="nodes2Enabled"
|
||||
class="ml-4"
|
||||
:aria-label="item.label"
|
||||
:pt="{
|
||||
root: {
|
||||
style: {
|
||||
width: '38px',
|
||||
height: '20px'
|
||||
}
|
||||
},
|
||||
handle: {
|
||||
style: {
|
||||
width: '16px',
|
||||
height: '16px'
|
||||
}
|
||||
}
|
||||
}"
|
||||
@click.stop
|
||||
@update:model-value="onNodes2ToggleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</TieredMenu>
|
||||
</template>
|
||||
@@ -73,6 +103,7 @@
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import TieredMenu from 'primevue/tieredmenu'
|
||||
import type { TieredMenuMethods, TieredMenuState } from 'primevue/tieredmenu'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -80,6 +111,7 @@ import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.
|
||||
import ComfyLogo from '@/components/icons/ComfyLogo.vue'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -98,10 +130,19 @@ const colorPaletteStore = useColorPaletteStore()
|
||||
const colorPaletteService = useColorPaletteService()
|
||||
const dialogStore = useDialogStore()
|
||||
const managerState = useManagerState()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const menuRef = ref<
|
||||
({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null
|
||||
>(null)
|
||||
const menuButtonRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const nodes2Enabled = computed({
|
||||
get: () => settingStore.get('Comfy.VueNodes.Enabled') ?? false,
|
||||
set: async (value: boolean) => {
|
||||
await settingStore.set('Comfy.VueNodes.Enabled', value)
|
||||
}
|
||||
})
|
||||
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
@@ -164,6 +205,10 @@ const extraMenuItems = computed(() => [
|
||||
label: t('menu.theme'),
|
||||
items: themeMenuItems.value
|
||||
},
|
||||
{
|
||||
key: 'nodes-2.0-toggle',
|
||||
label: 'Nodes 2.0'
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
key: 'browse-templates',
|
||||
@@ -281,6 +326,17 @@ const hasActiveStateSiblings = (item: MenuItem): boolean => {
|
||||
menuItemStore.menuItemHasActiveStateChildren[item.parentPath])
|
||||
)
|
||||
}
|
||||
|
||||
const handleNodes2ToggleClick = () => {
|
||||
return false
|
||||
}
|
||||
|
||||
const onNodes2ToggleChange = async (value: boolean) => {
|
||||
await settingStore.set('Comfy.VueNodes.Enabled', value)
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<nav
|
||||
ref="sideToolbarRef"
|
||||
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2"
|
||||
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2 pointer-events-auto"
|
||||
:class="{
|
||||
'small-sidebar': isSmall,
|
||||
'connected-sidebar': isConnected,
|
||||
@@ -145,7 +145,7 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
|
||||
const isOverflowing = ref(false)
|
||||
const groupClasses = computed(() =>
|
||||
cn(
|
||||
'sidebar-item-group pointer-events-auto flex flex-col items-center overflow-hidden flex-shrink-0' +
|
||||
'sidebar-item-group flex flex-col items-center overflow-hidden flex-shrink-0' +
|
||||
(isConnected.value ? '' : ' rounded-lg shadow-interface')
|
||||
)
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
>
|
||||
<slot name="top" />
|
||||
</div>
|
||||
<div v-if="slots.header" class="px-4">
|
||||
<div v-if="slots.header" class="px-4 pb-4">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<AssetsSidebarTemplate>
|
||||
<template #top>
|
||||
<span v-if="!isInFolderView" class="font-bold">
|
||||
{{ $t('sideToolbar.mediaAssets') }}
|
||||
{{ $t('sideToolbar.mediaAssets.title') }}
|
||||
</span>
|
||||
<div v-else class="flex w-full items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -36,9 +36,16 @@
|
||||
</div>
|
||||
<!-- Normal Tab View -->
|
||||
<TabList v-else v-model="activeTab" class="pt-4 pb-1">
|
||||
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
|
||||
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
|
||||
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
|
||||
</TabList>
|
||||
<!-- Filter Bar -->
|
||||
<MediaAssetFilterBar
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:sort-by="sortBy"
|
||||
v-model:media-type-filters="mediaTypeFilters"
|
||||
:show-generation-time-sort="activeTab === 'output'"
|
||||
/>
|
||||
</template>
|
||||
<template #body>
|
||||
<!-- Loading state -->
|
||||
@@ -66,7 +73,7 @@
|
||||
:grid-style="{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
padding: '0.5rem',
|
||||
padding: '0 0.5rem',
|
||||
gap: '0.5rem'
|
||||
}"
|
||||
@approach-end="handleApproachEnd"
|
||||
@@ -77,7 +84,7 @@
|
||||
:selected="isSelected(item.id)"
|
||||
:show-output-count="shouldShowOutputCount(item)"
|
||||
:output-count="getOutputCount(item)"
|
||||
:show-delete-button="!isInFolderView"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
@click="handleAssetSelect(item)"
|
||||
@zoom="handleZoomClick(item)"
|
||||
@output-count-click="enterFolderView(item)"
|
||||
@@ -89,7 +96,7 @@
|
||||
</template>
|
||||
<template #footer>
|
||||
<div
|
||||
v-if="hasSelection && activeTab === 'output'"
|
||||
v-if="hasSelection"
|
||||
class="flex h-18 w-full items-center justify-between px-4"
|
||||
>
|
||||
<div>
|
||||
@@ -117,7 +124,7 @@
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<IconTextButton
|
||||
v-if="!isInFolderView"
|
||||
v-if="shouldShowDeleteButton"
|
||||
:label="$t('mediaAsset.selection.deleteSelected')"
|
||||
type="secondary"
|
||||
icon-position="right"
|
||||
@@ -157,16 +164,21 @@ import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import { t } from '@/i18n'
|
||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
@@ -177,6 +189,13 @@ const folderPromptId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||
|
||||
// Determine if delete button should be shown
|
||||
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
|
||||
const shouldShowDeleteButton = computed(() => {
|
||||
if (activeTab.value === 'input' && !isCloud) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const getOutputCount = (item: AssetItem): number => {
|
||||
const count = item.user_metadata?.outputCount
|
||||
return typeof count === 'number' && count > 0 ? count : 0
|
||||
@@ -228,13 +247,22 @@ const currentGalleryAssetId = ref<string | null>(null)
|
||||
|
||||
const folderAssets = ref<AssetItem[]>([])
|
||||
|
||||
const displayAssets = computed(() => {
|
||||
// Base assets before search filtering
|
||||
const baseAssets = computed(() => {
|
||||
if (isInFolderView.value) {
|
||||
return folderAssets.value
|
||||
}
|
||||
return mediaAssets.value
|
||||
})
|
||||
|
||||
// Use media asset filtering composable
|
||||
const { searchQuery, sortBy, mediaTypeFilters, filteredAssets } =
|
||||
useMediaAssetFiltering(baseAssets)
|
||||
|
||||
const displayAssets = computed(() => {
|
||||
return filteredAssets.value
|
||||
})
|
||||
|
||||
watch(displayAssets, (newAssets) => {
|
||||
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
|
||||
const newIndex = newAssets.findIndex(
|
||||
@@ -293,6 +321,8 @@ watch(
|
||||
activeTab,
|
||||
() => {
|
||||
clearSelection()
|
||||
// Clear search when switching tabs
|
||||
searchQuery.value = ''
|
||||
// Reset pagination state when tab changes
|
||||
void refreshAssets()
|
||||
},
|
||||
@@ -305,6 +335,25 @@ const handleAssetSelect = (asset: AssetItem) => {
|
||||
}
|
||||
|
||||
const handleZoomClick = (asset: AssetItem) => {
|
||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||
|
||||
if (mediaType === '3D') {
|
||||
const dialogStore = useDialogStore()
|
||||
dialogStore.showDialog({
|
||||
key: 'asset-3d-viewer',
|
||||
title: asset.name,
|
||||
component: Load3dViewerContent,
|
||||
props: {
|
||||
modelUrl: asset.preview_url || ''
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: 80vw; height: 80vh;',
|
||||
maximizable: true
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
currentGalleryAssetId.value = asset.id
|
||||
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
|
||||
if (index !== -1) {
|
||||
@@ -350,6 +399,7 @@ const exitFolderView = () => {
|
||||
folderPromptId.value = null
|
||||
folderExecutionTime.value = undefined
|
||||
folderAssets.value = []
|
||||
searchQuery.value = ''
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,24 @@
|
||||
size="small"
|
||||
:label="t('vueNodesMigration.button')"
|
||||
text
|
||||
@click="handleOpenSettings"
|
||||
@click="switchBack"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Toast>
|
||||
<Toast
|
||||
group="vue-nodes-check-main-menu"
|
||||
position="bottom-center"
|
||||
class="w-auto"
|
||||
>
|
||||
<template #message>
|
||||
<div class="flex flex-auto items-center justify-between gap-4">
|
||||
<span class="whitespace-nowrap">{{
|
||||
t('vueNodesMigrationMainMenu.message')
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Toast>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -29,20 +42,38 @@ import Toast from 'primevue/toast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialogService = useDialogService()
|
||||
const isDismissed = useVueNodesMigrationDismissed()
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
dialogService.showSettingsDialog()
|
||||
const switchBack = async () => {
|
||||
await disableVueNodes()
|
||||
toast.removeGroup('vue-nodes-migration')
|
||||
isDismissed.value = true
|
||||
showMainMenuToast()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
isDismissed.value = true
|
||||
showMainMenuToast()
|
||||
}
|
||||
|
||||
const disableVueNodes = async () => {
|
||||
await useSettingStore().set('Comfy.VueNodes.Enabled', false)
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: `vue_nodes_migration_toast_switch_back_clicked`
|
||||
})
|
||||
}
|
||||
|
||||
const showMainMenuToast = () => {
|
||||
useToastStore().add({
|
||||
group: 'vue-nodes-check-main-menu',
|
||||
severity: 'info',
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -101,6 +101,7 @@ import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -111,6 +112,8 @@ const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
|
||||
const planSettingsLabel = isCloud
|
||||
? 'settingsCategories.PlanCredits'
|
||||
: 'settingsCategories.Credits'
|
||||
@@ -145,7 +148,9 @@ const handleTopUp = () => {
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
|
||||
buildDocsUrl('/tutorials/api-nodes/overview#api-nodes', {
|
||||
includeLocale: true
|
||||
}),
|
||||
'_blank'
|
||||
)
|
||||
emit('close')
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div>
|
||||
<div class="mb-1">{{ t('auth.loginButton.tooltipHelp') }}</div>
|
||||
<a
|
||||
href="https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes"
|
||||
:href="apiNodesOverviewUrl"
|
||||
target="_blank"
|
||||
class="text-neutral-500 hover:text-primary"
|
||||
>{{ t('auth.loginButton.tooltipLearnMore') }}</a
|
||||
@@ -37,9 +37,17 @@ import Popover from 'primevue/popover'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const { isLoggedIn, handleSignIn } = useCurrentUser()
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
const apiNodesOverviewUrl = buildDocsUrl(
|
||||
'/tutorials/api-nodes/overview#api-nodes',
|
||||
{
|
||||
includeLocale: true
|
||||
}
|
||||
)
|
||||
const popoverRef = ref<InstanceType<typeof Popover> | null>(null)
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let showTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
class="pointer-events-auto relative w-full h-10 bg-gradient-to-r from-blue-600 to-blue-700 flex items-center justify-center px-4"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<i class="icon-[lucide--sparkles]"></i>
|
||||
<span class="pl-2">{{ $t('vueNodesBanner.message') }}</span>
|
||||
<i class="icon-[lucide--rocket]"></i>
|
||||
<span class="pl-2 text-sm">{{ $t('vueNodesBanner.message') }}</span>
|
||||
<Button
|
||||
class="cursor-pointer bg-transparent rounded h-7 px-3 border border-white text-white ml-4 text-xs"
|
||||
@click="handleTryItOut"
|
||||
|
||||
@@ -79,7 +79,8 @@ export function useNodeArrangement() {
|
||||
return
|
||||
}
|
||||
|
||||
alignNodes(selectedNodes, alignOption.value)
|
||||
const newPositions = alignNodes(selectedNodes, alignOption.value)
|
||||
canvasStore.canvas?.repositionNodesVueMode(newPositions)
|
||||
|
||||
canvasRefresh.refreshCanvas()
|
||||
}
|
||||
@@ -93,7 +94,8 @@ export function useNodeArrangement() {
|
||||
return
|
||||
}
|
||||
|
||||
distributeNodes(selectedNodes, distributeOption.value)
|
||||
const newPositions = distributeNodes(selectedNodes, distributeOption.value)
|
||||
canvasStore.canvas?.repositionNodesVueMode(newPositions)
|
||||
canvasRefresh.refreshCanvas()
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
const isVueNodeToastDismissed = useVueNodesMigrationDismissed()
|
||||
|
||||
let hasShownMigrationToast = false
|
||||
|
||||
const initializeNodeManager = () => {
|
||||
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
|
||||
const activeGraph = comfyApp.canvas?.graph
|
||||
@@ -85,7 +87,12 @@ function useVueNodeLifecycleIndividual() {
|
||||
ensureCorrectLayoutScale(
|
||||
comfyApp.canvas?.graph?.extra.workflowRendererVersion
|
||||
)
|
||||
if (!wasEnabled && !isVueNodeToastDismissed.value) {
|
||||
if (
|
||||
wasEnabled === false &&
|
||||
!isVueNodeToastDismissed.value &&
|
||||
!hasShownMigrationToast
|
||||
) {
|
||||
hasShownMigrationToast = true
|
||||
useToastStore().add({
|
||||
group: 'vue-nodes-migration',
|
||||
severity: 'info',
|
||||
|
||||
671
src/composables/maskeditor/useBrushDrawing.ts
Normal file
@@ -0,0 +1,671 @@
|
||||
import { ref } from 'vue'
|
||||
import QuickLRU from '@alloc/quick-lru'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { hexToRgb, parseToRgb } from '@/utils/colorUtil'
|
||||
import { getStorageValue, setStorageValue } from '@/scripts/utils'
|
||||
import {
|
||||
Tools,
|
||||
BrushShape,
|
||||
CompositionOperation
|
||||
} from '@/extensions/core/maskeditor/types'
|
||||
import type { Brush, Point } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useCoordinateTransform } from './useCoordinateTransform'
|
||||
|
||||
const saveBrushToCache = debounce(function (key: string, brush: Brush): void {
|
||||
try {
|
||||
const brushString = JSON.stringify(brush)
|
||||
setStorageValue(key, brushString)
|
||||
} catch (error) {
|
||||
console.error('Failed to save brush to cache:', error)
|
||||
}
|
||||
}, 300)
|
||||
|
||||
function loadBrushFromCache(key: string): Brush | null {
|
||||
try {
|
||||
const brushString = getStorageValue(key)
|
||||
if (brushString) {
|
||||
return JSON.parse(brushString) as Brush
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load brush from cache:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function useBrushDrawing(initialSettings?: {
|
||||
useDominantAxis?: boolean
|
||||
brushAdjustmentSpeed?: number
|
||||
}) {
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const coordinateTransform = useCoordinateTransform()
|
||||
|
||||
const brushTextureCache = new QuickLRU<string, HTMLCanvasElement>({
|
||||
maxSize: 8
|
||||
})
|
||||
|
||||
const SMOOTHING_MAX_STEPS = 30
|
||||
const SMOOTHING_MIN_STEPS = 2
|
||||
|
||||
const isDrawing = ref(false)
|
||||
const isDrawingLine = ref(false)
|
||||
const lineStartPoint = ref<Point | null>(null)
|
||||
const smoothingCordsArray = ref<Point[]>([])
|
||||
const smoothingLastDrawTime = ref(new Date())
|
||||
const initialDraw = ref(true)
|
||||
|
||||
const brushStrokeCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const brushStrokeCtx = ref<CanvasRenderingContext2D | null>(null)
|
||||
|
||||
const initialPoint = ref<Point | null>(null)
|
||||
const useDominantAxis = ref(initialSettings?.useDominantAxis ?? false)
|
||||
const brushAdjustmentSpeed = ref(initialSettings?.brushAdjustmentSpeed ?? 1.0)
|
||||
|
||||
const cachedBrushSettings = loadBrushFromCache('maskeditor_brush_settings')
|
||||
if (cachedBrushSettings) {
|
||||
store.setBrushSize(cachedBrushSettings.size)
|
||||
store.setBrushOpacity(cachedBrushSettings.opacity)
|
||||
store.setBrushHardness(cachedBrushSettings.hardness)
|
||||
store.brushSettings.type = cachedBrushSettings.type
|
||||
store.setBrushSmoothingPrecision(cachedBrushSettings.smoothingPrecision)
|
||||
}
|
||||
|
||||
const createBrushStrokeCanvas = async (): Promise<void> => {
|
||||
if (brushStrokeCanvas.value !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
const maskCanvas = store.maskCanvas
|
||||
if (!maskCanvas) {
|
||||
throw new Error('Mask canvas not initialized')
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = maskCanvas.width
|
||||
canvas.height = maskCanvas.height
|
||||
|
||||
brushStrokeCanvas.value = canvas
|
||||
brushStrokeCtx.value = canvas.getContext('2d')!
|
||||
}
|
||||
|
||||
const initShape = (compositionOperation: CompositionOperation) => {
|
||||
const blendMode = store.maskBlendMode
|
||||
const mask_ctx = store.maskCtx
|
||||
const rgb_ctx = store.rgbCtx
|
||||
|
||||
if (!mask_ctx || !rgb_ctx) {
|
||||
throw new Error('Canvas contexts are required')
|
||||
}
|
||||
|
||||
mask_ctx.beginPath()
|
||||
rgb_ctx.beginPath()
|
||||
|
||||
if (compositionOperation === CompositionOperation.SourceOver) {
|
||||
mask_ctx.fillStyle = blendMode
|
||||
mask_ctx.globalCompositeOperation = CompositionOperation.SourceOver
|
||||
rgb_ctx.globalCompositeOperation = CompositionOperation.SourceOver
|
||||
} else if (compositionOperation === CompositionOperation.DestinationOut) {
|
||||
mask_ctx.globalCompositeOperation = CompositionOperation.DestinationOut
|
||||
rgb_ctx.globalCompositeOperation = CompositionOperation.DestinationOut
|
||||
}
|
||||
}
|
||||
|
||||
const formatRgba = (hex: string, alpha: number): string => {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
|
||||
const getCachedBrushTexture = (
|
||||
radius: number,
|
||||
hardness: number,
|
||||
color: string,
|
||||
opacity: number
|
||||
): HTMLCanvasElement => {
|
||||
const cacheKey = `${radius}_${hardness}_${color}_${opacity}`
|
||||
|
||||
if (brushTextureCache.has(cacheKey)) {
|
||||
return brushTextureCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
const tempCanvas = document.createElement('canvas')
|
||||
const tempCtx = tempCanvas.getContext('2d')!
|
||||
const size = radius * 2
|
||||
tempCanvas.width = size
|
||||
tempCanvas.height = size
|
||||
|
||||
const centerX = size / 2
|
||||
const centerY = size / 2
|
||||
const hardRadius = radius * hardness
|
||||
|
||||
const imageData = tempCtx.createImageData(size, size)
|
||||
const data = imageData.data
|
||||
const { r, g, b } = parseToRgb(color)
|
||||
|
||||
const fadeRange = radius - hardRadius
|
||||
|
||||
for (let y = 0; y < size; y++) {
|
||||
const dy = y - centerY
|
||||
for (let x = 0; x < size; x++) {
|
||||
const dx = x - centerX
|
||||
const index = (y * size + x) * 4
|
||||
|
||||
// Calculate square distance (Chebyshev distance)
|
||||
const distFromEdge = Math.max(Math.abs(dx), Math.abs(dy))
|
||||
|
||||
let pixelOpacity = 0
|
||||
if (distFromEdge <= hardRadius) {
|
||||
pixelOpacity = opacity
|
||||
} else if (distFromEdge <= radius) {
|
||||
const fadeProgress = (distFromEdge - hardRadius) / fadeRange
|
||||
pixelOpacity = opacity * (1 - fadeProgress)
|
||||
}
|
||||
|
||||
data[index] = r
|
||||
data[index + 1] = g
|
||||
data[index + 2] = b
|
||||
data[index + 3] = pixelOpacity * 255
|
||||
}
|
||||
}
|
||||
|
||||
tempCtx.putImageData(imageData, 0, 0)
|
||||
brushTextureCache.set(cacheKey, tempCanvas)
|
||||
|
||||
return tempCanvas
|
||||
}
|
||||
|
||||
const createBrushGradient = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
radius: number,
|
||||
hardness: number,
|
||||
color: string,
|
||||
opacity: number,
|
||||
isErasing: boolean
|
||||
): CanvasGradient => {
|
||||
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius)
|
||||
|
||||
if (isErasing) {
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`)
|
||||
gradient.addColorStop(hardness, `rgba(255, 255, 255, ${opacity * 0.5})`)
|
||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
|
||||
} else {
|
||||
const { r, g, b } = parseToRgb(color)
|
||||
gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${opacity})`)
|
||||
gradient.addColorStop(
|
||||
hardness,
|
||||
`rgba(${r}, ${g}, ${b}, ${opacity * 0.5})`
|
||||
)
|
||||
gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`)
|
||||
}
|
||||
|
||||
return gradient
|
||||
}
|
||||
|
||||
const drawShapeOnContext = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
brushType: BrushShape,
|
||||
x: number,
|
||||
y: number,
|
||||
radius: number
|
||||
): void => {
|
||||
ctx.beginPath()
|
||||
if (brushType === BrushShape.Rect) {
|
||||
ctx.rect(x - radius, y - radius, radius * 2, radius * 2)
|
||||
} else {
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2, false)
|
||||
}
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
const drawRgbShape = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
point: Point,
|
||||
brushType: BrushShape,
|
||||
brushRadius: number,
|
||||
hardness: number,
|
||||
opacity: number
|
||||
): void => {
|
||||
const { x, y } = point
|
||||
const rgbColor = store.rgbColor
|
||||
|
||||
if (brushType === BrushShape.Rect && hardness < 1) {
|
||||
const rgbaColor = formatRgba(rgbColor, opacity)
|
||||
const brushTexture = getCachedBrushTexture(
|
||||
brushRadius,
|
||||
hardness,
|
||||
rgbaColor,
|
||||
opacity
|
||||
)
|
||||
ctx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
|
||||
return
|
||||
}
|
||||
|
||||
if (hardness === 1) {
|
||||
const rgbaColor = formatRgba(rgbColor, opacity)
|
||||
ctx.fillStyle = rgbaColor
|
||||
drawShapeOnContext(ctx, brushType, x, y, brushRadius)
|
||||
return
|
||||
}
|
||||
|
||||
const gradient = createBrushGradient(
|
||||
ctx,
|
||||
x,
|
||||
y,
|
||||
brushRadius,
|
||||
hardness,
|
||||
rgbColor,
|
||||
opacity,
|
||||
false
|
||||
)
|
||||
ctx.fillStyle = gradient
|
||||
drawShapeOnContext(ctx, brushType, x, y, brushRadius)
|
||||
}
|
||||
|
||||
const drawMaskShape = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
point: Point,
|
||||
brushType: BrushShape,
|
||||
brushRadius: number,
|
||||
hardness: number,
|
||||
opacity: number,
|
||||
isErasing: boolean
|
||||
): void => {
|
||||
const { x, y } = point
|
||||
const maskColor = store.maskColor
|
||||
|
||||
if (brushType === BrushShape.Rect && hardness < 1) {
|
||||
const baseColor = isErasing
|
||||
? `rgba(255, 255, 255, ${opacity})`
|
||||
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
|
||||
const brushTexture = getCachedBrushTexture(
|
||||
brushRadius,
|
||||
hardness,
|
||||
baseColor,
|
||||
opacity
|
||||
)
|
||||
ctx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
|
||||
return
|
||||
}
|
||||
|
||||
if (hardness === 1) {
|
||||
ctx.fillStyle = isErasing
|
||||
? `rgba(255, 255, 255, ${opacity})`
|
||||
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
drawShapeOnContext(ctx, brushType, x, y, brushRadius)
|
||||
return
|
||||
}
|
||||
|
||||
const maskColorHex = `rgb(${maskColor.r}, ${maskColor.g}, ${maskColor.b})`
|
||||
const gradient = createBrushGradient(
|
||||
ctx,
|
||||
x,
|
||||
y,
|
||||
brushRadius,
|
||||
hardness,
|
||||
maskColorHex,
|
||||
opacity,
|
||||
isErasing
|
||||
)
|
||||
ctx.fillStyle = gradient
|
||||
drawShapeOnContext(ctx, brushType, x, y, brushRadius)
|
||||
}
|
||||
|
||||
const drawShape = (point: Point, overrideOpacity?: number) => {
|
||||
const brush = store.brushSettings
|
||||
const mask_ctx = store.maskCtx
|
||||
const rgb_ctx = store.rgbCtx
|
||||
|
||||
if (!mask_ctx || !rgb_ctx) {
|
||||
throw new Error('Canvas contexts are required')
|
||||
}
|
||||
|
||||
const brushType = brush.type
|
||||
const brushRadius = brush.size
|
||||
const hardness = brush.hardness
|
||||
const opacity = overrideOpacity ?? brush.opacity
|
||||
|
||||
const isErasing = mask_ctx.globalCompositeOperation === 'destination-out'
|
||||
const currentTool = store.currentTool
|
||||
const isRgbLayer = store.activeLayer === 'rgb'
|
||||
|
||||
if (
|
||||
isRgbLayer &&
|
||||
currentTool &&
|
||||
(currentTool === Tools.Eraser || currentTool === Tools.PaintPen)
|
||||
) {
|
||||
drawRgbShape(rgb_ctx, point, brushType, brushRadius, hardness, opacity)
|
||||
return
|
||||
}
|
||||
|
||||
drawMaskShape(
|
||||
mask_ctx,
|
||||
point,
|
||||
brushType,
|
||||
brushRadius,
|
||||
hardness,
|
||||
opacity,
|
||||
isErasing
|
||||
)
|
||||
}
|
||||
|
||||
const clampSmoothingPrecision = (value: number): number => {
|
||||
return Math.min(Math.max(value, 1), 100)
|
||||
}
|
||||
|
||||
const generateEquidistantPoints = (
|
||||
points: Point[],
|
||||
distance: number
|
||||
): Point[] => {
|
||||
const result: Point[] = []
|
||||
const cumulativeDistances: number[] = [0]
|
||||
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const dx = points[i].x - points[i - 1].x
|
||||
const dy = points[i].y - points[i - 1].y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
cumulativeDistances[i] = cumulativeDistances[i - 1] + dist
|
||||
}
|
||||
|
||||
const totalLength = cumulativeDistances[cumulativeDistances.length - 1]
|
||||
const numPoints = Math.floor(totalLength / distance)
|
||||
|
||||
for (let i = 0; i <= numPoints; i++) {
|
||||
const targetDistance = i * distance
|
||||
let idx = 0
|
||||
|
||||
while (
|
||||
idx < cumulativeDistances.length - 1 &&
|
||||
cumulativeDistances[idx + 1] < targetDistance
|
||||
) {
|
||||
idx++
|
||||
}
|
||||
|
||||
if (idx >= points.length - 1) {
|
||||
result.push(points[points.length - 1])
|
||||
continue
|
||||
}
|
||||
|
||||
const d0 = cumulativeDistances[idx]
|
||||
const d1 = cumulativeDistances[idx + 1]
|
||||
const t = (targetDistance - d0) / (d1 - d0)
|
||||
|
||||
const x = points[idx].x + t * (points[idx + 1].x - points[idx].x)
|
||||
const y = points[idx].y + t * (points[idx + 1].y - points[idx].y)
|
||||
|
||||
result.push({ x, y })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const drawWithBetterSmoothing = (point: Point): void => {
|
||||
if (!smoothingCordsArray.value) {
|
||||
smoothingCordsArray.value = []
|
||||
}
|
||||
|
||||
const opacityConstant = 1 / (1 + Math.exp(3))
|
||||
const interpolatedOpacity =
|
||||
1 / (1 + Math.exp(-6 * (store.brushSettings.opacity - 0.5))) -
|
||||
opacityConstant
|
||||
|
||||
smoothingCordsArray.value.push(point)
|
||||
|
||||
const POINTS_NR = 5
|
||||
if (smoothingCordsArray.value.length < POINTS_NR) {
|
||||
return
|
||||
}
|
||||
|
||||
let totalLength = 0
|
||||
const points = smoothingCordsArray.value
|
||||
const len = points.length - 1
|
||||
|
||||
let dx, dy
|
||||
for (let i = 0; i < len; i++) {
|
||||
dx = points[i + 1].x - points[i].x
|
||||
dy = points[i + 1].y - points[i].y
|
||||
totalLength += Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
const maxSteps = SMOOTHING_MAX_STEPS
|
||||
const minSteps = SMOOTHING_MIN_STEPS
|
||||
|
||||
const smoothing = clampSmoothingPrecision(
|
||||
store.brushSettings.smoothingPrecision
|
||||
)
|
||||
const normalizedSmoothing = (smoothing - 1) / 99
|
||||
|
||||
const stepNr = Math.round(
|
||||
Math.round(minSteps + (maxSteps - minSteps) * normalizedSmoothing)
|
||||
)
|
||||
|
||||
const distanceBetweenPoints = totalLength / stepNr
|
||||
|
||||
let interpolatedPoints = points
|
||||
|
||||
if (stepNr > 0) {
|
||||
interpolatedPoints = generateEquidistantPoints(
|
||||
smoothingCordsArray.value,
|
||||
distanceBetweenPoints
|
||||
)
|
||||
}
|
||||
|
||||
if (!initialDraw.value) {
|
||||
const spliceIndex = interpolatedPoints.findIndex(
|
||||
(p) =>
|
||||
p.x === smoothingCordsArray.value[2].x &&
|
||||
p.y === smoothingCordsArray.value[2].y
|
||||
)
|
||||
|
||||
if (spliceIndex !== -1) {
|
||||
interpolatedPoints = interpolatedPoints.slice(spliceIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of interpolatedPoints) {
|
||||
drawShape(p, interpolatedOpacity)
|
||||
}
|
||||
|
||||
if (!initialDraw.value) {
|
||||
smoothingCordsArray.value = smoothingCordsArray.value.slice(2)
|
||||
} else {
|
||||
initialDraw.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const drawLine = async (
|
||||
p1: Point,
|
||||
p2: Point,
|
||||
compositionOp: CompositionOperation
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const brush_size = store.brushSettings.size
|
||||
const distance = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2)
|
||||
const steps = Math.ceil(
|
||||
distance / ((brush_size / store.brushSettings.smoothingPrecision) * 4)
|
||||
)
|
||||
const interpolatedOpacity =
|
||||
1 / (1 + Math.exp(-6 * (store.brushSettings.opacity - 0.5))) -
|
||||
1 / (1 + Math.exp(3))
|
||||
|
||||
initShape(compositionOp)
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps
|
||||
const x = p1.x + (p2.x - p1.x) * t
|
||||
const y = p1.y + (p2.y - p1.y) * t
|
||||
const point = { x, y }
|
||||
|
||||
drawShape(point, interpolatedOpacity)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[useBrushDrawing] Failed to draw line:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const startDrawing = async (event: PointerEvent): Promise<void> => {
|
||||
isDrawing.value = true
|
||||
|
||||
try {
|
||||
let compositionOp: CompositionOperation
|
||||
const currentTool = store.currentTool
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(coords)
|
||||
|
||||
await createBrushStrokeCanvas()
|
||||
|
||||
if (currentTool === 'eraser' || event.buttons === 2) {
|
||||
compositionOp = CompositionOperation.DestinationOut
|
||||
} else {
|
||||
compositionOp = CompositionOperation.SourceOver
|
||||
}
|
||||
|
||||
if (event.shiftKey && lineStartPoint.value) {
|
||||
isDrawingLine.value = true
|
||||
await drawLine(lineStartPoint.value, coords_canvas, compositionOp)
|
||||
} else {
|
||||
isDrawingLine.value = false
|
||||
initShape(compositionOp)
|
||||
drawShape(coords_canvas)
|
||||
}
|
||||
|
||||
lineStartPoint.value = coords_canvas
|
||||
smoothingCordsArray.value = [coords_canvas]
|
||||
smoothingLastDrawTime.value = new Date()
|
||||
} catch (error) {
|
||||
console.error('[useBrushDrawing] Failed to start drawing:', error)
|
||||
|
||||
isDrawing.value = false
|
||||
isDrawingLine.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrawing = async (event: PointerEvent): Promise<void> => {
|
||||
const diff = performance.now() - smoothingLastDrawTime.value.getTime()
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(coords)
|
||||
const currentTool = store.currentTool
|
||||
|
||||
if (diff > 20 && !isDrawing.value) {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
initShape(CompositionOperation.SourceOver)
|
||||
drawShape(coords_canvas)
|
||||
smoothingCordsArray.value.push(coords_canvas)
|
||||
} catch (error) {
|
||||
console.error('[useBrushDrawing] Drawing error:', error)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
if (currentTool === 'eraser' || event.buttons === 2) {
|
||||
initShape(CompositionOperation.DestinationOut)
|
||||
} else {
|
||||
initShape(CompositionOperation.SourceOver)
|
||||
}
|
||||
|
||||
drawWithBetterSmoothing(coords_canvas)
|
||||
} catch (error) {
|
||||
console.error('[useBrushDrawing] Drawing error:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
smoothingLastDrawTime.value = new Date()
|
||||
}
|
||||
|
||||
const drawEnd = async (event: PointerEvent): Promise<void> => {
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(coords)
|
||||
|
||||
if (isDrawing.value) {
|
||||
isDrawing.value = false
|
||||
store.canvasHistory.saveState()
|
||||
lineStartPoint.value = coords_canvas
|
||||
initialDraw.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const startBrushAdjustment = async (event: PointerEvent): Promise<void> => {
|
||||
event.preventDefault()
|
||||
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(coords)
|
||||
|
||||
store.brushPreviewGradientVisible = true
|
||||
initialPoint.value = coords_canvas
|
||||
}
|
||||
|
||||
const handleBrushAdjustment = async (event: PointerEvent): Promise<void> => {
|
||||
if (!initialPoint.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const brushDeadZone = 5
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(coords)
|
||||
|
||||
const delta_x = coords_canvas.x - initialPoint.value.x
|
||||
const delta_y = coords_canvas.y - initialPoint.value.y
|
||||
|
||||
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
|
||||
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
|
||||
|
||||
let finalDeltaX = effectiveDeltaX
|
||||
let finalDeltaY = effectiveDeltaY
|
||||
|
||||
if (useDominantAxis.value) {
|
||||
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
|
||||
const threshold = 2.0
|
||||
|
||||
if (ratio > threshold) {
|
||||
finalDeltaY = 0
|
||||
} else if (ratio < 1 / threshold) {
|
||||
finalDeltaX = 0
|
||||
}
|
||||
}
|
||||
|
||||
const cappedDeltaX = Math.max(-100, Math.min(100, finalDeltaX))
|
||||
const cappedDeltaY = Math.max(-100, Math.min(100, finalDeltaY))
|
||||
|
||||
const newSize = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
100,
|
||||
store.brushSettings.size +
|
||||
(cappedDeltaX / 35) * brushAdjustmentSpeed.value
|
||||
)
|
||||
)
|
||||
|
||||
const newHardness = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
store.brushSettings.hardness -
|
||||
(cappedDeltaY / 4000) * brushAdjustmentSpeed.value
|
||||
)
|
||||
)
|
||||
|
||||
store.setBrushSize(newSize)
|
||||
store.setBrushHardness(newHardness)
|
||||
}
|
||||
|
||||
const saveBrushSettings = (): void => {
|
||||
saveBrushToCache('maskeditor_brush_settings', store.brushSettings)
|
||||
}
|
||||
|
||||
return {
|
||||
startDrawing,
|
||||
handleDrawing,
|
||||
drawEnd,
|
||||
startBrushAdjustment,
|
||||
handleBrushAdjustment,
|
||||
saveBrushSettings
|
||||
}
|
||||
}
|
||||
136
src/composables/maskeditor/useCanvasHistory.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
export function useCanvasHistory(maxStates = 20) {
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const states = ref<{ mask: ImageData; rgb: ImageData }[]>([])
|
||||
const currentStateIndex = ref(-1)
|
||||
const initialized = ref(false)
|
||||
|
||||
const canUndo = computed(
|
||||
() => states.value.length > 1 && currentStateIndex.value > 0
|
||||
)
|
||||
|
||||
const canRedo = computed(() => {
|
||||
return (
|
||||
states.value.length > 1 &&
|
||||
currentStateIndex.value < states.value.length - 1
|
||||
)
|
||||
})
|
||||
|
||||
const saveInitialState = () => {
|
||||
const maskCtx = store.maskCtx
|
||||
const rgbCtx = store.rgbCtx
|
||||
const maskCanvas = store.maskCanvas
|
||||
const rgbCanvas = store.rgbCanvas
|
||||
|
||||
if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) {
|
||||
requestAnimationFrame(saveInitialState)
|
||||
return
|
||||
}
|
||||
|
||||
if (!maskCanvas.width || !rgbCanvas.width) {
|
||||
requestAnimationFrame(saveInitialState)
|
||||
return
|
||||
}
|
||||
|
||||
states.value = []
|
||||
const maskState = maskCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
maskCanvas.width,
|
||||
maskCanvas.height
|
||||
)
|
||||
const rgbState = rgbCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
rgbCanvas.width,
|
||||
rgbCanvas.height
|
||||
)
|
||||
states.value.push({ mask: maskState, rgb: rgbState })
|
||||
currentStateIndex.value = 0
|
||||
initialized.value = true
|
||||
}
|
||||
|
||||
const saveState = () => {
|
||||
const maskCtx = store.maskCtx
|
||||
const rgbCtx = store.rgbCtx
|
||||
const maskCanvas = store.maskCanvas
|
||||
const rgbCanvas = store.rgbCanvas
|
||||
|
||||
if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) return
|
||||
|
||||
if (!initialized.value || currentStateIndex.value === -1) {
|
||||
saveInitialState()
|
||||
return
|
||||
}
|
||||
|
||||
states.value = states.value.slice(0, currentStateIndex.value + 1)
|
||||
|
||||
const maskState = maskCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
maskCanvas.width,
|
||||
maskCanvas.height
|
||||
)
|
||||
const rgbState = rgbCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
rgbCanvas.width,
|
||||
rgbCanvas.height
|
||||
)
|
||||
states.value.push({ mask: maskState, rgb: rgbState })
|
||||
currentStateIndex.value++
|
||||
|
||||
if (states.value.length > maxStates) {
|
||||
states.value.shift()
|
||||
currentStateIndex.value--
|
||||
}
|
||||
}
|
||||
|
||||
const undo = () => {
|
||||
if (!canUndo.value) {
|
||||
alert('No more undo states available')
|
||||
return
|
||||
}
|
||||
|
||||
currentStateIndex.value--
|
||||
restoreState(states.value[currentStateIndex.value])
|
||||
}
|
||||
|
||||
const redo = () => {
|
||||
if (!canRedo.value) {
|
||||
alert('No more redo states available')
|
||||
return
|
||||
}
|
||||
|
||||
currentStateIndex.value++
|
||||
restoreState(states.value[currentStateIndex.value])
|
||||
}
|
||||
|
||||
const restoreState = (state: { mask: ImageData; rgb: ImageData }) => {
|
||||
const maskCtx = store.maskCtx
|
||||
const rgbCtx = store.rgbCtx
|
||||
if (!maskCtx || !rgbCtx) return
|
||||
|
||||
maskCtx.putImageData(state.mask, 0, 0)
|
||||
rgbCtx.putImageData(state.rgb, 0, 0)
|
||||
}
|
||||
|
||||
const clearStates = () => {
|
||||
states.value = []
|
||||
currentStateIndex.value = -1
|
||||
initialized.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
canUndo,
|
||||
canRedo,
|
||||
saveInitialState,
|
||||
saveState,
|
||||
undo,
|
||||
redo,
|
||||
clearStates
|
||||
}
|
||||
}
|
||||
121
src/composables/maskeditor/useCanvasManager.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import { MaskBlendMode } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
export function useCanvasManager() {
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const prepareMask = async (
|
||||
image: HTMLImageElement,
|
||||
maskCanvasEl: HTMLCanvasElement,
|
||||
maskContext: CanvasRenderingContext2D
|
||||
): Promise<void> => {
|
||||
const maskColor = store.maskColor
|
||||
|
||||
maskContext.drawImage(image, 0, 0, maskCanvasEl.width, maskCanvasEl.height)
|
||||
|
||||
const maskData = maskContext.getImageData(
|
||||
0,
|
||||
0,
|
||||
maskCanvasEl.width,
|
||||
maskCanvasEl.height
|
||||
)
|
||||
|
||||
for (let i = 0; i < maskData.data.length; i += 4) {
|
||||
const alpha = maskData.data[i + 3]
|
||||
maskData.data[i] = maskColor.r
|
||||
maskData.data[i + 1] = maskColor.g
|
||||
maskData.data[i + 2] = maskColor.b
|
||||
maskData.data[i + 3] = 255 - alpha
|
||||
}
|
||||
|
||||
maskContext.globalCompositeOperation = 'source-over'
|
||||
maskContext.putImageData(maskData, 0, 0)
|
||||
}
|
||||
|
||||
const invalidateCanvas = async (
|
||||
origImage: HTMLImageElement,
|
||||
maskImage: HTMLImageElement,
|
||||
paintImage: HTMLImageElement | null
|
||||
): Promise<void> => {
|
||||
const { imgCanvas, maskCanvas, rgbCanvas, imgCtx, maskCtx, rgbCtx } = store
|
||||
|
||||
if (
|
||||
!imgCanvas ||
|
||||
!maskCanvas ||
|
||||
!rgbCanvas ||
|
||||
!imgCtx ||
|
||||
!maskCtx ||
|
||||
!rgbCtx
|
||||
) {
|
||||
throw new Error('Canvas elements or contexts not available')
|
||||
}
|
||||
|
||||
imgCanvas.width = origImage.width
|
||||
imgCanvas.height = origImage.height
|
||||
maskCanvas.width = origImage.width
|
||||
maskCanvas.height = origImage.height
|
||||
rgbCanvas.width = origImage.width
|
||||
rgbCanvas.height = origImage.height
|
||||
|
||||
imgCtx.drawImage(origImage, 0, 0, origImage.width, origImage.height)
|
||||
|
||||
if (paintImage) {
|
||||
rgbCtx.drawImage(paintImage, 0, 0, paintImage.width, paintImage.height)
|
||||
}
|
||||
|
||||
await prepareMask(maskImage, maskCanvas, maskCtx)
|
||||
}
|
||||
|
||||
const setCanvasBackground = (): void => {
|
||||
const canvasBackground = store.canvasBackground
|
||||
|
||||
if (!canvasBackground) return
|
||||
|
||||
if (store.maskBlendMode === MaskBlendMode.Black) {
|
||||
canvasBackground.style.backgroundColor = 'rgba(0,0,0,1)'
|
||||
} else if (store.maskBlendMode === MaskBlendMode.White) {
|
||||
canvasBackground.style.backgroundColor = 'rgba(255,255,255,1)'
|
||||
} else if (store.maskBlendMode === MaskBlendMode.Negative) {
|
||||
canvasBackground.style.backgroundColor = 'rgba(255,255,255,1)'
|
||||
}
|
||||
}
|
||||
|
||||
const updateMaskColor = async (): Promise<void> => {
|
||||
const { maskCanvas, maskCtx, maskColor, maskBlendMode, maskOpacity } = store
|
||||
|
||||
if (!maskCanvas || !maskCtx) return
|
||||
|
||||
if (maskBlendMode === MaskBlendMode.Negative) {
|
||||
maskCanvas.style.mixBlendMode = 'difference'
|
||||
maskCanvas.style.opacity = '1'
|
||||
} else {
|
||||
maskCanvas.style.mixBlendMode = 'initial'
|
||||
maskCanvas.style.opacity = String(maskOpacity)
|
||||
}
|
||||
|
||||
maskCtx.fillStyle = `rgb(${maskColor.r}, ${maskColor.g}, ${maskColor.b})`
|
||||
|
||||
setCanvasBackground()
|
||||
|
||||
const maskData = maskCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
maskCanvas.width,
|
||||
maskCanvas.height
|
||||
)
|
||||
|
||||
for (let i = 0; i < maskData.data.length; i += 4) {
|
||||
maskData.data[i] = maskColor.r
|
||||
maskData.data[i + 1] = maskColor.g
|
||||
maskData.data[i + 2] = maskColor.b
|
||||
}
|
||||
|
||||
maskCtx.putImageData(maskData, 0, 0)
|
||||
}
|
||||
|
||||
return {
|
||||
invalidateCanvas,
|
||||
updateMaskColor
|
||||
}
|
||||
}
|
||||
486
src/composables/maskeditor/useCanvasTools.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
|
||||
import type { Point } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
const getPixelAlpha = (
|
||||
data: Uint8ClampedArray,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number
|
||||
): number => {
|
||||
return data[(y * width + x) * 4 + 3]
|
||||
}
|
||||
|
||||
const getPixelColor = (
|
||||
data: Uint8ClampedArray,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number
|
||||
): { r: number; g: number; b: number } => {
|
||||
const index = (y * width + x) * 4
|
||||
return {
|
||||
r: data[index],
|
||||
g: data[index + 1],
|
||||
b: data[index + 2]
|
||||
}
|
||||
}
|
||||
|
||||
const setPixel = (
|
||||
data: Uint8ClampedArray,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
alpha: number,
|
||||
color: { r: number; g: number; b: number }
|
||||
): void => {
|
||||
const index = (y * width + x) * 4
|
||||
data[index] = color.r
|
||||
data[index + 1] = color.g
|
||||
data[index + 2] = color.b
|
||||
data[index + 3] = alpha
|
||||
}
|
||||
|
||||
// Color comparison utilities
|
||||
const rgbToHSL = (
|
||||
r: number,
|
||||
g: number,
|
||||
b: number
|
||||
): { h: number; s: number; l: number } => {
|
||||
r /= 255
|
||||
g /= 255
|
||||
b /= 255
|
||||
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
let h = 0
|
||||
let s = 0
|
||||
const l = (max + min) / 2
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0)
|
||||
break
|
||||
case g:
|
||||
h = (b - r) / d + 2
|
||||
break
|
||||
case b:
|
||||
h = (r - g) / d + 4
|
||||
break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
|
||||
return {
|
||||
h: h * 360,
|
||||
s: s * 100,
|
||||
l: l * 100
|
||||
}
|
||||
}
|
||||
|
||||
const rgbToLab = (rgb: {
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
}): {
|
||||
l: number
|
||||
a: number
|
||||
b: number
|
||||
} => {
|
||||
let r = rgb.r / 255
|
||||
let g = rgb.g / 255
|
||||
let b = rgb.b / 255
|
||||
|
||||
r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92
|
||||
g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92
|
||||
b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92
|
||||
|
||||
r *= 100
|
||||
g *= 100
|
||||
b *= 100
|
||||
|
||||
const x = r * 0.4124 + g * 0.3576 + b * 0.1805
|
||||
const y = r * 0.2126 + g * 0.7152 + b * 0.0722
|
||||
const z = r * 0.0193 + g * 0.1192 + b * 0.9505
|
||||
|
||||
const xn = 95.047
|
||||
const yn = 100.0
|
||||
const zn = 108.883
|
||||
|
||||
const xyz = [x / xn, y / yn, z / zn]
|
||||
for (let i = 0; i < xyz.length; i++) {
|
||||
xyz[i] =
|
||||
xyz[i] > 0.008856 ? Math.pow(xyz[i], 1 / 3) : 7.787 * xyz[i] + 16 / 116
|
||||
}
|
||||
|
||||
return {
|
||||
l: 116 * xyz[1] - 16,
|
||||
a: 500 * (xyz[0] - xyz[1]),
|
||||
b: 200 * (xyz[1] - xyz[2])
|
||||
}
|
||||
}
|
||||
|
||||
const isPixelInRangeSimple = (
|
||||
pixel: { r: number; g: number; b: number },
|
||||
target: { r: number; g: number; b: number },
|
||||
tolerance: number
|
||||
): boolean => {
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(pixel.r - target.r, 2) +
|
||||
Math.pow(pixel.g - target.g, 2) +
|
||||
Math.pow(pixel.b - target.b, 2)
|
||||
)
|
||||
return distance <= tolerance
|
||||
}
|
||||
|
||||
const isPixelInRangeHSL = (
|
||||
pixel: { r: number; g: number; b: number },
|
||||
target: { r: number; g: number; b: number },
|
||||
tolerance: number
|
||||
): boolean => {
|
||||
const pixelHSL = rgbToHSL(pixel.r, pixel.g, pixel.b)
|
||||
const targetHSL = rgbToHSL(target.r, target.g, target.b)
|
||||
|
||||
const hueDiff = Math.abs(pixelHSL.h - targetHSL.h)
|
||||
const satDiff = Math.abs(pixelHSL.s - targetHSL.s)
|
||||
const lightDiff = Math.abs(pixelHSL.l - targetHSL.l)
|
||||
|
||||
const distance = Math.sqrt(
|
||||
Math.pow((hueDiff / 360) * 255, 2) +
|
||||
Math.pow((satDiff / 100) * 255, 2) +
|
||||
Math.pow((lightDiff / 100) * 255, 2)
|
||||
)
|
||||
return distance <= tolerance
|
||||
}
|
||||
|
||||
const isPixelInRangeLab = (
|
||||
pixel: { r: number; g: number; b: number },
|
||||
target: { r: number; g: number; b: number },
|
||||
tolerance: number
|
||||
): boolean => {
|
||||
const pixelLab = rgbToLab(pixel)
|
||||
const targetLab = rgbToLab(target)
|
||||
|
||||
const deltaE = Math.sqrt(
|
||||
Math.pow(pixelLab.l - targetLab.l, 2) +
|
||||
Math.pow(pixelLab.a - targetLab.a, 2) +
|
||||
Math.pow(pixelLab.b - targetLab.b, 2)
|
||||
)
|
||||
|
||||
const normalizedDeltaE = (deltaE / 100) * 255
|
||||
return normalizedDeltaE <= tolerance
|
||||
}
|
||||
|
||||
const isPixelInRange = (
|
||||
pixel: { r: number; g: number; b: number },
|
||||
target: { r: number; g: number; b: number },
|
||||
tolerance: number,
|
||||
method: ColorComparisonMethod
|
||||
): boolean => {
|
||||
switch (method) {
|
||||
case ColorComparisonMethod.Simple:
|
||||
return isPixelInRangeSimple(pixel, target, tolerance)
|
||||
case ColorComparisonMethod.HSL:
|
||||
return isPixelInRangeHSL(pixel, target, tolerance)
|
||||
case ColorComparisonMethod.LAB:
|
||||
return isPixelInRangeLab(pixel, target, tolerance)
|
||||
default:
|
||||
return isPixelInRangeSimple(pixel, target, tolerance)
|
||||
}
|
||||
}
|
||||
|
||||
export function useCanvasTools() {
|
||||
const store = useMaskEditorStore()
|
||||
const lastColorSelectPoint = ref<Point | null>(null)
|
||||
|
||||
const paintBucketFill = (point: Point): void => {
|
||||
const ctx = store.maskCtx
|
||||
const canvas = store.maskCanvas
|
||||
if (!ctx || !canvas) return
|
||||
|
||||
const startX = Math.floor(point.x)
|
||||
const startY = Math.floor(point.y)
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
|
||||
if (startX < 0 || startX >= width || startY < 0 || startY >= height) {
|
||||
return
|
||||
}
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, width, height)
|
||||
const data = imageData.data
|
||||
|
||||
const targetAlpha = getPixelAlpha(data, startX, startY, width)
|
||||
const isFillMode = targetAlpha !== 255
|
||||
|
||||
if (targetAlpha === -1) return
|
||||
|
||||
const maskColor = store.maskColor
|
||||
const tolerance = store.paintBucketTolerance
|
||||
const fillOpacity = Math.floor((store.fillOpacity / 100) * 255)
|
||||
|
||||
const stack: Array<[number, number]> = []
|
||||
const visited = new Uint8Array(width * height)
|
||||
|
||||
const shouldProcessPixel = (
|
||||
currentAlpha: number,
|
||||
targetAlpha: number,
|
||||
tolerance: number,
|
||||
isFillMode: boolean
|
||||
): boolean => {
|
||||
if (currentAlpha === -1) return false
|
||||
|
||||
if (isFillMode) {
|
||||
return (
|
||||
currentAlpha !== 255 &&
|
||||
Math.abs(currentAlpha - targetAlpha) <= tolerance
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
currentAlpha === 255 ||
|
||||
Math.abs(currentAlpha - targetAlpha) <= tolerance
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldProcessPixel(targetAlpha, targetAlpha, tolerance, isFillMode)) {
|
||||
stack.push([startX, startY])
|
||||
}
|
||||
|
||||
while (stack.length > 0) {
|
||||
const [x, y] = stack.pop()!
|
||||
const visitedIndex = y * width + x
|
||||
|
||||
if (visited[visitedIndex]) continue
|
||||
|
||||
const currentAlpha = getPixelAlpha(data, x, y, width)
|
||||
if (
|
||||
!shouldProcessPixel(currentAlpha, targetAlpha, tolerance, isFillMode)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
visited[visitedIndex] = 1
|
||||
setPixel(data, x, y, width, isFillMode ? fillOpacity : 0, maskColor)
|
||||
|
||||
const checkNeighbor = (nx: number, ny: number) => {
|
||||
if (nx < 0 || nx >= width || ny < 0 || ny >= height) return
|
||||
if (!visited[ny * width + nx]) {
|
||||
const alpha = getPixelAlpha(data, nx, ny, width)
|
||||
if (shouldProcessPixel(alpha, targetAlpha, tolerance, isFillMode)) {
|
||||
stack.push([nx, ny])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkNeighbor(x - 1, y)
|
||||
checkNeighbor(x + 1, y)
|
||||
checkNeighbor(x, y - 1)
|
||||
checkNeighbor(x, y + 1)
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0)
|
||||
store.canvasHistory.saveState()
|
||||
}
|
||||
|
||||
const colorSelectFill = async (point: Point): Promise<void> => {
|
||||
const maskCtx = store.maskCtx
|
||||
const imgCtx = store.imgCtx
|
||||
const imgCanvas = store.imgCanvas
|
||||
|
||||
if (!maskCtx || !imgCtx || !imgCanvas) return
|
||||
|
||||
const width = imgCanvas.width
|
||||
const height = imgCanvas.height
|
||||
lastColorSelectPoint.value = point
|
||||
|
||||
const maskData = maskCtx.getImageData(0, 0, width, height)
|
||||
const maskDataArray = maskData.data
|
||||
const imageDataArray = imgCtx.getImageData(0, 0, width, height).data
|
||||
|
||||
const tolerance = store.colorSelectTolerance
|
||||
const method = store.colorComparisonMethod
|
||||
const maskColor = store.maskColor
|
||||
const selectOpacity = Math.floor((store.selectionOpacity / 100) * 255)
|
||||
const applyWholeImage = store.applyWholeImage
|
||||
const maskBoundary = store.maskBoundary
|
||||
const maskTolerance = store.maskTolerance
|
||||
|
||||
if (applyWholeImage) {
|
||||
const targetPixel = getPixelColor(
|
||||
imageDataArray,
|
||||
Math.floor(point.x),
|
||||
Math.floor(point.y),
|
||||
width
|
||||
)
|
||||
|
||||
const CHUNK_SIZE = 10000
|
||||
for (let i = 0; i < width * height; i += CHUNK_SIZE) {
|
||||
const endIndex = Math.min(i + CHUNK_SIZE, width * height)
|
||||
for (let pixelIndex = i; pixelIndex < endIndex; pixelIndex++) {
|
||||
const x = pixelIndex % width
|
||||
const y = Math.floor(pixelIndex / width)
|
||||
if (
|
||||
isPixelInRange(
|
||||
getPixelColor(imageDataArray, x, y, width),
|
||||
targetPixel,
|
||||
tolerance,
|
||||
method
|
||||
)
|
||||
) {
|
||||
setPixel(maskDataArray, x, y, width, selectOpacity, maskColor)
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
} else {
|
||||
const startX = Math.floor(point.x)
|
||||
const startY = Math.floor(point.y)
|
||||
|
||||
if (startX < 0 || startX >= width || startY < 0 || startY >= height) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetPixel = getPixelColor(imageDataArray, startX, startY, width)
|
||||
const stack: Array<[number, number]> = []
|
||||
const visited = new Uint8Array(width * height)
|
||||
|
||||
stack.push([startX, startY])
|
||||
|
||||
while (stack.length > 0) {
|
||||
const [x, y] = stack.pop()!
|
||||
const visitedIndex = y * width + x
|
||||
|
||||
if (
|
||||
visited[visitedIndex] ||
|
||||
!isPixelInRange(
|
||||
getPixelColor(imageDataArray, x, y, width),
|
||||
targetPixel,
|
||||
tolerance,
|
||||
method
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
visited[visitedIndex] = 1
|
||||
setPixel(maskDataArray, x, y, width, selectOpacity, maskColor)
|
||||
|
||||
const checkNeighbor = (nx: number, ny: number) => {
|
||||
if (nx < 0 || nx >= width || ny < 0 || ny >= height) return
|
||||
if (visited[ny * width + nx]) return
|
||||
if (
|
||||
!isPixelInRange(
|
||||
getPixelColor(imageDataArray, nx, ny, width),
|
||||
targetPixel,
|
||||
tolerance,
|
||||
method
|
||||
)
|
||||
)
|
||||
return
|
||||
if (
|
||||
maskBoundary &&
|
||||
255 - getPixelAlpha(maskDataArray, nx, ny, width) <= maskTolerance
|
||||
)
|
||||
return
|
||||
|
||||
stack.push([nx, ny])
|
||||
}
|
||||
|
||||
checkNeighbor(x - 1, y)
|
||||
checkNeighbor(x + 1, y)
|
||||
checkNeighbor(x, y - 1)
|
||||
checkNeighbor(x, y + 1)
|
||||
}
|
||||
}
|
||||
|
||||
maskCtx.putImageData(maskData, 0, 0)
|
||||
store.canvasHistory.saveState()
|
||||
}
|
||||
|
||||
const invertMask = (): void => {
|
||||
const ctx = store.maskCtx
|
||||
const canvas = store.maskCanvas
|
||||
if (!ctx || !canvas) return
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
const data = imageData.data
|
||||
|
||||
let maskR = 0,
|
||||
maskG = 0,
|
||||
maskB = 0
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i + 3] > 0) {
|
||||
maskR = data[i]
|
||||
maskG = data[i + 1]
|
||||
maskB = data[i + 2]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const alpha = data[i + 3]
|
||||
data[i + 3] = 255 - alpha
|
||||
|
||||
if (alpha === 0) {
|
||||
data[i] = maskR
|
||||
data[i + 1] = maskG
|
||||
data[i + 2] = maskB
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0)
|
||||
store.canvasHistory.saveState()
|
||||
}
|
||||
|
||||
const clearMask = (): void => {
|
||||
const maskCtx = store.maskCtx
|
||||
const maskCanvas = store.maskCanvas
|
||||
const rgbCtx = store.rgbCtx
|
||||
const rgbCanvas = store.rgbCanvas
|
||||
|
||||
if (maskCtx && maskCanvas) {
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height)
|
||||
}
|
||||
if (rgbCtx && rgbCanvas) {
|
||||
rgbCtx.clearRect(0, 0, rgbCanvas.width, rgbCanvas.height)
|
||||
}
|
||||
store.canvasHistory.saveState()
|
||||
}
|
||||
|
||||
const clearLastColorSelectPoint = () => {
|
||||
lastColorSelectPoint.value = null
|
||||
}
|
||||
|
||||
watch(
|
||||
[
|
||||
() => store.colorSelectTolerance,
|
||||
() => store.colorComparisonMethod,
|
||||
() => store.selectionOpacity
|
||||
],
|
||||
async () => {
|
||||
if (
|
||||
lastColorSelectPoint.value &&
|
||||
store.colorSelectLivePreview &&
|
||||
store.canUndo
|
||||
) {
|
||||
store.canvasHistory.undo()
|
||||
await colorSelectFill(lastColorSelectPoint.value)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
paintBucketFill,
|
||||
|
||||
colorSelectFill,
|
||||
clearLastColorSelectPoint,
|
||||
|
||||
invertMask,
|
||||
clearMask
|
||||
}
|
||||
}
|
||||
79
src/composables/maskeditor/useCoordinateTransform.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { unref } from 'vue'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
function useCoordinateTransformInternal() {
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const screenToCanvas = (clientPoint: Point): Point => {
|
||||
const pointerZoneEl = unref(store.pointerZone)
|
||||
const canvasContainerEl = unref(store.canvasContainer)
|
||||
const canvasEl = unref(store.maskCanvas)
|
||||
|
||||
if (!pointerZoneEl || !canvasContainerEl || !canvasEl) {
|
||||
console.warn('screenToCanvas called before elements are available')
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
const pointerZoneRect = pointerZoneEl.getBoundingClientRect()
|
||||
const canvasContainerRect = canvasContainerEl.getBoundingClientRect()
|
||||
const canvasRect = canvasEl.getBoundingClientRect()
|
||||
|
||||
const absoluteX = pointerZoneRect.left + clientPoint.x
|
||||
const absoluteY = pointerZoneRect.top + clientPoint.y
|
||||
|
||||
const canvasX = absoluteX - canvasContainerRect.left
|
||||
const canvasY = absoluteY - canvasContainerRect.top
|
||||
|
||||
const scaleX = canvasEl.width / canvasRect.width
|
||||
const scaleY = canvasEl.height / canvasRect.height
|
||||
|
||||
const x = canvasX * scaleX
|
||||
const y = canvasY * scaleY
|
||||
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
const canvasToScreen = (canvasPoint: Point): Point => {
|
||||
const pointerZoneEl = unref(store.pointerZone)
|
||||
const canvasContainerEl = unref(store.canvasContainer)
|
||||
const canvasEl = unref(store.maskCanvas)
|
||||
|
||||
if (!pointerZoneEl || !canvasContainerEl || !canvasEl) {
|
||||
console.warn('canvasToScreen called before elements are available')
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
const pointerZoneRect = pointerZoneEl.getBoundingClientRect()
|
||||
const canvasContainerRect = canvasContainerEl.getBoundingClientRect()
|
||||
const canvasRect = canvasEl.getBoundingClientRect()
|
||||
|
||||
const scaleX = canvasRect.width / canvasEl.width
|
||||
const scaleY = canvasRect.height / canvasEl.height
|
||||
|
||||
const displayX = canvasPoint.x * scaleX
|
||||
const displayY = canvasPoint.y * scaleY
|
||||
|
||||
const absoluteX = canvasContainerRect.left + displayX
|
||||
const absoluteY = canvasContainerRect.top + displayY
|
||||
|
||||
const x = absoluteX - pointerZoneRect.left
|
||||
const y = absoluteY - pointerZoneRect.top
|
||||
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
return {
|
||||
screenToCanvas,
|
||||
canvasToScreen
|
||||
}
|
||||
}
|
||||
|
||||
export const useCoordinateTransform = createSharedComposable(
|
||||
useCoordinateTransformInternal
|
||||
)
|
||||
54
src/composables/maskeditor/useImageLoader.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
|
||||
|
||||
function useImageLoaderInternal() {
|
||||
const store = useMaskEditorStore()
|
||||
const dataStore = useMaskEditorDataStore()
|
||||
const canvasManager = useCanvasManager()
|
||||
|
||||
const loadImages = async (): Promise<HTMLImageElement> => {
|
||||
const inputData = dataStore.inputData
|
||||
|
||||
if (!inputData) {
|
||||
throw new Error('No input data available in dataStore')
|
||||
}
|
||||
|
||||
const { imgCanvas, maskCanvas, rgbCanvas, imgCtx, maskCtx } = store
|
||||
|
||||
if (!imgCanvas || !maskCanvas || !rgbCanvas || !imgCtx || !maskCtx) {
|
||||
throw new Error('Canvas elements or contexts not available')
|
||||
}
|
||||
|
||||
imgCtx.clearRect(0, 0, imgCanvas.width, imgCanvas.height)
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height)
|
||||
|
||||
const baseImage = inputData.baseLayer.image
|
||||
const maskImage = inputData.maskLayer.image
|
||||
const paintImage = inputData.paintLayer?.image
|
||||
|
||||
maskCanvas.width = baseImage.width
|
||||
maskCanvas.height = baseImage.height
|
||||
rgbCanvas.width = baseImage.width
|
||||
rgbCanvas.height = baseImage.height
|
||||
|
||||
store.image = baseImage
|
||||
|
||||
await canvasManager.invalidateCanvas(
|
||||
baseImage,
|
||||
maskImage,
|
||||
paintImage || null
|
||||
)
|
||||
|
||||
await canvasManager.updateMaskColor()
|
||||
|
||||
return baseImage
|
||||
}
|
||||
|
||||
return {
|
||||
loadImages
|
||||
}
|
||||
}
|
||||
|
||||
export const useImageLoader = createSharedComposable(useImageLoaderInternal)
|
||||
62
src/composables/maskeditor/useKeyboard.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ref } from 'vue'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
export function useKeyboard() {
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const keysDown = ref<string[]>([])
|
||||
|
||||
const isKeyDown = (key: string): boolean => {
|
||||
return keysDown.value.includes(key)
|
||||
}
|
||||
|
||||
const clearKeys = (): void => {
|
||||
keysDown.value = []
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if (!keysDown.value.includes(event.key)) {
|
||||
keysDown.value.push(event.key)
|
||||
}
|
||||
|
||||
if (event.key === ' ') {
|
||||
event.preventDefault()
|
||||
const activeElement = document.activeElement as HTMLElement
|
||||
if (activeElement && activeElement.blur) {
|
||||
activeElement.blur()
|
||||
}
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey) {
|
||||
const key = event.key.toUpperCase()
|
||||
|
||||
if ((key === 'Y' && !event.shiftKey) || (key === 'Z' && event.shiftKey)) {
|
||||
store.canvasHistory.redo()
|
||||
} else if (key === 'Z' && !event.shiftKey) {
|
||||
store.canvasHistory.undo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent): void => {
|
||||
keysDown.value = keysDown.value.filter((key) => key !== event.key)
|
||||
}
|
||||
|
||||
const addListeners = (): void => {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('keyup', handleKeyUp)
|
||||
window.addEventListener('blur', clearKeys)
|
||||
}
|
||||
|
||||
const removeListeners = (): void => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('keyup', handleKeyUp)
|
||||
window.removeEventListener('blur', clearKeys)
|
||||
}
|
||||
|
||||
return {
|
||||
isKeyDown,
|
||||
addListeners,
|
||||
removeListeners
|
||||
}
|
||||
}
|
||||
310
src/composables/maskeditor/useMaskEditorLoader.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
|
||||
import type { ImageRef, ImageLayer } from '@/stores/maskEditorDataStore'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
// Private image utility functions
|
||||
interface ImageLayerFilenames {
|
||||
maskedImage: string
|
||||
paint: string
|
||||
paintedImage: string
|
||||
paintedMaskedImage: string
|
||||
}
|
||||
|
||||
interface MaskLayersResponse {
|
||||
painted_masked?: string
|
||||
painted?: string
|
||||
paint?: string
|
||||
mask?: string
|
||||
}
|
||||
|
||||
const paintedMaskedImagePrefix = 'clipspace-painted-masked-'
|
||||
|
||||
function imageLayerFilenamesIfApplicable(
|
||||
inputImageFilename: string
|
||||
): ImageLayerFilenames | undefined {
|
||||
const isPaintedMaskedImageFilename = inputImageFilename.startsWith(
|
||||
paintedMaskedImagePrefix
|
||||
)
|
||||
if (!isPaintedMaskedImageFilename) return undefined
|
||||
const suffix = inputImageFilename.slice(paintedMaskedImagePrefix.length)
|
||||
const timestamp = parseInt(suffix.split('.')[0], 10)
|
||||
return {
|
||||
maskedImage: `clipspace-mask-${timestamp}.png`,
|
||||
paint: `clipspace-paint-${timestamp}.png`,
|
||||
paintedImage: `clipspace-painted-${timestamp}.png`,
|
||||
paintedMaskedImage: `${paintedMaskedImagePrefix}${timestamp}.png`
|
||||
}
|
||||
}
|
||||
|
||||
function toRef(filename: string): ImageRef {
|
||||
return {
|
||||
filename,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
}
|
||||
}
|
||||
|
||||
function mkFileUrl(props: { ref: ImageRef; preview?: boolean }): string {
|
||||
const params = new URLSearchParams()
|
||||
params.set('filename', props.ref.filename)
|
||||
if (props.ref.subfolder) {
|
||||
params.set('subfolder', props.ref.subfolder)
|
||||
}
|
||||
if (props.ref.type) {
|
||||
params.set('type', props.ref.type)
|
||||
}
|
||||
|
||||
const pathPlusQueryParams = api.apiURL(
|
||||
'/view?' +
|
||||
params.toString() +
|
||||
app.getPreviewFormatParam() +
|
||||
app.getRandParam()
|
||||
)
|
||||
const imageElement = new Image()
|
||||
imageElement.crossOrigin = 'anonymous'
|
||||
imageElement.src = pathPlusQueryParams
|
||||
return imageElement.src
|
||||
}
|
||||
|
||||
export function useMaskEditorLoader() {
|
||||
const dataStore = useMaskEditorDataStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const loadFromNode = async (node: LGraphNode): Promise<void> => {
|
||||
dataStore.setLoading(true)
|
||||
|
||||
try {
|
||||
validateNode(node)
|
||||
|
||||
const nodeImageUrl = getNodeImageUrl(node)
|
||||
|
||||
const nodeImageRef = parseImageRef(nodeImageUrl)
|
||||
|
||||
let widgetFilename: string | undefined
|
||||
if (node.widgets) {
|
||||
const imageWidget = node.widgets.find((w) => w.name === 'image')
|
||||
if (
|
||||
imageWidget &&
|
||||
typeof imageWidget.value === 'object' &&
|
||||
imageWidget.value &&
|
||||
'filename' in imageWidget.value &&
|
||||
typeof imageWidget.value.filename === 'string'
|
||||
) {
|
||||
widgetFilename = imageWidget.value.filename
|
||||
}
|
||||
}
|
||||
|
||||
const fileToQuery = widgetFilename || nodeImageRef.filename
|
||||
|
||||
let maskLayersFromApi: MaskLayersResponse | undefined
|
||||
if (isCloud) {
|
||||
try {
|
||||
const response = await api.fetchApi(
|
||||
`/files/mask-layers?filename=${fileToQuery}`
|
||||
)
|
||||
if (response.ok) {
|
||||
maskLayersFromApi = await response.json()
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback to pattern matching if API call fails
|
||||
}
|
||||
}
|
||||
|
||||
let imageLayerFilenames = imageLayerFilenamesIfApplicable(
|
||||
nodeImageRef.filename
|
||||
)
|
||||
|
||||
if (maskLayersFromApi) {
|
||||
const baseFile =
|
||||
maskLayersFromApi.painted_masked || maskLayersFromApi.painted
|
||||
|
||||
if (baseFile) {
|
||||
imageLayerFilenames = {
|
||||
maskedImage: baseFile,
|
||||
paint: maskLayersFromApi.paint || '',
|
||||
paintedImage: maskLayersFromApi.painted || '',
|
||||
paintedMaskedImage: maskLayersFromApi.painted_masked || baseFile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baseImageUrl = imageLayerFilenames?.maskedImage
|
||||
? mkFileUrl({ ref: toRef(imageLayerFilenames.maskedImage) })
|
||||
: nodeImageUrl
|
||||
|
||||
const sourceRef = imageLayerFilenames?.maskedImage
|
||||
? parseImageRef(baseImageUrl)
|
||||
: nodeImageRef
|
||||
|
||||
let paintLayerUrl: string | null = null
|
||||
if (maskLayersFromApi?.paint) {
|
||||
paintLayerUrl = mkFileUrl({ ref: toRef(maskLayersFromApi.paint) })
|
||||
} else if (imageLayerFilenames?.paint) {
|
||||
paintLayerUrl = mkFileUrl({ ref: toRef(imageLayerFilenames.paint) })
|
||||
}
|
||||
|
||||
const [baseLayer, maskLayer, paintLayer] = await Promise.all([
|
||||
loadImageLayer(baseImageUrl, 'rgb'),
|
||||
loadImageLayer(baseImageUrl, 'a'),
|
||||
paintLayerUrl
|
||||
? loadPaintLayer(paintLayerUrl)
|
||||
: Promise.resolve(undefined)
|
||||
])
|
||||
|
||||
dataStore.inputData = {
|
||||
baseLayer,
|
||||
maskLayer,
|
||||
paintLayer,
|
||||
sourceRef,
|
||||
nodeId: node.id
|
||||
}
|
||||
|
||||
dataStore.sourceNode = node
|
||||
dataStore.setLoading(false)
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to load from node'
|
||||
console.error('[MaskEditorLoader]', errorMessage, error)
|
||||
dataStore.setLoading(false, errorMessage)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function validateNode(node: LGraphNode): void {
|
||||
if (!node) {
|
||||
throw new Error('Node is null or undefined')
|
||||
}
|
||||
|
||||
const hasImages = node.imgs?.length || node.previewMediaType === 'image'
|
||||
if (!hasImages) {
|
||||
throw new Error('Node has no images')
|
||||
}
|
||||
}
|
||||
|
||||
function getNodeImageUrl(node: LGraphNode): string {
|
||||
if (node.images?.[0]) {
|
||||
const img = node.images[0]
|
||||
const params = new URLSearchParams({
|
||||
filename: img.filename,
|
||||
type: img.type || 'output',
|
||||
subfolder: img.subfolder || ''
|
||||
})
|
||||
return api.apiURL(`/view?${params.toString()}`)
|
||||
}
|
||||
|
||||
const outputs = nodeOutputStore.getNodeOutputs(node)
|
||||
if (outputs?.images?.[0]) {
|
||||
const img = outputs.images[0]
|
||||
if (!img.filename) {
|
||||
throw new Error('nodeOutputStore image missing filename')
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('filename', img.filename)
|
||||
params.set('type', img.type || 'output')
|
||||
params.set('subfolder', img.subfolder || '')
|
||||
return api.apiURL(`/view?${params.toString()}`)
|
||||
}
|
||||
|
||||
if (node.imgs?.length) {
|
||||
const index = node.imageIndex ?? 0
|
||||
const imgSrc = node.imgs[index].src
|
||||
|
||||
if (imgSrc && !imgSrc.startsWith('data:')) {
|
||||
return imgSrc
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unable to get image URL from node')
|
||||
}
|
||||
|
||||
function parseImageRef(url: string): ImageRef {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const filename = urlObj.searchParams.get('filename')
|
||||
|
||||
if (!filename) {
|
||||
throw new Error('Image URL missing filename parameter')
|
||||
}
|
||||
|
||||
return {
|
||||
filename,
|
||||
subfolder: urlObj.searchParams.get('subfolder') || undefined,
|
||||
type: urlObj.searchParams.get('type') || undefined
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
const urlObj = new URL(url, window.location.origin)
|
||||
const filename = urlObj.searchParams.get('filename')
|
||||
|
||||
if (!filename) {
|
||||
throw new Error('Image URL missing filename parameter')
|
||||
}
|
||||
|
||||
return {
|
||||
filename,
|
||||
subfolder: urlObj.searchParams.get('subfolder') || undefined,
|
||||
type: urlObj.searchParams.get('type') || undefined
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid image URL: ${url}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadImageLayer(
|
||||
url: string,
|
||||
channel?: 'rgb' | 'a'
|
||||
): Promise<ImageLayer> {
|
||||
let urlObj: URL
|
||||
try {
|
||||
urlObj = new URL(url)
|
||||
} catch {
|
||||
urlObj = new URL(url, window.location.origin)
|
||||
}
|
||||
|
||||
if (channel) {
|
||||
urlObj.searchParams.delete('channel')
|
||||
urlObj.searchParams.set('channel', channel)
|
||||
}
|
||||
|
||||
const finalUrl = urlObj.toString()
|
||||
const image = await loadImage(finalUrl)
|
||||
|
||||
return { image, url: finalUrl }
|
||||
}
|
||||
|
||||
function loadImage(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = () => reject(new Error(`Failed to load image: ${url}`))
|
||||
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
async function loadPaintLayer(url: string): Promise<ImageLayer> {
|
||||
let urlObj: URL
|
||||
try {
|
||||
urlObj = new URL(url)
|
||||
} catch {
|
||||
urlObj = new URL(url, window.location.origin)
|
||||
}
|
||||
|
||||
const finalUrl = urlObj.toString()
|
||||
const image = await loadImage(finalUrl)
|
||||
|
||||
return { image, url: finalUrl }
|
||||
}
|
||||
|
||||
return {
|
||||
loadFromNode
|
||||
}
|
||||
}
|
||||
401
src/composables/maskeditor/useMaskEditorSaver.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import type {
|
||||
EditorOutputData,
|
||||
EditorOutputLayer,
|
||||
ImageRef
|
||||
} from '@/stores/maskEditorDataStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
// Private layer filename functions
|
||||
interface ImageLayerFilenames {
|
||||
maskedImage: string
|
||||
paint: string
|
||||
paintedImage: string
|
||||
paintedMaskedImage: string
|
||||
}
|
||||
|
||||
function imageLayerFilenamesByTimestamp(
|
||||
timestamp: number
|
||||
): ImageLayerFilenames {
|
||||
return {
|
||||
maskedImage: `clipspace-mask-${timestamp}.png`,
|
||||
paint: `clipspace-paint-${timestamp}.png`,
|
||||
paintedImage: `clipspace-painted-${timestamp}.png`,
|
||||
paintedMaskedImage: `clipspace-painted-masked-${timestamp}.png`
|
||||
}
|
||||
}
|
||||
|
||||
export function useMaskEditorSaver() {
|
||||
const dataStore = useMaskEditorDataStore()
|
||||
const editorStore = useMaskEditorStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
const sourceNode = dataStore.sourceNode as LGraphNode
|
||||
if (!sourceNode || !dataStore.inputData) {
|
||||
throw new Error('No source node or input data')
|
||||
}
|
||||
|
||||
try {
|
||||
const outputData = await prepareOutputData()
|
||||
dataStore.outputData = outputData
|
||||
|
||||
await updateNodePreview(sourceNode, outputData)
|
||||
|
||||
await uploadAllLayers(outputData)
|
||||
|
||||
updateNodeWithServerReferences(sourceNode, outputData)
|
||||
|
||||
app.graph.setDirtyCanvas(true)
|
||||
} catch (error) {
|
||||
console.error('[MaskEditorSaver] Save failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareOutputData(): Promise<EditorOutputData> {
|
||||
const maskCanvas = editorStore.maskCanvas
|
||||
const paintCanvas = editorStore.rgbCanvas
|
||||
const imgCanvas = editorStore.imgCanvas
|
||||
|
||||
if (!maskCanvas || !paintCanvas || !imgCanvas) {
|
||||
throw new Error('Canvas not initialized')
|
||||
}
|
||||
|
||||
const timestamp = Date.now()
|
||||
const filenames = imageLayerFilenamesByTimestamp(timestamp)
|
||||
|
||||
const [maskedImage, paintLayer, paintedImage, paintedMaskedImage] =
|
||||
await Promise.all([
|
||||
createMaskedImage(imgCanvas, maskCanvas, filenames.maskedImage),
|
||||
createPaintLayer(paintCanvas, filenames.paint),
|
||||
createPaintedImage(imgCanvas, paintCanvas, filenames.paintedImage),
|
||||
createPaintedMaskedImage(
|
||||
imgCanvas,
|
||||
paintCanvas,
|
||||
maskCanvas,
|
||||
filenames.paintedMaskedImage
|
||||
)
|
||||
])
|
||||
|
||||
return {
|
||||
maskedImage,
|
||||
paintLayer,
|
||||
paintedImage,
|
||||
paintedMaskedImage
|
||||
}
|
||||
}
|
||||
|
||||
async function createMaskedImage(
|
||||
imgCanvas: HTMLCanvasElement,
|
||||
maskCanvas: HTMLCanvasElement,
|
||||
filename: string
|
||||
): Promise<EditorOutputLayer> {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = imgCanvas.width
|
||||
canvas.height = imgCanvas.height
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
ctx.drawImage(imgCanvas, 0, 0)
|
||||
|
||||
const maskCtx = maskCanvas.getContext('2d')!
|
||||
const maskData = maskCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
maskCanvas.width,
|
||||
maskCanvas.height
|
||||
)
|
||||
|
||||
const refinedMaskData = new Uint8ClampedArray(maskData.data.length)
|
||||
for (let i = 0; i < maskData.data.length; i += 4) {
|
||||
refinedMaskData[i] = 0
|
||||
refinedMaskData[i + 1] = 0
|
||||
refinedMaskData[i + 2] = 0
|
||||
refinedMaskData[i + 3] = 255 - maskData.data[i + 3]
|
||||
}
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
imageData.data[i + 3] = refinedMaskData[i + 3]
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0)
|
||||
|
||||
const blob = await canvasToBlob(canvas)
|
||||
const ref = createFileRef(filename)
|
||||
|
||||
return { canvas, blob, ref }
|
||||
}
|
||||
|
||||
async function createPaintLayer(
|
||||
paintCanvas: HTMLCanvasElement,
|
||||
filename: string
|
||||
): Promise<EditorOutputLayer> {
|
||||
const canvas = cloneCanvas(paintCanvas)
|
||||
const blob = await canvasToBlob(canvas)
|
||||
const ref = createFileRef(filename)
|
||||
|
||||
return { canvas, blob, ref }
|
||||
}
|
||||
|
||||
async function createPaintedImage(
|
||||
imgCanvas: HTMLCanvasElement,
|
||||
paintCanvas: HTMLCanvasElement,
|
||||
filename: string
|
||||
): Promise<EditorOutputLayer> {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = imgCanvas.width
|
||||
canvas.height = imgCanvas.height
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
ctx.drawImage(imgCanvas, 0, 0)
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
ctx.drawImage(paintCanvas, 0, 0)
|
||||
|
||||
const blob = await canvasToBlob(canvas)
|
||||
const ref = createFileRef(filename)
|
||||
|
||||
return { canvas, blob, ref }
|
||||
}
|
||||
|
||||
async function createPaintedMaskedImage(
|
||||
imgCanvas: HTMLCanvasElement,
|
||||
paintCanvas: HTMLCanvasElement,
|
||||
maskCanvas: HTMLCanvasElement,
|
||||
filename: string
|
||||
): Promise<EditorOutputLayer> {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = imgCanvas.width
|
||||
canvas.height = imgCanvas.height
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
ctx.drawImage(imgCanvas, 0, 0)
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
ctx.drawImage(paintCanvas, 0, 0)
|
||||
|
||||
const maskCtx = maskCanvas.getContext('2d')!
|
||||
const maskData = maskCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
maskCanvas.width,
|
||||
maskCanvas.height
|
||||
)
|
||||
|
||||
const refinedMaskData = new Uint8ClampedArray(maskData.data.length)
|
||||
for (let i = 0; i < maskData.data.length; i += 4) {
|
||||
refinedMaskData[i] = 0
|
||||
refinedMaskData[i + 1] = 0
|
||||
refinedMaskData[i + 2] = 0
|
||||
refinedMaskData[i + 3] = 255 - maskData.data[i + 3]
|
||||
}
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
imageData.data[i + 3] = refinedMaskData[i + 3]
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0)
|
||||
|
||||
const blob = await canvasToBlob(canvas)
|
||||
const ref = createFileRef(filename)
|
||||
|
||||
return { canvas, blob, ref }
|
||||
}
|
||||
|
||||
async function uploadAllLayers(outputData: EditorOutputData): Promise<void> {
|
||||
const sourceRef = dataStore.inputData!.sourceRef
|
||||
|
||||
const actualMaskedRef = await uploadMask(outputData.maskedImage, sourceRef)
|
||||
const actualPaintRef = await uploadImage(outputData.paintLayer, sourceRef)
|
||||
const actualPaintedRef = await uploadImage(
|
||||
outputData.paintedImage,
|
||||
sourceRef
|
||||
)
|
||||
|
||||
const actualPaintedMaskedRef = await uploadMask(
|
||||
outputData.paintedMaskedImage,
|
||||
actualPaintedRef
|
||||
)
|
||||
|
||||
outputData.maskedImage.ref = actualMaskedRef
|
||||
outputData.paintLayer.ref = actualPaintRef
|
||||
outputData.paintedImage.ref = actualPaintedRef
|
||||
outputData.paintedMaskedImage.ref = actualPaintedMaskedRef
|
||||
}
|
||||
|
||||
async function uploadMask(
|
||||
layer: EditorOutputLayer,
|
||||
originalRef: ImageRef
|
||||
): Promise<ImageRef> {
|
||||
const formData = new FormData()
|
||||
formData.append('image', layer.blob, layer.ref.filename)
|
||||
formData.append('original_ref', JSON.stringify(originalRef))
|
||||
formData.append('type', 'input')
|
||||
formData.append('subfolder', 'clipspace')
|
||||
|
||||
const response = await api.fetchApi('/upload/mask', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload mask: ${layer.ref.filename}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await response.json()
|
||||
if (data?.name) {
|
||||
return {
|
||||
filename: data.name,
|
||||
subfolder: data.subfolder || layer.ref.subfolder,
|
||||
type: data.type || layer.ref.type
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
|
||||
}
|
||||
|
||||
return layer.ref
|
||||
}
|
||||
|
||||
async function uploadImage(
|
||||
layer: EditorOutputLayer,
|
||||
originalRef: ImageRef
|
||||
): Promise<ImageRef> {
|
||||
const formData = new FormData()
|
||||
formData.append('image', layer.blob, layer.ref.filename)
|
||||
formData.append('original_ref', JSON.stringify(originalRef))
|
||||
formData.append('type', 'input')
|
||||
formData.append('subfolder', 'clipspace')
|
||||
|
||||
const response = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload image: ${layer.ref.filename}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await response.json()
|
||||
if (data?.name) {
|
||||
return {
|
||||
filename: data.name,
|
||||
subfolder: data.subfolder || layer.ref.subfolder,
|
||||
type: data.type || layer.ref.type
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
|
||||
}
|
||||
|
||||
return layer.ref
|
||||
}
|
||||
|
||||
async function updateNodePreview(
|
||||
node: LGraphNode,
|
||||
outputData: EditorOutputData
|
||||
): Promise<void> {
|
||||
const canvas = outputData.paintedMaskedImage.canvas
|
||||
const dataUrl = canvas.toDataURL('image/png')
|
||||
|
||||
const mainImg = await loadImageFromUrl(dataUrl)
|
||||
node.imgs = [mainImg]
|
||||
|
||||
app.graph.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
function updateNodeWithServerReferences(
|
||||
node: LGraphNode,
|
||||
outputData: EditorOutputData
|
||||
): void {
|
||||
const mainRef = outputData.paintedMaskedImage.ref
|
||||
|
||||
node.images = [mainRef]
|
||||
|
||||
const imageWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (imageWidget) {
|
||||
// Widget value format differs between Cloud and OSS:
|
||||
// - Cloud: JUST the filename (subfolder handled by backend)
|
||||
// - OSS: subfolder/filename (traditional format)
|
||||
let widgetValue: string
|
||||
if (isCloud) {
|
||||
widgetValue =
|
||||
mainRef.filename + (mainRef.type ? ` [${mainRef.type}]` : '')
|
||||
} else {
|
||||
widgetValue =
|
||||
(mainRef.subfolder ? mainRef.subfolder + '/' : '') +
|
||||
mainRef.filename +
|
||||
(mainRef.type ? ` [${mainRef.type}]` : '')
|
||||
}
|
||||
|
||||
imageWidget.value = widgetValue
|
||||
|
||||
if (node.properties) {
|
||||
node.properties['image'] = widgetValue
|
||||
}
|
||||
|
||||
if (node.widgets_values && node.widgets) {
|
||||
const widgetIndex = node.widgets.indexOf(imageWidget)
|
||||
if (widgetIndex >= 0) {
|
||||
node.widgets_values[widgetIndex] = widgetValue
|
||||
}
|
||||
}
|
||||
|
||||
imageWidget.callback?.(widgetValue)
|
||||
}
|
||||
|
||||
nodeOutputStore.updateNodeImages(node)
|
||||
}
|
||||
|
||||
function loadImageFromUrl(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = (error) => {
|
||||
console.error('[MaskEditorSaver] Failed to load image:', url, error)
|
||||
reject(new Error(`Failed to load image: ${url}`))
|
||||
}
|
||||
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
function cloneCanvas(source: HTMLCanvasElement): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = source.width
|
||||
canvas.height = source.height
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.drawImage(source, 0, 0)
|
||||
return canvas
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) resolve(blob)
|
||||
else reject(new Error('Failed to create blob from canvas'))
|
||||
}, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
function createFileRef(filename: string): ImageRef {
|
||||
return {
|
||||
filename,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
save
|
||||
}
|
||||
}
|
||||
416
src/composables/maskeditor/usePanAndZoom.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import type { Offset, Point } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
export function usePanAndZoom() {
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const DOUBLE_TAP_DELAY = 300
|
||||
|
||||
const lastTwoFingerTap = ref(0)
|
||||
const isTouchZooming = ref(false)
|
||||
const lastTouchZoomDistance = ref(0)
|
||||
const lastTouchMidPoint = ref<Point>({ x: 0, y: 0 })
|
||||
const lastTouchPoint = ref<Point>({ x: 0, y: 0 })
|
||||
|
||||
const zoom_ratio = ref(1)
|
||||
const interpolatedZoomRatio = ref(1)
|
||||
const pan_offset = ref<Offset>({ x: 0, y: 0 })
|
||||
|
||||
const mouseDownPoint = ref<Point | null>(null)
|
||||
const initialPan = ref<Offset>({ x: 0, y: 0 })
|
||||
|
||||
const canvasContainer = ref<HTMLElement | null>(null)
|
||||
const maskCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const rgbCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const rootElement = ref<HTMLElement | null>(null)
|
||||
|
||||
const toolPanelElement = ref<HTMLElement | null>(null)
|
||||
const sidePanelElement = ref<HTMLElement | null>(null)
|
||||
|
||||
const image = ref<HTMLImageElement | null>(null)
|
||||
const imageRootWidth = ref(0)
|
||||
const imageRootHeight = ref(0)
|
||||
|
||||
const cursorPoint = ref<Point>({ x: 0, y: 0 })
|
||||
const penPointerIdList = ref<number[]>([])
|
||||
|
||||
const getTouchDistance = (touches: TouchList): number => {
|
||||
const dx = touches[0].clientX - touches[1].clientX
|
||||
const dy = touches[0].clientY - touches[1].clientY
|
||||
return Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
const getTouchMidpoint = (touches: TouchList): Point => {
|
||||
return {
|
||||
x: (touches[0].clientX + touches[1].clientX) / 2,
|
||||
y: (touches[0].clientY + touches[1].clientY) / 2
|
||||
}
|
||||
}
|
||||
|
||||
const updateCursorPosition = (clientPoint: Point): void => {
|
||||
const cursorX = clientPoint.x - pan_offset.value.x
|
||||
const cursorY = clientPoint.y - pan_offset.value.y
|
||||
cursorPoint.value = { x: cursorX, y: cursorY }
|
||||
store.setCursorPoint({ x: cursorX, y: cursorY })
|
||||
}
|
||||
|
||||
const handleDoubleTap = (): void => {
|
||||
store.canvasHistory.undo()
|
||||
}
|
||||
|
||||
const invalidatePanZoom = async (): Promise<void> => {
|
||||
// Single validation check upfront
|
||||
if (
|
||||
!image.value?.width ||
|
||||
!image.value?.height ||
|
||||
!pan_offset.value ||
|
||||
!zoom_ratio.value
|
||||
) {
|
||||
console.warn('Missing required properties for pan/zoom')
|
||||
return
|
||||
}
|
||||
|
||||
const raw_width = image.value.width * zoom_ratio.value
|
||||
const raw_height = image.value.height * zoom_ratio.value
|
||||
|
||||
if (!canvasContainer.value) {
|
||||
canvasContainer.value = store.canvasContainer
|
||||
}
|
||||
if (!canvasContainer.value) return
|
||||
|
||||
Object.assign(canvasContainer.value.style, {
|
||||
width: `${raw_width}px`,
|
||||
height: `${raw_height}px`,
|
||||
left: `${pan_offset.value.x}px`,
|
||||
top: `${pan_offset.value.y}px`
|
||||
})
|
||||
|
||||
if (!rgbCanvas.value) {
|
||||
rgbCanvas.value = store.rgbCanvas
|
||||
}
|
||||
if (rgbCanvas.value) {
|
||||
if (
|
||||
rgbCanvas.value.width !== image.value.width ||
|
||||
rgbCanvas.value.height !== image.value.height
|
||||
) {
|
||||
rgbCanvas.value.width = image.value.width
|
||||
rgbCanvas.value.height = image.value.height
|
||||
}
|
||||
|
||||
rgbCanvas.value.style.width = `${raw_width}px`
|
||||
rgbCanvas.value.style.height = `${raw_height}px`
|
||||
}
|
||||
|
||||
store.setPanOffset(pan_offset.value)
|
||||
store.setZoomRatio(zoom_ratio.value)
|
||||
}
|
||||
|
||||
const handlePanStart = (event: PointerEvent): void => {
|
||||
mouseDownPoint.value = { x: event.clientX, y: event.clientY }
|
||||
|
||||
store.isPanning = true
|
||||
initialPan.value = { ...pan_offset.value }
|
||||
}
|
||||
|
||||
const handlePanMove = async (event: PointerEvent): Promise<void> => {
|
||||
if (mouseDownPoint.value === null) {
|
||||
throw new Error('mouseDownPoint is null')
|
||||
}
|
||||
|
||||
const deltaX = mouseDownPoint.value.x - event.clientX
|
||||
const deltaY = mouseDownPoint.value.y - event.clientY
|
||||
|
||||
const pan_x = initialPan.value.x - deltaX
|
||||
const pan_y = initialPan.value.y - deltaY
|
||||
|
||||
pan_offset.value = { x: pan_x, y: pan_y }
|
||||
|
||||
await invalidatePanZoom()
|
||||
}
|
||||
|
||||
const handleSingleTouchPan = async (touch: Touch): Promise<void> => {
|
||||
if (lastTouchPoint.value === null) {
|
||||
lastTouchPoint.value = { x: touch.clientX, y: touch.clientY }
|
||||
return
|
||||
}
|
||||
|
||||
const deltaX = touch.clientX - lastTouchPoint.value.x
|
||||
const deltaY = touch.clientY - lastTouchPoint.value.y
|
||||
|
||||
pan_offset.value.x += deltaX
|
||||
pan_offset.value.y += deltaY
|
||||
|
||||
await invalidatePanZoom()
|
||||
|
||||
lastTouchPoint.value = { x: touch.clientX, y: touch.clientY }
|
||||
}
|
||||
|
||||
const handleTouchStart = (event: TouchEvent): void => {
|
||||
event.preventDefault()
|
||||
|
||||
if (penPointerIdList.value.length > 0) return
|
||||
|
||||
store.brushVisible = false
|
||||
|
||||
if (event.touches.length === 2) {
|
||||
const currentTime = new Date().getTime()
|
||||
const tapTimeDiff = currentTime - lastTwoFingerTap.value
|
||||
|
||||
if (tapTimeDiff < DOUBLE_TAP_DELAY) {
|
||||
handleDoubleTap()
|
||||
lastTwoFingerTap.value = 0
|
||||
} else {
|
||||
lastTwoFingerTap.value = currentTime
|
||||
|
||||
isTouchZooming.value = true
|
||||
lastTouchZoomDistance.value = getTouchDistance(event.touches)
|
||||
lastTouchMidPoint.value = getTouchMidpoint(event.touches)
|
||||
}
|
||||
} else if (event.touches.length === 1) {
|
||||
lastTouchPoint.value = {
|
||||
x: event.touches[0].clientX,
|
||||
y: event.touches[0].clientY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchMove = async (event: TouchEvent): Promise<void> => {
|
||||
event.preventDefault()
|
||||
|
||||
if (penPointerIdList.value.length > 0) return
|
||||
|
||||
lastTwoFingerTap.value = 0
|
||||
|
||||
if (isTouchZooming.value && event.touches.length === 2) {
|
||||
const newDistance = getTouchDistance(event.touches)
|
||||
const zoomFactor = newDistance / lastTouchZoomDistance.value
|
||||
const oldZoom = zoom_ratio.value
|
||||
zoom_ratio.value = Math.max(
|
||||
0.2,
|
||||
Math.min(10.0, zoom_ratio.value * zoomFactor)
|
||||
)
|
||||
const newZoom = zoom_ratio.value
|
||||
|
||||
const midpoint = getTouchMidpoint(event.touches)
|
||||
|
||||
if (lastTouchMidPoint.value) {
|
||||
const deltaX = midpoint.x - lastTouchMidPoint.value.x
|
||||
const deltaY = midpoint.y - lastTouchMidPoint.value.y
|
||||
|
||||
pan_offset.value.x += deltaX
|
||||
pan_offset.value.y += deltaY
|
||||
}
|
||||
|
||||
if (maskCanvas.value === null) {
|
||||
maskCanvas.value = store.maskCanvas
|
||||
}
|
||||
if (!maskCanvas.value) return
|
||||
|
||||
const rect = maskCanvas.value.getBoundingClientRect()
|
||||
const touchX = midpoint.x - rect.left
|
||||
const touchY = midpoint.y - rect.top
|
||||
|
||||
const scaleFactor = newZoom / oldZoom
|
||||
pan_offset.value.x += touchX - touchX * scaleFactor
|
||||
pan_offset.value.y += touchY - touchY * scaleFactor
|
||||
|
||||
await invalidatePanZoom()
|
||||
lastTouchZoomDistance.value = newDistance
|
||||
lastTouchMidPoint.value = midpoint
|
||||
} else if (event.touches.length === 1) {
|
||||
await handleSingleTouchPan(event.touches[0])
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = (event: TouchEvent): void => {
|
||||
event.preventDefault()
|
||||
|
||||
const lastTouch = event.touches[0]
|
||||
|
||||
if (lastTouch) {
|
||||
lastTouchPoint.value = {
|
||||
x: lastTouch.clientX,
|
||||
y: lastTouch.clientY
|
||||
}
|
||||
} else {
|
||||
isTouchZooming.value = false
|
||||
lastTouchMidPoint.value = { x: 0, y: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
const zoom = async (event: WheelEvent): Promise<void> => {
|
||||
const cursorPosition = { x: event.clientX, y: event.clientY }
|
||||
|
||||
const oldZoom = zoom_ratio.value
|
||||
const zoomFactor = event.deltaY < 0 ? 1.1 : 0.9
|
||||
zoom_ratio.value = Math.max(
|
||||
0.2,
|
||||
Math.min(10.0, zoom_ratio.value * zoomFactor)
|
||||
)
|
||||
const newZoom = zoom_ratio.value
|
||||
|
||||
if (!maskCanvas.value) {
|
||||
maskCanvas.value = store.maskCanvas
|
||||
}
|
||||
if (!maskCanvas.value) return
|
||||
|
||||
const rect = maskCanvas.value.getBoundingClientRect()
|
||||
const mouseX = cursorPosition.x - rect.left
|
||||
const mouseY = cursorPosition.y - rect.top
|
||||
|
||||
const scaleFactor = newZoom / oldZoom
|
||||
pan_offset.value.x += mouseX - mouseX * scaleFactor
|
||||
pan_offset.value.y += mouseY - mouseY * scaleFactor
|
||||
|
||||
await invalidatePanZoom()
|
||||
|
||||
const newImageWidth = maskCanvas.value.clientWidth
|
||||
|
||||
const zoomRatio = newImageWidth / imageRootWidth.value
|
||||
|
||||
interpolatedZoomRatio.value = zoomRatio
|
||||
store.displayZoomRatio = zoomRatio
|
||||
|
||||
updateCursorPosition(cursorPosition)
|
||||
}
|
||||
|
||||
const getPanelDimensions = (): {
|
||||
sidePanelWidth: number
|
||||
toolPanelWidth: number
|
||||
} => {
|
||||
const toolPanelWidth =
|
||||
toolPanelElement.value?.getBoundingClientRect().width || 64
|
||||
const sidePanelWidth =
|
||||
sidePanelElement.value?.getBoundingClientRect().width || 220
|
||||
|
||||
return { sidePanelWidth, toolPanelWidth }
|
||||
}
|
||||
|
||||
const smoothResetView = async (duration: number = 500): Promise<void> => {
|
||||
if (!image.value || !rootElement.value) return
|
||||
|
||||
const startZoom = zoom_ratio.value
|
||||
const startPan = { ...pan_offset.value }
|
||||
|
||||
const { sidePanelWidth, toolPanelWidth } = getPanelDimensions()
|
||||
|
||||
const availableWidth =
|
||||
rootElement.value.clientWidth - sidePanelWidth - toolPanelWidth
|
||||
const availableHeight = rootElement.value.clientHeight
|
||||
|
||||
// Calculate target zoom
|
||||
const zoomRatioWidth = availableWidth / image.value.width
|
||||
const zoomRatioHeight = availableHeight / image.value.height
|
||||
const targetZoom = Math.min(zoomRatioWidth, zoomRatioHeight)
|
||||
|
||||
const aspectRatio = image.value.width / image.value.height
|
||||
let finalWidth: number
|
||||
let finalHeight: number
|
||||
|
||||
const targetPan = { x: toolPanelWidth, y: 0 }
|
||||
|
||||
if (zoomRatioHeight > zoomRatioWidth) {
|
||||
finalWidth = availableWidth
|
||||
finalHeight = finalWidth / aspectRatio
|
||||
targetPan.y = (availableHeight - finalHeight) / 2
|
||||
} else {
|
||||
finalHeight = availableHeight
|
||||
finalWidth = finalHeight * aspectRatio
|
||||
targetPan.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
|
||||
}
|
||||
|
||||
const startTime = performance.now()
|
||||
const animate = async (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
const eased = 1 - Math.pow(1 - progress, 3)
|
||||
|
||||
zoom_ratio.value = startZoom + (targetZoom - startZoom) * eased
|
||||
pan_offset.value.x = startPan.x + (targetPan.x - startPan.x) * eased
|
||||
pan_offset.value.y = startPan.y + (targetPan.y - startPan.y) * eased
|
||||
|
||||
await invalidatePanZoom()
|
||||
|
||||
const interpolatedRatio = startZoom + (1.0 - startZoom) * eased
|
||||
store.displayZoomRatio = interpolatedRatio
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
interpolatedZoomRatio.value = 1.0
|
||||
}
|
||||
|
||||
const initializeCanvasPanZoom = async (
|
||||
img: HTMLImageElement,
|
||||
root: HTMLElement,
|
||||
toolPanel?: HTMLElement | null,
|
||||
sidePanel?: HTMLElement | null
|
||||
): Promise<void> => {
|
||||
rootElement.value = root
|
||||
toolPanelElement.value = toolPanel || null
|
||||
sidePanelElement.value = sidePanel || null
|
||||
|
||||
const { sidePanelWidth, toolPanelWidth } = getPanelDimensions()
|
||||
|
||||
const availableWidth = root.clientWidth - sidePanelWidth - toolPanelWidth
|
||||
const availableHeight = root.clientHeight
|
||||
|
||||
const zoomRatioWidth = availableWidth / img.width
|
||||
const zoomRatioHeight = availableHeight / img.height
|
||||
|
||||
const aspectRatio = img.width / img.height
|
||||
|
||||
let finalWidth: number
|
||||
let finalHeight: number
|
||||
|
||||
const panOffset: Offset = { x: toolPanelWidth, y: 0 }
|
||||
|
||||
if (zoomRatioHeight > zoomRatioWidth) {
|
||||
finalWidth = availableWidth
|
||||
finalHeight = finalWidth / aspectRatio
|
||||
panOffset.y = (availableHeight - finalHeight) / 2
|
||||
} else {
|
||||
finalHeight = availableHeight
|
||||
finalWidth = finalHeight * aspectRatio
|
||||
panOffset.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
|
||||
}
|
||||
|
||||
if (image.value === null) {
|
||||
image.value = img
|
||||
}
|
||||
|
||||
imageRootWidth.value = finalWidth
|
||||
imageRootHeight.value = finalHeight
|
||||
|
||||
zoom_ratio.value = Math.min(zoomRatioWidth, zoomRatioHeight)
|
||||
pan_offset.value = panOffset
|
||||
|
||||
penPointerIdList.value = []
|
||||
|
||||
await invalidatePanZoom()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.resetZoomTrigger,
|
||||
async () => {
|
||||
if (interpolatedZoomRatio.value === 1) return
|
||||
await smoothResetView()
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
initializeCanvasPanZoom,
|
||||
handlePanStart,
|
||||
handlePanMove,
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
updateCursorPosition,
|
||||
zoom,
|
||||
invalidatePanZoom
|
||||
}
|
||||
}
|
||||
228
src/composables/maskeditor/useToolManager.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import type {
|
||||
Point,
|
||||
ImageLayer,
|
||||
ToolInternalSettings
|
||||
} from '@/extensions/core/maskeditor/types'
|
||||
import { Tools } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useBrushDrawing } from './useBrushDrawing'
|
||||
import { useCanvasTools } from './useCanvasTools'
|
||||
import { useCoordinateTransform } from './useCoordinateTransform'
|
||||
import type { useKeyboard } from './useKeyboard'
|
||||
import type { usePanAndZoom } from './usePanAndZoom'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
export function useToolManager(
|
||||
keyboard: ReturnType<typeof useKeyboard>,
|
||||
panZoom: ReturnType<typeof usePanAndZoom>
|
||||
) {
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const coordinateTransform = useCoordinateTransform()
|
||||
|
||||
const brushDrawing = useBrushDrawing({
|
||||
useDominantAxis: app.extensionManager.setting.get(
|
||||
'Comfy.MaskEditor.UseDominantAxis'
|
||||
),
|
||||
brushAdjustmentSpeed: app.extensionManager.setting.get(
|
||||
'Comfy.MaskEditor.BrushAdjustmentSpeed'
|
||||
)
|
||||
})
|
||||
const canvasTools = useCanvasTools()
|
||||
|
||||
const mouseDownPoint = ref<Point | null>(null)
|
||||
|
||||
const toolSettings: Record<Tools, Partial<ToolInternalSettings>> = {
|
||||
[Tools.MaskPen]: {
|
||||
newActiveLayerOnSet: 'mask'
|
||||
},
|
||||
[Tools.Eraser]: {},
|
||||
[Tools.PaintPen]: {
|
||||
newActiveLayerOnSet: 'rgb'
|
||||
},
|
||||
[Tools.MaskBucket]: {
|
||||
cursor: "url('/cursor/paintBucket.png') 30 25, auto",
|
||||
newActiveLayerOnSet: 'mask'
|
||||
},
|
||||
[Tools.MaskColorFill]: {
|
||||
cursor: "url('/cursor/colorSelect.png') 15 25, auto",
|
||||
newActiveLayerOnSet: 'mask'
|
||||
}
|
||||
}
|
||||
|
||||
const setActiveLayer = (layer: ImageLayer) => {
|
||||
store.activeLayer = layer
|
||||
const currentTool = store.currentTool
|
||||
|
||||
const maskOnlyTools = [Tools.MaskPen, Tools.MaskBucket, Tools.MaskColorFill]
|
||||
if (maskOnlyTools.includes(currentTool) && layer === 'rgb') {
|
||||
switchTool(Tools.PaintPen)
|
||||
}
|
||||
|
||||
if (currentTool === Tools.PaintPen && layer === 'mask') {
|
||||
switchTool(Tools.MaskPen)
|
||||
}
|
||||
}
|
||||
|
||||
const switchTool = (tool: Tools) => {
|
||||
store.currentTool = tool
|
||||
|
||||
const newActiveLayer = toolSettings[tool].newActiveLayerOnSet
|
||||
if (newActiveLayer) {
|
||||
store.activeLayer = newActiveLayer
|
||||
}
|
||||
|
||||
const cursor = toolSettings[tool].cursor
|
||||
const pointerZone = store.pointerZone
|
||||
|
||||
if (cursor && pointerZone) {
|
||||
store.brushVisible = false
|
||||
pointerZone.style.cursor = cursor
|
||||
} else if (pointerZone) {
|
||||
store.brushVisible = true
|
||||
pointerZone.style.cursor = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
const updateCursor = () => {
|
||||
const currentTool = store.currentTool
|
||||
const cursor = toolSettings[currentTool].cursor
|
||||
const pointerZone = store.pointerZone
|
||||
|
||||
if (cursor && pointerZone) {
|
||||
store.brushVisible = false
|
||||
pointerZone.style.cursor = cursor
|
||||
} else if (pointerZone) {
|
||||
store.brushVisible = true
|
||||
pointerZone.style.cursor = 'none'
|
||||
}
|
||||
|
||||
store.brushPreviewGradientVisible = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.currentTool,
|
||||
(newTool) => {
|
||||
if (newTool !== Tools.MaskColorFill) {
|
||||
canvasTools.clearLastColorSelectPoint()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handlePointerDown = async (event: PointerEvent): Promise<void> => {
|
||||
event.preventDefault()
|
||||
if (event.pointerType === 'touch') return
|
||||
|
||||
const isSpacePressed = keyboard.isKeyDown(' ')
|
||||
|
||||
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
|
||||
panZoom.handlePanStart(event)
|
||||
|
||||
store.brushVisible = false
|
||||
return
|
||||
}
|
||||
|
||||
if (store.currentTool === Tools.PaintPen && event.button === 0) {
|
||||
await brushDrawing.startDrawing(event)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (store.currentTool === Tools.PaintPen && event.buttons === 1) {
|
||||
await brushDrawing.handleDrawing(event)
|
||||
return
|
||||
}
|
||||
|
||||
if (store.currentTool === Tools.MaskBucket && event.button === 0) {
|
||||
const offset = { x: event.offsetX, y: event.offsetY }
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(offset)
|
||||
canvasTools.paintBucketFill(coords_canvas)
|
||||
return
|
||||
}
|
||||
|
||||
if (store.currentTool === Tools.MaskColorFill && event.button === 0) {
|
||||
const offset = { x: event.offsetX, y: event.offsetY }
|
||||
const coords_canvas = coordinateTransform.screenToCanvas(offset)
|
||||
await canvasTools.colorSelectFill(coords_canvas)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.altKey && event.button === 2) {
|
||||
store.isAdjustingBrush = true
|
||||
await brushDrawing.startBrushAdjustment(event)
|
||||
return
|
||||
}
|
||||
|
||||
const isDrawingTool = [
|
||||
Tools.MaskPen,
|
||||
Tools.Eraser,
|
||||
Tools.PaintPen
|
||||
].includes(store.currentTool)
|
||||
|
||||
if ([0, 2].includes(event.button) && isDrawingTool) {
|
||||
await brushDrawing.startDrawing(event)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerMove = async (event: PointerEvent): Promise<void> => {
|
||||
event.preventDefault()
|
||||
if (event.pointerType === 'touch') return
|
||||
|
||||
const newCursorPoint = { x: event.clientX, y: event.clientY }
|
||||
panZoom.updateCursorPosition(newCursorPoint)
|
||||
|
||||
const isSpacePressed = keyboard.isKeyDown(' ')
|
||||
|
||||
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
|
||||
await panZoom.handlePanMove(event)
|
||||
return
|
||||
}
|
||||
|
||||
const isDrawingTool = [
|
||||
Tools.MaskPen,
|
||||
Tools.Eraser,
|
||||
Tools.PaintPen
|
||||
].includes(store.currentTool)
|
||||
if (!isDrawingTool) return
|
||||
|
||||
if (
|
||||
store.isAdjustingBrush &&
|
||||
(store.currentTool === Tools.MaskPen ||
|
||||
store.currentTool === Tools.Eraser) &&
|
||||
event.altKey &&
|
||||
event.buttons === 2
|
||||
) {
|
||||
await brushDrawing.handleBrushAdjustment(event)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.buttons === 1 || event.buttons === 2) {
|
||||
await brushDrawing.handleDrawing(event)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerUp = async (event: PointerEvent): Promise<void> => {
|
||||
store.isPanning = false
|
||||
store.brushVisible = true
|
||||
if (event.pointerType === 'touch') return
|
||||
updateCursor()
|
||||
store.isAdjustingBrush = false
|
||||
await brushDrawing.drawEnd(event)
|
||||
mouseDownPoint.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
switchTool,
|
||||
setActiveLayer,
|
||||
updateCursor,
|
||||
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
|
||||
brushDrawing
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
|
||||
import {
|
||||
DEFAULT_DARK_COLOR_PALETTE,
|
||||
@@ -76,6 +77,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { staticUrls, buildDocsUrl } = useExternalLink()
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
|
||||
@@ -761,10 +763,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
is_external: true,
|
||||
source: 'menu'
|
||||
})
|
||||
window.open(
|
||||
'https://github.com/comfyanonymous/ComfyUI/issues',
|
||||
'_blank'
|
||||
)
|
||||
window.open(staticUrls.githubIssues, '_blank')
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -779,7 +778,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
is_external: true,
|
||||
source: 'menu'
|
||||
})
|
||||
window.open('https://docs.comfy.org/', '_blank')
|
||||
window.open(buildDocsUrl('/', { includeLocale: true }), '_blank')
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -794,7 +793,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
is_external: true,
|
||||
source: 'menu'
|
||||
})
|
||||
window.open('https://www.comfy.org/discord', '_blank')
|
||||
window.open(staticUrls.discord, '_blank')
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -861,7 +860,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
is_external: true,
|
||||
source: 'menu'
|
||||
})
|
||||
window.open('https://forum.comfy.org/', '_blank')
|
||||
window.open(staticUrls.forum, '_blank')
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
99
src/composables/useExternalLink.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
/**
|
||||
* Composable for building docs.comfy.org URLs with automatic locale and platform detection
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { buildDocsUrl } = useExternalLink()
|
||||
*
|
||||
* // Simple usage
|
||||
* const changelogUrl = buildDocsUrl('/changelog', { includeLocale: true })
|
||||
* // => 'https://docs.comfy.org/zh-CN/changelog' (if Chinese)
|
||||
*
|
||||
* // With platform detection
|
||||
* const desktopUrl = buildDocsUrl('/installation/desktop', {
|
||||
* includeLocale: true,
|
||||
* platform: true
|
||||
* })
|
||||
* // => 'https://docs.comfy.org/zh-CN/installation/desktop/macos' (if Chinese + macOS)
|
||||
* ```
|
||||
*/
|
||||
export function useExternalLink() {
|
||||
const { locale } = useI18n()
|
||||
|
||||
const isChinese = computed(() => {
|
||||
return locale.value === 'zh' || locale.value === 'zh-TW'
|
||||
})
|
||||
|
||||
const platform = computed(() => {
|
||||
if (!isElectron()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const electronPlatform = electronAPI().getPlatform()
|
||||
return electronPlatform === 'darwin' ? 'macos' : 'windows'
|
||||
})
|
||||
|
||||
/**
|
||||
* Build a docs.comfy.org URL with optional locale and platform
|
||||
*
|
||||
* @param path - The path after the domain (e.g., '/installation/desktop')
|
||||
* @param options - Options for building the URL
|
||||
* @param options.includeLocale - Whether to include locale prefix (default: false)
|
||||
* @param options.platform - Whether to include platform suffix (default: false)
|
||||
* @returns The complete docs URL
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* buildDocsUrl('/changelog') // => 'https://docs.comfy.org/changelog'
|
||||
* buildDocsUrl('/changelog', { includeLocale: true }) // => 'https://docs.comfy.org/zh-CN/changelog' (if Chinese)
|
||||
* buildDocsUrl('/installation/desktop', { includeLocale: true, platform: true })
|
||||
* // => 'https://docs.comfy.org/zh-CN/installation/desktop/macos' (if Chinese + macOS)
|
||||
* ```
|
||||
*/
|
||||
const buildDocsUrl = (
|
||||
path: string,
|
||||
options: {
|
||||
includeLocale?: boolean
|
||||
platform?: boolean
|
||||
} = {}
|
||||
): string => {
|
||||
const { includeLocale = false, platform: includePlatform = false } = options
|
||||
|
||||
let url = 'https://docs.comfy.org'
|
||||
|
||||
if (includeLocale && isChinese.value) {
|
||||
url += '/zh-CN'
|
||||
}
|
||||
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
url += normalizedPath
|
||||
|
||||
if (includePlatform && platform.value) {
|
||||
url = url.endsWith('/') ? url : `${url}/`
|
||||
url += platform.value
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
const staticUrls = {
|
||||
// Static external URLs
|
||||
discord: 'https://www.comfy.org/discord',
|
||||
github: 'https://github.com/comfyanonymous/ComfyUI',
|
||||
githubIssues: 'https://github.com/comfyanonymous/ComfyUI/issues',
|
||||
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
|
||||
githubElectron: 'https://github.com/Comfy-Org/electron',
|
||||
forum: 'https://forum.comfy.org/',
|
||||
comfyOrg: 'https://www.comfy.org/'
|
||||
}
|
||||
|
||||
return {
|
||||
buildDocsUrl,
|
||||
staticUrls
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type {
|
||||
AnimationItem,
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
CameraType,
|
||||
LightConfig,
|
||||
MaterialMode,
|
||||
@@ -16,8 +17,10 @@ import type {
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
|
||||
type Load3dReadyCallback = (load3d: Load3d) => void
|
||||
@@ -68,10 +71,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const node = rawNode as LGraphNode
|
||||
|
||||
try {
|
||||
load3d = new Load3d(containerRef, {
|
||||
node
|
||||
})
|
||||
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
@@ -79,6 +78,27 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isPreview.value = true
|
||||
}
|
||||
|
||||
load3d = new Load3d(containerRef, {
|
||||
width: widthWidget?.value as number | undefined,
|
||||
height: heightWidget?.value as number | undefined,
|
||||
// Provide dynamic dimension getter for reactive updates
|
||||
getDimensions:
|
||||
widthWidget && heightWidget
|
||||
? () => ({
|
||||
width: widthWidget.value as number,
|
||||
height: heightWidget.value as number
|
||||
})
|
||||
: undefined,
|
||||
onContextMenu: (event) => {
|
||||
const menuOptions = app.canvas.getNodeMenuOptions(node)
|
||||
new LiteGraph.ContextMenu(menuOptions, {
|
||||
event,
|
||||
title: node.type,
|
||||
extra: node
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
await restoreConfigurationsFromNode(node)
|
||||
|
||||
node.onMouseEnter = function () {
|
||||
@@ -487,8 +507,26 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
}
|
||||
},
|
||||
animationListChange: (newValue: any) => {
|
||||
animationListChange: (newValue: AnimationItem[]) => {
|
||||
animations.value = newValue
|
||||
},
|
||||
cameraChanged: (cameraState: CameraState) => {
|
||||
const rawNode = toRaw(nodeRef.value)
|
||||
if (rawNode) {
|
||||
const node = rawNode as LGraphNode
|
||||
if (!node.properties) node.properties = {}
|
||||
const cameraConfigProp = node.properties['Camera Config']
|
||||
|
||||
if (cameraConfigProp) {
|
||||
;(cameraConfigProp as CameraConfig).state = cameraState
|
||||
} else {
|
||||
node.properties['Camera Config'] = {
|
||||
cameraType: cameraConfig.value.cameraType,
|
||||
fov: cameraConfig.value.fov,
|
||||
state: cameraState
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} as const
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type {
|
||||
BackgroundRenderModeType,
|
||||
CameraState,
|
||||
CameraType,
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
@@ -20,14 +21,18 @@ interface Load3dViewerState {
|
||||
cameraType: CameraType
|
||||
fov: number
|
||||
lightIntensity: number
|
||||
cameraState: any
|
||||
cameraState: CameraState | null
|
||||
backgroundImage: string
|
||||
backgroundRenderMode: BackgroundRenderModeType
|
||||
upDirection: UpDirection
|
||||
materialMode: MaterialMode
|
||||
}
|
||||
|
||||
export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
/**
|
||||
* @param node Optional node - if provided, viewer works in node mode with apply/restore
|
||||
* If not provided, viewer works in standalone mode for asset preview
|
||||
*/
|
||||
export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
const backgroundColor = ref('')
|
||||
const showGrid = ref(true)
|
||||
const cameraType = ref<CameraType>('perspective')
|
||||
@@ -40,6 +45,7 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
const materialMode = ref<MaterialMode>('original')
|
||||
const needApplyChanges = ref(true)
|
||||
const isPreview = ref(false)
|
||||
const isStandaloneMode = ref(false)
|
||||
|
||||
let load3d: Load3d | null = null
|
||||
let sourceLoad3d: Load3d | null = null
|
||||
@@ -166,18 +172,31 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Initialize viewer in node mode (with source Load3d)
|
||||
*/
|
||||
const initializeViewer = async (
|
||||
containerRef: HTMLElement,
|
||||
source: Load3d
|
||||
) => {
|
||||
if (!containerRef) return
|
||||
if (!containerRef || !node) return
|
||||
|
||||
sourceLoad3d = source
|
||||
|
||||
try {
|
||||
const width = node.widgets?.find((w) => w.name === 'width')
|
||||
const height = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
load3d = new Load3d(containerRef, {
|
||||
node: node,
|
||||
disablePreview: true,
|
||||
width: width ? (toRaw(width).value as number) : undefined,
|
||||
height: height ? (toRaw(height).value as number) : undefined,
|
||||
getDimensions:
|
||||
width && height
|
||||
? () => ({
|
||||
width: width.value as number,
|
||||
height: height.value as number
|
||||
})
|
||||
: undefined,
|
||||
isViewerMode: true
|
||||
})
|
||||
|
||||
@@ -245,16 +264,6 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
upDirection: upDirection.value,
|
||||
materialMode: materialMode.value
|
||||
}
|
||||
|
||||
const width = node.widgets?.find((w) => w.name === 'width')
|
||||
const height = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
if (width && height) {
|
||||
load3d.setTargetSize(
|
||||
toRaw(width).value as number,
|
||||
toRaw(height).value as number
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing Load3d viewer:', error)
|
||||
useToastStore().addAlert(
|
||||
@@ -263,6 +272,42 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize viewer in standalone mode (for asset preview)
|
||||
*/
|
||||
const initializeStandaloneViewer = async (
|
||||
containerRef: HTMLElement,
|
||||
modelUrl: string
|
||||
) => {
|
||||
if (!containerRef) return
|
||||
|
||||
try {
|
||||
isStandaloneMode.value = true
|
||||
|
||||
load3d = new Load3d(containerRef, {
|
||||
width: 800,
|
||||
height: 600,
|
||||
isViewerMode: true
|
||||
})
|
||||
|
||||
await load3d.loadModel(modelUrl)
|
||||
|
||||
backgroundColor.value = '#282828'
|
||||
showGrid.value = true
|
||||
cameraType.value = 'perspective'
|
||||
fov.value = 75
|
||||
lightIntensity.value = 1
|
||||
backgroundRenderMode.value = 'tiled'
|
||||
upDirection.value = 'original'
|
||||
materialMode.value = 'original'
|
||||
|
||||
isPreview.value = true
|
||||
} catch (error) {
|
||||
console.error('Error initializing standalone 3D viewer:', error)
|
||||
useToastStore().addAlert('Failed to load 3D model')
|
||||
}
|
||||
}
|
||||
|
||||
const exportModel = async (format: string) => {
|
||||
if (!load3d) return
|
||||
|
||||
@@ -289,6 +334,8 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
}
|
||||
|
||||
const restoreInitialState = () => {
|
||||
if (!node) return
|
||||
|
||||
const nodeValue = node
|
||||
|
||||
needApplyChanges.value = false
|
||||
@@ -324,7 +371,7 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
}
|
||||
|
||||
const applyChanges = async () => {
|
||||
if (!sourceLoad3d || !load3d) return false
|
||||
if (!node || !sourceLoad3d || !load3d) return false
|
||||
|
||||
const viewerCameraState = load3d.getCameraState()
|
||||
const nodeValue = node
|
||||
@@ -378,6 +425,10 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const resourceFolder =
|
||||
(node.properties['Resource Folder'] as string) || ''
|
||||
@@ -403,6 +454,10 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const resourceFolder =
|
||||
(node.properties['Resource Folder'] as string) || ''
|
||||
@@ -460,9 +515,11 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
materialMode,
|
||||
needApplyChanges,
|
||||
isPreview,
|
||||
isStandaloneMode,
|
||||
|
||||
// Methods
|
||||
initializeViewer,
|
||||
initializeStandaloneViewer,
|
||||
exportModel,
|
||||
handleResize,
|
||||
handleMouseEnter,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import log from 'loglevel'
|
||||
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { PYTHON_MIRROR } from '@/constants/uvMirrors'
|
||||
import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -8,13 +9,6 @@ import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { checkMirrorReachable } from '@/utils/electronMirrorCheck'
|
||||
import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
|
||||
|
||||
// Desktop documentation URLs
|
||||
const DESKTOP_DOCS = {
|
||||
WINDOWS: 'https://docs.comfy.org/installation/desktop/windows',
|
||||
MACOS: 'https://docs.comfy.org/installation/desktop/macos'
|
||||
} as const
|
||||
|
||||
;(async () => {
|
||||
if (!isElectron()) return
|
||||
|
||||
@@ -22,6 +16,7 @@ const DESKTOP_DOCS = {
|
||||
const desktopAppVersion = await electronAPI.getElectronVersion()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const toastStore = useToastStore()
|
||||
const { staticUrls, buildDocsUrl } = useExternalLink()
|
||||
|
||||
const onChangeRestartApp = (newValue: string, oldValue: string) => {
|
||||
// Add a delay to allow changes to take effect before restarting.
|
||||
@@ -165,11 +160,13 @@ const DESKTOP_DOCS = {
|
||||
label: 'Desktop User Guide',
|
||||
icon: 'pi pi-book',
|
||||
function() {
|
||||
const docsUrl =
|
||||
electronAPI.getPlatform() === 'darwin'
|
||||
? DESKTOP_DOCS.MACOS
|
||||
: DESKTOP_DOCS.WINDOWS
|
||||
window.open(docsUrl, '_blank')
|
||||
window.open(
|
||||
buildDocsUrl('/installation/desktop', {
|
||||
includeLocale: true,
|
||||
platform: true
|
||||
}),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -304,7 +301,7 @@ const DESKTOP_DOCS = {
|
||||
aboutPageBadges: [
|
||||
{
|
||||
label: 'ComfyUI_desktop v' + desktopAppVersion,
|
||||
url: 'https://github.com/Comfy-Org/electron',
|
||||
url: staticUrls.githubElectron,
|
||||
icon: 'pi pi-github'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -317,7 +317,7 @@ useExtensionService().registerExtension({
|
||||
const cameraConfig = node.properties['Camera Config'] as any
|
||||
const cameraState = cameraConfig?.state
|
||||
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
const width = node.widgets?.find((w) => w.name === 'width')
|
||||
@@ -444,7 +444,7 @@ useExtensionService().registerExtension({
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
|
||||
|
||||
@@ -5,8 +5,7 @@ import {
|
||||
type CameraManagerInterface,
|
||||
type CameraState,
|
||||
type CameraType,
|
||||
type EventManagerInterface,
|
||||
type NodeStorageInterface
|
||||
type EventManagerInterface
|
||||
} from './interfaces'
|
||||
|
||||
export class CameraManager implements CameraManagerInterface {
|
||||
@@ -17,7 +16,6 @@ export class CameraManager implements CameraManagerInterface {
|
||||
// @ts-expect-error unused variable
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private eventManager: EventManagerInterface
|
||||
private nodeStorage: NodeStorageInterface
|
||||
|
||||
private controls: OrbitControls | null = null
|
||||
|
||||
@@ -45,12 +43,10 @@ export class CameraManager implements CameraManagerInterface {
|
||||
|
||||
constructor(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
eventManager: EventManagerInterface,
|
||||
nodeStorage: NodeStorageInterface
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.renderer = renderer
|
||||
this.eventManager = eventManager
|
||||
this.nodeStorage = nodeStorage
|
||||
|
||||
this.perspectiveCamera = new THREE.PerspectiveCamera(
|
||||
this.DEFAULT_PERSPECTIVE_CAMERA.fov,
|
||||
@@ -82,17 +78,7 @@ export class CameraManager implements CameraManagerInterface {
|
||||
|
||||
if (this.controls) {
|
||||
this.controls.addEventListener('end', () => {
|
||||
const cameraState = this.getCameraState()
|
||||
|
||||
const cameraConfig = this.nodeStorage.loadNodeProperty(
|
||||
'Camera Config',
|
||||
{
|
||||
cameraType: this.getCurrentCameraType(),
|
||||
fov: this.perspectiveCamera.fov
|
||||
}
|
||||
)
|
||||
cameraConfig.state = cameraState
|
||||
this.nodeStorage.storeNodeProperty('Camera Config', cameraConfig)
|
||||
this.eventManager.emitEvent('cameraChanged', this.getCameraState())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,25 +3,20 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
|
||||
import {
|
||||
type ControlsManagerInterface,
|
||||
type EventManagerInterface,
|
||||
type NodeStorageInterface
|
||||
type EventManagerInterface
|
||||
} from './interfaces'
|
||||
|
||||
export class ControlsManager implements ControlsManagerInterface {
|
||||
controls: OrbitControls
|
||||
// @ts-expect-error unused variable
|
||||
private eventManager: EventManagerInterface
|
||||
private nodeStorage: NodeStorageInterface
|
||||
private camera: THREE.Camera
|
||||
|
||||
constructor(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
camera: THREE.Camera,
|
||||
eventManager: EventManagerInterface,
|
||||
nodeStorage: NodeStorageInterface
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.eventManager = eventManager
|
||||
this.nodeStorage = nodeStorage
|
||||
this.camera = camera
|
||||
|
||||
const container = renderer.domElement.parentElement || renderer.domElement
|
||||
@@ -44,15 +39,7 @@ export class ControlsManager implements ControlsManagerInterface {
|
||||
: 'orthographic'
|
||||
}
|
||||
|
||||
const cameraConfig = this.nodeStorage.loadNodeProperty('Camera Config', {
|
||||
cameraType: cameraState.cameraType,
|
||||
fov:
|
||||
this.camera instanceof THREE.PerspectiveCamera
|
||||
? (this.camera as THREE.PerspectiveCamera).fov
|
||||
: 75
|
||||
})
|
||||
cameraConfig.state = cameraState
|
||||
this.nodeStorage.storeNodeProperty('Camera Config', cameraConfig)
|
||||
this.eventManager.emitEvent('cameraChanged', cameraState)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@ import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type {
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
LightConfig,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type { Dictionary } from '@/lib/litegraph/src/interfaces'
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -13,14 +16,17 @@ import { api } from '@/scripts/api'
|
||||
type Load3DConfigurationSettings = {
|
||||
loadFolder: string
|
||||
modelWidget: IBaseWidget
|
||||
cameraState?: any
|
||||
cameraState?: CameraState
|
||||
width?: IBaseWidget
|
||||
height?: IBaseWidget
|
||||
bgImagePath?: string
|
||||
}
|
||||
|
||||
class Load3DConfiguration {
|
||||
constructor(private load3d: Load3d) {}
|
||||
constructor(
|
||||
private load3d: Load3d,
|
||||
private properties?: Dictionary<NodeProperty | undefined>
|
||||
) {}
|
||||
|
||||
configureForSaveMesh(loadFolder: 'input' | 'output', filePath: string) {
|
||||
this.setupModelHandlingForSaveMesh(filePath, loadFolder)
|
||||
@@ -62,7 +68,7 @@ class Load3DConfiguration {
|
||||
private setupModelHandling(
|
||||
modelWidget: IBaseWidget,
|
||||
loadFolder: string,
|
||||
cameraState?: any
|
||||
cameraState?: CameraState
|
||||
) {
|
||||
const onModelWidgetUpdate = this.createModelUpdateHandler(
|
||||
loadFolder,
|
||||
@@ -110,48 +116,48 @@ class Load3DConfiguration {
|
||||
}
|
||||
|
||||
private loadSceneConfig(): SceneConfig {
|
||||
const defaultConfig: SceneConfig = {
|
||||
if (this.properties && 'Scene Config' in this.properties) {
|
||||
return this.properties['Scene Config'] as SceneConfig
|
||||
}
|
||||
|
||||
return {
|
||||
showGrid: useSettingStore().get('Comfy.Load3D.ShowGrid'),
|
||||
backgroundColor:
|
||||
'#' + useSettingStore().get('Comfy.Load3D.BackgroundColor'),
|
||||
backgroundImage: ''
|
||||
}
|
||||
|
||||
const config = this.load3d.loadNodeProperty('Scene Config', defaultConfig)
|
||||
this.load3d.node.properties['Scene Config'] = config
|
||||
return config
|
||||
} as SceneConfig
|
||||
}
|
||||
|
||||
private loadCameraConfig(): CameraConfig {
|
||||
const defaultConfig: CameraConfig = {
|
||||
cameraType: useSettingStore().get('Comfy.Load3D.CameraType'),
|
||||
fov: 35
|
||||
if (this.properties && 'Camera Config' in this.properties) {
|
||||
return this.properties['Camera Config'] as CameraConfig
|
||||
}
|
||||
|
||||
const config = this.load3d.loadNodeProperty('Camera Config', defaultConfig)
|
||||
this.load3d.node.properties['Camera Config'] = config
|
||||
return config
|
||||
return {
|
||||
cameraType: useSettingStore().get('Comfy.Load3D.CameraType'),
|
||||
fov: 35
|
||||
} as CameraConfig
|
||||
}
|
||||
|
||||
private loadLightConfig(): LightConfig {
|
||||
const defaultConfig: LightConfig = {
|
||||
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity')
|
||||
if (this.properties && 'Light Config' in this.properties) {
|
||||
return this.properties['Light Config'] as LightConfig
|
||||
}
|
||||
|
||||
const config = this.load3d.loadNodeProperty('Light Config', defaultConfig)
|
||||
this.load3d.node.properties['Light Config'] = config
|
||||
return config
|
||||
return {
|
||||
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity')
|
||||
} as LightConfig
|
||||
}
|
||||
|
||||
private loadModelConfig(): ModelConfig {
|
||||
const defaultConfig: ModelConfig = {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
if (this.properties && 'Model Config' in this.properties) {
|
||||
return this.properties['Model Config'] as ModelConfig
|
||||
}
|
||||
|
||||
const config = this.load3d.loadNodeProperty('Model Config', defaultConfig)
|
||||
this.load3d.node.properties['Model Config'] = config
|
||||
return config
|
||||
return {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
} as ModelConfig
|
||||
}
|
||||
|
||||
private applySceneConfig(config: SceneConfig, bgImagePath?: string) {
|
||||
@@ -188,7 +194,10 @@ class Load3DConfiguration {
|
||||
this.load3d.setMaterialMode(config.materialMode)
|
||||
}
|
||||
|
||||
private createModelUpdateHandler(loadFolder: string, cameraState?: any) {
|
||||
private createModelUpdateHandler(
|
||||
loadFolder: string,
|
||||
cameraState?: CameraState
|
||||
) {
|
||||
let isFirstLoad = true
|
||||
return async (value: string | number | boolean | object) => {
|
||||
if (!value) return
|
||||
@@ -209,7 +218,7 @@ class Load3DConfiguration {
|
||||
const modelConfig = this.loadModelConfig()
|
||||
this.applyModelConfig(modelConfig)
|
||||
|
||||
if (isFirstLoad && cameraState && typeof cameraState === 'object') {
|
||||
if (isFirstLoad && cameraState) {
|
||||
try {
|
||||
this.load3d.setCameraState(cameraState)
|
||||
} catch (error) {
|
||||
@@ -230,8 +239,8 @@ class Load3DConfiguration {
|
||||
const subfolderParts = pathParts.slice(1, -1)
|
||||
const subfolder = subfolderParts.join('/')
|
||||
|
||||
if (subfolder) {
|
||||
this.load3d.node.properties['Resource Folder'] = subfolder
|
||||
if (subfolder && this.properties) {
|
||||
this.properties['Resource Folder'] = subfolder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { AnimationManager } from './AnimationManager'
|
||||
import { CameraManager } from './CameraManager'
|
||||
import { ControlsManager } from './ControlsManager'
|
||||
@@ -9,7 +7,6 @@ import { EventManager } from './EventManager'
|
||||
import { LightingManager } from './LightingManager'
|
||||
import { LoaderManager } from './LoaderManager'
|
||||
import { ModelExporter } from './ModelExporter'
|
||||
import { NodeStorage } from './NodeStorage'
|
||||
import { RecordingManager } from './RecordingManager'
|
||||
import { SceneManager } from './SceneManager'
|
||||
import { SceneModelManager } from './SceneModelManager'
|
||||
@@ -21,17 +18,16 @@ import {
|
||||
type MaterialMode,
|
||||
type UpDirection
|
||||
} from './interfaces'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
class Load3d {
|
||||
renderer: THREE.WebGLRenderer
|
||||
protected clock: THREE.Clock
|
||||
protected animationFrameId: number | null = null
|
||||
node: LGraphNode
|
||||
private loadingPromise: Promise<void> | null = null
|
||||
private onContextMenuCallback?: (event: MouseEvent) => void
|
||||
private getDimensionsCallback?: () => { width: number; height: number } | null
|
||||
|
||||
eventManager: EventManager
|
||||
nodeStorage: NodeStorage
|
||||
sceneManager: SceneManager
|
||||
cameraManager: CameraManager
|
||||
controlsManager: ControlsManager
|
||||
@@ -59,23 +55,16 @@ class Load3d {
|
||||
private readonly dragThreshold: number = 5
|
||||
private contextMenuAbortController: AbortController | null = null
|
||||
|
||||
constructor(
|
||||
container: Element | HTMLElement,
|
||||
options: Load3DOptions = {
|
||||
node: {} as LGraphNode
|
||||
}
|
||||
) {
|
||||
this.node = options.node || ({} as LGraphNode)
|
||||
constructor(container: Element | HTMLElement, options: Load3DOptions = {}) {
|
||||
this.clock = new THREE.Clock()
|
||||
this.isViewerMode = options.isViewerMode || false
|
||||
this.onContextMenuCallback = options.onContextMenu
|
||||
this.getDimensionsCallback = options.getDimensions
|
||||
|
||||
const widthWidget = this.node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = this.node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
if (widthWidget && heightWidget) {
|
||||
this.targetWidth = widthWidget.value as number
|
||||
this.targetHeight = heightWidget.value as number
|
||||
this.targetAspectRatio = this.targetWidth / this.targetHeight
|
||||
if (options.width && options.height) {
|
||||
this.targetWidth = options.width
|
||||
this.targetHeight = options.height
|
||||
this.targetAspectRatio = options.width / options.height
|
||||
}
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
|
||||
@@ -87,7 +76,6 @@ class Load3d {
|
||||
container.appendChild(this.renderer.domElement)
|
||||
|
||||
this.eventManager = new EventManager()
|
||||
this.nodeStorage = new NodeStorage(this.node)
|
||||
|
||||
this.sceneManager = new SceneManager(
|
||||
this.renderer,
|
||||
@@ -96,17 +84,12 @@ class Load3d {
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.cameraManager = new CameraManager(
|
||||
this.renderer,
|
||||
this.eventManager,
|
||||
this.nodeStorage
|
||||
)
|
||||
this.cameraManager = new CameraManager(this.renderer, this.eventManager)
|
||||
|
||||
this.controlsManager = new ControlsManager(
|
||||
this.renderer,
|
||||
this.cameraManager.activeCamera,
|
||||
this.eventManager,
|
||||
this.nodeStorage
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.cameraManager.setControls(this.controlsManager.controls)
|
||||
@@ -120,7 +103,7 @@ class Load3d {
|
||||
this.renderer,
|
||||
this.getActiveCamera.bind(this),
|
||||
this.getControls.bind(this),
|
||||
this.nodeStorage
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.modelManager = new SceneModelManager(
|
||||
@@ -221,13 +204,9 @@ class Load3d {
|
||||
}
|
||||
|
||||
private showNodeContextMenu(event: MouseEvent): void {
|
||||
const menuOptions = app.canvas.getNodeMenuOptions(this.node)
|
||||
|
||||
new LiteGraph.ContextMenu(menuOptions, {
|
||||
event,
|
||||
title: this.node.type,
|
||||
extra: this.node
|
||||
})
|
||||
if (this.onContextMenuCallback) {
|
||||
this.onContextMenuCallback(event)
|
||||
}
|
||||
}
|
||||
|
||||
getEventManager(): EventManager {
|
||||
@@ -259,6 +238,17 @@ class Load3d {
|
||||
return this.recordingManager
|
||||
}
|
||||
|
||||
getTargetSize(): { width: number; height: number } {
|
||||
return {
|
||||
width: this.targetWidth,
|
||||
height: this.targetHeight
|
||||
}
|
||||
}
|
||||
|
||||
private shouldMaintainAspectRatio(): boolean {
|
||||
return this.isViewerMode || (this.targetWidth > 0 && this.targetHeight > 0)
|
||||
}
|
||||
|
||||
forceRender(): void {
|
||||
const delta = this.clock.getDelta()
|
||||
this.animationManager.update(delta)
|
||||
@@ -280,18 +270,16 @@ class Load3d {
|
||||
const containerWidth = this.renderer.domElement.clientWidth
|
||||
const containerHeight = this.renderer.domElement.clientHeight
|
||||
|
||||
const widthWidget = this.node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = this.node.widgets?.find((w) => w.name === 'height')
|
||||
const shouldMaintainAspectRatio =
|
||||
(widthWidget && heightWidget) || this.isViewerMode
|
||||
|
||||
if (shouldMaintainAspectRatio) {
|
||||
if (widthWidget && heightWidget) {
|
||||
this.targetWidth = widthWidget.value as number
|
||||
this.targetHeight = heightWidget.value as number
|
||||
this.targetAspectRatio = this.targetWidth / this.targetHeight
|
||||
if (this.getDimensionsCallback) {
|
||||
const dims = this.getDimensionsCallback()
|
||||
if (dims) {
|
||||
this.targetWidth = dims.width
|
||||
this.targetHeight = dims.height
|
||||
this.targetAspectRatio = dims.width / dims.height
|
||||
}
|
||||
}
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
const containerAspectRatio = containerWidth / containerHeight
|
||||
|
||||
let renderWidth: number
|
||||
@@ -321,7 +309,7 @@ class Load3d {
|
||||
const renderAspectRatio = renderWidth / renderHeight
|
||||
this.cameraManager.updateAspectRatio(renderAspectRatio)
|
||||
} else {
|
||||
// Preview3D: fill the entire container
|
||||
// No aspect ratio constraint: fill the entire container
|
||||
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissorTest(true)
|
||||
@@ -459,13 +447,7 @@ class Load3d {
|
||||
const containerWidth = this.renderer.domElement.clientWidth
|
||||
const containerHeight = this.renderer.domElement.clientHeight
|
||||
|
||||
// Calculate the actual render area based on target aspect ratio
|
||||
const widthWidget = this.node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = this.node.widgets?.find((w) => w.name === 'height')
|
||||
const shouldMaintainAspectRatio =
|
||||
(widthWidget && heightWidget) || this.isViewerMode
|
||||
|
||||
if (shouldMaintainAspectRatio) {
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
const containerAspectRatio = containerWidth / containerHeight
|
||||
|
||||
let renderWidth: number
|
||||
@@ -486,7 +468,7 @@ class Load3d {
|
||||
renderHeight
|
||||
)
|
||||
} else {
|
||||
// For Preview3D mode without aspect ratio constraints
|
||||
// No aspect ratio constraints: fill container
|
||||
this.sceneManager.updateBackgroundSize(
|
||||
this.sceneManager.backgroundTexture,
|
||||
this.sceneManager.backgroundMesh,
|
||||
@@ -609,6 +591,7 @@ class Load3d {
|
||||
this.targetWidth = width
|
||||
this.targetHeight = height
|
||||
this.targetAspectRatio = width / height
|
||||
this.handleResize()
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
@@ -636,20 +619,16 @@ class Load3d {
|
||||
const containerWidth = parentElement.clientWidth
|
||||
const containerHeight = parentElement.clientHeight
|
||||
|
||||
// Check if we have width/height widgets (Load3D nodes) or if it's viewer mode
|
||||
const widthWidget = this.node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = this.node.widgets?.find((w) => w.name === 'height')
|
||||
const shouldMaintainAspectRatio =
|
||||
(widthWidget && heightWidget) || this.isViewerMode
|
||||
|
||||
if (shouldMaintainAspectRatio) {
|
||||
// Load3D or viewer mode: maintain aspect ratio
|
||||
if (widthWidget && heightWidget) {
|
||||
this.targetWidth = widthWidget.value as number
|
||||
this.targetHeight = heightWidget.value as number
|
||||
this.targetAspectRatio = this.targetWidth / this.targetHeight
|
||||
if (this.getDimensionsCallback) {
|
||||
const dims = this.getDimensionsCallback()
|
||||
if (dims) {
|
||||
this.targetWidth = dims.width
|
||||
this.targetHeight = dims.height
|
||||
this.targetAspectRatio = dims.width / dims.height
|
||||
}
|
||||
}
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
const containerAspectRatio = containerWidth / containerHeight
|
||||
let renderWidth: number
|
||||
let renderHeight: number
|
||||
@@ -666,7 +645,7 @@ class Load3d {
|
||||
this.cameraManager.handleResize(renderWidth, renderHeight)
|
||||
this.sceneManager.handleResize(renderWidth, renderHeight)
|
||||
} else {
|
||||
// Preview3D: use container dimensions directly
|
||||
// No aspect ratio constraint: use container dimensions directly
|
||||
this.renderer.setSize(containerWidth, containerHeight)
|
||||
this.cameraManager.handleResize(containerWidth, containerHeight)
|
||||
this.sceneManager.handleResize(containerWidth, containerHeight)
|
||||
@@ -679,10 +658,6 @@ class Load3d {
|
||||
return this.sceneManager.captureScene(width, height)
|
||||
}
|
||||
|
||||
loadNodeProperty(name: string, defaultValue: any) {
|
||||
return this.nodeStorage.loadNodeProperty(name, defaultValue)
|
||||
}
|
||||
|
||||
public async startRecording(): Promise<void> {
|
||||
this.viewHelperManager.visibleViewHelper(false)
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { type NodeStorageInterface } from './interfaces'
|
||||
|
||||
export class NodeStorage implements NodeStorageInterface {
|
||||
private node: LGraphNode
|
||||
|
||||
constructor(node: LGraphNode = {} as LGraphNode) {
|
||||
this.node = node
|
||||
}
|
||||
|
||||
storeNodeProperty(name: string, value: any): void {
|
||||
if (this.node && this.node.properties) {
|
||||
this.node.properties[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
loadNodeProperty(name: string, defaultValue: any): any {
|
||||
if (
|
||||
!this.node ||
|
||||
!this.node.properties ||
|
||||
!(name in this.node.properties)
|
||||
) {
|
||||
return defaultValue
|
||||
}
|
||||
return this.node.properties[name]
|
||||
}
|
||||
|
||||
setNode(node: LGraphNode): void {
|
||||
this.node = node
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
|
||||
|
||||
import {
|
||||
type NodeStorageInterface,
|
||||
type EventManagerInterface,
|
||||
type ViewHelperManagerInterface
|
||||
} from './interfaces'
|
||||
|
||||
@@ -13,7 +13,7 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
|
||||
|
||||
private getActiveCamera: () => THREE.Camera
|
||||
private getControls: () => OrbitControls
|
||||
private nodeStorage: NodeStorageInterface
|
||||
private eventManager: EventManagerInterface
|
||||
// @ts-expect-error unused variable
|
||||
private renderer: THREE.WebGLRenderer
|
||||
|
||||
@@ -21,12 +21,12 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
|
||||
renderer: THREE.WebGLRenderer,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
getControls: () => OrbitControls,
|
||||
nodeStorage: NodeStorageInterface
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.renderer = renderer
|
||||
this.getActiveCamera = getActiveCamera
|
||||
this.getControls = getControls
|
||||
this.nodeStorage = nodeStorage
|
||||
this.eventManager = eventManager
|
||||
}
|
||||
|
||||
init(): void {}
|
||||
@@ -87,18 +87,7 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
|
||||
: 'orthographic'
|
||||
}
|
||||
|
||||
const cameraConfig = this.nodeStorage.loadNodeProperty(
|
||||
'Camera Config',
|
||||
{
|
||||
cameraType: cameraState.cameraType,
|
||||
fov:
|
||||
this.getActiveCamera() instanceof THREE.PerspectiveCamera
|
||||
? (this.getActiveCamera() as THREE.PerspectiveCamera).fov
|
||||
: 75
|
||||
}
|
||||
)
|
||||
cameraConfig.state = cameraState
|
||||
this.nodeStorage.storeNodeProperty('Camera Config', cameraConfig)
|
||||
this.eventManager.emitEvent('cameraChanged', cameraState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,6 @@ import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
|
||||
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
export type MaterialMode = 'original' | 'normal' | 'wireframe' | 'depth'
|
||||
export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
|
||||
export type CameraType = 'perspective' | 'orthographic'
|
||||
@@ -49,10 +46,19 @@ export interface EventCallback {
|
||||
}
|
||||
|
||||
export interface Load3DOptions {
|
||||
node?: LGraphNode
|
||||
inputSpec?: CustomInputSpec
|
||||
disablePreview?: boolean
|
||||
// Optional target dimensions for aspect ratio control
|
||||
width?: number
|
||||
height?: number
|
||||
|
||||
// Dynamic dimension provider (called on every render)
|
||||
// Use this for reactive dimensions that change over time
|
||||
getDimensions?: () => { width: number; height: number } | null
|
||||
|
||||
// Viewer mode flag (affects aspect ratio behavior)
|
||||
isViewerMode?: boolean
|
||||
|
||||
// Optional context menu callback
|
||||
onContextMenu?: (event: MouseEvent) => void
|
||||
}
|
||||
|
||||
export interface CaptureResult {
|
||||
@@ -121,11 +127,6 @@ export interface EventManagerInterface {
|
||||
emitEvent(event: string, data?: any): void
|
||||
}
|
||||
|
||||
export interface NodeStorageInterface {
|
||||
storeNodeProperty(name: string, value: any): void
|
||||
loadNodeProperty(name: string, defaultValue: any): any
|
||||
}
|
||||
|
||||
export interface AnimationManagerInterface extends BaseManager {
|
||||
currentAnimation: THREE.AnimationMixer | null
|
||||
animationActions: THREE.AnimationAction[]
|
||||
|
||||
@@ -1,25 +1,62 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { app } from '../../scripts/app'
|
||||
import { ComfyApp } from '../../scripts/app'
|
||||
import { ClipspaceDialog } from './clipspace'
|
||||
import { MaskEditorDialog } from './maskeditor/MaskEditorDialog'
|
||||
import { app } from '@/scripts/app'
|
||||
import { ComfyApp } from '@/scripts/app'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
|
||||
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
|
||||
import { MaskEditorDialogOld } from './maskEditorOld'
|
||||
import { ClipspaceDialog } from './clipspace'
|
||||
|
||||
// Import styles to inject into document
|
||||
import './maskeditor/styles'
|
||||
function openMaskEditor(node: LGraphNode): void {
|
||||
if (!node) {
|
||||
console.error('[MaskEditor] No node provided')
|
||||
return
|
||||
}
|
||||
|
||||
if (!node.imgs?.length && node.previewMediaType !== 'image') {
|
||||
console.error('[MaskEditor] Node has no images')
|
||||
return
|
||||
}
|
||||
|
||||
// Function to open the mask editor
|
||||
function openMaskEditor(): void {
|
||||
const useNewEditor = app.extensionManager.setting.get(
|
||||
'Comfy.MaskEditor.UseNewEditor'
|
||||
)
|
||||
|
||||
if (useNewEditor) {
|
||||
const dlg = MaskEditorDialog.getInstance() as any
|
||||
if (dlg?.isOpened && !dlg.isOpened()) {
|
||||
dlg.show()
|
||||
}
|
||||
// Use new refactored editor
|
||||
useDialogStore().showDialog({
|
||||
key: 'global-mask-editor',
|
||||
headerComponent: TopBarHeader,
|
||||
component: MaskEditorContent,
|
||||
props: {
|
||||
node
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: 90vw; height: 90vh;',
|
||||
modal: true,
|
||||
maximizable: true,
|
||||
closable: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'mask-editor-dialog flex flex-col'
|
||||
},
|
||||
content: {
|
||||
class: 'flex flex-col min-h-0 flex-1 !p-0'
|
||||
},
|
||||
header: {
|
||||
class: '!p-2'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Use old editor
|
||||
ComfyApp.copyToClipspace(node)
|
||||
// @ts-expect-error clipspace_return_node is an extension property added at runtime
|
||||
ComfyApp.clipspace_return_node = node
|
||||
const dlg = MaskEditorDialogOld.getInstance() as any
|
||||
if (dlg?.isOpened && !dlg.isOpened()) {
|
||||
dlg.show()
|
||||
@@ -33,21 +70,12 @@ function isOpened(): boolean {
|
||||
'Comfy.MaskEditor.UseNewEditor'
|
||||
)
|
||||
if (useNewEditor) {
|
||||
return MaskEditorDialog.instance?.isOpened?.() ?? false
|
||||
return useDialogStore().isDialogOpen('global-mask-editor')
|
||||
} else {
|
||||
return (MaskEditorDialogOld.instance as any)?.isOpened?.() ?? false
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure boolean return type for context predicate
|
||||
const context_predicate = (): boolean => {
|
||||
return !!(
|
||||
ComfyApp.clipspace &&
|
||||
ComfyApp.clipspace.imgs &&
|
||||
ComfyApp.clipspace.imgs.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Comfy.MaskEditor',
|
||||
settings: [
|
||||
@@ -97,15 +125,7 @@ app.registerExtension({
|
||||
if (!selectedNodes || Object.keys(selectedNodes).length !== 1) return
|
||||
|
||||
const selectedNode = selectedNodes[Object.keys(selectedNodes)[0]]
|
||||
if (
|
||||
!selectedNode.imgs?.length &&
|
||||
selectedNode.previewMediaType !== 'image'
|
||||
)
|
||||
return
|
||||
ComfyApp.copyToClipspace(selectedNode)
|
||||
// @ts-expect-error clipspace_return_node is an extension property added at runtime
|
||||
ComfyApp.clipspace_return_node = selectedNode
|
||||
openMaskEditor()
|
||||
openMaskEditor(selectedNode)
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -122,24 +142,40 @@ app.registerExtension({
|
||||
}
|
||||
],
|
||||
init() {
|
||||
ComfyApp.open_maskeditor = openMaskEditor
|
||||
ComfyApp.maskeditor_is_opended = isOpened
|
||||
// Support for old editor clipspace integration
|
||||
const openMaskEditorFromClipspace = () => {
|
||||
const useNewEditor = app.extensionManager.setting.get(
|
||||
'Comfy.MaskEditor.UseNewEditor'
|
||||
)
|
||||
if (!useNewEditor) {
|
||||
const dlg = MaskEditorDialogOld.getInstance() as any
|
||||
if (dlg?.isOpened && !dlg.isOpened()) {
|
||||
dlg.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const context_predicate = (): boolean => {
|
||||
return !!(
|
||||
ComfyApp.clipspace &&
|
||||
ComfyApp.clipspace.imgs &&
|
||||
ComfyApp.clipspace.imgs.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
ClipspaceDialog.registerButton(
|
||||
'MaskEditor',
|
||||
context_predicate,
|
||||
openMaskEditor
|
||||
openMaskEditorFromClipspace
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const changeBrushSize = async (sizeChanger: (oldSize: number) => number) => {
|
||||
if (!isOpened()) return
|
||||
const maskEditor = MaskEditorDialog.getInstance()
|
||||
if (!maskEditor) return
|
||||
const messageBroker = maskEditor.getMessageBroker()
|
||||
const oldBrushSize = (await messageBroker.pull('brushSettings')).size
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const oldBrushSize = store.brushSettings.size
|
||||
const newBrushSize = sizeChanger(oldBrushSize)
|
||||
messageBroker.publish('setBrushSize', newBrushSize)
|
||||
messageBroker.publish('updateBrushPreview')
|
||||
store.setBrushSize(newBrushSize)
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import type { MaskEditorDialog } from './MaskEditorDialog'
|
||||
import type { MessageBroker } from './managers/MessageBroker'
|
||||
|
||||
export class CanvasHistory {
|
||||
// @ts-expect-error unused variable
|
||||
private maskEditor!: MaskEditorDialog
|
||||
private messageBroker!: MessageBroker
|
||||
|
||||
private canvas!: HTMLCanvasElement
|
||||
private ctx!: CanvasRenderingContext2D
|
||||
private rgbCanvas!: HTMLCanvasElement
|
||||
private rgbCtx!: CanvasRenderingContext2D
|
||||
private states: { mask: ImageData; rgb: ImageData }[] = []
|
||||
private currentStateIndex: number = -1
|
||||
private maxStates: number = 20
|
||||
private initialized: boolean = false
|
||||
|
||||
constructor(maskEditor: MaskEditorDialog, maxStates = 20) {
|
||||
this.maskEditor = maskEditor
|
||||
this.messageBroker = maskEditor.getMessageBroker()
|
||||
this.maxStates = maxStates
|
||||
this.createListeners()
|
||||
}
|
||||
|
||||
private async pullCanvas() {
|
||||
this.canvas = await this.messageBroker.pull('maskCanvas')
|
||||
this.ctx = await this.messageBroker.pull('maskCtx')
|
||||
this.rgbCanvas = await this.messageBroker.pull('rgbCanvas')
|
||||
this.rgbCtx = await this.messageBroker.pull('rgbCtx')
|
||||
}
|
||||
|
||||
private createListeners() {
|
||||
this.messageBroker.subscribe('saveState', () => this.saveState())
|
||||
this.messageBroker.subscribe('undo', () => this.undo())
|
||||
this.messageBroker.subscribe('redo', () => this.redo())
|
||||
}
|
||||
|
||||
clearStates() {
|
||||
this.states = []
|
||||
this.currentStateIndex = -1
|
||||
this.initialized = false
|
||||
}
|
||||
|
||||
async saveInitialState() {
|
||||
await this.pullCanvas()
|
||||
if (
|
||||
!this.canvas.width ||
|
||||
!this.canvas.height ||
|
||||
!this.rgbCanvas.width ||
|
||||
!this.rgbCanvas.height
|
||||
) {
|
||||
// Canvas not ready yet, defer initialization
|
||||
requestAnimationFrame(() => this.saveInitialState())
|
||||
return
|
||||
}
|
||||
|
||||
this.clearStates()
|
||||
const maskState = this.ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
this.canvas.width,
|
||||
this.canvas.height
|
||||
)
|
||||
const rgbState = this.rgbCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
this.rgbCanvas.width,
|
||||
this.rgbCanvas.height
|
||||
)
|
||||
this.states.push({ mask: maskState, rgb: rgbState })
|
||||
this.currentStateIndex = 0
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
saveState() {
|
||||
// Ensure we have an initial state
|
||||
if (!this.initialized || this.currentStateIndex === -1) {
|
||||
this.saveInitialState()
|
||||
return
|
||||
}
|
||||
|
||||
this.states = this.states.slice(0, this.currentStateIndex + 1)
|
||||
const maskState = this.ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
this.canvas.width,
|
||||
this.canvas.height
|
||||
)
|
||||
const rgbState = this.rgbCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
this.rgbCanvas.width,
|
||||
this.rgbCanvas.height
|
||||
)
|
||||
this.states.push({ mask: maskState, rgb: rgbState })
|
||||
this.currentStateIndex++
|
||||
|
||||
if (this.states.length > this.maxStates) {
|
||||
this.states.shift()
|
||||
this.currentStateIndex--
|
||||
}
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (this.states.length > 1 && this.currentStateIndex > 0) {
|
||||
this.currentStateIndex--
|
||||
this.restoreState(this.states[this.currentStateIndex])
|
||||
} else {
|
||||
alert('No more undo states available')
|
||||
}
|
||||
}
|
||||
|
||||
redo() {
|
||||
if (
|
||||
this.states.length > 1 &&
|
||||
this.currentStateIndex < this.states.length - 1
|
||||
) {
|
||||
this.currentStateIndex++
|
||||
this.restoreState(this.states[this.currentStateIndex])
|
||||
} else {
|
||||
alert('No more redo states available')
|
||||
}
|
||||
}
|
||||
|
||||
restoreState(state: { mask: ImageData; rgb: ImageData }) {
|
||||
if (state && this.initialized) {
|
||||
this.ctx.putImageData(state.mask, 0, 0)
|
||||
this.rgbCtx.putImageData(state.rgb, 0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
import { t } from '@/i18n'
|
||||
import { api } from '../../../scripts/api'
|
||||
import { ComfyApp } from '../../../scripts/app'
|
||||
import { $el, ComfyDialog } from '../../../scripts/ui'
|
||||
import { ClipspaceDialog } from '../clipspace'
|
||||
import { imageLayerFilenamesByTimestamp } from './utils/maskEditorLayerFilenames'
|
||||
import { CanvasHistory } from './CanvasHistory'
|
||||
import { CompositionOperation } from './types'
|
||||
import type { Ref } from './types'
|
||||
import {
|
||||
UIManager,
|
||||
ToolManager,
|
||||
PanAndZoomManager,
|
||||
KeyboardManager,
|
||||
MessageBroker
|
||||
} from './managers'
|
||||
import { BrushTool, PaintBucketTool, ColorSelectTool } from './tools'
|
||||
import {
|
||||
ensureImageFullyLoaded,
|
||||
removeImageRgbValuesAndInvertAlpha,
|
||||
createCanvasCopy,
|
||||
getCanvas2dContext,
|
||||
combineOriginalImageAndPaint,
|
||||
toRef,
|
||||
mkFileUrl,
|
||||
requestWithRetries,
|
||||
replaceClipspaceImages
|
||||
} from './utils'
|
||||
|
||||
export class MaskEditorDialog extends ComfyDialog {
|
||||
static instance: MaskEditorDialog | null = null
|
||||
|
||||
//new
|
||||
private uiManager!: UIManager
|
||||
// @ts-expect-error unused variable
|
||||
private toolManager!: ToolManager
|
||||
// @ts-expect-error unused variable
|
||||
private panAndZoomManager!: PanAndZoomManager
|
||||
// @ts-expect-error unused variable
|
||||
private brushTool!: BrushTool
|
||||
private paintBucketTool!: PaintBucketTool
|
||||
private colorSelectTool!: ColorSelectTool
|
||||
private canvasHistory!: CanvasHistory
|
||||
private messageBroker!: MessageBroker
|
||||
private keyboardManager!: KeyboardManager
|
||||
|
||||
private rootElement!: HTMLElement
|
||||
private imageURL!: string
|
||||
|
||||
private isLayoutCreated: boolean = false
|
||||
private isOpen: boolean = false
|
||||
|
||||
//variables needed?
|
||||
last_display_style: string | null = null
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.rootElement = $el(
|
||||
'div.maskEditor_hidden',
|
||||
{ parent: document.body },
|
||||
[]
|
||||
)
|
||||
|
||||
this.element = this.rootElement
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!ComfyApp.clipspace || !ComfyApp.clipspace.imgs) {
|
||||
throw new Error('No clipspace images found')
|
||||
}
|
||||
const currentSrc =
|
||||
ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src
|
||||
|
||||
if (
|
||||
!MaskEditorDialog.instance ||
|
||||
currentSrc !== MaskEditorDialog.instance.imageURL
|
||||
) {
|
||||
if (MaskEditorDialog.instance) MaskEditorDialog.instance.destroy()
|
||||
MaskEditorDialog.instance = new MaskEditorDialog()
|
||||
MaskEditorDialog.instance.imageURL = currentSrc
|
||||
}
|
||||
return MaskEditorDialog.instance
|
||||
}
|
||||
|
||||
override async show() {
|
||||
this.cleanup()
|
||||
if (!this.isLayoutCreated) {
|
||||
// layout
|
||||
this.messageBroker = new MessageBroker()
|
||||
this.canvasHistory = new CanvasHistory(this, 20)
|
||||
this.paintBucketTool = new PaintBucketTool(this)
|
||||
this.brushTool = new BrushTool(this)
|
||||
this.panAndZoomManager = new PanAndZoomManager(this)
|
||||
this.toolManager = new ToolManager(this)
|
||||
this.keyboardManager = new KeyboardManager(this)
|
||||
this.uiManager = new UIManager(this.rootElement, this)
|
||||
this.colorSelectTool = new ColorSelectTool(this)
|
||||
|
||||
// replacement of onClose hook since close is not real close
|
||||
const self = this
|
||||
const observer = new MutationObserver(function (mutations) {
|
||||
mutations.forEach(function (mutation) {
|
||||
if (
|
||||
mutation.type === 'attributes' &&
|
||||
mutation.attributeName === 'style'
|
||||
) {
|
||||
if (
|
||||
self.last_display_style &&
|
||||
self.last_display_style != 'none' &&
|
||||
self.element.style.display == 'none'
|
||||
) {
|
||||
//self.brush.style.display = 'none'
|
||||
ComfyApp.onClipspaceEditorClosed()
|
||||
}
|
||||
|
||||
self.last_display_style = self.element.style.display
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const config = { attributes: true }
|
||||
observer.observe(this.rootElement, config)
|
||||
|
||||
this.isLayoutCreated = true
|
||||
|
||||
await this.uiManager.setlayout()
|
||||
}
|
||||
|
||||
//this.zoomAndPanManager.reset()
|
||||
|
||||
this.rootElement.id = 'maskEditor'
|
||||
this.rootElement.style.display = 'flex'
|
||||
this.element.style.display = 'flex'
|
||||
await this.uiManager.initUI()
|
||||
this.paintBucketTool.initPaintBucketTool()
|
||||
this.colorSelectTool.initColorSelectTool()
|
||||
await this.canvasHistory.saveInitialState()
|
||||
this.isOpen = true
|
||||
if (ComfyApp.clipspace && ComfyApp.clipspace.imgs) {
|
||||
this.uiManager.setSidebarImage()
|
||||
}
|
||||
this.keyboardManager.addListeners()
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
// Remove all maskEditor elements
|
||||
const maskEditors = document.querySelectorAll('[id^="maskEditor"]')
|
||||
maskEditors.forEach((element) => element.remove())
|
||||
|
||||
// Remove brush elements specifically
|
||||
const brushElements = document.querySelectorAll('#maskEditor_brush')
|
||||
brushElements.forEach((element) => element.remove())
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.isLayoutCreated = false
|
||||
this.isOpen = false
|
||||
this.canvasHistory.clearStates()
|
||||
this.keyboardManager.removeListeners()
|
||||
this.cleanup()
|
||||
this.close()
|
||||
MaskEditorDialog.instance = null
|
||||
}
|
||||
|
||||
isOpened() {
|
||||
return this.isOpen
|
||||
}
|
||||
|
||||
async save() {
|
||||
const imageCanvas = this.uiManager.getImgCanvas()
|
||||
const maskCanvas = this.uiManager.getMaskCanvas()
|
||||
const maskCanvasCtx = getCanvas2dContext(maskCanvas)
|
||||
const paintCanvas = this.uiManager.getRgbCanvas()
|
||||
const image = this.uiManager.getImage()
|
||||
|
||||
try {
|
||||
await ensureImageFullyLoaded(maskCanvas.toDataURL())
|
||||
} catch (error) {
|
||||
console.error('Error loading mask image:', error)
|
||||
return
|
||||
}
|
||||
|
||||
const unrefinedMaskImageData = maskCanvasCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
maskCanvas.width,
|
||||
maskCanvas.height
|
||||
)
|
||||
|
||||
const refinedMaskOnlyData = new ImageData(
|
||||
removeImageRgbValuesAndInvertAlpha(unrefinedMaskImageData.data),
|
||||
unrefinedMaskImageData.width,
|
||||
unrefinedMaskImageData.height
|
||||
)
|
||||
|
||||
// We create an undisplayed copy so as not to alter the original--displayed--canvas
|
||||
const [refinedMaskCanvas, refinedMaskCanvasCtx] =
|
||||
createCanvasCopy(maskCanvas)
|
||||
refinedMaskCanvasCtx.globalCompositeOperation =
|
||||
CompositionOperation.SourceOver
|
||||
refinedMaskCanvasCtx.putImageData(refinedMaskOnlyData, 0, 0)
|
||||
|
||||
const timestamp = Math.round(performance.now())
|
||||
const filenames = imageLayerFilenamesByTimestamp(timestamp)
|
||||
const refs = {
|
||||
maskedImage: toRef(filenames.maskedImage),
|
||||
paint: toRef(filenames.paint),
|
||||
paintedImage: toRef(filenames.paintedImage),
|
||||
paintedMaskedImage: toRef(filenames.paintedMaskedImage)
|
||||
}
|
||||
|
||||
const [paintedImageCanvas] = combineOriginalImageAndPaint({
|
||||
originalImage: imageCanvas,
|
||||
paint: paintCanvas
|
||||
})
|
||||
|
||||
replaceClipspaceImages(refs.paintedMaskedImage, [refs.paint])
|
||||
|
||||
const originalImageUrl = new URL(image.src)
|
||||
|
||||
this.uiManager.setBrushOpacity(0)
|
||||
|
||||
const originalImageFilename = originalImageUrl.searchParams.get('filename')
|
||||
if (!originalImageFilename)
|
||||
throw new Error(
|
||||
"Expected original image URL to have a `filename` query parameter, but couldn't find it."
|
||||
)
|
||||
|
||||
const originalImageRef: Partial<Ref> = {
|
||||
filename: originalImageFilename,
|
||||
subfolder: originalImageUrl.searchParams.get('subfolder') ?? undefined,
|
||||
type: originalImageUrl.searchParams.get('type') ?? undefined
|
||||
}
|
||||
|
||||
const mkFormData = (
|
||||
blob: Blob,
|
||||
filename: string,
|
||||
originalImageRefOverride?: Partial<Ref>
|
||||
) => {
|
||||
const formData = new FormData()
|
||||
formData.append('image', blob, filename)
|
||||
formData.append(
|
||||
'original_ref',
|
||||
JSON.stringify(originalImageRefOverride ?? originalImageRef)
|
||||
)
|
||||
formData.append('type', 'input')
|
||||
formData.append('subfolder', 'clipspace')
|
||||
return formData
|
||||
}
|
||||
|
||||
const canvasToFormData = (
|
||||
canvas: HTMLCanvasElement,
|
||||
filename: string,
|
||||
originalImageRefOverride?: Partial<Ref>
|
||||
) => {
|
||||
const blob = this.dataURLToBlob(canvas.toDataURL())
|
||||
return mkFormData(blob, filename, originalImageRefOverride)
|
||||
}
|
||||
|
||||
const formDatas = {
|
||||
// Note: this canvas only contains mask data (no image), but during the upload process, the backend combines the mask with the original_image. Refer to the backend repo's `server.py`, search for `@routes.post("/upload/mask")`
|
||||
maskedImage: canvasToFormData(refinedMaskCanvas, filenames.maskedImage),
|
||||
paint: canvasToFormData(paintCanvas, filenames.paint),
|
||||
paintedImage: canvasToFormData(
|
||||
paintedImageCanvas,
|
||||
filenames.paintedImage
|
||||
),
|
||||
paintedMaskedImage: canvasToFormData(
|
||||
refinedMaskCanvas,
|
||||
filenames.paintedMaskedImage,
|
||||
refs.paintedImage
|
||||
)
|
||||
}
|
||||
|
||||
this.uiManager.setSaveButtonText(t('g.saving'))
|
||||
this.uiManager.setSaveButtonEnabled(false)
|
||||
this.keyboardManager.removeListeners()
|
||||
|
||||
try {
|
||||
await this.uploadMask(
|
||||
refs.maskedImage,
|
||||
formDatas.maskedImage,
|
||||
'selectedIndex'
|
||||
)
|
||||
await this.uploadImage(refs.paint, formDatas.paint)
|
||||
await this.uploadImage(refs.paintedImage, formDatas.paintedImage, false)
|
||||
|
||||
// IMPORTANT: We using `uploadMask` here, because the backend combines the mask with the painted image during the upload process. We do NOT want to combine the mask with the original image on the frontend, because the spec for CanvasRenderingContext2D does not allow for setting pixels to transparent while preserving their RGB values.
|
||||
// See: <https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/putImageData#data_loss_due_to_browser_optimization>
|
||||
// It is possible that WebGL contexts can achieve this, but WebGL is extremely complex, and the backend functionality is here for this purpose!
|
||||
// Refer to the backend repo's `server.py`, search for `@routes.post("/upload/mask")`
|
||||
await this.uploadMask(
|
||||
refs.paintedMaskedImage,
|
||||
formDatas.paintedMaskedImage,
|
||||
'combinedIndex'
|
||||
)
|
||||
|
||||
ComfyApp.onClipspaceEditorSave()
|
||||
this.destroy()
|
||||
} catch (error) {
|
||||
console.error('Error during upload:', error)
|
||||
this.uiManager.setSaveButtonText(t('g.save'))
|
||||
this.uiManager.setSaveButtonEnabled(true)
|
||||
this.keyboardManager.addListeners()
|
||||
}
|
||||
}
|
||||
|
||||
getMessageBroker() {
|
||||
return this.messageBroker
|
||||
}
|
||||
|
||||
// Helper function to convert a data URL to a Blob object
|
||||
private dataURLToBlob(dataURL: string) {
|
||||
const parts = dataURL.split(';base64,')
|
||||
const contentType = parts[0].split(':')[1]
|
||||
const byteString = atob(parts[1])
|
||||
const arrayBuffer = new ArrayBuffer(byteString.length)
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
uint8Array[i] = byteString.charCodeAt(i)
|
||||
}
|
||||
return new Blob([arrayBuffer], { type: contentType })
|
||||
}
|
||||
|
||||
private async uploadImage(
|
||||
filepath: Ref,
|
||||
formData: FormData,
|
||||
isPaintLayer = true
|
||||
) {
|
||||
const success = await requestWithRetries(() =>
|
||||
api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
if (!success) {
|
||||
throw new Error('Upload failed.')
|
||||
}
|
||||
|
||||
if (!isPaintLayer) {
|
||||
ClipspaceDialog.invalidatePreview()
|
||||
return success
|
||||
}
|
||||
try {
|
||||
const paintedIndex = ComfyApp.clipspace?.paintedIndex
|
||||
if (ComfyApp.clipspace?.imgs && paintedIndex !== undefined) {
|
||||
// Create and set new image
|
||||
const newImage = new Image()
|
||||
newImage.crossOrigin = 'anonymous'
|
||||
newImage.src = mkFileUrl({ ref: filepath, preview: true })
|
||||
ComfyApp.clipspace.imgs[paintedIndex] = newImage
|
||||
|
||||
// Update images array if it exists
|
||||
if (ComfyApp.clipspace.images) {
|
||||
ComfyApp.clipspace.images[paintedIndex] = filepath
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to update clipspace image:', err)
|
||||
}
|
||||
ClipspaceDialog.invalidatePreview()
|
||||
}
|
||||
|
||||
private async uploadMask(
|
||||
filepath: Ref,
|
||||
formData: FormData,
|
||||
clipspaceLocation: 'selectedIndex' | 'combinedIndex'
|
||||
) {
|
||||
const success = await requestWithRetries(() =>
|
||||
api.fetchApi('/upload/mask', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
if (!success) {
|
||||
throw new Error('Upload failed.')
|
||||
}
|
||||
|
||||
try {
|
||||
const nameOfIndexToSaveTo = (
|
||||
{
|
||||
selectedIndex: 'selectedIndex',
|
||||
combinedIndex: 'combinedIndex'
|
||||
} as const
|
||||
)[clipspaceLocation]
|
||||
if (!nameOfIndexToSaveTo) return
|
||||
const indexToSaveTo = ComfyApp.clipspace?.[nameOfIndexToSaveTo]
|
||||
if (!ComfyApp.clipspace?.imgs || indexToSaveTo === undefined) return
|
||||
// Create and set new image
|
||||
const newImage = new Image()
|
||||
newImage.crossOrigin = 'anonymous'
|
||||
newImage.src = mkFileUrl({ ref: filepath, preview: true })
|
||||
ComfyApp.clipspace.imgs[indexToSaveTo] = newImage
|
||||
|
||||
// Update images array if it exists
|
||||
if (ComfyApp.clipspace.images) {
|
||||
ComfyApp.clipspace.images[indexToSaveTo] = filepath
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to update clipspace image:', err)
|
||||
}
|
||||
ClipspaceDialog.invalidatePreview()
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { MaskEditorDialog } from '../MaskEditorDialog'
|
||||
import type { MessageBroker } from './MessageBroker'
|
||||
|
||||
export class KeyboardManager {
|
||||
private keysDown: string[] = []
|
||||
|
||||
// @ts-expect-error unused variable
|
||||
private maskEditor: MaskEditorDialog
|
||||
private messageBroker: MessageBroker
|
||||
|
||||
// Bound functions, for use in addListeners and removeListeners
|
||||
private handleKeyDownBound = this.handleKeyDown.bind(this)
|
||||
private handleKeyUpBound = this.handleKeyUp.bind(this)
|
||||
private clearKeysBound = this.clearKeys.bind(this)
|
||||
|
||||
constructor(maskEditor: MaskEditorDialog) {
|
||||
this.maskEditor = maskEditor
|
||||
this.messageBroker = maskEditor.getMessageBroker()
|
||||
this.addPullTopics()
|
||||
}
|
||||
|
||||
private addPullTopics() {
|
||||
// isKeyPressed
|
||||
this.messageBroker.createPullTopic('isKeyPressed', (key: string) =>
|
||||
Promise.resolve(this.isKeyDown(key))
|
||||
)
|
||||
}
|
||||
|
||||
addListeners() {
|
||||
document.addEventListener('keydown', this.handleKeyDownBound)
|
||||
document.addEventListener('keyup', this.handleKeyUpBound)
|
||||
window.addEventListener('blur', this.clearKeysBound)
|
||||
}
|
||||
|
||||
removeListeners() {
|
||||
document.removeEventListener('keydown', this.handleKeyDownBound)
|
||||
document.removeEventListener('keyup', this.handleKeyUpBound)
|
||||
window.removeEventListener('blur', this.clearKeysBound)
|
||||
}
|
||||
|
||||
private clearKeys() {
|
||||
this.keysDown = []
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
if (!this.keysDown.includes(event.key)) {
|
||||
this.keysDown.push(event.key)
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey) {
|
||||
const key = event.key.toUpperCase()
|
||||
// Redo: Ctrl + Y, or Ctrl + Shift + Z
|
||||
if ((key === 'Y' && !event.shiftKey) || (key == 'Z' && event.shiftKey)) {
|
||||
this.messageBroker.publish('redo')
|
||||
} else if (key === 'Z' && !event.shiftKey) {
|
||||
this.messageBroker.publish('undo')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeyUp(event: KeyboardEvent) {
|
||||
this.keysDown = this.keysDown.filter((key) => key !== event.key)
|
||||
}
|
||||
|
||||
private isKeyDown(key: string) {
|
||||
return this.keysDown.includes(key)
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import type { Callback } from '../types'
|
||||
|
||||
export class MessageBroker {
|
||||
private pushTopics: Record<string, Callback[]> = {}
|
||||
private pullTopics: Record<string, (data?: any) => Promise<any>> = {}
|
||||
|
||||
constructor() {
|
||||
this.registerListeners()
|
||||
}
|
||||
|
||||
// Push
|
||||
|
||||
private registerListeners() {
|
||||
// Register listeners
|
||||
this.createPushTopic('panStart')
|
||||
this.createPushTopic('paintBucketFill')
|
||||
this.createPushTopic('saveState')
|
||||
this.createPushTopic('brushAdjustmentStart')
|
||||
this.createPushTopic('drawStart')
|
||||
this.createPushTopic('panMove')
|
||||
this.createPushTopic('updateBrushPreview')
|
||||
this.createPushTopic('brushAdjustment')
|
||||
this.createPushTopic('draw')
|
||||
this.createPushTopic('paintBucketCursor')
|
||||
this.createPushTopic('panCursor')
|
||||
this.createPushTopic('drawEnd')
|
||||
this.createPushTopic('zoom')
|
||||
this.createPushTopic('undo')
|
||||
this.createPushTopic('redo')
|
||||
this.createPushTopic('cursorPoint')
|
||||
this.createPushTopic('panOffset')
|
||||
this.createPushTopic('zoomRatio')
|
||||
this.createPushTopic('getMaskCanvas')
|
||||
this.createPushTopic('getCanvasContainer')
|
||||
this.createPushTopic('screenToCanvas')
|
||||
this.createPushTopic('isKeyPressed')
|
||||
this.createPushTopic('isCombinationPressed')
|
||||
this.createPushTopic('setPaintBucketTolerance')
|
||||
this.createPushTopic('setBrushSize')
|
||||
this.createPushTopic('setBrushHardness')
|
||||
this.createPushTopic('setBrushOpacity')
|
||||
this.createPushTopic('setBrushShape')
|
||||
this.createPushTopic('initZoomPan')
|
||||
this.createPushTopic('setTool')
|
||||
this.createPushTopic('setActiveLayer')
|
||||
this.createPushTopic('pointerDown')
|
||||
this.createPushTopic('pointerMove')
|
||||
this.createPushTopic('pointerUp')
|
||||
this.createPushTopic('wheel')
|
||||
this.createPushTopic('initPaintBucketTool')
|
||||
this.createPushTopic('setBrushVisibility')
|
||||
this.createPushTopic('setBrushPreviewGradientVisibility')
|
||||
this.createPushTopic('handleTouchStart')
|
||||
this.createPushTopic('handleTouchMove')
|
||||
this.createPushTopic('handleTouchEnd')
|
||||
this.createPushTopic('colorSelectFill')
|
||||
this.createPushTopic('setColorSelectTolerance')
|
||||
this.createPushTopic('setLivePreview')
|
||||
this.createPushTopic('updateCursor')
|
||||
this.createPushTopic('setColorComparisonMethod')
|
||||
this.createPushTopic('clearLastPoint')
|
||||
this.createPushTopic('setWholeImage')
|
||||
this.createPushTopic('setMaskBoundary')
|
||||
this.createPushTopic('setMaskTolerance')
|
||||
this.createPushTopic('setBrushSmoothingPrecision')
|
||||
this.createPushTopic('setZoomText')
|
||||
this.createPushTopic('resetZoom')
|
||||
this.createPushTopic('invert')
|
||||
this.createPushTopic('setRGBColor')
|
||||
this.createPushTopic('paintedurl')
|
||||
this.createPushTopic('setSelectionOpacity')
|
||||
this.createPushTopic('setFillOpacity')
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new push topic (listener is notified)
|
||||
*
|
||||
* @param {string} topicName - The name of the topic to create.
|
||||
* @throws {Error} If the topic already exists.
|
||||
*/
|
||||
createPushTopic(topicName: string) {
|
||||
if (this.topicExists(this.pushTopics, topicName)) {
|
||||
throw new Error('Topic already exists')
|
||||
}
|
||||
this.pushTopics[topicName] = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a callback function to the given topic.
|
||||
*
|
||||
* @param {string} topicName - The name of the topic to subscribe to.
|
||||
* @param {Callback} callback - The callback function to be subscribed.
|
||||
* @throws {Error} If the topic does not exist.
|
||||
*/
|
||||
subscribe(topicName: string, callback: Callback) {
|
||||
if (!this.topicExists(this.pushTopics, topicName)) {
|
||||
throw new Error(`Topic "${topicName}" does not exist!`)
|
||||
}
|
||||
this.pushTopics[topicName].push(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a callback function from the list of subscribers for a given topic.
|
||||
*
|
||||
* @param {string} topicName - The name of the topic to unsubscribe from.
|
||||
* @param {Callback} callback - The callback function to remove from the subscribers list.
|
||||
* @throws {Error} If the topic does not exist in the list of topics.
|
||||
*/
|
||||
unsubscribe(topicName: string, callback: Callback) {
|
||||
if (!this.topicExists(this.pushTopics, topicName)) {
|
||||
throw new Error('Topic does not exist')
|
||||
}
|
||||
const index = this.pushTopics[topicName].indexOf(callback)
|
||||
if (index > -1) {
|
||||
this.pushTopics[topicName].splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes data to a specified topic with variable number of arguments.
|
||||
* @param {string} topicName - The name of the topic to publish to.
|
||||
* @param {...any[]} args - Variable number of arguments to pass to subscribers
|
||||
* @throws {Error} If the specified topic does not exist.
|
||||
*/
|
||||
publish(topicName: string, ...args: any[]) {
|
||||
if (!this.topicExists(this.pushTopics, topicName)) {
|
||||
throw new Error(`Topic "${topicName}" does not exist!`)
|
||||
}
|
||||
|
||||
this.pushTopics[topicName].forEach((callback) => {
|
||||
callback(...args)
|
||||
})
|
||||
}
|
||||
|
||||
// Pull
|
||||
|
||||
/**
|
||||
* Creates a new pull topic (listener must request data)
|
||||
*
|
||||
* @param {string} topicName - The name of the topic to create.
|
||||
* @param {() => Promise<any>} callBack - The callback function to be called when data is requested.
|
||||
* @throws {Error} If the topic already exists.
|
||||
*/
|
||||
createPullTopic(topicName: string, callBack: (data?: any) => Promise<any>) {
|
||||
if (this.topicExists(this.pullTopics, topicName)) {
|
||||
throw new Error('Topic already exists')
|
||||
}
|
||||
this.pullTopics[topicName] = callBack
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests data from a specified pull topic.
|
||||
* @param {string} topicName - The name of the topic to request data from.
|
||||
* @returns {Promise<any>} - The data from the pull topic.
|
||||
* @throws {Error} If the specified topic does not exist.
|
||||
*/
|
||||
async pull(topicName: string, data?: any): Promise<any> {
|
||||
if (!this.topicExists(this.pullTopics, topicName)) {
|
||||
throw new Error('Topic does not exist')
|
||||
}
|
||||
|
||||
const callBack = this.pullTopics[topicName]
|
||||
try {
|
||||
const result = await callBack(data)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error(`Error pulling data from topic "${topicName}":`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Helper Methods
|
||||
|
||||
/**
|
||||
* Checks if a topic exists in the given topics object.
|
||||
* @param {Record<string, any>} topics - The topics object to check.
|
||||
* @param {string} topicName - The name of the topic to check.
|
||||
* @returns {boolean} - True if the topic exists, false otherwise.
|
||||
*/
|
||||
private topicExists(topics: Record<string, any>, topicName: string): boolean {
|
||||
return topics.hasOwnProperty(topicName)
|
||||
}
|
||||
}
|
||||
@@ -1,496 +0,0 @@
|
||||
import type { MaskEditorDialog, Point, Offset } from '../types'
|
||||
import { MessageBroker } from './MessageBroker'
|
||||
|
||||
export class PanAndZoomManager {
|
||||
maskEditor: MaskEditorDialog
|
||||
messageBroker: MessageBroker
|
||||
|
||||
DOUBLE_TAP_DELAY: number = 300
|
||||
lastTwoFingerTap: number = 0
|
||||
|
||||
isTouchZooming: boolean = false
|
||||
lastTouchZoomDistance: number = 0
|
||||
lastTouchMidPoint: Point = { x: 0, y: 0 }
|
||||
lastTouchPoint: Point = { x: 0, y: 0 }
|
||||
|
||||
zoom_ratio: number = 1
|
||||
interpolatedZoomRatio: number = 1
|
||||
pan_offset: Offset = { x: 0, y: 0 }
|
||||
|
||||
mouseDownPoint: Point | null = null
|
||||
initialPan: Offset = { x: 0, y: 0 }
|
||||
|
||||
canvasContainer: HTMLElement | null = null
|
||||
maskCanvas: HTMLCanvasElement | null = null
|
||||
rgbCanvas: HTMLCanvasElement | null = null
|
||||
rootElement: HTMLElement | null = null
|
||||
|
||||
image: HTMLImageElement | null = null
|
||||
imageRootWidth: number = 0
|
||||
imageRootHeight: number = 0
|
||||
|
||||
cursorPoint: Point = { x: 0, y: 0 }
|
||||
penPointerIdList: number[] = []
|
||||
|
||||
constructor(maskEditor: MaskEditorDialog) {
|
||||
this.maskEditor = maskEditor
|
||||
this.messageBroker = maskEditor.getMessageBroker()
|
||||
|
||||
this.addListeners()
|
||||
this.addPullTopics()
|
||||
}
|
||||
|
||||
private addListeners() {
|
||||
this.messageBroker.subscribe(
|
||||
'initZoomPan',
|
||||
async (args: [HTMLImageElement, HTMLElement]) => {
|
||||
await this.initializeCanvasPanZoom(args[0], args[1])
|
||||
}
|
||||
)
|
||||
|
||||
this.messageBroker.subscribe('panStart', async (event: PointerEvent) => {
|
||||
this.handlePanStart(event)
|
||||
})
|
||||
|
||||
this.messageBroker.subscribe('panMove', async (event: PointerEvent) => {
|
||||
this.handlePanMove(event)
|
||||
})
|
||||
|
||||
this.messageBroker.subscribe('zoom', async (event: WheelEvent) => {
|
||||
this.zoom(event)
|
||||
})
|
||||
|
||||
this.messageBroker.subscribe('cursorPoint', async (point: Point) => {
|
||||
this.updateCursorPosition(point)
|
||||
})
|
||||
|
||||
this.messageBroker.subscribe('pointerDown', async (event: PointerEvent) => {
|
||||
if (event.pointerType === 'pen')
|
||||
this.penPointerIdList.push(event.pointerId)
|
||||
})
|
||||
|
||||
this.messageBroker.subscribe('pointerUp', async (event: PointerEvent) => {
|
||||
if (event.pointerType === 'pen') {
|
||||
const index = this.penPointerIdList.indexOf(event.pointerId)
|
||||
if (index > -1) this.penPointerIdList.splice(index, 1)
|
||||
}
|
||||
})
|
||||
|
||||
this.messageBroker.subscribe(
|
||||
'handleTouchStart',
|
||||
async (event: TouchEvent) => {
|
||||
this.handleTouchStart(event)
|
||||
}
|
||||
)
|
||||
|
||||
this.messageBroker.subscribe(
|
||||
'handleTouchMove',
|
||||
async (event: TouchEvent) => {
|
||||
this.handleTouchMove(event)
|
||||
}
|
||||
)
|
||||
|
||||
this.messageBroker.subscribe(
|
||||
'handleTouchEnd',
|
||||
async (event: TouchEvent) => {
|
||||
this.handleTouchEnd(event)
|
||||
}
|
||||
)
|
||||
|
||||
this.messageBroker.subscribe('resetZoom', async () => {
|
||||
if (this.interpolatedZoomRatio === 1) return
|
||||
await this.smoothResetView()
|
||||
})
|
||||
}
|
||||
|
||||
private addPullTopics() {
|
||||
this.messageBroker.createPullTopic(
|
||||
'cursorPoint',
|
||||
async () => this.cursorPoint
|
||||
)
|
||||
this.messageBroker.createPullTopic('zoomRatio', async () => this.zoom_ratio)
|
||||
this.messageBroker.createPullTopic('panOffset', async () => this.pan_offset)
|
||||
}
|
||||
|
||||
handleTouchStart(event: TouchEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
// for pen device, if drawing with pen, do not move the canvas
|
||||
if (this.penPointerIdList.length > 0) return
|
||||
|
||||
this.messageBroker.publish('setBrushVisibility', false)
|
||||
if (event.touches.length === 2) {
|
||||
const currentTime = new Date().getTime()
|
||||
const tapTimeDiff = currentTime - this.lastTwoFingerTap
|
||||
|
||||
if (tapTimeDiff < this.DOUBLE_TAP_DELAY) {
|
||||
// Double tap detected
|
||||
this.handleDoubleTap()
|
||||
this.lastTwoFingerTap = 0 // Reset to prevent triple-tap
|
||||
} else {
|
||||
this.lastTwoFingerTap = currentTime
|
||||
|
||||
// Existing two-finger touch logic
|
||||
this.isTouchZooming = true
|
||||
this.lastTouchZoomDistance = this.getTouchDistance(event.touches)
|
||||
const midpoint = this.getTouchMidpoint(event.touches)
|
||||
this.lastTouchMidPoint = midpoint
|
||||
}
|
||||
} else if (event.touches.length === 1) {
|
||||
this.lastTouchPoint = {
|
||||
x: event.touches[0].clientX,
|
||||
y: event.touches[0].clientY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleTouchMove(event: TouchEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
// for pen device, if drawing with pen, do not move the canvas
|
||||
if (this.penPointerIdList.length > 0) return
|
||||
|
||||
this.lastTwoFingerTap = 0
|
||||
if (this.isTouchZooming && event.touches.length === 2) {
|
||||
// Handle zooming
|
||||
const newDistance = this.getTouchDistance(event.touches)
|
||||
const zoomFactor = newDistance / this.lastTouchZoomDistance
|
||||
const oldZoom = this.zoom_ratio
|
||||
this.zoom_ratio = Math.max(
|
||||
0.2,
|
||||
Math.min(10.0, this.zoom_ratio * zoomFactor)
|
||||
)
|
||||
const newZoom = this.zoom_ratio
|
||||
|
||||
// Calculate the midpoint of the two touches
|
||||
const midpoint = this.getTouchMidpoint(event.touches)
|
||||
|
||||
// Handle panning - calculate the movement of the midpoint
|
||||
if (this.lastTouchMidPoint) {
|
||||
const deltaX = midpoint.x - this.lastTouchMidPoint.x
|
||||
const deltaY = midpoint.y - this.lastTouchMidPoint.y
|
||||
|
||||
// Apply the pan
|
||||
this.pan_offset.x += deltaX
|
||||
this.pan_offset.y += deltaY
|
||||
}
|
||||
|
||||
// Get touch position relative to the container
|
||||
if (this.maskCanvas === null) {
|
||||
this.maskCanvas = await this.messageBroker.pull('maskCanvas')
|
||||
}
|
||||
const rect = this.maskCanvas!.getBoundingClientRect()
|
||||
const touchX = midpoint.x - rect.left
|
||||
const touchY = midpoint.y - rect.top
|
||||
|
||||
// Calculate new pan position based on zoom
|
||||
const scaleFactor = newZoom / oldZoom
|
||||
this.pan_offset.x += touchX - touchX * scaleFactor
|
||||
this.pan_offset.y += touchY - touchY * scaleFactor
|
||||
|
||||
this.invalidatePanZoom()
|
||||
this.lastTouchZoomDistance = newDistance
|
||||
this.lastTouchMidPoint = midpoint
|
||||
} else if (event.touches.length === 1) {
|
||||
// Handle single touch pan
|
||||
this.handleSingleTouchPan(event.touches[0])
|
||||
}
|
||||
}
|
||||
|
||||
handleTouchEnd(event: TouchEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
const lastTouch = event.touches[0]
|
||||
// if all touches are removed, lastTouch will be null
|
||||
if (lastTouch) {
|
||||
this.lastTouchPoint = {
|
||||
x: lastTouch.clientX,
|
||||
y: lastTouch.clientY
|
||||
}
|
||||
} else {
|
||||
this.isTouchZooming = false
|
||||
this.lastTouchMidPoint = { x: 0, y: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private getTouchDistance(touches: TouchList) {
|
||||
const dx = touches[0].clientX - touches[1].clientX
|
||||
const dy = touches[0].clientY - touches[1].clientY
|
||||
return Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
private getTouchMidpoint(touches: TouchList) {
|
||||
return {
|
||||
x: (touches[0].clientX + touches[1].clientX) / 2,
|
||||
y: (touches[0].clientY + touches[1].clientY) / 2
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSingleTouchPan(touch: Touch) {
|
||||
if (this.lastTouchPoint === null) {
|
||||
this.lastTouchPoint = { x: touch.clientX, y: touch.clientY }
|
||||
return
|
||||
}
|
||||
|
||||
const deltaX = touch.clientX - this.lastTouchPoint.x
|
||||
const deltaY = touch.clientY - this.lastTouchPoint.y
|
||||
|
||||
this.pan_offset.x += deltaX
|
||||
this.pan_offset.y += deltaY
|
||||
|
||||
await this.invalidatePanZoom()
|
||||
|
||||
this.lastTouchPoint = { x: touch.clientX, y: touch.clientY }
|
||||
}
|
||||
|
||||
private updateCursorPosition(clientPoint: Point) {
|
||||
var cursorX = clientPoint.x - this.pan_offset.x
|
||||
var cursorY = clientPoint.y - this.pan_offset.y
|
||||
|
||||
this.cursorPoint = { x: cursorX, y: cursorY }
|
||||
}
|
||||
|
||||
//prob redundant
|
||||
handleDoubleTap() {
|
||||
this.messageBroker.publish('undo')
|
||||
// Add any additional logic needed after undo
|
||||
}
|
||||
|
||||
async zoom(event: WheelEvent) {
|
||||
// Store original cursor position
|
||||
const cursorPoint = { x: event.clientX, y: event.clientY }
|
||||
|
||||
// zoom canvas
|
||||
const oldZoom = this.zoom_ratio
|
||||
const zoomFactor = event.deltaY < 0 ? 1.1 : 0.9
|
||||
this.zoom_ratio = Math.max(
|
||||
0.2,
|
||||
Math.min(10.0, this.zoom_ratio * zoomFactor)
|
||||
)
|
||||
const newZoom = this.zoom_ratio
|
||||
|
||||
const maskCanvas = await this.messageBroker.pull('maskCanvas')
|
||||
|
||||
// Get mouse position relative to the container
|
||||
const rect = maskCanvas.getBoundingClientRect()
|
||||
const mouseX = cursorPoint.x - rect.left
|
||||
const mouseY = cursorPoint.y - rect.top
|
||||
|
||||
console.log(oldZoom, newZoom)
|
||||
// Calculate new pan position
|
||||
const scaleFactor = newZoom / oldZoom
|
||||
this.pan_offset.x += mouseX - mouseX * scaleFactor
|
||||
this.pan_offset.y += mouseY - mouseY * scaleFactor
|
||||
|
||||
// Update pan and zoom immediately
|
||||
await this.invalidatePanZoom()
|
||||
|
||||
const newImageWidth = maskCanvas.clientWidth
|
||||
|
||||
const zoomRatio = newImageWidth / this.imageRootWidth
|
||||
|
||||
this.interpolatedZoomRatio = zoomRatio
|
||||
|
||||
this.messageBroker.publish('setZoomText', `${Math.round(zoomRatio * 100)}%`)
|
||||
|
||||
// Update cursor position with new pan values
|
||||
this.updateCursorPosition(cursorPoint)
|
||||
|
||||
// Update brush preview after pan/zoom is complete
|
||||
requestAnimationFrame(() => {
|
||||
this.messageBroker.publish('updateBrushPreview')
|
||||
})
|
||||
}
|
||||
|
||||
private async smoothResetView(duration: number = 500) {
|
||||
// Store initial state
|
||||
const startZoom = this.zoom_ratio
|
||||
const startPan = { ...this.pan_offset }
|
||||
|
||||
// Panel dimensions
|
||||
const sidePanelWidth = 220
|
||||
const toolPanelWidth = 64
|
||||
const topBarHeight = 44
|
||||
|
||||
// Calculate available space
|
||||
const availableWidth =
|
||||
this.rootElement!.clientWidth - sidePanelWidth - toolPanelWidth
|
||||
const availableHeight = this.rootElement!.clientHeight - topBarHeight
|
||||
|
||||
// Calculate target zoom
|
||||
const zoomRatioWidth = availableWidth / this.image!.width
|
||||
const zoomRatioHeight = availableHeight / this.image!.height
|
||||
const targetZoom = Math.min(zoomRatioWidth, zoomRatioHeight)
|
||||
|
||||
// Calculate final dimensions
|
||||
const aspectRatio = this.image!.width / this.image!.height
|
||||
let finalWidth = 0
|
||||
let finalHeight = 0
|
||||
|
||||
// Calculate target pan position
|
||||
const targetPan = { x: toolPanelWidth, y: topBarHeight }
|
||||
|
||||
if (zoomRatioHeight > zoomRatioWidth) {
|
||||
finalWidth = availableWidth
|
||||
finalHeight = finalWidth / aspectRatio
|
||||
targetPan.y = (availableHeight - finalHeight) / 2 + topBarHeight
|
||||
} else {
|
||||
finalHeight = availableHeight
|
||||
finalWidth = finalHeight * aspectRatio
|
||||
targetPan.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
|
||||
}
|
||||
|
||||
const startTime = performance.now()
|
||||
const animate = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
// Cubic easing out for smooth deceleration
|
||||
const eased = 1 - Math.pow(1 - progress, 3)
|
||||
|
||||
// Calculate intermediate zoom and pan values
|
||||
const currentZoom = startZoom + (targetZoom - startZoom) * eased
|
||||
|
||||
this.zoom_ratio = currentZoom
|
||||
this.pan_offset.x = startPan.x + (targetPan.x - startPan.x) * eased
|
||||
this.pan_offset.y = startPan.y + (targetPan.y - startPan.y) * eased
|
||||
|
||||
this.invalidatePanZoom()
|
||||
|
||||
const interpolatedZoomRatio = startZoom + (1.0 - startZoom) * eased
|
||||
|
||||
this.messageBroker.publish(
|
||||
'setZoomText',
|
||||
`${Math.round(interpolatedZoomRatio * 100)}%`
|
||||
)
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
this.interpolatedZoomRatio = 1.0
|
||||
}
|
||||
|
||||
async initializeCanvasPanZoom(
|
||||
image: HTMLImageElement,
|
||||
rootElement: HTMLElement
|
||||
) {
|
||||
// Get side panel width
|
||||
let sidePanelWidth = 220
|
||||
const toolPanelWidth = 64
|
||||
let topBarHeight = 44
|
||||
|
||||
this.rootElement = rootElement
|
||||
|
||||
// Calculate available width accounting for both side panels
|
||||
let availableWidth =
|
||||
rootElement.clientWidth - sidePanelWidth - toolPanelWidth
|
||||
let availableHeight = rootElement.clientHeight - topBarHeight
|
||||
|
||||
let zoomRatioWidth = availableWidth / image.width
|
||||
let zoomRatioHeight = availableHeight / image.height
|
||||
|
||||
let aspectRatio = image.width / image.height
|
||||
|
||||
let finalWidth = 0
|
||||
let finalHeight = 0
|
||||
|
||||
let pan_offset: Offset = { x: toolPanelWidth, y: topBarHeight }
|
||||
|
||||
if (zoomRatioHeight > zoomRatioWidth) {
|
||||
finalWidth = availableWidth
|
||||
finalHeight = finalWidth / aspectRatio
|
||||
pan_offset.y = (availableHeight - finalHeight) / 2 + topBarHeight
|
||||
} else {
|
||||
finalHeight = availableHeight
|
||||
finalWidth = finalHeight * aspectRatio
|
||||
pan_offset.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
|
||||
}
|
||||
|
||||
if (this.image === null) {
|
||||
this.image = image
|
||||
}
|
||||
|
||||
this.imageRootWidth = finalWidth
|
||||
this.imageRootHeight = finalHeight
|
||||
|
||||
this.zoom_ratio = Math.min(zoomRatioWidth, zoomRatioHeight)
|
||||
this.pan_offset = pan_offset
|
||||
|
||||
this.penPointerIdList = []
|
||||
|
||||
await this.invalidatePanZoom()
|
||||
}
|
||||
|
||||
async invalidatePanZoom() {
|
||||
// Single validation check upfront
|
||||
if (
|
||||
!this.image?.width ||
|
||||
!this.image?.height ||
|
||||
!this.pan_offset ||
|
||||
!this.zoom_ratio
|
||||
) {
|
||||
console.warn('Missing required properties for pan/zoom')
|
||||
return
|
||||
}
|
||||
|
||||
// Now TypeScript knows these are non-null
|
||||
const raw_width = this.image.width * this.zoom_ratio
|
||||
const raw_height = this.image.height * this.zoom_ratio
|
||||
|
||||
// Get canvas container
|
||||
this.canvasContainer ??=
|
||||
await this.messageBroker?.pull('getCanvasContainer')
|
||||
if (!this.canvasContainer) return
|
||||
|
||||
// Apply styles
|
||||
Object.assign(this.canvasContainer.style, {
|
||||
width: `${raw_width}px`,
|
||||
height: `${raw_height}px`,
|
||||
left: `${this.pan_offset.x}px`,
|
||||
top: `${this.pan_offset.y}px`
|
||||
})
|
||||
|
||||
this.rgbCanvas = await this.messageBroker.pull('rgbCanvas')
|
||||
if (this.rgbCanvas) {
|
||||
// Ensure the canvas has the proper dimensions
|
||||
if (
|
||||
this.rgbCanvas.width !== this.image.width ||
|
||||
this.rgbCanvas.height !== this.image.height
|
||||
) {
|
||||
this.rgbCanvas.width = this.image.width
|
||||
this.rgbCanvas.height = this.image.height
|
||||
}
|
||||
|
||||
// Make sure the style dimensions match the container
|
||||
this.rgbCanvas.style.width = `${raw_width}px`
|
||||
this.rgbCanvas.style.height = `${raw_height}px`
|
||||
}
|
||||
}
|
||||
|
||||
private handlePanStart(event: PointerEvent) {
|
||||
this.messageBroker.pull('screenToCanvas', {
|
||||
x: event.offsetX,
|
||||
y: event.offsetY
|
||||
})
|
||||
this.mouseDownPoint = { x: event.clientX, y: event.clientY }
|
||||
this.messageBroker.publish('panCursor', true)
|
||||
this.initialPan = this.pan_offset
|
||||
return
|
||||
}
|
||||
|
||||
private handlePanMove(event: PointerEvent) {
|
||||
if (this.mouseDownPoint === null) throw new Error('mouseDownPoint is null')
|
||||
|
||||
let deltaX = this.mouseDownPoint.x - event.clientX
|
||||
let deltaY = this.mouseDownPoint.y - event.clientY
|
||||
|
||||
let pan_x = this.initialPan.x - deltaX
|
||||
let pan_y = this.initialPan.y - deltaY
|
||||
|
||||
this.pan_offset = { x: pan_x, y: pan_y }
|
||||
|
||||
this.invalidatePanZoom()
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import type { MaskEditorDialog, Point } from '../types'
|
||||
import { Tools } from '../types'
|
||||
import { MessageBroker } from './MessageBroker'
|
||||
|
||||
export class ToolManager {
|
||||
maskEditor: MaskEditorDialog
|
||||
messageBroker: MessageBroker
|
||||
mouseDownPoint: Point | null = null
|
||||
|
||||
currentTool: Tools = Tools.MaskPen
|
||||
isAdjustingBrush: boolean = false // is user adjusting brush size or hardness with alt + right mouse button
|
||||
|
||||
constructor(maskEditor: MaskEditorDialog) {
|
||||
this.maskEditor = maskEditor
|
||||
this.messageBroker = maskEditor.getMessageBroker()
|
||||
this.addListeners()
|
||||
this.addPullTopics()
|
||||
}
|
||||
|
||||
private addListeners() {
|
||||
this.messageBroker.subscribe('setTool', async (tool: Tools) => {
|
||||
this.setTool(tool)
|
||||
})
|
||||
|
||||
this.messageBroker.subscribe('pointerDown', async (event: PointerEvent) => {
|
||||
this.handlePointerDown(event)
|
||||
})
|
||||
|
||||
this.messageBroker.subscribe('pointerMove', async (event: PointerEvent) => {
|
||||
this.handlePointerMove(event)
|
||||
})
|
||||
|
||||
this.messageBroker.subscribe('pointerUp', async (event: PointerEvent) => {
|
||||
this.handlePointerUp(event)
|
||||
})
|
||||
|
||||
this.messageBroker.subscribe('wheel', async (event: WheelEvent) => {
|
||||
this.handleWheelEvent(event)
|
||||
})
|
||||
}
|
||||
|
||||
private async addPullTopics() {
|
||||
this.messageBroker.createPullTopic('currentTool', async () =>
|
||||
this.getCurrentTool()
|
||||
)
|
||||
}
|
||||
|
||||
//tools
|
||||
|
||||
setTool(tool: Tools) {
|
||||
this.currentTool = tool
|
||||
|
||||
if (tool != Tools.MaskColorFill) {
|
||||
this.messageBroker.publish('clearLastPoint')
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentTool() {
|
||||
return this.currentTool
|
||||
}
|
||||
|
||||
private async handlePointerDown(event: PointerEvent) {
|
||||
event.preventDefault()
|
||||
if (event.pointerType == 'touch') return
|
||||
|
||||
var isSpacePressed = await this.messageBroker.pull('isKeyPressed', ' ')
|
||||
|
||||
// Pan canvas
|
||||
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
|
||||
this.messageBroker.publish('panStart', event)
|
||||
this.messageBroker.publish('setBrushVisibility', false)
|
||||
return
|
||||
}
|
||||
|
||||
// RGB painting
|
||||
if (this.currentTool === Tools.PaintPen && event.button === 0) {
|
||||
this.messageBroker.publish('drawStart', event)
|
||||
this.messageBroker.publish('saveState')
|
||||
return
|
||||
}
|
||||
|
||||
// RGB painting
|
||||
if (this.currentTool === Tools.PaintPen && event.buttons === 1) {
|
||||
this.messageBroker.publish('draw', event)
|
||||
return
|
||||
}
|
||||
|
||||
//paint bucket
|
||||
if (this.currentTool === Tools.MaskBucket && event.button === 0) {
|
||||
const offset = { x: event.offsetX, y: event.offsetY }
|
||||
const coords_canvas = await this.messageBroker.pull(
|
||||
'screenToCanvas',
|
||||
offset
|
||||
)
|
||||
this.messageBroker.publish('paintBucketFill', coords_canvas)
|
||||
this.messageBroker.publish('saveState')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.currentTool === Tools.MaskColorFill && event.button === 0) {
|
||||
const offset = { x: event.offsetX, y: event.offsetY }
|
||||
const coords_canvas = await this.messageBroker.pull(
|
||||
'screenToCanvas',
|
||||
offset
|
||||
)
|
||||
this.messageBroker.publish('colorSelectFill', coords_canvas)
|
||||
return
|
||||
}
|
||||
|
||||
// (brush resize/change hardness) Check for alt + right mouse button
|
||||
if (event.altKey && event.button === 2) {
|
||||
this.isAdjustingBrush = true
|
||||
this.messageBroker.publish('brushAdjustmentStart', event)
|
||||
return
|
||||
}
|
||||
|
||||
var isDrawingTool = [Tools.MaskPen, Tools.Eraser, Tools.PaintPen].includes(
|
||||
this.currentTool
|
||||
)
|
||||
//drawing
|
||||
if ([0, 2].includes(event.button) && isDrawingTool) {
|
||||
this.messageBroker.publish('drawStart', event)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePointerMove(event: PointerEvent) {
|
||||
event.preventDefault()
|
||||
if (event.pointerType == 'touch') return
|
||||
const newCursorPoint = { x: event.clientX, y: event.clientY }
|
||||
this.messageBroker.publish('cursorPoint', newCursorPoint)
|
||||
|
||||
var isSpacePressed = await this.messageBroker.pull('isKeyPressed', ' ')
|
||||
this.messageBroker.publish('updateBrushPreview')
|
||||
|
||||
//move the canvas
|
||||
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
|
||||
this.messageBroker.publish('panMove', event)
|
||||
return
|
||||
}
|
||||
|
||||
//prevent drawing with other tools
|
||||
|
||||
var isDrawingTool = [Tools.MaskPen, Tools.Eraser, Tools.PaintPen].includes(
|
||||
this.currentTool
|
||||
)
|
||||
if (!isDrawingTool) return
|
||||
|
||||
// alt + right mouse button hold brush adjustment
|
||||
if (
|
||||
this.isAdjustingBrush &&
|
||||
(this.currentTool === Tools.MaskPen ||
|
||||
this.currentTool === Tools.Eraser) &&
|
||||
event.altKey &&
|
||||
event.buttons === 2
|
||||
) {
|
||||
this.messageBroker.publish('brushAdjustment', event)
|
||||
return
|
||||
}
|
||||
|
||||
//draw with pen or eraser
|
||||
if (event.buttons == 1 || event.buttons == 2) {
|
||||
this.messageBroker.publish('draw', event)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private handlePointerUp(event: PointerEvent) {
|
||||
this.messageBroker.publish('panCursor', false)
|
||||
if (event.pointerType === 'touch') return
|
||||
this.messageBroker.publish('updateCursor')
|
||||
this.isAdjustingBrush = false
|
||||
this.messageBroker.publish('drawEnd', event)
|
||||
this.mouseDownPoint = null
|
||||
}
|
||||
|
||||
private handleWheelEvent(event: WheelEvent) {
|
||||
this.messageBroker.publish('zoom', event)
|
||||
const newCursorPoint = { x: event.clientX, y: event.clientY }
|
||||
this.messageBroker.publish('cursorPoint', newCursorPoint)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { UIManager } from './UIManager'
|
||||
export { ToolManager } from './ToolManager'
|
||||
export { PanAndZoomManager } from './PanAndZoomManager'
|
||||
export { KeyboardManager } from './KeyboardManager'
|
||||
export { MessageBroker } from './MessageBroker'
|
||||
@@ -1,738 +0,0 @@
|
||||
const styles = `
|
||||
#maskEditorContainer {
|
||||
display: fixed;
|
||||
}
|
||||
#maskEditor_brush {
|
||||
position: absolute;
|
||||
backgroundColor: transparent;
|
||||
z-index: 8889;
|
||||
pointer-events: none;
|
||||
border-radius: 50%;
|
||||
overflow: visible;
|
||||
outline: 1px dashed black;
|
||||
box-shadow: 0 0 0 1px white;
|
||||
}
|
||||
#maskEditor_brushPreviewGradient {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
display: none;
|
||||
}
|
||||
#maskEditor {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
left: 0;
|
||||
z-index: 8888;
|
||||
position: fixed;
|
||||
background: rgba(50,50,50,0.75);
|
||||
backdrop-filter: blur(10px);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
--mask-editor-top-bar-height: 44px;
|
||||
}
|
||||
#maskEditor_sidePanelContainer {
|
||||
height: 100%;
|
||||
width: 220px;
|
||||
z-index: 8888;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#maskEditor_sidePanel {
|
||||
background: var(--comfy-menu-bg);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow-y: auto;
|
||||
width: 220px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
#maskEditor_sidePanelContent {
|
||||
width: 100%;
|
||||
}
|
||||
#maskEditor_sidePanelShortcuts {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
.maskEditor_sidePanelIconButton {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
.maskEditor_sidePanelIconButton:hover {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
#maskEditor_sidePanelBrushSettings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
.maskEditor_sidePanelTitle {
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
font-family: sans-serif;
|
||||
color: var(--descrip-text);
|
||||
margin-top: 10px;
|
||||
}
|
||||
#maskEditor_sidePanelBrushShapeContainer {
|
||||
display: flex;
|
||||
width: 180px;
|
||||
height: 50px;
|
||||
border: 1px solid var(--border-color);
|
||||
pointer-events: auto;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
#maskEditor_sidePanelBrushShapeCircle {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-color);
|
||||
pointer-events: auto;
|
||||
transition: background 0.1s;
|
||||
margin-left: 7.5px;
|
||||
}
|
||||
.maskEditor_sidePanelBrushRange {
|
||||
width: 180px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.maskEditor_sidePanelBrushRange::-webkit-slider-thumb {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
margin-top: -8px;
|
||||
background: var(--p-surface-700);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.maskEditor_sidePanelBrushRange::-moz-range-thumb {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
background: var(--p-surface-800);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.maskEditor_sidePanelBrushRange::-webkit-slider-runnable-track {
|
||||
background: var(--p-surface-700);
|
||||
height: 3px;
|
||||
}
|
||||
.maskEditor_sidePanelBrushRange::-moz-range-track {
|
||||
background: var(--p-surface-700);
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
#maskEditor_sidePanelBrushShapeSquare {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
margin: 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
pointer-events: auto;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.maskEditor_brushShape_dark {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.maskEditor_brushShape_dark:hover {
|
||||
background: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.maskEditor_brushShape_light {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.maskEditor_brushShape_light:hover {
|
||||
background: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
#maskEditor_sidePanelImageLayerSettings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
.maskEditor_sidePanelLayer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
}
|
||||
.maskEditor_sidePanelLayerVisibilityContainer {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.maskEditor_sidePanelVisibilityToggle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.maskEditor_sidePanelLayerIconContainer {
|
||||
width: 60px;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
fill: var(--input-text);
|
||||
}
|
||||
.maskEditor_sidePanelLayerIconContainer svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
#maskEditor_sidePanelMaskLayerBlendingContainer {
|
||||
width: 80px;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
#maskEditor_sidePanelMaskLayerBlendingSelect {
|
||||
width: 80px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
color: var(--input-text);
|
||||
font-family: sans-serif;
|
||||
font-size: 15px;
|
||||
pointer-events: auto;
|
||||
transition: background-color border 0.1s;
|
||||
}
|
||||
#maskEditor_sidePanelClearCanvasButton:hover {
|
||||
background-color: var(--p-overlaybadge-outline-color);
|
||||
border: none;
|
||||
}
|
||||
#maskEditor_sidePanelClearCanvasButton {
|
||||
width: 180px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--input-text);
|
||||
font-family: sans-serif;
|
||||
font-size: 15px;
|
||||
pointer-events: auto;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
#maskEditor_sidePanelClearCanvasButton:hover {
|
||||
background-color: var(--p-overlaybadge-outline-color);
|
||||
}
|
||||
#maskEditor_sidePanelHorizontalButtonContainer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
height: 40px;
|
||||
}
|
||||
.maskEditor_sidePanelBigButton {
|
||||
width: 85px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--input-text);
|
||||
font-family: sans-serif;
|
||||
font-size: 15px;
|
||||
pointer-events: auto;
|
||||
transition: background-color border 0.1s;
|
||||
}
|
||||
.maskEditor_sidePanelBigButton:hover {
|
||||
background-color: var(--p-overlaybadge-outline-color);
|
||||
border: none;
|
||||
}
|
||||
#maskEditor_toolPanel {
|
||||
height: 100%;
|
||||
width: 4rem;
|
||||
z-index: 8888;
|
||||
background: var(--comfy-menu-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.maskEditor_toolPanelContainer {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.maskEditor_toolPanelContainerSelected svg {
|
||||
fill: var(--p-button-text-primary-color) !important;
|
||||
}
|
||||
.maskEditor_toolPanelContainerSelected .maskEditor_toolPanelIndicator {
|
||||
display: block;
|
||||
}
|
||||
.maskEditor_toolPanelContainer svg {
|
||||
width: 75%;
|
||||
aspect-ratio: 1/1;
|
||||
fill: var(--p-button-text-secondary-color);
|
||||
}
|
||||
|
||||
.maskEditor_toolPanelContainerDark:hover {
|
||||
background-color: var(--p-surface-800);
|
||||
}
|
||||
|
||||
.maskEditor_toolPanelContainerLight:hover {
|
||||
background-color: var(--p-surface-300);
|
||||
}
|
||||
|
||||
.maskEditor_toolPanelIndicator {
|
||||
display: none;
|
||||
height: 100%;
|
||||
width: 4px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
background: var(--p-button-text-primary-color);
|
||||
}
|
||||
#maskEditor_sidePanelPaintBucketSettings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
#canvasBackground {
|
||||
background: white;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
#maskEditor_sidePanelButtonsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.maskEditor_sidePanelSeparator {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--border-color);
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
#maskEditor_pointerZone {
|
||||
width: calc(100% - 4rem - 220px);
|
||||
height: 100%;
|
||||
}
|
||||
#maskEditor_uiContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
z-index: 8888;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#maskEditorCanvasContainer {
|
||||
position: absolute;
|
||||
width: 1000px;
|
||||
height: 667px;
|
||||
left: 359px;
|
||||
top: 280px;
|
||||
}
|
||||
#imageCanvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
#maskCanvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
#maskEditor_uiHorizontalContainer {
|
||||
width: 100%;
|
||||
height: calc(100% - var(--mask-editor-top-bar-height));
|
||||
display: flex;
|
||||
}
|
||||
#maskEditor_topBar {
|
||||
display: flex;
|
||||
height: var(--mask-editor-top-bar-height);
|
||||
align-items: center;
|
||||
background: var(--comfy-menu-bg);
|
||||
shrink: 0;
|
||||
}
|
||||
#maskEditor_topBarTitle {
|
||||
margin: 0;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
#maskEditor_topBarButtonContainer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-right: 0.5rem;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
#maskEditor_topBarShortcutsContainer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.maskEditor_topPanelIconButton_dark {
|
||||
width: 50px;
|
||||
height: 30px;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: background-color 0.1s;
|
||||
background: var(--p-surface-800);
|
||||
border: 1px solid var(--p-form-field-border-color);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.maskEditor_topPanelIconButton_dark:hover {
|
||||
background-color: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.maskEditor_topPanelIconButton_dark svg {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
pointer-events: none;
|
||||
fill: var(--input-text);
|
||||
}
|
||||
|
||||
.maskEditor_topPanelIconButton_light {
|
||||
width: 50px;
|
||||
height: 30px;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: background-color 0.1s;
|
||||
background: var(--comfy-menu-bg);
|
||||
border: 1px solid var(--p-form-field-border-color);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.maskEditor_topPanelIconButton_light:hover {
|
||||
background-color: var(--p-surface-300);
|
||||
}
|
||||
|
||||
.maskEditor_topPanelIconButton_light svg {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
pointer-events: none;
|
||||
fill: var(--input-text);
|
||||
}
|
||||
|
||||
.maskEditor_topPanelButton_dark {
|
||||
height: 30px;
|
||||
background: var(--p-surface-800);
|
||||
border: 1px solid var(--p-form-field-border-color);
|
||||
border-radius: 10px;
|
||||
color: var(--input-text);
|
||||
font-family: sans-serif;
|
||||
pointer-events: auto;
|
||||
transition: 0.1s;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.maskEditor_topPanelButton_dark:hover {
|
||||
background-color: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.maskEditor_topPanelButton_light {
|
||||
height: 30px;
|
||||
background: var(--comfy-menu-bg);
|
||||
border: 1px solid var(--p-form-field-border-color);
|
||||
border-radius: 10px;
|
||||
color: var(--input-text);
|
||||
font-family: sans-serif;
|
||||
pointer-events: auto;
|
||||
transition: 0.1s;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.maskEditor_topPanelButton_light:hover {
|
||||
background-color: var(--p-surface-300);
|
||||
}
|
||||
|
||||
|
||||
#maskEditor_sidePanelColorSelectSettings {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanel_paintBucket_Container {
|
||||
width: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanel_colorSelect_Container {
|
||||
display: flex;
|
||||
width: 180px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
#maskEditor_sidePanelVisibilityToggle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#maskEditor_sidePanelColorSelectMethodSelect {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
height: 30px;
|
||||
border-radius: 0;
|
||||
border: 1px solid var(--border-color);
|
||||
background: rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
#maskEditor_sidePanelVisibilityToggle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanel_colorSelect_tolerance_container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelContainerColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelContainerRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.maskEditor_accent_bg_dark {
|
||||
background: var(--p-surface-800);
|
||||
}
|
||||
|
||||
.maskEditor_accent_bg_very_dark {
|
||||
background: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.maskEditor_accent_bg_light {
|
||||
background: var(--p-surface-300);
|
||||
}
|
||||
|
||||
.maskEditor_accent_bg_very_light {
|
||||
background: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
#maskEditor_paintBucketSettings {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#maskEditor_colorSelectSettings {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelToggleContainer {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.maskEditor_toggle_bg_dark {
|
||||
background: var(--p-surface-700);
|
||||
}
|
||||
|
||||
.maskEditor_toggle_bg_light {
|
||||
background: var(--p-surface-300);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelToggleSwitch {
|
||||
display: inline-block;
|
||||
border-radius: 16px;
|
||||
width: 40px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
transition: background 0.25s;
|
||||
}
|
||||
.maskEditor_sidePanelToggleSwitch:before, .maskEditor_sidePanelToggleSwitch:after {
|
||||
content: "";
|
||||
}
|
||||
.maskEditor_sidePanelToggleSwitch:before {
|
||||
display: block;
|
||||
background: linear-gradient(to bottom, #fff 0%, #eee 100%);
|
||||
border-radius: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
transition: ease 0.2s;
|
||||
}
|
||||
.maskEditor_sidePanelToggleContainer:hover .maskEditor_sidePanelToggleSwitch:before {
|
||||
background: linear-gradient(to bottom, #fff 0%, #fff 100%);
|
||||
}
|
||||
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch {
|
||||
background: var(--p-button-text-primary-color);
|
||||
}
|
||||
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_toggle_bg_dark:before {
|
||||
background: var(--p-surface-900);
|
||||
}
|
||||
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_toggle_bg_light:before {
|
||||
background: var(--comfy-menu-bg);
|
||||
}
|
||||
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch:before {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelToggleCheckbox {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelDropdown_dark {
|
||||
border: 1px solid var(--p-form-field-border-color);
|
||||
background: var(--p-surface-900);
|
||||
height: 24px;
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelDropdown_dark option {
|
||||
background: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelDropdown_dark:focus {
|
||||
outline: 1px solid var(--p-button-text-primary-color);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelDropdown_dark option:hover {
|
||||
background: white;
|
||||
}
|
||||
.maskEditor_sidePanelDropdown_dark option:active {
|
||||
background: var(--p-highlight-background);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelDropdown_light {
|
||||
border: 1px solid var(--p-form-field-border-color);
|
||||
background: var(--comfy-menu-bg);
|
||||
height: 24px;
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelDropdown_light option {
|
||||
background: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelDropdown_light:focus {
|
||||
outline: 1px solid var(--p-surface-300);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelDropdown_light option:hover {
|
||||
background: white;
|
||||
}
|
||||
.maskEditor_sidePanelDropdown_light option:active {
|
||||
background: var(--p-surface-300);
|
||||
}
|
||||
|
||||
.maskEditor_layerRow {
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelLayerPreviewContainer {
|
||||
width: 40px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelLayerPreviewContainer > svg{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
fill: var(--p-surface-100);
|
||||
}
|
||||
|
||||
#maskEditor_sidePanelImageLayerImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelSubTitle {
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-family: sans-serif;
|
||||
color: var(--descrip-text);
|
||||
}
|
||||
|
||||
.maskEditor_containerDropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelLayerCheckbox {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.maskEditor_toolPanelZoomIndicator {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--p-button-text-secondary-color);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
#maskEditor_toolPanelDimensionsText {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#maskEditor_topBarSaveButton {
|
||||
background: var(--p-primary-color) !important;
|
||||
color: var(--p-button-primary-color) !important;
|
||||
}
|
||||
|
||||
#maskEditor_topBarSaveButton:hover {
|
||||
background: var(--p-primary-hover-color) !important;
|
||||
}
|
||||
|
||||
`
|
||||
|
||||
// Inject styles into document
|
||||
const styleSheet = document.createElement('style')
|
||||
styleSheet.type = 'text/css'
|
||||
styleSheet.innerText = styles
|
||||
document.head.appendChild(styleSheet)
|
||||
@@ -1,779 +0,0 @@
|
||||
import QuickLRU from '@alloc/quick-lru'
|
||||
import { hexToRgb, parseToRgb } from '@/utils/colorUtil'
|
||||
import { app } from '@/scripts/app'
|
||||
import {
|
||||
BrushShape,
|
||||
CompositionOperation,
|
||||
MaskBlendMode,
|
||||
Tools,
|
||||
type Brush,
|
||||
type ImageLayer,
|
||||
type Point
|
||||
} from '../types'
|
||||
import { loadBrushFromCache, saveBrushToCache } from '../utils/brushCache'
|
||||
|
||||
// Forward declaration for MessageBroker type
|
||||
interface MessageBroker {
|
||||
subscribe(topic: string, callback: (data?: any) => void): void
|
||||
publish(topic: string, data?: any): void
|
||||
pull<T>(topic: string, data?: any): Promise<T>
|
||||
createPullTopic(topic: string, callback: (data?: any) => Promise<any>): void
|
||||
}
|
||||
|
||||
// Forward declaration for MaskEditorDialog type
|
||||
interface MaskEditorDialog {
|
||||
getMessageBroker(): MessageBroker
|
||||
}
|
||||
|
||||
class BrushTool {
|
||||
brushSettings: Brush //this saves the current brush settings
|
||||
maskBlendMode: MaskBlendMode
|
||||
|
||||
isDrawing: boolean = false
|
||||
isDrawingLine: boolean = false
|
||||
lineStartPoint: Point | null = null
|
||||
smoothingPrecision: number = 10
|
||||
smoothingCordsArray: Point[] = []
|
||||
smoothingLastDrawTime!: Date
|
||||
maskCtx: CanvasRenderingContext2D | null = null
|
||||
rgbCtx: CanvasRenderingContext2D | null = null
|
||||
initialDraw: boolean = true
|
||||
|
||||
private static brushTextureCache = new QuickLRU<string, HTMLCanvasElement>({
|
||||
maxSize: 8 // Reasonable limit for brush texture variations?
|
||||
})
|
||||
|
||||
brushStrokeCanvas: HTMLCanvasElement | null = null
|
||||
brushStrokeCtx: CanvasRenderingContext2D | null = null
|
||||
|
||||
private static readonly SMOOTHING_MAX_STEPS = 30
|
||||
private static readonly SMOOTHING_MIN_STEPS = 2
|
||||
|
||||
//brush adjustment
|
||||
isBrushAdjusting: boolean = false
|
||||
brushPreviewGradient: HTMLElement | null = null
|
||||
initialPoint: Point | null = null
|
||||
useDominantAxis: boolean = false
|
||||
brushAdjustmentSpeed: number = 1.0
|
||||
|
||||
maskEditor: MaskEditorDialog
|
||||
messageBroker: MessageBroker
|
||||
|
||||
private rgbColor: string = '#FF0000' // Default color
|
||||
private activeLayer: ImageLayer = 'mask'
|
||||
|
||||
constructor(maskEditor: MaskEditorDialog) {
|
||||
this.maskEditor = maskEditor
|
||||
this.messageBroker = maskEditor.getMessageBroker()
|
||||
this.createListeners()
|
||||
this.addPullTopics()
|
||||
|
||||
this.useDominantAxis = app.extensionManager.setting.get(
|
||||
'Comfy.MaskEditor.UseDominantAxis'
|
||||
)
|
||||
this.brushAdjustmentSpeed = app.extensionManager.setting.get(
|
||||
'Comfy.MaskEditor.BrushAdjustmentSpeed'
|
||||
)
|
||||
|
||||
const cachedBrushSettings = loadBrushFromCache('maskeditor_brush_settings')
|
||||
if (cachedBrushSettings) {
|
||||
this.brushSettings = cachedBrushSettings
|
||||
} else {
|
||||
this.brushSettings = {
|
||||
type: BrushShape.Arc,
|
||||
size: 10,
|
||||
opacity: 0.7,
|
||||
hardness: 1,
|
||||
smoothingPrecision: 10
|
||||
}
|
||||
}
|
||||
|
||||
this.maskBlendMode = MaskBlendMode.Black
|
||||
}
|
||||
|
||||
private createListeners() {
|
||||
//setters
|
||||
this.messageBroker.subscribe('setBrushSize', (size: number) =>
|
||||
this.setBrushSize(size)
|
||||
)
|
||||
this.messageBroker.subscribe('setBrushOpacity', (opacity: number) =>
|
||||
this.setBrushOpacity(opacity)
|
||||
)
|
||||
this.messageBroker.subscribe('setBrushHardness', (hardness: number) =>
|
||||
this.setBrushHardness(hardness)
|
||||
)
|
||||
this.messageBroker.subscribe('setBrushShape', (type: BrushShape) =>
|
||||
this.setBrushType(type)
|
||||
)
|
||||
this.messageBroker.subscribe(
|
||||
'setActiveLayer',
|
||||
(layer: ImageLayer) => (this.activeLayer = layer)
|
||||
)
|
||||
this.messageBroker.subscribe(
|
||||
'setBrushSmoothingPrecision',
|
||||
(precision: number) => this.setBrushSmoothingPrecision(precision)
|
||||
)
|
||||
this.messageBroker.subscribe('setRGBColor', (color: string) => {
|
||||
this.rgbColor = color
|
||||
})
|
||||
//brush adjustment
|
||||
this.messageBroker.subscribe(
|
||||
'brushAdjustmentStart',
|
||||
(event: PointerEvent) => this.startBrushAdjustment(event)
|
||||
)
|
||||
this.messageBroker.subscribe('brushAdjustment', (event: PointerEvent) =>
|
||||
this.handleBrushAdjustment(event)
|
||||
)
|
||||
//drawing
|
||||
this.messageBroker.subscribe('drawStart', (event: PointerEvent) =>
|
||||
this.startDrawing(event)
|
||||
)
|
||||
this.messageBroker.subscribe('draw', (event: PointerEvent) =>
|
||||
this.handleDrawing(event)
|
||||
)
|
||||
this.messageBroker.subscribe('drawEnd', (event: PointerEvent) =>
|
||||
this.drawEnd(event)
|
||||
)
|
||||
}
|
||||
|
||||
private addPullTopics() {
|
||||
this.messageBroker.createPullTopic(
|
||||
'brushSize',
|
||||
async () => this.brushSettings.size
|
||||
)
|
||||
this.messageBroker.createPullTopic(
|
||||
'brushOpacity',
|
||||
async () => this.brushSettings.opacity
|
||||
)
|
||||
this.messageBroker.createPullTopic(
|
||||
'brushHardness',
|
||||
async () => this.brushSettings.hardness
|
||||
)
|
||||
this.messageBroker.createPullTopic(
|
||||
'brushType',
|
||||
async () => this.brushSettings.type
|
||||
)
|
||||
this.messageBroker.createPullTopic(
|
||||
'brushSmoothingPrecision',
|
||||
async () => this.brushSettings.smoothingPrecision
|
||||
)
|
||||
this.messageBroker.createPullTopic(
|
||||
'maskBlendMode',
|
||||
async () => this.maskBlendMode
|
||||
)
|
||||
this.messageBroker.createPullTopic(
|
||||
'brushSettings',
|
||||
async () => this.brushSettings
|
||||
)
|
||||
}
|
||||
|
||||
private async createBrushStrokeCanvas() {
|
||||
if (this.brushStrokeCanvas !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
const maskCanvas =
|
||||
await this.messageBroker.pull<HTMLCanvasElement>('maskCanvas')
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = maskCanvas.width
|
||||
canvas.height = maskCanvas.height
|
||||
|
||||
this.brushStrokeCanvas = canvas
|
||||
this.brushStrokeCtx = canvas.getContext('2d')!
|
||||
}
|
||||
|
||||
private async startDrawing(event: PointerEvent) {
|
||||
this.isDrawing = true
|
||||
let compositionOp: CompositionOperation
|
||||
let currentTool = await this.messageBroker.pull('currentTool')
|
||||
let coords = { x: event.offsetX, y: event.offsetY }
|
||||
let coords_canvas = await this.messageBroker.pull<Point>(
|
||||
'screenToCanvas',
|
||||
coords
|
||||
)
|
||||
await this.createBrushStrokeCanvas()
|
||||
|
||||
//set drawing mode
|
||||
if (currentTool === Tools.Eraser || event.buttons == 2) {
|
||||
compositionOp = CompositionOperation.DestinationOut //eraser
|
||||
} else {
|
||||
compositionOp = CompositionOperation.SourceOver //pen
|
||||
}
|
||||
|
||||
if (event.shiftKey && this.lineStartPoint) {
|
||||
this.isDrawingLine = true
|
||||
this.drawLine(this.lineStartPoint, coords_canvas, compositionOp)
|
||||
} else {
|
||||
this.isDrawingLine = false
|
||||
this.init_shape(compositionOp)
|
||||
this.draw_shape(coords_canvas)
|
||||
}
|
||||
this.lineStartPoint = coords_canvas
|
||||
this.smoothingCordsArray = [coords_canvas] //used to smooth the drawing line
|
||||
this.smoothingLastDrawTime = new Date()
|
||||
}
|
||||
|
||||
private async handleDrawing(event: PointerEvent) {
|
||||
var diff = performance.now() - this.smoothingLastDrawTime.getTime()
|
||||
let coords = { x: event.offsetX, y: event.offsetY }
|
||||
let coords_canvas = await this.messageBroker.pull<Point>(
|
||||
'screenToCanvas',
|
||||
coords
|
||||
)
|
||||
let currentTool = await this.messageBroker.pull('currentTool')
|
||||
|
||||
if (diff > 20 && !this.isDrawing)
|
||||
requestAnimationFrame(() => {
|
||||
this.init_shape(CompositionOperation.SourceOver)
|
||||
this.draw_shape(coords_canvas)
|
||||
this.smoothingCordsArray.push(coords_canvas)
|
||||
})
|
||||
else
|
||||
requestAnimationFrame(() => {
|
||||
if (currentTool === Tools.Eraser || event.buttons == 2) {
|
||||
this.init_shape(CompositionOperation.DestinationOut)
|
||||
} else {
|
||||
this.init_shape(CompositionOperation.SourceOver)
|
||||
}
|
||||
|
||||
//use drawWithSmoothing for better performance or change step in drawWithBetterSmoothing
|
||||
this.drawWithBetterSmoothing(coords_canvas)
|
||||
})
|
||||
|
||||
this.smoothingLastDrawTime = new Date()
|
||||
}
|
||||
|
||||
private async drawEnd(event: PointerEvent) {
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const coords_canvas = await this.messageBroker.pull<Point>(
|
||||
'screenToCanvas',
|
||||
coords
|
||||
)
|
||||
|
||||
if (this.isDrawing) {
|
||||
this.isDrawing = false
|
||||
this.messageBroker.publish('saveState')
|
||||
this.lineStartPoint = coords_canvas
|
||||
this.initialDraw = true
|
||||
}
|
||||
}
|
||||
|
||||
private clampSmoothingPrecision(value: number): number {
|
||||
return Math.min(Math.max(value, 1), 100)
|
||||
}
|
||||
|
||||
private drawWithBetterSmoothing(point: Point) {
|
||||
// Add current point to the smoothing array
|
||||
if (!this.smoothingCordsArray) {
|
||||
this.smoothingCordsArray = []
|
||||
}
|
||||
const opacityConstant = 1 / (1 + Math.exp(3))
|
||||
const interpolatedOpacity =
|
||||
1 / (1 + Math.exp(-6 * (this.brushSettings.opacity - 0.5))) -
|
||||
opacityConstant
|
||||
|
||||
this.smoothingCordsArray.push(point)
|
||||
|
||||
// Keep a moving window of points for the spline
|
||||
const POINTS_NR = 5
|
||||
if (this.smoothingCordsArray.length < POINTS_NR) {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate total length more efficiently
|
||||
let totalLength = 0
|
||||
const points = this.smoothingCordsArray
|
||||
const len = points.length - 1
|
||||
|
||||
// Use local variables for better performance
|
||||
let dx, dy
|
||||
for (let i = 0; i < len; i++) {
|
||||
dx = points[i + 1].x - points[i].x
|
||||
dy = points[i + 1].y - points[i].y
|
||||
totalLength += Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
const maxSteps = BrushTool.SMOOTHING_MAX_STEPS
|
||||
const minSteps = BrushTool.SMOOTHING_MIN_STEPS
|
||||
|
||||
const smoothing = this.clampSmoothingPrecision(
|
||||
this.brushSettings.smoothingPrecision
|
||||
)
|
||||
const normalizedSmoothing = (smoothing - 1) / 99 // Convert to 0-1 range
|
||||
|
||||
// Optionality to use exponential curve
|
||||
const stepNr = Math.round(
|
||||
Math.round(minSteps + (maxSteps - minSteps) * normalizedSmoothing)
|
||||
)
|
||||
|
||||
// Calculate step distance capped by brush size
|
||||
const distanceBetweenPoints = totalLength / stepNr
|
||||
|
||||
let interpolatedPoints = points
|
||||
|
||||
if (stepNr > 0) {
|
||||
//this calculation needs to be improved
|
||||
interpolatedPoints = this.generateEquidistantPoints(
|
||||
this.smoothingCordsArray,
|
||||
distanceBetweenPoints // Distance between interpolated points
|
||||
)
|
||||
}
|
||||
|
||||
if (!this.initialDraw) {
|
||||
// Remove the first 3 points from the array to avoid drawing the same points twice
|
||||
const spliceIndex = interpolatedPoints.findIndex(
|
||||
(point) =>
|
||||
point.x === this.smoothingCordsArray[2].x &&
|
||||
point.y === this.smoothingCordsArray[2].y
|
||||
)
|
||||
|
||||
if (spliceIndex !== -1) {
|
||||
interpolatedPoints = interpolatedPoints.slice(spliceIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw all interpolated points
|
||||
for (const point of interpolatedPoints) {
|
||||
this.draw_shape(point, interpolatedOpacity)
|
||||
}
|
||||
|
||||
if (!this.initialDraw) {
|
||||
// initially draw on all 5 points, then remove the first 3 points to go into 2 new, 3 old points cycle
|
||||
this.smoothingCordsArray = this.smoothingCordsArray.slice(2)
|
||||
} else {
|
||||
this.initialDraw = false
|
||||
}
|
||||
}
|
||||
|
||||
private async drawLine(
|
||||
p1: Point,
|
||||
p2: Point,
|
||||
compositionOp: CompositionOperation
|
||||
) {
|
||||
const brush_size = await this.messageBroker.pull<number>('brushSize')
|
||||
const distance = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2)
|
||||
const steps = Math.ceil(
|
||||
distance / ((brush_size / this.brushSettings.smoothingPrecision) * 4)
|
||||
) // Adjust for smoother lines
|
||||
const interpolatedOpacity =
|
||||
1 / (1 + Math.exp(-6 * (this.brushSettings.opacity - 0.5))) -
|
||||
1 / (1 + Math.exp(3))
|
||||
this.init_shape(compositionOp)
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps
|
||||
const x = p1.x + (p2.x - p1.x) * t
|
||||
const y = p1.y + (p2.y - p1.y) * t
|
||||
const point = { x: x, y: y }
|
||||
this.draw_shape(point, interpolatedOpacity)
|
||||
}
|
||||
}
|
||||
|
||||
//brush adjustment
|
||||
|
||||
private async startBrushAdjustment(event: PointerEvent) {
|
||||
event.preventDefault()
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
let coords_canvas = await this.messageBroker.pull<Point>(
|
||||
'screenToCanvas',
|
||||
coords
|
||||
)
|
||||
this.messageBroker.publish('setBrushPreviewGradientVisibility', true)
|
||||
this.initialPoint = coords_canvas
|
||||
this.isBrushAdjusting = true
|
||||
return
|
||||
}
|
||||
|
||||
private async handleBrushAdjustment(event: PointerEvent) {
|
||||
const coords = { x: event.offsetX, y: event.offsetY }
|
||||
const brushDeadZone = 5
|
||||
let coords_canvas = await this.messageBroker.pull<Point>(
|
||||
'screenToCanvas',
|
||||
coords
|
||||
)
|
||||
|
||||
const delta_x = coords_canvas.x - this.initialPoint!.x
|
||||
const delta_y = coords_canvas.y - this.initialPoint!.y
|
||||
|
||||
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
|
||||
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
|
||||
|
||||
// New dominant axis logic
|
||||
let finalDeltaX = effectiveDeltaX
|
||||
let finalDeltaY = effectiveDeltaY
|
||||
|
||||
console.log(this.useDominantAxis)
|
||||
|
||||
if (this.useDominantAxis) {
|
||||
// New setting flag
|
||||
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
|
||||
const threshold = 2.0 // Configurable threshold
|
||||
|
||||
if (ratio > threshold) {
|
||||
finalDeltaY = 0 // X is dominant
|
||||
} else if (ratio < 1 / threshold) {
|
||||
finalDeltaX = 0 // Y is dominant
|
||||
}
|
||||
}
|
||||
|
||||
const cappedDeltaX = Math.max(-100, Math.min(100, finalDeltaX))
|
||||
const cappedDeltaY = Math.max(-100, Math.min(100, finalDeltaY))
|
||||
|
||||
// Rest of the function remains the same
|
||||
const newSize = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
100,
|
||||
this.brushSettings.size! +
|
||||
(cappedDeltaX / 35) * this.brushAdjustmentSpeed
|
||||
)
|
||||
)
|
||||
|
||||
const newHardness = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
this.brushSettings!.hardness -
|
||||
(cappedDeltaY / 4000) * this.brushAdjustmentSpeed
|
||||
)
|
||||
)
|
||||
|
||||
this.brushSettings.size = newSize
|
||||
this.brushSettings.hardness = newHardness
|
||||
|
||||
this.messageBroker.publish('updateBrushPreview')
|
||||
}
|
||||
|
||||
//helper functions
|
||||
|
||||
private async draw_shape(point: Point, overrideOpacity?: number) {
|
||||
const brushSettings: Brush = this.brushSettings
|
||||
const maskCtx =
|
||||
this.maskCtx ||
|
||||
(await this.messageBroker.pull<CanvasRenderingContext2D>('maskCtx'))
|
||||
const rgbCtx =
|
||||
this.rgbCtx ||
|
||||
(await this.messageBroker.pull<CanvasRenderingContext2D>('rgbCtx'))
|
||||
const brushType = await this.messageBroker.pull('brushType')
|
||||
const maskColor = await this.messageBroker.pull<{
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
}>('getMaskColor')
|
||||
const size = brushSettings.size
|
||||
const brushSettingsSliderOpacity = brushSettings.opacity
|
||||
const opacity =
|
||||
overrideOpacity == undefined
|
||||
? brushSettingsSliderOpacity
|
||||
: overrideOpacity
|
||||
const hardness = brushSettings.hardness
|
||||
const x = point.x
|
||||
const y = point.y
|
||||
|
||||
const brushRadius = size
|
||||
const isErasing = maskCtx.globalCompositeOperation === 'destination-out'
|
||||
const currentTool = await this.messageBroker.pull('currentTool')
|
||||
|
||||
// Helper function to get or create cached brush texture
|
||||
const getCachedBrushTexture = (
|
||||
radius: number,
|
||||
hardness: number,
|
||||
color: string,
|
||||
opacity: number
|
||||
): HTMLCanvasElement => {
|
||||
const cacheKey = `${radius}_${hardness}_${color}_${opacity}`
|
||||
|
||||
if (BrushTool.brushTextureCache.has(cacheKey)) {
|
||||
return BrushTool.brushTextureCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
const tempCanvas = document.createElement('canvas')
|
||||
const tempCtx = tempCanvas.getContext('2d')!
|
||||
const size = radius * 2
|
||||
tempCanvas.width = size
|
||||
tempCanvas.height = size
|
||||
|
||||
const centerX = size / 2
|
||||
const centerY = size / 2
|
||||
const hardRadius = radius * hardness
|
||||
|
||||
const imageData = tempCtx.createImageData(size, size)
|
||||
const data = imageData.data
|
||||
const { r, g, b } = parseToRgb(color)
|
||||
|
||||
// Pre-calculate values to avoid repeated computations
|
||||
const fadeRange = radius - hardRadius
|
||||
|
||||
for (let y = 0; y < size; y++) {
|
||||
const dy = y - centerY
|
||||
for (let x = 0; x < size; x++) {
|
||||
const dx = x - centerX
|
||||
const index = (y * size + x) * 4
|
||||
|
||||
// Calculate square distance (Chebyshev distance)
|
||||
const distFromEdge = Math.max(Math.abs(dx), Math.abs(dy))
|
||||
|
||||
let pixelOpacity = 0
|
||||
if (distFromEdge <= hardRadius) {
|
||||
pixelOpacity = opacity
|
||||
} else if (distFromEdge <= radius) {
|
||||
const fadeProgress = (distFromEdge - hardRadius) / fadeRange
|
||||
pixelOpacity = opacity * (1 - fadeProgress)
|
||||
}
|
||||
|
||||
data[index] = r
|
||||
data[index + 1] = g
|
||||
data[index + 2] = b
|
||||
data[index + 3] = pixelOpacity * 255
|
||||
}
|
||||
}
|
||||
|
||||
tempCtx.putImageData(imageData, 0, 0)
|
||||
|
||||
// Cache the texture
|
||||
BrushTool.brushTextureCache.set(cacheKey, tempCanvas)
|
||||
|
||||
return tempCanvas
|
||||
}
|
||||
|
||||
// RGB brush logic
|
||||
if (
|
||||
this.activeLayer === 'rgb' &&
|
||||
(currentTool === Tools.Eraser || currentTool === Tools.PaintPen)
|
||||
) {
|
||||
const rgbaColor = this.formatRgba(this.rgbColor, opacity)
|
||||
|
||||
if (brushType === BrushShape.Rect && hardness < 1) {
|
||||
const brushTexture = getCachedBrushTexture(
|
||||
brushRadius,
|
||||
hardness,
|
||||
rgbaColor,
|
||||
opacity
|
||||
)
|
||||
rgbCtx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
|
||||
return
|
||||
}
|
||||
|
||||
// For max hardness, use solid fill to avoid anti-aliasing
|
||||
if (hardness === 1) {
|
||||
rgbCtx.fillStyle = rgbaColor
|
||||
rgbCtx.beginPath()
|
||||
if (brushType === BrushShape.Rect) {
|
||||
rgbCtx.rect(
|
||||
x - brushRadius,
|
||||
y - brushRadius,
|
||||
brushRadius * 2,
|
||||
brushRadius * 2
|
||||
)
|
||||
} else {
|
||||
rgbCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
|
||||
}
|
||||
rgbCtx.fill()
|
||||
return
|
||||
}
|
||||
|
||||
// For soft brushes, use gradient
|
||||
let gradient = rgbCtx.createRadialGradient(x, y, 0, x, y, brushRadius)
|
||||
gradient.addColorStop(0, rgbaColor)
|
||||
gradient.addColorStop(
|
||||
hardness,
|
||||
this.formatRgba(this.rgbColor, opacity * 0.5)
|
||||
)
|
||||
gradient.addColorStop(1, this.formatRgba(this.rgbColor, 0))
|
||||
|
||||
rgbCtx.fillStyle = gradient
|
||||
rgbCtx.beginPath()
|
||||
if (brushType === BrushShape.Rect) {
|
||||
rgbCtx.rect(
|
||||
x - brushRadius,
|
||||
y - brushRadius,
|
||||
brushRadius * 2,
|
||||
brushRadius * 2
|
||||
)
|
||||
} else {
|
||||
rgbCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
|
||||
}
|
||||
rgbCtx.fill()
|
||||
return
|
||||
}
|
||||
|
||||
// Mask brush logic
|
||||
if (brushType === BrushShape.Rect && hardness < 1) {
|
||||
const baseColor = isErasing
|
||||
? `rgba(255, 255, 255, ${opacity})`
|
||||
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
|
||||
const brushTexture = getCachedBrushTexture(
|
||||
brushRadius,
|
||||
hardness,
|
||||
baseColor,
|
||||
opacity
|
||||
)
|
||||
maskCtx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
|
||||
return
|
||||
}
|
||||
|
||||
// For max hardness, use solid fill to avoid anti-aliasing
|
||||
if (hardness === 1) {
|
||||
const solidColor = isErasing
|
||||
? `rgba(255, 255, 255, ${opacity})`
|
||||
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
|
||||
maskCtx.fillStyle = solidColor
|
||||
maskCtx.beginPath()
|
||||
if (brushType === BrushShape.Rect) {
|
||||
maskCtx.rect(
|
||||
x - brushRadius,
|
||||
y - brushRadius,
|
||||
brushRadius * 2,
|
||||
brushRadius * 2
|
||||
)
|
||||
} else {
|
||||
maskCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
|
||||
}
|
||||
maskCtx.fill()
|
||||
return
|
||||
}
|
||||
|
||||
// For soft brushes, use gradient
|
||||
let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, brushRadius)
|
||||
|
||||
if (isErasing) {
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`)
|
||||
gradient.addColorStop(hardness, `rgba(255, 255, 255, ${opacity * 0.5})`)
|
||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
|
||||
} else {
|
||||
gradient.addColorStop(
|
||||
0,
|
||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
)
|
||||
gradient.addColorStop(
|
||||
hardness,
|
||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity * 0.5})`
|
||||
)
|
||||
gradient.addColorStop(
|
||||
1,
|
||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, 0)`
|
||||
)
|
||||
}
|
||||
|
||||
maskCtx.fillStyle = gradient
|
||||
maskCtx.beginPath()
|
||||
if (brushType === BrushShape.Rect) {
|
||||
maskCtx.rect(
|
||||
x - brushRadius,
|
||||
y - brushRadius,
|
||||
brushRadius * 2,
|
||||
brushRadius * 2
|
||||
)
|
||||
} else {
|
||||
maskCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
|
||||
}
|
||||
maskCtx.fill()
|
||||
}
|
||||
|
||||
private formatRgba(hex: string, alpha: number): string {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
|
||||
private async init_shape(compositionOperation: CompositionOperation) {
|
||||
const maskBlendMode =
|
||||
await this.messageBroker.pull<MaskBlendMode>('maskBlendMode')
|
||||
const maskCtx =
|
||||
this.maskCtx ||
|
||||
(await this.messageBroker.pull<CanvasRenderingContext2D>('maskCtx'))
|
||||
const rgbCtx =
|
||||
this.rgbCtx ||
|
||||
(await this.messageBroker.pull<CanvasRenderingContext2D>('rgbCtx'))
|
||||
|
||||
maskCtx.beginPath()
|
||||
rgbCtx.beginPath()
|
||||
|
||||
// For both contexts, set the composite operation based on the passed parameter
|
||||
// This ensures right-click always works for erasing
|
||||
if (compositionOperation == CompositionOperation.SourceOver) {
|
||||
maskCtx.fillStyle = maskBlendMode
|
||||
maskCtx.globalCompositeOperation = CompositionOperation.SourceOver
|
||||
rgbCtx.globalCompositeOperation = CompositionOperation.SourceOver
|
||||
} else if (compositionOperation == CompositionOperation.DestinationOut) {
|
||||
maskCtx.globalCompositeOperation = CompositionOperation.DestinationOut
|
||||
rgbCtx.globalCompositeOperation = CompositionOperation.DestinationOut
|
||||
}
|
||||
}
|
||||
|
||||
private generateEquidistantPoints(
|
||||
points: Point[],
|
||||
distance: number
|
||||
): Point[] {
|
||||
const result: Point[] = []
|
||||
const cumulativeDistances: number[] = [0]
|
||||
|
||||
// Calculate cumulative distances between points
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const dx = points[i].x - points[i - 1].x
|
||||
const dy = points[i].y - points[i - 1].y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
cumulativeDistances[i] = cumulativeDistances[i - 1] + dist
|
||||
}
|
||||
|
||||
const totalLength = cumulativeDistances[cumulativeDistances.length - 1]
|
||||
const numPoints = Math.floor(totalLength / distance)
|
||||
|
||||
for (let i = 0; i <= numPoints; i++) {
|
||||
const targetDistance = i * distance
|
||||
let idx = 0
|
||||
|
||||
// Find the segment where the target distance falls
|
||||
while (
|
||||
idx < cumulativeDistances.length - 1 &&
|
||||
cumulativeDistances[idx + 1] < targetDistance
|
||||
) {
|
||||
idx++
|
||||
}
|
||||
|
||||
if (idx >= points.length - 1) {
|
||||
result.push(points[points.length - 1])
|
||||
continue
|
||||
}
|
||||
|
||||
const d0 = cumulativeDistances[idx]
|
||||
const d1 = cumulativeDistances[idx + 1]
|
||||
const t = (targetDistance - d0) / (d1 - d0)
|
||||
|
||||
const x = points[idx].x + t * (points[idx + 1].x - points[idx].x)
|
||||
const y = points[idx].y + t * (points[idx + 1].y - points[idx].y)
|
||||
|
||||
result.push({ x, y })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private setBrushSize(size: number) {
|
||||
this.brushSettings.size = size
|
||||
saveBrushToCache('maskeditor_brush_settings', this.brushSettings)
|
||||
}
|
||||
|
||||
private setBrushOpacity(opacity: number) {
|
||||
this.brushSettings.opacity = opacity
|
||||
saveBrushToCache('maskeditor_brush_settings', this.brushSettings)
|
||||
}
|
||||
|
||||
private setBrushHardness(hardness: number) {
|
||||
this.brushSettings.hardness = hardness
|
||||
saveBrushToCache('maskeditor_brush_settings', this.brushSettings)
|
||||
}
|
||||
|
||||
private setBrushType(type: BrushShape) {
|
||||
this.brushSettings.type = type
|
||||
saveBrushToCache('maskeditor_brush_settings', this.brushSettings)
|
||||
}
|
||||
|
||||
private setBrushSmoothingPrecision(precision: number) {
|
||||
this.brushSettings.smoothingPrecision = precision
|
||||
saveBrushToCache('maskeditor_brush_settings', this.brushSettings)
|
||||
}
|
||||
}
|
||||
|
||||
export { BrushTool }
|
||||
@@ -1,464 +0,0 @@
|
||||
import { ColorComparisonMethod, type Point } from '../types'
|
||||
|
||||
// Forward declaration for MessageBroker type
|
||||
interface MessageBroker {
|
||||
subscribe(topic: string, callback: (data?: any) => void): void
|
||||
publish(topic: string, data?: any): void
|
||||
pull<T>(topic: string, data?: any): Promise<T>
|
||||
createPullTopic(topic: string, callback: (data?: any) => Promise<any>): void
|
||||
}
|
||||
|
||||
// Forward declaration for MaskEditorDialog type
|
||||
interface MaskEditorDialog {
|
||||
getMessageBroker(): MessageBroker
|
||||
}
|
||||
|
||||
class ColorSelectTool {
|
||||
// @ts-expect-error unused variable
|
||||
private maskEditor!: MaskEditorDialog
|
||||
private messageBroker!: MessageBroker
|
||||
private width: number | null = null
|
||||
private height: number | null = null
|
||||
private canvas!: HTMLCanvasElement
|
||||
private maskCTX!: CanvasRenderingContext2D
|
||||
private imageCTX!: CanvasRenderingContext2D
|
||||
private maskData: Uint8ClampedArray | null = null
|
||||
private imageData: Uint8ClampedArray | null = null
|
||||
private tolerance: number = 20
|
||||
private livePreview: boolean = false
|
||||
private lastPoint: Point | null = null
|
||||
private colorComparisonMethod: ColorComparisonMethod =
|
||||
ColorComparisonMethod.Simple
|
||||
private applyWholeImage: boolean = false
|
||||
private maskBoundry: boolean = false
|
||||
private maskTolerance: number = 0
|
||||
private selectOpacity: number = 255 // Add opacity property (default 100%)
|
||||
|
||||
constructor(maskEditor: MaskEditorDialog) {
|
||||
this.maskEditor = maskEditor
|
||||
this.messageBroker = maskEditor.getMessageBroker()
|
||||
this.createListeners()
|
||||
this.addPullTopics()
|
||||
}
|
||||
|
||||
async initColorSelectTool() {
|
||||
await this.pullCanvas()
|
||||
}
|
||||
|
||||
private async pullCanvas() {
|
||||
this.canvas = await this.messageBroker.pull('imgCanvas')
|
||||
this.maskCTX = await this.messageBroker.pull('maskCtx')
|
||||
this.imageCTX = await this.messageBroker.pull('imageCtx')
|
||||
}
|
||||
|
||||
private createListeners() {
|
||||
this.messageBroker.subscribe('colorSelectFill', (point: Point) =>
|
||||
this.fillColorSelection(point)
|
||||
)
|
||||
this.messageBroker.subscribe(
|
||||
'setColorSelectTolerance',
|
||||
(tolerance: number) => this.setTolerance(tolerance)
|
||||
)
|
||||
this.messageBroker.subscribe('setLivePreview', (livePreview: boolean) =>
|
||||
this.setLivePreview(livePreview)
|
||||
)
|
||||
this.messageBroker.subscribe(
|
||||
'setColorComparisonMethod',
|
||||
(method: ColorComparisonMethod) => this.setComparisonMethod(method)
|
||||
)
|
||||
|
||||
this.messageBroker.subscribe('clearLastPoint', () => this.clearLastPoint())
|
||||
|
||||
this.messageBroker.subscribe('setWholeImage', (applyWholeImage: boolean) =>
|
||||
this.setApplyWholeImage(applyWholeImage)
|
||||
)
|
||||
|
||||
this.messageBroker.subscribe('setMaskBoundary', (maskBoundry: boolean) =>
|
||||
this.setMaskBoundary(maskBoundry)
|
||||
)
|
||||
|
||||
this.messageBroker.subscribe('setMaskTolerance', (maskTolerance: number) =>
|
||||
this.setMaskTolerance(maskTolerance)
|
||||
)
|
||||
|
||||
// Add new listener for opacity setting
|
||||
this.messageBroker.subscribe('setSelectionOpacity', (opacity: number) =>
|
||||
this.setSelectOpacity(opacity)
|
||||
)
|
||||
}
|
||||
|
||||
private async addPullTopics() {
|
||||
this.messageBroker.createPullTopic(
|
||||
'getLivePreview',
|
||||
async () => this.livePreview
|
||||
)
|
||||
}
|
||||
|
||||
private getPixel(x: number, y: number): { r: number; g: number; b: number } {
|
||||
const index = (y * this.width! + x) * 4
|
||||
return {
|
||||
r: this.imageData![index],
|
||||
g: this.imageData![index + 1],
|
||||
b: this.imageData![index + 2]
|
||||
}
|
||||
}
|
||||
|
||||
private getMaskAlpha(x: number, y: number): number {
|
||||
return this.maskData![(y * this.width! + x) * 4 + 3]
|
||||
}
|
||||
|
||||
private isPixelInRange(
|
||||
pixel: { r: number; g: number; b: number },
|
||||
target: { r: number; g: number; b: number }
|
||||
): boolean {
|
||||
switch (this.colorComparisonMethod) {
|
||||
case ColorComparisonMethod.Simple:
|
||||
return this.isPixelInRangeSimple(pixel, target)
|
||||
case ColorComparisonMethod.HSL:
|
||||
return this.isPixelInRangeHSL(pixel, target)
|
||||
case ColorComparisonMethod.LAB:
|
||||
return this.isPixelInRangeLab(pixel, target)
|
||||
default:
|
||||
return this.isPixelInRangeSimple(pixel, target)
|
||||
}
|
||||
}
|
||||
|
||||
private isPixelInRangeSimple(
|
||||
pixel: { r: number; g: number; b: number },
|
||||
target: { r: number; g: number; b: number }
|
||||
): boolean {
|
||||
//calculate the euclidean distance between the two colors
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(pixel.r - target.r, 2) +
|
||||
Math.pow(pixel.g - target.g, 2) +
|
||||
Math.pow(pixel.b - target.b, 2)
|
||||
)
|
||||
return distance <= this.tolerance
|
||||
}
|
||||
|
||||
private isPixelInRangeHSL(
|
||||
pixel: { r: number; g: number; b: number },
|
||||
target: { r: number; g: number; b: number }
|
||||
): boolean {
|
||||
// Convert RGB to HSL
|
||||
const pixelHSL = this.rgbToHSL(pixel.r, pixel.g, pixel.b)
|
||||
const targetHSL = this.rgbToHSL(target.r, target.g, target.b)
|
||||
|
||||
// Compare mainly hue and saturation, be more lenient with lightness
|
||||
const hueDiff = Math.abs(pixelHSL.h - targetHSL.h)
|
||||
const satDiff = Math.abs(pixelHSL.s - targetHSL.s)
|
||||
const lightDiff = Math.abs(pixelHSL.l - targetHSL.l)
|
||||
|
||||
const distance = Math.sqrt(
|
||||
Math.pow((hueDiff / 360) * 255, 2) +
|
||||
Math.pow((satDiff / 100) * 255, 2) +
|
||||
Math.pow((lightDiff / 100) * 255, 2)
|
||||
)
|
||||
return distance <= this.tolerance
|
||||
}
|
||||
|
||||
private rgbToHSL(
|
||||
r: number,
|
||||
g: number,
|
||||
b: number
|
||||
): { h: number; s: number; l: number } {
|
||||
r /= 255
|
||||
g /= 255
|
||||
b /= 255
|
||||
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
let h = 0,
|
||||
s = 0,
|
||||
l = (max + min) / 2
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0)
|
||||
break
|
||||
case g:
|
||||
h = (b - r) / d + 2
|
||||
break
|
||||
case b:
|
||||
h = (r - g) / d + 4
|
||||
break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
|
||||
return {
|
||||
h: h * 360,
|
||||
s: s * 100,
|
||||
l: l * 100
|
||||
}
|
||||
}
|
||||
|
||||
private isPixelInRangeLab(
|
||||
pixel: { r: number; g: number; b: number },
|
||||
target: { r: number; g: number; b: number }
|
||||
): boolean {
|
||||
const pixelLab = this.rgbToLab(pixel)
|
||||
const targetLab = this.rgbToLab(target)
|
||||
|
||||
// Calculate Delta E (CIE76 formula)
|
||||
const deltaE = Math.sqrt(
|
||||
Math.pow(pixelLab.l - targetLab.l, 2) +
|
||||
Math.pow(pixelLab.a - targetLab.a, 2) +
|
||||
Math.pow(pixelLab.b - targetLab.b, 2)
|
||||
)
|
||||
|
||||
const normalizedDeltaE = (deltaE / 100) * 255
|
||||
return normalizedDeltaE <= this.tolerance
|
||||
}
|
||||
|
||||
private rgbToLab(rgb: { r: number; g: number; b: number }): {
|
||||
l: number
|
||||
a: number
|
||||
b: number
|
||||
} {
|
||||
// First convert RGB to XYZ
|
||||
let r = rgb.r / 255
|
||||
let g = rgb.g / 255
|
||||
let b = rgb.b / 255
|
||||
|
||||
r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92
|
||||
g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92
|
||||
b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92
|
||||
|
||||
r *= 100
|
||||
g *= 100
|
||||
b *= 100
|
||||
|
||||
const x = r * 0.4124 + g * 0.3576 + b * 0.1805
|
||||
const y = r * 0.2126 + g * 0.7152 + b * 0.0722
|
||||
const z = r * 0.0193 + g * 0.1192 + b * 0.9505
|
||||
|
||||
// Then XYZ to Lab
|
||||
const xn = 95.047
|
||||
const yn = 100.0
|
||||
const zn = 108.883
|
||||
|
||||
const xyz = [x / xn, y / yn, z / zn]
|
||||
for (let i = 0; i < xyz.length; i++) {
|
||||
xyz[i] =
|
||||
xyz[i] > 0.008856 ? Math.pow(xyz[i], 1 / 3) : 7.787 * xyz[i] + 16 / 116
|
||||
}
|
||||
|
||||
return {
|
||||
l: 116 * xyz[1] - 16,
|
||||
a: 500 * (xyz[0] - xyz[1]),
|
||||
b: 200 * (xyz[1] - xyz[2])
|
||||
}
|
||||
}
|
||||
|
||||
private setPixel(
|
||||
x: number,
|
||||
y: number,
|
||||
alpha: number,
|
||||
color: { r: number; g: number; b: number }
|
||||
): void {
|
||||
const index = (y * this.width! + x) * 4
|
||||
this.maskData![index] = color.r // R
|
||||
this.maskData![index + 1] = color.g // G
|
||||
this.maskData![index + 2] = color.b // B
|
||||
this.maskData![index + 3] = alpha // A
|
||||
}
|
||||
|
||||
async fillColorSelection(point: Point) {
|
||||
this.width = this.canvas.width
|
||||
this.height = this.canvas.height
|
||||
this.lastPoint = point
|
||||
|
||||
// Get image data
|
||||
const maskData = this.maskCTX.getImageData(0, 0, this.width, this.height)
|
||||
this.maskData = maskData.data
|
||||
this.imageData = this.imageCTX.getImageData(
|
||||
0,
|
||||
0,
|
||||
this.width,
|
||||
this.height
|
||||
).data
|
||||
|
||||
if (this.applyWholeImage) {
|
||||
// Process entire image
|
||||
const targetPixel = this.getPixel(
|
||||
Math.floor(point.x),
|
||||
Math.floor(point.y)
|
||||
)
|
||||
const maskColor = await this.messageBroker.pull<{
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
}>('getMaskColor')
|
||||
|
||||
// Use TypedArrays for better performance
|
||||
const width = this.width!
|
||||
const height = this.height!
|
||||
|
||||
// Process in chunks for better performance
|
||||
const CHUNK_SIZE = 10000
|
||||
for (let i = 0; i < width * height; i += CHUNK_SIZE) {
|
||||
const endIndex = Math.min(i + CHUNK_SIZE, width * height)
|
||||
for (let pixelIndex = i; pixelIndex < endIndex; pixelIndex++) {
|
||||
const x = pixelIndex % width
|
||||
const y = Math.floor(pixelIndex / width)
|
||||
if (this.isPixelInRange(this.getPixel(x, y), targetPixel)) {
|
||||
this.setPixel(x, y, this.selectOpacity, maskColor) // Use selectOpacity instead of 255
|
||||
}
|
||||
}
|
||||
// Allow UI updates between chunks
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
} else {
|
||||
// Original flood fill logic
|
||||
let startX = Math.floor(point.x)
|
||||
let startY = Math.floor(point.y)
|
||||
|
||||
if (
|
||||
startX < 0 ||
|
||||
startX >= this.width ||
|
||||
startY < 0 ||
|
||||
startY >= this.height
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const pixel = this.getPixel(startX, startY)
|
||||
|
||||
const stack: Array<[number, number]> = []
|
||||
const visited = new Uint8Array(this.width * this.height)
|
||||
|
||||
stack.push([startX, startY])
|
||||
const maskColor = await this.messageBroker.pull<{
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
}>('getMaskColor')
|
||||
|
||||
while (stack.length > 0) {
|
||||
const [x, y] = stack.pop()!
|
||||
const visitedIndex = y * this.width + x
|
||||
|
||||
if (
|
||||
visited[visitedIndex] ||
|
||||
!this.isPixelInRange(this.getPixel(x, y), pixel)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
visited[visitedIndex] = 1
|
||||
this.setPixel(x, y, this.selectOpacity, maskColor) // Use selectOpacity instead of 255
|
||||
|
||||
// Inline direction checks for better performance
|
||||
if (
|
||||
x > 0 &&
|
||||
!visited[y * this.width + (x - 1)] &&
|
||||
this.isPixelInRange(this.getPixel(x - 1, y), pixel)
|
||||
) {
|
||||
if (
|
||||
!this.maskBoundry ||
|
||||
255 - this.getMaskAlpha(x - 1, y) > this.maskTolerance
|
||||
) {
|
||||
stack.push([x - 1, y])
|
||||
}
|
||||
}
|
||||
if (
|
||||
x < this.width - 1 &&
|
||||
!visited[y * this.width + (x + 1)] &&
|
||||
this.isPixelInRange(this.getPixel(x + 1, y), pixel)
|
||||
) {
|
||||
if (
|
||||
!this.maskBoundry ||
|
||||
255 - this.getMaskAlpha(x + 1, y) > this.maskTolerance
|
||||
) {
|
||||
stack.push([x + 1, y])
|
||||
}
|
||||
}
|
||||
if (
|
||||
y > 0 &&
|
||||
!visited[(y - 1) * this.width + x] &&
|
||||
this.isPixelInRange(this.getPixel(x, y - 1), pixel)
|
||||
) {
|
||||
if (
|
||||
!this.maskBoundry ||
|
||||
255 - this.getMaskAlpha(x, y - 1) > this.maskTolerance
|
||||
) {
|
||||
stack.push([x, y - 1])
|
||||
}
|
||||
}
|
||||
if (
|
||||
y < this.height - 1 &&
|
||||
!visited[(y + 1) * this.width + x] &&
|
||||
this.isPixelInRange(this.getPixel(x, y + 1), pixel)
|
||||
) {
|
||||
if (
|
||||
!this.maskBoundry ||
|
||||
255 - this.getMaskAlpha(x, y + 1) > this.maskTolerance
|
||||
) {
|
||||
stack.push([x, y + 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.maskCTX.putImageData(maskData, 0, 0)
|
||||
this.messageBroker.publish('saveState')
|
||||
this.maskData = null
|
||||
this.imageData = null
|
||||
}
|
||||
setTolerance(tolerance: number): void {
|
||||
this.tolerance = tolerance
|
||||
|
||||
if (this.lastPoint && this.livePreview) {
|
||||
this.messageBroker.publish('undo')
|
||||
this.fillColorSelection(this.lastPoint)
|
||||
}
|
||||
}
|
||||
|
||||
setLivePreview(livePreview: boolean): void {
|
||||
this.livePreview = livePreview
|
||||
}
|
||||
|
||||
setComparisonMethod(method: ColorComparisonMethod): void {
|
||||
this.colorComparisonMethod = method
|
||||
|
||||
if (this.lastPoint && this.livePreview) {
|
||||
this.messageBroker.publish('undo')
|
||||
this.fillColorSelection(this.lastPoint)
|
||||
}
|
||||
}
|
||||
|
||||
clearLastPoint() {
|
||||
this.lastPoint = null
|
||||
}
|
||||
|
||||
setApplyWholeImage(applyWholeImage: boolean): void {
|
||||
this.applyWholeImage = applyWholeImage
|
||||
}
|
||||
|
||||
setMaskBoundary(maskBoundry: boolean): void {
|
||||
this.maskBoundry = maskBoundry
|
||||
}
|
||||
|
||||
setMaskTolerance(maskTolerance: number): void {
|
||||
this.maskTolerance = maskTolerance
|
||||
}
|
||||
|
||||
// Add method to set opacity
|
||||
setSelectOpacity(opacity: number): void {
|
||||
// Convert from percentage (0-100) to alpha value (0-255)
|
||||
this.selectOpacity = Math.floor((opacity / 100) * 255)
|
||||
|
||||
// Update preview if applicable
|
||||
if (this.lastPoint && this.livePreview) {
|
||||
this.messageBroker.publish('undo')
|
||||
this.fillColorSelection(this.lastPoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { ColorSelectTool }
|
||||
@@ -1,265 +0,0 @@
|
||||
import type { Point } from '../types'
|
||||
|
||||
// Forward declaration for MessageBroker type
|
||||
interface MessageBroker {
|
||||
subscribe(topic: string, callback: (data?: any) => void): void
|
||||
publish(topic: string, data?: any): void
|
||||
pull<T>(topic: string, data?: any): Promise<T>
|
||||
createPullTopic(topic: string, callback: (data?: any) => Promise<any>): void
|
||||
}
|
||||
|
||||
// Forward declaration for MaskEditorDialog type
|
||||
interface MaskEditorDialog {
|
||||
getMessageBroker(): MessageBroker
|
||||
}
|
||||
|
||||
class PaintBucketTool {
|
||||
maskEditor: MaskEditorDialog
|
||||
messageBroker: MessageBroker
|
||||
|
||||
private canvas!: HTMLCanvasElement
|
||||
private ctx!: CanvasRenderingContext2D
|
||||
private width: number | null = null
|
||||
private height: number | null = null
|
||||
private imageData: ImageData | null = null
|
||||
private data: Uint8ClampedArray | null = null
|
||||
private tolerance: number = 5
|
||||
private fillOpacity: number = 255 // Add opacity property (default 100%)
|
||||
|
||||
constructor(maskEditor: MaskEditorDialog) {
|
||||
this.maskEditor = maskEditor
|
||||
this.messageBroker = maskEditor.getMessageBroker()
|
||||
this.createListeners()
|
||||
this.addPullTopics()
|
||||
}
|
||||
|
||||
initPaintBucketTool() {
|
||||
this.pullCanvas()
|
||||
}
|
||||
|
||||
private async pullCanvas() {
|
||||
this.canvas = await this.messageBroker.pull('maskCanvas')
|
||||
this.ctx = await this.messageBroker.pull('maskCtx')
|
||||
}
|
||||
|
||||
private createListeners() {
|
||||
this.messageBroker.subscribe(
|
||||
'setPaintBucketTolerance',
|
||||
(tolerance: number) => this.setTolerance(tolerance)
|
||||
)
|
||||
|
||||
this.messageBroker.subscribe('paintBucketFill', (point: Point) =>
|
||||
this.floodFill(point)
|
||||
)
|
||||
|
||||
this.messageBroker.subscribe('invert', () => this.invertMask())
|
||||
|
||||
// Add new listener for opacity setting
|
||||
this.messageBroker.subscribe('setFillOpacity', (opacity: number) =>
|
||||
this.setFillOpacity(opacity)
|
||||
)
|
||||
}
|
||||
|
||||
private addPullTopics() {
|
||||
this.messageBroker.createPullTopic(
|
||||
'getTolerance',
|
||||
async () => this.tolerance
|
||||
)
|
||||
// Add pull topic for fillOpacity
|
||||
this.messageBroker.createPullTopic(
|
||||
'getFillOpacity',
|
||||
async () => (this.fillOpacity / 255) * 100
|
||||
)
|
||||
}
|
||||
|
||||
// Add method to set opacity
|
||||
setFillOpacity(opacity: number): void {
|
||||
// Convert from percentage (0-100) to alpha value (0-255)
|
||||
this.fillOpacity = Math.floor((opacity / 100) * 255)
|
||||
}
|
||||
|
||||
private getPixel(x: number, y: number): number {
|
||||
return this.data![(y * this.width! + x) * 4 + 3]
|
||||
}
|
||||
|
||||
private setPixel(
|
||||
x: number,
|
||||
y: number,
|
||||
alpha: number,
|
||||
color: { r: number; g: number; b: number }
|
||||
): void {
|
||||
const index = (y * this.width! + x) * 4
|
||||
this.data![index] = color.r // R
|
||||
this.data![index + 1] = color.g // G
|
||||
this.data![index + 2] = color.b // B
|
||||
this.data![index + 3] = alpha // A
|
||||
}
|
||||
|
||||
private shouldProcessPixel(
|
||||
currentAlpha: number,
|
||||
targetAlpha: number,
|
||||
tolerance: number,
|
||||
isFillMode: boolean
|
||||
): boolean {
|
||||
if (currentAlpha === -1) return false
|
||||
|
||||
if (isFillMode) {
|
||||
// Fill mode: process pixels that are empty/similar to target
|
||||
return (
|
||||
currentAlpha !== 255 &&
|
||||
Math.abs(currentAlpha - targetAlpha) <= tolerance
|
||||
)
|
||||
} else {
|
||||
// Erase mode: process pixels that are filled/similar to target
|
||||
return (
|
||||
currentAlpha === 255 ||
|
||||
Math.abs(currentAlpha - targetAlpha) <= tolerance
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async floodFill(point: Point): Promise<void> {
|
||||
let startX = Math.floor(point.x)
|
||||
let startY = Math.floor(point.y)
|
||||
this.width = this.canvas.width
|
||||
this.height = this.canvas.height
|
||||
|
||||
if (
|
||||
startX < 0 ||
|
||||
startX >= this.width ||
|
||||
startY < 0 ||
|
||||
startY >= this.height
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.imageData = this.ctx.getImageData(0, 0, this.width, this.height)
|
||||
this.data = this.imageData.data
|
||||
|
||||
const targetAlpha = this.getPixel(startX, startY)
|
||||
const isFillMode = targetAlpha !== 255 // Determine mode based on clicked pixel
|
||||
|
||||
if (targetAlpha === -1) return
|
||||
|
||||
const maskColor = await this.messageBroker.pull<{
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
}>('getMaskColor')
|
||||
const stack: Array<[number, number]> = []
|
||||
const visited = new Uint8Array(this.width * this.height)
|
||||
|
||||
if (
|
||||
this.shouldProcessPixel(
|
||||
targetAlpha,
|
||||
targetAlpha,
|
||||
this.tolerance,
|
||||
isFillMode
|
||||
)
|
||||
) {
|
||||
stack.push([startX, startY])
|
||||
}
|
||||
|
||||
while (stack.length > 0) {
|
||||
const [x, y] = stack.pop()!
|
||||
const visitedIndex = y * this.width + x
|
||||
|
||||
if (visited[visitedIndex]) continue
|
||||
|
||||
const currentAlpha = this.getPixel(x, y)
|
||||
if (
|
||||
!this.shouldProcessPixel(
|
||||
currentAlpha,
|
||||
targetAlpha,
|
||||
this.tolerance,
|
||||
isFillMode
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
visited[visitedIndex] = 1
|
||||
// Set alpha to fillOpacity for fill mode, 0 for erase mode
|
||||
this.setPixel(x, y, isFillMode ? this.fillOpacity : 0, maskColor)
|
||||
|
||||
// Check neighbors
|
||||
const checkNeighbor = (nx: number, ny: number) => {
|
||||
if (nx < 0 || nx >= this.width! || ny < 0 || ny >= this.height!) return
|
||||
if (!visited[ny * this.width! + nx]) {
|
||||
const alpha = this.getPixel(nx, ny)
|
||||
if (
|
||||
this.shouldProcessPixel(
|
||||
alpha,
|
||||
targetAlpha,
|
||||
this.tolerance,
|
||||
isFillMode
|
||||
)
|
||||
) {
|
||||
stack.push([nx, ny])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkNeighbor(x - 1, y) // Left
|
||||
checkNeighbor(x + 1, y) // Right
|
||||
checkNeighbor(x, y - 1) // Up
|
||||
checkNeighbor(x, y + 1) // Down
|
||||
}
|
||||
|
||||
this.ctx.putImageData(this.imageData, 0, 0)
|
||||
this.imageData = null
|
||||
this.data = null
|
||||
}
|
||||
|
||||
setTolerance(tolerance: number): void {
|
||||
this.tolerance = tolerance
|
||||
}
|
||||
|
||||
getTolerance(): number {
|
||||
return this.tolerance
|
||||
}
|
||||
|
||||
//invert mask
|
||||
|
||||
private invertMask() {
|
||||
const imageData = this.ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
this.canvas.width,
|
||||
this.canvas.height
|
||||
)
|
||||
const data = imageData.data
|
||||
|
||||
// Find first non-transparent pixel to get mask color
|
||||
let maskR = 0,
|
||||
maskG = 0,
|
||||
maskB = 0
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i + 3] > 0) {
|
||||
maskR = data[i]
|
||||
maskG = data[i + 1]
|
||||
maskB = data[i + 2]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Process each pixel
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const alpha = data[i + 3]
|
||||
// Invert alpha channel (0 becomes 255, 255 becomes 0)
|
||||
data[i + 3] = 255 - alpha
|
||||
|
||||
// If this was originally transparent (now opaque), fill with mask color
|
||||
if (alpha === 0) {
|
||||
data[i] = maskR
|
||||
data[i + 1] = maskG
|
||||
data[i + 2] = maskB
|
||||
}
|
||||
}
|
||||
|
||||
this.ctx.putImageData(imageData, 0, 0)
|
||||
this.messageBroker.publish('saveState')
|
||||
}
|
||||
}
|
||||
|
||||
export { PaintBucketTool }
|
||||
@@ -1,3 +0,0 @@
|
||||
export { PaintBucketTool } from './PaintBucketTool'
|
||||
export { ColorSelectTool } from './ColorSelectTool'
|
||||
export { BrushTool } from './BrushTool'
|
||||
@@ -19,7 +19,8 @@ export const allTools = [
|
||||
Tools.MaskColorFill
|
||||
]
|
||||
|
||||
export const allImageLayers = ['mask', 'rgb'] as const
|
||||
const allImageLayers = ['mask', 'rgb'] as const
|
||||
|
||||
export type ImageLayer = (typeof allImageLayers)[number]
|
||||
|
||||
export interface ToolInternalSettings {
|
||||
@@ -62,14 +63,3 @@ export interface Brush {
|
||||
hardness: number
|
||||
smoothingPrecision: number
|
||||
}
|
||||
|
||||
export type Callback = (data?: any) => void
|
||||
|
||||
export type Ref = { filename: string; subfolder?: string; type?: string }
|
||||
|
||||
// Forward declaration for MaskEditorDialog
|
||||
export interface MaskEditorDialog {
|
||||
getMessageBroker(): any // Will be MessageBroker, but avoiding circular dependency
|
||||
save(): void
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { getStorageValue, setStorageValue } from '@/scripts/utils'
|
||||
import type { Brush } from '../types'
|
||||
|
||||
export const saveBrushToCache = debounce(function (
|
||||
key: string,
|
||||
brush: Brush
|
||||
): void {
|
||||
try {
|
||||
const brushString = JSON.stringify(brush)
|
||||
setStorageValue(key, brushString)
|
||||
} catch (error) {
|
||||
console.error('Failed to save brush to cache:', error)
|
||||
}
|
||||
}, 300)
|
||||
|
||||
export function loadBrushFromCache(key: string): Brush | null {
|
||||
try {
|
||||
const brushString = getStorageValue(key)
|
||||
if (brushString) {
|
||||
const brush = JSON.parse(brushString) as Brush
|
||||
console.log('Loaded brush from cache:', brush)
|
||||
return brush
|
||||
} else {
|
||||
console.log('No brush found in cache.')
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load brush from cache:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
export const getCanvas2dContext = (
|
||||
canvas: HTMLCanvasElement
|
||||
): CanvasRenderingContext2D => {
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
||||
// Safe with the way we use canvases
|
||||
if (!ctx) throw new Error('Failed to get 2D context from canvas')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export const createCanvasCopy = (
|
||||
canvas: HTMLCanvasElement
|
||||
): [HTMLCanvasElement, CanvasRenderingContext2D] => {
|
||||
const newCanvas = document.createElement('canvas')
|
||||
const newCanvasCtx = getCanvas2dContext(newCanvas)
|
||||
newCanvas.width = canvas.width
|
||||
newCanvas.height = canvas.height
|
||||
newCanvasCtx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
newCanvasCtx.drawImage(
|
||||
canvas,
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height
|
||||
)
|
||||
return [newCanvas, newCanvasCtx]
|
||||
}
|
||||
|
||||
export const combineOriginalImageAndPaint = (
|
||||
canvases: Record<'originalImage' | 'paint', HTMLCanvasElement>
|
||||
): [HTMLCanvasElement, CanvasRenderingContext2D] => {
|
||||
const { originalImage, paint } = canvases
|
||||
const [resultCanvas, resultCanvasCtx] = createCanvasCopy(originalImage)
|
||||
resultCanvasCtx.drawImage(paint, 0, 0)
|
||||
return [resultCanvas, resultCanvasCtx]
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { ComfyApp } from '@/scripts/app'
|
||||
import type { Ref } from '../types'
|
||||
|
||||
/**
|
||||
* Note: the images' positions are important here. What the positions mean is hardcoded in `src/scripts/app.ts` in the `copyToClipspace` method.
|
||||
* - `newMainOutput` should be the fully composited image: base image + mask (in the alpha channel) + paint.
|
||||
* - The first array element of `extraImagesShownButNotOutputted` should be JUST the paint layer, with a transparent background.
|
||||
* - It is possible to add more images in the clipspace array, but is not useful currently.
|
||||
* With this configuration, the MaskEditor will properly load the paint layer separately from the base image, ensuring it is editable.
|
||||
* */
|
||||
export const replaceClipspaceImages = (
|
||||
newMainOutput: Ref,
|
||||
otherImagesInClipspace?: Ref[]
|
||||
) => {
|
||||
try {
|
||||
if (!ComfyApp?.clipspace?.widgets?.length) return
|
||||
const firstImageWidgetIndex = ComfyApp.clipspace.widgets.findIndex(
|
||||
(obj) => obj?.name === 'image'
|
||||
)
|
||||
const firstImageWidget = ComfyApp.clipspace.widgets[firstImageWidgetIndex]
|
||||
if (!firstImageWidget) return
|
||||
|
||||
ComfyApp!.clipspace!.widgets![firstImageWidgetIndex].value = newMainOutput
|
||||
|
||||
otherImagesInClipspace?.forEach((extraImage, extraImageIndex) => {
|
||||
const extraImageWidgetIndex = firstImageWidgetIndex + extraImageIndex + 1
|
||||
ComfyApp!.clipspace!.widgets![extraImageWidgetIndex].value = extraImage
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to set widget value:', err)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { Ref } from '../types'
|
||||
|
||||
export const ensureImageFullyLoaded = (src: string) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const maskImage = new Image()
|
||||
maskImage.src = src
|
||||
maskImage.onload = () => resolve()
|
||||
maskImage.onerror = reject
|
||||
})
|
||||
|
||||
const isAlphaValue = (index: number) => index % 4 === 3
|
||||
|
||||
export const removeImageRgbValuesAndInvertAlpha = (
|
||||
imageData: Uint8ClampedArray
|
||||
) => imageData.map((val, i) => (isAlphaValue(i) ? 255 - val : 0))
|
||||
|
||||
export const toRef = (filename: string): Ref => ({
|
||||
filename,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
export const mkFileUrl = (props: { ref: Ref; preview?: boolean }) => {
|
||||
const pathPlusQueryParams = api.apiURL(
|
||||
'/view?' +
|
||||
new URLSearchParams(props.ref).toString() +
|
||||
app.getPreviewFormatParam() +
|
||||
app.getRandParam()
|
||||
)
|
||||
const imageElement = new Image()
|
||||
imageElement.src = pathPlusQueryParams
|
||||
return imageElement.src
|
||||
}
|
||||
|
||||
export const requestWithRetries = async (
|
||||
mkRequest: () => Promise<Response>,
|
||||
maxRetries: number = 3
|
||||
): Promise<{ success: boolean }> => {
|
||||
let attempt = 0
|
||||
let success = false
|
||||
while (attempt < maxRetries && !success) {
|
||||
try {
|
||||
const response = await mkRequest()
|
||||
if (response.ok) {
|
||||
success = true
|
||||
} else {
|
||||
console.log('Failed to upload mask:', response)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Upload attempt ${attempt + 1} failed:`, error)
|
||||
attempt++
|
||||
if (attempt < maxRetries) {
|
||||
console.log('Retrying upload...')
|
||||
} else {
|
||||
console.log('Max retries reached. Upload failed.')
|
||||
}
|
||||
}
|
||||
}
|
||||
return { success }
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
export {
|
||||
toRef,
|
||||
mkFileUrl,
|
||||
ensureImageFullyLoaded,
|
||||
removeImageRgbValuesAndInvertAlpha,
|
||||
requestWithRetries
|
||||
} from './image'
|
||||
export { imageLayerFilenamesIfApplicable } from './maskEditorLayerFilenames'
|
||||
export {
|
||||
getCanvas2dContext,
|
||||
createCanvasCopy,
|
||||
combineOriginalImageAndPaint
|
||||
} from './canvas'
|
||||
export { replaceClipspaceImages } from './clipspace'
|
||||
@@ -1,29 +0,0 @@
|
||||
interface ImageLayerFilenames {
|
||||
maskedImage: string
|
||||
paint: string
|
||||
paintedImage: string
|
||||
paintedMaskedImage: string
|
||||
}
|
||||
|
||||
const paintedMaskedImagePrefix = 'clipspace-painted-masked-'
|
||||
|
||||
export const imageLayerFilenamesByTimestamp = (
|
||||
timestamp: number
|
||||
): ImageLayerFilenames => ({
|
||||
maskedImage: `clipspace-mask-${timestamp}.png`,
|
||||
paint: `clipspace-paint-${timestamp}.png`,
|
||||
paintedImage: `clipspace-painted-${timestamp}.png`,
|
||||
paintedMaskedImage: `${paintedMaskedImagePrefix}${timestamp}.png`
|
||||
})
|
||||
|
||||
export const imageLayerFilenamesIfApplicable = (
|
||||
inputImageFilename: string
|
||||
): ImageLayerFilenames | undefined => {
|
||||
const isPaintedMaskedImageFilename = inputImageFilename.startsWith(
|
||||
paintedMaskedImagePrefix
|
||||
)
|
||||
if (!isPaintedMaskedImageFilename) return undefined
|
||||
const suffix = inputImageFilename.slice(paintedMaskedImagePrefix.length)
|
||||
const timestamp = parseInt(suffix.split('.')[0], 10)
|
||||
return imageLayerFilenamesByTimestamp(timestamp)
|
||||
}
|
||||
@@ -81,7 +81,7 @@ useExtensionService().registerExtension({
|
||||
|
||||
modelWidget.value = filePath
|
||||
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
|
||||
config.configureForSaveMesh(fileInfo['type'], filePath)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ import type {
|
||||
ISlotType,
|
||||
LinkNetwork,
|
||||
LinkSegment,
|
||||
NewNodePosition,
|
||||
NullableProperties,
|
||||
Point,
|
||||
Positionable,
|
||||
@@ -1011,7 +1012,8 @@ export class LGraphCanvas
|
||||
direction: Direction,
|
||||
align_to?: LGraphNode
|
||||
): void {
|
||||
alignNodes(Object.values(nodes), direction, align_to)
|
||||
const newPositions = alignNodes(Object.values(nodes), direction, align_to)
|
||||
LGraphCanvas.active_canvas.repositionNodesVueMode(newPositions)
|
||||
LGraphCanvas.active_canvas.setDirty(true, true)
|
||||
}
|
||||
|
||||
@@ -1031,11 +1033,12 @@ export class LGraphCanvas
|
||||
})
|
||||
|
||||
function inner_clicked(value: string) {
|
||||
alignNodes(
|
||||
const newPositions = alignNodes(
|
||||
Object.values(LGraphCanvas.active_canvas.selected_nodes),
|
||||
value.toLowerCase() as Direction,
|
||||
node
|
||||
)
|
||||
LGraphCanvas.active_canvas.repositionNodesVueMode(newPositions)
|
||||
LGraphCanvas.active_canvas.setDirty(true, true)
|
||||
}
|
||||
}
|
||||
@@ -1055,10 +1058,11 @@ export class LGraphCanvas
|
||||
})
|
||||
|
||||
function inner_clicked(value: string) {
|
||||
alignNodes(
|
||||
const newPositions = alignNodes(
|
||||
Object.values(LGraphCanvas.active_canvas.selected_nodes),
|
||||
value.toLowerCase() as Direction
|
||||
)
|
||||
LGraphCanvas.active_canvas.repositionNodesVueMode(newPositions)
|
||||
LGraphCanvas.active_canvas.setDirty(true, true)
|
||||
}
|
||||
}
|
||||
@@ -1079,10 +1083,11 @@ export class LGraphCanvas
|
||||
|
||||
function inner_clicked(value: string) {
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
distributeNodes(
|
||||
const newPositions = distributeNodes(
|
||||
Object.values(canvas.selected_nodes),
|
||||
value === 'Horizontally'
|
||||
)
|
||||
canvas.repositionNodesVueMode(newPositions)
|
||||
canvas.setDirty(true, true)
|
||||
}
|
||||
}
|
||||
@@ -8557,10 +8562,7 @@ export class LGraphCanvas
|
||||
) {
|
||||
const mutations = this.initLayoutMutations()
|
||||
const nodesInMovingGroups = this.collectNodesInGroups(allItems)
|
||||
const nodesToMove: Array<{
|
||||
node: LGraphNode
|
||||
newPos: { x: number; y: number }
|
||||
}> = []
|
||||
const nodesToMove: NewNodePosition[] = []
|
||||
|
||||
// First, collect all the moves we need to make
|
||||
for (const item of allItems) {
|
||||
@@ -8586,4 +8588,9 @@ export class LGraphCanvas
|
||||
// Now apply all the node moves at once
|
||||
this.applyNodePositionUpdates(nodesToMove, mutations)
|
||||
}
|
||||
|
||||
repositionNodesVueMode(nodesToReposition: NewNodePosition[]) {
|
||||
const mutations = this.initLayoutMutations()
|
||||
this.applyNodePositionUpdates(nodesToReposition, mutations)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +254,13 @@ type KeysOfType<T, Match> = Exclude<
|
||||
|
||||
/** The names of all (optional) methods and functions in T */
|
||||
export type MethodNames<T> = KeysOfType<T, ((...args: any) => any) | undefined>
|
||||
|
||||
export interface NewNodePosition {
|
||||
node: LGraphNode
|
||||
newPos: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
}
|
||||
export interface IBoundaryNodes {
|
||||
top: LGraphNode
|
||||
right: LGraphNode
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LGraphNode } from '../LGraphNode'
|
||||
import type { Direction, IBoundaryNodes } from '../interfaces'
|
||||
import type { Direction, IBoundaryNodes, NewNodePosition } from '../interfaces'
|
||||
|
||||
/**
|
||||
* Finds the nodes that are farthest in all four directions, representing the boundary of the nodes.
|
||||
@@ -43,9 +43,9 @@ export function getBoundaryNodes(nodes: LGraphNode[]): IBoundaryNodes | null {
|
||||
export function distributeNodes(
|
||||
nodes: LGraphNode[],
|
||||
horizontal?: boolean
|
||||
): void {
|
||||
): NewNodePosition[] {
|
||||
const nodeCount = nodes?.length
|
||||
if (!(nodeCount > 1)) return
|
||||
if (!(nodeCount > 1)) return []
|
||||
|
||||
const index = horizontal ? 0 : 1
|
||||
|
||||
@@ -68,6 +68,16 @@ export function distributeNodes(
|
||||
node.pos[index] = startAt + gap * i
|
||||
startAt += node.size[index]
|
||||
}
|
||||
const newPositions = sorted.map(
|
||||
(node): NewNodePosition => ({
|
||||
node,
|
||||
newPos: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1]
|
||||
}
|
||||
})
|
||||
)
|
||||
return newPositions
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,32 +90,56 @@ export function alignNodes(
|
||||
nodes: LGraphNode[],
|
||||
direction: Direction,
|
||||
align_to?: LGraphNode
|
||||
): void {
|
||||
if (!nodes) return
|
||||
): NewNodePosition[] {
|
||||
if (!nodes) return []
|
||||
|
||||
const boundary =
|
||||
align_to === undefined
|
||||
? getBoundaryNodes(nodes)
|
||||
: { top: align_to, right: align_to, bottom: align_to, left: align_to }
|
||||
|
||||
if (boundary === null) return
|
||||
if (boundary === null) return []
|
||||
|
||||
for (const node of nodes) {
|
||||
const nodePositions = nodes.map((node): NewNodePosition => {
|
||||
switch (direction) {
|
||||
case 'right':
|
||||
node.pos[0] =
|
||||
boundary.right.pos[0] + boundary.right.size[0] - node.size[0]
|
||||
break
|
||||
return {
|
||||
node,
|
||||
newPos: {
|
||||
x: boundary.right.pos[0] + boundary.right.size[0] - node.size[0],
|
||||
y: node.pos[1]
|
||||
}
|
||||
}
|
||||
case 'left':
|
||||
node.pos[0] = boundary.left.pos[0]
|
||||
break
|
||||
return {
|
||||
node,
|
||||
newPos: {
|
||||
x: boundary.left.pos[0],
|
||||
y: node.pos[1]
|
||||
}
|
||||
}
|
||||
case 'top':
|
||||
node.pos[1] = boundary.top.pos[1]
|
||||
break
|
||||
return {
|
||||
node,
|
||||
newPos: {
|
||||
x: node.pos[0],
|
||||
y: boundary.top.pos[1]
|
||||
}
|
||||
}
|
||||
case 'bottom':
|
||||
node.pos[1] =
|
||||
boundary.bottom.pos[1] + boundary.bottom.size[1] - node.size[1]
|
||||
break
|
||||
return {
|
||||
node,
|
||||
newPos: {
|
||||
x: node.pos[0],
|
||||
y: boundary.bottom.pos[1] + boundary.bottom.size[1] - node.size[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for (const { node, newPos } of nodePositions) {
|
||||
node.pos[0] = newPos.x
|
||||
node.pos[1] = newPos.y
|
||||
}
|
||||
return nodePositions
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@
|
||||
"lastUpdated": "Last Updated",
|
||||
"noDescription": "No description available",
|
||||
"installSelected": "Install Selected",
|
||||
"installAllMissingNodes": "Install All Missing Nodes",
|
||||
"installAllMissingNodes": "Install All",
|
||||
"allMissingNodesInstalled": "All missing nodes have been successfully installed",
|
||||
"packsSelected": "packs selected",
|
||||
"mixedSelectionMessage": "Cannot perform bulk action on mixed selection",
|
||||
@@ -618,8 +618,19 @@
|
||||
"workflows": "Workflows",
|
||||
"templates": "Templates",
|
||||
"assets": "Assets",
|
||||
"mediaAssets": "Media Assets",
|
||||
"mediaAssets": {
|
||||
"title": "Media Assets",
|
||||
"sortNewestFirst": "Newest first",
|
||||
"sortOldestFirst": "Oldest first",
|
||||
"sortLongestFirst": "Generation time (longest first)",
|
||||
"sortFastestFirst": "Generation time (fastest first)",
|
||||
"filterImage": "Image",
|
||||
"filterVideo": "Video",
|
||||
"filterAudio": "Audio",
|
||||
"filter3D": "3D"
|
||||
},
|
||||
"backToAssets": "Back to all assets",
|
||||
"searchAssets": "Search assets...",
|
||||
"labels": {
|
||||
"queue": "Queue",
|
||||
"nodes": "Nodes",
|
||||
@@ -873,29 +884,42 @@
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
"maskEditor": {
|
||||
"Invert": "Invert",
|
||||
"Clear": "Clear",
|
||||
"Brush Settings": "Brush Settings",
|
||||
"Brush Shape": "Brush Shape",
|
||||
"Thickness": "Thickness",
|
||||
"Opacity": "Opacity",
|
||||
"Hardness": "Hardness",
|
||||
"Smoothing Precision": "Smoothing Precision",
|
||||
"Reset to Default": "Reset to Default",
|
||||
"Paint Bucket Settings": "Paint Bucket Settings",
|
||||
"Tolerance": "Tolerance",
|
||||
"Fill Opacity": "Fill Opacity",
|
||||
"Color Select Settings": "Color Select Settings",
|
||||
"Selection Opacity": "Selection Opacity",
|
||||
"Live Preview": "Live Preview",
|
||||
"Apply to Whole Image": "Apply to Whole Image",
|
||||
"Method": "Method",
|
||||
"Stop at mask": "Stop at mask",
|
||||
"Mask Tolerance": "Mask Tolerance",
|
||||
"Layers": "Layers",
|
||||
"Mask Layer": "Mask Layer",
|
||||
"Mask Opacity": "Mask Opacity",
|
||||
"Image Layer": "Image Layer"
|
||||
"title": "Mask Editor",
|
||||
"invert": "Invert",
|
||||
"clear": "Clear",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"clickToResetZoom": "Click to reset zoom",
|
||||
"brushSettings": "Brush Settings",
|
||||
"brushShape": "Brush Shape",
|
||||
"colorSelector": "Color Selector",
|
||||
"thickness": "Thickness",
|
||||
"opacity": "Opacity",
|
||||
"hardness": "Hardness",
|
||||
"smoothingPrecision": "Smoothing Precision",
|
||||
"resetToDefault": "Reset to Default",
|
||||
"paintBucketSettings": "Paint Bucket Settings",
|
||||
"tolerance": "Tolerance",
|
||||
"fillOpacity": "Fill Opacity",
|
||||
"colorSelectSettings": "Color Select Settings",
|
||||
"selectionOpacity": "Selection Opacity",
|
||||
"livePreview": "Live Preview",
|
||||
"applyToWholeImage": "Apply to Whole Image",
|
||||
"method": "Method",
|
||||
"stopAtMask": "Stop at mask",
|
||||
"maskTolerance": "Mask Tolerance",
|
||||
"layers": "Layers",
|
||||
"maskLayer": "Mask Layer",
|
||||
"maskOpacity": "Mask Opacity",
|
||||
"imageLayer": "Image Layer",
|
||||
"maskBlendingOptions": "Mask Blending Options",
|
||||
"paintLayer": "Paint Layer",
|
||||
"baseImageLayer": "Base Image Layer",
|
||||
"activateLayer": "Activate Layer",
|
||||
"baseLayerPreview": "Base layer preview",
|
||||
"black": "Black",
|
||||
"white": "White",
|
||||
"negative": "Negative"
|
||||
},
|
||||
"commands": {
|
||||
"runWorkflow": "Run workflow",
|
||||
@@ -1399,13 +1423,6 @@
|
||||
"missingModels": "Missing Models",
|
||||
"missingModelsMessage": "When loading the graph, the following models were not found"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"missingNodesTitle": "Some Nodes Are Missing",
|
||||
"missingNodesDescription": "When loading the graph, the following node types were not found.\nThis may also happen if your installed version is lower and that node type can’t be found.",
|
||||
"outdatedVersion": "Some nodes require a newer version of ComfyUI (current: {version}). Please update to use all nodes.",
|
||||
"outdatedVersionGeneric": "Some nodes require a newer version of ComfyUI. Please update to use all nodes.",
|
||||
"coreNodesFromVersion": "Requires ComfyUI {version}:"
|
||||
},
|
||||
"versionMismatchWarning": {
|
||||
"title": "Version Compatibility Warning",
|
||||
"frontendOutdated": "Frontend version {frontendVersion} is outdated. Backend requires version {requiredVersion} or higher.",
|
||||
@@ -1984,37 +2001,12 @@
|
||||
"noModelsInFolder": "No {type} available in this folder",
|
||||
"searchAssetsPlaceholder": "Type to search...",
|
||||
"uploadModel": "Upload model",
|
||||
"uploadModelFromCivitai": "Upload a model from Civitai",
|
||||
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
|
||||
"uploadModelDescription2": "Only links from https://civitai.com are supported at the moment",
|
||||
"uploadModelDescription3": "Max file size: 1 GB",
|
||||
"civitaiLinkLabel": "Civitai model download link",
|
||||
"civitaiLinkPlaceholder": "Paste link here",
|
||||
"civitaiLinkExample": "Example: https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor",
|
||||
"confirmModelDetails": "Confirm Model Details",
|
||||
"fileName": "File Name",
|
||||
"fileSize": "File Size",
|
||||
"modelName": "Model Name",
|
||||
"modelNamePlaceholder": "Enter a name for this model",
|
||||
"tags": "Tags",
|
||||
"tagsPlaceholder": "e.g., models, checkpoint",
|
||||
"tagsHelp": "Separate tags with commas",
|
||||
"upload": "Upload",
|
||||
"uploadingModel": "Uploading model...",
|
||||
"uploadSuccess": "Model uploaded successfully!",
|
||||
"uploadFailed": "Upload failed",
|
||||
"modelAssociatedWithLink": "The model associated with the link you provided:",
|
||||
"whatTypeOfModel": "What type of model is this?",
|
||||
"selectModelType": "Select model type",
|
||||
"notSureLeaveAsIs": "Not sure? Just leave this as is",
|
||||
"modelUploaded": "Model uploaded!",
|
||||
"findInLibrary": "Find it in the {type} section of the models library.",
|
||||
"finish": "Finish",
|
||||
"allModels": "All Models",
|
||||
"allCategory": "All {category}",
|
||||
"unknown": "Unknown",
|
||||
"fileFormats": "File formats",
|
||||
"baseModels": "Base models",
|
||||
"filterBy": "Filter by",
|
||||
"sortBy": "Sort by",
|
||||
"sortAZ": "A-Z",
|
||||
"sortZA": "Z-A",
|
||||
@@ -2052,7 +2044,8 @@
|
||||
"downloadsStarted": "Started downloading {count} file(s)",
|
||||
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
|
||||
"failedToDeleteAssets": "Failed to delete selected assets"
|
||||
}
|
||||
},
|
||||
"noJobIdFound": "No job ID found for this asset"
|
||||
},
|
||||
"actionbar": {
|
||||
"dockToTop": "Dock to top",
|
||||
@@ -2068,24 +2061,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"vueNodesMigration": {
|
||||
"message": "Prefer the classic node design?",
|
||||
"button": "Open Settings"
|
||||
},
|
||||
"vueNodesBanner": {
|
||||
"message": "Nodes just got a new look and feel",
|
||||
"message": "Introducing Nodes 2.0 – More flexible workflows, powerful new widgets, built for extensibility",
|
||||
"tryItOut": "Try it out"
|
||||
},
|
||||
"cloud": {
|
||||
"missingNodes": {
|
||||
"vueNodesMigration": {
|
||||
"message": "Prefer the legacy design?",
|
||||
"button": "Switch back"
|
||||
},
|
||||
"vueNodesMigrationMainMenu": {
|
||||
"message": "Switch back to Nodes 2.0 anytime from the main menu."
|
||||
},
|
||||
"missingNodes": {
|
||||
"cloud": {
|
||||
"title": "These nodes aren't available on Comfy Cloud yet",
|
||||
"description": "This workflow uses custom nodes that aren't supported in the Cloud version yet.",
|
||||
"priorityMessage": "We've automatically flagged these nodes so we can prioritize adding them.",
|
||||
"missingNodes": "Missing Nodes",
|
||||
"replacementInstruction": "In the meantime, replace these nodes (highlighted red on the canvas) with supported ones if possible, or try a different workflow.",
|
||||
"learnMore": "Learn more",
|
||||
"gotIt": "Ok, got it",
|
||||
"cannotRun": "Workflow contains unsupported nodes (highlighted red). Remove these to run the workflow. "
|
||||
"gotIt": "Ok, got it"
|
||||
},
|
||||
"oss": {
|
||||
"title": "This workflow has missing nodes",
|
||||
"description": "This workflow uses custom nodes you haven't installed yet.",
|
||||
"replacementInstruction": "Install these nodes to run this workflow, or replace them with installed alternatives. Missing nodes are highlighted in red on the canvas."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,14 +73,11 @@ import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
||||
import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vue'
|
||||
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
@@ -95,7 +92,6 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'asset-select': [asset: AssetDisplayItem]
|
||||
@@ -193,15 +189,6 @@ const { flags } = useFeatureFlags()
|
||||
const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled)
|
||||
|
||||
function handleUploadClick() {
|
||||
dialogStore.showDialog({
|
||||
key: 'upload-model',
|
||||
headerComponent: UploadModelDialogHeader,
|
||||
component: UploadModelDialog,
|
||||
props: {
|
||||
onUploadSuccess: async () => {
|
||||
await execute()
|
||||
}
|
||||
}
|
||||
})
|
||||
// Will be implemented in the future commit
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -248,7 +248,7 @@ provide(MediaAssetKey, {
|
||||
|
||||
const containerClasses = computed(() =>
|
||||
cn(
|
||||
'gap-1',
|
||||
'gap-1 select-none',
|
||||
selected
|
||||
? 'border-3 border-zinc-900 dark-theme:border-white bg-zinc-200 dark-theme:bg-zinc-700'
|
||||
: 'hover:bg-zinc-100 dark-theme:hover:bg-zinc-800'
|
||||
|
||||
74
src/platform/assets/components/MediaAssetFilterBar.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="flex gap-3 pt-2">
|
||||
<SearchBox
|
||||
:model-value="searchQuery"
|
||||
:placeholder="$t('sideToolbar.searchAssets')"
|
||||
size="lg"
|
||||
@update:model-value="handleSearchChange"
|
||||
/>
|
||||
<MediaAssetFilterButton
|
||||
v-if="isCloud"
|
||||
v-tooltip.top="{ value: $t('assetBrowser.filterBy') }"
|
||||
size="md"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<MediaAssetFilterMenu
|
||||
:media-type-filters="mediaTypeFilters"
|
||||
:close="close"
|
||||
@update:media-type-filters="handleMediaTypeFiltersChange"
|
||||
/>
|
||||
</template>
|
||||
</MediaAssetFilterButton>
|
||||
<AssetSortButton
|
||||
v-if="isCloud"
|
||||
v-tooltip.top="{ value: $t('assetBrowser.sortBy') }"
|
||||
size="md"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<MediaAssetSortMenu
|
||||
:sort-by="sortBy"
|
||||
:show-generation-time-sort
|
||||
:close="close"
|
||||
@update:sort-by="handleSortChange"
|
||||
/>
|
||||
</template>
|
||||
</AssetSortButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SearchBox from '@/components/input/SearchBox.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
import MediaAssetFilterButton from './MediaAssetFilterButton.vue'
|
||||
import MediaAssetFilterMenu from './MediaAssetFilterMenu.vue'
|
||||
import AssetSortButton from './MediaAssetSortButton.vue'
|
||||
import MediaAssetSortMenu from './MediaAssetSortMenu.vue'
|
||||
|
||||
const { showGenerationTimeSort = false } = defineProps<{
|
||||
searchQuery: string
|
||||
sortBy: 'newest' | 'oldest' | 'longest' | 'fastest'
|
||||
showGenerationTimeSort?: boolean
|
||||
mediaTypeFilters: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:searchQuery': [value: string]
|
||||
'update:sortBy': [value: 'newest' | 'oldest' | 'longest' | 'fastest']
|
||||
'update:mediaTypeFilters': [value: string[]]
|
||||
}>()
|
||||
|
||||
const handleSearchChange = (value: string | undefined) => {
|
||||
emit('update:searchQuery', value ?? '')
|
||||
}
|
||||
|
||||
const handleSortChange = (
|
||||
value: 'newest' | 'oldest' | 'longest' | 'fastest'
|
||||
) => {
|
||||
emit('update:sortBy', value)
|
||||
}
|
||||
|
||||
const handleMediaTypeFiltersChange = (value: string[]) => {
|
||||
emit('update:mediaTypeFilters', value)
|
||||
}
|
||||
</script>
|
||||