feat: right side panel (#6952)

<img width="1183" height="809" alt="CleanShot 2025-11-26 at 16 01 15"
src="https://github.com/user-attachments/assets/c14dc5c3-a672-4dcd-917d-14f16310188e"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6952-feat-right-side-panel-2b76d73d36508112b121c283a479f42a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Rizumu Ayaka
2025-12-03 13:55:24 +08:00
committed by GitHub
parent fb54669dc3
commit 68274134c8
42 changed files with 1271 additions and 374 deletions

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { provide } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import { getComponent } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import RightPanelSection from '../layout/RightPanelSection.vue'
defineProps<{
label?: string
widgets: { widget: IBaseWidget; node: LGraphNode }[]
}>()
provide('hideLayoutField', true)
const canvasStore = useCanvasStore()
function getWidgetComponent(widget: IBaseWidget) {
const component = getComponent(widget.type, widget.name)
return component || WidgetLegacy
}
function onWidgetValueChange(
widget: IBaseWidget,
value: string | number | boolean | object
) {
widget.value = value
widget.callback?.(value)
canvasStore.canvas?.setDirty(true, true)
}
</script>
<template>
<RightPanelSection>
<template #label>
<slot name="label">
{{ label ?? $t('rightSidePanel.inputs') }}
</slot>
</template>
<div class="space-y-4 rounded-lg bg-interface-surface px-4">
<div
v-for="({ widget, node }, index) in widgets"
:key="`widget-${index}-${widget.name}`"
class="widget-item gap-1.5 col-span-full grid grid-cols-subgrid"
>
<div class="min-h-8">
<p v-if="widget.name" class="text-sm leading-8 p-0 m-0 line-clamp-1">
{{ widget.label || widget.name }}
</p>
</div>
<component
:is="getWidgetComponent(widget)"
:widget="widget"
:model-value="widget.value"
:node-id="String(node.id)"
:node-type="node.type"
class="col-span-1"
@update:model-value="
(value: string | number | boolean | object) =>
onWidgetValueChange(widget, value)
"
/>
</div>
</div>
</RightPanelSection>
</template>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed, shallowRef } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import SidePanelSearch from '../layout/SidePanelSearch.vue'
import SectionWidgets from './SectionWidgets.vue'
const props = defineProps<{
nodes: LGraphNode[]
}>()
const widgetsSectionDataList = computed(() => {
const list: {
widgets: { node: LGraphNode; widget: IBaseWidget }[]
node: LGraphNode
}[] = []
for (const node of props.nodes) {
const shownWidgets: IBaseWidget[] = []
for (const widget of node.widgets ?? []) {
if (widget.options?.canvasOnly || widget.options?.hidden) continue
shownWidgets.push(widget)
}
list.push({
widgets: shownWidgets?.map((widget) => ({ node, widget })) ?? [],
node
})
}
return list
})
const searchedWidgetsSectionDataList = shallowRef<
{
widgets: { node: LGraphNode; widget: IBaseWidget }[]
node: LGraphNode
}[]
>([])
/**
* Searches widgets in all selected nodes and returns search results.
* Filters by name, localized label, type, and user-input value.
* Performs basic tokenization of the query string.
*/
async function searcher(query: string) {
if (query.trim() === '') {
searchedWidgetsSectionDataList.value = widgetsSectionDataList.value
return
}
const words = query.trim().toLowerCase().split(' ')
searchedWidgetsSectionDataList.value = widgetsSectionDataList.value
.map((item) => {
return {
...item,
widgets: item.widgets.filter(({ widget }) => {
const label = widget.label?.toLowerCase()
const name = widget.name.toLowerCase()
const type = widget.type.toLowerCase()
const value = widget.value?.toString().toLowerCase()
return words.every(
(word) =>
name.includes(word) ||
label?.includes(word) ||
type?.includes(word) ||
value?.includes(word)
)
})
}
})
.filter((item) => item.widgets.length > 0)
}
</script>
<template>
<div class="p-4 flex gap-2 border-b border-interface-stroke">
<SidePanelSearch :searcher :update-key="widgetsSectionDataList" />
</div>
<SectionWidgets
v-for="section in searchedWidgetsSectionDataList"
:key="section.node.id"
:label="widgetsSectionDataList.length > 1 ? section.node.title : undefined"
:widgets="section.widgets"
:default-collapse="
widgetsSectionDataList.length > 1 &&
widgetsSectionDataList === searchedWidgetsSectionDataList
"
class="border-b border-interface-stroke"
/>
</template>