Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b3bc77926 | ||
|
|
c94cedf8ee | ||
|
|
ba355b543d | ||
|
|
e9b641cfb7 | ||
|
|
56153596d9 | ||
|
|
b679bfe8f8 | ||
|
|
54979701d0 | ||
|
|
64e704c2f9 | ||
|
|
b1050e3195 | ||
|
|
ba100c4a04 | ||
|
|
cafd2de961 | ||
|
|
1f3fb90b1b | ||
|
|
27afd01297 | ||
|
|
535f857330 | ||
|
|
adb15aac40 | ||
|
|
8752f1b06d | ||
|
|
90c2c0fae0 | ||
|
|
34155bccb1 | ||
|
|
8849d54e20 | ||
|
|
63cb271509 | ||
|
|
22a84b1c0c | ||
|
|
35d53c2c75 | ||
|
|
3c11226fdd | ||
|
|
437c3b2da0 | ||
|
|
549ef79e02 | ||
|
|
a2ef569b9c | ||
|
|
265f1257e7 | ||
|
|
fac86e35bf | ||
|
|
693fbbd3e4 | ||
|
|
47688fe363 | ||
|
|
7c2a768d83 | ||
|
|
a4fc68a9eb | ||
|
|
6c9743c1a6 | ||
|
|
4ab1e824b7 | ||
|
|
adecd258b6 | ||
|
|
2c4280a28d | ||
|
|
c2150c6046 | ||
|
|
cdffcfaa33 | ||
|
|
eae93c2a79 | ||
|
|
4cfa5b4b5d | ||
|
|
a10c01db4c | ||
|
|
f38255bff4 | ||
|
|
befa84130b | ||
|
|
dc8a0e04a5 | ||
|
|
9cd7a39f09 | ||
|
|
4810b5728a | ||
|
|
6fe88dba54 | ||
|
|
8df0a3885d | ||
|
|
8dfdac3fc4 | ||
|
|
0692253e90 | ||
|
|
3cded2c45f | ||
|
|
fd236b3587 | ||
|
|
e1f707ffe2 | ||
|
|
fddebd4a73 | ||
|
|
704de20245 | ||
|
|
feda2d47b0 | ||
|
|
2d22c9f7f0 | ||
|
|
d808998863 | ||
|
|
7bf8e5af38 | ||
|
|
31e405abc8 | ||
|
|
0e9c29e7b7 | ||
|
|
8d27c4fb1b | ||
|
|
02aaf577ec | ||
|
|
431594a6fc | ||
|
|
dffb07c745 | ||
|
|
72389637ed | ||
|
|
de535269ee | ||
|
|
dad2d803df | ||
|
|
f2aea9c823 | ||
|
|
c8e24190cd | ||
|
|
6fc5748a32 | ||
|
|
99958d3aa9 | ||
|
|
1cdfc6934a | ||
|
|
d1108867e2 | ||
|
|
89b073f96f | ||
|
|
9197119ed8 | ||
|
|
6abff41f08 | ||
|
|
a537124a9f | ||
|
|
e05e988730 | ||
|
|
afa10f7a1e | ||
|
|
91b5a7de17 | ||
|
|
da078a6071 |
@@ -25,3 +25,6 @@ e3bb29ceb8174b8bbca9e48ec7d42cd540f40efa
|
||||
|
||||
# [refactor] Improve updates/notifications domain organization (#5590)
|
||||
27ab355f9c73415dc39f4d3f512b02308f847801
|
||||
|
||||
# Migrate Tailwind styles to design-system package
|
||||
9f19d8fb4bd22518879343b49c05634dca777df0
|
||||
|
||||
116
.github/actions/comment-release-links/action.yaml
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
name: Post Release Summary Comment
|
||||
description: Post or update a PR comment summarizing release links with diff, derived versions, and optional extras.
|
||||
author: ComfyUI Frontend Team
|
||||
|
||||
inputs:
|
||||
issue-number:
|
||||
description: Optional PR number override (defaults to the current pull request)
|
||||
default: ''
|
||||
version_file:
|
||||
description: Path to the JSON file containing the current version (relative to repo root)
|
||||
required: true
|
||||
|
||||
outputs:
|
||||
prev_version:
|
||||
description: Previous version derived from the parent commit
|
||||
value: ${{ steps.build.outputs.prev_version }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Build comment body
|
||||
id: build
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION_FILE="${{ inputs.version_file }}"
|
||||
REPO="${{ github.repository }}"
|
||||
|
||||
if [[ -z "$VERSION_FILE" ]]; then
|
||||
echo '::error::version_file input is required' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PREV_JSON=$(git show HEAD^1:"$VERSION_FILE" 2>/dev/null || true)
|
||||
if [[ -z "$PREV_JSON" ]]; then
|
||||
echo "::error::Unable to read $VERSION_FILE from parent commit" >&2
|
||||
exit 1
|
||||
fi
|
||||
PREV_VERSION=$(printf '%s' "$PREV_JSON" | node -pe "const data = JSON.parse(require('fs').readFileSync(0, 'utf8')); if (!data.version) { process.exit(1); } data.version")
|
||||
if [[ -z "$PREV_VERSION" ]]; then
|
||||
echo "::error::Unable to determine previous version from $VERSION_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEW_VERSION=$(node -pe "const fs=require('fs');const data=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));if(!data.version){process.exit(1);}data.version" "$VERSION_FILE")
|
||||
if [[ -z "$NEW_VERSION" ]]; then
|
||||
echo "::error::Unable to determine current version from $VERSION_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MARKER='release-summary'
|
||||
MESSAGE='Publish jobs finished successfully:'
|
||||
LINKS_VALUE=''
|
||||
|
||||
case "$VERSION_FILE" in
|
||||
package.json)
|
||||
LINKS_VALUE=$'PyPI|https://pypi.org/project/comfyui-frontend-package/{{version}}/\n''npm types|https://npm.im/@comfyorg/comfyui-frontend-types@{{version}}'
|
||||
;;
|
||||
apps/desktop-ui/package.json)
|
||||
MARKER='desktop-release-summary'
|
||||
LINKS_VALUE='npm desktop UI|https://npm.im/@comfyorg/desktop-ui@{{version}}'
|
||||
;;
|
||||
esac
|
||||
|
||||
DIFF_PREFIX='v'
|
||||
DIFF_LABEL='Diff'
|
||||
DIFF_URL="https://github.com/${REPO}/compare/${DIFF_PREFIX}${PREV_VERSION}...${DIFF_PREFIX}${NEW_VERSION}"
|
||||
COMMENT_FILE=$(mktemp)
|
||||
|
||||
{
|
||||
printf '<!--%s:%s%s-->\n' "$MARKER" "$DIFF_PREFIX" "$NEW_VERSION"
|
||||
printf '%s\n\n' "$MESSAGE"
|
||||
printf -- '- %s: [%s%s...%s%s](%s)\n' "$DIFF_LABEL" "$DIFF_PREFIX" "$PREV_VERSION" "$DIFF_PREFIX" "$NEW_VERSION" "$DIFF_URL"
|
||||
|
||||
while IFS= read -r RAW_LINE; do
|
||||
LINE=$(printf '%s' "$RAW_LINE" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
[[ -z "$LINE" ]] && continue
|
||||
if [[ "$LINE" != *"|"* ]]; then
|
||||
echo "::warning::Skipping malformed link entry: $LINE" >&2
|
||||
continue
|
||||
fi
|
||||
LABEL=${LINE%%|*}
|
||||
URL_TEMPLATE=${LINE#*|}
|
||||
URL=${URL_TEMPLATE//\{\{version\}\}/$NEW_VERSION}
|
||||
URL=${URL//\{\{prev_version\}\}/$PREV_VERSION}
|
||||
printf -- '- %s: %s\n' "$LABEL" "$URL"
|
||||
done <<< "$LINKS_VALUE"
|
||||
|
||||
printf '\n'
|
||||
} > "$COMMENT_FILE"
|
||||
|
||||
{
|
||||
echo "body<<'EOF'"
|
||||
cat "$COMMENT_FILE"
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "prev_version=$PREV_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "marker_search=<!--$MARKER:" >> "$GITHUB_OUTPUT"
|
||||
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Find existing comment
|
||||
id: find
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad
|
||||
with:
|
||||
issue-number: ${{ inputs.issue-number || github.event.pull_request.number }}
|
||||
comment-author: github-actions[bot]
|
||||
body-includes: ${{ steps.build.outputs.marker_search }}
|
||||
|
||||
- name: Post or update comment
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
|
||||
with:
|
||||
issue-number: ${{ inputs.issue-number || github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.find.outputs.comment-id }}
|
||||
body: ${{ steps.build.outputs.body }}
|
||||
edit-mode: replace
|
||||
33
.github/workflows/pr-claude-review.yaml
vendored
@@ -17,40 +17,9 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
wait-for-ci:
|
||||
claude-review:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.label.name == 'claude-review'
|
||||
outputs:
|
||||
should-proceed: ${{ steps.check-status.outputs.proceed }}
|
||||
steps:
|
||||
- name: Wait for other CI checks
|
||||
uses: lewagon/wait-on-check-action@e106e5c43e8ca1edea6383a39a01c5ca495fd812
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
check-regexp: '^(lint-and-format|test|playwright-tests)'
|
||||
allowed-conclusions: success,skipped,failure,cancelled,neutral,action_required,timed_out,stale
|
||||
wait-interval: 30
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check if we should proceed
|
||||
id: check-status
|
||||
run: |
|
||||
CHECK_RUNS=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs --jq '.check_runs[] | select(.name | test("lint-and-format")) | {name, conclusion}')
|
||||
|
||||
if echo "$CHECK_RUNS" | grep -Eq '"conclusion": "(failure|cancelled|timed_out|action_required)"'; then
|
||||
echo "Some CI checks failed - skipping Claude review"
|
||||
echo "proceed=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "All CI checks passed - proceeding with Claude review"
|
||||
echo "proceed=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
claude-review:
|
||||
needs: wait-for-ci
|
||||
if: needs.wait-for-ci.outputs.should-proceed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
name: Publish Desktop UI on PR Merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [ closed ]
|
||||
branches: [ main, core/* ]
|
||||
types: ['closed']
|
||||
branches: [main, core/*]
|
||||
paths:
|
||||
- 'apps/desktop-ui/package.json'
|
||||
|
||||
@@ -57,3 +58,26 @@ jobs:
|
||||
secrets:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
comment_desktop_publish:
|
||||
name: Comment Desktop Publish Summary
|
||||
needs:
|
||||
- resolve
|
||||
- publish
|
||||
if: success()
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout merge commit
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Post desktop release summary comment
|
||||
uses: ./.github/actions/comment-release-links
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
version_file: apps/desktop-ui/package.json
|
||||
|
||||
51
.github/workflows/release-draft-create.yaml
vendored
@@ -1,9 +1,10 @@
|
||||
---
|
||||
name: Release Draft Create
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [ closed ]
|
||||
branches: [ main, core/* ]
|
||||
types: ['closed']
|
||||
branches: [main, core/*]
|
||||
paths:
|
||||
- 'package.json'
|
||||
|
||||
@@ -30,7 +31,9 @@ jobs:
|
||||
|
||||
- name: Get current version
|
||||
id: current_version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
- name: Check if prerelease
|
||||
id: check_prerelease
|
||||
run: |
|
||||
@@ -71,7 +74,8 @@ jobs:
|
||||
name: dist-files
|
||||
- name: Create release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
|
||||
uses: >-
|
||||
softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -79,9 +83,14 @@ jobs:
|
||||
dist.zip
|
||||
tag_name: v${{ needs.build.outputs.version }}
|
||||
target_commitish: ${{ github.event.pull_request.base.ref }}
|
||||
make_latest: ${{ github.event.pull_request.base.ref == 'main' && needs.build.outputs.is_prerelease == 'false' }}
|
||||
draft: ${{ github.event.pull_request.base.ref != 'main' || needs.build.outputs.is_prerelease == 'true' }}
|
||||
prerelease: ${{ needs.build.outputs.is_prerelease == 'true' }}
|
||||
make_latest: >-
|
||||
${{ github.event.pull_request.base.ref == 'main' &&
|
||||
needs.build.outputs.is_prerelease == 'false' }}
|
||||
draft: >-
|
||||
${{ github.event.pull_request.base.ref != 'main' ||
|
||||
needs.build.outputs.is_prerelease == 'true' }}
|
||||
prerelease: >-
|
||||
${{ needs.build.outputs.is_prerelease == 'true' }}
|
||||
generate_release_notes: true
|
||||
|
||||
publish_pypi:
|
||||
@@ -110,7 +119,8 @@ jobs:
|
||||
env:
|
||||
COMFYUI_FRONTEND_VERSION: ${{ needs.build.outputs.version }}
|
||||
- name: Publish pypi package
|
||||
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
|
||||
uses: >-
|
||||
pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
packages-dir: comfyui_frontend_package/dist
|
||||
@@ -122,3 +132,28 @@ jobs:
|
||||
version: ${{ needs.build.outputs.version }}
|
||||
ref: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
secrets: inherit
|
||||
|
||||
comment_release_summary:
|
||||
name: Comment Release Summary
|
||||
needs:
|
||||
- draft_release
|
||||
- publish_pypi
|
||||
- publish_types
|
||||
if: success()
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout merge commit
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Post release summary comment
|
||||
uses: ./.github/actions/comment-release-links
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
version_file: package.json
|
||||
|
||||
4
.gitignore
vendored
@@ -18,6 +18,7 @@ yarn.lock
|
||||
.stylelintcache
|
||||
|
||||
node_modules
|
||||
.pnpm-store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
@@ -92,3 +93,6 @@ storybook-static
|
||||
.github/instructions/nx.instructions.md
|
||||
vite.config.*.timestamp*
|
||||
vitest.config.*.timestamp*
|
||||
|
||||
# Weekly docs check output
|
||||
/output.txt
|
||||
|
||||
@@ -74,8 +74,15 @@ const config: StorybookConfig = {
|
||||
'@': process.cwd() + '/src'
|
||||
}
|
||||
},
|
||||
esbuild: {
|
||||
// Prevent minification of identifiers to preserve _sfc_main
|
||||
minifyIdentifiers: false,
|
||||
keepNames: true
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
// Disable tree-shaking for Storybook to prevent Vue SFC exports from being removed
|
||||
treeshake: false,
|
||||
onwarn: (warning, warn) => {
|
||||
// Suppress specific warnings
|
||||
if (
|
||||
|
||||
@@ -75,8 +75,15 @@ const config: StorybookConfig = {
|
||||
'@frontend-locales': process.cwd() + '/../../src/locales'
|
||||
}
|
||||
},
|
||||
esbuild: {
|
||||
// Prevent minification of identifiers to preserve _sfc_main
|
||||
minifyIdentifiers: false,
|
||||
keepNames: true
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
// Disable tree-shaking for Storybook to prevent Vue SFC exports from being removed
|
||||
treeshake: false,
|
||||
onwarn: (warning, warn) => {
|
||||
// Suppress specific warnings
|
||||
if (
|
||||
|
||||
206
apps/desktop-ui/src/components/common/LanguageSelector.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<Select
|
||||
:id="dropdownId"
|
||||
v-model="selectedLocale"
|
||||
:options="localeOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:disabled="isSwitching"
|
||||
:pt="dropdownPt"
|
||||
:size="props.size"
|
||||
class="language-selector"
|
||||
@change="onLocaleChange"
|
||||
>
|
||||
<template #value="{ value }">
|
||||
<span :class="valueClass">
|
||||
<i class="pi pi-language" :class="iconClass" />
|
||||
<span>{{ displayLabel(value as SupportedLocale) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<span :class="optionClass">
|
||||
<i class="pi pi-language" :class="iconClass" />
|
||||
<span class="leading-none">{{ option.label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import type { SelectChangeEvent } from 'primevue/select'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { i18n, loadLocale, st } from '@/i18n'
|
||||
|
||||
type VariantKey = 'dark' | 'light'
|
||||
type SizeKey = 'small' | 'large'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: VariantKey
|
||||
size?: SizeKey
|
||||
}>(),
|
||||
{
|
||||
variant: 'dark',
|
||||
size: 'small'
|
||||
}
|
||||
)
|
||||
|
||||
const dropdownId = `language-select-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
const LOCALES = [
|
||||
['en', 'English'],
|
||||
['zh', '中文'],
|
||||
['zh-TW', '繁體中文'],
|
||||
['ru', 'Русский'],
|
||||
['ja', '日本語'],
|
||||
['ko', '한국어'],
|
||||
['fr', 'Français'],
|
||||
['es', 'Español'],
|
||||
['ar', 'عربي'],
|
||||
['tr', 'Türkçe']
|
||||
] as const satisfies ReadonlyArray<[string, string]>
|
||||
|
||||
type SupportedLocale = (typeof LOCALES)[number][0]
|
||||
|
||||
const SIZE_PRESETS = {
|
||||
large: {
|
||||
wrapper: 'px-3 py-1 min-w-[7rem]',
|
||||
gap: 'gap-2',
|
||||
valueText: 'text-xs',
|
||||
optionText: 'text-sm',
|
||||
icon: 'text-sm'
|
||||
},
|
||||
small: {
|
||||
wrapper: 'px-2 py-0.5 min-w-[5rem]',
|
||||
gap: 'gap-1',
|
||||
valueText: 'text-[0.65rem]',
|
||||
optionText: 'text-xs',
|
||||
icon: 'text-xs'
|
||||
}
|
||||
} as const satisfies Record<SizeKey, Record<string, string>>
|
||||
|
||||
const VARIANT_PRESETS = {
|
||||
light: {
|
||||
root: 'bg-white/80 border border-neutral-200 text-neutral-700 rounded-full shadow-sm backdrop-blur hover:border-neutral-400 transition-colors focus-visible:ring-offset-2 focus-visible:ring-offset-white',
|
||||
trigger: 'text-neutral-500 hover:text-neutral-700',
|
||||
item: 'text-neutral-700 bg-transparent hover:bg-neutral-100 focus-visible:outline-none',
|
||||
valueText: 'text-neutral-600',
|
||||
optionText: 'text-neutral-600',
|
||||
icon: 'text-neutral-500'
|
||||
},
|
||||
dark: {
|
||||
root: 'bg-neutral-900/70 border border-neutral-700 text-neutral-200 rounded-full shadow-sm backdrop-blur hover:border-neutral-500 transition-colors focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900',
|
||||
trigger: 'text-neutral-400 hover:text-neutral-200',
|
||||
item: 'text-neutral-200 bg-transparent hover:bg-neutral-800/80 focus-visible:outline-none',
|
||||
valueText: 'text-neutral-100',
|
||||
optionText: 'text-neutral-100',
|
||||
icon: 'text-neutral-300'
|
||||
}
|
||||
} as const satisfies Record<VariantKey, Record<string, string>>
|
||||
|
||||
const selectedLocale = ref<string>(i18n.global.locale.value)
|
||||
const isSwitching = ref(false)
|
||||
|
||||
const sizePreset = computed(() => SIZE_PRESETS[props.size as SizeKey])
|
||||
const variantPreset = computed(
|
||||
() => VARIANT_PRESETS[props.variant as VariantKey]
|
||||
)
|
||||
|
||||
const dropdownPt = computed(() => ({
|
||||
root: {
|
||||
class: `${variantPreset.value.root} ${sizePreset.value.wrapper}`
|
||||
},
|
||||
trigger: {
|
||||
class: variantPreset.value.trigger
|
||||
},
|
||||
item: {
|
||||
class: `${variantPreset.value.item} ${sizePreset.value.optionText}`
|
||||
}
|
||||
}))
|
||||
|
||||
const valueClass = computed(() =>
|
||||
[
|
||||
'flex items-center font-medium uppercase tracking-wide leading-tight',
|
||||
sizePreset.value.gap,
|
||||
sizePreset.value.valueText,
|
||||
variantPreset.value.valueText
|
||||
].join(' ')
|
||||
)
|
||||
|
||||
const optionClass = computed(() =>
|
||||
[
|
||||
'flex items-center leading-tight',
|
||||
sizePreset.value.gap,
|
||||
variantPreset.value.optionText,
|
||||
sizePreset.value.optionText
|
||||
].join(' ')
|
||||
)
|
||||
|
||||
const iconClass = computed(() =>
|
||||
[sizePreset.value.icon, variantPreset.value.icon].join(' ')
|
||||
)
|
||||
|
||||
const localeOptions = computed(() =>
|
||||
LOCALES.map(([value, fallback]) => ({
|
||||
value,
|
||||
label: st(`settings.Comfy_Locale.options.${value}`, fallback)
|
||||
}))
|
||||
)
|
||||
|
||||
const labelLookup = computed(() =>
|
||||
localeOptions.value.reduce<Record<string, string>>((acc, option) => {
|
||||
acc[option.value] = option.label
|
||||
return acc
|
||||
}, {})
|
||||
)
|
||||
|
||||
function displayLabel(locale?: SupportedLocale) {
|
||||
if (!locale) {
|
||||
return st('settings.Comfy_Locale.name', 'Language')
|
||||
}
|
||||
|
||||
return labelLookup.value[locale] ?? locale
|
||||
}
|
||||
|
||||
watch(
|
||||
() => i18n.global.locale.value,
|
||||
(newLocale) => {
|
||||
if (newLocale !== selectedLocale.value) {
|
||||
selectedLocale.value = newLocale
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function onLocaleChange(event: SelectChangeEvent) {
|
||||
const nextLocale = event.value as SupportedLocale | undefined
|
||||
|
||||
if (!nextLocale || nextLocale === i18n.global.locale.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isSwitching.value = true
|
||||
try {
|
||||
await loadLocale(nextLocale)
|
||||
i18n.global.locale.value = nextLocale
|
||||
} catch (error) {
|
||||
console.error(`Failed to change locale to "${nextLocale}"`, error)
|
||||
selectedLocale.value = i18n.global.locale.value
|
||||
} finally {
|
||||
isSwitching.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.p-dropdown-panel .p-dropdown-item) {
|
||||
@apply transition-colors;
|
||||
}
|
||||
|
||||
:deep(.p-dropdown) {
|
||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-yellow/60 focus-visible:ring-offset-2;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark>
|
||||
<BaseViewTemplate dark hide-language-selector>
|
||||
<div class="h-full p-8 2xl:p-16 flex flex-col items-center justify-center">
|
||||
<div
|
||||
class="bg-neutral-800 rounded-lg shadow-lg p-6 w-full max-w-[600px] flex flex-col gap-6"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark>
|
||||
<div class="flex items-center justify-center min-h-screen">
|
||||
<div class="grid grid-rows-2 gap-8">
|
||||
<div class="grid gap-8">
|
||||
<!-- Top container: Logo -->
|
||||
<div class="flex items-end justify-center">
|
||||
<img
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<template>
|
||||
<div
|
||||
class="font-sans w-screen h-screen flex flex-col"
|
||||
class="font-sans w-screen h-screen flex flex-col relative"
|
||||
:class="[
|
||||
dark
|
||||
? 'text-neutral-300 bg-neutral-900 dark-theme'
|
||||
: 'text-neutral-900 bg-neutral-300'
|
||||
]"
|
||||
>
|
||||
<div v-if="showLanguageSelector" class="absolute top-6 right-6 z-10">
|
||||
<LanguageSelector :variant="variant" />
|
||||
</div>
|
||||
<!-- Virtual top menu for native window (drag handle) -->
|
||||
<div
|
||||
v-show="isNativeWindow()"
|
||||
@@ -20,14 +23,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
import LanguageSelector from '@/components/common/LanguageSelector.vue'
|
||||
|
||||
import { electronAPI, isElectron, isNativeWindow } from '../../utils/envUtil'
|
||||
|
||||
const { dark = false } = defineProps<{
|
||||
const { dark = false, hideLanguageSelector = false } = defineProps<{
|
||||
dark?: boolean
|
||||
hideLanguageSelector?: boolean
|
||||
}>()
|
||||
|
||||
const variant = computed(() => (dark ? 'dark' : 'light'))
|
||||
const showLanguageSelector = computed(() => !hideLanguageSelector)
|
||||
|
||||
const darkTheme = {
|
||||
color: 'rgba(0, 0, 0, 0)',
|
||||
symbolColor: '#d4d4d4'
|
||||
|
||||
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 77 KiB |
14
global.d.ts
vendored
@@ -8,7 +8,21 @@ declare const __USE_PROD_CONFIG__: boolean
|
||||
interface Window {
|
||||
__CONFIG__: {
|
||||
mixpanel_token?: string
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
max_upload_size?: number
|
||||
comfy_api_base_url?: string
|
||||
comfy_platform_base_url?: string
|
||||
firebase_config?: {
|
||||
apiKey: string
|
||||
authDomain: string
|
||||
databaseURL?: string
|
||||
projectId: string
|
||||
storageBucket: string
|
||||
messagingSenderId: string
|
||||
appId: string
|
||||
measurementId?: string
|
||||
}
|
||||
server_health_alert?: {
|
||||
message: string
|
||||
tooltip?: string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.31.1",
|
||||
"version": "1.32.4",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -16,6 +16,7 @@
|
||||
"size:collect": "node scripts/size-collect.js",
|
||||
"size:report": "node scripts/size-report.js",
|
||||
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
|
||||
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' nx serve",
|
||||
"dev:desktop": "nx dev @comfyorg/desktop-ui",
|
||||
"dev:electron": "nx serve --config vite.electron.config.mts",
|
||||
"dev": "nx serve",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
|
||||
--color-jade-400: #47e469;
|
||||
--color-jade-600: #00cd72;
|
||||
--color-graphite-400: #9C9EAB;
|
||||
|
||||
--color-gold-400: #fcbf64;
|
||||
--color-gold-500: #fdab34;
|
||||
@@ -157,6 +158,8 @@
|
||||
--button-surface: var(--color-white);
|
||||
--button-surface-contrast: var(--color-black);
|
||||
|
||||
--subscription-button-gradient: linear-gradient(315deg, rgb(105 230 255 / 0.15) 0%, rgb(99 73 233 / 0.50) 100%), radial-gradient(70.71% 70.71% at 50% 50%, rgb(62 99 222 / 0.15) 0.01%, rgb(66 0 123 / 0.50) 100%), linear-gradient(92deg, #D000FF 0.38%, #B009FE 37.07%, #3E1FFC 65.17%, #009DFF 103.86%), var(--color-button-surface, #2D2E32);
|
||||
|
||||
--modal-card-button-surface: var(--color-smoke-300);
|
||||
|
||||
/* Code styling colors for help menu*/
|
||||
@@ -225,7 +228,7 @@
|
||||
--brand-yellow: var(--color-electric-400);
|
||||
--brand-blue: var(--color-sapphire-700);
|
||||
--secondary-background: var(--color-smoke-200);
|
||||
--secondary-background-hover: var(--color-smoke-400);
|
||||
--secondary-background-hover: var(--color-smoke-200);
|
||||
--secondary-background-selected: var(--color-smoke-600);
|
||||
--base-background: var(--color-white);
|
||||
--primary-background: var(--color-azure-400);
|
||||
@@ -239,6 +242,29 @@
|
||||
--border-subtle: var(--color-smoke-400);
|
||||
--muted-background: var(--color-smoke-700);
|
||||
--accent-background: var(--color-smoke-800);
|
||||
|
||||
/* Component/Node tokens from design system light */
|
||||
--component-node-background: var(--color-white);
|
||||
--component-node-border: var(--color-border-default);
|
||||
--component-node-foreground: var(--base-foreground);
|
||||
--component-node-foreground-secondary: var(--color-muted-foreground);
|
||||
--component-node-widget-background: var(--secondary-background);
|
||||
--component-node-widget-background-hovered: var(--secondary-background-hover);
|
||||
--component-node-widget-background-selected: var(--secondary-background-selected);
|
||||
--component-node-widget-background-disabled: var(--color-alpha-ash-500-20);
|
||||
--component-node-widget-background-highlighted: var(--color-ash-500);
|
||||
|
||||
/* Default UI element color palette variables */
|
||||
--palette-contrast-mix-color: #fff;
|
||||
--palette-interface-panel-surface: var(--comfy-menu-bg);
|
||||
--palette-interface-stroke: color-mix(in srgb, var(--interface-panel-surface) 75.5%, var(--contrast-mix-color));
|
||||
|
||||
--palette-interface-panel-box-shadow: 1px 1px 8px 0 rgb(0 0 0 / 0.4);
|
||||
--palette-interface-panel-drop-shadow: 1px 1px 4px rgb(0 0 0 / 0.4);
|
||||
--palette-interface-panel-hover-surface: color-mix(in srgb, var(--interface-panel-surface) 92.5%, var(--contrast-mix-color));
|
||||
--palette-interface-panel-selected-surface: color-mix(in srgb, var(--interface-panel-surface) 87.5%, var(--contrast-mix-color));
|
||||
--palette-interface-button-hover-surface: color-mix(in srgb, var(--interface-panel-surface) 82%, var(--contrast-mix-color));
|
||||
|
||||
}
|
||||
|
||||
.dark-theme {
|
||||
@@ -258,6 +284,8 @@
|
||||
--button-active-surface: var(--color-charcoal-600);
|
||||
--button-icon: var(--color-smoke-800);
|
||||
|
||||
--subscription-button-gradient: linear-gradient(315deg, rgb(105 230 255 / 0.15) 0%, rgb(99 73 233 / 0.50) 100%), radial-gradient(70.71% 70.71% at 50% 50%, rgb(62 99 222 / 0.15) 0.01%, rgb(66 0 123 / 0.50) 100%), linear-gradient(92deg, #D000FF 0.38%, #B009FE 37.07%, #3E1FFC 65.17%, #009DFF 103.86%), var(--color-button-surface, #2D2E32);
|
||||
|
||||
--modal-card-button-surface: var(--color-charcoal-300);
|
||||
|
||||
--dialog-surface: var(--color-neutral-700);
|
||||
@@ -285,7 +313,7 @@
|
||||
--node-component-surface-highlight: var(--color-slate-100);
|
||||
--node-component-surface-hovered: var(--color-charcoal-600);
|
||||
--node-component-surface-selected: var(--color-charcoal-200);
|
||||
--node-component-surface: var(--color-charcoal-800);
|
||||
--node-component-surface: var(--color-charcoal-600);
|
||||
--node-component-tooltip: var(--color-white);
|
||||
--node-component-tooltip-border: var(--color-slate-300);
|
||||
--node-component-tooltip-surface: var(--color-charcoal-800);
|
||||
@@ -323,6 +351,17 @@
|
||||
--border-subtle: var(--color-charcoal-300);
|
||||
--muted-background: var(--color-charcoal-100);
|
||||
--accent-background: var(--color-charcoal-100);
|
||||
|
||||
/* Component/Node tokens from design dark system */
|
||||
--component-node-background: var(--color-charcoal-600);
|
||||
--component-node-border: var(--color-charcoal-100);
|
||||
--component-node-foreground: var(--base-foreground);
|
||||
--component-node-foreground-secondary: var(--color-muted-foreground);
|
||||
--component-node-widget-background: var(--secondary-background-hover);
|
||||
--component-node-widget-background-hovered: var(--secondary-background-selected);
|
||||
--component-node-widget-background-selected: var(--color-charcoal-100);
|
||||
--component-node-widget-background-disabled: var(--color-alpha-charcoal-600-30);
|
||||
--component-node-widget-background-highlighted: var(--color-graphite-400);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -332,6 +371,7 @@
|
||||
--color-button-icon: var(--button-icon);
|
||||
--color-button-surface: var(--button-surface);
|
||||
--color-button-surface-contrast: var(--button-surface-contrast);
|
||||
--color-subscription-button-gradient: var(--subscription-button-gradient);
|
||||
--color-modal-card-button-surface: var(--modal-card-button-surface);
|
||||
--color-dialog-surface: var(--dialog-surface);
|
||||
--color-interface-menu-component-surface-hovered: var(
|
||||
@@ -344,6 +384,14 @@
|
||||
--interface-menu-keybind-surface-default
|
||||
);
|
||||
--color-interface-panel-surface: var(--interface-panel-surface);
|
||||
--color-interface-panel-hover-surface: var(--interface-panel-hover-surface);
|
||||
--color-interface-panel-selected-surface: var(
|
||||
--interface-panel-selected-surface
|
||||
);
|
||||
--color-interface-button-hover-surface: var(
|
||||
--interface-button-hover-surface
|
||||
);
|
||||
--color-comfy-menu-bg: var(--comfy-menu-bg);
|
||||
--color-interface-stroke: var(--interface-stroke);
|
||||
--color-nav-background: var(--nav-background);
|
||||
--color-node-border: var(--node-border);
|
||||
@@ -389,6 +437,17 @@
|
||||
--color-text-primary: var(--text-primary);
|
||||
--color-input-surface: var(--input-surface);
|
||||
|
||||
/* Component/Node design tokens */
|
||||
--color-component-node-background: var(--component-node-background);
|
||||
--color-component-node-border: var(--component-node-border);
|
||||
--color-component-node-foreground: var(--component-node-foreground);
|
||||
--color-component-node-foreground-secondary: var(--component-node-foreground-secondary);
|
||||
--color-component-node-widget-background: var(--component-node-widget-background);
|
||||
--color-component-node-widget-background-hovered: var(--component-node-widget-background-hovered);
|
||||
--color-component-node-widget-background-selected: var(--component-node-widget-background-selected);
|
||||
--color-component-node-widget-background-disabled: var(--component-node-widget-background-disabled);
|
||||
--color-component-node-widget-background-highlighted: var(--component-node-widget-background-highlighted);
|
||||
|
||||
/* Semantic tokens */
|
||||
--color-base-foreground: var(--base-foreground);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
@@ -492,36 +551,40 @@ body {
|
||||
.comfy-markdown {
|
||||
/* We assign the textarea and the Tiptap editor to the same CSS grid area to stack them on top of one another. */
|
||||
display: grid;
|
||||
}
|
||||
& > textarea,
|
||||
.tiptap {
|
||||
grid-area: 1 / 1 / 2 / 2;
|
||||
}
|
||||
|
||||
.comfy-markdown > textarea {
|
||||
grid-area: 1 / 1 / 2 / 2;
|
||||
}
|
||||
& > textarea {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.comfy-markdown .tiptap {
|
||||
grid-area: 1 / 1 / 2 / 2;
|
||||
background-color: var(--comfy-input-bg);
|
||||
color: var(--input-text);
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
resize: none;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
font-size: var(--comfy-textarea-font-size);
|
||||
height: 100%;
|
||||
padding: 0.5em;
|
||||
}
|
||||
&.editing {
|
||||
& > textarea {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.comfy-markdown.editing .tiptap {
|
||||
display: none;
|
||||
}
|
||||
.tiptap {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.comfy-markdown .tiptap :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.tiptap {
|
||||
overflow-y: auto;
|
||||
font-size: var(--comfy-textarea-font-size);
|
||||
|
||||
.comfy-markdown .tiptap :last-child {
|
||||
margin-bottom: 0;
|
||||
:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comfy-markdown .tiptap blockquote {
|
||||
|
||||
4
packages/design-system/src/icons/pin.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.13727 11.2201L6.27454 14.4398" stroke="var(--pin-color, var(--color-smoke-800, #8a8a8a))" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M6.28085 6.68385C6.21652 6.92342 6.08664 7.1403 5.9058 7.31009C5.72497 7.47989 5.50035 7.59587 5.25721 7.645L3.95569 7.91742C3.71254 7.96655 3.48793 8.08253 3.30709 8.25233C3.12626 8.42212 2.99637 8.639 2.93204 8.87857L2.80091 9.36797C2.75515 9.53876 2.7791 9.72073 2.86751 9.87385C2.95591 10.027 3.10153 10.1387 3.27231 10.1845L10.9997 12.255C11.1705 12.3008 11.3525 12.2768 11.5056 12.1884C11.6587 12.1 11.7705 11.9544 11.8162 11.7836L11.9474 11.2942C12.0114 11.0546 12.0074 10.8018 11.9357 10.5643C11.864 10.3269 11.7274 10.1141 11.5414 9.95001L10.5505 9.06333C10.3645 8.89921 10.2279 8.68646 10.1562 8.44899C10.0845 8.21153 10.0805 7.95877 10.1446 7.71913L10.7933 5.29787C10.8391 5.12709 10.9508 4.98148 11.1039 4.89307C11.2571 4.80466 11.439 4.78071 11.6098 4.82647C11.9514 4.91799 12.3153 4.87008 12.6216 4.69327C12.9278 4.51646 13.1513 4.22523 13.2428 3.88366C13.3343 3.54209 13.2864 3.17815 13.1096 2.8719C12.9328 2.56566 12.6416 2.34219 12.3 2.25067L7.1484 0.870299C6.80683 0.778775 6.44289 0.826689 6.13665 1.0035C5.8304 1.18031 5.60694 1.47154 5.51541 1.81311C5.42389 2.15468 5.4718 2.51862 5.64861 2.82487C5.82542 3.13111 6.11665 3.35458 6.45822 3.4461C6.62901 3.49186 6.77462 3.6036 6.86302 3.75672C6.95143 3.90984 6.97539 4.09181 6.92962 4.2626L6.28085 6.68385Z" fill="var(--handle-color, var(--color-gold-600, #fd9903))" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -4,6 +4,13 @@ import { addDynamicIconSelectors } from '@iconify/tailwind'
|
||||
import { iconCollection } from './src/iconCollection'
|
||||
|
||||
export default {
|
||||
theme: {
|
||||
extend: {
|
||||
boxShadow: {
|
||||
interface: 'var(--interface-panel-box-shadow)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
addDynamicIconSelectors({
|
||||
iconSets: {
|
||||
|
||||
40403
packages/registry-types/src/comfyRegistryTypes.ts
generated
6
pnpm-lock.yaml
generated
@@ -12,15 +12,9 @@ catalogs:
|
||||
'@eslint/js':
|
||||
specifier: ^9.35.0
|
||||
version: 9.35.0
|
||||
'@iconify-json/lucide':
|
||||
specifier: ^1.1.178
|
||||
version: 1.2.66
|
||||
'@iconify/json':
|
||||
specifier: ^2.2.380
|
||||
version: 2.2.380
|
||||
'@iconify/tailwind':
|
||||
specifier: ^1.1.3
|
||||
version: 1.2.0
|
||||
'@intlify/eslint-plugin-vue-i18n':
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
|
||||
@@ -112,6 +112,7 @@ onlyBuiltDependencies:
|
||||
- '@playwright/browser-chromium'
|
||||
- '@playwright/browser-firefox'
|
||||
- '@playwright/browser-webkit'
|
||||
- '@sentry/cli'
|
||||
- '@tailwindcss/oxide'
|
||||
- esbuild
|
||||
- nx
|
||||
|
||||
4
public/assets/images/comfy-cloud-logo.svg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/assets/images/og-image.png
Normal file
|
After Width: | Height: | Size: 325 KiB |
26
src/App.vue
@@ -16,6 +16,10 @@ import { computed, onMounted } from 'vue'
|
||||
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { t } from '@/i18n'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
@@ -23,6 +27,8 @@ import { electronAPI, isElectron } from './utils/envUtil'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const conflictDetection = useConflictDetection()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const dialogService = useDialogService()
|
||||
const isLoading = computed<boolean>(() => workspaceStore.spinner)
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
workspaceStore.shiftDown = e.shiftKey
|
||||
@@ -48,6 +54,26 @@ onMounted(() => {
|
||||
document.addEventListener('contextmenu', showContextMenu)
|
||||
}
|
||||
|
||||
// Handle Vite preload errors (e.g., when assets are deleted after deployment)
|
||||
window.addEventListener('vite:preloadError', async (_event) => {
|
||||
// Auto-reload if app is not ready or there are no unsaved changes
|
||||
if (!app.vueAppReady || !workflowStore.activeWorkflow?.isModified) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
// Show confirmation dialog if there are unsaved changes
|
||||
await dialogService
|
||||
.confirm({
|
||||
title: t('g.vitePreloadErrorTitle'),
|
||||
message: t('g.vitePreloadErrorMessage')
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (confirmed) {
|
||||
window.location.reload()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Initialize conflict detection in background
|
||||
// This runs async and doesn't block UI setup
|
||||
void conflictDetection.initializeConflictDetection()
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"litegraph_base": {
|
||||
"BACKGROUND_IMAGE": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII=",
|
||||
"CLEAR_BACKGROUND_COLOR": "#222",
|
||||
"CLEAR_BACKGROUND_COLOR": "#141414",
|
||||
"NODE_TITLE_COLOR": "#999",
|
||||
"NODE_SELECTED_TITLE_COLOR": "#FFF",
|
||||
"NODE_TEXT_SIZE": 14,
|
||||
@@ -52,7 +52,7 @@
|
||||
"comfy_base": {
|
||||
"fg-color": "#fff",
|
||||
"bg-color": "#202020",
|
||||
"comfy-menu-bg": "#353535",
|
||||
"comfy-menu-bg": "#171718",
|
||||
"comfy-menu-secondary-bg": "#303030",
|
||||
"comfy-input-bg": "#222",
|
||||
"input-text": "#ddd",
|
||||
|
||||
@@ -68,7 +68,12 @@
|
||||
"content-fg": "#222",
|
||||
"content-hover-bg": "#adadad",
|
||||
"content-hover-fg": "#222",
|
||||
"bar-shadow": "rgba(16, 16, 16, 0.25) 0 0 0.5rem"
|
||||
"bar-shadow": "rgba(16, 16, 16, 0.25) 0 0 0.5rem",
|
||||
"interface-panel-box-shadow": "1px 1px 8px 0 rgba(0, 0, 0, 0.2)",
|
||||
"interface-panel-drop-shadow": "1px 1px 4px rgba(0, 0, 0, 0.4)",
|
||||
"interface-panel-hover-surface": "var(--color-gray-200)",
|
||||
"interface-panel-selected-surface": "color-mix(in srgb, var(--interface-panel-surface) 78%, var(--contrast-mix-color))",
|
||||
"contrast-mix-color": "#000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<div v-if="!workspaceStore.focusMode" class="ml-2 flex pt-1">
|
||||
<div v-if="!workspaceStore.focusMode" class="ml-1 flex gap-x-0.5 pt-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="actionbar-container pointer-events-auto mx-1 flex h-12 items-center rounded-lg px-2 shadow-md"
|
||||
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-[var(--interface-stroke)] px-2 shadow-interface"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
@@ -24,6 +25,7 @@ import { onMounted, ref } from 'vue'
|
||||
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
@@ -48,6 +50,5 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.actionbar-container {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
border: 1px solid var(--p-panel-border-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -48,6 +48,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ComfyRunButton from './ComfyRunButton'
|
||||
@@ -132,6 +133,15 @@ watch(visible, async (newVisible) => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Track run button handle drag start using mousedown on the drag handle.
|
||||
*/
|
||||
useEventListener(dragHandleRef, 'mousedown', () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'actionbar_run_handle_drag_start'
|
||||
})
|
||||
})
|
||||
|
||||
const lastDragState = ref({
|
||||
x: x.value,
|
||||
y: y.value,
|
||||
@@ -258,7 +268,9 @@ const panelClass = computed(() =>
|
||||
cn(
|
||||
'actionbar pointer-events-auto z1000',
|
||||
isDragging.value && 'select-none pointer-events-none',
|
||||
isDocked.value ? 'p-0 static mr-2 border-none bg-transparent' : 'fixed'
|
||||
isDocked.value
|
||||
? 'p-0 static mr-2 border-none bg-transparent'
|
||||
: 'fixed shadow-interface'
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -100,7 +100,7 @@ import BatchCountEdit from '../BatchCountEdit.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
|
||||
const { mode: queueMode } = storeToRefs(useQueueSettingsStore())
|
||||
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
|
||||
const { t } = useI18n()
|
||||
const queueModeMenuItemLookup = computed(() => {
|
||||
@@ -118,6 +118,9 @@ const queueModeMenuItemLookup = computed(() => {
|
||||
label: `${t('menu.run')} (${t('menu.onChange')})`,
|
||||
tooltip: t('menu.onChangeTooltip'),
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_mode_option_run_on_change_selected'
|
||||
})
|
||||
queueMode.value = 'change'
|
||||
}
|
||||
}
|
||||
@@ -128,6 +131,9 @@ const queueModeMenuItemLookup = computed(() => {
|
||||
label: `${t('menu.run')} (${t('menu.instant')})`,
|
||||
tooltip: t('menu.instantTooltip'),
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_mode_option_run_instant_selected'
|
||||
})
|
||||
queueMode.value = 'instant'
|
||||
}
|
||||
}
|
||||
@@ -158,11 +164,18 @@ const queuePrompt = async (e: Event) => {
|
||||
? 'Comfy.QueuePromptFront'
|
||||
: 'Comfy.QueuePrompt'
|
||||
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackRunButton({ subscribe_to_run: false })
|
||||
if (batchCount.value > 1) {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_run_multiple_batches_submitted'
|
||||
})
|
||||
}
|
||||
|
||||
await commandStore.execute(commandId)
|
||||
await commandStore.execute(commandId, {
|
||||
metadata: {
|
||||
subscribe_to_run: false,
|
||||
trigger_source: 'button'
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="subgraph-breadcrumb w-auto drop-shadow-md"
|
||||
class="subgraph-breadcrumb w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
|
||||
:class="{
|
||||
'subgraph-breadcrumb-collapse': collapseTabs,
|
||||
'subgraph-breadcrumb-overflow': overflowingTabs
|
||||
@@ -40,6 +40,7 @@ import { computed, onUpdated, ref, watch } from 'vue'
|
||||
|
||||
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
@@ -73,6 +74,9 @@ const items = computed(() => {
|
||||
const items = navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
|
||||
label: subgraph.name,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_item_selected'
|
||||
})
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
@@ -97,6 +101,9 @@ const home = computed(() => ({
|
||||
key: 'root',
|
||||
isBlueprint: isBlueprint.value,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_root_selected'
|
||||
})
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
@@ -201,8 +208,8 @@ onUpdated(() => {
|
||||
:deep(.p-breadcrumb-separator),
|
||||
:deep(.p-breadcrumb-item) {
|
||||
@apply h-12;
|
||||
border-top: 1px solid var(--p-panel-border-color);
|
||||
border-bottom: 1px solid var(--p-panel-border-color);
|
||||
border-top: 1px solid var(--interface-stroke);
|
||||
border-bottom: 1px solid var(--interface-stroke);
|
||||
background-color: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
@@ -214,7 +221,7 @@ onUpdated(() => {
|
||||
@apply rounded-l-lg;
|
||||
/* Then collapse the root workflow */
|
||||
flex-shrink: 5000;
|
||||
border-left: 1px solid var(--p-panel-border-color);
|
||||
border-left: 1px solid var(--interface-stroke);
|
||||
|
||||
.p-breadcrumb-item-link {
|
||||
padding-left: var(--p-breadcrumb-item-padding);
|
||||
@@ -225,7 +232,7 @@ onUpdated(() => {
|
||||
@apply rounded-r-lg;
|
||||
/* Then collapse the active item */
|
||||
flex-shrink: 1;
|
||||
border-right: 1px solid var(--p-panel-border-color);
|
||||
border-right: 1px solid var(--interface-stroke);
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item-link:hover),
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('UserAvatar', () => {
|
||||
const avatar = wrapper.findComponent(Avatar)
|
||||
expect(avatar.exists()).toBe(true)
|
||||
expect(avatar.props('image')).toBeNull()
|
||||
expect(avatar.props('icon')).toBe('pi pi-user')
|
||||
expect(avatar.props('icon')).toBe('icon-[lucide--user]')
|
||||
})
|
||||
|
||||
it('renders with default icon when provided photo Url is null', () => {
|
||||
@@ -67,7 +67,7 @@ describe('UserAvatar', () => {
|
||||
const avatar = wrapper.findComponent(Avatar)
|
||||
expect(avatar.exists()).toBe(true)
|
||||
expect(avatar.props('image')).toBeNull()
|
||||
expect(avatar.props('icon')).toBe('pi pi-user')
|
||||
expect(avatar.props('icon')).toBe('icon-[lucide--user]')
|
||||
})
|
||||
|
||||
it('falls back to icon when image fails to load', async () => {
|
||||
@@ -82,7 +82,7 @@ describe('UserAvatar', () => {
|
||||
avatar.vm.$emit('error')
|
||||
await nextTick()
|
||||
|
||||
expect(avatar.props('icon')).toBe('pi pi-user')
|
||||
expect(avatar.props('icon')).toBe('icon-[lucide--user]')
|
||||
})
|
||||
|
||||
it('uses provided ariaLabel', () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<Avatar
|
||||
class="bg-interface-panel-selected-surface"
|
||||
:image="photoUrl ?? undefined"
|
||||
:icon="hasAvatar ? undefined : 'pi pi-user'"
|
||||
:icon="hasAvatar ? undefined : 'icon-[lucide--user]'"
|
||||
:pt:icon:class="{ 'size-4': !hasAvatar }"
|
||||
shape="circle"
|
||||
:aria-label="ariaLabel ?? $t('auth.login.userAvatar')"
|
||||
@error="handleImageError"
|
||||
|
||||
@@ -68,17 +68,17 @@
|
||||
</template>
|
||||
</MultiSelect>
|
||||
|
||||
<!-- License Filter -->
|
||||
<!-- Runs On Filter -->
|
||||
<MultiSelect
|
||||
v-model="selectedLicenseObjects"
|
||||
:label="licenseFilterLabel"
|
||||
:options="licenseOptions"
|
||||
v-model="selectedRunsOnObjects"
|
||||
:label="runsOnFilterLabel"
|
||||
:options="runsOnOptions"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--file-text]" />
|
||||
<i class="icon-[lucide--server]" />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
@@ -382,7 +382,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, onBeforeUnmount, provide, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
@@ -403,6 +403,8 @@ import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useLazyPagination } from '@/composables/useLazyPagination'
|
||||
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
@@ -412,10 +414,34 @@ import { createGridStyle } from '@/utils/gridUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { onClose } = defineProps<{
|
||||
const { onClose: originalOnClose } = defineProps<{
|
||||
onClose: () => void
|
||||
}>()
|
||||
|
||||
// Track session time for telemetry
|
||||
const sessionStartTime = ref<number>(0)
|
||||
const templateWasSelected = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
sessionStartTime.value = Date.now()
|
||||
})
|
||||
|
||||
// Wrap onClose to track session end
|
||||
const onClose = () => {
|
||||
if (isCloud) {
|
||||
const timeSpentSeconds = Math.floor(
|
||||
(Date.now() - sessionStartTime.value) / 1000
|
||||
)
|
||||
|
||||
useTelemetry()?.trackTemplateLibraryClosed({
|
||||
template_selected: templateWasSelected.value,
|
||||
time_spent_seconds: timeSpentSeconds
|
||||
})
|
||||
}
|
||||
|
||||
originalOnClose()
|
||||
}
|
||||
|
||||
provide(OnCloseKey, onClose)
|
||||
|
||||
// Workflow templates store and composable
|
||||
@@ -502,12 +528,12 @@ const {
|
||||
searchQuery,
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
selectedLicenses,
|
||||
selectedRunsOn,
|
||||
sortBy,
|
||||
filteredTemplates,
|
||||
availableModels,
|
||||
availableUseCases,
|
||||
availableLicenses,
|
||||
availableRunsOn,
|
||||
filteredCount,
|
||||
totalCount,
|
||||
resetFilters
|
||||
@@ -535,15 +561,15 @@ const selectedUseCaseObjects = computed({
|
||||
}
|
||||
})
|
||||
|
||||
const selectedLicenseObjects = computed({
|
||||
const selectedRunsOnObjects = computed({
|
||||
get() {
|
||||
return selectedLicenses.value.map((license) => ({
|
||||
name: license,
|
||||
value: license
|
||||
return selectedRunsOn.value.map((runsOn) => ({
|
||||
name: runsOn,
|
||||
value: runsOn
|
||||
}))
|
||||
},
|
||||
set(value: { name: string; value: string }[]) {
|
||||
selectedLicenses.value = value.map((item) => item.value)
|
||||
selectedRunsOn.value = value.map((item) => item.value)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -576,10 +602,10 @@ const useCaseOptions = computed(() =>
|
||||
}))
|
||||
)
|
||||
|
||||
const licenseOptions = computed(() =>
|
||||
availableLicenses.value.map((license) => ({
|
||||
name: license,
|
||||
value: license
|
||||
const runsOnOptions = computed(() =>
|
||||
availableRunsOn.value.map((runsOn) => ({
|
||||
name: runsOn,
|
||||
value: runsOn
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -608,14 +634,14 @@ const useCaseFilterLabel = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const licenseFilterLabel = computed(() => {
|
||||
if (selectedLicenseObjects.value.length === 0) {
|
||||
return t('templateWorkflows.licenseFilter', 'License')
|
||||
} else if (selectedLicenseObjects.value.length === 1) {
|
||||
return selectedLicenseObjects.value[0].name
|
||||
const runsOnFilterLabel = computed(() => {
|
||||
if (selectedRunsOnObjects.value.length === 0) {
|
||||
return t('templateWorkflows.runsOnFilter', 'Runs On')
|
||||
} else if (selectedRunsOnObjects.value.length === 1) {
|
||||
return selectedRunsOnObjects.value[0].name
|
||||
} else {
|
||||
return t('templateWorkflows.licensesSelected', {
|
||||
count: selectedLicenseObjects.value.length
|
||||
return t('templateWorkflows.runsOnSelected', {
|
||||
count: selectedRunsOnObjects.value.length
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -682,7 +708,7 @@ watch(
|
||||
sortBy,
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
selectedLicenses
|
||||
selectedRunsOn
|
||||
],
|
||||
() => {
|
||||
resetPagination()
|
||||
@@ -700,6 +726,7 @@ const onLoadWorkflow = async (template: any) => {
|
||||
template.name,
|
||||
getEffectiveSourceModule(template)
|
||||
)
|
||||
templateWasSelected.value = true
|
||||
onClose()
|
||||
} finally {
|
||||
loadingTemplate.value = null
|
||||
|
||||
@@ -61,6 +61,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -86,18 +87,33 @@ const repoOwner = 'comfyanonymous'
|
||||
const repoName = 'ComfyUI'
|
||||
const reportContent = ref('')
|
||||
const reportOpen = ref(false)
|
||||
/**
|
||||
* Open the error report content and track telemetry.
|
||||
*/
|
||||
const showReport = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_show_report_clicked'
|
||||
})
|
||||
reportOpen.value = true
|
||||
}
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const title = computed<string>(
|
||||
() => error.nodeType ?? error.exceptionType ?? t('errorDialog.defaultTitle')
|
||||
)
|
||||
|
||||
/**
|
||||
* Open contact support flow from error dialog and track telemetry.
|
||||
*/
|
||||
const showContactSupport = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
await useCommandStore().execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
<small class="text-center text-muted">
|
||||
{{ t('auth.apiKey.helpText') }}
|
||||
<a
|
||||
:href="`${COMFY_PLATFORM_BASE_URL}/login`"
|
||||
:href="`${comfyPlatformBaseUrl}/login`"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-blue-500"
|
||||
>
|
||||
@@ -145,11 +145,15 @@
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import Message from 'primevue/message'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import {
|
||||
configValueOrDefault,
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { SignInData, SignUpData } from '@/schemas/signInSchema'
|
||||
import { isHostWhitelisted, normalizeHost } from '@/utils/hostWhitelist'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
@@ -168,6 +172,13 @@ const isSecureContext = window.isSecureContext
|
||||
const isSignIn = ref(true)
|
||||
const showApiKeyForm = ref(false)
|
||||
const ssoAllowed = isHostWhitelisted(normalizeHost(window.location.hostname))
|
||||
const comfyPlatformBaseUrl = computed(() =>
|
||||
configValueOrDefault(
|
||||
remoteConfig.value,
|
||||
'comfy_platform_base_url',
|
||||
getComfyPlatformBaseUrl()
|
||||
)
|
||||
)
|
||||
|
||||
const toggleState = () => {
|
||||
isSignIn.value = !isSignIn.value
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
const props = defineProps<{
|
||||
errorMessage: string
|
||||
repoOwner: string
|
||||
@@ -19,7 +21,13 @@ const props = defineProps<{
|
||||
|
||||
const queryString = computed(() => props.errorMessage + ' is:issue')
|
||||
|
||||
/**
|
||||
* Open GitHub issues search and track telemetry.
|
||||
*/
|
||||
const openGitHubIssues = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_find_existing_issues_clicked'
|
||||
})
|
||||
const query = encodeURIComponent(queryString.value)
|
||||
const url = `https://github.com/${props.repoOwner}/${props.repoName}/issues?q=${query}`
|
||||
window.open(url, '_blank')
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<UserCredit text-class="text-3xl font-bold" />
|
||||
<Skeleton v-if="loading" width="2rem" height="2rem" />
|
||||
<Button
|
||||
v-else
|
||||
v-else-if="isActiveSubscription"
|
||||
:label="$t('credits.purchaseCredits')"
|
||||
:loading="loading"
|
||||
@click="handlePurchaseCreditsClick"
|
||||
@@ -92,6 +92,13 @@
|
||||
icon="pi pi-question-circle"
|
||||
@click="handleFaqClick"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('subscription.partnerNodesCredits')"
|
||||
text
|
||||
severity="secondary"
|
||||
icon="pi pi-question-circle"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('credits.messageSupport')"
|
||||
text
|
||||
@@ -116,6 +123,8 @@ 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 { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
@@ -132,6 +141,8 @@ const dialogService = useDialogService()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
@@ -153,6 +164,8 @@ watch(
|
||||
)
|
||||
|
||||
const handlePurchaseCreditsClick = () => {
|
||||
// Track purchase credits entry from Settings > Credits panel
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
@@ -161,6 +174,11 @@ const handleCreditsHistoryClick = async () => {
|
||||
}
|
||||
|
||||
const handleMessageSupport = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'credits_panel'
|
||||
})
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
@@ -168,5 +186,12 @@ const handleFaqClick = () => {
|
||||
window.open('https://docs.comfy.org/tutorials/api-nodes/faq', '_blank')
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const creditHistory = ref<CreditHistoryItemData[]>([])
|
||||
</script>
|
||||
|
||||
@@ -96,6 +96,7 @@ import Message from 'primevue/message'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import {
|
||||
EventType,
|
||||
@@ -159,6 +160,9 @@ const loadEvents = async () => {
|
||||
if (response.totalPages) {
|
||||
pagination.value.totalPages = response.totalPages
|
||||
}
|
||||
|
||||
// Check if a pending top-up has completed
|
||||
useTelemetry()?.checkForCompletedTopup(response.events)
|
||||
} else {
|
||||
error.value = customerEventService.error.value || 'Failed to load events'
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
|
||||
import ApiKeyForm from './ApiKeyForm.vue'
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('ApiKeyForm', () => {
|
||||
const helpText = wrapper.find('small')
|
||||
expect(helpText.text()).toContain('Need an API key?')
|
||||
expect(helpText.find('a').attributes('href')).toBe(
|
||||
`${COMFY_PLATFORM_BASE_URL}/login`
|
||||
`${getComfyPlatformBaseUrl()}/login`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<small class="text-muted">
|
||||
{{ t('auth.apiKey.helpText') }}
|
||||
<a
|
||||
:href="`${COMFY_PLATFORM_BASE_URL}/login`"
|
||||
:href="`${comfyPlatformBaseUrl}/login`"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-blue-500"
|
||||
>
|
||||
@@ -88,7 +88,11 @@ import Message from 'primevue/message'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import {
|
||||
configValueOrDefault,
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
import { apiKeySchema } from '@/schemas/signInSchema'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
@@ -96,6 +100,13 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const apiKeyStore = useApiKeyAuthStore()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const comfyPlatformBaseUrl = computed(() =>
|
||||
configValueOrDefault(
|
||||
remoteConfig.value,
|
||||
'comfy_platform_base_url',
|
||||
getComfyPlatformBaseUrl()
|
||||
)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<Button
|
||||
ref="buttonRef"
|
||||
severity="secondary"
|
||||
class="group h-8 rounded-none! bg-interface-panel-surface p-0 transition-none! hover:rounded-lg! hover:bg-button-hover-surface!"
|
||||
class="group h-8 rounded-none! bg-comfy-menu-bg p-0 transition-none! hover:rounded-lg! hover:bg-interface-button-hover-surface!"
|
||||
:style="buttonStyles"
|
||||
@click="toggle"
|
||||
>
|
||||
<template #default>
|
||||
<div class="flex items-center gap-1 pr-0.5">
|
||||
<div
|
||||
class="rounded-lg bg-button-active-surface p-2 group-hover:bg-button-hover-surface"
|
||||
class="rounded-lg bg-interface-panel-selected-surface p-2 group-hover:bg-interface-button-hover-surface"
|
||||
>
|
||||
<i :class="currentModeIcon" class="block h-4 w-4" />
|
||||
</div>
|
||||
@@ -114,7 +114,7 @@ const popoverPt = computed(() => ({
|
||||
content: {
|
||||
class: [
|
||||
'mb-2 text-text-primary',
|
||||
'shadow-lg border border-node-border',
|
||||
'shadow-lg border border-interface-stroke',
|
||||
'bg-nav-background',
|
||||
'rounded-lg',
|
||||
'p-2 px-3',
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
></div>
|
||||
|
||||
<ButtonGroup
|
||||
class="absolute right-0 bottom-0 z-[1200] flex-row gap-1 border-[1px] border-node-border bg-interface-panel-surface p-2"
|
||||
:style="stringifiedMinimapStyles.buttonGroupStyles"
|
||||
class="absolute right-0 bottom-0 z-[1200] flex-row gap-1 border-[1px] border-interface-stroke bg-comfy-menu-bg p-2"
|
||||
:style="{
|
||||
...stringifiedMinimapStyles.buttonGroupStyles
|
||||
}"
|
||||
@wheel="canvasInteractions.handleWheel"
|
||||
>
|
||||
<CanvasModeSelector
|
||||
@@ -26,7 +28,7 @@
|
||||
icon="pi pi-expand"
|
||||
:aria-label="fitViewTooltip"
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
class="h-8 w-8 bg-interface-panel-surface p-0 hover:bg-button-hover-surface!"
|
||||
class="h-8 w-8 bg-comfy-menu-bg p-0 hover:bg-interface-button-hover-surface!"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.FitView')"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -61,7 +63,7 @@
|
||||
data-testid="toggle-minimap-button"
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
:class="minimapButtonClass"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.ToggleMinimap')"
|
||||
@click="onMinimapToggleClick"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--map] h-4 w-4" />
|
||||
@@ -82,7 +84,7 @@
|
||||
:aria-label="linkVisibilityAriaLabel"
|
||||
data-testid="toggle-link-visibility-button"
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')"
|
||||
@click="onLinkVisibilityToggleClick"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--route-off] h-4 w-4" />
|
||||
@@ -101,6 +103,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useZoomControls } from '@/composables/useZoomControls'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
||||
@@ -163,18 +166,18 @@ const minimapCommandText = computed(() =>
|
||||
|
||||
// Computed properties for button classes and states
|
||||
const zoomButtonClass = computed(() => [
|
||||
'bg-interface-panel-surface',
|
||||
isModalVisible.value ? 'not-active:bg-button-active-surface!' : '',
|
||||
'hover:bg-button-hover-surface!',
|
||||
'bg-comfy-menu-bg',
|
||||
isModalVisible.value ? 'not-active:bg-interface-panel-selected-surface!' : '',
|
||||
'hover:bg-interface-button-hover-surface!',
|
||||
'p-0',
|
||||
'h-8',
|
||||
'w-15'
|
||||
])
|
||||
|
||||
const minimapButtonClass = computed(() => ({
|
||||
'bg-interface-panel-surface': true,
|
||||
'hover:bg-button-hover-surface!': true,
|
||||
'not-active:bg-button-active-surface!': settingStore.get(
|
||||
'bg-comfy-menu-bg': true,
|
||||
'hover:bg-interface-button-hover-surface!': true,
|
||||
'not-active:bg-interface-panel-selected-surface!': settingStore.get(
|
||||
'Comfy.Minimap.Visible'
|
||||
),
|
||||
'p-0': true,
|
||||
@@ -206,9 +209,9 @@ const linkVisibilityAriaLabel = computed(() =>
|
||||
: t('graphCanvasMenu.hideLinks')
|
||||
)
|
||||
const linkVisibleClass = computed(() => [
|
||||
'bg-interface-panel-surface',
|
||||
linkHidden.value ? 'not-active:bg-button-active-surface!' : '',
|
||||
'hover:bg-button-hover-surface!',
|
||||
'bg-comfy-menu-bg',
|
||||
linkHidden.value ? 'not-active:bg-interface-panel-selected-surface!' : '',
|
||||
'hover:bg-interface-button-hover-surface!',
|
||||
'p-0',
|
||||
'w-8',
|
||||
'h-8'
|
||||
@@ -218,6 +221,26 @@ onMounted(() => {
|
||||
canvasStore.initScaleSync()
|
||||
})
|
||||
|
||||
/**
|
||||
* Track minimap toggle button click and execute the command.
|
||||
*/
|
||||
const onMinimapToggleClick = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'graph_menu_minimap_toggle_clicked'
|
||||
})
|
||||
void commandStore.execute('Comfy.Canvas.ToggleMinimap')
|
||||
}
|
||||
|
||||
/**
|
||||
* Track hide/show links button click and execute the command.
|
||||
*/
|
||||
const onLinkVisibilityToggleClick = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'graph_menu_hide_links_toggle_clicked'
|
||||
})
|
||||
void commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
canvasStore.cleanupScaleSync()
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
class="absolute right-0 bottom-[62px] z-1300 flex w-[250px] justify-center border-0! bg-inherit!"
|
||||
>
|
||||
<div
|
||||
class="w-4/5 rounded-lg border border-node-border bg-interface-panel-surface p-2 text-text-primary shadow-lg select-none"
|
||||
class="w-4/5 rounded-lg border border-interface-stroke bg-interface-panel-surface p-2 text-text-primary shadow-lg select-none"
|
||||
:style="filteredMinimapStyles"
|
||||
@click.stop
|
||||
>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
@click="toggleBypass"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--ban] h-4 w-4" />
|
||||
<i class="icon-[lucide--redo-dot] h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
data-testid="info-button"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="toggleHelp"
|
||||
@click="onInfoClick"
|
||||
>
|
||||
<i class="icon-[lucide--info] h-4 w-4" />
|
||||
</Button>
|
||||
@@ -17,6 +17,17 @@
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
const { showNodeHelp: toggleHelp } = useSelectionState()
|
||||
|
||||
/**
|
||||
* Track node info button click and toggle node help.
|
||||
*/
|
||||
const onInfoClick = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'selection_toolbox_node_info_opened'
|
||||
})
|
||||
toggleHelp()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -27,7 +27,33 @@ const props = defineProps<{
|
||||
|
||||
const executionStore = useExecutionStore()
|
||||
const isParentNodeExecuting = ref(true)
|
||||
const formattedText = computed(() => nl2br(linkifyHtml(modelValue.value)))
|
||||
const formattedText = computed(() => {
|
||||
const src = modelValue.value
|
||||
// Turn [[label|url]] into placeholders to avoid interfering with linkifyHtml
|
||||
const tokens: { label: string; url: string }[] = []
|
||||
const holed = src.replace(
|
||||
/\[\[([^|\]]+)\|([^\]]+)\]\]/g,
|
||||
(_m, label, url) => {
|
||||
tokens.push({ label: String(label), url: String(url) })
|
||||
return `__LNK${tokens.length - 1}__`
|
||||
}
|
||||
)
|
||||
|
||||
// Keep current behavior (auto-link bare URLs + \n -> <br>)
|
||||
let html = nl2br(linkifyHtml(holed))
|
||||
|
||||
// Restore placeholders as <a>...</a> (minimal escaping + http default)
|
||||
html = html.replace(/__LNK(\d+)__/g, (_m, i) => {
|
||||
const { label, url } = tokens[+i]
|
||||
const safeHref = url.replace(/"/g, '"')
|
||||
const safeLabel = label.replace(/</g, '<').replace(/>/g, '>')
|
||||
return /^https?:\/\//i.test(url)
|
||||
? `<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${safeLabel}</a>`
|
||||
: safeLabel
|
||||
})
|
||||
|
||||
return html
|
||||
})
|
||||
|
||||
let parentNodeId: NodeId | null = null
|
||||
onMounted(() => {
|
||||
|
||||
@@ -138,13 +138,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import type { CSSProperties, Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -196,6 +197,7 @@ const { t, locale } = useI18n()
|
||||
const releaseStore = useReleaseStore()
|
||||
const commandStore = useCommandStore()
|
||||
const settingStore = useSettingStore()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
@@ -207,6 +209,7 @@ const isSubmenuVisible = ref(false)
|
||||
const submenuRef = ref<HTMLElement | null>(null)
|
||||
const submenuStyle = ref<CSSProperties>({})
|
||||
let hoverTimeout: number | null = null
|
||||
const openedAt = ref<number>(Date.now())
|
||||
|
||||
// Computed
|
||||
const hasReleases = computed(() => releaseStore.releases.length > 0)
|
||||
@@ -226,6 +229,7 @@ const moreItems = computed<MenuItem[]>(() => {
|
||||
label: t('helpCenter.desktopUserGuide'),
|
||||
visible: isElectron(),
|
||||
action: () => {
|
||||
trackResourceClick('docs', true)
|
||||
const docsUrl =
|
||||
electronAPI().getPlatform() === 'darwin'
|
||||
? EXTERNAL_LINKS.DESKTOP_GUIDE_MACOS
|
||||
@@ -281,6 +285,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
icon: 'pi pi-book',
|
||||
label: t('helpCenter.docs'),
|
||||
action: () => {
|
||||
trackResourceClick('docs', true)
|
||||
openExternalLink(EXTERNAL_LINKS.DOCS)
|
||||
emit('close')
|
||||
}
|
||||
@@ -291,6 +296,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
icon: 'pi pi-discord',
|
||||
label: 'Discord',
|
||||
action: () => {
|
||||
trackResourceClick('discord', true)
|
||||
openExternalLink(EXTERNAL_LINKS.DISCORD)
|
||||
emit('close')
|
||||
}
|
||||
@@ -301,6 +307,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
icon: 'pi pi-github',
|
||||
label: t('helpCenter.github'),
|
||||
action: () => {
|
||||
trackResourceClick('github', true)
|
||||
openExternalLink(EXTERNAL_LINKS.GITHUB)
|
||||
emit('close')
|
||||
}
|
||||
@@ -311,6 +318,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
icon: 'pi pi-question-circle',
|
||||
label: t('helpCenter.helpFeedback'),
|
||||
action: () => {
|
||||
trackResourceClick('help_feedback', false)
|
||||
void commandStore.execute('Comfy.ContactSupport')
|
||||
emit('close')
|
||||
}
|
||||
@@ -326,6 +334,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
label: t('helpCenter.managerExtension'),
|
||||
showRedDot: shouldShowManagerRedDot.value,
|
||||
action: async () => {
|
||||
trackResourceClick('manager', false)
|
||||
await useManagerState().openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
showToastOnLegacyError: false
|
||||
@@ -349,6 +358,23 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
})
|
||||
|
||||
// Utility Functions
|
||||
const trackResourceClick = (
|
||||
resourceType:
|
||||
| 'docs'
|
||||
| 'discord'
|
||||
| 'github'
|
||||
| 'help_feedback'
|
||||
| 'manager'
|
||||
| 'release_notes',
|
||||
isExternal: boolean
|
||||
): void => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: resourceType,
|
||||
is_external: isExternal,
|
||||
source: 'help_center'
|
||||
})
|
||||
}
|
||||
|
||||
const openExternalLink = (url: string): void => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
@@ -504,6 +530,7 @@ const onReinstall = (): void => {
|
||||
}
|
||||
|
||||
const onReleaseClick = (release: ReleaseNote): void => {
|
||||
trackResourceClick('release_notes', true)
|
||||
void releaseStore.handleShowChangelog(release.version)
|
||||
const versionAnchor = formatVersionAnchor(release.version)
|
||||
const changelogUrl = `${getChangelogUrl()}#${versionAnchor}`
|
||||
@@ -512,6 +539,7 @@ const onReleaseClick = (release: ReleaseNote): void => {
|
||||
}
|
||||
|
||||
const onUpdate = (_: ReleaseNote): void => {
|
||||
trackResourceClick('docs', true)
|
||||
openExternalLink(EXTERNAL_LINKS.UPDATE_GUIDE)
|
||||
emit('close')
|
||||
}
|
||||
@@ -526,10 +554,16 @@ const getChangelogUrl = (): string => {
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
telemetry?.trackHelpCenterOpened({ source: 'sidebar' })
|
||||
if (!hasReleases.value) {
|
||||
await releaseStore.fetchReleases()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const timeSpentSeconds = Math.round((Date.now() - openedAt.value) / 1000)
|
||||
telemetry?.trackHelpCenterClosed({ time_spent_seconds: timeSpentSeconds })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -4,13 +4,11 @@
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14.8193 0.600586C15.1248 0.600586 15.3296 0.70893 15.459 0.881836C15.5914 1.05888 15.6471 1.33774 15.5527 1.66895L14.8037 4.30176C14.7063 4.64386 14.4729 4.97024 14.1641 5.21191C13.8544 5.45415 13.496 5.58984 13.1699 5.58984H13.1689L9.5791 5.59668H7.90625C7.52654 5.59668 7.19496 5.84986 7.09082 6.21289L5.69434 11.0889C5.63007 11.3133 5.66134 11.5534 5.77734 11.7529L5.83203 11.8359C5.99177 12.0491 6.24252 12.1758 6.50977 12.1758H6.51074L8.88281 12.1709H11.4971C11.7643 12.171 11.9541 12.254 12.084 12.3906L12.1357 12.4521C12.2685 12.6295 12.3249 12.9089 12.2305 13.2402L11.4805 15.8721C11.383 16.2144 11.1498 16.5415 10.8408 16.7832C10.5314 17.0252 10.1736 17.161 9.84766 17.1611H9.84668L6.25684 17.168H3.64258C3.33762 17.1679 3.13349 17.0588 3.00391 16.8857C2.87135 16.7087 2.81482 16.43 2.90918 16.0986L3.39551 14.3887C3.46841 14.1327 3.41794 13.8576 3.25879 13.6445V13.6436C3.09901 13.4303 2.84745 13.3037 2.58008 13.3037H1.18066C0.875088 13.3037 0.670398 13.1953 0.541016 13.0225C0.408483 12.8451 0.351891 12.5655 0.446289 12.2344L2.11914 6.38965L2.30371 5.74707V5.74609C2.40139 5.40341 2.63456 5.07671 2.94336 4.83496C3.25302 4.59258 3.61143 4.45705 3.9375 4.45703H5.6123C5.94484 4.45703 6.24083 4.26316 6.37891 3.9707L6.42773 3.83984L6.98145 1.89551C7.07894 1.55317 7.31212 1.22614 7.62109 0.984375C7.93074 0.742127 8.2892 0.606445 8.61523 0.606445H8.61621L12.1982 0.600586H14.8193Z"
|
||||
:stroke="color"
|
||||
stroke-width="1"
|
||||
v-bind="attributes"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -22,11 +20,18 @@ interface Props {
|
||||
size?: number | string
|
||||
color?: string
|
||||
class?: string
|
||||
mode?: 'outline' | 'fill'
|
||||
}
|
||||
const {
|
||||
size = 16,
|
||||
color = 'currentColor',
|
||||
mode = 'outline',
|
||||
class: className
|
||||
} = defineProps<Props>()
|
||||
const iconClass = computed(() => className || '')
|
||||
const attributes = computed(() => ({
|
||||
stroke: mode === 'outline' ? color : undefined,
|
||||
strokeWidth: mode === 'outline' ? 1 : undefined,
|
||||
fill: mode === 'fill' ? color : 'none'
|
||||
}))
|
||||
</script>
|
||||
@@ -1,71 +1,48 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative h-full w-full"
|
||||
class="widget-expands relative h-full w-full"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
>
|
||||
<Load3DScene
|
||||
v-if="node"
|
||||
ref="load3DSceneRef"
|
||||
:node="node"
|
||||
:input-spec="inputSpec"
|
||||
:background-color="backgroundColor"
|
||||
:show-grid="showGrid"
|
||||
:light-intensity="lightIntensity"
|
||||
:fov="fov"
|
||||
:camera-type="cameraType"
|
||||
:show-preview="showPreview"
|
||||
:background-image="backgroundImage"
|
||||
:up-direction="upDirection"
|
||||
:material-mode="materialMode"
|
||||
:edge-threshold="edgeThreshold"
|
||||
@material-mode-change="listenMaterialModeChange"
|
||||
@background-color-change="listenBackgroundColorChange"
|
||||
@light-intensity-change="listenLightIntensityChange"
|
||||
@fov-change="listenFOVChange"
|
||||
@camera-type-change="listenCameraTypeChange"
|
||||
@show-grid-change="listenShowGridChange"
|
||||
@show-preview-change="listenShowPreviewChange"
|
||||
@background-image-change="listenBackgroundImageChange"
|
||||
@up-direction-change="listenUpDirectionChange"
|
||||
@edge-threshold-change="listenEdgeThresholdChange"
|
||||
@recording-status-change="listenRecordingStatusChange"
|
||||
/>
|
||||
<Load3DControls
|
||||
:input-spec="inputSpec"
|
||||
:background-color="backgroundColor"
|
||||
:show-grid="showGrid"
|
||||
:show-preview="showPreview"
|
||||
:light-intensity="lightIntensity"
|
||||
:show-light-intensity-button="showLightIntensityButton"
|
||||
:fov="fov"
|
||||
:show-f-o-v-button="showFOVButton"
|
||||
:show-preview-button="showPreviewButton"
|
||||
:camera-type="cameraType"
|
||||
:has-background-image="hasBackgroundImage"
|
||||
:up-direction="upDirection"
|
||||
:material-mode="materialMode"
|
||||
:edge-threshold="edgeThreshold"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@switch-camera="switchCamera"
|
||||
@toggle-grid="toggleGrid"
|
||||
@update-background-color="handleBackgroundColorChange"
|
||||
@update-light-intensity="handleUpdateLightIntensity"
|
||||
@toggle-preview="togglePreview"
|
||||
@update-f-o-v="handleUpdateFOV"
|
||||
@update-up-direction="handleUpdateUpDirection"
|
||||
@update-material-mode="handleUpdateMaterialMode"
|
||||
@update-edge-threshold="handleUpdateEdgeThreshold"
|
||||
@export-model="handleExportModel"
|
||||
:initialize-load3d="initializeLoad3d"
|
||||
:cleanup="cleanup"
|
||||
:loading="loading"
|
||||
:loading-message="loadingMessage"
|
||||
:on-model-drop="isPreview ? undefined : handleModelDrop"
|
||||
:is-preview="isPreview"
|
||||
/>
|
||||
<div class="pointer-events-none absolute top-0 left-0 h-full w-full">
|
||||
<Load3DControls
|
||||
v-model:scene-config="sceneConfig"
|
||||
v-model:model-config="modelConfig"
|
||||
v-model:camera-config="cameraConfig"
|
||||
v-model:light-config="lightConfig"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
<AnimationControls
|
||||
v-if="animations && animations.length > 0"
|
||||
v-model:animations="animations"
|
||||
v-model:playing="playing"
|
||||
v-model:selected-speed="selectedSpeed"
|
||||
v-model:selected-animation="selectedAnimation"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="enable3DViewer"
|
||||
v-if="enable3DViewer && node"
|
||||
class="pointer-events-auto absolute top-12 right-2 z-20"
|
||||
>
|
||||
<ViewerControls :node="node" />
|
||||
<ViewerControls :node="node as LGraphNode" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showRecordingControls"
|
||||
v-if="!isPreview"
|
||||
class="pointer-events-auto absolute right-2 z-20"
|
||||
:class="{
|
||||
'top-12': !enable3DViewer,
|
||||
@@ -73,10 +50,9 @@
|
||||
}"
|
||||
>
|
||||
<RecordingControls
|
||||
:node="node"
|
||||
:is-recording="isRecording"
|
||||
:has-recording="hasRecording"
|
||||
:recording-duration="recordingDuration"
|
||||
v-model:is-recording="isRecording"
|
||||
v-model:has-recording="hasRecording"
|
||||
v-model:recording-duration="recordingDuration"
|
||||
@start-recording="handleStartRecording"
|
||||
@stop-recording="handleStopRecording"
|
||||
@export-recording="handleExportRecording"
|
||||
@@ -87,250 +63,79 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import Load3DControls from '@/components/load3d/Load3DControls.vue'
|
||||
import Load3DScene from '@/components/load3d/Load3DScene.vue'
|
||||
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
|
||||
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
|
||||
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type {
|
||||
CameraType,
|
||||
Load3DNodeType,
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { useLoad3d } from '@/composables/useLoad3d'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { widget } = defineProps<{
|
||||
widget: ComponentWidget<string[]>
|
||||
const props = defineProps<{
|
||||
widget: ComponentWidget<string[]> | SimplifiedWidget
|
||||
nodeId?: NodeId
|
||||
}>()
|
||||
|
||||
const inputSpec = widget.inputSpec as CustomInputSpec
|
||||
function isComponentWidget(
|
||||
widget: ComponentWidget<string[]> | SimplifiedWidget
|
||||
): widget is ComponentWidget<string[]> {
|
||||
return 'node' in widget && widget.node !== undefined
|
||||
}
|
||||
|
||||
const node = widget.node
|
||||
const type = inputSpec.type as Load3DNodeType
|
||||
const node = ref<LGraphNode | null>(null)
|
||||
|
||||
if (isComponentWidget(props.widget)) {
|
||||
node.value = props.widget.node
|
||||
} else if (props.nodeId) {
|
||||
onMounted(() => {
|
||||
node.value = app.rootGraph?.getNodeById(props.nodeId!) || null
|
||||
})
|
||||
}
|
||||
|
||||
const backgroundColor = ref('#000000')
|
||||
const showGrid = ref(true)
|
||||
const showPreview = ref(false)
|
||||
const lightIntensity = ref(5)
|
||||
const showLightIntensityButton = ref(true)
|
||||
const fov = ref(75)
|
||||
const showFOVButton = ref(true)
|
||||
const cameraType = ref<CameraType>('perspective')
|
||||
const hasBackgroundImage = ref(false)
|
||||
const backgroundImage = ref('')
|
||||
const upDirection = ref<UpDirection>('original')
|
||||
const materialMode = ref<MaterialMode>('original')
|
||||
const edgeThreshold = ref(85)
|
||||
const load3DSceneRef = ref<InstanceType<typeof Load3DScene> | null>(null)
|
||||
const isRecording = ref(false)
|
||||
const hasRecording = ref(false)
|
||||
const recordingDuration = ref(0)
|
||||
const showRecordingControls = ref(!inputSpec.isPreview)
|
||||
|
||||
const {
|
||||
// config
|
||||
sceneConfig,
|
||||
modelConfig,
|
||||
cameraConfig,
|
||||
lightConfig,
|
||||
|
||||
// other state
|
||||
isRecording,
|
||||
isPreview,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
animations,
|
||||
playing,
|
||||
selectedSpeed,
|
||||
selectedAnimation,
|
||||
loading,
|
||||
loadingMessage,
|
||||
|
||||
// Methods
|
||||
initializeLoad3d,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
handleStartRecording,
|
||||
handleStopRecording,
|
||||
handleExportRecording,
|
||||
handleClearRecording,
|
||||
handleBackgroundImageUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
cleanup
|
||||
} = useLoad3d(node as Ref<LGraphNode | null>)
|
||||
|
||||
const enable3DViewer = computed(() =>
|
||||
useSettingStore().get('Comfy.Load3D.3DViewerEnable')
|
||||
)
|
||||
|
||||
const showPreviewButton = computed(() => {
|
||||
return !type.includes('Preview')
|
||||
})
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (load3DSceneRef.value?.load3d) {
|
||||
load3DSceneRef.value.load3d.updateStatusMouseOnScene(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (load3DSceneRef.value?.load3d) {
|
||||
load3DSceneRef.value.load3d.updateStatusMouseOnScene(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartRecording = async () => {
|
||||
if (load3DSceneRef.value?.load3d) {
|
||||
await load3DSceneRef.value.load3d.startRecording()
|
||||
isRecording.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopRecording = () => {
|
||||
if (load3DSceneRef.value?.load3d) {
|
||||
load3DSceneRef.value.load3d.stopRecording()
|
||||
isRecording.value = false
|
||||
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportRecording = () => {
|
||||
if (load3DSceneRef.value?.load3d) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `${timestamp}-scene-recording.mp4`
|
||||
load3DSceneRef.value.load3d.exportRecording(filename)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearRecording = () => {
|
||||
if (load3DSceneRef.value?.load3d) {
|
||||
load3DSceneRef.value.load3d.clearRecording()
|
||||
hasRecording.value = false
|
||||
recordingDuration.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const switchCamera = () => {
|
||||
cameraType.value =
|
||||
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
|
||||
|
||||
showFOVButton.value = cameraType.value === 'perspective'
|
||||
|
||||
node.properties['Camera Type'] = cameraType.value
|
||||
}
|
||||
|
||||
const togglePreview = (value: boolean) => {
|
||||
showPreview.value = value
|
||||
|
||||
node.properties['Show Preview'] = showPreview.value
|
||||
}
|
||||
|
||||
const toggleGrid = (value: boolean) => {
|
||||
showGrid.value = value
|
||||
|
||||
node.properties['Show Grid'] = showGrid.value
|
||||
}
|
||||
|
||||
const handleUpdateLightIntensity = (value: number) => {
|
||||
lightIntensity.value = value
|
||||
|
||||
node.properties['Light Intensity'] = lightIntensity.value
|
||||
}
|
||||
|
||||
const handleBackgroundImageUpdate = async (file: File | null) => {
|
||||
if (!file) {
|
||||
hasBackgroundImage.value = false
|
||||
backgroundImage.value = ''
|
||||
node.properties['Background Image'] = ''
|
||||
return
|
||||
}
|
||||
|
||||
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
|
||||
|
||||
const subfolder = resourceFolder.trim() ? `3d/${resourceFolder.trim()}` : '3d'
|
||||
|
||||
backgroundImage.value = await Load3dUtils.uploadFile(file, subfolder)
|
||||
|
||||
node.properties['Background Image'] = backgroundImage.value
|
||||
}
|
||||
|
||||
const handleUpdateFOV = (value: number) => {
|
||||
fov.value = value
|
||||
|
||||
node.properties['FOV'] = fov.value
|
||||
}
|
||||
|
||||
const handleUpdateEdgeThreshold = (value: number) => {
|
||||
edgeThreshold.value = value
|
||||
|
||||
node.properties['Edge Threshold'] = edgeThreshold.value
|
||||
}
|
||||
|
||||
const handleBackgroundColorChange = (value: string) => {
|
||||
backgroundColor.value = value
|
||||
|
||||
node.properties['Background Color'] = value
|
||||
}
|
||||
|
||||
const handleUpdateUpDirection = (value: UpDirection) => {
|
||||
upDirection.value = value
|
||||
|
||||
node.properties['Up Direction'] = value
|
||||
}
|
||||
|
||||
const handleUpdateMaterialMode = (value: MaterialMode) => {
|
||||
materialMode.value = value
|
||||
|
||||
node.properties['Material Mode'] = value
|
||||
}
|
||||
|
||||
const handleExportModel = async (format: string) => {
|
||||
if (!load3DSceneRef.value?.load3d) {
|
||||
useToastStore().addAlert(t('toastMessages.no3dSceneToExport'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await load3DSceneRef.value.load3d.exportModel(format)
|
||||
} catch (error) {
|
||||
console.error('Error exporting model:', error)
|
||||
useToastStore().addAlert(
|
||||
t('toastMessages.failedToExportModel', {
|
||||
format: format.toUpperCase()
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const listenMaterialModeChange = (mode: MaterialMode) => {
|
||||
materialMode.value = mode
|
||||
|
||||
showLightIntensityButton.value = mode === 'original'
|
||||
}
|
||||
|
||||
const listenUpDirectionChange = (value: UpDirection) => {
|
||||
upDirection.value = value
|
||||
}
|
||||
|
||||
const listenEdgeThresholdChange = (value: number) => {
|
||||
edgeThreshold.value = value
|
||||
}
|
||||
|
||||
const listenRecordingStatusChange = (value: boolean) => {
|
||||
isRecording.value = value
|
||||
|
||||
if (!value && load3DSceneRef.value?.load3d) {
|
||||
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
}
|
||||
}
|
||||
|
||||
const listenBackgroundColorChange = (value: string) => {
|
||||
backgroundColor.value = value
|
||||
}
|
||||
|
||||
const listenLightIntensityChange = (value: number) => {
|
||||
lightIntensity.value = value
|
||||
}
|
||||
|
||||
const listenFOVChange = (value: number) => {
|
||||
fov.value = value
|
||||
}
|
||||
|
||||
const listenCameraTypeChange = (value: CameraType) => {
|
||||
cameraType.value = value
|
||||
showFOVButton.value = cameraType.value === 'perspective'
|
||||
}
|
||||
|
||||
const listenShowGridChange = (value: boolean) => {
|
||||
showGrid.value = value
|
||||
}
|
||||
|
||||
const listenShowPreviewChange = (value: boolean) => {
|
||||
showPreview.value = value
|
||||
}
|
||||
|
||||
const listenBackgroundImageChange = (value: string) => {
|
||||
backgroundImage.value = value
|
||||
|
||||
if (backgroundImage.value && backgroundImage.value !== '') {
|
||||
hasBackgroundImage.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative h-full w-full"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<Load3DAnimationScene
|
||||
ref="load3DAnimationSceneRef"
|
||||
:node="node"
|
||||
:input-spec="inputSpec"
|
||||
:background-color="backgroundColor"
|
||||
:show-grid="showGrid"
|
||||
:light-intensity="lightIntensity"
|
||||
:fov="fov"
|
||||
:camera-type="cameraType"
|
||||
:show-preview="showPreview"
|
||||
:show-f-o-v-button="showFOVButton"
|
||||
:show-light-intensity-button="showLightIntensityButton"
|
||||
:playing="playing"
|
||||
:selected-speed="selectedSpeed"
|
||||
:selected-animation="selectedAnimation"
|
||||
:background-image="backgroundImage"
|
||||
:up-direction="upDirection"
|
||||
:material-mode="materialMode"
|
||||
@material-mode-change="listenMaterialModeChange"
|
||||
@background-color-change="listenBackgroundColorChange"
|
||||
@light-intensity-change="listenLightIntensityChange"
|
||||
@fov-change="listenFOVChange"
|
||||
@camera-type-change="listenCameraTypeChange"
|
||||
@show-grid-change="listenShowGridChange"
|
||||
@show-preview-change="listenShowPreviewChange"
|
||||
@background-image-change="listenBackgroundImageChange"
|
||||
@animation-list-change="animationListChange"
|
||||
@up-direction-change="listenUpDirectionChange"
|
||||
@recording-status-change="listenRecordingStatusChange"
|
||||
/>
|
||||
<div class="pointer-events-none absolute top-0 left-0 h-full w-full">
|
||||
<Load3DControls
|
||||
:input-spec="inputSpec"
|
||||
:background-color="backgroundColor"
|
||||
:show-grid="showGrid"
|
||||
:show-preview="showPreview"
|
||||
:light-intensity="lightIntensity"
|
||||
:show-light-intensity-button="showLightIntensityButton"
|
||||
:fov="fov"
|
||||
:show-f-o-v-button="showFOVButton"
|
||||
:show-preview-button="showPreviewButton"
|
||||
:camera-type="cameraType"
|
||||
:has-background-image="hasBackgroundImage"
|
||||
:up-direction="upDirection"
|
||||
:material-mode="materialMode"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@switch-camera="switchCamera"
|
||||
@toggle-grid="toggleGrid"
|
||||
@update-background-color="handleBackgroundColorChange"
|
||||
@update-light-intensity="handleUpdateLightIntensity"
|
||||
@toggle-preview="togglePreview"
|
||||
@update-f-o-v="handleUpdateFOV"
|
||||
@update-up-direction="handleUpdateUpDirection"
|
||||
@update-material-mode="handleUpdateMaterialMode"
|
||||
/>
|
||||
<Load3DAnimationControls
|
||||
:animations="animations"
|
||||
:playing="playing"
|
||||
@toggle-play="togglePlay"
|
||||
@speed-change="speedChange"
|
||||
@animation-change="animationChange"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showRecordingControls"
|
||||
class="pointer-events-auto absolute top-12 right-2 z-20"
|
||||
>
|
||||
<RecordingControls
|
||||
:node="node"
|
||||
:is-recording="isRecording"
|
||||
:has-recording="hasRecording"
|
||||
:recording-duration="recordingDuration"
|
||||
@start-recording="handleStartRecording"
|
||||
@stop-recording="handleStopRecording"
|
||||
@export-recording="handleExportRecording"
|
||||
@clear-recording="handleClearRecording"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Load3DAnimationControls from '@/components/load3d/Load3DAnimationControls.vue'
|
||||
import Load3DAnimationScene from '@/components/load3d/Load3DAnimationScene.vue'
|
||||
import Load3DControls from '@/components/load3d/Load3DControls.vue'
|
||||
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type {
|
||||
AnimationItem,
|
||||
CameraType,
|
||||
Load3DAnimationNodeType,
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: ComponentWidget<string[]>
|
||||
}>()
|
||||
|
||||
const inputSpec = widget.inputSpec as CustomInputSpec
|
||||
|
||||
const node = widget.node
|
||||
const type = inputSpec.type as Load3DAnimationNodeType
|
||||
|
||||
const backgroundColor = ref('#000000')
|
||||
const showGrid = ref(true)
|
||||
const showPreview = ref(false)
|
||||
const lightIntensity = ref(5)
|
||||
const showLightIntensityButton = ref(true)
|
||||
const fov = ref(75)
|
||||
const showFOVButton = ref(true)
|
||||
const cameraType = ref<'perspective' | 'orthographic'>('perspective')
|
||||
const hasBackgroundImage = ref(false)
|
||||
|
||||
const animations = ref<AnimationItem[]>([])
|
||||
const playing = ref(false)
|
||||
const selectedSpeed = ref(1)
|
||||
const selectedAnimation = ref(0)
|
||||
const backgroundImage = ref('')
|
||||
|
||||
const isRecording = ref(false)
|
||||
const hasRecording = ref(false)
|
||||
const recordingDuration = ref(0)
|
||||
const showRecordingControls = ref(!inputSpec.isPreview)
|
||||
|
||||
const showPreviewButton = computed(() => {
|
||||
return !type.includes('Preview')
|
||||
})
|
||||
|
||||
const load3DAnimationSceneRef = ref<InstanceType<
|
||||
typeof Load3DAnimationScene
|
||||
> | null>(null)
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
|
||||
if (sceneRef?.load3d) {
|
||||
sceneRef.load3d.updateStatusMouseOnScene(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
|
||||
if (sceneRef?.load3d) {
|
||||
sceneRef.load3d.updateStatusMouseOnScene(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartRecording = async () => {
|
||||
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
|
||||
if (sceneRef?.load3d) {
|
||||
await sceneRef.load3d.startRecording()
|
||||
isRecording.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopRecording = () => {
|
||||
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
|
||||
if (sceneRef?.load3d) {
|
||||
sceneRef.load3d.stopRecording()
|
||||
isRecording.value = false
|
||||
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportRecording = () => {
|
||||
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
|
||||
if (sceneRef?.load3d) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `${timestamp}-animation-recording.mp4`
|
||||
sceneRef.load3d.exportRecording(filename)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearRecording = () => {
|
||||
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
|
||||
if (sceneRef?.load3d) {
|
||||
sceneRef.load3d.clearRecording()
|
||||
hasRecording.value = false
|
||||
recordingDuration.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const listenRecordingStatusChange = (value: boolean) => {
|
||||
isRecording.value = value
|
||||
|
||||
if (!value) {
|
||||
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
|
||||
if (sceneRef?.load3d) {
|
||||
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const switchCamera = () => {
|
||||
cameraType.value =
|
||||
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
|
||||
|
||||
showFOVButton.value = cameraType.value === 'perspective'
|
||||
|
||||
node.properties['Camera Type'] = cameraType.value
|
||||
}
|
||||
|
||||
const togglePreview = (value: boolean) => {
|
||||
showPreview.value = value
|
||||
|
||||
node.properties['Show Preview'] = showPreview.value
|
||||
}
|
||||
|
||||
const toggleGrid = (value: boolean) => {
|
||||
showGrid.value = value
|
||||
|
||||
node.properties['Show Grid'] = showGrid.value
|
||||
}
|
||||
|
||||
const handleUpdateLightIntensity = (value: number) => {
|
||||
lightIntensity.value = value
|
||||
|
||||
node.properties['Light Intensity'] = lightIntensity.value
|
||||
}
|
||||
|
||||
const handleBackgroundImageUpdate = async (file: File | null) => {
|
||||
if (!file) {
|
||||
hasBackgroundImage.value = false
|
||||
backgroundImage.value = ''
|
||||
node.properties['Background Image'] = ''
|
||||
return
|
||||
}
|
||||
|
||||
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
|
||||
|
||||
const subfolder = resourceFolder.trim() ? `3d/${resourceFolder.trim()}` : '3d'
|
||||
|
||||
backgroundImage.value = await Load3dUtils.uploadFile(file, subfolder)
|
||||
|
||||
node.properties['Background Image'] = backgroundImage.value
|
||||
}
|
||||
|
||||
const handleUpdateFOV = (value: number) => {
|
||||
fov.value = value
|
||||
|
||||
node.properties['FOV'] = fov.value
|
||||
}
|
||||
|
||||
const materialMode = ref<MaterialMode>('original')
|
||||
const upDirection = ref<UpDirection>('original')
|
||||
|
||||
const handleUpdateUpDirection = (value: UpDirection) => {
|
||||
upDirection.value = value
|
||||
|
||||
node.properties['Up Direction'] = value
|
||||
}
|
||||
|
||||
const handleUpdateMaterialMode = (value: MaterialMode) => {
|
||||
materialMode.value = value
|
||||
|
||||
node.properties['Material Mode'] = value
|
||||
}
|
||||
|
||||
const handleBackgroundColorChange = (value: string) => {
|
||||
backgroundColor.value = value
|
||||
|
||||
node.properties['Background Color'] = value
|
||||
}
|
||||
|
||||
const togglePlay = (value: boolean) => {
|
||||
playing.value = value
|
||||
}
|
||||
|
||||
const speedChange = (value: number) => {
|
||||
selectedSpeed.value = value
|
||||
}
|
||||
|
||||
const animationChange = (value: number) => {
|
||||
selectedAnimation.value = value
|
||||
}
|
||||
|
||||
const animationListChange = (value: any) => {
|
||||
animations.value = value
|
||||
}
|
||||
|
||||
const listenMaterialModeChange = (mode: MaterialMode) => {
|
||||
materialMode.value = mode
|
||||
|
||||
showLightIntensityButton.value = mode === 'original'
|
||||
}
|
||||
|
||||
const listenUpDirectionChange = (value: UpDirection) => {
|
||||
upDirection.value = value
|
||||
}
|
||||
|
||||
const listenBackgroundColorChange = (value: string) => {
|
||||
backgroundColor.value = value
|
||||
}
|
||||
|
||||
const listenLightIntensityChange = (value: number) => {
|
||||
lightIntensity.value = value
|
||||
}
|
||||
|
||||
const listenFOVChange = (value: number) => {
|
||||
fov.value = value
|
||||
}
|
||||
|
||||
const listenCameraTypeChange = (value: CameraType) => {
|
||||
cameraType.value = value
|
||||
|
||||
showFOVButton.value = cameraType.value === 'perspective'
|
||||
}
|
||||
|
||||
const listenShowGridChange = (value: boolean) => {
|
||||
showGrid.value = value
|
||||
}
|
||||
|
||||
const listenShowPreviewChange = (value: boolean) => {
|
||||
showPreview.value = value
|
||||
}
|
||||
|
||||
const listenBackgroundImageChange = (value: string) => {
|
||||
backgroundImage.value = value
|
||||
|
||||
if (backgroundImage.value && backgroundImage.value !== '') {
|
||||
hasBackgroundImage.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,208 +0,0 @@
|
||||
<template>
|
||||
<Load3DScene
|
||||
ref="load3DSceneRef"
|
||||
:node="node"
|
||||
:input-spec="inputSpec"
|
||||
:background-color="backgroundColor"
|
||||
:show-grid="showGrid"
|
||||
:light-intensity="lightIntensity"
|
||||
:fov="fov"
|
||||
:camera-type="cameraType"
|
||||
:show-preview="showPreview"
|
||||
:extra-listeners="animationListeners"
|
||||
:background-image="backgroundImage"
|
||||
:up-direction="upDirection"
|
||||
:material-mode="materialMode"
|
||||
@material-mode-change="listenMaterialModeChange"
|
||||
@background-color-change="listenBackgroundColorChange"
|
||||
@light-intensity-change="listenLightIntensityChange"
|
||||
@fov-change="listenFOVChange"
|
||||
@camera-type-change="listenCameraTypeChange"
|
||||
@show-grid-change="listenShowGridChange"
|
||||
@show-preview-change="listenShowPreviewChange"
|
||||
@recording-status-change="listenRecordingStatusChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import Load3DScene from '@/components/load3d/Load3DScene.vue'
|
||||
import type Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
|
||||
import type {
|
||||
CameraType,
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
const props = defineProps<{
|
||||
node: any
|
||||
inputSpec: CustomInputSpec
|
||||
backgroundColor: string
|
||||
showGrid: boolean
|
||||
lightIntensity: number
|
||||
fov: number
|
||||
cameraType: CameraType
|
||||
showPreview: boolean
|
||||
materialMode: MaterialMode
|
||||
upDirection: UpDirection
|
||||
showFOVButton: boolean
|
||||
showLightIntensityButton: boolean
|
||||
playing: boolean
|
||||
selectedSpeed: number
|
||||
selectedAnimation: number
|
||||
backgroundImage: string
|
||||
}>()
|
||||
|
||||
const node = ref(props.node)
|
||||
const backgroundColor = ref(props.backgroundColor)
|
||||
const showPreview = ref(props.showPreview)
|
||||
const fov = ref(props.fov)
|
||||
const lightIntensity = ref(props.lightIntensity)
|
||||
const cameraType = ref(props.cameraType)
|
||||
const showGrid = ref(props.showGrid)
|
||||
const upDirection = ref(props.upDirection)
|
||||
const materialMode = ref(props.materialMode)
|
||||
const showFOVButton = ref(props.showFOVButton)
|
||||
const showLightIntensityButton = ref(props.showLightIntensityButton)
|
||||
const load3DSceneRef = ref<InstanceType<typeof Load3DScene> | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.cameraType,
|
||||
(newValue) => {
|
||||
cameraType.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showGrid,
|
||||
(newValue) => {
|
||||
showGrid.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.backgroundColor,
|
||||
(newValue) => {
|
||||
backgroundColor.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.lightIntensity,
|
||||
(newValue) => {
|
||||
lightIntensity.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.fov,
|
||||
(newValue) => {
|
||||
fov.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.upDirection,
|
||||
(newValue) => {
|
||||
upDirection.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.materialMode,
|
||||
(newValue) => {
|
||||
materialMode.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showPreview,
|
||||
(newValue) => {
|
||||
showPreview.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.playing,
|
||||
(newValue) => {
|
||||
const load3d = load3DSceneRef.value?.load3d as Load3dAnimation | null
|
||||
load3d?.toggleAnimation(newValue)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.selectedSpeed,
|
||||
(newValue) => {
|
||||
const load3d = load3DSceneRef.value?.load3d as Load3dAnimation | null
|
||||
load3d?.setAnimationSpeed(newValue)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.selectedAnimation,
|
||||
(newValue) => {
|
||||
const load3d = load3DSceneRef.value?.load3d as Load3dAnimation | null
|
||||
load3d?.updateSelectedAnimation(newValue)
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'animationListChange', animationList: string): void
|
||||
(e: 'materialModeChange', materialMode: MaterialMode): void
|
||||
(e: 'backgroundColorChange', color: string): void
|
||||
(e: 'lightIntensityChange', lightIntensity: number): void
|
||||
(e: 'fovChange', fov: number): void
|
||||
(e: 'cameraTypeChange', cameraType: CameraType): void
|
||||
(e: 'showGridChange', showGrid: boolean): void
|
||||
(e: 'showPreviewChange', showPreview: boolean): void
|
||||
(e: 'upDirectionChange', direction: UpDirection): void
|
||||
(e: 'recording-status-change', status: boolean): void
|
||||
}>()
|
||||
|
||||
const listenMaterialModeChange = (mode: MaterialMode) => {
|
||||
materialMode.value = mode
|
||||
|
||||
showLightIntensityButton.value = mode === 'original'
|
||||
}
|
||||
|
||||
const listenBackgroundColorChange = (value: string) => {
|
||||
backgroundColor.value = value
|
||||
}
|
||||
|
||||
const listenLightIntensityChange = (value: number) => {
|
||||
lightIntensity.value = value
|
||||
}
|
||||
|
||||
const listenFOVChange = (value: number) => {
|
||||
fov.value = value
|
||||
}
|
||||
|
||||
const listenCameraTypeChange = (value: CameraType) => {
|
||||
cameraType.value = value
|
||||
|
||||
showFOVButton.value = cameraType.value === 'perspective'
|
||||
}
|
||||
|
||||
const listenShowGridChange = (value: boolean) => {
|
||||
showGrid.value = value
|
||||
}
|
||||
|
||||
const listenShowPreviewChange = (value: boolean) => {
|
||||
showPreview.value = value
|
||||
}
|
||||
|
||||
const listenRecordingStatusChange = (value: boolean) => {
|
||||
emit('recording-status-change', value)
|
||||
}
|
||||
|
||||
const animationListeners = {
|
||||
animationListChange: (newValue: any) => {
|
||||
emit('animationListChange', newValue)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
load3DSceneRef
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<div
|
||||
class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-smoke-700/30"
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
@wheel.stop
|
||||
>
|
||||
<div class="show-menu relative">
|
||||
<Button class="p-button-rounded p-button-text" @click="toggleMenu">
|
||||
@@ -20,7 +24,9 @@
|
||||
@click="selectCategory(category)"
|
||||
>
|
||||
<i :class="getCategoryIcon(category)" />
|
||||
<span class="text-white">{{ t(categoryLabels[category]) }}</span>
|
||||
<span class="whitespace-nowrap text-white">{{
|
||||
$t(categoryLabels[category])
|
||||
}}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,71 +34,47 @@
|
||||
|
||||
<div v-show="activeCategory" class="rounded-lg bg-smoke-700/30">
|
||||
<SceneControls
|
||||
v-if="activeCategory === 'scene'"
|
||||
v-if="showSceneControls"
|
||||
ref="sceneControlsRef"
|
||||
:background-color="backgroundColor"
|
||||
:show-grid="showGrid"
|
||||
:has-background-image="hasBackgroundImage"
|
||||
@toggle-grid="handleToggleGrid"
|
||||
@update-background-color="handleBackgroundColorChange"
|
||||
v-model:show-grid="sceneConfig!.showGrid"
|
||||
v-model:background-color="sceneConfig!.backgroundColor"
|
||||
v-model:background-image="sceneConfig!.backgroundImage"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
/>
|
||||
|
||||
<ModelControls
|
||||
v-if="activeCategory === 'model'"
|
||||
v-if="showModelControls"
|
||||
ref="modelControlsRef"
|
||||
:input-spec="inputSpec"
|
||||
:up-direction="upDirection"
|
||||
:material-mode="materialMode"
|
||||
:edge-threshold="edgeThreshold"
|
||||
@update-up-direction="handleUpdateUpDirection"
|
||||
@update-material-mode="handleUpdateMaterialMode"
|
||||
@update-edge-threshold="handleUpdateEdgeThreshold"
|
||||
v-model:material-mode="modelConfig!.materialMode"
|
||||
v-model:up-direction="modelConfig!.upDirection"
|
||||
/>
|
||||
|
||||
<CameraControls
|
||||
v-if="activeCategory === 'camera'"
|
||||
v-if="showCameraControls"
|
||||
ref="cameraControlsRef"
|
||||
:camera-type="cameraType"
|
||||
:fov="fov"
|
||||
:show-f-o-v-button="showFOVButton"
|
||||
@switch-camera="switchCamera"
|
||||
@update-f-o-v="handleUpdateFOV"
|
||||
v-model:camera-type="cameraConfig!.cameraType"
|
||||
v-model:fov="cameraConfig!.fov"
|
||||
/>
|
||||
|
||||
<LightControls
|
||||
v-if="activeCategory === 'light'"
|
||||
v-if="showLightControls"
|
||||
ref="lightControlsRef"
|
||||
:light-intensity="lightIntensity"
|
||||
:show-light-intensity-button="showLightIntensityButton"
|
||||
@update-light-intensity="handleUpdateLightIntensity"
|
||||
v-model:light-intensity="lightConfig!.intensity"
|
||||
v-model:material-mode="modelConfig!.materialMode"
|
||||
/>
|
||||
|
||||
<ExportControls
|
||||
v-if="activeCategory === 'export'"
|
||||
v-if="showExportControls"
|
||||
ref="exportControlsRef"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showPreviewButton">
|
||||
<Button class="p-button-rounded p-button-text" @click="togglePreview">
|
||||
<i
|
||||
v-tooltip.right="{ value: t('load3d.previewOutput'), showDelay: 300 }"
|
||||
:class="[
|
||||
'pi',
|
||||
showPreview ? 'pi-eye' : 'pi-eye-slash',
|
||||
'text-lg text-white'
|
||||
]"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from 'primevue'
|
||||
import Button from 'primevue/button'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
|
||||
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
|
||||
@@ -100,31 +82,16 @@ import LightControls from '@/components/load3d/controls/LightControls.vue'
|
||||
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
|
||||
import SceneControls from '@/components/load3d/controls/SceneControls.vue'
|
||||
import type {
|
||||
CameraType,
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
CameraConfig,
|
||||
LightConfig,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
const vTooltip = Tooltip
|
||||
|
||||
const props = defineProps<{
|
||||
inputSpec: CustomInputSpec
|
||||
backgroundColor: string
|
||||
showGrid: boolean
|
||||
showPreview: boolean
|
||||
lightIntensity: number
|
||||
showLightIntensityButton: boolean
|
||||
fov: number
|
||||
showFOVButton: boolean
|
||||
showPreviewButton: boolean
|
||||
cameraType: CameraType
|
||||
hasBackgroundImage?: boolean
|
||||
upDirection: UpDirection
|
||||
materialMode: MaterialMode
|
||||
edgeThreshold?: number
|
||||
}>()
|
||||
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
|
||||
const modelConfig = defineModel<ModelConfig>('modelConfig')
|
||||
const cameraConfig = defineModel<CameraConfig>('cameraConfig')
|
||||
const lightConfig = defineModel<LightConfig>('lightConfig')
|
||||
|
||||
const isMenuOpen = ref(false)
|
||||
const activeCategory = ref<string>('scene')
|
||||
@@ -137,15 +104,26 @@ const categoryLabels: Record<string, string> = {
|
||||
}
|
||||
|
||||
const availableCategories = computed(() => {
|
||||
const baseCategories = ['scene', 'model', 'camera', 'light']
|
||||
|
||||
if (!props.inputSpec.isAnimation) {
|
||||
return [...baseCategories, 'export']
|
||||
}
|
||||
|
||||
return baseCategories
|
||||
return ['scene', 'model', 'camera', 'light', 'export']
|
||||
})
|
||||
|
||||
const showSceneControls = computed(
|
||||
() => activeCategory.value === 'scene' && !!sceneConfig.value
|
||||
)
|
||||
const showModelControls = computed(
|
||||
() => activeCategory.value === 'model' && !!modelConfig.value
|
||||
)
|
||||
const showCameraControls = computed(
|
||||
() => activeCategory.value === 'camera' && !!cameraConfig.value
|
||||
)
|
||||
const showLightControls = computed(
|
||||
() =>
|
||||
activeCategory.value === 'light' &&
|
||||
!!lightConfig.value &&
|
||||
!!modelConfig.value
|
||||
)
|
||||
const showExportControls = computed(() => activeCategory.value === 'export')
|
||||
|
||||
const toggleMenu = () => {
|
||||
isMenuOpen.value = !isMenuOpen.value
|
||||
}
|
||||
@@ -168,73 +146,14 @@ const getCategoryIcon = (category: string) => {
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'switchCamera'): void
|
||||
(e: 'toggleGrid', value: boolean): void
|
||||
(e: 'updateBackgroundColor', color: string): void
|
||||
(e: 'updateLightIntensity', value: number): void
|
||||
(e: 'updateFOV', value: number): void
|
||||
(e: 'togglePreview', value: boolean): void
|
||||
(e: 'updateBackgroundImage', file: File | null): void
|
||||
(e: 'updateUpDirection', direction: UpDirection): void
|
||||
(e: 'updateMaterialMode', mode: MaterialMode): void
|
||||
(e: 'updateEdgeThreshold', value: number): void
|
||||
(e: 'exportModel', format: string): void
|
||||
}>()
|
||||
|
||||
const backgroundColor = ref(props.backgroundColor)
|
||||
const showGrid = ref(props.showGrid)
|
||||
const showPreview = ref(props.showPreview)
|
||||
const lightIntensity = ref(props.lightIntensity)
|
||||
const upDirection = ref(props.upDirection || 'original')
|
||||
const materialMode = ref(props.materialMode || 'original')
|
||||
const showLightIntensityButton = ref(props.showLightIntensityButton)
|
||||
const fov = ref(props.fov)
|
||||
const showFOVButton = ref(props.showFOVButton)
|
||||
const showPreviewButton = ref(props.showPreviewButton)
|
||||
const hasBackgroundImage = ref(props.hasBackgroundImage)
|
||||
const edgeThreshold = ref(props.edgeThreshold)
|
||||
|
||||
const switchCamera = () => {
|
||||
emit('switchCamera')
|
||||
}
|
||||
|
||||
const togglePreview = () => {
|
||||
showPreview.value = !showPreview.value
|
||||
emit('togglePreview', showPreview.value)
|
||||
}
|
||||
|
||||
const handleToggleGrid = (value: boolean) => {
|
||||
emit('toggleGrid', value)
|
||||
}
|
||||
|
||||
const handleBackgroundColorChange = (value: string) => {
|
||||
emit('updateBackgroundColor', value)
|
||||
}
|
||||
|
||||
const handleBackgroundImageUpdate = (file: File | null) => {
|
||||
emit('updateBackgroundImage', file)
|
||||
}
|
||||
|
||||
const handleUpdateUpDirection = (direction: UpDirection) => {
|
||||
emit('updateUpDirection', direction)
|
||||
}
|
||||
|
||||
const handleUpdateMaterialMode = (mode: MaterialMode) => {
|
||||
emit('updateMaterialMode', mode)
|
||||
}
|
||||
|
||||
const handleUpdateEdgeThreshold = (value: number) => {
|
||||
emit('updateEdgeThreshold', value)
|
||||
}
|
||||
|
||||
const handleUpdateLightIntensity = (value: number) => {
|
||||
emit('updateLightIntensity', value)
|
||||
}
|
||||
|
||||
const handleUpdateFOV = (value: number) => {
|
||||
emit('updateFOV', value)
|
||||
}
|
||||
|
||||
const handleExportModel = (format: string) => {
|
||||
emit('exportModel', format)
|
||||
}
|
||||
@@ -247,101 +166,6 @@ const closeSlider = (e: MouseEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.upDirection,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
upDirection.value = newValue
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.backgroundColor,
|
||||
(newValue) => {
|
||||
backgroundColor.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.fov,
|
||||
(newValue) => {
|
||||
fov.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.lightIntensity,
|
||||
(newValue) => {
|
||||
lightIntensity.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showFOVButton,
|
||||
(newValue) => {
|
||||
showFOVButton.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showLightIntensityButton,
|
||||
(newValue) => {
|
||||
showLightIntensityButton.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.upDirection,
|
||||
(newValue) => {
|
||||
upDirection.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.materialMode,
|
||||
(newValue) => {
|
||||
materialMode.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showPreviewButton,
|
||||
(newValue) => {
|
||||
showPreviewButton.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showPreview,
|
||||
(newValue) => {
|
||||
showPreview.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.hasBackgroundImage,
|
||||
(newValue) => {
|
||||
hasBackgroundImage.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.materialMode,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
materialMode.value = newValue
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.edgeThreshold,
|
||||
(newValue) => {
|
||||
edgeThreshold.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeSlider)
|
||||
})
|
||||
|
||||
@@ -1,238 +1,72 @@
|
||||
<template>
|
||||
<div ref="container" class="relative h-full w-full">
|
||||
<LoadingOverlay ref="loadingOverlayRef" />
|
||||
<div
|
||||
ref="container"
|
||||
class="relative h-full w-full"
|
||||
data-capture-wheel="true"
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
@mousedown.stop
|
||||
@mousemove.stop
|
||||
@mouseup.stop
|
||||
@contextmenu.stop.prevent
|
||||
@dragover.prevent.stop="handleDragOver"
|
||||
@dragleave.stop="handleDragLeave"
|
||||
@drop.prevent.stop="handleDrop"
|
||||
>
|
||||
<LoadingOverlay
|
||||
ref="loadingOverlayRef"
|
||||
:loading="loading"
|
||||
:loading-message="loadingMessage"
|
||||
/>
|
||||
<div
|
||||
v-if="!isPreview && isDragging"
|
||||
class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="rounded-lg border-2 border-dashed border-blue-400 bg-blue-500/20 px-6 py-4 text-lg font-medium text-blue-100"
|
||||
>
|
||||
{{ dragMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, toRaw, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import LoadingOverlay from '@/components/load3d/LoadingOverlay.vue'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
|
||||
import type {
|
||||
CameraType,
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
|
||||
|
||||
const props = defineProps<{
|
||||
node: LGraphNode
|
||||
inputSpec: CustomInputSpec
|
||||
backgroundColor: string
|
||||
showGrid: boolean
|
||||
lightIntensity: number
|
||||
fov: number
|
||||
cameraType: CameraType
|
||||
showPreview: boolean
|
||||
backgroundImage: string
|
||||
upDirection: UpDirection
|
||||
materialMode: MaterialMode
|
||||
edgeThreshold?: number
|
||||
extraListeners?: Record<string, (value: any) => void>
|
||||
initializeLoad3d: (containerRef: HTMLElement) => Promise<void>
|
||||
cleanup: () => void
|
||||
loading: boolean
|
||||
loadingMessage: string
|
||||
onModelDrop?: (file: File) => void | Promise<void>
|
||||
isPreview: boolean
|
||||
}>()
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const node = ref(props.node)
|
||||
const load3d = ref<Load3d | Load3dAnimation | null>(null)
|
||||
const loadingOverlayRef = ref<InstanceType<typeof LoadingOverlay> | null>(null)
|
||||
|
||||
const eventConfig = {
|
||||
materialModeChange: (value: string) =>
|
||||
emit('materialModeChange', value as MaterialMode),
|
||||
backgroundColorChange: (value: string) =>
|
||||
emit('backgroundColorChange', value),
|
||||
lightIntensityChange: (value: number) => emit('lightIntensityChange', value),
|
||||
fovChange: (value: number) => emit('fovChange', value),
|
||||
cameraTypeChange: (value: string) =>
|
||||
emit('cameraTypeChange', value as CameraType),
|
||||
showGridChange: (value: boolean) => emit('showGridChange', value),
|
||||
showPreviewChange: (value: boolean) => emit('showPreviewChange', value),
|
||||
backgroundImageChange: (value: string) =>
|
||||
emit('backgroundImageChange', value),
|
||||
backgroundImageLoadingStart: () =>
|
||||
loadingOverlayRef.value?.startLoading(t('load3d.loadingBackgroundImage')),
|
||||
backgroundImageLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
|
||||
upDirectionChange: (value: string) =>
|
||||
emit('upDirectionChange', value as UpDirection),
|
||||
edgeThresholdChange: (value: number) => emit('edgeThresholdChange', value),
|
||||
modelLoadingStart: () =>
|
||||
loadingOverlayRef.value?.startLoading(t('load3d.loadingModel')),
|
||||
modelLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
|
||||
materialLoadingStart: () =>
|
||||
loadingOverlayRef.value?.startLoading(t('load3d.switchingMaterialMode')),
|
||||
materialLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
|
||||
exportLoadingStart: (message: string) => {
|
||||
loadingOverlayRef.value?.startLoading(message || t('load3d.exportingModel'))
|
||||
},
|
||||
exportLoadingEnd: () => {
|
||||
loadingOverlayRef.value?.endLoading()
|
||||
},
|
||||
recordingStatusChange: (value: boolean) =>
|
||||
emit('recordingStatusChange', value)
|
||||
} as const
|
||||
|
||||
watch(
|
||||
() => props.showPreview,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.togglePreview(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.cameraType,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.toggleCamera(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.fov,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.setFOV(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.lightIntensity,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.setLightIntensity(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showGrid,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.toggleGrid(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.backgroundColor,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.setBackgroundColor(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.backgroundImage,
|
||||
async (newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
await rawLoad3d.setBackgroundImage(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.upDirection,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.setUpDirection(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.materialMode,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.setMaterialMode(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.edgeThreshold,
|
||||
(newValue) => {
|
||||
if (load3d.value && newValue) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.setEdgeThreshold(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'materialModeChange', materialMode: MaterialMode): void
|
||||
(e: 'backgroundColorChange', color: string): void
|
||||
(e: 'lightIntensityChange', lightIntensity: number): void
|
||||
(e: 'fovChange', fov: number): void
|
||||
(e: 'cameraTypeChange', cameraType: CameraType): void
|
||||
(e: 'showGridChange', showGrid: boolean): void
|
||||
(e: 'showPreviewChange', showPreview: boolean): void
|
||||
(e: 'backgroundImageChange', backgroundImage: string): void
|
||||
(e: 'upDirectionChange', upDirection: UpDirection): void
|
||||
(e: 'edgeThresholdChange', threshold: number): void
|
||||
(e: 'recordingStatusChange', status: boolean): void
|
||||
}>()
|
||||
|
||||
const handleEvents = (action: 'add' | 'remove') => {
|
||||
if (!load3d.value) return
|
||||
|
||||
Object.entries(eventConfig).forEach(([event, handler]) => {
|
||||
const method = `${action}EventListener` as const
|
||||
load3d.value?.[method](event, handler)
|
||||
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
|
||||
useLoad3dDrag({
|
||||
onModelDrop: async (file) => {
|
||||
if (props.onModelDrop) {
|
||||
await props.onModelDrop(file)
|
||||
}
|
||||
},
|
||||
disabled: computed(() => props.isPreview)
|
||||
})
|
||||
|
||||
if (props.extraListeners) {
|
||||
Object.entries(props.extraListeners).forEach(([event, handler]) => {
|
||||
const method = `${action}EventListener` as const
|
||||
load3d.value?.[method](event, handler)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (container.value) {
|
||||
load3d.value = useLoad3dService().registerLoad3d(
|
||||
node.value as LGraphNode,
|
||||
container.value,
|
||||
props.inputSpec
|
||||
)
|
||||
void props.initializeLoad3d(container.value)
|
||||
}
|
||||
handleEvents('add')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
handleEvents('remove')
|
||||
useLoad3dService().removeLoad3d(node.value as LGraphNode)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
load3d
|
||||
props.cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,20 @@
|
||||
ref="containerRef"
|
||||
class="absolute h-full w-full"
|
||||
@resize="viewer.handleResize"
|
||||
@dragover.prevent.stop="handleDragOver"
|
||||
@dragleave.stop="handleDragLeave"
|
||||
@drop.prevent.stop="handleDrop"
|
||||
/>
|
||||
<div
|
||||
v-if="isDragging"
|
||||
class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="rounded-lg border-2 border-dashed border-blue-400 bg-blue-500/20 px-6 py-4 text-lg font-medium text-blue-100"
|
||||
>
|
||||
{{ dragMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-72 flex-col">
|
||||
@@ -75,6 +88,7 @@ import ExportControls from '@/components/load3d/controls/viewer/ViewerExportCont
|
||||
import LightControls from '@/components/load3d/controls/viewer/ViewerLightControls.vue'
|
||||
import ModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue'
|
||||
import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue'
|
||||
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
@@ -92,6 +106,14 @@ const mutationObserver = ref<MutationObserver | null>(null)
|
||||
|
||||
const viewer = useLoad3dService().getOrCreateViewer(toRaw(props.node))
|
||||
|
||||
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
|
||||
useLoad3dDrag({
|
||||
onModelDrop: async (file) => {
|
||||
await viewer.handleModelDrop(file)
|
||||
},
|
||||
disabled: viewer.isPreview
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const source = useLoad3dService().getLoad3d(props.node)
|
||||
if (source && containerRef.value) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="modelLoading"
|
||||
class="absolute inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
v-if="loading"
|
||||
class="bg-opacity-50 absolute inset-0 z-50 flex items-center justify-center bg-black"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="spinner" />
|
||||
@@ -15,29 +15,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const modelLoading = ref(false)
|
||||
const loadingMessage = ref('')
|
||||
|
||||
const startLoading = async (message?: string) => {
|
||||
loadingMessage.value = message || t('load3d.loadingModel')
|
||||
modelLoading.value = true
|
||||
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
const endLoading = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
modelLoading.value = false
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
startLoading,
|
||||
endLoading
|
||||
})
|
||||
defineProps<{
|
||||
loading: boolean
|
||||
loadingMessage: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
class="w-24"
|
||||
@change="speedChange"
|
||||
/>
|
||||
|
||||
<Select
|
||||
@@ -24,7 +23,6 @@
|
||||
option-label="name"
|
||||
option-value="index"
|
||||
class="w-32"
|
||||
@change="animationChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -32,23 +30,13 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Select from 'primevue/select'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
animations: Array<{ name: string; index: number }>
|
||||
playing: boolean
|
||||
}>()
|
||||
type Animation = { name: string; index: number }
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'togglePlay', value: boolean): void
|
||||
(e: 'speedChange', value: number): void
|
||||
(e: 'animationChange', value: number): void
|
||||
}>()
|
||||
|
||||
const animations = ref(props.animations)
|
||||
const playing = ref(props.playing)
|
||||
const selectedSpeed = ref(1)
|
||||
const selectedAnimation = ref(0)
|
||||
const animations = defineModel<Animation[]>('animations')
|
||||
const playing = defineModel<boolean>('playing')
|
||||
const selectedSpeed = defineModel<number>('selectedSpeed')
|
||||
const selectedAnimation = defineModel<number>('selectedAnimation')
|
||||
|
||||
const speedOptions = [
|
||||
{ name: '0.1x', value: 0.1 },
|
||||
@@ -58,24 +46,7 @@ const speedOptions = [
|
||||
{ name: '2x', value: 2 }
|
||||
]
|
||||
|
||||
watch(
|
||||
() => props.animations,
|
||||
(newVal) => {
|
||||
animations.value = newVal
|
||||
}
|
||||
)
|
||||
|
||||
const togglePlay = () => {
|
||||
playing.value = !playing.value
|
||||
|
||||
emit('togglePlay', playing.value)
|
||||
}
|
||||
|
||||
const speedChange = () => {
|
||||
emit('speedChange', selectedSpeed.value)
|
||||
}
|
||||
|
||||
const animationChange = () => {
|
||||
emit('animationChange', selectedAnimation.value)
|
||||
}
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@
|
||||
<Button class="p-button-rounded p-button-text" @click="switchCamera">
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.switchCamera'),
|
||||
value: $t('load3d.switchCamera'),
|
||||
showDelay: 300
|
||||
}"
|
||||
:class="['pi', getCameraIcon, 'text-lg text-white']"
|
||||
@@ -12,7 +12,7 @@
|
||||
<div v-if="showFOVButton" class="show-fov relative">
|
||||
<Button class="p-button-rounded p-button-text" @click="toggleFOV">
|
||||
<i
|
||||
v-tooltip.right="{ value: t('load3d.fov'), showDelay: 300 }"
|
||||
v-tooltip.right="{ value: $t('load3d.fov'), showDelay: 300 }"
|
||||
class="pi pi-expand text-lg text-white"
|
||||
/>
|
||||
</Button>
|
||||
@@ -21,83 +21,37 @@
|
||||
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg"
|
||||
style="width: 150px"
|
||||
>
|
||||
<Slider
|
||||
v-model="fov"
|
||||
class="w-full"
|
||||
:min="10"
|
||||
:max="150"
|
||||
:step="1"
|
||||
@change="updateFOV"
|
||||
/>
|
||||
<Slider v-model="fov" class="w-full" :min="10" :max="150" :step="1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from 'primevue'
|
||||
import Button from 'primevue/button'
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const vTooltip = Tooltip
|
||||
|
||||
const props = defineProps<{
|
||||
cameraType: CameraType
|
||||
fov: number
|
||||
showFOVButton: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'switchCamera'): void
|
||||
(e: 'updateFOV', value: number): void
|
||||
}>()
|
||||
|
||||
const cameraType = ref(props.cameraType)
|
||||
const fov = ref(props.fov)
|
||||
const showFOVButton = ref(props.showFOVButton)
|
||||
const showFOV = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.fov,
|
||||
(newValue) => {
|
||||
fov.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showFOVButton,
|
||||
(newValue) => {
|
||||
showFOVButton.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.cameraType,
|
||||
(newValue) => {
|
||||
cameraType.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
const switchCamera = () => {
|
||||
emit('switchCamera')
|
||||
}
|
||||
const cameraType = defineModel<CameraType>('cameraType')
|
||||
const fov = defineModel<number>('fov')
|
||||
const showFOVButton = computed(() => cameraType.value === 'perspective')
|
||||
const getCameraIcon = computed(() => {
|
||||
return cameraType.value === 'perspective' ? 'pi-camera' : 'pi-camera'
|
||||
})
|
||||
|
||||
const toggleFOV = () => {
|
||||
showFOV.value = !showFOV.value
|
||||
}
|
||||
|
||||
const updateFOV = () => {
|
||||
emit('updateFOV', fov.value)
|
||||
const switchCamera = () => {
|
||||
cameraType.value =
|
||||
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
|
||||
}
|
||||
|
||||
const getCameraIcon = computed(() => {
|
||||
return props.cameraType === 'perspective' ? 'pi-camera' : 'pi-camera'
|
||||
})
|
||||
|
||||
const closeCameraSlider = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
>
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.exportModel'),
|
||||
value: $t('load3d.exportModel'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-download text-lg text-white"
|
||||
@@ -33,14 +33,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from 'primevue'
|
||||
import Button from 'primevue/button'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const vTooltip = Tooltip
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'exportModel', format: string): void
|
||||
}>()
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
>
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.lightIntensity'),
|
||||
value: $t('load3d.lightIntensity'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-sun text-lg text-white"
|
||||
@@ -24,7 +24,6 @@
|
||||
:min="lightIntensityMinimum"
|
||||
:max="lightIntensityMaximum"
|
||||
:step="lightAdjustmentIncrement"
|
||||
@change="updateLightIntensity"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,27 +31,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from 'primevue'
|
||||
import Button from 'primevue/button'
|
||||
import Slider from 'primevue/slider'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { MaterialMode } from '@/extensions/core/load3d/interfaces'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const vTooltip = Tooltip
|
||||
const lightIntensity = defineModel<number>('lightIntensity')
|
||||
const materialMode = defineModel<MaterialMode>('materialMode')
|
||||
|
||||
const props = defineProps<{
|
||||
lightIntensity: number
|
||||
showLightIntensityButton: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateLightIntensity', value: number): void
|
||||
}>()
|
||||
|
||||
const lightIntensity = ref(props.lightIntensity)
|
||||
const showLightIntensityButton = ref(props.showLightIntensityButton)
|
||||
const showLightIntensityButton = computed(
|
||||
() => materialMode.value === 'original'
|
||||
)
|
||||
const showLightIntensity = ref(false)
|
||||
|
||||
const lightIntensityMaximum = useSettingStore().get(
|
||||
@@ -65,28 +56,10 @@ const lightAdjustmentIncrement = useSettingStore().get(
|
||||
'Comfy.Load3D.LightAdjustmentIncrement'
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.lightIntensity,
|
||||
(newValue) => {
|
||||
lightIntensity.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showLightIntensityButton,
|
||||
(newValue) => {
|
||||
showLightIntensityButton.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
const toggleLightIntensity = () => {
|
||||
showLightIntensity.value = !showLightIntensity.value
|
||||
}
|
||||
|
||||
const updateLightIntensity = () => {
|
||||
emit('updateLightIntensity', lightIntensity.value)
|
||||
}
|
||||
|
||||
const closeLightSlider = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
:class="{ 'bg-blue-500': upDirection === direction }"
|
||||
@click="selectUpDirection(direction)"
|
||||
>
|
||||
{{ formatOption(direction) }}
|
||||
{{ direction.toUpperCase() }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,7 +49,7 @@
|
||||
<Button
|
||||
v-for="mode in materialModes"
|
||||
:key="mode"
|
||||
class="p-button-text text-white"
|
||||
class="p-button-text whitespace-nowrap text-white"
|
||||
:class="{ 'bg-blue-500': materialMode === mode }"
|
||||
@click="selectMaterialMode(mode)"
|
||||
>
|
||||
@@ -58,75 +58,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="materialMode === 'lineart'" class="show-edge-threshold relative">
|
||||
<Button
|
||||
class="p-button-rounded p-button-text"
|
||||
@click="toggleEdgeThreshold"
|
||||
>
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.edgeThreshold'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-sliders-h text-lg text-white"
|
||||
/>
|
||||
</Button>
|
||||
<div
|
||||
v-show="showEdgeThreshold"
|
||||
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg"
|
||||
style="width: 150px"
|
||||
>
|
||||
<label class="mb-1 block text-xs text-white"
|
||||
>{{ t('load3d.edgeThreshold') }}: {{ edgeThreshold }}°</label
|
||||
>
|
||||
<Slider
|
||||
v-model="edgeThreshold"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="120"
|
||||
:step="1"
|
||||
@change="updateEdgeThreshold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from 'primevue'
|
||||
import Button from 'primevue/button'
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
const vTooltip = Tooltip
|
||||
const materialMode = defineModel<MaterialMode>('materialMode')
|
||||
const upDirection = defineModel<UpDirection>('upDirection')
|
||||
|
||||
const props = defineProps<{
|
||||
inputSpec: CustomInputSpec
|
||||
upDirection: UpDirection
|
||||
materialMode: MaterialMode
|
||||
edgeThreshold?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateUpDirection', direction: UpDirection): void
|
||||
(e: 'updateMaterialMode', mode: MaterialMode): void
|
||||
(e: 'updateEdgeThreshold', value: number): void
|
||||
}>()
|
||||
|
||||
const upDirection = ref(props.upDirection || 'original')
|
||||
const materialMode = ref(props.materialMode || 'original')
|
||||
const edgeThreshold = ref(props.edgeThreshold || 85)
|
||||
const showUpDirection = ref(false)
|
||||
const showMaterialMode = ref(false)
|
||||
const showEdgeThreshold = ref(false)
|
||||
|
||||
const upDirections: UpDirection[] = [
|
||||
'original',
|
||||
@@ -146,65 +95,26 @@ const materialModes = computed(() => {
|
||||
//'depth' disable for now
|
||||
]
|
||||
|
||||
if (!props.inputSpec.isAnimation && !props.inputSpec.isPreview) {
|
||||
modes.push('lineart')
|
||||
}
|
||||
|
||||
return modes
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.upDirection,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
upDirection.value = newValue
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.materialMode,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
materialMode.value = newValue
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.edgeThreshold,
|
||||
(newValue) => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
edgeThreshold.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
const toggleUpDirection = () => {
|
||||
showUpDirection.value = !showUpDirection.value
|
||||
showMaterialMode.value = false
|
||||
showEdgeThreshold.value = false
|
||||
}
|
||||
|
||||
const selectUpDirection = (direction: UpDirection) => {
|
||||
upDirection.value = direction
|
||||
emit('updateUpDirection', direction)
|
||||
showUpDirection.value = false
|
||||
}
|
||||
|
||||
const formatOption = (option: string) => {
|
||||
if (option === 'original') return 'Original'
|
||||
return option.toUpperCase()
|
||||
}
|
||||
|
||||
const toggleMaterialMode = () => {
|
||||
showMaterialMode.value = !showMaterialMode.value
|
||||
showUpDirection.value = false
|
||||
showEdgeThreshold.value = false
|
||||
}
|
||||
|
||||
const selectMaterialMode = (mode: MaterialMode) => {
|
||||
materialMode.value = mode
|
||||
emit('updateMaterialMode', mode)
|
||||
showMaterialMode.value = false
|
||||
}
|
||||
|
||||
@@ -212,16 +122,6 @@ const formatMaterialMode = (mode: MaterialMode) => {
|
||||
return t(`load3d.materialModes.${mode}`)
|
||||
}
|
||||
|
||||
const toggleEdgeThreshold = () => {
|
||||
showEdgeThreshold.value = !showEdgeThreshold.value
|
||||
showUpDirection.value = false
|
||||
showMaterialMode.value = false
|
||||
}
|
||||
|
||||
const updateEdgeThreshold = () => {
|
||||
emit('updateEdgeThreshold', edgeThreshold.value)
|
||||
}
|
||||
|
||||
const closeSceneSlider = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
@@ -232,10 +132,6 @@ const closeSceneSlider = (e: MouseEvent) => {
|
||||
if (!target.closest('.show-material-mode')) {
|
||||
showMaterialMode.value = false
|
||||
}
|
||||
|
||||
if (!target.closest('.show-edge-threshold')) {
|
||||
showEdgeThreshold.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
<template>
|
||||
<div class="relative rounded-lg bg-smoke-700/30">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
class="p-button-rounded p-button-text"
|
||||
@click="resizeNodeMatchOutput"
|
||||
>
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.resizeNodeMatchOutput'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-window-maximize text-lg text-white"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
class="p-button-rounded p-button-text"
|
||||
:class="{
|
||||
@@ -24,8 +12,8 @@
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: isRecording
|
||||
? t('load3d.stopRecording')
|
||||
: t('load3d.startRecording'),
|
||||
? $t('load3d.stopRecording')
|
||||
: $t('load3d.startRecording'),
|
||||
showDelay: 300
|
||||
}"
|
||||
:class="[
|
||||
@@ -39,11 +27,11 @@
|
||||
<Button
|
||||
v-if="hasRecording && !isRecording"
|
||||
class="p-button-rounded p-button-text"
|
||||
@click="exportRecording"
|
||||
@click="handleExportRecording"
|
||||
>
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.exportRecording'),
|
||||
value: $t('load3d.exportRecording'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-download text-lg text-white"
|
||||
@@ -53,11 +41,11 @@
|
||||
<Button
|
||||
v-if="hasRecording && !isRecording"
|
||||
class="p-button-rounded p-button-text"
|
||||
@click="clearRecording"
|
||||
@click="handleClearRecording"
|
||||
>
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.clearRecording'),
|
||||
value: $t('load3d.clearRecording'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-trash text-lg text-white"
|
||||
@@ -65,7 +53,7 @@
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-if="recordingDuration > 0 && !isRecording"
|
||||
v-if="recordingDuration && recordingDuration > 0 && !isRecording"
|
||||
class="mt-1 text-center text-xs text-white"
|
||||
>
|
||||
{{ formatDuration(recordingDuration) }}
|
||||
@@ -75,21 +63,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from 'primevue'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
|
||||
const vTooltip = Tooltip
|
||||
|
||||
const { hasRecording, isRecording, node, recordingDuration } = defineProps<{
|
||||
hasRecording: boolean
|
||||
isRecording: boolean
|
||||
node: LGraphNode
|
||||
recordingDuration: number
|
||||
}>()
|
||||
const hasRecording = defineModel<boolean>('hasRecording')
|
||||
const isRecording = defineModel<boolean>('isRecording')
|
||||
const recordingDuration = defineModel<number>('recordingDuration')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'startRecording'): void
|
||||
@@ -98,49 +76,19 @@ const emit = defineEmits<{
|
||||
(e: 'clearRecording'): void
|
||||
}>()
|
||||
|
||||
const resizeNodeMatchOutput = () => {
|
||||
const outputWidth = node.widgets?.find((w) => w.name === 'width')
|
||||
const outputHeight = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
if (outputWidth && outputHeight && outputHeight.value && outputWidth.value) {
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
const scene = node.widgets?.find((w) => w.name === 'image')
|
||||
|
||||
const sceneHeight = scene?.computedHeight
|
||||
|
||||
if (sceneHeight) {
|
||||
const sceneWidth = oldWidth - 20
|
||||
|
||||
const outputRatio = Number(outputHeight.value) / Number(outputWidth.value)
|
||||
const expectSceneHeight = sceneWidth * outputRatio
|
||||
|
||||
node.setSize([oldWidth, oldHeight + (expectSceneHeight - sceneHeight)])
|
||||
|
||||
node.graph?.setDirtyCanvas(true, true)
|
||||
|
||||
const load3d = useLoad3dService().getLoad3d(node as LGraphNode)
|
||||
|
||||
if (load3d) {
|
||||
load3d.refreshViewport()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRecording = () => {
|
||||
if (isRecording) {
|
||||
if (isRecording.value) {
|
||||
emit('stopRecording')
|
||||
} else {
|
||||
emit('startRecording')
|
||||
}
|
||||
}
|
||||
|
||||
const exportRecording = () => {
|
||||
const handleExportRecording = () => {
|
||||
emit('exportRecording')
|
||||
}
|
||||
|
||||
const clearRecording = () => {
|
||||
const handleClearRecording = () => {
|
||||
emit('clearRecording')
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
@click="toggleGrid"
|
||||
>
|
||||
<i
|
||||
v-tooltip.right="{ value: t('load3d.showGrid'), showDelay: 300 }"
|
||||
v-tooltip.right="{ value: $t('load3d.showGrid'), showDelay: 300 }"
|
||||
class="pi pi-table text-lg text-white"
|
||||
/>
|
||||
</Button>
|
||||
@@ -15,7 +15,7 @@
|
||||
<Button class="p-button-rounded p-button-text" @click="openColorPicker">
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.backgroundColor'),
|
||||
value: $t('load3d.backgroundColor'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-palette text-lg text-white"
|
||||
@@ -36,7 +36,7 @@
|
||||
<Button class="p-button-rounded p-button-text" @click="openImagePicker">
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.uploadBackgroundImage'),
|
||||
value: $t('load3d.uploadBackgroundImage'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-image text-lg text-white"
|
||||
@@ -58,7 +58,7 @@
|
||||
>
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.removeBackgroundImage'),
|
||||
value: $t('load3d.removeBackgroundImage'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-times text-lg text-white"
|
||||
@@ -69,60 +69,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from 'primevue'
|
||||
import Button from 'primevue/button'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const vTooltip = Tooltip
|
||||
|
||||
const props = defineProps<{
|
||||
backgroundColor: string
|
||||
showGrid: boolean
|
||||
hasBackgroundImage?: boolean
|
||||
}>()
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggleGrid', value: boolean): void
|
||||
(e: 'updateBackgroundColor', color: string): void
|
||||
(e: 'updateBackgroundImage', file: File | null): void
|
||||
}>()
|
||||
|
||||
const backgroundColor = ref(props.backgroundColor)
|
||||
const showGrid = ref(props.showGrid)
|
||||
const hasBackgroundImage = ref(props.hasBackgroundImage)
|
||||
const showGrid = defineModel<boolean>('showGrid')
|
||||
const backgroundColor = defineModel<string>('backgroundColor')
|
||||
const backgroundImage = defineModel<string>('backgroundImage')
|
||||
const hasBackgroundImage = computed(
|
||||
() => backgroundImage.value && backgroundImage.value !== ''
|
||||
)
|
||||
|
||||
const colorPickerRef = ref<HTMLInputElement | null>(null)
|
||||
const imagePickerRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.backgroundColor,
|
||||
(newValue) => {
|
||||
backgroundColor.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showGrid,
|
||||
(newValue) => {
|
||||
showGrid.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.hasBackgroundImage,
|
||||
(newValue) => {
|
||||
hasBackgroundImage.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
const toggleGrid = () => {
|
||||
showGrid.value = !showGrid.value
|
||||
emit('toggleGrid', showGrid.value)
|
||||
}
|
||||
|
||||
const updateBackgroundColor = (color: string) => {
|
||||
emit('updateBackgroundColor', color)
|
||||
backgroundColor.value = color
|
||||
}
|
||||
|
||||
const openColorPicker = () => {
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from 'primevue'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
@@ -24,8 +23,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const vTooltip = Tooltip
|
||||
|
||||
const { node } = defineProps<{
|
||||
node: LGraphNode
|
||||
}>()
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</Select>
|
||||
|
||||
<Button severity="secondary" text rounded @click="exportModel(exportFormat)">
|
||||
{{ t('load3d.export') }}
|
||||
{{ $t('load3d.export') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -17,8 +17,6 @@ import Button from 'primevue/button'
|
||||
import Select from 'primevue/select'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'exportModel', format: string): void
|
||||
}>()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<label>{{ t('load3d.lightIntensity') }}</label>
|
||||
<label>{{ $t('load3d.lightIntensity') }}</label>
|
||||
|
||||
<Slider
|
||||
v-model="lightIntensity"
|
||||
@@ -13,7 +13,6 @@
|
||||
<script setup lang="ts">
|
||||
import Slider from 'primevue/slider'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const lightIntensity = defineModel<number>('lightIntensity')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label>{{ t('load3d.upDirection') }}</label>
|
||||
<label>{{ $t('load3d.upDirection') }}</label>
|
||||
<Select
|
||||
v-model="upDirection"
|
||||
:options="upDirectionOptions"
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{{ t('load3d.materialMode') }}</label>
|
||||
<label>{{ $t('load3d.materialMode') }}</label>
|
||||
<Select
|
||||
v-model="materialMode"
|
||||
:options="materialModeOptions"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="space-y-4">
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<label>
|
||||
{{ t('load3d.backgroundColor') }}
|
||||
{{ $t('load3d.backgroundColor') }}
|
||||
</label>
|
||||
<input v-model="backgroundColor" type="color" class="w-full" />
|
||||
</div>
|
||||
@@ -10,14 +10,14 @@
|
||||
<div>
|
||||
<Checkbox v-model="showGrid" input-id="showGrid" binary name="showGrid" />
|
||||
<label for="showGrid" class="pl-2">
|
||||
{{ t('load3d.showGrid') }}
|
||||
{{ $t('load3d.showGrid') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<Button
|
||||
severity="secondary"
|
||||
:label="t('load3d.uploadBackgroundImage')"
|
||||
:label="$t('load3d.uploadBackgroundImage')"
|
||||
icon="pi pi-image"
|
||||
class="w-full"
|
||||
@click="openImagePicker"
|
||||
@@ -34,7 +34,7 @@
|
||||
<div v-if="hasBackgroundImage" class="space-y-2">
|
||||
<Button
|
||||
severity="secondary"
|
||||
:label="t('load3d.removeBackgroundImage')"
|
||||
:label="$t('load3d.removeBackgroundImage')"
|
||||
icon="pi pi-times"
|
||||
class="w-full"
|
||||
@click="removeBackgroundImage"
|
||||
@@ -48,8 +48,6 @@ import Button from 'primevue/button'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const backgroundColor = defineModel<string>('backgroundColor')
|
||||
const showGrid = defineModel<boolean>('showGrid')
|
||||
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
<template>
|
||||
<div
|
||||
class="comfy-menu-button-wrapper flex shrink-0 cursor-pointer flex-col items-center justify-center rounded-t-md p-2 transition-colors"
|
||||
v-tooltip="{
|
||||
value: t('sideToolbar.labels.menu'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
class="comfy-menu-button-wrapper flex shrink-0 cursor-pointer flex-col items-center justify-center p-2 transition-colors"
|
||||
:class="{
|
||||
'comfy-menu-button-active': menuRef?.visible
|
||||
}"
|
||||
@click="menuRef?.toggle($event)"
|
||||
@click="onLogoMenuClick($event)"
|
||||
>
|
||||
<ComfyLogoTransparent
|
||||
alt="ComfyUI Logo"
|
||||
class="comfyui-logo h-[18px] w-[18px]"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="!isSmall"
|
||||
class="side-bar-button-label mt-1 text-center text-[10px]"
|
||||
>{{ t('sideToolbar.labels.menu') }}</span
|
||||
>
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-black">
|
||||
<ComfyLogo
|
||||
alt="ComfyUI Logo"
|
||||
class="comfyui-logo h-[18px] w-[18px] text-white"
|
||||
mode="fill"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TieredMenu
|
||||
@@ -75,8 +77,10 @@ import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
|
||||
import ComfyLogoTransparent from '@/components/icons/ComfyLogoTransparent.vue'
|
||||
import ComfyLogo from '@/components/icons/ComfyLogo.vue'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -95,14 +99,19 @@ const colorPaletteService = useColorPaletteService()
|
||||
const dialogStore = useDialogStore()
|
||||
const managerState = useManagerState()
|
||||
|
||||
const { isSmall = false } = defineProps<{
|
||||
isSmall?: boolean
|
||||
}>()
|
||||
|
||||
const menuRef = ref<
|
||||
({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null
|
||||
>(null)
|
||||
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
function onLogoMenuClick(event: MouseEvent) {
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_comfy_menu_opened'
|
||||
})
|
||||
menuRef.value?.toggle(event)
|
||||
}
|
||||
|
||||
const translateMenuItem = (item: MenuItem): MenuItem => {
|
||||
const label = typeof item.label === 'function' ? item.label() : item.label
|
||||
const translatedLabel = label
|
||||
@@ -160,13 +169,18 @@ const extraMenuItems = computed(() => [
|
||||
key: 'browse-templates',
|
||||
label: t('menuLabels.Browse Templates'),
|
||||
icon: 'icon-[comfy--template]',
|
||||
command: () => commandStore.execute('Comfy.BrowseTemplates')
|
||||
command: () => useWorkflowTemplateSelectorDialog().show('menu')
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: t('g.settings'),
|
||||
icon: 'mdi mdi-cog-outline',
|
||||
command: () => showSettings()
|
||||
command: () => {
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_settings_menu_opened'
|
||||
})
|
||||
showSettings()
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'manage-extensions',
|
||||
@@ -276,12 +290,12 @@ const hasActiveStateSiblings = (item: MenuItem): boolean => {
|
||||
}
|
||||
|
||||
.comfy-menu-button-wrapper:hover {
|
||||
background: var(--p-button-text-secondary-hover-background);
|
||||
background: var(--interface-panel-hover-surface);
|
||||
}
|
||||
|
||||
.comfy-menu-button-active,
|
||||
.comfy-menu-button-active:hover {
|
||||
background-color: var(--content-hover-bg);
|
||||
background: var(--interface-panel-selected-surface);
|
||||
}
|
||||
|
||||
.keybinding-tag {
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
'small-sidebar': isSmall,
|
||||
'connected-sidebar': isConnected,
|
||||
'floating-sidebar': !isConnected,
|
||||
'overflowing-sidebar': isOverflowing
|
||||
'overflowing-sidebar': isOverflowing,
|
||||
'border-r border-[var(--interface-stroke)] shadow-interface': isConnected
|
||||
}"
|
||||
>
|
||||
<div
|
||||
@@ -18,7 +19,7 @@
|
||||
"
|
||||
>
|
||||
<div ref="topToolbarRef" :class="groupClasses">
|
||||
<ComfyMenuButton :is-small="isSmall" />
|
||||
<ComfyMenuButton />
|
||||
<SidebarIcon
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
@@ -57,6 +58,7 @@ import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore'
|
||||
@@ -97,10 +99,40 @@ const isConnected = computed(
|
||||
const tabs = computed(() => workspaceStore.getSidebarTabs())
|
||||
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
|
||||
|
||||
const onTabClick = async (item: SidebarTabExtension) =>
|
||||
/**
|
||||
* Handle sidebar tab icon click.
|
||||
* - Emits UI button telemetry for known tabs
|
||||
* - Delegates to the corresponding toggle command
|
||||
*/
|
||||
const onTabClick = async (item: SidebarTabExtension) => {
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const isNodeLibraryTab = item.id === 'node-library'
|
||||
const isModelLibraryTab = item.id === 'model-library'
|
||||
const isWorkflowsTab = item.id === 'workflows'
|
||||
const isAssetsTab = item.id === 'assets'
|
||||
|
||||
if (isNodeLibraryTab)
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_tab_node_library_selected'
|
||||
})
|
||||
else if (isModelLibraryTab)
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_tab_model_library_selected'
|
||||
})
|
||||
else if (isWorkflowsTab)
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_tab_workflows_selected'
|
||||
})
|
||||
else if (isAssetsTab)
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_tab_assets_media_selected'
|
||||
})
|
||||
|
||||
await commandStore.commands
|
||||
.find((cmd) => cmd.id === `Workspace.ToggleSidebarTab.${item.id}`)
|
||||
?.function?.()
|
||||
}
|
||||
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
|
||||
@@ -114,7 +146,7 @@ const isOverflowing = ref(false)
|
||||
const groupClasses = computed(() =>
|
||||
cn(
|
||||
'sidebar-item-group pointer-events-auto flex flex-col items-center overflow-hidden flex-shrink-0' +
|
||||
(isConnected.value ? '' : ' rounded-lg shadow-md')
|
||||
(isConnected.value ? '' : ' rounded-lg shadow-interface')
|
||||
)
|
||||
)
|
||||
|
||||
@@ -183,7 +215,7 @@ onMounted(() => {
|
||||
--sidebar-padding: 4px;
|
||||
--sidebar-icon-size: 1rem;
|
||||
|
||||
--sidebar-default-floating-width: 56px;
|
||||
--sidebar-default-floating-width: 48px;
|
||||
--sidebar-default-connected-width: calc(
|
||||
var(--sidebar-default-floating-width) + var(--sidebar-padding) * 2
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
:label="$t('sideToolbar.labels.console')"
|
||||
:tooltip="$t('menu.toggleBottomPanel')"
|
||||
:selected="bottomPanelStore.activePanel == 'terminal'"
|
||||
@click="bottomPanelStore.toggleBottomPanel"
|
||||
@click="toggleConsole"
|
||||
>
|
||||
<template #icon>
|
||||
<i-ph:terminal-bold />
|
||||
@@ -12,9 +12,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
|
||||
/**
|
||||
* Toggle console bottom panel and track UI button click.
|
||||
*/
|
||||
const toggleConsole = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_bottom_panel_console_toggled'
|
||||
})
|
||||
bottomPanelStore.toggleBottomPanel()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -65,6 +65,7 @@ import { computed, onMounted, toRefs } from 'vue'
|
||||
|
||||
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import ReleaseNotificationToast from '@/platform/updates/components/ReleaseNotificationToast.vue'
|
||||
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
|
||||
@@ -104,7 +105,13 @@ const sidebarLocation = computed(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
|
||||
/**
|
||||
* Toggle Help Center and track UI button click.
|
||||
*/
|
||||
const toggleHelpCenter = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_help_center_toggled'
|
||||
})
|
||||
helpCenterStore.toggle()
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,11 @@ const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
|
||||
}
|
||||
|
||||
.side-bar-button-selected {
|
||||
background-color: var(--content-hover-bg);
|
||||
background-color: var(--interface-panel-selected-surface);
|
||||
color: var(--content-hover-fg);
|
||||
}
|
||||
.side-bar-button:hover {
|
||||
background-color: var(--interface-panel-hover-surface);
|
||||
color: var(--content-hover-fg);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
@@ -34,7 +35,13 @@ const tooltipText = computed(
|
||||
() => `${t('shortcuts.keyboardShortcuts')} (${formatKeySequence(command)})`
|
||||
)
|
||||
|
||||
/**
|
||||
* Toggle keyboard shortcuts panel and track UI button click.
|
||||
*/
|
||||
const toggleShortcutsPanel = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_shortcuts_panel_toggled'
|
||||
})
|
||||
bottomPanelStore.togglePanel('shortcuts')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,19 +12,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
const isSmall = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Size') === 'small'
|
||||
)
|
||||
|
||||
/**
|
||||
* Open templates dialog from sidebar and track UI button click.
|
||||
*/
|
||||
const openTemplates = () => {
|
||||
void commandStore.execute('Comfy.BrowseTemplates')
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_templates_dialog_opened'
|
||||
})
|
||||
useWorkflowTemplateSelectorDialog().show('sidebar')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -170,7 +170,7 @@ import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
import AssetsSidebarTemplate from './AssetSidebarTemplate.vue'
|
||||
|
||||
const activeTab = ref<'input' | 'output'>('input')
|
||||
const activeTab = ref<'input' | 'output'>('output')
|
||||
const folderPromptId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||
|
||||