mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 19:21:54 +00:00
feat(ui): add copy button to read-only textarea widget on hover (#9331)
## Summary Added a `copy-to-clipboard` button that appears when hovering over read-only textarea widgets to improve user experience. ## Changes - **What**: Added a copy button utilizing `useCopyToClipboard` to [WidgetTextarea.vue](cci:7://file:///Users/kelly/Documents/comfyui/ComfyUI_frontend/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue:0:0-0:0) that only displays when the widget is read-only and hovered. ## Screenshots <img width="670" height="498" alt="e30362fdc6792f3a955f3415f0f42afb" src="https://github.com/user-attachments/assets/1b7ec5dc-3733-48b6-9708-6ae56926054a" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9331-feat-ui-add-copy-button-to-read-only-textarea-widget-on-hover-3176d73d36508159a339d567b5c33591) by [Unito](https://www.unito.io) --------- Co-authored-by: Terry Jia <terryjia88@gmail.com> Co-authored-by: Alexander Brown <DrJKL0424@gmail.com> Co-authored-by: Dante <bunggl@naver.com> Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
@@ -1,10 +1,18 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||||
|
|
||||||
import WidgetTextarea from './WidgetTextarea.vue'
|
import WidgetTextarea from './WidgetTextarea.vue'
|
||||||
|
|
||||||
|
const mockCopyToClipboard = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||||
|
useCopyToClipboard: vi.fn().mockReturnValue({
|
||||||
|
copyToClipboard: mockCopyToClipboard
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
function createMockWidget(
|
function createMockWidget(
|
||||||
value: string = 'default text',
|
value: string = 'default text',
|
||||||
options: SimplifiedWidget['options'] = {},
|
options: SimplifiedWidget['options'] = {},
|
||||||
@@ -31,6 +39,11 @@ function mountComponent(
|
|||||||
modelValue,
|
modelValue,
|
||||||
readonly,
|
readonly,
|
||||||
placeholder
|
placeholder
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
mocks: {
|
||||||
|
$t: (msg: string) => msg
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -190,6 +203,41 @@ describe('WidgetTextarea Value Binding', () => {
|
|||||||
expect(textarea.attributes('placeholder')).toBe('Custom placeholder')
|
expect(textarea.attributes('placeholder')).toBe('Custom placeholder')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
describe('Copy Button Behavior', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCopyToClipboard.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides copy button when not read-only', async () => {
|
||||||
|
const widget = createMockWidget('test')
|
||||||
|
const wrapper = mountComponent(widget, 'test', false)
|
||||||
|
|
||||||
|
const button = wrapper.find('button')
|
||||||
|
expect(button.exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copy button has invisible class by default when read-only', () => {
|
||||||
|
const widget = createMockWidget('test', { read_only: true })
|
||||||
|
const wrapper = mountComponent(widget, 'test', true)
|
||||||
|
|
||||||
|
const button = wrapper.find('button')
|
||||||
|
expect(button.exists()).toBe(true)
|
||||||
|
expect(button.classes()).toContain('invisible')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copy button has group-hover:visible class when read-only, and copies on click', async () => {
|
||||||
|
const widget = createMockWidget('test value', { read_only: true })
|
||||||
|
const wrapper = mountComponent(widget, 'test value', true)
|
||||||
|
|
||||||
|
const button = wrapper.find('button')
|
||||||
|
expect(button.exists()).toBe(true)
|
||||||
|
expect(button.classes()).toContain('group-hover:visible')
|
||||||
|
|
||||||
|
await button.trigger('click')
|
||||||
|
|
||||||
|
expect(mockCopyToClipboard).toHaveBeenCalledWith('test value')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('Edge Cases', () => {
|
describe('Edge Cases', () => {
|
||||||
it('handles very long text', async () => {
|
it('handles very long text', async () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div
|
<div
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'relative rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
|
'group relative rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
|
||||||
widget.borderStyle
|
widget.borderStyle
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
@@ -33,13 +33,27 @@
|
|||||||
@pointerup.capture.stop
|
@pointerup.capture.stop
|
||||||
@contextmenu.capture.stop
|
@contextmenu.capture.stop
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="isReadOnly"
|
||||||
|
variant="textonly"
|
||||||
|
size="icon"
|
||||||
|
class="invisible absolute top-1.5 right-1.5 z-10 hover:bg-base-foreground/10 group-hover:visible"
|
||||||
|
:title="$t('g.copyToClipboard')"
|
||||||
|
:aria-label="$t('g.copyToClipboard')"
|
||||||
|
@click="handleCopy"
|
||||||
|
@pointerdown.capture.stop
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--copy] size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, useId } from 'vue'
|
import { computed, useId } from 'vue'
|
||||||
|
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import Textarea from '@/components/ui/textarea/Textarea.vue'
|
import Textarea from '@/components/ui/textarea/Textarea.vue'
|
||||||
|
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||||
import { useHideLayoutField } from '@/types/widgetTypes'
|
import { useHideLayoutField } from '@/types/widgetTypes'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
@@ -58,6 +72,7 @@ const { widget, placeholder = '' } = defineProps<{
|
|||||||
const modelValue = defineModel<string>({ default: '' })
|
const modelValue = defineModel<string>({ default: '' })
|
||||||
|
|
||||||
const hideLayoutField = useHideLayoutField()
|
const hideLayoutField = useHideLayoutField()
|
||||||
|
const { copyToClipboard } = useCopyToClipboard()
|
||||||
|
|
||||||
const filteredProps = computed(() =>
|
const filteredProps = computed(() =>
|
||||||
filterWidgetProps(widget.options, INPUT_EXCLUDED_PROPS)
|
filterWidgetProps(widget.options, INPUT_EXCLUDED_PROPS)
|
||||||
@@ -69,4 +84,8 @@ const id = useId()
|
|||||||
const isReadOnly = computed(
|
const isReadOnly = computed(
|
||||||
() => widget.options?.read_only ?? widget.options?.disabled ?? false
|
() => widget.options?.read_only ?? widget.options?.disabled ?? false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function handleCopy() {
|
||||||
|
copyToClipboard(modelValue.value)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user