fix: reviews update

This commit is contained in:
Yourz
2026-02-21 10:44:38 +08:00
parent 2674897596
commit d75fd0671d
10 changed files with 265 additions and 248 deletions

View File

@@ -21,28 +21,4 @@
width: 100%;
}
@keyframes marquee-scroll {
0%,
20% {
transform: translateX(0);
}
80%,
100% {
transform: translateX(var(--_marquee-end, 0px));
}
}
.marquee-container {
container-type: inline-size;
mask-image: linear-gradient(to right, black 70%, transparent);
}
.essential-node-card:hover .marquee-container {
mask-image: none;
}
.essential-node-card:hover .marquee-text {
--_marquee-end: min(calc(-100% + 100cqw), 0px);
animation: marquee-scroll 3s linear infinite alternate;
}
}

View File

@@ -0,0 +1,24 @@
<template>
<div
class="overflow-hidden [container-type:inline-size] [mask-image:linear-gradient(to_right,black_70%,transparent)] motion-safe:group-hover:[mask-image:none]"
>
<span
class="whitespace-nowrap inline-block min-w-full text-center [--_marquee-end:min(calc(-100%+100cqw),0px)] motion-safe:group-hover:[animation:marquee-scroll_3s_linear_infinite_alternate]"
>
<slot />
</span>
</div>
</template>
<style>
@keyframes marquee-scroll {
0%,
20% {
transform: translateX(0);
}
80%,
100% {
transform: translateX(var(--_marquee-end));
}
}
</style>

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { splitTextAtWordBoundary } from './textTickerUtils'
describe('splitTextAtWordBoundary', () => {
it('returns full text when ratio >= 1 (fits in one line)', () => {
expect(splitTextAtWordBoundary('Load Checkpoint', 1)).toEqual([
'Load Checkpoint',
''
])
expect(splitTextAtWordBoundary('Load Checkpoint', 1.5)).toEqual([
'Load Checkpoint',
''
])
})
it('splits at last word boundary before estimated break', () => {
// "Load Checkpoint Loader" = 22 chars, ratio 0.5 → estimate at char 11
// lastIndexOf(' ', 11) → 15? No: "Load Checkpoint Loader"
// 0123456789...
// ' ' at index 4 and 15
// lastIndexOf(' ', 11) → 4
expect(splitTextAtWordBoundary('Load Checkpoint Loader', 0.5)).toEqual([
'Load',
'Checkpoint Loader'
])
})
it('splits longer text proportionally', () => {
// ratio 0.7 → estimate at char 15
// lastIndexOf(' ', 15) → 15 (the space between "Checkpoint" and "Loader")
expect(splitTextAtWordBoundary('Load Checkpoint Loader', 0.7)).toEqual([
'Load Checkpoint',
'Loader'
])
})
it('returns full text when no word boundary found', () => {
expect(splitTextAtWordBoundary('Superlongwordwithoutspaces', 0.5)).toEqual([
'Superlongwordwithoutspaces',
''
])
})
it('handles single word text', () => {
expect(splitTextAtWordBoundary('Checkpoint', 0.5)).toEqual([
'Checkpoint',
''
])
})
it('handles ratio near zero', () => {
expect(splitTextAtWordBoundary('Load Checkpoint Loader', 0.1)).toEqual([
'Load Checkpoint Loader',
''
])
})
})

View File

@@ -0,0 +1,66 @@
<template>
<div>
<!-- Hidden single-line measurement element for overflow detection -->
<div
ref="measureRef"
class="invisible absolute inset-x-0 top-0 overflow-hidden whitespace-nowrap pointer-events-none"
aria-hidden="true"
>
<slot />
</div>
<MarqueeLine v-if="!secondLine">
<slot />
</MarqueeLine>
<div v-else class="flex flex-col w-full">
<MarqueeLine>{{ firstLine }}</MarqueeLine>
<MarqueeLine>{{ secondLine }}</MarqueeLine>
</div>
</div>
</template>
<script setup lang="ts">
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
import { ref } from 'vue'
import MarqueeLine from './MarqueeLine.vue'
import { splitTextAtWordBoundary } from './textTickerUtils'
const measureRef = ref<HTMLElement | null>(null)
const firstLine = ref('')
const secondLine = ref('')
function splitLines() {
const el = measureRef.value
const text = el?.textContent?.trim()
if (!el || !text) {
firstLine.value = ''
secondLine.value = ''
return
}
const containerWidth = el.clientWidth
const textWidth = el.scrollWidth
if (textWidth <= containerWidth) {
firstLine.value = text
secondLine.value = ''
return
}
const [first, second] = splitTextAtWordBoundary(
text,
containerWidth / textWidth
)
firstLine.value = first
secondLine.value = second
}
useResizeObserver(measureRef, splitLines)
useMutationObserver(measureRef, splitLines, {
childList: true,
characterData: true,
subtree: true
})
</script>

View File

@@ -0,0 +1,10 @@
export function splitTextAtWordBoundary(
text: string,
ratio: number
): [string, string] {
if (ratio >= 1) return [text, '']
const estimate = Math.floor(text.length * ratio)
const breakIndex = text.lastIndexOf(' ', estimate)
if (breakIndex <= 0) return [text, '']
return [text.substring(0, breakIndex), text.substring(breakIndex + 1)]
}

View File

@@ -1,12 +1,10 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { TabId } from '@/types/nodeOrganizationTypes'
import NodeLibrarySidebarTabV2 from './NodeLibrarySidebarTabV2.vue'
vi.mock('@vueuse/core', async () => {
@@ -115,7 +113,7 @@ describe('NodeLibrarySidebarTabV2', () => {
const wrapper = mountComponent()
const triggers = wrapper.findAllComponents(TabsTrigger)
expect(triggers.length).toBe(3)
expect(triggers).toHaveLength(3)
})
it('should render search box', () => {
@@ -132,93 +130,3 @@ describe('NodeLibrarySidebarTabV2', () => {
expect(wrapper.find('[data-testid="custom-panel"]').exists()).toBe(false)
})
})
describe('NodeLibrarySidebarTabV2 expandedKeys logic', () => {
describe('per-tab expandedKeys', () => {
function createExpandedKeysState(initialTab: TabId = 'essentials') {
const selectedTab = ref<TabId>(initialTab)
const expandedKeysByTab = ref<Record<TabId, string[]>>({
essentials: [],
all: [],
custom: []
})
const expandedKeys = computed({
get: () => expandedKeysByTab.value[selectedTab.value],
set: (value) => {
expandedKeysByTab.value[selectedTab.value] = value
}
})
return { selectedTab, expandedKeysByTab, expandedKeys }
}
it('should initialize with empty arrays for all tabs', () => {
const { expandedKeysByTab } = createExpandedKeysState()
expect(expandedKeysByTab.value.essentials).toEqual([])
expect(expandedKeysByTab.value.all).toEqual([])
expect(expandedKeysByTab.value.custom).toEqual([])
})
it('should return keys for the current tab', () => {
const { selectedTab, expandedKeysByTab, expandedKeys } =
createExpandedKeysState('essentials')
expandedKeysByTab.value.essentials = ['key1', 'key2']
expandedKeysByTab.value.all = ['key3']
expect(expandedKeys.value).toEqual(['key1', 'key2'])
selectedTab.value = 'all'
expect(expandedKeys.value).toEqual(['key3'])
})
it('should set keys only for the current tab', () => {
const { expandedKeysByTab, expandedKeys } =
createExpandedKeysState('essentials')
expandedKeys.value = ['new-key1', 'new-key2']
expect(expandedKeysByTab.value.essentials).toEqual([
'new-key1',
'new-key2'
])
expect(expandedKeysByTab.value.all).toEqual([])
expect(expandedKeysByTab.value.custom).toEqual([])
})
it('should preserve keys when switching tabs', () => {
const { selectedTab, expandedKeysByTab, expandedKeys } =
createExpandedKeysState('essentials')
expandedKeys.value = ['essentials-key']
selectedTab.value = 'all'
expandedKeys.value = ['all-key']
selectedTab.value = 'custom'
expandedKeys.value = ['custom-key']
expect(expandedKeysByTab.value.essentials).toEqual(['essentials-key'])
expect(expandedKeysByTab.value.all).toEqual(['all-key'])
expect(expandedKeysByTab.value.custom).toEqual(['custom-key'])
selectedTab.value = 'essentials'
expect(expandedKeys.value).toEqual(['essentials-key'])
})
it('should not share keys between tabs', () => {
const { selectedTab, expandedKeys } =
createExpandedKeysState('essentials')
expandedKeys.value = ['shared-key']
selectedTab.value = 'all'
expect(expandedKeys.value).toEqual([])
selectedTab.value = 'custom'
expect(expandedKeys.value).toEqual([])
selectedTab.value = 'essentials'
expect(expandedKeys.value).toEqual(['shared-key'])
})
})
})

View File

@@ -114,6 +114,7 @@ import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBoxV2.vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import { usePerTabState } from '@/composables/usePerTabState'
import {
DEFAULT_SORTING_ID,
DEFAULT_TAB_ID,
@@ -148,15 +149,7 @@ const sortOrderByTab = useLocalStorage<Record<TabId, SortingStrategyId>>(
custom: 'alphabetical'
}
)
const sortOrder = computed({
get: () => sortOrderByTab.value[selectedTab.value],
set: (value) => {
sortOrderByTab.value = {
...sortOrderByTab.value,
[selectedTab.value]: value
}
}
})
const sortOrder = usePerTabState(selectedTab, sortOrderByTab)
const sortingOptions = computed(() =>
nodeOrganizationService.getSortingStrategies().map((strategy) => ({
@@ -174,12 +167,7 @@ const expandedKeysByTab = ref<Record<TabId, string[]>>({
all: [],
custom: []
})
const expandedKeys = computed({
get: () => expandedKeysByTab.value[selectedTab.value],
set: (value) => {
expandedKeysByTab.value[selectedTab.value] = value
}
})
const expandedKeys = usePerTabState(selectedTab, expandedKeysByTab)
const nodeDefStore = useNodeDefStore()
const { startDrag } = useNodeDragToCanvas()

View File

@@ -1,13 +1,7 @@
<template>
<div
:class="
cn(
'essential-node-card relative flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content',
'bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border',
'aspect-square'
)
"
:data-node-name="nodeDef?.display_name"
class="group relative flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border aspect-square"
:data-node-name="node.data?.display_name"
draggable="true"
@click="handleClick"
@dragstart="handleDragStart"
@@ -19,40 +13,11 @@
<i :class="cn(nodeIcon, 'size-14 text-muted-foreground')" />
</div>
<!-- Hidden measurement span for line-break detection -->
<span
ref="measureRef"
class="invisible absolute inset-x-0 top-0 pointer-events-none px-2 text-xs font-bold leading-4"
aria-hidden="true"
<TextTickerMultiLine
class="shrink-0 h-8 w-full text-xs font-bold text-foreground leading-4"
>
{{ nodeDef?.display_name }}
</span>
<!-- Single line (text fits without wrapping) -->
<span
v-if="!secondLine"
class="shrink-0 h-8 flex items-center text-xs font-bold text-center text-foreground leading-3"
>
{{ nodeDef?.display_name }}
</span>
<!-- Two lines: static first line + marquee second line -->
<div v-else class="shrink-0 h-9 w-full flex flex-col justify-center">
<div class="marquee-container w-full overflow-hidden h-[18px]">
<span
class="marquee-text inline-block whitespace-nowrap text-xs font-bold text-foreground leading-3 min-w-full text-center"
>
{{ firstLine }}
</span>
</div>
<div class="marquee-container w-full overflow-hidden h-[18px]">
<span
class="marquee-text inline-block whitespace-nowrap text-xs font-bold text-foreground leading-3 min-w-full text-center"
>
{{ secondLine }}
</span>
</div>
</div>
{{ node.data?.display_name }}
</TextTickerMultiLine>
</div>
<Teleport v-if="showPreview" to="body">
@@ -60,16 +25,19 @@
:ref="(el) => (previewRef = el as HTMLElement)"
:style="nodePreviewStyle"
>
<NodePreviewCard :node-def="nodeDef!" :show-inputs-and-outputs="false" />
<NodePreviewCard
:node-def="node.data!"
:show-inputs-and-outputs="false"
/>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useResizeObserver } from '@vueuse/core'
import { kebabCase } from 'es-toolkit/string'
import { computed, inject, nextTick, ref, watch } from 'vue'
import { computed, inject } from 'vue'
import TextTickerMultiLine from '@/components/common/TextTickerMultiLine.vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { SidebarContainerKey } from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
@@ -85,9 +53,8 @@ const emit = defineEmits<{
click: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
}>()
const nodeDef = computed(() => node.data)
const panelRef = inject(SidebarContainerKey, undefined)
const nodeDef = computed(() => node.data)
const {
previewRef,
@@ -100,80 +67,13 @@ const {
} = useNodePreviewAndDrag(nodeDef, { panelRef })
const nodeIcon = computed(() => {
const nodeName = nodeDef.value?.name
const nodeName = node.data?.name
const iconName = nodeName ? kebabCase(nodeName) : 'node'
return `icon-[comfy--${iconName}]`
})
function handleClick() {
if (!nodeDef.value) return
if (!node.data) return
emit('click', node)
}
const measureRef = ref<HTMLElement | null>(null)
const firstLine = ref('')
const secondLine = ref('')
function splitLines() {
const el = measureRef.value
const text = nodeDef.value?.display_name
if (!el || !text) {
firstLine.value = ''
secondLine.value = ''
return
}
const textNode = el.firstChild
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
firstLine.value = text
secondLine.value = ''
return
}
const range = document.createRange()
range.selectNodeContents(textNode)
const rects = range.getClientRects()
if (rects.length <= 1) {
firstLine.value = text
secondLine.value = ''
return
}
const firstRectBottom = rects[0].bottom
let splitIndex = text.length
let low = 1
let high = text.length - 1
while (low <= high) {
const mid = Math.floor((low + high) / 2)
range.setStart(textNode, mid)
range.setEnd(textNode, mid + 1)
const charRect = range.getBoundingClientRect()
if (charRect.top >= firstRectBottom - 1) {
splitIndex = mid
high = mid - 1
} else {
low = mid + 1
}
}
if (splitIndex < text.length) {
firstLine.value = text.substring(0, splitIndex).trim()
secondLine.value = text.substring(splitIndex).trim()
} else {
firstLine.value = text
secondLine.value = ''
}
}
useResizeObserver(measureRef, splitLines)
watch(
() => nodeDef.value?.display_name,
async () => {
await nextTick()
splitLines()
}
)
</script>

View File

@@ -0,0 +1,73 @@
import { ref } from 'vue'
import { describe, expect, it } from 'vitest'
import { usePerTabState } from './usePerTabState'
type TabId = 'a' | 'b' | 'c'
describe('usePerTabState', () => {
function setup(initialTab: TabId = 'a') {
const selectedTab = ref<TabId>(initialTab)
const stateByTab = ref<Record<TabId, string[]>>({
a: [],
b: [],
c: []
})
const state = usePerTabState(selectedTab, stateByTab)
return { selectedTab, stateByTab, state }
}
it('should return state for the current tab', () => {
const { selectedTab, stateByTab, state } = setup()
stateByTab.value.a = ['key1', 'key2']
stateByTab.value.b = ['key3']
expect(state.value).toEqual(['key1', 'key2'])
selectedTab.value = 'b'
expect(state.value).toEqual(['key3'])
})
it('should set state only for the current tab', () => {
const { stateByTab, state } = setup()
state.value = ['new-key1', 'new-key2']
expect(stateByTab.value.a).toEqual(['new-key1', 'new-key2'])
expect(stateByTab.value.b).toEqual([])
expect(stateByTab.value.c).toEqual([])
})
it('should preserve state when switching tabs', () => {
const { selectedTab, stateByTab, state } = setup()
state.value = ['a-key']
selectedTab.value = 'b'
state.value = ['b-key']
selectedTab.value = 'c'
state.value = ['c-key']
expect(stateByTab.value.a).toEqual(['a-key'])
expect(stateByTab.value.b).toEqual(['b-key'])
expect(stateByTab.value.c).toEqual(['c-key'])
selectedTab.value = 'a'
expect(state.value).toEqual(['a-key'])
})
it('should not share state between tabs', () => {
const { selectedTab, state } = setup()
state.value = ['only-a']
selectedTab.value = 'b'
expect(state.value).toEqual([])
selectedTab.value = 'c'
expect(state.value).toEqual([])
selectedTab.value = 'a'
expect(state.value).toEqual(['only-a'])
})
})

View File

@@ -0,0 +1,14 @@
import type { Ref } from 'vue'
import { computed } from 'vue'
export function usePerTabState<K extends string, V>(
selectedTab: Ref<K>,
stateByTab: Ref<Record<K, V>>
) {
return computed({
get: () => stateByTab.value[selectedTab.value],
set: (value) => {
stateByTab.value[selectedTab.value] = value
}
})
}