mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-13 00:50:01 +00:00
Mobile input tweaks (#9686)
- Buttons are marked as `touch-manipulation` so double-tapping on them doesn't initiate a zoom. - Move scrubable inputs to usePointerSwipe - Strangely, swipe direction was inverted on mobile. This solves the issue and simplifies code - Moves event handlers into the scrubbable input component - Make the slightly bigger buttons only apply when on mobile. - Updates the workflows dropdown to have a check by the activeWorkflow and truncate workflow names - Displays dropzones (for the image preview) on mobile, but disables the prompt to drag and drop an image if none is selected. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9686-Mobile-input-tweaks-31f6d73d3650811d9025d0cd1ac58534) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -54,11 +54,12 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
||||
:disabled="toValue(item.disabled) ?? !item.command"
|
||||
@select="item.command?.({ originalEvent: $event, item })"
|
||||
>
|
||||
<i class="size-5" :class="item.icon" />
|
||||
{{ item.label }}
|
||||
<i class="size-5 shrink-0" :class="item.icon" />
|
||||
<div class="mr-auto truncate" v-text="item.label" />
|
||||
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
|
||||
<div
|
||||
v-if="item.new"
|
||||
class="ml-auto flex items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
|
||||
v-else-if="item.new"
|
||||
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
|
||||
v-text="t('contextMenu.new')"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -27,7 +27,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
|
||||
const itemClass = computed(() =>
|
||||
cn(
|
||||
'm-1 flex cursor-pointer gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
|
||||
'm-1 flex cursor-pointer items-center-safe gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
|
||||
itemProp
|
||||
)
|
||||
)
|
||||
|
||||
@@ -33,19 +33,20 @@
|
||||
spellcheck="false"
|
||||
@blur="handleBlur"
|
||||
@keyup.enter="handleBlur"
|
||||
@dragstart.prevent
|
||||
@keydown.up.prevent="updateValueBy(step)"
|
||||
@keydown.down.prevent="updateValueBy(-step)"
|
||||
@keydown.page-up.prevent="updateValueBy(10 * step)"
|
||||
@keydown.page-down.prevent="updateValueBy(-10 * step)"
|
||||
/>
|
||||
<div
|
||||
ref="swipeElement"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 z-10 cursor-ew-resize',
|
||||
'absolute inset-0 z-10 cursor-ew-resize touch-pan-y',
|
||||
textEdit && 'pointer-events-none hidden'
|
||||
)
|
||||
"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointercancel="resetDrag"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
@@ -65,7 +66,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { onClickOutside, usePointerSwipe, whenever } from '@vueuse/core'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -73,8 +74,8 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
min,
|
||||
max,
|
||||
min = -Number.MAX_VALUE,
|
||||
max = Number.MAX_VALUE,
|
||||
step = 1,
|
||||
disabled = false,
|
||||
hideButtons = false,
|
||||
@@ -96,6 +97,7 @@ const modelValue = defineModel<number>({ default: 0 })
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const inputField = useTemplateRef<HTMLInputElement>('inputField')
|
||||
const swipeElement = useTemplateRef('swipeElement')
|
||||
const textEdit = ref(false)
|
||||
|
||||
onClickOutside(container, () => {
|
||||
@@ -103,21 +105,11 @@ onClickOutside(container, () => {
|
||||
})
|
||||
|
||||
function clamp(value: number): number {
|
||||
const lo = min ?? -Infinity
|
||||
const hi = max ?? Infinity
|
||||
return Math.min(hi, Math.max(lo, value))
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
const canDecrement = computed(
|
||||
() => modelValue.value > (min ?? -Infinity) && !disabled
|
||||
)
|
||||
const canIncrement = computed(
|
||||
() => modelValue.value < (max ?? Infinity) && !disabled
|
||||
)
|
||||
|
||||
const dragging = ref(false)
|
||||
const dragDelta = ref(0)
|
||||
const hasDragged = ref(false)
|
||||
const canDecrement = computed(() => modelValue.value > min && !disabled)
|
||||
const canIncrement = computed(() => modelValue.value < max && !disabled)
|
||||
|
||||
function handleBlur(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
@@ -135,41 +127,27 @@ function handleBlur(e: Event) {
|
||||
textEdit.value = false
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (disabled) return
|
||||
const target = e.target as HTMLElement
|
||||
target.setPointerCapture(e.pointerId)
|
||||
dragging.value = true
|
||||
dragDelta.value = 0
|
||||
hasDragged.value = false
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
dragDelta.value += e.movementX
|
||||
const steps = (dragDelta.value / 10) | 0
|
||||
if (steps === 0) return
|
||||
hasDragged.value = true
|
||||
const unclipped = modelValue.value + steps * step
|
||||
dragDelta.value %= 10
|
||||
modelValue.value = clamp(unclipped)
|
||||
}
|
||||
|
||||
let dragDelta = 0
|
||||
function handlePointerUp() {
|
||||
if (!dragging.value) return
|
||||
if (isSwiping.value) return
|
||||
|
||||
if (!hasDragged.value) {
|
||||
textEdit.value = true
|
||||
inputField.value?.focus()
|
||||
inputField.value?.select()
|
||||
}
|
||||
|
||||
resetDrag()
|
||||
textEdit.value = true
|
||||
inputField.value?.focus()
|
||||
inputField.value?.select()
|
||||
}
|
||||
|
||||
function resetDrag() {
|
||||
dragging.value = false
|
||||
dragDelta.value = 0
|
||||
const { distanceX, isSwiping } = usePointerSwipe(swipeElement, {
|
||||
onSwipeEnd: () => (dragDelta = 0)
|
||||
})
|
||||
|
||||
whenever(distanceX, () => {
|
||||
if (disabled) return
|
||||
const delta = ((distanceX.value - dragDelta) / 10) | 0
|
||||
dragDelta += delta * 10
|
||||
modelValue.value = clamp(modelValue.value - delta * step)
|
||||
})
|
||||
|
||||
function updateValueBy(delta: number) {
|
||||
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const buttonVariants = cva({
|
||||
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
|
||||
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer touch-manipulation whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
|
||||
variants: {
|
||||
variant: {
|
||||
secondary:
|
||||
|
||||
@@ -3181,6 +3181,7 @@
|
||||
"deleteAllAssets": "Delete all assets from this run",
|
||||
"hasCreditCost": "Requires additional credits",
|
||||
"viewGraph": "View node graph",
|
||||
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
|
||||
"welcome": {
|
||||
"title": "App Mode",
|
||||
"message": "A simplified view that hides the node graph so you can focus on creating.",
|
||||
|
||||
@@ -120,7 +120,7 @@ function getDropIndicator(node: LGraphNode) {
|
||||
return {
|
||||
iconClass: 'icon-[lucide--image]',
|
||||
imageUrl: buildImageUrl(),
|
||||
label: t('linearMode.dragAndDropImage'),
|
||||
label: props.mobile ? undefined : t('linearMode.dragAndDropImage'),
|
||||
onClick: () => node.widgets?.[1]?.callback?.(undefined)
|
||||
}
|
||||
}
|
||||
@@ -206,14 +206,14 @@ defineExpose({ runButtonClick })
|
||||
<DropZone
|
||||
:on-drag-over="nodeData.onDragOver"
|
||||
:on-drag-drop="nodeData.onDragDrop"
|
||||
:drop-indicator="mobile ? undefined : nodeData.dropIndicator"
|
||||
:drop-indicator="nodeData.dropIndicator"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
<NodeWidgets
|
||||
:node-data
|
||||
:class="
|
||||
cn(
|
||||
'gap-y-3 rounded-lg py-3 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 **:[.h-7]:h-10',
|
||||
'gap-y-3 rounded-lg py-3 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
|
||||
nodeData.hasErrors &&
|
||||
'ring-2 ring-node-stroke-error ring-inset'
|
||||
)
|
||||
@@ -325,4 +325,9 @@ defineExpose({ runButtonClick })
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="mobile"
|
||||
class="flex size-full items-center bg-base-background p-4 text-center"
|
||||
v-text="t('linearMode.mobileNoWorkflow')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -8,7 +8,6 @@ import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -76,13 +75,16 @@ function onClick(index: number) {
|
||||
}
|
||||
|
||||
const workflowsEntries = computed(() => {
|
||||
return workflowStore.openWorkflows.map((w) => ({
|
||||
label: w.filename,
|
||||
icon: w.activeState?.extra?.linearMode
|
||||
? 'icon-[lucide--panels-top-left] bg-primary-background'
|
||||
: undefined,
|
||||
command: () => workflowService.openWorkflow(w)
|
||||
}))
|
||||
return [
|
||||
...workflowStore.openWorkflows.map((w) => ({
|
||||
label: w.filename,
|
||||
icon: w.activeState?.extra?.linearMode
|
||||
? 'icon-[lucide--panels-top-left] bg-primary-background'
|
||||
: undefined,
|
||||
command: () => workflowService.openWorkflow(w),
|
||||
checked: workflowStore.activeWorkflow === w
|
||||
}))
|
||||
]
|
||||
})
|
||||
|
||||
const menuEntries = computed<MenuItem[]>(() => [
|
||||
@@ -157,9 +159,9 @@ const menuEntries = computed<MenuItem[]>(() => [
|
||||
class="flex h-16 w-full items-center gap-3 border-b border-border-subtle bg-base-background px-4 py-3"
|
||||
>
|
||||
<DropdownMenu :entries="menuEntries" />
|
||||
<Popover
|
||||
<DropdownMenu
|
||||
:entries="workflowsEntries"
|
||||
class="w-(--reka-popover-content-available-width)"
|
||||
class="max-h-[40vh] w-(--reka-dropdown-menu-content-available-width)"
|
||||
:collision-padding="20"
|
||||
>
|
||||
<template #button>
|
||||
@@ -179,7 +181,7 @@ const menuEntries = computed<MenuItem[]>(() => [
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</DropdownMenu>
|
||||
<CurrentUserButton v-if="isLoggedIn" :show-arrow="false" />
|
||||
</header>
|
||||
<div class="size-full rounded-b-4xl contain-content">
|
||||
|
||||
@@ -121,12 +121,6 @@ const buttonsDisabled = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
function updateValueBy(delta: number) {
|
||||
const max = filteredProps.value.max ?? Number.MAX_VALUE
|
||||
const min = filteredProps.value.min ?? -Number.MAX_VALUE
|
||||
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
|
||||
}
|
||||
|
||||
const buttonTooltip = computed(() => {
|
||||
if (buttonsDisabled.value) {
|
||||
return 'Increment/decrement disabled: value exceeds JavaScript precision limit (±2^53)'
|
||||
@@ -171,10 +165,6 @@ const inputAriaAttrs = computed(() => ({
|
||||
:parse-value="parseWidgetValue"
|
||||
:input-attrs="inputAriaAttrs"
|
||||
:class="cn(WidgetInputBaseClass, 'relative flex h-7 grow text-xs')"
|
||||
@keydown.up.prevent="updateValueBy(stepValue)"
|
||||
@keydown.down.prevent="updateValueBy(-stepValue)"
|
||||
@keydown.page-up.prevent="updateValueBy(10 * stepValue)"
|
||||
@keydown.page-down.prevent="updateValueBy(-10 * stepValue)"
|
||||
>
|
||||
<template #background>
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user